Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c9b101dfc | |||
| d0af884760 | |||
| 01bcca992c | |||
| 6dc5d94cb9 | |||
| 819ca4d7ce | |||
| a949fe6f57 | |||
| 0c9dffed78 | |||
| 1c99d6fa8d | |||
| 9ab27d5d61 | |||
| f6822f8f45 | |||
| 410349767d | |||
| 316153dc98 | |||
| a506b19d17 | |||
| fdfd5e1b27 | |||
| 8aa6bf2270 | |||
| 88638d774a | |||
| e0851e3e17 | |||
| a26d6ba317 | |||
| 697be0ce43 | |||
| 4558bc3b1f | |||
| 2d03f2d507 | |||
| 0123b68719 | |||
| d1ade5653b |
@@ -98,6 +98,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
- **The gRPC boundary is authenticated (PSK) as of 2026-07-22; Akka remoting still is not, and nothing is encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths` — so intra-cluster Akka remoting remains open to anyone who can reach the remoting port, and that boundary still assumes a trusted network. The gRPC listener stays **h2c**, but `SiteStreamService` is no longer open: `ControlPlaneAuthInterceptor` (`Host/ControlPlaneAuthInterceptor.cs`) gates `/sitestream.SiteStreamService/` — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — against a **per-site preshared key**, fail-closed, constant-time compared, alongside the separate `LocalDbSyncAuthInterceptor` on `/localdb_sync.v1.LocalDbSync/` with its own separate key. **Site side:** `ScadaBridge:Communication:GrpcPsk`, in production `${secret:SB-GRPC-PSK-<siteId>}`, and **`StartupValidator` refuses to boot a site node without it** (an unset key would leave the node healthy-looking but serving nothing). **Central side:** `SitePskProvider` resolves `SB-GRPC-PSK-{siteId}` from the secrets store at channel-build time (sites are added at runtime, so no boot-time expansion is possible), with `ScadaBridge:Communication:SitePsks:{siteId}` as an override for hosts running without a master key — the docker rig uses the latter. One key per site, never fleet-wide. A bearer token over h2c is readable and replayable on-path; TLS is the follow-on hardening and needs no change to this design. Introduced by Phase 0 of the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`).
|
||||
- gRPC streaming channel — **note the direction is inverted from the data flow**: data moves site→central, but each **site node hosts the gRPC server** (`SiteStreamGrpcServer`, Kestrel h2c, port 8083, mapped **only in the Site branch** of `Program.cs`) and **central is the client**, dialling in. Central creates per-site `SiteStreamGrpcClient` via `SiteStreamGrpcClientFactory`, keyed **`(siteId, endpoint)`** — the key was widened from site-only to fix an arch-review High where one session's NodeA→NodeB flip disposed a channel another session was still using. Proto evolution is **additive only** and field numbers are never reused (`AlarmStateUpdate` grew 7→23 fields for the native-alarm mirror). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle.
|
||||
- Native alarms are a **read-only** mirror of OPC UA Alarms & Conditions and MxAccess Gateway alarms — **no ack-back, no central tables**; state lives in the site's `native_alarm_state`, survives failover, and is cleared on redeploy/undeploy (mirrors static overrides). Central's per-site live alarm cache (`ISiteAlarmLiveCache`) is **transient in-memory only** — there is deliberately no persisted central alarm store, so the 15s poll remains the NotReporting authority behind the live stream. See `Component-DataConnectionLayer.md` / `Component-CentralUI.md` for the model and the authoring surface.
|
||||
- **`AckTime` mirror enrichment + the `Alarms` script accessor (MES alarm-status API Phase 1, 2026-08-01).** `AlarmStateChanged` carries an additive `AckTime` (`DateTimeOffset?`), mirrored on the vendored `AlarmStateUpdate` proto as **field 24** and persisted inside `native_alarm_state`'s `metadata_json` — deliberately NOT a new column, because that table is `RegisterReplicated` and LocalDb builds its CDC triggers from the column list at registration time. Set only while a condition is active AND acknowledged (so it is null while unacked and cleared on re-raise); the DCL stamps the source's own ack instant for OPC UA (new SelectClause **index 18** = `AckedState/TransitionTime`) and its observation time of the ack transition for MxGateway, which supplies none. Site `Call` scripts read alarms via the new **`Alarms.CurrentAsync()`** accessor (`ScriptRuntimeContext` + `ScriptGlobals`, local Ask on `GetAlarmSnapshotRequest`, returns `Commons.Types.Scripts.ScriptAlarm`), mirrored on `ScriptCompileSurface` AND the Central UI `SandboxScriptHost` editor surface. The trust model needed no change — it is a deny-list over API roots, not an allow-list of context members. Plan: `docs/plans/2026-06-30-mes-alarm-status-api.md` (Phases 2–4 are deployed config, not repo).
|
||||
- OPC UA cert trust is **site-local and not persisted centrally** (follow-up): the verify-endpoint probe captures an untrusted server cert but **NEVER trusts it**, and DeploymentManager broadcasts `TrustServerCertCommand`/`RemoveServerCertCommand` to **BOTH** site nodes — `CertStoreActor` runs on every site node, not as a singleton, so PKI stores stay consistent across failover.
|
||||
|
||||
### External Integrations
|
||||
@@ -149,7 +150,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
|
||||
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
|
||||
- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots).
|
||||
- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; bridges inbound API → routed-site-script, alarm-triggered on-trigger scripts, and nested `CallScript`/`CallShared` invocations; `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk. Tag-cascade coverage is complete as of M5.4 (T4) — no further spawn points are deferred.
|
||||
- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; bridges inbound API → routed-site-script, alarm-triggered on-trigger scripts, and nested `CallScript`/`CallShared` invocations; `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk. **Tag-cascade (alarm leg) is populated, not just plumbed** — M5.4 T4 threaded the `parentExecutionId` parameter but every `AlarmActor.SpawnAlarmExecution` call site passed null, so alarm runs were silently always roots; the id now rides site-locally as `SetStaticAttributeCommand.SourceExecutionId` → `AttributeValueChanged.SourceExecutionId` → `SpawnAlarmExecution` (all additive/nullable, no wire/proto/schema change; `Expression` triggers capture the writer *with* the evaluated snapshot since the eval completes off-dispatcher). Sources are `ScriptRuntimeContext.SetAttribute` (the run's own `ExecutionId`) and `Route.To(...).SetAttributes(...)` (the inbound request's). **Still roots by design, not omission:** alarms fired by DCL data (external values have no spawning execution — including the device echo of a script write to a *data-sourced* attribute, so only **static** writes cascade), and `ScriptActor` value-change/conditional/expression/timer trigger runs (a timer tick has no spawner; a `WhileTrue`/interval run has no single identifiable write).
|
||||
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
|
||||
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
|
||||
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in. Inbound API: full verbatim capture up to `InboundMaxBytes` (default 1 MiB); request headers stored in `Extra.requestHeaders` (post-redaction); per-method `SkipBodyCapture` flag suppresses bodies while still recording headers + metadata; `AuditInboundCeilingHits` counter surfaced on health snapshot. (M5.3 T7)
|
||||
|
||||
@@ -41,9 +41,31 @@ In `Single` mode the component uses `SelectedKey` / `SelectedKeyChanged` (two-wa
|
||||
|
||||
When `ContextMenu` is non-null, right-clicking any row suppresses the browser default and positions a Bootstrap `dropdown-menu show` div at the cursor coordinates using `position: fixed`. An invisible overlay behind the menu dismisses it on click-outside; Escape also dismisses it. The menu receives the `TItem` of the right-clicked node, so the consumer's fragment can branch on node type.
|
||||
|
||||
### Keyboard navigation & accessibility (WAI-ARIA tree pattern)
|
||||
|
||||
Delivered 2026-08-01 (M10 residual R7). The component implements the full [WAI-ARIA tree pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).
|
||||
|
||||
**Roving tabindex.** Exactly one `li[role="treeitem"]` carries `tabindex="0"` at a time; every other node is `tabindex="-1"`, so the whole tree is a single Tab stop. The target is resolved once per render by `ResolveTabbableKey()`: the node the user last landed on (`_focusedKey`) while it is still visible → else the currently `SelectedKey` node → else the first visible node. A keyboard move sets `_focusNeedsApply`, and `OnAfterRenderAsync` pulls browser focus onto the new node via a per-node `ElementReference`, guarded by the same `JSException` / `JSDisconnectedException` / `InvalidOperationException` triple the context-menu focus uses (under bUnit there is no real focus, so it is a safe no-op).
|
||||
|
||||
| Key | Behaviour |
|
||||
| --- | --- |
|
||||
| `ArrowDown` / `ArrowUp` | Move to the next / previous **visible** node (a collapsed branch's children are skipped). No-op at the ends. |
|
||||
| `ArrowRight` | Collapsed branch → expand (focus stays); expanded branch → move to first child; leaf → no-op. |
|
||||
| `ArrowLeft` | Expanded branch → collapse (focus stays); otherwise → move to parent (via `BuildParentLookup()`). No-op at a root leaf. |
|
||||
| `Home` / `End` | Move to the first / last visible node. |
|
||||
| `Enter` / `Space` | Activate the node — routed to the *same* path a mouse click takes (`OnContentClick` in `Single` mode, `OnCheckboxToggle` in `Checkbox` mode), so selection semantics never diverge between input modes. |
|
||||
|
||||
The visible order is produced by `BuildVisibleNodes()`, which mirrors `RenderNode`'s visibility rules exactly — **the two must stay in step**, or arrow navigation will skip or invent rows.
|
||||
|
||||
**ARIA attributes.** `ul[role=tree]` root, `ul[role=group]` for children, `li[role=treeitem]` nodes carrying `aria-level` (1-based), `aria-posinset`, `aria-setsize`, `aria-expanded` (branches only), and `aria-selected` (selectable / checkbox trees only — absent on a non-selectable tree).
|
||||
|
||||
**Event scoping.** `@onkeydown:stopPropagation` sits on the `<li>` (so a nested node's keypress is not also handled by its ancestors) and on the chevron, the checkbox, and the `.tv-content` slot — so a consumer's own buttons and inputs inside `NodeContent` keep their key handling. Browser scroll-on-Space/Arrow is suppressed by a small **native** inline `onkeydown` on the root `<ul>` that calls `preventDefault()` only for the navigation keys and only when the event target is the treeitem itself; Blazor's `preventDefault` directive is all-or-nothing per element, so putting it on the `<li>` would both trap Tab inside the tree and cancel Enter/Space activation of consumer buttons. (The app sets no Content-Security-Policy, so the inline handler executes.)
|
||||
|
||||
Covered by `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs` (31 bUnit tests).
|
||||
|
||||
## Architecture
|
||||
|
||||
The component is a single `@typeparam` `.razor` file with a private `void RenderNode(TItem item, int depth)` local function that recurses the tree at render time — no intermediate view model is built inside the component. Every `<li>` carries `@key="key"` so Blazor can diff the list efficiently.
|
||||
The component is a single `@typeparam` `.razor` file with a private `void RenderNode(TItem item, int depth, int posInSet, int setSize)` local function that recurses the tree at render time — no intermediate view model is built inside the component. Every `<li>` carries `@key="key"` so Blazor can diff the list efficiently.
|
||||
|
||||
`IJSRuntime` is injected for two purposes: reading/writing `sessionStorage` for expansion persistence, and setting `input.indeterminate` for tri-state checkboxes. Both call sites guard `JSDisconnectedException` so a disconnected circuit never throws out of the lifecycle methods.
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ Each site has its own two-node cluster:
|
||||
|
||||
### Site Cluster Behavior
|
||||
|
||||
- Same split-brain resolver as central (keep-oldest).
|
||||
- Same split-brain resolver as central (`auto-down`, per the 2026-07-21 decision — see the Central Cluster Behavior note above).
|
||||
- Singleton actors: Site Deployment Manager migrates on failover.
|
||||
- Staggered instance startup: 50ms delay between Instance Actor creation to prevent reconnection storms.
|
||||
- SQLite persistence: each node owns its own consolidated LocalDb database, kept in step by
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2026-03-16
|
||||
**Component**: Cluster Infrastructure (`docs/requirements/Component-ClusterInfrastructure.md`)
|
||||
**Status**: Approved
|
||||
**Status**: Approved — superseded in part: the keep-oldest SBR decision was replaced by the auto-down decision 2026-07-21 (`docs/plans/2026-07-21-auto-down-availability-decision.md`).
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2026-03-16
|
||||
**Component**: Central–Site Communication (`docs/requirements/Component-Communication.md`)
|
||||
**Status**: Approved
|
||||
**Status**: Approved — transport decisions superseded by the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`); the "no buffering at central" decision still stands.
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-17-deploy-artifacts-remove-configdb.md",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"tasks": [
|
||||
{"id": 4, "subject": "Task 1: Create SiteExternalSystemRepository", "status": "pending"},
|
||||
{"id": 5, "subject": "Task 2: Create SiteNotificationRepository", "status": "pending"},
|
||||
{"id": 6, "subject": "Task 3: Add data connections to DeployArtifactsCommand", "status": "pending"},
|
||||
{"id": 7, "subject": "Task 4: Wire site-local repositories into DI", "status": "pending", "blockedBy": [4, 5, 6]},
|
||||
{"id": 8, "subject": "Task 5: Add Deploy Artifacts button to Sites admin page", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 9, "subject": "Task 6: Update ArtifactDeploymentService to include all artifact types", "status": "pending"},
|
||||
{"id": 10, "subject": "Task 7: End-to-end test", "status": "pending", "blockedBy": [7, 8, 9]}
|
||||
{"id": 4, "subject": "Task 1: Create SiteExternalSystemRepository", "status": "completed"},
|
||||
{"id": 5, "subject": "Task 2: Create SiteNotificationRepository", "status": "completed", "note": "Shipped as planned, then deliberately removed 2026-07-10 (arch-review 08 §1.3 — excise vestigial site notification surface; notification delivery is central-only). The notification_lists / smtp_configurations tables survive on the site as deliberately-empty, unregistered (non-replicated) tables."},
|
||||
{"id": 6, "subject": "Task 3: Add data connections to DeployArtifactsCommand", "status": "completed"},
|
||||
{"id": 7, "subject": "Task 4: Wire site-local repositories into DI", "status": "completed", "blockedBy": [4, 5, 6]},
|
||||
{"id": 8, "subject": "Task 5: Add Deploy Artifacts button to Sites admin page", "status": "completed", "blockedBy": [9]},
|
||||
{"id": 9, "subject": "Task 6: Update ArtifactDeploymentService to include all artifact types", "status": "completed"},
|
||||
{"id": 10, "subject": "Task 7: End-to-end test", "status": "completed", "blockedBy": [7, 8, 9]}
|
||||
],
|
||||
"lastUpdated": "2026-03-17T16:40:00Z"
|
||||
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-17-management-service-cli.md",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"tasks": [
|
||||
{"id": 11, "subject": "Task 1: Create ManagementService project and test project", "status": "pending"},
|
||||
{"id": 12, "subject": "Task 2: Define management message contracts in Commons", "status": "pending"},
|
||||
{"id": 13, "subject": "Task 3: Implement ManagementActor", "status": "pending", "blockedBy": [11, 12]},
|
||||
{"id": 14, "subject": "Task 4: Register ManagementActor on Central with ClusterClientReceptionist", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 5: Create CLI project with ClusterClient scaffolding", "status": "pending", "blockedBy": [12]},
|
||||
{"id": 16, "subject": "Task 6: Implement CLI command groups", "status": "pending", "blockedBy": [15]},
|
||||
{"id": 17, "subject": "Task 7: Write ManagementActor unit tests", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 18, "subject": "Task 8: End-to-end integration test", "status": "pending", "blockedBy": [14, 16, 17]}
|
||||
{"id": 11, "subject": "Task 1: Create ManagementService project and test project", "status": "completed"},
|
||||
{"id": 12, "subject": "Task 2: Define management message contracts in Commons", "status": "completed"},
|
||||
{"id": 13, "subject": "Task 3: Implement ManagementActor", "status": "completed", "blockedBy": [11, 12]},
|
||||
{"id": 14, "subject": "Task 4: Register ManagementActor on Central with ClusterClientReceptionist", "status": "completed", "blockedBy": [13], "note": "OBSOLETE — never built; CLI shipped HTTP-only (ManagementHttpClient), receptionist deleted in the 2026-07-22 gRPC migration. Marked completed only to close the tracker."},
|
||||
{"id": 15, "subject": "Task 5: Create CLI project with ClusterClient scaffolding", "status": "completed", "blockedBy": [12], "note": "OBSOLETE — never built; CLI shipped HTTP-only (ManagementHttpClient), receptionist deleted in the 2026-07-22 gRPC migration. The CLI project itself exists; only the ClusterClient scaffolding is obsolete."},
|
||||
{"id": 16, "subject": "Task 6: Implement CLI command groups", "status": "completed", "blockedBy": [15]},
|
||||
{"id": 17, "subject": "Task 7: Write ManagementActor unit tests", "status": "completed", "blockedBy": [13]},
|
||||
{"id": 18, "subject": "Task 8: End-to-end integration test", "status": "completed", "blockedBy": [14, 16, 17]}
|
||||
],
|
||||
"lastUpdated": "2026-03-17T17:00:00Z"
|
||||
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-21-grpc-streaming-channel.md",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code — feature shipped and later extended by the full ClusterClient→gRPC migration (docs/plans/2026-07-22-clusterclient-to-grpc-plan.md)",
|
||||
"tasks": [
|
||||
{"id": 0, "taskId": "1", "subject": "Task 0: Proto Definition & Stub Generation", "status": "pending"},
|
||||
{"id": 1, "taskId": "2", "subject": "Task 1: Site Config — GrpcPort in NodeOptions", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "taskId": "3", "subject": "Task 2: Site Entity — gRPC Address Fields", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 3, "taskId": "4", "subject": "Task 3: Site-Side gRPC Server — StreamRelayActor", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 4, "taskId": "5", "subject": "Task 4: Site-Side gRPC Server — SiteStreamGrpcServer", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "taskId": "6", "subject": "Task 5: Switch Site Host to WebApplicationBuilder + gRPC", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "taskId": "7", "subject": "Task 6: Central-Side gRPC Client", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 7, "taskId": "8", "subject": "Task 7: Update DebugStreamBridgeActor to Use gRPC", "status": "pending", "blockedBy": [6, 5]},
|
||||
{"id": 8, "taskId": "9", "subject": "Task 8: Remove ClusterClient Streaming Path", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "taskId": "10", "subject": "Task 9: Docker & End-to-End Integration Test", "status": "pending", "blockedBy": [5, 1, 2]},
|
||||
{"id": 10, "taskId": "11", "subject": "Task 10: Documentation Updates", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "taskId": "12", "subject": "Task 11: Final Guardrail Tests", "status": "pending", "blockedBy": [9]}
|
||||
{"id": 0, "taskId": "1", "subject": "Task 0: Proto Definition & Stub Generation", "status": "completed"},
|
||||
{"id": 1, "taskId": "2", "subject": "Task 1: Site Config — GrpcPort in NodeOptions", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 2, "taskId": "3", "subject": "Task 2: Site Entity — gRPC Address Fields", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 3, "taskId": "4", "subject": "Task 3: Site-Side gRPC Server — StreamRelayActor", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 4, "taskId": "5", "subject": "Task 4: Site-Side gRPC Server — SiteStreamGrpcServer", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 5, "taskId": "6", "subject": "Task 5: Switch Site Host to WebApplicationBuilder + gRPC", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 6, "taskId": "7", "subject": "Task 6: Central-Side gRPC Client", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 7, "taskId": "8", "subject": "Task 7: Update DebugStreamBridgeActor to Use gRPC", "status": "completed", "blockedBy": [6, 5]},
|
||||
{"id": 8, "taskId": "9", "subject": "Task 8: Remove ClusterClient Streaming Path", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 9, "taskId": "10", "subject": "Task 9: Docker & End-to-End Integration Test", "status": "completed", "blockedBy": [5, 1, 2]},
|
||||
{"id": 10, "taskId": "11", "subject": "Task 10: Documentation Updates", "status": "completed", "blockedBy": [9]},
|
||||
{"id": 11, "taskId": "12", "subject": "Task 11: Final Guardrail Tests", "status": "completed", "blockedBy": [9]}
|
||||
],
|
||||
"lastUpdated": "2026-03-21T14:15:00Z"
|
||||
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-22-primary-backup-data-connections.md",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code — shipped (EF migration AddPrimaryBackupDataConnections; failover coverage in DataConnectionActorTests)",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Entity Model & Database Migration", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Update CreateConnectionCommand & Manager Actor", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: DataConnectionActor Failover State Machine", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 4, "subject": "Task 4: Failover Tests", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Health Reporting & Site Event Logging", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 6, "subject": "Task 6: Central UI Changes", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 7, "subject": "Task 7: CLI, Management API, and Deployment", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 8, "subject": "Task 8: Documentation Updates", "status": "pending", "blockedBy": [3]}
|
||||
{"id": 1, "subject": "Task 1: Entity Model & Database Migration", "status": "completed", "note": "EF migration AddPrimaryBackupDataConnections."},
|
||||
{"id": 2, "subject": "Task 2: Update CreateConnectionCommand & Manager Actor", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: DataConnectionActor Failover State Machine", "status": "completed", "blockedBy": [1, 2]},
|
||||
{"id": 4, "subject": "Task 4: Failover Tests", "status": "completed", "blockedBy": [3], "note": "Covered by DataConnectionActorTests."},
|
||||
{"id": 5, "subject": "Task 5: Health Reporting & Site Event Logging", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 6, "subject": "Task 6: Central UI Changes", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 7, "subject": "Task 7: CLI, Management API, and Deployment", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 8, "subject": "Task 8: Documentation Updates", "status": "completed", "blockedBy": [3]}
|
||||
],
|
||||
"lastUpdated": "2026-03-22T12:00:00Z"
|
||||
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-23-treeview-component.md",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"tasks": [
|
||||
{"id": 22, "subject": "Task 1: Create TreeView.razor — Core Rendering (R1-R4, R14)", "status": "pending"},
|
||||
{"id": 23, "subject": "Task 2: Add Selection Support (R5)", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "Task 3: Add Session Storage Persistence (R11)", "status": "pending", "blockedBy": [23]},
|
||||
{"id": 25, "subject": "Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13)", "status": "pending", "blockedBy": [24]},
|
||||
{"id": 26, "subject": "Task 5: Add Context Menu (R15)", "status": "pending", "blockedBy": [25]},
|
||||
{"id": 27, "subject": "Task 6: Add External Filtering Tests (R8)", "status": "pending", "blockedBy": [26]},
|
||||
{"id": 28, "subject": "Task 7: Integrate TreeView into Data Connections Page", "status": "pending", "blockedBy": [27]},
|
||||
{"id": 29, "subject": "Task 8: Integrate TreeView into Areas Page", "status": "pending", "blockedBy": [27]},
|
||||
{"id": 30, "subject": "Task 9: Integrate TreeView into Instances Page", "status": "pending", "blockedBy": [27]},
|
||||
{"id": 31, "subject": "Task 10: Full Build Verification", "status": "pending", "blockedBy": [28, 29, 30]}
|
||||
{"id": 22, "subject": "Task 1: Create TreeView.razor — Core Rendering (R1-R4, R14)", "status": "completed"},
|
||||
{"id": 23, "subject": "Task 2: Add Selection Support (R5)", "status": "completed", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "Task 3: Add Session Storage Persistence (R11)", "status": "completed", "blockedBy": [23]},
|
||||
{"id": 25, "subject": "Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13)", "status": "completed", "blockedBy": [24]},
|
||||
{"id": 26, "subject": "Task 5: Add Context Menu (R15)", "status": "completed", "blockedBy": [25]},
|
||||
{"id": 27, "subject": "Task 6: Add External Filtering Tests (R8)", "status": "completed", "blockedBy": [26]},
|
||||
{"id": 28, "subject": "Task 7: Integrate TreeView into Data Connections Page", "status": "completed", "blockedBy": [27]},
|
||||
{"id": 29, "subject": "Task 8: Integrate TreeView into Areas Page", "status": "completed", "blockedBy": [27], "note": "OBSOLETE — those pages never existed; Site/Area/Instance render in one tree on Pages/Deployment/Topology.razor (2026-05-11 topology design). Marked completed only to close the tracker."},
|
||||
{"id": 30, "subject": "Task 9: Integrate TreeView into Instances Page", "status": "completed", "blockedBy": [27], "note": "OBSOLETE — those pages never existed; Site/Area/Instance render in one tree on Pages/Deployment/Topology.razor (2026-05-11 topology design). Marked completed only to close the tracker."},
|
||||
{"id": 31, "subject": "Task 10: Full Build Verification", "status": "completed", "blockedBy": [28, 29, 30]}
|
||||
],
|
||||
"lastUpdated": "2026-03-23T00:00:00Z"
|
||||
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -138,9 +138,9 @@ Areas can be moved freely (subject to validation). Templates are different becau
|
||||
- `AreaService.UpdateAreaAsync` (stays name-only)
|
||||
- `InstanceService` lifecycle methods (already used by current Instances page)
|
||||
|
||||
### CLI / ManagementService parity (optional follow-up)
|
||||
- Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.
|
||||
- Add CLI: `cli area move --id X --parent-id Y --username … --password …` (omit `--parent-id` to move to site root).
|
||||
### CLI / ManagementService parity (optional follow-up) — **DONE 2026-08-01**
|
||||
- ~~Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.~~ Shipped: `MoveAreaCommand(int AreaId, int? NewParentAreaId)` in `Commons/Messages/Management/SiteCommands.cs`, dispatched by `ManagementActor.HandleMoveArea` (delegates to `AreaService.MoveAreaAsync`; failures surface as the standard curated failure response), gated any-of `[Designer, Deployer]` like the other area mutations.
|
||||
- ~~Add CLI: `cli area move --id X --parent-id Y …` (omit `--parent-id` to move to site root).~~ Shipped as **`scadabridge site area move --id X [--parent-id Y]`** — the area verbs live under the existing `site area` group, not at the CLI root, so the verb was placed alongside `site area create|update|delete` rather than introducing a second top-level spelling.
|
||||
|
||||
Not strictly required to ship the UI page, but worth doing for parity with how the rest of the app exposes admin ops.
|
||||
|
||||
|
||||
@@ -2180,6 +2180,15 @@ Expected: image rebuilds, 5-container cluster starts.
|
||||
|
||||
**Step 3: Manual smoke checklist**
|
||||
|
||||
> **RETIRED 2026-08-01 — never run; unrunnable as written and superseded by delivered coverage.**
|
||||
> Two steps (drag a template onto "Dev"; drag "Dev" onto "Sub") reference folder drag-drop, which was
|
||||
> **permanently deferred** by the M9 decision (menu-based reorder shipped instead — see the deferred-work
|
||||
> register's Resolved row 18), so the checklist cannot pass as written. The remaining behaviors are
|
||||
> covered by the CentralUI bUnit suites (folder CRUD/context menus/cycle guard/TreeView reveal +
|
||||
> sessionStorage expansion) delivered through M9/M10, and the management folder commands gained full CLI
|
||||
> parity on 2026-08-01 (`template folder list|create|rename|move|reorder|delete`, commit `88638d77`).
|
||||
> Do not run or refresh this checklist.
|
||||
|
||||
Open http://localhost:9000/design/templates (login `multi-role` / `password`). Verify:
|
||||
|
||||
- [ ] Existing templates appear at root (no folder).
|
||||
@@ -2202,7 +2211,7 @@ Open http://localhost:9000/design/templates (login `multi-role` / `password`). V
|
||||
|
||||
## Out of scope (per design)
|
||||
|
||||
- CLI commands for folder operations (Management Service contracts now exist; CLI follows in a future plan).
|
||||
- ~~CLI commands for folder operations (Management Service contracts now exist; CLI follows in a future plan).~~ **DONE 2026-08-01** — shipped as `scadabridge template folder list|create|rename|move|reorder|delete`, mapping 1:1 onto `ListTemplateFolders` / `CreateTemplateFolder` / `RenameTemplateFolder` / `MoveTemplateFolder` / `ReorderTemplateFolder` / `DeleteTemplateFolder`. Omitting `--parent-id` on create/move targets the tree root; `reorder --direction` takes the lowercase literals `up` / `down`.
|
||||
- Tree search / filter input.
|
||||
- Sibling reordering via drag-drop (alphabetical sort is fixed).
|
||||
- Root-area context menu (right-click in empty tree space).
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-11-templates-folder-hierarchy.md",
|
||||
"tasks": [
|
||||
{"id": 7, "subject": "Task 0: Confirm baseline + create work branch", "status": "pending"},
|
||||
{"id": 8, "subject": "Task 1: Add TemplateFolder entity + Template.FolderId", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 2: EF configuration for TemplateFolder + Template.FolderId", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 3: Generate EF migration AddTemplateFolders", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 4: Repository methods for TemplateFolder", "status": "pending", "blockedBy": [10]},
|
||||
{"id": 12, "subject": "Task 5: TemplateFolderService.CreateFolderAsync (TDD)", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 13, "subject": "Task 6: TemplateFolderService.RenameFolderAsync", "status": "pending", "blockedBy": [12]},
|
||||
{"id": 14, "subject": "Task 7: TemplateFolderService.MoveFolderAsync with cycle detection", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 8: TemplateFolderService.DeleteFolderAsync (non-empty check)", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 9: TemplateService.MoveTemplateAsync", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 17, "subject": "Task 10: DI registration for TemplateFolderService", "status": "pending", "blockedBy": [15, 16]},
|
||||
{"id": 18, "subject": "Task 11: Management command records for TemplateFolder", "status": "pending", "blockedBy": [17]},
|
||||
{"id": 19, "subject": "Task 12: ManagementActor authorization + handlers", "status": "pending", "blockedBy": [18]},
|
||||
{"id": 20, "subject": "Task 13: Templates.razor — load folders alongside templates", "status": "pending", "blockedBy": [17]},
|
||||
{"id": 21, "subject": "Task 14: Build new TmplNode tree model", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 22, "subject": "Task 15: Split-pane layout + new TreeView wiring", "status": "pending", "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 16: Per-kind context menus", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "Task 17: New-folder, new-template, move-template dialogs", "status": "pending", "blockedBy": [23]},
|
||||
{"id": 25, "subject": "Task 18: Drag-drop reorganization", "status": "pending", "blockedBy": [24]},
|
||||
{"id": 26, "subject": "Task 19: Deep-link reveal on load", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 27, "subject": "Task 20: bUnit tests for the new page", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 28, "subject": "Task 21: Documentation updates", "status": "pending", "blockedBy": [25, 26, 27]},
|
||||
{"id": 29, "subject": "Task 22: Final smoke + green-suite check", "status": "pending", "blockedBy": [25, 26, 27, 28]}
|
||||
{"id": 7, "subject": "Task 0: Confirm baseline + create work branch", "status": "completed"},
|
||||
{"id": 8, "subject": "Task 1: Add TemplateFolder entity + Template.FolderId", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 2: EF configuration for TemplateFolder + Template.FolderId", "status": "completed", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 3: Generate EF migration AddTemplateFolders", "status": "completed", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 4: Repository methods for TemplateFolder", "status": "completed", "blockedBy": [10]},
|
||||
{"id": 12, "subject": "Task 5: TemplateFolderService.CreateFolderAsync (TDD)", "status": "completed", "blockedBy": [11]},
|
||||
{"id": 13, "subject": "Task 6: TemplateFolderService.RenameFolderAsync", "status": "completed", "blockedBy": [12]},
|
||||
{"id": 14, "subject": "Task 7: TemplateFolderService.MoveFolderAsync with cycle detection", "status": "completed", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 8: TemplateFolderService.DeleteFolderAsync (non-empty check)", "status": "completed", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 9: TemplateService.MoveTemplateAsync", "status": "completed", "blockedBy": [11]},
|
||||
{"id": 17, "subject": "Task 10: DI registration for TemplateFolderService", "status": "completed", "blockedBy": [15, 16]},
|
||||
{"id": 18, "subject": "Task 11: Management command records for TemplateFolder", "status": "completed", "blockedBy": [17]},
|
||||
{"id": 19, "subject": "Task 12: ManagementActor authorization + handlers", "status": "completed", "blockedBy": [18]},
|
||||
{"id": 20, "subject": "Task 13: Templates.razor — load folders alongside templates", "status": "completed", "blockedBy": [17]},
|
||||
{"id": 21, "subject": "Task 14: Build new TmplNode tree model", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 22, "subject": "Task 15: Split-pane layout + new TreeView wiring", "status": "completed", "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 16: Per-kind context menus", "status": "completed", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "Task 17: New-folder, new-template, move-template dialogs", "status": "completed", "blockedBy": [23]},
|
||||
{"id": 25, "subject": "Task 18: Drag-drop reorganization", "status": "completed", "blockedBy": [24], "notes": "DROPPED — superseded by M9 menu-based reorder (commits e3bc19c6/314c7dea) + MoveFolderDialog; no DnD anywhere in CentralUI"},
|
||||
{"id": 26, "subject": "Task 19: Deep-link reveal on load", "status": "completed", "blockedBy": [22], "notes": "OBSOLETE — /design/templates/{id} is TemplateEdit's own route; two-page split makes in-tree reveal moot"},
|
||||
{"id": 27, "subject": "Task 20: bUnit tests for the new page", "status": "completed", "blockedBy": [22]},
|
||||
{"id": 28, "subject": "Task 21: Documentation updates", "status": "completed", "blockedBy": [25, 26, 27]},
|
||||
{"id": 29, "subject": "Task 22: Final smoke + green-suite check", "status": "completed", "blockedBy": [25, 26, 27, 28]}
|
||||
],
|
||||
"lastUpdated": "2026-05-11"
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"lastUpdated": "2026-08-01"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Derive-on-compose: implementation status
|
||||
|
||||
> **2026-08-01:** The "Still to verify" items below are satisfied — the
|
||||
> Phase-3 migration ships and auto-applies, and
|
||||
> `2026-05-18-contained-template-names-design.md` records it applied and
|
||||
> browser-verified on the dev cluster. The "How to resume" section is
|
||||
> historical.
|
||||
|
||||
> **For Claude resuming later:** All nine phases are implemented. This
|
||||
> file is the change-record for the work, not a plan. See the companion
|
||||
> design doc `2026-05-12-derive-on-compose-design.md` for rationale.
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-12-opcua-config-model.md",
|
||||
"tasks": [
|
||||
{"id": 45, "subject": "Task 1: Create OPC UA config POCOs + ValidationCategory.ConnectionConfig", "status": "pending"},
|
||||
{"id": 46, "subject": "Task 2: TDD failing tests for OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [45]},
|
||||
{"id": 47, "subject": "Task 3: Implement OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [46]},
|
||||
{"id": 48, "subject": "Task 4: TDD failing tests for OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [45]},
|
||||
{"id": 49, "subject": "Task 5: Implement OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [48]},
|
||||
{"id": 50, "subject": "Task 6: Refactor OpcUaDataConnection.ConnectAsync to use FromFlatDict", "status": "pending", "blockedBy": [47]},
|
||||
{"id": 51, "subject": "Task 7: Refactor DeploymentManagerActor.EnsureDclConnections", "status": "pending", "blockedBy": [47]},
|
||||
{"id": 52, "subject": "Task 8: TDD failing bUnit tests for OpcUaEndpointEditor", "status": "pending", "blockedBy": [45, 49]},
|
||||
{"id": 53, "subject": "Task 9: Implement OpcUaEndpointEditor.razor", "status": "pending", "blockedBy": [52]},
|
||||
{"id": 54, "subject": "Task 10: TDD failing bUnit tests for DataConnectionForm refactor", "status": "pending", "blockedBy": [47, 49]},
|
||||
{"id": 55, "subject": "Task 11: Refactor DataConnectionForm.razor", "status": "pending", "blockedBy": [53, 54]},
|
||||
{"id": 56, "subject": "Task 12: Solution build + all test suites green", "status": "pending", "blockedBy": [50, 51, 55]},
|
||||
{"id": 57, "subject": "Task 13: Docker deploy + browser smoke", "status": "pending", "blockedBy": [56]},
|
||||
{"id": 58, "subject": "Task 14: Push to origin", "status": "pending", "blockedBy": [57]}
|
||||
{"id": 45, "subject": "Task 1: Create OPC UA config POCOs + ValidationCategory.ConnectionConfig", "status": "completed"},
|
||||
{"id": 46, "subject": "Task 2: TDD failing tests for OpcUaEndpointConfigSerializer", "status": "completed", "blockedBy": [45]},
|
||||
{"id": 47, "subject": "Task 3: Implement OpcUaEndpointConfigSerializer", "status": "completed", "blockedBy": [46]},
|
||||
{"id": 48, "subject": "Task 4: TDD failing tests for OpcUaEndpointConfigValidator", "status": "completed", "blockedBy": [45]},
|
||||
{"id": 49, "subject": "Task 5: Implement OpcUaEndpointConfigValidator", "status": "completed", "blockedBy": [48]},
|
||||
{"id": 50, "subject": "Task 6: Refactor OpcUaDataConnection.ConnectAsync to use FromFlatDict", "status": "completed", "blockedBy": [47]},
|
||||
{"id": 51, "subject": "Task 7: Refactor DeploymentManagerActor.EnsureDclConnections", "status": "completed", "blockedBy": [47]},
|
||||
{"id": 52, "subject": "Task 8: TDD failing bUnit tests for OpcUaEndpointEditor", "status": "completed", "blockedBy": [45, 49]},
|
||||
{"id": 53, "subject": "Task 9: Implement OpcUaEndpointEditor.razor", "status": "completed", "blockedBy": [52]},
|
||||
{"id": 54, "subject": "Task 10: TDD failing bUnit tests for DataConnectionForm refactor", "status": "completed", "blockedBy": [47, 49]},
|
||||
{"id": 55, "subject": "Task 11: Refactor DataConnectionForm.razor", "status": "completed", "blockedBy": [53, 54]},
|
||||
{"id": 56, "subject": "Task 12: Solution build + all test suites green", "status": "completed", "blockedBy": [50, 51, 55]},
|
||||
{"id": 57, "subject": "Task 13: Docker deploy + browser smoke", "status": "completed", "blockedBy": [56]},
|
||||
{"id": 58, "subject": "Task 14: Push to origin", "status": "completed", "blockedBy": [57]}
|
||||
],
|
||||
"lastUpdated": "2026-05-12T04:33:33Z"
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"lastUpdated": "2026-08-01"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
**Scope:** All Razor pages, layout, and shared components in `src/ZB.MOM.WW.ScadaBridge.CentralUI`.
|
||||
**Reference pattern:** `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor` — 2-column responsive card grid, header flex row, kebab menus, search filter, Bootstrap collapse for noisy details, `@key=` on iterated cards, "No X match the filter." and empty-state CTAs.
|
||||
|
||||
> **2026-08-01: fully superseded** — every cross-cutting recommendation was either shipped or explicitly scoped into the M10 UI/UX platform plan (`2026-06-18`) and KPI History (#26); retained for reference only.
|
||||
|
||||
## Constraints (recap)
|
||||
|
||||
- Blazor Server + Bootstrap 5 only. **No third-party component frameworks** (no MudBlazor / Radzen / Blazorise / Syncfusion).
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-16-expression-trigger.md",
|
||||
"tasks": [
|
||||
{"id": 25, "subject": "Task 1: Trigger model + codecs", "status": "pending"},
|
||||
{"id": 26, "subject": "Task 2: Runtime expression evaluation", "status": "pending", "blockedBy": [25]},
|
||||
{"id": 27, "subject": "Task 3: Trigger editor panels", "status": "pending", "blockedBy": [25]},
|
||||
{"id": 28, "subject": "Task 4: Pre-deployment validation", "status": "pending", "blockedBy": [25, 26]},
|
||||
{"id": 29, "subject": "Task 5: Build, deploy, verify", "status": "pending", "blockedBy": [25, 26, 27, 28]}
|
||||
{"id": 25, "subject": "Task 1: Trigger model + codecs", "status": "completed"},
|
||||
{"id": 26, "subject": "Task 2: Runtime expression evaluation", "status": "completed", "blockedBy": [25]},
|
||||
{"id": 27, "subject": "Task 3: Trigger editor panels", "status": "completed", "blockedBy": [25]},
|
||||
{"id": 28, "subject": "Task 4: Pre-deployment validation", "status": "completed", "blockedBy": [25, 26]},
|
||||
{"id": 29, "subject": "Task 5: Build, deploy, verify", "status": "completed", "blockedBy": [25, 26, 27, 28]}
|
||||
],
|
||||
"lastUpdated": "2026-05-16"
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"lastUpdated": "2026-08-01"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-18-notification-outbox.md",
|
||||
"tasks": [
|
||||
{"id": 7, "subject": "Task 1: Create Component-NotificationOutbox.md", "status": "pending"},
|
||||
{"id": 8, "subject": "Task 2: Revise Component-NotificationService.md", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 3: Revise Component-StoreAndForward.md", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 10, "subject": "Task 4: Revise Component-HealthMonitoring.md", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 11, "subject": "Task 5: Revise Component-SiteEventLogging.md", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 12, "subject": "Task 6: Revise Component-Communication.md", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 13, "subject": "Task 7: Revise Component-CentralUI.md", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 14, "subject": "Task 8: Revise Component-ConfigurationDatabase.md and Component-Commons.md", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 15, "subject": "Task 9: Update README.md", "status": "pending", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14]},
|
||||
{"id": 16, "subject": "Task 10: Update CLAUDE.md", "status": "pending", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14]},
|
||||
{"id": 17, "subject": "Task 11: Cross-reference consistency sweep", "status": "pending", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14, 15, 16]}
|
||||
{"id": 7, "subject": "Task 1: Create Component-NotificationOutbox.md", "status": "completed"},
|
||||
{"id": 8, "subject": "Task 2: Revise Component-NotificationService.md", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 3: Revise Component-StoreAndForward.md", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 10, "subject": "Task 4: Revise Component-HealthMonitoring.md", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 11, "subject": "Task 5: Revise Component-SiteEventLogging.md", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 12, "subject": "Task 6: Revise Component-Communication.md", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 13, "subject": "Task 7: Revise Component-CentralUI.md", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 14, "subject": "Task 8: Revise Component-ConfigurationDatabase.md and Component-Commons.md", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 15, "subject": "Task 9: Update README.md", "status": "completed", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14]},
|
||||
{"id": 16, "subject": "Task 10: Update CLAUDE.md", "status": "completed", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14]},
|
||||
{"id": 17, "subject": "Task 11: Cross-reference consistency sweep", "status": "completed", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14, 15, 16]}
|
||||
],
|
||||
"lastUpdated": "2026-05-18"
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"lastUpdated": "2026-08-01"
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-19-cached-call-tracking.md",
|
||||
"tasks": [
|
||||
{"id": 6, "subject": "Task 1: Create Site Call Audit component doc", "status": "pending"},
|
||||
{"id": 7, "subject": "Task 2: Add tracking contracts to Commons", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 3: Update Store-and-Forward doc", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 9, "subject": "Task 4: Update External System Gateway doc", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 10, "subject": "Task 5: Update Site Runtime Script Runtime API", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 11, "subject": "Task 6: Update Communication doc", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 12, "subject": "Task 7: Update Configuration Database doc", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 13, "subject": "Task 8: Update Central UI doc", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 14, "subject": "Task 9: Update Health Monitoring doc", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 15, "subject": "Task 10: Note shared model in notification docs", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 16, "subject": "Task 11: Update README component table", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 17, "subject": "Task 12: Update CLAUDE.md", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 18, "subject": "Task 13: Final cross-reference consistency pass", "status": "pending", "blockedBy": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]}
|
||||
{"id": 6, "subject": "Task 1: Create Site Call Audit component doc", "status": "completed"},
|
||||
{"id": 7, "subject": "Task 2: Add tracking contracts to Commons", "status": "completed", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 3: Update Store-and-Forward doc", "status": "completed", "blockedBy": [6, 7]},
|
||||
{"id": 9, "subject": "Task 4: Update External System Gateway doc", "status": "completed", "blockedBy": [6, 7]},
|
||||
{"id": 10, "subject": "Task 5: Update Site Runtime Script Runtime API", "status": "completed", "blockedBy": [6, 7]},
|
||||
{"id": 11, "subject": "Task 6: Update Communication doc", "status": "completed", "blockedBy": [6, 7]},
|
||||
{"id": 12, "subject": "Task 7: Update Configuration Database doc", "status": "completed", "blockedBy": [6, 7]},
|
||||
{"id": 13, "subject": "Task 8: Update Central UI doc", "status": "completed", "blockedBy": [6, 7]},
|
||||
{"id": 14, "subject": "Task 9: Update Health Monitoring doc", "status": "completed", "blockedBy": [6, 7]},
|
||||
{"id": 15, "subject": "Task 10: Note shared model in notification docs", "status": "completed", "blockedBy": [6, 7]},
|
||||
{"id": 16, "subject": "Task 11: Update README component table", "status": "completed", "blockedBy": [6]},
|
||||
{"id": 17, "subject": "Task 12: Update CLAUDE.md", "status": "completed", "blockedBy": [6]},
|
||||
{"id": 18, "subject": "Task 13: Final cross-reference consistency pass", "status": "completed", "blockedBy": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]}
|
||||
],
|
||||
"lastUpdated": "2026-05-19"
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"lastUpdated": "2026-08-01"
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-19-notification-outbox-implementation.md",
|
||||
"tasks": [
|
||||
{"id": 18, "subject": "Task 1: Notification enums", "status": "pending"},
|
||||
{"id": 19, "subject": "Task 2: Notification entity POCO", "status": "pending", "blockedBy": [18]},
|
||||
{"id": 20, "subject": "Task 3: Type field on NotificationList", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 4: Notification EF configuration + DbSet", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 22, "subject": "Task 5: NotificationOutbox repository", "status": "pending", "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 6: EF migration AddNotificationsTable", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "Task 7: Site/central notification message contracts", "status": "pending", "blockedBy": [23]},
|
||||
{"id": 25, "subject": "Task 8: Outbox query/action contracts", "status": "pending", "blockedBy": [24]},
|
||||
{"id": 26, "subject": "Task 9: Scaffold ZB.MOM.WW.ScadaBridge.NotificationOutbox project", "status": "pending", "blockedBy": [25]},
|
||||
{"id": 27, "subject": "Task 10: NotificationOutboxOptions", "status": "pending", "blockedBy": [26]},
|
||||
{"id": 28, "subject": "Task 11: Delivery adapter abstraction", "status": "pending", "blockedBy": [27]},
|
||||
{"id": 29, "subject": "Task 12: Email delivery adapter", "status": "pending", "blockedBy": [28]},
|
||||
{"id": 30, "subject": "Task 13: NotificationOutboxActor ingest", "status": "pending", "blockedBy": [29]},
|
||||
{"id": 31, "subject": "Task 14: Dispatcher loop", "status": "pending", "blockedBy": [30]},
|
||||
{"id": 32, "subject": "Task 15: Query, retry, discard, KPI handlers", "status": "pending", "blockedBy": [31]},
|
||||
{"id": 33, "subject": "Task 16: Daily purge job", "status": "pending", "blockedBy": [32]},
|
||||
{"id": 34, "subject": "Task 17: AddNotificationOutbox DI extension", "status": "pending", "blockedBy": [33]},
|
||||
{"id": 35, "subject": "Task 18: Retarget site S&F notification handler to central", "status": "pending", "blockedBy": [34]},
|
||||
{"id": 36, "subject": "Task 19: Async Notify.Send + Notify.Status", "status": "pending", "blockedBy": [35]},
|
||||
{"id": 37, "subject": "Task 20: Central ingest routing", "status": "pending", "blockedBy": [36]},
|
||||
{"id": 38, "subject": "Task 21: Host registration + appsettings", "status": "pending", "blockedBy": [37]},
|
||||
{"id": 39, "subject": "Task 22: CommunicationService outbox methods", "status": "pending", "blockedBy": [38]},
|
||||
{"id": 40, "subject": "Task 23: Notification Outbox Blazor page", "status": "pending", "blockedBy": [39]},
|
||||
{"id": 41, "subject": "Task 24: Health dashboard outbox KPI tiles", "status": "pending", "blockedBy": [40]},
|
||||
{"id": 42, "subject": "Task 25: End-to-end integration test", "status": "pending", "blockedBy": [41]},
|
||||
{"id": 43, "subject": "Task 26: Full build + suite verification", "status": "pending", "blockedBy": [42]}
|
||||
{"id": 18, "subject": "Task 1: Notification enums", "status": "completed"},
|
||||
{"id": 19, "subject": "Task 2: Notification entity POCO", "status": "completed", "blockedBy": [18]},
|
||||
{"id": 20, "subject": "Task 3: Type field on NotificationList", "status": "completed", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 4: Notification EF configuration + DbSet", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 22, "subject": "Task 5: NotificationOutbox repository", "status": "completed", "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 6: EF migration AddNotificationsTable", "status": "completed", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "Task 7: Site/central notification message contracts", "status": "completed", "blockedBy": [23]},
|
||||
{"id": 25, "subject": "Task 8: Outbox query/action contracts", "status": "completed", "blockedBy": [24]},
|
||||
{"id": 26, "subject": "Task 9: Scaffold ZB.MOM.WW.ScadaBridge.NotificationOutbox project", "status": "completed", "blockedBy": [25]},
|
||||
{"id": 27, "subject": "Task 10: NotificationOutboxOptions", "status": "completed", "blockedBy": [26]},
|
||||
{"id": 28, "subject": "Task 11: Delivery adapter abstraction", "status": "completed", "blockedBy": [27]},
|
||||
{"id": 29, "subject": "Task 12: Email delivery adapter", "status": "completed", "blockedBy": [28]},
|
||||
{"id": 30, "subject": "Task 13: NotificationOutboxActor ingest", "status": "completed", "blockedBy": [29]},
|
||||
{"id": 31, "subject": "Task 14: Dispatcher loop", "status": "completed", "blockedBy": [30]},
|
||||
{"id": 32, "subject": "Task 15: Query, retry, discard, KPI handlers", "status": "completed", "blockedBy": [31]},
|
||||
{"id": 33, "subject": "Task 16: Daily purge job", "status": "completed", "blockedBy": [32]},
|
||||
{"id": 34, "subject": "Task 17: AddNotificationOutbox DI extension", "status": "completed", "blockedBy": [33]},
|
||||
{"id": 35, "subject": "Task 18: Retarget site S&F notification handler to central", "status": "completed", "blockedBy": [34]},
|
||||
{"id": 36, "subject": "Task 19: Async Notify.Send + Notify.Status", "status": "completed", "blockedBy": [35]},
|
||||
{"id": 37, "subject": "Task 20: Central ingest routing", "status": "completed", "blockedBy": [36]},
|
||||
{"id": 38, "subject": "Task 21: Host registration + appsettings", "status": "completed", "blockedBy": [37]},
|
||||
{"id": 39, "subject": "Task 22: CommunicationService outbox methods", "status": "completed", "blockedBy": [38]},
|
||||
{"id": 40, "subject": "Task 23: Notification Outbox Blazor page", "status": "completed", "blockedBy": [39]},
|
||||
{"id": 41, "subject": "Task 24: Health dashboard outbox KPI tiles", "status": "completed", "blockedBy": [40]},
|
||||
{"id": 42, "subject": "Task 25: End-to-end integration test", "status": "completed", "blockedBy": [41]},
|
||||
{"id": 43, "subject": "Task 26: Full build + suite verification", "status": "completed", "blockedBy": [42]}
|
||||
],
|
||||
"lastUpdated": "2026-05-19"
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"lastUpdated": "2026-08-01"
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
> for site roles, with `NoOpSiteStreamAuditClient` retained only for central/test
|
||||
> composition roots; and `AuditLogQueryFilter` is now multi-value per dimension.
|
||||
>
|
||||
> **Transport wording below is stale:** the ClusterClient-based push described throughout this roadmap was replaced by gRPC in the 2026-07-22 ClusterClient→gRPC migration, and `ClusterClientSiteAuditClient` is now `SiteCommunicationAuditClient` (commit `63c16d69`).
|
||||
>
|
||||
> **For Claude:** REQUIRED SUB-SKILL FLOW per milestone: `brainstorming` → `writing-plans` → `subagent-driven-development`. Use `docs/requirements/Component-AuditLog.md` + `alog.md` as the spec; this document is the roadmap that sequences milestones and locks acceptance criteria for each. **M1 carries full TDD-level task detail; M2–M8 are milestone-shape detail and will be expanded into bite-sized plans by their own writing-plans pass when their turn comes.**
|
||||
|
||||
**Goal:** Implement central component #23 Audit Log — append-only forensic + operational record across every script-trust-boundary action — into the existing ScadaBridge codebase.
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-20-auditlog-m1-foundation.md",
|
||||
"tasks": [
|
||||
{"id": "A1", "subject": "Bundle A T1: Add audit enums (Channel, Kind, Status, ForwardState)", "status": "pending"},
|
||||
{"id": "A2", "subject": "Bundle A T2: Add AuditEvent record", "status": "pending", "blockedBy": ["A1"]},
|
||||
{"id": "A3", "subject": "Bundle A T3: Add IAuditWriter + ICentralAuditWriter", "status": "pending", "blockedBy": ["A2"]},
|
||||
{"id": "A4", "subject": "Bundle A T4: Add audit telemetry + pull message DTOs", "status": "pending", "blockedBy": ["A2"]},
|
||||
{"id": "A-rev", "subject": "Bundle A combined spec+quality review", "status": "pending", "blockedBy": ["A1", "A2", "A3", "A4"]},
|
||||
{"id": "B5", "subject": "Bundle B T5: ScadaBridgeDbContext.AuditLogs + IEntityTypeConfiguration<AuditEvent> with five named indexes", "status": "pending", "blockedBy": ["A-rev"]},
|
||||
{"id": "B-rev", "subject": "Bundle B review", "status": "pending", "blockedBy": ["B5"]},
|
||||
{"id": "C67", "subject": "Bundle C T6+T7: AddAuditLogTable migration (partition fn/scheme/table/indexes) + DB roles, with infra/mssql integration tests", "status": "pending", "blockedBy": ["B-rev"]},
|
||||
{"id": "C-rev", "subject": "Bundle C review", "status": "pending", "blockedBy": ["C67"]},
|
||||
{"id": "D8", "subject": "Bundle D T8: IAuditLogRepository + EF implementation + DI registration", "status": "pending", "blockedBy": ["C-rev"]},
|
||||
{"id": "D-rev", "subject": "Bundle D review", "status": "pending", "blockedBy": ["D8"]},
|
||||
{"id": "E10", "subject": "Bundle E T10: Scaffold src/ZB.MOM.WW.ScadaBridge.AuditLog/ project + slnx entries", "status": "pending", "blockedBy": ["D-rev"]},
|
||||
{"id": "E9", "subject": "Bundle E T9: AuditLogOptions + validator", "status": "pending", "blockedBy": ["E10"]},
|
||||
{"id": "E-rev", "subject": "Bundle E review", "status": "pending", "blockedBy": ["E10", "E9"]},
|
||||
{"id": "F11", "subject": "Bundle F T11 (controller-direct): Register ZB.MOM.WW.ScadaBridge.AuditLog in Component-Host.md + README confirm", "status": "pending", "blockedBy": ["E-rev"]},
|
||||
{"id": "FINAL-rev", "subject": "Final cross-bundle review over the whole M1 branch", "status": "pending", "blockedBy": ["F11"]},
|
||||
{"id": "MERGE", "subject": "Verify gate: full solution dotnet test green, then merge --no-ff to main", "status": "pending", "blockedBy": ["FINAL-rev"]},
|
||||
{"id": "ROADMAP", "subject": "Update downstream M2-M8 sections of roadmap with realities learned in M1", "status": "pending", "blockedBy": ["MERGE"]}
|
||||
{"id": "A1", "subject": "Bundle A T1: Add audit enums (Channel, Kind, Status, ForwardState)", "status": "completed"},
|
||||
{"id": "A2", "subject": "Bundle A T2: Add AuditEvent record", "status": "completed", "blockedBy": ["A1"]},
|
||||
{"id": "A3", "subject": "Bundle A T3: Add IAuditWriter + ICentralAuditWriter", "status": "completed", "blockedBy": ["A2"]},
|
||||
{"id": "A4", "subject": "Bundle A T4: Add audit telemetry + pull message DTOs", "status": "completed", "blockedBy": ["A2"]},
|
||||
{"id": "A-rev", "subject": "Bundle A combined spec+quality review", "status": "completed", "blockedBy": ["A1", "A2", "A3", "A4"]},
|
||||
{"id": "B5", "subject": "Bundle B T5: ScadaBridgeDbContext.AuditLogs + IEntityTypeConfiguration<AuditEvent> with five named indexes", "status": "completed", "blockedBy": ["A-rev"]},
|
||||
{"id": "B-rev", "subject": "Bundle B review", "status": "completed", "blockedBy": ["B5"]},
|
||||
{"id": "C67", "subject": "Bundle C T6+T7: AddAuditLogTable migration (partition fn/scheme/table/indexes) + DB roles, with infra/mssql integration tests", "status": "completed", "blockedBy": ["B-rev"]},
|
||||
{"id": "C-rev", "subject": "Bundle C review", "status": "completed", "blockedBy": ["C67"]},
|
||||
{"id": "D8", "subject": "Bundle D T8: IAuditLogRepository + EF implementation + DI registration", "status": "completed", "blockedBy": ["C-rev"]},
|
||||
{"id": "D-rev", "subject": "Bundle D review", "status": "completed", "blockedBy": ["D8"]},
|
||||
{"id": "E10", "subject": "Bundle E T10: Scaffold src/ZB.MOM.WW.ScadaBridge.AuditLog/ project + slnx entries", "status": "completed", "blockedBy": ["D-rev"]},
|
||||
{"id": "E9", "subject": "Bundle E T9: AuditLogOptions + validator", "status": "completed", "blockedBy": ["E10"]},
|
||||
{"id": "E-rev", "subject": "Bundle E review", "status": "completed", "blockedBy": ["E10", "E9"]},
|
||||
{"id": "F11", "subject": "Bundle F T11 (controller-direct): Register ZB.MOM.WW.ScadaBridge.AuditLog in Component-Host.md + README confirm", "status": "completed", "blockedBy": ["E-rev"]},
|
||||
{"id": "FINAL-rev", "subject": "Final cross-bundle review over the whole M1 branch", "status": "completed", "blockedBy": ["F11"]},
|
||||
{"id": "MERGE", "subject": "Verify gate: full solution dotnet test green, then merge --no-ff to main", "status": "completed", "blockedBy": ["FINAL-rev"]},
|
||||
{"id": "ROADMAP", "subject": "Update downstream M2-M8 sections of roadmap with realities learned in M1", "status": "completed", "blockedBy": ["MERGE"]}
|
||||
],
|
||||
"lastUpdated": "2026-05-20T00:00:00Z"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -3,24 +3,25 @@
|
||||
"spec": "alog.md (commit fec0bb1)",
|
||||
"repoNature": "design-documentation-only",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Prepare branch", "status": "pending", "blockedBy": []},
|
||||
{"id": 1, "subject": "Task 1: Author Component-AuditLog.md", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: Update Component-Commons.md", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 3, "subject": "Task 3: Update Component-ConfigurationDatabase.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4: Update Component-ClusterInfrastructure.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "subject": "Task 5: Update Component-SiteRuntime.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 6, "subject": "Task 6: Update Component-ExternalSystemGateway.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 7, "subject": "Task 7: Update Component-SiteCallAudit.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 8, "subject": "Task 8: Update Component-NotificationOutbox.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 9, "subject": "Task 9: Update Component-InboundAPI.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 10, "subject": "Task 10: Update Component-CentralUI.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 11, "subject": "Task 11: Update Component-HealthMonitoring.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 12, "subject": "Task 12: Update Component-CLI.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 13, "subject": "Task 13: Update README.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 14, "subject": "Task 14: Update HighLevelReqs.md", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 15, "subject": "Task 15: Update CLAUDE.md", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]},
|
||||
{"id": 16, "subject": "Task 16: Final cross-reference verification", "status": "pending", "blockedBy": [15]},
|
||||
{"id": 17, "subject": "Task 17: Merge to main (user-gated)", "status": "pending", "blockedBy": [16]}
|
||||
{"id": 0, "subject": "Task 0: Prepare branch", "status": "completed", "blockedBy": []},
|
||||
{"id": 1, "subject": "Task 1: Author Component-AuditLog.md", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: Update Component-Commons.md", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 3, "subject": "Task 3: Update Component-ConfigurationDatabase.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4: Update Component-ClusterInfrastructure.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 5, "subject": "Task 5: Update Component-SiteRuntime.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 6, "subject": "Task 6: Update Component-ExternalSystemGateway.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 7, "subject": "Task 7: Update Component-SiteCallAudit.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 8, "subject": "Task 8: Update Component-NotificationOutbox.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 9, "subject": "Task 9: Update Component-InboundAPI.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 10, "subject": "Task 10: Update Component-CentralUI.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 11, "subject": "Task 11: Update Component-HealthMonitoring.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 12, "subject": "Task 12: Update Component-CLI.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 13, "subject": "Task 13: Update README.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 14, "subject": "Task 14: Update HighLevelReqs.md", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 15, "subject": "Task 15: Update CLAUDE.md", "status": "completed", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]},
|
||||
{"id": 16, "subject": "Task 16: Final cross-reference verification", "status": "completed", "blockedBy": [15]},
|
||||
{"id": 17, "subject": "Task 17: Merge to main (user-gated)", "status": "completed", "blockedBy": [16]}
|
||||
],
|
||||
"lastUpdated": "2026-05-20T00:00:00Z"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
|
||||
"tasks": [
|
||||
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
|
||||
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]},
|
||||
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]},
|
||||
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]},
|
||||
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]},
|
||||
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]},
|
||||
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]}
|
||||
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "completed"},
|
||||
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "completed", "blockedBy": [50]},
|
||||
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "completed", "blockedBy": [51]},
|
||||
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "completed", "blockedBy": [52]},
|
||||
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "completed", "blockedBy": [53]},
|
||||
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "completed", "blockedBy": [52]},
|
||||
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "completed", "blockedBy": [52]},
|
||||
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "completed", "blockedBy": [51]},
|
||||
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "completed", "blockedBy": [51]},
|
||||
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "completed", "blockedBy": [53, 54, 55, 56, 57, 58]}
|
||||
],
|
||||
"lastUpdated": "2026-05-21T00:00:00Z"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-21-audit-parent-executionid.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: Foundation — AuditEvent.ParentExecutionId + central AuditLog column", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Inbound request id minting + RouteToCallRequest.ParentExecutionId", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 4, "subject": "Task 4: Thread ParentExecutionId into routed script ScriptRuntimeContext", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Site script-side emitters stamp ParentExecutionId", "status": "pending", "blockedBy": [4, 2]},
|
||||
{"id": 6, "subject": "Task 6: Cached S&F retry-loop rows carry ParentExecutionId", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: Central NotifyDeliver rows carry ParentExecutionId", "status": "pending", "blockedBy": [5, 1]},
|
||||
{"id": 8, "subject": "Task 8: Repository — GetExecutionTreeAsync", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 9, "subject": "Task 9: Central UI — ParentExecutionId column, filter, parent drill-in", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 10, "subject": "Task 10: Central UI — execution-chain tree view", "status": "pending", "blockedBy": [8, 9]},
|
||||
{"id": 11, "subject": "Task 11: CLI + ManagementService — ParentExecutionId filter", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 12, "subject": "Task 12: End-to-end integration test + docs", "status": "pending", "blockedBy": [5, 6, 7, 10, 11]}
|
||||
{"id": 0, "subject": "Task 0: Prep — verify branch + baseline", "status": "completed"},
|
||||
{"id": 1, "subject": "Task 1: Foundation — AuditEvent.ParentExecutionId + central AuditLog column", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Inbound request id minting + RouteToCallRequest.ParentExecutionId", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 4, "subject": "Task 4: Thread ParentExecutionId into routed script ScriptRuntimeContext", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Site script-side emitters stamp ParentExecutionId", "status": "completed", "blockedBy": [4, 2]},
|
||||
{"id": 6, "subject": "Task 6: Cached S&F retry-loop rows carry ParentExecutionId", "status": "completed", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: Central NotifyDeliver rows carry ParentExecutionId", "status": "completed", "blockedBy": [5, 1]},
|
||||
{"id": 8, "subject": "Task 8: Repository — GetExecutionTreeAsync", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 9, "subject": "Task 9: Central UI — ParentExecutionId column, filter, parent drill-in", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 10, "subject": "Task 10: Central UI — execution-chain tree view", "status": "completed", "blockedBy": [8, 9]},
|
||||
{"id": 11, "subject": "Task 11: CLI + ManagementService — ParentExecutionId filter", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 12, "subject": "Task 12: End-to-end integration test + docs", "status": "completed", "blockedBy": [5, 6, 7, 10, 11]}
|
||||
],
|
||||
"lastUpdated": "2026-05-21"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -929,12 +929,12 @@ git commit -m "chore(audit): smoke-verify SourceNode end-to-end across cluster"
|
||||
|
||||
## Acceptance Criteria (the whole-plan checklist)
|
||||
|
||||
- [ ] Every audit row written from this commit forward carries `SourceNode` populated (`central-a/b` for central direct-write, `node-a/b` for site rows).
|
||||
- [ ] Every new `Notifications` and `SiteCalls` row carries `SourceNode` from the site.
|
||||
- [ ] `IX_AuditLog_Node_Occurred` exists in central MS SQL.
|
||||
- [ ] Site SQLite `AuditLog` and `OperationTracking` tables both have `SourceNode TEXT NULL`; existing site DBs are upgraded idempotently on startup.
|
||||
- [ ] Proto `AuditEventDto.source_node = 22` and `SiteCallOperationalDto.source_node = 12` exist; no field numbers reused.
|
||||
- [ ] Central UI Audit Log, Notifications, and Site Calls pages all display a "Node" column and support filtering by it.
|
||||
- [ ] All test projects green.
|
||||
- [ ] Cluster comes up clean via `bash docker/deploy.sh`; CLI smoke confirms expected node names land in the central tables.
|
||||
- [ ] Design docs (already committed in Task 0) match the implementation.
|
||||
- [x] Every audit row written from this commit forward carries `SourceNode` populated (`central-a/b` for central direct-write, `node-a/b` for site rows).
|
||||
- [x] Every new `Notifications` and `SiteCalls` row carries `SourceNode` from the site.
|
||||
- [x] `IX_AuditLog_Node_Occurred` exists in central MS SQL.
|
||||
- [x] Site SQLite `AuditLog` and `OperationTracking` tables both have `SourceNode TEXT NULL`; existing site DBs are upgraded idempotently on startup.
|
||||
- [x] Proto `AuditEventDto.source_node = 22` and `SiteCallOperationalDto.source_node = 12` exist; no field numbers reused.
|
||||
- [x] Central UI Audit Log, Notifications, and Site Calls pages all display a "Node" column and support filtering by it.
|
||||
- [x] All test projects green.
|
||||
- [x] Cluster comes up clean via `bash docker/deploy.sh`; CLI smoke confirms expected node names land in the central tables. (`NodeName` is now startup-validated — an empty value fails fast rather than NULLing `SourceNode`, commit `4a0462e4` — and the rig has redeployed many times since.)
|
||||
- [x] Design docs (already committed in Task 0) match the implementation.
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-23-audit-source-node.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 0: Branch + Snapshot", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 1: NodeOptions.NodeName + INodeIdentityProvider", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 2: Add SourceNode to AuditEvent record", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 3: Add SourceNode to SiteCallOperational + SiteCall entity", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 5, "subject": "Task 4: Add SourceNode to Notification entity + NotificationSubmit", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 6, "subject": "Task 5: Add source_node to proto + update DTO mappers", "status": "pending", "blockedBy": [3, 4]},
|
||||
{"id": 7, "subject": "Task 6: EF migration — SourceNode on AuditLog + IX_AuditLog_Node_Occurred", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 8, "subject": "Task 7: EF migration — SourceNode on Notifications", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 9, "subject": "Task 8: EF migration — SourceNode on SiteCalls", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 10, "subject": "Task 9: Site SQLite AuditLog — add SourceNode (idempotent upgrade)", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 11, "subject": "Task 10: Site SQLite OperationTracking — add SourceNode", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 12, "subject": "Task 11: Stamp SourceNode at site SqliteAuditWriter", "status": "pending", "blockedBy": [2, 10]},
|
||||
{"id": 13, "subject": "Task 12: Stamp SourceNode at CentralAuditWriter + persist via repo", "status": "pending", "blockedBy": [2, 7]},
|
||||
{"id": 14, "subject": "Task 13: Carry SourceNode through Notifications S&F handoff", "status": "pending", "blockedBy": [2, 5, 8]},
|
||||
{"id": 15, "subject": "Task 14: Carry SourceNode through cached-call telemetry → SiteCalls", "status": "pending", "blockedBy": [2, 9, 11]},
|
||||
{"id": 16, "subject": "Task 15: UI — Node column + filter on AuditLog grid", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 17, "subject": "Task 16: UI — Node column + filter on Notifications grid", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 18, "subject": "Task 17: UI — Node column + filter on SiteCalls grid", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 19, "subject": "Task 18: Docker appsettings — NodeName on all 8 nodes", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 20, "subject": "Task 19: Full build + targeted test sweep", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 19]},
|
||||
{"id": 21, "subject": "Task 20: Docker redeploy + smoke verify", "status": "pending", "blockedBy": [20]}
|
||||
{"id": 1, "subject": "Task 0: Branch + Snapshot", "status": "completed"},
|
||||
{"id": 2, "subject": "Task 1: NodeOptions.NodeName + INodeIdentityProvider", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 2: Add SourceNode to AuditEvent record", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 3: Add SourceNode to SiteCallOperational + SiteCall entity", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 5, "subject": "Task 4: Add SourceNode to Notification entity + NotificationSubmit", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 6, "subject": "Task 5: Add source_node to proto + update DTO mappers", "status": "completed", "blockedBy": [3, 4]},
|
||||
{"id": 7, "subject": "Task 6: EF migration — SourceNode on AuditLog + IX_AuditLog_Node_Occurred", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 8, "subject": "Task 7: EF migration — SourceNode on Notifications", "status": "completed", "blockedBy": [5]},
|
||||
{"id": 9, "subject": "Task 8: EF migration — SourceNode on SiteCalls", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 10, "subject": "Task 9: Site SQLite AuditLog — add SourceNode (idempotent upgrade)", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 11, "subject": "Task 10: Site SQLite OperationTracking — add SourceNode", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 12, "subject": "Task 11: Stamp SourceNode at site SqliteAuditWriter", "status": "completed", "blockedBy": [2, 10]},
|
||||
{"id": 13, "subject": "Task 12: Stamp SourceNode at CentralAuditWriter + persist via repo", "status": "completed", "blockedBy": [2, 7]},
|
||||
{"id": 14, "subject": "Task 13: Carry SourceNode through Notifications S&F handoff", "status": "completed", "blockedBy": [2, 5, 8]},
|
||||
{"id": 15, "subject": "Task 14: Carry SourceNode through cached-call telemetry → SiteCalls", "status": "completed", "blockedBy": [2, 9, 11]},
|
||||
{"id": 16, "subject": "Task 15: UI — Node column + filter on AuditLog grid", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 17, "subject": "Task 16: UI — Node column + filter on Notifications grid", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 18, "subject": "Task 17: UI — Node column + filter on SiteCalls grid", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 19, "subject": "Task 18: Docker appsettings — NodeName on all 8 nodes", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 20, "subject": "Task 19: Full build + targeted test sweep", "status": "completed", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 19]},
|
||||
{"id": 21, "subject": "Task 20: Docker redeploy + smoke verify", "status": "completed", "blockedBy": [20]}
|
||||
],
|
||||
"lastUpdated": "2026-05-23T00:00:00Z"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -132,17 +132,17 @@ same "options validation" path used for other AuditLog settings.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `AuditLog:InboundMaxBytes` option exists on the AuditLog options class,
|
||||
- [x] `AuditLog:InboundMaxBytes` option exists on the AuditLog options class,
|
||||
with the documented default and bounds, validated at startup.
|
||||
- [ ] Inbound request middleware writes `RequestSummary` and `ResponseSummary`
|
||||
- [x] Inbound request middleware writes `RequestSummary` and `ResponseSummary`
|
||||
using the inbound ceiling instead of the 8 KB / 64 KB defaults.
|
||||
- [ ] Other channels' rows (e.g. an `ApiOutbound.ApiCall` over the limit) still
|
||||
- [x] Other channels' rows (e.g. an `ApiOutbound.ApiCall` over the limit) still
|
||||
truncate at 8 KB (64 KB on error rows) — regression-tested.
|
||||
- [ ] `PayloadTruncated = 1` on an inbound row iff request body or response
|
||||
- [x] `PayloadTruncated = 1` on an inbound row iff request body or response
|
||||
body exceeded `InboundMaxBytes`.
|
||||
- [ ] Header redaction list and per-target body redactors still apply to
|
||||
- [x] Header redaction list and per-target body redactors still apply to
|
||||
inbound rows.
|
||||
- [ ] Redactor failure on an inbound row still produces `<redacted: redactor
|
||||
- [x] Redactor failure on an inbound row still produces `<redacted: redactor
|
||||
error>` and increments `AuditRedactionFailure`.
|
||||
- [ ] `Component-AuditLog.md` and `Component-InboundAPI.md` updated as
|
||||
- [x] `Component-AuditLog.md` and `Component-InboundAPI.md` updated as
|
||||
described in **Doc Edits**.
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-23-inbound-api-full-response-audit.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 0: Prep — branch, baseline build", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 1: Add InboundMaxBytes to AuditLogOptions (TDD)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 2: Wire InboundMaxBytes into DefaultAuditPayloadFilter (TDD)", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 3: Capture response body in AuditWriteMiddleware (TDD)", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 4: Update Component-AuditLog.md", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 5: Update Component-InboundAPI.md", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 6: Final build + full test run + branch summary", "status": "pending", "blockedBy": [6]}
|
||||
{"id": 1, "subject": "Task 0: Prep — branch, baseline build", "status": "completed"},
|
||||
{"id": 2, "subject": "Task 1: Add InboundMaxBytes to AuditLogOptions (TDD)", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 2: Wire InboundMaxBytes into DefaultAuditPayloadFilter (TDD)", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 3: Capture response body in AuditWriteMiddleware (TDD)", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 4: Update Component-AuditLog.md", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 5: Update Component-InboundAPI.md", "status": "completed", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 6: Final build + full test run + branch summary", "status": "completed", "blockedBy": [6]}
|
||||
],
|
||||
"lastUpdated": "2026-05-23"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Env2 + Transport Manual Verification Checklist
|
||||
|
||||
> **RETIRED 2026-08-01 — never run as written; superseded by automated suites + a live cross-environment run.**
|
||||
> The step list below predates the M8 import-wizard Map step and was never annotated. Instead of
|
||||
> refreshing ~48 stale steps, the core scenario this checklist exists for was run live on 2026-08-01
|
||||
> (rig session, task #12): CLI `bundle export` from the primary cluster (encrypted, `--include-dependencies`,
|
||||
> template closure + shared scripts + external system + site-a + instances) → `bundle preview` against the
|
||||
> empty env2 (correct Map requirements surfaced) → `bundle import --create-missing-sites
|
||||
> --create-missing-connections` into env2 → 20 entities added, secrets (`AuthConfiguration`) intact,
|
||||
> instances landed `NotDeployed`, connection FK wired to the created site. **The run caught a real bug**
|
||||
> (create-missing site id never materialised before the connection insert → FK 547 on real SQL Server,
|
||||
> masked by the in-memory test provider) — fixed the same day with an SQLite regression test
|
||||
> (`CreateMissingSiteRelationalTests`, commit `0c9dffed`). Env2 boot/PSK/LocalDb were separately proven
|
||||
> PASS 3/3 by Gitea #31 (2026-07-23). Remaining coverage lives in
|
||||
> `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests` (RoundTrip/RoundTripEquivalence/
|
||||
> ConflictResolution/ValidationFailure/SiteInstanceImport/CreateMissingSiteRelational). This checklist
|
||||
> is retired — do not run or refresh it.
|
||||
|
||||
**Date created:** 2026-05-24
|
||||
**Companion to:** [`2026-05-24-second-environment-design.md`](2026-05-24-second-environment-design.md), [`Component-Transport.md`](../requirements/Component-Transport.md)
|
||||
**Goal:** Exercise the Transport (#24) bundle export/import flow against two real running environments (primary + env2).
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Transport Manual Verification
|
||||
|
||||
> **RETIRED 2026-08-01 — never run as written; ~80% overlap with
|
||||
> `2026-05-24-second-environment-verification.md`, which carries the full retirement rationale.**
|
||||
> The steps predate the M8 import-wizard Map step (numbering stale). The export→preview→import flow
|
||||
> was proven live cross-environment on 2026-08-01 via the CLI (see the retirement banner in the env2
|
||||
> checklist), and the UI wizard surface is covered by the Transport Playwright/bUnit suites. Retired —
|
||||
> do not run or refresh.
|
||||
|
||||
This document is a one-time manual verification to be run against the docker cluster after the Transport feature is fully built and `bash docker/deploy.sh` has rebuilt the image. Each step is sequential and assumes the previous step succeeded. The entire flow takes approximately 15 minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -1758,6 +1758,16 @@ git commit -m "feat(centralui): add OPC UA browse button + override column to In
|
||||
|
||||
### Task 19: End-to-end smoke (manual)
|
||||
|
||||
> **PASS 2026-08-01 (online + offline)** — run live on the docker rig against a purpose-built
|
||||
> deployed instance (data-sourced Float attribute bound to site-a's "OPC PLC Simulator";
|
||||
> artifacts deleted after). Online: Browse dialog opened, root populated (Server visible),
|
||||
> expanded OpcPlc → Telemetry → Basic, selected `StepUp` — footer + override stored the durable
|
||||
> `nsu=http://microsoft.com/Opc/OpcPlc/;s=StepUp` form and it persisted across save + reload.
|
||||
> Offline: with BOTH site-a nodes stopped, Browse showed the error banner
|
||||
> (`StatusCode="Unavailable"` + Retry) with the manual-paste field still usable; a hand-typed
|
||||
> `ns=3;s=TestChildObject.TestInt` (bare-index advisory shown as designed) went Use → Select →
|
||||
> Save and persisted across reload while the site was down. Pair restarted staggered and rejoined.
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~5 min (manual)
|
||||
**Parallelizable with:** none (validates Tasks 1–18 together)
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-28-opcua-tag-browser.md",
|
||||
"tasks": [
|
||||
{"id": 70, "subject": "Task 1: Add DataSourceReferenceOverride to InstanceConnectionBinding entity", "status": "pending"},
|
||||
{"id": 71, "subject": "Task 2: Add override to ConnectionBinding wire record + ManagementActor mapping", "status": "pending"},
|
||||
{"id": 72, "subject": "Task 3: EF mapping for DataSourceReferenceOverride column", "status": "pending"},
|
||||
{"id": 73, "subject": "Task 4: EF Core migration AddInstanceConnectionBindingOverride", "status": "pending", "blockedBy": [72]},
|
||||
{"id": 74, "subject": "Task 5: IBrowsableDataConnection interface + BrowseNode types", "status": "pending"},
|
||||
{"id": 75, "subject": "Task 6: BrowseCommands.cs (BrowseOpcUaNodeCommand + result + failure)", "status": "pending"},
|
||||
{"id": 76, "subject": "Task 7: Add BrowseChildrenAsync to IOpcUaClient", "status": "pending", "blockedBy": [74, 75]},
|
||||
{"id": 77, "subject": "Task 8: Implement BrowseChildrenAsync on RealOpcUaClient", "status": "pending", "blockedBy": [76]},
|
||||
{"id": 78, "subject": "Task 9: Implement IBrowsableDataConnection on OpcUaDataConnection", "status": "pending", "blockedBy": [76]},
|
||||
{"id": 79, "subject": "Task 10: Handle BrowseOpcUaNodeCommand in DataConnectionManagerActor", "status": "pending", "blockedBy": [75, 76]},
|
||||
{"id": 80, "subject": "Task 11: Forward BrowseOpcUaNodeCommand in SiteCommunicationActor", "status": "pending", "blockedBy": [79]},
|
||||
{"id": 81, "subject": "Task 12: Apply override in FlatteningService.ApplyConnectionBindings", "status": "pending", "blockedBy": [70]},
|
||||
{"id": 82, "subject": "Task 13: Revision-hash regression test", "status": "pending", "blockedBy": [81]},
|
||||
{"id": 83, "subject": "Task 14: IOpcUaBrowseService + impl + DI registration", "status": "pending", "blockedBy": [75]},
|
||||
{"id": 84, "subject": "Task 15: Scaffold OpcUaBrowserDialog modal", "status": "pending"},
|
||||
{"id": 85, "subject": "Task 16: Tree rendering + lazy load + selection in dialog", "status": "pending", "blockedBy": [83, 84]},
|
||||
{"id": 86, "subject": "Task 17: Error banner mapping + polish on dialog", "status": "pending", "blockedBy": [85]},
|
||||
{"id": 87, "subject": "Task 18: Add Override column + Browse button to InstanceConfigure.razor", "status": "pending", "blockedBy": [83, 86]},
|
||||
{"id": 88, "subject": "Task 19: End-to-end manual smoke (online + offline)", "status": "pending", "blockedBy": [70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87]},
|
||||
{"id": 89, "subject": "Task 20: Update Component-DataConnectionLayer.md", "status": "pending", "blockedBy": [81]},
|
||||
{"id": 90, "subject": "Task 21: Update Component-TemplateEngine.md", "status": "pending", "blockedBy": [81]},
|
||||
{"id": 91, "subject": "Task 22: Update Component-CentralUI.md", "status": "pending", "blockedBy": [87]}
|
||||
{"id": 70, "subject": "Task 1: Add DataSourceReferenceOverride to InstanceConnectionBinding entity", "status": "completed"},
|
||||
{"id": 71, "subject": "Task 2: Add override to ConnectionBinding wire record + ManagementActor mapping", "status": "completed"},
|
||||
{"id": 72, "subject": "Task 3: EF mapping for DataSourceReferenceOverride column", "status": "completed"},
|
||||
{"id": 73, "subject": "Task 4: EF Core migration AddInstanceConnectionBindingOverride", "status": "completed", "blockedBy": [72]},
|
||||
{"id": 74, "subject": "Task 5: IBrowsableDataConnection interface + BrowseNode types", "status": "completed"},
|
||||
{"id": 75, "subject": "Task 6: BrowseCommands.cs (BrowseOpcUaNodeCommand + result + failure)", "status": "completed"},
|
||||
{"id": 76, "subject": "Task 7: Add BrowseChildrenAsync to IOpcUaClient", "status": "completed", "blockedBy": [74, 75]},
|
||||
{"id": 77, "subject": "Task 8: Implement BrowseChildrenAsync on RealOpcUaClient", "status": "completed", "blockedBy": [76]},
|
||||
{"id": 78, "subject": "Task 9: Implement IBrowsableDataConnection on OpcUaDataConnection", "status": "completed", "blockedBy": [76]},
|
||||
{"id": 79, "subject": "Task 10: Handle BrowseOpcUaNodeCommand in DataConnectionManagerActor", "status": "completed", "blockedBy": [75, 76]},
|
||||
{"id": 80, "subject": "Task 11: Forward BrowseOpcUaNodeCommand in SiteCommunicationActor", "status": "completed", "blockedBy": [79]},
|
||||
{"id": 81, "subject": "Task 12: Apply override in FlatteningService.ApplyConnectionBindings", "status": "completed", "blockedBy": [70]},
|
||||
{"id": 82, "subject": "Task 13: Revision-hash regression test", "status": "completed", "blockedBy": [81]},
|
||||
{"id": 83, "subject": "Task 14: IOpcUaBrowseService + impl + DI registration", "status": "completed", "blockedBy": [75]},
|
||||
{"id": 84, "subject": "Task 15: Scaffold OpcUaBrowserDialog modal", "status": "completed"},
|
||||
{"id": 85, "subject": "Task 16: Tree rendering + lazy load + selection in dialog", "status": "completed", "blockedBy": [83, 84]},
|
||||
{"id": 86, "subject": "Task 17: Error banner mapping + polish on dialog", "status": "completed", "blockedBy": [85]},
|
||||
{"id": 87, "subject": "Task 18: Add Override column + Browse button to InstanceConfigure.razor", "status": "completed", "blockedBy": [83, 86]},
|
||||
{"id": 88, "subject": "Task 19: End-to-end manual smoke (online + offline)", "status": "pending", "blockedBy": [70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87], "notes": "needs live rig — tracked in the 2026-08-01 pending-work task list"},
|
||||
{"id": 89, "subject": "Task 20: Update Component-DataConnectionLayer.md", "status": "completed", "blockedBy": [81]},
|
||||
{"id": 90, "subject": "Task 21: Update Component-TemplateEngine.md", "status": "completed", "blockedBy": [81]},
|
||||
{"id": 91, "subject": "Task 22: Update Component-CentralUI.md", "status": "completed", "blockedBy": [87]}
|
||||
],
|
||||
"lastUpdated": "2026-05-28T00:00:00Z"
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"lastUpdated": "2026-08-01"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ScadaLink → ZB.MOM.WW.ScadaBridge Rename — Design
|
||||
|
||||
**Date:** 2026-05-28
|
||||
**Status:** Approved, in implementation
|
||||
**Status:** Implemented (commit `7b0b9c73`)
|
||||
**Scope:** Repo-wide rename of the product from "ScadaLink" to "ScadaBridge" and addition of the `ZB.MOM.WW` company prefix to every .NET project. Code, runtime artifacts (containers, network, databases), docs, and CLI config.
|
||||
|
||||
## Decisions
|
||||
@@ -115,7 +115,7 @@ Drop volume + re-seed. `docker compose -f infra/docker-compose.yml down -v` then
|
||||
## Out of Scope
|
||||
|
||||
- Sister repos `~/Desktop/MxAccessGateway`, `~/Desktop/OtOpcUa` — independent codebases, independent timelines.
|
||||
- Repo folder `~/Desktop/scadalink-design` — left as-is to preserve Claude Code memory paths and user shell context.
|
||||
- Repo folder `~/Desktop/scadalink-design` — left as-is to preserve Claude Code memory paths and user shell context. *(2026-08-01: this exclusion was later reversed and completed — see `2026-05-31-folder-repo-rename-scadabridge-plan.md` / `-design.md`, which renamed the folder and the Gitea repo to ScadaBridge.)*
|
||||
- Existing Transport bundle manifests with `SourceEnvironment = "docker-cluster"` — those values are deployment IDs and stay stable. If the user later wants to rename the deployment IDs themselves, that's a separate design.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-03-component-reference-docs.md",
|
||||
"tasks": [
|
||||
{"id": 19, "subject": "Task 0: Scaffold + shared assets", "status": "pending"},
|
||||
{"id": 20, "subject": "Task 1: Pilot exemplar AuditLog.md (approval gate)", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 2: Commons.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 22, "subject": "Task 3: ConfigurationDatabase.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 23, "subject": "Task 4: Communication.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 24, "subject": "Task 5: ClusterInfrastructure.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 25, "subject": "Task 6: Host.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 26, "subject": "Task 7: Security.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 27, "subject": "Task 8: TemplateEngine.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 28, "subject": "Task 9: DeploymentManager.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 29, "subject": "Task 10: SiteRuntime.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 30, "subject": "Task 11: DataConnectionLayer.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 31, "subject": "Task 12: StoreAndForward.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 32, "subject": "Task 13: ExternalSystemGateway.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 33, "subject": "Task 14: NotificationService.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 34, "subject": "Task 15: NotificationOutbox.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 35, "subject": "Task 16: SiteCallAudit.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 36, "subject": "Task 17: HealthMonitoring.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 37, "subject": "Task 18: SiteEventLogging.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 38, "subject": "Task 19: InboundAPI.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 39, "subject": "Task 20: ManagementService.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 40, "subject": "Task 21: CLI.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 41, "subject": "Task 22: Transport.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 42, "subject": "Task 23: CentralUI.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 43, "subject": "Task 24: TraefikProxy.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 44, "subject": "Task 25: TreeView.md reference doc", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 45, "subject": "Task 26: Index + README link", "status": "pending", "blockedBy": [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44]},
|
||||
{"id": 46, "subject": "Task 27: Verification & fix pass", "status": "pending", "blockedBy": [45]}
|
||||
{"id": 19, "subject": "Task 0: Scaffold + shared assets", "status": "completed"},
|
||||
{"id": 20, "subject": "Task 1: Pilot exemplar AuditLog.md (approval gate)", "status": "completed", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 2: Commons.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 22, "subject": "Task 3: ConfigurationDatabase.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 23, "subject": "Task 4: Communication.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 24, "subject": "Task 5: ClusterInfrastructure.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 25, "subject": "Task 6: Host.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 26, "subject": "Task 7: Security.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 27, "subject": "Task 8: TemplateEngine.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 28, "subject": "Task 9: DeploymentManager.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 29, "subject": "Task 10: SiteRuntime.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 30, "subject": "Task 11: DataConnectionLayer.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 31, "subject": "Task 12: StoreAndForward.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 32, "subject": "Task 13: ExternalSystemGateway.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 33, "subject": "Task 14: NotificationService.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 34, "subject": "Task 15: NotificationOutbox.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 35, "subject": "Task 16: SiteCallAudit.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 36, "subject": "Task 17: HealthMonitoring.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 37, "subject": "Task 18: SiteEventLogging.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 38, "subject": "Task 19: InboundAPI.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 39, "subject": "Task 20: ManagementService.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 40, "subject": "Task 21: CLI.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 41, "subject": "Task 22: Transport.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 42, "subject": "Task 23: CentralUI.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 43, "subject": "Task 24: TraefikProxy.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 44, "subject": "Task 25: TreeView.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||
{"id": 45, "subject": "Task 26: Index + README link", "status": "completed", "blockedBy": [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44]},
|
||||
{"id": 46, "subject": "Task 27: Verification & fix pass", "status": "completed", "blockedBy": [45]}
|
||||
],
|
||||
"lastUpdated": "2026-06-03"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-05-playwright-coverage-expansion.md",
|
||||
"lastUpdated": "2026-06-05T00:00:00Z",
|
||||
"lastUpdated": "2026-08-01T00:00:00Z",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||
"nativeTaskIdBase": 57,
|
||||
"tasks": [
|
||||
{"id": 0, "nativeId": 57, "subject": "Task 0: Add CLI ProjectReference to test project", "status": "pending"},
|
||||
{"id": 1, "nativeId": 58, "subject": "Task 1: CliRunner core + ClusterAvailability probe", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "nativeId": 59, "subject": "Task 2: CliRunner typed fixture helpers", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": 60, "subject": "Task 3: Standardize skip policy + skip-count logging", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "nativeId": 61, "subject": "Task 4: DeploymentFixture (ephemeral instance on site-a)", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 5, "nativeId": 62, "subject": "Task 5: DeploymentActionTests.Deploy", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "nativeId": 63, "subject": "Task 6: DeploymentActionTests.Enable + Disable", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "nativeId": 64, "subject": "Task 7: DeploymentActionTests.Delete", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": 65, "subject": "Task 8: Notification retry/discard + ParkedMessages query", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 9, "nativeId": 66, "subject": "Task 9: Transport Import round-trip", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 10, "nativeId": 67, "subject": "Task 10: Site CRUD round-trip", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 11, "nativeId": 68, "subject": "Task 11: Template CRUD round-trip", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 12, "nativeId": 69, "subject": "Task 12: LDAP mapping CRUD round-trip", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 13, "nativeId": 70, "subject": "Task 13: Navigation render-assertion hardening", "status": "pending"},
|
||||
{"id": 14, "nativeId": 71, "subject": "Task 14: Health KPI load test", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 15, "nativeId": 72, "subject": "Task 15: Full-suite verification + no-residue check", "status": "pending", "blockedBy": [3, 7, 8, 9, 10, 11, 12, 13, 14]}
|
||||
{"id": 0, "nativeId": 57, "subject": "Task 0: Add CLI ProjectReference to test project", "status": "completed"},
|
||||
{"id": 1, "nativeId": 58, "subject": "Task 1: CliRunner core + ClusterAvailability probe", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 2, "nativeId": 59, "subject": "Task 2: CliRunner typed fixture helpers", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": 60, "subject": "Task 3: Standardize skip policy + skip-count logging", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 4, "nativeId": 61, "subject": "Task 4: DeploymentFixture (ephemeral instance on site-a)", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 5, "nativeId": 62, "subject": "Task 5: DeploymentActionTests.Deploy", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 6, "nativeId": 63, "subject": "Task 6: DeploymentActionTests.Enable + Disable", "status": "completed", "blockedBy": [5]},
|
||||
{"id": 7, "nativeId": 64, "subject": "Task 7: DeploymentActionTests.Delete", "status": "completed", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": 65, "subject": "Task 8: Notification retry/discard + ParkedMessages query", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 9, "nativeId": 66, "subject": "Task 9: Transport Import round-trip", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 10, "nativeId": 67, "subject": "Task 10: Site CRUD round-trip", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 11, "nativeId": 68, "subject": "Task 11: Template CRUD round-trip", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 12, "nativeId": 69, "subject": "Task 12: LDAP mapping CRUD round-trip", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 13, "nativeId": 70, "subject": "Task 13: Navigation render-assertion hardening", "status": "completed"},
|
||||
{"id": 14, "nativeId": 71, "subject": "Task 14: Health KPI load test", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 15, "nativeId": 72, "subject": "Task 15: Full-suite verification + no-residue check", "status": "completed", "blockedBy": [3, 7, 8, 9, 10, 11, 12, 13, 14]}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -78,7 +78,10 @@ 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 — KPI History & Trends (T11 delivered; T9/T10 deferred)
|
||||
#### M6 — KPI History & Trends (T11 delivered; T9/T10 **since DELIVERED 2026-06-19**)
|
||||
|
||||
> **2026-08-01 bookkeeping sync:** the T9/T10 "deferred to the next major version" text below is superseded — **T9/T10 were delivered 2026-06-19 as the SMS (Twilio) adapter** (`SmsNotificationDeliveryAdapter`, `NotificationType.Sms`, Central UI Type selector + SMS recipient input; see `docs/plans/2026-06-19-sms-notifications.md`). **Teams was evaluated and dropped by design decision** — outbound-only backends cannot send 1:1 chat bodies via Graph without a Bot Framework inbound endpoint; SMS is inherently per-person and outbound-only.
|
||||
|
||||
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.
|
||||
@@ -132,7 +135,7 @@ This is the **final milestone** of the system-completion roadmap. All in-scope M
|
||||
## Dependencies & sequencing
|
||||
|
||||
- **M1 → M5** — audit hardening builds on the wired purge/reconciliation.
|
||||
- **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.
|
||||
- **M6/T11** — delivered as the #26 KpiHistory backbone; reused **central MS SQL** (a tall/EAV `KpiSample` table) rather than introducing new infra. T9/T10 since delivered 2026-06-19 as the SMS (Twilio) adapter (see note above).
|
||||
- **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.
|
||||
|
||||
@@ -27,9 +27,10 @@
|
||||
{"ref": "#19", "subject": "script started/completed events", "status": "done in M1.8"}
|
||||
],
|
||||
"followups": [
|
||||
{"id": 52, "subject": "Investigate 2 partition-purge E2E test failures (AuditLogPurgeActor/PartitionPurge)", "from": "M2.0", "status": "pending"},
|
||||
{"id": 53, "subject": "Dedup alarm-capable protocol predicate (3 copies → AlarmCapableProtocols)", "from": "M2.1", "status": "pending"},
|
||||
{"id": 54, "subject": "Expose ExecutionTimeoutSeconds (+ MinTimeBetweenRuns) in CLI + UI script authoring", "from": "M2.5", "status": "pending"}
|
||||
{"id": 52, "subject": "Investigate 2 partition-purge E2E test failures (AuditLogPurgeActor/PartitionPurge)", "from": "M2.0", "status": "resolved", "note": "RESOLVED — commit 639e331d de-dated the 2 EndToEnd purge tests (M5.7)"},
|
||||
{"id": 53, "subject": "Dedup alarm-capable protocol predicate (3 copies → AlarmCapableProtocols)", "from": "M2.1", "status": "resolved", "note": "RESOLVED — shared Commons/Interfaces/Protocol/AlarmCapableProtocols.cs is now the single predicate"},
|
||||
{"id": 54, "subject": "Expose ExecutionTimeoutSeconds (+ MinTimeBetweenRuns) in CLI + UI script authoring", "from": "M2.5", "status": "resolved", "note": "RESOLVED — CLI --execution-timeout-seconds on template script add/update plus TemplateEdit binding"}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-15-stillpending-phase1-implementation.md",
|
||||
"tasks": [
|
||||
{"id": 22, "subject": "M1.0: Confirm proto/site surface for audit pull (spike)", "status": "pending"},
|
||||
{"id": 23, "subject": "M1.1: Production gRPC IPullAuditEventsClient", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "M1.2: Wire reconciliation + purge actors as central singletons", "status": "pending"},
|
||||
{"id": 25, "subject": "M1.3: SiteCallAudit periodic reconciliation pull", "status": "pending"},
|
||||
{"id": 26, "subject": "M1.4: SiteCallAudit daily terminal-row purge scheduler", "status": "pending"},
|
||||
{"id": 27, "subject": "M1.5: SiteEventLog — emit Alarm events", "status": "pending"},
|
||||
{"id": 28, "subject": "M1.6: SiteEventLog — Deployment + Instance-lifecycle events", "status": "pending"},
|
||||
{"id": 29, "subject": "M1.7: SiteEventLog — Store-and-Forward + Notification events", "status": "pending"},
|
||||
{"id": 30, "subject": "M1.8: SiteEventLog — script started/completed (Info)", "status": "pending"},
|
||||
{"id": 31, "subject": "M1.9: M1 integration verification + redeploy", "status": "pending", "blockedBy": [23, 24, 25, 26, 27, 28, 29, 30]},
|
||||
{"id": 13, "subject": "M2 — Correctness & behavioral gaps (Tier 2) [umbrella; split per-item at execution]", "status": "pending"},
|
||||
{"id": 14, "subject": "M3 — Script trust boundary (Tier 1 #1-#2) [umbrella]", "status": "pending"},
|
||||
{"id": 15, "subject": "M4 — Doc reconciliation (Tier 4) [umbrella]", "status": "pending"}
|
||||
{"id": 22, "subject": "M1.0: Confirm proto/site surface for audit pull (spike)", "status": "completed"},
|
||||
{"id": 23, "subject": "M1.1: Production gRPC IPullAuditEventsClient", "status": "completed", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "M1.2: Wire reconciliation + purge actors as central singletons", "status": "completed"},
|
||||
{"id": 25, "subject": "M1.3: SiteCallAudit periodic reconciliation pull", "status": "completed"},
|
||||
{"id": 26, "subject": "M1.4: SiteCallAudit daily terminal-row purge scheduler", "status": "completed"},
|
||||
{"id": 27, "subject": "M1.5: SiteEventLog — emit Alarm events", "status": "completed"},
|
||||
{"id": 28, "subject": "M1.6: SiteEventLog — Deployment + Instance-lifecycle events", "status": "completed"},
|
||||
{"id": 29, "subject": "M1.7: SiteEventLog — Store-and-Forward + Notification events", "status": "completed"},
|
||||
{"id": 30, "subject": "M1.8: SiteEventLog — script started/completed (Info)", "status": "completed"},
|
||||
{"id": 31, "subject": "M1.9: M1 integration verification + redeploy", "status": "completed", "blockedBy": [23, 24, 25, 26, 27, 28, 29, 30]},
|
||||
{"id": 13, "subject": "M2 — Correctness & behavioral gaps (Tier 2) [umbrella; split per-item at execution]", "status": "completed"},
|
||||
{"id": 14, "subject": "M3 — Script trust boundary (Tier 1 #1-#2) [umbrella]", "status": "completed"},
|
||||
{"id": 15, "subject": "M4 — Doc reconciliation (Tier 4) [umbrella]", "status": "completed"}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-16-ipsen-mes-movein.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: InboundDatabaseHelper (read-only DB for inbound scripts)", "classification": "standard", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Expose Database to inbound scripts (context + executor)", "classification": "high-risk", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Build + regression-test ScadaBridge", "classification": "small", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 4: Commit Component A", "classification": "trivial", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Deploy ScadaBridge Central (Component A) to wonder-app-vd03", "classification": "high-risk", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: Apply Component B - update inbound IpsenMESMoveIn script", "classification": "high-risk", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: Apply Component C - IpsenMoveIn template script on T1", "classification": "high-risk", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 8, "subject": "Task 8: On-box end-to-end verification", "classification": "standard", "status": "pending", "blockedBy": [6, 7]}
|
||||
{"id": 1, "subject": "Task 1: InboundDatabaseHelper (read-only DB for inbound scripts)", "classification": "standard", "status": "completed"},
|
||||
{"id": 2, "subject": "Task 2: Expose Database to inbound scripts (context + executor)", "classification": "high-risk", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Build + regression-test ScadaBridge", "classification": "small", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 4: Commit Component A", "classification": "trivial", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Deploy ScadaBridge Central (Component A) to wonder-app-vd03", "classification": "high-risk", "status": "completed", "note": "done off-repo on wonder-app-vd03 (live methods authored against Database.* fixed there 2026-06-25)", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: Apply Component B - update inbound IpsenMESMoveIn script", "classification": "high-risk", "status": "completed", "note": "done off-repo on wonder-app-vd03 (live methods authored against Database.* fixed there 2026-06-25)", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: Apply Component C - IpsenMoveIn template script on T1", "classification": "high-risk", "status": "pending", "note": "NEEDS LIVE vd03 verification — tracked in the 2026-08-01 pending-work task list", "blockedBy": [5]},
|
||||
{"id": 8, "subject": "Task 8: On-box end-to-end verification", "classification": "standard", "status": "pending", "note": "NEEDS LIVE vd03 verification — tracked in the 2026-08-01 pending-work task list", "blockedBy": [6, 7]}
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-16-m5-audit-hardening.md",
|
||||
"tasks": [
|
||||
{"id": 119, "subject": "M5.1 (T8): CLI audit tree + tree endpoint", "status": "pending"},
|
||||
{"id": 120, "subject": "M5.2 (T6): Per-node stuck-count KPIs", "status": "pending"},
|
||||
{"id": 121, "subject": "M5.3 (T7): Structured response-capture increments", "status": "pending"},
|
||||
{"id": 122, "subject": "M5.4 (T4): ParentExecutionId tag-cascade", "status": "pending"},
|
||||
{"id": 123, "subject": "M5.5 (T3): Per-channel retention overrides", "status": "pending"},
|
||||
{"id": 124, "subject": "M5.6 (T5): SourceNode sentinel backfill + runbook", "status": "pending", "blockedBy": [119]},
|
||||
{"id": 125, "subject": "M5.7: M5 integration verification + docs", "status": "pending", "blockedBy": [119, 120, 121, 122, 123, 124]}
|
||||
{"id": 119, "subject": "M5.1 (T8): CLI audit tree + tree endpoint", "status": "completed"},
|
||||
{"id": 120, "subject": "M5.2 (T6): Per-node stuck-count KPIs", "status": "completed"},
|
||||
{"id": 121, "subject": "M5.3 (T7): Structured response-capture increments", "status": "completed"},
|
||||
{"id": 122, "subject": "M5.4 (T4): ParentExecutionId tag-cascade", "status": "completed"},
|
||||
{"id": 123, "subject": "M5.5 (T3): Per-channel retention overrides", "status": "completed"},
|
||||
{"id": 124, "subject": "M5.6 (T5): SourceNode sentinel backfill + runbook", "status": "completed", "blockedBy": [119]},
|
||||
{"id": 125, "subject": "M5.7: M5 integration verification + docs", "status": "completed", "blockedBy": [119, 120, 121, 122, 123, 124]}
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-16-script-analysis-consolidation.md",
|
||||
"tasks": [
|
||||
{"id": 103, "subject": "M3.0: Spike — compile-surface stub feasibility + package refs", "status": "pending"},
|
||||
{"id": 104, "subject": "M3.1: Build ScriptAnalysis project (policy+validator+compiler+surfaces+tests)", "status": "pending", "blockedBy": [103]},
|
||||
{"id": 105, "subject": "M3.2: TemplateEngine deploy gate → shared analyzer", "status": "pending", "blockedBy": [104]},
|
||||
{"id": 106, "subject": "M3.3: SiteRuntime → shared analyzer + parity test", "status": "pending", "blockedBy": [104]},
|
||||
{"id": 107, "subject": "M3.4: InboundAPI → shared analyzer", "status": "pending", "blockedBy": [104]},
|
||||
{"id": 108, "subject": "M3.5: CentralUI → shared analyzer (keep markers + Test-Run host)", "status": "pending", "blockedBy": [104]},
|
||||
{"id": 109, "subject": "M3.6: Integration verification + docs + fixture cleanup", "status": "pending", "blockedBy": [105, 106, 107, 108]}
|
||||
{"id": 103, "subject": "M3.0: Spike — compile-surface stub feasibility + package refs", "status": "completed"},
|
||||
{"id": 104, "subject": "M3.1: Build ScriptAnalysis project (policy+validator+compiler+surfaces+tests)", "status": "completed", "blockedBy": [103]},
|
||||
{"id": 105, "subject": "M3.2: TemplateEngine deploy gate → shared analyzer", "status": "completed", "blockedBy": [104]},
|
||||
{"id": 106, "subject": "M3.3: SiteRuntime → shared analyzer + parity test", "status": "completed", "blockedBy": [104]},
|
||||
{"id": 107, "subject": "M3.4: InboundAPI → shared analyzer", "status": "completed", "blockedBy": [104]},
|
||||
{"id": 108, "subject": "M3.5: CentralUI → shared analyzer (keep markers + Test-Run host)", "status": "completed", "blockedBy": [104]},
|
||||
{"id": 109, "subject": "M3.6: Integration verification + docs + fixture cleanup", "status": "completed", "blockedBy": [105, 106, 107, 108]}
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-17-debugview-tabs-trees.md",
|
||||
"tasks": [
|
||||
{"id": 170, "subject": "DV-1: Native-binding linkage — additive AlarmStateChanged contract chain", "classification": "high-risk", "status": "pending"},
|
||||
{"id": 171, "subject": "DV-2: Site snapshot — placeholder rows for idle native source bindings", "classification": "standard", "status": "pending", "blockedBy": [170]},
|
||||
{"id": 172, "subject": "DV-3: DebugTreeNode model + attribute-tree builder", "classification": "standard", "status": "pending", "blockedBy": [170]},
|
||||
{"id": 173, "subject": "DV-4: Alarm-tree builder — computed leaves + native binding grouping + roll-up", "classification": "standard", "status": "pending", "blockedBy": [172, 170]},
|
||||
{"id": 174, "subject": "DV-5: DebugView page — tabs + two TreeViews + in-place updates", "classification": "standard", "status": "pending", "blockedBy": [173]},
|
||||
{"id": 175, "subject": "DV-6: Documentation — CentralUI + SiteRuntime + streaming contract", "classification": "small", "status": "pending", "blockedBy": [173]},
|
||||
{"id": 176, "subject": "DV-7: Integration — full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "pending", "blockedBy": [174, 175, 171]}
|
||||
{"id": 170, "subject": "DV-1: Native-binding linkage — additive AlarmStateChanged contract chain", "classification": "high-risk", "status": "completed"},
|
||||
{"id": 171, "subject": "DV-2: Site snapshot — placeholder rows for idle native source bindings", "classification": "standard", "status": "completed", "blockedBy": [170]},
|
||||
{"id": 172, "subject": "DV-3: DebugTreeNode model + attribute-tree builder", "classification": "standard", "status": "completed", "blockedBy": [170]},
|
||||
{"id": 173, "subject": "DV-4: Alarm-tree builder — computed leaves + native binding grouping + roll-up", "classification": "standard", "status": "completed", "blockedBy": [172, 170]},
|
||||
{"id": 174, "subject": "DV-5: DebugView page — tabs + two TreeViews + in-place updates", "classification": "standard", "status": "completed", "blockedBy": [173]},
|
||||
{"id": 175, "subject": "DV-6: Documentation — CentralUI + SiteRuntime + streaming contract", "classification": "small", "status": "completed", "blockedBy": [173]},
|
||||
{"id": 176, "subject": "DV-7: Integration — full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "completed", "blockedBy": [174, 175, 171]}
|
||||
],
|
||||
"waves": [
|
||||
{"wave": 1, "tasks": [170]},
|
||||
@@ -16,5 +16,6 @@
|
||||
{"wave": 4, "tasks": [174, 175]},
|
||||
{"wave": 5, "tasks": [176]}
|
||||
],
|
||||
"lastUpdated": "2026-06-17"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"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]}
|
||||
{"id": 1, "subject": "K1: Foundation contracts (Commons)", "nativeId": 136, "classification": "high-risk", "status": "completed"},
|
||||
{"id": 2, "subject": "K2: Persistence — EF config + repository + migration", "nativeId": 137, "classification": "high-risk", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "K3: KpiHistory project scaffold + options", "nativeId": 138, "classification": "standard", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "K4: KpiHistoryRecorderActor", "nativeId": 139, "classification": "high-risk", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "K5: Host wiring + appsettings", "nativeId": 140, "classification": "high-risk", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "K6: NotificationOutboxKpiSampleSource", "nativeId": 141, "classification": "small", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 7, "subject": "K7: SiteCallAuditKpiSampleSource", "nativeId": 142, "classification": "small", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 8, "subject": "K8: AuditLogKpiSampleSource", "nativeId": 143, "classification": "small", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 9, "subject": "K9: SiteHealthKpiSampleSource", "nativeId": 144, "classification": "standard", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 10, "subject": "K10: KpiSeriesBucketer (pure helper)", "nativeId": 145, "classification": "small", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 11, "subject": "K11: KpiHistoryQueryService (CentralUI)", "nativeId": 146, "classification": "standard", "status": "completed", "blockedBy": [2, 10]},
|
||||
{"id": 12, "subject": "K12: KpiTrendChart.razor reusable SVG component", "nativeId": 147, "classification": "standard", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 13, "subject": "K13: Notification Outbox page trend section", "nativeId": 148, "classification": "standard", "status": "completed", "blockedBy": [11, 12]},
|
||||
{"id": 14, "subject": "K14: Site Calls page trend section", "nativeId": 149, "classification": "standard", "status": "completed", "blockedBy": [11, 12]},
|
||||
{"id": 15, "subject": "K15: Audit Log page trend section", "nativeId": 150, "classification": "standard", "status": "completed", "blockedBy": [11, 12]},
|
||||
{"id": 16, "subject": "K16: Health dashboard per-site trend panel", "nativeId": 151, "classification": "standard", "status": "completed", "blockedBy": [11, 12]},
|
||||
{"id": 17, "subject": "K17: Integration — docs, deploy, Playwright, full verification", "nativeId": 152, "classification": "high-risk", "status": "completed", "blockedBy": [5, 6, 7, 8, 9, 13, 14, 15, 16]}
|
||||
],
|
||||
"lastUpdated": "2026-06-17"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
],
|
||||
"reviewPolishCommit": "8dcc55f",
|
||||
"followUps": [
|
||||
{"id": 162, "subject": "normalize routed GetAttributes List values for cross-process transport (surfaced by WS-4 review)"}
|
||||
{"id": 162, "subject": "normalize routed GetAttributes List values for cross-process transport (surfaced by WS-4 review)", "status": "resolved", "note": "RESOLVED — DeploymentManagerActor.cs RouteInboundApiGetAttributes now normalizes via NormalizeRoutedReturnValue"}
|
||||
],
|
||||
"lastUpdated": "2026-06-17"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -239,7 +239,9 @@ fully cover the DELMIA/MES use case.
|
||||
> **Status:** IMPLEMENTED. `Route.To(code).WaitForAttribute(name, targetValue, timeout)` is wired
|
||||
> end-to-end (`RouteToWaitForAttributeRequest/Response` → `IInstanceRouter` → `CommunicationService`
|
||||
> → `SiteCommunicationActor` → `DeploymentManagerActor` → `InstanceActor`), value-equality only
|
||||
> across the wire. NOT wired into the CentralUI Test-Run sandbox — that remains a follow-up.
|
||||
> across the wire. ~~NOT wired into the CentralUI Test-Run sandbox — that remains a follow-up.~~
|
||||
> **Follow-up SHIPPED 2026-07-10** — the CentralUI Test-Run sandbox is wired via
|
||||
> `SandboxInstanceGateway`/`SandboxScriptHost` (deferred-work register row #14 resolved).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-17-waitfor-deferred-items.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "WD-1: site-local WaitForAsync + WaitResult + quality-gated mode (§3+§4.2)", "classification": "high-risk", "status": "pending", "parallelizableWith": [2]},
|
||||
{"id": 2, "subject": "WD-2a: routed contract + central path (§6 part 1)", "classification": "high-risk", "status": "pending", "parallelizableWith": [1]},
|
||||
{"id": 3, "subject": "WD-2b: site unpacking + DeploymentManager handler (§6 part 2)", "classification": "high-risk", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "WD-3: integration — docs + full verification", "classification": "standard", "status": "pending", "blockedBy": [1, 2, 3]}
|
||||
{"id": 1, "subject": "WD-1: site-local WaitForAsync + WaitResult + quality-gated mode (§3+§4.2)", "classification": "high-risk", "status": "completed", "parallelizableWith": [2]},
|
||||
{"id": 2, "subject": "WD-2a: routed contract + central path (§6 part 1)", "classification": "high-risk", "status": "completed", "parallelizableWith": [1]},
|
||||
{"id": 3, "subject": "WD-2b: site unpacking + DeploymentManager handler (§6 part 2)", "classification": "high-risk", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "WD-3: integration — docs + full verification", "classification": "standard", "status": "completed", "blockedBy": [1, 2, 3]}
|
||||
],
|
||||
"lastUpdated": "2026-06-17"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -459,15 +459,15 @@ Markup: two `<input type="datetime-local">` with `id="@(IdPrefix)-from"`/`-to`,
|
||||
## Deferred / out of scope (log as FOLLOWUPs at INT)
|
||||
- Unified offset+keyset pagination framework (blocked by total-count mismatch).
|
||||
- `Deployments.razor` PagerWindow → OffsetPager (different windowed UX; intentionally left).
|
||||
- Complex `TemplateEdit` page-embedded modals → host migration.
|
||||
- TreeView arrow-key navigation (R7).
|
||||
- ~~Complex `TemplateEdit` page-embedded modals → host migration.~~ **DONE 2026-08-01.**
|
||||
- ~~TreeView arrow-key navigation (R7).~~ **DONE 2026-08-01.**
|
||||
- Theme-package side-rail dark theming, IF the spike verdict is "rail stays light" (coordination follow-up).
|
||||
|
||||
## Follow-ups logged at delivery (INT findings)
|
||||
- **#207 (pre-existing, open since M6/K14):** `QueryStringDrillInTests` fixture does not register `IKpiHistoryQueryService` — 3 `SiteCallsReport` drill-in tests red on this gap; unrelated to M10.
|
||||
- **#163 (pre-existing):** `InstanceConfigureListOverrideTests` codec roundtrip red; pre-dates M10.
|
||||
- **NotificationReport `OffsetPager` always-visible:** pager is now always visible when results exist (previously hidden on sub-page-size sets); buttons are correctly disabled on a single page — product decision whether to re-add an `@if (_totalCount > _pageSize)` guard.
|
||||
- **#207 — FIXED (2026-08-01 bookkeeping sync).** ~~(pre-existing, open since M6/K14):~~ `QueryStringDrillInTests` now registers the `IKpiHistoryQueryService` substitute in its fixture (lines ~165-167), so the 3 `SiteCallsReport` drill-in tests are green.
|
||||
- **#163 — CLOSED (2026-08-01).** ~~(pre-existing):~~ `InstanceConfigureListOverrideTests` codec roundtrip verified green (6/6; `dotnet test …CentralUI.Tests --filter InstanceConfigureListOverride`) — fixed at some point since it was logged; no longer a known red. (#207 `QueryStringDrillInTests` re-verified green the same day, 4/4.)
|
||||
- **NotificationReport `OffsetPager` always-visible — DECIDED + DONE (2026-08-01).** A pager whose only controls are permanently disabled is noise, so the conventional behaviour ships: the bar is hidden on a provably single page. Implemented **reusably but opt-in** — `OffsetPager.HideWhenSinglePage` (default `false`, so no consumer changes behaviour implicitly), set `true` at the `NotificationReport` call site. It is NOT defaulted on because the pager's summary span doubles as the "N total" result-count readout on `ConfigurationAuditLog`, which has no other place to show it (and whose Playwright fixture asserts `3 total` on a 3-row single page). The guard only fires when single-page-ness is *positively* established (`PageCount <= 1` **and** `Page <= 1` **and** `!HasNextPage`), so a null `TotalCount` still renders the bar.
|
||||
- **`Deployments.razor` PagerWindow intentionally kept:** windowed numbered-button UX is deliberate; NOT migrated to `OffsetPager`.
|
||||
- **`TemplateEdit` inline modals NOT migrated:** the page-embedded modals (wave-3 T34c already tokenized their backdrops); full migration to the host is deferred.
|
||||
- **TreeView full arrow-key navigation (R7) still deferred.**
|
||||
- **Full-app `bg-light`/`bg-white` → theme-aware utility sweep deferred:** only the bounded modal-surface offenders were addressed in T34c; INT dark-mode smoke may surface additional instances.
|
||||
- **`TemplateEdit` inline modals — MIGRATED (2026-08-01).** All four page-embedded modals (Attribute, Alarm, Native Alarm Source, Script) now open through `IDialogService.ShowAsync`, so the host owns the backdrop, focus trap, Escape and focus restoration. Each body was extracted to its own component next to the page — `TemplateAttributeDialog`, `TemplateAlarmDialog`, `TemplateNativeAlarmSourceDialog`, `TemplateScriptDialog` — following the `MoveDataConnectionDialog` pattern: the body owns the form state and renders validation/server errors INLINE while staying open, closing with `Close(true)` only on a successful save. Persistence stayed on the page (it owns `TemplateService` and the inherited-member rules) and is reached through an `OnSaveAsync` delegate returning `null` on success or the message to display. The script dialog keeps all four tab panels mounted (Monaco/JSONJoy must not tear down on tab switch) and hosts the Test Run panel, which needs the live unsaved buffer. Extraction moved markup that three structural source-scanning tests pinned (`TemplateNativeAlarmSourceEditorTests`, `AttributeListEditorTests`, `TestRunWarningTests`) — all three were repointed at the new files.
|
||||
- **~~TreeView full arrow-key navigation (R7) still deferred.~~ DONE 2026-08-01** — the WAI-ARIA tree pattern (roving tabindex, Arrow/Home/End movement, Enter/Space activation, `aria-level`) now ships on `TreeView<TItem>`; see `docs/components/TreeView.md`.
|
||||
- **Full-app `bg-light`/`bg-white` → theme-aware utility sweep — DONE 2026-08-01.** 35 class swaps across 19 Central UI files: surfaces/`<pre>`/`<code>` `bg-light` → `bg-body-secondary`, panel `bg-white` → `bg-body`, neutral `badge bg-light text-dark` → `badge bg-secondary-subtle text-secondary-emphasis`, `bg-light text-muted border` → `bg-body-secondary text-body-secondary border`. **Deliberately left** (7 sites): the neutral member of a *status-badge* switch/ternary whose siblings are all solid non-theme-aware colours (`bg-success`/`bg-danger`/`bg-warning`), where swapping only the neutral one breaks the set's visual weight — `Topology.razor:513,588`, `InstanceConfigure.razor:1520`, `NotificationReport.razor` `StatusBadgeClass` fallback, `TransportImport.razor` ConflictKind fallback, `SecuredWrites.razor` `text-bg-light` fallback, `Health.razor:377`. `SchemaBuilder.razor:88` was already `bg-light-subtle` (theme-aware). `bg-dark text-light` console panels and `site.css`/`#reconnect-modal` are intentional fixed colours.
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-18-m10-uiux-platform.md",
|
||||
"tasks": [
|
||||
{"id": 269, "subject": "M10-T33a: Modal host ShowAsync<T> + focus trap/restore + backdrop hook", "status": "pending"},
|
||||
{"id": 270, "subject": "M10-T34-spike: Verify ZB.MOM.WW.Theme dark-mode feasibility", "status": "pending"},
|
||||
{"id": 271, "subject": "M10-T41: Alarm-override Playwright trigger-config scenarios", "status": "pending"},
|
||||
{"id": 272, "subject": "M10-T33b: Migrate 5 simple dialogs to ShowAsync<T>", "status": "pending", "blockedBy": [269]},
|
||||
{"id": 273, "subject": "M10-T34a: Dark token layer in site.css + .sb-modal-backdrop", "status": "pending", "blockedBy": [270]},
|
||||
{"id": 274, "subject": "M10-T35a: OffsetPager component", "status": "pending"},
|
||||
{"id": 275, "subject": "M10-T35b: KeysetPager component", "status": "pending"},
|
||||
{"id": 276, "subject": "M10-T35c: DateTimeRangeFilter component", "status": "pending"},
|
||||
{"id": 277, "subject": "M10-T35d: Adopt OffsetPager+DateTimeRangeFilter into NotificationReport (+backdrop token)", "status": "pending", "blockedBy": [274, 276, 273]},
|
||||
{"id": 278, "subject": "M10-T35e: Adopt OffsetPager+DateTimeRangeFilter into ConfigurationAuditLog", "status": "pending", "blockedBy": [274, 276]},
|
||||
{"id": 279, "subject": "M10-T35f: Adopt KeysetPager+DateTimeRangeFilter into SiteCallsReport (+backdrop token)", "status": "pending", "blockedBy": [275, 276, 273]},
|
||||
{"id": 280, "subject": "M10-T35g: Adopt KeysetPager into AuditResultsGrid + DateTimeRangeFilter into AuditFilterBar", "status": "pending", "blockedBy": [275, 276]},
|
||||
{"id": 281, "subject": "M10-T35h: Adopt DateTimeRangeFilter into EventLogs", "status": "pending", "blockedBy": [276]},
|
||||
{"id": 282, "subject": "M10-T34c: Tokenize remaining standalone backdrops + bg-* audit", "status": "pending", "blockedBy": [273]},
|
||||
{"id": 283, "subject": "M10-T34b: Dark-mode toggle + persistence + SSR hydration", "status": "pending", "blockedBy": [270, 273]},
|
||||
{"id": 284, "subject": "M10-T36a: Accessibility pass — TreeView chevron + icon audit + toast verify", "status": "pending"},
|
||||
{"id": 285, "subject": "M10-INT: Integration — build, docker, Playwright, a11y+dark smoke, docs, review", "status": "pending", "blockedBy": [271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284]}
|
||||
{"id": 269, "subject": "M10-T33a: Modal host ShowAsync<T> + focus trap/restore + backdrop hook", "status": "completed"},
|
||||
{"id": 270, "subject": "M10-T34-spike: Verify ZB.MOM.WW.Theme dark-mode feasibility", "status": "completed"},
|
||||
{"id": 271, "subject": "M10-T41: Alarm-override Playwright trigger-config scenarios", "status": "completed"},
|
||||
{"id": 272, "subject": "M10-T33b: Migrate 5 simple dialogs to ShowAsync<T>", "status": "completed", "blockedBy": [269]},
|
||||
{"id": 273, "subject": "M10-T34a: Dark token layer in site.css + .sb-modal-backdrop", "status": "completed", "blockedBy": [270]},
|
||||
{"id": 274, "subject": "M10-T35a: OffsetPager component", "status": "completed"},
|
||||
{"id": 275, "subject": "M10-T35b: KeysetPager component", "status": "completed"},
|
||||
{"id": 276, "subject": "M10-T35c: DateTimeRangeFilter component", "status": "completed"},
|
||||
{"id": 277, "subject": "M10-T35d: Adopt OffsetPager+DateTimeRangeFilter into NotificationReport (+backdrop token)", "status": "completed", "blockedBy": [274, 276, 273]},
|
||||
{"id": 278, "subject": "M10-T35e: Adopt OffsetPager+DateTimeRangeFilter into ConfigurationAuditLog", "status": "completed", "blockedBy": [274, 276]},
|
||||
{"id": 279, "subject": "M10-T35f: Adopt KeysetPager+DateTimeRangeFilter into SiteCallsReport (+backdrop token)", "status": "completed", "blockedBy": [275, 276, 273]},
|
||||
{"id": 280, "subject": "M10-T35g: Adopt KeysetPager into AuditResultsGrid + DateTimeRangeFilter into AuditFilterBar", "status": "completed", "blockedBy": [275, 276]},
|
||||
{"id": 281, "subject": "M10-T35h: Adopt DateTimeRangeFilter into EventLogs", "status": "completed", "blockedBy": [276]},
|
||||
{"id": 282, "subject": "M10-T34c: Tokenize remaining standalone backdrops + bg-* audit", "status": "completed", "blockedBy": [273]},
|
||||
{"id": 283, "subject": "M10-T34b: Dark-mode toggle + persistence + SSR hydration", "status": "completed", "blockedBy": [270, 273]},
|
||||
{"id": 284, "subject": "M10-T36a: Accessibility pass — TreeView chevron + icon audit + toast verify", "status": "completed"},
|
||||
{"id": 285, "subject": "M10-INT: Integration — build, docker, Playwright, a11y+dark smoke, docs, review", "status": "completed", "blockedBy": [271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284]}
|
||||
],
|
||||
"lastUpdated": "2026-06-18"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -4,28 +4,28 @@
|
||||
"branch": "worktree-m7-opcua-mxgateway-ux",
|
||||
"baseSha": "254e0e7",
|
||||
"tasks": [
|
||||
{"id": 184, "subject": "M7-A1: Extract AlarmStateBadges shared component", "classification": "standard", "status": "pending"},
|
||||
{"id": 185, "subject": "M7-A2: Operator Alarm Summary page + fan-out service", "classification": "standard", "status": "pending", "blockedBy": [184]},
|
||||
{"id": 186, "subject": "M7-A3: Operator + Verifier roles + policies + LDAP mapping", "classification": "high-risk", "status": "pending"},
|
||||
{"id": 187, "subject": "M7-B1: Browse type-info fields on BrowseNode", "classification": "standard", "status": "pending"},
|
||||
{"id": 188, "subject": "M7-B2: BrowseNext continuation through browse contract", "classification": "high-risk", "status": "pending", "blockedBy": [187]},
|
||||
{"id": 189, "subject": "M7-B3: Thread continuation token through browse plumbing", "classification": "standard", "status": "pending", "blockedBy": [188]},
|
||||
{"id": 190, "subject": "M7-B4: Bounded recursive address-space search — adapter", "classification": "high-risk", "status": "pending", "blockedBy": [188]},
|
||||
{"id": 191, "subject": "M7-B5: Search plumbing — message + actor + comm + service", "classification": "standard", "status": "pending", "blockedBy": [189, 190]},
|
||||
{"id": 192, "subject": "M7-B6: NodeBrowserDialog — load-more + search box + type column", "classification": "standard", "status": "pending", "blockedBy": [189, 191]},
|
||||
{"id": 193, "subject": "M7-B7: Verify-endpoint — message + site probe handler", "classification": "high-risk", "status": "pending", "blockedBy": [191]},
|
||||
{"id": 194, "subject": "M7-B8: Verify-endpoint plumbing + UI", "classification": "standard", "status": "pending", "blockedBy": [193]},
|
||||
{"id": 195, "subject": "M7-B9: Cert trust — per-node CertStore actor + broadcast", "classification": "high-risk", "status": "pending", "blockedBy": [193]},
|
||||
{"id": 196, "subject": "M7-B10: Cert trust plumbing + cert-management UI", "classification": "standard", "status": "pending", "blockedBy": [194, 195]},
|
||||
{"id": 197, "subject": "M7-C1: PendingSecuredWrite entity + persistence + migration", "classification": "high-risk", "status": "pending"},
|
||||
{"id": 198, "subject": "M7-C2: Secured-write commands + submit/reject/list handlers", "classification": "high-risk", "status": "pending", "blockedBy": [197, 186]},
|
||||
{"id": 199, "subject": "M7-C3: Approve → site write relay", "classification": "high-risk", "status": "pending", "blockedBy": [198]},
|
||||
{"id": 200, "subject": "M7-C4: AuditKind.SecuredWrite + audit wiring", "classification": "high-risk", "status": "pending", "blockedBy": [198, 199]},
|
||||
{"id": 201, "subject": "M7-C5: Secured Writes Central UI page", "classification": "standard", "status": "pending", "blockedBy": [198, 199, 186]},
|
||||
{"id": 202, "subject": "M7-D1: OverrideCsvParser pure helper", "classification": "standard", "status": "pending"},
|
||||
{"id": 203, "subject": "M7-D2: InstanceConfigure CSV import UI", "classification": "standard", "status": "pending", "blockedBy": [202]},
|
||||
{"id": 204, "subject": "M7-D3: CLI instance import-overrides --file", "classification": "small", "status": "pending", "blockedBy": [202]},
|
||||
{"id": 205, "subject": "M7-E1: Integration — docs, full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "pending", "blockedBy": [184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204]}
|
||||
{"id": 184, "subject": "M7-A1: Extract AlarmStateBadges shared component", "classification": "standard", "status": "completed"},
|
||||
{"id": 185, "subject": "M7-A2: Operator Alarm Summary page + fan-out service", "classification": "standard", "status": "completed", "blockedBy": [184]},
|
||||
{"id": 186, "subject": "M7-A3: Operator + Verifier roles + policies + LDAP mapping", "classification": "high-risk", "status": "completed"},
|
||||
{"id": 187, "subject": "M7-B1: Browse type-info fields on BrowseNode", "classification": "standard", "status": "completed"},
|
||||
{"id": 188, "subject": "M7-B2: BrowseNext continuation through browse contract", "classification": "high-risk", "status": "completed", "blockedBy": [187]},
|
||||
{"id": 189, "subject": "M7-B3: Thread continuation token through browse plumbing", "classification": "standard", "status": "completed", "blockedBy": [188]},
|
||||
{"id": 190, "subject": "M7-B4: Bounded recursive address-space search — adapter", "classification": "high-risk", "status": "completed", "blockedBy": [188]},
|
||||
{"id": 191, "subject": "M7-B5: Search plumbing — message + actor + comm + service", "classification": "standard", "status": "completed", "blockedBy": [189, 190]},
|
||||
{"id": 192, "subject": "M7-B6: NodeBrowserDialog — load-more + search box + type column", "classification": "standard", "status": "completed", "blockedBy": [189, 191]},
|
||||
{"id": 193, "subject": "M7-B7: Verify-endpoint — message + site probe handler", "classification": "high-risk", "status": "completed", "blockedBy": [191]},
|
||||
{"id": 194, "subject": "M7-B8: Verify-endpoint plumbing + UI", "classification": "standard", "status": "completed", "blockedBy": [193]},
|
||||
{"id": 195, "subject": "M7-B9: Cert trust — per-node CertStore actor + broadcast", "classification": "high-risk", "status": "completed", "blockedBy": [193]},
|
||||
{"id": 196, "subject": "M7-B10: Cert trust plumbing + cert-management UI", "classification": "standard", "status": "completed", "blockedBy": [194, 195]},
|
||||
{"id": 197, "subject": "M7-C1: PendingSecuredWrite entity + persistence + migration", "classification": "high-risk", "status": "completed"},
|
||||
{"id": 198, "subject": "M7-C2: Secured-write commands + submit/reject/list handlers", "classification": "high-risk", "status": "completed", "blockedBy": [197, 186]},
|
||||
{"id": 199, "subject": "M7-C3: Approve → site write relay", "classification": "high-risk", "status": "completed", "blockedBy": [198]},
|
||||
{"id": 200, "subject": "M7-C4: AuditKind.SecuredWrite + audit wiring", "classification": "high-risk", "status": "completed", "blockedBy": [198, 199]},
|
||||
{"id": 201, "subject": "M7-C5: Secured Writes Central UI page", "classification": "standard", "status": "completed", "blockedBy": [198, 199, 186]},
|
||||
{"id": 202, "subject": "M7-D1: OverrideCsvParser pure helper", "classification": "standard", "status": "completed"},
|
||||
{"id": 203, "subject": "M7-D2: InstanceConfigure CSV import UI", "classification": "standard", "status": "completed", "blockedBy": [202]},
|
||||
{"id": 204, "subject": "M7-D3: CLI instance import-overrides --file", "classification": "small", "status": "completed", "blockedBy": [202]},
|
||||
{"id": 205, "subject": "M7-E1: Integration — docs, full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "completed", "blockedBy": [184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204]}
|
||||
],
|
||||
"waves": {
|
||||
"A": [184, 185, 186],
|
||||
@@ -34,5 +34,6 @@
|
||||
"D": [202, 203, 204],
|
||||
"E": [205]
|
||||
},
|
||||
"lastUpdated": "2026-06-18"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-18-m8-transport.md",
|
||||
"tasks": [
|
||||
{"id": 210, "subject": "M8-A1: Commons name-map types + preview/selection/summary extensions", "status": "pending"},
|
||||
{"id": 211, "subject": "M8-A2: Transport bundle DTOs — Site/DataConnection/Instance + BundleContentDto", "status": "pending"},
|
||||
{"id": 212, "subject": "M8-A3: LineDiffer — pure Myers per-line diff helper (T20)", "status": "pending"},
|
||||
{"id": 213, "subject": "M8-B1: DependencyResolver site/instance expansion", "status": "pending", "blockedBy": [211]},
|
||||
{"id": 214, "subject": "M8-B2: EntitySerializer site/connection/instance mapping (both directions)", "status": "pending", "blockedBy": [211]},
|
||||
{"id": 215, "subject": "M8-B3: ManifestBuilder + summary counts + schemaVersion 1.1", "status": "pending", "blockedBy": [210]},
|
||||
{"id": 216, "subject": "M8-B4: Export plumbing — selection wiring, command, ManagementActor, CLI", "status": "pending", "blockedBy": [213, 214, 215]},
|
||||
{"id": 217, "subject": "M8-C1: ArtifactDiff — Myers code diff (T20) + Site/Connection/Instance compares", "status": "pending", "blockedBy": [211, 212]},
|
||||
{"id": 218, "subject": "M8-C2: BundleImporter.PreviewAsync — new-type diff + required-mapping detection + blockers", "status": "pending", "blockedBy": [214, 217]},
|
||||
{"id": 219, "subject": "M8-D1: BundleImporter.ApplyAsync — nameMap, resolve-or-create, instance upsert + FK rewire", "status": "pending", "blockedBy": [218]},
|
||||
{"id": 220, "subject": "M8-D2: #16 — real stale-instance enumeration in ImportResult", "status": "pending", "blockedBy": [219]},
|
||||
{"id": 221, "subject": "M8-D3: Import plumbing — name-map through command, ManagementActor, CLI", "status": "pending", "blockedBy": [219, 216]},
|
||||
{"id": 222, "subject": "M8-E1: Export wizard — Sites/Instances selection", "status": "pending", "blockedBy": [216]},
|
||||
{"id": 223, "subject": "M8-E2: Import wizard — Map step + Modified +/- diff render", "status": "pending", "blockedBy": [221]},
|
||||
{"id": 224, "subject": "M8-INT: Docs, full build, docker rebuild, Playwright, live smoke, end-to-end trace", "status": "pending", "blockedBy": [222, 223]}
|
||||
{"id": 210, "subject": "M8-A1: Commons name-map types + preview/selection/summary extensions", "status": "completed"},
|
||||
{"id": 211, "subject": "M8-A2: Transport bundle DTOs — Site/DataConnection/Instance + BundleContentDto", "status": "completed"},
|
||||
{"id": 212, "subject": "M8-A3: LineDiffer — pure Myers per-line diff helper (T20)", "status": "completed"},
|
||||
{"id": 213, "subject": "M8-B1: DependencyResolver site/instance expansion", "status": "completed", "blockedBy": [211]},
|
||||
{"id": 214, "subject": "M8-B2: EntitySerializer site/connection/instance mapping (both directions)", "status": "completed", "blockedBy": [211]},
|
||||
{"id": 215, "subject": "M8-B3: ManifestBuilder + summary counts + schemaVersion 1.1", "status": "completed", "blockedBy": [210]},
|
||||
{"id": 216, "subject": "M8-B4: Export plumbing — selection wiring, command, ManagementActor, CLI", "status": "completed", "blockedBy": [213, 214, 215]},
|
||||
{"id": 217, "subject": "M8-C1: ArtifactDiff — Myers code diff (T20) + Site/Connection/Instance compares", "status": "completed", "blockedBy": [211, 212]},
|
||||
{"id": 218, "subject": "M8-C2: BundleImporter.PreviewAsync — new-type diff + required-mapping detection + blockers", "status": "completed", "blockedBy": [214, 217]},
|
||||
{"id": 219, "subject": "M8-D1: BundleImporter.ApplyAsync — nameMap, resolve-or-create, instance upsert + FK rewire", "status": "completed", "blockedBy": [218]},
|
||||
{"id": 220, "subject": "M8-D2: #16 — real stale-instance enumeration in ImportResult", "status": "completed", "blockedBy": [219]},
|
||||
{"id": 221, "subject": "M8-D3: Import plumbing — name-map through command, ManagementActor, CLI", "status": "completed", "blockedBy": [219, 216]},
|
||||
{"id": 222, "subject": "M8-E1: Export wizard — Sites/Instances selection", "status": "completed", "blockedBy": [216]},
|
||||
{"id": 223, "subject": "M8-E2: Import wizard — Map step + Modified +/- diff render", "status": "completed", "blockedBy": [221]},
|
||||
{"id": 224, "subject": "M8-INT: Docs, full build, docker rebuild, Playwright, live smoke, end-to-end trace", "status": "completed", "blockedBy": [222, 223]}
|
||||
],
|
||||
"lastUpdated": "2026-06-18"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -4,23 +4,23 @@
|
||||
"branch": "worktree-m9-templates-authoring",
|
||||
"baseRef": "72aec3b4",
|
||||
"tasks": [
|
||||
{"id": 240, "label": "T22", "subject": "M9-T22: Template tree search box", "classification": "small", "wave": 1, "status": "pending"},
|
||||
{"id": 241, "label": "CLI", "subject": "M9-CLI: cached-call retry/discard CLI group", "classification": "small", "wave": 1, "status": "pending"},
|
||||
{"id": 242, "label": "T28a", "subject": "M9-T28a: Strict expression-trigger kind — backend", "classification": "small", "wave": 1, "status": "pending"},
|
||||
{"id": 243, "label": "T28b", "subject": "M9-T28b: Strict trigger-kind — UI selector + CLI flag", "classification": "small", "wave": 1, "status": "pending", "blockedBy": [242]},
|
||||
{"id": 244, "label": "T23a", "subject": "M9-T23a: Folder sibling reorder — service + command + handler", "classification": "standard", "wave": 2, "status": "pending"},
|
||||
{"id": 245, "label": "T23b", "subject": "M9-T23b: Folder reorder + root context menu — UI", "classification": "standard", "wave": 2, "status": "pending", "blockedBy": [240, 244]},
|
||||
{"id": 246, "label": "T25", "subject": "M9-T25: Connection live-status indicators", "classification": "standard", "wave": 2, "status": "pending"},
|
||||
{"id": 247, "label": "T24a", "subject": "M9-T24a: Move data connection between sites — command + handler + guards", "classification": "high-risk", "wave": 3, "status": "pending"},
|
||||
{"id": 248, "label": "T24b", "subject": "M9-T24b: Move connection — UI dialog + action", "classification": "standard", "wave": 3, "status": "pending", "blockedBy": [247, 246]},
|
||||
{"id": 249, "label": "T32a", "subject": "M9-T32a: SharedSchema entity + EF config + migration + repo", "classification": "high-risk", "wave": 3, "status": "pending"},
|
||||
{"id": 250, "label": "T32b", "subject": "M9-T32b: JSON Schema $ref resolver + deploy-time validation", "classification": "high-risk", "wave": 3, "status": "pending", "blockedBy": [249]},
|
||||
{"id": 251, "label": "T32c", "subject": "M9-T32c: Schema library — CRUD commands + handlers + Central UI page", "classification": "high-risk", "wave": 4, "status": "pending", "blockedBy": [249, 250]},
|
||||
{"id": 252, "label": "T30", "subject": "M9-T30: Schema-driven nested value-entry forms", "classification": "standard", "wave": 4, "status": "pending", "blockedBy": [250]},
|
||||
{"id": 253, "label": "T31", "subject": "M9-T31: Monaco JSON-Schema hover/completion", "classification": "standard", "wave": 4, "status": "pending", "blockedBy": [250, 252]},
|
||||
{"id": 254, "label": "T26a", "subject": "M9-T26a: Inheritance resolve service + query command", "classification": "high-risk", "wave": 4, "status": "pending"},
|
||||
{"id": 255, "label": "T26b", "subject": "M9-T26b: TemplateEdit — full inherited set + staleness banner", "classification": "standard", "wave": 4, "status": "pending", "blockedBy": [254]},
|
||||
{"id": 256, "label": "INT", "subject": "M9-INT: Integration — build, docker, Playwright, smoke, end-to-end trace", "classification": "high-risk", "wave": 5, "status": "pending", "blockedBy": [240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]}
|
||||
{"id": 240, "label": "T22", "subject": "M9-T22: Template tree search box", "classification": "small", "wave": 1, "status": "completed"},
|
||||
{"id": 241, "label": "CLI", "subject": "M9-CLI: cached-call retry/discard CLI group", "classification": "small", "wave": 1, "status": "completed"},
|
||||
{"id": 242, "label": "T28a", "subject": "M9-T28a: Strict expression-trigger kind — backend", "classification": "small", "wave": 1, "status": "completed"},
|
||||
{"id": 243, "label": "T28b", "subject": "M9-T28b: Strict trigger-kind — UI selector + CLI flag", "classification": "small", "wave": 1, "status": "completed", "blockedBy": [242]},
|
||||
{"id": 244, "label": "T23a", "subject": "M9-T23a: Folder sibling reorder — service + command + handler", "classification": "standard", "wave": 2, "status": "completed"},
|
||||
{"id": 245, "label": "T23b", "subject": "M9-T23b: Folder reorder + root context menu — UI", "classification": "standard", "wave": 2, "status": "completed", "blockedBy": [240, 244]},
|
||||
{"id": 246, "label": "T25", "subject": "M9-T25: Connection live-status indicators", "classification": "standard", "wave": 2, "status": "completed"},
|
||||
{"id": 247, "label": "T24a", "subject": "M9-T24a: Move data connection between sites — command + handler + guards", "classification": "high-risk", "wave": 3, "status": "completed"},
|
||||
{"id": 248, "label": "T24b", "subject": "M9-T24b: Move connection — UI dialog + action", "classification": "standard", "wave": 3, "status": "completed", "blockedBy": [247, 246]},
|
||||
{"id": 249, "label": "T32a", "subject": "M9-T32a: SharedSchema entity + EF config + migration + repo", "classification": "high-risk", "wave": 3, "status": "completed"},
|
||||
{"id": 250, "label": "T32b", "subject": "M9-T32b: JSON Schema $ref resolver + deploy-time validation", "classification": "high-risk", "wave": 3, "status": "completed", "blockedBy": [249]},
|
||||
{"id": 251, "label": "T32c", "subject": "M9-T32c: Schema library — CRUD commands + handlers + Central UI page", "classification": "high-risk", "wave": 4, "status": "completed", "blockedBy": [249, 250]},
|
||||
{"id": 252, "label": "T30", "subject": "M9-T30: Schema-driven nested value-entry forms", "classification": "standard", "wave": 4, "status": "completed", "blockedBy": [250]},
|
||||
{"id": 253, "label": "T31", "subject": "M9-T31: Monaco JSON-Schema hover/completion", "classification": "standard", "wave": 4, "status": "completed", "blockedBy": [250, 252]},
|
||||
{"id": 254, "label": "T26a", "subject": "M9-T26a: Inheritance resolve service + query command", "classification": "high-risk", "wave": 4, "status": "completed"},
|
||||
{"id": 255, "label": "T26b", "subject": "M9-T26b: TemplateEdit — full inherited set + staleness banner", "classification": "standard", "wave": 4, "status": "completed", "blockedBy": [254]},
|
||||
{"id": 256, "label": "INT", "subject": "M9-INT: Integration — build, docker, Playwright, smoke, end-to-end trace", "classification": "high-risk", "wave": 5, "status": "completed", "blockedBy": [240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]}
|
||||
],
|
||||
"serializationPoints": {
|
||||
"ManagementActor.cs+ManagementCommandRegistry.cs": [244, 247, 251, 254],
|
||||
@@ -30,5 +30,6 @@
|
||||
"TemplateEdit.razor": [243, 255],
|
||||
"ParameterValueForm.razor/value-entry-surface": [252, 253]
|
||||
},
|
||||
"lastUpdated": "2026-06-18"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-19-sms-notifications.md",
|
||||
"tasks": [
|
||||
{"id": 299, "subject": "S1: Commons foundation — NotificationType.Sms + recipient phone + SmsConfiguration + repo iface", "status": "pending"},
|
||||
{"id": 300, "subject": "S2: Config-DB — EF mappings + AuthToken encryption + idempotent migration", "status": "pending", "blockedBy": [299]},
|
||||
{"id": 303, "subject": "S5: Management — list-command Type + SMS-config commands/handlers", "status": "pending", "blockedBy": [299]},
|
||||
{"id": 301, "subject": "S3: SmsNotificationDeliveryAdapter (Twilio REST) + classifier + options + DI + tests", "status": "pending", "blockedBy": [299, 300]},
|
||||
{"id": 302, "subject": "S4: NotificationOutboxActor ingest type-stamping from list", "status": "pending", "blockedBy": [299]},
|
||||
{"id": 304, "subject": "S6: CLI — list --type/--phones + notification sms group", "status": "pending", "blockedBy": [303]},
|
||||
{"id": 305, "subject": "S7: Central UI — NotificationListForm adapter-gated Type selector + per-type recipient input", "status": "pending", "blockedBy": [299, 303]},
|
||||
{"id": 306, "subject": "S8: Central UI — NotificationLists Type column", "status": "pending", "blockedBy": [299]},
|
||||
{"id": 307, "subject": "S9: Central UI — SMS configuration page (/notifications/sms)", "status": "pending", "blockedBy": [300, 303]},
|
||||
{"id": 308, "subject": "S10: Transport — recipient PhoneNumber DTO + SmsConfigDto round-trip", "status": "pending", "blockedBy": [299]},
|
||||
{"id": 309, "subject": "S11: INT — build, drift, docker, Playwright, live smoke, docs, whole-branch review", "status": "pending", "blockedBy": [300, 301, 302, 303, 304, 305, 306, 307, 308]}
|
||||
{"id": 299, "subject": "S1: Commons foundation — NotificationType.Sms + recipient phone + SmsConfiguration + repo iface", "status": "completed"},
|
||||
{"id": 300, "subject": "S2: Config-DB — EF mappings + AuthToken encryption + idempotent migration", "status": "completed", "blockedBy": [299]},
|
||||
{"id": 303, "subject": "S5: Management — list-command Type + SMS-config commands/handlers", "status": "completed", "blockedBy": [299]},
|
||||
{"id": 301, "subject": "S3: SmsNotificationDeliveryAdapter (Twilio REST) + classifier + options + DI + tests", "status": "completed", "blockedBy": [299, 300]},
|
||||
{"id": 302, "subject": "S4: NotificationOutboxActor ingest type-stamping from list", "status": "completed", "blockedBy": [299]},
|
||||
{"id": 304, "subject": "S6: CLI — list --type/--phones + notification sms group", "status": "completed", "blockedBy": [303]},
|
||||
{"id": 305, "subject": "S7: Central UI — NotificationListForm adapter-gated Type selector + per-type recipient input", "status": "completed", "blockedBy": [299, 303]},
|
||||
{"id": 306, "subject": "S8: Central UI — NotificationLists Type column", "status": "completed", "blockedBy": [299]},
|
||||
{"id": 307, "subject": "S9: Central UI — SMS configuration page (/notifications/sms)", "status": "completed", "blockedBy": [300, 303]},
|
||||
{"id": 308, "subject": "S10: Transport — recipient PhoneNumber DTO + SmsConfigDto round-trip", "status": "completed", "blockedBy": [299]},
|
||||
{"id": 309, "subject": "S11: INT — build, drift, docker, Playwright, live smoke, docs, whole-branch review", "status": "completed", "blockedBy": [300, 301, 302, 303, 304, 305, 306, 307, 308]}
|
||||
],
|
||||
"lastUpdated": "2026-06-19"
|
||||
"lastUpdated": "2026-08-01",
|
||||
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
**Date:** 2026-06-26 · **Status:** APPROVED (design) · **Area:** Deployment Manager / Site Runtime / Cluster Communication
|
||||
**Fixes:** [`docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`](../known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md)
|
||||
|
||||
> **2026-08-01 bookkeeping sync:** the central→site ClusterClient hop described below is now **gRPC** (`SiteCommandService`/`GrpcSiteTransport`) after the 2026-07-22 ClusterClient→gRPC migration — the notify-and-fetch behavior described here is unchanged.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The flattened instance config travels over Akka on **two** hops today, and both are bounded by Akka.Remote's default `maximum-frame-size` (**128 KB / `128000b`**). A large config (e.g. a 3rd composition of the same base template) silently breaks both:
|
||||
@@ -177,7 +179,7 @@ Smoke-tested on the docker cluster (rebuilt from this branch). Validated end-to-
|
||||
|
||||
The smoke surfaced two real bugs in the reconciliation path (missed by unit/integration tests because those didn't have a second concurrent node or a lingering expired row), both fixed:
|
||||
1. **Concurrent-gap omit** — when two nodes were concurrently missing the same instance, the second node's `StagePendingIfAbsentAsync` returned false and the handler *omitted* the item, leaving that node unhealed. Fix: on false, return the **existing** pending row's deploymentId + token (multi-use within TTL) so all concurrently-missing nodes heal in the same round.
|
||||
2. **Expired pending row blocks self-heal** — `StagePendingIfAbsentAsync` checked existence by `InstanceId` ignoring expiry, so an expired-but-unpurged row (the periodic purge is still a deferred TODO) blocked a fresh stage *and* would collide with the snapshot's reused `DeploymentId` on the unique index. Fix: **expiry-aware staging** — delete expired rows for the instance first, then check only live rows; `GetPendingDeploymentByInstanceIdAsync` filters by expiry. This also opportunistically cleans expired rows, reducing reliance on the deferred periodic purge.
|
||||
2. **Expired pending row blocks self-heal** — `StagePendingIfAbsentAsync` checked existence by `InstanceId` ignoring expiry, so an expired-but-unpurged row (the periodic purge was a deferred TODO at the time — **RESOLVED 2026-08-01 bookkeeping sync: `PendingDeploymentPurgeActor` shipped in PLAN-04; deferred-work register row #20 closed**) blocked a fresh stage *and* would collide with the snapshot's reused `DeploymentId` on the unique index. Fix: **expiry-aware staging** — delete expired rows for the instance first, then check only live rows; `GetPendingDeploymentByInstanceIdAsync` filters by expiry. This also opportunistically cleans expired rows, reducing reliance on the deferred periodic purge.
|
||||
|
||||
## 12. Affected files (for the plan)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# MES Alarm-Status API — Implementation Plan
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Status:** Draft plan — NOT yet executed. Captures design + task breakdown for review.
|
||||
**Component touchpoints:** Inbound API (#14), Script Analysis (#25), Site Runtime (#3), Template Engine (#1) — plus deployed config (inbound methods + CvdReactor/MESReceiver template scripts).
|
||||
**Status:** **Phase 1 [repo] COMPLETE 2026-08-01** — the `Alarms.CurrentAsync()` script accessor and the `AckTime` native-mirror enrichment are shipped (see §7). Phases 2–4 are **deployed config** (inbound methods + CvdReactor template scripts) and still pending; they need a live rig. All §6 open questions DECIDED 2026-08-01 (design review with user).
|
||||
**Component touchpoints:** Inbound API (#14), Script Analysis (#25), Site Runtime (#3), Template Engine (#1) — plus deployed config (inbound methods + CvdReactor template scripts).
|
||||
|
||||
---
|
||||
|
||||
@@ -12,9 +12,9 @@ Port the legacy **WWSupport / APIServer** MES alarm-status endpoints onto ScadaB
|
||||
|
||||
1. **Update** the existing `SimpleAlarmStatusRequest` inbound method (currently a stub) to do real work.
|
||||
2. **Create** a new `AlarmStatus` inbound method (the full, filtered endpoint).
|
||||
3. Both inbound methods **forward to MES-receiver site scripts** (`SAPID → BTDB machine lookup → Route.To(code).Call(...)`), exactly like `MesMoveIn`.
|
||||
4. The **generic version on the `MESReceiver` template** resolves the machine's alarms by **querying the BTDB `MachineAlarm` table** (the legacy approach).
|
||||
5. The **`CvdReactor` template provides an override version** that reads the **alarms actually defined on the CvdReactor object** (its 7 native alarm-source bindings) directly — no `MachineAlarm` lookup.
|
||||
3. Both inbound methods **forward to site scripts** (`SAPID → BTDB machine lookup → Route.To(code).Call(...)`), exactly like `MesMoveIn`.
|
||||
4. ~~The generic version on the `MESReceiver` template queries the BTDB `MachineAlarm` table.~~ **DROPPED (Q2, 2026-08-01):** no MESReceiver version ships — a config-only answer with no live state was judged not worth having. Machines whose template doesn't implement the scripts get a clean `WasSuccessful=false` "not supported on this machine" error from the inbound router.
|
||||
5. The **`CvdReactor` template provides the (only) implementation**, reading the **alarms actually defined on the CvdReactor object** (its 7 native alarm-source bindings) directly — no `MachineAlarm` lookup anywhere.
|
||||
|
||||
Legacy spec being mirrored: [`docs/former-api-specs/mes/Alarm-API.md`](../former-api-specs/mes/Alarm-API.md).
|
||||
|
||||
@@ -74,10 +74,11 @@ From the site-script API map:
|
||||
```
|
||||
INBOUND (central) ENABLING CODE (repo) SITE TEMPLATE SCRIPTS (deployed config)
|
||||
───────────────── ──────────────────── ───────────────────────────────────────
|
||||
SimpleAlarmStatusRequest ─┐ Alarms script accessor ┌────► MESReceiver.SimpleAlarmStatus (BTDB MachineAlarm)
|
||||
├─ Route.To(code).Call(...) ──┤ MESReceiver.AlarmStatus (BTDB MachineAlarm)
|
||||
AlarmStatus (new) ─┘ (ScriptRuntimeContext + └────► CvdReactor.SimpleAlarmStatus (native sources — OVERRIDE)
|
||||
ScriptCompileSurface) CvdReactor.AlarmStatus (native sources — OVERRIDE)
|
||||
SimpleAlarmStatusRequest ─┐ Alarms script accessor
|
||||
├─ Route.To(code).Call(...) ─────────► CvdReactor.SimpleAlarmStatus (native sources)
|
||||
AlarmStatus (new) ─┘ + AckTime mirror enrichment ──► CvdReactor.AlarmStatus (native sources)
|
||||
(ScriptRuntimeContext +
|
||||
ScriptCompileSurface) (no MESReceiver version — Q2 DECIDED: dropped)
|
||||
```
|
||||
|
||||
### 5.1 Layer A — Inbound API methods (deployed config; central)
|
||||
@@ -110,6 +111,8 @@ try {
|
||||
|
||||
> **Note:** Machine resolution stays on the inbound side (central) because it is environment/SQL-shaped and identical for both endpoints; site scripts receive an already-resolved `MachineCode` plus the alarm filter.
|
||||
|
||||
> **Unsupported machines (Q2 DECIDED):** since only CvdReactor implements the site scripts, `Route.Call` on any other machine fails script-not-found. Both routers catch that case specifically and return `{ WasSuccessful=false, ErrorText = $"Alarm status is not supported on machine '{code}'" }` instead of the raw exception text (verify during impl what exception/message `Route.Call` yields for a missing script so the catch can distinguish it).
|
||||
|
||||
### 5.2 Layer B — Enabling code change: script-facing `Alarms` accessor (repo)
|
||||
|
||||
New read-only accessor on the site script surface so scripts can enumerate the instance's current alarms.
|
||||
@@ -132,9 +135,14 @@ public sealed record ScriptAlarm(
|
||||
string Message, string AlarmTypeName, string Category,
|
||||
string OperatorUser, string OperatorComment,
|
||||
DateTimeOffset? OriginalRaiseTime, DateTimeOffset Timestamp,
|
||||
DateTimeOffset? AckTime, // Q4 DECIDED: real ack timestamp from the enriched mirror
|
||||
string CurrentValue, string LimitValue, bool IsConfiguredPlaceholder);
|
||||
```
|
||||
|
||||
**Ack-timestamp enrichment (Q4 DECIDED 2026-08-01 — enrich the mirror now, not a follow-up):**
|
||||
- Add an additive `AckTime` (`DateTimeOffset?`) to `AlarmStateChanged`, the vendored `AlarmStateUpdate` proto (additive field number, never reuse — manual toggle-build-copy-untoggle regen), and the site `native_alarm_state` persistence so it survives failover.
|
||||
- Semantics: when the underlying source supplies a true ack time (OPC UA A&C ack transitions do), use it; when it doesn't (MxGateway events without one), stamp the observation time of the ack transition at the DCL — accurate to when the system saw the ack, never fabricated. Null while unacked; cleared on re-raise.
|
||||
|
||||
Implementation:
|
||||
- Runtime: `Alarms.CurrentAsync()` Asks the Instance Actor for its alarm snapshot (reuse the existing internal alarm-state map that feeds `DebugViewSnapshot.AlarmStates`; project each `AlarmStateChanged` → `ScriptAlarm`). New internal request/response message (or reuse `DebugSnapshotRequest` and project off `AlarmStates`).
|
||||
- Compile surface: matching stub returning `Task.FromResult(empty)` so design-time compile + Test Run pass.
|
||||
@@ -143,13 +151,9 @@ Implementation:
|
||||
|
||||
### 5.3 Layer C — Site template scripts (deployed config)
|
||||
|
||||
**MESReceiver (generic / "as before" — queries BTDB `MachineAlarm`):** `SimpleAlarmStatus` and `AlarmStatus`.
|
||||
- Use `await Database.Connection("BTDB")` (raw ADO.NET — site Database helper has **no** `QuerySingleAsync`; that's inbound-only) to read the machine's `MachineAlarm` rows (`Name`, `Severity`, `FlaggedForMES`, …) for the resolved `MachineCode`.
|
||||
- Apply legacy filter semantics (Simple = flagged-only; full = `FlaggedOnly/MinSeverity/MaxSeverity/NameFilter`).
|
||||
- Determine live "triggered" state + ack/timestamps and project to `AlarmInfo` (**live-state source = open question §6.2**).
|
||||
- **No `_A`/`_B` side parsing.** This generic version is **whole-machine**, matching the legacy endpoint — it accepts a bare/numeric SAPID and never requires (or errors on) a missing side suffix. Side routing is **CvdReactor-only** (§5.3).
|
||||
**MESReceiver version — DROPPED (Q2 DECIDED 2026-08-01).** With no native alarm sources, MESReceiver has no live "triggered" signal; a configured-catalog-only answer was judged misleading rather than useful. No BTDB `MachineAlarm` read ships anywhere in this feature. Machines without the CvdReactor-style scripts get the router's "not supported on machine" error (§5.1).
|
||||
|
||||
**CvdReactor (override — reads native sources directly, no DB):** `SimpleAlarmStatus` and `AlarmStatus` as **root-level scripts** (so `Route.To(code).Call("SimpleAlarmStatus")` resolves CvdReactor's version for CvdReactor instances — mirrors `MesMoveIn`).
|
||||
**CvdReactor (the only implementation — reads native sources directly, no DB):** `SimpleAlarmStatus` and `AlarmStatus` (Q5 DECIDED: names match the endpoints, template-agnostic contract) as **root-level scripts** (mirrors `MesMoveIn`).
|
||||
|
||||
The override **routes left vs right off the SAPID suffix** — `_A` ⇒ Left, `_B` ⇒ Right — and scopes the returned alarms to that side's native sources plus the shared reactor-wide source. The `_LT` (leak-test) suffix is **ignored** for scoping: it is stripped before reading the side, and both the side's run and leak-test sources are included. Source → side map (the 7 `CvdReactor` native sources):
|
||||
|
||||
@@ -160,6 +164,10 @@ The override **routes left vs right off the SAPID suffix** — `_A` ⇒ Left, `_
|
||||
|
||||
```csharp
|
||||
// CvdReactor.SimpleAlarmStatus (sketch)
|
||||
// Q1 DECIDED: MES relevance = dedicated severity band. Galaxy alarm priorities for
|
||||
// MES-relevant alarms are configured into 900-999; nothing else may use that band.
|
||||
const int MesBandMin = 900;
|
||||
const int MesBandMax = 999;
|
||||
try {
|
||||
var raw = (Parameters["SAPID"] as string) ?? "";
|
||||
var code = Parameters["MachineCode"]?.ToString() ?? "";
|
||||
@@ -180,16 +188,17 @@ try {
|
||||
var infos = alarms
|
||||
.Where(a => a.Active && !a.IsConfiguredPlaceholder) // only triggered
|
||||
.Where(a => InScope(a.NativeSourceCanonicalName)) // _A => Left*, _B => Right*, + ReactorAlarms
|
||||
// SimpleAlarmStatus: flagged-only + acked always included (see §6.1 for FlaggedForMES)
|
||||
// SimpleAlarmStatus semantics: flagged-only (= MES band, Q1) + acked always included
|
||||
.Where(a => a.Severity >= MesBandMin && a.Severity <= MesBandMax)
|
||||
.Select(a => new {
|
||||
Name = a.Name,
|
||||
HierarchicalName = code + "." + a.Name,
|
||||
Description = a.Message, // §6.3
|
||||
IsFlaggedForMES = true, // §6.1
|
||||
Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message, // Q3 DECIDED
|
||||
IsFlaggedForMES = a.Severity >= MesBandMin && a.Severity <= MesBandMax, // Q1 DECIDED: real predicate
|
||||
Severity = a.Severity,
|
||||
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
|
||||
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp),
|
||||
AckDT = (DateTime?)null, // §6.4
|
||||
AckDT = a.AckTime, // Q4 DECIDED: enriched mirror
|
||||
AckComment = a.OperatorComment,
|
||||
}).ToList();
|
||||
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos };
|
||||
@@ -197,29 +206,27 @@ try {
|
||||
return new { WasSuccessful = false, ErrorText = "SimpleAlarmStatus failed: " + ex.Message, Alarms = Array.Empty<object>() };
|
||||
}
|
||||
```
|
||||
`CvdReactor.AlarmStatus` is the same — same side parsing + `InScope` filter — but additionally applies the passed `AlarmFilter` (NameFilter/MinSeverity/MaxSeverity/FlaggedOnly/IncludeAcked) over the scoped native list. (When `AlarmStatus` was selected by `Code`/`ZTag`/`MachineID` with no SAPID suffix, `side == null`; in that case return all sources instead of erroring — see §6.6.)
|
||||
`CvdReactor.AlarmStatus` is the same — same side parsing + `InScope` filter, WITHOUT the always-on band filter — but applies the passed `AlarmFilter` (NameFilter/MinSeverity/MaxSeverity/FlaggedOnly/IncludeAcked) over the scoped native list; `FlaggedOnly=true` means the MES-band predicate (Q1). `IsFlaggedForMES` is always reported per-row from the band predicate. (When `AlarmStatus` was selected by `Code`/`ZTag`/`MachineID` with no SAPID suffix, `side == null`; in that case return all sources instead of erroring — Q6b DECIDED.)
|
||||
|
||||
> **Shared `ReactorAlarms` (`Z28061.`) is included on both sides** — reactor-wide faults apply regardless of side. Flip this if MES wants strictly side-local alarms (drop the `StartsWith("Reactor")` clause).
|
||||
> **Shared `ReactorAlarms` (`Z28061.`) is included on both sides — Q6a DECIDED 2026-08-01:** reactor-wide faults apply regardless of which side MES asks about.
|
||||
|
||||
---
|
||||
|
||||
## 6. Key design decisions & OPEN QUESTIONS (resolve before execution)
|
||||
## 6. Key design decisions — ALL DECIDED 2026-08-01 (design review with user)
|
||||
|
||||
These are the Socratic checkpoints — they change script bodies and/or the contract.
|
||||
1. **`IsFlaggedForMES` for native alarms — DECIDED: severity band.** MES relevance is encoded in the alarm severity itself: MES-relevant alarms are configured (in the Galaxy alarm priority) into a **dedicated band 900–999** reserved exclusively for MES-relevant alarms, so it cannot collide with ordinary criticality tuning. Scripts carry `MesBandMin = 900` / `MesBandMax = 999` as named constants; `IsFlaggedForMES = (Severity in band)` is reported honestly per row; `SimpleAlarmStatus` (always flagged-only) and `AlarmStatus` with `FlaggedOnly=true` filter by the band predicate. Since `Severity` is returned verbatim, MES's own `MinSeverity`/`MaxSeverity` filters compose naturally with the band. No new tables, code flags, or allow-lists. **Operational prerequisite:** the Galaxy alarm priorities for MES-relevant CvdReactor alarms must be set into 900–999 before the endpoints are meaningful.
|
||||
|
||||
1. **`IsFlaggedForMES` for native alarms.** The native model has no MES flag (it lived in `MachineAlarm.FlaggedForMES`). Options: (a) treat all native alarms as MES-relevant (`true`); (b) reintroduce an MES allow-list (per native source binding, or a small config/table) the override consults; (c) for `SimpleAlarmStatus` (flagged-only) return all, and only honor `FlaggedOnly` in the full endpoint by cross-referencing `MachineAlarm`. **Recommend (a)** for v1 with (b) as a follow-up, unless MES needs a true flag.
|
||||
2. **MESReceiver version — DECIDED: dropped entirely.** With no native alarm sources there is no live state; a config-only catalog answer was judged not worth shipping. Only CvdReactor implements the scripts; other machines get the router's clean "not supported on machine" error (§5.1). No BTDB `MachineAlarm` dependency remains.
|
||||
|
||||
2. **MESReceiver live-state source.** MESReceiver has no native alarm sources, so after reading `MachineAlarm` config it has no live "InAlarm" source. Options: (a) MESReceiver version returns **configured alarms only** (no live triggered filter) — explicitly a degraded/reference path; (b) MESReceiver version ALSO reads `Alarms.CurrentAsync()` and cross-references `MachineAlarm` by name (works only if the instance happens to mirror native alarms); (c) declare the BTDB-only path returns config and document that live status requires the CvdReactor-style override. **Recommend (c)** — the real live path is CvdReactor; the MESReceiver version demonstrates the MachineAlarm-driven catalog and is the fallback for machine types catalogued in SQL.
|
||||
3. **`Description` mapping — DECIDED:** `Message`, falling back to `AlarmTypeName` when `Message` is empty.
|
||||
|
||||
3. **`Description` mapping.** Legacy `Description` came from the MXAccess `DescAttrName` tag. `AlarmStateChanged` has no dedicated description; closest is `Message` (per-band operator text) / `AlarmTypeName` / `Category`. **Recommend `Message`, fall back to `AlarmTypeName`.** Confirm acceptable.
|
||||
4. **`AckDT` mapping — DECIDED: enrich the native mirror now.** Additive `AckTime` on `AlarmStateChanged` + vendored proto + `native_alarm_state` (survives failover). True source ack time where available (OPC UA A&C); DCL observation time of the ack transition where the source lacks one (MxGateway); null while unacked; cleared on re-raise. See §5.2.
|
||||
|
||||
4. **`AckDT` mapping.** No explicit ack-timestamp in `AlarmStateChanged` (`Timestamp` = last change, `OriginalRaiseTime` = raise). For acked alarms we can't precisely fill `AckDT`. Options: (a) leave `null`; (b) use `Timestamp` when `Acknowledged` (approximate); (c) enrich the native mirror to carry an ack timestamp (larger change). **Recommend (a)** for v1, (c) as a follow-up if MES depends on it.
|
||||
5. **Script naming — DECIDED: `SimpleAlarmStatus` / `AlarmStatus`** (match the endpoint names; template-agnostic contract any future machine template can implement). With the MESReceiver version dropped there is no parallel definition, so no override-resolution concern remains.
|
||||
|
||||
5. **Script naming / override resolution.** Keep script names **identical** across both templates (`SimpleAlarmStatus`, `AlarmStatus`) so the inbound method is template-agnostic (`Call("AlarmStatus")`), and CvdReactor's root-level definition is what `Route.Call` resolves for CvdReactor instances. (Alternative: `Mes`-prefixed CvdReactor scripts like `MesMoveIn`.) **Recommend identical names**; confirm `Route.Call` resolves a CvdReactor root script over any composed-module script of the same name (verify during impl — the MoveIn precedent says yes).
|
||||
6. **Side scoping — DECIDED (all parts).** CvdReactor-only: `_A` ⇒ Left, `_B` ⇒ Right; `_LT` stripped before reading the side (leak-test sources still included). (a) shared `ReactorAlarms` (`Z28061.`) **included on both sides** — reactor-wide faults apply regardless of side. (b) missing suffix: `SimpleAlarmStatus` **errors** on a SAPID without `_A`/`_B` (matches `MesMoveIn`); `AlarmStatus` selected by `Code`/`ZTag`/`MachineID` (no SAPID) **returns all sources**; a SAPID selector without a suffix errors.
|
||||
|
||||
6. **Side scoping — DECIDED, CvdReactor-only.** Only the **CvdReactor** override routes off the SAPID suffix: `_A` ⇒ Left, `_B` ⇒ Right; `_LT` is ignored (stripped before reading the side; leak-test sources still included). Mirrors `MesMoveIn`. The generic **MESReceiver** version and the inbound method do **not** parse or require the suffix — they stay whole-machine (legacy). Two residual sub-choices remain: (a) **shared `ReactorAlarms` (`Z28061.`) included on both sides** (current plan) vs strictly side-local — confirm; (b) **missing `_A`/`_B` suffix** — the **CvdReactor** `SimpleAlarmStatus` **errors** (matches `MesMoveIn`); the **CvdReactor** `AlarmStatus` selected by `Code`/`ZTag`/`MachineID` (no SAPID) **returns all sources** rather than erroring; the MESReceiver version never errors on a missing suffix — confirm acceptable.
|
||||
|
||||
7. **New method roles / auth.** `AlarmStatus` is a new inbound `ApiMethod` (`X-API-Key`); creating it requires `Roles.Designer`. No new global roles. Confirm the API key in use is authorized.
|
||||
7. **Roles / auth — DECIDED:** no new roles; create `AlarmStatus` as Designer via CLI; authorize the **existing MES API key** (the one already calling `MesMoveIn`/`MesMoveOut`) for both alarm endpoints — one key per external system.
|
||||
|
||||
---
|
||||
|
||||
@@ -227,26 +234,39 @@ These are the Socratic checkpoints — they change script bodies and/or the cont
|
||||
|
||||
> Two artifact classes: **[repo]** = source/tests/docs committed to git; **[deployed]** = inbound methods / template scripts pushed to the cluster via CLI/UI (not in repo). The user said "don't execute yet" — this is the ordered plan only.
|
||||
|
||||
**Phase 1 — Enabling `Alarms` script API [repo]**
|
||||
1. Add `AlarmsAccessor` + `ScriptAlarm` to the runtime context (`ScriptRuntimeContext`) — local Ask to the Instance Actor; project `AlarmStateChanged` → `ScriptAlarm`. Add internal request/response message if not reusing `DebugSnapshotRequest`.
|
||||
2. Mirror the stub on `ScriptCompileSurface` (and confirm `TriggerCompileSurface` not needed — trigger expressions don't read alarms).
|
||||
3. Confirm/extend `ScriptTrustPolicy` allow-list so `Alarms` is permitted; no new forbidden APIs.
|
||||
4. Unit tests: runtime accessor projection (active/acked/severity/timestamps), compile-surface compiles a representative `Alarms.CurrentAsync()` script, trust-policy accepts it.
|
||||
5. Doc: update `docs/requirements/Component-SiteRuntime.md` (+ Script Analysis #25 surface list) to document the `Alarms` accessor; note it in `Component-InboundAPI.md` routing examples.
|
||||
**Phase 1 — Enabling `Alarms` script API + AckTime mirror enrichment [repo]** — ✅ **DONE 2026-08-01**
|
||||
|
||||
1. ✅ **DONE 2026-08-01** — **AckTime enrichment (§6.4):** additive `AckTime` on `AlarmStateChanged`, the vendored `AlarmStateUpdate` proto (manual regen), `native_alarm_state` persistence, and the DCL ack-transition stamping (source ack time where supplied, else observation time).
|
||||
- `AlarmStateChanged.AckTime` (init-only, `null` default) + `NativeAlarmTransition.AckTime` (trailing optional positional — all 14-arg call sites unchanged).
|
||||
- Proto: **field 24** `google.protobuf.Timestamp ack_time` on `AlarmStateUpdate`; regenerated with `docker/regen-proto.sh sitestream` (csproj diff verified empty). Packed/unpacked by `StreamRelayActor` / `SiteStreamGrpcClient`; an absent Timestamp round-trips to `null`.
|
||||
- Stamping rule (both protocols): non-null **only** while the condition is active AND acknowledged — that one predicate yields "null while unacked", "cleared on re-raise", and no phantom ack on the MxGateway return-to-normal (which maps `INACTIVE → Acknowledged = true`). Lives in the pure `OpcUaAlarmMapper.DeriveAckTime` / `MxGatewayAlarmMapper.DeriveAckTime`.
|
||||
- OPC UA gets a **true source ack instant**: new SelectClause **index 18** = `AcknowledgeableConditionType/AckedState/TransitionTime`, appended so indices 0–17 keep their meaning; falls back to the event's `Time` when the server omits it. MxGateway uses the ack transition's own timestamp (its feed carries no ack time), and an `ACTIVE_ACKED` re-subscribe snapshot restores one from `LastTransitionTimestamp`.
|
||||
- Persistence: rides `native_alarm_state`'s existing `metadata_json` blob, **not** a new column — that table is `RegisterReplicated` in `SiteLocalDbSetup` and LocalDb builds its CDC triggers from the column list at registration time, so an additive JSON property changes no schema, no triggers and no replication contract (`metadata_json` is exactly the extension point UA4 added). Pre-AckTime rows deserialize it as `null`.
|
||||
2. ✅ **DONE 2026-08-01** — Add `AlarmsAccessor` + `ScriptAlarm` to the runtime context (`ScriptRuntimeContext`) — local Ask to the Instance Actor; project `AlarmStateChanged` → `ScriptAlarm` (incl. `AckTime`). Add internal request/response message if not reusing `DebugSnapshotRequest`.
|
||||
- Dedicated `GetAlarmSnapshotRequest`/`GetAlarmSnapshotResponse` (Commons `Messages/Instance`) rather than reusing `DebugSnapshotRequest`, which would materialise every attribute value on every alarm poll. Served from the same `BuildAlarmStatesSnapshot()` the Debug View uses, so the two can never disagree.
|
||||
- `AlarmsAccessor` sits in `ScopeAccessors.cs` beside the other accessors but is **not scope-prefixed** — alarm identity is not a scope-relative attribute name, so every scope sees the whole list. Exposed as `ScriptRuntimeContext.Alarms` and the top-level `ScriptGlobals.Alarms`.
|
||||
3. ✅ **DONE 2026-08-01** — Mirror the stub on `ScriptCompileSurface` (and confirm `TriggerCompileSurface` not needed — trigger expressions don't read alarms).
|
||||
- `CompileAlarmsAccessor` returns the **same** `ScriptAlarm` type as the runtime (Commons is already in `DefaultAssemblies`), so field access binds identically at the gate and at the site. `TriggerCompileSurface` confirmed not needed.
|
||||
- Also mirrored on the **third** hand-maintained surface, the Central UI Test-Run `SandboxScriptHost` — without it the design page would false-flag CS1061 on scripts the deploy gate accepts. It throws a labelled `ScriptSandboxException` at run time (no central route to per-instance alarm state) rather than returning an empty list that would read as "nothing is in alarm".
|
||||
4. ✅ **DONE 2026-08-01** — Confirm/extend `ScriptTrustPolicy` allow-list so `Alarms` is permitted; no new forbidden APIs.
|
||||
- **No change needed, and the reason is structural:** the trust boundary is a deny-list over API roots, not an allow-list of context members. Pinned by a test asserting no `ForbiddenScopes` entry prefixes the Commons script-surface namespace, so a future deny-list entry cannot silently make `ScriptAlarm` untouchable.
|
||||
5. ✅ **DONE 2026-08-01** — Unit tests: runtime accessor projection (active/acked/severity/timestamps/AckTime), AckTime stamping + failover persistence, compile-surface compiles a representative `Alarms.CurrentAsync()` script, trust-policy accepts it.
|
||||
- New `AlarmsAccessorTests` (6), `NativeAlarmActor` AckTime emit/rehydrate/pre-AckTime-row (3), `InstanceActor` alarm-snapshot (2), mapper AckTime (4 OPC UA + 6 MxGateway), proto round-trip (1), Commons additive/back-compat (4), compile-surface + trust (4), `SandboxScriptHost` diagnose-clean (1). `AlarmsAccessor` added to the `CompileSurfaceParityTests` mirror pairs; the OPC UA SelectClause count lock-in went 18 → 19 with an index-18 assertion (intended — the clause is appended).
|
||||
6. ✅ **DONE 2026-08-01** — Doc: update `Component-SiteRuntime.md` + `Component-DataConnectionLayer.md` (native-mirror AckTime) + Script Analysis #25 surface list; note the accessor in `Component-InboundAPI.md` routing examples.
|
||||
- Also updated the `CLAUDE.md` native-alarm bullet. The Inbound API note records the *negative* decision: no `Route.To(...).GetAlarms(...)` verb — alarm reads go through a routed site script so the filtering happens where the data lives.
|
||||
|
||||
**Phase 2 — Inbound methods [deployed] + doc [repo]**
|
||||
6. Update `SimpleAlarmStatusRequest` (id 9) body to the §5.1 router (validate via design page first to avoid the stale-handler trap — see memory `inbound-noncompiling-update-keeps-old-handler`).
|
||||
7. Create `AlarmStatus` method (params/return/script per §5.1).
|
||||
8. Doc the two endpoints in `docs/requirements/Component-InboundAPI.md` (or a dedicated MES-integration note) cross-referencing the legacy spec.
|
||||
7. Update `SimpleAlarmStatusRequest` (id 9) body to the §5.1 router incl. the not-supported-machine catch (validate via design page first to avoid the stale-handler trap — see memory `inbound-noncompiling-update-keeps-old-handler`).
|
||||
8. Create `AlarmStatus` method (params/return/script per §5.1); authorize the existing MES API key for both methods (§6.7).
|
||||
9. Doc the two endpoints in `docs/requirements/Component-InboundAPI.md` (or a dedicated MES-integration note) cross-referencing the legacy spec.
|
||||
|
||||
**Phase 3 — Site template scripts [deployed]**
|
||||
9. Add `SimpleAlarmStatus` + `AlarmStatus` to **MESReceiver** (BTDB `MachineAlarm` via `Database.Connection("BTDB")`), per §6.2 decision.
|
||||
10. Add override `SimpleAlarmStatus` + `AlarmStatus` to **CvdReactor** (native sources via `Alarms.CurrentAsync()`), per §5.3.
|
||||
11. `template validate` both templates (script compilation gate) before redeploy; redeploy affected instances.
|
||||
**Phase 3 — Site template scripts [deployed]** *(MESReceiver task removed — §6.2 decided dropped)*
|
||||
10. Add `SimpleAlarmStatus` + `AlarmStatus` to **CvdReactor** (native sources via `Alarms.CurrentAsync()`, MES band constants), per §5.3.
|
||||
11. `template validate` (script compilation gate) before redeploy; redeploy affected instances.
|
||||
|
||||
**Phase 4 — Verify**
|
||||
12. Build affected projects + run targeted tests (per memory `targeted-tests-not-full-suite`).
|
||||
13. Live smoke against a real reactor instance (see §8) — both endpoints, success + machine-not-found + filtered.
|
||||
13. Live smoke against a real reactor instance (see §8) — both endpoints, success + machine-not-found + not-supported-machine + filtered. Requires the Galaxy MES-band priorities (§6.1) to be set for a meaningful flagged-only result.
|
||||
|
||||
---
|
||||
|
||||
@@ -261,10 +281,10 @@ These are the Socratic checkpoints — they change script bodies and/or the cont
|
||||
|
||||
## 9. Out of scope / follow-ups
|
||||
|
||||
- True `IsFlaggedForMES` for native alarms (per-source MES allow-list) — §6.1 option (b).
|
||||
- Precise `AckDT` (enrich native mirror with an ack timestamp) — §6.4 option (c).
|
||||
- ~~True `IsFlaggedForMES` allow-list~~ — resolved by the §6.1 severity-band decision (no follow-up needed).
|
||||
- ~~Precise `AckDT` follow-up~~ — pulled INTO scope by §6.4 (mirror enriched now).
|
||||
- A live/aggregated central alarm store or stream (the M7 follow-up) — these endpoints stay pull-based, per-instance, like the Alarm Summary page.
|
||||
- MESReceiver live-state path if §6.2 (c) is chosen and MES later needs live status from non-native machines.
|
||||
- Alarm-status support for non-CvdReactor machine types (any future template just implements `SimpleAlarmStatus`/`AlarmStatus` root scripts against the same contract).
|
||||
|
||||
---
|
||||
|
||||
@@ -272,13 +292,14 @@ These are the Socratic checkpoints — they change script bodies and/or the cont
|
||||
|
||||
| Artifact | Type | Change |
|
||||
|---|---|---|
|
||||
| `ScriptRuntimeContext` | [repo] | New `Alarms` accessor + `ScriptAlarm`; local alarm-snapshot Ask |
|
||||
| `ScriptCompileSurface` | [repo] | Mirror `Alarms` stub |
|
||||
| `ScriptTrustPolicy` (#25) | [repo] | Allow `Alarms` member (verify) |
|
||||
| Internal alarm-snapshot message (or reuse `DebugSnapshotRequest`) | [repo] | Additive |
|
||||
| Site Runtime + Script Analysis + Inbound API docs | [repo] | Document `Alarms` accessor + endpoints |
|
||||
| Unit tests (SiteRuntime / ScriptAnalysis / InboundAPI) | [repo] | New |
|
||||
| `SimpleAlarmStatusRequest` (id 9) | [deployed] | Stub → real router |
|
||||
| `AlarmStatus` (new) | [deployed] | New inbound method |
|
||||
| `MESReceiver.SimpleAlarmStatus` / `.AlarmStatus` | [deployed] | New BTDB-driven scripts |
|
||||
| `CvdReactor.SimpleAlarmStatus` / `.AlarmStatus` | [deployed] | New native-source override scripts |
|
||||
| `AlarmStateChanged` + vendored `AlarmStateUpdate` proto (**field 24**) + `native_alarm_state` (`metadata_json`) + DCL stamping (OPC UA SelectClause **index 18**) | [repo] ✅ | Additive `AckTime` enrichment (§6.4) |
|
||||
| `ScriptRuntimeContext` + `ScriptGlobals` | [repo] ✅ | New `Alarms` accessor + `ScriptAlarm` (incl. `AckTime`); local alarm-snapshot Ask |
|
||||
| `ScriptCompileSurface` + Central UI `SandboxScriptHost` | [repo] ✅ | Mirror `Alarms` stub on both design-time surfaces |
|
||||
| `ScriptTrustPolicy` (#25) | [repo] ✅ | Verified — **no change needed** (deny-list, not member allow-list); pinned by test |
|
||||
| `GetAlarmSnapshotRequest`/`Response` (Commons) | [repo] ✅ | New, additive — chosen over reusing `DebugSnapshotRequest` |
|
||||
| Site Runtime + DCL + Script Analysis + Inbound API docs + `CLAUDE.md` | [repo] ✅ | Document `Alarms` accessor, `AckTime`, endpoints |
|
||||
| Unit tests (Commons / DCL / SiteRuntime / ScriptAnalysis / Communication / CentralUI) | [repo] ✅ | New |
|
||||
| `SimpleAlarmStatusRequest` (id 9) | [deployed] | Stub → real router (+ not-supported catch) |
|
||||
| `AlarmStatus` (new) | [deployed] | New inbound method, existing MES key authorized |
|
||||
| `CvdReactor.SimpleAlarmStatus` / `.AlarmStatus` | [deployed] | New native-source scripts (MES band 900–999) |
|
||||
| Galaxy alarm priorities | [external] | MES-relevant CvdReactor alarms configured into 900–999 (§6.1 prerequisite) |
|
||||
|
||||
@@ -14,10 +14,14 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
|
||||
| 8 | Hash-chain tamper evidence (T1); CLI verify-chain is a no-op stub | audit-log roadmap :12 | v1.x by locked decision; append-only DB roles are the control | Compliance requirement for cryptographic tamper evidence |
|
||||
| 9 | Parquet audit archival (T2); endpoint returns 501 | AuditEndpoints.cs:204 | v1.x; 501 + CLI messaging are honest | AuditLog partition volume nears retention ceiling |
|
||||
| 11 | Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
|
||||
| 12 | Native-alarm-source-override CSV import — **Central UI `InstanceConfigure` upload affordance only** (CLI + Management API + parser shipped 2026-07-10, see Resolved) | m7 follow-ups | CLI/API path closes the operator parity gap; the Blazor upload button is polish | First request to bulk-import native sources from the UI rather than the CLI |
|
||||
| 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
|
||||
| 18 | Folder drag-drop | same, [PERM] | Permanently closed; menu reorder shipped | — (closed) |
|
||||
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
|
||||
| 23 | Live LDAP group-membership re-query for an active session | `docs/requirements/Component-Security.md` :61-69 (+ :78-79) | Blocked on an external package. The mid-session refresh re-maps the **stored** groups against the central DB with **no LDAP call**, so a directory group-membership change lands only at next login. A live re-query needs a passwordless service-account group-search method on the shared `ZB.MOM.WW.Auth.Ldap` library — an external NuGet `PackageReference` (`src/ZB.MOM.WW.ScadaBridge.Security/…csproj:23`) exposing only `AuthenticateAsync(username, password, ct)`. Central role-mapping/scope changes still apply within ~15 min (`RoleRefreshThresholdMinutes`). | `ZB.MOM.WW.Auth.Ldap` gains a standalone group-search API, or a requirement that a directory-side group revocation take effect mid-session rather than at next login |
|
||||
| 24 | M8 large-bundle performance hardening | `docs/plans/2026-06-15-stillpending-completion-design.md:106` — "Small follow-ups logged (not blocking): … large-bundle/perf hardening" | Logged as a non-blocking follow-up when M8 shipped and never given an artifact: **no plan, no task entry, no perf/load test exists** (`tests/…Transport.Tests/Import/BundleImporterLoadTests.cs` is a `LoadAsync` unit suite despite the name). No measured problem; the only sizing controls in place are the 5-minute CLI transport timeout, `LineDiffer`'s `MaxInputLines`=4000 summary-only cap, and `MaxConcurrentImportSessions`=8. | First real bundle that times out, exhausts memory, or makes the import wizard's diff step unusable |
|
||||
| 25 | Phase-8 WP-4 target-scale load test (10 sites × 500 instances × 75 tags = 37,500 subscriptions/site, 375,000 total) | `docs/plans/phase-8-production-readiness.md:152-170` (WP-4) + `:314-320` (test protocol); status claimed in `docs/plans/phase-8-checklist.md` | **Claimed complete but unevidenced.** The whole WP-4 deliverable is a **107-byte** checklist stub asserting "Status: Complete / Tests: All passing / Build: 0 errors, 0 warnings" with no per-work-package results and no linked run. Nearest real coverage is arithmetic/aggregation only — `PerformanceTests/StaggeredStartupTests.cs` (`TagCapacity_75TagsPer500Machines_37500Total`, 500-instances-over-10-sites distribution) and `HealthAggregationTests` (10-site report aggregation) — plus a **single-subscriber** 100k-event `Streaming/SiteStreamThroughputTests.cs`. No sustained multi-site run exists anywhere in `tests/` or `docker/`. | Before any production go-live at target scale; or the first site approaching ~500 instances / ~37.5k subscriptions |
|
||||
| 26 | Ipsen MES MoveIn tail: leak-test (`-LT`) receivers + routing, PLC-output-flag writes, `Z28062` BTDB data completeness | `docs/plans/2026-06-16-ipsen-mes-movein.md:409` ("Out of scope (future)"); design `2026-06-16-ipsen-mes-movein-design.md:58-60, :196-198` | Customer-site scope, not a platform gap. `-LT` routing needs an MES-receiver child + Galaxy reference that do not exist on the reactor template (any `-LT`/unknown suffix returns `WasSuccessful=false` with an "unsupported side/target" message by decision); `MoveInComplete`/`Successful`/`ErrorText` are **PLC-owned** by locked decision, so ScadaBridge deliberately does not write them; `Z28062` completeness is an operational data fix, not code. Note the separate alarm-status path already handles the suffix — `_LT` is stripped before side-scoping (`2026-06-30-mes-alarm-status-api.md:158`). | Ipsen creates the leak-test receiver + Galaxy reference, or asks ScadaBridge to own the PLC-output flags — otherwise a **candidate won't-do** (`[PERM]`) at the next Ipsen scope review |
|
||||
| 27 | External-system per-system retry config (`MaxRetries`/`RetryDelay`) never reaches sites, and has no CLI/management surface | Found live 2026-08-01 (rig session, #11 gRPC live checks) | Two stacked gaps: (a) `ExternalSystemArtifact` (Commons) carries `TimeoutSeconds` but NOT `MaxRetries`/`RetryDelay`, and the site `external_systems` table has no such columns — so a centrally-configured retry policy is silently ignored on sites; every cached call buffers with the S&F default (`DefaultMaxRetries` 50 × `DefaultRetryInterval` 30s ≈ 25 min to park). (b) `Create/UpdateExternalSystemCommand` don't expose the fields either — the only way to set them today is a direct DB edit of `ExternalSystemDefinitions`. Transport bundles DO carry them (arch-review 05 "ES retry config"), which masks the gap in export/import round-trips. Fix is additive: extend the artifact + site schema + apply path, and add `--max-retries`/`--retry-delay` to the CLI. | First operator who tunes retry policy on an external system and expects site cached calls to honor it |
|
||||
| 28 | Health-dashboard "Trigger failover" confirm dialog's confirm button is labeled **"Delete"** | Found live 2026-08-01 (rig session, #11 TriggerSiteFailover check) | The DialogService confirmation host's default destructive-action label leaks through — the dialog copy is correct but the red confirm button says "Delete" for a failover. One-line fix: pass an explicit confirm label ("Fail over") at the Health-dashboard call site (and audit other confirm-dialog call sites for the same default). | Next Central UI session |
|
||||
|
||||
## Resolved (verified against the code 2026-07-10)
|
||||
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.
|
||||
@@ -26,11 +30,12 @@ Rows removed from the Deferred table above once confirmed shipped. Kept here for
|
||||
|---|------|-----------|
|
||||
| 10 | Aggregated live alarm stream for Alarm Summary | Shipped 2026-07-10 (`docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`): a **transient, in-memory** per-site central live alarm cache (`ISiteAlarmLiveCache`/`SiteAlarmLiveCacheService` + per-site `SiteAlarmAggregatorActor`) fed by a new site-wide, alarm-only `SubscribeSite` gRPC stream (`SiteStreamManager.SubscribeSiteAlarms`), seed-then-stream with dedup + NodeA↔NodeB re-seed + periodic reconcile. Alarm Summary is now live-cache-driven (`AlarmSummaryService.BuildFromLiveAlarms`) with the 15s poll retained as fallback + `NotReporting` authority. Honors the `[PERM]` no-central-store rule — nothing persisted (no EF table/migration). Options on `CommunicationOptions` (eagerly validated) + two `ScadaBridgeTelemetry` signals. |
|
||||
| 7 | SecuredWrite audit rows leave SourceNode NULL | Resolved (PLAN-07): `ManagementActor.EmitSecuredWriteAuditAsync` routes through `ICentralAuditWriter`, which stamps `SourceNode` (`central-a`/`central-b`) from `INodeIdentityProvider`. |
|
||||
| 12 (CLI/API) | Native-alarm-source-override CSV import | Shipped 2026-07-10: shared `CsvLineSplitter`, `NativeAlarmSourceOverrideCsvParser`, bulk all-or-nothing `SetInstanceNativeAlarmSourceOverridesCommand` + ManagementActor handler (Deployer-gated), CLI `instance native-alarm-source import --file`, parser/CLI/handler tests. **UI upload affordance still pending — see row 12 above.** |
|
||||
| 12 (CLI/API) | Native-alarm-source-override CSV import | Shipped 2026-07-10: shared `CsvLineSplitter`, `NativeAlarmSourceOverrideCsvParser`, bulk all-or-nothing `SetInstanceNativeAlarmSourceOverridesCommand` + ManagementActor handler (Deployer-gated), CLI `instance native-alarm-source import --file`, parser/CLI/handler tests. **UI upload affordance shipped 2026-08-01** — second `InputFile` on the `InstanceConfigure` Native Alarm Source Overrides card reusing the shared parser, mirroring the attribute importer's UX and the server's all-or-nothing merge semantics (`InstanceConfigureNativeAlarmCsvImportTests`); row 12 removed from the Deferred table. |
|
||||
| 13 | WaitForAttribute quality-gated ("Good"-only) mode | Already implemented (Commons `WaitForAttribute.RequireGoodQuality`, enforced in `InstanceActor`, threaded through `ScriptRuntimeContext`, tested in `InstanceActorWaitForAttributeTests`). Stale "planned enhancement" doc line corrected 2026-07-10. |
|
||||
| 14 | WaitForAttribute in Test-Run sandbox | Shipped 2026-07-10 (full fidelity): sandbox `Attributes.WaitAsync`/`WaitForAsync` (value-equality) route to the bound instance via `ISandboxInstanceGateway.WaitForAttributeAsync` → the existing `CommunicationService.RouteToWaitForAttributeAsync` cross-site route. Additive `RouteToWaitForAttributeRequest.RequireGoodQuality` (honored by the site handler) makes quality-gated waits route too. **Predicate-form waits stay unsupported** (an in-process lambda can't be routed) and throw a labelled `ScriptSandboxException`. Tests: sandbox accessor routing (CentralUI), site-handler quality-flag threading (SiteRuntime). |
|
||||
| 15 | BrowseNext final-page signal not surfaced | Already surfaced (M7 browse work): `RealOpcUaClient` sets `Truncated=false`/`ContinuationToken=null` on the last page; `BrowseNodeResult` carries both; `TreeRow.razor` renders "Load more" only when a continuation token remains — no wasted BrowseNext. |
|
||||
| 16 | StubOpcUaClient throws on browse | Already resolved: `StubOpcUaClient` supports browse + address-space search, covered by `StubOpcUaClientBrowseTests`/`StubOpcUaClientSearchTests`. |
|
||||
| 18 | Folder drag-drop | Closed — **permanently deferred (`[PERM]`)** by the M9 decision (`docs/plans/2026-06-15-stillpending-completion-design.md:122`): menu-based reorder (T23) shipped instead, and the folder-hierarchy design fixed the reorganization UX as "right-click context menus only (no drag-drop)" (`2026-05-11-templates-folder-hierarchy-design.md:27`). Row removed from the Deferred table 2026-08-01 — nothing left to revisit. |
|
||||
| 20 | Deployment EXPIRED-row purge | Already resolved (PLAN-04): `PendingDeploymentPurgeActor` central singleton (spawned in `AkkaHostedService`) ticks `IDeploymentManagerRepository.PurgeExpiredPendingDeploymentsAsync` every `CommunicationOptions.PendingDeploymentPurgeInterval` (default 1h), options-validated, tested. |
|
||||
| 21 | SiteAuditBacklogReporter threshold consolidation | Shipped 2026-07-10: `SqliteAuditWriterOptions.BacklogPollIntervalSeconds` (default 30) now drives the reporter's poll cadence; explicit ctor override still wins (tests), non-positive falls back to the 30 s default. Cadence tests added; stale "hard-code / follow-up" class-doc corrected. |
|
||||
| 22 | KPI history hourly rollups | Shipped 2026-07-10 (`docs/plans/2026-07-10-kpi-history-hourly-rollups-plan.md`, T1–T8): new `KpiRollupHourly` table (migration `20260710153953`) folded by a third recorder tick (`kpi-rollup`, `RollupInterval` default 1h) over a re-folded `RollupLookbackHours` window via an idempotent, failover-self-healing upsert; per-metric gauge-vs-rate aggregation (`KpiMetricAggregationCatalog`); a one-shot backfill of the retention window on start; raw-vs-rollup query routing by `RollupThresholdHours` (default 168h); longer rollup retention (`RollupRetentionDays` default 365 ≥ `RetentionDays`, dual daily purge); and 30 d / 90 d trend windows added to the four surfaces. Options + validator, docs (`Component-KpiHistory.md`), and tests shipped. |
|
||||
@@ -41,7 +46,8 @@ Rows removed from the Deferred table above once confirmed shipped. Kept here for
|
||||
| Communication → HealthMonitoring layering (ICentralHealthAggregator consumed by CentralCommunicationActor.cs:351) | Moving the interface + SiteHealthState to Commons ripples across 5 projects for a cosmetic inversion | Next breaking change to ICentralHealthAggregator |
|
||||
| docs/components reference docs for ScriptAnalysis, KpiHistory, DelmiaNotifier | Reference docs are substantial (StyleGuide-conformant); README claim scoped instead (PLAN-08 Task 10) | Next doc-writing session touching those components |
|
||||
| Test-coverage backfill: SiteCallAudit.Tests (31 tests/1.6k LOC), DeploymentManager.Tests | No defect identified; coverage partly lives in ManagementService/Host/Integration suites | First regression escaping either component |
|
||||
| Failover-timing + broader perf envelope (S&F drain rate, per-subscriber backpressure) | **Trigger fired 2026-07-08** — the "PLAN-01 rig landing" trigger fired when `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs` landed (before the placeholder harness even shipped), and went unnoticed (NF2). **Owner: PLAN-R2-01** — `archreview/plans/PLAN-R2-01-*.md` (failover-envelope measurement task) owns wiring `FailoverTimingTests` to the fixture rig or recording the true blocker. | Owned by PLAN-R2-01; this register row tracks the handoff only |
|
||||
| ~~Failover-timing measurement (the "~25s total failover" envelope)~~ **RESOLVED 2026-08-01** — split out of the combined row and closed. `tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs` is no longer a skipped placeholder: it runs as a live `[Fact]` (`Category=Performance`) on the real two-node in-process rig (`TwoNodeClusterFixture`, production `BuildHocon`) at production timings — 2s heartbeat / 10s failure-detection threshold / 15s stable-after — hard-killing the younger node and timing the survivor's member REMOVAL with singleton continuity asserted on the oldest. Delivered by **PLAN-R2-01 Task 4** (`archreview/plans/PLAN-R2-01-cluster-host-failover.md:226`). The oldest-crash direction is covered behaviorally by `SbrFailoverTests.AutoDown_HardCrashOfOldestNode_*` and by `docker/failover-drill.sh`. | The 2026-07-08 "PLAN-01 rig landing" trigger had fired unnoticed (NF2); PLAN-R2-01 T4 wired the placeholder to the fixture rig rather than recording a blocker. | Closed. |
|
||||
| Broader perf envelope — **S&F drain rate + per-subscriber stream backpressure** (the still-open half of the former combined row) | Never measured, and no owner plan survives now that PLAN-R2-01 closed the failover half. `PerformanceTests` covers failover timing, staggered startup, health aggregation, audit hot-path latency and a **single-subscriber** 100k-event `Streaming/SiteStreamThroughputTests.cs` — nothing measures store-and-forward drain throughput, nor what a slow/stalled subscriber does to the per-subscriber buffering in `Communication/Actors/StreamRelayActor.cs` / `Grpc/SiteStreamGrpcServer.cs` with many subscribers attached. No defect observed; deferred as measurement-only work. | First field S&F backlog that fails to drain within an operator's patience, a slow gRPC subscriber degrading a site stream for others, or the WP-4 target-scale run (row 25) being scheduled — that run should absorb this |
|
||||
|
||||
## Deferred — operational risk (from the initiative tracker, folded in 2026-07-12)
|
||||
Two live items previously tracked ONLY in `archreview/plans/00-MASTER-TRACKER.md`'s registry are folded in here (NF5) so this register is the single tracking place. The tracker's narrative subsections remain as the historical evidence.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Plan: Aggregated Live Alarm Stream for Alarm Summary (deferred #10)
|
||||
|
||||
**Status:** Draft plan (not yet executed) — 2026-07-10
|
||||
**Status:** **Delivered 2026-07-10** — shipped as designed (`SiteAlarmAggregatorActor`,
|
||||
`ISiteAlarmLiveCache`, the `SubscribeSite` streaming RPC, and
|
||||
`AlarmSummaryService.BuildFromLiveAlarms` are all in `src/`). The 15 s poll is retained as the
|
||||
NotReporting authority behind the live stream, per the design below.
|
||||
**Register row:** `docs/plans/2026-07-08-deferred-work-register.md` #10
|
||||
**Revisit trigger that fired this plan:** *"Alarm Summary latency complaints or >~50 instances/site."*
|
||||
**Owning component:** Central UI (#9) + Central–Site Communication (#5); no new component.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Plan: KPI History Hourly Rollups (deferred #22)
|
||||
|
||||
**Status:** Draft plan (not yet executed) — 2026-07-10
|
||||
**Status:** **Delivered 2026-07-10** — shipped as designed: the `KpiRollupHourly` entity +
|
||||
EF migration `20260710153953_AddKpiRollupHourlyTable`, `KpiMetricAggregationCatalog`
|
||||
(per-metric gauge-vs-rate fold), and the raw-vs-rollup query routing on `RollupThresholdHours`
|
||||
are all in `src/`.
|
||||
**Register row:** `docs/plans/2026-07-08-deferred-work-register.md` #22
|
||||
**Revisit trigger that fired this plan:** *"KpiSample query latency on dashboards."*
|
||||
**Owning component:** KPI History (#26); touches Configuration Database (#17),
|
||||
|
||||
@@ -191,5 +191,5 @@
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-19",
|
||||
"phase1Status": "COMPLETE - all 15 tasks done, live gate PASS. Branch feat/localdb-phase1 NOT merged/pushed."
|
||||
"phase1Status": "COMPLETE - all 15 tasks done, live gate PASS. MERGED to main as 28ca04d7 ('LocalDb adoption Phase 1 + 2: consolidate the site database, delete the bespoke replicators (#23)') - Phase 1 and Phase 2 landed together in that one merge."
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
{"id": 18, "subject": "Task 18: Two-node convergence suite for the Phase 2 tables", "status": "completed", "classification": "high-risk", "blockedBy": [17], "note": "DEVIATION: landed as a NEW file LocalDbPhase2ConvergenceTests.cs rather than extending Phase 1's LocalDbSitePairConvergenceTests.cs, and drives the REAL SiteStorageService instead of hand-written SQL - possible only post-cutover, and what makes the cascade scenario test the shipped transaction rather than a re-creation of it. Scenario 4 was retargeted onto shared_scripts/external_systems/static_attribute_overrides because the plan's version overlapped LocalDbConfigConvergenceTests' N1 scenario almost exactly; the union-survives property is per-table, so re-proving it on untouched tables is the non-redundant half. Cascade test carries a never-removed control instance (absence assertions otherwise cannot distinguish 'cascade converged' from 'node B lost these tables'). Non-vacuity PROVEN as mandated: with the 8 RegisterReplicated calls commented out, 4 failed / 0 passed; restored, 20/20 across the three LocalDb suites.", "commit": "15013156"},
|
||||
{"id": 19, "subject": "Task 19: Rig configuration (MaxOplogRows/MaxOplogAge + MaxBatchSize per D6)", "status": "completed", "classification": "small", "blockedBy": [17], "note": "MaxBatchSize 500->16 (D6: row-count batching x ~70 KB production config_json vs the 4 MB gRPC cap; 16 => ~1.1 MB worst case). MaxOplogRows 1M->250,000 and MaxOplogAge 7d->2d from the soak's 0.80 rows/sec (~69k/day). Tighter-than-default is SAFE because a cap breach prunes + sets needs_snapshot (graceful snapshot resync), not data loss - the Task 1 finding that the plan's stop condition was weaker than written. site-b/site-c left unreplicated so default-OFF stays proven side by side.", "commit": "921edab4"},
|
||||
{"id": 20, "subject": "Task 20: Live gate on the docker rig", "status": "completed", "classification": "high-risk", "blockedBy": [18, 19], "note": "ALL 10 CHECKS PASS. Evidence: docs/plans/2026-07-19-localdb-phase2-live-gate.md. Key blocker found and fixed mid-run: external systems reach a site ONLY via ArtifactDeploymentService, which `instance deploy` never invokes - `deploy artifacts` was needed both to deliver the probe harness and to propagate the owed ExternalSystemDefinitions restore. THREE METHOD CORRECTIONS to the plan: (1) its instruction to run DB checks host-side against the bind mounts is UNSAFE - host sqlite3 poisons the container WAL; copy the db/-wal/-shm triplet and query the copy. (2) `docker exec ... curl` cannot scrape metrics (no curl in aspnet:10.0) and with 2>/dev/null the failure is silent - it nearly became a false 'metrics missing' finding; use a network-sharing curl sidecar. (3) checks needing S&F load need CachedCall, not Call. CAVEATS recorded not glossed: check 2's zero-count is vacuous alone (legacy source was also empty) and rests on the ABSENCE of CDC triggers; check 7's native_alarm_state leg was empty live and is covered only offline; check 10's sampling is coarse and the real rise/drain evidence comes from check 6's 0->4->0.", "commit": "158e79bb"},
|
||||
{"id": 21, "subject": "Task 21: Documentation truth pass", "status": "completed", "classification": "standard", "blockedBy": [20], "note": "Both CLAUDE.md files + Component-StoreAndForward.md:83 (normative resync paragraph rewritten for CDC, stating the duplicate-delivery bound explicitly: limited to messages the OLD primary delivered whose status change had not yet replicated when the gate flipped - one flush interval plus in-flight ack, and it does NOT grow with backlog depth or absence duration) + components/{StoreAndForward,SiteRuntime,Host}.md + the frame-size known-issue (amended: Phase 2 deleted notify-and-fetch itself, so the 128KB Akka frame constraint is gone from the intra-site hop entirely; successor ceiling is the 4MB gRPC cap via MaxBatchSize, and note the failure mode differs - oversized gRPC is REJECTED, not silently dropped) + deployment topology-guide.md and installation-guide.md (D5 stop-both-together, D2 TombstoneRetention resurrection bound). DoD closed: build 0 warnings, all 10 suites green (3509 tests, 0 failures). DoD grep nuance recorded in the plan: 4 matches remain in src/ and are all deliberate COMMENT prose explaining what was deleted - a literal 'no matches' would delete the explanations that stop someone re-introducing the old design.", "commit": null}
|
||||
{"id": 21, "subject": "Task 21: Documentation truth pass", "status": "completed", "classification": "standard", "blockedBy": [20], "note": "Both CLAUDE.md files + Component-StoreAndForward.md:83 (normative resync paragraph rewritten for CDC, stating the duplicate-delivery bound explicitly: limited to messages the OLD primary delivered whose status change had not yet replicated when the gate flipped - one flush interval plus in-flight ack, and it does NOT grow with backlog depth or absence duration) + components/{StoreAndForward,SiteRuntime,Host}.md + the frame-size known-issue (amended: Phase 2 deleted notify-and-fetch itself, so the 128KB Akka frame constraint is gone from the intra-site hop entirely; successor ceiling is the 4MB gRPC cap via MaxBatchSize, and note the failure mode differs - oversized gRPC is REJECTED, not silently dropped) + deployment topology-guide.md and installation-guide.md (D5 stop-both-together, D2 TombstoneRetention resurrection bound). DoD closed: build 0 warnings, all 10 suites green (3509 tests, 0 failures). DoD grep nuance recorded in the plan: 4 matches remain in src/ and are all deliberate COMMENT prose explaining what was deleted - a literal 'no matches' would delete the explanations that stop someone re-introducing the old design.", "commit": "7b5a5a6f"}
|
||||
],
|
||||
"knownFlakes": [
|
||||
{
|
||||
@@ -102,5 +102,5 @@
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-20",
|
||||
"phase2Status": "UNBLOCKED - Task 1 gate CLOSED (verdict PROCEED) and Task 2 DONE, 2026-07-20. Task 1's original STOP verdict is SUPERSEDED: the 'Phase 1 disk I/O defect' was OBSERVER-INDUCED (host-side sqlite3 against live bind-mounted WAL files resets the WAL across virtiofs and permanently poisons the container's connections) - NOT a product defect. See docs/known-issues/2026-07-20-localdb-disk-io-error-under-load.md. Both earlier isolation claims were confounded: one sampling pass had already poisoned BOTH nodes, and a poisoned standby looks healthy only because it issues almost no statements; 'LocalDb-specific' was sampling-selection bias (only the LocalDb file had ever been host-read). Clean re-run 2026-07-20 with the copy-based snap() helper on restarted nodes, 6 consecutive 60s intervals: sf_messages 48 rows/min = 0.80 rows/sec dead steady, retry-UPDATE rate 0/sec (SUM(retry_count) flat at 200), oplog 0, native_alarm_state 0, max payload_json 76 B, max config_json 721 B, ZERO SQLite errors across 30 min, both site-a nodes converged at 3564 rows. Honest gap: 0.80/s is ~1.6% of the 50/s ceiling and the retry-UPDATE path was never exercised - acceptable because that ceiling is structural (SweepBatchLimit/RetryTimerInterval), not empirical. Rig config rows (721 B) are NOT representative; D6 sizing rests on the documented ~60-70 KB production config_json. Plan-premise corrections stand: D6 (MaxBatchSize 500 -> 16, the one firmly evidence-backed number; Task 19 sets it), D4 (alarm writes bounded by per-SourceReference coalescing at a 100 ms flush, NOT unbounded), sf_messages hard ceiling 50 rows/sec, oplog cap overrun = graceful snapshot resync (needs_snapshot), not data loss. NEXT: Wave 1 = Tasks 3 + 4, dispatchable in parallel (disjoint Files blocks). Tasks 3-21 untouched; no plan code written yet. Rig cleanup still owed before Task 20: restore ExternalSystemDefinitions id 1 to http://scadabridge-restapi:5200 (currently http://127.0.0.1:9), and remove SoakGenerator template 2021 + instances soakgen-1..4 (ids 5-8), still deployed and generating load."
|
||||
"phase2Status": "COMPLETE - all 21 tasks done, live gate PASS (docs/plans/2026-07-19-localdb-phase2-live-gate.md), MERGED to main as 28ca04d7 ('LocalDb adoption Phase 1 + 2 ... (#23)'), which carried Phase 1 in the same merge. History below retained for context. Task 1 gate CLOSED (verdict PROCEED) and Task 2 DONE, 2026-07-20. Task 1's original STOP verdict is SUPERSEDED: the 'Phase 1 disk I/O defect' was OBSERVER-INDUCED (host-side sqlite3 against live bind-mounted WAL files resets the WAL across virtiofs and permanently poisons the container's connections) - NOT a product defect. See docs/known-issues/2026-07-20-localdb-disk-io-error-under-load.md. Both earlier isolation claims were confounded: one sampling pass had already poisoned BOTH nodes, and a poisoned standby looks healthy only because it issues almost no statements; 'LocalDb-specific' was sampling-selection bias (only the LocalDb file had ever been host-read). Clean re-run 2026-07-20 with the copy-based snap() helper on restarted nodes, 6 consecutive 60s intervals: sf_messages 48 rows/min = 0.80 rows/sec dead steady, retry-UPDATE rate 0/sec (SUM(retry_count) flat at 200), oplog 0, native_alarm_state 0, max payload_json 76 B, max config_json 721 B, ZERO SQLite errors across 30 min, both site-a nodes converged at 3564 rows. Honest gap: 0.80/s is ~1.6% of the 50/s ceiling and the retry-UPDATE path was never exercised - acceptable because that ceiling is structural (SweepBatchLimit/RetryTimerInterval), not empirical. Rig config rows (721 B) are NOT representative; D6 sizing rests on the documented ~60-70 KB production config_json. Plan-premise corrections stand: D6 (MaxBatchSize 500 -> 16, the one firmly evidence-backed number; Task 19 sets it), D4 (alarm writes bounded by per-SourceReference coalescing at a 100 ms flush, NOT unbounded), sf_messages hard ceiling 50 rows/sec, oplog cap overrun = graceful snapshot resync (needs_snapshot), not data loss. Tasks 3-21 were subsequently executed and all landed. Rig cleanup that was owed before Task 20 is DONE: ExternalSystemDefinitions id 1 restored to http://scadabridge-restapi:5200 (propagation to both site nodes confirmed), and SoakGenerator template 2021 + instances soakgen-1..4 deleted - see 'Rig state as left' in the live-gate doc."
|
||||
}
|
||||
|
||||
@@ -195,7 +195,9 @@ was down and back to 0 after rejoin.
|
||||
- **Deleting an instance orphans its buffered messages.** Removing the `soakgen-*` instances left
|
||||
their 11,804 `sf_messages` with no tracking snapshot, producing a continuous
|
||||
`Cached-telemetry drain: no tracking snapshot for …` warning flood. Also pre-existing, and worth
|
||||
a cleanup path.
|
||||
a cleanup path. **RESOLVED** by `62cddcfa` ("let an unresolvable cached row leave the drain
|
||||
queue", merged `663d03e8`) — an unresolvable row is now dropped from the drain queue instead of
|
||||
being retried forever, so the hot loop and the warning flood are gone.
|
||||
|
||||
## Rig state as left
|
||||
|
||||
|
||||
@@ -72,7 +72,11 @@ Sustained rate observed: ~**2.9 HTTP attempts/sec** (688–864 connection-refuse
|
||||
image does not implement. After `reseed.sh` drops the volume (`docker compose down -v`),
|
||||
nothing recreates `ScadaBridgeConfig` or the `scadabridge_app` login, so `reseed.sh` hangs
|
||||
forever on its "Waiting for setup.sql to create ScadaBridgeConfig" poll. Worked around by
|
||||
applying the three init scripts by hand. **NOT yet fixed in the repo.**
|
||||
applying the three init scripts by hand. **Since FIXED in the repo:** `infra/reseed.sh` now
|
||||
applies all three scripts explicitly via `sqlcmd` (`mssql/setup.sql`,
|
||||
`mssql/machinedata_seed.sql`, `mssql/setup-env2.sql`), with a comment recording that the
|
||||
`mssql/server` image has no initdb hook; the `/docker-entrypoint-initdb.d/` mounts in
|
||||
`infra/docker-compose.yml` are documented as **informational only**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -97,12 +97,17 @@ mis-explanation in the repo ("the alone-oldest is dead and cannot down itself").
|
||||
|
||||
## Residual operational notes
|
||||
|
||||
- **Seed-node bootstrap constraint still applies to boot-alone**: only the first seed
|
||||
may self-form a cluster. Auto-down removes the active-crash outage (the survivor never
|
||||
restarts), but a node that must BOOT alone while its peer is dead (cold start of only
|
||||
the non-first-seed VM, or the survivor crashing while the peer is still down) still
|
||||
waits in `InitJoin` for its peer. Operator recovery unchanged (restart first seed, or
|
||||
self-first seed override).
|
||||
- ~~**Seed-node bootstrap constraint still applies to boot-alone**~~ — **SUPERSEDED
|
||||
(2026-07-22), no longer a residual.** As written: only the first seed may self-form a
|
||||
cluster, so a node that must BOOT alone while its peer is dead (cold start of only the
|
||||
non-first-seed VM, or the survivor crashing while the peer is still down) waits in
|
||||
`InitJoin` for its peer. This was **closed** by the self-first seed-node ordering change
|
||||
(`docs/plans/2026-07-22-selfform-fallback-and-manual-failover.md`): every node now lists
|
||||
ITSELF first in `seed-nodes`, so any node can cold-start alone and become operational
|
||||
unattended, and `StartupValidator` fails the boot if the ordering is broken — regression
|
||||
coverage in `SelfFirstSeedBootstrapTests`. The residual cost of self-first-on-both (a
|
||||
*truly simultaneous* cold start racing `FirstSeedNodeProcess` on both nodes) is in turn
|
||||
covered by the opt-in `ScadaBridge:Cluster:BootstrapGuard` (Gitea #33, default OFF).
|
||||
- Monitoring already surfaces dual-active if it ever happens: both nodes report
|
||||
`IsActive` in heartbeats / both `/health/active` = 200 — the Health dashboard shows
|
||||
two Primaries.
|
||||
|
||||
@@ -118,6 +118,9 @@ Server's retrying strategy refuses to run a split query inside a caller's transa
|
||||
**bundle import fails against real MS SQL**. The fix is the one the exception names: wrap the
|
||||
transaction in `Database.CreateExecutionStrategy().ExecuteAsync(...)`.
|
||||
|
||||
> **FIXED (Gitea #28, commit `c4dcd9bc`)** — bundle import now runs inside the execution
|
||||
> strategy, with a regression test.
|
||||
|
||||
Why the whole unit/integration suite is green on it: those tests use the in-memory EF provider,
|
||||
which has no retrying execution strategy — and `BeginTransactionAsync` is a no-op there. The
|
||||
comment directly above line 1298 documents that divergence without drawing the conclusion. Only
|
||||
@@ -134,13 +137,18 @@ This has been failing since 2026-07-10, and it matters more than a red line: eve
|
||||
the toast assertion — including **the secret-non-leak assertion that the Auth Token value never
|
||||
reaches the page HTML** — has not executed since. Fix is a valid 32-hex SID in the fixture.
|
||||
|
||||
> **FIXED (Gitea #29, commit `8524a7f7`)** — the fixture now uses a valid 32-hex Twilio SID, so
|
||||
> the create path (and the secret-non-leak assertion behind it) executes again.
|
||||
|
||||
### Not covered by this gate
|
||||
|
||||
- Streaming subscriptions were exercised in-process (TestServer), not over the rig. The
|
||||
interceptor is path-scoped, not method-scoped, so the rig's `PullAuditEvents` evidence covers
|
||||
the same code path — but a live `SubscribeInstance` under load is untested here.
|
||||
- Key **rotation** on a live pair.
|
||||
- `docker-env2` was updated with its own key but not redeployed or gated.
|
||||
- `docker-env2` was updated with its own key but not redeployed or gated. — **CLOSED
|
||||
(Gitea #31, commit `e0f105c3`)**: `docker-env2` was rebuilt on the PSK build and gated,
|
||||
**PASS 3/3**; see the "Env2 gate" section at the end of this document.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ Critical path ≈ 1B: **~4–6 weeks total**, matching the design estimate.
|
||||
|
||||
**Phase 5 — live gate** (record in `2026-07-22-clusterclient-to-grpc-live-gate.md`) — **PASS 2026-07-23** (deletion build, `main` @ `7fd5cb2b`)
|
||||
- [x] 1 PSK negatives · [x] 2 site→central matrix · [x] 3 central→site matrix · [x] 4 failover/failback both directions · [x] 5 mid-drain kill · [x] 6 >128 KB frame-class proof · [x] 7 no cross-boundary Akka association · [x] 8 full-rig restart clean
|
||||
- All 8 PASS. Notables: check 1 clarified the site-side gate uses the node's single `GrpcPsk` and ignores `x-scadabridge-site` (central-side routing hint) — per-site isolation proven by wrong-key reject, not the header; check 6 = a **297,574-byte** single gRPC reply (a payload Akka's 128 KB frame dropped); check 7 = Akka cluster membership strictly pair-only, 0 cross-boundary association; check 8 = full-rig restart, 0 receptionist/ClusterClient lines on any of the 8 nodes. Instance-dependent central→site RPCs (`ExecuteOpcUa`/`ExecuteRoute`/standby parked retry/`TriggerFailover`) carried forward unit-proven — bare rig has no OPC-bound instance / inbound method / parked op / CLI failover verb.
|
||||
- All 8 PASS. Notables: check 1 clarified the site-side gate uses the node's single `GrpcPsk` and ignores `x-scadabridge-site` (central-side routing hint) — per-site isolation proven by wrong-key reject, not the header; check 6 = a **297,574-byte** single gRPC reply (a payload Akka's 128 KB frame dropped); check 7 = Akka cluster membership strictly pair-only, 0 cross-boundary association; check 8 = full-rig restart, 0 receptionist/ClusterClient lines on any of the 8 nodes. Instance-dependent central→site RPCs (`ExecuteOpcUa`/`ExecuteRoute`/standby parked retry/`TriggerFailover`) carried forward unit-proven — bare rig has no OPC-bound instance / inbound method / parked op / CLI failover verb. **All four LIVE-PROVEN 2026-08-01** (rig session): `ExecuteOpcUa` = `data-connection browse` returned the opc-plc root children on site-a; `ExecuteRoute` = `POST /api/RouteCheckHello` → `Route.To("route-check-1").Call("Hello")` round-tripped `hello-from-site-a`; parked retry = a cached call parked at 50 attempts against a stopped REST API, `cached-call retry` relay unparked it and it delivered HTTP 200 (test artifacts cleaned up after); `TriggerSiteFailover` = Health-dashboard button on site-b → relay accepted, active node CoordinatedShutdown (ClusterLeavingReason, exit 0), standby's singletons Younger→Oldest with no gap, old active auto-rejoined as standby in ~21 s. Nothing remains unit-proven-only on this migration.
|
||||
|
||||
## Gotchas for the executor (will bite; read twice)
|
||||
|
||||
|
||||
@@ -132,11 +132,12 @@
|
||||
"id": "P1A.DoD",
|
||||
"phase": "1A",
|
||||
"subject": "1A DoD: rig site-a on Grpc proves all 5 site->central paths while site-b/c stay Akka; PR merged before 1B",
|
||||
"status": "pending",
|
||||
"status": "completed",
|
||||
"activeForm": "Verifying the 1A DoD",
|
||||
"blockedBy": [
|
||||
"T1A.4"
|
||||
]
|
||||
],
|
||||
"notes": "PASS - ticked in the plan MD. Rig site-a on CentralTransport=Grpc proved the site->central paths (heartbeat/health/reconcile + Akka coexistence on site-b/c); PR #26 merged as aa60f438. Notification/audit deferred to the Phase 2 soak (no deployed instance at the time). The rig caught and fixed a central :5000 HTTP-drop regression (0e162cb2). Gate: docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md."
|
||||
},
|
||||
{
|
||||
"id": "T1B.1",
|
||||
@@ -187,12 +188,13 @@
|
||||
"id": "P1B.DoD",
|
||||
"phase": "1B",
|
||||
"subject": "1B DoD: rig central on Grpc for site-a proves full command matrix incl. standby parked retry; rebased on 1A; PR merged",
|
||||
"status": "pending",
|
||||
"status": "completed",
|
||||
"activeForm": "Verifying the 1B DoD",
|
||||
"blockedBy": [
|
||||
"T1B.4",
|
||||
"P1A.DoD"
|
||||
]
|
||||
],
|
||||
"notes": "PASS (proportionate) - ticked in the plan MD (2fa5e93c). Rig central on SiteTransport=Grpc proved central->site rides the authenticated SiteCommandService for all 3 sites (ExecuteQuery/ExecuteParked/ExecuteLifecycle -> 200, per-site PSK, 0 auth failures); rebased on 1A. Instance-dependent commands (tag ops/lifecycle/standby parked retry) + TriggerSiteFailover deferred to Phase 3 (no deployed instance / destructive UI-only); command-plane coexistence not expressible because SiteTransport is central-wide. Phases 2-5 completed downstream. Gate: docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md."
|
||||
},
|
||||
{
|
||||
"id": "P2",
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# OtOpcUa v3.0 dual-namespace cutover — scoping (Gitea #14)
|
||||
|
||||
**Date:** 2026-07-23 · **Status:** SCOPING (decisions needed — see §6) · **Tracked:** Gitea
|
||||
[#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14) · **Area:** Data Connection
|
||||
Layer (OPC UA adapter) · **Upstream:** OtOpcUa v3.0 (merged `master` 2026-07-16, PR #472, merge
|
||||
`ec6598ce`); design `~/Desktop/OtOpcUa/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`.
|
||||
**Date:** 2026-07-23 · **Status:** **DECIDED — D-1/D-2/D-3 all resolved inline in §6; phase 2
|
||||
(`nsu=` hardening + browse UX, items D/E/F) SHIPPED** (`0eb44314`, `97afa84f`, `e04c2617`; this
|
||||
scope doc + the phase-2 plan committed as `266f001a`). Live gates PASS: raw-path
|
||||
`2026-07-23-otopcua-v3-raw-path-live-gate.md`, alarms
|
||||
`2026-07-23-otopcua-v3-alarm-bc-live-gate.md`. Only **item A** (re-author legacy `ns=2`
|
||||
bindings — data/operational, greenfield) remains open. · **Tracked:** Gitea
|
||||
[#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14) (CLOSED 2026-07-24) · **Area:**
|
||||
Data Connection Layer (OPC UA adapter) · **Upstream:** OtOpcUa v3.0 (merged `master` 2026-07-16,
|
||||
PR #472, merge `ec6598ce`); design
|
||||
`~/Desktop/OtOpcUa/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`.
|
||||
|
||||
## 1. Headline
|
||||
|
||||
@@ -74,7 +80,7 @@ Grounded in the DCL code (`src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/`):
|
||||
|---|------|------|---------------|------------|
|
||||
| A | **Legacy `ns=<index>` bindings collide with v3.** v2's sole custom namespace and v3's `raw` both land at `ns=2`, so a stored `ns=2;s=…` resolves **without error** but now means a raw-tree node. Every stored binding must be re-authored (or migrated) to the new address space, ideally in `nsu=` form. | **Data** | `TemplateAttribute.DataSourceReference`, `InstanceConnectionBinding.DataSourceReferenceOverride` (config DB); heartbeat `TagPath`; Transport bundles | High (inspection) |
|
||||
| B | **Native-alarm dedup across the raw+uns fan-out.** `RealOpcUaClient.HandleAlarmEvent` keys on RawPath+ConditionName with **no `ConditionId`-based dedup**; it assumes one condition arrives on one feed. If a v3 server delivers the same condition through two notifier paths into the same feed, two `EventFieldList`s each call `onTransition`. Must verify the Server-object aggregate feed collapses to one copy (the v3 doc says the SDK dedups Server-object subscribers, so this *should* hold — but it is the biggest genuine gap and needs a live check; add ConditionId-based dedup only if the live server double-delivers). | **Code + live validation** | `Adapters/RealOpcUaClient.cs` (HandleAlarmEvent), `Adapters/OpcUaAlarmMapper.cs` | Medium (needs live v3) |
|
||||
| C | **Alarm routing for UNS-bound sources (confirmed by D-1 = ingest both).** `DataConnectionActor` routes transitions by `SourceReference.StartsWith(bindingRef)`, but a v3 condition's `SourceName` is the **RawPath** regardless of subtree — so a **UNS-bound** alarm source won't `StartsWith`-match. Needs a routing change: resolve a UNS-bound source to its backing RawPath (via the browseable UNS→Raw `Organizes` reference) or map identities. Confirmed scope; validate the exact `SourceName`/`SourceNode` payload on the live rig before finalizing the mapping. | **Code + live validation** | `Actors/DataConnectionActor.cs` (alarm routing), `Adapters/RealOpcUaClient.cs` | Medium (needs live v3) |
|
||||
| C | **~~Alarm routing for UNS-bound sources~~ — OBSOLETE (2026-07-23); no code change needed.** The premise below is superseded by Gitea #17: `OpcUaAlarmMapper.BuildIdentity` stamps `SourceObjectReference` = the **subscribed node reference**, and `DataConnectionActor` routes on *that*, not on the event `SourceName`. Confirmed live — alarm B/C live gate **PASS** 2026-07-23 (`2026-07-23-otopcua-v3-alarm-bc-live-gate.md`): a UNS-node-scoped binding received and routed the condition correctly, and `SourceName` turned out to be the ScriptedAlarmId (not the RawPath) without breaking anything. *Original scoping text:* **Alarm routing for UNS-bound sources (confirmed by D-1 = ingest both).** `DataConnectionActor` routes transitions by `SourceReference.StartsWith(bindingRef)`, but a v3 condition's `SourceName` is the **RawPath** regardless of subtree — so a **UNS-bound** alarm source won't `StartsWith`-match. Needs a routing change: resolve a UNS-bound source to its backing RawPath (via the browseable UNS→Raw `Organizes` reference) or map identities. Confirmed scope; validate the exact `SourceName`/`SourceNode` payload on the live rig before finalizing the mapping. | **Code + live validation** | `Actors/DataConnectionActor.cs` (alarm routing), `Adapters/RealOpcUaClient.cs` | Medium (needs live v3) |
|
||||
| D | **Browse / search UX now shows two subtrees.** `BrowseChildrenAsync` / `AddressSpaceSearch.SearchAsync` start at the standard Objects root and will enumerate **both** raw and uns as sibling roots. Functional, not breaking — but the picker shows two roots, the manual-entry placeholder still nudges `ns=2;s=…`, and the deeper raw hierarchy stresses `VisitedNodeCeiling`/`maxDepth`. | **UX** | `CentralUI/Components/Dialogs/NodeBrowserDialog.razor`, `IOpcUaClient` search caps | High (inspection) |
|
||||
| E | **`nsu=` hardening (optional, recommended).** The picker already emits `nsu=` via `ToDurable`, but the seam still **accepts** bare `ns=<index>`. Decide whether to warn/reject bare `ns=` on save (closes A permanently) or leave it permissive. | **Code** | `OpcUaNodeReference`, binding validation, picker | High (inspection) |
|
||||
| F | **Doc drift.** `ns=` examples in code comments + `Component-DataConnectionLayer.md`; update to `nsu=`. Update the scadaproj umbrella index (cutover step 4). | **Docs** | doc comments, `docs/requirements/Component-DataConnectionLayer.md`, `../scadaproj/CLAUDE.md` | High |
|
||||
|
||||
@@ -250,9 +250,12 @@ git -C ../scadaproj add CLAUDE.md && git -C ../scadaproj commit -m "docs: note S
|
||||
|
||||
Tracked in the scope doc (§5 phases 3–5, §7). Do not attempt without a re-seeded v3 server:
|
||||
|
||||
- **Item A** — re-author existing dev/test bindings across both subtrees via the picker (data, greenfield; D-3 = no migration code).
|
||||
- **Item B** — native-alarm dedup across the raw+uns notifier fan-out; add `ConditionId` dedup **only if** the live server double-delivers.
|
||||
- **Item C** — UNS-bound alarm routing: a v3 condition's `SourceName` is always the RawPath, so a UNS-bound source won't `StartsWith`-match — resolve UNS→Raw via the browseable `Organizes` reference. Build against the real `SourceName`/`SourceNode` payload captured on the rig.
|
||||
- **Item A** — **STILL OPEN** — re-author existing dev/test bindings across both subtrees via the picker (data, greenfield; D-3 = no migration code).
|
||||
- ~~**Item B**~~ — **CLOSED 2026-07-23** by the native-alarm B/C live gate (**PASS** — `docs/plans/2026-07-23-otopcua-v3-alarm-bc-live-gate.md`): the live v3 server does **not** double-deliver — one subscription per connection delivers each retained condition once, to the most-specific monitored notifier — so no `ConditionId` dedup was added. *(Original: native-alarm dedup across the raw+uns notifier fan-out; add `ConditionId` dedup only if the live server double-delivers.)*
|
||||
- ~~**Item C**~~ — **CLOSED 2026-07-23** by the same gate (**PASS**): the premise is obsolete. ScadaBridge routes native alarms by the **subscribed node reference** (`OpcUaAlarmMapper.BuildIdentity` → `SourceObjectReference`), not the event `SourceName` (Gitea #17), so a UNS-bound source routes correctly with no code change. *(Original: a v3 condition's `SourceName` is always the RawPath, so a UNS-bound source won't `StartsWith`-match — resolve UNS→Raw via the browseable `Organizes` reference.)*
|
||||
|
||||
Also still open (tracked, needs a live v3 rig): calibrating the address-space search `maxDepth: 6`
|
||||
for the deeper raw hierarchy — see the note under Task 2.
|
||||
|
||||
## Verification (whole-plan, after Task 3)
|
||||
|
||||
|
||||
@@ -63,14 +63,16 @@ client (`IEndpointDiscovery`). Commit `a1abbff7` on `fix/opcua-discovery-host-re
|
||||
|
||||
## Deferred (unchanged — need OtOpcUa-side authoring on the dev cluster)
|
||||
|
||||
- **Item A** — re-author legacy `ns=2` bindings to `nsu=` (data; the collision is confirmed real here).
|
||||
- **Item B** — native-alarm dedup across raw+uns fan-out: **no native alarms exist** to drive it.
|
||||
- **Item C** — UNS-bound → RawPath alarm routing: **no UNS nodes and no alarms exist** to drive it.
|
||||
- **Item A** — **STILL OPEN** — re-author legacy `ns=2` bindings to `nsu=` (data; the collision is confirmed real here).
|
||||
- ~~**Item B**~~ — **CLOSED 2026-07-23**, see below.
|
||||
- ~~**Item C**~~ — **CLOSED 2026-07-23**, see below.
|
||||
|
||||
To gate B/C, the OtOpcUa dev cluster needs UNS Equipment authored (populate ns=3, referencing raw
|
||||
tags) and at least one alarm-configured tag raising a Part-9 condition that fans to raw + equipment
|
||||
notifiers.
|
||||
|
||||
**That authoring was done the same day and B/C were gated: `docs/plans/2026-07-23-otopcua-v3-alarm-bc-live-gate.md` — PASS, no ScadaBridge code change needed.** B: one subscription per connection delivers each retained condition once, to the most-specific monitored notifier, so overlapping bindings do not double-mirror. C: the premise was obsolete — routing keys off the **subscribed node reference** (`OpcUaAlarmMapper.BuildIdentity` → `SourceObjectReference`), not the event `SourceName` (Gitea #17). Item **A** remains the only open #14 work.
|
||||
|
||||
## Rig artifacts created (non-default rig state)
|
||||
|
||||
- ScadaBridge site-a-a / site-a-b attached to `otopcua-dev_default` (dropped on next `docker/deploy.sh`).
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
| Q-P3C-2 | Max parked messages per remote query? | Paginated, 100 per page, consistent with Site Event Logging pattern. | 2026-03-16 |
|
||||
| Q-P3C-3 | Per-instance operation lock — in-memory or persisted? | In-memory. Released on failover. Site state query resolves any ambiguity. | 2026-03-16 |
|
||||
| Q-P4-1 | API key values — auto-generated or user-provided? | Auto-generated with copy-to-clipboard. User can regenerate. | 2026-03-16 |
|
||||
| Q-P4-2 | Health dashboard refresh interval? | Updates on every report arrival (server push). No UI-side polling. | 2026-03-16 |
|
||||
| Q-P4-2 | Health dashboard refresh interval? | Updates on every report arrival (server push). No UI-side polling. **Superseded (2026-08-01): as shipped, the Health dashboard refreshes on a 10s UI polling timer — the server-push answer recorded here was not the implemented behavior.** | 2026-03-16 |
|
||||
| Q-P4-3 | Area deletion — cascade or bottom-up? | Cascade delete child areas if no instances are assigned to any area in the subtree. | 2026-03-16 |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Requirements Traceability Matrix
|
||||
|
||||
> **Note (2026-08-01)**: The Status column is frozen at plan-generation time (March 2026). "Pending" means the phase plan had not yet been generated at that time — **not** that a requirement is unimplemented. See `phase-N-checklist.md` for delivery status. All phases have since been generated and delivered.
|
||||
|
||||
**Purpose**: Ensures every requirement from docs/requirements/HighLevelReqs.md, every REQ-* identifier, and every design constraint from CLAUDE.md and docs/requirements/Component-*.md maps to at least one work package in an implementation phase plan. Updated as plan documents are generated.
|
||||
|
||||
**Traceability levels**:
|
||||
|
||||
@@ -91,7 +91,7 @@ row per lifecycle event across all channels.
|
||||
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
||||
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
||||
| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. |
|
||||
| `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`); NULL for a top-level run (inbound, tag-change / timer-triggered, un-bridged). |
|
||||
| `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`), a nested `CallScript`/`CallShared` run (= the caller's), and an alarm on-trigger run fired by a script- or inbound-API-initiated static attribute write (= the writer's). NULL for a top-level run: the inbound request itself, timer- and value-change-triggered scripts, and alarms fired by DCL (external device) data. |
|
||||
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
|
||||
| `SourceNode` | `varchar(64)` NULL | The cluster node on which the event was emitted — `node-a` / `node-b` for site rows (qualified by `SourceSiteId`), `central-a` / `central-b` for central-originated rows. Nullable so reconciled rows from a node that has since been retired don't block ingest. |
|
||||
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
||||
@@ -195,20 +195,41 @@ known spawn points:
|
||||
that calls `Route.Call`; the routed site script records the inbound request's
|
||||
`ExecutionId` as its `ParentExecutionId`, while the inbound `InboundRequest` row
|
||||
is top-level (`ParentExecutionId` NULL).
|
||||
- **Alarm-triggered on-trigger script** — when an alarm fires and its on-trigger
|
||||
script runs (via `AlarmActor → AlarmExecutionActor`), the alarm context's
|
||||
`ExecutionId` is carried as the run's `ParentExecutionId`. Currently the alarm
|
||||
subsystem has no Guid-typed firing id so on-trigger runs are roots (NULL) in
|
||||
practice, but the wiring is in place for a future alarm `ExecutionId`.
|
||||
- **Alarm-triggered on-trigger script** — when a write trips an alarm and its
|
||||
on-trigger script runs (via `AlarmActor → AlarmExecutionActor`), the run
|
||||
records the **writing execution's** `ExecutionId` as its `ParentExecutionId`.
|
||||
The write's originating execution rides site-locally from
|
||||
`ScriptRuntimeContext.SetAttribute` (or the inbound API's
|
||||
`Route.To(...).SetAttributes(...)`, whose `ParentExecutionId` is reused)
|
||||
→ `SetStaticAttributeCommand.SourceExecutionId` → the Instance Actor's
|
||||
published `AttributeValueChanged.SourceExecutionId` → the Alarm Actor's
|
||||
`SpawnAlarmExecution`. All four computed trigger types are covered; for an
|
||||
`Expression` trigger the writer captured with the evaluated snapshot is used,
|
||||
since the evaluation completes off the dispatcher after the firing change has
|
||||
left scope.
|
||||
- **Nested `CallScript` / `CallShared` invocations** — when a script calls
|
||||
`Instance.CallScript(...)` or a shared script via `CallShared`, the calling
|
||||
execution's `ExecutionId` threads into the spawned run as its
|
||||
`ParentExecutionId`, making deeply nested call chains visible as a tree.
|
||||
|
||||
Attribute-write-triggered cascades (one tag change triggering another script via a
|
||||
tag subscription) are also wired: trigger-driven runs carry `ParentExecutionId =
|
||||
NULL` (top-level roots), and any nested `CallScript`/`CallShared` they perform
|
||||
chains as above. The schema is unchanged — no further tag-cascade work is deferred.
|
||||
**Runs that remain roots — by design, not by omission.** `ParentExecutionId` is
|
||||
NULL where no spawning execution exists:
|
||||
|
||||
- **Alarms fired by Data Connection Layer data.** A value that arrives from a
|
||||
device subscription has no originating execution, so it carries no
|
||||
`SourceExecutionId` and the on-trigger run it fires is a root. This also covers
|
||||
the *confirmed* value of a script-initiated write to a **data-sourced**
|
||||
attribute: that write goes to the device and the echo returns on the
|
||||
subscription, long after the writing execution ended. Only **static**
|
||||
attribute writes (in-memory + persisted override) cascade.
|
||||
- **Script value-change / conditional / expression triggers and timer-driven
|
||||
runs** (`ScriptActor`). A timer tick has no spawner at all, and a
|
||||
`WhileTrue`/interval script fires repeatedly from a timer rather than from one
|
||||
identifiable write, so these runs stay roots; any nested `CallScript` /
|
||||
`CallShared` they perform chains normally beneath them.
|
||||
|
||||
The schema is unchanged throughout — the cascade is carried on site-local
|
||||
message fields, not on the wire or in central tables.
|
||||
|
||||
**Execution-tree traversal bound.** `GetExecutionTreeAsync` first walks up
|
||||
`ParentExecutionId` to the chain root, then walks down via a recursive CTE. The
|
||||
|
||||
@@ -47,7 +47,8 @@ Central cluster only. Sites have no user interface.
|
||||
- Manage template hierarchy (inheritance) — visual tree of parent/child relationships.
|
||||
- Manage composition — add/remove feature module instances within templates. **Naming collision detection** provides immediate feedback if composed modules introduce duplicate attribute, alarm, or script names.
|
||||
- Define and edit attributes, alarms, and scripts on templates.
|
||||
- **Native Alarms tab** (`TemplateEdit`): a tab alongside Attributes / Alarms / Scripts / Compositions that lists the template's **native alarm source bindings** — the OPC UA Alarms & Conditions / MxAccess Gateway sources whose alarm state the instance mirrors. Each binding carries Name, Connection, Source Reference, optional Condition Filter, Description, and a Lock flag. Add / edit / delete go through a **modal**:
|
||||
- **Member authoring dialogs are hosted, not page-embedded** (M10 residual, 2026-08-01): the template editor's Attribute, Alarm, Native Alarm Source, and Script forms all open through `IDialogService.ShowAsync`, so the single `DialogHost` in `MainLayout` owns the backdrop, focus trap, Escape, and focus restoration. Each form body is its own component beside the page (`TemplateAttributeDialog`, `TemplateAlarmDialog`, `TemplateNativeAlarmSourceDialog`, `TemplateScriptDialog`), following the `MoveDataConnectionDialog` pattern — the body renders validation and server errors **inline and stays open**, closing only on a successful save; persistence stays on the page (which owns `TemplateService`) behind an `OnSaveAsync` delegate.
|
||||
- **Native Alarms tab** (`TemplateEdit`): a tab alongside Attributes / Alarms / Scripts / Compositions that lists the template's **native alarm source bindings** — the OPC UA Alarms & Conditions / MxAccess Gateway sources whose alarm state the instance mirrors. Each binding carries Name, Connection, Source Reference, optional Condition Filter, Description, and a Lock flag. Add / edit / delete go through a **hosted dialog** (`TemplateNativeAlarmSourceDialog`):
|
||||
- **Name** — unique within the template (lock/inherit bookkeeping mirrors `TemplateAlarm`).
|
||||
- **Connection** — a dropdown filtered to **alarm-capable connections only** (OPC UA and MxGateway protocols).
|
||||
- **Source Reference** — the native key (OPC UA SourceNode / notifier nodeId, or MxAccess object/area).
|
||||
@@ -125,6 +126,7 @@ Central cluster only. Sites have no user interface.
|
||||
- **Source Reference** — the concrete native key for this instance.
|
||||
- **Filter** — the per-instance condition filter.
|
||||
- A **blank field inherits** the template default (the greyed placeholder shows the inherited value for context, mirroring the per-attribute Override field). **Save** and **Clear** act per row — Save persists the row's overrides, Clear reverts the row to the template-inherited binding. Locked template sources are not overridable.
|
||||
- **Bulk retarget CSV import**: an `InputFile` upload on the card header accepts a CSV of `SourceName, Connection, SourceReference, Filter` rows — the same file format and batch rules as the CLI `instance native-alarm-source import --file` (see Component-CLI.md), parsed with the shared `NativeAlarmSourceOverrideCsvParser`. Each row is validated against the template's source bindings (name resolves, source not template-locked, no duplicate source in the file); the import is **all-or-nothing** — any error aborts the whole upload with a per-line error summary and nothing is applied. Semantics are **merge, not full replace**: sources absent from the file keep their existing override, a blank field keeps the inherited value, and a row with all three fields blank clears that source's override. On success the rows are upserted through the same repository path the inline Save uses.
|
||||
- Filter/search instances by site, area, template, or status.
|
||||
- **Disable** instances — stops data collection, script triggers, and alarm evaluation at the site while retaining the deployed configuration.
|
||||
- **Enable** instances — re-activates a disabled instance.
|
||||
@@ -157,7 +159,7 @@ Central cluster only. Sites have no user interface.
|
||||
|
||||
#### Tabbed Layout
|
||||
|
||||
The Debug View page uses a **two-tab layout** — an **Attributes** tab and an **Alarms** tab — replacing the earlier side-by-side flat tables. Each tab renders its data as a **collapsible hierarchy tree** using the existing generic `TreeView<TItem>` component.
|
||||
The Debug View page uses a **two-tab layout** — an **Attributes** tab and an **Alarms** tab — replacing the earlier side-by-side flat tables. Each tab renders its data as a **collapsible hierarchy tree** using the existing generic `TreeView<TItem>` component. As of 2026-08-01 that component implements the full **WAI-ARIA tree keyboard pattern** — roving tabindex (one Tab stop per tree), Arrow/Home/End movement, Enter/Space activation, plus `aria-level`/`aria-posinset`/`aria-setsize` — so every tree surface in the Central UI (Debug View, Data Connections, Topology, the template folder browser) is keyboard-navigable. See [`docs/components/TreeView.md`](../components/TreeView.md).
|
||||
|
||||
**Tree hierarchy** — the hierarchy is derived from the path-qualified canonical names already present in the debug snapshot (e.g. `Motor1.Compressor.Pump`). The instance is the root node; composed modules are collapsible branch nodes; individual attributes (Attributes tab) or alarms/native-source bindings (Alarms tab) are leaf nodes.
|
||||
|
||||
|
||||
@@ -270,12 +270,30 @@ All defined in Commons so the feed is identical across protocols:
|
||||
|
||||
| Type | Shape |
|
||||
|------|-------|
|
||||
| `NativeAlarmTransition` | `SourceReference`, `SourceObjectReference`, `AlarmTypeName`, `Kind`, `Condition`, `Category`, `Description`, `Message`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `TransitionTime`, `CurrentValue`, `LimitValue` |
|
||||
| `NativeAlarmTransition` | `SourceReference`, `SourceObjectReference`, `AlarmTypeName`, `Kind`, `Condition`, `Category`, `Description`, `Message`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `TransitionTime`, `CurrentValue`, `LimitValue`, `AckTime?` |
|
||||
| `AlarmConditionState` | `Active`, `Acknowledged`, `Confirmed?` (null when not confirmable), `Shelve`, `Suppressed`, `Severity` (0–1000) |
|
||||
| `AlarmTransitionKind` (enum) | `Snapshot`, `SnapshotComplete`, `Raise`, `Acknowledge`, `Clear`, `Retrigger`, `StateChange` |
|
||||
|
||||
`OperatorUser` / `OperatorComment` and `CurrentValue` / `LimitValue` are display-only mirrors from the source.
|
||||
|
||||
### Ack Timestamp (`AckTime`)
|
||||
|
||||
`AckTime` is the instant a condition was acknowledged, mirrored end-to-end (transition → `AlarmStateChanged` → `AlarmStateUpdate` field 24 → site `native_alarm_state`) so MES can report a real `AckDT` and the `Alarms.CurrentAsync()` script accessor can surface it. Added additively as a trailing optional parameter — every pre-existing positional construction still compiles and yields `null`.
|
||||
|
||||
**One rule decides whether it is set: the condition must be BOTH active and acknowledged.** That single predicate gives all three required behaviours — null while unacknowledged, cleared on re-raise (a re-raise transition arrives unacknowledged), and no phantom ack on a return-to-normal (which matters for MxGateway, where `INACTIVE` maps to `Acknowledged = true`).
|
||||
|
||||
**Provenance differs by protocol, and the difference is deliberate — the value is never fabricated:**
|
||||
|
||||
| Source | Ack instant used | Accuracy |
|
||||
|---|---|---|
|
||||
| OPC UA A&C | `AcknowledgeableConditionType/AckedState/TransitionTime` — **SelectClause index 18**, appended after the limit fields so indices 0–17 keep their meaning | The source's own ack instant |
|
||||
| OPC UA A&C, server omits the field | The event's `Time` field | When the DCL observed the acknowledged state |
|
||||
| MxAccess Gateway | The transition's own timestamp (`TransitionTimestamp`, or the DCL's receipt time when the gateway omits it); on a re-subscribe snapshot, `LastTransitionTimestamp` for an `ACTIVE_ACKED` entry | When the system saw the ack — the gateway feed carries no dedicated ack timestamp |
|
||||
|
||||
The decision lives in the pure mappers (`OpcUaAlarmMapper.DeriveAckTime`, `MxGatewayAlarmMapper.DeriveAckTime`), so it is unit-tested without a live server or gateway.
|
||||
|
||||
Design record: `docs/plans/2026-06-30-mes-alarm-status-api.md` §6.4.
|
||||
|
||||
**Messages:**
|
||||
|
||||
- `SubscribeAlarmsRequest` / `SubscribeAlarmsResponse` — instance (via the DCL manager) subscribes a source binding to native alarms; the response carries success + an optional error message.
|
||||
|
||||
@@ -177,6 +177,8 @@ A deployment to a site includes the flattened instance configuration plus any sy
|
||||
|
||||
System-wide artifact deployment is a **separate action** from instance deployment, triggered explicitly by a user with the Deployment role. Artifacts can be deployed to all sites at once or to an individual site (per-site deployment via the Sites admin page).
|
||||
|
||||
Each artifact class in the per-site command carries the **complete system-wide set** (the site-scoped full set, for data connections), and the site applies it as **upsert-then-reconcile**: every incoming artifact is stored, then any stored row absent from the set is deleted — the artifact was deleted centrally. This is how central deletes reach sites; there is no per-artifact delete command. Reconciled removals also clean up runtime state on the site: a removed shared script is unregistered from the compiled script library, and a removed data connection's live DCL connection actor is stopped (any deployed instance still referencing it sees bad quality — the standard disconnected signal — until redeployed against current central config). A `null` artifact list on the command means "field not shipped" and touches nothing.
|
||||
|
||||
Notification lists and SMTP configuration are **not** deployable artifacts — they
|
||||
are central-only definitions managed by the Notification Service (see
|
||||
Component-NotificationService.md). Notification delivery happens on the central
|
||||
|
||||
@@ -224,6 +224,8 @@ Inbound API scripts **cannot** call shared scripts directly — shared scripts a
|
||||
- `Route.To("instanceUniqueCode").SetAttributes(dictionary)` — Write multiple attribute values in a **single call**, accepting a dictionary of name-value pairs.
|
||||
- `Route.To("instanceUniqueCode").WaitForAttribute("attributeName", targetValue, timeout)` — Wait, event-driven, until an attribute on a specific instance at any site reaches `targetValue` (value-equality only across the wire), bounded by `timeout`. Returns `true` if matched within the timeout, `false` if it timed out. **The wait is bounded by its own `timeout`, not the generic method-level timeout** — this is the one routed call that may legitimately outlive the method timeout (the site enforces `timeout` and returns `false` when it elapses). A client disconnect still cancels the wait. This is the deliberate exception to the rule below that routed calls inherit the method-level timeout (see "Routing Behavior"): a long event-driven wait is the explicit reason `timeout` governs here.
|
||||
|
||||
> **Reading alarm state via a routed script.** There is deliberately **no** `Route.To(...).GetAlarms(...)` verb. An inbound method that needs a machine's current alarms resolves the instance and calls a **site script** that uses the Site Runtime `Alarms.CurrentAsync()` accessor — `Route.To(code).Call("SimpleAlarmStatus", new { ... })`. Alarm state is per-instance and lives on the site's Instance Actor; keeping the read inside a site script means the filtering/shaping happens where the data is, and central stays a thin router. This is the shape the MES alarm-status endpoints use (`docs/plans/2026-06-30-mes-alarm-status-api.md` §5.1); see [Component-SiteRuntime.md](Component-SiteRuntime.md) → *Alarm State (read-only)* for the accessor.
|
||||
|
||||
#### Input/Output
|
||||
- **Input parameters** are available as defined in the method definition.
|
||||
- **Return value** construction matching the defined return structure.
|
||||
|
||||
@@ -98,9 +98,12 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
|
||||
- **CreateTemplateFolder** (`Name`, `ParentFolderId?`): Create a folder, optionally nested under a parent (Design role).
|
||||
- **RenameTemplateFolder** (`FolderId`, `NewName`): Rename a folder; enforces sibling uniqueness (Design role).
|
||||
- **MoveTemplateFolder** (`FolderId`, `NewParentFolderId?`): Move a folder to a new parent (or root); rejects cycles (Design role).
|
||||
- **ReorderTemplateFolder** (`FolderId`, `Direction`): Swap a folder's `SortOrder` with its previous (`Up`) or next (`Down`) sibling; reordering past either end is a no-op (Design role).
|
||||
- **DeleteTemplateFolder** (`FolderId`): Delete a folder; blocked if the folder contains any subfolders or templates (Design role).
|
||||
- **MoveTemplateToFolder** (`TemplateId`, `NewFolderId?`): Move a template into a folder, or to the root when null (Design role).
|
||||
|
||||
The whole folder surface is reachable from the CLI as `template folder list|create|rename|move|reorder|delete` as well as from the Central UI template tree.
|
||||
|
||||
### Template Members
|
||||
|
||||
- **AddTemplateAttribute** / **UpdateTemplateAttribute** / **DeleteTemplateAttribute**: Manage attributes on a template.
|
||||
@@ -127,6 +130,7 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
|
||||
- **ListSites** / **GetSite**: Query site definitions.
|
||||
- **CreateSite** / **UpdateSite** / **DeleteSite**: Manage site definitions.
|
||||
- **ListAreas** / **CreateArea** / **UpdateArea** / **DeleteArea**: Manage area hierarchies per site.
|
||||
- **MoveArea** (`AreaId`, `NewParentAreaId?`): Re-parent an area within its own site; a null `NewParentAreaId` moves it to the site root. Delegates every rule to `AreaService.MoveAreaAsync` — rejects self-parent, descendant-parent (cycle), cross-site parent, and sibling name collision at the target level; the service writes the `"Move"` audit row. Same any-of `[Designer, Deployer]` gate as the other area mutations. CLI: `site area move`.
|
||||
|
||||
### Data Connections
|
||||
|
||||
@@ -221,7 +225,7 @@ Every incoming message carries the authenticated user's identity and roles. The
|
||||
- **Admin** role required for: site management, API key management, role mapping management, scope rule management, system configuration.
|
||||
- **Design** role required for: template authoring (including template member management: attributes, alarms, native alarm sources, scripts, compositions), shared scripts, external system definitions, database connection definitions, notification lists, inbound API method definitions.
|
||||
- **Deployment** role required for: instance management (including instance alarm overrides and native alarm source overrides), deployments, debug view, debug snapshot, parked message queries, site event log queries. Site scoping is enforced for site-scoped Deployment users.
|
||||
- **Any of Design / Deployment** required for: area management (`CreateAreaCommand` / `UpdateAreaCommand` / `DeleteAreaCommand`). Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies — enforced as an any-of gate (arch-review C6). Administrator is not implicitly included.
|
||||
- **Any of Design / Deployment** required for: area management (`CreateAreaCommand` / `UpdateAreaCommand` / `MoveAreaCommand` / `DeleteAreaCommand`). Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies — enforced as an any-of gate (arch-review C6). Administrator is not implicitly included.
|
||||
- **Operator** role required for: submitting a secured write (`SubmitSecuredWriteCommand`).
|
||||
- **Verifier** role required for: approving / rejecting a secured write (`ApproveSecuredWriteCommand` / `RejectSecuredWriteCommand`). The no-self-approval rule (`Operator ≠ Verifier`) is enforced in the handler, independent of the role check.
|
||||
- **Any of Operator / Verifier / Administrator** required for: listing / querying secured writes (`ListSecuredWritesCommand`). Read-only, but the history exposes process-sensitive tag values, so it is gated any-of rather than open to every authenticated user (arch-review UA1). Enforced as an any-of gate (`GetRequiredRoles` returns the permitted set; the caller must hold at least one).
|
||||
|
||||
@@ -145,7 +145,11 @@ Used by the Template Engine deploy gate:
|
||||
```csharp
|
||||
var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||
```
|
||||
This allows the compile to bind `Attributes["name"]`, `Notify.To("x").Send(...)`, `ExternalSystem.Call(...)`, and similar API calls against real types, catching undefined-symbol and type-mismatch errors before deployment.
|
||||
This allows the compile to bind `Attributes["name"]`, `Notify.To("x").Send(...)`, `ExternalSystem.Call(...)`, `Alarms.CurrentAsync()`, and similar API calls against real types, catching undefined-symbol and type-mismatch errors before deployment.
|
||||
|
||||
Where an accessor returns a domain type rather than a primitive, the stub returns the **same** type the runtime returns (not a compile-only stand-in) — `Alarms.CurrentAsync()` yields `IReadOnlyList<ScriptAlarm>` on both surfaces, so a script reading `a.Severity` / `a.AckTime` binds identically at the gate and at the site. This works because those types live in Commons, which is already in `DefaultAssemblies`.
|
||||
|
||||
**Trust-model note:** the script trust boundary is a **deny-list over API roots**, not an allow-list of context members, so adding a globals member such as `Alarms` requires no `ScriptTrustPolicy` change. What must hold is that the member's return type resolves in a *permitted* namespace — pinned by a test asserting no entry in `ForbiddenScopes` prefixes the Commons script-surface namespace.
|
||||
|
||||
#### `TriggerCompileSurface`
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ flowchart TD
|
||||
- Receives updated shared scripts, external system definitions, database connection definitions, and data connection definitions from central. (Notification lists and SMTP configuration are central-only and are not deployed to sites — see Component-NotificationService.md.)
|
||||
- Stores all artifacts in local SQLite. After artifact deployment, the site is fully self-contained — all runtime configuration is read from local SQLite with no access to the central configuration database.
|
||||
- Recompiles shared scripts and makes updated code available to all Script Actors.
|
||||
- **Reconciles deletions**: each non-null artifact list is the complete set for its class, so after upserting, any stored row absent from the set is deleted (the artifact was deleted centrally — there is no per-artifact delete command). A removed shared script is also unregistered from the compiled script library, and a removed data connection's live DCL connection actor is stopped. A `null` list means "field not shipped" and reconciles nothing.
|
||||
|
||||
### Instance Lifecycle Commands
|
||||
- **Disable**: Stops the Instance Actor and its children. Retains the deployed configuration in SQLite so the instance can be re-enabled without redeployment.
|
||||
@@ -258,6 +259,41 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
|
||||
- **Can** call instance scripts via `Instance.CallScript()` — sends an ask message to the appropriate sibling Script Actor.
|
||||
- Instance scripts **cannot** call alarm on-trigger scripts — the call direction is one-way.
|
||||
|
||||
### Audit correlation of an on-trigger run (`ParentExecutionId` tag-cascade)
|
||||
|
||||
An alarm fired by a **script- or inbound-API-initiated static attribute write**
|
||||
is a genuine execution spawn, and the on-trigger run records the writing
|
||||
execution as its parent in the central Audit Log (#23). The originating id rides
|
||||
site-locally through three additive, nullable fields — no wire, proto or schema
|
||||
change:
|
||||
|
||||
| Hop | Field |
|
||||
|---|---|
|
||||
| `ScriptRuntimeContext.SetAttribute` (this run's `ExecutionId`) / `RouteToSetAttributesRequest.ParentExecutionId` (the inbound request's) | → `SetStaticAttributeCommand.SourceExecutionId` |
|
||||
| Instance Actor static-write path (`HandleSetStaticAttributeCore`) | → `AttributeValueChanged.SourceExecutionId` |
|
||||
| Alarm Actor trigger evaluation → `SpawnAlarmExecution` | → `AlarmExecutionActor` → `ScriptRuntimeContext.ParentExecutionId` |
|
||||
|
||||
All four computed trigger types participate. `Expression` triggers evaluate a
|
||||
whole attribute snapshot **off the dispatcher**, so the firing change is no
|
||||
longer in scope when the boolean returns: the writer of the newest value folded
|
||||
into the snapshot is captured *with* the snapshot and echoed back on the
|
||||
evaluation result, so a change arriving mid-flight cannot mis-attribute the
|
||||
raise.
|
||||
|
||||
The on-trigger run is correctly a **tree root** (`ParentExecutionId` NULL) when
|
||||
the firing value has no originating execution:
|
||||
|
||||
- Values from the **Data Connection Layer** — external device data has no
|
||||
spawning execution. This includes the confirmed value of a write to a
|
||||
**data-sourced** attribute: that write is forwarded to the device and the echo
|
||||
arrives on the subscription long after the writing execution ended, so only
|
||||
**static** attribute writes cascade.
|
||||
- Deploy-time seeding and central Test Run / tooling writes.
|
||||
|
||||
`AttributeValueChanged.SourceExecutionId` is deliberately **not** projected onto
|
||||
the gRPC debug/stream wire shapes — the cascade is resolved inside the site node
|
||||
that owns the Instance Actor, and central reads the linkage from audit rows.
|
||||
|
||||
---
|
||||
|
||||
## Native Alarm Actor
|
||||
@@ -314,6 +350,7 @@ The Instance Actor owns native-alarm setup alongside its computed Script and Ala
|
||||
- **Columns**: `condition_json` (the serialized `AlarmConditionState`), `metadata_json` (display metadata — type/category/message/current+limit, UA4), and `last_transition_at` (the accepted `TransitionTime`).
|
||||
- **Operations**: `Upsert` (on accepted transition), `Delete` (on condition drop-out), `Get` (PreStart rehydrate, scoped to instance + source), and `ClearForInstance` (redeploy/undeploy reset).
|
||||
- **Display metadata persistence (UA4)**: persisted rows include the condition's display metadata, so **rehydrated conditions render fully** (type / category / message / current + limit values) before the first fresh source snapshot arrives; a pre-UA4 row with no metadata rehydrates with empty display fields (unchanged behavior).
|
||||
- **Ack timestamp persistence**: the condition's `AckTime` rides the same `metadata_json` blob, so an acknowledged alarm keeps its ack instant across restart/failover rather than reappearing as if it had never been acknowledged. It is deliberately **not** a new physical column: `native_alarm_state` is `RegisterReplicated` in `SiteLocalDbSetup`, and LocalDb builds that table's CDC triggers from the column list at registration time — an additive JSON property changes no schema, no triggers and no replication contract, and `metadata_json` is exactly the extension point UA4 introduced for this. A row written before the field existed deserializes it as `null` ("ack time unknown"), which is the correct fallback. See `docs/plans/2026-06-30-mes-alarm-status-api.md` §6.4.
|
||||
- **Coalesced writes (P4)**: per-transition upserts are coalesced (latest-per-source-reference) and flushed on a short single-shot timer rather than one SQLite write per transition, so an alarm storm collapses to one batched write; a pending upsert is dropped if the condition is deleted before the flush (no resurrection). Durability window is at most one flush interval on crash, reconciled by the next re-subscribe snapshot.
|
||||
- This is a **peer SQLite store** to the existing deployed-configuration, store-and-forward, operation-tracking, and `AuditLog` stores. Unlike computed alarm state, native mirror state is intentionally persisted so it survives failover.
|
||||
|
||||
@@ -329,8 +366,9 @@ The `AlarmStateChanged` message published by both Alarm Actors and Native Alarm
|
||||
- **Computed-alarm projection**: computed alarms are surfaced as **auto-acknowledged** with `Severity = Priority`, so a single enriched shape carries both computed and native alarms onto the stream and into the DebugView snapshot.
|
||||
- **`NativeSourceCanonicalName`** (`string?`): the canonical name of the native alarm source binding a native condition belongs to (e.g. `Motor1.NativeAlarms`). `null` for computed alarms. Stamped by the `NativeAlarmActor` on every emitted event; also carried on idle-binding placeholder rows emitted by the Instance Actor.
|
||||
- **`IsConfiguredPlaceholder`** (`bool`): `true` for a synthetic row emitted by the Instance Actor to represent a configured native source binding that currently has no live conditions. These rows are never persisted or forwarded as operational alarms — they exist solely so the Debug View can render every configured binding as a tree node. `false` on all other rows.
|
||||
- **`AckTime`** (`DateTimeOffset?`): the instant the condition was acknowledged; `null` while unacknowledged, `null` on computed alarms (auto-acked but with no operator ack event), and cleared on re-raise. Stamped by the DCL adapter — the source's own ack instant where the protocol supplies one (OPC UA A&C), the DCL's observation time of the ack transition where it does not (MxAccess Gateway) — and carried verbatim by the `NativeAlarmActor`, which never invents or suppresses one. See [Component-DataConnectionLayer.md](Component-DataConnectionLayer.md) → *Ack Timestamp*.
|
||||
|
||||
Both fields are **additive-only** (init-only with defaults: `null` / `false`) and are mirrored additively on the gRPC `AlarmStateUpdate` proto as fields 22 (`native_source_canonical_name`, `string`) and 23 (`is_configured_placeholder`, `bool`). `StreamRelayActor` and `SiteStreamGrpcClient` pack/unpack these fields without touching existing fields, preserving backward compatibility across version-skew boundaries.
|
||||
These fields are **additive-only** (init-only with defaults: `null` / `false`) and are mirrored additively on the gRPC `AlarmStateUpdate` proto as fields 22 (`native_source_canonical_name`, `string`), 23 (`is_configured_placeholder`, `bool`) and 24 (`ack_time`, `google.protobuf.Timestamp`, absent ⇒ `null`). `StreamRelayActor` and `SiteStreamGrpcClient` pack/unpack these fields without touching existing fields, preserving backward compatibility across version-skew boundaries.
|
||||
|
||||
The enriched message flows Instance Actor → site-wide Akka stream → `SiteStreamManager` → `SiteStreamGrpcServer` and is streamed to central as the gRPC `AlarmStateUpdate` event (see [Component-Communication.md](Component-Communication.md)).
|
||||
|
||||
@@ -401,6 +439,16 @@ Available to all Script Execution Actors and Alarm Execution Actors:
|
||||
### Operation Tracking
|
||||
- `Tracking.Status("trackedOperationId")` — Returns a status record (status, retry count, last error, key timestamps) for any tracked operation: a cached external system call, a cached database write, or a notification. For cached calls and writes the answer is always site-local and authoritative — the site owns the operation tracking table. (`Notify.Status(...)` is a thin alias scoped to the notification domain.)
|
||||
|
||||
### Alarm State (read-only)
|
||||
- `Alarms.CurrentAsync()` — Returns a snapshot of the instance's **current** alarm conditions as `IReadOnlyList<ScriptAlarm>` (`Commons.Types.Scripts`), covering computed alarms and mirrored native (OPC UA A&C / MxAccess Gateway) conditions alike. Served by a **local Ask** (`GetAlarmSnapshotRequest`/`GetAlarmSnapshotResponse`) to the script's own Instance Actor — the same mechanism attribute reads use, never a cross-cluster hop.
|
||||
- Available to **every** script, unlike the `Alarm` global, which exists only inside an on-trigger handler and describes the single alarm that fired. This accessor is what lets an inbound-API-routed `Call` script answer "what is in alarm on this machine right now?"; before it, native mirrored conditions were reachable only from the Debug View.
|
||||
- **Read-only by design** — native alarms are a read-only mirror of the source (no ack-back), so the accessor exposes no acknowledge/shelve operation.
|
||||
- **Not scope-prefixed**, unlike `Attributes`: alarm identity is not a scope-relative attribute name (computed alarms are keyed by configured name, native conditions by a source-supplied reference), so every scope sees the same list rather than a silently truncated one.
|
||||
- The snapshot is built by the same `BuildAlarmStatesSnapshot()` the Debug View uses, so it **includes placeholder rows** for configured-but-quiet native source bindings (`IsConfiguredPlaceholder`). Callers enumerating real alarms filter on `Active && !IsConfiguredPlaceholder`.
|
||||
- `ScriptAlarm` is a flat projection of the retained `AlarmStateChanged`: `Name`, `SourceReference`, `NativeSourceCanonicalName`, `Active`, `Acknowledged`, `Confirmed?`, `Shelved` (all shelve sub-states collapse to one boolean), `Suppressed`, `Severity` (0–1000), `Kind`, `Message`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `Timestamp`, `AckTime?`, `CurrentValue`, `LimitValue`, `IsConfiguredPlaceholder`. The `Condition` is the authority for active/acked/severity, so computed and native alarms read identically from one filter expression.
|
||||
- The type lives in Commons so the runtime accessor and the Script Analysis compile-only surface (`ScriptCompileSurface.CompileAlarmsAccessor`) project to the **same** type — a script binding at the design-time gate binds identically at the site. Parity is pinned by `CompileSurfaceParityTests`.
|
||||
- Design record: `docs/plans/2026-06-30-mes-alarm-status-api.md` §5.2.
|
||||
|
||||
### Parameter Access
|
||||
- `Parameters["key"]` — Raw dictionary access (returns `object?`, requires manual casting).
|
||||
- `Parameters.Get<T>("key")` — Typed access with descriptive error messages. Throws `ScriptParameterException` if parameter is missing, null, or cannot be converted to `T`.
|
||||
@@ -420,7 +468,7 @@ Available to all Script Execution Actors and Alarm Execution Actors:
|
||||
|
||||
Scripts execute **in-process** with constrained access. The following restrictions are enforced at compilation and runtime:
|
||||
|
||||
- **Allowed**: Access to the Script Runtime API (GetAttribute, SetAttribute, CallScript, CallShared, ExternalSystem, Notify, Database, Tracking), standard C# language features, basic .NET types (collections, string manipulation, math, date/time). `System.Diagnostics.Stopwatch`, `Debug`, and `Activity` are permitted.
|
||||
- **Allowed**: Access to the Script Runtime API (GetAttribute, SetAttribute, CallScript, CallShared, ExternalSystem, Notify, Database, Tracking, Alarms), standard C# language features, basic .NET types (collections, string manipulation, math, date/time). `System.Diagnostics.Stopwatch`, `Debug`, and `Activity` are permitted.
|
||||
- **Forbidden**: File system access (`System.IO`), process spawning (`System.Diagnostics.Process`), threading (`System.Threading` — except `Tasks`, `CancellationToken`, and `CancellationTokenSource`), reflection (`System.Reflection`), all raw network access (`System.Net` — must use `ExternalSystem.Call`), native interop (`System.Runtime.InteropServices`, `Microsoft.Win32`), assembly loading, unsafe code, `dynamic`, `Activator`.
|
||||
- **Execution timeout**: Configurable per-script maximum execution time. Exceeding the timeout cancels the script **cooperatively** (S2/UA5): a script blocked in synchronous I/O or a tight CPU loop does not observe cancellation and continues to occupy its dedicated script-execution thread. A **watchdog logs the script by name** once its thread has not returned within a grace period after the timeout, and the script-execution scheduler's **queue depth / busy-thread / oldest-busy-age gauges** are reported through Health Monitoring so a saturated or stuck scheduler is visible.
|
||||
- **Memory**: Scripts share the host process memory. No per-script memory limit, but the execution timeout prevents runaway allocations.
|
||||
|
||||
@@ -183,6 +183,23 @@ public static class SiteCommands
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
// Omitting --parent-id moves the area to the site root, matching
|
||||
// AreaService.MoveAreaAsync's null newParentAreaId contract.
|
||||
var moveIdOption = new Option<int>("--id") { Description = "Area ID", Required = true };
|
||||
var moveParentOption = new Option<int?>("--parent-id") { Description = "New parent area ID; omit to move to the site root" };
|
||||
var moveCmd = new Command("move") { Description = "Move an area under a new parent area (or to the site root)" };
|
||||
moveCmd.Add(moveIdOption);
|
||||
moveCmd.Add(moveParentOption);
|
||||
moveCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(moveIdOption);
|
||||
var parentId = result.GetValue(moveParentOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new MoveAreaCommand(id, parentId));
|
||||
});
|
||||
group.Add(moveCmd);
|
||||
|
||||
var deleteIdOption = new Option<int>("--id") { Description = "Area ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete an area" };
|
||||
deleteCmd.Add(deleteIdOption);
|
||||
|
||||
@@ -29,11 +29,116 @@ public static class TemplateCommands
|
||||
command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildFolder(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildResyncMembers(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>template folder</c> subgroup — the CLI surface for the template
|
||||
/// folder hierarchy management commands (folders are a Central UI organizational
|
||||
/// device only; they have no effect on template resolution or flattening).
|
||||
/// Omitting <c>--parent-id</c> on create/move places the folder at the tree root,
|
||||
/// matching the nullable <c>ParentFolderId</c> on the underlying commands.
|
||||
/// </summary>
|
||||
private static Command BuildFolder(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("folder") { Description = "Manage template folders" };
|
||||
|
||||
var listCmd = new Command("list") { Description = "List all template folders" };
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplateFoldersCommand());
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
var createNameOption = new Option<string>("--name") { Description = "Folder name", Required = true };
|
||||
var createParentOption = new Option<int?>("--parent-id") { Description = "Parent folder ID; omit to create at the tree root" };
|
||||
var createCmd = new Command("create") { Description = "Create a template folder" };
|
||||
createCmd.Add(createNameOption);
|
||||
createCmd.Add(createParentOption);
|
||||
createCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(createNameOption)!;
|
||||
var parentId = result.GetValue(createParentOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateTemplateFolderCommand(name, parentId));
|
||||
});
|
||||
group.Add(createCmd);
|
||||
|
||||
var renameIdOption = new Option<int>("--id") { Description = "Folder ID", Required = true };
|
||||
var renameNameOption = new Option<string>("--name") { Description = "New folder name", Required = true };
|
||||
var renameCmd = new Command("rename") { Description = "Rename a template folder" };
|
||||
renameCmd.Add(renameIdOption);
|
||||
renameCmd.Add(renameNameOption);
|
||||
renameCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(renameIdOption);
|
||||
var name = result.GetValue(renameNameOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new RenameTemplateFolderCommand(id, name));
|
||||
});
|
||||
group.Add(renameCmd);
|
||||
|
||||
var moveIdOption = new Option<int>("--id") { Description = "Folder ID", Required = true };
|
||||
var moveParentOption = new Option<int?>("--parent-id") { Description = "New parent folder ID; omit to move to the tree root" };
|
||||
var moveCmd = new Command("move") { Description = "Move a template folder under a new parent folder (or to the tree root)" };
|
||||
moveCmd.Add(moveIdOption);
|
||||
moveCmd.Add(moveParentOption);
|
||||
moveCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(moveIdOption);
|
||||
var parentId = result.GetValue(moveParentOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new MoveTemplateFolderCommand(id, parentId));
|
||||
});
|
||||
group.Add(moveCmd);
|
||||
|
||||
var reorderIdOption = new Option<int>("--id") { Description = "Folder ID", Required = true };
|
||||
var reorderDirectionOption = new Option<string>("--direction")
|
||||
{
|
||||
Description = "Reorder direction: 'up' swaps with the previous sibling, 'down' with the next",
|
||||
Required = true
|
||||
};
|
||||
reorderDirectionOption.AcceptOnlyFromAmong("up", "down");
|
||||
var reorderCmd = new Command("reorder") { Description = "Reorder a template folder among its siblings" };
|
||||
reorderCmd.Add(reorderIdOption);
|
||||
reorderCmd.Add(reorderDirectionOption);
|
||||
reorderCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(reorderIdOption);
|
||||
// AcceptOnlyFromAmong has already rejected anything but the two literals,
|
||||
// so this maps a validated value rather than parsing untrusted input.
|
||||
var direction = result.GetValue(reorderDirectionOption) == "up"
|
||||
? ReorderDirection.Up
|
||||
: ReorderDirection.Down;
|
||||
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new ReorderTemplateFolderCommand(id, direction));
|
||||
});
|
||||
group.Add(reorderCmd);
|
||||
|
||||
var deleteIdOption = new Option<int>("--id") { Description = "Folder ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete a template folder (fails if it contains subfolders or templates)" };
|
||||
deleteCmd.Add(deleteIdOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(deleteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteTemplateFolderCommand(id));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildResyncMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true };
|
||||
|
||||
@@ -431,6 +431,84 @@ scadabridge --url <url> template resync-members --id <int>
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Template ID (its derived subtree is included) |
|
||||
|
||||
#### `template folder list`
|
||||
|
||||
List all template folders. Folders are a Central UI organizational device only — they
|
||||
have no effect on template resolution or flattening.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template folder list
|
||||
```
|
||||
|
||||
Takes no options beyond the global ones.
|
||||
|
||||
#### `template folder create`
|
||||
|
||||
Create a template folder. Omit `--parent-id` to create it at the tree root. Sibling
|
||||
names must be unique (case-insensitive).
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template folder create --name <string> [--parent-id <int>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--name` | yes | Folder name |
|
||||
| `--parent-id` | no | Parent folder ID; omit to create at the tree root |
|
||||
|
||||
#### `template folder rename`
|
||||
|
||||
Rename a template folder. Enforces sibling-name uniqueness.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template folder rename --id <int> --name <string>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Folder ID |
|
||||
| `--name` | yes | New folder name |
|
||||
|
||||
#### `template folder move`
|
||||
|
||||
Move a template folder under a new parent folder. Omit `--parent-id` to move it to the
|
||||
tree root. Rejects cycles (a folder cannot move under one of its own descendants).
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template folder move --id <int> [--parent-id <int>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Folder ID to move |
|
||||
| `--parent-id` | no | New parent folder ID; omit to move to the tree root |
|
||||
|
||||
#### `template folder reorder`
|
||||
|
||||
Swap a folder's `SortOrder` with an adjacent sibling. `up` swaps with the previous
|
||||
sibling, `down` with the next; reordering past either end is a no-op.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template folder reorder --id <int> --direction <up|down>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Folder ID |
|
||||
| `--direction` | yes | `up` or `down` (lowercase literals only) |
|
||||
|
||||
#### `template folder delete`
|
||||
|
||||
Delete a template folder. Blocked if the folder still contains any subfolders or templates.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template folder delete --id <int>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Folder ID |
|
||||
|
||||
---
|
||||
|
||||
### `instance` — Manage instances
|
||||
@@ -831,6 +909,21 @@ scadabridge --url <url> site area update --id <int> --name <string>
|
||||
| `--id` | yes | Area ID |
|
||||
| `--name` | yes | Area name |
|
||||
|
||||
#### `site area move`
|
||||
|
||||
Re-parent an area within its own site. Omit `--parent-id` to move the area to the
|
||||
site root. Rejected for: self-parent, moving under one of the area's own descendants
|
||||
(cycle), a parent in a different site, and a sibling name collision at the target level.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> site area move --id <int> [--parent-id <int>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Area ID to move |
|
||||
| `--parent-id` | no | New parent area ID; omit to move to the site root |
|
||||
|
||||
#### `site area delete`
|
||||
|
||||
Delete an area. Fails if any instances are assigned to it.
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Event.ErrorDetail))
|
||||
{
|
||||
<pre class="bg-light border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
|
||||
<pre class="bg-body-secondary border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -119,7 +119,7 @@
|
||||
{
|
||||
<section class="mb-3" data-test="section-extra">
|
||||
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Extra</h6>
|
||||
<pre class="bg-light border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
|
||||
<pre class="bg-body-secondary border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
|
||||
</section>
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<small class="text-muted ms-1">@match.Path</small>
|
||||
@if (!string.IsNullOrEmpty(node.DataType))
|
||||
{
|
||||
<span class="badge bg-light text-muted border ms-1">@node.DataType</span>
|
||||
<span class="badge bg-body-secondary text-body-secondary border ms-1">@node.DataType</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</a>
|
||||
@if (!string.IsNullOrEmpty(Node.DataType))
|
||||
{
|
||||
<span class="badge bg-light text-muted border ms-1" data-test="node-type">@Node.DataType</span>
|
||||
<span class="badge bg-body-secondary text-body-secondary border ms-1" data-test="node-type">@Node.DataType</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
@if (result.FailureKind == VerifyFailureKind.UntrustedCertificate
|
||||
&& result.Cert is { } cert)
|
||||
{
|
||||
<div class="border rounded bg-light p-2 mt-2 small" data-test="verify-cert-panel">
|
||||
<div class="border rounded bg-body-secondary p-2 mt-2 small" data-test="verify-cert-panel">
|
||||
<div class="text-muted mb-1">Untrusted server certificate</div>
|
||||
<dl class="row mb-1 small">
|
||||
<dt class="col-sm-3">Subject</dt>
|
||||
|
||||
+2
-2
@@ -155,7 +155,7 @@
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<pre class="bg-light p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson!)</pre>
|
||||
<pre class="bg-body-secondary p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson!)</pre>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -179,7 +179,7 @@
|
||||
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre class="bg-light p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
|
||||
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
|
||||
|
||||
+188
-34
@@ -240,13 +240,13 @@
|
||||
<tr data-test="override-row-@attr.Name">
|
||||
<td class="small">@attr.Name</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">@attr.DataType</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis">@attr.DataType</span>
|
||||
@if (attr.DataType == DataType.List)
|
||||
{
|
||||
@* Element type is fixed by the base attribute — shown
|
||||
read-only here (the List editor renders it hidden via
|
||||
ShowElementType="false"). *@
|
||||
<span class="badge bg-light text-dark border ms-1"
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis border ms-1"
|
||||
data-test="override-element-type">
|
||||
of @(attr.ElementDataType ?? DataType.String)
|
||||
</span>
|
||||
@@ -326,7 +326,7 @@
|
||||
<tr data-test="alarm-override-row-@alarm.Name">
|
||||
<td class="small">@alarm.Name</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">@alarm.TriggerType</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis border">@alarm.TriggerType</span>
|
||||
</td>
|
||||
<td class="small text-muted text-truncate font-monospace" style="max-width: 280px;"
|
||||
title="@alarm.TriggerConfiguration">
|
||||
@@ -373,14 +373,14 @@
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">
|
||||
Edit override: @_editingAlarm.Name
|
||||
<span class="badge bg-light text-dark border ms-1">@_editingAlarm.TriggerType</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis border ms-1">@_editingAlarm.TriggerType</span>
|
||||
</h6>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelEditOverride"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3 small">
|
||||
<div class="text-muted text-uppercase fw-semibold mb-1">Inherited from template</div>
|
||||
<code class="d-block bg-light p-2 rounded text-break">@(_editingAlarm.TriggerConfiguration ?? "(none)")</code>
|
||||
<code class="d-block bg-body-secondary p-2 rounded text-break">@(_editingAlarm.TriggerConfiguration ?? "(none)")</code>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -431,14 +431,59 @@
|
||||
|
||||
@* Native Alarm Source Overrides *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2">
|
||||
<strong>Native Alarm Source Overrides</strong>
|
||||
<small class="text-muted ms-2">
|
||||
Retarget an inherited native alarm source binding for this instance.
|
||||
Leave a field blank to keep the inherited value.
|
||||
</small>
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>Native Alarm Source Overrides</strong>
|
||||
<small class="text-muted ms-2">
|
||||
Retarget an inherited native alarm source binding for this instance.
|
||||
Leave a field blank to keep the inherited value.
|
||||
</small>
|
||||
</div>
|
||||
@* Bulk import of native alarm source retargets from a CSV
|
||||
(SourceName,Connection,SourceReference,Filter) — the SAME file
|
||||
format and batch rules as the CLI
|
||||
`instance native-alarm-source import --file`. Selecting a file
|
||||
parses it with the shared NativeAlarmSourceOverrideCsvParser and
|
||||
validates every row against this template's sources; all-or-nothing,
|
||||
it either upserts each row through the SAME repository path the
|
||||
inline Save uses, or shows the per-line error list and applies
|
||||
nothing. Merge semantics — sources absent from the file keep their
|
||||
existing override. *@
|
||||
@if (_nativeSources.Count > 0)
|
||||
{
|
||||
<label class="btn btn-outline-secondary btn-sm mb-0 flex-shrink-0 ms-2">
|
||||
Import overrides (CSV)
|
||||
<InputFile OnChange="OnNativeAlarmSourceCsvImportSelectedAsync"
|
||||
accept=".csv"
|
||||
data-test="nas-csv-import-input"
|
||||
class="d-none"
|
||||
disabled="@_saving" />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (_nasCsvImportResult is not null)
|
||||
{
|
||||
<div class="p-2 pb-0">
|
||||
<div class="alert @(_nasCsvImportSucceeded ? "alert-success" : "alert-danger") small mb-0"
|
||||
data-test="nas-csv-import-result">
|
||||
@if (_nasCsvImportSucceeded)
|
||||
{
|
||||
<span>@_nasCsvImportResult</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="fw-semibold mb-1">@_nasCsvImportResult</div>
|
||||
<ul class="mb-0 ps-3">
|
||||
@foreach (var err in _nasCsvImportErrors)
|
||||
{
|
||||
<li>@err</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (_nativeSources.Count == 0)
|
||||
{
|
||||
<p class="text-muted small p-3 mb-0">No native alarm sources on this template.</p>
|
||||
@@ -623,6 +668,12 @@
|
||||
private Dictionary<string, string?> _nasRefEdit = new();
|
||||
private Dictionary<string, string?> _nasFilterEdit = new();
|
||||
|
||||
// Native-alarm-source CSV bulk-import result summary — same shape as the
|
||||
// attribute importer's (_csvImport*) trio. Null until an import runs.
|
||||
private string? _nasCsvImportResult;
|
||||
private bool _nasCsvImportSucceeded;
|
||||
private IReadOnlyList<string> _nasCsvImportErrors = Array.Empty<string>();
|
||||
|
||||
// Override edit modal state — non-null while the modal is open.
|
||||
private TemplateAlarm? _editingAlarm;
|
||||
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
|
||||
@@ -1368,29 +1419,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
|
||||
if (existing == null)
|
||||
{
|
||||
var ovr = new InstanceNativeAlarmSourceOverride(sourceName)
|
||||
{
|
||||
InstanceId = Id,
|
||||
ConnectionNameOverride = conn,
|
||||
SourceReferenceOverride = sref,
|
||||
ConditionFilterOverride = filt
|
||||
};
|
||||
await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
_existingNativeOverrides[sourceName] = ovr;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ConnectionNameOverride = conn;
|
||||
existing.SourceReferenceOverride = sref;
|
||||
existing.ConditionFilterOverride = filt;
|
||||
await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
_existingNativeOverrides[sourceName] = existing;
|
||||
}
|
||||
await UpsertNativeOverrideCore(sourceName, conn, sref, filt);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1415,6 +1445,42 @@
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts one native alarm source override row and re-seeds the inline editor's
|
||||
/// state for that source. Does NOT call <c>SaveChangesAsync</c> — the caller does,
|
||||
/// so a bulk import commits the whole batch in one go. Shared by the inline Save
|
||||
/// and the CSV import so both take the identical persistence path.
|
||||
/// </summary>
|
||||
private async Task UpsertNativeOverrideCore(string sourceName, string? conn, string? sref, string? filt)
|
||||
{
|
||||
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
|
||||
if (existing == null)
|
||||
{
|
||||
var ovr = new InstanceNativeAlarmSourceOverride(sourceName)
|
||||
{
|
||||
InstanceId = Id,
|
||||
ConnectionNameOverride = conn,
|
||||
SourceReferenceOverride = sref,
|
||||
ConditionFilterOverride = filt
|
||||
};
|
||||
await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
|
||||
_existingNativeOverrides[sourceName] = ovr;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ConnectionNameOverride = conn;
|
||||
existing.SourceReferenceOverride = sref;
|
||||
existing.ConditionFilterOverride = filt;
|
||||
await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
|
||||
_existingNativeOverrides[sourceName] = existing;
|
||||
}
|
||||
|
||||
// Keep the inline editor showing what was just persisted.
|
||||
_nasConnEdit[sourceName] = conn;
|
||||
_nasRefEdit[sourceName] = sref;
|
||||
_nasFilterEdit[sourceName] = filt;
|
||||
}
|
||||
|
||||
private async Task ClearNativeOverrideCore(string sourceName)
|
||||
{
|
||||
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
|
||||
@@ -1431,6 +1497,94 @@
|
||||
|
||||
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
|
||||
|
||||
// ── Native alarm source CSV bulk import ─────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Handles a selected native-alarm-source-override CSV. Reads the file text
|
||||
/// (size-capped the same way as the attribute importer), parses it with the shared
|
||||
/// <see cref="NativeAlarmSourceOverrideCsvParser"/> — the exact parser the CLI
|
||||
/// <c>instance native-alarm-source import --file</c> command uses — validates every
|
||||
/// row via <see cref="BuildNativeAlarmSourceCsvImport"/>, and — all-or-nothing —
|
||||
/// either upserts each row through the SAME repository path the inline Save uses,
|
||||
/// or shows the per-line error list and applies nothing. Merge semantics, matching
|
||||
/// the CLI: sources absent from the file keep their existing override, a blank
|
||||
/// field keeps the inherited value, and a row with all three fields blank clears
|
||||
/// that source's override.
|
||||
/// </summary>
|
||||
private async Task OnNativeAlarmSourceCsvImportSelectedAsync(InputFileChangeEventArgs e)
|
||||
{
|
||||
_saving = true;
|
||||
_nasCsvImportResult = null;
|
||||
_nasCsvImportSucceeded = false;
|
||||
_nasCsvImportErrors = Array.Empty<string>();
|
||||
try
|
||||
{
|
||||
var file = e.File;
|
||||
if (file.Size > MaxCsvImportBytes)
|
||||
{
|
||||
ShowNativeCsvImportFailure(
|
||||
$"File too large ({file.Size:N0} bytes). The maximum is {MaxCsvImportBytes:N0} bytes.",
|
||||
Array.Empty<string>());
|
||||
return;
|
||||
}
|
||||
|
||||
string text;
|
||||
using (var reader = new StreamReader(file.OpenReadStream(MaxCsvImportBytes)))
|
||||
{
|
||||
text = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
var parsed = NativeAlarmSourceOverrideCsvParser.Parse(text);
|
||||
var outcome = BuildNativeAlarmSourceCsvImport(parsed, _nativeSources);
|
||||
|
||||
if (outcome.HasErrors)
|
||||
{
|
||||
ShowNativeCsvImportFailure(
|
||||
$"Import rejected — {outcome.Errors.Count} error(s); no overrides applied.",
|
||||
outcome.Errors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcome.Rows.Count == 0)
|
||||
{
|
||||
ShowNativeCsvImportFailure("No override rows found in the file.", Array.Empty<string>());
|
||||
return;
|
||||
}
|
||||
|
||||
// Every row was validated up front, so the batch applies as a unit through
|
||||
// the same repository upsert/clear the inline editor uses.
|
||||
foreach (var row in outcome.Rows)
|
||||
{
|
||||
if (row.IsClear)
|
||||
await ClearNativeOverrideCore(row.SourceName);
|
||||
else
|
||||
await UpsertNativeOverrideCore(
|
||||
row.SourceName, row.Connection, row.SourceReference, row.Filter);
|
||||
}
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
|
||||
_nasCsvImportSucceeded = true;
|
||||
_nasCsvImportResult = $"Imported {outcome.Rows.Count} native alarm source override(s).";
|
||||
_toast.ShowSuccess(_nasCsvImportResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowNativeCsvImportFailure($"Import failed: {ex.Message}", Array.Empty<string>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowNativeCsvImportFailure(string headline, IReadOnlyList<string> errors)
|
||||
{
|
||||
_nasCsvImportSucceeded = false;
|
||||
_nasCsvImportResult = headline;
|
||||
_nasCsvImportErrors = errors;
|
||||
_toast.ShowError(headline);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum
|
||||
/// to the canonical SCADA type string the AlarmTriggerEditor compares
|
||||
|
||||
+114
@@ -11,6 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
||||
/// file's text, calls <see cref="OverrideCsvParser.Parse"/>, then feeds the result
|
||||
/// here; on success it applies the returned dict through the SAME
|
||||
/// <c>InstanceService.SetAttributeOverrideAsync</c> path the manual editor uses.
|
||||
///
|
||||
/// <para>The native-alarm-source CSV import (<see cref="BuildNativeAlarmSourceCsvImport"/>)
|
||||
/// follows the identical shape: the Razor side parses the upload with the shared
|
||||
/// <see cref="NativeAlarmSourceOverrideCsvParser"/> — the same parser the CLI
|
||||
/// <c>instance native-alarm-source import --file</c> command uses — validates it here,
|
||||
/// then upserts each row through the SAME repository path the inline editor's Save
|
||||
/// uses.</para>
|
||||
/// </summary>
|
||||
public partial class InstanceConfigure
|
||||
{
|
||||
@@ -108,6 +115,113 @@ public partial class InstanceConfigure
|
||||
return new CsvOverrideImportOutcome(overrides, Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One validated native-alarm-source retarget from an imported CSV: the source
|
||||
/// binding's name plus the three optional override fields (null = keep the
|
||||
/// inherited value). A row whose three fields are ALL null means "no override" —
|
||||
/// the caller clears any existing override row for that source, exactly as the
|
||||
/// inline editor's Save does when every field is left blank.
|
||||
/// </summary>
|
||||
internal sealed record NativeAlarmSourceCsvImportRow(
|
||||
string SourceName, string? Connection, string? SourceReference, string? Filter)
|
||||
{
|
||||
/// <summary>True when all three override fields are blank (row means "inherit everything").</summary>
|
||||
public bool IsClear => Connection is null && SourceReference is null && Filter is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of validating a parsed native-alarm-source-override CSV against an
|
||||
/// instance's template source bindings. <see cref="Rows"/> is the ordered list of
|
||||
/// retargets to apply (populated ONLY when there are no errors — all-or-nothing);
|
||||
/// <see cref="Errors"/> carries parser errors plus per-row validation errors, each
|
||||
/// pointing back at the operator's source line.
|
||||
/// </summary>
|
||||
internal sealed record NativeAlarmSourceCsvImportOutcome(
|
||||
IReadOnlyList<NativeAlarmSourceCsvImportRow> Rows,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
/// <summary>True when at least one parser or validation error was collected.</summary>
|
||||
public bool HasErrors => Errors.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a parsed native-alarm-source-override CSV against the template's
|
||||
/// source bindings and, if everything checks out, builds the retarget list to
|
||||
/// apply. Pure and deterministic — no I/O, no page state.
|
||||
///
|
||||
/// <para>Rules — the same batch rules the CLI's
|
||||
/// <c>instance native-alarm-source import --file</c> path enforces server-side in
|
||||
/// <c>ManagementActor.HandleSetInstanceNativeAlarmSourceOverrides</c>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>The source name must resolve for this instance — an unknown name would
|
||||
/// create a dead override the flattener silently drops.</item>
|
||||
/// <item>The source must not be template-locked.</item>
|
||||
/// <item>A source may appear at most once in the batch — a duplicate makes the
|
||||
/// intended override ambiguous and the last-writer-wins upsert would silently
|
||||
/// discard the earlier row.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Semantics are <b>merge, not full replace</b> (matching the CLI): sources
|
||||
/// absent from the CSV keep whatever override they already have, and a blank field
|
||||
/// within a row keeps the inherited value.</para>
|
||||
///
|
||||
/// <para>All-or-nothing: if ANY parser or validation error is present, the returned
|
||||
/// <see cref="NativeAlarmSourceCsvImportOutcome.Rows"/> is empty so the caller
|
||||
/// applies nothing and surfaces the full error list.</para>
|
||||
/// </summary>
|
||||
/// <param name="parsed">The result of <see cref="NativeAlarmSourceOverrideCsvParser.Parse"/>.</param>
|
||||
/// <param name="templateSources">The template's native alarm source bindings (the page's <c>_nativeSources</c>).</param>
|
||||
/// <returns>The validated retarget outcome; <see cref="NativeAlarmSourceCsvImportOutcome.Rows"/>
|
||||
/// is empty and errors are populated when any row fails validation.</returns>
|
||||
internal static NativeAlarmSourceCsvImportOutcome BuildNativeAlarmSourceCsvImport(
|
||||
NativeAlarmSourceOverrideCsvParseResult parsed,
|
||||
IReadOnlyList<TemplateNativeAlarmSource> templateSources)
|
||||
{
|
||||
var errors = new List<string>(parsed.Errors);
|
||||
var byName = templateSources.ToDictionary(s => s.Name, StringComparer.Ordinal);
|
||||
var rows = new List<NativeAlarmSourceCsvImportRow>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var row in parsed.Rows)
|
||||
{
|
||||
if (!byName.TryGetValue(row.SourceName, out var source))
|
||||
{
|
||||
errors.Add(
|
||||
$"Line {row.LineNumber}: native alarm source '{row.SourceName}' does not "
|
||||
+ "resolve for this instance and cannot be overridden.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.IsLocked)
|
||||
{
|
||||
errors.Add(
|
||||
$"Line {row.LineNumber}: native alarm source '{row.SourceName}' is locked at "
|
||||
+ "the template level and cannot be overridden.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(row.SourceName))
|
||||
{
|
||||
errors.Add(
|
||||
$"Line {row.LineNumber}: native alarm source '{row.SourceName}' appears more "
|
||||
+ "than once in the file.");
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new NativeAlarmSourceCsvImportRow(
|
||||
row.SourceName,
|
||||
Blank(row.Connection),
|
||||
Blank(row.SourceReference),
|
||||
Blank(row.Filter)));
|
||||
}
|
||||
|
||||
// All-or-nothing: any error means nothing is applied.
|
||||
if (errors.Count > 0)
|
||||
return new NativeAlarmSourceCsvImportOutcome(Array.Empty<NativeAlarmSourceCsvImportRow>(), errors);
|
||||
|
||||
return new NativeAlarmSourceCsvImportOutcome(rows, Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a single non-null override value against the attribute's declared
|
||||
/// type using <see cref="AttributeValueCodec"/>. Returns a line-qualified error
|
||||
|
||||
@@ -148,9 +148,9 @@
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-success mb-1">
|
||||
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
|
||||
Return value <span class="badge bg-secondary-subtle text-secondary-emphasis ms-1">@_runResult.ReturnTypeName</span>
|
||||
</label>
|
||||
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
<pre class="bg-body-secondary border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@
|
||||
Backup endpoint
|
||||
@if (!_showBackup)
|
||||
{
|
||||
<span class="badge bg-light text-muted border ms-2">Optional</span>
|
||||
<span class="badge bg-body-secondary text-body-secondary border ms-2">Optional</span>
|
||||
}
|
||||
</h6>
|
||||
@if (!_showBackup)
|
||||
|
||||
@@ -175,8 +175,8 @@
|
||||
<p class="small text-muted text-truncate mb-1" title="@es.EndpointUrl">@es.EndpointUrl</p>
|
||||
<div>
|
||||
<span class="badge bg-secondary me-1">@es.AuthType</span>
|
||||
<span class="badge bg-light text-dark me-1">Max @es.MaxRetries retries</span>
|
||||
<span class="badge bg-light text-dark">Delay @es.RetryDelay.TotalSeconds s</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1">Max @es.MaxRetries retries</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis">Delay @es.RetryDelay.TotalSeconds s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,8 +248,8 @@
|
||||
@* Connection strings carry credentials — never rendered here (not even for admins). Shown only on the create/edit form. *@
|
||||
<p class="small text-muted fst-italic mb-1">Connection string hidden — edit to view</p>
|
||||
<div>
|
||||
<span class="badge bg-light text-dark me-1">Max @dc.MaxRetries retries</span>
|
||||
<span class="badge bg-light text-dark">Delay @dc.RetryDelay.TotalSeconds s</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1">Max @dc.MaxRetries retries</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis">Delay @dc.RetryDelay.TotalSeconds s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,7 +325,7 @@
|
||||
<pre class="small text-muted font-monospace mb-2"
|
||||
style="white-space: pre-wrap; word-break: break-all;"
|
||||
title="@m.Script">@preview</pre>
|
||||
<span class="badge bg-light text-dark">Timeout @m.TimeoutSeconds s</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis">Timeout @m.TimeoutSeconds s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,9 +141,9 @@
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-success mb-1">
|
||||
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
|
||||
Return value <span class="badge bg-secondary-subtle text-secondary-emphasis ms-1">@_runResult.ReturnTypeName</span>
|
||||
</label>
|
||||
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
<pre class="bg-body-secondary border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
|
||||
@@ -87,8 +87,8 @@
|
||||
title="@s.Code">@preview</pre>
|
||||
|
||||
<div>
|
||||
<span class="badge bg-light text-dark me-1">@paramCount params</span>
|
||||
<span class="badge bg-light text-dark">
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1">@paramCount params</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis">
|
||||
@(returnLabel == "void" ? "void" : $"returns {returnLabel}")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
|
||||
@*
|
||||
M10 residual: body component for the template "Add / Edit Alarm" dialog hosted
|
||||
by IDialogService.ShowAsync. Renders ONLY the form fields + action buttons
|
||||
inside the host's .modal-body; the host owns the backdrop, header, focus trap,
|
||||
Escape, and focus restoration.
|
||||
|
||||
Same contract as TemplateAttributeDialog: OnSaveAsync returns null on success
|
||||
(dialog closes with true) or the error to render inline (dialog stays open).
|
||||
*@
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="alarm-name">Name</label>
|
||||
<input id="alarm-name" type="text" class="form-control" @bind="_name" readonly="@Editing" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="alarm-trigger-type">Trigger Type</label>
|
||||
<select id="alarm-trigger-type" class="form-select" @bind="_triggerType" disabled="@Editing">
|
||||
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
|
||||
{
|
||||
<option value="@tt">@tt</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="alarm-priority">Priority</label>
|
||||
<input id="alarm-priority" type="number" class="form-control" @bind="_priority" min="0" max="1000" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Trigger Configuration</label>
|
||||
<AlarmTriggerEditor TriggerType="@_triggerType"
|
||||
Value="@_triggerConfig"
|
||||
ValueChanged="@(v => _triggerConfig = v)"
|
||||
AvailableAttributes="@AvailableAttributes"
|
||||
FallbackPriority="@_priority" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="_isLocked" id="alarmLocked" />
|
||||
<label class="form-check-label" for="alarmLocked">Locked</label>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="col-12"><div class="text-danger small" data-test="alarm-form-error">@_error</div></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Host-supplied context: Close(true) on a successful save, Cancel on dismiss.</summary>
|
||||
[Parameter, EditorRequired] public DialogContext<bool> Context { get; set; } = default!;
|
||||
|
||||
/// <summary>True when editing an existing alarm (name + trigger type become read-only).</summary>
|
||||
[Parameter] public bool Editing { get; set; }
|
||||
|
||||
[Parameter] public string InitialName { get; set; } = string.Empty;
|
||||
[Parameter] public int InitialPriority { get; set; }
|
||||
[Parameter] public AlarmTriggerType InitialTriggerType { get; set; }
|
||||
[Parameter] public string? InitialTriggerConfig { get; set; }
|
||||
[Parameter] public bool InitialIsLocked { get; set; }
|
||||
|
||||
/// <summary>Attribute choices offered by the structured trigger editor's picker.</summary>
|
||||
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; }
|
||||
= Array.Empty<AlarmAttributeChoice>();
|
||||
|
||||
/// <summary>
|
||||
/// Persists the draft. Returns <c>null</c> on success (the dialog closes with
|
||||
/// <c>true</c>) or the message to render inline, leaving the dialog open.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public Func<AlarmDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
|
||||
|
||||
private string _name = string.Empty;
|
||||
private int _priority;
|
||||
private AlarmTriggerType _triggerType;
|
||||
private string? _triggerConfig;
|
||||
private bool _isLocked;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_name = InitialName;
|
||||
_priority = InitialPriority;
|
||||
_triggerType = InitialTriggerType;
|
||||
_triggerConfig = InitialTriggerConfig;
|
||||
_isLocked = InitialIsLocked;
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (_busy) return;
|
||||
_error = null;
|
||||
if (string.IsNullOrWhiteSpace(_name)) { _error = "Name is required."; return; }
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var error = await OnSaveAsync(new AlarmDraft(_name, _triggerType, _priority, _triggerConfig, _isLocked));
|
||||
if (error == null)
|
||||
{
|
||||
Context.Close(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = error;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The authored alarm as submitted; the page performs the add/update.</summary>
|
||||
/// <param name="Name">Alarm name (ignored on edit — the stored name is fixed).</param>
|
||||
/// <param name="TriggerType">Selected trigger type (ignored on edit — server-fixed).</param>
|
||||
/// <param name="Priority">Priority level.</param>
|
||||
/// <param name="TriggerConfig">Structured trigger configuration payload.</param>
|
||||
/// <param name="IsLocked">Whether instances may override the alarm.</param>
|
||||
public sealed record AlarmDraft(
|
||||
string Name,
|
||||
AlarmTriggerType TriggerType,
|
||||
int Priority,
|
||||
string? TriggerConfig,
|
||||
bool IsLocked);
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
|
||||
@*
|
||||
M10 residual: body component for the template "Add / Edit Attribute" dialog
|
||||
hosted by IDialogService.ShowAsync. Renders ONLY the form fields + action
|
||||
buttons inside the host's .modal-body; the host owns the backdrop, header,
|
||||
focus trap, Escape, and focus restoration.
|
||||
|
||||
Mirrors MoveDataConnectionDialog: the save round-trip runs from inside the
|
||||
body, a failure is shown INLINE and the dialog STAYS OPEN, and success closes
|
||||
with Close(true) so the page toasts and reloads. The actual persistence stays
|
||||
on the page (TemplateEdit owns TemplateService + the inherited-member rules)
|
||||
and is reached through the OnSaveAsync delegate, which returns null on success
|
||||
or the error message to display.
|
||||
*@
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="attr-name">Name</label>
|
||||
<input id="attr-name" type="text" class="form-control" @bind="_name" readonly="@Editing" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="attr-data-type">Data Type</label>
|
||||
<select id="attr-data-type" class="form-select" value="@_dataType" @onchange="OnDataTypeChanged" disabled="@Editing">
|
||||
@foreach (var dt in Enum.GetValues<DataType>())
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</select>
|
||||
@if (Editing)
|
||||
{
|
||||
<div class="form-text">Data type is fixed once the attribute is created.</div>
|
||||
}
|
||||
</div>
|
||||
@if (_dataType == DataType.List)
|
||||
{
|
||||
<div class="col-12">
|
||||
@* List VALUE stays editable when editing; only DataType/ElementDataType are server-fixed (ShowElementType hides the type select on edit). *@
|
||||
<AttributeListEditor @bind-ElementDataType="_elementDataType"
|
||||
@bind-Rows="_listRows"
|
||||
ShowElementType="@(!Editing)"
|
||||
Disabled="false" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="attr-value">Value</label>
|
||||
<input id="attr-value" type="text" class="form-control" @bind="_value" />
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="attr-data-source-ref">Data Source Ref</label>
|
||||
<input id="attr-data-source-ref" type="text" class="form-control" @bind="_dataSourceRef" placeholder="Tag path" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="_isLocked" id="attrLocked" />
|
||||
<label class="form-check-label" for="attrLocked">Locked</label>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="col-12"><div class="text-danger small" data-test="attr-form-error">@_error</div></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Host-supplied context: Close(true) on a successful save, Cancel on dismiss.</summary>
|
||||
[Parameter, EditorRequired] public DialogContext<bool> Context { get; set; } = default!;
|
||||
|
||||
/// <summary>True when editing an existing attribute (name + data type become read-only).</summary>
|
||||
[Parameter] public bool Editing { get; set; }
|
||||
|
||||
[Parameter] public string InitialName { get; set; } = string.Empty;
|
||||
[Parameter] public string? InitialValue { get; set; }
|
||||
[Parameter] public DataType InitialDataType { get; set; }
|
||||
[Parameter] public DataType InitialElementDataType { get; set; } = DataType.String;
|
||||
[Parameter] public IReadOnlyList<string> InitialListRows { get; set; } = Array.Empty<string>();
|
||||
[Parameter] public bool InitialIsLocked { get; set; }
|
||||
[Parameter] public string? InitialDataSourceRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Persists the draft. Returns <c>null</c> on success (the dialog closes with
|
||||
/// <c>true</c>) or the message to render inline, leaving the dialog open.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public Func<AttributeDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
|
||||
|
||||
private string _name = string.Empty;
|
||||
private string? _value;
|
||||
private DataType _dataType;
|
||||
private DataType _elementDataType = DataType.String;
|
||||
private List<string> _listRows = new();
|
||||
private bool _isLocked;
|
||||
private string? _dataSourceRef;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_name = InitialName;
|
||||
_value = InitialValue;
|
||||
_dataType = InitialDataType;
|
||||
_elementDataType = InitialElementDataType;
|
||||
_listRows = InitialListRows.ToList();
|
||||
_isLocked = InitialIsLocked;
|
||||
_dataSourceRef = InitialDataSourceRef;
|
||||
}
|
||||
|
||||
// Switching the data type clears stale list state so a List ⇄ scalar
|
||||
// toggle never carries the other mode's value into the submit.
|
||||
private void OnDataTypeChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (!Enum.TryParse<DataType>((string?)e.Value, out var dt) || dt == _dataType) return;
|
||||
_dataType = dt;
|
||||
if (dt == DataType.List)
|
||||
{
|
||||
_value = null;
|
||||
_listRows = new();
|
||||
if (!AttributeValueCodec.IsValidElementType(_elementDataType))
|
||||
_elementDataType = DataType.String;
|
||||
}
|
||||
else
|
||||
{
|
||||
_listRows = new();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (_busy) return;
|
||||
_error = null;
|
||||
if (string.IsNullOrWhiteSpace(_name)) { _error = "Name is required."; return; }
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var draft = new AttributeDraft(
|
||||
_name,
|
||||
_dataType,
|
||||
_elementDataType,
|
||||
_listRows.ToList(),
|
||||
_value,
|
||||
_dataSourceRef,
|
||||
_isLocked);
|
||||
|
||||
var error = await OnSaveAsync(draft);
|
||||
if (error == null)
|
||||
{
|
||||
Context.Close(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = error;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authored attribute as submitted. The page resolves the stored value
|
||||
/// (list encoding + validation for <see cref="DataType.List"/>) and performs
|
||||
/// the add/update, so this record carries the raw form state only.
|
||||
/// </summary>
|
||||
/// <param name="Name">Attribute name (ignored on edit — the stored name is fixed).</param>
|
||||
/// <param name="DataType">Selected data type (fixed on edit).</param>
|
||||
/// <param name="ElementDataType">Element scalar type; meaningful only for a List attribute.</param>
|
||||
/// <param name="ListRows">Per-element string rows; meaningful only for a List attribute.</param>
|
||||
/// <param name="Value">Scalar value; meaningful only for a non-List attribute.</param>
|
||||
/// <param name="DataSourceRef">Tag path binding, or null.</param>
|
||||
/// <param name="IsLocked">Whether instances may override the attribute.</param>
|
||||
public sealed record AttributeDraft(
|
||||
string Name,
|
||||
DataType DataType,
|
||||
DataType ElementDataType,
|
||||
List<string> ListRows,
|
||||
string? Value,
|
||||
string? DataSourceRef,
|
||||
bool IsLocked);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+154
@@ -0,0 +1,154 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
||||
|
||||
@*
|
||||
M10 residual: body component for the template "Add / Edit Native Alarm Source"
|
||||
dialog hosted by IDialogService.ShowAsync. Renders ONLY the form fields +
|
||||
action buttons inside the host's .modal-body; the host owns the backdrop,
|
||||
header, focus trap, Escape, and focus restoration.
|
||||
|
||||
Native alarm sources are a READ-ONLY mirror of OPC UA A&C / MxAccess Gateway
|
||||
alarms, so the connection picker is restricted to alarm-capable protocols —
|
||||
the page filters that list via AlarmCapableProtocols.IsAlarmCapable and hands
|
||||
it in. OnSaveAsync returns null on success (dialog closes with true) or the
|
||||
error to render inline (dialog stays open).
|
||||
*@
|
||||
<div class="row g-2">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="nas-name">Name</label>
|
||||
<input id="nas-name" type="text" class="form-control" @bind="_nasName" readonly="@Editing" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="nas-connection">Connection</label>
|
||||
<select id="nas-connection" class="form-select" @bind="_nasConnection">
|
||||
<option value="">— select an alarm-capable connection —</option>
|
||||
@foreach (var c in AlarmCapableConnections)
|
||||
{
|
||||
<option value="@c.Name">@c.Name (@c.Protocol)</option>
|
||||
}
|
||||
</select>
|
||||
@if (AlarmCapableConnections.Count == 0)
|
||||
{
|
||||
<div class="form-text text-warning">No OPC UA or MxGateway connections defined yet.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="nas-source-ref">Source Reference</label>
|
||||
<input id="nas-source-ref" type="text" class="form-control font-monospace" @bind="_nasSourceRef"
|
||||
placeholder="OPC UA SourceNode nodeId, or MxAccess object/area" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="nas-filter">Condition Filter <span class="text-muted">(optional)</span></label>
|
||||
<input id="nas-filter" type="text" class="form-control" @bind="_nasFilter"
|
||||
placeholder="Blank = mirror all conditions under the source" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="nas-description">Description <span class="text-muted">(optional)</span></label>
|
||||
<input id="nas-description" type="text" class="form-control" @bind="_nasDescription" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="_nasIsLocked" id="nasLocked" />
|
||||
<label class="form-check-label" for="nasLocked">Locked</label>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="col-12"><div class="text-danger small" data-test="nas-form-error">@_error</div></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Host-supplied context: Close(true) on a successful save, Cancel on dismiss.</summary>
|
||||
[Parameter, EditorRequired] public DialogContext<bool> Context { get; set; } = default!;
|
||||
|
||||
/// <summary>True when editing an existing source (the name becomes read-only).</summary>
|
||||
[Parameter] public bool Editing { get; set; }
|
||||
|
||||
[Parameter] public string InitialName { get; set; } = string.Empty;
|
||||
[Parameter] public string InitialConnection { get; set; } = string.Empty;
|
||||
[Parameter] public string InitialSourceRef { get; set; } = string.Empty;
|
||||
[Parameter] public string? InitialFilter { get; set; }
|
||||
[Parameter] public string? InitialDescription { get; set; }
|
||||
[Parameter] public bool InitialIsLocked { get; set; }
|
||||
|
||||
/// <summary>Connections whose protocol can surface native alarms (OPC UA / MxGateway).</summary>
|
||||
[Parameter] public IReadOnlyList<DataConnection> AlarmCapableConnections { get; set; }
|
||||
= Array.Empty<DataConnection>();
|
||||
|
||||
/// <summary>
|
||||
/// Persists the draft. Returns <c>null</c> on success (the dialog closes with
|
||||
/// <c>true</c>) or the message to render inline, leaving the dialog open.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public Func<NativeSourceDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
|
||||
|
||||
private string _nasName = string.Empty;
|
||||
private string _nasConnection = string.Empty;
|
||||
private string _nasSourceRef = string.Empty;
|
||||
private string? _nasFilter;
|
||||
private string? _nasDescription;
|
||||
private bool _nasIsLocked;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_nasName = InitialName;
|
||||
_nasConnection = InitialConnection;
|
||||
_nasSourceRef = InitialSourceRef;
|
||||
_nasFilter = InitialFilter;
|
||||
_nasDescription = InitialDescription;
|
||||
_nasIsLocked = InitialIsLocked;
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (_busy) return;
|
||||
_error = null;
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var draft = new NativeSourceDraft(
|
||||
_nasName,
|
||||
_nasConnection,
|
||||
_nasSourceRef,
|
||||
_nasFilter,
|
||||
_nasDescription,
|
||||
_nasIsLocked);
|
||||
|
||||
var error = await OnSaveAsync(draft);
|
||||
if (error == null)
|
||||
{
|
||||
Context.Close(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = error;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The authored native alarm source as submitted; the page performs the add/update.</summary>
|
||||
/// <param name="Name">Source name (ignored on edit — the stored name is fixed).</param>
|
||||
/// <param name="ConnectionName">Alarm-capable data connection to mirror from.</param>
|
||||
/// <param name="SourceReference">OPC UA SourceNode nodeId, or MxAccess object/area.</param>
|
||||
/// <param name="ConditionFilter">Optional condition filter; blank mirrors every condition.</param>
|
||||
/// <param name="Description">Optional free-text description.</param>
|
||||
/// <param name="IsLocked">Whether instances may override the binding.</param>
|
||||
public sealed record NativeSourceDraft(
|
||||
string Name,
|
||||
string ConnectionName,
|
||||
string SourceReference,
|
||||
string? ConditionFilter,
|
||||
string? Description,
|
||||
bool IsLocked);
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
@inject ScriptAnalysisService AnalysisService
|
||||
|
||||
@*
|
||||
M10 residual: body component for the template "Add / Edit Script" dialog hosted
|
||||
by IDialogService.ShowAsync. Renders ONLY the tabbed authoring surface, the
|
||||
Test Run panel, and the action buttons inside the host's .modal-body; the host
|
||||
owns the backdrop, header, focus trap, Escape, and focus restoration.
|
||||
|
||||
All four tab panels stay mounted (toggled via display:none) so the Monaco
|
||||
editors and the JSONJoy React island don't tear down on tab switch — that is
|
||||
why the whole form lives in ONE component rather than per-tab bodies.
|
||||
|
||||
The Test Run panel runs here (it needs the live, unsaved editor buffer), so
|
||||
this component injects ScriptAnalysisService directly. Persistence stays on the
|
||||
page and is reached through OnSaveAsync, which returns null on success (the
|
||||
dialog closes with true) or the error to render inline (dialog stays open).
|
||||
*@
|
||||
@implements IDisposable
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="script-name">Name</label>
|
||||
<input id="script-name" type="text" class="form-control" @bind="_name" readonly="@Editing" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="_isLocked" id="scriptLocked" />
|
||||
<label class="form-check-label" for="scriptLocked">Locked</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Tabs: Trigger, Code, Parameters, Return. All panels stay
|
||||
mounted (toggled via display:none) so Monaco editors and the
|
||||
JSONJoy React island don't tear down on tab switch. *@
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button type="button"
|
||||
class="nav-link @(_tab == "trigger" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_tab == "trigger" ? "true" : "false")"
|
||||
@onclick='() => _tab = "trigger"'>Trigger</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button type="button"
|
||||
class="nav-link @(_tab == "code" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_tab == "code" ? "true" : "false")"
|
||||
@onclick='() => _tab = "code"'>Code</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button type="button"
|
||||
class="nav-link @(_tab == "parameters" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_tab == "parameters" ? "true" : "false")"
|
||||
@onclick='() => _tab = "parameters"'>Parameters</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button type="button"
|
||||
class="nav-link @(_tab == "return" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_tab == "return" ? "true" : "false")"
|
||||
@onclick='() => _tab = "return"'>Return type</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="border border-top-0 rounded-bottom p-3">
|
||||
<div style="display: @(_tab == "trigger" ? "block" : "none")">
|
||||
<ScriptTriggerEditor TriggerType="@_triggerType"
|
||||
TriggerConfig="@_triggerConfig"
|
||||
Changed="@OnTriggerChanged"
|
||||
AvailableAttributes="@AvailableAttributes" />
|
||||
@if (ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(_triggerType))
|
||||
{
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Min time between runs</label>
|
||||
<div class="row g-2" style="max-width: 420px;">
|
||||
<div class="col-7">
|
||||
<input type="number" min="1" step="1" class="form-control"
|
||||
placeholder="(optional)"
|
||||
@bind="_minTimeValue" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<select class="form-select" @bind="_minTimeUnit">
|
||||
<option value="ms">milliseconds</option>
|
||||
<option value="sec">seconds</option>
|
||||
<option value="min">minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@if (TriggerIsWhileTrue())
|
||||
{
|
||||
<div class="form-text">
|
||||
This is the re-fire interval for the
|
||||
<strong>WhileTrue</strong> trigger above.
|
||||
</div>
|
||||
@if (DurationInput.Compose(_minTimeValue, _minTimeUnit) is null)
|
||||
{
|
||||
<div class="alert alert-warning py-1 px-2 small mt-1 mb-0">
|
||||
The WhileTrue trigger has no interval set — the script
|
||||
will fire only once. Set a value here to make it re-fire.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="form-text">
|
||||
Optional throttle — skips trigger invocations that fire
|
||||
sooner than this.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Execution timeout</label>
|
||||
<div class="input-group" style="max-width: 280px;">
|
||||
<input type="number" min="1" step="1" class="form-control"
|
||||
placeholder="(site default)"
|
||||
@bind="_executionTimeoutSeconds" @bind:event="oninput" />
|
||||
<span class="input-group-text">seconds</span>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Per-script execution timeout. Leave blank (or 0) to use the
|
||||
site's global default.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: @(_tab == "code" ? "block" : "none")">
|
||||
<MonacoEditor @ref="_editor" Value="@_code" ValueChanged="@(v => _code = v)"
|
||||
Language="csharp" Height="360px"
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_parameters)"
|
||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_parameters)"
|
||||
SiblingScripts="@SiblingScripts"
|
||||
SelfAttributes="@SelfAttributes"
|
||||
Children="@EditorChildren"
|
||||
Parent="@EditorParent"
|
||||
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||
</div>
|
||||
<div style="display: @(_tab == "parameters" ? "block" : "none")">
|
||||
<SchemaBuilder Mode="object"
|
||||
Value="@_parameters"
|
||||
ValueChanged="@(v => _parameters = v)" />
|
||||
</div>
|
||||
<div style="display: @(_tab == "return" ? "block" : "none")">
|
||||
<SchemaBuilder Mode="value"
|
||||
Value="@_return"
|
||||
ValueChanged="@(v => _return = v)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="text-danger small mt-2" data-test="script-form-error">@_error</div>
|
||||
}
|
||||
|
||||
@if (_showTestRun)
|
||||
{
|
||||
<div class="card mt-3" id="script-test-run-panel">
|
||||
<div class="card-header py-2">
|
||||
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||
<strong>Heads up:</strong>
|
||||
runs the script as typed (unsaved edits included) against the supplied
|
||||
<code>Parameters</code>.
|
||||
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
|
||||
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
|
||||
<code>Instance</code>, <code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and <code>CallScript</code> throw unless a bound instance is selected below — then they route to that live instance (attribute writes are permanent too).
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Bind to instance <span class="text-muted">(optional)</span></label>
|
||||
@if (DeployedInstances.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No running instances of this template.
|
||||
<code>Instance</code>/<code>Attributes</code>/<code>CallScript</code> will throw.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<select class="form-select form-select-sm" @bind="_bindInstance">
|
||||
<option value="">— None (Instance/Attributes throw) —</option>
|
||||
@foreach (var inst in DeployedInstances)
|
||||
{
|
||||
<option value="@inst.UniqueName">@inst.UniqueName</option>
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Routes <code>Instance.GetAttribute/SetAttribute</code>,
|
||||
<code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and
|
||||
<code>CallScript</code> to the selected live instance.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameter values</label>
|
||||
<ParameterValueForm ParameterDefinitions="@_parameters"
|
||||
Values="_paramValues"
|
||||
ValuesChanged="@(v => _paramValues = v)" />
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
|
||||
@if (_running)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span>Running…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Run</span>
|
||||
}
|
||||
</button>
|
||||
@if (_runResult != null)
|
||||
{
|
||||
<span class="text-muted small">@_runResult.DurationMs ms</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_runResult != null)
|
||||
{
|
||||
@if (_runResult.Success)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-success mb-1">
|
||||
Return value <span class="badge bg-secondary-subtle text-secondary-emphasis ms-1">@_runResult.ReturnTypeName</span>
|
||||
</label>
|
||||
<pre class="bg-body-secondary border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-danger mb-1">
|
||||
<span class="badge bg-danger me-1">@ScriptErrorKindLabel(_runResult.ErrorKind)</span>
|
||||
</label>
|
||||
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
|
||||
@if (_runResult.Markers is { Count: > 0 })
|
||||
{
|
||||
<ul class="small text-danger mt-2 mb-0">
|
||||
@foreach (var m in _runResult.Markers)
|
||||
{
|
||||
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
|
||||
{
|
||||
<div class="mb-0">
|
||||
<label class="form-label small mb-1">Console output</label>
|
||||
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-primary btn-sm me-auto" @onclick="ToggleTestRunPanel">
|
||||
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Cancel" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Host-supplied context: Close(true) on a successful save, Cancel on dismiss.</summary>
|
||||
[Parameter, EditorRequired] public DialogContext<bool> Context { get; set; } = default!;
|
||||
|
||||
/// <summary>True when editing an existing script (the name becomes read-only).</summary>
|
||||
[Parameter] public bool Editing { get; set; }
|
||||
|
||||
[Parameter] public string InitialName { get; set; } = string.Empty;
|
||||
[Parameter] public string InitialCode { get; set; } = string.Empty;
|
||||
[Parameter] public string? InitialTriggerType { get; set; }
|
||||
[Parameter] public string? InitialTriggerConfig { get; set; }
|
||||
[Parameter] public TimeSpan? InitialMinTimeBetweenRuns { get; set; }
|
||||
[Parameter] public string? InitialParameters { get; set; }
|
||||
[Parameter] public string? InitialReturn { get; set; }
|
||||
[Parameter] public bool InitialIsLocked { get; set; }
|
||||
[Parameter] public int? InitialExecutionTimeoutSeconds { get; set; }
|
||||
|
||||
/// <summary>Attribute choices offered by the structured trigger editor's picker.</summary>
|
||||
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; }
|
||||
= Array.Empty<AlarmAttributeChoice>();
|
||||
|
||||
/// <summary>Sibling scripts on the same template, for <c>CallScript</c> completion.</summary>
|
||||
[Parameter] public IReadOnlyList<ScriptShape>? SiblingScripts { get; set; }
|
||||
|
||||
/// <summary>This template's own attributes, for <c>Attributes.*</c> completion.</summary>
|
||||
[Parameter] public IReadOnlyList<AttributeShape>? SelfAttributes { get; set; }
|
||||
|
||||
/// <summary>Composed children's contexts, for <c>Children.*</c> completion.</summary>
|
||||
[Parameter] public IReadOnlyList<CompositionContext>? EditorChildren { get; set; }
|
||||
|
||||
/// <summary>The slot-owner context, for <c>Parent.*</c> completion; null for a base template.</summary>
|
||||
[Parameter] public CompositionContext? EditorParent { get; set; }
|
||||
|
||||
/// <summary>Running instances of this template, offered as Test Run bind targets.</summary>
|
||||
[Parameter] public IReadOnlyList<ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance> DeployedInstances { get; set; }
|
||||
= Array.Empty<ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance>();
|
||||
|
||||
/// <summary>
|
||||
/// Persists the draft. Returns <c>null</c> on success (the dialog closes with
|
||||
/// <c>true</c>) or the message to render inline, leaving the dialog open.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public Func<ScriptDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
|
||||
|
||||
private string _name = string.Empty;
|
||||
private string _code = string.Empty;
|
||||
private string? _triggerType;
|
||||
private string? _triggerConfig;
|
||||
private string? _minTimeValue;
|
||||
private string _minTimeUnit = "sec";
|
||||
private string? _parameters;
|
||||
private string? _return;
|
||||
private bool _isLocked;
|
||||
private int? _executionTimeoutSeconds;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
private string _tab = "trigger"; // "trigger" | "code" | "parameters" | "return"
|
||||
private MonacoEditor? _editor;
|
||||
private IReadOnlyList<DiagnosticMarker> _markers = Array.Empty<DiagnosticMarker>();
|
||||
|
||||
// Test Run state.
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
private Dictionary<string, object?> _paramValues = new();
|
||||
private SandboxRunResult? _runResult;
|
||||
private CancellationTokenSource? _runCts;
|
||||
private string _bindInstance = string.Empty;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_name = InitialName;
|
||||
_code = InitialCode;
|
||||
_triggerType = InitialTriggerType;
|
||||
_triggerConfig = InitialTriggerConfig;
|
||||
(_minTimeValue, _minTimeUnit) = DurationInput.Split(InitialMinTimeBetweenRuns);
|
||||
_parameters = InitialParameters;
|
||||
_return = InitialReturn;
|
||||
_isLocked = InitialIsLocked;
|
||||
_executionTimeoutSeconds = InitialExecutionTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>Cancels an in-flight Test Run when the dialog is torn down.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
_runCts?.Dispose();
|
||||
_runCts = null;
|
||||
}
|
||||
|
||||
/// <summary>Applies the structured trigger editor's type + config atomically.</summary>
|
||||
private void OnTriggerChanged(ScriptTriggerValue v)
|
||||
{
|
||||
_triggerType = v.TriggerType;
|
||||
_triggerConfig = v.Config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the current script trigger is a WhileTrue Conditional/Expression
|
||||
/// trigger — the case where the "Min time between runs" interval is required
|
||||
/// (it is the re-fire cadence).
|
||||
/// </summary>
|
||||
private bool TriggerIsWhileTrue()
|
||||
{
|
||||
var kind = ScriptTriggerConfigCodec.ParseKind(_triggerType);
|
||||
return kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression
|
||||
&& ScriptTriggerConfigCodec.Parse(_triggerConfig, kind).Mode
|
||||
== ScriptTriggerMode.WhileTrue;
|
||||
}
|
||||
|
||||
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
Dispose();
|
||||
Context.Cancel();
|
||||
}
|
||||
|
||||
private async Task RunInSandboxAsync()
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
_runCts = new CancellationTokenSource();
|
||||
_running = true;
|
||||
_runResult = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var jsonParams = _paramValues.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
|
||||
var request = new SandboxRunRequest(
|
||||
_code, jsonParams, TimeoutSeconds: null,
|
||||
BindInstanceUniqueName: string.IsNullOrEmpty(_bindInstance) ? null : _bindInstance);
|
||||
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded by next Run click */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_runResult = new SandboxRunResult(
|
||||
Success: false,
|
||||
ReturnValueJson: null,
|
||||
ReturnTypeName: null,
|
||||
ConsoleOutput: "",
|
||||
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
|
||||
ErrorKind: SandboxErrorKind.RuntimeError,
|
||||
DurationMs: 0,
|
||||
Markers: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ScriptErrorKindLabel(SandboxErrorKind kind) => kind switch
|
||||
{
|
||||
SandboxErrorKind.CompileError => "Compile error",
|
||||
SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
|
||||
SandboxErrorKind.RuntimeError => "Runtime error",
|
||||
SandboxErrorKind.Timeout => "Timeout",
|
||||
_ => "Error"
|
||||
};
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (_busy) return;
|
||||
_error = null;
|
||||
if (string.IsNullOrWhiteSpace(_name)) { _error = "Name is required."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_code)) { _error = "Code is required."; return; }
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var draft = new ScriptDraft(
|
||||
_name,
|
||||
_code,
|
||||
_triggerType,
|
||||
_triggerConfig,
|
||||
DurationInput.Compose(_minTimeValue, _minTimeUnit),
|
||||
_parameters,
|
||||
_return,
|
||||
_isLocked,
|
||||
_executionTimeoutSeconds);
|
||||
|
||||
var error = await OnSaveAsync(draft);
|
||||
if (error == null)
|
||||
{
|
||||
Dispose();
|
||||
Context.Close(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = error;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The authored script as submitted; the page performs the add/update.</summary>
|
||||
/// <param name="Name">Script name (ignored on edit — the stored name is fixed).</param>
|
||||
/// <param name="Code">Script body as typed.</param>
|
||||
/// <param name="TriggerType">Structured trigger type token, or null.</param>
|
||||
/// <param name="TriggerConfig">Structured trigger configuration payload, or null.</param>
|
||||
/// <param name="MinTimeBetweenRuns">Composed throttle / re-fire interval, or null.</param>
|
||||
/// <param name="ParameterDefinitions">Parameter schema JSON, or null.</param>
|
||||
/// <param name="ReturnDefinition">Return-type schema JSON, or null.</param>
|
||||
/// <param name="IsLocked">Whether instances may override the script.</param>
|
||||
/// <param name="ExecutionTimeoutSeconds">Raw timeout input; the page normalizes ≤0 to null.</param>
|
||||
public sealed record ScriptDraft(
|
||||
string Name,
|
||||
string Code,
|
||||
string? TriggerType,
|
||||
string? TriggerConfig,
|
||||
TimeSpan? MinTimeBetweenRuns,
|
||||
string? ParameterDefinitions,
|
||||
string? ReturnDefinition,
|
||||
bool IsLocked,
|
||||
int? ExecutionTimeoutSeconds);
|
||||
}
|
||||
+1
-1
@@ -295,7 +295,7 @@
|
||||
</dl>
|
||||
|
||||
<div class="text-muted text-uppercase small fw-semibold mb-1">Error</div>
|
||||
<pre class="bg-light border rounded p-2 small mb-0 parked-error-pre">@_drawerMessage.ErrorMessage</pre>
|
||||
<pre class="bg-body-secondary border rounded p-2 small mb-0 parked-error-pre">@_drawerMessage.ErrorMessage</pre>
|
||||
</div>
|
||||
<div class="border-top p-3 d-flex gap-2">
|
||||
<button class="btn btn-outline-success btn-sm flex-grow-1"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user