From ae08d931603c68c03050ae6c082c0864f79ac09a Mon Sep 17 00:00:00 2001 From: Brian Tyler Date: Sun, 26 Apr 2026 22:32:31 +0100 Subject: [PATCH 1/4] feat: enhance throughput chart with series filtering and dynamic rendering --- pack-local.sh | 37 ++++ .../Components/DashboardApp.razor | 15 +- .../Components/Pages/Metrics.razor | 55 +++++- .../Components/ThroughputChart.razor | 138 ++++++++----- .../vendor/sheddueller-throughput-chart.js | 183 +++++++++++++++--- .../DashboardEndpointTests.cs | 2 + 6 files changed, 339 insertions(+), 91 deletions(-) create mode 100755 pack-local.sh diff --git a/pack-local.sh b/pack-local.sh new file mode 100755 index 0000000..cfd7806 --- /dev/null +++ b/pack-local.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +feed="${SHEDDUELLER_LOCAL_FEED:-"$HOME/.nuget/local-sheddueller"}" +version="${1:-${SHEDDUELLER_LOCAL_VERSION:-"0.1.0-local.$(date -u +%Y%m%d).$(date -u +%s)"}}" +configuration="${CONFIGURATION:-Release}" + +projects=( + "src/Sheddueller/Sheddueller.csproj" + "src/Sheddueller.Worker/Sheddueller.Worker.csproj" + "src/Sheddueller.Postgres/Sheddueller.Postgres.csproj" + "src/Sheddueller.Dashboard/Sheddueller.Dashboard.csproj" + "src/Sheddueller.Testing/Sheddueller.Testing.csproj" +) + +mkdir -p "$feed" + +{ + printf 'Packing Sheddueller local packages\n' + printf ' Version: %s\n' "$version" + printf ' Feed: %s\n' "$feed" + printf ' Configuration: %s\n' "$configuration" +} >&2 + +cd "$repo_root" + +for project in "${projects[@]}"; do + printf 'Packing %s\n' "$project" >&2 + dotnet pack "$project" \ + --configuration "$configuration" \ + --output "$feed" \ + -p:Version="$version" \ + -p:PackageVersion="$version" >&2 +done + +printf '%s\n' "$version" diff --git a/src/Sheddueller.Dashboard/Components/DashboardApp.razor b/src/Sheddueller.Dashboard/Components/DashboardApp.razor index f14ce24..34ff77b 100644 --- a/src/Sheddueller.Dashboard/Components/DashboardApp.razor +++ b/src/Sheddueller.Dashboard/Components/DashboardApp.razor @@ -21,7 +21,7 @@ - + @@ -31,4 +31,17 @@ => DashboardOptions.Value.Prerender ? InteractiveServer : new InteractiveServerRenderMode(prerender: false); + + private static string DashboardAssetVersion + { + get + { + var assembly = typeof(ShedduellerDashboardOptions).Assembly; + var attribute = (System.Reflection.AssemblyInformationalVersionAttribute?)Attribute.GetCustomAttribute( + assembly, + typeof(System.Reflection.AssemblyInformationalVersionAttribute)); + + return Uri.EscapeDataString(attribute?.InformationalVersion ?? assembly.GetName().Version?.ToString() ?? "dev"); + } + } } diff --git a/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor b/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor index 87b485f..ce43c55 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor @@ -493,21 +493,20 @@ } .metrics-rate-stack { - flex-wrap: wrap; - gap: 8px; + display: grid; + min-height: 36px; + align-content: start; + gap: 4px; color: var(--sd-on-surface-variant); font-size: 12px; line-height: 16px; } .metrics-rate-stack span { - padding-right: 8px; - border-right: 1px solid var(--sd-outline-variant); - } - - .metrics-rate-stack span:last-child { - padding-right: 0; - border-right: 0; + display: grid; + grid-template-columns: 52px max-content; + gap: 6px; + white-space: nowrap; } .metrics-node-health { @@ -707,6 +706,24 @@ height: 220px; } + .throughput-chart__clip-layer { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + } + + .throughput-chart__clip-marker { + position: absolute; + width: 7px; + height: 7px; + border: 1px solid var(--sd-surface-lowest); + border-radius: 999px; + box-shadow: 0 0 0 1px var(--sd-background); + pointer-events: auto; + transform: translate(-50%, 0); + } + .throughput-chart__empty, .throughput-chart--empty { position: absolute; @@ -740,10 +757,26 @@ background: var(--sd-surface); padding: 6px 8px; color: var(--sd-on-surface-variant); + cursor: pointer; + font: inherit; font-size: 12px; line-height: 16px; } + .throughput-chart-legend__item:hover { + border-color: var(--sd-outline); + background: var(--sd-surface-high); + } + + .throughput-chart-legend__item:focus-visible { + outline: 2px solid var(--sd-primary); + outline-offset: 2px; + } + + .throughput-chart-legend__item--muted { + opacity: 0.55; + } + .throughput-chart-legend__item>span { width: 8px; height: 8px; @@ -752,6 +785,10 @@ background: var(--sd-outline); } + .throughput-chart-legend__item.throughput-chart-legend__item--muted>span { + background: var(--sd-outline); + } + .throughput-chart-legend__item em { font-style: normal; } diff --git a/src/Sheddueller.Dashboard/Components/ThroughputChart.razor b/src/Sheddueller.Dashboard/Components/ThroughputChart.razor index 876c1a9..7140f72 100644 --- a/src/Sheddueller.Dashboard/Components/ThroughputChart.razor +++ b/src/Sheddueller.Dashboard/Components/ThroughputChart.razor @@ -22,41 +22,33 @@ else } -
- - - 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) - +
+ @foreach (var series in SeriesDefinitions) + { + + }
} @code { + private static readonly ThroughputSeriesDefinition[] SeriesDefinitions = + [ + new("queued", "Queued", "throughput-chart-legend__item--queued", "#515f74", static bucket => bucket.QueuedCount), + new("started", "Started", "throughput-chart-legend__item--started", "#1d4ed8", static bucket => bucket.StartedCount), + new("succeeded", "Succeeded", "throughput-chart-legend__item--succeeded", "#166534", static bucket => bucket.SucceededCount), + new("failed", "Failed", "throughput-chart-legend__item--failed", "#ba1a1a", static bucket => bucket.FailedCount), + new("canceled", "Canceled", "throughput-chart-legend__item--canceled", "#8a5a00", static bucket => bucket.CanceledCount), + new("failedAttempts", "Failed Attempts", "throughput-chart-legend__item--attempts", "#7c3aed", static bucket => bucket.FailedAttemptCount), + ]; + + private readonly HashSet _selectedSeriesKeys = new(StringComparer.Ordinal); + private ElementReference _chartElement; private bool _chartRendered; @@ -73,7 +65,7 @@ else return; } - await Js.InvokeVoidAsync("shedduellerThroughputChart.render", this._chartElement, CreateModel(this.ThroughputSnapshot)); + await Js.InvokeVoidAsync("shedduellerThroughputChart.render", this._chartElement, CreateModel(this.ThroughputSnapshot, this._selectedSeriesKeys)); this._chartRendered = true; } @@ -104,6 +96,33 @@ else || bucket.CanceledCount > 0 || bucket.FailedAttemptCount > 0); + private bool IsSeriesActive(string key) + => this._selectedSeriesKeys.Count == 0 || this._selectedSeriesKeys.Contains(key); + + private void ToggleSeries(string key) + { + if (this._selectedSeriesKeys.Count == 0) + { + this._selectedSeriesKeys.Add(key); + + return; + } + + if (this._selectedSeriesKeys.Contains(key)) + { + this._selectedSeriesKeys.Remove(key); + + return; + } + + this._selectedSeriesKeys.Add(key); + } + + private string LegendItemClass(ThroughputSeriesDefinition series) + => this.IsSeriesActive(series.Key) + ? $"throughput-chart-legend__item {series.CssClass}" + : $"throughput-chart-legend__item {series.CssClass} throughput-chart-legend__item--muted"; + private static DashboardThroughputBucket RecentTotals(DashboardThroughputSnapshot snapshot) { var oldestIncludedUtc = snapshot.WindowEndUtc.AddMinutes(-1); @@ -123,30 +142,49 @@ else 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 static ThroughputChartModel CreateModel( + DashboardThroughputSnapshot snapshot, + IReadOnlySet selectedSeriesKeys) + { + var activeSeries = selectedSeriesKeys.Count == 0 + ? SeriesDefinitions + : SeriesDefinitions + .Where(series => selectedSeriesKeys.Contains(series.Key)) + .ToArray(); + + return new ThroughputChartModel( + (int)snapshot.BucketSize.TotalSeconds, + snapshot.WindowStartUtc.ToUnixTimeSeconds(), + snapshot.WindowEndUtc.ToUnixTimeSeconds(), + snapshot.Buckets.Select(static bucket => bucket.StartedAtUtc.ToUnixTimeSeconds()).ToArray(), + activeSeries + .Select(series => new ThroughputChartSeriesModel( + series.Key, + series.Label, + series.Color, + snapshot.Buckets.Select(series.Value).ToArray())) + .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); + IReadOnlyList Series); + + private sealed record ThroughputChartSeriesModel( + string Key, + string Label, + string Color, + int[] Values); + + private sealed record ThroughputSeriesDefinition( + string Key, + string Label, + string CssClass, + string Color, + Func Value); private sealed record DashboardThroughputTotals( int QueuedCount = 0, diff --git a/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js index 4f60b54..8b55913 100644 --- a/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js +++ b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js @@ -34,19 +34,18 @@ } 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 timestamps = read(model, "Timestamps") ?? []; + const modelSeries = chartSeries(model); + const seriesKeys = modelSeries.map((series) => series.key).join("|"); + const data = [timestamps, ...modelSeries.map((series) => series.values)]; + const scale = scaleInfo(timestamps, modelSeries); + const timeScale = timeScaleRange(model, timestamps); const existing = charts.get(element); - if (existing?.kind === "uplot") { - existing.chart.setData(data); + if (existing?.kind === "uplot" && existing.seriesKeys === seriesKeys) { existing.chart.setSize(chartSize(element)); + existing.chart.setData(data, false); + applyScales(existing.chart, timeScale, scale.max); + renderClipMarkers(element, scale.clipped, timestamps, existing.chart); return; } @@ -60,8 +59,8 @@ show: false, }, scales: { - x: { time: true }, - y: { range: [0, null] }, + x: { time: true, range: [timeScale.min, timeScale.max] }, + y: { range: [0, scale.max] }, }, axes: [ { @@ -76,17 +75,14 @@ ], series: [ {}, - series("Queued", "#515f74"), - series("Started", "#1d4ed8"), - series("Succeeded", "#166534"), - series("Failed", "#ba1a1a"), - series("Canceled", "#8a5a00"), - series("Failed Attempts", "#7c3aed"), + ...modelSeries.map((modelSeries) => series(modelSeries.label, modelSeries.color)), ], }; const chart = new window.uPlot(options, data, element); - charts.set(element, { kind: "uplot", chart }); + applyScales(chart, timeScale, scale.max); + charts.set(element, { kind: "uplot", chart, seriesKeys }); + renderClipMarkers(element, scale.clipped, timestamps, chart); } function renderCanvas(element, model) { @@ -120,16 +116,11 @@ 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)); + const timestamps = read(model, "Timestamps") ?? []; + const datasets = chartSeries(model); + const pointCount = timestamps.length; + const scale = scaleInfo(timestamps, datasets); + const maxValue = scale.max; context.strokeStyle = gridColor(); context.lineWidth = 1; @@ -157,7 +148,8 @@ 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; + const value = Math.min(dataset.values[index] ?? 0, maxValue); + const y = padding.top + plotHeight - (plotHeight * value) / maxValue; if (index === 0) { context.moveTo(x, y); } else { @@ -167,6 +159,8 @@ context.stroke(); } + + renderClipMarkers(canvas.parentElement, scale.clipped, timestamps); } function series(label, stroke) { @@ -186,6 +180,133 @@ }; } + function applyScales(chart, timeScale, yMax) { + chart.setScale("x", timeScale); + chart.setScale("y", { min: 0, max: yMax }); + } + + function timeScaleRange(model, timestamps) { + const min = read(model, "WindowStartUnixSeconds") ?? timestamps[0] ?? 0; + const max = read(model, "WindowEndUnixSeconds") ?? timestamps[timestamps.length - 1] ?? min; + return { min, max: Math.max(min + 1, max) }; + } + + function chartSeries(model) { + return (read(model, "Series") ?? []).map((series) => ({ + key: read(series, "Key"), + label: read(series, "Label"), + color: read(series, "Color"), + values: read(series, "Values") ?? [], + })); + } + + function scaleInfo(timestamps, series) { + const values = series + .flatMap((series) => Array.from(series.values)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((left, right) => left - right); + + if (values.length === 0) { + return { max: 1, clipped: [] }; + } + + const max = values[values.length - 1]; + if (values.length === 1) { + const yMax = niceMax(max * 1.1); + return { max: yMax, clipped: clippedPoints(timestamps, series, yMax) }; + } + + const p95 = values[Math.floor((values.length - 1) * 0.95)]; + const target = max > p95 * 5 ? p95 * 1.25 : max * 1.05; + const yMax = niceMax(Math.max(1, target)); + return { max: yMax, clipped: clippedPoints(timestamps, series, yMax) }; + } + + function clippedPoints(timestamps, series, yMax) { + return series.flatMap((series) => + series.values + .map((value, index) => ({ + timestamp: timestamps[index], + value, + label: series.label, + color: series.color, + })) + .filter((point) => Number.isFinite(point.timestamp) && Number.isFinite(point.value) && point.value > yMax), + ); + } + + function renderClipMarkers(element, clipped, timestamps, chart) { + element.querySelector(".throughput-chart__clip-layer")?.remove(); + if (clipped.length === 0) { + return; + } + + const bounds = plotBounds(element, chart); + const start = timestamps[0] ?? 0; + const end = timestamps[timestamps.length - 1] ?? start; + const span = Math.max(1, end - start); + const layer = document.createElement("div"); + layer.className = "throughput-chart__clip-layer"; + layer.setAttribute("aria-hidden", "true"); + + const timestampCounts = new Map(); + for (const point of clipped) { + const stackIndex = timestampCounts.get(point.timestamp) ?? 0; + timestampCounts.set(point.timestamp, stackIndex + 1); + const marker = document.createElement("span"); + marker.className = "throughput-chart__clip-marker"; + marker.title = `${point.label}: ${point.value.toLocaleString()} clipped above visible scale`; + marker.style.backgroundColor = point.color; + marker.style.left = `${xPosition(point.timestamp, start, span, bounds, chart)}px`; + marker.style.top = `${bounds.top + 2 + stackIndex * 8}px`; + layer.appendChild(marker); + } + + element.appendChild(layer); + } + + function plotBounds(element, chart) { + if (chart?.bbox) { + const rect = element.getBoundingClientRect(); + const scale = chart.bbox.width > rect.width ? window.devicePixelRatio || 1 : 1; + return { + left: chart.bbox.left / scale, + top: chart.bbox.top / scale, + width: chart.bbox.width / scale, + height: chart.bbox.height / scale, + }; + } + + return { + left: 40, + top: 14, + width: Math.max(1, element.getBoundingClientRect().width - 58), + height: Math.max(1, element.getBoundingClientRect().height - 38), + }; + } + + function xPosition(timestamp, start, span, bounds, chart) { + if (chart?.valToPos) { + const position = chart.valToPos(timestamp, "x"); + if (Number.isFinite(position)) { + return position <= bounds.width + 1 ? bounds.left + position : position; + } + } + + return bounds.left + bounds.width * ((timestamp - start) / span); + } + + function niceMax(value) { + const exponent = Math.floor(Math.log10(value)); + const magnitude = 10 ** exponent; + const normalized = value / magnitude; + let niceNormalized; + const steps = [1, 1.25, 1.5, 2, 2.5, 5, 7.5, 10]; + niceNormalized = steps.find((step) => normalized <= step) ?? 10; + + return niceNormalized * magnitude; + } + function axisColor() { return getComputedStyle(document.documentElement).getPropertyValue("--sd-on-surface-variant").trim() || "#45464d"; } diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index 4ecc30d..529b87d 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -330,6 +330,8 @@ public async Task Metrics_KnownData_RendersRollingHealth() html.ShouldContain("Schedule Fire Lag"); html.ShouldContain("Live Throughput"); html.ShouldContain("1s Buckets / 1h Window"); + html.ShouldContain("aria-label=\"Throughput series filters\""); + html.ShouldContain("aria-pressed=\"true\""); html.ShouldContain("Failed Attempts"); html.ShouldContain("Queue Latency"); html.ShouldContain("Execution Duration"); From 24880970e5539d9ffc017e00262f1886deb1d1cb Mon Sep 17 00:00:00 2001 From: Brian Tyler Date: Sun, 26 Apr 2026 22:39:07 +0100 Subject: [PATCH 2/4] feat: update sidebar subtitle and adjust metrics page tests for consistency --- .../Components/DashboardLayout.razor | 9 ++++++--- .../Components/Pages/Metrics.razor | 10 ---------- .../DashboardEndpointTests.cs | 4 ++-- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/Sheddueller.Dashboard/Components/DashboardLayout.razor b/src/Sheddueller.Dashboard/Components/DashboardLayout.razor index 2f20cfd..02085f4 100644 --- a/src/Sheddueller.Dashboard/Components/DashboardLayout.razor +++ b/src/Sheddueller.Dashboard/Components/DashboardLayout.razor @@ -6,7 +6,7 @@
@DashboardMetricsFormat.Duration(selectedWindow.P95ScheduleFireLag)
- p95 over @DashboardMetricsFormat.WindowLabel(selectedWindow.Window)
@@ -477,14 +475,6 @@ line-height: 16px; } - .metrics-summary-card__detail>span:first-child { - width: 6px; - height: 6px; - flex: 0 0 auto; - border-radius: 999px; - background: var(--sd-outline); - } - .metrics-summary-card__detail em, .metrics-rate-stack em { color: var(--sd-on-surface); diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index 529b87d..d1f8689 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -59,7 +59,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"); + html.ShouldNotContain("Dashboard"); } [Fact] @@ -69,7 +69,7 @@ public async Task Overview_KnownData_RendersOperationalSummary() var html = await GetOkHtmlAsync(app, "/sheddueller/"); html.ShouldContain("base href=\"http://localhost/sheddueller/\""); - html.ShouldContain("Operational Control"); + html.ShouldContain("Dashboard"); html.ShouldContain("Overview"); html.ShouldContain("_framework/blazor.web.js"); html.ShouldContain("prefers-color-scheme: dark"); From f236d395ea2bb9a6e5a51413826b81c807241944 Mon Sep 17 00:00:00 2001 From: Brian Tyler Date: Sun, 26 Apr 2026 23:31:47 +0100 Subject: [PATCH 3/4] feat: enhance throughput chart with auto-refresh and series visibility features --- .../Components/DashboardApp.razor | 7 +- .../Components/Pages/Metrics.razor | 38 ++--- .../Components/ThroughputChart.razor | 148 +++++++++++++----- .../vendor/sheddueller-throughput-chart.js | 63 +++++--- .../DashboardEndpointTests.cs | 3 +- 5 files changed, 174 insertions(+), 85 deletions(-) diff --git a/src/Sheddueller.Dashboard/Components/DashboardApp.razor b/src/Sheddueller.Dashboard/Components/DashboardApp.razor index 34ff77b..6fa7c4e 100644 --- a/src/Sheddueller.Dashboard/Components/DashboardApp.razor +++ b/src/Sheddueller.Dashboard/Components/DashboardApp.razor @@ -40,8 +40,13 @@ var attribute = (System.Reflection.AssemblyInformationalVersionAttribute?)Attribute.GetCustomAttribute( assembly, typeof(System.Reflection.AssemblyInformationalVersionAttribute)); + var version = attribute?.InformationalVersion ?? assembly.GetName().Version?.ToString() ?? "dev"; + var location = assembly.Location; + var stamp = string.IsNullOrEmpty(location) || !File.Exists(location) + ? assembly.ManifestModule.ModuleVersionId.ToString("N") + : File.GetLastWriteTimeUtc(location).Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture); - return Uri.EscapeDataString(attribute?.InformationalVersion ?? assembly.GetName().Version?.ToString() ?? "dev"); + return Uri.EscapeDataString(string.Concat(version, ".", stamp)); } } } diff --git a/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor b/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor index e7a1233..3e9e5f7 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor @@ -1,7 +1,6 @@ @page "/metrics" @inherits DashboardPageComponent @inject IMetricsInspectionReader Reader -@inject IDashboardThroughputReader ThroughputReader
@@ -143,7 +142,7 @@ 1s Buckets / 1h Window
- +
@@ -686,7 +685,12 @@ overflow: hidden; } - .throughput-chart>div.uplot { + .throughput-chart__plot { + position: absolute; + inset: 0; + } + + .throughput-chart__plot>div.uplot { width: 100%; } @@ -976,7 +980,6 @@ TimeSpan.FromHours(24)]; private MetricsInspectionSnapshot? _snapshot; - private DashboardThroughputSnapshot? _throughputSnapshot; private string? _loadError; private TimeSpan _selectedWindow = TimeSpan.FromHours(1); private bool _isLoading; @@ -1012,8 +1015,10 @@ TimeSpan.FromHours(24)]; try { - var snapshot = await this.ReadPageSnapshotAsync(CancellationToken.None); - this.ApplySnapshot(snapshot.Metrics, snapshot.Throughput); + var snapshot = await Reader.GetMetricsAsync( + new MetricsInspectionQuery(DefaultMetricWindows), + CancellationToken.None); + this.ApplySnapshot(snapshot); this.LiveRefresh.MarkUpdated(); } catch (NotSupportedException) @@ -1033,25 +1038,15 @@ TimeSpan.FromHours(24)]; private async Task RefreshCurrentQueryAsync(CancellationToken cancellationToken) { - var snapshot = await this.ReadPageSnapshotAsync(cancellationToken); - await this.ApplyPageStateAsync(() => this.ApplySnapshot(snapshot.Metrics, snapshot.Throughput)); - } - - private async Task ReadPageSnapshotAsync(CancellationToken cancellationToken) - { - var metrics = await Reader.GetMetricsAsync( + var snapshot = await Reader.GetMetricsAsync( new MetricsInspectionQuery(DefaultMetricWindows), - cancellationToken) - ; - return new MetricsPageSnapshot(metrics, ThroughputReader.GetSnapshot()); + cancellationToken); + await this.ApplyPageStateAsync(() => this.ApplySnapshot(snapshot)); } - private void ApplySnapshot( - MetricsInspectionSnapshot snapshot, - DashboardThroughputSnapshot throughputSnapshot) + private void ApplySnapshot(MetricsInspectionSnapshot snapshot) { this._snapshot = snapshot; - this._throughputSnapshot = throughputSnapshot; this._loadError = null; this._metricsUnsupported = false; @@ -1064,7 +1059,4 @@ 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/ThroughputChart.razor b/src/Sheddueller.Dashboard/Components/ThroughputChart.razor index 7140f72..3a803e5 100644 --- a/src/Sheddueller.Dashboard/Components/ThroughputChart.razor +++ b/src/Sheddueller.Dashboard/Components/ThroughputChart.razor @@ -1,7 +1,8 @@ @implements IAsyncDisposable @inject IJSRuntime Js +@inject IDashboardThroughputReader ThroughputReader -@if (ThroughputSnapshot is null) +@if (_snapshot is null) {
@@ -10,10 +11,11 @@ } else { - var recent = RecentTotals(ThroughputSnapshot); + var visibleTotals = WindowTotals(_snapshot); -
- @if (!HasActivity(ThroughputSnapshot)) +
+
+ @if (!HasActivity(_snapshot)) {
@@ -30,13 +32,15 @@ else @onclick="@(() => ToggleSeries(series.Key))"> @series.Label - @DashboardFormat.Count(series.Value(recent)) + @DashboardFormat.Count(series.Value(visibleTotals)) }
} @code { + private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); + private static readonly ThroughputSeriesDefinition[] SeriesDefinitions = [ new("queued", "Queued", "throughput-chart-legend__item--queued", "#515f74", static bucket => bucket.QueuedCount), @@ -47,44 +51,108 @@ else new("failedAttempts", "Failed Attempts", "throughput-chart-legend__item--attempts", "#7c3aed", static bucket => bucket.FailedAttemptCount), ]; + private readonly CancellationTokenSource _refreshCancellation = new(); private readonly HashSet _selectedSeriesKeys = new(StringComparer.Ordinal); + private DashboardThroughputSnapshot? _snapshot; private ElementReference _chartElement; + private PeriodicTimer? _refreshTimer; + private Task? _refreshTask; private bool _chartRendered; + private bool _disposed; + + [Parameter] + public bool AutoRefreshEnabled { get; set; } = true; [Parameter] - public object? Snapshot { get; set; } + public Func? AutoRefreshEnabledProvider { get; set; } + + private bool IsAutoRefreshEnabled + => this.AutoRefreshEnabledProvider?.Invoke() ?? this.AutoRefreshEnabled; - private DashboardThroughputSnapshot? ThroughputSnapshot - => this.Snapshot as DashboardThroughputSnapshot; + protected override void OnInitialized() + => this._snapshot = ThroughputReader.GetSnapshot(); protected override async Task OnAfterRenderAsync(bool firstRender) { - if (this.ThroughputSnapshot is null) + if (firstRender) + { + this.StartRefreshTimer(); + } + + if (this._snapshot is null) { return; } - await Js.InvokeVoidAsync("shedduellerThroughputChart.render", this._chartElement, CreateModel(this.ThroughputSnapshot, this._selectedSeriesKeys)); + await Js.InvokeVoidAsync("shedduellerThroughputChart.render", this._chartElement, CreateModel(this._snapshot, this._selectedSeriesKeys)); this._chartRendered = true; } public async ValueTask DisposeAsync() { - if (!this._chartRendered) + this._disposed = true; + await this._refreshCancellation.CancelAsync().ConfigureAwait(false); + this._refreshTimer?.Dispose(); + + if (this._refreshTask is not null) { - return; + await this._refreshTask.ConfigureAwait(false); } + this._refreshCancellation.Dispose(); + + if (this._chartRendered) + { + try + { + await Js.InvokeVoidAsync("shedduellerThroughputChart.destroy", this._chartElement); + } + catch (JSDisconnectedException) + { + } + } + + GC.SuppressFinalize(this); + } + + private void StartRefreshTimer() + { + this._refreshTimer = new PeriodicTimer(RefreshInterval); + this._refreshTask = this.RunRefreshTimerAsync(this._refreshTimer, this._refreshCancellation.Token); + } + + private async Task RunRefreshTimerAsync( + PeriodicTimer timer, + CancellationToken cancellationToken) + { try { - await Js.InvokeVoidAsync("shedduellerThroughputChart.destroy", this._chartElement); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + if (this.IsAutoRefreshEnabled) + { + await this.RefreshSnapshotAsync(cancellationToken).ConfigureAwait(false); + } + } } - catch (JSDisconnectedException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + } - GC.SuppressFinalize(this); + private async Task RefreshSnapshotAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var snapshot = ThroughputReader.GetSnapshot(); + await this.InvokeAsync(() => + { + if (!this._disposed) + { + this._snapshot = snapshot; + this.StateHasChanged(); + } + }).ConfigureAwait(false); } private static bool HasActivity(DashboardThroughputSnapshot snapshot) @@ -123,17 +191,15 @@ else ? $"throughput-chart-legend__item {series.CssClass}" : $"throughput-chart-legend__item {series.CssClass} throughput-chart-legend__item--muted"; - private static DashboardThroughputBucket RecentTotals(DashboardThroughputSnapshot snapshot) + private static DashboardThroughputBucket WindowTotals(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), + snapshot.WindowStartUtc, totals.QueuedCount, totals.StartedCount, totals.SucceededCount, @@ -146,24 +212,25 @@ else DashboardThroughputSnapshot snapshot, IReadOnlySet selectedSeriesKeys) { - var activeSeries = selectedSeriesKeys.Count == 0 - ? SeriesDefinitions - : SeriesDefinitions - .Where(series => selectedSeriesKeys.Contains(series.Key)) - .ToArray(); + var allSelected = selectedSeriesKeys.Count == 0; return new ThroughputChartModel( (int)snapshot.BucketSize.TotalSeconds, snapshot.WindowStartUtc.ToUnixTimeSeconds(), snapshot.WindowEndUtc.ToUnixTimeSeconds(), snapshot.Buckets.Select(static bucket => bucket.StartedAtUtc.ToUnixTimeSeconds()).ToArray(), - activeSeries - .Select(series => new ThroughputChartSeriesModel( - series.Key, - series.Label, - series.Color, - snapshot.Buckets.Select(series.Value).ToArray())) - .ToArray()); + allSelected || selectedSeriesKeys.Contains("queued"), + allSelected || selectedSeriesKeys.Contains("started"), + allSelected || selectedSeriesKeys.Contains("succeeded"), + allSelected || selectedSeriesKeys.Contains("failed"), + allSelected || selectedSeriesKeys.Contains("canceled"), + allSelected || selectedSeriesKeys.Contains("failedAttempts"), + 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( @@ -171,13 +238,18 @@ else long WindowStartUnixSeconds, long WindowEndUnixSeconds, long[] Timestamps, - IReadOnlyList Series); - - private sealed record ThroughputChartSeriesModel( - string Key, - string Label, - string Color, - int[] Values); + bool QueuedVisible, + bool StartedVisible, + bool SucceededVisible, + bool FailedVisible, + bool CanceledVisible, + bool FailedAttemptsVisible, + int[] Queued, + int[] Started, + int[] Succeeded, + int[] Failed, + int[] Canceled, + int[] FailedAttempts); private sealed record ThroughputSeriesDefinition( string Key, diff --git a/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js index 8b55913..64ec81c 100644 --- a/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js +++ b/src/Sheddueller.Dashboard/wwwroot/vendor/sheddueller-throughput-chart.js @@ -1,5 +1,13 @@ (function () { const charts = new WeakMap(); + const seriesDefinitions = [ + { label: "Queued", color: "#515f74", valuesKey: "Queued", visibleKey: "QueuedVisible" }, + { label: "Started", color: "#1d4ed8", valuesKey: "Started", visibleKey: "StartedVisible" }, + { label: "Succeeded", color: "#166534", valuesKey: "Succeeded", visibleKey: "SucceededVisible" }, + { label: "Failed", color: "#ba1a1a", valuesKey: "Failed", visibleKey: "FailedVisible" }, + { label: "Canceled", color: "#8a5a00", valuesKey: "Canceled", visibleKey: "CanceledVisible" }, + { label: "Failed Attempts", color: "#7c3aed", valuesKey: "FailedAttempts", visibleKey: "FailedAttemptsVisible" }, + ]; function read(model, key) { const lower = key.charAt(0).toLowerCase() + key.slice(1); @@ -35,19 +43,11 @@ function renderUPlot(element, model) { const timestamps = read(model, "Timestamps") ?? []; - const modelSeries = chartSeries(model); - const seriesKeys = modelSeries.map((series) => series.key).join("|"); - const data = [timestamps, ...modelSeries.map((series) => series.values)]; - const scale = scaleInfo(timestamps, modelSeries); + const datasets = chartSeries(model); + const data = [timestamps, ...datasets.map((series) => series.values)]; + const visibleSeries = datasets.filter((series) => series.visible); + const scale = scaleInfo(timestamps, visibleSeries); const timeScale = timeScaleRange(model, timestamps); - const existing = charts.get(element); - if (existing?.kind === "uplot" && existing.seriesKeys === seriesKeys) { - existing.chart.setSize(chartSize(element)); - existing.chart.setData(data, false); - applyScales(existing.chart, timeScale, scale.max); - renderClipMarkers(element, scale.clipped, timestamps, existing.chart); - return; - } destroy(element); const options = { @@ -75,13 +75,15 @@ ], series: [ {}, - ...modelSeries.map((modelSeries) => series(modelSeries.label, modelSeries.color)), + ...datasets.map((dataset) => series(dataset.label, dataset.color, dataset.visible)), ], }; const chart = new window.uPlot(options, data, element); + applySeriesVisibility(chart, datasets); applyScales(chart, timeScale, scale.max); - charts.set(element, { kind: "uplot", chart, seriesKeys }); + charts.set(element, { kind: "uplot", chart }); + updateDebugAttributes(element, model, timestamps, data); renderClipMarkers(element, scale.clipped, timestamps, chart); } @@ -97,6 +99,7 @@ } drawCanvas(existing.canvas, model); + updateDebugAttributes(element, model, read(model, "Timestamps") ?? [], []); } function drawCanvas(canvas, model) { @@ -118,8 +121,9 @@ const plotHeight = height - padding.top - padding.bottom; const timestamps = read(model, "Timestamps") ?? []; const datasets = chartSeries(model); + const visibleDatasets = datasets.filter((dataset) => dataset.visible); const pointCount = timestamps.length; - const scale = scaleInfo(timestamps, datasets); + const scale = scaleInfo(timestamps, visibleDatasets); const maxValue = scale.max; context.strokeStyle = gridColor(); @@ -142,7 +146,7 @@ context.fillText(value.toLocaleString(), padding.left - 8, y); } - for (const dataset of datasets) { + for (const dataset of visibleDatasets) { context.strokeStyle = dataset.color; context.lineWidth = 1.5; context.beginPath(); @@ -163,9 +167,10 @@ renderClipMarkers(canvas.parentElement, scale.clipped, timestamps); } - function series(label, stroke) { + function series(label, stroke, show) { return { label, + show, stroke, width: 1.5, points: { show: false }, @@ -185,6 +190,12 @@ chart.setScale("y", { min: 0, max: yMax }); } + function applySeriesVisibility(chart, series) { + for (let index = 0; index < series.length; index++) { + chart.setSeries(index + 1, { show: series[index].visible }); + } + } + function timeScaleRange(model, timestamps) { const min = read(model, "WindowStartUnixSeconds") ?? timestamps[0] ?? 0; const max = read(model, "WindowEndUnixSeconds") ?? timestamps[timestamps.length - 1] ?? min; @@ -192,14 +203,22 @@ } function chartSeries(model) { - return (read(model, "Series") ?? []).map((series) => ({ - key: read(series, "Key"), - label: read(series, "Label"), - color: read(series, "Color"), - values: read(series, "Values") ?? [], + return seriesDefinitions.map((definition) => ({ + label: definition.label, + color: definition.color, + visible: read(model, definition.visibleKey) !== false, + values: read(model, definition.valuesKey) ?? [], })); } + function updateDebugAttributes(element, model, timestamps, data) { + const currentCount = Number.parseInt(element.dataset.renderCount ?? "0", 10); + element.dataset.renderCount = `${Number.isFinite(currentCount) ? currentCount + 1 : 1}`; + element.dataset.windowEnd = `${read(model, "WindowEndUnixSeconds") ?? ""}`; + element.dataset.pointCount = `${timestamps.length}`; + element.dataset.seriesCount = `${Math.max(0, data.length - 1)}`; + } + function scaleInfo(timestamps, series) { const values = series .flatMap((series) => Array.from(series.values)) diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index d1f8689..715dc88 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -59,7 +59,8 @@ 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("Dashboard"); + html.ShouldContain("_content/Sheddueller.Dashboard/vendor/sheddueller-throughput-chart.js?v="); + html.ShouldNotContain("sd-sidebar__brand"); } [Fact] From a74cc4afe4418d0e714c51cb1568796464796695 Mon Sep 17 00:00:00 2001 From: Brian Tyler Date: Mon, 27 Apr 2026 00:01:23 +0100 Subject: [PATCH 4/4] feat: update throughput metrics to use 5s buckets and adjust related tests --- .../Components/Pages/Metrics.razor | 2 +- .../Components/ThroughputChart.razor | 2 +- .../Internal/DashboardThroughputStore.cs | 51 +++++++++++-------- .../DashboardEndpointTests.cs | 10 ++-- .../DashboardThroughputStoreTests.cs | 10 ++-- 5 files changed, 42 insertions(+), 33 deletions(-) diff --git a/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor b/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor index 3e9e5f7..8783e1f 100644 --- a/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor +++ b/src/Sheddueller.Dashboard/Components/Pages/Metrics.razor @@ -139,7 +139,7 @@

Live Throughput

- 1s Buckets / 1h Window + 5s Buckets / 1h Window
diff --git a/src/Sheddueller.Dashboard/Components/ThroughputChart.razor b/src/Sheddueller.Dashboard/Components/ThroughputChart.razor index 3a803e5..ba53921 100644 --- a/src/Sheddueller.Dashboard/Components/ThroughputChart.razor +++ b/src/Sheddueller.Dashboard/Components/ThroughputChart.razor @@ -39,7 +39,7 @@ else } @code { - private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(5); private static readonly ThroughputSeriesDefinition[] SeriesDefinitions = [ diff --git a/src/Sheddueller.Dashboard/Internal/DashboardThroughputStore.cs b/src/Sheddueller.Dashboard/Internal/DashboardThroughputStore.cs index 3cbdde8..102b118 100644 --- a/src/Sheddueller.Dashboard/Internal/DashboardThroughputStore.cs +++ b/src/Sheddueller.Dashboard/Internal/DashboardThroughputStore.cs @@ -4,9 +4,11 @@ namespace Sheddueller.Dashboard.Internal; internal sealed class DashboardThroughputStore : IDashboardThroughputReader, IDisposable { - internal static readonly TimeSpan BucketSize = TimeSpan.FromSeconds(1); + private const int BucketSizeSeconds = 5; + + internal static readonly TimeSpan BucketSize = TimeSpan.FromSeconds(BucketSizeSeconds); internal static readonly TimeSpan Window = TimeSpan.FromHours(1); - internal const int BucketCount = 3600; + internal const int BucketCount = 720; private readonly Lock _gate = new(); private readonly DashboardThroughputBucketState[] _buckets = new DashboardThroughputBucketState[BucketCount]; @@ -25,18 +27,18 @@ public DashboardThroughputStore( public DashboardThroughputSnapshot GetSnapshot() { - var windowEndUtc = TruncateToSecond(this._timeProvider.GetUtcNow()); - var windowStartUtc = windowEndUtc.AddSeconds(-(BucketCount - 1)); + var windowEndUtc = TruncateToBucket(this._timeProvider.GetUtcNow()); + var windowStartUtc = windowEndUtc.AddSeconds(-(BucketCount - 1) * BucketSizeSeconds); 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 + var bucketStartUtc = windowStartUtc.AddSeconds(offset * BucketSizeSeconds); + var bucketNumber = BucketNumber(bucketStartUtc); + var state = this._buckets[BucketIndex(bucketNumber)]; + buckets[offset] = state.BucketNumber == bucketNumber ? state.ToBucket(bucketStartUtc) : EmptyBucket(bucketStartUtc); } @@ -63,21 +65,23 @@ internal void Record(JobEvent jobEvent) return; } - var eventBucketUtc = TruncateToSecond(jobEvent.OccurredAtUtc); - var nowUtc = TruncateToSecond(this._timeProvider.GetUtcNow()); - if (eventBucketUtc <= nowUtc.Subtract(Window) || eventBucketUtc > nowUtc) + var occurredAtUtc = jobEvent.OccurredAtUtc.ToUniversalTime(); + var nowUtc = this._timeProvider.GetUtcNow(); + var nowBucketUtc = TruncateToBucket(nowUtc); + var eventBucketUtc = TruncateToBucket(occurredAtUtc); + if (eventBucketUtc <= nowBucketUtc.Subtract(Window) || occurredAtUtc > nowUtc) { return; } - var unixSecond = eventBucketUtc.ToUnixTimeSeconds(); - var index = BucketIndex(unixSecond); + var bucketNumber = BucketNumber(eventBucketUtc); + var index = BucketIndex(bucketNumber); lock (this._gate) { ref var state = ref this._buckets[index]; - if (state.UnixSecond != unixSecond) + if (state.BucketNumber != bucketNumber) { - state = new DashboardThroughputBucketState(unixSecond); + state = new DashboardThroughputBucketState(bucketNumber); } state.Increment(metric); @@ -143,14 +147,19 @@ private static bool TryClassifyLifecycle( return false; } - private static DateTimeOffset TruncateToSecond(DateTimeOffset timestamp) + private static DateTimeOffset TruncateToBucket(DateTimeOffset timestamp) { var utc = timestamp.ToUniversalTime(); - return new DateTimeOffset(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, utc.Second, TimeSpan.Zero); + var unixSeconds = utc.ToUnixTimeSeconds(); + var bucketStartUnixSeconds = unixSeconds - (unixSeconds % BucketSizeSeconds); + return DateTimeOffset.FromUnixTimeSeconds(bucketStartUnixSeconds); } - private static int BucketIndex(long unixSecond) - => (int)(((unixSecond % BucketCount) + BucketCount) % BucketCount); + private static long BucketNumber(DateTimeOffset timestamp) + => timestamp.ToUnixTimeSeconds() / BucketSizeSeconds; + + private static int BucketIndex(long bucketNumber) + => (int)(((bucketNumber % BucketCount) + BucketCount) % BucketCount); private static DashboardThroughputBucket EmptyBucket(DateTimeOffset startedAtUtc) => new(startedAtUtc, 0, 0, 0, 0, 0, 0); @@ -165,9 +174,9 @@ private enum DashboardThroughputMetric FailedAttempt, } - private struct DashboardThroughputBucketState(long unixSecond) + private struct DashboardThroughputBucketState(long bucketNumber) { - public long UnixSecond { get; private set; } = unixSecond; + public long BucketNumber { get; private set; } = bucketNumber; public int QueuedCount { get; private set; } diff --git a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs index 715dc88..6852435 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardEndpointTests.cs @@ -330,7 +330,7 @@ public async Task Metrics_KnownData_RendersRollingHealth() html.ShouldContain("Throughput Rate"); html.ShouldContain("Schedule Fire Lag"); html.ShouldContain("Live Throughput"); - html.ShouldContain("1s Buckets / 1h Window"); + html.ShouldContain("5s Buckets / 1h Window"); html.ShouldContain("aria-label=\"Throughput series filters\""); html.ShouldContain("aria-pressed=\"true\""); html.ShouldContain("Failed Attempts"); @@ -1274,12 +1274,12 @@ 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.AddSeconds(-10), WindowEndUtc, - TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(5), [ new DashboardThroughputBucket( - WindowEndUtc.AddSeconds(-2), + WindowEndUtc.AddSeconds(-10), QueuedCount: 0, StartedCount: 0, SucceededCount: 0, @@ -1287,7 +1287,7 @@ private sealed class StubDashboardThroughputReader : IDashboardThroughputReader CanceledCount: 0, FailedAttemptCount: 0), new DashboardThroughputBucket( - WindowEndUtc.AddSeconds(-1), + WindowEndUtc.AddSeconds(-5), QueuedCount: 12, StartedCount: 10, SucceededCount: 9, diff --git a/test/Sheddueller.Dashboard.Tests/DashboardThroughputStoreTests.cs b/test/Sheddueller.Dashboard.Tests/DashboardThroughputStoreTests.cs index 014c3d2..1c1a61d 100644 --- a/test/Sheddueller.Dashboard.Tests/DashboardThroughputStoreTests.cs +++ b/test/Sheddueller.Dashboard.Tests/DashboardThroughputStoreTests.cs @@ -10,16 +10,16 @@ namespace Sheddueller.Dashboard.Tests; public sealed class DashboardThroughputStoreTests { [Fact] - public void Snapshot_NoEvents_ZeroFillsOneHourOfSecondBuckets() + public void Snapshot_NoEvents_ZeroFillsOneHourOfFiveSecondBuckets() { 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.BucketSize.ShouldBe(TimeSpan.FromSeconds(5)); + snapshot.Buckets.Count.ShouldBe(720); + snapshot.WindowStartUtc.ShouldBe(now.AddSeconds(-3595)); snapshot.WindowEndUtc.ShouldBe(now); snapshot.Buckets[0].StartedAtUtc.ShouldBe(snapshot.WindowStartUtc); snapshot.Buckets[^1].StartedAtUtc.ShouldBe(now); @@ -27,7 +27,7 @@ public void Snapshot_NoEvents_ZeroFillsOneHourOfSecondBuckets() } [Fact] - public void Record_KnownJobEvents_StoresCountsInSecondBucket() + public void Record_KnownJobEvents_StoresCountsInFiveSecondBucket() { var now = new DateTimeOffset(2026, 4, 26, 12, 0, 5, TimeSpan.Zero); using var store = CreateStore(now);