diff --git a/.vscode/settings.json b/.vscode/settings.json index fc139c8..48d900c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -32,6 +32,8 @@ "Antiforgery", "Cronos", "Npgsql", + "Prerender", + "prerendered", "Sheddueller", "Shouldly", "Upserted", diff --git a/samples/Sheddueller.SampleHost/LauncherPageRenderer.cs b/samples/Sheddueller.SampleHost/LauncherPageRenderer.cs index 3c92562..e054274 100644 --- a/samples/Sheddueller.SampleHost/LauncherPageRenderer.cs +++ b/samples/Sheddueller.SampleHost/LauncherPageRenderer.cs @@ -71,6 +71,7 @@ public static string Render(string? statusMessage) AppendActionCard(builder, "/launch/retry-then-succeed", "Retry then succeed", "Fails twice, then succeeds so retry history is visible.", "Enqueue job"); AppendActionCard(builder, "/launch/permanent-failure", "Permanent failure", "Fails terminally without retries.", "Enqueue job"); AppendActionCard(builder, "/launch/delayed", "Delayed job", "Queues a short delayed job to exercise delayed state and not-before time.", "Enqueue job"); + AppendActionCard(builder, "/launch/many-tags", "Many tags", "Queues a tagged job with informational tags first and ceremonial tags later.", "Enqueue job"); AppendActionCard(builder, "/launch/blocking-batch", "Concurrency batch", "Sets a shared group limit to 1 and enqueues several long jobs.", "Enqueue batch"); AppendActionCard(builder, "/launch/idempotent", "Idempotent reprice", "Queues one reprice-listing-3 job behind a held group slot; click twice quickly to reuse the queued job.", "Enqueue job"); AppendActionCard(builder, "/launch/cancelable", "Cancelable delayed job", "Creates a delayed queued job that can be canceled from the dashboard.", "Enqueue job"); diff --git a/samples/Sheddueller.SampleHost/Program.cs b/samples/Sheddueller.SampleHost/Program.cs index f30c2eb..01da2ab 100644 --- a/samples/Sheddueller.SampleHost/Program.cs +++ b/samples/Sheddueller.SampleHost/Program.cs @@ -32,7 +32,11 @@ options.HeartbeatInterval = TimeSpan.FromSeconds(5); options.DefaultRetryPolicy = new RetryPolicy(3, RetryBackoffKind.Fixed, TimeSpan.FromSeconds(2)); })); -builder.Services.AddShedduellerDashboard(options => options.EventRetention = TimeSpan.FromDays(14)); +builder.Services.AddShedduellerDashboard(options => +{ + options.EventRetention = TimeSpan.FromDays(14); + options.TagDisplayOrder = ["tenant", "listing", "domain", "workflow", "source", "demo"]; +}); var app = builder.Build(); @@ -92,6 +96,26 @@ return RedirectWithMessage($"Queued delayed job {jobId:D} for {notBeforeUtc:O}."); }); +app.MapPost("/launch/many-tags", async (IJobEnqueuer enqueuer, CancellationToken cancellationToken) => +{ + var jobId = await enqueuer.EnqueueAsync( + (service, ct) => service.RunProgressAsync("many-tags-demo", Job.Context, ct), + new JobSubmission( + Priority: 15, + Tags: + [ + new JobTag("tenant", "acme"), + new JobTag("listing", "villa-8842"), + new JobTag("domain", "pricing"), + new JobTag("workflow", "rate-refresh"), + new JobTag("source", "sample-host"), + new JobTag("demo", "many-tags"), + new JobTag("ceremony", "dashboard-overflow"), + ]), + cancellationToken).ConfigureAwait(false); + return RedirectWithMessage($"Queued many-tags demo job {jobId:D}."); +}); + app.MapPost("/launch/blocking-batch", async ( IConcurrencyGroupManager concurrencyGroupManager, IJobEnqueuer enqueuer, diff --git a/src/Sheddueller.Dashboard/Components/ChipList.razor b/src/Sheddueller.Dashboard/Components/ChipList.razor index 3a3bd2a..273ff32 100644 --- a/src/Sheddueller.Dashboard/Components/ChipList.razor +++ b/src/Sheddueller.Dashboard/Components/ChipList.razor @@ -5,8 +5,9 @@ } else { - @foreach (var value in Values) + @for (var i = 0; i < VisibleCount; i++) { + var value = Values[i]; @if (HrefFactory is null) { @value @@ -17,6 +18,27 @@ aria-label="@GetLinkAriaLabel(value)">@value } } + @if (HiddenCount > 0) + { + + + + @for (var i = VisibleCount; i < Values.Count; i++) + { + var value = Values[i]; + @if (HrefFactory is null) + { + @value + } + else + { + @value + } + } + + + } } @@ -39,15 +61,45 @@ [Parameter] public string? LinkAriaLabelPrefix { get; set; } + [Parameter] + public int MaxVisible { get; set; } = int.MaxValue; + + [Parameter] + public string OverflowAriaLabelPrefix { get; set; } = "Additional values"; + + private int VisibleCount + => Math.Clamp(this.MaxVisible, 0, this.Values.Count); + + private int HiddenCount + => this.Values.Count - this.VisibleCount; + private string ListClass - => string.Concat(this.ClassPrefix, "-list"); + => string.Concat("sd-chip-list ", this.ClassPrefix, "-list"); private string ChipClass - => this.ClassPrefix; + => string.Concat("sd-chip ", this.ClassPrefix); private string EmptyClass => string.Concat(this.ClassPrefix, "-empty"); + private string OverflowClass + => string.Concat("sd-chip-overflow ", this.ClassPrefix, "-overflow"); + + private string OverflowTriggerClass + => string.Concat(this.ChipClass, " ", this.ClassPrefix, "--overflow sd-chip-overflow__summary"); + + private string OverflowPanelClass + => string.Concat("sd-chip-overflow__panel ", this.ClassPrefix, "-overflow-panel"); + + private string OverflowItemClass + => string.Concat("sd-chip-overflow__item ", this.ClassPrefix, "-overflow-item"); + + private string OverflowAriaLabel + => string.Concat(this.OverflowAriaLabelPrefix, ": ", this.Title); + + private string OverflowText + => string.Create(CultureInfo.InvariantCulture, $"+{this.HiddenCount}"); + private string? GetLinkAriaLabel(string value) => string.IsNullOrWhiteSpace(this.LinkAriaLabelPrefix) ? null diff --git a/src/Sheddueller.Dashboard/Components/DashboardApp.razor b/src/Sheddueller.Dashboard/Components/DashboardApp.razor index dd05bef..f14ce24 100644 --- a/src/Sheddueller.Dashboard/Components/DashboardApp.razor +++ b/src/Sheddueller.Dashboard/Components/DashboardApp.razor @@ -12,12 +12,16 @@ + + + + diff --git a/src/Sheddueller.Dashboard/Components/DashboardLayout.razor b/src/Sheddueller.Dashboard/Components/DashboardLayout.razor index 294a5a0..2f20cfd 100644 --- a/src/Sheddueller.Dashboard/Components/DashboardLayout.razor +++ b/src/Sheddueller.Dashboard/Components/DashboardLayout.razor @@ -151,6 +151,79 @@ color: inherit; } + .sd-chip-list { + position: relative; + overflow: visible; + } + + .sd-chip-list .sd-chip { + max-width: 180px; + flex: 0 0 auto; + } + + .sd-chip-overflow { + position: relative; + display: inline-flex; + flex: 0 0 auto; + } + + .sd-chip-overflow__summary { + display: inline-flex; + min-width: 30px; + max-width: none; + appearance: none; + align-items: center; + justify-content: center; + cursor: pointer; + font: inherit; + } + + .sd-chip-overflow:hover .sd-chip-overflow__summary, + .sd-chip-overflow:focus-within .sd-chip-overflow__summary { + border-color: var(--sd-primary); + color: var(--sd-primary); + } + + .sd-chip-overflow__panel { + position: absolute; + top: 100%; + right: 0; + z-index: 30; + display: none; + min-width: 220px; + max-width: min(420px, 70vw); + gap: 2px; + border: 1px solid var(--sd-outline-variant); + border-radius: 4px; + background: var(--sd-surface-lowest); + box-shadow: 0 12px 28px rgb(0 0 0 / 30%); + padding: 10px 6px 6px; + white-space: normal; + } + + .sd-chip-overflow:hover .sd-chip-overflow__panel, + .sd-chip-overflow:focus-within .sd-chip-overflow__panel { + display: grid; + } + + .sd-chip-overflow__item { + display: block; + overflow-wrap: anywhere; + border-radius: 2px; + color: var(--sd-on-surface); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 11px; + line-height: 16px; + padding: 4px 6px; + text-decoration: none; + white-space: normal; + } + + a.sd-chip-overflow__item:hover { + background: var(--sd-surface-low); + color: var(--sd-primary); + } + button, input, select { diff --git a/src/Sheddueller.Dashboard/Components/Pages/JobDetail.razor b/src/Sheddueller.Dashboard/Components/Pages/JobDetail.razor index 620d0fd..781c06d 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/JobDetail.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/JobDetail.razor @@ -127,6 +127,13 @@ @DashboardFormat.Utc(GetStartedAt(_detail)) +
+
+ Run Time: + @DashboardFormat.RunTime(_detail) +
+ @DashboardFormat.Utc(DashboardFormat.TerminalTimestamp(job)) +
@@ -195,9 +202,8 @@
Tags - +
@@ -622,6 +628,7 @@ .job-detail-metadata-item strong, .job-detail-chip, .job-detail-invocation-call, + .job-detail-inline-duration, .job-detail-parameter pre, .job-detail-progress-percent, .job-detail-log-table, @@ -1028,10 +1035,14 @@ margin: 0; border: 1px solid var(--sd-outline-variant); border-radius: 2px; + background: var(--sd-surface-container); + box-shadow: none; + color: var(--sd-on-surface); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 13px; line-height: 20px; padding: 10px; + text-shadow: none; white-space: pre-wrap; overflow-wrap: anywhere; } @@ -1039,7 +1050,9 @@ pre.job-detail-invocation-call[class*="language-"] > code[class*="language-"] { display: block; background: transparent; + color: inherit; font: inherit; + text-shadow: none; white-space: inherit; overflow-wrap: inherit; } diff --git a/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor b/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor index 8712ba8..85e4af1 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/Jobs.razor @@ -25,6 +25,11 @@
+ + @@ -89,6 +95,7 @@ Enqueued Terminal Time State + Queue Handler Tags Progress @@ -102,7 +109,7 @@ @if (_jobs.Count == 0) { - @EmptyText + @EmptyText } else @@ -126,15 +133,17 @@ + + @QueuePositionText(job) + @DashboardFormat.ShortHandler(job) - + @@ -277,6 +286,11 @@ min-width: 180px; } + .jobs-filter-bar .sd-table-select { + flex: 0 0 166px; + min-width: 150px; + } + button.jobs-clear-button, button.jobs-load-more-button { display: inline-flex; @@ -368,7 +382,7 @@ .jobs-table { width: 100%; - min-width: 1640px; + min-width: 1816px; border-collapse: collapse; table-layout: fixed; white-space: nowrap; @@ -382,6 +396,14 @@ width: 144px; } + .jobs-table__col--queue { + width: 96px; + } + + .jobs-table__col--tags { + width: 380px; + } + .jobs-table__col--disposition { width: 144px; } @@ -486,12 +508,12 @@ min-width: 0; align-items: center; gap: 4px; - overflow: hidden; + overflow: visible; } .jobs-chip { display: inline-block; - max-width: 112px; + max-width: 180px; overflow: hidden; border: 1px solid var(--sd-outline-variant); border-radius: 2px; @@ -584,6 +606,44 @@ text-overflow: ellipsis; } + .jobs-queue { + display: inline-flex; + min-width: 56px; + align-items: center; + justify-content: center; + border: 1px solid var(--sd-outline-variant); + border-radius: 2px; + padding: 2px 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 10px; + font-weight: 700; + line-height: 12px; + text-transform: uppercase; + } + + .jobs-queue--claimable { + border-color: var(--sd-primary); + color: var(--sd-primary); + } + + .jobs-queue--claimed { + border-color: var(--sd-secondary-container); + background: var(--sd-secondary-container); + color: var(--sd-on-secondary-container); + } + + .jobs-queue--blocked, + .jobs-queue--waiting, + .jobs-queue--delayed { + color: var(--sd-on-surface-variant); + } + + .jobs-queue--empty { + min-width: 0; + border-color: transparent; + color: var(--sd-on-surface-variant); + } + .jobs-disposition--error { color: var(--sd-error); } @@ -668,6 +728,7 @@ .jobs-status-filter, .jobs-text-filters, .jobs-filter-bar .sd-table-search, + .jobs-filter-bar .sd-table-select, button.jobs-clear-button, .jobs-pagination { width: 100%; @@ -768,6 +829,21 @@ private Task SetConcurrencyGroupContainsAsync(string value) => this.SetTextFilterAsync(value, this._filters.SetConcurrencyGroupContains); + private async Task SetSortAsync(ChangeEventArgs eventArgs) + { + if (eventArgs.Value is not string value + || !Enum.TryParse(value, ignoreCase: true, out var sort) + || !Enum.IsDefined(sort) + || !this._filters.SetSort(sort)) + { + return; + } + + this.CancelPendingFilterLoad(); + this.NavigateToCurrentFilters(); + await this.LoadAsync(); + } + private Task SetTextFilterAsync( string value, Func setFilter) @@ -1021,6 +1097,32 @@ private static string StatusFilterClass(JobState state) => string.Concat("jobs-status-option jobs-status-option--", DashboardFormat.StateCssModifier(state)); + private static string QueuePositionText(JobInspectionSummary job) + => job.QueuePosition switch + { + { Kind: JobQueuePositionKind.Claimable, Position: { } position } => string.Create(CultureInfo.InvariantCulture, $"#{position}"), + { Kind: JobQueuePositionKind.Claimable } => "Ready", + { Kind: JobQueuePositionKind.Claimed } => "Running", + { Kind: JobQueuePositionKind.BlockedByConcurrency } => "Blocked", + { Kind: JobQueuePositionKind.RetryWaiting } => "Retry", + { Kind: JobQueuePositionKind.Delayed } => "Delayed", + _ => "-", + }; + + private static string QueuePositionTitle(JobInspectionSummary job) + => job.QueuePosition?.Reason ?? QueuePositionText(job); + + private static string QueuePositionClass(JobInspectionSummary job) + => string.Concat("jobs-queue jobs-queue--", job.QueuePosition?.Kind switch + { + JobQueuePositionKind.Claimable => "claimable", + JobQueuePositionKind.Claimed => "claimed", + JobQueuePositionKind.BlockedByConcurrency => "blocked", + JobQueuePositionKind.RetryWaiting => "waiting", + JobQueuePositionKind.Delayed => "delayed", + _ => "empty", + }); + private static string GetDispositionClass(JobInspectionSummary job) => job.State == JobState.Failed ? "jobs-disposition--error" : "jobs-disposition--muted"; diff --git a/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor b/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor index dce061d..87b485f 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor @@ -1,6 +1,7 @@ @page "/metrics" @inherits DashboardPageComponent @inject IMetricsInspectionReader Reader +@inject IDashboardThroughputReader ThroughputReader
@@ -138,6 +139,15 @@ +
+
+

Live Throughput

+ 1s Buckets / 1h Window +
+ + +
+
@@ -424,6 +434,7 @@ .metrics-summary-card, .metrics-panel, + .metrics-throughput-panel, .metrics-table-shell { border: 1px solid var(--sd-outline-variant); border-radius: var(--sd-radius); @@ -558,6 +569,13 @@ padding: 16px; } + .metrics-throughput-panel { + display: grid; + gap: 16px; + min-height: 342px; + padding: 16px; + } + .metrics-panel__header { justify-content: space-between; gap: 12px; @@ -673,6 +691,101 @@ height: 6px; } + .throughput-chart { + position: relative; + min-height: 220px; + overflow: hidden; + } + + .throughput-chart>div.uplot { + width: 100%; + } + + .throughput-chart__canvas { + display: block; + width: 100%; + height: 220px; + } + + .throughput-chart__empty, + .throughput-chart--empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--sd-on-surface-variant); + font-size: 12px; + line-height: 16px; + pointer-events: none; + } + + .throughput-chart--empty { + position: relative; + } + + .throughput-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .throughput-chart-legend__item { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--sd-outline-variant); + border-radius: var(--sd-radius); + background: var(--sd-surface); + padding: 6px 8px; + color: var(--sd-on-surface-variant); + font-size: 12px; + line-height: 16px; + } + + .throughput-chart-legend__item>span { + width: 8px; + height: 8px; + flex: 0 0 auto; + border-radius: 999px; + background: var(--sd-outline); + } + + .throughput-chart-legend__item em { + font-style: normal; + } + + .throughput-chart-legend__item strong { + color: var(--sd-on-surface); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-weight: 700; + } + + .throughput-chart-legend__item--queued>span { + background: var(--sd-secondary); + } + + .throughput-chart-legend__item--started>span { + background: var(--sd-info); + } + + .throughput-chart-legend__item--succeeded>span { + background: var(--sd-success); + } + + .throughput-chart-legend__item--failed>span { + background: var(--sd-error); + } + + .throughput-chart-legend__item--canceled>span { + background: var(--sd-warning); + } + + .throughput-chart-legend__item--attempts>span { + background: #7c3aed; + } + .metrics-table-shell { overflow: hidden; } @@ -836,6 +949,7 @@ TimeSpan.FromHours(24)]; private MetricsInspectionSnapshot? _snapshot; + private DashboardThroughputSnapshot? _throughputSnapshot; private string? _loadError; private TimeSpan _selectedWindow = TimeSpan.FromHours(1); private bool _isLoading; @@ -871,8 +985,8 @@ TimeSpan.FromHours(24)]; try { - var snapshot = await this.ReadSnapshotAsync(CancellationToken.None); - this.ApplySnapshot(snapshot); + var snapshot = await this.ReadPageSnapshotAsync(CancellationToken.None); + this.ApplySnapshot(snapshot.Metrics, snapshot.Throughput); this.LiveRefresh.MarkUpdated(); } catch (NotSupportedException) @@ -892,19 +1006,25 @@ TimeSpan.FromHours(24)]; private async Task RefreshCurrentQueryAsync(CancellationToken cancellationToken) { - var snapshot = await this.ReadSnapshotAsync(cancellationToken); - await this.ApplyPageStateAsync(() => this.ApplySnapshot(snapshot)); + var snapshot = await this.ReadPageSnapshotAsync(cancellationToken); + await this.ApplyPageStateAsync(() => this.ApplySnapshot(snapshot.Metrics, snapshot.Throughput)); } - private async Task ReadSnapshotAsync(CancellationToken cancellationToken) - => await Reader.GetMetricsAsync( + private async Task ReadPageSnapshotAsync(CancellationToken cancellationToken) + { + var metrics = await Reader.GetMetricsAsync( new MetricsInspectionQuery(DefaultMetricWindows), cancellationToken) ; + return new MetricsPageSnapshot(metrics, ThroughputReader.GetSnapshot()); + } - private void ApplySnapshot(MetricsInspectionSnapshot snapshot) + private void ApplySnapshot( + MetricsInspectionSnapshot snapshot, + DashboardThroughputSnapshot throughputSnapshot) { this._snapshot = snapshot; + this._throughputSnapshot = throughputSnapshot; this._loadError = null; this._metricsUnsupported = false; @@ -916,4 +1036,8 @@ TimeSpan.FromHours(24)]; private string WindowRowClass(MetricsInspectionWindow window) => window.Window == (this.SelectedWindow?.Window ?? this._selectedWindow) ? "metrics-row--selected" : string.Empty; + + private sealed record MetricsPageSnapshot( + MetricsInspectionSnapshot Metrics, + DashboardThroughputSnapshot Throughput); } diff --git a/src/Sheddueller.Dashboard/Components/Pages/Overview.razor b/src/Sheddueller.Dashboard/Components/Pages/Overview.razor index 1176c5a..c0bca2b 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/Overview.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/Overview.razor @@ -97,10 +97,9 @@ aria-label="@HandlerFilterTitle(job)">@DashboardFormat.ShortHandler(job) - + @DashboardFormat.ShortHandler(job) - + - +
@@ -297,7 +297,7 @@ } .schedules-table { - min-width: 1280px; + min-width: 1360px; table-layout: fixed; white-space: nowrap; } @@ -331,7 +331,7 @@ } .schedules-table__col--metadata { - width: 232px; + width: 312px; } .schedules-table__col--actions { @@ -487,13 +487,13 @@ display: flex; min-width: 0; gap: 4px; - overflow: hidden; + overflow: visible; } .schedules-chip { display: inline-flex; min-width: 0; - max-width: 96px; + max-width: 148px; overflow: hidden; border: 1px solid var(--sd-outline-variant); border-radius: 2px; diff --git a/src/Sheddueller.Dashboard/Components/TagChipList.razor b/src/Sheddueller.Dashboard/Components/TagChipList.razor new file mode 100644 index 0000000..0b2b31d --- /dev/null +++ b/src/Sheddueller.Dashboard/Components/TagChipList.razor @@ -0,0 +1,34 @@ +@inject Microsoft.Extensions.Options.IOptions DashboardOptions + + + +@code { + [Parameter] + public IReadOnlyList Tags { get; set; } = []; + + [Parameter] + public string EmptyText { get; set; } = "-"; + + [Parameter] + public string ClassPrefix { get; set; } = "sd-chip"; + + [Parameter] + public Func? HrefFactory { get; set; } + + [Parameter] + public string? LinkAriaLabelPrefix { get; set; } + + [Parameter] + public int MaxVisible { get; set; } = int.MaxValue; + + private IReadOnlyList OrderedTags + => DashboardTagOrder.Apply(this.Tags, DashboardOptions.Value.TagDisplayOrder); + + private IReadOnlyList OrderedValues + => DashboardFormat.Tags(this.OrderedTags); + + private string OrderedTitle + => DashboardFormat.TagsTitle(this.OrderedTags); +} diff --git a/src/Sheddueller.Dashboard/Components/ThroughputChart.razor b/src/Sheddueller.Dashboard/Components/ThroughputChart.razor new file mode 100644 index 0000000..876c1a9 --- /dev/null +++ b/src/Sheddueller.Dashboard/Components/ThroughputChart.razor @@ -0,0 +1,170 @@ +@implements IAsyncDisposable +@inject IJSRuntime Js + +@if (ThroughputSnapshot is null) +{ +
+ + Waiting for live throughput data +
+} +else +{ + var recent = RecentTotals(ThroughputSnapshot); + +
+ @if (!HasActivity(ThroughputSnapshot)) + { +
+ + Waiting for live job events +
+ } +
+ +
+ + + Queued + @DashboardFormat.Count(recent.QueuedCount) + + + + Started + @DashboardFormat.Count(recent.StartedCount) + + + + Succeeded + @DashboardFormat.Count(recent.SucceededCount) + + + + Failed + @DashboardFormat.Count(recent.FailedCount) + + + + Canceled + @DashboardFormat.Count(recent.CanceledCount) + + + + Failed Attempts + @DashboardFormat.Count(recent.FailedAttemptCount) + +
+} + +@code { + private ElementReference _chartElement; + private bool _chartRendered; + + [Parameter] + public object? Snapshot { get; set; } + + private DashboardThroughputSnapshot? ThroughputSnapshot + => this.Snapshot as DashboardThroughputSnapshot; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (this.ThroughputSnapshot is null) + { + return; + } + + await Js.InvokeVoidAsync("shedduellerThroughputChart.render", this._chartElement, CreateModel(this.ThroughputSnapshot)); + this._chartRendered = true; + } + + public async ValueTask DisposeAsync() + { + if (!this._chartRendered) + { + return; + } + + try + { + await Js.InvokeVoidAsync("shedduellerThroughputChart.destroy", this._chartElement); + } + catch (JSDisconnectedException) + { + } + + GC.SuppressFinalize(this); + } + + private static bool HasActivity(DashboardThroughputSnapshot snapshot) + => snapshot.Buckets.Any(bucket => + bucket.QueuedCount > 0 + || bucket.StartedCount > 0 + || bucket.SucceededCount > 0 + || bucket.FailedCount > 0 + || bucket.CanceledCount > 0 + || bucket.FailedAttemptCount > 0); + + private static DashboardThroughputBucket RecentTotals(DashboardThroughputSnapshot snapshot) + { + var oldestIncludedUtc = snapshot.WindowEndUtc.AddMinutes(-1); + var totals = snapshot.Buckets + .Where(bucket => bucket.StartedAtUtc > oldestIncludedUtc) + .Aggregate( + new DashboardThroughputTotals(), + static (current, bucket) => current.Add(bucket)); + + return new DashboardThroughputBucket( + snapshot.WindowEndUtc.AddMinutes(-1), + totals.QueuedCount, + totals.StartedCount, + totals.SucceededCount, + totals.FailedCount, + totals.CanceledCount, + totals.FailedAttemptCount); + } + + private static ThroughputChartModel CreateModel(DashboardThroughputSnapshot snapshot) + => new( + (int)snapshot.BucketSize.TotalSeconds, + snapshot.WindowStartUtc.ToUnixTimeSeconds(), + snapshot.WindowEndUtc.ToUnixTimeSeconds(), + snapshot.Buckets.Select(static bucket => bucket.StartedAtUtc.ToUnixTimeSeconds()).ToArray(), + snapshot.Buckets.Select(static bucket => bucket.QueuedCount).ToArray(), + snapshot.Buckets.Select(static bucket => bucket.StartedCount).ToArray(), + snapshot.Buckets.Select(static bucket => bucket.SucceededCount).ToArray(), + snapshot.Buckets.Select(static bucket => bucket.FailedCount).ToArray(), + snapshot.Buckets.Select(static bucket => bucket.CanceledCount).ToArray(), + snapshot.Buckets.Select(static bucket => bucket.FailedAttemptCount).ToArray()); + + private sealed record ThroughputChartModel( + int BucketSizeSeconds, + long WindowStartUnixSeconds, + long WindowEndUnixSeconds, + long[] Timestamps, + int[] Queued, + int[] Started, + int[] Succeeded, + int[] Failed, + int[] Canceled, + int[] FailedAttempts); + + private sealed record DashboardThroughputTotals( + int QueuedCount = 0, + int StartedCount = 0, + int SucceededCount = 0, + int FailedCount = 0, + int CanceledCount = 0, + int FailedAttemptCount = 0) + { + public DashboardThroughputTotals Add(DashboardThroughputBucket bucket) + => this with + { + QueuedCount = this.QueuedCount + bucket.QueuedCount, + StartedCount = this.StartedCount + bucket.StartedCount, + SucceededCount = this.SucceededCount + bucket.SucceededCount, + FailedCount = this.FailedCount + bucket.FailedCount, + CanceledCount = this.CanceledCount + bucket.CanceledCount, + FailedAttemptCount = this.FailedAttemptCount + bucket.FailedAttemptCount, + }; + } +} diff --git a/src/Sheddueller.Dashboard/Components/_Imports.razor b/src/Sheddueller.Dashboard/Components/_Imports.razor index 55e823a..7edf627 100644 --- a/src/Sheddueller.Dashboard/Components/_Imports.razor +++ b/src/Sheddueller.Dashboard/Components/_Imports.razor @@ -2,6 +2,7 @@ @using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop @using Sheddueller @using Sheddueller.Dashboard.Components @using Sheddueller.Dashboard diff --git a/src/Sheddueller.Dashboard/Internal/DashboardFilters.cs b/src/Sheddueller.Dashboard/Internal/DashboardFilters.cs index c9b644e..22a5b10 100644 --- a/src/Sheddueller.Dashboard/Internal/DashboardFilters.cs +++ b/src/Sheddueller.Dashboard/Internal/DashboardFilters.cs @@ -21,6 +21,8 @@ internal sealed class DashboardJobFilters public string ConcurrencyGroupContains => this._concurrencyGroupContains; + public JobInspectionSort Sort { get; private set; } = JobInspectionSort.Operational; + public IReadOnlyList SelectedStates => [.. Enum.GetValues().Where(this._states.Contains)]; @@ -47,6 +49,17 @@ public bool SetTagContains(string value) public bool SetConcurrencyGroupContains(string value) => SetTextFilter(ref this._concurrencyGroupContains, value); + public bool SetSort(JobInspectionSort sort) + { + if (this.Sort == sort) + { + return false; + } + + this.Sort = sort; + return true; + } + public bool ReplaceStates(IEnumerable states) { var nextStates = states.ToHashSet(); @@ -69,7 +82,8 @@ public bool ReplaceWith(DashboardJobFilters filters) var changed = !this.SelectedStates.SequenceEqual(filters.SelectedStates) || !string.Equals(this.HandlerContains, filters.HandlerContains, StringComparison.Ordinal) || !string.Equals(this.TagContains, filters.TagContains, StringComparison.Ordinal) - || !string.Equals(this.ConcurrencyGroupContains, filters.ConcurrencyGroupContains, StringComparison.Ordinal); + || !string.Equals(this.ConcurrencyGroupContains, filters.ConcurrencyGroupContains, StringComparison.Ordinal) + || this.Sort != filters.Sort; if (!changed) { @@ -85,6 +99,7 @@ public bool ReplaceWith(DashboardJobFilters filters) this._handlerContains = filters.HandlerContains; this._tagContains = filters.TagContains; this._concurrencyGroupContains = filters.ConcurrencyGroupContains; + this.Sort = filters.Sort; return true; } @@ -112,7 +127,8 @@ public JobInspectionQuery ToQuery( Normalize(this.TagContains), Normalize(this.ConcurrencyGroupContains), pageSize, - continuationToken); + continuationToken, + this.Sort); private static bool SetTextFilter( ref string field, @@ -137,6 +153,7 @@ internal static class DashboardJobFilterQuery public const string HandlerParameter = "handler"; public const string TagParameter = "tag"; public const string GroupParameter = "group"; + public const string SortParameter = "sort"; private const string JobsPath = "jobs"; @@ -177,6 +194,13 @@ public static DashboardJobFilters ParseQuery(string queryString) _ = filters.SetConcurrencyGroupContains(group); } + if (TryGetLastNonEmptyValue(query, SortParameter, out var sortValue) + && Enum.TryParse(sortValue, ignoreCase: true, out var sort) + && Enum.IsDefined(sort)) + { + _ = filters.SetSort(sort); + } + return filters; } @@ -192,6 +216,10 @@ public static string ToHref(DashboardJobFilters filters) AddQueryPart(queryParts, HandlerParameter, filters.HandlerContains); AddQueryPart(queryParts, TagParameter, filters.TagContains); AddQueryPart(queryParts, GroupParameter, filters.ConcurrencyGroupContains); + if (filters.Sort != JobInspectionSort.Operational) + { + AddQueryPart(queryParts, SortParameter, filters.Sort.ToString()); + } return queryParts.Count == 0 ? JobsPath diff --git a/src/Sheddueller.Dashboard/Internal/DashboardFormat.cs b/src/Sheddueller.Dashboard/Internal/DashboardFormat.cs index dd96a39..8508df6 100644 --- a/src/Sheddueller.Dashboard/Internal/DashboardFormat.cs +++ b/src/Sheddueller.Dashboard/Internal/DashboardFormat.cs @@ -71,6 +71,22 @@ public static string Nullable(string? value) public static DateTimeOffset? TerminalTimestamp(JobInspectionSummary job) => job.FailedAtUtc ?? job.CompletedAtUtc ?? job.CanceledAtUtc; + public static TimeSpan? RunTimeDuration(JobInspectionDetail detail) + { + var claimedAtUtc = detail.ClaimedAtUtc; + var terminalAtUtc = TerminalTimestamp(detail.Summary); + if (claimedAtUtc is null || terminalAtUtc is null) + { + return null; + } + + var duration = terminalAtUtc.Value - claimedAtUtc.Value; + return duration < TimeSpan.Zero ? null : duration; + } + + public static string RunTime(JobInspectionDetail detail) + => DashboardMetricsFormat.Duration(RunTimeDuration(detail)); + public static string ProgressText( JobProgressSnapshot? progress, string missingText = "No progress reported", diff --git a/src/Sheddueller.Dashboard/Internal/DashboardTagOrder.cs b/src/Sheddueller.Dashboard/Internal/DashboardTagOrder.cs new file mode 100644 index 0000000..e4700bd --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/DashboardTagOrder.cs @@ -0,0 +1,87 @@ +namespace Sheddueller.Dashboard.Internal; + +internal static class DashboardTagOrder +{ + public static bool IsValid(ShedduellerDashboardOptions options) + => IsValid(options.TagDisplayOrder); + + public static bool IsValid(IReadOnlyList? tagDisplayOrder) + { + if (tagDisplayOrder is null) + { + return false; + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var tagName in tagDisplayOrder) + { + if (tagName is null) + { + return false; + } + + var normalized = tagName.Trim(); + if (normalized.Length == 0 || !seen.Add(normalized)) + { + return false; + } + } + + return true; + } + + public static IReadOnlyList Apply( + IReadOnlyList tags, + IReadOnlyList? tagDisplayOrder) + { + if (tags.Count == 0 || tagDisplayOrder is null || tagDisplayOrder.Count == 0) + { + return tags; + } + + var ranks = CreateRanks(tagDisplayOrder); + if (ranks.Count == 0) + { + return tags; + } + + return + [ + .. tags + .Select((tag, index) => new OrderedTag(tag, index, GetRank(ranks, tag))) + .OrderBy(tag => tag.Rank) + .ThenBy(tag => tag.OriginalIndex) + .Select(tag => tag.Tag), + ]; + } + + private static Dictionary CreateRanks(IReadOnlyList tagDisplayOrder) + { + var ranks = new Dictionary(StringComparer.Ordinal); + foreach (var tagName in tagDisplayOrder) + { + if (tagName is null) + { + continue; + } + + var normalized = tagName.Trim(); + if (normalized.Length > 0 && !ranks.ContainsKey(normalized)) + { + ranks.Add(normalized, ranks.Count); + } + } + + return ranks; + } + + private static int GetRank( + Dictionary ranks, + JobTag tag) + => ranks.TryGetValue(tag.Name.Trim(), out var rank) ? rank : int.MaxValue; + + private sealed record OrderedTag( + JobTag Tag, + int OriginalIndex, + int Rank); +} diff --git a/src/Sheddueller.Dashboard/Internal/DashboardThroughputBucket.cs b/src/Sheddueller.Dashboard/Internal/DashboardThroughputBucket.cs new file mode 100644 index 0000000..1a22f90 --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/DashboardThroughputBucket.cs @@ -0,0 +1,10 @@ +namespace Sheddueller.Dashboard.Internal; + +internal sealed record DashboardThroughputBucket( + DateTimeOffset StartedAtUtc, + int QueuedCount, + int StartedCount, + int SucceededCount, + int FailedCount, + int CanceledCount, + int FailedAttemptCount); diff --git a/src/Sheddueller.Dashboard/Internal/DashboardThroughputHostedService.cs b/src/Sheddueller.Dashboard/Internal/DashboardThroughputHostedService.cs new file mode 100644 index 0000000..6df07dd --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/DashboardThroughputHostedService.cs @@ -0,0 +1,15 @@ +namespace Sheddueller.Dashboard.Internal; + +using Microsoft.Extensions.Hosting; + +internal sealed class DashboardThroughputHostedService(DashboardThroughputStore store) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + _ = store; + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + => Task.CompletedTask; +} diff --git a/src/Sheddueller.Dashboard/Internal/DashboardThroughputSnapshot.cs b/src/Sheddueller.Dashboard/Internal/DashboardThroughputSnapshot.cs new file mode 100644 index 0000000..5538256 --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/DashboardThroughputSnapshot.cs @@ -0,0 +1,7 @@ +namespace Sheddueller.Dashboard.Internal; + +internal sealed record DashboardThroughputSnapshot( + DateTimeOffset WindowStartUtc, + DateTimeOffset WindowEndUtc, + TimeSpan BucketSize, + IReadOnlyList Buckets); diff --git a/src/Sheddueller.Dashboard/Internal/DashboardThroughputStore.cs b/src/Sheddueller.Dashboard/Internal/DashboardThroughputStore.cs new file mode 100644 index 0000000..3cbdde8 --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/DashboardThroughputStore.cs @@ -0,0 +1,221 @@ +namespace Sheddueller.Dashboard.Internal; + +using Sheddueller.Storage; + +internal sealed class DashboardThroughputStore : IDashboardThroughputReader, IDisposable +{ + internal static readonly TimeSpan BucketSize = TimeSpan.FromSeconds(1); + internal static readonly TimeSpan Window = TimeSpan.FromHours(1); + internal const int BucketCount = 3600; + + private readonly Lock _gate = new(); + private readonly DashboardThroughputBucketState[] _buckets = new DashboardThroughputBucketState[BucketCount]; + private readonly DashboardLiveUpdateStream _stream; + private readonly TimeProvider _timeProvider; + private bool _disposed; + + public DashboardThroughputStore( + DashboardLiveUpdateStream stream, + TimeProvider timeProvider) + { + this._stream = stream; + this._timeProvider = timeProvider; + this._stream.JobEventPublished += this.RecordAsync; + } + + public DashboardThroughputSnapshot GetSnapshot() + { + var windowEndUtc = TruncateToSecond(this._timeProvider.GetUtcNow()); + var windowStartUtc = windowEndUtc.AddSeconds(-(BucketCount - 1)); + var buckets = new DashboardThroughputBucket[BucketCount]; + + lock (this._gate) + { + for (var offset = 0; offset < BucketCount; offset++) + { + var bucketStartUtc = windowStartUtc.AddSeconds(offset); + var unixSecond = bucketStartUtc.ToUnixTimeSeconds(); + var state = this._buckets[BucketIndex(unixSecond)]; + buckets[offset] = state.UnixSecond == unixSecond + ? state.ToBucket(bucketStartUtc) + : EmptyBucket(bucketStartUtc); + } + } + + return new DashboardThroughputSnapshot(windowStartUtc, windowEndUtc, BucketSize, buckets); + } + + public void Dispose() + { + if (this._disposed) + { + return; + } + + this._disposed = true; + this._stream.JobEventPublished -= this.RecordAsync; + } + + internal void Record(JobEvent jobEvent) + { + if (!TryClassify(jobEvent, out var metric)) + { + return; + } + + var eventBucketUtc = TruncateToSecond(jobEvent.OccurredAtUtc); + var nowUtc = TruncateToSecond(this._timeProvider.GetUtcNow()); + if (eventBucketUtc <= nowUtc.Subtract(Window) || eventBucketUtc > nowUtc) + { + return; + } + + var unixSecond = eventBucketUtc.ToUnixTimeSeconds(); + var index = BucketIndex(unixSecond); + lock (this._gate) + { + ref var state = ref this._buckets[index]; + if (state.UnixSecond != unixSecond) + { + state = new DashboardThroughputBucketState(unixSecond); + } + + state.Increment(metric); + } + } + + private ValueTask RecordAsync( + JobEvent jobEvent, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + this.Record(jobEvent); + return ValueTask.CompletedTask; + } + + private static bool TryClassify( + JobEvent jobEvent, + out DashboardThroughputMetric metric) + { + metric = default; + switch (jobEvent.Kind) + { + case JobEventKind.AttemptStarted: + metric = DashboardThroughputMetric.Started; + return true; + case JobEventKind.AttemptCompleted: + metric = DashboardThroughputMetric.Succeeded; + return true; + case JobEventKind.AttemptFailed: + metric = DashboardThroughputMetric.FailedAttempt; + return true; + case JobEventKind.Lifecycle: + return TryClassifyLifecycle(jobEvent.Message, out metric); + default: + return false; + } + } + + private static bool TryClassifyLifecycle( + string? message, + out DashboardThroughputMetric metric) + { + metric = default; + if (string.Equals(message, "Queued", StringComparison.Ordinal)) + { + metric = DashboardThroughputMetric.Queued; + return true; + } + + if (string.Equals(message, "Failed", StringComparison.Ordinal) + || message?.StartsWith("Failed;", StringComparison.Ordinal) == true) + { + metric = DashboardThroughputMetric.Failed; + return true; + } + + if (string.Equals(message, "Canceled", StringComparison.Ordinal)) + { + metric = DashboardThroughputMetric.Canceled; + return true; + } + + return false; + } + + private static DateTimeOffset TruncateToSecond(DateTimeOffset timestamp) + { + var utc = timestamp.ToUniversalTime(); + return new DateTimeOffset(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, utc.Second, TimeSpan.Zero); + } + + private static int BucketIndex(long unixSecond) + => (int)(((unixSecond % BucketCount) + BucketCount) % BucketCount); + + private static DashboardThroughputBucket EmptyBucket(DateTimeOffset startedAtUtc) + => new(startedAtUtc, 0, 0, 0, 0, 0, 0); + + private enum DashboardThroughputMetric + { + Queued, + Started, + Succeeded, + Failed, + Canceled, + FailedAttempt, + } + + private struct DashboardThroughputBucketState(long unixSecond) + { + public long UnixSecond { get; private set; } = unixSecond; + + public int QueuedCount { get; private set; } + + public int StartedCount { get; private set; } + + public int SucceededCount { get; private set; } + + public int FailedCount { get; private set; } + + public int CanceledCount { get; private set; } + + public int FailedAttemptCount { get; private set; } + + public void Increment(DashboardThroughputMetric metric) + { + switch (metric) + { + case DashboardThroughputMetric.Queued: + this.QueuedCount++; + break; + case DashboardThroughputMetric.Started: + this.StartedCount++; + break; + case DashboardThroughputMetric.Succeeded: + this.SucceededCount++; + break; + case DashboardThroughputMetric.Failed: + this.FailedCount++; + break; + case DashboardThroughputMetric.Canceled: + this.CanceledCount++; + break; + case DashboardThroughputMetric.FailedAttempt: + this.FailedAttemptCount++; + break; + default: + throw new ArgumentOutOfRangeException(nameof(metric), metric, "Throughput metric is not supported."); + } + } + + public readonly DashboardThroughputBucket ToBucket(DateTimeOffset startedAtUtc) + => new( + startedAtUtc, + this.QueuedCount, + this.StartedCount, + this.SucceededCount, + this.FailedCount, + this.CanceledCount, + this.FailedAttemptCount); + } +} diff --git a/src/Sheddueller.Dashboard/Internal/IDashboardThroughputReader.cs b/src/Sheddueller.Dashboard/Internal/IDashboardThroughputReader.cs new file mode 100644 index 0000000..b759432 --- /dev/null +++ b/src/Sheddueller.Dashboard/Internal/IDashboardThroughputReader.cs @@ -0,0 +1,6 @@ +namespace Sheddueller.Dashboard.Internal; + +internal interface IDashboardThroughputReader +{ + DashboardThroughputSnapshot GetSnapshot(); +} diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs index 6e81e33..6c2f1a2 100644 --- a/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardOptions.cs @@ -17,4 +17,10 @@ public sealed class ShedduellerDashboardOptions /// Gets or sets how long job events are retained after their owning job reaches a terminal state. /// public TimeSpan EventRetention { get; set; } = TimeSpan.FromDays(7); + + /// + /// Gets or sets the dashboard display order for tag names. + /// Tags with names listed here are shown first in the configured order; all other tags keep their persisted order. + /// + public IReadOnlyList TagDisplayOrder { get; set; } = []; } diff --git a/src/Sheddueller.Dashboard/ShedduellerDashboardServiceCollectionExtensions.cs b/src/Sheddueller.Dashboard/ShedduellerDashboardServiceCollectionExtensions.cs index 03087f0..416d550 100644 --- a/src/Sheddueller.Dashboard/ShedduellerDashboardServiceCollectionExtensions.cs +++ b/src/Sheddueller.Dashboard/ShedduellerDashboardServiceCollectionExtensions.cs @@ -30,14 +30,19 @@ public static IServiceCollection AddShedduellerDashboard( services.AddOptions() .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.") .ValidateOnStart(); services.AddRazorComponents() .AddInteractiveServerComponents(); services.AddSignalR(); + services.TryAddSingleton(TimeProvider.System); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.Replace(ServiceDescriptor.Singleton()); TryAddStartupValidationHostedService(services); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/src/Sheddueller.Dashboard/wwwroot/vendor/prism/prism-dark.css b/src/Sheddueller.Dashboard/wwwroot/vendor/prism/prism-dark.css new file mode 100644 index 0000000..bfd7997 --- /dev/null +++ b/src/Sheddueller.Dashboard/wwwroot/vendor/prism/prism-dark.css @@ -0,0 +1,3 @@ +/* PrismJS 1.30.0 +https://prismjs.com/download#themes=prism-okaidia&languages=clike+csharp */ +code[class*=language-],pre[class*=language-]{color:#f8f8f2;background:0 0;text-shadow:0 1px rgba(0,0,0,.3);font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre)>code[class*=language-],pre[class*=language-]{background:#272822}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#8292a2}.token.punctuation{color:#f8f8f2}.token.namespace{opacity:.7}.token.constant,.token.deleted,.token.property,.token.symbol,.token.tag{color:#f92672}.token.boolean,.token.number{color:#ae81ff}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#a6e22e}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f8f8f2}.token.atrule,.token.attr-value,.token.class-name,.token.function{color:#e6db74}.token.keyword{color:#66d9ef}.token.important,.token.regex{color:#fd971f}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help} diff --git a/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js new file mode 100644 index 0000000..4f60b54 --- /dev/null +++ b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js @@ -0,0 +1,198 @@ +(function () { + const charts = new WeakMap(); + + function read(model, key) { + const lower = key.charAt(0).toLowerCase() + key.slice(1); + return model[lower] ?? model[key]; + } + + function destroy(element) { + const existing = charts.get(element); + if (!existing) { + return; + } + + if (existing.kind === "uplot") { + existing.chart.destroy(); + } + + charts.delete(element); + element.replaceChildren(); + } + + function render(element, model) { + if (!element || !model) { + return; + } + + if (window.uPlot) { + renderUPlot(element, model); + return; + } + + renderCanvas(element, model); + } + + function renderUPlot(element, model) { + const data = [ + read(model, "Timestamps"), + read(model, "Queued"), + read(model, "Started"), + read(model, "Succeeded"), + read(model, "Failed"), + read(model, "Canceled"), + read(model, "FailedAttempts"), + ]; + const existing = charts.get(element); + if (existing?.kind === "uplot") { + existing.chart.setData(data); + existing.chart.setSize(chartSize(element)); + return; + } + + destroy(element); + const options = { + ...chartSize(element), + cursor: { + drag: { x: false, y: false }, + }, + legend: { + show: false, + }, + scales: { + x: { time: true }, + y: { range: [0, null] }, + }, + axes: [ + { + stroke: axisColor(), + grid: { stroke: gridColor() }, + }, + { + stroke: axisColor(), + grid: { stroke: gridColor() }, + values: (_, values) => values.map((value) => value.toLocaleString()), + }, + ], + series: [ + {}, + series("Queued", "#515f74"), + series("Started", "#1d4ed8"), + series("Succeeded", "#166534"), + series("Failed", "#ba1a1a"), + series("Canceled", "#8a5a00"), + series("Failed Attempts", "#7c3aed"), + ], + }; + + const chart = new window.uPlot(options, data, element); + charts.set(element, { kind: "uplot", chart }); + } + + function renderCanvas(element, model) { + let existing = charts.get(element); + if (existing?.kind !== "canvas") { + destroy(element); + const canvas = document.createElement("canvas"); + canvas.className = "throughput-chart__canvas"; + element.appendChild(canvas); + existing = { kind: "canvas", canvas }; + charts.set(element, existing); + } + + drawCanvas(existing.canvas, model); + } + + function drawCanvas(canvas, model) { + const rect = canvas.parentElement.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = Math.max(320, Math.floor(rect.width)); + const height = Math.max(220, Math.floor(rect.height)); + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const context = canvas.getContext("2d"); + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.clearRect(0, 0, width, height); + + const padding = { top: 14, right: 18, bottom: 24, left: 40 }; + const plotWidth = width - padding.left - padding.right; + const plotHeight = height - padding.top - padding.bottom; + const datasets = [ + { values: read(model, "Queued"), color: "#515f74" }, + { values: read(model, "Started"), color: "#1d4ed8" }, + { values: read(model, "Succeeded"), color: "#166534" }, + { values: read(model, "Failed"), color: "#ba1a1a" }, + { values: read(model, "Canceled"), color: "#8a5a00" }, + { values: read(model, "FailedAttempts"), color: "#7c3aed" }, + ]; + const pointCount = datasets[0].values.length; + const maxValue = Math.max(1, ...datasets.flatMap((dataset) => dataset.values)); + + context.strokeStyle = gridColor(); + context.lineWidth = 1; + for (let index = 0; index <= 4; index++) { + const y = padding.top + (plotHeight / 4) * index; + context.beginPath(); + context.moveTo(padding.left, y); + context.lineTo(width - padding.right, y); + context.stroke(); + } + + context.fillStyle = axisColor(); + context.font = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + context.textAlign = "right"; + context.textBaseline = "middle"; + for (let index = 0; index <= 4; index++) { + const value = Math.round(maxValue - (maxValue / 4) * index); + const y = padding.top + (plotHeight / 4) * index; + context.fillText(value.toLocaleString(), padding.left - 8, y); + } + + for (const dataset of datasets) { + context.strokeStyle = dataset.color; + context.lineWidth = 1.5; + context.beginPath(); + for (let index = 0; index < pointCount; index++) { + const x = padding.left + (plotWidth * index) / Math.max(1, pointCount - 1); + const y = padding.top + plotHeight - (plotHeight * dataset.values[index]) / maxValue; + if (index === 0) { + context.moveTo(x, y); + } else { + context.lineTo(x, y); + } + } + + context.stroke(); + } + } + + function series(label, stroke) { + return { + label, + stroke, + width: 1.5, + points: { show: false }, + }; + } + + function chartSize(element) { + const rect = element.getBoundingClientRect(); + return { + width: Math.max(320, Math.floor(rect.width)), + height: Math.max(220, Math.floor(rect.height)), + }; + } + + function axisColor() { + return getComputedStyle(document.documentElement).getPropertyValue("--sd-on-surface-variant").trim() || "#45464d"; + } + + function gridColor() { + return getComputedStyle(document.documentElement).getPropertyValue("--sd-outline-variant").trim() || "#c6c6cd"; + } + + window.shedduellerThroughputChart = { render, destroy }; +})(); diff --git a/src/Sheddueller.Postgres/Internal/Operations/EnqueueJobOperation.cs b/src/Sheddueller.Postgres/Internal/Operations/EnqueueJobOperation.cs index c4f95a5..69f4ad1 100644 --- a/src/Sheddueller.Postgres/Internal/Operations/EnqueueJobOperation.cs +++ b/src/Sheddueller.Postgres/Internal/Operations/EnqueueJobOperation.cs @@ -106,6 +106,7 @@ group_key text not null create temp table sheddueller_enqueue_tags ( job_id uuid not null, + ordinal integer not null, name text not null, value text not null ) on commit drop; @@ -237,6 +238,7 @@ private static async ValueTask CopyTagsAsync( """ copy sheddueller_enqueue_tags ( job_id, + ordinal, name, value) from stdin (format binary) @@ -246,10 +248,13 @@ from stdin (format binary) foreach (var request in requests) { - foreach (var tag in SubmissionValidator.NormalizeJobTags(request.Tags)) + var tags = SubmissionValidator.NormalizeJobTags(request.Tags); + for (var i = 0; i < tags.Count; i++) { + var tag = tags[i]; await importer.StartRowAsync(cancellationToken).ConfigureAwait(false); await importer.WriteAsync(request.JobId, NpgsqlDbType.Uuid, cancellationToken).ConfigureAwait(false); + await importer.WriteAsync(i, NpgsqlDbType.Integer, cancellationToken).ConfigureAwait(false); await importer.WriteAsync(tag.Name, NpgsqlDbType.Text, cancellationToken).ConfigureAwait(false); await importer.WriteAsync(tag.Value, NpgsqlDbType.Text, cancellationToken).ConfigureAwait(false); } @@ -518,8 +523,8 @@ private static async ValueTask InsertStagedTagsAsync( connection, transaction, $""" - insert into {context.Names.JobTags} (job_id, name, value) - select distinct tag.job_id, tag.name, tag.value + insert into {context.Names.JobTags} (job_id, ordinal, name, value) + select tag.job_id, tag.ordinal, tag.name, tag.value from sheddueller_enqueue_tags tag join sheddueller_enqueue_results result on result.job_id = tag.job_id and result.was_enqueued = true diff --git a/src/Sheddueller.Postgres/Internal/Operations/PostgresJobInspectionOperation.cs b/src/Sheddueller.Postgres/Internal/Operations/PostgresJobInspectionOperation.cs index d59cac8..75dc554 100644 --- a/src/Sheddueller.Postgres/Internal/Operations/PostgresJobInspectionOperation.cs +++ b/src/Sheddueller.Postgres/Internal/Operations/PostgresJobInspectionOperation.cs @@ -12,6 +12,7 @@ namespace Sheddueller.Postgres.Internal.Operations; internal static class PostgresJobInspectionOperation { + private const string OperationalContinuationTokenPrefix = "op:"; private const int SerializedArgumentDisplayByteLimit = 64 * 1024; private static readonly JsonSerializerOptions SerializedArgumentDisplayJsonOptions = new() @@ -49,7 +50,6 @@ public static async ValueTask SearchJobsAsync( ValidateJobQuery(query); await using var connection = await context.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); - var afterSequence = DecodeContinuationToken(query.ContinuationToken); void configureFilters(NpgsqlCommand command, List conditions) { if (query.States is { Count: > 0 } states) @@ -93,35 +93,84 @@ await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false), await using var command = connection.CreateCommand(); var conditions = new List(); configureFilters(command, conditions); - if (afterSequence is { } sequence) - { - conditions.Add("job.enqueue_sequence < @after_sequence"); - command.Parameters.AddWithValue("after_sequence", sequence); - } - command.Parameters.AddWithValue("limit", query.PageSize + 1); var whereClause = CreateWhereClause(conditions); - command.CommandText = - $""" - {JobSelectSql(context)} - {whereClause} - order by job.enqueue_sequence desc - limit @limit; - """; + string? continuationToken; - var rows = await ReadRowsAsync(command, cancellationToken).ConfigureAwait(false); - var pageRows = rows.Take(query.PageSize).ToArray(); - var jobs = new List(pageRows.Length); - foreach (var row in pageRows) + switch (query.Sort) { - jobs.Add(await CreateSummaryAsync(context, connection, row, cancellationToken).ConfigureAwait(false)); + case JobInspectionSort.NewestFirst: + { + if (DecodeNewestContinuationToken(query.ContinuationToken) is { } sequence) + { + conditions.Add("job.enqueue_sequence < @after_sequence"); + command.Parameters.AddWithValue("after_sequence", sequence); + whereClause = CreateWhereClause(conditions); + } + + command.CommandText = + $""" + {JobSelectSql(context)} + {whereClause} + order by job.enqueue_sequence desc + limit @limit; + """; + + var newestRows = await ReadRowsAsync(command, cancellationToken).ConfigureAwait(false); + var newestPageRows = newestRows.Take(query.PageSize).ToArray(); + var newestJobs = await CreateSummariesAsync(context, connection, newestPageRows, cancellationToken).ConfigureAwait(false); + continuationToken = newestRows.Count > query.PageSize + ? newestPageRows[^1].EnqueueSequence.ToString(CultureInfo.InvariantCulture) + : null; + + return new JobInspectionPage(newestJobs, continuationToken, totalCount); + } + + case JobInspectionSort.Operational: + { + var offset = DecodeOperationalContinuationToken(query.ContinuationToken); + command.Parameters.AddWithValue("offset", offset); + command.CommandText = + $""" + with filtered_jobs as ( + {JobSelectSql(context)} + {whereClause} + ) + select * + from filtered_jobs job + order by {OperationalOrderBySql(context)} + offset @offset + limit @limit; + """; + + var operationalRows = await ReadRowsAsync(command, cancellationToken).ConfigureAwait(false); + var operationalPageRows = operationalRows.Take(query.PageSize).ToArray(); + var operationalJobs = await CreateSummariesAsync(context, connection, operationalPageRows, cancellationToken).ConfigureAwait(false); + continuationToken = operationalRows.Count > query.PageSize + ? EncodeOperationalContinuationToken(offset + query.PageSize) + : null; + + return new JobInspectionPage(operationalJobs, continuationToken, totalCount); + } + + default: + throw new ArgumentOutOfRangeException(nameof(query), query.Sort, "Job inspection sort is not supported."); } + } - var continuationToken = rows.Count > query.PageSize - ? pageRows[^1].EnqueueSequence.ToString(CultureInfo.InvariantCulture) - : null; + private static async ValueTask> CreateSummariesAsync( + PostgresOperationContext context, + NpgsqlConnection connection, + IReadOnlyList rows, + CancellationToken cancellationToken) + { + var jobs = new List(rows.Count); + foreach (var row in rows) + { + jobs.Add(await CreateSummaryAsync(context, connection, row, cancellationToken).ConfigureAwait(false)); + } - return new JobInspectionPage(jobs, continuationToken, totalCount); + return jobs; } public static async ValueTask GetJobAsync( @@ -679,6 +728,37 @@ private static string JobSelectSql(PostgresOperationContext context) from {context.Names.Jobs} job """; + private static string OperationalOrderBySql(PostgresOperationContext context) + => $""" + case job.state + when 'Claimed' then 0 + when 'Queued' then 1 + else 2 + end asc, + case when job.state = 'Claimed' then job.claimed_at_utc end desc nulls last, + case when job.state = 'Claimed' then job.enqueue_sequence end desc, + case + when job.state <> 'Queued' then 0 + when job.not_before_utc is not null + and job.not_before_utc > transaction_timestamp() + and job.failed_at_utc is not null then 2 + when job.not_before_utc is not null + and job.not_before_utc > transaction_timestamp() then 3 + when exists ( + select 1 + from {context.Names.JobConcurrencyGroups} job_group + left join {context.Names.ConcurrencyGroups} concurrency_group on concurrency_group.group_key = job_group.group_key + where job_group.job_id = job.job_id + and coalesce(concurrency_group.in_use_count, 0) >= coalesce(concurrency_group.configured_limit, 1) + ) then 1 + else 0 + end asc, + case when job.state = 'Queued' then job.priority end desc, + case when job.state = 'Queued' then job.enqueue_sequence end asc, + case when job.state in ('Completed', 'Failed', 'Canceled') then coalesce(job.completed_at_utc, job.failed_at_utc, job.canceled_at_utc) end desc nulls last, + job.enqueue_sequence desc + """; + private static void ValidateJobQuery(JobInspectionQuery query) { if (query.PageSize <= 0) @@ -697,7 +777,17 @@ private static void ValidateJobQuery(JobInspectionQuery query) } } - _ = DecodeContinuationToken(query.ContinuationToken); + if (!Enum.IsDefined(query.Sort)) + { + throw new ArgumentOutOfRangeException(nameof(query), query.Sort, "Job inspection sort is not supported."); + } + + _ = query.Sort switch + { + JobInspectionSort.NewestFirst => DecodeNewestContinuationToken(query.ContinuationToken) ?? 0, + JobInspectionSort.Operational => DecodeOperationalContinuationToken(query.ContinuationToken), + _ => throw new ArgumentOutOfRangeException(nameof(query), query.Sort, "Job inspection sort is not supported."), + }; } private static string? NormalizeContains(string? value) @@ -720,7 +810,29 @@ private static void ValidateEventReadOptions(JobEventReadOptions options) } } - private static long? DecodeContinuationToken(string? continuationToken) + private static string EncodeOperationalContinuationToken(long offset) + => string.Concat(OperationalContinuationTokenPrefix, offset.ToString(CultureInfo.InvariantCulture)); + + private static long DecodeOperationalContinuationToken(string? continuationToken) + { + if (continuationToken is null) + { + return 0; + } + + var offsetToken = continuationToken.StartsWith(OperationalContinuationTokenPrefix, StringComparison.Ordinal) + ? continuationToken[OperationalContinuationTokenPrefix.Length..] + : continuationToken; + + if (!long.TryParse(offsetToken, NumberStyles.None, CultureInfo.InvariantCulture, out var offset) || offset < 0) + { + throw new ArgumentException("Job inspection continuation token is invalid.", nameof(continuationToken)); + } + + return offset; + } + + private static long? DecodeNewestContinuationToken(string? continuationToken) { if (continuationToken is null) { diff --git a/src/Sheddueller.Postgres/Internal/Operations/PostgresJobTags.cs b/src/Sheddueller.Postgres/Internal/Operations/PostgresJobTags.cs index 45ca897..d6176dc 100644 --- a/src/Sheddueller.Postgres/Internal/Operations/PostgresJobTags.cs +++ b/src/Sheddueller.Postgres/Internal/Operations/PostgresJobTags.cs @@ -21,19 +21,21 @@ await PostgresOperationContext.ExecuteCountAsync( cancellationToken) .ConfigureAwait(false); - foreach (var tag in normalized) + for (var i = 0; i < normalized.Count; i++) { + var tag = normalized[i]; await PostgresOperationContext.ExecuteCountAsync( connection, transaction, $""" - insert into {context.Names.JobTags} (job_id, name, value) - values (@job_id, @name, @value) + insert into {context.Names.JobTags} (job_id, ordinal, name, value) + values (@job_id, @ordinal, @name, @value) on conflict (job_id, name, value) do nothing; """, command => { command.Parameters.AddWithValue("job_id", jobId); + command.Parameters.AddWithValue("ordinal", i); command.Parameters.AddWithValue("name", tag.Name); command.Parameters.AddWithValue("value", tag.Value); }, @@ -54,7 +56,7 @@ public static async ValueTask> ReadJobTagsAsync( select name, value from {context.Names.JobTags} where job_id = @job_id - order by name asc, value asc; + order by ordinal asc, name asc, value asc; """; command.Parameters.AddWithValue("job_id", jobId); diff --git a/src/Sheddueller.Postgres/Internal/Operations/PostgresScheduleTags.cs b/src/Sheddueller.Postgres/Internal/Operations/PostgresScheduleTags.cs index 534e264..86b0a95 100644 --- a/src/Sheddueller.Postgres/Internal/Operations/PostgresScheduleTags.cs +++ b/src/Sheddueller.Postgres/Internal/Operations/PostgresScheduleTags.cs @@ -21,19 +21,21 @@ await PostgresOperationContext.ExecuteCountAsync( cancellationToken) .ConfigureAwait(false); - foreach (var tag in normalized) + for (var i = 0; i < normalized.Count; i++) { + var tag = normalized[i]; await PostgresOperationContext.ExecuteCountAsync( connection, transaction, $""" - insert into {context.Names.ScheduleTags} (schedule_key, name, value) - values (@schedule_key, @name, @value) + insert into {context.Names.ScheduleTags} (schedule_key, ordinal, name, value) + values (@schedule_key, @ordinal, @name, @value) on conflict (schedule_key, name, value) do nothing; """, command => { command.Parameters.AddWithValue("schedule_key", scheduleKey); + command.Parameters.AddWithValue("ordinal", i); command.Parameters.AddWithValue("name", tag.Name); command.Parameters.AddWithValue("value", tag.Value); }, @@ -56,7 +58,7 @@ public static async ValueTask> ReadScheduleTagsAsync( select name, value from {context.Names.ScheduleTags} where schedule_key = @schedule_key - order by name asc, value asc; + order by ordinal asc, name asc, value asc; """; command.Parameters.AddWithValue("schedule_key", scheduleKey); diff --git a/src/Sheddueller.Postgres/Internal/PostgresMigrator.cs b/src/Sheddueller.Postgres/Internal/PostgresMigrator.cs index 2705e7b..d973c39 100644 --- a/src/Sheddueller.Postgres/Internal/PostgresMigrator.cs +++ b/src/Sheddueller.Postgres/Internal/PostgresMigrator.cs @@ -138,13 +138,39 @@ add constraint jobs_invocation_target_kind_check check (invocation_target_kind i create table if not exists {this._names.JobTags} ( job_id uuid not null references {this._names.Jobs}(job_id) on delete cascade, + ordinal integer not null, name text not null, value text not null, primary key (job_id, name, value), + constraint job_tags_ordinal_check check (ordinal >= 0), constraint job_tags_name_check check (length(name) > 0), constraint job_tags_value_check check (length(value) > 0) ); + alter table {this._names.JobTags} + add column if not exists ordinal integer; + + with ordered_tags as ( + select + job_id, + name, + value, + row_number() over (partition by job_id order by name asc, value asc) - 1 as ordinal + from {this._names.JobTags} + where ordinal is null + ) + update {this._names.JobTags} tag + set ordinal = ordered_tags.ordinal + from ordered_tags + where tag.job_id = ordered_tags.job_id + and tag.name = ordered_tags.name + and tag.value = ordered_tags.value; + + alter table {this._names.JobTags} + alter column ordinal set not null, + drop constraint if exists job_tags_ordinal_check, + add constraint job_tags_ordinal_check check (ordinal >= 0); + create table if not exists {this._names.ConcurrencyGroups} ( group_key text primary key, configured_limit integer null, @@ -197,13 +223,39 @@ primary key (schedule_key, group_key) create table if not exists {this._names.ScheduleTags} ( schedule_key text not null references {this._names.RecurringSchedules}(schedule_key) on delete cascade, + ordinal integer not null, name text not null, value text not null, primary key (schedule_key, name, value), + constraint schedule_tags_ordinal_check check (ordinal >= 0), constraint schedule_tags_name_check check (length(name) > 0), constraint schedule_tags_value_check check (length(value) > 0) ); + alter table {this._names.ScheduleTags} + add column if not exists ordinal integer; + + with ordered_tags as ( + select + schedule_key, + name, + value, + row_number() over (partition by schedule_key order by name asc, value asc) - 1 as ordinal + from {this._names.ScheduleTags} + where ordinal is null + ) + update {this._names.ScheduleTags} tag + set ordinal = ordered_tags.ordinal + from ordered_tags + where tag.schedule_key = ordered_tags.schedule_key + and tag.name = ordered_tags.name + and tag.value = ordered_tags.value; + + alter table {this._names.ScheduleTags} + alter column ordinal set not null, + drop constraint if exists schedule_tags_ordinal_check, + add constraint schedule_tags_ordinal_check check (ordinal >= 0); + create table if not exists {this._names.JobEvents} ( job_id uuid not null references {this._names.Jobs}(job_id) on delete cascade, event_sequence bigint not null, @@ -290,9 +342,15 @@ constraint worker_nodes_current_execution_count_check check (current_execution_c create index if not exists idx_job_tags_name_value_job_id on {this._names.JobTags} (name, value, job_id); + create unique index if not exists idx_job_tags_job_id_ordinal + on {this._names.JobTags} (job_id, ordinal); + create index if not exists idx_schedule_tags_name_value_schedule_key on {this._names.ScheduleTags} (name, value, schedule_key); + create unique index if not exists idx_schedule_tags_schedule_key_ordinal + on {this._names.ScheduleTags} (schedule_key, ordinal); + create index if not exists idx_job_events_job_sequence on {this._names.JobEvents} (job_id, event_sequence); diff --git a/src/Sheddueller.Postgres/Internal/PostgresNames.cs b/src/Sheddueller.Postgres/Internal/PostgresNames.cs index fac8175..619ed63 100644 --- a/src/Sheddueller.Postgres/Internal/PostgresNames.cs +++ b/src/Sheddueller.Postgres/Internal/PostgresNames.cs @@ -4,7 +4,7 @@ namespace Sheddueller.Postgres.Internal; internal sealed class PostgresNames { - public const int ExpectedSchemaVersion = 7; + public const int ExpectedSchemaVersion = 8; public const string WakeupChannel = "sheddueller_wakeup"; public const string JobEventChannel = "sheddueller_job_event"; diff --git a/src/Sheddueller/Inspection/Jobs/JobInspectionQuery.cs b/src/Sheddueller/Inspection/Jobs/JobInspectionQuery.cs index 45216c8..e63d37f 100644 --- a/src/Sheddueller/Inspection/Jobs/JobInspectionQuery.cs +++ b/src/Sheddueller/Inspection/Jobs/JobInspectionQuery.cs @@ -11,4 +11,5 @@ public sealed record JobInspectionQuery( string? TagContains = null, string? ConcurrencyGroupContains = null, int PageSize = 100, - string? ContinuationToken = null); + string? ContinuationToken = null, + JobInspectionSort Sort = JobInspectionSort.Operational); diff --git a/src/Sheddueller/Inspection/Jobs/JobInspectionSort.cs b/src/Sheddueller/Inspection/Jobs/JobInspectionSort.cs new file mode 100644 index 0000000..a4cbda2 --- /dev/null +++ b/src/Sheddueller/Inspection/Jobs/JobInspectionSort.cs @@ -0,0 +1,17 @@ +namespace Sheddueller.Inspection.Jobs; + +/// +/// Job inspection result ordering. +/// +public enum JobInspectionSort +{ + /// + /// Orders active work first, with queued jobs in claim order. + /// + Operational, + + /// + /// Orders newest enqueued jobs first. + /// + NewestFirst, +} diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index df0a2b3..4ecc30d 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -11,6 +11,7 @@ namespace Sheddueller.Dashboard.Tests; using Microsoft.Extensions.DependencyInjection; using Sheddueller; +using Sheddueller.Dashboard.Internal; using Sheddueller.Inspection.ConcurrencyGroups; using Sheddueller.Inspection.Jobs; using Sheddueller.Inspection.Metrics; @@ -57,6 +58,7 @@ public async Task MapShedduellerDashboard_DefaultOptions_DoesNotPrerenderRouteCo html.ShouldContain("base href=\"http://localhost/sheddueller/\""); html.ShouldContain("_framework/blazor.web.js"); + html.ShouldContain("_content/Sheddueller.Dashboard/vendor/prism/prism-dark.css\" rel=\"stylesheet\" media=\"(prefers-color-scheme: dark)\""); html.ShouldNotContain("Operational Control"); } @@ -105,6 +107,9 @@ public async Task Jobs_KnownData_RendersSearchResults() html.ShouldContain("Filter by handler substring"); html.ShouldContain("Filter by tag substring"); html.ShouldContain("Filter by concurrency group substring"); + html.ShouldContain("Sort jobs"); + html.ShouldContain("Operational Order"); + html.ShouldContain("Newest First"); html.ShouldContain("Clear Filters"); html.ShouldNotContain("Expand query filters"); html.ShouldNotContain("Execute Query"); @@ -122,6 +127,52 @@ public async Task Jobs_KnownData_RendersSearchResults() html.ShouldContain("href=\"jobs?group=tenant-acme\""); AssertShellRefresh(html); html.ShouldContain($"href=\"jobs/{StubJobInspectionReader.JobId:D}\""); + html.ShouldContain("Queue"); + html.ShouldContain("Running"); + html.ShouldContain("#1"); + } + + [Fact] + public async Task Jobs_ClaimedAndQueuedFilters_RendersClaimedBeforeQueued() + { + await using var app = await CreateStartedDashboardAsync(); + var html = await GetOkHtmlAsync(app, "/sheddueller/jobs?state=Queued&state=Claimed"); + + AssertStatusCheckbox(html, "Queued", isChecked: true); + AssertStatusCheckbox(html, "Claimed", isChecked: true); + AssertAppearsBefore(html, $"href=\"jobs/{StubJobInspectionReader.JobId:D}\"", $"href=\"jobs/{StubJobInspectionReader.QueuedJobId:D}\""); + html.ShouldContain("Running"); + html.ShouldContain("#1"); + } + + [Fact] + public async Task Jobs_SortQuery_RendersSelectedSortAndPreservesQuickLinks() + { + await using var app = await CreateStartedDashboardAsync(); + var html = await GetOkHtmlAsync(app, "/sheddueller/jobs?state=Queued&sort=NewestFirst"); + + AssertSelectValue(html, "Sort jobs", "NewestFirst"); + html.ShouldContain("href=\"jobs?state=Claimed&sort=NewestFirst\""); + html.ShouldContain("href=\"jobs?state=Queued&handler=StubService.Run&sort=NewestFirst\""); + } + + [Fact] + public async Task Jobs_TagDisplayOrder_ConfiguredNamesLeadCompactChips() + { + await using var app = await CreateStartedDashboardAsync(configureDashboard: options => + { + options.TagDisplayOrder = ["domain", "tenant"]; + }); + var html = await GetOkHtmlAsync(app, "/sheddueller/jobs"); + + AssertAppearsBefore(html, "href=\"jobs?tag=domain%3Apayments\"", "href=\"jobs?tag=tenant%3Aacme\""); + html.ShouldContain("jobs-chip--overflow"); + html.ShouldContain("aria-haspopup=\"true\""); + html.ShouldContain("+2"); + html.ShouldContain("sd-chip-overflow__panel"); + html.ShouldContain("href=\"jobs?tag=schedule%3Adaily_rollup\""); + html.ShouldContain("href=\"jobs?tag=source%3Astub\""); + html.ShouldContain("aria-label=\"Additional tags: domain:payments, tenant:acme, schedule:daily_rollup, source:stub\""); } [Fact] @@ -277,6 +328,9 @@ public async Task Metrics_KnownData_RendersRollingHealth() html.ShouldContain("Queue Depth"); html.ShouldContain("Throughput Rate"); html.ShouldContain("Schedule Fire Lag"); + html.ShouldContain("Live Throughput"); + html.ShouldContain("1s Buckets / 1h Window"); + html.ShouldContain("Failed Attempts"); html.ShouldContain("Queue Latency"); html.ShouldContain("Execution Duration"); html.ShouldContain("Window Comparison"); @@ -309,6 +363,8 @@ public async Task JobDetail_KnownJob_RendersDetailAndDefaultLogFilter() html.ShouldContain("href=\"jobs?tag=tenant%3Aacme\""); html.ShouldContain("href=\"jobs?group=tenant-acme\""); html.ShouldContain("Invocation"); + html.ShouldContain("pre.job-detail-invocation-call[class*=\"language-\"]"); + html.ShouldContain("box-shadow: none;"); html.ShouldContain("StubService.Run("); html.ShouldContain("permanent-failure"); html.ShouldContain("Job.Resolve"); @@ -358,6 +414,17 @@ public async Task JobDetail_CancellationRequestedJob_RendersDisabledCancelAction disabled: true); } + [Fact] + public async Task JobDetail_CompletedJob_RendersRunTime() + { + await using var app = await CreateStartedDashboardAsync(); + var html = await GetOkHtmlAsync(app, $"/sheddueller/jobs/{StubJobInspectionReader.CompletedJobId:D}"); + + html.ShouldContain("Run Time:"); + html.ShouldContain("4 m"); + html.ShouldContain("2026-04-20 12:09:00 UTC"); + } + [Theory] [InlineData("a1543a1d-b7e0-4b43-b7ed-62249dc117be", "This job completed at 2026-04-20 12:09:00 UTC and cannot be canceled.")] [InlineData("b4f8131d-a097-4410-8f47-8d37387e1357", "This job failed at 2026-04-20 12:04:00 UTC and cannot be canceled.")] @@ -398,7 +465,8 @@ public async Task JobDetail_MissingJob_RendersNotFoundWithDisabledCancelAction() private static async Task CreateStartedDashboardAsync( bool prerender = true, - bool mapWithWebApplication = true) + bool mapWithWebApplication = true, + Action? configureDashboard = null) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -410,7 +478,12 @@ private static async Task CreateStartedDashboardAsync( builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddShedduellerDashboard(options => options.Prerender = prerender); + builder.Services.AddSingleton(); + builder.Services.AddShedduellerDashboard(options => + { + options.Prerender = prerender; + configureDashboard?.Invoke(options); + }); var app = builder.Build(); if (mapWithWebApplication) @@ -482,6 +555,38 @@ private static void AssertStatusCheckbox( label.ShouldNotContain("checked"); } + private static void AssertSelectValue( + string html, + string ariaLabel, + string value) + { + var markerIndex = html.IndexOf(string.Create(CultureInfo.InvariantCulture, $"aria-label=\"{ariaLabel}\""), StringComparison.Ordinal); + markerIndex.ShouldBeGreaterThanOrEqualTo(0); + + var startIndex = html.LastIndexOf("", markerIndex, StringComparison.Ordinal); + + startIndex.ShouldBeGreaterThanOrEqualTo(0); + endIndex.ShouldBeGreaterThan(startIndex); + + var select = html[startIndex..(endIndex + "".Length)]; + var selectOpen = select[..select.IndexOf('>', StringComparison.Ordinal)]; + if (selectOpen.Contains(string.Create(CultureInfo.InvariantCulture, $"value=\"{value}\""), StringComparison.Ordinal)) + { + return; + } + + var optionMarker = string.Create(CultureInfo.InvariantCulture, $"value=\"{value}\""); + var optionMarkerIndex = select.IndexOf(optionMarker, StringComparison.Ordinal); + optionMarkerIndex.ShouldBeGreaterThanOrEqualTo(0); + var optionStartIndex = select.LastIndexOf("", optionMarkerIndex, StringComparison.Ordinal); + optionStartIndex.ShouldBeGreaterThanOrEqualTo(0); + optionEndIndex.ShouldBeGreaterThan(optionStartIndex); + var option = select[optionStartIndex..(optionEndIndex + "".Length)]; + option.ShouldContain("selected"); + } + private static void AssertCancelButton( string html, string expectedText, @@ -582,6 +687,8 @@ private sealed class StubJobInspectionReader : IJobInspectionReader [ new JobTag("tenant", "acme"), new JobTag("schedule", "daily_rollup"), + new JobTag("domain", "payments"), + new JobTag("source", "stub"), ], ConcurrencyGroupKeys: ["tenant-acme", "daily-rollup"], SourceScheduleKey: "daily_rollup", @@ -712,7 +819,7 @@ public ValueTask GetOverviewAsync( public ValueTask SearchJobsAsync( JobInspectionQuery query, CancellationToken cancellationToken = default) - => ValueTask.FromResult(new JobInspectionPage([Job], ContinuationToken: null, TotalCount: 1)); + => ValueTask.FromResult(new JobInspectionPage([Job, QueuedJob], ContinuationToken: null, TotalCount: 2)); public ValueTask GetJobAsync( Guid jobId, @@ -1158,4 +1265,43 @@ public ValueTask GetMetricsAsync( CancellationToken cancellationToken = default) => ValueTask.FromResult(Snapshot); } + + private sealed class StubDashboardThroughputReader : IDashboardThroughputReader + { + private static readonly DateTimeOffset WindowEndUtc = new(2026, 4, 20, 12, 30, 0, TimeSpan.Zero); + + private static readonly DashboardThroughputSnapshot Snapshot = new( + WindowEndUtc.AddSeconds(-2), + WindowEndUtc, + TimeSpan.FromSeconds(1), + [ + new DashboardThroughputBucket( + WindowEndUtc.AddSeconds(-2), + QueuedCount: 0, + StartedCount: 0, + SucceededCount: 0, + FailedCount: 0, + CanceledCount: 0, + FailedAttemptCount: 0), + new DashboardThroughputBucket( + WindowEndUtc.AddSeconds(-1), + QueuedCount: 12, + StartedCount: 10, + SucceededCount: 9, + FailedCount: 1, + CanceledCount: 0, + FailedAttemptCount: 2), + new DashboardThroughputBucket( + WindowEndUtc, + QueuedCount: 14, + StartedCount: 11, + SucceededCount: 8, + FailedCount: 0, + CanceledCount: 1, + FailedAttemptCount: 3), + ]); + + public DashboardThroughputSnapshot GetSnapshot() + => Snapshot; + } } diff --git a/test/Sheddueller.Dashboard.Tests/DashboardFilterTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardFilterTests.cs index 763f0b6..80bf300 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardFilterTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardFilterTests.cs @@ -4,6 +4,7 @@ namespace Sheddueller.Dashboard.Tests; using Sheddueller.Dashboard.Internal; using Sheddueller.Inspection.ConcurrencyGroups; +using Sheddueller.Inspection.Jobs; using Sheddueller.Inspection.Nodes; using Sheddueller.Inspection.Schedules; using Sheddueller.Storage; @@ -22,6 +23,7 @@ public void JobFilters_ToQuery_NormalizesLiveFilters() filters.SetHandlerContains(" InvoiceHandler ").ShouldBeTrue(); filters.SetTagContains(" tenant:acme ").ShouldBeTrue(); filters.SetConcurrencyGroupContains(" acme ").ShouldBeTrue(); + filters.SetSort(JobInspectionSort.NewestFirst).ShouldBeTrue(); var query = filters.ToQuery(pageSize: 25, continuationToken: "next"); @@ -31,6 +33,7 @@ public void JobFilters_ToQuery_NormalizesLiveFilters() query.ConcurrencyGroupContains.ShouldBe("acme"); query.PageSize.ShouldBe(25); query.ContinuationToken.ShouldBe("next"); + query.Sort.ShouldBe(JobInspectionSort.NewestFirst); filters.HasAppliedFilters.ShouldBeTrue(); filters.IsStateSelected(JobState.Claimed).ShouldBeTrue(); } @@ -51,6 +54,7 @@ public void JobFilters_Clear_ResetsFiltersAndIgnoresWhitespace() query.HandlerContains.ShouldBeNull(); query.TagContains.ShouldBeNull(); query.ConcurrencyGroupContains.ShouldBeNull(); + query.Sort.ShouldBe(JobInspectionSort.Operational); filters.HasAppliedFilters.ShouldBeFalse(); filters.IsStateSelected(JobState.Failed).ShouldBeFalse(); } @@ -59,7 +63,7 @@ public void JobFilters_Clear_ResetsFiltersAndIgnoresWhitespace() public void JobFilterQuery_ParseQuery_NormalizesUrlFilters() { var filters = DashboardJobFilterQuery.ParseQuery( - "?state=claimed&state=invalid&state=Queued&handler=Ignored.Run&handler=%20BillingWorker.Run%20&tag=tenant%3Aacme&tag=%20schedule%3Adaily%20&group=%20tenant-acme%20"); + "?state=claimed&state=invalid&state=Queued&handler=Ignored.Run&handler=%20BillingWorker.Run%20&tag=tenant%3Aacme&tag=%20schedule%3Adaily%20&group=%20tenant-acme%20&sort=newestfirst"); var query = filters.ToQuery(pageSize: 25, continuationToken: null); @@ -71,6 +75,7 @@ public void JobFilterQuery_ParseQuery_NormalizesUrlFilters() query.HandlerContains.ShouldBe("BillingWorker.Run"); query.TagContains.ShouldBe("schedule:daily"); query.ConcurrencyGroupContains.ShouldBe("tenant-acme"); + query.Sort.ShouldBe(JobInspectionSort.NewestFirst); } [Fact] @@ -82,13 +87,14 @@ public void JobFilterQuery_LinkGeneration_ReplacesClickedDimensionAndPreservesOt filters.SetHandlerContains("OldWorker.Run"); filters.SetTagContains("tenant:acme"); filters.SetConcurrencyGroupContains("tenant acme"); + filters.SetSort(JobInspectionSort.NewestFirst); DashboardJobFilterQuery.WithStateHref(filters, JobState.Claimed) - .ShouldBe("jobs?state=Claimed&handler=OldWorker.Run&tag=tenant%3Aacme&group=tenant%20acme"); + .ShouldBe("jobs?state=Claimed&handler=OldWorker.Run&tag=tenant%3Aacme&group=tenant%20acme&sort=NewestFirst"); DashboardJobFilterQuery.WithHandlerHref(filters, "BillingWorker.Run") - .ShouldBe("jobs?state=Queued&state=Failed&handler=BillingWorker.Run&tag=tenant%3Aacme&group=tenant%20acme"); + .ShouldBe("jobs?state=Queued&state=Failed&handler=BillingWorker.Run&tag=tenant%3Aacme&group=tenant%20acme&sort=NewestFirst"); DashboardJobFilterQuery.WithTagHref(filters, "schedule:daily") - .ShouldBe("jobs?state=Queued&state=Failed&handler=OldWorker.Run&tag=schedule%3Adaily&group=tenant%20acme"); + .ShouldBe("jobs?state=Queued&state=Failed&handler=OldWorker.Run&tag=schedule%3Adaily&group=tenant%20acme&sort=NewestFirst"); DashboardJobFilterQuery.TagHref("tenant:acme west").ShouldBe("jobs?tag=tenant%3Aacme%20west"); } diff --git a/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs index ec26d59..0824ec1 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardFormatTests.cs @@ -48,6 +48,39 @@ public void Relative_FutureValueWhenFutureDisabled_RendersJustNow() DashboardFormat.Relative(now.AddSeconds(30), now, allowFuture: false).ShouldBe("just now"); } + [Fact] + public void RunTime_TerminalJob_FormatsClaimToTerminalDuration() + { + var claimedAtUtc = DateTimeOffset.Parse("2026-04-20T12:05:00Z", CultureInfo.InvariantCulture); + var job = CreateJob() with + { + State = JobState.Completed, + CompletedAtUtc = claimedAtUtc.AddSeconds(72), + }; + + DashboardFormat.RunTime(CreateDetail(job, claimedAtUtc)).ShouldBe("1.2 m"); + } + + [Fact] + public void RunTime_MissingOrInvalidTimestamps_ReturnsEmptyValue() + { + var claimedAtUtc = DateTimeOffset.Parse("2026-04-20T12:05:00Z", CultureInfo.InvariantCulture); + var terminalJob = CreateJob() with + { + State = JobState.Completed, + CompletedAtUtc = claimedAtUtc.AddSeconds(72), + }; + var activeJob = CreateJob(); + var invalidJob = terminalJob with + { + CompletedAtUtc = claimedAtUtc.AddSeconds(-1), + }; + + DashboardFormat.RunTime(CreateDetail(terminalJob, claimedAtUtc: null)).ShouldBe("-"); + DashboardFormat.RunTime(CreateDetail(activeJob, claimedAtUtc)).ShouldBe("-"); + DashboardFormat.RunTime(CreateDetail(invalidJob, claimedAtUtc)).ShouldBe("-"); + } + [Fact] public void LiveStatusText_LastUpdatedAfterDashboardClock_DoesNotRenderFutureUpdate() { @@ -72,6 +105,35 @@ public void TagsAndGroups_EmptyAndPopulatedValues_FormatsTitlesForChips() DashboardFormat.GroupKeysTitle(["tenant-acme", "daily-rollup"]).ShouldBe("tenant-acme, daily-rollup"); } + [Fact] + public void TagOrder_ConfiguredNames_PrioritizesConfiguredNamesAndPreservesOrdinalFallback() + { + var tags = new[] + { + new JobTag("source", "api"), + new JobTag("tenant", "acme"), + new JobTag("domain", "billing"), + new JobTag("job", "sync"), + }; + + DashboardTagOrder.Apply(tags, ["domain", "tenant"]) + .ShouldBe( + [ + new JobTag("domain", "billing"), + new JobTag("tenant", "acme"), + new JobTag("source", "api"), + new JobTag("job", "sync"), + ]); + } + + [Fact] + public void TagOrder_OptionsValidation_RejectsEmptyAndDuplicateNames() + { + DashboardTagOrder.IsValid(new ShedduellerDashboardOptions { TagDisplayOrder = [" tenant ", "domain"] }).ShouldBeTrue(); + DashboardTagOrder.IsValid(new ShedduellerDashboardOptions { TagDisplayOrder = ["tenant", " tenant "] }).ShouldBeFalse(); + DashboardTagOrder.IsValid(new ShedduellerDashboardOptions { TagDisplayOrder = ["tenant", " "] }).ShouldBeFalse(); + } + [Fact] public void JobEvent_LogAndTimelineEvents_FormatsOperationalText() { @@ -150,6 +212,16 @@ private static JobInspectionSummary CreateJob( FailedAtUtc: null, CanceledAtUtc: null); + private static JobInspectionDetail CreateDetail( + JobInspectionSummary job, + DateTimeOffset? claimedAtUtc) + => new( + job, + claimedAtUtc, + ClaimedByNodeId: null, + LeaseExpiresAtUtc: null, + ScheduledFireAtUtc: null); + private static MetricsInspectionWindow CreateMetricsWindow() => new( TimeSpan.FromMinutes(5), diff --git a/test/Sheddueller.Dashboard.Tests/DashboardThroughputStoreTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardThroughputStoreTests.cs new file mode 100644 index 0000000..014c3d2 --- /dev/null +++ b/test/Sheddueller.Dashboard.Tests/DashboardThroughputStoreTests.cs @@ -0,0 +1,95 @@ +namespace Sheddueller.Dashboard.Tests; + +using Microsoft.Extensions.Time.Testing; + +using Sheddueller.Dashboard.Internal; +using Sheddueller.Storage; + +using Shouldly; + +public sealed class DashboardThroughputStoreTests +{ + [Fact] + public void Snapshot_NoEvents_ZeroFillsOneHourOfSecondBuckets() + { + var now = new DateTimeOffset(2026, 4, 26, 12, 0, 5, TimeSpan.Zero); + using var store = CreateStore(now); + + var snapshot = store.GetSnapshot(); + + snapshot.BucketSize.ShouldBe(TimeSpan.FromSeconds(1)); + snapshot.Buckets.Count.ShouldBe(3600); + snapshot.WindowStartUtc.ShouldBe(now.AddSeconds(-3599)); + snapshot.WindowEndUtc.ShouldBe(now); + snapshot.Buckets[0].StartedAtUtc.ShouldBe(snapshot.WindowStartUtc); + snapshot.Buckets[^1].StartedAtUtc.ShouldBe(now); + snapshot.Buckets.Sum(bucket => bucket.QueuedCount).ShouldBe(0); + } + + [Fact] + public void Record_KnownJobEvents_StoresCountsInSecondBucket() + { + var now = new DateTimeOffset(2026, 4, 26, 12, 0, 5, TimeSpan.Zero); + using var store = CreateStore(now); + + store.Record(CreateEvent(JobEventKind.Lifecycle, now, "Queued")); + store.Record(CreateEvent(JobEventKind.AttemptStarted, now)); + store.Record(CreateEvent(JobEventKind.AttemptCompleted, now)); + store.Record(CreateEvent(JobEventKind.AttemptFailed, now)); + store.Record(CreateEvent(JobEventKind.Lifecycle, now, "Failed")); + store.Record(CreateEvent(JobEventKind.Lifecycle, now, "Canceled")); + store.Record(CreateEvent(JobEventKind.Log, now, "ignored")); + + var bucket = store.GetSnapshot().Buckets.Single(bucket => bucket.StartedAtUtc == now); + bucket.QueuedCount.ShouldBe(1); + bucket.StartedCount.ShouldBe(1); + bucket.SucceededCount.ShouldBe(1); + bucket.FailedAttemptCount.ShouldBe(1); + bucket.FailedCount.ShouldBe(1); + bucket.CanceledCount.ShouldBe(1); + } + + [Fact] + public void Record_OutsideRollingWindow_IgnoresEvent() + { + var now = new DateTimeOffset(2026, 4, 26, 12, 0, 5, TimeSpan.Zero); + using var store = CreateStore(now); + + store.Record(CreateEvent(JobEventKind.Lifecycle, now.AddHours(-2), "Queued")); + store.Record(CreateEvent(JobEventKind.Lifecycle, now.AddSeconds(1), "Queued")); + + store.GetSnapshot().Buckets.Sum(bucket => bucket.QueuedCount).ShouldBe(0); + } + + [Fact] + public void Record_ReusedSlot_ResetsPreviousBucketCounts() + { + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 26, 12, 0, 0, TimeSpan.Zero)); + using var store = new DashboardThroughputStore(new DashboardLiveUpdateStream(), timeProvider); + + store.Record(CreateEvent(JobEventKind.Lifecycle, timeProvider.GetUtcNow(), "Queued")); + + timeProvider.SetUtcNow(timeProvider.GetUtcNow().AddHours(1)); + store.Record(CreateEvent(JobEventKind.AttemptStarted, timeProvider.GetUtcNow())); + + var bucket = store.GetSnapshot().Buckets.Single(bucket => bucket.StartedAtUtc == timeProvider.GetUtcNow()); + bucket.QueuedCount.ShouldBe(0); + bucket.StartedCount.ShouldBe(1); + } + + private static DashboardThroughputStore CreateStore(DateTimeOffset now) + => new(new DashboardLiveUpdateStream(), new FakeTimeProvider(now)); + + private static JobEvent CreateEvent( + JobEventKind kind, + DateTimeOffset occurredAtUtc, + string? message = null) + => new( + Guid.NewGuid(), + Guid.NewGuid(), + EventSequence: 1, + kind, + occurredAtUtc, + AttemptNumber: 1, + Message: message); +} diff --git a/test/Sheddueller.Dashboard.Tests/Sheddueller.Dashboard.Tests.csproj b/test/Sheddueller.Dashboard.Tests/Sheddueller.Dashboard.Tests.csproj index bc871f6..b15aa9c 100644 --- a/test/Sheddueller.Dashboard.Tests/Sheddueller.Dashboard.Tests.csproj +++ b/test/Sheddueller.Dashboard.Tests/Sheddueller.Dashboard.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/test/Sheddueller.Postgres.Tests/Operations/CreateOrUpdateRecurringScheduleOperationTests.cs b/test/Sheddueller.Postgres.Tests/Operations/CreateOrUpdateRecurringScheduleOperationTests.cs index 2594c85..bf323d8 100644 --- a/test/Sheddueller.Postgres.Tests/Operations/CreateOrUpdateRecurringScheduleOperationTests.cs +++ b/test/Sheddueller.Postgres.Tests/Operations/CreateOrUpdateRecurringScheduleOperationTests.cs @@ -15,6 +15,7 @@ public async Task CreateOrUpdateRecurringSchedule_Create_PersistsDefinitionAndGr "schedule-a", priority: 5, groupKeys: ["beta", "alpha", "alpha"], + tags: [new JobTag("tenant", "acme"), new JobTag("domain", "payments")], retryPolicy: new RetryPolicy(3, RetryBackoffKind.Exponential, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(8)), overlapMode: RecurringOverlapMode.Allow)); @@ -35,6 +36,7 @@ public async Task CreateOrUpdateRecurringSchedule_Create_PersistsDefinitionAndGr schedule.RetryMaxDelayMs.ShouldBe(8000); schedule.NextFireAtUtc.ShouldNotBeNull(); (await context.ReadScheduleGroupKeysAsync("schedule-a")).ShouldBe(["alpha", "beta"]); + (await context.ReadScheduleTagsAsync("schedule-a")).ShouldBe([new JobTag("tenant", "acme"), new JobTag("domain", "payments")]); } [Fact] @@ -70,13 +72,22 @@ public async Task CreateOrUpdateRecurringSchedule_Unchanged_ReturnsUnchanged() public async Task CreateOrUpdateRecurringSchedule_UpdateActiveSchedule_ReplacesDefinitionAndGroups() { await using var context = await PostgresTestContext.CreateMigratedAsync(fixture); - await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.CreateSchedule("schedule-a", priority: 1, groupKeys: ["old"])); + await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.CreateSchedule( + "schedule-a", + priority: 1, + groupKeys: ["old"], + tags: [new JobTag("tenant", "old")])); - var result = await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.CreateSchedule("schedule-a", priority: 9, groupKeys: ["new"])); + var result = await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.CreateSchedule( + "schedule-a", + priority: 9, + groupKeys: ["new"], + tags: [new JobTag("source", "api"), new JobTag("tenant", "new")])); result.ShouldBe(RecurringScheduleUpsertResult.Updated); (await context.ReadScheduleAsync("schedule-a")).Priority.ShouldBe(9); (await context.ReadScheduleGroupKeysAsync("schedule-a")).ShouldBe(["new"]); + (await context.ReadScheduleTagsAsync("schedule-a")).ShouldBe([new JobTag("source", "api"), new JobTag("tenant", "new")]); } [Fact] diff --git a/test/Sheddueller.Postgres.Tests/Operations/EnqueueJobOperationTests.cs b/test/Sheddueller.Postgres.Tests/Operations/EnqueueJobOperationTests.cs index e566d48..7a024df 100644 --- a/test/Sheddueller.Postgres.Tests/Operations/EnqueueJobOperationTests.cs +++ b/test/Sheddueller.Postgres.Tests/Operations/EnqueueJobOperationTests.cs @@ -106,7 +106,7 @@ public async Task EnqueueMany_PersistsJobsGroupsTagsAndEventsAtomically() first, priority: 7, groupKeys: ["beta", "alpha", "alpha"], - tags: [new JobTag("tenant", "acme")]), + tags: [new JobTag("tenant", "acme"), new JobTag("domain", "payments"), new JobTag("tenant", "acme")]), PostgresTestData.CreateRequest( second, priority: 2, @@ -125,7 +125,7 @@ public async Task EnqueueMany_PersistsJobsGroupsTagsAndEventsAtomically() secondJob.EnqueueSequence.ShouldBe(results[1].EnqueueSequence); (await context.ReadJobGroupKeysAsync(first)).ShouldBe(["alpha", "beta"]); (await context.ReadJobGroupKeysAsync(second)).ShouldBe(["gamma"]); - (await context.ReadJobTagsAsync(first)).ShouldBe([new JobTag("tenant", "acme")]); + (await context.ReadJobTagsAsync(first)).ShouldBe([new JobTag("tenant", "acme"), new JobTag("domain", "payments")]); (await context.ReadJobTagsAsync(second)).ShouldBe([new JobTag("kind", "secondary")]); (await context.CountJobEventsAsync(first)).ShouldBe(1); (await context.CountJobEventsAsync(second)).ShouldBe(1); diff --git a/test/Sheddueller.Postgres.Tests/Operations/TriggerRecurringScheduleOperationTests.cs b/test/Sheddueller.Postgres.Tests/Operations/TriggerRecurringScheduleOperationTests.cs index be71397..72970ea 100644 --- a/test/Sheddueller.Postgres.Tests/Operations/TriggerRecurringScheduleOperationTests.cs +++ b/test/Sheddueller.Postgres.Tests/Operations/TriggerRecurringScheduleOperationTests.cs @@ -33,7 +33,7 @@ await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.Create priority: 9, groupKeys: ["shared"], retryPolicy, - tags: [new JobTag("tenant", "acme")], + tags: [new JobTag("tenant", "acme"), new JobTag("domain", "payments")], invocationTargetKind: JobInvocationTargetKind.Static, methodParameterBindings: [ @@ -59,7 +59,7 @@ await context.Store.CreateOrUpdateRecurringScheduleAsync(PostgresTestData.Create job.InvocationTargetKind.ShouldBe(nameof(JobInvocationTargetKind.Static)); job.MethodParameterBindings.ShouldBe([nameof(JobMethodParameterBindingKind.CancellationToken)]); (await context.ReadJobGroupKeysAsync(jobId)).ShouldBe(["shared"]); - (await context.ReadJobTagsAsync(jobId)).ShouldBe([new JobTag("tenant", "acme")]); + (await context.ReadJobTagsAsync(jobId)).ShouldBe([new JobTag("tenant", "acme"), new JobTag("domain", "payments")]); var inspectionReader = context.Store.ShouldBeAssignableTo(); var inspected = await inspectionReader.GetJobAsync(jobId); diff --git a/test/Sheddueller.Postgres.Tests/PostgresMigrationTests.cs b/test/Sheddueller.Postgres.Tests/PostgresMigrationTests.cs index 1ae9c62..12e0c9d 100644 --- a/test/Sheddueller.Postgres.Tests/PostgresMigrationTests.cs +++ b/test/Sheddueller.Postgres.Tests/PostgresMigrationTests.cs @@ -98,6 +98,41 @@ from pg_indexes indexDefinition.ShouldContain("state = 'Queued'"); } + [Fact] + public async Task Migration_FreshSchema_CreatesTagOrdinalColumnsAndIndexes() + { + await using var context = await PostgresTestContext.CreateMigratedAsync(fixture); + + await AssertOrdinalColumnAsync(context, "job_tags"); + await AssertOrdinalColumnAsync(context, "schedule_tags"); + + (await ScalarAsync( + context, + """ + select exists ( + select 1 + from pg_indexes + where schemaname = @schema_name + and indexname = 'idx_job_tags_job_id_ordinal' + and indexdef like '%UNIQUE INDEX%' + ); + """)) + .ShouldBeTrue(); + + (await ScalarAsync( + context, + """ + select exists ( + select 1 + from pg_indexes + where schemaname = @schema_name + and indexname = 'idx_schedule_tags_schedule_key_ordinal' + and indexdef like '%UNIQUE INDEX%' + ); + """)) + .ShouldBeTrue(); + } + private static async ValueTask ScalarAsync( PostgresTestContext context, string commandText) @@ -108,4 +143,36 @@ private static async ValueTask ScalarAsync( result.ShouldNotBeNull(); return result.ShouldBeOfType(); } + + private static async Task AssertOrdinalColumnAsync( + PostgresTestContext context, + string tableName) + => (await ScalarAsync( + context, + """ + select exists ( + select 1 + from information_schema.columns + where table_schema = @schema_name + and table_name = @table_name + and column_name = 'ordinal' + and data_type = 'integer' + and is_nullable = 'NO' + ); + """, + command => command.Parameters.AddWithValue("table_name", tableName))) + .ShouldBeTrue(); + + private static async ValueTask ScalarAsync( + PostgresTestContext context, + string commandText, + Action configure) + { + await using var command = context.DataSource.CreateCommand(commandText); + command.Parameters.AddWithValue("schema_name", context.SchemaName); + configure(command); + var result = await command.ExecuteScalarAsync(); + result.ShouldNotBeNull(); + return result.ShouldBeOfType(); + } } diff --git a/test/Sheddueller.Postgres.Tests/PostgresTestContext.cs b/test/Sheddueller.Postgres.Tests/PostgresTestContext.cs index 163b1da..a180ce2 100644 --- a/test/Sheddueller.Postgres.Tests/PostgresTestContext.cs +++ b/test/Sheddueller.Postgres.Tests/PostgresTestContext.cs @@ -232,7 +232,7 @@ public async ValueTask> ReadJobTagsAsync(Guid jobId) select name, value from {this.Table("job_tags")} where job_id = @id - order by name asc, value asc; + order by ordinal asc, name asc, value asc; """); command.Parameters.AddWithValue("id", jobId); @@ -246,6 +246,27 @@ public async ValueTask> ReadJobTagsAsync(Guid jobId) return tags; } + public async ValueTask> ReadScheduleTagsAsync(string scheduleKey) + { + await using var command = this.DataSource.CreateCommand( + $""" + select name, value + from {this.Table("schedule_tags")} + where schedule_key = @id + order by ordinal asc, name asc, value asc; + """); + command.Parameters.AddWithValue("id", scheduleKey); + + var tags = new List(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + tags.Add(new JobTag(reader.GetString(0), reader.GetString(1))); + } + + return tags; + } + public async ValueTask CountJobEventsAsync(Guid jobId) { var result = await this.ExecuteScalarAsync( diff --git a/test/Sheddueller.ProviderContracts/InspectionContractTests.cs b/test/Sheddueller.ProviderContracts/InspectionContractTests.cs index b3b6038..59b8483 100644 --- a/test/Sheddueller.ProviderContracts/InspectionContractTests.cs +++ b/test/Sheddueller.ProviderContracts/InspectionContractTests.cs @@ -36,7 +36,7 @@ await context.Store.EnqueueAsync(CreateRequest( var page = await context.Reader.SearchJobsAsync(new JobInspectionQuery(TagContains: "LISTING_ID:23")); page.Jobs.Select(job => job.JobId).ShouldBe([tagged]); - page.Jobs[0].Tags.ShouldBe([new JobTag("listing_id", "23"), new JobTag("tenant", "acme")], ignoreOrder: true); + page.Jobs[0].Tags.ShouldBe([new JobTag("listing_id", "23"), new JobTag("tenant", "acme")]); } [Fact] @@ -222,20 +222,70 @@ await context.Store.MarkCompletedAsync(new CompleteJobRequest( page.TotalCount.ShouldBe(2L); } + [Fact] + public async Task SearchJobs_DefaultSort_OrdersClaimedThenQueuedByClaimOrder() + { + await using var context = await this.CreateContextAsync(); + var firstLow = Guid.NewGuid(); + var secondLow = Guid.NewGuid(); + var high = Guid.NewGuid(); + var running = Guid.NewGuid(); + + await context.Store.EnqueueAsync(CreateRequest(firstLow, priority: 0)); + await context.Store.EnqueueAsync(CreateRequest(secondLow, priority: 0)); + await context.Store.EnqueueAsync(CreateRequest(high, priority: 10)); + await context.Store.EnqueueAsync(CreateRequest(running, priority: 100)); + (await ClaimAsync(context.Store)).JobId.ShouldBe(running); + + var page = await context.Reader.SearchJobsAsync(new JobInspectionQuery( + States: [JobState.Queued, JobState.Claimed])); + + page.Jobs.Select(job => job.JobId).ShouldBe([running, high, firstLow, secondLow]); + page.Jobs.Select(job => job.QueuePosition?.Kind).ShouldBe([ + JobQueuePositionKind.Claimed, + JobQueuePositionKind.Claimable, + JobQueuePositionKind.Claimable, + JobQueuePositionKind.Claimable, + ]); + page.Jobs.Select(job => job.QueuePosition?.Position).ShouldBe([null, 1L, 2L, 3L]); + } + + [Fact] + public async Task SearchJobs_NewestFirstSort_OrdersByNewestEnqueueSequence() + { + await using var context = await this.CreateContextAsync(); + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + var third = Guid.NewGuid(); + + await context.Store.EnqueueAsync(CreateRequest(first)); + await context.Store.EnqueueAsync(CreateRequest(second)); + await context.Store.EnqueueAsync(CreateRequest(third)); + + var page = await context.Reader.SearchJobsAsync(new JobInspectionQuery( + Sort: JobInspectionSort.NewestFirst)); + + page.Jobs.Select(job => job.JobId).ShouldBe([third, second, first]); + } + [Fact] public async Task SearchJobs_PagedQuery_ReturnsTotalMatchingCount() { await using var context = await this.CreateContextAsync(); + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + var third = Guid.NewGuid(); - await context.Store.EnqueueAsync(CreateRequest(Guid.NewGuid(), tags: [new JobTag("tenant", "acme")])); - await context.Store.EnqueueAsync(CreateRequest(Guid.NewGuid(), tags: [new JobTag("tenant", "acme")])); - await context.Store.EnqueueAsync(CreateRequest(Guid.NewGuid(), tags: [new JobTag("tenant", "acme")])); + await context.Store.EnqueueAsync(CreateRequest(first, tags: [new JobTag("tenant", "acme")])); + await context.Store.EnqueueAsync(CreateRequest(second, tags: [new JobTag("tenant", "acme")])); + await context.Store.EnqueueAsync(CreateRequest(third, tags: [new JobTag("tenant", "acme")])); await context.Store.EnqueueAsync(CreateRequest(Guid.NewGuid(), tags: [new JobTag("tenant", "contoso")])); var firstPage = await context.Reader.SearchJobsAsync(new JobInspectionQuery( TagContains: "tenant:acme", PageSize: 2)); + firstPage.Jobs.Select(job => job.JobId).ShouldBe([first, second]); firstPage.Jobs.Count.ShouldBe(2); firstPage.TotalCount.ShouldBe(3L); firstPage.ContinuationToken.ShouldNotBeNull(); @@ -245,6 +295,7 @@ public async Task SearchJobs_PagedQuery_ReturnsTotalMatchingCount() PageSize: 2, ContinuationToken: firstPage.ContinuationToken)); + secondPage.Jobs.Select(job => job.JobId).ShouldBe([third]); secondPage.Jobs.Count.ShouldBe(1); secondPage.TotalCount.ShouldBe(3L); secondPage.ContinuationToken.ShouldBeNull(); @@ -318,10 +369,11 @@ public async Task ScheduleViews_TagsAndPauseState_RoundTrip() await context.Store.CreateOrUpdateRecurringScheduleAsync(CreateSchedule( "hourly-cleanup", - tags: [new JobTag("area", "billing")])); + tags: [new JobTag("tenant", "acme"), new JobTag("area", "billing")])); var page = await context.ScheduleReader.SearchSchedulesAsync(new ScheduleInspectionQuery(Tag: new JobTag("area", "billing"))); page.Schedules.ShouldHaveSingleItem().ScheduleKey.ShouldBe("hourly-cleanup"); + page.Schedules[0].Tags.ShouldBe([new JobTag("tenant", "acme"), new JobTag("area", "billing")]); page.TotalCount.ShouldBe(1L); var scheduleKeyPage = await context.ScheduleReader.SearchSchedulesAsync(new ScheduleInspectionQuery(ScheduleKey: "CLEAN"));