From 139cf23dcb786d653001c33616ef14a3bc1f19b5 Mon Sep 17 00:00:00 2001 From: Brian Tyler Date: Tue, 25 Aug 2026 13:19:48 +0100 Subject: [PATCH 1/2] feat: add dashboard views --- Directory.Packages.props | 20 +- README.md | 55 +- dotnet-tools.json | 2 +- .../Components/DashboardApp.razor | 1 + .../Components/Pages/Jobs.razor | 1083 +++++++++++++++-- .../Internal/DashboardFilters.cs | 10 + .../Internal/DashboardJobViews.cs | 247 ++++ .../ShedduellerDashboardJobColumn.cs | 12 + .../ShedduellerDashboardJobColumnKind.cs | 46 + .../ShedduellerDashboardJobView.cs | 41 + .../ShedduellerDashboardOptions.cs | 11 + ...lerDashboardServiceCollectionExtensions.cs | 1 + .../wwwroot/vendor/sheddueller-job-views.js | 13 + .../DashboardEndpointTests.cs | 55 + .../DashboardFilterTests.cs | 11 + .../DashboardFormatTests.cs | 96 ++ 16 files changed, 1588 insertions(+), 116 deletions(-) create mode 100644 src/Sheddueller.Dashboard/Internal/DashboardJobViews.cs create mode 100644 src/Sheddueller.Dashboard/ShedduellerDashboardJobColumn.cs create mode 100644 src/Sheddueller.Dashboard/ShedduellerDashboardJobColumnKind.cs create mode 100644 src/Sheddueller.Dashboard/ShedduellerDashboardJobView.cs create mode 100644 src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-job-views.js diff --git a/Directory.Packages.props b/Directory.Packages.props index 22485a2..3ddfaff 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,25 +1,25 @@ true - [10.0.0, 11.0.0) - [10.0.0, 11.0.0) + 10.0.11 + 10.0.3 - + - + - - - + + + - - + + - + \ No newline at end of file diff --git a/README.md b/README.md index 13d9242..28d6a66 100644 --- a/README.md +++ b/README.md @@ -218,13 +218,66 @@ Cron expressions use the standard five-field format and are evaluated in UTC. builder.Services.AddShedduellerDashboard(options => { options.EventRetention = TimeSpan.FromDays(14); + options.JobViews = + [ + new ShedduellerDashboardJobView("Active Work") + { + States = [JobState.Queued, JobState.Claimed], + Columns = + [ + new(ShedduellerDashboardJobColumnKind.JobId), + new(ShedduellerDashboardJobColumnKind.Enqueued), + new(ShedduellerDashboardJobColumnKind.State), + new(ShedduellerDashboardJobColumnKind.Queue), + new(ShedduellerDashboardJobColumnKind.Handler), + new(ShedduellerDashboardJobColumnKind.Tag, "provider", "Provider"), + new(ShedduellerDashboardJobColumnKind.Groups), + new(ShedduellerDashboardJobColumnKind.Progress), + new(ShedduellerDashboardJobColumnKind.Attempts), + ], + }, + new ShedduellerDashboardJobView("Failures") + { + States = [JobState.Failed], + Sort = JobInspectionSort.NewestFirst, + Columns = + [ + new(ShedduellerDashboardJobColumnKind.JobId), + new(ShedduellerDashboardJobColumnKind.TerminalTime), + new(ShedduellerDashboardJobColumnKind.Handler), + new(ShedduellerDashboardJobColumnKind.Tag, "provider", "Provider"), + new(ShedduellerDashboardJobColumnKind.Tag, "manager", "Manager"), + new(ShedduellerDashboardJobColumnKind.Tag, "external-listing", "External Listing"), + new(ShedduellerDashboardJobColumnKind.Disposition), + new(ShedduellerDashboardJobColumnKind.Attempts), + ], + }, + new ShedduellerDashboardJobView("Listings") + { + TagContains = "domain:listing", + Sort = JobInspectionSort.NewestFirst, + Columns = + [ + new(ShedduellerDashboardJobColumnKind.JobId), + new(ShedduellerDashboardJobColumnKind.Enqueued), + new(ShedduellerDashboardJobColumnKind.State), + new(ShedduellerDashboardJobColumnKind.Handler), + new(ShedduellerDashboardJobColumnKind.Tag, "provider", "Provider"), + new(ShedduellerDashboardJobColumnKind.Tag, "manager", "Manager"), + new(ShedduellerDashboardJobColumnKind.Tag, "listing-id", "Listing"), + new(ShedduellerDashboardJobColumnKind.Tag, "external-listing", "External Listing"), + new(ShedduellerDashboardJobColumnKind.Disposition), + ], + }, + ]; + options.DefaultJobViewName = "Active Work"; }); app.UseAntiforgery(); app.MapShedduellerDashboard("/sheddueller"); ``` -The dashboard uses the configured Sheddueller provider and can be hosted by a worker process or a client-only web process. +The dashboard uses the configured Sheddueller provider and can be hosted by a worker process or a client-only web process. Configured job views are shared, read-only presets. Dashboard users can copy or create personal views that persist in browser storage for the dashboard's mounted path. A view restores filters, sort order, visible column order, and tag-derived columns; promoted tags are removed from the remaining Tags cell. ## Testing diff --git a/dotnet-tools.json b/dotnet-tools.json index 2c2331f..cc8da6a 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-outdated-tool": { - "version": "4.7.1", + "version": "4.8.1", "commands": [ "dotnet-outdated" ], diff --git a/src/Sheddueller.Dashboard/Components/DashboardApp.razor b/src/Sheddueller.Dashboard/Components/DashboardApp.razor index 6fa7c4e..233fc07 100644 --- a/src/Sheddueller.Dashboard/Components/DashboardApp.razor +++ b/src/Sheddueller.Dashboard/Components/DashboardApp.razor @@ -21,6 +21,7 @@ + diff --git a/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor b/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor index 7da158f..5e7e074 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor @@ -5,6 +5,7 @@ @inject DashboardLiveUpdateStream LiveUpdates @inject NavigationManager Navigation @inject IJSRuntime JsRuntime +@inject Microsoft.Extensions.Options.IOptions DashboardOptions
@@ -20,6 +21,150 @@
+
+ + @CurrentViewStatus +
+ + + + + @if (CurrentView?.Source == DashboardJobViewSource.Personal) + { + + + } +
+
+ + @if (_isColumnEditorOpen) + { +
+
+
+

Columns

+

Choose and order fields for this view. Job ID remains visible so job details are always reachable.

+
+ +
+ +
+ @for (var index = 0; index < _columns.Count; index++) + { + var columnIndex = index; + var column = _columns[columnIndex]; +
+ @if (column.Kind == ShedduellerDashboardJobColumnKind.Tag) + { + + } + else + { + + } + @DashboardJobViews.Heading(column) + @if (column.Kind == ShedduellerDashboardJobColumnKind.Tag) + { + tag: @column.TagName + } + + +
+ } + @foreach (var kind in HiddenBuiltInColumns) + { + + } +
+ +
+ + + @foreach (var tagName in ObservedTagNames) + { + + } + + + + +
+
+ } +
Status @@ -88,43 +233,26 @@
- +
- - - - - - - - - - - - - + @foreach (var column in _columns) + { + + } - - - - - - - - - - - - + @foreach (var column in _columns) + { + + } @if (_jobs.Count == 0) { - + } else @@ -132,49 +260,62 @@ @foreach (var job in _jobs) { - - - - - - - - - - - - + @foreach (var column in _columns) + { + @switch (column.Kind) + { + case ShedduellerDashboardJobColumnKind.JobId: + + break; + case ShedduellerDashboardJobColumnKind.Enqueued: + + break; + case ShedduellerDashboardJobColumnKind.TerminalTime: + + break; + case ShedduellerDashboardJobColumnKind.State: + + break; + case ShedduellerDashboardJobColumnKind.Queue: + + break; + case ShedduellerDashboardJobColumnKind.Handler: + + break; + case ShedduellerDashboardJobColumnKind.Tags: + + break; + case ShedduellerDashboardJobColumnKind.Progress: + + break; + case ShedduellerDashboardJobColumnKind.Disposition: + + break; + case ShedduellerDashboardJobColumnKind.Groups: + + break; + case ShedduellerDashboardJobColumnKind.Priority: + + break; + case ShedduellerDashboardJobColumnKind.Attempts: + + break; + case ShedduellerDashboardJobColumnKind.Tag: + var values = TagValues(job, column); + + break; + } + } } } @@ -218,6 +359,189 @@ line-height: 16px; } + .jobs-view-bar { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + border: 1px solid var(--sd-outline-variant); + border-radius: var(--sd-radius); + background: var(--sd-surface-lowest); + padding: 8px; + } + + .jobs-view-select { + display: flex; + min-width: 220px; + align-items: center; + gap: 8px; + } + + .jobs-view-select .sd-table-select { + min-width: 160px; + } + + .jobs-view-status { + flex: 1 1 auto; + overflow: hidden; + color: var(--sd-on-surface-variant); + font-size: 11px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .jobs-view-actions { + display: flex; + align-items: center; + gap: 4px; + } + + .jobs-view-actions button, + .jobs-column-editor button, + .jobs-tag-column-form button { + display: inline-flex; + min-height: 32px; + align-items: center; + justify-content: center; + gap: 4px; + border: 1px solid var(--sd-outline-variant); + border-radius: 2px; + background: var(--sd-surface-lowest); + color: var(--sd-on-surface); + padding: 6px 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + line-height: 12px; + text-transform: uppercase; + } + + .jobs-view-actions button:hover:not(:disabled), + .jobs-column-editor button:hover:not(:disabled), + .jobs-tag-column-form button:hover:not(:disabled) { + border-color: var(--sd-primary); + color: var(--sd-primary); + } + + .jobs-view-actions button:disabled, + .jobs-column-editor button:disabled { + cursor: default; + opacity: 0.5; + } + + .jobs-view-actions .material-symbols-outlined, + .jobs-column-editor .material-symbols-outlined, + .jobs-tag-column-form .material-symbols-outlined { + font-size: 16px; + } + + .jobs-view-actions .jobs-view-delete { + color: var(--sd-error); + } + + .jobs-column-editor { + display: grid; + gap: 12px; + border: 1px solid var(--sd-outline-variant); + border-radius: var(--sd-radius); + background: var(--sd-surface-lowest); + padding: 12px; + } + + .jobs-column-editor__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + } + + .jobs-column-editor__header h2 { + font-size: 14px; + line-height: 20px; + } + + .jobs-column-editor__header p { + color: var(--sd-on-surface-variant); + font-size: 11px; + line-height: 16px; + } + + .jobs-column-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 4px; + } + + .jobs-column-row { + display: grid; + min-height: 34px; + align-items: center; + gap: 6px; + grid-template-columns: 28px minmax(80px, 1fr) minmax(0, auto) 28px 28px; + border: 1px solid var(--sd-outline-variant); + border-radius: 2px; + background: var(--sd-surface-low); + padding: 3px 4px; + } + + .jobs-column-row--hidden { + grid-template-columns: 28px minmax(80px, 1fr) auto; + color: var(--sd-on-surface-variant); + opacity: 0.8; + } + + .jobs-column-row button { + min-height: 26px; + padding: 3px; + } + + .jobs-column-row__name { + overflow: hidden; + font-size: 11px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + .jobs-column-row__tag, + .jobs-field-optional { + overflow: hidden; + color: var(--sd-on-surface-variant); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .jobs-tag-column-form { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 8px; + border-top: 1px solid var(--sd-outline-variant); + padding-top: 12px; + } + + .jobs-tag-column-form label { + display: grid; + min-width: 180px; + gap: 3px; + color: var(--sd-on-surface-variant); + font-size: 11px; + font-weight: 600; + } + + .jobs-tag-column-form input { + min-height: 32px; + border: 1px solid var(--sd-outline-variant); + border-radius: 2px; + background: var(--sd-surface-lowest); + color: var(--sd-on-surface); + padding: 6px 8px; + font-size: 12px; + } + .jobs-filter-bar { display: flex; align-items: center; @@ -442,44 +766,12 @@ .jobs-table { width: 100%; - min-width: 1816px; + min-width: 100%; border-collapse: collapse; table-layout: fixed; white-space: nowrap; } - .jobs-table__col--job-id { - width: 64px; - } - - .jobs-table__col--state { - width: 144px; - } - - .jobs-table__col--queue { - width: 96px; - } - - .jobs-table__col--tags { - width: 380px; - } - - .jobs-table__col--disposition { - width: 144px; - } - - .jobs-table__col--priority { - width: 64px; - } - - .jobs-table__col--timestamp { - width: 172px; - } - - .jobs-table__col--attempts { - width: 64px; - } - .jobs-table thead { position: sticky; top: 0; @@ -602,6 +894,42 @@ line-height: 16px; } + .jobs-derived-tag-list { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + flex-wrap: wrap; + gap: 4px; + white-space: normal; + } + + .jobs-derived-tag { + display: inline-block; + max-width: 100%; + overflow-wrap: anywhere; + border: 1px solid var(--sd-outline-variant); + border-radius: 2px; + background: var(--sd-surface-low); + color: var(--sd-on-surface-variant); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 10px; + line-height: 14px; + padding: 1px 6px; + text-decoration: none; + white-space: normal; + } + + a.jobs-derived-tag:hover { + border-color: var(--sd-primary); + color: var(--sd-primary); + } + + .jobs-derived-tag-empty { + color: var(--sd-on-surface-variant); + font-size: 11px; + } + .jobs-state { display: inline-flex; align-items: center; @@ -784,6 +1112,9 @@ } @@media (max-width: 720px) { + .jobs-view-bar, + .jobs-view-actions, + .jobs-view-select, .jobs-filter-bar, .jobs-status-filter, .jobs-text-filters, @@ -796,6 +1127,9 @@ } .jobs-filter-bar, + .jobs-view-bar, + .jobs-view-actions, + .jobs-view-select, .jobs-status-filter, .jobs-text-filters, .jobs-pagination { @@ -803,6 +1137,12 @@ flex-direction: column; } + .jobs-view-bar, + .jobs-view-actions, + .jobs-view-select { + align-items: stretch; + } + .jobs-status-option span { justify-content: center; } @@ -817,17 +1157,72 @@ private readonly DashboardJobFilters _filters = new(); private readonly List _jobs = []; + private readonly List _columns = []; + private readonly List _views = []; + private readonly List _storedViews = []; private JobInspectionPage? _page; private JobInspectionQuery _lastQuery = new(PageSize: DefaultPageSize); private CancellationTokenSource? _filterDebounceCts; private string? _error; private string? _actionMessage; private string? _pendingFilterNavigationUri; + private string _selectedViewKey = DashboardJobViews.BuiltInKey; + private string _preferredViewKey = DashboardJobViews.BuiltInKey; + private string _newTagName = string.Empty; + private string _newTagHeading = string.Empty; private int _queryVersion; private JobsActionSeverity _actionSeverity = JobsActionSeverity.Success; private bool _isLoading; private bool _isLoadingMore; private bool _isClearQueuedRunning; + private bool _isColumnEditorOpen; + private bool _isViewDirty; + private bool _hasExplicitInitialQuery; + private bool _viewStorageLoaded; + + private IReadOnlyList SharedViews + => [.. this._views.Where(view => view.Source == DashboardJobViewSource.Shared)]; + + private IReadOnlyList PersonalViews + => [.. this._views.Where(view => view.Source == DashboardJobViewSource.Personal)]; + + private DashboardResolvedJobView? CurrentView + => this.FindView(this._selectedViewKey); + + private bool CanSaveCurrentView + => this._isViewDirty && this.CurrentView?.Source == DashboardJobViewSource.Personal; + + private bool IsCurrentViewPreferred + => string.Equals(this._selectedViewKey, this._preferredViewKey, StringComparison.Ordinal); + + private bool CanSetPreferredView + => !this._isViewDirty && !this.IsCurrentViewPreferred; + + private string CurrentViewStatus + => string.Concat( + this.CurrentView?.Name ?? "Custom", + this._isViewDirty ? " · Modified" : string.Empty, + this.IsCurrentViewPreferred ? " · Default" : string.Empty); + + private IReadOnlyList HiddenBuiltInColumns + => + [ + .. Enum.GetValues() + .Where(kind => kind != ShedduellerDashboardJobColumnKind.Tag) + .Where(kind => this._columns.All(column => column.Kind != kind)), + ]; + + private IReadOnlyList ObservedTagNames + => + [ + .. this._jobs.SelectMany(job => job.Tags) + .Select(tag => tag.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase), + ]; + + private string TableStyle + => string.Create(CultureInfo.InvariantCulture, $"min-width: {this._columns.Sum(ColumnWidth)}px"); private string HeaderText => this._page is null ? "Reading scheduler job records." : this.ResultRangeText; @@ -883,7 +1278,12 @@ protected override async Task OnInitializedAsync() { Navigation.LocationChanged += this.OnLocationChanged; - _ = this._filters.ReplaceWith(DashboardJobFilterQuery.ParseUri(Navigation.Uri)); + this.InitializeConfiguredViews(); + this._hasExplicitInitialQuery = DashboardJobViews.HasRecognizedQuery(Navigation.Uri); + _ = this._filters.ReplaceWith(this._hasExplicitInitialQuery + ? DashboardJobFilterQuery.ParseUri(Navigation.Uri) + : DashboardJobViews.CreateFilters(this.CurrentView!.Definition)); + this._isViewDirty = this._hasExplicitInitialQuery; this.InitializeLiveRefresh( this.RefreshCurrentQueryAsync, showRefreshing: true, @@ -892,6 +1292,17 @@ await this.LoadAsync(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || this._viewStorageLoaded) + { + return; + } + + this._viewStorageLoaded = true; + await this.LoadStoredViewsAsync(); + } + private protected override ValueTask DisposePageAsync() { Navigation.LocationChanged -= this.OnLocationChanged; @@ -900,6 +1311,465 @@ return ValueTask.CompletedTask; } + private void InitializeConfiguredViews() + { + this._views.Add(new DashboardResolvedJobView( + DashboardJobViews.BuiltInKey, + DashboardJobViews.BuiltInName, + DashboardJobViewSource.BuiltIn, + DashboardJobViews.BuiltInView)); + + foreach (var configuredView in DashboardOptions.Value.JobViews) + { + var normalizedView = configuredView with + { + Name = configuredView.Name.Trim(), + }; + this._views.Add(new DashboardResolvedJobView( + DashboardJobViews.SharedKey(normalizedView.Name), + normalizedView.Name, + DashboardJobViewSource.Shared, + normalizedView)); + } + + var configuredDefault = string.IsNullOrWhiteSpace(DashboardOptions.Value.DefaultJobViewName) + ? null + : this._views.FirstOrDefault(view => + view.Source == DashboardJobViewSource.Shared + && string.Equals(view.Name, DashboardOptions.Value.DefaultJobViewName.Trim(), StringComparison.OrdinalIgnoreCase)); + var initialView = configuredDefault ?? this._views[0]; + this._selectedViewKey = initialView.Key; + this._preferredViewKey = initialView.Key; + this.ReplaceColumns(DashboardJobViews.GetColumns(initialView.Definition)); + } + + private async Task LoadStoredViewsAsync() + { + try + { + var payload = await JsRuntime.InvokeAsync( + "shedduellerJobViews.load", + this.ViewStorageKey()); + if (payload is null) + { + return; + } + + if (payload.Version != DashboardJobViewStoragePayload.CurrentVersion) + { + this.SetActionWarning("Saved job views use an unsupported format and were ignored."); + return; + } + + var invalidViewFound = false; + var ids = new HashSet(StringComparer.Ordinal); + var names = this._views.Select(view => view.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var storedView in payload.Views ?? []) + { + if (storedView is null + || !Guid.TryParseExact(storedView.Id, "D", out _) + || !ids.Add(storedView.Id) + || !DashboardJobViews.IsValid(storedView.View) + || !names.Add(storedView.View.Name.Trim())) + { + invalidViewFound = true; + continue; + } + + storedView.View = storedView.View with + { + Name = storedView.View.Name.Trim(), + }; + this._storedViews.Add(storedView); + } + + this.RebuildPersonalViews(); + if (!string.IsNullOrWhiteSpace(payload.PreferredViewKey) + && this.FindView(payload.PreferredViewKey) is { } preferredView) + { + this._preferredViewKey = preferredView.Key; + this._selectedViewKey = preferredView.Key; + this.ReplaceColumns(DashboardJobViews.GetColumns(preferredView.Definition)); + + if (this._hasExplicitInitialQuery) + { + this._isViewDirty = true; + } + else + { + _ = this._filters.ReplaceWith(DashboardJobViews.CreateFilters(preferredView.Definition)); + this._isViewDirty = false; + this.NavigateToCurrentFilters(); + await this.LoadAsync(); + } + } + + if (invalidViewFound) + { + this.SetActionWarning("One or more invalid saved job views were ignored."); + } + + this.StateHasChanged(); + } + catch (JSException) + { + this.SetActionWarning("Personal job views are unavailable because browser storage could not be read."); + this.StateHasChanged(); + } + catch (InvalidOperationException) + { + this.SetActionWarning("Personal job views are unavailable in this rendering mode."); + this.StateHasChanged(); + } + } + + private async Task SetViewAsync(ChangeEventArgs eventArgs) + { + if (eventArgs.Value is not string key || this.FindView(key) is not { } nextView) + { + return; + } + + if (this._isViewDirty + && !await JsRuntime.InvokeAsync("confirm", "Discard unsaved changes to the current job view?")) + { + this.StateHasChanged(); + return; + } + + await this.ActivateViewAsync(nextView, applyQuery: true); + } + + private async Task ActivateViewAsync( + DashboardResolvedJobView view, + bool applyQuery) + { + this.CancelPendingFilterLoad(); + this._selectedViewKey = view.Key; + this.ReplaceColumns(DashboardJobViews.GetColumns(view.Definition)); + this._isViewDirty = false; + this.ClearActionAlert(); + + if (applyQuery) + { + _ = this._filters.ReplaceWith(DashboardJobViews.CreateFilters(view.Definition)); + this.NavigateToCurrentFilters(); + await this.LoadAsync(); + } + } + + private async Task SaveCurrentViewAsync() + { + if (!this.CanSaveCurrentView || this.FindStoredView(this._selectedViewKey) is not { } storedView) + { + return; + } + + storedView.View = DashboardJobViews.CreateView(storedView.View.Name, this._filters, this._columns); + this.RebuildPersonalViews(); + this._isViewDirty = false; + await this.PersistStoredViewsAsync("Saved job view."); + } + + private async Task SaveViewAsAsync() + { + var suggestedName = this.CurrentView?.Source == DashboardJobViewSource.BuiltIn + ? "My View" + : string.Concat(this.CurrentView?.Name ?? "Job View", " Copy"); + var name = await JsRuntime.InvokeAsync("prompt", "Save job view as:", suggestedName); + if (string.IsNullOrWhiteSpace(name)) + { + return; + } + + var normalizedName = name.Trim(); + if (!this.IsViewNameAvailable(normalizedName, exceptKey: null)) + { + this.SetActionError("A job view with that name already exists."); + return; + } + + var id = Guid.NewGuid().ToString("D", CultureInfo.InvariantCulture); + var storedView = new DashboardStoredJobView + { + Id = id, + View = DashboardJobViews.CreateView(normalizedName, this._filters, this._columns), + }; + this._storedViews.Add(storedView); + this.RebuildPersonalViews(); + this._selectedViewKey = DashboardJobViews.PersonalKey(id); + this._isViewDirty = false; + await this.PersistStoredViewsAsync("Saved personal job view."); + } + + private async Task RenameCurrentViewAsync() + { + if (this.FindStoredView(this._selectedViewKey) is not { } storedView) + { + return; + } + + var name = await JsRuntime.InvokeAsync("prompt", "Rename job view:", storedView.View.Name); + if (string.IsNullOrWhiteSpace(name)) + { + return; + } + + var normalizedName = name.Trim(); + if (!this.IsViewNameAvailable(normalizedName, this._selectedViewKey)) + { + this.SetActionError("A job view with that name already exists."); + return; + } + + storedView.View = storedView.View with + { + Name = normalizedName, + }; + this.RebuildPersonalViews(); + await this.PersistStoredViewsAsync("Renamed personal job view."); + } + + private async Task DeleteCurrentViewAsync() + { + if (this.FindStoredView(this._selectedViewKey) is not { } storedView + || !await JsRuntime.InvokeAsync("confirm", string.Concat("Delete job view ‘", storedView.View.Name, "’?"))) + { + return; + } + + var deletedKey = DashboardJobViews.PersonalKey(storedView.Id); + _ = this._storedViews.Remove(storedView); + this.RebuildPersonalViews(); + if (string.Equals(this._preferredViewKey, deletedKey, StringComparison.Ordinal)) + { + this._preferredViewKey = this.ConfiguredDefaultView().Key; + } + + await this.ActivateViewAsync(this.FindView(this._preferredViewKey) ?? this.ConfiguredDefaultView(), applyQuery: true); + await this.PersistStoredViewsAsync("Deleted personal job view."); + } + + private async Task SetPreferredViewAsync() + { + if (!this.CanSetPreferredView || this.CurrentView is null) + { + return; + } + + this._preferredViewKey = this._selectedViewKey; + await this.PersistStoredViewsAsync("Set the default job view."); + } + + private async Task PersistStoredViewsAsync(string successMessage) + { + try + { + await JsRuntime.InvokeVoidAsync( + "shedduellerJobViews.save", + this.ViewStorageKey(), + new DashboardJobViewStoragePayload + { + PreferredViewKey = this._preferredViewKey, + Views = [.. this._storedViews], + }); + this.SetActionSuccess(successMessage); + } + catch (JSException) + { + this.SetActionWarning("The view is available for this session, but browser storage could not be updated."); + } + } + + private void ToggleColumnEditor() + => this._isColumnEditorOpen = !this._isColumnEditorOpen; + + private void SetBuiltInColumnVisible( + ShedduellerDashboardJobColumnKind kind, + ChangeEventArgs eventArgs) + { + var visible = eventArgs.Value is bool selected && selected; + var index = this._columns.FindIndex(column => column.Kind == kind); + if (visible && index < 0) + { + this._columns.Add(new ShedduellerDashboardJobColumn(kind)); + this.MarkViewDirty(); + } + else if (!visible && index >= 0 && kind != ShedduellerDashboardJobColumnKind.JobId) + { + this._columns.RemoveAt(index); + this.MarkViewDirty(); + } + } + + private void MoveColumn( + int index, + int offset) + { + var target = index + offset; + if (index < 0 || index >= this._columns.Count || target < 0 || target >= this._columns.Count) + { + return; + } + + var column = this._columns[index]; + this._columns.RemoveAt(index); + this._columns.Insert(target, column); + this.MarkViewDirty(); + } + + private void RemoveColumn(int index) + { + if (index < 0 || index >= this._columns.Count + || this._columns[index].Kind != ShedduellerDashboardJobColumnKind.Tag) + { + return; + } + + this._columns.RemoveAt(index); + this.MarkViewDirty(); + } + + private void AddTagColumn() + { + if (string.IsNullOrWhiteSpace(this._newTagName)) + { + this.SetActionError("Enter a tag name to promote."); + return; + } + + var tagName = this._newTagName.Trim(); + if (this._columns.Any(column => + column.Kind == ShedduellerDashboardJobColumnKind.Tag + && string.Equals(column.TagName, tagName, StringComparison.OrdinalIgnoreCase))) + { + this.SetActionError("That tag already has a promoted column."); + return; + } + + this._columns.Add(new ShedduellerDashboardJobColumn( + ShedduellerDashboardJobColumnKind.Tag, + tagName, + string.IsNullOrWhiteSpace(this._newTagHeading) ? null : this._newTagHeading.Trim())); + this._newTagName = string.Empty; + this._newTagHeading = string.Empty; + this.ClearActionAlert(); + this.MarkViewDirty(); + } + + private void ResetColumns() + { + this.ReplaceColumns(DashboardJobViews.BuiltInColumns); + this.MarkViewDirty(); + } + + private void ReplaceColumns(IReadOnlyList columns) + { + this._columns.Clear(); + this._columns.AddRange(columns); + } + + private void RebuildPersonalViews() + { + this._views.RemoveAll(view => view.Source == DashboardJobViewSource.Personal); + foreach (var storedView in this._storedViews) + { + this._views.Add(new DashboardResolvedJobView( + DashboardJobViews.PersonalKey(storedView.Id), + storedView.View.Name, + DashboardJobViewSource.Personal, + storedView.View)); + } + } + + private DashboardResolvedJobView ConfiguredDefaultView() + { + if (!string.IsNullOrWhiteSpace(DashboardOptions.Value.DefaultJobViewName) + && this._views.FirstOrDefault(view => + view.Source == DashboardJobViewSource.Shared + && string.Equals(view.Name, DashboardOptions.Value.DefaultJobViewName.Trim(), StringComparison.OrdinalIgnoreCase)) is { } configuredDefault) + { + return configuredDefault; + } + + return this._views[0]; + } + + private DashboardResolvedJobView? FindView(string key) + => this._views.FirstOrDefault(view => string.Equals(view.Key, key, StringComparison.Ordinal)); + + private DashboardStoredJobView? FindStoredView(string key) + { + if (!key.StartsWith(DashboardJobViews.PersonalKeyPrefix, StringComparison.Ordinal)) + { + return null; + } + + var id = key[DashboardJobViews.PersonalKeyPrefix.Length..]; + return this._storedViews.FirstOrDefault(view => string.Equals(view.Id, id, StringComparison.Ordinal)); + } + + private bool IsViewNameAvailable( + string name, + string? exceptKey) + => this._views.All(view => + string.Equals(view.Key, exceptKey, StringComparison.Ordinal) + || !string.Equals(view.Name, name, StringComparison.OrdinalIgnoreCase)); + + private string ViewStorageKey() + => string.Concat( + "sheddueller.dashboard.jobViews.v1:", + new Uri(Navigation.BaseUri, UriKind.Absolute).AbsolutePath.TrimEnd('/')); + + private void MarkViewDirty() + => this._isViewDirty = true; + + private static string ColumnKey(ShedduellerDashboardJobColumn column) + => column.Kind == ShedduellerDashboardJobColumnKind.Tag + ? string.Concat("tag:", column.TagName) + : column.Kind.ToString(); + + private static int ColumnWidth(ShedduellerDashboardJobColumn column) + => column.Kind switch + { + ShedduellerDashboardJobColumnKind.JobId => 64, + ShedduellerDashboardJobColumnKind.Enqueued or ShedduellerDashboardJobColumnKind.TerminalTime => 160, + ShedduellerDashboardJobColumnKind.State => 112, + ShedduellerDashboardJobColumnKind.Queue => 96, + ShedduellerDashboardJobColumnKind.Handler => 220, + ShedduellerDashboardJobColumnKind.Tags => 320, + ShedduellerDashboardJobColumnKind.Progress => 200, + ShedduellerDashboardJobColumnKind.Disposition => 144, + ShedduellerDashboardJobColumnKind.Groups => 180, + ShedduellerDashboardJobColumnKind.Priority or ShedduellerDashboardJobColumnKind.Attempts => 56, + ShedduellerDashboardJobColumnKind.Tag => 160, + _ => 144, + }; + + private static string ColumnWidthStyle(ShedduellerDashboardJobColumn column) + => string.Create(CultureInfo.InvariantCulture, $"width: {ColumnWidth(column)}px"); + + private static string? ColumnHeaderClass(ShedduellerDashboardJobColumn column) + => column.Kind is ShedduellerDashboardJobColumnKind.Priority or ShedduellerDashboardJobColumnKind.Attempts + ? "jobs-table__number" + : null; + + private IReadOnlyList ResidualTags(JobInspectionSummary job) + => DashboardJobViews.GetResidualTags(job.Tags, this._columns); + + private static IReadOnlyList TagValues( + JobInspectionSummary job, + ShedduellerDashboardJobColumn column) + => [.. DashboardJobViews.GetTagValues(job.Tags, column.TagName!).Select(tag => tag.Value)]; + + private static string TagValuesTitle(IReadOnlyList values) + => values.Count == 0 ? "No value" : string.Join(", ", values); + + private string TagValueFilterHref( + ShedduellerDashboardJobColumn column, + string value) + => DashboardJobFilterQuery.WithTagHref(this._filters, string.Concat(column.TagName, ":", value)); + private async Task SetStateFilterAsync( JobState state, ChangeEventArgs eventArgs) @@ -909,6 +1779,7 @@ return; } + this.MarkViewDirty(); this.CancelPendingFilterLoad(); this.NavigateToCurrentFilters(); await this.LoadAsync(); @@ -934,6 +1805,7 @@ } this.CancelPendingFilterLoad(); + this.MarkViewDirty(); this.NavigateToCurrentFilters(); await this.LoadAsync(); } @@ -944,6 +1816,7 @@ { if (setFilter(value)) { + this.MarkViewDirty(); this.ScheduleDebouncedLoad(); } @@ -959,6 +1832,7 @@ this.CancelPendingFilterLoad(); this._filters.Clear(); + this.MarkViewDirty(); this.NavigateToCurrentFilters(); await this.LoadAsync(); } @@ -1187,6 +2061,7 @@ return; } + this.MarkViewDirty(); this.CancelPendingFilterLoad(); await this.LoadAsync(); this.StateHasChanged(); diff --git a/src/Sheddueller.Dashboard/Internal/DashboardFilters.cs b/src/Sheddueller.Dashboard/Internal/DashboardFilters.cs index d1257f1..bfaab0b 100644 --- a/src/Sheddueller.Dashboard/Internal/DashboardFilters.cs +++ b/src/Sheddueller.Dashboard/Internal/DashboardFilters.cs @@ -160,6 +160,16 @@ internal static class DashboardJobFilterQuery public static DashboardJobFilters ParseUri(string uri) => ParseQuery(new Uri(uri, UriKind.Absolute).Query); + public static bool HasRecognizedQuery(string queryString) + { + var query = QueryHelpers.ParseQuery(queryString); + return query.ContainsKey(StateParameter) + || query.ContainsKey(HandlerParameter) + || query.ContainsKey(TagParameter) + || query.ContainsKey(GroupParameter) + || query.ContainsKey(SortParameter); + } + public static DashboardJobFilters ParseQuery(string queryString) { var filters = new DashboardJobFilters(); diff --git a/src/Sheddueller.Dashboard/Internal/DashboardJobViews.cs b/src/Sheddueller.Dashboard/Internal/DashboardJobViews.cs new file mode 100644 index 0000000..7dcb07f --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/DashboardJobViews.cs @@ -0,0 +1,247 @@ +namespace Sheddueller.Dashboard.Internal; + +internal static class DashboardJobViews +{ + public const string BuiltInKey = "built-in"; + public const string BuiltInName = "Default"; + public const string SharedKeyPrefix = "shared:"; + public const string PersonalKeyPrefix = "personal:"; + + public static readonly IReadOnlyList BuiltInColumns = + [ + new(ShedduellerDashboardJobColumnKind.JobId), + new(ShedduellerDashboardJobColumnKind.Enqueued), + new(ShedduellerDashboardJobColumnKind.TerminalTime), + new(ShedduellerDashboardJobColumnKind.State), + new(ShedduellerDashboardJobColumnKind.Queue), + new(ShedduellerDashboardJobColumnKind.Handler), + new(ShedduellerDashboardJobColumnKind.Tags), + new(ShedduellerDashboardJobColumnKind.Disposition), + new(ShedduellerDashboardJobColumnKind.Groups), + new(ShedduellerDashboardJobColumnKind.Priority), + new(ShedduellerDashboardJobColumnKind.Attempts), + ]; + + public static readonly ShedduellerDashboardJobView BuiltInView = new(BuiltInName) + { + Columns = BuiltInColumns, + }; + + public static bool IsValid(ShedduellerDashboardOptions options) + { + if (options.JobViews is null) + { + return false; + } + + var names = new HashSet(StringComparer.OrdinalIgnoreCase) + { + BuiltInName, + }; + + foreach (var view in options.JobViews) + { + if (view is null || !IsValid(view) || !names.Add(view.Name.Trim())) + { + return false; + } + } + + return string.IsNullOrWhiteSpace(options.DefaultJobViewName) + || options.JobViews.Any(view => string.Equals( + view.Name.Trim(), + options.DefaultJobViewName.Trim(), + StringComparison.OrdinalIgnoreCase)); + } + + public static bool IsValid(ShedduellerDashboardJobView view) + { + if (view is null + || string.IsNullOrWhiteSpace(view.Name) + || !Enum.IsDefined(view.Sort) + || view.States is null + || view.States.Any(state => !Enum.IsDefined(state)) + || view.States.Count != view.States.Distinct().Count()) + { + return false; + } + + return view.Columns is null || IsValidColumns(view.Columns); + } + + public static IReadOnlyList GetColumns(ShedduellerDashboardJobView view) + => NormalizeColumns(view.Columns ?? BuiltInColumns); + + public static DashboardJobFilters CreateFilters(ShedduellerDashboardJobView view) + { + var filters = new DashboardJobFilters(); + _ = filters.ReplaceStates(view.States); + _ = filters.SetHandlerContains(view.HandlerContains ?? string.Empty); + _ = filters.SetTagContains(view.TagContains ?? string.Empty); + _ = filters.SetConcurrencyGroupContains(view.ConcurrencyGroupContains ?? string.Empty); + _ = filters.SetSort(view.Sort); + return filters; + } + + public static ShedduellerDashboardJobView CreateView( + string name, + DashboardJobFilters filters, + IReadOnlyList columns) + => new(name.Trim()) + { + States = filters.SelectedStates, + HandlerContains = Normalize(filters.HandlerContains), + TagContains = Normalize(filters.TagContains), + ConcurrencyGroupContains = Normalize(filters.ConcurrencyGroupContains), + Sort = filters.Sort, + Columns = NormalizeColumns(columns), + }; + + public static string SharedKey(string name) + => string.Concat(SharedKeyPrefix, name.Trim()); + + public static string PersonalKey(string id) + => string.Concat(PersonalKeyPrefix, id); + + public static IReadOnlyList GetTagValues( + IReadOnlyList tags, + string tagName) + { + var values = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var tag in tags) + { + if (string.Equals(tag.Name, tagName, StringComparison.OrdinalIgnoreCase) + && seen.Add(tag.Value)) + { + values.Add(tag); + } + } + + return values; + } + + public static IReadOnlyList GetResidualTags( + IReadOnlyList tags, + IReadOnlyList columns) + { + var promotedNames = columns + .Where(column => column.Kind == ShedduellerDashboardJobColumnKind.Tag) + .Select(column => column.TagName ?? string.Empty) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return promotedNames.Count == 0 + ? tags + : [.. tags.Where(tag => !promotedNames.Contains(tag.Name))]; + } + + public static string Heading(ShedduellerDashboardJobColumn column) + => column.Kind == ShedduellerDashboardJobColumnKind.Tag + ? Normalize(column.Heading) ?? column.TagName! + : column.Kind switch + { + ShedduellerDashboardJobColumnKind.JobId => "Job ID", + ShedduellerDashboardJobColumnKind.Enqueued => "Enqueued", + ShedduellerDashboardJobColumnKind.TerminalTime => "Terminal Time", + ShedduellerDashboardJobColumnKind.State => "State", + ShedduellerDashboardJobColumnKind.Queue => "Queue", + ShedduellerDashboardJobColumnKind.Handler => "Handler", + ShedduellerDashboardJobColumnKind.Tags => "Tags", + ShedduellerDashboardJobColumnKind.Progress => "Progress", + ShedduellerDashboardJobColumnKind.Disposition => "Disposition", + ShedduellerDashboardJobColumnKind.Groups => "Groups", + ShedduellerDashboardJobColumnKind.Priority => "Pri", + ShedduellerDashboardJobColumnKind.Attempts => "Att", + _ => column.Kind.ToString(), + }; + + public static bool HasRecognizedQuery(string uri) + => DashboardJobFilterQuery.HasRecognizedQuery(new Uri(uri, UriKind.Absolute).Query); + + private static bool IsValidColumns(IReadOnlyList columns) + { + if (columns.Count == 0) + { + return false; + } + + var builtInKinds = new HashSet(); + var tagNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var hasJobId = false; + foreach (var column in columns) + { + if (column is null || !Enum.IsDefined(column.Kind)) + { + return false; + } + + if (column.Kind == ShedduellerDashboardJobColumnKind.Tag) + { + if (string.IsNullOrWhiteSpace(column.TagName) || !tagNames.Add(column.TagName.Trim())) + { + return false; + } + + continue; + } + + if (!string.IsNullOrWhiteSpace(column.TagName) + || !string.IsNullOrWhiteSpace(column.Heading) + || !builtInKinds.Add(column.Kind)) + { + return false; + } + + hasJobId |= column.Kind == ShedduellerDashboardJobColumnKind.JobId; + } + + return hasJobId; + } + + private static IReadOnlyList NormalizeColumns( + IReadOnlyList columns) + => + [ + .. columns.Select(column => column.Kind == ShedduellerDashboardJobColumnKind.Tag + ? column with + { + TagName = column.TagName!.Trim(), + Heading = Normalize(column.Heading), + } + : new ShedduellerDashboardJobColumn(column.Kind)), + ]; + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +internal enum DashboardJobViewSource +{ + BuiltIn, + Shared, + Personal, +} + +internal sealed record DashboardResolvedJobView( + string Key, + string Name, + DashboardJobViewSource Source, + ShedduellerDashboardJobView Definition); + +internal sealed class DashboardJobViewStoragePayload +{ + public const int CurrentVersion = 1; + + public int Version { get; set; } = CurrentVersion; + + public string? PreferredViewKey { get; set; } + + public List Views { get; set; } = []; +} + +internal sealed class DashboardStoredJobView +{ + public string Id { get; set; } = string.Empty; + + public ShedduellerDashboardJobView View { get; set; } = new(string.Empty); +} diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumn.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumn.cs new file mode 100644 index 0000000..aef7361 --- /dev/null +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumn.cs @@ -0,0 +1,12 @@ +namespace Sheddueller.Dashboard; + +/// +/// Configures one visible column in a shared dashboard job view. +/// +/// The kind of column to render. +/// The tag name to extract when is . +/// An optional heading for a tag column. The tag name is used when omitted. +public sealed record ShedduellerDashboardJobColumn( + ShedduellerDashboardJobColumnKind Kind, + string? TagName = null, + string? Heading = null); diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumnKind.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumnKind.cs new file mode 100644 index 0000000..a205265 --- /dev/null +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumnKind.cs @@ -0,0 +1,46 @@ +namespace Sheddueller.Dashboard; + +/// +/// Identifies a column available to a configured dashboard job view. +/// +public enum ShedduellerDashboardJobColumnKind +{ + /// The job identifier and detail-page link. + JobId, + + /// The enqueue timestamp. + Enqueued, + + /// The completed, failed, or canceled timestamp. + TerminalTime, + + /// The current job state. + State, + + /// The operational queue position. + Queue, + + /// The job handler. + Handler, + + /// Tags not promoted into their own columns. + Tags, + + /// The latest reported progress. + Progress, + + /// The current operational disposition. + Disposition, + + /// Concurrency group keys. + Groups, + + /// The job priority. + Priority, + + /// The current and maximum attempt counts. + Attempts, + + /// Values extracted from a named job tag. + Tag, +} diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardJobView.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardJobView.cs new file mode 100644 index 0000000..705cb9d --- /dev/null +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardJobView.cs @@ -0,0 +1,41 @@ +namespace Sheddueller.Dashboard; + +using Sheddueller.Inspection.Jobs; +using Sheddueller.Storage; + +/// +/// Configures a shared, read-only dashboard job view. +/// +/// The user-facing view name. +public sealed record ShedduellerDashboardJobView(string Name) +{ + /// + /// Gets the job states included by the view. An empty list includes every state. + /// + public IReadOnlyList States { get; init; } = []; + + /// + /// Gets the optional handler substring filter. + /// + public string? HandlerContains { get; init; } + + /// + /// Gets the optional tag substring filter. + /// + public string? TagContains { get; init; } + + /// + /// Gets the optional concurrency group substring filter. + /// + public string? ConcurrencyGroupContains { get; init; } + + /// + /// Gets the job sort applied by the view. + /// + public JobInspectionSort Sort { get; init; } = JobInspectionSort.Operational; + + /// + /// Gets the ordered visible columns. A value uses the built-in column layout. + /// + public IReadOnlyList? Columns { get; init; } +} diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs index 6c2f1a2..9afbe97 100644 --- a/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs @@ -23,4 +23,15 @@ public sealed class ShedduellerDashboardOptions /// Tags with names listed here are shown first in the configured order; all other tags keep their persisted order. /// public IReadOnlyList TagDisplayOrder { get; set; } = []; + + /// + /// Gets or sets shared, read-only job views exposed by the dashboard host. + /// + public IReadOnlyList JobViews { get; set; } = []; + + /// + /// Gets or sets the name of the shared job view used when a user has not chosen a personal default. + /// A value uses the built-in default view. + /// + public string? DefaultJobViewName { get; set; } } diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardServiceCollectionExtensions.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardServiceCollectionExtensions.cs index 9288827..5a31d5a 100644 --- a/src/Sheddueller.Dashboard/ShedduellerDashboardServiceCollectionExtensions.cs +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ public static IServiceCollection AddShedduellerDashboard( .Configure(options => configure?.Invoke(options)) .Validate(options => options.EventRetention > TimeSpan.Zero, "ShedduellerDashboardOptions.EventRetention must be positive.") .Validate(DashboardTagOrder.IsValid, "ShedduellerDashboardOptions.TagDisplayOrder cannot contain null, empty, or duplicate tag names.") + .Validate(DashboardJobViews.IsValid, "ShedduellerDashboardOptions job views are invalid. Names and columns must be unique, the default must exist, and every layout must include JobId.") .ValidateOnStart(); services.AddRazorComponents() diff --git a/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-job-views.js b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-job-views.js new file mode 100644 index 0000000..b158b17 --- /dev/null +++ b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-job-views.js @@ -0,0 +1,13 @@ +(function () { + "use strict"; + + window.shedduellerJobViews = { + load: function (key) { + const value = window.localStorage.getItem(key); + return value === null ? null : JSON.parse(value); + }, + save: function (key, value) { + window.localStorage.setItem(key, JSON.stringify(value)); + } + }; +})(); diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index 0b43772..7c5c1ea 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -179,6 +179,61 @@ public async Task Jobs_TagDisplayOrder_ConfiguredNamesLeadCompactChips() html.ShouldContain("aria-label=\"Additional tags: domain:payments, tenant:acme, schedule:daily_rollup, source:stub\""); } + [Fact] + public async Task Jobs_SharedDefaultView_RendersConfiguredFiltersAndPromotedTagColumn() + { + await using var app = await CreateStartedDashboardAsync(configureDashboard: options => + { + options.JobViews = + [ + new ShedduellerDashboardJobView("Failures") + { + States = [JobState.Claimed], + Sort = JobInspectionSort.NewestFirst, + Columns = + [ + new(ShedduellerDashboardJobColumnKind.JobId), + new(ShedduellerDashboardJobColumnKind.Handler), + new(ShedduellerDashboardJobColumnKind.Tag, "tenant", "Customer"), + new(ShedduellerDashboardJobColumnKind.Attempts), + ], + }, + ]; + options.DefaultJobViewName = "Failures"; + }); + var html = await GetOkHtmlAsync(app, "/sheddueller/jobs"); + + html.ShouldContain("Shared Presets"); + html.ShouldContain("Failures"); + AssertStatusCheckbox(html, "Claimed", isChecked: true); + AssertSelectValue(html, "Sort jobs", "NewestFirst"); + html.ShouldContain(""); + html.ShouldContain("jobs-derived-tag"); + html.ShouldContain(">acme"); + html.ShouldContain("href=\"jobs?state=Claimed&tag=tenant%3Aacme&sort=NewestFirst\""); + html.ShouldNotContain(""); + html.ShouldNotContain(""); + + var filteredHtml = await GetOkHtmlAsync(app, "/sheddueller/jobs?state=Failed"); + AssertStatusCheckbox(filteredHtml, "Failed", isChecked: true); + AssertStatusCheckbox(filteredHtml, "Claimed", isChecked: false); + filteredHtml.ShouldContain(""); + } + + [Fact] + public async Task Jobs_BuiltInView_HidesProgressAndRendersViewControls() + { + await using var app = await CreateStartedDashboardAsync(); + var html = await GetOkHtmlAsync(app, "/sheddueller/jobs"); + + html.ShouldContain("aria-label=\"Select job view\""); + html.ShouldContain("Save As"); + html.ShouldContain("Set Default"); + html.ShouldContain(">Columns"); + html.ShouldNotContain(""); + html.ShouldContain(""); + } + [Fact] public async Task Jobs_QueryFilters_RendersControlsAndPreservingQuickLinks() { diff --git a/test/Sheddueller.Dashboard.Tests/DashboardFilterTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardFilterTests.cs index af3eea6..3822871 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardFilterTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardFilterTests.cs @@ -78,6 +78,17 @@ public void JobFilterQuery_ParseQuery_NormalizesUrlFilters() query.Sort.ShouldBe(JobInspectionSort.NewestFirst); } + [Theory] + [InlineData("", false)] + [InlineData("?utm_source=dashboard", false)] + [InlineData("?state=Failed", true)] + [InlineData("?tag=provider%3Astreamline", true)] + [InlineData("?sort=NewestFirst", true)] + public void JobFilterQuery_RecognizedQuery_DetectsOnlyJobViewFilters( + string query, + bool expected) + => DashboardJobFilterQuery.HasRecognizedQuery(query).ShouldBe(expected); + [Fact] public void JobFilterQuery_LinkGeneration_ReplacesClickedDimensionAndPreservesOthers() { diff --git a/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs index 0824ec1..0848048 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs @@ -1,6 +1,7 @@ namespace Sheddueller.Dashboard.Tests; using System.Globalization; +using System.Text.Json; using Sheddueller.Dashboard.Internal; using Sheddueller.Inspection.Jobs; @@ -134,6 +135,101 @@ public void TagOrder_OptionsValidation_RejectsEmptyAndDuplicateNames() DashboardTagOrder.IsValid(new ShedduellerDashboardOptions { TagDisplayOrder = ["tenant", " "] }).ShouldBeFalse(); } + [Fact] + public void JobViews_OptionsValidation_RequiresUniqueNamesValidColumnsAndExistingDefault() + { + var validView = new ShedduellerDashboardJobView("Failures") + { + States = [JobState.Failed], + Sort = JobInspectionSort.NewestFirst, + Columns = + [ + new(ShedduellerDashboardJobColumnKind.JobId), + new(ShedduellerDashboardJobColumnKind.Handler), + new(ShedduellerDashboardJobColumnKind.Tag, "provider", "Provider"), + ], + }; + + DashboardJobViews.IsValid(new ShedduellerDashboardOptions + { + JobViews = [validView], + DefaultJobViewName = " failures ", + }).ShouldBeTrue(); + DashboardJobViews.IsValid(new ShedduellerDashboardOptions + { + JobViews = [validView, validView with { Name = " failures " }], + }).ShouldBeFalse(); + DashboardJobViews.IsValid(new ShedduellerDashboardOptions + { + JobViews = [validView with { Columns = [new(ShedduellerDashboardJobColumnKind.Handler)] }], + }).ShouldBeFalse(); + DashboardJobViews.IsValid(new ShedduellerDashboardOptions + { + JobViews = [validView], + DefaultJobViewName = "Missing", + }).ShouldBeFalse(); + } + + [Fact] + public void JobViews_PromotedTags_ExtractsDistinctValuesAndRemovesResidualTags() + { + var tags = new[] + { + new JobTag("source", "channel"), + new JobTag("Provider", "streamline"), + new JobTag("provider", "streamline"), + new JobTag("provider", "rentals-united"), + new JobTag("listing-id", "42"), + }; + var columns = new[] + { + new ShedduellerDashboardJobColumn(ShedduellerDashboardJobColumnKind.JobId), + new ShedduellerDashboardJobColumn(ShedduellerDashboardJobColumnKind.Tag, "provider"), + }; + + DashboardJobViews.GetTagValues(tags, "provider") + .ShouldBe([new JobTag("Provider", "streamline"), new JobTag("provider", "rentals-united")]); + DashboardJobViews.GetResidualTags(tags, columns) + .ShouldBe([new JobTag("source", "channel"), new JobTag("listing-id", "42")]); + DashboardJobViews.BuiltInColumns.ShouldNotContain( + column => column.Kind == ShedduellerDashboardJobColumnKind.Progress); + } + + [Fact] + public void JobViews_StoragePayload_RoundTripsVersionPreferenceAndDefinition() + { + var id = Guid.Parse("ab40ef11-b70d-44c9-8a91-3c356f45da73").ToString("D"); + var payload = new DashboardJobViewStoragePayload + { + PreferredViewKey = DashboardJobViews.PersonalKey(id), + Views = + [ + new DashboardStoredJobView + { + Id = id, + View = new ShedduellerDashboardJobView("Listings") + { + TagContains = "domain:listing", + Columns = + [ + new(ShedduellerDashboardJobColumnKind.JobId), + new(ShedduellerDashboardJobColumnKind.Tag, "provider", "Provider"), + ], + }, + }, + ], + }; + + var json = JsonSerializer.Serialize(payload); + var restored = JsonSerializer.Deserialize(json); + + restored.ShouldNotBeNull(); + restored.Version.ShouldBe(DashboardJobViewStoragePayload.CurrentVersion); + restored.PreferredViewKey.ShouldBe(payload.PreferredViewKey); + restored.Views.Single().View.TagContains.ShouldBe("domain:listing"); + restored.Views.Single().View.Columns![1].TagName.ShouldBe("provider"); + } + [Fact] public void JobEvent_LogAndTimelineEvents_FormatsOperationalText() { From 4dc0d1feaecdcd32e5e5d669a3d07c3dd1d45722 Mon Sep 17 00:00:00 2001 From: Brian Tyler Date: Tue, 25 Aug 2026 17:22:12 +0100 Subject: [PATCH 2/2] feat: refine dashboard job views --- README.md | 2 +- .../Components/JobColumnConfigurator.razor | 908 ++++++++++++++++++ .../Components/Pages/Jobs.razor | 602 ++++++------ .../Components/TimestampBlock.razor | 38 +- .../Internal/DashboardFormat.cs | 3 + .../Internal/DashboardJobColumnEditor.cs | 137 +++ .../Internal/DashboardJobColumnWidths.cs | 115 +++ .../Internal/DashboardJobViews.cs | 9 +- .../ShedduellerDashboardJobColumn.cs | 9 +- .../wwwroot/vendor/sheddueller-job-views.js | 151 +++ .../DashboardEndpointTests.cs | 22 +- .../DashboardFormatTests.cs | 22 +- .../DashboardJobColumnEditorTests.cs | 95 ++ .../DashboardJobColumnWidthTests.cs | 66 ++ 14 files changed, 1878 insertions(+), 301 deletions(-) create mode 100644 src/Sheddueller.Dashboard/Components/JobColumnConfigurator.razor create mode 100644 src/Sheddueller.Dashboard/Internal/DashboardJobColumnEditor.cs create mode 100644 src/Sheddueller.Dashboard/Internal/DashboardJobColumnWidths.cs create mode 100644 test/Sheddueller.Dashboard.Tests/DashboardJobColumnEditorTests.cs create mode 100644 test/Sheddueller.Dashboard.Tests/DashboardJobColumnWidthTests.cs diff --git a/README.md b/README.md index 28d6a66..b5a831b 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ app.UseAntiforgery(); app.MapShedduellerDashboard("/sheddueller"); ``` -The dashboard uses the configured Sheddueller provider and can be hosted by a worker process or a client-only web process. Configured job views are shared, read-only presets. Dashboard users can copy or create personal views that persist in browser storage for the dashboard's mounted path. A view restores filters, sort order, visible column order, and tag-derived columns; promoted tags are removed from the remaining Tags cell. +The dashboard uses the configured Sheddueller provider and can be hosted by a worker process or a client-only web process. Configured job views are shared, read-only presets. Dashboard users can copy or create personal views that persist in browser storage for the dashboard's mounted path. A view restores filters, sort order, visible column order, per-column width overrides, and tag-derived columns; promoted tags are removed from the remaining Tags cell. Column widths can be set in host presets with the optional `Width` property or resized and auto-fitted from the dashboard. ## Testing diff --git a/src/Sheddueller.Dashboard/Components/JobColumnConfigurator.razor b/src/Sheddueller.Dashboard/Components/JobColumnConfigurator.razor new file mode 100644 index 0000000..5978e5c --- /dev/null +++ b/src/Sheddueller.Dashboard/Components/JobColumnConfigurator.razor @@ -0,0 +1,908 @@ +@implements IAsyncDisposable +@inject IJSRuntime JsRuntime + +@if (IsOpen) +{ + +} + + + +@code { + [Parameter] + public bool IsOpen { get; set; } + + [Parameter] + public IReadOnlyList Columns { get; set; } = []; + + [Parameter] + public IReadOnlyList HiddenBuiltInColumns { get; set; } = []; + + [Parameter] + public IReadOnlyList ObservedTagNames { get; set; } = []; + + [Parameter] + public bool IsPersonalView { get; set; } + + [Parameter] + public bool IsDirty { get; set; } + + [Parameter] + public EventCallback CloseRequested { get; set; } + + [Parameter] + public EventCallback SaveRequested { get; set; } + + [Parameter] + public EventCallback SaveAsRequested { get; set; } + + [Parameter] + public EventCallback ResetRequested { get; set; } + + [Parameter] + public EventCallback AutoFitRequested { get; set; } + + [Parameter] + public EventCallback ResetWidthsRequested { get; set; } + + [Parameter] + public EventCallback<(int FromIndex, int ToIndex)> ColumnMoved { get; set; } + + [Parameter] + public EventCallback<(ShedduellerDashboardJobColumnKind Kind, bool Visible)> VisibilityChanged { get; set; } + + [Parameter] + public EventCallback<(int Index, ShedduellerDashboardJobColumn Column)> TagColumnEdited { get; set; } + + [Parameter] + public EventCallback TagColumnRemoved { get; set; } + + [Parameter] + public EventCallback TagColumnAdded { get; set; } + + [Parameter] + public EventCallback ColumnWidthResetRequested { get; set; } + + private ElementReference _closeButton; + private DotNetObjectReference? _selfReference; + private int? _dragSourceIndex; + private int? _dragTargetIndex; + private int? _editingIndex; + private string _editTagName = string.Empty; + private string _editHeading = string.Empty; + private string _newTagName = string.Empty; + private string _newTagHeading = string.Empty; + private string? _editError; + private string? _addError; + private string? _announcement; + private bool _isAddingTag; + private bool _wasOpen; + private bool _escapeRegistered; + + protected override void OnParametersSet() + { + if (this._editingIndex is { } index + && (index < 0 || index >= this.Columns.Count || this.Columns[index].Kind != ShedduellerDashboardJobColumnKind.Tag)) + { + this.CancelTagEdit(); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + _ = firstRender; + + if (this.IsOpen && !this._wasOpen) + { + this._wasOpen = true; + await this._closeButton.FocusAsync(); + } + else if (!this.IsOpen) + { + this._wasOpen = false; + } + + if (this.IsOpen && !this._escapeRegistered) + { + this._selfReference ??= DotNetObjectReference.Create(this); + await JsRuntime.InvokeVoidAsync("shedduellerJobViews.registerDrawerEscape", this._selfReference); + this._escapeRegistered = true; + } + else if (!this.IsOpen && this._escapeRegistered) + { + await JsRuntime.InvokeVoidAsync("shedduellerJobViews.unregisterDrawerEscape"); + this._escapeRegistered = false; + } + } + + public async ValueTask DisposeAsync() + { + if (this._escapeRegistered) + { + try + { + await JsRuntime.InvokeVoidAsync("shedduellerJobViews.unregisterDrawerEscape"); + } + catch (JSDisconnectedException) + { + } + } + + this._selfReference?.Dispose(); + } + + [JSInvokable] + public Task CloseFromEscapeAsync() + => this.CloseAsync(); + + private Task CloseAsync() + => this.CloseRequested.InvokeAsync(); + + private void BeginDrag(int index) + { + this._dragSourceIndex = index; + this._dragTargetIndex = index; + } + + private void SetDragTarget(int index) + { + if (this._dragSourceIndex is not null) + { + this._dragTargetIndex = index; + } + } + + private async Task DropColumnAsync(int targetIndex) + { + if (this._dragSourceIndex is { } sourceIndex) + { + await this.MoveColumnAsync(sourceIndex, targetIndex); + } + + this.EndDrag(); + } + + private void EndDrag() + { + this._dragSourceIndex = null; + this._dragTargetIndex = null; + } + + private async Task HandleReorderKeyAsync( + int index, + KeyboardEventArgs eventArgs) + { + var targetIndex = eventArgs.Key switch + { + "ArrowUp" => index - 1, + "ArrowDown" => index + 1, + "Home" => 0, + "End" => this.Columns.Count - 1, + _ => index, + }; + + if (targetIndex != index && targetIndex >= 0 && targetIndex < this.Columns.Count) + { + await this.MoveColumnAsync(index, targetIndex); + } + } + + private async Task MoveColumnAsync( + int fromIndex, + int toIndex) + { + if (fromIndex < 0 || fromIndex >= this.Columns.Count + || toIndex < 0 || toIndex >= this.Columns.Count + || fromIndex == toIndex) + { + return; + } + + var heading = DashboardJobViews.Heading(this.Columns[fromIndex]); + await this.ColumnMoved.InvokeAsync((fromIndex, toIndex)); + this._announcement = string.Create( + CultureInfo.InvariantCulture, + $"{heading} moved to position {toIndex + 1} of {this.Columns.Count}."); + } + + private Task SetColumnVisibleAsync( + ShedduellerDashboardJobColumnKind kind, + bool visible) + => this.VisibilityChanged.InvokeAsync((kind, visible)); + + private void BeginTagEdit(int index) + { + var column = this.Columns[index]; + this._editingIndex = index; + this._editTagName = column.TagName ?? string.Empty; + this._editHeading = column.Heading ?? string.Empty; + this._editError = null; + } + + private async Task ApplyTagEditAsync() + { + if (this._editingIndex is not { } index) + { + return; + } + + var preview = this.Columns.ToList(); + if (!DashboardJobColumnEditor.TryUpdateTag( + preview, + index, + this._editTagName, + this._editHeading, + out this._editError)) + { + return; + } + + await this.TagColumnEdited.InvokeAsync((index, preview[index])); + this.CancelTagEdit(); + } + + private void CancelTagEdit() + { + this._editingIndex = null; + this._editTagName = string.Empty; + this._editHeading = string.Empty; + this._editError = null; + } + + private Task RemoveTagColumnAsync(int index) + => this.TagColumnRemoved.InvokeAsync(index); + + private void ToggleAddTag() + { + this._isAddingTag = !this._isAddingTag; + this._addError = null; + } + + private async Task AddTagColumnAsync() + { + var preview = this.Columns.ToList(); + if (!DashboardJobColumnEditor.TryAddTag( + preview, + this._newTagName, + this._newTagHeading, + out this._addError)) + { + return; + } + + await this.TagColumnAdded.InvokeAsync(preview[^1]); + this.CancelAddTag(); + } + + private void CancelAddTag() + { + this._isAddingTag = false; + this._newTagName = string.Empty; + this._newTagHeading = string.Empty; + this._addError = null; + } + + private string ColumnRowClass(int index) + => string.Concat( + "jobs-column-drawer__row", + this._dragSourceIndex == index ? " jobs-column-drawer__row--dragging" : string.Empty, + this._dragTargetIndex == index && this._dragSourceIndex != index + ? " jobs-column-drawer__row--drop-target" + : string.Empty); + + private static string ColumnKey(ShedduellerDashboardJobColumn column) + => column.Kind == ShedduellerDashboardJobColumnKind.Tag + ? string.Concat("tag:", column.TagName) + : column.Kind.ToString(); + + private static string ColumnWidthText(ShedduellerDashboardJobColumn column) + => string.Create( + CultureInfo.InvariantCulture, + $"{DashboardJobColumnWidths.Get(column)} px · {(column.Width is null ? "default" : "custom")}"); +} diff --git a/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor b/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor index 5e7e074..a7f74b6 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor @@ -50,18 +50,28 @@ @CurrentViewStatus
- - - + @if (CurrentView?.Source == DashboardJobViewSource.Personal) + { + + + } + else + { + + }
- @if (_isColumnEditorOpen) - { -
-
-
-

Columns

-

Choose and order fields for this view. Job ID remains visible so job details are always reachable.

-
- -
- -
- @for (var index = 0; index < _columns.Count; index++) - { - var columnIndex = index; - var column = _columns[columnIndex]; -
- @if (column.Kind == ShedduellerDashboardJobColumnKind.Tag) - { - - } - else - { - - } - @DashboardJobViews.Heading(column) - @if (column.Kind == ShedduellerDashboardJobColumnKind.Tag) - { - tag: @column.TagName - } - - -
- } - @foreach (var kind in HiddenBuiltInColumns) - { - - } -
- -
- - - @foreach (var tagName in ObservedTagNames) - { - - } - - - - -
-
- } +
@@ -242,9 +177,23 @@
- @foreach (var column in _columns) + @for (var index = 0; index < _columns.Count; index++) { - + var columnIndex = index; + var column = _columns[columnIndex]; + } @@ -268,10 +217,10 @@ break; case ShedduellerDashboardJobColumnKind.Enqueued: - + break; case ShedduellerDashboardJobColumnKind.TerminalTime: - + break; case ShedduellerDashboardJobColumnKind.State: + HrefFactory="GroupFilterHref" LinkAriaLabelPrefix="Filter jobs by group" + MaxVisible="2" /> break; case ShedduellerDashboardJobColumnKind.Priority: @@ -397,9 +347,7 @@ gap: 4px; } - .jobs-view-actions button, - .jobs-column-editor button, - .jobs-tag-column-form button { + .jobs-view-actions button { display: inline-flex; min-height: 32px; align-items: center; @@ -417,22 +365,17 @@ text-transform: uppercase; } - .jobs-view-actions button:hover:not(:disabled), - .jobs-column-editor button:hover:not(:disabled), - .jobs-tag-column-form button:hover:not(:disabled) { + .jobs-view-actions button:hover:not(:disabled) { border-color: var(--sd-primary); color: var(--sd-primary); } - .jobs-view-actions button:disabled, - .jobs-column-editor button:disabled { + .jobs-view-actions button:disabled { cursor: default; opacity: 0.5; } - .jobs-view-actions .material-symbols-outlined, - .jobs-column-editor .material-symbols-outlined, - .jobs-tag-column-form .material-symbols-outlined { + .jobs-view-actions .material-symbols-outlined { font-size: 16px; } @@ -440,108 +383,6 @@ color: var(--sd-error); } - .jobs-column-editor { - display: grid; - gap: 12px; - border: 1px solid var(--sd-outline-variant); - border-radius: var(--sd-radius); - background: var(--sd-surface-lowest); - padding: 12px; - } - - .jobs-column-editor__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - } - - .jobs-column-editor__header h2 { - font-size: 14px; - line-height: 20px; - } - - .jobs-column-editor__header p { - color: var(--sd-on-surface-variant); - font-size: 11px; - line-height: 16px; - } - - .jobs-column-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - gap: 4px; - } - - .jobs-column-row { - display: grid; - min-height: 34px; - align-items: center; - gap: 6px; - grid-template-columns: 28px minmax(80px, 1fr) minmax(0, auto) 28px 28px; - border: 1px solid var(--sd-outline-variant); - border-radius: 2px; - background: var(--sd-surface-low); - padding: 3px 4px; - } - - .jobs-column-row--hidden { - grid-template-columns: 28px minmax(80px, 1fr) auto; - color: var(--sd-on-surface-variant); - opacity: 0.8; - } - - .jobs-column-row button { - min-height: 26px; - padding: 3px; - } - - .jobs-column-row__name { - overflow: hidden; - font-size: 11px; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; - } - - .jobs-column-row__tag, - .jobs-field-optional { - overflow: hidden; - color: var(--sd-on-surface-variant); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; - } - - .jobs-tag-column-form { - display: flex; - align-items: end; - flex-wrap: wrap; - gap: 8px; - border-top: 1px solid var(--sd-outline-variant); - padding-top: 12px; - } - - .jobs-tag-column-form label { - display: grid; - min-width: 180px; - gap: 3px; - color: var(--sd-on-surface-variant); - font-size: 11px; - font-weight: 600; - } - - .jobs-tag-column-form input { - min-height: 32px; - border: 1px solid var(--sd-outline-variant); - border-radius: 2px; - background: var(--sd-surface-lowest); - color: var(--sd-on-surface); - padding: 6px 8px; - font-size: 12px; - } - .jobs-filter-bar { display: flex; align-items: center; @@ -765,8 +606,8 @@ } .jobs-table { - width: 100%; - min-width: 100%; + width: auto; + min-width: 0; border-collapse: collapse; table-layout: fixed; white-space: nowrap; @@ -788,6 +629,7 @@ } .jobs-table th { + position: relative; color: var(--sd-on-surface-variant); font-size: 11px; font-weight: 700; @@ -796,6 +638,38 @@ text-transform: uppercase; } + .jobs-table__resize-handle { + position: absolute; + inset: 0 -4px 0 auto; + z-index: 3; + display: block; + width: 9px; + cursor: col-resize; + outline: none; + touch-action: none; + } + + .jobs-table__resize-handle::after { + position: absolute; + top: 20%; + right: 4px; + width: 1px; + height: 60%; + background: transparent; + content: ""; + } + + .jobs-table__resize-handle:hover::after, + .jobs-table__resize-handle:focus-visible::after, + .jobs-table--resizing .jobs-table__resize-handle::after { + width: 2px; + background: var(--sd-primary); + } + + .jobs-table--resizing { + user-select: none; + } + .jobs-table td { height: 40px; color: var(--sd-on-surface); @@ -863,6 +737,32 @@ overflow: visible; } + .jobs-table .sd-chip-list { + width: 100%; + min-width: 0; + max-width: 100%; + align-content: center; + flex-wrap: wrap; + } + + .jobs-table .sd-chip-list>.sd-chip { + display: -webkit-box; + min-width: 0; + max-width: 100%; + flex: 0 1 auto; + overflow: hidden; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + + .jobs-table .sd-chip-overflow { + min-width: 0; + max-width: 100%; + } + .jobs-chip { display: inline-block; max-width: 180px; @@ -1042,14 +942,30 @@ .jobs-timestamp { display: flex; - flex-direction: column; - gap: 1px; + min-width: 0; + align-items: baseline; + gap: 4px; + overflow: hidden; + white-space: nowrap; } .jobs-timestamp__relative { + flex: 0 0 auto; color: var(--sd-on-surface); } + .jobs-timestamp__separator { + flex: 0 0 auto; + color: var(--sd-outline); + } + + .jobs-timestamp__absolute { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .jobs-timestamp__absolute, .jobs-progress-text, .jobs-empty-row, @@ -1160,6 +1076,8 @@ private readonly List _columns = []; private readonly List _views = []; private readonly List _storedViews = []; + private DotNetObjectReference? _columnResizeReceiver; + private ElementReference _columnsButton; private JobInspectionPage? _page; private JobInspectionQuery _lastQuery = new(PageSize: DefaultPageSize); private CancellationTokenSource? _filterDebounceCts; @@ -1168,8 +1086,6 @@ private string? _pendingFilterNavigationUri; private string _selectedViewKey = DashboardJobViews.BuiltInKey; private string _preferredViewKey = DashboardJobViews.BuiltInKey; - private string _newTagName = string.Empty; - private string _newTagHeading = string.Empty; private int _queryVersion; private JobsActionSeverity _actionSeverity = JobsActionSeverity.Success; private bool _isLoading; @@ -1178,6 +1094,7 @@ private bool _isColumnEditorOpen; private bool _isViewDirty; private bool _hasExplicitInitialQuery; + private bool _columnResizingRegistered; private bool _viewStorageLoaded; private IReadOnlyList SharedViews @@ -1199,10 +1116,20 @@ => !this._isViewDirty && !this.IsCurrentViewPreferred; private string CurrentViewStatus - => string.Concat( - this.CurrentView?.Name ?? "Custom", - this._isViewDirty ? " · Modified" : string.Empty, - this.IsCurrentViewPreferred ? " · Default" : string.Empty); + { + get + { + var name = this.CurrentView?.Name ?? "Custom"; + if (this._isViewDirty) + { + return string.Concat(name, " · Unsaved changes"); + } + + return this.IsCurrentViewPreferred && this.CurrentView?.Source != DashboardJobViewSource.BuiltIn + ? string.Concat(name, " · Default view") + : name; + } + } private IReadOnlyList HiddenBuiltInColumns => @@ -1222,7 +1149,13 @@ ]; private string TableStyle - => string.Create(CultureInfo.InvariantCulture, $"min-width: {this._columns.Sum(ColumnWidth)}px"); + { + get + { + var width = DashboardJobColumnWidths.Total(this._columns); + return string.Create(CultureInfo.InvariantCulture, $"width: {width}px; min-width: {width}px"); + } + } private string HeaderText => this._page is null ? "Reading scheduler job records." : this.ResultRangeText; @@ -1294,21 +1227,39 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender || this._viewStorageLoaded) + if (!this._columnResizingRegistered) { - return; + this._columnResizeReceiver ??= DotNetObjectReference.Create(this); + await JsRuntime.InvokeVoidAsync( + "shedduellerJobViews.registerTableColumnResizing", + this._columnResizeReceiver); + this._columnResizingRegistered = true; } - this._viewStorageLoaded = true; - await this.LoadStoredViewsAsync(); + if (firstRender && !this._viewStorageLoaded) + { + this._viewStorageLoaded = true; + await this.LoadStoredViewsAsync(); + } } - private protected override ValueTask DisposePageAsync() + private protected override async ValueTask DisposePageAsync() { Navigation.LocationChanged -= this.OnLocationChanged; LiveUpdates.JobEventPublished -= this.OnJobEventPublishedAsync; this.CancelPendingFilterLoad(); - return ValueTask.CompletedTask; + if (this._columnResizingRegistered) + { + try + { + await JsRuntime.InvokeVoidAsync("shedduellerJobViews.unregisterTableColumnResizing"); + } + catch (JSDisconnectedException) + { + } + } + + this._columnResizeReceiver?.Dispose(); } private void InitializeConfiguredViews() @@ -1581,80 +1532,179 @@ } } - private void ToggleColumnEditor() - => this._isColumnEditorOpen = !this._isColumnEditorOpen; + private void OpenColumnEditor() + => this._isColumnEditorOpen = true; - private void SetBuiltInColumnVisible( - ShedduellerDashboardJobColumnKind kind, - ChangeEventArgs eventArgs) + private async Task CloseColumnEditorAsync() + { + this._isColumnEditorOpen = false; + await this._columnsButton.FocusAsync(); + } + + private void SetBuiltInColumnVisible((ShedduellerDashboardJobColumnKind Kind, bool Visible) change) { - var visible = eventArgs.Value is bool selected && selected; - var index = this._columns.FindIndex(column => column.Kind == kind); - if (visible && index < 0) + if (DashboardJobColumnEditor.SetBuiltInVisibility(this._columns, change.Kind, change.Visible)) { - this._columns.Add(new ShedduellerDashboardJobColumn(kind)); this.MarkViewDirty(); } - else if (!visible && index >= 0 && kind != ShedduellerDashboardJobColumnKind.JobId) + } + + private void MoveColumn((int FromIndex, int ToIndex) move) + { + if (DashboardJobColumnEditor.Move(this._columns, move.FromIndex, move.ToIndex)) { - this._columns.RemoveAt(index); this.MarkViewDirty(); } } - private void MoveColumn( - int index, - int offset) + private void RemoveColumn(int index) { - var target = index + offset; - if (index < 0 || index >= this._columns.Count || target < 0 || target >= this._columns.Count) + if (!DashboardJobColumnEditor.RemoveTag(this._columns, index)) { return; } - var column = this._columns[index]; - this._columns.RemoveAt(index); - this._columns.Insert(target, column); this.MarkViewDirty(); } - private void RemoveColumn(int index) + private void AddTagColumn(ShedduellerDashboardJobColumn column) { - if (index < 0 || index >= this._columns.Count - || this._columns[index].Kind != ShedduellerDashboardJobColumnKind.Tag) + if (!DashboardJobColumnEditor.TryAddTag( + this._columns, + column.TagName ?? string.Empty, + column.Heading, + out var error)) { + this.SetActionError(error!); return; } - this._columns.RemoveAt(index); + this.ClearActionAlert(); this.MarkViewDirty(); } - private void AddTagColumn() + private void EditTagColumn((int Index, ShedduellerDashboardJobColumn Column) change) { - if (string.IsNullOrWhiteSpace(this._newTagName)) + if (!DashboardJobColumnEditor.TryUpdateTag( + this._columns, + change.Index, + change.Column.TagName ?? string.Empty, + change.Column.Heading, + out var error)) { - this.SetActionError("Enter a tag name to promote."); + this.SetActionError(error!); return; } - var tagName = this._newTagName.Trim(); - if (this._columns.Any(column => - column.Kind == ShedduellerDashboardJobColumnKind.Tag - && string.Equals(column.TagName, tagName, StringComparison.OrdinalIgnoreCase))) + this.ClearActionAlert(); + this.MarkViewDirty(); + } + + [JSInvokable] + public Task SetColumnWidthFromResizeAsync( + int columnIndex, + int width) + => this.InvokeAsync(() => + { + this.SetColumnWidth(columnIndex, width); + this.StateHasChanged(); + }); + + private Task HandleColumnWidthKeyAsync( + int columnIndex, + KeyboardEventArgs eventArgs) + { + if (columnIndex < 0 || columnIndex >= this._columns.Count) + { + return Task.CompletedTask; + } + + var currentWidth = ColumnWidth(this._columns[columnIndex]); + var step = eventArgs.ShiftKey ? 32 : 8; + var nextWidth = eventArgs.Key switch { - this.SetActionError("That tag already has a promoted column."); - return; + "ArrowLeft" => currentWidth - step, + "ArrowRight" => currentWidth + step, + "Home" => DashboardJobColumnWidths.MinimumOverride, + "End" => DashboardJobColumnWidths.MaximumOverride, + _ => currentWidth, + }; + + if (nextWidth != currentWidth) + { + this.SetColumnWidth( + columnIndex, + Math.Clamp( + nextWidth, + DashboardJobColumnWidths.MinimumOverride, + DashboardJobColumnWidths.MaximumOverride)); } - this._columns.Add(new ShedduellerDashboardJobColumn( - ShedduellerDashboardJobColumnKind.Tag, - tagName, - string.IsNullOrWhiteSpace(this._newTagHeading) ? null : this._newTagHeading.Trim())); - this._newTagName = string.Empty; - this._newTagHeading = string.Empty; - this.ClearActionAlert(); - this.MarkViewDirty(); + return Task.CompletedTask; + } + + private async Task AutoFitColumnAsync(int columnIndex) + { + var widths = await this.MeasureAutoFitWidthsAsync(); + if (columnIndex >= 0 && columnIndex < widths.Count) + { + this.SetColumnWidth(columnIndex, widths[columnIndex]); + } + } + + private async Task AutoFitAllColumnsAsync() + { + var widths = await this.MeasureAutoFitWidthsAsync(); + var changed = false; + for (var index = 0; index < Math.Min(this._columns.Count, widths.Count); index++) + { + changed |= DashboardJobColumnWidths.Set(this._columns, index, widths[index]); + } + + if (changed) + { + this.MarkViewDirty(); + } + } + + private async Task> MeasureAutoFitWidthsAsync() + { + var minimums = this._columns + .Select(column => DashboardJobColumnWidths.AutoFitMinimum(column.Kind)) + .ToArray(); + var maximums = this._columns + .Select(column => DashboardJobColumnWidths.AutoFitMaximum(column.Kind)) + .ToArray(); + return await JsRuntime.InvokeAsync( + "shedduellerJobViews.measureJobColumnWidths", + minimums, + maximums); + } + + private void ResetColumnWidth(int columnIndex) + { + if (DashboardJobColumnWidths.Set(this._columns, columnIndex, width: null)) + { + this.MarkViewDirty(); + } + } + + private void ResetColumnWidths() + { + if (DashboardJobColumnWidths.Reset(this._columns)) + { + this.MarkViewDirty(); + } + } + + private void SetColumnWidth( + int columnIndex, + int width) + { + if (DashboardJobColumnWidths.Set(this._columns, columnIndex, width)) + { + this.MarkViewDirty(); + } } private void ResetColumns() @@ -1730,21 +1780,7 @@ : column.Kind.ToString(); private static int ColumnWidth(ShedduellerDashboardJobColumn column) - => column.Kind switch - { - ShedduellerDashboardJobColumnKind.JobId => 64, - ShedduellerDashboardJobColumnKind.Enqueued or ShedduellerDashboardJobColumnKind.TerminalTime => 160, - ShedduellerDashboardJobColumnKind.State => 112, - ShedduellerDashboardJobColumnKind.Queue => 96, - ShedduellerDashboardJobColumnKind.Handler => 220, - ShedduellerDashboardJobColumnKind.Tags => 320, - ShedduellerDashboardJobColumnKind.Progress => 200, - ShedduellerDashboardJobColumnKind.Disposition => 144, - ShedduellerDashboardJobColumnKind.Groups => 180, - ShedduellerDashboardJobColumnKind.Priority or ShedduellerDashboardJobColumnKind.Attempts => 56, - ShedduellerDashboardJobColumnKind.Tag => 160, - _ => 144, - }; + => DashboardJobColumnWidths.Get(column); private static string ColumnWidthStyle(ShedduellerDashboardJobColumn column) => string.Create(CultureInfo.InvariantCulture, $"width: {ColumnWidth(column)}px"); diff --git a/src/Sheddueller.Dashboard/Components/TimestampBlock.razor b/src/Sheddueller.Dashboard/Components/TimestampBlock.razor index 510302d..ab612ce 100644 --- a/src/Sheddueller.Dashboard/Components/TimestampBlock.razor +++ b/src/Sheddueller.Dashboard/Components/TimestampBlock.razor @@ -1,8 +1,16 @@ @implements IAsyncDisposable -
- @DashboardFormat.Relative(Value, CurrentNowUtc, Prefix, EmptyText, AllowFutureRelative) - @DashboardFormat.Utc(Value) +
+ @RelativeText + @if (Inline && Value is not null) + { + + @DashboardFormat.UtcMinute(Value) + } + else if (!Inline) + { + @DashboardFormat.Utc(Value) + }
@code { @@ -29,6 +37,9 @@ [Parameter] public bool AlignEnd { get; set; } + [Parameter] + public bool Inline { get; set; } + [Parameter] public string EmptyText { get; set; } = "-"; @@ -41,10 +52,22 @@ private DateTimeOffset CurrentNowUtc => this.NowUtc ?? this._nowUtc; + private string RelativeText + => DashboardFormat.Relative(this.Value, this.CurrentNowUtc, this.Prefix, this.EmptyText, this.AllowFutureRelative); + + private string? Tooltip + => this.Inline && this.Value is not null ? DashboardFormat.Utc(this.Value) : null; + + private string? AccessibleLabel + => this.Inline && this.Value is not null + ? string.Concat(this.RelativeText, ", ", DashboardFormat.Utc(this.Value)) + : null; + private string CssClass - => this.AlignEnd - ? string.Concat(this.BaseClass, " ", this.BaseClass, "--end") - : this.BaseClass; + => string.Concat( + this.BaseClass, + this.Inline ? string.Concat(" ", this.BaseClass, "--inline") : string.Empty, + this.AlignEnd ? string.Concat(" ", this.BaseClass, "--end") : string.Empty); private string RelativeClass => string.Concat(this.BaseClass, "__relative"); @@ -52,6 +75,9 @@ private string AbsoluteClass => string.Concat(this.BaseClass, "__absolute"); + private string SeparatorClass + => string.Concat(this.BaseClass, "__separator"); + protected override Task OnAfterRenderAsync(bool firstRender) { if (firstRender && this.NowUtc is null) diff --git a/src/Sheddueller.Dashboard/Internal/DashboardFormat.cs b/src/Sheddueller.Dashboard/Internal/DashboardFormat.cs index 6de13db..2a5701a 100644 --- a/src/Sheddueller.Dashboard/Internal/DashboardFormat.cs +++ b/src/Sheddueller.Dashboard/Internal/DashboardFormat.cs @@ -251,6 +251,9 @@ public static string Relative( public static string Utc(DateTimeOffset? value) => value?.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? string.Empty; + public static string UtcMinute(DateTimeOffset? value) + => value?.ToUniversalTime().ToString("HH:mm 'UTC'", CultureInfo.InvariantCulture) ?? string.Empty; + public static string UtcClock( DateTimeOffset value, bool includeMilliseconds = false) diff --git a/src/Sheddueller.Dashboard/Internal/DashboardJobColumnEditor.cs b/src/Sheddueller.Dashboard/Internal/DashboardJobColumnEditor.cs new file mode 100644 index 0000000..44290f0 --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/DashboardJobColumnEditor.cs @@ -0,0 +1,137 @@ +namespace Sheddueller.Dashboard.Internal; + +internal static class DashboardJobColumnEditor +{ + public static bool Move( + List columns, + int fromIndex, + int toIndex) + { + if (fromIndex < 0 || fromIndex >= columns.Count + || toIndex < 0 || toIndex >= columns.Count + || fromIndex == toIndex) + { + return false; + } + + var column = columns[fromIndex]; + columns.RemoveAt(fromIndex); + columns.Insert(toIndex, column); + return true; + } + + public static bool SetBuiltInVisibility( + List columns, + ShedduellerDashboardJobColumnKind kind, + bool visible) + { + if (kind == ShedduellerDashboardJobColumnKind.Tag) + { + return false; + } + + var index = columns.FindIndex(column => column.Kind == kind); + if (visible && index < 0) + { + columns.Add(new ShedduellerDashboardJobColumn(kind)); + return true; + } + + if (!visible && index >= 0 && kind != ShedduellerDashboardJobColumnKind.JobId) + { + columns.RemoveAt(index); + return true; + } + + return false; + } + + public static bool TryAddTag( + List columns, + string tagName, + string? heading, + out string? error) + { + if (!TryCreateTag(columns, exceptIndex: null, tagName, heading, out var column, out error)) + { + return false; + } + + columns.Add(column!); + return true; + } + + public static bool TryUpdateTag( + List columns, + int index, + string tagName, + string? heading, + out string? error) + { + if (index < 0 || index >= columns.Count + || columns[index].Kind != ShedduellerDashboardJobColumnKind.Tag) + { + error = "The promoted tag column no longer exists."; + return false; + } + + if (!TryCreateTag(columns, index, tagName, heading, out var column, out error)) + { + return false; + } + + columns[index] = column!; + return true; + } + + public static bool RemoveTag( + List columns, + int index) + { + if (index < 0 || index >= columns.Count + || columns[index].Kind != ShedduellerDashboardJobColumnKind.Tag) + { + return false; + } + + columns.RemoveAt(index); + return true; + } + + private static bool TryCreateTag( + List columns, + int? exceptIndex, + string tagName, + string? heading, + out ShedduellerDashboardJobColumn? column, + out string? error) + { + if (string.IsNullOrWhiteSpace(tagName)) + { + column = null; + error = "Enter a tag name to promote."; + return false; + } + + var normalizedName = tagName.Trim(); + if (columns.Select((candidate, index) => (candidate, index)).Any(item => + item.index != exceptIndex + && item.candidate.Kind == ShedduellerDashboardJobColumnKind.Tag + && string.Equals(item.candidate.TagName, normalizedName, StringComparison.OrdinalIgnoreCase))) + { + column = null; + error = "That tag already has a promoted column."; + return false; + } + + column = new ShedduellerDashboardJobColumn( + ShedduellerDashboardJobColumnKind.Tag, + normalizedName, + string.IsNullOrWhiteSpace(heading) ? null : heading.Trim()) + { + Width = exceptIndex is { } index ? columns[index].Width : null, + }; + error = null; + return true; + } +} diff --git a/src/Sheddueller.Dashboard/Internal/DashboardJobColumnWidths.cs b/src/Sheddueller.Dashboard/Internal/DashboardJobColumnWidths.cs new file mode 100644 index 0000000..8696c66 --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/DashboardJobColumnWidths.cs @@ -0,0 +1,115 @@ +namespace Sheddueller.Dashboard.Internal; + +internal static class DashboardJobColumnWidths +{ + public const int MinimumOverride = 48; + public const int MaximumOverride = 800; + + public static bool IsValid(int? width) + => width is null or (>= MinimumOverride and <= MaximumOverride); + + public static int Get(ShedduellerDashboardJobColumn column) + => column.Width ?? Default(column.Kind); + + public static int Default(ShedduellerDashboardJobColumnKind kind) + => kind switch + { + ShedduellerDashboardJobColumnKind.JobId => 64, + ShedduellerDashboardJobColumnKind.Enqueued or ShedduellerDashboardJobColumnKind.TerminalTime => 160, + ShedduellerDashboardJobColumnKind.State => 112, + ShedduellerDashboardJobColumnKind.Queue => 96, + ShedduellerDashboardJobColumnKind.Handler => 220, + ShedduellerDashboardJobColumnKind.Tags => 320, + ShedduellerDashboardJobColumnKind.Progress => 200, + ShedduellerDashboardJobColumnKind.Disposition => 144, + ShedduellerDashboardJobColumnKind.Groups => 180, + ShedduellerDashboardJobColumnKind.Priority or ShedduellerDashboardJobColumnKind.Attempts => 56, + ShedduellerDashboardJobColumnKind.Tag => 160, + _ => 144, + }; + + public static int AutoFitMinimum(ShedduellerDashboardJobColumnKind kind) + => kind switch + { + ShedduellerDashboardJobColumnKind.JobId => 64, + ShedduellerDashboardJobColumnKind.Enqueued or ShedduellerDashboardJobColumnKind.TerminalTime => 128, + ShedduellerDashboardJobColumnKind.State => 88, + ShedduellerDashboardJobColumnKind.Queue => 72, + ShedduellerDashboardJobColumnKind.Handler => 120, + ShedduellerDashboardJobColumnKind.Tags => 120, + ShedduellerDashboardJobColumnKind.Progress => 120, + ShedduellerDashboardJobColumnKind.Disposition => 96, + ShedduellerDashboardJobColumnKind.Groups => 120, + ShedduellerDashboardJobColumnKind.Priority or ShedduellerDashboardJobColumnKind.Attempts => 48, + ShedduellerDashboardJobColumnKind.Tag => 72, + _ => MinimumOverride, + }; + + public static int AutoFitMaximum(ShedduellerDashboardJobColumnKind kind) + => kind switch + { + ShedduellerDashboardJobColumnKind.JobId => 160, + ShedduellerDashboardJobColumnKind.Enqueued or ShedduellerDashboardJobColumnKind.TerminalTime => 220, + ShedduellerDashboardJobColumnKind.State => 160, + ShedduellerDashboardJobColumnKind.Queue => 180, + ShedduellerDashboardJobColumnKind.Handler => 420, + ShedduellerDashboardJobColumnKind.Tags => 520, + ShedduellerDashboardJobColumnKind.Progress => 360, + ShedduellerDashboardJobColumnKind.Disposition => 360, + ShedduellerDashboardJobColumnKind.Groups => 420, + ShedduellerDashboardJobColumnKind.Priority or ShedduellerDashboardJobColumnKind.Attempts => 96, + ShedduellerDashboardJobColumnKind.Tag => 320, + _ => MaximumOverride, + }; + + public static int ClampAutoFit( + ShedduellerDashboardJobColumnKind kind, + int width) + => Math.Clamp(width, AutoFitMinimum(kind), AutoFitMaximum(kind)); + + public static int Total(IReadOnlyList columns) + => columns.Sum(Get); + + public static bool Set( + List columns, + int index, + int? width) + { + if (index < 0 || index >= columns.Count || !IsValid(width)) + { + return false; + } + + var normalizedWidth = width == Default(columns[index].Kind) ? null : width; + if (columns[index].Width == normalizedWidth) + { + return false; + } + + columns[index] = columns[index] with + { + Width = normalizedWidth, + }; + return true; + } + + public static bool Reset(List columns) + { + var changed = false; + for (var index = 0; index < columns.Count; index++) + { + if (columns[index].Width is null) + { + continue; + } + + columns[index] = columns[index] with + { + Width = null, + }; + changed = true; + } + + return changed; + } +} diff --git a/src/Sheddueller.Dashboard/Internal/DashboardJobViews.cs b/src/Sheddueller.Dashboard/Internal/DashboardJobViews.cs index 7dcb07f..aca4536 100644 --- a/src/Sheddueller.Dashboard/Internal/DashboardJobViews.cs +++ b/src/Sheddueller.Dashboard/Internal/DashboardJobViews.cs @@ -170,7 +170,9 @@ private static bool IsValidColumns(IReadOnlyList var hasJobId = false; foreach (var column in columns) { - if (column is null || !Enum.IsDefined(column.Kind)) + if (column is null + || !Enum.IsDefined(column.Kind) + || !DashboardJobColumnWidths.IsValid(column.Width)) { return false; } @@ -208,7 +210,10 @@ .. columns.Select(column => column.Kind == ShedduellerDashboardJobColumnKind.Tag TagName = column.TagName!.Trim(), Heading = Normalize(column.Heading), } - : new ShedduellerDashboardJobColumn(column.Kind)), + : new ShedduellerDashboardJobColumn(column.Kind) + { + Width = column.Width, + }), ]; private static string? Normalize(string? value) diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumn.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumn.cs index aef7361..0911b94 100644 --- a/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumn.cs +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumn.cs @@ -9,4 +9,11 @@ namespace Sheddueller.Dashboard; public sealed record ShedduellerDashboardJobColumn( ShedduellerDashboardJobColumnKind Kind, string? TagName = null, - string? Heading = null); + string? Heading = null) +{ + /// + /// Gets an optional per-view width override in pixels. + /// A value uses the dashboard default for the column kind. + /// + public int? Width { get; init; } +} diff --git a/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-job-views.js b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-job-views.js index b158b17..bc70c41 100644 --- a/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-job-views.js +++ b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-job-views.js @@ -1,6 +1,10 @@ (function () { "use strict"; + let drawerEscapeHandler = null; + let tableColumnPointerDownHandler = null; + let activeColumnResizeCleanup = null; + window.shedduellerJobViews = { load: function (key) { const value = window.localStorage.getItem(key); @@ -8,6 +12,153 @@ }, save: function (key, value) { window.localStorage.setItem(key, JSON.stringify(value)); + }, + registerDrawerEscape: function (receiver) { + if (drawerEscapeHandler !== null) { + document.removeEventListener("keydown", drawerEscapeHandler); + } + + drawerEscapeHandler = function (event) { + if (event.key === "Escape") { + event.preventDefault(); + receiver.invokeMethodAsync("CloseFromEscapeAsync"); + } + }; + document.addEventListener("keydown", drawerEscapeHandler); + }, + unregisterDrawerEscape: function () { + if (drawerEscapeHandler !== null) { + document.removeEventListener("keydown", drawerEscapeHandler); + drawerEscapeHandler = null; + } + }, + registerTableColumnResizing: function (receiver) { + window.shedduellerJobViews.unregisterTableColumnResizing(); + + tableColumnPointerDownHandler = function (event) { + if (event.button !== 0) { + return; + } + + const handle = event.target.closest("[data-sd-column-resizer]"); + if (handle === null) { + return; + } + + const table = handle.closest(".jobs-table"); + const index = Number.parseInt(handle.dataset.columnIndex, 10); + const columns = Array.from(table.querySelectorAll("col")); + if (!Number.isInteger(index) || index < 0 || index >= columns.length) { + return; + } + + handle.focus({ preventScroll: true }); + event.preventDefault(); + const minimum = Number.parseInt(handle.dataset.minWidth, 10); + const maximum = Number.parseInt(handle.dataset.maxWidth, 10); + const startX = event.clientX; + const widths = columns.map(column => Math.round(column.getBoundingClientRect().width)); + const startWidth = widths[index]; + const previousCursor = document.body.style.cursor; + let currentWidth = startWidth; + + table.classList.add("jobs-table--resizing"); + document.body.style.cursor = "col-resize"; + handle.setPointerCapture(event.pointerId); + + const resize = function (moveEvent) { + moveEvent.preventDefault(); + currentWidth = Math.max(minimum, Math.min(maximum, Math.round(startWidth + moveEvent.clientX - startX))); + widths[index] = currentWidth; + columns[index].style.width = `${currentWidth}px`; + const tableWidth = widths.reduce((total, width) => total + width, 0); + table.style.width = `${tableWidth}px`; + table.style.minWidth = `${tableWidth}px`; + handle.setAttribute("aria-valuenow", currentWidth.toString()); + }; + + const finish = function (finishEvent, commit) { + finishEvent?.preventDefault(); + document.removeEventListener("pointermove", resize); + document.removeEventListener("pointerup", pointerUp); + document.removeEventListener("pointercancel", pointerCancel); + table.classList.remove("jobs-table--resizing"); + document.body.style.cursor = previousCursor; + if (handle.hasPointerCapture(event.pointerId)) { + handle.releasePointerCapture(event.pointerId); + } + activeColumnResizeCleanup = null; + + if (commit && currentWidth !== startWidth) { + receiver.invokeMethodAsync("SetColumnWidthFromResizeAsync", index, currentWidth); + } + }; + + const pointerUp = function (upEvent) { + finish(upEvent, true); + }; + + const pointerCancel = function (cancelEvent) { + finish(cancelEvent, true); + }; + + activeColumnResizeCleanup = function () { + finish(null, false); + }; + document.addEventListener("pointermove", resize, { passive: false }); + document.addEventListener("pointerup", pointerUp, { once: true }); + document.addEventListener("pointercancel", pointerCancel, { once: true }); + }; + + document.addEventListener("pointerdown", tableColumnPointerDownHandler); + }, + unregisterTableColumnResizing: function () { + if (activeColumnResizeCleanup !== null) { + activeColumnResizeCleanup(); + } + + if (tableColumnPointerDownHandler !== null) { + document.removeEventListener("pointerdown", tableColumnPointerDownHandler); + tableColumnPointerDownHandler = null; + } + }, + measureJobColumnWidths: function (minimums, maximums) { + const table = document.querySelector(".jobs-table"); + if (table === null) { + return []; + } + + const clone = table.cloneNode(true); + clone.querySelectorAll("[data-sd-column-resizer]").forEach(handle => handle.remove()); + clone.querySelectorAll("col").forEach(column => column.removeAttribute("style")); + clone.removeAttribute("style"); + Object.assign(clone.style, { + position: "fixed", + top: "0", + left: "-100000px", + width: "max-content", + minWidth: "0", + tableLayout: "auto", + visibility: "hidden", + pointerEvents: "none" + }); + document.body.appendChild(clone); + + try { + const headerCount = clone.querySelectorAll("thead th").length; + const widths = []; + for (let index = 0; index < headerCount; index++) { + const cells = clone.querySelectorAll(`tr > :nth-child(${index + 1})`); + const measured = Array.from(cells).reduce( + (maximum, cell) => Math.max(maximum, Math.ceil(cell.getBoundingClientRect().width)), + 0); + widths.push(Math.max(minimums[index], Math.min(maximums[index], measured + 2))); + } + + return widths; + } finally { + clone.remove(); + } } }; })(); diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index 7c5c1ea..7d52c45 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -131,7 +131,7 @@ public async Task Jobs_KnownData_RendersSearchResults() html.ShouldContain("href=\"jobs?group=tenant-acme\""); AssertShellRefresh(html); html.ShouldContain($"href=\"jobs/{StubJobInspectionReader.JobId:D}\""); - html.ShouldContain("
"); + html.ShouldContain(">Queue"); html.ShouldContain("Running"); html.ShouldContain("#1"); } @@ -207,17 +207,17 @@ public async Task Jobs_SharedDefaultView_RendersConfiguredFiltersAndPromotedTagC html.ShouldContain("Failures"); AssertStatusCheckbox(html, "Claimed", isChecked: true); AssertSelectValue(html, "Sort jobs", "NewestFirst"); - html.ShouldContain(""); + html.ShouldContain(">Customer"); html.ShouldContain("jobs-derived-tag"); html.ShouldContain(">acme"); html.ShouldContain("href=\"jobs?state=Claimed&tag=tenant%3Aacme&sort=NewestFirst\""); - html.ShouldNotContain(""); - html.ShouldNotContain(""); + html.ShouldNotContain(">Progress"); + html.ShouldNotContain(">Tags"); var filteredHtml = await GetOkHtmlAsync(app, "/sheddueller/jobs?state=Failed"); AssertStatusCheckbox(filteredHtml, "Failed", isChecked: true); AssertStatusCheckbox(filteredHtml, "Claimed", isChecked: false); - filteredHtml.ShouldContain(""); + filteredHtml.ShouldContain(">Customer"); } [Fact] @@ -230,8 +230,16 @@ public async Task Jobs_BuiltInView_HidesProgressAndRendersViewControls() html.ShouldContain("Save As"); html.ShouldContain("Set Default"); html.ShouldContain(">Columns"); - html.ShouldNotContain(""); - html.ShouldContain(""); + html.ShouldNotContain("Default · Default"); + html.ShouldNotContain(">Save"); + html.ShouldNotContain(">Progress"); + html.ShouldContain(">Disposition"); + html.ShouldContain("style=\"width: 1568px; min-width: 1568px\""); + html.ShouldContain("data-sd-column-resizer"); + html.ShouldContain("jobs-timestamp jobs-timestamp--inline"); + html.ShouldContain("jobs-timestamp__separator"); + html.ShouldContain(">12:00 UTC"); + html.ShouldContain("title=\"2026-04-20 12:00:00 UTC\""); } [Fact] diff --git a/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs index 0848048..b588e3e 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs @@ -39,6 +39,8 @@ public void Relative_PastAndFutureValues_FormatsCompactUtcDurations() DashboardFormat.Relative(now.AddMinutes(-42), now).ShouldBe("42m ago"); DashboardFormat.Relative(now.AddHours(3), now).ShouldBe("in 3h"); DashboardFormat.Utc(now).ShouldBe("2026-04-20 12:00:00 UTC"); + DashboardFormat.UtcMinute(now).ShouldBe("12:00 UTC"); + DashboardFormat.UtcMinute(null).ShouldBeEmpty(); } [Fact] @@ -168,6 +170,20 @@ public void JobViews_OptionsValidation_RequiresUniqueNamesValidColumnsAndExistin JobViews = [validView], DefaultJobViewName = "Missing", }).ShouldBeFalse(); + DashboardJobViews.IsValid(new ShedduellerDashboardOptions + { + JobViews = + [ + validView with + { + Columns = + [ + new(ShedduellerDashboardJobColumnKind.JobId), + new ShedduellerDashboardJobColumn(ShedduellerDashboardJobColumnKind.Handler) { Width = 47 }, + ], + }, + ], + }).ShouldBeFalse(); } [Fact] @@ -213,7 +229,10 @@ public void JobViews_StoragePayload_RoundTripsVersionPreferenceAndDefinition() Columns = [ new(ShedduellerDashboardJobColumnKind.JobId), - new(ShedduellerDashboardJobColumnKind.Tag, "provider", "Provider"), + new ShedduellerDashboardJobColumn(ShedduellerDashboardJobColumnKind.Tag, "provider", "Provider") + { + Width = 124, + }, ], }, }, @@ -228,6 +247,7 @@ public void JobViews_StoragePayload_RoundTripsVersionPreferenceAndDefinition() restored.PreferredViewKey.ShouldBe(payload.PreferredViewKey); restored.Views.Single().View.TagContains.ShouldBe("domain:listing"); restored.Views.Single().View.Columns![1].TagName.ShouldBe("provider"); + restored.Views.Single().View.Columns![1].Width.ShouldBe(124); } [Fact] diff --git a/test/Sheddueller.Dashboard.Tests/DashboardJobColumnEditorTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardJobColumnEditorTests.cs new file mode 100644 index 0000000..78fbacd --- /dev/null +++ b/test/Sheddueller.Dashboard.Tests/DashboardJobColumnEditorTests.cs @@ -0,0 +1,95 @@ +namespace Sheddueller.Dashboard.Tests; + +using Sheddueller.Dashboard.Internal; + +using Shouldly; + +public sealed class DashboardJobColumnEditorTests +{ + [Fact] + public void Move_ValidTarget_ReordersLinearColumnList() + { + var columns = CreateColumns( + ShedduellerDashboardJobColumnKind.JobId, + ShedduellerDashboardJobColumnKind.Enqueued, + ShedduellerDashboardJobColumnKind.State, + ShedduellerDashboardJobColumnKind.Handler); + + DashboardJobColumnEditor.Move(columns, fromIndex: 3, toIndex: 1).ShouldBeTrue(); + + columns.Select(column => column.Kind).ShouldBe( + [ + ShedduellerDashboardJobColumnKind.JobId, + ShedduellerDashboardJobColumnKind.Handler, + ShedduellerDashboardJobColumnKind.Enqueued, + ShedduellerDashboardJobColumnKind.State, + ]); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(0, -1)] + [InlineData(0, 0)] + [InlineData(0, 4)] + public void Move_InvalidOrUnchangedTarget_DoesNotMutate( + int fromIndex, + int toIndex) + { + var columns = CreateColumns( + ShedduellerDashboardJobColumnKind.JobId, + ShedduellerDashboardJobColumnKind.State, + ShedduellerDashboardJobColumnKind.Handler, + ShedduellerDashboardJobColumnKind.Attempts); + var original = columns.ToArray(); + + DashboardJobColumnEditor.Move(columns, fromIndex, toIndex).ShouldBeFalse(); + + columns.ShouldBe(original); + } + + [Fact] + public void Visibility_HideAndRestoreBuiltIn_AppendsRestoredColumnAndLocksJobId() + { + var columns = CreateColumns( + ShedduellerDashboardJobColumnKind.JobId, + ShedduellerDashboardJobColumnKind.State, + ShedduellerDashboardJobColumnKind.Handler); + + DashboardJobColumnEditor.SetBuiltInVisibility(columns, ShedduellerDashboardJobColumnKind.State, visible: false).ShouldBeTrue(); + DashboardJobColumnEditor.SetBuiltInVisibility(columns, ShedduellerDashboardJobColumnKind.State, visible: true).ShouldBeTrue(); + DashboardJobColumnEditor.SetBuiltInVisibility(columns, ShedduellerDashboardJobColumnKind.JobId, visible: false).ShouldBeFalse(); + + columns.Select(column => column.Kind).ShouldBe( + [ + ShedduellerDashboardJobColumnKind.JobId, + ShedduellerDashboardJobColumnKind.Handler, + ShedduellerDashboardJobColumnKind.State, + ]); + } + + [Fact] + public void PromotedTag_AddAndUpdate_NormalizesValuesAndRejectsDuplicates() + { + var columns = CreateColumns(ShedduellerDashboardJobColumnKind.JobId); + + DashboardJobColumnEditor.TryAddTag(columns, " provider ", " Provider ", out var addError).ShouldBeTrue(); + columns[1] = columns[1] with { Width = 240 }; + DashboardJobColumnEditor.TryAddTag(columns, "PROVIDER", null, out var duplicateError).ShouldBeFalse(); + DashboardJobColumnEditor.TryUpdateTag(columns, 1, " manager ", " ", out var updateError).ShouldBeTrue(); + + addError.ShouldBeNull(); + duplicateError.ShouldBe("That tag already has a promoted column."); + updateError.ShouldBeNull(); + columns[1].ShouldBe(new ShedduellerDashboardJobColumn( + ShedduellerDashboardJobColumnKind.Tag, + "manager", + Heading: null) + { + Width = 240, + }); + } + + private static List CreateColumns( + params ShedduellerDashboardJobColumnKind[] kinds) + => [.. kinds.Select(kind => new ShedduellerDashboardJobColumn(kind))]; +} diff --git a/test/Sheddueller.Dashboard.Tests/DashboardJobColumnWidthTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardJobColumnWidthTests.cs new file mode 100644 index 0000000..ec3264f --- /dev/null +++ b/test/Sheddueller.Dashboard.Tests/DashboardJobColumnWidthTests.cs @@ -0,0 +1,66 @@ +namespace Sheddueller.Dashboard.Tests; + +using Sheddueller.Dashboard.Internal; + +using Shouldly; + +public sealed class DashboardJobColumnWidthTests +{ + [Fact] + public void Widths_DefaultsAndOverrides_CalculateExactTableWidth() + { + var columns = new[] + { + new ShedduellerDashboardJobColumn(ShedduellerDashboardJobColumnKind.JobId), + new ShedduellerDashboardJobColumn(ShedduellerDashboardJobColumnKind.Handler) { Width = 300 }, + new ShedduellerDashboardJobColumn(ShedduellerDashboardJobColumnKind.Attempts), + }; + + DashboardJobColumnWidths.Get(columns[0]).ShouldBe(64); + DashboardJobColumnWidths.Get(columns[1]).ShouldBe(300); + DashboardJobColumnWidths.Total(columns).ShouldBe(420); + } + + [Theory] + [InlineData(null, true)] + [InlineData(48, true)] + [InlineData(800, true)] + [InlineData(47, false)] + [InlineData(801, false)] + public void Widths_Validation_EnforcesSupportedOverrides( + int? width, + bool expected) + => DashboardJobColumnWidths.IsValid(width).ShouldBe(expected); + + [Fact] + public void Widths_SetAndReset_NormalizesDefaultsAndPreservesOtherColumnMetadata() + { + var columns = new List + { + new(ShedduellerDashboardJobColumnKind.JobId), + new(ShedduellerDashboardJobColumnKind.Tag, "provider", "Provider"), + }; + + DashboardJobColumnWidths.Set(columns, index: 1, width: 240).ShouldBeTrue(); + columns[1].ShouldBe(new ShedduellerDashboardJobColumn( + ShedduellerDashboardJobColumnKind.Tag, + "provider", + "Provider") + { + Width = 240, + }); + DashboardJobColumnWidths.Set(columns, index: 1, width: 160).ShouldBeTrue(); + columns[1].Width.ShouldBeNull(); + DashboardJobColumnWidths.Set(columns, index: 0, width: 120).ShouldBeTrue(); + DashboardJobColumnWidths.Reset(columns).ShouldBeTrue(); + columns.ShouldAllBe(column => column.Width == null); + } + + [Fact] + public void Widths_AutoFit_ClampsCompactAndDescriptiveKindsIndependently() + { + DashboardJobColumnWidths.ClampAutoFit(ShedduellerDashboardJobColumnKind.Attempts, 500).ShouldBe(96); + DashboardJobColumnWidths.ClampAutoFit(ShedduellerDashboardJobColumnKind.Handler, 500).ShouldBe(420); + DashboardJobColumnWidths.ClampAutoFit(ShedduellerDashboardJobColumnKind.Tag, 20).ShouldBe(72); + } +}
Job IDEnqueuedTerminal TimeStateQueueHandlerTagsProgressDispositionGroupsPriAtt@DashboardJobViews.Heading(column)
@EmptyText@EmptyText
- - - - - - - - - @QueuePositionText(job) - - @DashboardFormat.ShortHandler(job) - - - - - - @DashboardFormat.Disposition(job) - - - @job.Priority@DashboardFormat.Attempts(job, compact: true)@QueuePositionText(job)@DashboardFormat.ShortHandler(job)@DashboardFormat.Disposition(job)@job.Priority@DashboardFormat.Attempts(job, compact: true)
CustomerProgressTagsCustomerProgressDisposition
@DashboardJobViews.Heading(column) + @DashboardJobViews.Heading(column) + +
@job.PriorityQueueCustomerProgressTagsCustomerProgressDisposition