Compare commits

...

24 Commits

Author SHA1 Message Date
Joseph Doherty 241a792e7b docs(kpi): K17 — #26 KpiHistory component doc + README/CLAUDE + cross-component interactions + completion-design update 2026-06-17 20:52:12 -04:00
Joseph Doherty 3f1f4ed7c6 test(kpi): K17 — Playwright KPI trend-chart spec (tolerant, SkippableFact) 2026-06-17 20:50:23 -04:00
Joseph Doherty eb4bce3e49 refactor(kpi): K13/K15 trend review fixups — per-metric isolation, disable-during-load + logging, loading-flag finally, test coverage 2026-06-17 20:44:34 -04:00
Joseph Doherty 7d7c6cbb05 feat(kpi): K16 — Health dashboard per-site trend panel 2026-06-17 20:36:09 -04:00
Joseph Doherty 3595a41349 feat(kpi): K15 — Audit Log trend charts 2026-06-17 20:30:38 -04:00
Joseph Doherty 4a88355098 feat(kpi): K14 — Site Calls trend charts 2026-06-17 20:30:36 -04:00
Joseph Doherty 0dc819f191 feat(kpi): K13 — Notification Outbox trend charts (T11 first consumer) 2026-06-17 20:29:30 -04:00
Joseph Doherty f0177d5073 feat(kpi): K11 — KpiHistoryQueryService (scoped read + bucketing) 2026-06-17 20:21:17 -04:00
Joseph Doherty e14433cd64 feat(kpi): K5 — Host central wiring + KpiHistoryRecorder cluster singleton + appsettings (not readiness-gated)
Wire the M6 KPI History recorder into the central composition path:
- Program.cs: call services.AddKpiHistory(configuration) on the central-only
  branch alongside AddNotificationOutbox/AddAuditLog/AddSiteCallAudit.
- AkkaHostedService.cs: register KpiHistoryRecorderActor as a central,
  non-role-scoped ClusterSingletonManager + ClusterSingletonProxy + a
  PhaseClusterLeave CoordinatedShutdown graceful-stop drain (singleton name
  'kpi-history-recorder'), copied/adapted from the audit-log-purge block.
- appsettings.Central.json (Host + docker + docker-env2 central nodes): add a
  ScadaBridge:KpiHistory section (SampleInterval 00:01:00, RetentionDays 90,
  PurgeInterval 1.00:00:00, DefaultMaxSeriesPoints 200).

KPI history is observability/best-effort and MUST NOT gate readiness: the
recorder is deliberately NOT added to RequiredSingletonsHealthCheck or any
other readiness gate.
2026-06-17 20:20:34 -04:00
Joseph Doherty 601cc6f594 feat(kpi): K9 — SiteHealth sample source (per-site, from aggregator) 2026-06-17 20:20:18 -04:00
Joseph Doherty cb2a516187 refactor(kpi): K4/K10/K12 review fixups — test data-race + faulted-tick liveness, dead-branch/unused removal, NaN-guard assertions, value clamp + doc 2026-06-17 20:15:47 -04:00
Joseph Doherty 5613a5efb7 feat(kpi): K12 — reusable KpiTrendChart SVG component 2026-06-17 20:06:31 -04:00
Joseph Doherty 9c2e7ab4cb feat(kpi): K4 — KpiHistoryRecorderActor (best-effort sampling + daily purge) 2026-06-17 20:06:09 -04:00
Joseph Doherty 76f5ed72e4 feat(kpi): K10 — KpiSeriesBucketer last-per-bucket downsampler 2026-06-17 20:04:51 -04:00
Joseph Doherty e6c15250ce refactor(kpi): K2/K6/K7 review fixups — empty-batch guard + sealed repo + uniform TryAddEnumerable + KPI-age doc fidelity + coverage 2026-06-17 20:00:43 -04:00
Joseph Doherty 456e61dff3 feat(kpi): K7 — SiteCallAudit sample source 2026-06-17 19:53:49 -04:00
Joseph Doherty 6f6157ce89 feat(kpi): K8 — AuditLog sample source 2026-06-17 19:53:41 -04:00
Joseph Doherty 0d6c026dff feat(kpi): K6 — NotificationOutbox sample source (global/site/node) 2026-06-17 19:53:39 -04:00
Joseph Doherty 9ffa34d3e7 feat(kpi): K3 — KpiHistory project + options/validator + AddKpiHistory 2026-06-17 19:48:59 -04:00
Joseph Doherty cabc557629 feat(kpi): K2 — KpiSample EF mapping + KpiHistoryRepository + AddKpiSampleTable migration 2026-06-17 19:44:51 -04:00
Joseph Doherty 460777bffa feat(kpi): K1 — KpiSample + IKpiSampleSource + IKpiHistoryRepository contracts (Commons) 2026-06-17 19:40:17 -04:00
Joseph Doherty 4c6ae9da0e docs(m6): KPI History tasks.json (K1-K17 with blockedBy graph) 2026-06-17 19:32:58 -04:00
Joseph Doherty 1809664bcb docs(m6): KPI History & Trends implementation plan (K1-K17, bite-sized tasks + execution waves) 2026-06-17 19:32:58 -04:00
Joseph Doherty 6084e56c9f docs(m6): KPI History & Trends design — reusable tall/EAV KPI-history backbone + trend charts for all sources; T9/T10 (Teams) deferred to next major version 2026-06-17 19:32:58 -04:00
79 changed files with 8076 additions and 6 deletions
+3 -1
View File
@@ -63,7 +63,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
- Commit related changes together with a descriptive message summarizing the design decision and the implementation slice.
- After non-trivial code changes, build (`dotnet build ZB.MOM.WW.ScadaBridge.slnx`) and run relevant tests before declaring done; for cluster-runtime changes, rebuild the image with `bash docker/deploy.sh`.
## Current Component List (25 components)
## Current Component List (26 components)
1. Template Engine — Template modeling, inheritance, composition, validation, flattening, diffs.
2. Deployment Manager — Central-side deployment pipeline, system-wide artifact deployment, instance lifecycle.
@@ -90,6 +90,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
23. Audit Log — Central append-only AuditLog table spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site SQLite hot-path + gRPC telemetry + reconciliation; combined telemetry with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API; monthly partitioning, 365-day retention.
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates, system artifacts, central-only configuration. Per-conflict resolution. Correlated audit via `BundleImportId`. No site involvement.
25. Script Analysis — Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI.
26. KPI History — Reusable central KPI-history backbone: tall/EAV `KpiSample` store in central MS SQL, `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s every minute, bucketed query (`GetRawSeriesAsync` + `KpiSeriesBucketer`) + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart`; ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health.
## Key Design Decisions (for context across sessions)
@@ -199,6 +200,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
- Stuck = `Pending`/`Retrying` older than a configurable age threshold (default 10 min) — display-only (KPI count + row badge), no escalation/alerting.
- Headline KPI tiles surface on the Health dashboard; a new Central UI Notification Outbox page offers a queryable list with Retry/Discard actions on parked notifications.
- Site Call Audit KPIs are central-computed point-in-time from the `SiteCalls` table (global + per-site), mirroring the Notification Outbox KPI shape; tiles surface on the Health dashboard alongside a queryable Central UI Site Calls page with Retry/Discard on parked rows.
- KPI History & Trends (#26, M6): a reusable central KPI-history backbone — supersedes the prior "point-in-time only, no time-series store" stance — backed by a tall/EAV `KpiSample` table in central MS SQL (no new infra). A `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, **not readiness-gated**, best-effort with per-source isolation) samples every minute by enumerating DI-registered `IKpiSampleSource`s (each lives with its owner, registered via `TryAddEnumerable`, reusing existing KPI/aggregator reads); daily purge after `RetentionDays` (default 90). Querying is `IKpiHistoryRepository.GetRawSeriesAsync` → `KpiSeriesBucketer` (last-value-per-bucket) → scoped dual-ctor `KpiHistoryQueryService` → a reusable **custom-SVG** `KpiTrendChart` (no third-party charting lib). Trends ship on four surfaces: Notification Outbox, Site Calls, Audit Log pages + a per-site Health-dashboard panel. `KpiHistoryOptions` (`ScadaBridge:KpiHistory`): SampleInterval 60s, RetentionDays 90, PurgeInterval 1d, DefaultMaxSeriesPoints 200; validated. M6's T9 (Teams + other non-Email delivery adapters) and T10 (`NotificationType` enum values + Central UI list "Type" selector) are deferred to the next major version.
### Code Organization
- Entity classes are persistence-ignorant POCOs in Commons; EF mappings in Configuration Database.
+1
View File
@@ -101,6 +101,7 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
| 23 | Audit Log | [docs/requirements/Component-AuditLog.md](docs/requirements/Component-AuditLog.md) | New central append-only AuditLog spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site-local SQLite hot-path append + gRPC telemetry + central reconciliation; combined telemetry packet with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API middleware; monthly partitioning, 365-day default retention. |
| 24 | Transport | [docs/requirements/Component-Transport.md](docs/requirements/Component-Transport.md) | Bundle export/import for templates, shared scripts, external systems, central-only artifacts. AES-256-GCM encryption; per-conflict resolution on import; correlated audit trail. |
| 25 | Script Analysis | [docs/requirements/Component-ScriptAnalysis.md](docs/requirements/Component-ScriptAnalysis.md) | Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI. |
| 26 | KPI History | [docs/requirements/Component-KpiHistory.md](docs/requirements/Component-KpiHistory.md) | Reusable central KPI-history backbone: tall/EAV `KpiSample` store (central MS SQL), `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s each minute, bucketed `GetRawSeriesAsync` + `KpiSeriesBucketer` query, and a reusable custom-SVG `KpiTrendChart`. Ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health. |
**Shared UI sub-component** (not a top-level component): [TreeView](docs/requirements/Component-TreeView.md) — reusable hierarchical tree/grid Blazor component used by the Central UI (#9) for the templates folder hierarchy, data-connection browse, and tag pickers.
+2
View File
@@ -24,6 +24,7 @@
<Project Path="src/ZB.MOM.WW.ScadaBridge.CLI/ZB.MOM.WW.ScadaBridge.CLI.csproj" />
<Project Path="src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
<Project Path="src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj" />
<Project Path="src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests.csproj" />
@@ -53,5 +54,6 @@
<Project Path="tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ZB.MOM.WW.ScadaBridge.Transport.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj" />
<Project Path="tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj" />
</Folder>
</Solution>
@@ -61,6 +61,12 @@
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"KpiHistory": {
"SampleInterval": "00:01:00",
"RetentionDays": 90,
"PurgeInterval": "1.00:00:00",
"DefaultMaxSeriesPoints": 200
},
"Transport": {
"SourceEnvironment": "docker-cluster-env2"
},
@@ -61,6 +61,12 @@
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"KpiHistory": {
"SampleInterval": "00:01:00",
"RetentionDays": 90,
"PurgeInterval": "1.00:00:00",
"DefaultMaxSeriesPoints": 200
},
"Transport": {
"SourceEnvironment": "docker-cluster-env2"
},
@@ -64,6 +64,12 @@
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"KpiHistory": {
"SampleInterval": "00:01:00",
"RetentionDays": 90,
"PurgeInterval": "1.00:00:00",
"DefaultMaxSeriesPoints": 200
},
"Transport": {
"SourceEnvironment": "docker-cluster"
},
@@ -64,6 +64,12 @@
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"KpiHistory": {
"SampleInterval": "00:01:00",
"RetentionDays": 90,
"PurgeInterval": "1.00:00:00",
"DefaultMaxSeriesPoints": 200
},
"Transport": {
"SourceEnvironment": "docker-cluster"
},
@@ -78,8 +78,11 @@ Wire up behavior that exists in code but is never started, and fill the event-lo
#### M5 — Audit hardening (T1–T8)
Hash-chain tamper evidence (off by default, `verify-chain` made real); Parquet export/archival (replace the 501); per-channel retention overrides; tag-cascade for `ParentExecutionId` (thread writing-execution id through trigger-driven runs); ExecutionId/ParentExecutionId + SourceNode backfill on historical rows; per-node stuck-count KPIs; structured response capture (headers/content-type, inbound request headers, per-method opt-out, `AuditInboundCeilingHits` metric); CLI `audit tree`.
#### M6 — Notifications (T9–T11)
Teams + other non-Email delivery adapters behind the existing `INotificationDeliveryAdapter` seam; `NotificationType` enum values; Central UI notification-list `Type` selector; historical/trend KPI charts (introduce a time-series store).
#### M6 — KPI History & Trends (T11 delivered; T9/T10 deferred)
Reshaped during the 2026-06-17 brainstorm (see `docs/plans/2026-06-17-m6-kpi-history-design.md`):
- **T11 — DELIVERED** as the reusable **KPI-history backbone** (#26 KpiHistory), promoted from a notifications-only feature. A tall/EAV `KpiSample` store in **central MS SQL** (no new infra — supersedes the original "point-in-time only, no time-series store" stance), a `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated, best-effort with per-source isolation) sampling DI-registered `IKpiSampleSource`s every minute, a bucketed `GetRawSeriesAsync` + `KpiSeriesBucketer` query + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart` (no third-party charting lib). Trends shipped for **all** current KPI sources — Notification Outbox, Site Call Audit, Audit Log, and Site Health — across four UI surfaces.
- **T9 (Teams + other non-Email delivery adapters behind `INotificationDeliveryAdapter`) — DEFERRED to the next major version.** The seam exists; no code now. Transport choice (Incoming Webhook vs Microsoft Graph) and the Teams list-targeting model remain to be designed.
- **T10 (`NotificationType` enum values + Central UI notification-list `Type` selector) — DEFERRED with T9.** A Type selector has no purpose until a second delivery type exists.
#### M7 — OPC UA / MxGateway UX (T13–T17)
Dedicated operator Alarm Summary page; MxGateway secured writes (operator+verifier); OPC UA address-space search + `BrowseNext` paging; type-info surfacing + bulk override CSV import; "Verify endpoint" connectivity button + cert-management UI.
@@ -96,7 +99,7 @@ Template tree search/filter; folder drag-drop + sibling reorder + root context m
## Dependencies & sequencing
- **M1 → M5** — audit hardening builds on the wired purge/reconciliation.
- **M6/T11** — depends on introducing a time-series store (new infra; size carefully).
- **M6/T11** — delivered as the #26 KpiHistory backbone; reused **central MS SQL** (a tall/EAV `KpiSample` table) rather than introducing new infra. T9/T10 deferred to the next major version.
- **M9/T26** — base-template versioning is the largest authoring item; may split.
- **M4** — runs anytime; cheap and high-clarity, good to interleave.
- **M3** — independent; can run in parallel with M1/M2.
@@ -121,7 +124,7 @@ Template tree search/filter; folder drag-drop + sibling reorder + root context m
## Open items / risks
- M3 real-compile may surface latent invalid scripts in existing templates/fixtures — budget for fixture cleanup.
- M6 time-series store is the one genuinely-new piece of infrastructure; scope it deliberately (could reuse MS SQL with a rollup table rather than a new dependency).
- M6 KPI history (resolved): reused **central MS SQL** with a tall/EAV `KpiSample` table rather than a new dependency, so no genuinely-new infrastructure was introduced.
- The Phase 2 roadmap is large; treat each milestone as a separate planning + implementation pass, not a single mega-effort.
## Next step
@@ -0,0 +1,182 @@
# M6 — KPI History & Trends — Design
**Date:** 2026-06-17
**Milestone:** M6 (from `docs/plans/2026-06-15-stillpending-completion-design.md`)
**Status:** Approved — proceed to implementation plan.
## Summary
M6 was originally scoped as Notifications (T9 Teams adapter, T10 `NotificationType` enum
values + UI Type selector, T11 historical/trend KPI charts). During brainstorming the scope
was reshaped:
- **T9 (Teams + other non-Email delivery adapters)** — **DEFERRED to next major version**
(user decision 2026-06-17). The `INotificationDeliveryAdapter` seam already exists; no code
now. Transport choice (Incoming Webhook vs Microsoft Graph) and the Teams list-targeting
model remain to be designed.
- **T10 (`NotificationType` enum values + Central UI list "Type" selector)** — **DEFERRED with
T9.** A Type selector has no purpose until a second delivery type exists.
- **T11 (historical/trend KPI charts)** — promoted from a notifications-only feature into a
**reusable common KPI-history backbone** with trend charts shipped for **all** current KPI
sources.
This supersedes the original `docs/plans/notif.md` "KPI history — point-in-time only … no
separate time-series store is added" decision: the completion plan (T11) explicitly introduces
a store. We keep it in **central MS SQL** (the existing HA store) — no new infra dependency.
## Goal
A reusable central KPI-history backbone — tall/EAV store + periodic recorder + bucketed query
API + a custom SVG trend-chart component — with trend charts delivered for Notification Outbox,
Site Call Audit, Audit Log, and Site Health.
## Reuse map (why a common backbone)
Every source below is **point-in-time only today** (computed on demand, or only the latest kept
in memory). The backbone turns each into a trend with no per-source schema work:
| Source | Metrics (already computed) | Scope | Persisted today? |
|---|---|---|---|
| **Notification Outbox** | queue depth, stuck, parked, delivered/interval, oldest-pending age | global / per-site / per-node | on-demand only |
| **Site Call Audit** | buffered, parked, failed/interval, delivered/interval, oldest-pending, stuck | global / per-site / per-node | on-demand only |
| **Audit Log** | events/hour, error events/hour, backlog total | global | on-demand only |
| **Site Health** | connection up/down, tag-resolution good/bad, script errors, alarm-eval errors, S&F buffer depth, dead-letter count, parked count, deployed/enabled/disabled instances, audit backlog (pending/oldest/bytes), event-log write failures | per-site | only latest, in-memory |
Site Health is the largest latent win — sequence-numbered every 30s, but history is currently
discarded.
## Architecture
### New component: `#26 KpiHistory`
A small central component (mirrors NotificationOutbox / SiteCallAudit / AuditLog being their own
projects). Owns the recorder, options, and the `IKpiSampleSource` abstraction.
**Decoupling.** Each owning component registers its own `IKpiSampleSource` into DI; the recorder
enumerates `IEnumerable<IKpiSampleSource>` (same pattern as `INotificationDeliveryAdapter`). So
KpiHistory does **not** reference every component — each source impl lives with its owner and
calls that owner's existing `Compute…KpisAsync` methods (or, for Site Health, reads the
in-memory aggregator). CLAUDE.md component count goes 25 → 26.
### Schema — `KpiSample` (tall / EAV)
Commons entity, EF mapping + migration in ConfigurationDatabase, central MS SQL:
| Column | Type | Notes |
|---|---|---|
| `Id` | `bigint` PK identity | |
| `Source` | `varchar(64)` | `NotificationOutbox` / `SiteCallAudit` / `AuditLog` / `SiteHealth` |
| `Metric` | `varchar(64)` | per-source constant, e.g. `queueDepth`, `parkedCount`, `deadLetterCount` |
| `Scope` | `varchar(16)` | `Global` / `Site` / `Node` |
| `ScopeKey` | `varchar(64)` NULL | site id / node name; `NULL` for `Global` |
| `Value` | `float` | counts exact within range; ages stored as **seconds** |
| `CapturedAtUtc` | `datetime2` | recorder tick timestamp (UTC) |
Indexes:
- `IX_KpiSample_Series (Source, Metric, Scope, ScopeKey, CapturedAtUtc)` — per-series range query.
- `IX_KpiSample_Captured (CapturedAtUtc)` — retention purge.
### Recorder — `KpiHistoryRecorderActor` (central cluster singleton)
Runs on the active central node (consistent with the existing central singletons — Notification
Outbox, Site Call Audit, purge actors). Timer fires every `SampleIntervalSeconds` (default 60s).
Per tick:
1. Open a DI scope (scoped `DbContext`/repository — mirrors `NotificationOutboxActor`'s
scope-per-sweep pattern).
2. Enumerate registered `IKpiSampleSource`s; each returns `IReadOnlyList<KpiSample>` stamped with
the tick's `CapturedAtUtc`.
3. Write all samples via `IKpiHistoryRepository.RecordSamplesAsync`.
**Best-effort (R2).** Each source call and the write are individually wrapped; a failure logs and
skips that source's samples for the tick — it never throws into or disrupts the source component.
**Retention.** Daily purge (`PurgeIntervalHours`, default 24) deletes rows older than
`RetentionDays` (default 90), reusing the existing purge-scheduler shape. Hourly downsampling
beyond N days is deferred (YAGNI).
### Sample sources
- **`NotificationOutboxKpiSampleSource`** (in NotificationOutbox) →
`queueDepth, stuckCount, parkedCount, deliveredLastInterval, oldestPendingAgeSeconds`;
Global + per-Site + per-Node (reuses the M5 `ComputePerNodeKpisAsync`).
- **`SiteCallAuditKpiSampleSource`** (in SiteCallAudit) →
`buffered, parked, failedLastInterval, deliveredLastInterval, oldestPendingAgeSeconds, stuck`;
Global + per-Site + per-Node.
- **`AuditLogKpiSampleSource`** (in AuditLog) →
`totalEventsLastHour, errorEventsLastHour, backlogTotal`; Global.
- **`SiteHealthKpiSampleSource`** (in HealthMonitoring) → reads
`ICentralHealthAggregator.GetAllSiteStates()` (in-memory, no DB): per-Site
`connectionsUp/Down, tagsGood/Bad, scriptErrors, alarmEvalErrors, sfBufferDepth, deadLetters,
parkedMessages, deployedInstances/enabledInstances/disabledInstances, auditBacklogPending,
eventLogWriteFailures`.
### Query + UI
- **`IKpiHistoryRepository.GetSeriesAsync(source, metric, scope, scopeKey, fromUtc, toUtc,
maxPoints)`** → buckets `[from, toUtc]` into ≤ `maxPoints` buckets and returns
**last-value per bucket** (`KpiSeriesPoint(BucketStartUtc, Value)`). Last-value is correct for
gauge metrics; v1 uses one aggregation, avg/min/max deferred.
- **`KpiHistoryQueryService`** (CentralUI) — scoped-repo direct read with a dual-ctor test seam,
exactly like `AuditLogQueryService`.
- **`KpiTrendChart.razor`** — reusable custom **SVG** line/area component: polyline path, min/max
+ time-range axis labels, responsive `viewBox`, clean corporate styling (no third-party
charting lib, per CLAUDE.md). The time window (24h / 7d) is owned by the parent page.
- **Surfaces:** trend charts on the **Notification Outbox**, **Site Calls**, and **Audit Log**
pages, plus a per-site trend panel on the **Health dashboard**.
### Config — `KpiHistoryOptions`
`SampleIntervalSeconds` (60), `RetentionDays` (90), `PurgeIntervalHours` (24),
`DefaultMaxSeriesPoints` (200), with an options validator; bound on the central role in Host.
## Error handling
- Recorder: best-effort, per-source isolation (above). The KPI history is observability, never on
a user-facing critical path.
- Query: a query failure surfaces in the UI as an unavailable chart (em-dash / message), mirroring
how the existing KPI tiles surface transient failures — it never breaks the hosting page.
## Testing
- Recorder writes samples; **best-effort source-failure isolation** (a throwing source does not
abort the tick or other sources).
- Repository range/bucket-query correctness; retention purge deletes only aged rows.
- `KpiTrendChart` SVG render (unit/bUnit-style).
- One Playwright trend-chart UI test (per the M5–M10 testing strategy).
- Targeted tests per task (filtered tests + per-project builds); full-solution build at
integration.
## Docs & deploy
- New `docs/requirements/Component-KpiHistory.md` (#26).
- README component table + CLAUDE.md (25 → 26 + KPI-history bullet).
- Interactions updated in Component-NotificationOutbox / -SiteCallAudit / -AuditLog /
-HealthMonitoring / -CentralUI.
- Update `docs/plans/2026-06-15-stillpending-completion-design.md` (T9/T10 deferred; T11 →
KPI-history backbone).
- EF migration auto-applies in dev; cluster rebuild via `bash docker/deploy.sh` at integration.
## Execution housekeeping
- Work in the dedicated worktree `m6-kpi-history`, branched off **local `main` (639e331,
includes unpushed M5)** — not `origin/main`.
- Implementers commit **pathspec form** (`git commit -m "…" -- <paths>`), retry on
`.git/index.lock`.
- Keep concurrent committers to ≤ 2–3 and run a **post-wave HEAD-presence check** per the
concurrent-commit ref-race lesson.
## Deferred (next major version)
- **T9** — Teams (and other non-Email) delivery adapter behind `INotificationDeliveryAdapter`.
- **T10** — `NotificationType` enum values + Central UI list "Type" selector.
## Task list
- **MK-1 (first priority):** Common KPI-history/rollup store — backbone (schema + recorder +
repository + retention + chart component).
- **MK-2:** Notification Outbox trend charts (T11 first consumer) — blocked by MK-1.
- **MK-3:** Trend consumers — Site Call Audit / Audit Log / Site Health — blocked by MK-1.
(With the "charts for all sources" scope, MK-2 and MK-3 together deliver the full UI this
milestone; the implementation plan sequences them.)
+442
View File
@@ -0,0 +1,442 @@
# 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 ≈ 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` (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 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:
```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, 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: "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.
@@ -0,0 +1,23 @@
{
"planPath": "docs/plans/2026-06-17-m6-kpi-history.md",
"tasks": [
{"id": 1, "subject": "K1: Foundation contracts (Commons)", "nativeId": 136, "classification": "high-risk", "status": "pending"},
{"id": 2, "subject": "K2: Persistence — EF config + repository + migration", "nativeId": 137, "classification": "high-risk", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "K3: KpiHistory project scaffold + options", "nativeId": 138, "classification": "standard", "status": "pending", "blockedBy": [1]},
{"id": 4, "subject": "K4: KpiHistoryRecorderActor", "nativeId": 139, "classification": "high-risk", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "K5: Host wiring + appsettings", "nativeId": 140, "classification": "high-risk", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "K6: NotificationOutboxKpiSampleSource", "nativeId": 141, "classification": "small", "status": "pending", "blockedBy": [1]},
{"id": 7, "subject": "K7: SiteCallAuditKpiSampleSource", "nativeId": 142, "classification": "small", "status": "pending", "blockedBy": [1]},
{"id": 8, "subject": "K8: AuditLogKpiSampleSource", "nativeId": 143, "classification": "small", "status": "pending", "blockedBy": [1]},
{"id": 9, "subject": "K9: SiteHealthKpiSampleSource", "nativeId": 144, "classification": "standard", "status": "pending", "blockedBy": [1]},
{"id": 10, "subject": "K10: KpiSeriesBucketer (pure helper)", "nativeId": 145, "classification": "small", "status": "pending", "blockedBy": [1]},
{"id": 11, "subject": "K11: KpiHistoryQueryService (CentralUI)", "nativeId": 146, "classification": "standard", "status": "pending", "blockedBy": [2, 10]},
{"id": 12, "subject": "K12: KpiTrendChart.razor reusable SVG component", "nativeId": 147, "classification": "standard", "status": "pending", "blockedBy": [1]},
{"id": 13, "subject": "K13: Notification Outbox page trend section", "nativeId": 148, "classification": "standard", "status": "pending", "blockedBy": [11, 12]},
{"id": 14, "subject": "K14: Site Calls page trend section", "nativeId": 149, "classification": "standard", "status": "pending", "blockedBy": [11, 12]},
{"id": 15, "subject": "K15: Audit Log page trend section", "nativeId": 150, "classification": "standard", "status": "pending", "blockedBy": [11, 12]},
{"id": 16, "subject": "K16: Health dashboard per-site trend panel", "nativeId": 151, "classification": "standard", "status": "pending", "blockedBy": [11, 12]},
{"id": 17, "subject": "K17: Integration — docs, deploy, Playwright, full verification", "nativeId": 152, "classification": "high-risk", "status": "pending", "blockedBy": [5, 6, 7, 8, 9, 13, 14, 15, 16]}
],
"lastUpdated": "2026-06-17"
}
+5
View File
@@ -578,3 +578,8 @@ orphaned entries) and in the CLI's `audit tree` output.
`scadabridge audit backfill-source-node --sentinel <s> --before <date>`, and
`scadabridge audit verify-chain` (no-op placeholder for the deferred hash-chain
feature); same permission requirements as the UI.
- **[KPI History (#26)](Component-KpiHistory.md)** — emits `IKpiSampleSource`
(`AuditLogKpiSampleSource`, Global) consumed by the KpiHistory recorder (#26),
reusing the existing audit-KPI reads; the resulting `totalEventsLastHour` /
`errorEventsLastHour` / `backlogTotal` series render as trends on the Audit Log
page via `KpiTrendChart`.
+1
View File
@@ -249,3 +249,4 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
- **Notification Outbox**: Provides notification delivery KPIs and serves the `Notifications` table queries and Retry/Discard actions for the Notification Outbox page.
- **Site Call Audit**: Serves the `SiteCalls` table queries and relays Retry/Discard actions to sites for the Site Calls page.
- **Audit Log (#23)**: Serves all `AuditLog` table queries (filter / grid / drilldown / CSV export) for the new Audit Log page and the drill-in surfaces on Notifications, Site Calls, External Systems, Inbound API keys, Sites, and Instances. Payload capture, redaction, and per-site authorization follow the Audit Log component's "Payload Capture Policy" and "Security & Tamper-Evidence" sections.
- **KPI History (#26)**: The Central UI hosts the `KpiHistoryQueryService` (scoped-repository read over `IKpiHistoryRepository.GetRawSeriesAsync` + `KpiSeriesBucketer`, dual-ctor test seam) and renders the reusable custom-SVG `KpiTrendChart` fed by it. Trend sections appear on the Notification Outbox, Site Calls, and Audit Log pages and in a per-site panel on the Health Monitoring dashboard; a query failure degrades to an unavailable-chart placeholder rather than breaking the page. See [Component-KpiHistory.md](Component-KpiHistory.md).
@@ -116,3 +116,4 @@ These tiles are **point-in-time** like the Notification Outbox and Site Call Aud
- **Central UI**: Health Monitoring Dashboard displays aggregated metrics.
- **Communication Layer**: Health reports flow as periodic messages.
- **KPI History (#26)**: emits `IKpiSampleSource` (`SiteHealthKpiSampleSource`, per-Site) consumed by the KpiHistory recorder (#26). It reads the in-memory `ICentralHealthAggregator.GetAllSiteStates()` (no DB), turning the per-site snapshot — previously sequence-numbered every 30s but discarded — into trends (`connectionsUp`/`connectionsDown`, `scriptErrors`, `alarmEvalErrors`, `sfBufferDepth`, `deadLetters`, `parkedMessages`, `deployedInstances`/`enabledInstances`/`disabledInstances`, `auditBacklogPending`, `eventLogWriteFailures`) rendered in the dashboard's per-site `KpiTrendChart` panel. See [Component-KpiHistory.md](Component-KpiHistory.md).
+168
View File
@@ -0,0 +1,168 @@
# Component: KPI History
## Purpose
The KPI History component is the central, reusable **KPI-history backbone** — a tall / EAV time-series store, a periodic recorder singleton, a bucketed query API, and a reusable custom SVG trend-chart component. It turns the system's existing point-in-time KPIs into trends, and ships those trends for the **Notification Outbox (#21)**, **Site Call Audit (#22)**, **Audit Log (#23)**, and **Site Health (#11)** sources.
This supersedes the earlier "KPI history — point-in-time only, no separate time-series store is added" stance carried by the Notification Outbox and Site Call Audit KPI sections. M6 explicitly introduces a store. It lives in **central MS SQL** — the existing HA store — so it adds **no new infrastructure dependency**: a single `KpiSample` table, an EF mapping + migration, and a central cluster singleton that samples every minute.
The backbone is deliberately source-agnostic. Each owning component contributes an `IKpiSampleSource` registered into DI; the recorder enumerates them. KPI History therefore does **not** reference every component, and every source reuses the KPI reads its owner already computes — no per-source schema or storage work.
## Location
- `src/ZB.MOM.WW.ScadaBridge.KpiHistory` — the component project: the `KpiHistoryRecorderActor`, `KpiHistoryOptions` + validator, and the DI/options wiring (`ServiceCollectionExtensions`). It owns the recorder, the options, and consumes the `IKpiSampleSource` abstraction (defined in Commons).
- **`IKpiSampleSource` implementations live with their owners**, not here — `NotificationOutboxKpiSampleSource` (in NotificationOutbox), `SiteCallAuditKpiSampleSource` (in SiteCallAudit), `AuditLogKpiSampleSource` (in AuditLog), `SiteHealthKpiSampleSource` (in HealthMonitoring). Each registers itself via `TryAddEnumerable`.
- **Commons** — the `KpiSample` POCO entity (`Entities/Kpi`), the `IKpiSampleSource` and `IKpiHistoryRepository` interfaces (`Interfaces/Kpi`), and the `KpiSources` / `KpiScopes` constant catalogs + `KpiSeriesPoint` / `KpiSeriesBucketer` types (`Types/Kpi`).
- **Configuration Database** — the EF mapping (`KpiSampleEntityTypeConfiguration`), the migration that creates the `KpiSample` table + indexes, and the `KpiHistoryRepository` implementation.
- **Central UI** — the `KpiHistoryQueryService` query service and the reusable `KpiTrendChart.razor` component, plus the trend sections embedded on four surfaces.
The recorder is a **singleton on the active central node**, consistent with the other central singletons (Notification Outbox, Site Call Audit, purge actors).
## Responsibilities
- Own the `KpiSample` table — the central tall / EAV KPI-history store in MS SQL.
- Run the recorder loop: every `SampleInterval`, enumerate all registered `IKpiSampleSource`s and persist their samples stamped with one shared tick timestamp.
- Isolate sources from one another and from the store: a failure in any one source (or in the write) is logged and skipped for that tick and never disrupts the source component or the rest of the tick (best-effort observability).
- Purge aged rows on a daily cadence (`PurgeInterval`) older than `RetentionDays`.
- Provide a bucketed series-query API (`IKpiHistoryRepository.GetRawSeriesAsync` + `KpiSeriesBucketer`) and the Central UI query service + reusable trend chart that consume it.
KPI History is **observability, never a user-facing critical path** — neither recording nor querying may ever break a hosting page or disrupt a source component.
## Schema — `KpiSample` (tall / EAV)
A persistence-ignorant POCO in Commons; EF mapping + migration in Configuration Database; one table in central MS SQL. One row per `(Source, Metric, Scope, ScopeKey)` per recorder tick:
| Column | Type | Notes |
|---|---|---|
| `Id` | `bigint` PK identity | Surrogate key assigned by the store. |
| `Source` | `varchar(64)` | Owning source — a `KpiSources` constant: `NotificationOutbox` / `SiteCallAudit` / `AuditLog` / `SiteHealth`. |
| `Metric` | `varchar(64)` | Per-source metric name, e.g. `queueDepth`, `parkedCount`, `deadLetters` — drawn from each source's own metric catalog. |
| `Scope` | `varchar(16)` | A `KpiScopes` constant: `Global` / `Site` / `Node`. |
| `ScopeKey` | `varchar(64)` NULL | Site id (for `Site`) or node name (for `Node`); `NULL` for `Global`. |
| `Value` | `float` (`double`) | Counts carried exactly within range; ages stored as **seconds**. |
| `CapturedAtUtc` | `datetime2` | The recorder tick timestamp (UTC), shared across every sample in one tick. |
All timestamps are UTC, consistent with the system-wide convention.
Two named indexes back the access paths:
- **`IX_KpiSample_Series` (`Source`, `Metric`, `Scope`, `ScopeKey`, `CapturedAtUtc`)** — the per-series range query (one series scanned in time order).
- **`IX_KpiSample_Captured` (`CapturedAtUtc`)** — the retention purge.
## Recorder — `KpiHistoryRecorderActor`
The recorder is the Akka.NET cluster singleton **`kpi-history-recorder`** (singleton-manager actor `kpi-history-recorder-singleton`), running on the active central node. It is **not readiness-gated** — the recorder is pure observability and must never gate `/health/ready`, so it is started outside the readiness barrier (unlike the operational singletons). On graceful shutdown it drains via a `CoordinatedShutdown` task for clean singleton handover.
A timer fires every `SampleInterval` (default 60s; an immediate first tick primes the series, then it settles into the periodic cadence). On each tick the recorder:
1. Opens a **per-tick DI scope** (scoped `DbContext`/repository — the same scope-per-sweep pattern as the `NotificationOutboxActor`).
2. Enumerates the registered `IEnumerable<IKpiSampleSource>`. Each source returns an `IReadOnlyList<KpiSample>` stamped with the tick's single `CapturedAtUtc`.
3. Writes all collected samples via `IKpiHistoryRepository.RecordSamplesAsync`.
**Best-effort, per-source isolation.** Each source call and the write are individually guarded. A throwing source is logged and its samples skipped for that tick; it never aborts the tick, the other sources, or the source component itself. This is the same `IEnumerable<>`-of-adapters decoupling pattern used by `INotificationDeliveryAdapter`.
**Retention.** A daily purge timer (`PurgeInterval`, default 24h) deletes rows older than `RetentionDays` (default 90) via `IKpiHistoryRepository.PurgeOlderThanAsync`, reusing the existing purge-scheduler shape. Hourly/longer-range downsampling is deferred (YAGNI).
## Sample Sources
Each `IKpiSampleSource` lives in its owning component and is registered into DI with `TryAddEnumerable` (idempotent, additive). Each reuses the KPI reads its owner already performs — the Notification Outbox / Site Call Audit / Audit Log sources call their owners' existing `Compute…KpisAsync` aggregator reads; the Site Health source reads the in-memory `ICentralHealthAggregator` (no DB read). `Value` carries counts exactly and ages as seconds; all metric names below are the exact shipped strings.
### `NotificationOutboxKpiSampleSource` (in NotificationOutbox)
Scopes: **Global + per-Site + per-Node** (the per-node breakdown reuses the M5 `ComputePerNodeKpisAsync`).
- `queueDepth`
- `stuckCount`
- `parkedCount`
- `deliveredLastInterval`
- `oldestPendingAgeSeconds`
### `SiteCallAuditKpiSampleSource` (in SiteCallAudit)
Scopes: **Global + per-Site + per-Node**.
- `buffered`
- `parked`
- `failedLastInterval`
- `deliveredLastInterval`
- `stuck`
- `oldestPendingAgeSeconds`
### `AuditLogKpiSampleSource` (in AuditLog)
Scope: **Global**.
- `totalEventsLastHour`
- `errorEventsLastHour`
- `backlogTotal`
### `SiteHealthKpiSampleSource` (in HealthMonitoring)
Reads `ICentralHealthAggregator.GetAllSiteStates()` (in-memory, no DB). Scope: **per-Site** — the largest latent win, since Site Health was previously sequence-numbered every 30s but its history discarded.
- `connectionsUp`
- `connectionsDown`
- `scriptErrors`
- `alarmEvalErrors`
- `sfBufferDepth`
- `deadLetters`
- `parkedMessages`
- `deployedInstances`
- `enabledInstances`
- `disabledInstances`
- `auditBacklogPending`
- `eventLogWriteFailures`
## Query + UI
### Bucketed query
`IKpiHistoryRepository.GetRawSeriesAsync(source, metric, scope, scopeKey, fromUtc, toUtc, …)` returns the raw points for one series over `[fromUtc, toUtc]`. `KpiSeriesBucketer.Bucket(raw, fromUtc, toUtc, maxPoints)` then partitions the window into ≤ `maxPoints` time buckets and returns the **last value per bucket** as `KpiSeriesPoint(BucketStartUtc, Value)`. Last-value is correct for gauge metrics; v1 ships exactly one aggregation — avg / min / max are deferred.
### `KpiHistoryQueryService` (Central UI)
A scoped-repository direct read with a **dual-constructor test seam** (one ctor resolves a scoped `IKpiHistoryRepository` per call; the other accepts an injected repository for tests) — the same shape as `AuditLogQueryService`. `GetSeriesAsync` resolves the effective point cap (caller override or `KpiHistoryOptions.DefaultMaxSeriesPoints`), fetches the raw series, and reduces it via `KpiSeriesBucketer`. A query failure surfaces as an unavailable chart (em-dash / message), mirroring how the existing KPI tiles surface transient failures — it never breaks the hosting page.
### `KpiTrendChart.razor` (Central UI)
A reusable **custom inline-SVG** line/area chart — a polyline path with min/max + time-range axis labels, a responsive `viewBox`, and clean corporate styling. There is **no third-party charting library** (per the CLAUDE.md no-third-party-component-framework rule). The time window (e.g. 24h / 7d) is owned by the parent page.
### Surfaces
Trend sections render on four pages, each feeding `KpiTrendChart` from `KpiHistoryQueryService`:
- **Notification Outbox** page — outbox KPI trends.
- **Site Calls** page — cached-call KPI trends.
- **Audit Log** page — audit volume / error / backlog trends.
- **Health dashboard** — a per-site Site Health trend panel.
## Configuration — `KpiHistoryOptions`
Bound from the `ScadaBridge:KpiHistory` section on the central host (Options pattern), validated on startup by `KpiHistoryOptionsValidator`:
| Option | Default | Notes |
|---|---|---|
| `SampleInterval` | `60s` | Recorder tick cadence. Must be `> 0`. |
| `RetentionDays` | `90` | Rows older than this are purged. Bounded to `[1, 3650]` days. |
| `PurgeInterval` | `1d` | Daily purge cadence. Must be `> 0`. |
| `DefaultMaxSeriesPoints` | `200` | Default bucket cap for a series query when the caller does not override it. Bounded to `[2, 5000]`. |
Validation fails fast at startup on a non-positive `SampleInterval` / `PurgeInterval` (which would stall the recorder / purge), an out-of-range `RetentionDays` (too short loses history; too long defeats retention), or an out-of-range `DefaultMaxSeriesPoints`.
## Dependencies
- **Commons**: defines the `KpiSample` entity, the `IKpiSampleSource` and `IKpiHistoryRepository` interfaces, the `KpiSources` / `KpiScopes` catalogs, and the `KpiSeriesPoint` / `KpiSeriesBucketer` query types.
- **Configuration Database**: hosts the `KpiSample` table, its EF mapping, the migration, and the `KpiHistoryRepository` implementation.
- **Cluster Infrastructure**: hosts the `kpi-history-recorder` cluster singleton with active/standby failover.
- **Host**: binds `KpiHistoryOptions`, registers the component on the central role, and starts the recorder singleton **outside** the readiness barrier.
- **Notification Outbox / Site Call Audit / Audit Log / Health Monitoring**: each contributes an `IKpiSampleSource` and the KPI/aggregator reads it reuses. KPI History depends on the `IKpiSampleSource` abstraction, not on these components directly.
- **Central UI**: hosts `KpiHistoryQueryService` and the `KpiTrendChart` component.
## Interactions
- **Notification Outbox (#21)**: registers `NotificationOutboxKpiSampleSource` (Global / Site / Node), sampled each recorder tick; its trends render on the Notification Outbox page.
- **Site Call Audit (#22)**: registers `SiteCallAuditKpiSampleSource` (Global / Site / Node); its trends render on the Site Calls page.
- **Audit Log (#23)**: registers `AuditLogKpiSampleSource` (Global); its trends render on the Audit Log page.
- **Health Monitoring (#11)**: registers `SiteHealthKpiSampleSource` (per-Site), reading the in-memory central health aggregator; its trends render in the Health dashboard's per-site panel.
- **Central UI (#9)**: renders the reusable `KpiTrendChart` fed by `KpiHistoryQueryService` across the four trend surfaces; a query failure degrades to an unavailable-chart placeholder rather than breaking the page.
- **Cluster Infrastructure (#13)**: provides the active/standby singleton hosting for the recorder, which drains on `CoordinatedShutdown` for clean handover.
@@ -190,3 +190,4 @@ Delivery max-retry-count and retry interval are not part of `NotificationOutboxO
- **Notification Service**: Supplies delivery adapters and resolves notification lists at delivery time.
- **Central UI**: Queries the `Notifications` table for the Notification Outbox page and issues operator Retry/Discard actions on parked notifications.
- **Health Monitoring**: Polls the outbox for KPI tiles on the health dashboard.
- **KPI History (#26)**: Emits `IKpiSampleSource` (`NotificationOutboxKpiSampleSource`, Global + per-Site + per-Node) consumed by the KpiHistory recorder (#26), reusing the existing `Compute…KpisAsync` reads; the resulting `queueDepth` / `stuckCount` / `parkedCount` / `deliveredLastInterval` / `oldestPendingAgeSeconds` series render as trends on the Notification Outbox page via `KpiTrendChart`. See [Component-KpiHistory.md](Component-KpiHistory.md).
@@ -146,3 +146,9 @@ configurable window (default 365 days), matching the `Notifications` purge.
- **Health Monitoring**: surfaces Site Call Audit KPI tiles on the dashboard.
- **Cluster Infrastructure**: hosts the `SiteCallAuditActor` singleton with
active/standby failover.
- **KPI History (#26)**: emits `IKpiSampleSource`
(`SiteCallAuditKpiSampleSource`, Global + per-Site + per-Node) consumed by the
KpiHistory recorder (#26), reusing the existing KPI reads; the resulting
`buffered` / `parked` / `failedLastInterval` / `deliveredLastInterval` /
`stuck` / `oldestPendingAgeSeconds` series render as trends on the Site Calls
page via `KpiTrendChart`. See [Component-KpiHistory.md](Component-KpiHistory.md).
@@ -0,0 +1,97 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Kpi;
/// <summary>
/// Audit Log (#23) M6 "KPI History &amp; Trends" (K8) — the
/// <see cref="IKpiSampleSource"/> for the <see cref="KpiSources.AuditLog"/> source.
/// The central recorder singleton enumerates this provider once per sampling pass
/// and persists its samples into the central <c>KpiSample</c> history table.
/// </summary>
/// <remarks>
/// <para>
/// Snapshots the Audit Log volume + error-rate counts the live Health-dashboard
/// "Audit" tiles already read (<see cref="IAuditLogRepository.GetKpiSnapshotAsync"/>)
/// over the same trailing <see cref="Window"/>, plus the global pending backlog the
/// snapshot carries. All three metrics are <see cref="KpiScopes.Global"/> only — the
/// snapshot is a system-wide aggregate with no per-site / per-node breakdown, so
/// <see cref="KpiSample.ScopeKey"/> is always <c>null</c>.
/// </para>
/// <para>
/// The trailing window is anchored on <c>capturedAtUtc</c> (passed as the snapshot's
/// <c>nowUtc</c>) so the sample reflects the same instant the recorder stamps every
/// other source with — deterministic across the whole sampling pass rather than the
/// repository's server-side <c>UtcNow</c> at call time.
/// </para>
/// </remarks>
public sealed class AuditLogKpiSampleSource : IKpiSampleSource
{
/// <summary>
/// Trailing window for the volume + error-rate aggregate. Fixed at 1 hour to
/// match the live Audit KPI tiles (<c>AuditLogQueryService.KpiWindow</c>) and
/// the <c>…LastHour</c> metric names below.
/// </summary>
private static readonly TimeSpan Window = TimeSpan.FromHours(1);
// Metric catalog — the exact strings persisted in KpiSample.Metric. Stable
// identifiers the recorder + UI trend charts key on; do not rename.
private const string TotalEventsLastHourMetric = "totalEventsLastHour";
private const string ErrorEventsLastHourMetric = "errorEventsLastHour";
private const string BacklogTotalMetric = "backlogTotal";
private readonly IAuditLogRepository _repository;
/// <summary>
/// Initializes a new instance of the <see cref="AuditLogKpiSampleSource"/> class.
/// </summary>
/// <param name="repository">
/// Append-only Audit Log repository — its <see cref="IAuditLogRepository.GetKpiSnapshotAsync"/>
/// supplies the volume, error, and backlog counts snapshotted here.
/// </param>
public AuditLogKpiSampleSource(IAuditLogRepository repository)
{
ArgumentNullException.ThrowIfNull(repository);
_repository = repository;
}
/// <inheritdoc/>
public string Source => KpiSources.AuditLog;
/// <inheritdoc/>
public async Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc,
CancellationToken cancellationToken = default)
{
var snapshot = await _repository
.GetKpiSnapshotAsync(Window, nowUtc: capturedAtUtc, cancellationToken)
.ConfigureAwait(false);
// Defensive: a null snapshot would mean the repo broke its non-null
// contract, but the source must degrade to "nothing to report" rather
// than NRE the whole sampling pass.
if (snapshot is null)
{
return [];
}
return
[
Sample(TotalEventsLastHourMetric, snapshot.TotalEventsLastHour, capturedAtUtc),
Sample(ErrorEventsLastHourMetric, snapshot.ErrorEventsLastHour, capturedAtUtc),
Sample(BacklogTotalMetric, snapshot.BacklogTotal, capturedAtUtc),
];
}
private static KpiSample Sample(string metric, double value, DateTime capturedAtUtc) => new()
{
Source = KpiSources.AuditLog,
Metric = metric,
Scope = KpiScopes.Global,
ScopeKey = null,
Value = value,
CapturedAtUtc = capturedAtUtc,
};
}
@@ -7,10 +7,12 @@ using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Kpi;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
@@ -234,6 +236,18 @@ public static class ServiceCollectionExtensions
// provider as a singleton on both site and central paths.
sp.GetRequiredService<INodeIdentityProvider>()));
// M6 (K8) KPI History & Trends: the AuditLog source the central KPI
// recorder enumerates each sampling pass to snapshot Global-scope Audit
// Log KPIs (volume / error rate / backlog) into the KpiSample history
// table. Scoped to match its IAuditLogRepository dependency — a SCOPED
// EF Core service; the recorder opens a fresh scope per sampling pass.
// TryAdd-Enumerable so multiple sources can register the same interface
// (NotificationOutbox + SiteCallAudit + SiteHealth add their own) without
// any one composition root clobbering another, and re-registration is a
// no-op if AddAuditLog were ever called twice.
services.TryAddEnumerable(
ServiceDescriptor.Scoped<IKpiSampleSource, AuditLogKpiSampleSource>());
return services;
}
@@ -1,6 +1,7 @@
@page "/audit/log"
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Security
@@ -11,6 +12,56 @@
<div class="container-fluid mt-3">
<h1 class="h4 mb-3">Audit Log</h1>
@* Trends panel (M6 / K15). A best-effort collapsible Bootstrap card sitting
above the audit query UI: one KpiTrendChart per AuditLog global metric over
a 24h (default) / 7d window. Series are fetched independently in the
code-behind — a failed fetch degrades only that chart to the unavailable
placeholder and never breaks the filter bar / grid below. *@
<div class="card mb-3" data-test="audit-trends">
<div class="card-header d-flex justify-content-between align-items-center">
<button type="button"
class="btn btn-link p-0 text-decoration-none fw-semibold"
aria-expanded="@_trendsOpen"
@onclick="ToggleTrendsAsync">
Trends
<span class="text-muted small">@(_trendsOpen ? "▾" : "▸")</span>
</button>
@if (_trendsOpen)
{
<div class="btn-group btn-group-sm" role="group" aria-label="Trend window">
<button type="button"
class="btn @(_windowHours == 24 ? "btn-secondary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(24)" disabled="@_trendsLoading">
24h
</button>
<button type="button"
class="btn @(_windowHours == 168 ? "btn-secondary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(168)" disabled="@_trendsLoading">
7d
</button>
</div>
}
</div>
@if (_trendsOpen)
{
<div class="card-body">
<div class="row g-3">
@foreach (var (metric, title, unit) in TrendMetrics)
{
var series = SeriesFor(metric);
<div class="col-12 col-md-4">
<KpiTrendChart Points="@series.Points"
Title="@title"
Unit="@unit"
IsAvailable="@series.IsAvailable"
ErrorMessage="@series.ErrorMessage" />
</div>
}
</div>
</div>
}
</div>
@* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid.
Bundle D (M7-T10..T12) threads a query-string instance prefill through
InitialInstanceSearch — UI-only because the filter contract has no instance column. *@
@@ -2,9 +2,11 @@ using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit;
@@ -48,6 +50,17 @@ public partial class AuditLogPage : IDisposable
{
[Inject] private NavigationManager Navigation { get; set; } = null!;
/// <summary>
/// KPI-history facade for the M6 (K15) Trends panel — fetched per metric,
/// per window. Best-effort: a failed series fetch degrades that one chart to
/// the unavailable placeholder and never breaks the audit query UI.
/// </summary>
[Inject] private IKpiHistoryQueryService KpiHistory { get; set; } = null!;
/// <summary>Logger for the best-effort Trends panel — a degraded series fetch
/// is logged at warning level so the silent fallback is still observable.</summary>
[Inject] private ILogger<AuditLogPage> Logger { get; set; } = null!;
private AuditLogQueryFilter? _currentFilter;
private AuditEventView? _selectedEvent;
private bool _drawerOpen;
@@ -60,6 +73,18 @@ public partial class AuditLogPage : IDisposable
Navigation.LocationChanged += HandleLocationChanged;
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
// The Trends panel is open by default, so load its series on first render.
// Done in OnInitializedAsync (not OnInitialized) so the per-metric awaits
// do not block the synchronous query-string drill-in parsing above.
if (_trendsOpen)
{
await LoadTrendsAsync();
}
}
/// <summary>
/// Re-applies the query-string drill-in filters when the URL changes while
/// this page stays routed (e.g. the drawer's "View parent execution" action
@@ -237,6 +262,110 @@ public partial class AuditLogPage : IDisposable
_drawerOpen = false;
}
// ─────────────────────────────────────────────────────────────────────────
// M6 (K15) — Audit Log trend charts.
//
// A best-effort Trends panel that sits above the audit query UI: one
// KpiTrendChart per AuditLog global metric, over a 24h (default) or 7d
// window. Each series is fetched independently and a failure degrades only
// that chart (IsAvailable=false + short message) — the audit query UI is
// never affected. Re-queries on init (panel open by default), on the window
// toggle, and when the panel is re-shown after being collapsed.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Whether the Trends panel is expanded (open by default).</summary>
private bool _trendsOpen = true;
/// <summary>
/// True while a window's series are being (re)fetched — disables the 24h/7d
/// window toggle buttons so a mid-flight click cannot stack overlapping loads
/// (mirrors the K13/K14 trend pages).
/// </summary>
private bool _trendsLoading;
/// <summary>Active window in hours — 24 (default) or 168 (7 days).</summary>
private int _windowHours = 24;
/// <summary>One rendered chart's state: the fetched series + availability.</summary>
private readonly record struct TrendSeries(
IReadOnlyList<KpiSeriesPoint>? Points, bool IsAvailable, string? ErrorMessage);
/// <summary>The metrics rendered in the panel, in display order.</summary>
private static readonly (string Metric, string Title, string? Unit)[] TrendMetrics =
{
("totalEventsLastHour", "Events / hour", null),
("errorEventsLastHour", "Error events / hour", null),
("backlogTotal", "Backlog", null),
};
/// <summary>Per-metric series state, keyed by the metric name.</summary>
private readonly Dictionary<string, TrendSeries> _trendSeries = new();
/// <summary>
/// Fetches every Trends metric for the current window. Each
/// <see cref="IKpiHistoryQueryService.GetSeriesAsync"/> call is wrapped so a
/// failure surfaces as an unavailable chart rather than throwing out of the
/// page — audit-log functionality must never depend on KPI history being up.
/// </summary>
private async Task LoadTrendsAsync()
{
_trendsLoading = true;
try
{
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
foreach (var (metric, _, _) in TrendMetrics)
{
try
{
var points = await KpiHistory.GetSeriesAsync(
KpiSources.AuditLog, metric, KpiScopes.Global, scopeKey: null,
fromUtc, toUtc);
_trendSeries[metric] = new TrendSeries(points, IsAvailable: true, ErrorMessage: null);
}
catch (Exception ex)
{
// Best-effort: degrade this chart only, keep the rest of the page
// alive — but log the failure so the silent fallback is observable.
Logger.LogWarning(ex, "Failed to load Audit Log KPI trend series for metric {Metric}.", metric);
_trendSeries[metric] = new TrendSeries(
Points: null, IsAvailable: false, ErrorMessage: "Trend data unavailable.");
}
}
}
finally
{
_trendsLoading = false;
}
}
/// <summary>Returns the rendered state for a metric, defaulting to available-empty.</summary>
private TrendSeries SeriesFor(string metric) =>
_trendSeries.TryGetValue(metric, out var s) ? s : new TrendSeries(null, true, null);
/// <summary>Expands/collapses the panel; re-queries when it is (re)shown.</summary>
private async Task ToggleTrendsAsync()
{
_trendsOpen = !_trendsOpen;
if (_trendsOpen)
{
await LoadTrendsAsync();
}
}
/// <summary>Switches the window (24h ↔ 7d) and re-queries every series.</summary>
private async Task SetWindowAsync(int hours)
{
if (_windowHours == hours)
{
return;
}
_windowHours = hours;
await LoadTrendsAsync();
}
/// <summary>
/// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most
/// recently applied filter as query-string params so the server-side
@@ -10,12 +10,14 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi
@using ZB.MOM.WW.ScadaBridge.Communication
@implements IDisposable
@inject ICentralHealthAggregator HealthAggregator
@inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
@inject IAuditLogQueryService AuditLogQueryService
@inject IKpiHistoryQueryService KpiHistory
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -76,6 +78,75 @@
IsAvailable="@_auditKpiAvailable"
ErrorMessage="@_auditKpiError" />
@* Site Health Trends (M6 K16) — per-site Site Health KPI history. Loads on a
separate path from the 10s tile-refresh timer so a trend-query fault can
never disturb the live dashboard or its polling loop. The site selector
reuses the site keys already loaded into _siteStates; the window toggle
drives the time range. Both re-query independently. *@
<div class="card mb-3" data-test="site-health-trends">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<div class="d-flex align-items-center">
<h6 class="text-muted mb-0 me-3">Site Health Trends</h6>
@if (_trendSiteKeys.Count > 0)
{
<select class="form-select form-select-sm" style="width:auto"
data-test="site-health-trends-site"
value="@_trendSiteId"
@* Blazor wraps this async handler in an EventCallback, so the returned Task IS awaited — keep the Async signature; do not change it to a void/fire-and-forget handler. *@
@onchange="OnTrendSiteChangedAsync">
@foreach (var key in _trendSiteKeys)
{
<option value="@key">@TrendSiteLabel(key)</option>
}
</select>
}
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Trend window">
<button type="button"
class="btn @(_trendWindowHours == 24 ? "btn-primary" : "btn-outline-secondary")"
@onclick="() => SetTrendWindowAsync(24)" disabled="@_trendsLoading">24h</button>
<button type="button"
class="btn @(_trendWindowHours == 168 ? "btn-primary" : "btn-outline-secondary")"
@onclick="() => SetTrendWindowAsync(168)" disabled="@_trendsLoading">7d</button>
</div>
</div>
<div class="card-body p-3">
@if (_trendSiteKeys.Count == 0)
{
<span class="text-muted small">No sites available for trends yet.</span>
}
else
{
<div class="row g-3">
<div class="col-lg-3 col-md-6">
<KpiTrendChart Title="Connections Down"
Points="@_connectionsDownSeries"
IsAvailable="@_connectionsDownAvailable"
ErrorMessage="@_connectionsDownError" />
</div>
<div class="col-lg-3 col-md-6">
<KpiTrendChart Title="Dead Letters"
Points="@_deadLettersSeries"
IsAvailable="@_deadLettersAvailable"
ErrorMessage="@_deadLettersError" />
</div>
<div class="col-lg-3 col-md-6">
<KpiTrendChart Title="Script Errors"
Points="@_scriptErrorsSeries"
IsAvailable="@_scriptErrorsAvailable"
ErrorMessage="@_scriptErrorsError" />
</div>
<div class="col-lg-3 col-md-6">
<KpiTrendChart Title="S&amp;F Buffer Depth"
Points="@_sfBufferDepthSeries"
IsAvailable="@_sfBufferDepthAvailable"
ErrorMessage="@_sfBufferDepthError" />
</div>
</div>
}
</div>
</div>
@if (_siteStates.Count == 0)
{
<div class="alert alert-info">No site health reports received yet.</div>
@@ -387,6 +458,36 @@
Array.Empty<SiteCallNodeKpiSnapshot>();
private bool _siteCallNodeKpiAvailable;
// ── Site Health Trends (M6 K16) ───────────────────────────────────────────
// Per-site Site Health KPI history, loaded on a path entirely separate from
// the 10s tile-refresh timer (LoadSiteHealthTrendsAsync, never called from
// the timer tick). The site keys are a snapshot of the dashboard's site set,
// captured each time trends load so the selector mirrors the live cards.
// Window in hours: 24h (default) or 168h (7d). Changing the selected site OR
// the window re-queries. Each metric chart carries its own availability +
// error so one failed GetSeriesAsync degrades a single chart, never the
// dashboard.
private IReadOnlyList<string> _trendSiteKeys = Array.Empty<string>();
private string? _trendSiteId;
private int _trendWindowHours = 24;
private bool _trendsLoading;
private IReadOnlyList<KpiSeriesPoint>? _connectionsDownSeries;
private bool _connectionsDownAvailable = true;
private string? _connectionsDownError;
private IReadOnlyList<KpiSeriesPoint>? _deadLettersSeries;
private bool _deadLettersAvailable = true;
private string? _deadLettersError;
private IReadOnlyList<KpiSeriesPoint>? _scriptErrorsSeries;
private bool _scriptErrorsAvailable = true;
private string? _scriptErrorsError;
private IReadOnlyList<KpiSeriesPoint>? _sfBufferDepthSeries;
private bool _sfBufferDepthAvailable = true;
private string? _sfBufferDepthError;
private static bool SiteHasActiveErrors(SiteHealthState state)
{
var report = state.LatestReport;
@@ -410,6 +511,13 @@
}
await RefreshNow();
// Site Health Trends (M6 K16) load on their own path — never from the
// timer tick below — so a trend-query fault can't disturb the live tile
// refresh. Seed the selector from the sites just loaded into _siteStates
// and query the default site.
await LoadSiteHealthTrendsAsync();
_refreshTimer = new Timer(_ =>
{
InvokeAsync(async () =>
@@ -420,6 +528,109 @@
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
}
// Re-query when the operator picks a different site. Best-effort: the load
// itself swallows faults per-chart.
private async Task OnTrendSiteChangedAsync(ChangeEventArgs e)
{
var selected = e.Value?.ToString();
if (string.IsNullOrEmpty(selected) || selected == _trendSiteId)
{
return;
}
_trendSiteId = selected;
await LoadSiteHealthTrendsAsync(refreshSiteKeys: false);
}
// Re-query when the window toggle changes (24h ↔ 7d).
private async Task SetTrendWindowAsync(int windowHours)
{
if (_trendWindowHours == windowHours)
{
return;
}
_trendWindowHours = windowHours;
await LoadSiteHealthTrendsAsync(refreshSiteKeys: false);
}
// Loads the four Site Health trend series for the selected site over the
// selected window. Deliberately decoupled from RefreshNow / the 10s timer:
// a fault here degrades the affected chart(s) only and never propagates to
// the tile-refresh loop.
//
// refreshSiteKeys re-snapshots the dashboard's site set into the selector
// (true on init); the site-change / window-toggle paths pass false so a
// mid-interaction site addition/removal can't yank the operator's choice.
private async Task LoadSiteHealthTrendsAsync(bool refreshSiteKeys = true)
{
if (refreshSiteKeys)
{
// Mirror the dashboard ordering: central cluster pinned first, then
// sites alphabetically — the same comparer the detail cards use.
_trendSiteKeys = _siteStates.Keys
.OrderBy(k => k == CentralHealthReportLoop.CentralSiteId ? 0 : 1)
.ThenBy(k => k)
.ToList();
// Default to the first site (or keep a still-valid prior selection).
if (_trendSiteId == null || !_trendSiteKeys.Contains(_trendSiteId))
{
_trendSiteId = _trendSiteKeys.FirstOrDefault();
}
}
if (string.IsNullOrEmpty(_trendSiteId))
{
return;
}
_trendsLoading = true;
try
{
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_trendWindowHours);
var siteId = _trendSiteId;
(_connectionsDownSeries, _connectionsDownAvailable, _connectionsDownError) =
await LoadTrendSeriesAsync("connectionsDown", siteId, fromUtc, toUtc);
(_deadLettersSeries, _deadLettersAvailable, _deadLettersError) =
await LoadTrendSeriesAsync("deadLetters", siteId, fromUtc, toUtc);
(_scriptErrorsSeries, _scriptErrorsAvailable, _scriptErrorsError) =
await LoadTrendSeriesAsync("scriptErrors", siteId, fromUtc, toUtc);
(_sfBufferDepthSeries, _sfBufferDepthAvailable, _sfBufferDepthError) =
await LoadTrendSeriesAsync("sfBufferDepth", siteId, fromUtc, toUtc);
}
finally
{
_trendsLoading = false;
}
}
// Single best-effort series fetch. Site Health metrics are Site-scoped, so
// scope = KpiScopes.Site and scopeKey = the selected site id. On any fault
// the chart falls back to the unavailable placeholder — a failure here must
// NEVER break the dashboard.
private async Task<(IReadOnlyList<KpiSeriesPoint>?, bool, string?)> LoadTrendSeriesAsync(
string metric, string siteId, DateTime fromUtc, DateTime toUtc)
{
try
{
var series = await KpiHistory.GetSeriesAsync(
KpiSources.SiteHealth, metric, KpiScopes.Site, siteId, fromUtc, toUtc);
return (series, true, null);
}
catch
{
return (null, false, "Trend data unavailable.");
}
}
private string TrendSiteLabel(string siteKey) =>
siteKey == CentralHealthReportLoop.CentralSiteId
? "Central Cluster"
: $"{GetSiteName(siteKey)} ({siteKey})";
private async Task RefreshNow()
{
_siteStates = HealthAggregator.GetAllSiteStates();
@@ -4,9 +4,12 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi
@using ZB.MOM.WW.ScadaBridge.Communication
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject IKpiHistoryQueryService KpiHistory
@inject ILogger<NotificationKpis> Logger
<div class="container-fluid mt-3">
@@ -158,6 +161,39 @@
</table>
</div>
}
@* ── Trends (T11: first KPI-history consumer) ── *@
<div class="d-flex justify-content-between align-items-center mt-4 mb-2" data-test="notification-trends">
<h5 class="mb-0">Trends</h5>
<div class="btn-group btn-group-sm" role="group" aria-label="Trend window">
<button type="button"
class="btn @(_windowHours == 24 ? "btn-primary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(24)" disabled="@_trendsLoading">24h</button>
<button type="button"
class="btn @(_windowHours == 168 ? "btn-primary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(168)" disabled="@_trendsLoading">7d</button>
</div>
</div>
<div class="row g-3">
<div class="col-lg-4 col-md-6">
<KpiTrendChart Title="Queue Depth"
Points="@_queueDepthSeries"
IsAvailable="@_queueDepthAvailable"
ErrorMessage="@_queueDepthError" />
</div>
<div class="col-lg-4 col-md-6">
<KpiTrendChart Title="Parked"
Points="@_parkedSeries"
IsAvailable="@_parkedAvailable"
ErrorMessage="@_parkedError" />
</div>
<div class="col-lg-4 col-md-6">
<KpiTrendChart Title="Delivered / interval"
Points="@_deliveredSeries"
IsAvailable="@_deliveredAvailable"
ErrorMessage="@_deliveredError" />
</div>
</div>
</div>
@code {
@@ -175,6 +211,27 @@
private bool _loading;
// ── Trends (T11: first KPI-history consumer) ──
// Window in hours: 24h (default) or 168h (7d). Toggling re-queries.
// Per-metric isolation (mirrors the K14 SiteCallsReport pattern): each metric
// carries its own series + availability + error, each loaded via its own
// try/catch so one metric's failure only blanks that one chart and the others
// still render their already-fetched data.
private int _windowHours = 24;
private bool _trendsLoading;
private IReadOnlyList<KpiSeriesPoint>? _queueDepthSeries;
private bool _queueDepthAvailable = true;
private string? _queueDepthError;
private IReadOnlyList<KpiSeriesPoint>? _parkedSeries;
private bool _parkedAvailable = true;
private string? _parkedError;
private IReadOnlyList<KpiSeriesPoint>? _deliveredSeries;
private bool _deliveredAvailable = true;
private string? _deliveredError;
protected override async Task OnInitializedAsync()
{
try
@@ -195,10 +252,68 @@
_loading = true;
// Race-free despite all tasks mutating component fields: Blazor Server runs
// every continuation on the circuit's single-threaded synchronization context.
await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis(), LoadPerNodeKpis());
await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis(), LoadPerNodeKpis(), LoadTrends());
_loading = false;
}
private async Task SetWindowAsync(int windowHours)
{
if (_windowHours == windowHours)
{
return;
}
_windowHours = windowHours;
await LoadTrends();
}
private async Task LoadTrends()
{
_trendsLoading = true;
try
{
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
// Per-metric isolation: each series is loaded via its own try/catch so
// one metric's failure only blanks that one chart while the others still
// render their fetched data — and a throw never breaks the KPI tiles above.
(_queueDepthSeries, _queueDepthAvailable, _queueDepthError) =
await LoadSeries("queueDepth", fromUtc, toUtc);
(_parkedSeries, _parkedAvailable, _parkedError) =
await LoadSeries("parkedCount", fromUtc, toUtc);
(_deliveredSeries, _deliveredAvailable, _deliveredError) =
await LoadSeries("deliveredLastInterval", fromUtc, toUtc);
}
finally
{
_trendsLoading = false;
}
}
/// <summary>
/// Fetch one NotificationOutbox / Global series, swallowing any failure into the
/// chart's unavailable state. Returns the points (null on failure), the
/// availability flag, and a short error message for the chart placeholder.
/// </summary>
private async Task<(IReadOnlyList<KpiSeriesPoint>? Points, bool Available, string? Error)>
LoadSeries(string metric, DateTime fromUtc, DateTime toUtc)
{
try
{
var points = await KpiHistory.GetSeriesAsync(
KpiSources.NotificationOutbox, metric, KpiScopes.Global, scopeKey: null, fromUtc, toUtc);
return (points, true, null);
}
catch (Exception ex)
{
// A KPI-history hiccup must never break the page — the chart degrades to
// its unavailable placeholder while the KPI tiles above stay rendered.
Logger.LogWarning(ex, "Failed to load notification-outbox KPI trend series for metric {Metric}.", metric);
return (null, false, "Trend data unavailable.");
}
}
private async Task LoadGlobalKpis()
{
try
@@ -5,9 +5,12 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit
@using ZB.MOM.WW.ScadaBridge.Communication
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject IDialogService Dialog
@inject IKpiHistoryQueryService KpiHistory
@inject ILogger<SiteCallsReport> Logger
<div class="container-fluid mt-3">
@@ -230,6 +233,59 @@
</div>
</div>
}
@* ── Trends (K14: collapsible KPI-history charts) ──
A best-effort section — each chart's GetSeriesAsync is wrapped so a KPI
backend hiccup renders the chart unavailable rather than breaking the
grid above. The series load lazily on first expand (and re-load on the
24h/7d window toggle), so the page's primary job (the call list) is never
blocked on the history query. *@
<div class="mt-4" data-test="site-calls-trends">
<div class="d-flex justify-content-between align-items-center mb-2">
<button type="button" class="btn btn-link text-decoration-none px-0"
@onclick="ToggleTrendsAsync"
aria-expanded="@(_trendsExpanded ? "true" : "false")"
data-test="site-calls-trends-toggle">
<span class="me-1">@(_trendsExpanded ? "▾" : "▸")</span>
<span class="h5 mb-0">Trends</span>
</button>
@if (_trendsExpanded)
{
<div class="btn-group btn-group-sm" role="group" aria-label="Trend window">
<button type="button"
class="btn @(_windowHours == 24 ? "btn-primary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(24)" disabled="@_trendsLoading">24h</button>
<button type="button"
class="btn @(_windowHours == 168 ? "btn-primary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(168)" disabled="@_trendsLoading">7d</button>
</div>
}
</div>
@if (_trendsExpanded)
{
<div class="row g-3">
<div class="col-lg-4 col-md-6">
<KpiTrendChart Title="Buffered"
Points="@_bufferedSeries"
IsAvailable="@_bufferedAvailable"
ErrorMessage="@_bufferedError" />
</div>
<div class="col-lg-4 col-md-6">
<KpiTrendChart Title="Parked"
Points="@_parkedSeries"
IsAvailable="@_parkedAvailable"
ErrorMessage="@_parkedError" />
</div>
<div class="col-lg-4 col-md-6">
<KpiTrendChart Title="Failed / interval"
Points="@_failedSeries"
IsAvailable="@_failedAvailable"
ErrorMessage="@_failedError" />
</div>
</div>
}
</div>
</div>
@* ── Row detail modal ── *@
@@ -3,8 +3,10 @@ using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls;
@@ -94,6 +96,29 @@ public partial class SiteCallsReport
private DateTime? _fromFilter;
private DateTime? _toFilter;
// ── Trends (K14) ──────────────────────────────────────────────────────────
// Collapsible KPI-history charts for the SiteCallAudit / Global series. The
// section is collapsed on init and the series load lazily on first expand
// (and on each 24h/7d window toggle), so the page's primary job — the call
// list — never waits on the history query. Each metric carries its own
// series + availability + error so one metric's failure leaves the other two
// charts intact; a GetSeriesAsync throw never breaks the grid above.
private bool _trendsExpanded;
private bool _trendsLoading;
private int _windowHours = 24;
private IReadOnlyList<KpiSeriesPoint>? _bufferedSeries;
private bool _bufferedAvailable = true;
private string? _bufferedError;
private IReadOnlyList<KpiSeriesPoint>? _parkedSeries;
private bool _parkedAvailable = true;
private string? _parkedError;
private IReadOnlyList<KpiSeriesPoint>? _failedSeries;
private bool _failedAvailable = true;
private string? _failedError;
private bool HasNextPage => _nextCursor is not null;
/// <inheritdoc />
@@ -521,4 +546,87 @@ public partial class SiteCallsReport
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
}
// ── Trends (K14) ──────────────────────────────────────────────────────────
/// <summary>
/// Expand/collapse the Trends section. The series load on first expand (and
/// stay loaded across collapse/expand cycles); collapsing leaves the loaded
/// series in place so re-expanding is instant. A re-fetch only happens on the
/// window toggle.
/// </summary>
private async Task ToggleTrendsAsync()
{
_trendsExpanded = !_trendsExpanded;
if (_trendsExpanded && _bufferedSeries is null && _parkedSeries is null && _failedSeries is null)
{
await LoadTrendsAsync();
}
}
/// <summary>Switch the trend window (24h / 7d) and re-load the series.</summary>
private async Task SetWindowAsync(int windowHours)
{
if (_windowHours == windowHours)
{
return;
}
_windowHours = windowHours;
await LoadTrendsAsync();
}
/// <summary>
/// Load all three SiteCallAudit / Global trend series for the current window.
/// Best-effort: each <see cref="IKpiHistoryQueryService.GetSeriesAsync"/> is
/// wrapped independently so one metric's failure renders only that chart
/// unavailable, and a throw never propagates to break the Site Calls grid.
/// </summary>
private async Task LoadTrendsAsync()
{
_trendsLoading = true;
try
{
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
(_bufferedSeries, _bufferedAvailable, _bufferedError) =
await LoadSeriesAsync("buffered", fromUtc, toUtc);
(_parkedSeries, _parkedAvailable, _parkedError) =
await LoadSeriesAsync("parked", fromUtc, toUtc);
(_failedSeries, _failedAvailable, _failedError) =
await LoadSeriesAsync("failedLastInterval", fromUtc, toUtc);
}
finally
{
// try/finally so an unexpected throw before this line can't lock the
// spinner/disabled state on forever. The per-metric LoadSeriesAsync
// try/catches already swallow GetSeriesAsync failures, so this guards
// only the truly unexpected.
_trendsLoading = false;
}
}
/// <summary>
/// Fetch one SiteCallAudit / Global series, swallowing any failure into the
/// chart's unavailable state. Returns the points (null on failure), the
/// availability flag, and a short error message for the chart placeholder.
/// </summary>
private async Task<(IReadOnlyList<KpiSeriesPoint>? Points, bool Available, string? Error)>
LoadSeriesAsync(string metric, DateTime fromUtc, DateTime toUtc)
{
try
{
var points = await KpiHistory.GetSeriesAsync(
KpiSources.SiteCallAudit, metric, KpiScopes.Global, scopeKey: null, fromUtc, toUtc);
return (points, true, null);
}
catch (Exception ex)
{
// A KPI-history hiccup must never break the Site Calls page — the
// chart degrades to its unavailable placeholder instead.
Logger.LogWarning(ex, "Failed to load Site Calls trend series for metric {Metric}.", metric);
return (null, false, "Trend data unavailable.");
}
}
}
@@ -0,0 +1,64 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@* M6 (K12) reusable KPI trend chart. Dependency-free inline SVG inside a
Bootstrap card, styled to sit beside the AuditKpiTiles / SiteCallKpiTiles
KPI cards (card + card-body, small text-muted labels, muted single stroke).
Three states: a ≥2-point polyline chart, a single-sample note, and an
em-dash placeholder for the unavailable / empty case. The coordinate math
and number formatting live in the code-behind (InvariantCulture throughout). *@
<div class="card h-100" data-test="@DataTest">
<div class="card-body">
<h6 class="card-title text-muted mb-2">@Title</h6>
@if (HasChart)
{
<svg viewBox="@ViewBox" preserveAspectRatio="none"
role="img"
style="width:100%; height:140px;"
class="d-block">
<title>@Title trend</title>
@* Baseline (x-axis) at value 0. *@
<line x1="@PlotLeftXString" y1="@BaselineYString"
x2="@PlotRightXString" y2="@BaselineYString"
stroke="currentColor" stroke-opacity="0.25" stroke-width="1" />
@* The series itself — a single muted stroke, no fill. *@
<polyline points="@BuildPoints()"
fill="none"
stroke="currentColor" stroke-opacity="0.7"
stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" />
</svg>
@* Value (left) + time (full width) labels beneath the chart. *@
<div class="d-flex justify-content-between small text-muted mt-1">
<span>max @MaxValueLabel</span>
<span>min @BaselineLabel</span>
</div>
<div class="d-flex justify-content-between small text-muted">
<span>@StartTimeLabel</span>
<span>@EndTimeLabel</span>
</div>
}
else if (HasSingleSample)
{
<div class="text-center text-muted py-4">
<div class="h4 mb-1">@MaxValueLabel</div>
<div class="small">Only one sample in range.</div>
</div>
}
else
{
@* Unavailable OR null/empty — em-dash placeholder, never an SVG. *@
<div class="text-center py-4">
<div class="display-6 text-muted mb-1">—</div>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="small text-muted">@ErrorMessage</div>
}
</div>
}
</div>
</div>
@@ -0,0 +1,210 @@
using System.Globalization;
using System.Text;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Reusable dependency-free SVG trend chart for M6 "KPI History &amp; Trends"
/// (Task K12). Renders a <see cref="KpiSeriesPoint"/> series as a single muted
/// polyline inside a Bootstrap card, with a baseline (x-axis) and value/time
/// labels. No third-party charting library — the project rule is custom Blazor
/// components on Bootstrap CSS only.
/// </summary>
/// <remarks>
/// <para>
/// <b>Purely presentational.</b> The component never fetches data; the parent
/// page supplies a fresh <see cref="Points"/> list and an
/// <see cref="IsAvailable"/> flag, mirroring how <c>AuditKpiTiles</c> and
/// <c>SiteCallKpiTiles</c> consume a snapshot. The same card chrome and
/// <c>small text-muted</c> label styling keep the chart visually consistent
/// with the KPI tiles it sits beside.
/// </para>
/// <para>
/// <b>Coordinate math.</b> X is normalised across the time range
/// <c>[min(BucketStartUtc), max(BucketStartUtc)]</c>; Y is normalised across
/// <c>[0, max(Value)]</c> with the baseline pinned at 0. Every guard that could
/// otherwise divide by zero is explicit: an empty / single-point series never
/// reaches the polyline math, an all-equal-timestamp series falls back to even
/// horizontal spacing, and an all-zero (or single-distinct-value) series draws a
/// flat line at the baseline. All coordinate doubles are formatted with
/// <see cref="CultureInfo.InvariantCulture"/> so locales with comma decimals do
/// not corrupt the SVG <c>points</c> attribute.
/// </para>
/// </remarks>
public partial class KpiTrendChart
{
// The fixed user-space coordinate system the polyline is computed in. The
// SVG is rendered with width="100%" + preserveAspectRatio="none" so it
// scales responsively to its container while the math stays integer-clean.
internal const int ViewBoxWidth = 600;
internal const int ViewBoxHeight = 140;
// Inner plot inset so the polyline + baseline never touch the card edge and
// the top sample is not clipped by the 1px stroke.
private const double PadX = 4;
private const double PadTop = 6;
private const double PadBottom = 6;
/// <summary>
/// The series to plot, assumed ordered ascending by
/// <see cref="KpiSeriesPoint.BucketStartUtc"/>. <c>null</c> or empty renders
/// the unavailable placeholder rather than an empty chart.
/// </summary>
[Parameter] public IReadOnlyList<KpiSeriesPoint>? Points { get; set; }
/// <summary>Card title — also used for the SVG <c>&lt;title&gt;</c> and the
/// <c>data-test</c> slug. Required-ish; defaults to "Trend" if blank.</summary>
[Parameter] public string Title { get; set; } = "Trend";
/// <summary>Optional unit suffix appended to value labels (e.g. "s").</summary>
[Parameter] public string? Unit { get; set; }
/// <summary>
/// True when the supplied series is a successful query result. False renders
/// the em-dash placeholder (no SVG), mirroring the KPI tiles' unavailable
/// state.
/// </summary>
[Parameter] public bool IsAvailable { get; set; } = true;
/// <summary>Optional message rendered under the placeholder when
/// <see cref="IsAvailable"/> is false.</summary>
[Parameter] public string? ErrorMessage { get; set; }
// ── Derived state ────────────────────────────────────────────────────────
private IReadOnlyList<KpiSeriesPoint> Series => Points ?? Array.Empty<KpiSeriesPoint>();
/// <summary>A drawable chart needs to be available and have ≥2 points.</summary>
private bool HasChart => IsAvailable && Series.Count >= 2;
/// <summary>Exactly one sample — show the single-sample note, not a polyline.</summary>
private bool HasSingleSample => IsAvailable && Series.Count == 1;
/// <summary>
/// Stable Playwright hook: <c>kpi-trend-&lt;slug&gt;</c> where the slug is the
/// title lowercased with each run of non-alphanumerics collapsed to a single
/// dash and trimmed. Empty / symbol-only titles fall back to "chart".
/// </summary>
private string DataTest => $"kpi-trend-{Slugify(Title)}";
internal static string Slugify(string? title)
{
if (string.IsNullOrWhiteSpace(title))
{
return "chart";
}
var sb = new StringBuilder(title.Length);
var lastWasDash = false;
foreach (var ch in title)
{
if (char.IsLetterOrDigit(ch))
{
sb.Append(char.ToLowerInvariant(ch));
lastWasDash = false;
}
else if (!lastWasDash)
{
sb.Append('-');
lastWasDash = true;
}
}
var slug = sb.ToString().Trim('-');
return slug.Length == 0 ? "chart" : slug;
}
// ── Value/time range ─────────────────────────────────────────────────────
private double MaxValue => Series.Count == 0 ? 0 : Series.Max(p => p.Value);
private DateTime FirstUtc => Series[0].BucketStartUtc;
private DateTime LastUtc => Series[^1].BucketStartUtc;
// ── Label formatting ─────────────────────────────────────────────────────
private string MaxValueLabel => FormatValue(MaxValue);
private string BaselineLabel => FormatValue(0);
private string FormatValue(double v)
{
var num = v.ToString("0.###", CultureInfo.InvariantCulture);
return string.IsNullOrEmpty(Unit) ? num : $"{num} {Unit}";
}
// Time labels: include the date prefix when the range spans more than a day,
// otherwise just HH:mm. Always UTC (all timestamps in the system are UTC).
private string StartTimeLabel => FormatTime(FirstUtc);
private string EndTimeLabel => FormatTime(LastUtc);
private string FormatTime(DateTime utc)
{
var spansDays = Series.Count >= 2 && (LastUtc.Date != FirstUtc.Date);
return utc.ToString(spansDays ? "MM-dd HH:mm" : "HH:mm", CultureInfo.InvariantCulture);
}
// ── Polyline geometry ────────────────────────────────────────────────────
/// <summary>
/// Builds the SVG <c>polyline points</c> string. Only called when
/// <see cref="HasChart"/> is true (≥2 points). X spreads each point across
/// the time range; if every timestamp is identical the range is zero and we
/// fall back to even index-based spacing. Y is inverted (SVG origin is
/// top-left) and normalised against <see cref="MaxValue"/>; a zero max draws
/// every point on the baseline.
/// </summary>
private string BuildPoints()
{
var n = Series.Count;
var plotW = ViewBoxWidth - (2 * PadX);
var plotH = ViewBoxHeight - PadTop - PadBottom;
var baselineY = ViewBoxHeight - PadBottom;
var firstTicks = FirstUtc.Ticks;
double timeSpanTicks = LastUtc.Ticks - firstTicks;
var max = MaxValue;
var sb = new StringBuilder(n * 12);
for (var i = 0; i < n; i++)
{
var p = Series[i];
// X — by time fraction, or even index spacing when all timestamps equal.
// n >= 2 is guaranteed by the HasChart precondition (Series.Count >= 2),
// so the (n - 1) divisor is always ≥ 1 and the n == 1 arm is unreachable.
double xFrac = timeSpanTicks > 0
? (p.BucketStartUtc.Ticks - firstTicks) / timeSpanTicks
: (double)i / (n - 1);
var x = PadX + (xFrac * plotW);
// Y — baseline at 0, top at max. Flat at baseline when max == 0.
// Clamp to non-negative so a stale negative Value cannot push a point
// above the baseline or outside the viewBox.
double yFrac = max > 0 ? Math.Max(0, p.Value) / max : 0.0;
var y = baselineY - (yFrac * plotH);
if (i > 0)
{
sb.Append(' ');
}
sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture));
sb.Append(',');
sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture));
}
return sb.ToString();
}
private string BaselineYString =>
(ViewBoxHeight - PadBottom).ToString("0.##", CultureInfo.InvariantCulture);
private string PlotRightXString =>
(ViewBoxWidth - PadX).ToString("0.##", CultureInfo.InvariantCulture);
private string PlotLeftXString =>
PadX.ToString("0.##", CultureInfo.InvariantCulture);
private string ViewBox => $"0 0 {ViewBoxWidth} {ViewBoxHeight}";
}
@@ -1,10 +1,12 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
namespace ZB.MOM.WW.ScadaBridge.CentralUI;
@@ -51,6 +53,19 @@ public static class ServiceCollectionExtensions
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
// KPI History (M6, K11): CentralUI facade over IKpiHistoryRepository that
// fetches a raw series and reduces it with KpiSeriesBucketer for the trend chart.
//
// Registered with an explicit factory so the IServiceScopeFactory ctor is
// always chosen — KpiHistoryQueryService has a second (test-seam) ctor that
// takes IKpiHistoryRepository directly, and both are constructor-resolvable,
// so default activation would be ambiguous. The scope-factory ctor opens a
// fresh DbContext per query, mirroring AuditLogQueryService so a chart's
// auto-load never races other reads on the shared circuit-scoped context.
services.AddScoped<IKpiHistoryQueryService>(sp => new KpiHistoryQueryService(
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<IOptions<KpiHistoryOptions>>()));
// OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseNodeAsync
// that enforces the CentralUI-side Design-role trust boundary and translates
// transport failures into typed BrowseFailure results for the dialog.
@@ -0,0 +1,51 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// CentralUI facade over
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IKpiHistoryRepository"/>
/// (M6 "KPI History &amp; Trends", K11). The reusable trend chart talks to this
/// service rather than the repository directly so tests can substitute a fake
/// without spinning up EF Core, and so the bucketing / downsampling step lives in
/// one place rather than being re-implemented per page.
/// </summary>
/// <remarks>
/// The query path fetches the raw series for one (source, metric, scope, scopeKey)
/// tuple over <c>[fromUtc, toUtc]</c> and reduces it with
/// <see cref="KpiSeriesBucketer"/> to at most <c>maxPoints</c> points. Mirroring
/// <see cref="IAuditLogQueryService"/>, the production implementation opens its own
/// DI scope per call so a chart's auto-load never contends with the circuit-scoped
/// <c>ScadaBridgeDbContext</c>; a second (test-seam) constructor injects the
/// repository directly.
/// </remarks>
public interface IKpiHistoryQueryService
{
/// <summary>
/// Returns the bucketed series for one
/// (<paramref name="source"/>, <paramref name="metric"/>, <paramref name="scope"/>,
/// <paramref name="scopeKey"/>) tuple over <c>[<paramref name="fromUtc"/>,
/// <paramref name="toUtc"/>]</c>, reduced to at most
/// <paramref name="maxPoints"/> points (defaulting to
/// <c>KpiHistoryOptions.DefaultMaxSeriesPoints</c> when null).
/// </summary>
/// <remarks>
/// The window must be non-degenerate (<paramref name="toUtc"/> strictly after
/// <paramref name="fromUtc"/>) and the effective max must be at least 2 — the
/// page layer guarantees both; this service passes them straight through to
/// <see cref="KpiSeriesBucketer.Bucket"/>, which throws
/// <see cref="ArgumentOutOfRangeException"/> on violation.
/// </remarks>
/// <param name="source">Source identifier — a value from <c>KpiSources</c>.</param>
/// <param name="metric">Metric name from the source's catalog.</param>
/// <param name="scope">Scope discriminator — a value from <c>KpiScopes</c>.</param>
/// <param name="scopeKey">Scope qualifier (site id / node name); <c>null</c> for the Global scope.</param>
/// <param name="fromUtc">Inclusive lower bound of the time window (UTC).</param>
/// <param name="toUtc">Inclusive upper bound of the time window (UTC); must be after <paramref name="fromUtc"/>.</param>
/// <param name="maxPoints">Optional ceiling on returned points; defaults to the configured default when null.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the bucketed series in ascending bucket-start order.</returns>
Task<IReadOnlyList<KpiSeriesPoint>> GetSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,95 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="IKpiHistoryQueryService"/> implementation (M6 K11) — fetches
/// the raw series via <see cref="IKpiHistoryRepository.GetRawSeriesAsync"/> and
/// reduces it with <see cref="KpiSeriesBucketer.Bucket"/> to at most the requested
/// (or configured-default) number of points.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="AuditLogQueryService"/>: the production constructor takes an
/// <see cref="IServiceScopeFactory"/> and opens a fresh DI scope per query —
/// resolving a fresh <see cref="IKpiHistoryRepository"/> (and therefore a fresh
/// <c>ScadaBridgeDbContext</c>) — so a trend chart's auto-load never contends with
/// the circuit-scoped context the rest of the page uses.
/// </para>
/// <para>
/// A second constructor injects an <see cref="IKpiHistoryRepository"/> directly — a
/// test seam (same dual-ctor pattern as <see cref="AuditLogQueryService"/>) so unit
/// tests can substitute a stub without standing up a DI container. Both ctors take
/// <see cref="IOptions{KpiHistoryOptions}"/> for the default series-point ceiling.
/// </para>
/// </remarks>
public sealed class KpiHistoryQueryService : IKpiHistoryQueryService
{
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
private readonly IServiceScopeFactory? _scopeFactory;
// Test seam: a directly-injected repository whose lifetime the test owns.
// Null in the production ctor.
private readonly IKpiHistoryRepository? _injectedRepository;
private readonly KpiHistoryOptions _options;
/// <summary>
/// Production constructor — resolves <see cref="IKpiHistoryRepository"/> from a
/// fresh DI scope on every call so each query gets its own
/// <c>ScadaBridgeDbContext</c> and never contends with the circuit-scoped context.
/// </summary>
/// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
/// <param name="options">KPI History options supplying the default series-point ceiling.</param>
public KpiHistoryQueryService(
IServiceScopeFactory scopeFactory,
IOptions<KpiHistoryOptions> options)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// Test-seam constructor — injects a repository instance whose lifetime the
/// caller owns. Used by unit tests that substitute a stub repository.
/// </summary>
/// <param name="repository">The KPI history repository instance to use directly.</param>
/// <param name="options">KPI History options supplying the default series-point ceiling.</param>
public KpiHistoryQueryService(
IKpiHistoryRepository repository,
IOptions<KpiHistoryOptions> options)
{
_injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc />
public async Task<IReadOnlyList<KpiSeriesPoint>> GetSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken cancellationToken = default)
{
var effectiveMax = maxPoints ?? _options.DefaultMaxSeriesPoints;
// Test-seam ctor: use the injected repository directly.
if (_injectedRepository is not null)
{
var injectedRaw = await _injectedRepository.GetRawSeriesAsync(
source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
return KpiSeriesBucketer.Bucket(injectedRaw, fromUtc, toUtc, effectiveMax);
}
// Production: a fresh scope (and thus a fresh DbContext) per query so a
// chart's auto-load never shares the circuit-scoped context.
await using var serviceScope = _scopeFactory!.CreateAsyncScope();
var repository = serviceScope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
var raw = await repository.GetRawSeriesAsync(
source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
return KpiSeriesBucketer.Bucket(raw, fromUtc, toUtc, effectiveMax);
}
}
@@ -28,6 +28,7 @@
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
</ItemGroup>
@@ -0,0 +1,55 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
/// <summary>
/// A single point-in-time KPI measurement in the central KPI-history backbone
/// (M6 "KPI History &amp; Trends"). One row per (Source, Metric, Scope, ScopeKey)
/// captured by the recorder singleton at a sampling instant — a tall / EAV row in
/// the central <c>KpiSample</c> table.
/// </summary>
/// <remarks>
/// <para>
/// Persistence-ignorant POCO; the EF Core mapping lives in the Configuration
/// Database component. <see cref="Source"/> draws from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiSources"/> and
/// <see cref="Scope"/> from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiScopes"/>; <see cref="Metric"/>
/// is drawn from each owning source's own metric catalog.
/// </para>
/// <para>
/// <see cref="Value"/> is a <see cref="double"/> that carries counts exactly and
/// ages as seconds. <see cref="CapturedAtUtc"/> is UTC, like every timestamp in
/// the system.
/// </para>
/// </remarks>
public sealed class KpiSample
{
/// <summary>Surrogate identity key assigned by the store.</summary>
public long Id { get; set; }
/// <summary>
/// Owning component / source — a value from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiSources"/>.
/// </summary>
public required string Source { get; set; }
/// <summary>Metric name, drawn from the owning source's metric catalog.</summary>
public required string Metric { get; set; }
/// <summary>
/// Scope discriminator — a value from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiScopes"/>.
/// </summary>
public required string Scope { get; set; }
/// <summary>
/// Scope qualifier — the site id or node name the sample belongs to;
/// <c>null</c> for <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiScopes.Global"/>.
/// </summary>
public string? ScopeKey { get; set; }
/// <summary>Measured value — counts are exact; ages are expressed in seconds.</summary>
public double Value { get; set; }
/// <summary>The UTC instant at which the sample was captured.</summary>
public DateTime CapturedAtUtc { get; set; }
}
@@ -0,0 +1,33 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
/// <summary>
/// A DI-registered provider of KPI samples (M6 "KPI History &amp; Trends"),
/// implemented by each owning component and enumerated by the central recorder
/// singleton at every sampling interval. The recorder stamps a single
/// <c>capturedAtUtc</c>, fans out to every source's <see cref="CollectAsync"/>,
/// and bulk-writes the combined samples.
/// </summary>
public interface IKpiSampleSource
{
/// <summary>
/// The source this provider reports for — a value from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiSources"/>. Every
/// <see cref="KpiSample"/> returned by <see cref="CollectAsync"/> carries this value
/// in <see cref="KpiSample.Source"/>.
/// </summary>
string Source { get; }
/// <summary>
/// Compute this source's KPIs as-of-now, stamping each returned sample's
/// <see cref="KpiSample.CapturedAtUtc"/> with <paramref name="capturedAtUtc"/>.
/// </summary>
/// <param name="capturedAtUtc">The shared UTC capture instant for this sampling pass.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A task resolving to the samples computed for this source; empty when there is
/// nothing to report.
/// </returns>
Task<IReadOnlyList<KpiSample>> CollectAsync(DateTime capturedAtUtc, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
/// <summary>
/// Data access for the central KPI-history backbone (M6 "KPI History &amp; Trends")
/// — the tall / EAV <c>KpiSample</c> table in central MS SQL. Backs the recorder
/// singleton's bulk write, the bucketed query path that feeds the reusable trend
/// chart, and the retention purge. Implementation lives in the Configuration
/// Database component.
/// </summary>
public interface IKpiHistoryRepository
{
/// <summary>
/// Bulk-inserts a batch of captured samples (one sampling pass across all
/// registered sources).
/// </summary>
/// <param name="samples">The samples to record.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous write.</returns>
Task RecordSamplesAsync(IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the raw points for one series in <c>[fromUtc, toUtc]</c>, ordered by
/// <see cref="KpiSample.CapturedAtUtc"/> ascending.
/// </summary>
/// <param name="source">Source identifier — a value from <see cref="KpiSources"/>.</param>
/// <param name="metric">Metric name from the source's catalog.</param>
/// <param name="scope">Scope discriminator — a value from <see cref="KpiScopes"/>.</param>
/// <param name="scopeKey">Scope qualifier (site id / node name); <c>null</c> for the Global scope.</param>
/// <param name="fromUtc">Inclusive lower bound of the time window (UTC).</param>
/// <param name="toUtc">Inclusive upper bound of the time window (UTC).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A task resolving to the raw series points in ascending capture order; empty when
/// the series has no samples in the window.
/// </returns>
Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default);
/// <summary>
/// Bulk-deletes rows whose <see cref="KpiSample.CapturedAtUtc"/> is strictly older
/// than <paramref name="before"/> (retention purge).
/// </summary>
/// <param name="before">UTC cut-off; rows captured before this instant are deleted.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the number of rows deleted.</returns>
Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// <summary>
/// Canonical KPI scope discriminators (M6 "KPI History &amp; Trends") — the value of
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample.Scope"/>. Each
/// constant's value equals its name. The companion
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample.ScopeKey"/> qualifies
/// the scope: <c>null</c> for <see cref="Global"/>, the site id for <see cref="Site"/>,
/// the node name for <see cref="Node"/>.
/// </summary>
public static class KpiScopes
{
/// <summary>System-wide sample; <c>ScopeKey</c> is <c>null</c>.</summary>
public const string Global = "Global";
/// <summary>Per-site sample; <c>ScopeKey</c> is the site id.</summary>
public const string Site = "Site";
/// <summary>Per-node sample; <c>ScopeKey</c> is the node name.</summary>
public const string Node = "Node";
}
@@ -0,0 +1,110 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// <summary>
/// Pure, deterministic downsampling helper for KPI series charting (M6 "KPI History &amp; Trends").
/// Reduces a raw <see cref="KpiSeriesPoint"/> series to at most <c>maxPoints</c> points using
/// last-value-per-bucket / gauge semantics — suitable for step/area charts where the most
/// recent value in a window best represents that window.
/// </summary>
public static class KpiSeriesBucketer
{
/// <summary>
/// Reduces <paramref name="raw"/> to at most <paramref name="maxPoints"/> points by dividing
/// the window <c>[<paramref name="fromUtc"/>, <paramref name="toUtc"/>]</c> into equal-width
/// time buckets and keeping the <em>last</em> raw point (highest
/// <see cref="KpiSeriesPoint.BucketStartUtc"/>) within each non-empty bucket.
/// Empty buckets are omitted — no gap-filling.
/// </summary>
/// <param name="raw">
/// Input series, assumed to be sorted ascending by <see cref="KpiSeriesPoint.BucketStartUtc"/>.
/// If not sorted, the point with the largest timestamp within each bucket is selected.
/// If <c>null</c> or empty, an empty list is returned.
/// </param>
/// <param name="fromUtc">UTC start of the query window (inclusive).</param>
/// <param name="toUtc">UTC end of the query window (inclusive on the right edge).</param>
/// <param name="maxPoints">Maximum number of output points. Must be ≥ 2.</param>
/// <returns>
/// An <see cref="IReadOnlyList{T}"/> of at most <paramref name="maxPoints"/> bucketed points,
/// ordered by <see cref="KpiSeriesPoint.BucketStartUtc"/> ascending.
/// Returns <paramref name="raw"/> unchanged (same reference) when
/// <c>raw.Count &lt;= maxPoints</c>; callers must not mutate the underlying
/// collection in that case, as it is the same object passed in.
/// </returns>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="maxPoints"/> &lt; 2 or
/// <paramref name="toUtc"/> &lt;= <paramref name="fromUtc"/>.
/// These are caller programming errors — a chart needs at least two points and a
/// non-degenerate window.
/// </exception>
public static IReadOnlyList<KpiSeriesPoint> Bucket(
IReadOnlyList<KpiSeriesPoint> raw,
DateTime fromUtc,
DateTime toUtc,
int maxPoints)
{
if (maxPoints < 2)
throw new ArgumentOutOfRangeException(nameof(maxPoints),
maxPoints, "maxPoints must be >= 2.");
if (toUtc <= fromUtc)
throw new ArgumentOutOfRangeException(nameof(toUtc),
toUtc, "toUtc must be strictly greater than fromUtc.");
// Normal runtime case — empty or short series: return as-is.
if (raw is null || raw.Count == 0)
return Array.Empty<KpiSeriesPoint>();
if (raw.Count <= maxPoints)
return raw;
// Divide the window into maxPoints equal-width buckets.
// Each bucket covers [bucketStart, bucketStart + bucketWidth).
// The right edge (toUtc) belongs to the last bucket to avoid overflow.
double windowTicks = (double)(toUtc.Ticks - fromUtc.Ticks);
double bucketWidthTicks = windowTicks / maxPoints;
// For each bucket, track the candidate point: the one with the
// maximum BucketStartUtc (last value within the bucket).
// We use a fixed-size array indexed by bucket number.
// Nullable KpiSeriesPoint[] with 'hasValue' flags is fine since the
// struct is small.
var best = new KpiSeriesPoint[maxPoints];
var occupied = new bool[maxPoints];
foreach (var point in raw)
{
long offsetTicks = point.BucketStartUtc.Ticks - fromUtc.Ticks;
// Skip points outside [fromUtc, toUtc].
if (offsetTicks < 0 || point.BucketStartUtc > toUtc)
continue;
// Compute bucket index; clamp to last bucket so toUtc itself
// doesn't overflow to index maxPoints.
int bucketIndex = (int)(offsetTicks / bucketWidthTicks);
if (bucketIndex >= maxPoints)
bucketIndex = maxPoints - 1;
// Keep the point with the highest timestamp in this bucket
// (last-value semantics; if ties, keep first encountered — stable).
if (!occupied[bucketIndex] ||
point.BucketStartUtc > best[bucketIndex].BucketStartUtc)
{
best[bucketIndex] = new KpiSeriesPoint(
fromUtc + TimeSpan.FromTicks((long)(bucketIndex * bucketWidthTicks)),
point.Value);
occupied[bucketIndex] = true;
}
}
// Collect non-empty buckets in order.
var result = new List<KpiSeriesPoint>(maxPoints);
for (int i = 0; i < maxPoints; i++)
{
if (occupied[i])
result.Add(best[i]);
}
return result;
}
}
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// <summary>
/// A single bucketed point of a KPI series (M6 "KPI History &amp; Trends") — the
/// aggregated value of one series over the bucket starting at
/// <see cref="BucketStartUtc"/>. Returned by the bucketed query path and the
/// reusable trend chart.
/// </summary>
/// <param name="BucketStartUtc">UTC start of the time bucket this point covers.</param>
/// <param name="Value">Aggregated value for the bucket — counts exact; ages as seconds.</param>
public readonly record struct KpiSeriesPoint(DateTime BucketStartUtc, double Value);
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// <summary>
/// Canonical KPI source identifiers (M6 "KPI History &amp; Trends") — the value of
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample.Source"/> and the
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi.IKpiSampleSource.Source"/>
/// each owning component reports. Each constant's value equals its name.
/// </summary>
public static class KpiSources
{
/// <summary>Notification Outbox (#21) delivery KPIs.</summary>
public const string NotificationOutbox = "NotificationOutbox";
/// <summary>Site Call Audit (#22) cached-call KPIs.</summary>
public const string SiteCallAudit = "SiteCallAudit";
/// <summary>Audit Log (#23) ingest / backlog KPIs.</summary>
public const string AuditLog = "AuditLog";
/// <summary>Site Health (#11) monitoring KPIs.</summary>
public const string SiteHealth = "SiteHealth";
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
/// <summary>
/// Maps the <see cref="KpiSample"/> POCO to the central <c>KpiSample</c> table
/// (M6 "KPI History &amp; Trends") — the tall / EAV row written by the recorder
/// singleton. Operational history, NOT audit, so the table is non-partitioned,
/// standard <c>[PRIMARY]</c> filegroup, no DB-role restriction.
/// </summary>
/// <remarks>
/// Two named indexes back the access paths: <c>IX_KpiSample_Series</c> covers the
/// bucketed series query (filter by Source/Metric/Scope/ScopeKey, ordered by
/// capture time) and <c>IX_KpiSample_Captured</c> backs the retention purge
/// (delete by <see cref="KpiSample.CapturedAtUtc"/>).
/// </remarks>
public class KpiSampleEntityTypeConfiguration : IEntityTypeConfiguration<KpiSample>
{
/// <summary>
/// Configures the EF Core entity type mapping for <see cref="KpiSample"/>.
/// </summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<KpiSample> builder)
{
builder.ToTable("KpiSample");
// Surrogate long identity assigned by the store.
builder.HasKey(s => s.Id);
// Catalog-bounded ASCII columns — values come from the KpiSources /
// KpiScopes catalogs and per-source metric names.
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();
// Scope qualifier — null for the Global scope.
builder.Property(s => s.ScopeKey).HasMaxLength(64).IsUnicode(false);
// Measured value — double / float column.
builder.Property(s => s.Value);
builder.Property(s => s.CapturedAtUtc).IsRequired();
// Series index — backs the bucketed query path (filter one series, scan in
// capture order). Names locked for migration discoverability.
// Global-scope rows (ScopeKey IS NULL) are included in the index and are
// seeked via the IS NULL predicate, so the index covers all scope levels.
builder.HasIndex(s => new { s.Source, s.Metric, s.Scope, s.ScopeKey, s.CapturedAtUtc })
.HasDatabaseName("IX_KpiSample_Series");
// Captured index — backs the retention purge (delete by capture time).
builder.HasIndex(s => s.CapturedAtUtc)
.HasDatabaseName("IX_KpiSample_Captured");
}
}
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddKpiSampleTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "KpiSample",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Source = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false),
Metric = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false),
Scope = table.Column<string>(type: "varchar(16)", unicode: false, maxLength: 16, nullable: false),
ScopeKey = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: true),
Value = table.Column<double>(type: "float", nullable: false),
CapturedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KpiSample", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_KpiSample_Captured",
table: "KpiSample",
column: "CapturedAtUtc");
migrationBuilder.CreateIndex(
name: "IX_KpiSample_Series",
table: "KpiSample",
columns: new[] { "Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "KpiSample");
}
}
}
@@ -650,6 +650,54 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("InstanceNativeAlarmSourceOverrides");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CapturedAtUtc")
.HasColumnType("datetime2");
b.Property<string>("Metric")
.IsRequired()
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("Scope")
.IsRequired()
.HasMaxLength(16)
.IsUnicode(false)
.HasColumnType("varchar(16)");
b.Property<string>("ScopeKey")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<double>("Value")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("CapturedAtUtc")
.HasDatabaseName("IX_KpiSample_Captured");
b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc")
.HasDatabaseName("IX_KpiSample_Series");
b.ToTable("KpiSample", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b =>
{
b.Property<string>("NotificationId")
@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IKpiHistoryRepository"/> over the central
/// <c>KpiSample</c> table (M6 "KPI History &amp; Trends"). See the interface for the
/// contract; this class adds notes on the data-access strategy per method.
/// </summary>
public sealed class KpiHistoryRepository : IKpiHistoryRepository
{
private readonly ScadaBridgeDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="KpiHistoryRepository"/> class.
/// </summary>
/// <param name="context">The EF Core database context.</param>
public KpiHistoryRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task RecordSamplesAsync(
IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(samples);
// Avoid a no-op SaveChanges round-trip on quiet sampling ticks.
if (samples.Count == 0)
{
return;
}
// Bulk-insert one sampling pass. AddRange + a single SaveChanges keeps the
// whole batch in one round-trip; the store assigns each row's identity.
_context.KpiSamples.AddRange(samples);
await _context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default)
{
// The ScopeKey == scopeKey comparison is intentional: when scopeKey is null
// EF translates it to "ScopeKey IS NULL", which matches the Global-scope rows
// (null key) and excludes the site/node-scoped rows that carry a non-null key.
return await _context.KpiSamples
.Where(s => s.Source == source
&& s.Metric == metric
&& s.Scope == scope
&& s.ScopeKey == scopeKey
&& s.CapturedAtUtc >= fromUtc
&& s.CapturedAtUtc <= toUtc)
.OrderBy(s => s.CapturedAtUtc)
.Select(s => new KpiSeriesPoint(s.CapturedAtUtc, s.Value))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default)
{
// Set-based delete — no entity materialisation; returns the rows affected.
return await _context.KpiSamples
.Where(s => s.CapturedAtUtc < before)
.ExecuteDeleteAsync(cancellationToken);
}
}
@@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
@@ -130,6 +131,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
/// <summary>Gets the set of site calls.</summary>
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
// KPI History (M6 "KPI History & Trends")
/// <summary>Gets the set of KPI samples (central tall/EAV KPI-history backbone).</summary>
public DbSet<KpiSample> KpiSamples => Set<KpiSample>();
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
/// <summary>Gets the set of data protection keys.</summary>
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
@@ -54,6 +54,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
services.AddScoped<IKpiHistoryRepository, KpiHistoryRepository>();
// Auth re-arch (C5): inbound API keys are no longer persisted in SQL Server —
// the repository now exposes only API-method access, so a plain scoped
// registration suffices (no peppered-hasher accessor to wire).
@@ -0,0 +1,150 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Kpi;
/// <summary>
/// Site Health (#11) <see cref="IKpiSampleSource"/> for the M6 "KPI History &amp;
/// Trends" backbone. Unlike the other M6 sample sources — which each query a
/// central repository — this one reads the in-memory
/// <see cref="ICentralHealthAggregator"/> state, snapshotting the latest
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport"/>
/// each tracked site has reported into per-site (<see cref="KpiScopes.Site"/>)
/// <see cref="KpiSample"/> rows.
/// </summary>
/// <remarks>
/// <para>
/// Each sampling pass the central recorder enumerates this source and calls
/// <see cref="CollectAsync"/>, which iterates
/// <see cref="ICentralHealthAggregator.GetAllSiteStates"/> and, for every site
/// whose <see cref="SiteHealthState.LatestReport"/> is non-null, emits the
/// fixed metric catalog below. Sites known only via heartbeats (null
/// <see cref="SiteHealthState.LatestReport"/>) contribute nothing.
/// </para>
/// <para>
/// The aggregator is registered as a singleton (its state is shared, immutable,
/// and safe to hand straight to callers); this source is nonetheless registered
/// DI-scoped for consistency with the recorder's per-tick scope and the other
/// M6 sample sources. A scoped consumer resolving a singleton dependency is the
/// normal DI lifetime relationship and introduces no captive-dependency issue.
/// </para>
/// </remarks>
public sealed class SiteHealthKpiSampleSource : IKpiSampleSource
{
// ── Metric catalog (the M6-agreed metric-name strings for this source) ──
// Declaration order matches the emission order in AddSiteSnapshot.
private const string MetricConnectionsUp = "connectionsUp";
private const string MetricConnectionsDown = "connectionsDown";
private const string MetricScriptErrors = "scriptErrors";
private const string MetricAlarmEvalErrors = "alarmEvalErrors";
private const string MetricSfBufferDepth = "sfBufferDepth";
private const string MetricDeadLetters = "deadLetters";
private const string MetricParkedMessages = "parkedMessages";
private const string MetricDeployedInstances = "deployedInstances";
private const string MetricEnabledInstances = "enabledInstances";
private const string MetricDisabledInstances = "disabledInstances";
private const string MetricAuditBacklogPending = "auditBacklogPending";
private const string MetricEventLogWriteFailures = "eventLogWriteFailures";
private readonly ICentralHealthAggregator _aggregator;
/// <summary>
/// Creates the sample source over the central in-memory health aggregator.
/// </summary>
/// <param name="aggregator">The central health aggregator holding per-site state.</param>
public SiteHealthKpiSampleSource(ICentralHealthAggregator aggregator)
{
ArgumentNullException.ThrowIfNull(aggregator);
_aggregator = aggregator;
}
/// <inheritdoc />
public string Source => KpiSources.SiteHealth;
/// <inheritdoc />
public Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default)
{
var samples = new List<KpiSample>();
foreach (var (siteId, state) in _aggregator.GetAllSiteStates())
{
// Sites known only via heartbeats (no report yet) contribute nothing.
if (state.LatestReport is not { } report)
{
continue;
}
AddSiteSnapshot(samples, capturedAtUtc, siteId, report);
}
return Task.FromResult<IReadOnlyList<KpiSample>>(samples);
}
/// <summary>
/// Appends the per-site metric catalog for one site's latest report,
/// mapping each metric to the corresponding
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport"/>
/// field.
/// </summary>
private static void AddSiteSnapshot(
List<KpiSample> samples,
DateTime capturedAtUtc,
string siteId,
ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport report)
{
// Connection statuses: Connected counts as up, everything else
// (Disconnected / Connecting / Error) counts as down.
var connectionsUp = 0;
var connectionsDown = 0;
foreach (var status in report.DataConnectionStatuses.Values)
{
if (status == ConnectionHealth.Connected)
{
connectionsUp++;
}
else
{
connectionsDown++;
}
}
// Sum of the store-and-forward buffer depths across all buffers.
long sfBufferDepth = 0;
foreach (var depth in report.StoreAndForwardBufferDepths.Values)
{
sfBufferDepth += depth;
}
samples.Add(Sample(capturedAtUtc, MetricConnectionsUp, siteId, connectionsUp));
samples.Add(Sample(capturedAtUtc, MetricConnectionsDown, siteId, connectionsDown));
samples.Add(Sample(capturedAtUtc, MetricScriptErrors, siteId, report.ScriptErrorCount));
samples.Add(Sample(capturedAtUtc, MetricAlarmEvalErrors, siteId, report.AlarmEvaluationErrorCount));
samples.Add(Sample(capturedAtUtc, MetricSfBufferDepth, siteId, sfBufferDepth));
samples.Add(Sample(capturedAtUtc, MetricDeadLetters, siteId, report.DeadLetterCount));
samples.Add(Sample(capturedAtUtc, MetricParkedMessages, siteId, report.ParkedMessageCount));
samples.Add(Sample(capturedAtUtc, MetricDeployedInstances, siteId, report.DeployedInstanceCount));
samples.Add(Sample(capturedAtUtc, MetricEnabledInstances, siteId, report.EnabledInstanceCount));
samples.Add(Sample(capturedAtUtc, MetricDisabledInstances, siteId, report.DisabledInstanceCount));
// Nested audit-backlog object may be null (no data yet) — treat as 0.
samples.Add(Sample(
capturedAtUtc, MetricAuditBacklogPending, siteId,
report.SiteAuditBacklog?.PendingCount ?? 0));
samples.Add(Sample(
capturedAtUtc, MetricEventLogWriteFailures, siteId, report.SiteEventLogWriteFailures));
}
private static KpiSample Sample(
DateTime capturedAtUtc, string metric, string scopeKey, double value) =>
new()
{
Source = KpiSources.SiteHealth,
Metric = metric,
Scope = KpiScopes.Site,
ScopeKey = scopeKey,
Value = value,
CapturedAtUtc = capturedAtUtc,
};
}
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
@@ -61,6 +62,14 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ICentralHealthAggregator>(sp => sp.GetRequiredService<CentralHealthAggregator>());
services.AddHostedService(sp => sp.GetRequiredService<CentralHealthAggregator>());
services.AddHostedService<CentralHealthReportLoop>();
// M6 "KPI History & Trends" (K9): per-site Site Health KPI sample source.
// Reads the in-memory central aggregator (a singleton) rather than a
// repository; registered Scoped to match the recorder's per-tick scope
// and the other M6 sample sources (a scoped source over a singleton
// dependency is fine — no captive dependency).
services.TryAddEnumerable(
ServiceDescriptor.Scoped<IKpiSampleSource, Kpi.SiteHealthKpiSampleSource>());
return services;
}
@@ -699,6 +699,63 @@ akka {{
_actorSystem.ActorOf(auditReconProxyProps, "site-audit-reconciliation-proxy");
_logger.LogInformation("SiteAuditReconciliationActor singleton created");
// KPI History (#26, M6) — central singleton that periodically samples the
// Notification Outbox / Site Call Audit point-in-time KPIs into the
// KpiHistorySamples table and runs the daily retention purge. Mirrors the
// audit-log-purge singleton pattern above: a ClusterSingletonManager pins
// the recorder to the active central node, a ClusterSingletonProxy gives a
// stable address, and a PhaseClusterLeave graceful-stop task drains the
// in-flight tick before handover. The recorder's sample + purge timers
// self-schedule in PreStart. Options come from AddKpiHistory (central
// composition root only). The actor takes the root IServiceProvider and
// opens its own per-tick DI scope (the KPI repository is a scoped EF Core
// service), so the 3 ctor args (IServiceProvider, KpiHistoryOptions,
// ILogger) are resolved here from DI exactly like the other singletons.
// NOT readiness-gated by design: KPI history is observability/best-effort
// (it must never gate /health/ready), so kpi-history-recorder is
// deliberately absent from RequiredSingletonsHealthCheck.
var kpiHistoryOptions = _serviceProvider
.GetRequiredService<IOptions<ZB.MOM.WW.ScadaBridge.KpiHistory.KpiHistoryOptions>>().Value;
var kpiHistoryLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ZB.MOM.WW.ScadaBridge.KpiHistory.KpiHistoryRecorderActor>();
var kpiHistorySingletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new ZB.MOM.WW.ScadaBridge.KpiHistory.KpiHistoryRecorderActor(
_serviceProvider,
kpiHistoryOptions,
kpiHistoryLogger)),
terminationMessage: PoisonPill.Instance,
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
.WithSingletonName("kpi-history-recorder"));
var kpiHistorySingletonManager =
_actorSystem!.ActorOf(kpiHistorySingletonProps, "kpi-history-recorder-singleton");
var kpiHistoryShutdown = Akka.Actor.CoordinatedShutdown.Get(_actorSystem);
kpiHistoryShutdown.AddTask(
Akka.Actor.CoordinatedShutdown.PhaseClusterLeave,
"drain-kpi-history-recorder-singleton",
async () =>
{
try
{
await kpiHistorySingletonManager.GracefulStop(TimeSpan.FromSeconds(10));
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"KpiHistoryRecorder singleton did not drain within the graceful-stop "
+ "timeout; falling through to PoisonPill handover");
}
return Akka.Done.Instance;
});
var kpiHistoryProxyProps = ClusterSingletonProxy.Props(
singletonManagerPath: "/user/kpi-history-recorder-singleton",
settings: ClusterSingletonProxySettings.Create(_actorSystem)
.WithSingletonName("kpi-history-recorder"));
_actorSystem.ActorOf(kpiHistoryProxyProps, "kpi-history-recorder-proxy");
_logger.LogInformation("KpiHistoryRecorderActor singleton created (not readiness-gated)");
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
}
@@ -16,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors;
using ZB.MOM.WW.ScadaBridge.Host.Health;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
using ZB.MOM.WW.ScadaBridge.NotificationService;
@@ -110,6 +111,11 @@ try
// but the call is here for symmetry with the other audit composition
// roots so future per-actor DI lands without touching Program.cs.
builder.Services.AddSiteCallAudit();
// KPI History (#26, M6) — central-only. Binds KpiHistoryOptions from
// ScadaBridge:KpiHistory and registers the validated options consumed by
// the KpiHistoryRecorderActor cluster singleton (started in
// AkkaHostedService). Observability/best-effort: NOT readiness-gated.
builder.Services.AddKpiHistory(builder.Configuration);
builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager();
// Host is the composition root and owns config-coupled wiring: register the
@@ -64,6 +64,12 @@
"PurgeInterval": "1.00:00:00",
"DeliveredKpiWindow": "00:01:00"
},
"KpiHistory": {
"SampleInterval": "00:01:00",
"RetentionDays": 90,
"PurgeInterval": "1.00:00:00",
"DefaultMaxSeriesPoints": 200
},
"Transport": {
"BundleSessionTtlMinutes": 30,
"MaxBundleSizeMb": 100,
@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
/// <summary>
/// Configuration for the KPI History (#26, M6) component. Bound from the
/// <c>ScadaBridge:KpiHistory</c> section of <c>appsettings.json</c>. Defaults
/// reflect the design: a 60-second sampling cadence for the point-in-time
/// Notification Outbox / Site Call Audit KPI snapshots, a 90-day central
/// retention window with a daily purge sweep, and a 200-point default ceiling
/// on the series returned to the Central UI trend charts.
/// </summary>
public sealed class KpiHistoryOptions
{
/// <summary>
/// How often the recorder captures a KPI sample row (default 60 s). Must be
/// strictly positive.
/// </summary>
public TimeSpan SampleInterval { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Central retention window in days for recorded KPI samples (default 90,
/// range <c>[1, 3650]</c>). Rows older than this are dropped by the purge
/// sweep.
/// </summary>
public int RetentionDays { get; set; } = 90;
/// <summary>
/// How often the purge sweep drops expired sample rows (default 1 day). Must
/// be strictly positive.
/// </summary>
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1);
/// <summary>
/// Default ceiling on the number of points returned in a single trend series
/// query when the caller does not specify one (default 200, range
/// <c>[2, 5000]</c>). At least two points are required to draw a line.
/// </summary>
public int DefaultMaxSeriesPoints { get; set; } = 200;
}
@@ -0,0 +1,58 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
/// <summary>
/// Validates <see cref="KpiHistoryOptions"/> on startup. A non-positive
/// <see cref="KpiHistoryOptions.SampleInterval"/> or
/// <see cref="KpiHistoryOptions.PurgeInterval"/> would stall the recorder /
/// purge loops, so both are required to be strictly positive.
/// <see cref="KpiHistoryOptions.RetentionDays"/> is bounded to <c>[1, 3650]</c>
/// to keep the purge window sane (too short drops trends still being viewed;
/// too long defeats the retention purpose). <see cref="KpiHistoryOptions.DefaultMaxSeriesPoints"/>
/// is bounded to <c>[2, 5000]</c> — at least two points are needed to draw a
/// line, and an unbounded ceiling would let a single trend query stream an
/// arbitrarily large series to the Central UI.
/// </summary>
public sealed class KpiHistoryOptionsValidator : OptionsValidatorBase<KpiHistoryOptions>
{
/// <summary>Inclusive lower bound for <see cref="KpiHistoryOptions.RetentionDays"/>.</summary>
public const int MinRetentionDays = 1;
/// <summary>Inclusive upper bound for <see cref="KpiHistoryOptions.RetentionDays"/>.</summary>
public const int MaxRetentionDays = 3650;
/// <summary>Inclusive lower bound for <see cref="KpiHistoryOptions.DefaultMaxSeriesPoints"/>.</summary>
public const int MinDefaultMaxSeriesPoints = 2;
/// <summary>Inclusive upper bound for <see cref="KpiHistoryOptions.DefaultMaxSeriesPoints"/>.</summary>
public const int MaxDefaultMaxSeriesPoints = 5000;
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options)
{
builder.RequireThat(options.SampleInterval > TimeSpan.Zero,
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SampleInterval)} ({options.SampleInterval}) " +
"must be > 0; it is the recorder's sampling cadence.");
// Valid when RetentionDays is within [Min, Max] inclusive. The De Morgan'd
// guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
builder.RequireThat(
!(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays),
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.RetentionDays)} ({options.RetentionDays}) " +
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
builder.RequireThat(options.PurgeInterval > TimeSpan.Zero,
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.PurgeInterval)} ({options.PurgeInterval}) " +
"must be > 0; it is the purge-sweep cadence.");
// Valid when DefaultMaxSeriesPoints is within [Min, Max] inclusive. The
// De Morgan'd guard !(below Min OR above Max) is equivalent to
// (>= Min AND <= Max).
builder.RequireThat(
!(options.DefaultMaxSeriesPoints < MinDefaultMaxSeriesPoints
|| options.DefaultMaxSeriesPoints > MaxDefaultMaxSeriesPoints),
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.DefaultMaxSeriesPoints)} ({options.DefaultMaxSeriesPoints}) " +
$"must be in [{MinDefaultMaxSeriesPoints}, {MaxDefaultMaxSeriesPoints}].");
}
}
@@ -0,0 +1,305 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
/// <summary>
/// Central cluster-singleton (M6 "KPI History &amp; Trends", K4) that drives the
/// KPI sampling backbone. On a periodic timer (default 60 s) it:
/// <list type="number">
/// <item>Stamps a single <c>capturedAtUtc</c> for the pass.</item>
/// <item>Fans out to every DI-registered <see cref="IKpiSampleSource"/>,
/// collecting each source's point-in-time samples.</item>
/// <item>Bulk-writes the combined batch through
/// <see cref="IKpiHistoryRepository.RecordSamplesAsync"/>.</item>
/// </list>
/// A separate daily timer (default 1 d) runs the retention purge, dropping rows
/// older than <see cref="KpiHistoryOptions.RetentionDays"/> via
/// <see cref="IKpiHistoryRepository.PurgeOlderThanAsync"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Best-effort.</b> KPI history is observability — it must NEVER disrupt the
/// system it observes. Every fault is contained: a throwing
/// <see cref="IKpiSampleSource"/> is caught per-source so it neither aborts the
/// pass nor suppresses the other sources; a throwing repository write/purge is
/// caught and logged. No exception escapes either tick handler, so the singleton
/// stays alive across transient DB outages and misbehaving sources.
/// </para>
/// <para>
/// <b>Off-thread work.</b> The actual sampling and purge I/O runs off the actor
/// thread; the result is piped back to <see cref="Self"/> via
/// <see cref="PipeToSupport.PipeTo{T}(Task{T}, ICanTell, IActorRef, System.Func{T, object}, System.Func{System.Exception, object})"/>
/// so the mailbox is never blocked while a DB round-trip is in flight. This
/// mirrors the <see cref="ZB.MOM.WW.ScadaBridge.NotificationOutbox.NotificationOutboxActor"/>
/// timer + scope-per-tick + PipeTo pattern.
/// </para>
/// <para>
/// <b>DI scopes.</b> <see cref="IKpiSampleSource"/>s and
/// <see cref="IKpiHistoryRepository"/> are scoped EF Core-backed services; the
/// recorder opens a fresh <see cref="IServiceScope"/> per tick and resolves
/// there, asynchronously disposing it (<c>CreateAsyncScope</c>) so EF Core's
/// async connection cleanup does not block.
/// </para>
/// <para>
/// <b>Singleton wiring.</b> The <c>Props</c> is built in the Host (K5) on the
/// active central node with the constructor args resolved from DI — this actor
/// has no knowledge of <c>Akka.Cluster.Tools</c>, mirroring the Notification
/// Outbox (#21) singleton split.
/// </para>
/// </remarks>
public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
{
private const string SampleTimerKey = "kpi-sample";
private const string PurgeTimerKey = "kpi-purge";
private readonly IServiceProvider _serviceProvider;
private readonly KpiHistoryOptions _options;
private readonly ILogger<KpiHistoryRecorderActor> _logger;
/// <summary>
/// Lifecycle-scoped cancellation source, cancelled in <see cref="PostStop"/> so any
/// in-flight sample/purge pass observes a coordinated shutdown / failover promptly
/// instead of blocking the singleton handover for a full DB round-trip timeout.
/// </summary>
private CancellationTokenSource? _shutdownCts;
/// <summary>Akka timer scheduler, assigned by the actor system via <see cref="IWithTimers"/>.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>
/// Initializes the recorder with its dependencies and registers the tick handlers.
/// </summary>
/// <param name="serviceProvider">DI service provider used to open a scope per tick for the sample sources and repository.</param>
/// <param name="options">KPI history configuration options.</param>
/// <param name="logger">Logger for this actor.</param>
public KpiHistoryRecorderActor(
IServiceProvider serviceProvider,
KpiHistoryOptions options,
ILogger<KpiHistoryRecorderActor> logger)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
Receive<SampleTick>(_ => HandleSampleTick());
Receive<SampleComplete>(_ => { }); // best-effort: no actor state to reset on completion
Receive<PurgeTick>(_ => HandlePurgeTick());
Receive<PurgeComplete>(_ => { }); // best-effort: no actor state to reset on completion
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
_shutdownCts = new CancellationTokenSource();
// Fire an initial sample shortly after start so the first trend point lands
// without waiting a full SampleInterval, then settle into the periodic cadence.
Timers.StartPeriodicTimer(
SampleTimerKey,
SampleTick.Instance,
initialDelay: TimeSpan.FromSeconds(5),
interval: _options.SampleInterval);
// The purge is daily and idempotent — no initial fast tick; the first sweep
// fires after a full PurgeInterval.
Timers.StartPeriodicTimer(
PurgeTimerKey,
PurgeTick.Instance,
_options.PurgeInterval);
}
/// <inheritdoc />
protected override void PostStop()
{
// Cancel before disposing so an in-flight pass observes cancellation; disposing
// first would race with a pass registering against the token.
try
{
_shutdownCts?.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed under a restarted-actor race; nothing to do.
}
_shutdownCts?.Dispose();
_shutdownCts = null;
base.PostStop();
}
/// <summary>
/// Handles a sample tick: captures the shared <c>capturedAtUtc</c> instant on the actor
/// thread, then launches the asynchronous sampling pass off-thread and pipes a
/// completion back to <see cref="Self"/> so the mailbox is never blocked while sources
/// are collected and the batch is written.
/// </summary>
private void HandleSampleTick()
{
var capturedAt = DateTime.UtcNow;
var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;
// RunSamplePass self-isolates its faults (it never throws), but the failure
// projection is kept as a belt-and-braces guard so even a faulted task still
// produces a SampleComplete.
RunSamplePass(capturedAt, cancellationToken).PipeTo(
Self,
success: () => SampleComplete.Instance,
failure: ex =>
{
_logger.LogError(ex, "KPI sample pass faulted unexpectedly.");
return SampleComplete.Instance;
});
}
/// <summary>
/// Runs a single sampling pass: opens a DI scope, enumerates every registered
/// <see cref="IKpiSampleSource"/>, collects each source's samples (isolating per-source
/// faults), and bulk-writes the combined batch. The whole body is wrapped so the
/// returned task never faults — best-effort observability must never disrupt anything.
/// </summary>
private async Task RunSamplePass(DateTime capturedAt, CancellationToken cancellationToken)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope();
var sources = scope.ServiceProvider.GetServices<IKpiSampleSource>();
var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
var samples = new List<KpiSample>();
foreach (var source in sources)
{
try
{
var collected = await source.CollectAsync(capturedAt, cancellationToken);
samples.AddRange(collected);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown interrupted collection; abandon the rest of the pass. The next
// active node samples on its own cadence. Not a failure.
return;
}
catch (Exception ex)
{
// A throwing source must NOT abort the pass or the other sources.
_logger.LogError(ex, "KPI source {Source} failed to collect samples.", source.Source);
}
}
if (samples.Count == 0)
{
return;
}
try
{
await repository.RecordSamplesAsync(samples, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown interrupted the write; the batch is dropped (best-effort). Not a failure.
}
catch (Exception ex)
{
_logger.LogError(ex, "KPI sample write failed for {SampleCount} sample(s).", samples.Count);
}
}
catch (Exception ex)
{
// Scope creation or service resolution faulted; swallow and log so the returned
// task completes normally and the singleton stays alive.
_logger.LogError(ex, "KPI sample pass failed unexpectedly.");
}
}
/// <summary>
/// Handles a purge tick: computes the retention cut-off on the actor thread, then runs
/// the bulk delete off-thread and pipes a completion back to <see cref="Self"/>. Purges
/// are daily and idempotent, so no in-flight guard is needed.
/// </summary>
private void HandlePurgeTick()
{
var before = DateTime.UtcNow - TimeSpan.FromDays(_options.RetentionDays);
var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;
RunPurgePass(before, cancellationToken).PipeTo(
Self,
success: deleted =>
{
if (deleted > 0)
{
_logger.LogInformation(
"KPI history purge removed {DeletedCount} sample(s) older than {Cutoff:o}.",
deleted, before);
}
return PurgeComplete.Instance;
},
failure: ex =>
{
_logger.LogError(ex, "KPI history purge faulted unexpectedly.");
return PurgeComplete.Instance;
});
}
/// <summary>
/// Runs a single purge sweep: opens a DI scope, resolves the repository, and bulk-deletes
/// rows captured before <paramref name="before"/>, returning the deleted count. The whole
/// body is wrapped so the returned task never faults — on failure the exception is logged
/// and 0 is returned, mirroring <see cref="RunSamplePass"/>'s best-effort contract.
/// </summary>
private async Task<int> RunPurgePass(DateTime before, CancellationToken cancellationToken)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
return await repository.PurgeOlderThanAsync(before, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown interrupted the purge; the next active sweep retries. Not a failure.
return 0;
}
catch (Exception ex)
{
_logger.LogError(ex, "KPI history purge failed unexpectedly.");
return 0;
}
}
/// <summary>Self-tick triggering a sampling pass across all registered sources.</summary>
internal sealed class SampleTick
{
public static readonly SampleTick Instance = new();
private SampleTick() { }
}
/// <summary>Piped-back completion of a sampling pass; lets the pass run off the actor thread.</summary>
internal sealed class SampleComplete
{
public static readonly SampleComplete Instance = new();
private SampleComplete() { }
}
/// <summary>Self-tick triggering a retention purge sweep.</summary>
internal sealed class PurgeTick
{
public static readonly PurgeTick Instance = new();
private PurgeTick() { }
}
/// <summary>Piped-back completion of a purge sweep; lets the sweep run off the actor thread.</summary>
internal sealed class PurgeComplete
{
public static readonly PurgeComplete Instance = new();
private PurgeComplete() { }
}
}
@@ -0,0 +1,44 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
/// <summary>
/// Composition root for the KPI History (#26, M6) component.
/// </summary>
/// <remarks>
/// <para>
/// K3 scaffolds the component: it binds and validates
/// <see cref="KpiHistoryOptions"/>. The recorder actor itself is created via
/// <c>Props</c> in the Host on the active central node (K4/K5), not registered
/// here — mirroring the Notification Outbox (#21) singleton wiring.
/// </para>
/// </remarks>
public static class ServiceCollectionExtensions
{
/// <summary>Configuration section bound to <see cref="KpiHistoryOptions"/>.</summary>
public const string OptionsSection = "ScadaBridge:KpiHistory";
/// <summary>
/// Registers the KPI History (#26) component services: the validated
/// <see cref="KpiHistoryOptions"/> binding. Idempotent re-registration of the
/// validator is handled by the shared <c>AddValidatedOptions</c> helper
/// (<c>TryAddEnumerable</c>); the options binding itself is call-once per
/// <see cref="IServiceCollection"/>.
/// </summary>
/// <param name="services">The service collection to register into.</param>
/// <param name="configuration">Application configuration used to bind <see cref="KpiHistoryOptions"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddKpiHistory(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Binds the "ScadaBridge:KpiHistory" section, registers the validator,
// and enables ValidateOnStart in one call — same shape as AddAuditLog.
services.AddValidatedOptions<KpiHistoryOptions, KpiHistoryOptionsValidator>(configuration, OptionsSection);
return services;
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
</ItemGroup>
<ItemGroup>
<!-- K4: expose the actor's internal tick messages (SampleTick/PurgeTick) so the
recorder's TestKit tests can drive ticks deterministically. Mirrors the
NotificationOutbox InternalsVisibleTo pattern. -->
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.KpiHistory.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,144 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
/// <summary>
/// <see cref="IKpiSampleSource"/> for the Notification Outbox (#21): snapshots the same
/// point-in-time delivery KPIs the live Health-dashboard tiles surface — queue depth, stuck
/// count, parked count, delivered-last-interval, and oldest-pending age — at every sampling
/// pass of the central KPI-history recorder (M6 "KPI History &amp; Trends").
/// </summary>
/// <remarks>
/// <para>
/// Computes the same cutoffs the live KPI handlers in
/// <see cref="NotificationOutboxActor"/> use, anchored on the recorder's shared
/// <c>capturedAtUtc</c> rather than wall-clock <c>now</c>: the stuck cutoff is
/// <c>capturedAtUtc - <see cref="NotificationOutboxOptions.StuckAgeThreshold"/></c> and the
/// delivered window is <c>capturedAtUtc - <see cref="NotificationOutboxOptions.DeliveredKpiWindow"/></c>.
/// The COUNT metrics (queueDepth, stuckCount, parkedCount, deliveredLastInterval) equal the
/// live tile at the same instant; the <c>oldestPendingAgeSeconds</c> metric is computed against
/// the repository's internal clock and may differ from the live tile by the query-execution latency.
/// </para>
/// <para>
/// Emits Global (<c>ScopeKey == null</c>), per-Site (<c>ScopeKey == SourceSiteId</c>), and
/// per-Node (<c>ScopeKey == SourceNode</c>) samples, mirroring the repository's three KPI
/// computation methods. The oldest-pending age (a <see cref="TimeSpan"/>?) maps to
/// <c>oldestPendingAgeSeconds</c> via <see cref="TimeSpan.TotalSeconds"/>; that one metric is
/// omitted when the age is <c>null</c>.
/// </para>
/// </remarks>
public sealed class NotificationOutboxKpiSampleSource : IKpiSampleSource
{
private const string MetricQueueDepth = "queueDepth";
private const string MetricStuckCount = "stuckCount";
private const string MetricParkedCount = "parkedCount";
private const string MetricDeliveredLastInterval = "deliveredLastInterval";
private const string MetricOldestPendingAgeSeconds = "oldestPendingAgeSeconds";
private readonly INotificationOutboxRepository _repository;
private readonly NotificationOutboxOptions _options;
/// <summary>
/// Creates the sample source.
/// </summary>
/// <param name="repository">Outbox repository providing the KPI computation methods.</param>
/// <param name="options">Outbox options carrying the stuck-age and delivered-window cutoffs.</param>
public NotificationOutboxKpiSampleSource(
INotificationOutboxRepository repository,
IOptions<NotificationOutboxOptions> options)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(options);
_repository = repository;
_options = options.Value;
}
/// <inheritdoc />
public string Source => KpiSources.NotificationOutbox;
/// <inheritdoc />
public async Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default)
{
// Anchor the live KPI cutoffs on the recorder's shared capture instant, so a sample
// captured at the same moment equals the live Health-dashboard tile.
var capturedAt = new DateTimeOffset(capturedAtUtc, TimeSpan.Zero);
var stuckCutoff = capturedAt - _options.StuckAgeThreshold;
var deliveredSince = capturedAt - _options.DeliveredKpiWindow;
var samples = new List<KpiSample>();
var global = await _repository.ComputeKpisAsync(stuckCutoff, deliveredSince, cancellationToken)
.ConfigureAwait(false);
AddSnapshot(
samples, capturedAtUtc, KpiScopes.Global, scopeKey: null,
global.QueueDepth, global.StuckCount, global.ParkedCount,
global.DeliveredLastInterval, global.OldestPendingAge);
var perSite = await _repository.ComputePerSiteKpisAsync(stuckCutoff, deliveredSince, cancellationToken)
.ConfigureAwait(false);
foreach (var site in perSite)
{
AddSnapshot(
samples, capturedAtUtc, KpiScopes.Site, site.SourceSiteId,
site.QueueDepth, site.StuckCount, site.ParkedCount,
site.DeliveredLastInterval, site.OldestPendingAge);
}
var perNode = await _repository.ComputePerNodeKpisAsync(stuckCutoff, deliveredSince, cancellationToken)
.ConfigureAwait(false);
foreach (var node in perNode)
{
AddSnapshot(
samples, capturedAtUtc, KpiScopes.Node, node.SourceNode,
node.QueueDepth, node.StuckCount, node.ParkedCount,
node.DeliveredLastInterval, node.OldestPendingAge);
}
return samples;
}
/// <summary>
/// Appends the five outbox metrics for one snapshot at the given scope, omitting
/// <c>oldestPendingAgeSeconds</c> when <paramref name="oldestPendingAge"/> is <c>null</c>.
/// </summary>
private void AddSnapshot(
List<KpiSample> samples,
DateTime capturedAtUtc,
string scope,
string? scopeKey,
int queueDepth,
int stuckCount,
int parkedCount,
int deliveredLastInterval,
TimeSpan? oldestPendingAge)
{
samples.Add(Sample(capturedAtUtc, MetricQueueDepth, scope, scopeKey, queueDepth));
samples.Add(Sample(capturedAtUtc, MetricStuckCount, scope, scopeKey, stuckCount));
samples.Add(Sample(capturedAtUtc, MetricParkedCount, scope, scopeKey, parkedCount));
samples.Add(Sample(capturedAtUtc, MetricDeliveredLastInterval, scope, scopeKey, deliveredLastInterval));
if (oldestPendingAge is { } age)
{
samples.Add(Sample(capturedAtUtc, MetricOldestPendingAgeSeconds, scope, scopeKey, age.TotalSeconds));
}
}
private KpiSample Sample(
DateTime capturedAtUtc, string metric, string scope, string? scopeKey, double value) =>
new()
{
Source = KpiSources.NotificationOutbox,
Metric = metric,
Scope = scope,
ScopeKey = scopeKey,
Value = value,
CapturedAtUtc = capturedAtUtc,
};
}
@@ -1,5 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox;
@@ -46,6 +49,12 @@ public static class ServiceCollectionExtensions
services.AddScoped<INotificationDeliveryAdapter>(
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
// KPI history (M6): the recorder singleton enumerates every IKpiSampleSource each
// sampling pass to snapshot the outbox delivery KPIs into the central history store.
// TryAddEnumerable is idempotent — no double-registration if AddNotificationOutbox
// is ever called more than once, matching the AuditLog (K8) idiom.
services.TryAddEnumerable(ServiceDescriptor.Scoped<IKpiSampleSource, NotificationOutboxKpiSampleSource>());
return services;
}
}
@@ -0,0 +1,163 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Kpi;
/// <summary>
/// Site Call Audit (#22) <see cref="IKpiSampleSource"/> for the M6 "KPI History
/// &amp; Trends" backbone. Each sampling pass the central recorder enumerates this
/// source and calls <see cref="CollectAsync"/>, which snapshots the same
/// point-in-time <c>SiteCalls</c> KPIs the live Health-dashboard tiles show —
/// global, per-source-site, and per-originating-node — into flat
/// <see cref="KpiSample"/> rows.
/// </summary>
/// <remarks>
/// <para>
/// The cutoffs are derived from <see cref="SiteCallAuditOptions"/> exactly as the
/// live <c>SiteCallAuditActor</c> KPI handlers derive them
/// (<c>stuckCutoff = capturedAtUtc - StuckAgeThreshold</c>,
/// <c>intervalSince = capturedAtUtc - KpiInterval</c>). The COUNT metrics
/// (buffered, parked, failedLastInterval, deliveredLastInterval, stuck) equal the
/// live tile at the same instant; the <c>oldestPendingAgeSeconds</c> metric is
/// computed against the repository's internal clock and may differ from the live
/// tile by the query-execution latency. The recorder's <c>capturedAtUtc</c> is
/// the single anchor for both cutoffs.
/// </para>
/// <para>
/// Registered DI-scoped (next to the rest of the Site Call Audit composition) so
/// each sampling pass resolves a fresh repository scope, mirroring the actor's
/// scope-per-message repository access.
/// </para>
/// </remarks>
public sealed class SiteCallAuditKpiSampleSource : IKpiSampleSource
{
// ── Metric catalog (the M6-agreed metric-name strings for this source) ──
// Declaration order matches the emission order in AddSnapshot.
private const string MetricBuffered = "buffered";
private const string MetricParked = "parked";
private const string MetricFailedLastInterval = "failedLastInterval";
private const string MetricDeliveredLastInterval = "deliveredLastInterval";
private const string MetricStuck = "stuck";
private const string MetricOldestPendingAgeSeconds = "oldestPendingAgeSeconds";
private readonly ISiteCallAuditRepository _repository;
private readonly SiteCallAuditOptions _options;
/// <summary>
/// Creates the sample source over the central <c>SiteCalls</c> repository and
/// the Site Call Audit options that define the stuck-age + KPI-interval
/// windows.
/// </summary>
/// <param name="repository">The central <c>SiteCalls</c> operational-state repository.</param>
/// <param name="options">Site Call Audit windowing options (stuck-age + KPI interval).</param>
public SiteCallAuditKpiSampleSource(
ISiteCallAuditRepository repository,
IOptions<SiteCallAuditOptions> options)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(options);
_repository = repository;
_options = options.Value;
}
/// <inheritdoc />
public string Source => KpiSources.SiteCallAudit;
/// <inheritdoc />
public async Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default)
{
// Match the live SiteCallAuditActor KPI handlers: stuck cutoff and
// interval window are both anchored on the single capture instant.
var stuckCutoff = capturedAtUtc - _options.StuckAgeThreshold;
var intervalSince = capturedAtUtc - _options.KpiInterval;
var global = await _repository
.ComputeKpisAsync(stuckCutoff, intervalSince, cancellationToken)
.ConfigureAwait(false);
var perSite = await _repository
.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, cancellationToken)
.ConfigureAwait(false);
var perNode = await _repository
.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, cancellationToken)
.ConfigureAwait(false);
var samples = new List<KpiSample>();
// Global scope (null ScopeKey).
AddSnapshot(
samples, capturedAtUtc, KpiScopes.Global, scopeKey: null,
global.BufferedCount, global.ParkedCount,
global.FailedLastInterval, global.DeliveredLastInterval,
global.OldestPendingAge, global.StuckCount);
// Per-site scope (ScopeKey = source site id).
foreach (var site in perSite)
{
AddSnapshot(
samples, capturedAtUtc, KpiScopes.Site, scopeKey: site.SourceSite,
site.BufferedCount, site.ParkedCount,
site.FailedLastInterval, site.DeliveredLastInterval,
site.OldestPendingAge, site.StuckCount);
}
// Per-node scope (ScopeKey = node name).
foreach (var node in perNode)
{
AddSnapshot(
samples, capturedAtUtc, KpiScopes.Node, scopeKey: node.SourceNode,
node.BufferedCount, node.ParkedCount,
node.FailedLastInterval, node.DeliveredLastInterval,
node.OldestPendingAge, node.StuckCount);
}
return samples;
}
/// <summary>
/// Appends the six-metric catalog for one snapshot at the given scope. The
/// oldest-pending-age metric is omitted when the snapshot's age is
/// <c>null</c> (no non-terminal rows) rather than written as zero.
/// </summary>
private void AddSnapshot(
List<KpiSample> samples,
DateTime capturedAtUtc,
string scope,
string? scopeKey,
int buffered,
int parked,
int failedLastInterval,
int deliveredLastInterval,
TimeSpan? oldestPendingAge,
int stuck)
{
samples.Add(Sample(capturedAtUtc, MetricBuffered, scope, scopeKey, buffered));
samples.Add(Sample(capturedAtUtc, MetricParked, scope, scopeKey, parked));
samples.Add(Sample(capturedAtUtc, MetricFailedLastInterval, scope, scopeKey, failedLastInterval));
samples.Add(Sample(capturedAtUtc, MetricDeliveredLastInterval, scope, scopeKey, deliveredLastInterval));
samples.Add(Sample(capturedAtUtc, MetricStuck, scope, scopeKey, stuck));
if (oldestPendingAge is { } age)
{
samples.Add(Sample(
capturedAtUtc, MetricOldestPendingAgeSeconds, scope, scopeKey, age.TotalSeconds));
}
}
private KpiSample Sample(
DateTime capturedAtUtc, string metric, string scope, string? scopeKey, double value) =>
new()
{
Source = KpiSources.SiteCallAudit,
Metric = metric,
Scope = scope,
ScopeKey = scopeKey,
Value = value,
CapturedAtUtc = capturedAtUtc,
};
}
@@ -1,4 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.SiteCallAudit.Kpi;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit;
@@ -37,6 +40,15 @@ public static class ServiceCollectionExtensions
services.AddOptions<SiteCallAuditOptions>()
.BindConfiguration(OptionsSection);
// M6 KPI History (#K7): the central recorder enumerates every registered
// IKpiSampleSource each sampling pass; this one snapshots the SiteCalls
// KPIs (global + per-site + per-node) into the KpiSample history store.
// Scoped so each pass resolves a fresh ISiteCallAuditRepository scope,
// mirroring the actor's scope-per-message repository access.
// TryAddEnumerable is idempotent — no double-registration if AddSiteCallAudit
// is ever called more than once, matching the AuditLog (K8) idiom.
services.TryAddEnumerable(ServiceDescriptor.Scoped<IKpiSampleSource, SiteCallAuditKpiSampleSource>());
return services;
}
}
@@ -0,0 +1,108 @@
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Kpi;
/// <summary>
/// M6 "KPI History &amp; Trends" (K8) coverage for <see cref="AuditLogKpiSampleSource"/> —
/// the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi.IKpiSampleSource"/> the
/// central recorder enumerates to snapshot Audit Log KPIs. Asserts the source maps the
/// repository's <see cref="AuditLogKpiSnapshot"/> onto exactly three Global-scope samples
/// with the canonical metric names, the recorder-supplied capture instant, and a null
/// <c>ScopeKey</c>.
/// </summary>
public class AuditLogKpiSampleSourceTests
{
private static readonly DateTime CapturedAt = new(2026, 6, 17, 12, 30, 0, DateTimeKind.Utc);
[Fact]
public void Source_is_AuditLog()
{
var repo = Substitute.For<IAuditLogRepository>();
var sut = new AuditLogKpiSampleSource(repo);
Assert.Equal(KpiSources.AuditLog, sut.Source);
}
[Fact]
public async Task CollectAsync_emits_three_global_samples_mapped_from_snapshot()
{
// Distinct values so a swapped mapping (e.g. error ↔ backlog) is caught.
var snapshot = new AuditLogKpiSnapshot(
TotalEventsLastHour: 4200,
ErrorEventsLastHour: 17,
BacklogTotal: 305,
AsOfUtc: new DateTime(2026, 6, 17, 12, 30, 5, DateTimeKind.Utc));
var repo = Substitute.For<IAuditLogRepository>();
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.Returns(snapshot);
var sut = new AuditLogKpiSampleSource(repo);
var samples = await sut.CollectAsync(CapturedAt);
Assert.Equal(3, samples.Count);
// Every sample: AuditLog source, Global scope, null ScopeKey, the
// recorder-stamped capture instant.
Assert.All(samples, s =>
{
Assert.Equal(KpiSources.AuditLog, s.Source);
Assert.Equal(KpiScopes.Global, s.Scope);
Assert.Null(s.ScopeKey);
Assert.Equal(CapturedAt, s.CapturedAtUtc);
});
// Exact (Metric, Value) catalog mapping.
AssertMetric(samples, "totalEventsLastHour", 4200);
AssertMetric(samples, "errorEventsLastHour", 17);
AssertMetric(samples, "backlogTotal", 305);
}
[Fact]
public async Task CollectAsync_anchors_the_window_on_capturedAtUtc()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.Returns(new AuditLogKpiSnapshot(0, 0, 0, CapturedAt));
var sut = new AuditLogKpiSampleSource(repo);
await sut.CollectAsync(CapturedAt);
// The trailing 1h window is anchored on the recorder's shared instant,
// not the repository's server-side UtcNow, so the sample is reproducible.
await repo.Received(1).GetKpiSnapshotAsync(
TimeSpan.FromHours(1),
CapturedAt,
Arg.Any<CancellationToken>());
}
[Fact]
public async Task CollectAsync_returns_empty_when_snapshot_is_null()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.Returns((AuditLogKpiSnapshot?)null!);
var sut = new AuditLogKpiSampleSource(repo);
var samples = await sut.CollectAsync(CapturedAt);
Assert.NotNull(samples);
Assert.Empty(samples);
}
private static void AssertMetric(
IReadOnlyList<Commons.Entities.Kpi.KpiSample> samples,
string metric,
double expectedValue)
{
var sample = Assert.Single(samples, s => s.Metric == metric);
Assert.Equal(expectedValue, sample.Value);
}
}
@@ -0,0 +1,106 @@
using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring;
/// <summary>
/// E2E coverage for the M6 "KPI History &amp; Trends" feature (K17's spec slice):
/// proves a <c>KpiTrendChart</c> trend section renders in the live Central UI.
///
/// <para>
/// <b>Page targeted</b> — the Notification Outbox KPIs page
/// (<c>NotificationKpis.razor</c>, <c>@page "/notifications/kpis"</c>). Its trend
/// section is wrapped in a container marked <c>data-test="notification-trends"</c>
/// and renders three <see cref="KpiTrendChart"/> cards (Queue Depth, Parked,
/// Delivered / interval). The NotificationOutbox KPI source samples on every
/// recorder tick even at zero values, so this page is the most reliably-populated
/// trend surface on a fresh cluster — preferred over the Health dashboard fallback
/// (<c>/monitoring/health</c>, container <c>data-test="site-health-trends"</c>).
/// The page requires the Deployment policy, which the <c>multi-role</c> test user
/// holds (Admin + Design + Deployment).
/// </para>
///
/// <para>
/// <b>Fixture / auth / skip plumbing mirrors <see cref="HealthDashboardTests"/></b>
/// — a navigate-and-read test (no instance seeding), so it takes the
/// <see cref="PlaywrightFixture"/> directly (not <c>DeploymentFixture</c>),
/// authenticates with <see cref="PlaywrightFixture.NewAuthenticatedPageAsync()"/>
/// (the <c>multi-role</c> / <c>password</c> default), and gates on
/// <see cref="ClusterAvailability.IsAvailableAsync"/> via <c>Skip.IfNot</c> so the
/// suite stays green when docker is down.
/// </para>
///
/// <para>
/// <b>Why the polyline assertion is tolerant</b> (the DebugView lesson) — a fresh
/// cluster may have little or no KPI history sampled yet.
/// <see cref="KpiTrendChart"/> renders its <c>data-test="kpi-trend-*"</c> CARD in
/// ALL THREE states: the ≥2-point <c>&lt;polyline&gt;</c> chart, the single-sample
/// note, and the unavailable/empty em-dash placeholder. The <c>&lt;polyline&gt;</c>
/// only exists in the first (≥2 samples) state. So the hard gate is card presence
/// (renders regardless of data); the polyline is asserted TOLERANTLY — present is
/// logged, absent is fine (recorder hasn't sampled twice yet). This mirrors exactly
/// how <see cref="DebugViewTreeTests"/> tolerated the empty alarm tree
/// (<c>[role='tree']</c> present vs. EmptyContent hint).
/// </para>
/// </summary>
[Collection("Playwright")]
public class KpiTrendChartTests
{
/// <summary>Notification Outbox KPIs page route (from <c>NotificationKpis.razor</c>'s <c>@page</c>).</summary>
private const string KpisUrl = "/notifications/kpis";
private readonly PlaywrightFixture _fixture;
public KpiTrendChartTests(PlaywrightFixture fixture)
{
_fixture = fixture;
}
/// <summary>
/// Navigates to the Notification Outbox KPIs page and asserts the KPI trend
/// section renders: the trends container is visible, at least one
/// <c>kpi-trend-*</c> card is present, and (tolerantly) reports whether any
/// polyline has been plotted yet.
/// </summary>
[SkippableFact]
public async Task NotificationKpis_RendersTrendSection_WithKpiTrendCards()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{KpisUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// ── Hard gate 1: the trend section container renders. ──
// NotificationKpis.razor wraps the Trends heading + window toggle in a flex
// container marked data-test="notification-trends"; the three trend cards sit
// in the div.row immediately below it.
var trends = page.Locator("[data-test='notification-trends']");
await Assertions.Expect(trends).ToBeVisibleAsync(new() { Timeout = 20_000 });
// ── Hard gate 2: at least one kpi-trend-* card is present. ──
// KpiTrendChart renders its data-test="kpi-trend-<slug>" card in EVERY state
// (polyline chart, single-sample note, OR em-dash placeholder), so this holds
// regardless of whether any history has been sampled yet on a fresh cluster.
var trendCards = page.Locator("[data-test^='kpi-trend-']");
await Assertions.Expect(trendCards.First).ToBeVisibleAsync(new() { Timeout = 20_000 });
var cardCount = await trendCards.CountAsync();
Assert.True(cardCount >= 1, $"Expected at least one kpi-trend-* card; found {cardCount}.");
// ── Tolerant data assertion: the polyline (actual plotted series). ──
// The <polyline> renders ONLY in KpiTrendChart's ≥2-point chart state. On a
// fresh cluster the recorder may not have sampled twice yet (single-sample or
// empty), so its absence is NOT a failure — card presence above is the hard
// gate. Mirrors DebugViewTreeTests tolerating the empty alarm tree.
var polylineCount = await trendCards.Locator("polyline").CountAsync();
if (polylineCount > 0)
{
// History has been sampled — at least one card plotted a series.
Assert.True(
polylineCount > 0,
$"Observed {polylineCount} plotted trend polyline(s) across the kpi-trend-* cards.");
}
// else: no polyline yet (recorder hasn't accumulated ≥2 samples / empty
// history) — tolerated by design; the card-presence gate above is authoritative.
}
}
@@ -0,0 +1,149 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Shared;
/// <summary>
/// bUnit tests for <see cref="KpiTrendChart"/> (M6 K12) — the reusable
/// dependency-free SVG trend chart. Coverage pins the three render states
/// (multi-point chart, unavailable placeholder, single-sample note), the stable
/// <c>data-test</c> slug, and the coordinate-math edge guards (all-equal
/// timestamps, all-zero values) that must not divide by zero or throw.
/// </summary>
public class KpiTrendChartTests : BunitContext
{
private static readonly DateTime Base = new(2026, 6, 15, 10, 0, 0, DateTimeKind.Utc);
private static IReadOnlyList<KpiSeriesPoint> ThreePoints() => new[]
{
new KpiSeriesPoint(Base, 1.0),
new KpiSeriesPoint(Base.AddMinutes(5), 4.0),
new KpiSeriesPoint(Base.AddMinutes(10), 2.0),
};
[Fact]
public void Available_WithThreePoints_RendersPolylineTitleAndDataTest()
{
var cut = Render<KpiTrendChart>(p => p
.Add(c => c.Points, ThreePoints())
.Add(c => c.Title, "Queue Depth")
.Add(c => c.IsAvailable, true));
var markup = cut.Markup;
Assert.Contains("<polyline", markup);
Assert.Contains("Queue Depth", markup);
Assert.Contains("data-test=\"kpi-trend-queue-depth\"", markup);
// The SVG must carry an accessible title.
Assert.Contains("<title>", markup);
}
[Fact]
public void Available_WithUnit_AppendsUnitToValueLabels()
{
var cut = Render<KpiTrendChart>(p => p
.Add(c => c.Points, ThreePoints())
.Add(c => c.Title, "Oldest Pending")
.Add(c => c.Unit, "s")
.Add(c => c.IsAvailable, true));
// Max value label is "4 s" with the unit suffix applied.
Assert.Contains("max 4 s", cut.Markup);
}
[Fact]
public void Unavailable_RendersEmDashPlaceholderAndError_NoPolyline()
{
var cut = Render<KpiTrendChart>(p => p
.Add(c => c.Points, ThreePoints())
.Add(c => c.Title, "Queue Depth")
.Add(c => c.IsAvailable, false)
.Add(c => c.ErrorMessage, "KPI query failed"));
var markup = cut.Markup;
Assert.Contains("—", markup); // em dash
Assert.Contains("KPI query failed", markup);
Assert.DoesNotContain("<polyline", markup);
}
[Fact]
public void NullPoints_RendersPlaceholder_NoPolyline()
{
var cut = Render<KpiTrendChart>(p => p
.Add(c => c.Points, (IReadOnlyList<KpiSeriesPoint>?)null)
.Add(c => c.Title, "Stuck Count")
.Add(c => c.IsAvailable, true));
var markup = cut.Markup;
Assert.Contains("—", markup);
Assert.DoesNotContain("<polyline", markup);
}
[Fact]
public void SinglePoint_RendersSingleSampleState_NoPolyline_DoesNotThrow()
{
var cut = Render<KpiTrendChart>(p => p
.Add(c => c.Points, new[] { new KpiSeriesPoint(Base, 7.0) })
.Add(c => c.Title, "Backlog")
.Add(c => c.IsAvailable, true));
var markup = cut.Markup;
Assert.Contains("Only one sample in range.", markup);
Assert.DoesNotContain("<polyline", markup);
}
[Fact]
public void AllEqualTimestamps_DoesNotThrow_AndRendersPolyline()
{
// Zero time span would divide by zero without the even-spacing guard.
var cut = Render<KpiTrendChart>(p => p
.Add(c => c.Points, new[]
{
new KpiSeriesPoint(Base, 1.0),
new KpiSeriesPoint(Base, 2.0),
new KpiSeriesPoint(Base, 3.0),
})
.Add(c => c.Title, "Flat Time")
.Add(c => c.IsAvailable, true));
var markup = cut.Markup;
Assert.Contains("<polyline", markup);
// The even-spacing fallback must produce finite coordinates — no NaN or Infinity
// from the divide-by-zero guard path.
Assert.DoesNotContain("NaN", markup);
Assert.DoesNotContain("Infinity", markup);
}
[Fact]
public void AllZeroValues_DoesNotThrow_AndRendersFlatPolyline()
{
// Zero max value would divide by zero without the flat-line guard.
var cut = Render<KpiTrendChart>(p => p
.Add(c => c.Points, new[]
{
new KpiSeriesPoint(Base, 0.0),
new KpiSeriesPoint(Base.AddMinutes(5), 0.0),
new KpiSeriesPoint(Base.AddMinutes(10), 0.0),
})
.Add(c => c.Title, "Quiet")
.Add(c => c.IsAvailable, true));
var markup = cut.Markup;
Assert.Contains("<polyline", markup);
// The zero-max flat-line guard must produce finite coordinates — no NaN or Infinity
// from the yFrac divide-by-zero protection path.
Assert.DoesNotContain("NaN", markup);
Assert.DoesNotContain("Infinity", markup);
}
[Theory]
[InlineData("Queue Depth", "kpi-trend-queue-depth")]
[InlineData("Oldest Pending (s)", "kpi-trend-oldest-pending-s")]
[InlineData(" Trailing Spaces ", "kpi-trend-trailing-spaces")]
[InlineData("!!!", "kpi-trend-chart")]
[InlineData("", "kpi-trend-chart")]
public void Slugify_ProducesStableSlug(string title, string expectedDataTest)
{
Assert.Equal(expectedDataTest, $"kpi-trend-{KpiTrendChart.Slugify(title)}");
}
}
@@ -71,6 +71,9 @@ public class AuditLogPagePermissionTests : BunitContext
// a permitted render is exercised end-to-end.
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
// M6 (K15): the page now injects IKpiHistoryQueryService for the Trends
// panel — register a stand-in so a permitted render is exercised end-to-end.
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
}
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
@@ -62,6 +62,9 @@ public class AuditLogPageScaffoldTests : BunitContext
// Provide stand-ins so the scaffold smoke tests still render the page.
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteRepository>());
Services.AddSingleton(_queryService);
// M6 (K15): the page now injects IKpiHistoryQueryService for the Trends
// panel. Provide a stand-in so the scaffold/drill-in renders still work.
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
if (!string.IsNullOrEmpty(query))
{
@@ -0,0 +1,141 @@
using System.Security.Claims;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Security;
using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.AuditLogPage;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Tests for the M6 (K15) Trends panel added to the Audit Log page — one
/// <c>KpiTrendChart</c> per AuditLog global metric, over a 24h/7d window. The
/// panel is best-effort: a failing <see cref="IKpiHistoryQueryService"/> must
/// degrade the affected charts without breaking the audit query UI (the filter
/// bar + results grid that share the page).
///
/// <para>
/// bUnit hosts the page directly, so the test registers every service the page
/// and its child components inject: <see cref="AuthenticationStateProvider"/> +
/// the real authorization policies (the page carries
/// <c>[Authorize(OperationalAudit)]</c> and an in-page <c>AuthorizeView</c> for
/// the Export button), <see cref="ISiteRepository"/> (AuditFilterBar),
/// <see cref="IAuditLogQueryService"/> (AuditResultsGrid), and the new
/// <see cref="IKpiHistoryQueryService"/> (the Trends panel). The page is wrapped
/// in a <see cref="CascadingAuthenticationState"/> the router would supply in
/// production.
/// </para>
/// </summary>
public class AuditLogPageTrendTests : BunitContext
{
private static readonly DateTime Base = new(2026, 6, 15, 10, 0, 0, DateTimeKind.Utc);
public AuditLogPageTrendTests()
{
// AuditResultsGrid's OnAfterRenderAsync wires its column resize/reorder UX
// through audit-grid.js. Loose mode lets those unconfigured JS calls no-op
// so these trend tests need not configure browser interop.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private static IReadOnlyList<KpiSeriesPoint> ThreePoints() => new[]
{
new KpiSeriesPoint(Base, 1.0),
new KpiSeriesPoint(Base.AddMinutes(20), 4.0),
new KpiSeriesPoint(Base.AddMinutes(40), 2.0),
};
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(IKpiHistoryQueryService kpiHistory)
{
var user = BuildPrincipal("Administrator");
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
// The page hosts AuditFilterBar + AuditResultsGrid (Bundle B) and the
// K15 Trends panel — register a stand-in for each injected dependency.
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
Services.AddSingleton(kpiHistory);
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<AuditLogPage>(0);
builder.CloseComponent();
})));
return host.FindComponent<AuditLogPage>();
}
[Fact]
public void TrendsPanel_WithSeries_RendersChartsWithPolyline()
{
var kpi = Substitute.For<IKpiHistoryQueryService>();
kpi.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(ThreePoints()));
var cut = RenderAuditLogPage(kpi);
cut.WaitForAssertion(() =>
{
// The panel container is present...
Assert.Contains("data-test=\"audit-trends\"", cut.Markup);
// ...and at least one chart rendered an actual polyline series.
Assert.Contains("kpi-trend-", cut.Markup);
Assert.Contains("<polyline", cut.Markup);
// The audit query UI still rendered alongside the trends panel.
Assert.Contains("Audit Log", cut.Markup);
});
// All three AuditLog global metrics were queried (one chart each).
kpi.Received().GetSeriesAsync(
KpiSources.AuditLog, "totalEventsLastHour", KpiScopes.Global, null,
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
kpi.Received().GetSeriesAsync(
KpiSources.AuditLog, "errorEventsLastHour", KpiScopes.Global, null,
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
kpi.Received().GetSeriesAsync(
KpiSources.AuditLog, "backlogTotal", KpiScopes.Global, null,
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
}
[Fact]
public void TrendsPanel_WhenQueryServiceThrows_PageStillRenders()
{
// Best-effort contract: a failing KPI-history query degrades the charts to
// the unavailable placeholder but must NOT throw out of the page render.
var kpi = Substitute.For<IKpiHistoryQueryService>();
kpi.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ => throw new InvalidOperationException("kpi down"));
var cut = RenderAuditLogPage(kpi);
cut.WaitForAssertion(() =>
{
// The page rendered — heading + trends panel container both present —
// even though every series fetch threw.
Assert.Contains("Audit Log", cut.Markup);
Assert.Contains("data-test=\"audit-trends\"", cut.Markup);
});
}
}
@@ -14,6 +14,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.Health;
@@ -65,6 +66,17 @@ public class HealthPageTests : BunitContext
.Returns(new Dictionary<string, SiteHealthState>());
Services.AddSingleton(aggregator);
// M6 K16 — the Health page now injects IKpiHistoryQueryService to feed the
// per-site Site Health Trends panel. Stub it with a known non-empty series
// so the page resolves the dependency and the trend charts have data; the
// dedicated trend tests below seed sites / override behaviour.
var kpiHistory = Substitute.For<IKpiHistoryQueryService>();
kpiHistory.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(SampleSeries()));
Services.AddSingleton(kpiHistory);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
@@ -210,6 +222,92 @@ public class HealthPageTests : BunitContext
});
}
[Fact]
public void Renders_SiteHealthTrends_PanelAndChart_ForSelectedSite()
{
// Seed one site so the trend panel's selector has an option and the
// default-site load produces charts.
SeedSites("site-a");
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// The panel + its site selector render at the documented hooks.
Assert.Contains("data-test=\"site-health-trends\"", cut.Markup);
Assert.Contains("data-test=\"site-health-trends-site\"", cut.Markup);
// The four metric charts render (the shared KpiTrendChart slug hook),
// and the seeded non-empty series draws a polyline. The "S&F Buffer
// Depth" title slugifies to "s-f-buffer-depth" (the & and the spaces
// each collapse to a dash) — see KpiTrendChart.Slugify.
Assert.Contains("kpi-trend-connections-down", cut.Markup);
Assert.Contains("kpi-trend-dead-letters", cut.Markup);
Assert.Contains("kpi-trend-script-errors", cut.Markup);
Assert.Contains("kpi-trend-s-f-buffer-depth", cut.Markup);
Assert.Contains("<polyline", cut.Markup);
});
}
[Fact]
public void SiteHealthTrendsFailure_DoesNotBreakDashboard()
{
SeedSites("site-a");
// The KPI-history service throws on every query — the trend load is
// best-effort, so the dashboard (and its tiles) must still render.
var faulting = Substitute.For<IKpiHistoryQueryService>();
faulting.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ =>
throw new InvalidOperationException("kpi history unavailable"));
Services.AddSingleton(faulting);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// No unhandled exception: the core dashboard tiles still render, and
// the panel falls back to the per-chart unavailable placeholder.
Assert.Contains("Notification Outbox", cut.Markup);
Assert.Contains("data-test=\"site-health-trends\"", cut.Markup);
Assert.Contains("Trend data unavailable.", cut.Markup);
});
}
// Re-seeds the aggregator substitute so the trend panel's site selector has
// options. Each site id maps to a minimal online SiteHealthState (a null
// report is fine — the trend panel keys off the site ids, not the report).
private void SeedSites(params string[] siteIds)
{
var aggregator = Substitute.For<ICentralHealthAggregator>();
var states = siteIds.ToDictionary(
id => id,
id => new SiteHealthState
{
SiteId = id,
IsOnline = true,
LastHeartbeatAt = DateTimeOffset.UtcNow,
});
aggregator.GetAllSiteStates()
.Returns(new Dictionary<string, SiteHealthState>(states));
Services.AddSingleton(aggregator);
}
// A known non-empty (≥2-point) series so KpiTrendChart renders a polyline
// rather than the single-sample / unavailable placeholder.
private static IReadOnlyList<KpiSeriesPoint> SampleSeries()
{
var baseUtc = DateTime.UtcNow.AddHours(-24);
return new List<KpiSeriesPoint>
{
new(baseUtc, 1),
new(baseUtc.AddHours(6), 3),
new(baseUtc.AddHours(12), 2),
new(baseUtc.AddHours(18), 5),
};
}
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -10,8 +10,10 @@ using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Security;
using NotificationKpisPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationKpis;
@@ -46,6 +48,18 @@ public class NotificationKpisPageTests : BunitContext
DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)),
});
// KPI-history trend service (K13 / T11). Default substitute returns a
// 3-point series for any (source, metric, scope, …) tuple so every chart
// renders a polyline; individual tests can re-stub it (e.g. to throw).
private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For<IKpiHistoryQueryService>();
private static IReadOnlyList<KpiSeriesPoint> SampleSeries() => new List<KpiSeriesPoint>
{
new(DateTime.UtcNow.AddHours(-3), 3),
new(DateTime.UtcNow.AddHours(-2), 5),
new(DateTime.UtcNow.AddHours(-1), 4),
};
public NotificationKpisPageTests()
{
_comms = new CommunicationService(
@@ -66,6 +80,12 @@ public class NotificationKpisPageTests : BunitContext
}));
Services.AddSingleton(siteRepo);
_kpiHistory.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(SampleSeries()));
Services.AddSingleton(_kpiHistory);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
@@ -142,6 +162,85 @@ public class NotificationKpisPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup));
}
[Fact]
public void RendersTrendCharts_WhenSeriesAvailable()
{
var cut = Render<NotificationKpisPage>();
cut.WaitForAssertion(() =>
{
// The Trends section container is present …
Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]"));
// … and at least one trend chart renders an actual polyline (a chart
// needs IsAvailable + >= 2 points, which the substitute supplies).
Assert.NotEmpty(cut.FindAll("[data-test^=\"kpi-trend-\"]"));
Assert.Contains("<polyline", cut.Markup);
});
}
[Fact]
public void PartialTrendFailure_IsolatesToTheFailingMetric()
{
// Per-metric isolation (K13 fixup): the substitute returns a known series
// for two metrics but THROWS for "parkedCount". The two surviving charts
// must still draw their polylines while only the failing chart degrades to
// the unavailable placeholder — one metric's failure no longer blanks all
// three (and does not discard the already-fetched siblings).
_kpiHistory.GetSeriesAsync(
Arg.Any<string>(), Arg.Is<string>(m => m == "parkedCount"), Arg.Any<string>(),
Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<DateTime>(),
Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ => throw new InvalidOperationException("parked metric down"));
var cut = Render<NotificationKpisPage>();
cut.WaitForAssertion(() =>
{
// The page is still alive — KPI tiles render and the trends section
// never went fully blank.
Assert.Contains("Queue Depth", cut.Markup);
Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]"));
// The two surviving metrics (queueDepth, deliveredLastInterval) drew
// their polylines …
var queueChart = cut.Find("[data-test=\"kpi-trend-queue-depth\"]");
Assert.Contains("<polyline", queueChart.InnerHtml);
var deliveredChart = cut.Find("[data-test=\"kpi-trend-delivered-interval\"]");
Assert.Contains("<polyline", deliveredChart.InnerHtml);
// … while only the failing "Parked" chart degraded to the unavailable
// placeholder (no polyline, the short error message instead).
var parkedChart = cut.Find("[data-test=\"kpi-trend-parked\"]");
Assert.DoesNotContain("<polyline", parkedChart.InnerHtml);
Assert.Contains("Trend data unavailable.", parkedChart.InnerHtml);
});
}
[Fact]
public void TrendQueryFailure_DoesNotBreakPage()
{
// Best-effort contract: a throwing trend query must not surface an
// unhandled exception — the KPI tiles still render and the charts fall
// back to their unavailable placeholders.
_kpiHistory.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ => throw new InvalidOperationException("kpi history down"));
var cut = Render<NotificationKpisPage>();
cut.WaitForAssertion(() =>
{
// Tiles still render.
Assert.Contains("Queue Depth", cut.Markup);
Assert.Contains("7", cut.Markup);
// Trends section + chart placeholders are present; no polyline drawn.
Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]"));
Assert.NotEmpty(cut.FindAll("[data-test^=\"kpi-trend-\"]"));
Assert.DoesNotContain("<polyline", cut.Markup);
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -10,9 +10,11 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
@@ -34,6 +36,11 @@ public class SiteCallsReportPageTests : BunitContext
private readonly ActorSystem _system = ActorSystem.Create("site-calls-report-tests");
private readonly CommunicationService _comms;
// K14: KPI-history facade for the Trends section. Defaults to a known
// non-empty (≥2-point) series so an expanded chart draws a polyline;
// individual tests reconfigure it (e.g. to throw) before rendering.
private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For<IKpiHistoryQueryService>();
private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222");
@@ -77,6 +84,20 @@ public class SiteCallsReportPageTests : BunitContext
Services.AddSingleton(_comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
// K14: by default every trend series resolves to a known ≥2-point series so
// an expanded chart draws a polyline. Tests that need a failure reconfigure
// this substitute before rendering.
_kpiHistory.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(new List<KpiSeriesPoint>
{
new(DateTime.UtcNow.AddHours(-2), 3),
new(DateTime.UtcNow.AddHours(-1), 7),
new(DateTime.UtcNow, 5),
}));
Services.AddSingleton(_kpiHistory);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
@@ -510,6 +531,91 @@ public class SiteCallsReportPageTests : BunitContext
Assert.DoesNotContain(FailedId.ToString("N")[..12], cut.Markup);
}
// ─────────────────────────────────────────────────────────────────────────
// Trends (K14) — a collapsible section with one KpiTrendChart per metric
// (buffered / parked / failedLastInterval) for SiteCallAudit / Global. The
// section is collapsed on init; expanding it lazy-loads the series.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void TrendsSection_Container_AlwaysRenders()
{
var cut = Render<SiteCallsReportPage>();
// The collapsible container (with its toggle) is present even while
// collapsed — only the charts are gated behind the expand.
cut.WaitForAssertion(() =>
Assert.NotNull(cut.Find("[data-test='site-calls-trends']")));
}
[Fact]
public void TrendsSection_WhenExpanded_RendersChartsFromKnownSeries()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
// Collapsed on init — no chart yet.
Assert.Empty(cut.FindAll("[data-test^='kpi-trend-']"));
// Expand the Trends section; the lazy series load fills the charts.
cut.Find("[data-test='site-calls-trends-toggle']").Click();
cut.WaitForAssertion(() =>
{
// At least one trend chart rendered, and (because the series has ≥2
// points) it draws a polyline rather than the unavailable placeholder.
var charts = cut.FindAll("[data-test^='kpi-trend-']");
Assert.NotEmpty(charts);
Assert.Contains("<polyline", cut.Markup);
});
}
[Fact]
public void TrendsSection_WindowToggle_ReloadsSeries()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
cut.Find("[data-test='site-calls-trends-toggle']").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-test^='kpi-trend-']")));
// First expand issued 3 GetSeriesAsync calls (one per metric).
_kpiHistory.ReceivedCalls();
var afterExpand = _kpiHistory.ReceivedCalls().Count();
// Switch the window to 7d — the series re-load (another 3 calls).
cut.FindAll("button").First(b => b.TextContent.Trim() == "7d").Click();
cut.WaitForAssertion(() =>
Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand));
}
[Fact]
public void TrendsSection_WhenKpiHistoryThrows_PageStillRenders()
{
// Best-effort contract: a GetSeriesAsync throw must never break the page.
_kpiHistory.GetSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ =>
throw new InvalidOperationException("KPI backend down"));
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
// Expanding triggers the failing loads — the grid and trends container
// both survive; the charts fall back to the unavailable placeholder.
cut.Find("[data-test='site-calls-trends-toggle']").Click();
cut.WaitForAssertion(() =>
{
Assert.Contains("ERP.GetOrder", cut.Markup);
Assert.NotNull(cut.Find("[data-test='site-calls-trends']"));
// No polyline — the charts degraded to the unavailable em-dash state.
Assert.DoesNotContain("<polyline", cut.Markup);
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -0,0 +1,179 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Service-level tests for <see cref="KpiHistoryQueryService"/> (M6 K11). The
/// service fetches a raw series via <see cref="IKpiHistoryRepository.GetRawSeriesAsync"/>
/// and reduces it with <see cref="KpiSeriesBucketer"/>; these tests pin the
/// argument-forwarding contract, the bucketer pass-through, and the
/// <c>maxPoints ?? DefaultMaxSeriesPoints</c> fallback.
/// </summary>
public class KpiHistoryQueryServiceTests
{
private static readonly DateTime From = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
private static readonly DateTime To = new(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
private static IOptions<KpiHistoryOptions> Options(int defaultMax) =>
Microsoft.Extensions.Options.Options.Create(new KpiHistoryOptions { DefaultMaxSeriesPoints = defaultMax });
private static IReadOnlyList<KpiSeriesPoint> Series(params (int minute, double value)[] pts) =>
pts.Select(p => new KpiSeriesPoint(From.AddMinutes(p.minute), p.value)).ToList();
[Fact]
public async Task GetSeriesAsync_ForwardsAllArgs_ToRepository()
{
var repo = Substitute.For<IKpiHistoryRepository>();
var raw = Series((0, 1d), (30, 2d));
repo.GetRawSeriesAsync(
"notification-outbox", "queue_depth", "site", "plant-a",
From, To, Arg.Any<CancellationToken>())
.Returns(Task.FromResult(raw));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
await sut.GetSeriesAsync(
"notification-outbox", "queue_depth", "site", "plant-a", From, To);
await repo.Received(1).GetRawSeriesAsync(
"notification-outbox", "queue_depth", "site", "plant-a",
From, To, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetSeriesAsync_ForwardsNullScopeKey_ForGlobalScope()
{
var repo = Substitute.For<IKpiHistoryRepository>();
repo.GetRawSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Series()));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
await sut.GetSeriesAsync(
"site-call-audit", "parked_count", "global", scopeKey: null, From, To);
await repo.Received(1).GetRawSeriesAsync(
"site-call-audit", "parked_count", "global",
Arg.Is<string?>(k => k == null),
From, To, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetSeriesAsync_ReturnsRawUnchanged_WhenCountWithinMax()
{
// raw.Count (3) <= max (200) → bucketer returns the same reference.
var repo = Substitute.For<IKpiHistoryRepository>();
var raw = Series((0, 5d), (20, 6d), (40, 7d));
repo.GetRawSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(raw));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
var result = await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
// Bucketer returns the raw reference unchanged when raw.Count <= maxPoints.
Assert.Same(raw, result);
}
[Fact]
public async Task GetSeriesAsync_NullMaxPoints_UsesDefaultFromOptions()
{
// raw has 4 points. With DefaultMaxSeriesPoints=2 the bucketer downsamples
// (4 > 2), so the result is NOT the raw reference and has at most 2 points.
var repo = Substitute.For<IKpiHistoryRepository>();
var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d));
repo.GetRawSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(raw));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2));
var result = await sut.GetSeriesAsync(
"src", "metric", "global", null, From, To, maxPoints: null);
Assert.NotSame(raw, result);
Assert.True(result.Count <= 2);
}
[Fact]
public async Task GetSeriesAsync_ExplicitMaxPoints_OverridesDefault()
{
// raw has 4 points; explicit maxPoints=10 (>= raw.Count) wins over the
// option's tiny default of 2, so the bucketer returns the raw reference.
var repo = Substitute.For<IKpiHistoryRepository>();
var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d));
repo.GetRawSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(raw));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2));
var result = await sut.GetSeriesAsync(
"src", "metric", "global", null, From, To, maxPoints: 10);
// Explicit max (10) >= raw.Count (4) → raw returned unchanged, proving the
// explicit value beat the default of 2 (which would have downsampled).
Assert.Same(raw, result);
}
[Fact]
public async Task GetSeriesAsync_EmptySeries_ReturnsEmpty()
{
var repo = Substitute.For<IKpiHistoryRepository>();
repo.GetRawSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
var result = await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
Assert.Empty(result);
}
[Fact]
public async Task GetSeriesAsync_OpensFreshScopePerCall_OnProductionCtor()
{
// The production (IServiceScopeFactory) ctor must resolve a fresh repository
// per call — same scope-per-query contract AuditLogQueryService upholds, so a
// chart's auto-load never shares the circuit-scoped DbContext.
var resolvedRepos = new List<IKpiHistoryRepository>();
var services = new ServiceCollection();
services.AddScoped<IKpiHistoryRepository>(_ =>
{
var repo = Substitute.For<IKpiHistoryRepository>();
repo.GetRawSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
resolvedRepos.Add(repo);
return repo;
});
await using var provider = services.BuildServiceProvider();
var sut = new KpiHistoryQueryService(
provider.GetRequiredService<IServiceScopeFactory>(),
Options(defaultMax: 200));
await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
// Each call opened its own scope → two distinct repo instances.
Assert.Equal(2, resolvedRepos.Count);
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
}
@@ -0,0 +1,77 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Kpi;
public class KpiSampleTests
{
[Fact]
public void KpiSample_ConstructedWithRequiredMembers_RoundTripsValues()
{
var capturedAt = new DateTime(2026, 6, 15, 12, 30, 0, DateTimeKind.Utc);
var sample = new KpiSample
{
Id = 42,
Source = KpiSources.NotificationOutbox,
Metric = "QueueDepth",
Scope = KpiScopes.Global,
ScopeKey = null,
Value = 17,
CapturedAtUtc = capturedAt,
};
Assert.Equal(42, sample.Id);
Assert.Equal("NotificationOutbox", sample.Source);
Assert.Equal("QueueDepth", sample.Metric);
Assert.Equal("Global", sample.Scope);
Assert.Null(sample.ScopeKey);
Assert.Equal(17, sample.Value);
Assert.Equal(capturedAt, sample.CapturedAtUtc);
Assert.Equal(DateTimeKind.Utc, sample.CapturedAtUtc.Kind);
}
[Fact]
public void KpiSample_ScopedSample_CarriesScopeKey()
{
var sample = new KpiSample
{
Source = KpiSources.SiteHealth,
Metric = "OldestPendingAgeSeconds",
Scope = KpiScopes.Site,
ScopeKey = "site-a",
Value = 12.5,
CapturedAtUtc = DateTime.UtcNow,
};
Assert.Equal(KpiScopes.Site, sample.Scope);
Assert.Equal("site-a", sample.ScopeKey);
Assert.Equal(12.5, sample.Value);
}
[Fact]
public void KpiScopesAndSources_ConstantValues_EqualTheirNames()
{
Assert.Equal("NotificationOutbox", KpiSources.NotificationOutbox);
Assert.Equal("SiteCallAudit", KpiSources.SiteCallAudit);
Assert.Equal("AuditLog", KpiSources.AuditLog);
Assert.Equal("SiteHealth", KpiSources.SiteHealth);
Assert.Equal("Global", KpiScopes.Global);
Assert.Equal("Site", KpiScopes.Site);
Assert.Equal("Node", KpiScopes.Node);
}
[Fact]
public void KpiSeriesPoint_IsValueRecordStruct_WithComponentEquality()
{
var bucket = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc);
var a = new KpiSeriesPoint(bucket, 3.0);
var b = new KpiSeriesPoint(bucket, 3.0);
Assert.Equal(bucket, a.BucketStartUtc);
Assert.Equal(3.0, a.Value);
Assert.Equal(a, b);
}
}
@@ -0,0 +1,316 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Kpi;
/// <summary>Tests for <see cref="KpiSeriesBucketer"/>.</summary>
public class KpiSeriesBucketerTests
{
// Fixed epoch used throughout so tests are fully deterministic.
private static readonly DateTime Epoch =
new(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc);
private static DateTime T(int minutesFromEpoch) =>
Epoch.AddMinutes(minutesFromEpoch);
// -----------------------------------------------------------------------
// Guard / invalid-argument contracts
// -----------------------------------------------------------------------
[Fact]
public void Bucket_MaxPointsLessThan2_ThrowsArgumentOutOfRangeException()
{
var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };
Assert.Throws<ArgumentOutOfRangeException>(() =>
KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 1));
}
[Fact]
public void Bucket_MaxPointsZero_ThrowsArgumentOutOfRangeException()
{
var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };
Assert.Throws<ArgumentOutOfRangeException>(() =>
KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 0));
}
[Fact]
public void Bucket_ToUtcEqualFromUtc_ThrowsArgumentOutOfRangeException()
{
var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };
Assert.Throws<ArgumentOutOfRangeException>(() =>
KpiSeriesBucketer.Bucket(raw, T(30), T(30), maxPoints: 5));
}
[Fact]
public void Bucket_ToUtcBeforeFromUtc_ThrowsArgumentOutOfRangeException()
{
var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };
Assert.Throws<ArgumentOutOfRangeException>(() =>
KpiSeriesBucketer.Bucket(raw, T(60), T(0), maxPoints: 5));
}
// -----------------------------------------------------------------------
// Empty / null raw series
// -----------------------------------------------------------------------
[Fact]
public void Bucket_NullRaw_ReturnsEmptyList()
{
var result = KpiSeriesBucketer.Bucket(null!, T(0), T(60), maxPoints: 10);
Assert.Empty(result);
}
[Fact]
public void Bucket_EmptyRaw_ReturnsEmptyList()
{
var result = KpiSeriesBucketer.Bucket(
Array.Empty<KpiSeriesPoint>(), T(0), T(60), maxPoints: 10);
Assert.Empty(result);
}
// -----------------------------------------------------------------------
// raw.Count <= maxPoints → returned unchanged (same reference)
// -----------------------------------------------------------------------
[Fact]
public void Bucket_RawCountEqualsMaxPoints_ReturnsSameReference()
{
var raw = new[]
{
new KpiSeriesPoint(T(0), 1.0),
new KpiSeriesPoint(T(10), 2.0),
new KpiSeriesPoint(T(20), 3.0),
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);
Assert.Same(raw, result);
}
[Fact]
public void Bucket_RawCountLessThanMaxPoints_ReturnsSameReference()
{
var raw = new[]
{
new KpiSeriesPoint(T(0), 5.0),
new KpiSeriesPoint(T(5), 6.0),
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 10);
Assert.Same(raw, result);
}
[Fact]
public void Bucket_SinglePoint_ReturnsSameReference()
{
var raw = new[] { new KpiSeriesPoint(T(30), 42.0) };
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 5);
Assert.Same(raw, result);
}
// -----------------------------------------------------------------------
// Downsampling: correct bucket count and last-value selection
// -----------------------------------------------------------------------
[Fact]
public void Bucket_MorePointsThanMaxPoints_ReducesToMaxPointsBuckets()
{
// 60-minute window / 3 buckets → 20 min each.
// Plant exactly one point in each bucket; all three buckets occupied.
var raw = new[]
{
new KpiSeriesPoint(T(5), 10.0), // bucket 0: [0, 20)
new KpiSeriesPoint(T(25), 20.0), // bucket 1: [20, 40)
new KpiSeriesPoint(T(45), 30.0), // bucket 2: [40, 60]
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);
Assert.Equal(3, result.Count);
Assert.Equal(10.0, result[0].Value);
Assert.Equal(20.0, result[1].Value);
Assert.Equal(30.0, result[2].Value);
}
[Fact]
public void Bucket_MultiplePointsInSameBucket_LastValueWins()
{
// 60-minute window / 2 buckets → 30 min each.
// Three points all land in bucket 0; the last (T(25)) should win.
var raw = new[]
{
new KpiSeriesPoint(T(5), 1.0),
new KpiSeriesPoint(T(15), 2.0),
new KpiSeriesPoint(T(25), 99.0), // latest in bucket 0 → wins
new KpiSeriesPoint(T(35), 5.0), // bucket 1
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);
Assert.Equal(2, result.Count);
Assert.Equal(99.0, result[0].Value); // last in bucket 0
Assert.Equal(5.0, result[1].Value); // only point in bucket 1
}
[Fact]
public void Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp()
{
// 60-minute window / 3 buckets → 20 min each.
// Output BucketStartUtc must be the bucket boundary, not the raw point's time.
// raw.Count (4) > maxPoints (3) ensures the downsampling path runs.
var raw = new[]
{
new KpiSeriesPoint(T(7), 1.0), // inside bucket 0: [0, 20)
new KpiSeriesPoint(T(22), 2.0), // inside bucket 1: [20, 40)
new KpiSeriesPoint(T(35), 3.0), // inside bucket 1: later → wins bucket 1
new KpiSeriesPoint(T(55), 4.0), // inside bucket 2: [40, 60]
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);
// Bucket boundaries: 0 min, 20 min, 40 min
Assert.Equal(3, result.Count);
Assert.Equal(T(0), result[0].BucketStartUtc);
Assert.Equal(T(20), result[1].BucketStartUtc);
Assert.Equal(T(40), result[2].BucketStartUtc);
}
// -----------------------------------------------------------------------
// Right-edge: point exactly at toUtc lands in the last bucket
// -----------------------------------------------------------------------
[Fact]
public void Bucket_PointAtToUtc_LandsInLastBucket()
{
// 60-minute window / 2 buckets → 30 min each.
// A point exactly at T(60) = toUtc must go to bucket 1 (the last bucket),
// not overflow to an out-of-range index.
// T(10) → bucket 0: [0, 30); T(35) → bucket 1: [30, 60]; T(60) → bucket 1.
var raw = new[]
{
new KpiSeriesPoint(T(10), 5.0),
new KpiSeriesPoint(T(35), 6.0),
new KpiSeriesPoint(T(60), 7.0), // exactly toUtc → must land in last bucket
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);
Assert.Equal(2, result.Count);
// Bucket 1 holds both T(35) and T(60); T(60) is later → wins.
Assert.Equal(7.0, result[1].Value);
}
[Fact]
public void Bucket_ThreeBucketWindow_PointAtToUtc_LandsInLastBucket()
{
// 60-minute window / 3 buckets → 20 min each.
// A point exactly at T(60) = toUtc must land in bucket 2 (the last),
// not overflow. Use 4 raw points so downsampling is forced (4 > 3).
// T(5) → bucket 0: [0,20); T(25) → bucket 1: [20,40);
// T(45) → bucket 2: [40,60]; T(60) → bucket 2 (right edge, later → wins).
var raw = new[]
{
new KpiSeriesPoint(T(5), 1.0),
new KpiSeriesPoint(T(25), 2.0),
new KpiSeriesPoint(T(45), 3.0),
new KpiSeriesPoint(T(60), 99.0), // right edge — must land in last bucket
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);
Assert.Equal(3, result.Count);
// Bucket 2 holds T(45)=3.0 and T(60)=99.0; T(60) is later → wins.
Assert.Equal(99.0, result[2].Value);
}
// -----------------------------------------------------------------------
// Empty buckets omitted — no gap-filling
// -----------------------------------------------------------------------
[Fact]
public void Bucket_GapInRaw_EmptyBucketsOmitted()
{
// 60-minute window / 4 buckets → 15 min each.
// Populate only buckets 0 and 3; buckets 1 and 2 are empty.
// Expect 2 output points, not 4.
var raw = new[]
{
new KpiSeriesPoint(T(5), 10.0), // bucket 0: [0, 15)
new KpiSeriesPoint(T(50), 20.0), // bucket 3: [45, 60]
};
// raw.Count (2) < maxPoints (4), so normally returns same reference.
// To test the gap-omission path we need raw.Count > maxPoints and a gap.
var raw2 = new[]
{
new KpiSeriesPoint(T(5), 10.0),
new KpiSeriesPoint(T(6), 11.0),
new KpiSeriesPoint(T(50), 20.0),
new KpiSeriesPoint(T(51), 21.0),
};
// 4 raw points, maxPoints=4 → returns same reference (no downsampling).
// Use maxPoints=3 to trigger the downsampler.
// 60-minute window / 3 buckets → 20 min each.
// T(5) → bucket 0, T(6) → bucket 0, T(50) → bucket 2, T(51) → bucket 2.
// Bucket 1 is empty → 2 output points.
var result = KpiSeriesBucketer.Bucket(raw2, T(0), T(60), maxPoints: 3);
Assert.Equal(2, result.Count);
Assert.Equal(11.0, result[0].Value); // last in bucket 0
Assert.Equal(21.0, result[1].Value); // last in bucket 2
}
// -----------------------------------------------------------------------
// Points outside [fromUtc, toUtc] are ignored
// -----------------------------------------------------------------------
[Fact]
public void Bucket_PointsOutsideWindow_AreIgnored()
{
// Window [T(10), T(50)], 2 buckets.
// Points at T(0) and T(60) are outside — should not appear in output.
var raw = new[]
{
new KpiSeriesPoint(T(0), 999.0), // before window
new KpiSeriesPoint(T(20), 1.0), // inside bucket 0: [T(10), T(30))
new KpiSeriesPoint(T(40), 2.0), // inside bucket 1: [T(30), T(50)]
new KpiSeriesPoint(T(60), 999.0), // after window
};
var result = KpiSeriesBucketer.Bucket(raw, T(10), T(50), maxPoints: 2);
Assert.Equal(2, result.Count);
Assert.Equal(1.0, result[0].Value);
Assert.Equal(2.0, result[1].Value);
}
// -----------------------------------------------------------------------
// Two-point minimum — maxPoints == 2 works correctly
// -----------------------------------------------------------------------
[Fact]
public void Bucket_MaxPointsExactly2_ProducesAtMostTwoBuckets()
{
var raw = Enumerable
.Range(0, 10)
.Select(i => new KpiSeriesPoint(T(i * 6), (double)i))
.ToArray();
// 60-minute window / 2 buckets → 30 min each.
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);
Assert.Equal(2, result.Count);
// Bucket 0: T(0)–T(29) → last is T(24) (value 4); bucket 1: T(30)–T(60) → last is T(54) (value 9).
Assert.Equal(4.0, result[0].Value);
Assert.Equal(9.0, result[1].Value);
}
}
@@ -0,0 +1,157 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// M6 (K2) coverage for <see cref="KpiHistoryRepository"/> over the in-memory SQLite
/// harness. Exercises the bulk write, the single-series query (including the
/// null-ScopeKey Global match), and the retention purge.
/// </summary>
public class KpiHistoryRepositoryTests
{
// Fixed UTC base so the time assertions are deterministic.
private static readonly DateTime Base = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
private static KpiSample Sample(
string source, string metric, string scope, string? scopeKey, double value, DateTime capturedAtUtc) =>
new()
{
Source = source,
Metric = metric,
Scope = scope,
ScopeKey = scopeKey,
Value = value,
CapturedAtUtc = capturedAtUtc,
};
[Fact]
public async Task GetRawSeriesAsync_ReturnsOnlyMatchingSeries_InAscendingTimeOrder()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
// Two metrics ("queueDepth", "parkedCount") under one source; the target
// series ("queueDepth", Global, null key) has two points at different
// timestamps — inserted out of order to prove the OrderBy.
await repo.RecordSamplesAsync(new[]
{
// Target series — Global / null ScopeKey, two points (later first).
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 7, capturedAtUtc: Base.AddMinutes(10)),
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(5)),
// Different metric — must be excluded.
Sample("NotificationOutbox", "parkedCount", "Global", null, value: 99, capturedAtUtc: Base.AddMinutes(5)),
// Same metric but a Site scope with a non-null key — must be excluded
// from the Global (null-key) query.
Sample("NotificationOutbox", "queueDepth", "Site", "plant-a", value: 42, capturedAtUtc: Base.AddMinutes(5)),
});
var series = await repo.GetRawSeriesAsync(
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
fromUtc: Base, toUtc: Base.AddMinutes(60));
// Only the two Global/null-key queueDepth points, ascending by capture time.
Assert.Equal(2, series.Count);
Assert.Equal(Base.AddMinutes(5), series[0].BucketStartUtc);
Assert.Equal(3, series[0].Value);
Assert.Equal(Base.AddMinutes(10), series[1].BucketStartUtc);
Assert.Equal(7, series[1].Value);
}
[Fact]
public async Task GetRawSeriesAsync_SiteScopedKey_MatchesOnlyThatKey()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("SiteCallAudit", "buffered", "Site", "plant-a", value: 5, capturedAtUtc: Base.AddMinutes(5)),
Sample("SiteCallAudit", "buffered", "Site", "plant-b", value: 8, capturedAtUtc: Base.AddMinutes(5)),
// A Global (null-key) row with the same metric must NOT leak into a
// site-keyed query.
Sample("SiteCallAudit", "buffered", "Global", null, value: 13, capturedAtUtc: Base.AddMinutes(5)),
});
var series = await repo.GetRawSeriesAsync(
"SiteCallAudit", "buffered", "Site", scopeKey: "plant-a",
fromUtc: Base, toUtc: Base.AddMinutes(60));
Assert.Single(series);
Assert.Equal(5, series[0].Value);
}
[Fact]
public async Task GetRawSeriesAsync_HonorsTimeWindowBounds()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("Health", "ageSeconds", "Global", null, value: 1, capturedAtUtc: Base),
Sample("Health", "ageSeconds", "Global", null, value: 2, capturedAtUtc: Base.AddMinutes(30)),
// Outside the [from, to] window — excluded.
Sample("Health", "ageSeconds", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(120)),
});
var series = await repo.GetRawSeriesAsync(
"Health", "ageSeconds", "Global", scopeKey: null,
fromUtc: Base, toUtc: Base.AddMinutes(60));
Assert.Equal(2, series.Count);
Assert.Equal(new[] { 1d, 2d }, series.Select(p => p.Value).ToArray());
}
[Fact]
public async Task RecordSamplesAsync_EmptyBatch_IsNoOp_AndGetRawSeriesAsync_EmptyTable_ReturnsEmpty()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
// Empty batch must not throw (early-return guard, no SaveChanges round-trip).
await repo.RecordSamplesAsync(Array.Empty<KpiSample>());
// With nothing written, GetRawSeriesAsync must return a non-null empty list.
var series = await repo.GetRawSeriesAsync(
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
fromUtc: Base, toUtc: Base.AddMinutes(60));
Assert.NotNull(series);
Assert.Empty(series);
}
[Fact]
public async Task PurgeOlderThanAsync_DeletesOnlyRowsOlderThanCutoff_AndReturnsCount()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
// Two rows strictly older than the cutoff — should be purged.
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 1, capturedAtUtc: Base.AddDays(-10)),
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 2, capturedAtUtc: Base.AddDays(-8)),
// One row AT the cutoff — strictly-older predicate keeps it.
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddDays(-7)),
// One row newer than the cutoff — kept.
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 4, capturedAtUtc: Base.AddDays(-1)),
});
var cutoff = Base.AddDays(-7);
var deleted = await repo.PurgeOlderThanAsync(cutoff);
Assert.Equal(2, deleted);
var remaining = await repo.GetRawSeriesAsync(
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
fromUtc: Base.AddDays(-30), toUtc: Base.AddDays(30));
// The cutoff row (==) and the newer row survive.
Assert.Equal(2, remaining.Count);
Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray());
}
}
@@ -0,0 +1,170 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring.Kpi;
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.Kpi;
/// <summary>
/// M6 "KPI History &amp; Trends" (K9) coverage for
/// <see cref="SiteHealthKpiSampleSource"/>. The source reads the in-memory
/// <see cref="ICentralHealthAggregator"/> and emits per-site
/// (<see cref="KpiScopes.Site"/>) <see cref="KpiSample"/> rows for each site that
/// has reported a <see cref="SiteHealthReport"/>; sites known only via heartbeats
/// (null <see cref="SiteHealthState.LatestReport"/>) contribute nothing.
/// </summary>
public class SiteHealthKpiSampleSourceTests
{
private static readonly DateTime CapturedAt =
new(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Source_Is_SiteHealth()
{
var source = new SiteHealthKpiSampleSource(new StubAggregator());
Assert.Equal(KpiSources.SiteHealth, source.Source);
}
[Fact]
public async Task CollectAsync_PopulatedSite_EmitsAllMetrics_NullReportSite_EmitsNothing()
{
// site-a: fully-populated report. site-b: heartbeat-only (null report).
var report = new SiteHealthReport(
SiteId: "site-a",
SequenceNumber: 5,
ReportTimestamp: CapturedAt,
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>
{
["conn-1"] = ConnectionHealth.Connected,
["conn-2"] = ConnectionHealth.Connected,
["conn-3"] = ConnectionHealth.Disconnected,
["conn-4"] = ConnectionHealth.Connecting,
["conn-5"] = ConnectionHealth.Error,
},
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
ScriptErrorCount: 3,
AlarmEvaluationErrorCount: 4,
StoreAndForwardBufferDepths: new Dictionary<string, int>
{
["buf-1"] = 10,
["buf-2"] = 15,
},
DeadLetterCount: 6,
DeployedInstanceCount: 20,
EnabledInstanceCount: 18,
DisabledInstanceCount: 2,
ParkedMessageCount: 7,
SiteAuditBacklog: new SiteAuditBacklogSnapshot(
PendingCount: 9, OldestPendingUtc: null, OnDiskBytes: 0),
SiteEventLogWriteFailures: 11);
var aggregator = new StubAggregator
{
States =
{
["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report },
["site-b"] = new SiteHealthState { SiteId = "site-b", LatestReport = null },
},
};
var source = new SiteHealthKpiSampleSource(aggregator);
var samples = await source.CollectAsync(CapturedAt);
// Every sample is for site-a only — the null-report site yields nothing.
Assert.All(samples, s =>
{
Assert.Equal(KpiSources.SiteHealth, s.Source);
Assert.Equal(KpiScopes.Site, s.Scope);
Assert.Equal("site-a", s.ScopeKey);
Assert.Equal(CapturedAt, s.CapturedAtUtc);
});
Assert.DoesNotContain(samples, s => s.ScopeKey == "site-b");
// Exact (Metric, Value) tuples for the populated site.
var byMetric = samples.ToDictionary(s => s.Metric, s => s.Value);
Assert.Equal(2, byMetric["connectionsUp"]); // 2 Connected
Assert.Equal(3, byMetric["connectionsDown"]); // Disconnected + Connecting + Error
Assert.Equal(3, byMetric["scriptErrors"]);
Assert.Equal(4, byMetric["alarmEvalErrors"]);
Assert.Equal(25, byMetric["sfBufferDepth"]); // 10 + 15
Assert.Equal(6, byMetric["deadLetters"]);
Assert.Equal(7, byMetric["parkedMessages"]);
Assert.Equal(20, byMetric["deployedInstances"]);
Assert.Equal(18, byMetric["enabledInstances"]);
Assert.Equal(2, byMetric["disabledInstances"]);
Assert.Equal(9, byMetric["auditBacklogPending"]);
Assert.Equal(11, byMetric["eventLogWriteFailures"]);
// All 12 metrics emitted, exactly once each, for the one populated site.
Assert.Equal(12, byMetric.Count);
Assert.Equal(12, samples.Count);
}
[Fact]
public async Task CollectAsync_NullAuditBacklog_EmitsZeroAuditBacklogPending()
{
var report = MinimalReport("site-a") with { SiteAuditBacklog = null };
var aggregator = new StubAggregator
{
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
};
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
Assert.Equal(0, samples.Single(s => s.Metric == "auditBacklogPending").Value);
}
[Fact]
public async Task CollectAsync_NoSitesWithReports_ReturnsEmptyList()
{
var aggregator = new StubAggregator
{
States = { ["site-b"] = new SiteHealthState { SiteId = "site-b", LatestReport = null } },
};
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
Assert.NotNull(samples);
Assert.Empty(samples);
}
private static SiteHealthReport MinimalReport(string siteId) =>
new(
SiteId: siteId,
SequenceNumber: 1,
ReportTimestamp: CapturedAt,
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
ScriptErrorCount: 0,
AlarmEvaluationErrorCount: 0,
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
DeadLetterCount: 0,
DeployedInstanceCount: 0,
EnabledInstanceCount: 0,
DisabledInstanceCount: 0);
/// <summary>
/// Hand-rolled <see cref="ICentralHealthAggregator"/> stub — the
/// HealthMonitoring.Tests project has no mocking library. Only
/// <see cref="GetAllSiteStates"/> is exercised by the source under test.
/// </summary>
private sealed class StubAggregator : ICentralHealthAggregator
{
public Dictionary<string, SiteHealthState> States { get; } = new();
public IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates() => States;
public SiteHealthState? GetSiteState(string siteId) =>
States.TryGetValue(siteId, out var state) ? state : null;
public void ProcessReport(SiteHealthReport report) =>
throw new NotSupportedException();
public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt) =>
throw new NotSupportedException();
}
}
@@ -0,0 +1,152 @@
using ZB.MOM.WW.ScadaBridge.KpiHistory;
namespace ZB.MOM.WW.ScadaBridge.KpiHistory.Tests;
/// <summary>
/// K3 unit tests for <see cref="KpiHistoryOptionsValidator"/>: the default
/// options must validate, and each rule (SampleInterval / RetentionDays /
/// PurgeInterval / DefaultMaxSeriesPoints) must reject its out-of-range values.
/// </summary>
public class KpiHistoryOptionsValidatorTests
{
private static KpiHistoryOptionsValidator NewValidator() => new();
[Fact]
public void Validate_Defaults_Pass()
{
var result = NewValidator().Validate(null, new KpiHistoryOptions());
Assert.True(result.Succeeded);
}
// ---------------------------------------------------------------------
// SampleInterval > TimeSpan.Zero
// ---------------------------------------------------------------------
[Fact]
public void Validate_SampleInterval_Zero_Fails()
{
var opts = new KpiHistoryOptions { SampleInterval = TimeSpan.Zero };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.SampleInterval), StringComparison.Ordinal));
}
[Fact]
public void Validate_SampleInterval_Negative_Fails()
{
var opts = new KpiHistoryOptions { SampleInterval = TimeSpan.FromSeconds(-1) };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.SampleInterval), StringComparison.Ordinal));
}
// ---------------------------------------------------------------------
// RetentionDays in [1, 3650]
// ---------------------------------------------------------------------
[Theory]
[InlineData(1)] // min
[InlineData(90)] // default
[InlineData(3650)] // max
public void Validate_RetentionDays_InRange_Passes(int value)
{
var opts = new KpiHistoryOptions { RetentionDays = value };
Assert.True(NewValidator().Validate(null, opts).Succeeded);
}
[Theory]
[InlineData(0)]
[InlineData(3651)]
[InlineData(-1)]
public void Validate_RetentionDays_OutOfRange_Fails(int value)
{
var opts = new KpiHistoryOptions { RetentionDays = value };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.RetentionDays), StringComparison.Ordinal));
}
// ---------------------------------------------------------------------
// PurgeInterval > TimeSpan.Zero
// ---------------------------------------------------------------------
[Fact]
public void Validate_PurgeInterval_Zero_Fails()
{
var opts = new KpiHistoryOptions { PurgeInterval = TimeSpan.Zero };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.PurgeInterval), StringComparison.Ordinal));
}
[Fact]
public void Validate_PurgeInterval_Negative_Fails()
{
var opts = new KpiHistoryOptions { PurgeInterval = TimeSpan.FromHours(-1) };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.PurgeInterval), StringComparison.Ordinal));
}
// ---------------------------------------------------------------------
// DefaultMaxSeriesPoints in [2, 5000]
// ---------------------------------------------------------------------
[Theory]
[InlineData(2)] // min
[InlineData(200)] // default
[InlineData(5000)] // max
public void Validate_DefaultMaxSeriesPoints_InRange_Passes(int value)
{
var opts = new KpiHistoryOptions { DefaultMaxSeriesPoints = value };
Assert.True(NewValidator().Validate(null, opts).Succeeded);
}
[Theory]
[InlineData(1)]
[InlineData(5001)]
[InlineData(0)]
public void Validate_DefaultMaxSeriesPoints_OutOfRange_Fails(int value)
{
var opts = new KpiHistoryOptions { DefaultMaxSeriesPoints = value };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.DefaultMaxSeriesPoints), StringComparison.Ordinal));
}
[Fact]
public void Validate_AllRulesViolated_AccumulatesEveryFailure()
{
// The base class aggregates ALL failures, not just the first — one
// message per violated rule.
var opts = new KpiHistoryOptions
{
SampleInterval = TimeSpan.Zero,
RetentionDays = 0,
PurgeInterval = TimeSpan.Zero,
DefaultMaxSeriesPoints = 1,
};
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Equal(4, result.Failures!.Count());
}
}
@@ -0,0 +1,255 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.KpiHistory.Tests;
/// <summary>
/// K4 tests for <see cref="KpiHistoryRecorderActor"/>. The actor's internal
/// <c>SampleTick</c>/<c>PurgeTick</c> messages are exposed to this assembly via
/// <c>InternalsVisibleTo</c> so a tick can be driven deterministically without
/// racing the periodic timer. Hand-rolled fakes (no mocking lib in this test
/// project) record what the recorder hands to the repository.
/// </summary>
public class KpiHistoryRecorderActorTests : TestKit
{
/// <summary>
/// A healthy sample source that returns a fixed set of samples, stamping each with the
/// <c>capturedAtUtc</c> the recorder supplies.
/// </summary>
private sealed class HealthySource : IKpiSampleSource
{
public string Source => KpiSources.NotificationOutbox;
public Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default)
{
IReadOnlyList<KpiSample> samples = new List<KpiSample>
{
new()
{
Source = Source,
Metric = "QueueDepth",
Scope = KpiScopes.Global,
ScopeKey = null,
Value = 7,
CapturedAtUtc = capturedAtUtc,
},
new()
{
Source = Source,
Metric = "ParkedCount",
Scope = KpiScopes.Global,
ScopeKey = null,
Value = 2,
CapturedAtUtc = capturedAtUtc,
},
};
return Task.FromResult(samples);
}
}
/// <summary>A source whose <see cref="CollectAsync"/> always throws — must not abort the pass.</summary>
private sealed class ThrowingSource : IKpiSampleSource
{
public string Source => KpiSources.SiteCallAudit;
public Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("simulated source failure");
}
/// <summary>
/// A source that throws on the first <see cref="CollectAsync"/> call and returns a
/// healthy sample on every subsequent call. Used to drive a faulted first tick followed
/// by a healthy second tick on the <em>same</em> actor instance.
/// </summary>
private sealed class ThrowOnceSource : IKpiSampleSource
{
private int _callCount;
public string Source => KpiSources.SiteCallAudit;
public Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default)
{
if (Interlocked.Increment(ref _callCount) == 1)
throw new InvalidOperationException("simulated first-call source failure");
IReadOnlyList<KpiSample> samples = new[]
{
new KpiSample
{
Source = Source,
Metric = "RecoveredSample",
Scope = KpiScopes.Global,
ScopeKey = null,
Value = 1,
CapturedAtUtc = capturedAtUtc,
},
};
return Task.FromResult(samples);
}
}
/// <summary>
/// Recording repository fake. Captures the samples handed to
/// <see cref="RecordSamplesAsync"/> and the cut-off handed to
/// <see cref="PurgeOlderThanAsync"/>.
/// </summary>
private sealed class RecordingRepository : IKpiHistoryRepository
{
private readonly object _gate = new();
private readonly List<KpiSample> _recorded = new();
private DateTime? _purgeCutoff;
public IReadOnlyList<KpiSample> Recorded
{
get { lock (_gate) { return _recorded.ToArray(); } }
}
// PurgeOlderThanAsync runs on a threadpool thread; guard the field with
// the same _gate lock used by _recorded so test-thread reads are race-free.
public DateTime? PurgeCutoff
{
get { lock (_gate) { return _purgeCutoff; } }
}
public Task RecordSamplesAsync(
IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
{
lock (_gate)
{
_recorded.AddRange(samples);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());
public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default)
{
lock (_gate) { _purgeCutoff = before; }
return Task.FromResult(0);
}
}
private IServiceProvider BuildServiceProvider(
IKpiHistoryRepository repository, params IKpiSampleSource[] sources)
{
var services = new ServiceCollection();
// Mirror production: sources + repository are scoped, so the recorder opens a fresh
// scope per tick and resolves there.
foreach (var source in sources)
{
var captured = source;
services.AddScoped(_ => captured);
}
services.AddScoped(_ => repository);
return services.BuildServiceProvider();
}
/// <summary>
/// Creates the recorder with both timers set to a long interval so neither periodic timer
/// fires during a test — ticks are sent manually instead.
/// </summary>
private IActorRef CreateActor(IServiceProvider serviceProvider, KpiHistoryOptions? options = null)
{
return Sys.ActorOf(Props.Create(() => new KpiHistoryRecorderActor(
serviceProvider,
options ?? new KpiHistoryOptions
{
SampleInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
},
NullLogger<KpiHistoryRecorderActor>.Instance)));
}
[Fact]
public void SampleTick_WritesHealthySourceSamples_AndThrowingSourceDoesNotAbortTick()
{
var repository = new RecordingRepository();
// Order the throwing source FIRST so the test also proves a throw early in the
// enumeration does not suppress a later healthy source.
var sp = BuildServiceProvider(repository, new ThrowingSource(), new HealthySource());
var actor = CreateActor(sp);
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
AwaitAssert(
() =>
{
// The healthy source's two samples were written despite the throwing source.
Assert.Equal(2, repository.Recorded.Count);
Assert.Contains(repository.Recorded, s => s.Metric == "QueueDepth" && s.Value == 7);
Assert.Contains(repository.Recorded, s => s.Metric == "ParkedCount" && s.Value == 2);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
[Fact]
public void PurgeTick_CallsPurgeWithCutoff_AtUtcNowMinusRetentionDays()
{
var repository = new RecordingRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
const int retentionDays = 30;
var actor = CreateActor(sp, new KpiHistoryOptions
{
SampleInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
RetentionDays = retentionDays,
});
actor.Tell(KpiHistoryRecorderActor.PurgeTick.Instance);
AwaitAssert(
() =>
{
Assert.NotNull(repository.PurgeCutoff);
var expected = DateTime.UtcNow - TimeSpan.FromDays(retentionDays);
Assert.True(
Math.Abs((repository.PurgeCutoff!.Value - expected).TotalMinutes) < 1.0,
$"purge cutoff {repository.PurgeCutoff:o} should be within 1 minute of {expected:o}");
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
[Fact]
public void FaultedTick_DoesNotCrashActor_AndSubsequentTickStillRuns()
{
// ThrowOnceSource throws on the first CollectAsync call and returns a healthy
// sample on every subsequent call. This lets us send two ticks to the SAME
// actor instance and verify that:
// • The first tick (faulted source) records nothing but does not crash the actor.
// • The second tick reaches the same actor and records the recovered sample,
// proving the singleton's message loop is still alive after a faulted pass.
var repository = new RecordingRepository();
var sp = BuildServiceProvider(repository, new ThrowOnceSource());
var actor = CreateActor(sp);
// First tick: source throws on first call — caught per-source, nothing written, actor lives.
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
AwaitAssert(
() => Assert.Empty(repository.Recorded),
duration: TimeSpan.FromSeconds(2),
interval: TimeSpan.FromMilliseconds(50));
// Second tick to the SAME actor: source now returns a healthy sample.
// AwaitAssert confirms the actor processed the message and recorded it.
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
AwaitAssert(
() => Assert.Single(repository.Recorded),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,182 @@
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Kpi;
/// <summary>
/// Tests for <see cref="NotificationOutboxKpiSampleSource"/> — the M6 KPI sample source that
/// snapshots the Notification Outbox delivery KPIs (global / per-site / per-node) into the
/// central KPI-history store.
/// </summary>
public class NotificationOutboxKpiSampleSourceTests
{
private static readonly DateTime CapturedAt = new(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc);
private static readonly NotificationOutboxOptions Options = new()
{
StuckAgeThreshold = TimeSpan.FromMinutes(10),
DeliveredKpiWindow = TimeSpan.FromMinutes(1),
};
private static NotificationOutboxKpiSampleSource CreateSource(INotificationOutboxRepository repository) =>
new(repository, Microsoft.Extensions.Options.Options.Create(Options));
[Fact]
public void Source_IsNotificationOutbox()
{
var source = CreateSource(Substitute.For<INotificationOutboxRepository>());
Assert.Equal(KpiSources.NotificationOutbox, source.Source);
}
[Fact]
public async Task CollectAsync_PassesCutoffsAnchoredOnCapturedAt()
{
var repository = Substitute.For<INotificationOutboxRepository>();
StubEmptySnapshots(repository);
var source = CreateSource(repository);
await source.CollectAsync(CapturedAt);
var expectedStuckCutoff = new DateTimeOffset(CapturedAt, TimeSpan.Zero) - Options.StuckAgeThreshold;
var expectedDeliveredSince = new DateTimeOffset(CapturedAt, TimeSpan.Zero) - Options.DeliveredKpiWindow;
await repository.Received(1).ComputeKpisAsync(
expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
await repository.Received(1).ComputePerSiteKpisAsync(
expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
await repository.Received(1).ComputePerNodeKpisAsync(
expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
}
[Fact]
public async Task CollectAsync_EmitsGlobalSiteAndNodeSamples_WithExpectedTuples()
{
var repository = Substitute.For<INotificationOutboxRepository>();
repository.ComputeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new NotificationKpiSnapshot(
QueueDepth: 5,
StuckCount: 2,
ParkedCount: 1,
DeliveredLastInterval: 7,
OldestPendingAge: TimeSpan.FromSeconds(90)));
repository.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new[]
{
new SiteNotificationKpiSnapshot(
SourceSiteId: "site-a",
QueueDepth: 3,
StuckCount: 1,
ParkedCount: 0,
DeliveredLastInterval: 4,
OldestPendingAge: TimeSpan.FromSeconds(30)),
});
repository.ComputePerNodeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new[]
{
new NodeNotificationKpiSnapshot(
SourceNode: "node-a",
QueueDepth: 2,
StuckCount: 1,
ParkedCount: 1,
DeliveredLastInterval: 3,
OldestPendingAge: TimeSpan.FromSeconds(60)),
});
var source = CreateSource(repository);
var samples = await source.CollectAsync(CapturedAt);
// 3 scopes × 5 metrics (all ages non-null) = 15 samples.
Assert.Equal(15, samples.Count);
Assert.All(samples, s => Assert.Equal(KpiSources.NotificationOutbox, s.Source));
Assert.All(samples, s => Assert.Equal(CapturedAt, s.CapturedAtUtc));
// Global — null ScopeKey.
AssertSample(samples, "queueDepth", KpiScopes.Global, null, 5);
AssertSample(samples, "stuckCount", KpiScopes.Global, null, 2);
AssertSample(samples, "parkedCount", KpiScopes.Global, null, 1);
AssertSample(samples, "deliveredLastInterval", KpiScopes.Global, null, 7);
AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Global, null, 90);
// Site — ScopeKey == site id.
AssertSample(samples, "queueDepth", KpiScopes.Site, "site-a", 3);
AssertSample(samples, "stuckCount", KpiScopes.Site, "site-a", 1);
AssertSample(samples, "parkedCount", KpiScopes.Site, "site-a", 0);
AssertSample(samples, "deliveredLastInterval", KpiScopes.Site, "site-a", 4);
AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Site, "site-a", 30);
// Node — ScopeKey == node name.
AssertSample(samples, "queueDepth", KpiScopes.Node, "node-a", 2);
AssertSample(samples, "stuckCount", KpiScopes.Node, "node-a", 1);
AssertSample(samples, "parkedCount", KpiScopes.Node, "node-a", 1);
AssertSample(samples, "deliveredLastInterval", KpiScopes.Node, "node-a", 3);
AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Node, "node-a", 60);
}
[Fact]
public async Task CollectAsync_OmitsOldestPendingAge_WhenNull()
{
var repository = Substitute.For<INotificationOutboxRepository>();
repository.ComputeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new NotificationKpiSnapshot(
QueueDepth: 0,
StuckCount: 0,
ParkedCount: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null));
repository.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<SiteNotificationKpiSnapshot>());
repository.ComputePerNodeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<NodeNotificationKpiSnapshot>());
var source = CreateSource(repository);
var samples = await source.CollectAsync(CapturedAt);
// Only the global snapshot, age omitted -> the four count metrics.
Assert.Equal(4, samples.Count);
Assert.DoesNotContain(samples, s => s.Metric == "oldestPendingAgeSeconds");
AssertSample(samples, "queueDepth", KpiScopes.Global, null, 0);
AssertSample(samples, "stuckCount", KpiScopes.Global, null, 0);
AssertSample(samples, "parkedCount", KpiScopes.Global, null, 0);
AssertSample(samples, "deliveredLastInterval", KpiScopes.Global, null, 0);
}
[Fact]
public async Task CollectAsync_ReturnsEmptyList_NeverNull_WhenNothingToReport()
{
// ComputeKpisAsync always returns a global snapshot; the only way the list is empty is
// a guard that produces no samples. Confirm an all-zero global with null age still yields
// the four count metrics (i.e. the list is never null even at idle).
var repository = Substitute.For<INotificationOutboxRepository>();
StubEmptySnapshots(repository);
var source = CreateSource(repository);
var samples = await source.CollectAsync(CapturedAt);
Assert.NotNull(samples);
}
private static void StubEmptySnapshots(INotificationOutboxRepository repository)
{
repository.ComputeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new NotificationKpiSnapshot(0, 0, 0, 0, null));
repository.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<SiteNotificationKpiSnapshot>());
repository.ComputePerNodeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<NodeNotificationKpiSnapshot>());
}
private static void AssertSample(
IReadOnlyList<KpiSample> samples, string metric, string scope, string? scopeKey, double value)
{
var match = Assert.Single(
samples, s => s.Metric == metric && s.Scope == scope && s.ScopeKey == scopeKey);
Assert.Equal(value, match.Value);
}
}
@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ZB.MOM.WW.ScadaBridge.StoreAndForward.csproj" />
</ItemGroup>
@@ -0,0 +1,308 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.SiteCallAudit.Kpi;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests.Kpi;
/// <summary>
/// Unit tests for <see cref="SiteCallAuditKpiSampleSource"/> (M6 KPI History,
/// #K7). A hand-rolled <see cref="ISiteCallAuditRepository"/> fake (matching the
/// project's existing test-double style — no mocking library) returns known
/// global + per-site + per-node snapshots; the assertions pin the exact
/// <c>(Metric, Scope, ScopeKey, Value)</c> tuples the recorder will persist, the
/// cutoffs derived from <see cref="SiteCallAuditOptions"/>, and the
/// oldest-pending-age omission when its source is <c>null</c>.
/// </summary>
public class SiteCallAuditKpiSampleSourceTests
{
private static readonly DateTime CapturedAt =
new(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc);
private static SiteCallAuditOptions Options(
TimeSpan? stuckAge = null, TimeSpan? kpiInterval = null) => new()
{
StuckAgeThreshold = stuckAge ?? TimeSpan.FromMinutes(10),
KpiInterval = kpiInterval ?? TimeSpan.FromMinutes(1),
};
private static SiteCallAuditKpiSampleSource CreateSource(
ISiteCallAuditRepository repo, SiteCallAuditOptions? options = null) =>
new(repo, OptionsWrap(options ?? Options()));
private static IOptions<SiteCallAuditOptions> OptionsWrap(SiteCallAuditOptions o) =>
Microsoft.Extensions.Options.Options.Create(o);
// ---------------------------------------------------------------------
// 1. Source identifier is the canonical SiteCallAudit constant.
// ---------------------------------------------------------------------
[Fact]
public void Source_IsSiteCallAuditConstant()
{
var source = CreateSource(new StubRepo());
Assert.Equal(KpiSources.SiteCallAudit, source.Source);
}
// ---------------------------------------------------------------------
// 2. Full snapshot: global + per-site + per-node, exact tuples.
// ---------------------------------------------------------------------
[Fact]
public async Task CollectAsync_EmitsExpectedTuples_ForGlobalSiteAndNode()
{
var repo = new StubRepo
{
Global = new SiteCallKpiSnapshot(
BufferedCount: 5,
ParkedCount: 2,
FailedLastInterval: 1,
DeliveredLastInterval: 9,
OldestPendingAge: TimeSpan.FromSeconds(42),
StuckCount: 3),
PerSite =
[
new SiteCallSiteKpiSnapshot(
SourceSite: "site-a",
BufferedCount: 4,
ParkedCount: 1,
FailedLastInterval: 0,
DeliveredLastInterval: 7,
OldestPendingAge: TimeSpan.FromSeconds(30),
StuckCount: 2),
],
PerNode =
[
new SiteCallNodeKpiSnapshot(
SourceNode: "node-a",
BufferedCount: 3,
ParkedCount: 1,
FailedLastInterval: 1,
DeliveredLastInterval: 5,
OldestPendingAge: TimeSpan.FromSeconds(20),
StuckCount: 1),
],
};
var samples = await CreateSource(repo).CollectAsync(CapturedAt);
// Every sample carries Source + CapturedAtUtc.
Assert.All(samples, s =>
{
Assert.Equal(KpiSources.SiteCallAudit, s.Source);
Assert.Equal(CapturedAt, s.CapturedAtUtc);
});
// Global (null ScopeKey): six metrics (age present).
AssertHas(samples, "buffered", KpiScopes.Global, null, 5);
AssertHas(samples, "parked", KpiScopes.Global, null, 2);
AssertHas(samples, "failedLastInterval", KpiScopes.Global, null, 1);
AssertHas(samples, "deliveredLastInterval", KpiScopes.Global, null, 9);
AssertHas(samples, "oldestPendingAgeSeconds", KpiScopes.Global, null, 42);
AssertHas(samples, "stuck", KpiScopes.Global, null, 3);
// Per-site (ScopeKey = site id).
AssertHas(samples, "buffered", KpiScopes.Site, "site-a", 4);
AssertHas(samples, "parked", KpiScopes.Site, "site-a", 1);
AssertHas(samples, "failedLastInterval", KpiScopes.Site, "site-a", 0);
AssertHas(samples, "deliveredLastInterval", KpiScopes.Site, "site-a", 7);
AssertHas(samples, "oldestPendingAgeSeconds", KpiScopes.Site, "site-a", 30);
AssertHas(samples, "stuck", KpiScopes.Site, "site-a", 2);
// Per-node (ScopeKey = node name).
AssertHas(samples, "buffered", KpiScopes.Node, "node-a", 3);
AssertHas(samples, "parked", KpiScopes.Node, "node-a", 1);
AssertHas(samples, "failedLastInterval", KpiScopes.Node, "node-a", 1);
AssertHas(samples, "deliveredLastInterval", KpiScopes.Node, "node-a", 5);
AssertHas(samples, "oldestPendingAgeSeconds", KpiScopes.Node, "node-a", 20);
AssertHas(samples, "stuck", KpiScopes.Node, "node-a", 1);
// 6 metrics × 3 scopes, all ages present.
Assert.Equal(18, samples.Count);
}
// ---------------------------------------------------------------------
// 3. Null oldest-pending-age is omitted (not written as zero).
// ---------------------------------------------------------------------
[Fact]
public async Task CollectAsync_OmitsOldestPendingAge_WhenNull()
{
var repo = new StubRepo
{
Global = new SiteCallKpiSnapshot(0, 0, 0, 0, OldestPendingAge: null, 0),
PerSite =
[
new SiteCallSiteKpiSnapshot("site-a", 0, 0, 0, 0, OldestPendingAge: null, 0),
],
PerNode =
[
new SiteCallNodeKpiSnapshot("node-a", 0, 0, 0, 0, OldestPendingAge: null, 0),
],
};
var samples = await CreateSource(repo).CollectAsync(CapturedAt);
// No oldestPendingAgeSeconds rows at any scope when the age is null.
Assert.DoesNotContain(samples, s => s.Metric == "oldestPendingAgeSeconds");
// The five count metrics are still present at each of the 3 scopes.
Assert.Equal(15, samples.Count);
AssertHas(samples, "buffered", KpiScopes.Global, null, 0);
AssertHas(samples, "stuck", KpiScopes.Node, "node-a", 0);
}
// ---------------------------------------------------------------------
// 4. Empty per-site/per-node: only the global six metrics emitted.
// ---------------------------------------------------------------------
[Fact]
public async Task CollectAsync_GlobalOnly_WhenNoPerSiteOrPerNodeRows()
{
var repo = new StubRepo
{
Global = new SiteCallKpiSnapshot(1, 0, 0, 0, TimeSpan.FromSeconds(5), 0),
PerSite = [],
PerNode = [],
};
var samples = await CreateSource(repo).CollectAsync(CapturedAt);
Assert.All(samples, s => Assert.Equal(KpiScopes.Global, s.Scope));
Assert.Equal(6, samples.Count);
}
// ---------------------------------------------------------------------
// 5. Cutoffs are anchored on capturedAtUtc using the options windows.
// ---------------------------------------------------------------------
[Fact]
public async Task CollectAsync_DerivesCutoffs_FromOptionsAnchoredOnCapturedAt()
{
var repo = new StubRepo();
var options = Options(
stuckAge: TimeSpan.FromMinutes(10),
kpiInterval: TimeSpan.FromMinutes(2));
await CreateSource(repo, options).CollectAsync(CapturedAt);
var expectedStuck = CapturedAt - TimeSpan.FromMinutes(10);
var expectedSince = CapturedAt - TimeSpan.FromMinutes(2);
// Every repository call must have received the identical anchored cutoffs.
Assert.NotEmpty(repo.Calls);
Assert.All(repo.Calls, c =>
{
Assert.Equal(expectedStuck, c.StuckCutoff);
Assert.Equal(expectedSince, c.IntervalSince);
});
// All three KPI methods were invoked.
Assert.Equal(3, repo.Calls.Count);
}
// ---------------------------------------------------------------------
// 6. Multiple sites/nodes are all emitted with distinct ScopeKeys.
// ---------------------------------------------------------------------
[Fact]
public async Task CollectAsync_EmitsAllSitesAndNodes()
{
var repo = new StubRepo
{
Global = new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0),
PerSite =
[
new SiteCallSiteKpiSnapshot("site-a", 1, 0, 0, 0, null, 0),
new SiteCallSiteKpiSnapshot("site-b", 2, 0, 0, 0, null, 0),
],
PerNode =
[
new SiteCallNodeKpiSnapshot("node-a", 0, 0, 0, 0, null, 0),
new SiteCallNodeKpiSnapshot("node-b", 0, 0, 0, 0, null, 0),
],
};
var samples = await CreateSource(repo).CollectAsync(CapturedAt);
AssertHas(samples, "buffered", KpiScopes.Site, "site-a", 1);
AssertHas(samples, "buffered", KpiScopes.Site, "site-b", 2);
Assert.Contains(samples, s => s is { Scope: "Node", ScopeKey: "node-a" });
Assert.Contains(samples, s => s is { Scope: "Node", ScopeKey: "node-b" });
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private static void AssertHas(
IReadOnlyList<KpiSample> samples,
string metric, string scope, string? scopeKey, double value)
{
var match = samples.SingleOrDefault(s =>
s.Metric == metric && s.Scope == scope && s.ScopeKey == scopeKey);
Assert.True(
match is not null,
$"expected a sample for ({metric}, {scope}, {scopeKey ?? "<null>"})");
Assert.Equal(value, match!.Value);
}
/// <summary>Captured arguments of one KPI computation call.</summary>
private readonly record struct KpiCall(DateTime StuckCutoff, DateTime IntervalSince);
/// <summary>
/// Hand-rolled <see cref="ISiteCallAuditRepository"/> stub returning
/// configurable snapshots and recording the cutoffs each KPI method received.
/// Non-KPI members are inert (this source only reads KPIs).
/// </summary>
private sealed class StubRepo : ISiteCallAuditRepository
{
public SiteCallKpiSnapshot Global { get; set; } =
new(0, 0, 0, 0, null, 0);
public IReadOnlyList<SiteCallSiteKpiSnapshot> PerSite { get; set; } =
Array.Empty<SiteCallSiteKpiSnapshot>();
public IReadOnlyList<SiteCallNodeKpiSnapshot> PerNode { get; set; } =
Array.Empty<SiteCallNodeKpiSnapshot>();
public List<KpiCall> Calls { get; } = new();
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
Calls.Add(new KpiCall(stuckCutoff, intervalSince));
return Task.FromResult(Global);
}
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
Calls.Add(new KpiCall(stuckCutoff, intervalSince));
return Task.FromResult(PerSite);
}
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
Calls.Add(new KpiCall(stuckCutoff, intervalSince));
return Task.FromResult(PerNode);
}
// ── Inert non-KPI members ──
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
Task.CompletedTask;
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
Task.FromResult<SiteCall?>(null);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
Task.FromResult(0);
}
}