Compare commits

...

23 Commits

Author SHA1 Message Date
Joseph Doherty 3c9b101dfc docs(alarms): document the Alarms accessor + AckTime, tick MES plan Phase 1
Design doc and code travel together (CLAUDE.md editing rules), so this records
what the two preceding commits shipped and, more usefully, WHY the non-obvious
choices were made -- the parts a future reader would otherwise re-litigate:

  - Component-SiteRuntime.md: the Alarms.CurrentAsync() runtime API entry (why
    it is not scope-prefixed, why it is read-only, why placeholder rows are
    included), the full ScriptAlarm shape, AckTime on the enriched
    AlarmStateChanged, proto field 24, and the metadata_json-vs-new-column
    persistence rationale (native_alarm_state is RegisterReplicated; LocalDb
    builds its CDC triggers from the column list at registration time).
  - Component-DataConnectionLayer.md already carried the AckTime section in the
    first commit; this adds the SiteRuntime/ScriptAnalysis/InboundAPI halves.
  - Component-ScriptAnalysis.md: accessors returning domain types return the
    SAME type on both surfaces, and the trust-model note that a deny-list needs
    no entry for a new globals member -- only that its return type resolves in
    a permitted namespace.
  - Component-InboundAPI.md records the NEGATIVE decision: there is
    deliberately no Route.To(...).GetAlarms(...) verb. Alarm state is
    per-instance and lives on the site's Instance Actor, so the read goes
    through a routed site script and the filtering happens where the data is;
    central stays a thin router.
  - CLAUDE.md native-alarm bullet gains the enrichment + accessor summary.
  - The plan's §7 Phase 1 rows are ticked with 2026-08-01 and annotated with
    what was actually built (incl. the two choices that differ from the plan's
    "or" options: a dedicated snapshot message rather than DebugSnapshotRequest,
    and the extra SandboxScriptHost mirror the plan did not list). Phases 2-4
    stay open -- they are deployed config and need a live rig.
2026-08-01 13:12:46 -04:00
Joseph Doherty d0af884760 feat(scripts): add the Alarms.CurrentAsync() read accessor for site scripts
MES alarm-status API §5.2 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 tasks 2-4). Site `Call` scripts had NO way to read alarm condition
state: the `Alarm` global exists only inside an on-trigger handler and
describes the one alarm that fired, and native mirrored conditions were
reachable only from the Debug View. That gap blocked the CvdReactor
SimpleAlarmStatus/AlarmStatus scripts entirely -- they cannot be written
without it. `Alarms.CurrentAsync()` closes it.

The data was already local: the script runs inside its own Instance Actor's
context, so this is a LOCAL Ask -- the same mechanism attribute reads use, no
cross-cluster hop. A dedicated GetAlarmSnapshotRequest/Response is used rather
than reusing DebugSnapshotRequest, which would materialise every attribute
value on every alarm poll; both are served from the same
BuildAlarmStatesSnapshot(), so the script view and the operator's Debug View
can never disagree.

Deliberate shape decisions:
  - 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 prefixing
    would hand a composed script a silently truncated list.
  - Read-only. Native alarms are a read-only mirror of the source (no
    ack-back), so no acknowledge/shelve operation is exposed.
  - Placeholder rows are NOT pre-filtered: a caller must be able to tell
    "binding configured and quiet" from "binding unknown". The documented
    filter is `Active && !IsConfiguredPlaceholder`.
  - ScriptAlarm lives in Commons so the runtime accessor and the compile-only
    surface project to the SAME type -- a script binding at the design-time
    gate binds identically at the site. Condition is the authority for
    active/acked/severity, so one filter expression works across computed and
    native alarms.

Mirrored on BOTH design-time surfaces. ScriptCompileSurface is covered by the
reflection parity guard (AlarmsAccessor added to its mirror pairs). The Central
UI Test-Run SandboxScriptHost is the third, hand-maintained mirror that the
parity test cannot reach (Central UI does not reference Site Runtime); without
it the design page would false-flag CS1061 on scripts the deploy gate accepts.
It throws a labelled ScriptSandboxException at run time -- there is no central
route to per-instance alarm state, and returning an empty list would read as
"nothing is in alarm", which is worse than an error.

ScriptTrustPolicy needs NO change, and the reason is structural rather than
incidental: the trust boundary is a deny-list over API roots, not an allow-list
of context members. A test pins that no ForbiddenScopes entry prefixes the
Commons script-surface namespace, so a future deny-list entry cannot silently
make ScriptAlarm untouchable.

Tests: 6 accessor cases (Ask contract, full native projection incl. AckTime,
unacked, computed-alarm derivation, placeholder visibility, scope-independence),
2 InstanceActor snapshot cases incl. equality with the Debug View row set, the
full MES script shape compiling against ScriptCompileSurface, 2 trust cases,
and a sandbox diagnose-clean case reading every projected ScriptAlarm field.
2026-08-01 13:12:30 -04:00
Joseph Doherty 01bcca992c feat(alarms): thread an additive AckTime through the native-alarm mirror
MES alarm-status API §6.4 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 task 1). MES needs a real AckDT for a triggered alarm, and the mirror
carried acked-vs-unacked but never WHEN. AckTime now rides the whole path:
DCL transition -> AlarmStateChanged -> gRPC AlarmStateUpdate -> site SQLite.

Stamping rule, identical on both protocols: non-null ONLY while the condition
is active AND acknowledged. That single predicate yields all three required
behaviours -- null while unacked, cleared on re-raise (a re-raise arrives
unacknowledged), and no phantom ack on a return-to-normal. The last one is
load-bearing for MxGateway, which maps INACTIVE to Acknowledged = true; without
the active check every clear would claim an ack the system never observed.

Provenance is honest, never fabricated:
  - OPC UA A&C supplies a TRUE ack instant, so we now select it:
    AcknowledgeableConditionType/AckedState/TransitionTime at SelectClause
    index 18, APPENDED so the positional reads at 0-17 keep their meaning.
    Servers that omit the field fall back to the event's own Time.
  - MxAccess Gateway supplies none, so the ack transition's own timestamp is
    used -- accurate to when the system SAW the ack. An ACTIVE_ACKED
    re-subscribe snapshot restores one from LastTransitionTimestamp rather
    than dropping it.
The decision lives in pure mappers (Opc/Mx AlarmMapper.DeriveAckTime), so it is
unit-tested with no live server or gateway.

Additive-only throughout: init-only property on AlarmStateChanged, trailing
optional positional on NativeAlarmTransition (all 14-arg call sites untouched),
proto field 24 (never reusing a number) regenerated via docker/regen-proto.sh
sitestream with the csproj diff verified empty.

Persistence rides native_alarm_state's existing metadata_json blob rather than
a new column -- deliberately. 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
introduced for this. Rows written before the field deserialize it as null.

Tests: 4 OPC UA + 6 MxGateway mapper cases, 3 NativeAlarmActor (emit,
failover rehydrate, pre-AckTime row), 1 proto round-trip incl. the null case,
4 Commons additive/back-compat. The OPC UA SelectClause count lock-in moves
18 -> 19 with an index-18 assertion -- intended, the clause is appended, which
is precisely what that guard exists to make visible.
2026-08-01 13:12:04 -04:00
Joseph Doherty 6dc5d94cb9 docs(plans): tag-browser Task 19 manual smoke PASS 2026-08-01 — online + offline paths verified live 2026-08-01 12:41:24 -04:00
Joseph Doherty 819ca4d7ce docs(plans): retire the folder-hierarchy manual smoke — drag-drop steps obsolete under the [PERM] decision, rest covered by bUnit suites + CLI parity 2026-08-01 12:37:48 -04:00
Joseph Doherty a949fe6f57 docs(plans): retire the env2 + transport manual checklists — core scenario run live 2026-08-01 (caught + fixed the create-missing FK bug), rest covered by automated suites + #31 2026-08-01 12:34:34 -04:00
Joseph Doherty 0c9dffed78 fix(transport): flush created-site ids before the connection pass — create-missing import failed FK 547 on real SQL Server
Caught live 2026-08-01 importing a bundle into the empty env2 cluster: a
create-missing Site has Id == 0 until SaveChanges on a relational provider,
and ApplyDataConnectionsAsync stamps DataConnection.SiteId as a raw scalar
(no navigation, no EF fix-up), so the insert violated
FK_DataConnections_Sites_SiteId. Every importer integration test runs on
the EF in-memory provider, which assigns ids eagerly on AddAsync — the
exact masking the in-code comment predicted. Fix: one SaveChangesAsync
between the site pass and the connection pass, riding the same outer
transaction (all-or-nothing preserved; the failed live import rolled back
cleanly). Regression test runs the create-missing path on SQLite
(CreateMissingSiteRelationalTests) — red without the fix, green with it.
2026-08-01 12:32:45 -04:00
Joseph Doherty 1c99d6fa8d docs(grpc-plan): live gate fully closed — ExecuteOpcUa/ExecuteRoute/parked-retry/TriggerSiteFailover all live-proven 2026-08-01 2026-08-01 12:25:27 -04:00
Joseph Doherty 9ab27d5d61 docs(register): rows 27-28 — ES retry config never reaches sites (found live), failover dialog mislabeled Delete 2026-08-01 12:02:23 -04:00
Joseph Doherty f6822f8f45 docs(m10): close follow-up #163 — InstanceConfigureListOverrideTests verified green (#207 re-verified too) 2026-08-01 11:28:46 -04:00
Joseph Doherty 410349767d style(centralui): full-app bg-light/bg-white -> theme-aware utility sweep (M10 residual)
T34c only fixed the bounded modal-surface offenders. Bootstrap 5.3's bg-light
and bg-white are fixed light values that do NOT flip under [data-bs-theme=dark],
so every remaining use was a dark-mode contrast break. 35 swaps across 19 files:

  surface / <pre> / <code>  bg-light                -> bg-body-secondary
  panel                     bg-white                -> bg-body
  neutral badge             bg-light text-dark      -> bg-secondary-subtle text-secondary-emphasis
  muted badge / input group bg-light text-muted     -> bg-body-secondary text-body-secondary

DELIBERATELY LEFT (7 sites): the neutral member of a status-badge switch or
ternary whose siblings are all solid, non-theme-aware colours (bg-success,
bg-danger, bg-warning) — swapping only the neutral one to a subtle token breaks
the visual weight of the set, so these stay until the whole family is retoned:
  Topology.razor:513 (Current badge) and :588 (InstanceState.NotDeployed)
  InstanceConfigure.razor:1520 (same InstanceState switch)
  NotificationReport.razor StatusBadgeClass fallback
  TransportImport.razor ConflictKind badge fallback
  SecuredWrites.razor text-bg-light fallback (a text-bg-* family with no
    theme-aware member at all)
  Health.razor:377 depth ternary
Also untouched by design: SchemaBuilder.razor:88 (already bg-light-subtle,
theme-aware), bg-dark text-light console panels, and site.css / #reconnect-modal.

InstanceConfigure.razor is edited concurrently elsewhere; its change here is 5
pure class-string swaps on existing lines, no reflow. No test asserted any of
these classes. Also marks all four M10 residuals done in the plan doc.
2026-08-01 11:28:20 -04:00
Joseph Doherty 316153dc98 feat(centralui): hide OffsetPager on a single page, opt-in — NotificationReport enabled (M10 residual)
DECISION: a pager whose only two controls are permanently disabled is noise, so
the conventional behaviour ships — the bar is hidden when the result set is a
single page. NotificationReport, which raised the finding, opts in.

RATIONALE for opt-in rather than default-on. OffsetPager is shared, and its
summary span ("Page N of Y · T total") doubles as the ONLY total-count readout
on ConfigurationAuditLog — auto-hiding there would silently delete the result
count for every query returning under a page, and the existing Playwright
fixture asserts "3 total" on exactly such a query. So the behaviour lives in the
shared component (reusable for the next consumer) behind HideWhenSinglePage,
default false, which keeps every current consumer byte-identical.

The guard fires only when single-page-ness is POSITIVELY established:
PageCount <= 1 AND Page <= 1 AND !HasNextPage. A null TotalCount means the host
cannot count, so the bar renders — the controls never vanish while a further
page still exists.

NotificationReport's own Playwright pagination test seeds 51 rows (2 pages) and
is unaffected. 5 new bUnit tests cover hide-on-single-page, hide-on-empty,
show-on-multi-page, show-when-TotalCount-unknown, and the default-off path.
2026-08-01 11:28:20 -04:00
Joseph Doherty a506b19d17 refactor(centralui): migrate TemplateEdit's four page-embedded modals to the DialogService host (M10 residual)
TemplateEdit was the last page still hand-rolling its own modal chrome after
M10 — T34c only tokenized its backdrops. All four member-authoring forms
(Attribute, Alarm, Native Alarm Source, Script) now open through
IDialogService.ShowAsync, so the single DialogHost in MainLayout owns the
backdrop, focus trap, Escape and focus restoration.

Pattern copied from the already-migrated pages (MoveDataConnectionDialog +
Templates' Move/Rename dialogs): each body is its own component beside the page
taking a DialogContext<bool>, owning its form state, rendering validation and
server errors INLINE while staying open, and closing with Close(true) only once
the save succeeded — at which point the page reloads. An inline RenderFragment
would NOT have worked: DialogHost renders the captured fragment in its own tree,
so the page's StateHasChanged could never refresh it.

Persistence deliberately stayed on the page (it owns TemplateService, the
inherited-member rules, and the repository-direct native-source path) and is
reached through an OnSaveAsync delegate returning null on success or the message
to display. Behaviour preserved verbatim, including the List-attribute encode +
Decode round-trip check, the name/trigger-type read-only-on-edit rules, the
duplicate-native-source-name guard, and NormalizeExecutionTimeout.

TemplateScriptDialog keeps all four tab panels mounted (Monaco and the JSONJoy
island must not tear down on tab switch) and hosts the Test Run panel, which
needs the live unsaved editor buffer, so it injects ScriptAnalysisService
directly and cancels an in-flight run on dispose.

Extraction moved markup that three structural source-scanning tests pinned;
all three were repointed at the new files rather than weakened:
  - TemplateNativeAlarmSourceEditorTests (+ a new test asserting the form is
    host-mounted and the body renders no chrome of its own)
  - AttributeListEditorTests (list-editor reveal now in the dialog body)
  - TestRunWarningTests (Real I/O warning travelled with the script panel)

Build 0/0; CentralUI.Tests 973/973 green.
2026-08-01 11:28:20 -04:00
Joseph Doherty fdfd5e1b27 feat(centralui): TreeView full WAI-ARIA keyboard navigation (M10 residual R7)
TreeView<TItem> handled only Enter/Space on the chevron; the tree itself was
unreachable by keyboard. Implements the WAI-ARIA tree pattern:

- Roving tabindex on li[role=treeitem] — exactly one node is tabbable, so the
  whole tree is a single Tab stop. Target resolved per render: last-focused node
  while still visible -> SelectedKey -> first visible node. Browser focus follows
  via a per-node ElementReference + FocusAsync in OnAfterRenderAsync, guarded by
  the same JSException/JSDisconnectedException/InvalidOperationException triple
  the context-menu focus already used (a no-op under bUnit).
- ArrowDown/ArrowUp move between VISIBLE nodes; ArrowRight expands a collapsed
  branch else moves to first child; ArrowLeft collapses an expanded branch else
  moves to parent; Home/End jump to first/last; Enter/Space activate through the
  SAME path a click takes (OnContentClick / OnCheckboxToggle) so selection
  semantics never diverge between input modes.
- ARIA: aria-level, aria-posinset, aria-setsize added; aria-selected now renders
  true/false on selectable+checkbox trees and stays absent on non-selectable ones.
- Event scoping: @onkeydown:stopPropagation on the li, chevron, checkbox and
  content slot, so nested nodes do not double-handle and consumer controls inside
  NodeContent keep their own key handling. Browser scroll-on-Space/Arrow is
  suppressed by a minimal NATIVE inline onkeydown on the root ul, targeted at
  treeitems only — Blazor's preventDefault directive is all-or-nothing per
  element, so on the li it would trap Tab and cancel Enter/Space on consumer
  buttons. No CSP is configured, so the inline handler runs.

BuildVisibleNodes() mirrors RenderNode's visibility rules and must stay in step.

Tests: 31 new bUnit tests in TreeViewKeyboardNavigationTests; the 47 existing
TreeView tests are unchanged and still green. Docs: new keyboard/a11y section in
docs/components/TreeView.md.
2026-08-01 11:28:20 -04:00
Joseph Doherty 8aa6bf2270 fix(audit): populate ParentExecutionId on alarm-triggered script runs
M5.4 T4 threaded a `parentExecutionId` parameter through
AlarmActor.SpawnAlarmExecution → AlarmExecutionActor → ScriptRuntimeContext,
but every call site passed null — so alarm on-trigger runs were silently always
execution-tree roots, contradicting the "tag-cascade coverage is complete"
claim in CLAUDE.md and Component-AuditLog.md.

Source the id where a spawner genuinely exists: a static attribute write issued
by a site script (`Instance.SetAttribute`) or by an inbound API request
(`Route.To(...).SetAttributes(...)`, whose ParentExecutionId was already carried
to the site and then dropped). The id rides site-locally through three additive,
nullable fields — no wire, proto or central schema change:

  ScriptRuntimeContext.SetAttribute / RouteToSetAttributesRequest.ParentExecutionId
    → SetStaticAttributeCommand.SourceExecutionId
    → AttributeValueChanged.SourceExecutionId   (InstanceActor static-write path)
    → AlarmActor.SpawnAlarmExecution → AlarmExecutionActor → ScriptRuntimeContext

All four computed trigger types participate. Expression triggers evaluate off
the dispatcher, so the writer of the newest value folded into the snapshot is
captured *with* the snapshot and echoed home on ExpressionEvalResult /
ExpressionEvalFailed — a change arriving mid-flight cannot mis-attribute the
raise.

Deliberately still roots (documented, not deferred): alarms fired by Data
Connection Layer values (external device data has no spawning execution — this
includes 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).

Tests: new SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests pins all
three hops — SetAttribute stamps the run's ExecutionId, InstanceActor publishes
it on the change (and publishes null when absent), and ValueMatch/HiLo/
Expression alarms parent the on-trigger run to the writer while a DCL-originated
change leaves it a root.

Docs: CLAUDE.md and Component-AuditLog.md corrected from "complete" to the true
behaviour; Component-SiteRuntime.md gains an "Audit correlation of an on-trigger
run" section with the hop table and the by-design root cases.
2026-08-01 11:22:07 -04:00
Joseph Doherty 88638d774a feat(cli,management): close area-move and template-folder CLI parity gaps
Two verified-absent parity gaps between the service layer and the CLI /
ManagementActor command surface, both left as follow-ups by the 2026-05-11
design plans.

(1) area move. AreaService.MoveAreaAsync had existed since the deployment
topology page shipped but was reachable only from the Blazor UI. Adds
MoveAreaCommand(AreaId, NewParentAreaId?) to Commons, a ManagementActor
dispatch arm delegating straight to AreaService.MoveAreaAsync (not-found /
self-parent / descendant-cycle / cross-site / name-collision all surface as
the standard curated ManagementCommandException failure response; the service
writes its own "Move" audit row), and the CLI verb `site area move --id
[--parent-id]`. Omitting --parent-id moves the area to the site root, matching
the command's nullable NewParentAreaId. The command carries the SAME any-of
[Designer, Deployer] gate as CreateArea/UpdateArea/DeleteArea (arch-review C6):
re-parenting is the same structural authoring act, exposed on the same two
surfaces. Placed under the existing `site area` group rather than a new
top-level `area` group, alongside its create/update/delete siblings.

(2) template folder verbs. The five folder management commands have been
handled by ManagementActor since the folder-hierarchy plan, but the promised
CLI surface was never written. Adds `template folder
list|create|rename|move|reorder|delete` mapping 1:1 onto ListTemplateFolders /
CreateTemplateFolder / RenameTemplateFolder / MoveTemplateFolder /
ReorderTemplateFolder / DeleteTemplateFolder. --parent-id is omitted to target
the tree root; --direction takes the lowercase literals up/down, validated at
parse time by AcceptOnlyFromAmong (same case-sensitive contract as the audit
--channel/--kind/--status options).

Follow-on updates: the frozen authorization matrix gains its MoveArea entry
(reflection-driven, so a missing entry would have failed CI); CommandTreeTests
pins both new verb sets plus the omit-parent-id-means-root parse behaviour and
registry round-trips; ManagementActorTests covers the MoveArea role gate and
the delegate-to-service success/root/cycle/not-found paths; the CLI README and
Component-ManagementService.md document the new surface (the latter also gained
the previously-undocumented ReorderTemplateFolder); both plan docs' follow-up
lines are marked done.
2026-08-01 11:15:24 -04:00
Joseph Doherty e0851e3e17 docs(register): close row 12 — InstanceConfigure native-alarm-source CSV upload shipped 2026-08-01 11:12:10 -04:00
Joseph Doherty a26d6ba317 feat(central-ui): native-alarm-source CSV import on InstanceConfigure
The Native Alarm Source Overrides card had no bulk affordance — the CSV path
shipped CLI-only (instance native-alarm-source import --file), so the UI could
only retarget one source at a time inline.

Adds a second InputFile on that card, mirroring the attribute importer's UX
(hidden input behind a button-styled label, 512 KB cap, success/error alert
with the per-line error list, toast). Parsing reuses the SHARED
NativeAlarmSourceOverrideCsvParser — the exact parser the CLI uses — and the
new pure InstanceConfigure.BuildNativeAlarmSourceCsvImport applies the same
batch rules the server enforces in
ManagementActor.HandleSetInstanceNativeAlarmSourceOverrides: the source must
resolve, must not be template-locked, and may appear at most once; any error
rejects the whole file and applies nothing.

Semantics match the CLI: merge, not full replace — sources absent from the
file keep their existing override, a blank field keeps the inherited value,
and an all-blank row clears that source's override (equivalent to the CLI's
all-null override row, without leaving a dead row behind).

Persistence reuses the inline editor's path: SaveNativeOverride's upsert body
is extracted to UpsertNativeOverrideCore (no SaveChangesAsync inside), so the
import commits the validated batch in one SaveChangesAsync — no new server
method, no duplicated parsing.

Tests: InstanceConfigureNativeAlarmCsvImportTests (9) — happy path, blank-field
inheritance, all-blank clear, merge semantics, unknown/locked/duplicate source
and parser-error rejection, plus structural pins on the InputFile wiring.
Docs: Component-CentralUI.md native-alarm-source card gains the import bullet.
2026-08-01 11:11:40 -04:00
Joseph Doherty 697be0ce43 docs(topology): fix stale keep-oldest reference in Site Cluster Behavior — clusters run auto-down since 2026-07-21 2026-08-01 11:10:48 -04:00
Joseph Doherty 4558bc3b1f docs(register): track 4 untracked deferrals, split the failover/perf row, close #18
Adds rows 23-26 to the deferred-work register: live LDAP group-membership
re-query (blocked on ZB.MOM.WW.Auth.Ldap gaining a passwordless group
search), M8 large-bundle perf hardening (logged in the M9 completion design,
never given a plan or perf test), the Phase-8 WP-4 target-scale load test
(claimed complete by a 107-byte checklist stub with no evidence), and the
Ipsen MES MoveIn tail (-LT routing, PLC-output flags, Z28062 data).

Splits the review-08 "Failover-timing + broader perf envelope" row: the
failover-timing half is resolved by FailoverTimingTests, now a live [Fact]
on TwoNodeClusterFixture at production timings (PLAN-R2-01 T4); the S&F
drain-rate + per-subscriber backpressure half stays open as its own row.

Moves the closed folder drag-drop row (18, [PERM]) out of Deferred into the
Resolved table per the register's own rule.
2026-08-01 11:10:19 -04:00
Joseph Doherty 2d03f2d507 fix(site-runtime): reconcile artifact deletions on apply — central deletes no longer orphan site rows
The artifact apply (DeploymentManagerActor.HandleDeployArtifacts) was
upsert-only, so deleting an external system (or shared script, DB connection,
data connection) centrally never removed the site's SQLite row — a deleted
external system stayed callable from site scripts forever. Central always
ships the COMPLETE set of each artifact class (ArtifactDeploymentService
GetAll* snapshots; the wire's presence-tracking wrapper lists preserve
null-vs-empty), so the site now applies upsert-then-reconcile: after storing
the incoming set, SiteStorageService.DeleteRowsExceptAsync removes any stored
row absent from it, per artifact table. A null list still means 'field not
shipped' and touches nothing.

Runtime cleanup rides along: a reconciled-away shared script is unregistered
from the compiled SharedScriptLibrary (a stale delegate would stay callable
until restart), and a removed data connection is evicted from the DCL hash
cache and its live connection actor stopped via the previously-caller-less
RemoveConnectionCommand — both on the actor thread via the extended
ApplyArtifactDataConnectionsToDcl message. All four tables are
RegisterReplicated, so the deletes reach the standby as ordinary CDC row
tombstones.

Tests: storage-level reconcile per table (incl. empty-set-deletes-all and
idempotency) in ArtifactStorageTests; actor-level pins in
DeploymentManagerActorTests (orphan delete, null-set no-op, library
unregistration, DCL stop for the removed connection only). Docs:
Component-DeploymentManager + Component-SiteRuntime record the
full-set/reconcile semantics.
2026-08-01 10:54:18 -04:00
Joseph Doherty 0123b68719 docs(plans): MES alarm-status API — all 7 open design questions decided
Design review 2026-08-01: (1) MES relevance = dedicated severity band 900-999
(script constants, real IsFlaggedForMES predicate); (2) MESReceiver version
DROPPED — CvdReactor is the only implementation, router returns a clean
not-supported error elsewhere; (3) Description = Message else AlarmTypeName;
(4) enrich the native mirror NOW with an additive AckTime (proto +
native_alarm_state + DCL stamping); (5) script names match the endpoints;
(6) shared ReactorAlarms on both sides, suffix behavior as planned;
(7) existing MES API key, no new roles. Plan is now ready to execute.
2026-08-01 10:29:44 -04:00
Joseph Doherty d1ade5653b docs(plans): bookkeeping sync — reconcile stale trackers with merged code
A verified audit of all ~90 plan documents (2026-08-01) found ~30 .tasks.json
trackers and several plan headers still reporting 'pending'/'draft' for work
fully merged to main. Sync them so future audits don't re-litigate closed work:

- Flip ~380 stale task statuses to completed across March/May/June/July
  trackers (audit-log series, milestones M5-M10, playwright waves, stillpending,
  LocalDb, ClusterClient->gRPC DoD rows, and more), each verified against
  code/git evidence before flipping.
- Annotate obsolete-not-done rows: ClusterClient CLI transport (never built,
  HTTP shipped), TreeView Areas/Instances pages (replaced by Topology),
  template-tree drag-drop (dropped for M9 menu reorder), otopcua item C
  (premise superseded by #17).
- Flip stale headers: aggregated-live-alarm + kpi-rollups 'Draft not executed'
  -> Delivered 2026-07-10; otopcua cutover-scope SCOPING -> DECIDED;
  scadabridge-rename -> Implemented; LocalDb phase1/2 status strings ->
  merged 28ca04d7.
- Fix doc drift: T9/T10 'deferred' -> shipped as SMS (Teams dropped); waitfor
  sandbox follow-up shipped; followups #52/#53/#54/#162/#207 resolved; purge
  TODO closed by PendingDeploymentPurgeActor; live-gate pre-existing failures
  #28/#29/#31 fixed; auto-down boot-alone residual superseded by self-first
  seeds; supersession banners on keep-oldest SBR + ClusterClient-era designs;
  requirements-traceability 'Pending' clarified as frozen plan-generation
  status.

Deliberately left pending (genuinely open, tracked in the pending-work list):
opcua-tag-browser task 19 (live smoke), ipsen tasks 7-8 (vd03 verification),
selfform task 7 (vd03 overlay, user-held), live-gate observation 1
(external-system delete orphan bug), otopcua item A + maxDepth calibration.
2026-08-01 08:53:56 -04:00
164 changed files with 6635 additions and 1797 deletions
+2 -1
View File
@@ -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)
+23 -1
View File
@@ -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.
+1 -1
View File
@@ -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"
}
+2
View File
@@ -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"
}
+9 -9
View File
@@ -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"
}
+8 -8
View File
@@ -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)
+79 -58
View File
@@ -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
+5 -1
View File
@@ -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`).
+1 -1
View File
@@ -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 |
+2
View File
@@ -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**:
+31 -10
View File
@@ -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
+4 -2
View File
@@ -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`
+50 -2
View File
@@ -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 };
+93
View File
@@ -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>
@@ -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>
@@ -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
@@ -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
@@ -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>
@@ -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);
}
@@ -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
@@ -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);
}
@@ -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);
}
@@ -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