Files
ScadaBridge/docs/plans/2026-06-17-m6-kpi-history.md
T

29 KiB
Raw Blame History

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 local main 639e331). 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>..HEAD presence check (per the concurrent-commit ref-race lesson).
  • Targeted tests only: dotnet test <testproj> --filter <name> + per-project dotnet 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

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 constants NotificationOutbox/SiteCallAudit/AuditLog/SiteHealth)
  • Create: src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiScopes.cs (string constants Global/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 (add DbSet<KpiSample> KpiSamples => Set<KpiSample>();; ApplyConfigurationsFromAssembly already picks up the config)
  • Create: src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs (add services.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: RecordSamplesAsynccontext.KpiSamples.AddRange(samples); await SaveChangesAsync(ct);. GetRawSeriesAsync → filtered Where (note ScopeKey == scopeKey handles null correctly in EF) ordered by CapturedAtUtc, projected to KpiSeriesPoint. PurgeOlderThanAsyncawait 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 (mirror NotificationOutbox.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 (mirror AuditLogOptionsValidator / 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). ReceiveActor with Timers (ITimerScheduler) or Context.System.Scheduler cancelable.
  • PreStart: start periodic SampleTick at opts.SampleInterval; start periodic PurgeTick at opts.PurgeInterval; create _shutdownCts.
  • SampleTick: capturedAt = DateTime.UtcNow; open await using var scope = sp.CreateAsyncScope(); resolve IEnumerable<IKpiSampleSource> + IKpiHistoryRepository; for each source, try CollectAsync(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 with NotificationOutboxActor (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 ≈ nowRetentionDays. 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 (call services.AddKpiHistory(configuration) on the central composition path — alongside AddNotificationOutbox/AddAuditLog)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs (register KpiHistoryRecorderActor as a non-role-scoped ClusterSingletonManager + proxy + CoordinatedShutdown drain — copy the audit-log-purge block; singleton name kpi-history-recorder)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json (add ScadaBridge:KpiHistory section with defaults)
  • Modify: docker/appsettings.Central.json and docker-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 (register IKpiSampleSource)
  • 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 (register IKpiSampleSource)
  • 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 (register IKpiSampleSource)
  • 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 the IServiceScopeFactory ctor — mirror AuditLogQueryService lines 4648)
  • 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, K6K11

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: "emits IKpiSampleSource consumed 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 a data-test="kpi-trend-*" SVG renders with a <polyline> — seed a few samples via a small seeder or rely on the recorder having run)

Steps:

  1. Write docs.
  2. Full-solution build: dotnet build ZB.MOM.WW.ScadaBridge.slnx — Expected: 0 errors, 0 warnings.
  3. Targeted test sweep: run the filtered suites for ConfigurationDatabase, KpiHistory, NotificationService, SiteCallAudit, AuditLog, HealthMonitoring, CentralUI — all green.
  4. Docker rebuild: bash docker/deploy.sh; confirm cluster healthy + migration applied (KpiSample table exists).
  5. Run the Playwright trend spec (skips if cluster down).
  6. 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.