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

443 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `IKpiSampleSource`s (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:**
```csharp
// 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`):
```csharp
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:**
```bash
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:**
```csharp
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 `IKpiSampleSource`s 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:
```csharp
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.