29 KiB
M6 — KPI History & Trends Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Build a reusable central KPI-history backbone (tall/EAV store + cluster-singleton recorder + bucketed query + custom SVG trend chart) and ship trend charts for Notification Outbox, Site Call Audit, Audit Log, and Site Health.
Architecture: New #26 KpiHistory central component. A tall KpiSample table in central MS SQL holds (Source, Metric, Scope, ScopeKey, Value, CapturedAtUtc) rows. A KpiHistoryRecorderActor cluster singleton samples every SampleIntervalSeconds by enumerating DI-registered IKpiSampleSources (each owned by its component, reusing existing Compute…KpisAsync/aggregator reads) and bulk-writing samples; a daily purge enforces retention. Central UI reads via a KpiHistoryQueryService (scoped-repo, dual-ctor test seam like AuditLogQueryService) that fetches raw points and reduces them with a pure KpiSeriesBucketer (last-value per bucket); a reusable KpiTrendChart.razor (inline SVG, no third-party lib) renders the series on four surfaces.
Tech Stack: C#/.NET 10, Akka.NET 1.5 (ReceiveActor + Timers + ClusterSingleton), EF Core 10 (MS SQL central / SQLite tests), Blazor Server + Bootstrap 5, xUnit + bUnit + NSubstitute, Playwright.
Design doc: docs/plans/2026-06-17-m6-kpi-history-design.md (committed dda9398).
Conventions for every task
- Worktree: all work happens in
m6-kpi-history(branched off localmain639e331). Implementers do NOT create worktrees. - Commits: pathspec form —
git commit -m "…" -- <paths>; retry on.git/index.lock(wait ~3s). New files:git add <path>first, then pathspec commit. - Concurrency: the controller keeps ≤3 concurrent committers per wave and runs a post-wave
git log --oneline <base>..HEADpresence check (per the concurrent-commit ref-race lesson). - Targeted tests only:
dotnet test <testproj> --filter <name>+ per-projectdotnet build <proj>. Full-solution build (dotnet build ZB.MOM.WW.ScadaBridge.slnx) + docker rebuild only in K17. - Namespaces:
ZB.MOM.WW.ScadaBridge.<Component>mirrors folder. UTC everywhere. - The
Files:block is the contract — Read/Edit exactly those paths. If you need another file, that's a plan defect — surface it.
Shared naming (used across tasks)
- Sources (string constants in
KpiSources):NotificationOutbox,SiteCallAudit,AuditLog,SiteHealth. - Scopes (string constants in
KpiScopes):Global,Site,Node. - Metric-name catalog (lowerCamel, defined as constants on each source impl):
- NotificationOutbox:
queueDepth, stuckCount, parkedCount, deliveredLastInterval, oldestPendingAgeSeconds - SiteCallAudit:
buffered, parked, failedLastInterval, deliveredLastInterval, oldestPendingAgeSeconds, stuck - AuditLog:
totalEventsLastHour, errorEventsLastHour, backlogTotal - SiteHealth:
connectionsUp, connectionsDown, scriptErrors, alarmEvalErrors, sfBufferDepth, deadLetters, parkedMessages, deployedInstances, enabledInstances, disabledInstances, auditBacklogPending, eventLogWriteFailures
- NotificationOutbox:
Task K1: Foundation contracts (Commons)
Classification: high-risk Estimated implement time: ~4 min Parallelizable with: none (everything depends on it)
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Kpi/KpiSample.cs - Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesPoint.cs - Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSources.cs(string constantsNotificationOutbox/SiteCallAudit/AuditLog/SiteHealth) - Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiScopes.cs(string constantsGlobal/Site/Node) - Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Kpi/IKpiSampleSource.cs - Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSampleTests.cs(if a Commons.Tests project exists; else fold the construction assertion into K2's test and note it)
Shapes:
// KpiSample.cs — persistence-ignorant POCO
public sealed class KpiSample
{
public long Id { get; set; }
public required string Source { get; set; } // KpiSources.*
public required string Metric { get; set; } // per-source catalog
public required string Scope { get; set; } // KpiScopes.*
public string? ScopeKey { get; set; } // site id / node name; null for Global
public double Value { get; set; } // counts exact; ages as seconds
public DateTime CapturedAtUtc { get; set; } // UTC
}
// KpiSeriesPoint.cs
public readonly record struct KpiSeriesPoint(DateTime BucketStartUtc, double Value);
// IKpiSampleSource.cs — implemented by each owning component, enumerated by the recorder
public interface IKpiSampleSource
{
string Source { get; } // KpiSources.*
/// Compute this source's KPIs as-of-now, stamped with capturedAtUtc.
Task<IReadOnlyList<KpiSample>> CollectAsync(DateTime capturedAtUtc, CancellationToken ct = default);
}
// IKpiHistoryRepository.cs
public interface IKpiHistoryRepository
{
Task RecordSamplesAsync(IReadOnlyCollection<KpiSample> samples, CancellationToken ct = default);
/// Raw points for one series in [fromUtc, toUtc], ordered by CapturedAtUtc ascending.
Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken ct = default);
/// Bulk-delete rows older than `before`. Returns rows deleted.
Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken ct = default);
}
Steps: write the types → dotnet build src/ZB.MOM.WW.ScadaBridge.Commons (Expected: success) → if a Commons.Tests project exists, add a one-line construction/round-trip-able test and run it → commit feat(kpi): K1 — KpiSample + IKpiSampleSource + IKpiHistoryRepository contracts (Commons) with pathspec of the created files.
Task K2: Persistence — entity config, repository, migration (ConfigurationDatabase)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: K3, K6, K7, K8, K9, K10, K12 (disjoint files)
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/KpiSampleEntityTypeConfiguration.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs(addDbSet<KpiSample> KpiSamples => Set<KpiSample>();;ApplyConfigurationsFromAssemblyalready picks up the config) - Create:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs(addservices.AddScoped<IKpiHistoryRepository, KpiHistoryRepository>();) - Create (generated):
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/*_AddKpiSampleTable.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs
EF config (mirror SiteCallEntityTypeConfiguration):
builder.ToTable("KpiSample");
builder.HasKey(s => s.Id);
builder.Property(s => s.Source).HasMaxLength(64).IsUnicode(false).IsRequired();
builder.Property(s => s.Metric).HasMaxLength(64).IsUnicode(false).IsRequired();
builder.Property(s => s.Scope).HasMaxLength(16).IsUnicode(false).IsRequired();
builder.Property(s => s.ScopeKey).HasMaxLength(64).IsUnicode(false); // nullable
builder.Property(s => s.Value); // float
builder.Property(s => s.CapturedAtUtc).IsRequired();
builder.HasIndex(s => new { s.Source, s.Metric, s.Scope, s.ScopeKey, s.CapturedAtUtc })
.HasDatabaseName("IX_KpiSample_Series");
builder.HasIndex(s => s.CapturedAtUtc).HasDatabaseName("IX_KpiSample_Captured");
Repository: RecordSamplesAsync → context.KpiSamples.AddRange(samples); await SaveChangesAsync(ct);. GetRawSeriesAsync → filtered Where (note ScopeKey == scopeKey handles null correctly in EF) ordered by CapturedAtUtc, projected to KpiSeriesPoint. PurgeOlderThanAsync → await context.KpiSamples.Where(s => s.CapturedAtUtc < before).ExecuteDeleteAsync(ct);.
Migration:
dotnet ef migrations add AddKpiSampleTable \
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
After generating, dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase and confirm no PendingModelChangesWarning (M2-pre regression guard).
Test: in-memory SQLite (the existing ConfigurationDatabase.Tests harness) — insert samples across two metrics/timestamps, assert GetRawSeriesAsync returns only the matching series in time order; assert PurgeOlderThanAsync(cutoff) deletes only aged rows.
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter KpiHistoryRepository
Commit: feat(kpi): K2 — KpiSample EF mapping + KpiHistoryRepository + AddKpiSampleTable migration.
Task K3: KpiHistory project scaffold + options (new component)
Classification: standard Estimated implement time: ~5 min Parallelizable with: K2, K6, K7, K8, K9, K10, K12
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj(mirrorNotificationOutbox.csproj: TargetFramework, project ref to Commons, package refs Akka + Microsoft.Extensions.{Options,Logging.Abstractions,DependencyInjection.Abstractions}) - Modify:
ZB.MOM.WW.ScadaBridge.slnx(add project) - Create:
src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs - Create:
src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs(mirrorAuditLogOptionsValidator/OptionsValidatorBase) - Create:
src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs(AddKpiHistory(this IServiceCollection, IConfiguration)—AddValidatedOptions<KpiHistoryOptions, KpiHistoryOptionsValidator>(config, "ScadaBridge:KpiHistory")) - Test:
tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs(create test project, add to slnx)
Options:
public sealed class KpiHistoryOptions
{
public TimeSpan SampleInterval { get; set; } = TimeSpan.FromSeconds(60);
public int RetentionDays { get; set; } = 90;
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1);
public int DefaultMaxSeriesPoints { get; set; } = 200;
}
Validator: SampleInterval > 0, RetentionDays in [1, 3650], PurgeInterval > 0, DefaultMaxSeriesPoints in [2, 5000].
Steps: scaffold → dotnet build src/ZB.MOM.WW.ScadaBridge.KpiHistory → validator test (dotnet test tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests --filter Validator) → commit feat(kpi): K3 — KpiHistory project + options/validator + AddKpiHistory.
Task K4: KpiHistoryRecorderActor (KpiHistory project)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends K3 project + K1 contracts)
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs(no DI for the actor itself — created via Props in Host — but expose any logger/helpers if needed) - Test:
tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs
Behavior (mirror NotificationOutboxActor timer + scope-per-tick and AuditLogPurgeActor daily purge):
- ctor:
(IServiceProvider sp, KpiHistoryOptions opts, ILogger<KpiHistoryRecorderActor> log).ReceiveActorwithTimers(ITimerScheduler) orContext.System.Schedulercancelable. PreStart: start periodicSampleTickatopts.SampleInterval; start periodicPurgeTickatopts.PurgeInterval; create_shutdownCts.SampleTick:capturedAt = DateTime.UtcNow; openawait using var scope = sp.CreateAsyncScope(); resolveIEnumerable<IKpiSampleSource>+IKpiHistoryRepository; for each source, tryCollectAsync(capturedAt)(catch+log, skip on throw), accumulate; if any samples,RecordSamplesAsync(try/catch+log). Best-effort: no exception escapes the tick.PurgeTick:before = DateTime.UtcNow - TimeSpan.FromDays(opts.RetentionDays); scope → repo →PurgeOlderThanAsync(before)(try/catch+log).PostStop: cancel_shutdownCts, cancel timers.- Use
PipeTo(Self)or async-receive pattern consistent withNotificationOutboxActor(do not block the actor thread).
Test (Akka.TestKit): register two fake IKpiSampleSources in a test IServiceProvider (one returns 2 samples, one throws); a fake IKpiHistoryRepository capturing RecordSamplesAsync. Drive a SampleTick; assert the healthy source's 2 samples were recorded and the throwing source did not abort the tick. Drive a PurgeTick; assert PurgeOlderThanAsync called with a cutoff ≈ now−RetentionDays.
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests --filter Recorder
Commit: feat(kpi): K4 — KpiHistoryRecorderActor (best-effort sampling + daily purge).
Task K5: Host wiring + appsettings
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends K3, K4; touches Host)
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Program.cs(callservices.AddKpiHistory(configuration)on the central composition path — alongsideAddNotificationOutbox/AddAuditLog) - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs(registerKpiHistoryRecorderActoras a non-role-scopedClusterSingletonManager+ proxy +CoordinatedShutdowndrain — copy theaudit-log-purgeblock; singleton namekpi-history-recorder) - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json(addScadaBridge:KpiHistorysection with defaults) - Modify:
docker/appsettings.Central.jsonanddocker-env2/appsettings.Central.json(add the same section) - Modify:
deploy/wonder-app-vd03/appsettings.Central.json(add the section)
Note (readiness): do NOT add kpi-history-recorder to RequiredSingletonsHealthCheck — KPI history is observability/best-effort, not a readiness gate. State this in the commit message.
Steps: wire → dotnet build src/ZB.MOM.WW.ScadaBridge.Host (Expected: success) → commit feat(kpi): K5 — Host central wiring + KpiHistoryRecorder cluster singleton + appsettings (not readiness-gated).
Task K6: NotificationOutboxKpiSampleSource
Classification: small Estimated implement time: ~4 min Parallelizable with: K2, K3, K7, K8, K9, K10, K12
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Kpi/NotificationOutboxKpiSampleSource.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs(services.AddScoped<IKpiSampleSource, NotificationOutboxKpiSampleSource>();) - Test:
tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Kpi/NotificationOutboxKpiSampleSourceTests.cs
Behavior: Source => KpiSources.NotificationOutbox. ctor injects INotificationOutboxRepository + IOptions<NotificationOutboxOptions>. In CollectAsync(capturedAt): compute stuckCutoff = capturedAt - opts.StuckAgeThreshold, deliveredSince = capturedAt - opts.DeliveredKpiWindow (so sampled values match the live tiles). Call ComputeKpisAsync → emit Global metrics; ComputePerSiteKpisAsync → emit Site-scoped (ScopeKey = site id); ComputePerNodeKpisAsync → emit Node-scoped. Map OldestPendingAge (TimeSpan?) to oldestPendingAgeSeconds (.TotalSeconds, omit the metric when null). All CapturedAtUtc = capturedAt.
Test: mock INotificationOutboxRepository returning a known global snapshot + one per-site + one per-node; assert the emitted KpiSample list has the expected (Metric, Scope, ScopeKey, Value) tuples.
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests --filter NotificationOutboxKpiSampleSource
Commit: feat(kpi): K6 — NotificationOutbox sample source (global/site/node).
Task K7: SiteCallAuditKpiSampleSource
Classification: small Estimated implement time: ~4 min Parallelizable with: K2, K3, K6, K8, K9, K10, K12
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/Kpi/SiteCallAuditKpiSampleSource.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/ServiceCollectionExtensions.cs(registerIKpiSampleSource) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/Kpi/SiteCallAuditKpiSampleSourceTests.cs
Behavior: Source => KpiSources.SiteCallAudit. ctor injects ISiteCallAuditRepository + the SiteCallAudit options (for stuck/interval windows — match the live KPI computation; reuse whatever options type SiteCallAuditActor uses, DateTime cutoffs). Emit buffered/parked/failedLastInterval/deliveredLastInterval/oldestPendingAgeSeconds/stuck Global + per-Site + per-Node from ComputeKpisAsync/ComputePerSiteKpisAsync/ComputePerNodeKpisAsync.
Test: mock repo; assert emitted tuples. Run filter SiteCallAuditKpiSampleSource. Commit feat(kpi): K7 — SiteCallAudit sample source.
Task K8: AuditLogKpiSampleSource
Classification: small Estimated implement time: ~3 min Parallelizable with: K2, K3, K6, K7, K9, K10, K12
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.AuditLog/Kpi/AuditLogKpiSampleSource.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs(registerIKpiSampleSource) - Test:
tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs
Behavior: Source => KpiSources.AuditLog. ctor injects IAuditLogRepository. In CollectAsync: var k = await repo.GetKpiSnapshotAsync(...); emit Global totalEventsLastHour, errorEventsLastHour, backlogTotal (all from AuditLogKpiSnapshot). Check GetKpiSnapshotAsync's exact signature/params before calling.
Test: mock repo; assert 3 Global metrics. Run filter AuditLogKpiSampleSource. Commit feat(kpi): K8 — AuditLog sample source.
Task K9: SiteHealthKpiSampleSource
Classification: standard Estimated implement time: ~5 min Parallelizable with: K2, K3, K6, K7, K8, K10, K12
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs(registerIKpiSampleSource) - Test:
tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/SiteHealthKpiSampleSourceTests.cs
Behavior: Source => KpiSources.SiteHealth. ctor injects ICentralHealthAggregator. In CollectAsync: foreach (var (siteId, state) in aggregator.GetAllSiteStates()) — when state.LatestReport is { } r, emit Site-scoped (ScopeKey = siteId) metrics derived from r (see SiteHealthReport): connectionsUp/Down from DataConnectionStatuses counts, scriptErrors = r.ScriptErrorCount, alarmEvalErrors = r.AlarmEvaluationErrorCount, sfBufferDepth = sum(r.StoreAndForwardBufferDepths.Values), deadLetters = r.DeadLetterCount, parkedMessages = r.ParkedMessageCount, deployedInstances/enabledInstances/disabledInstances, auditBacklogPending = r.SiteAuditBacklog?.PendingCount ?? 0, eventLogWriteFailures = r.SiteEventLogWriteFailures. Sites with LatestReport == null contribute nothing. Read SiteHealthReport/ConnectionHealth field names before mapping.
Test: fake ICentralHealthAggregator returning one site with a populated LatestReport; assert the expected per-site metric tuples; a site with null report yields no samples.
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests --filter SiteHealthKpiSampleSource
Commit: feat(kpi): K9 — SiteHealth sample source (per-site, from aggregator).
Task K10: KpiSeriesBucketer (pure helper)
Classification: small Estimated implement time: ~3 min Parallelizable with: K2, K3, K6, K7, K8, K9, K12
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesBucketer.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs
Behavior: static IReadOnlyList<KpiSeriesPoint> Bucket(IReadOnlyList<KpiSeriesPoint> raw, DateTime fromUtc, DateTime toUtc, int maxPoints). If raw.Count <= maxPoints return raw unchanged. Else divide [from,to] into maxPoints equal time buckets; for each non-empty bucket emit one point: BucketStartUtc = bucket start, Value = last raw value in the bucket (gauge semantics). Empty buckets omitted. Guard maxPoints >= 2, toUtc > fromUtc, empty input → empty output.
Test: raw under/over maxPoints; last-per-bucket selection; empty input; single bucket. Run filter KpiSeriesBucketer. Commit feat(kpi): K10 — KpiSeriesBucketer last-per-bucket downsampler.
Task K11: KpiHistoryQueryService (CentralUI)
Classification: standard Estimated implement time: ~5 min Parallelizable with: K12 (after K2 + K10 land)
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IKpiHistoryQueryService.cs - Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs(explicit-factory registration choosing theIServiceScopeFactoryctor — mirrorAuditLogQueryServicelines 46–48) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs
Behavior: dual-ctor (production IServiceScopeFactory scope-per-query + test-seam injected IKpiHistoryRepository + IOptions<KpiHistoryOptions> for default maxPoints). Method:
Task<IReadOnlyList<KpiSeriesPoint>> GetSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken ct = default);
→ resolve repo (scope or injected) → raw = await repo.GetRawSeriesAsync(...) → return KpiSeriesBucketer.Bucket(raw, fromUtc, toUtc, maxPoints ?? opts.DefaultMaxSeriesPoints).
Test: NSubstitute repo returns raw points; assert forwarding args + bucketed result. Run filter KpiHistoryQueryService. Commit feat(kpi): K11 — KpiHistoryQueryService (scoped read + bucketing).
Task K12: KpiTrendChart.razor reusable SVG component (CentralUI)
Classification: standard Estimated implement time: ~5 min Parallelizable with: K2, K3, K6–K11
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor - Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs(bUnit)
Behavior: parameters [Parameter] IReadOnlyList<KpiSeriesPoint>? Points, string Title, string? Unit, bool IsAvailable, string? ErrorMessage. Render an inline <svg viewBox="0 0 <W> <H>" preserveAspectRatio="none"> with a <polyline> over the points (normalize X by time across [min,max], Y by value across [0,max]); a baseline; min/max value labels + start/end time labels (Bootstrap small text-muted). Empty/!IsAvailable → a placeholder card with an em-dash + ErrorMessage. Add data-test="kpi-trend-<Title-slug>" for Playwright. Pattern after the existing inline SVG in AlarmTriggerEditor.razor + the AuditKpiTiles card styling. No third-party lib; component-scoped .razor.css only if needed.
Test (bUnit): render with 3 points + IsAvailable=true → markup contains <polyline and the title; render IsAvailable=false → contains the em-dash placeholder + error message.
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter KpiTrendChart
Commit: feat(kpi): K12 — reusable KpiTrendChart SVG component.
Task K13: Notification Outbox page trend section
Classification: standard Estimated implement time: ~4 min Parallelizable with: K14, K15, K16
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor(+ code-behind if present) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs(extend)
Behavior: inject IKpiHistoryQueryService; on init load series for KpiSources.NotificationOutbox Global metrics queueDepth, parkedCount, deliveredLastInterval over a default 24h window (from = UtcNow-24h); render a KpiTrendChart per metric below the existing tiles. Add a simple window toggle (24h / 7d) that re-queries. Best-effort: a query failure renders the chart's unavailable placeholder, never breaks the page.
Test: bUnit/xUnit — page renders the trend charts when the query service returns points (substitute the service). Run filter NotificationKpisPage. Commit feat(kpi): K13 — Notification Outbox trend charts (T11 first consumer).
Task K14: Site Calls page trend section
Classification: standard Estimated implement time: ~4 min Parallelizable with: K13, K15, K16
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor(+.razor.cs) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs(extend)
Behavior: inject IKpiHistoryQueryService; add a collapsible "Trends" section with KpiTrendChart for KpiSources.SiteCallAudit Global buffered, parked, failedLastInterval (24h/7d toggle). Same best-effort placeholder behavior.
Run filter SiteCallsReportPage. Commit feat(kpi): K14 — Site Calls trend charts.
Task K15: Audit Log page trend section
Classification: standard Estimated implement time: ~4 min Parallelizable with: K13, K14, K16
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor(+.razor.cs) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs(new)
Behavior: inject IKpiHistoryQueryService; add a "Trends" panel with KpiTrendChart for KpiSources.AuditLog Global totalEventsLastHour, errorEventsLastHour, backlogTotal (24h/7d). Best-effort placeholder.
Run filter AuditLogPageTrend. Commit feat(kpi): K15 — Audit Log trend charts.
Task K16: Health dashboard per-site Site Health trend panel
Classification: standard Estimated implement time: ~5 min Parallelizable with: K13, K14, K15
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor(+.razor.cs) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs(extend)
Behavior: inject IKpiHistoryQueryService; add a "Site Health Trends" panel with a site selector (populated from the already-loaded _siteStates keys) and KpiTrendChart for KpiSources.SiteHealth Site-scoped (ScopeKey = selected site) metrics connectionsDown, deadLetters, scriptErrors, sfBufferDepth (24h/7d). Best-effort placeholder; do not perturb the existing 10s tile refresh loop.
Run filter HealthPage. Commit feat(kpi): K16 — Health dashboard per-site trend panel.
Task K17: Integration — docs, deploy, Playwright, full verification
Classification: high-risk Estimated implement time: ~5 min (controller-driven; multiple sub-steps) Parallelizable with: none (final)
Files:
- Create:
docs/requirements/Component-KpiHistory.md(#26 — Purpose, Location, Responsibilities, schema, recorder, sources, query/UI, options, Dependencies, Interactions) - Modify:
README.md(component table: add #26 KpiHistory row) - Modify:
CLAUDE.md(25 → 26 components; add a "KPI History" entry + a Key-Design-Decision bullet; note T9/T10 deferred) - Modify:
docs/requirements/Component-NotificationOutbox.md,-SiteCallAudit(file:Component-SiteCallAudit.md),Component-AuditLog.md,Component-HealthMonitoring.md(if present, else the health doc),Component-CentralUI.md(Interactions: "emitsIKpiSampleSourceconsumed by KpiHistory" / "renders KpiTrendChart") - Modify:
docs/plans/2026-06-15-stillpending-completion-design.md(M6: T9/T10 deferred to next major version; T11 → KPI-history backbone delivered) - Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/KpiTrendChartTests.cs(one[SkippableFact]gated on cluster availability: authenticate, navigate to/monitoring/health(or the notifications KPI page), assert adata-test="kpi-trend-*"SVG renders with a<polyline>— seed a few samples via a small seeder or rely on the recorder having run)
Steps:
- Write docs.
- Full-solution build:
dotnet build ZB.MOM.WW.ScadaBridge.slnx— Expected: 0 errors, 0 warnings. - Targeted test sweep: run the filtered suites for ConfigurationDatabase, KpiHistory, NotificationService, SiteCallAudit, AuditLog, HealthMonitoring, CentralUI — all green.
- Docker rebuild:
bash docker/deploy.sh; confirm cluster healthy + migration applied (KpiSampletable exists). - Run the Playwright trend spec (skips if cluster down).
- Commit docs+spec together:
feat(kpi): K17 — #26 KpiHistory docs + completion-design update + Playwright trend spec + integration verification.
Execution waves (controller guidance — ≤3 concurrent committers/wave)
- Wave 1: K1 (solo).
- Wave 2: K2, K3 (2 committers).
- Wave 3: K6, K7, K8 (sources, 3 committers).
- Wave 4: K9, K10, K12 (3 committers).
- Wave 5: K4 (needs K3) → then K5 (needs K4) — serial (Host/actor).
- Wave 6: K11 (needs K2+K10).
- Wave 7: K13, K14, K15 (3 committers; need K11+K12).
- Wave 8: K16.
- Wave 9: K17 (final integration).
Reorder freely as long as blockedBy holds; run the post-wave HEAD-presence check after every multi-committer wave.