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..b5a831b 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, 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/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/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 7da158f..a7f74b6 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,85 @@
+
+ + @CurrentViewStatus +
+ + @if (CurrentView?.Source == DashboardJobViewSource.Personal) + { + + + } + else + { + + } + + @if (CurrentView?.Source == DashboardJobViewSource.Personal) + { + + + } +
+
+ + +
Status @@ -88,43 +168,40 @@
- +
- - - - - - - - - - - - - + @foreach (var column in _columns) + { + + } - - - - - - - - - - - - + @for (var index = 0; index < _columns.Count; index++) + { + var columnIndex = index; + var column = _columns[columnIndex]; + + } @if (_jobs.Count == 0) { - + } else @@ -132,49 +209,63 @@ @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 +309,80 @@ 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 { + 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) { + border-color: var(--sd-primary); + color: var(--sd-primary); + } + + .jobs-view-actions button:disabled { + cursor: default; + opacity: 0.5; + } + + .jobs-view-actions .material-symbols-outlined { + font-size: 16px; + } + + .jobs-view-actions .jobs-view-delete { + color: var(--sd-error); + } + .jobs-filter-bar { display: flex; align-items: center; @@ -441,45 +606,13 @@ } .jobs-table { - width: 100%; - min-width: 1816px; + width: auto; + min-width: 0; 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; @@ -496,6 +629,7 @@ } .jobs-table th { + position: relative; color: var(--sd-on-surface-variant); font-size: 11px; font-weight: 700; @@ -504,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); @@ -571,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; @@ -602,6 +794,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; @@ -714,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, @@ -784,6 +1028,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 +1043,9 @@ } .jobs-filter-bar, + .jobs-view-bar, + .jobs-view-actions, + .jobs-view-select, .jobs-status-filter, .jobs-text-filters, .jobs-pagination { @@ -803,6 +1053,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 +1073,89 @@ private readonly DashboardJobFilters _filters = new(); private readonly List _jobs = []; + 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; private string? _error; private string? _actionMessage; private string? _pendingFilterNavigationUri; + private string _selectedViewKey = DashboardJobViews.BuiltInKey; + private string _preferredViewKey = DashboardJobViews.BuiltInKey; 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 _columnResizingRegistered; + 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 + { + 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 + => + [ + .. 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 + { + 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; @@ -883,7 +1211,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,14 +1225,587 @@ await this.LoadAsync(); } - private protected override ValueTask DisposePageAsync() + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!this._columnResizingRegistered) + { + this._columnResizeReceiver ??= DotNetObjectReference.Create(this); + await JsRuntime.InvokeVoidAsync( + "shedduellerJobViews.registerTableColumnResizing", + this._columnResizeReceiver); + this._columnResizingRegistered = true; + } + + if (firstRender && !this._viewStorageLoaded) + { + this._viewStorageLoaded = true; + await this.LoadStoredViewsAsync(); + } + } + + 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() + { + 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 OpenColumnEditor() + => this._isColumnEditorOpen = true; + + private async Task CloseColumnEditorAsync() + { + this._isColumnEditorOpen = false; + await this._columnsButton.FocusAsync(); + } + + private void SetBuiltInColumnVisible((ShedduellerDashboardJobColumnKind Kind, bool Visible) change) + { + if (DashboardJobColumnEditor.SetBuiltInVisibility(this._columns, change.Kind, change.Visible)) + { + this.MarkViewDirty(); + } + } + + private void MoveColumn((int FromIndex, int ToIndex) move) + { + if (DashboardJobColumnEditor.Move(this._columns, move.FromIndex, move.ToIndex)) + { + this.MarkViewDirty(); + } + } + + private void RemoveColumn(int index) + { + if (!DashboardJobColumnEditor.RemoveTag(this._columns, index)) + { + return; + } + + this.MarkViewDirty(); + } + + private void AddTagColumn(ShedduellerDashboardJobColumn column) + { + if (!DashboardJobColumnEditor.TryAddTag( + this._columns, + column.TagName ?? string.Empty, + column.Heading, + out var error)) + { + this.SetActionError(error!); + return; + } + + this.ClearActionAlert(); + this.MarkViewDirty(); + } + + private void EditTagColumn((int Index, ShedduellerDashboardJobColumn Column) change) + { + if (!DashboardJobColumnEditor.TryUpdateTag( + this._columns, + change.Index, + change.Column.TagName ?? string.Empty, + change.Column.Heading, + out var error)) + { + this.SetActionError(error!); + return; + } + + 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 + { + "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)); + } + + 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() + { + 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) + => DashboardJobColumnWidths.Get(column); + + 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 +1815,7 @@ return; } + this.MarkViewDirty(); this.CancelPendingFilterLoad(); this.NavigateToCurrentFilters(); await this.LoadAsync(); @@ -934,6 +1841,7 @@ } this.CancelPendingFilterLoad(); + this.MarkViewDirty(); this.NavigateToCurrentFilters(); await this.LoadAsync(); } @@ -944,6 +1852,7 @@ { if (setFilter(value)) { + this.MarkViewDirty(); this.ScheduleDebouncedLoad(); } @@ -959,6 +1868,7 @@ this.CancelPendingFilterLoad(); this._filters.Clear(); + this.MarkViewDirty(); this.NavigateToCurrentFilters(); await this.LoadAsync(); } @@ -1187,6 +2097,7 @@ return; } + this.MarkViewDirty(); this.CancelPendingFilterLoad(); await this.LoadAsync(); this.StateHasChanged(); 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/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/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 new file mode 100644 index 0000000..aca4536 --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/DashboardJobViews.cs @@ -0,0 +1,252 @@ +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) + || !DashboardJobColumnWidths.IsValid(column.Width)) + { + 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) + { + Width = column.Width, + }), + ]; + + 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..0911b94 --- /dev/null +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardJobColumn.cs @@ -0,0 +1,19 @@ +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) +{ + /// + /// 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/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..bc70c41 --- /dev/null +++ b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-job-views.js @@ -0,0 +1,164 @@ +(function () { + "use strict"; + + let drawerEscapeHandler = null; + let tableColumnPointerDownHandler = null; + let activeColumnResizeCleanup = null; + + 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)); + }, + 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 0b43772..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"); } @@ -179,6 +179,69 @@ 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(">Customer"); + html.ShouldContain("jobs-derived-tag"); + html.ShouldContain(">acme"); + html.ShouldContain("href=\"jobs?state=Claimed&tag=tenant%3Aacme&sort=NewestFirst\""); + 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(">Customer"); + } + + [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("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] 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..b588e3e 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; @@ -38,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] @@ -134,6 +137,119 @@ 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(); + DashboardJobViews.IsValid(new ShedduellerDashboardOptions + { + JobViews = + [ + validView with + { + Columns = + [ + new(ShedduellerDashboardJobColumnKind.JobId), + new ShedduellerDashboardJobColumn(ShedduellerDashboardJobColumnKind.Handler) { Width = 47 }, + ], + }, + ], + }).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 ShedduellerDashboardJobColumn(ShedduellerDashboardJobColumnKind.Tag, "provider", "Provider") + { + Width = 124, + }, + ], + }, + }, + ], + }; + + 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"); + restored.Views.Single().View.Columns![1].Width.ShouldBe(124); + } + [Fact] public void JobEvent_LogAndTimelineEvents_FormatsOperationalText() { 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)
Queue