Files
ScadaBridge/docs/plans/2026-09-14-pending-updates-design.md
T

9.0 KiB

Pending Updates: site artifact staleness, rail indicator, scrollable nav — Design

Date: 2026-09-14 · Status: approved for implementation (plan: 2026-09-14-pending-updates.md)

1. Ask

Three Central UI changes, requested together:

  1. The Sites page does not show whether a site's deployed system-wide artifacts (shared scripts, external systems, DB connections, that site's data connections) are out of date. It should carry a Stale / Current badge like the Topology (template-instance) page does for instances.
  2. A single "pending updates" indicator in the rail footer, bottom-left just above the user name, summarising both kinds of pending update: stale instances and sites with stale artifacts.
  3. The expanding rail menu should sit in a scroll pane so a long nav never pushes the footer off-screen.

2. Current state (verified 2026-09-14)

  • Instance staleness = DeploymentService.GetDeploymentComparisonAsync re-flattens the instance (validateScripts:false) and compares RevisionHash with the DeployedConfigSnapshot (after the I-1 List-value normalisation). Topology computes it per loaded page with bounded concurrency, off the 15 s live timer, with a Re-check staleness button. There is no aggregate anywhere.
  • Artifact deployment (ArtifactDeploymentService) builds one DeployArtifactsCommand per site and persists a SystemArtifactDeploymentRecord with a per-site status JSON, but records no revision of what was sent, so "are this site's artifacts current?" is currently unanswerable.
  • The rail is the ZB.MOM.WW.Theme kit's ThemeShell (nav.side-rail, flex column; .rail-foot has margin-top:auto; #theme-rail is sticky 100vh on ≥ lg). The nav items are direct children of .side-rail; nothing scrolls internally, so a long nav grows past the viewport.
  • SingleFlightMemo<T> + KpiSnapshotCache already give the process-level, per-TTL, single-flight cache shape a polled aggregate needs.

3. Design

3.1 Site artifact revision (Commons + ConfigurationDatabase + DeploymentManager)

  • Site gains two nullable columns: ArtifactRevisionHash (string?, max 100) and ArtifactsDeployedAt (DateTimeOffset?). Migration AddSiteArtifactRevision. Additive; a site that has never had artifacts deployed stays null = Never deployed.
  • New ArtifactRevisionHashService (DeploymentManager) computes a deterministic SHA-256 over a canonical projection of a per-site DeployArtifactsCommand: every artifact class sorted by Name (ordinal), DeploymentId/timestamp excluded, alphabetically-declared hashable records, camelCase JSON, nulls omitted — the same determinism contract as RevisionHashService, pinned by a test. Data-connection configuration is plaintext in memory (EF value converter), so a no-op re-save that re-encrypts at rest does not change the hash.
  • ArtifactDeploymentService stamps ArtifactRevisionHash + ArtifactsDeployedAt on the Site row only for a site whose deploy succeeded (both DeployCoreAsync and RetryForSiteAsync). A failed site keeps its previous stamp, so it keeps showing out of date. The stamp is written in the same SaveChanges as the deployment record where possible.
  • ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync(sites) returns siteId → hash for the current definitions, hoisting the global artifact queries once (same shape as DeployCoreAsync).
  • Transport does NOT carry the two columns. They are environment-specific runtime state, exactly like imported instances landing NotDeployed; the round-trip guard exclusion list names them with that reason. An import that changes a site's data connections therefore makes the site read out of date until artifacts are redeployed — the correct outcome.

3.2 Pending-updates aggregate (DeploymentManager, cached in CentralUI)

  • DeploymentService.GetInstanceStalenessAsync(instanceIds) → IReadOnlyDictionary<int, bool?>: one shared FlattenSession for the batch (template chains walked once, as DeploySiteAsync does), compares against a projection read of (InstanceId, RevisionHash) from DeployedConfigSnapshot (new IDeploymentManagerRepository.GetDeployedRevisionHashesAsync() — never the fat ConfigurationJson for the whole fleet). For a hash mismatch only, the full snapshot is read and the I-1 normalised deployed hash recomputed, so the aggregate agrees exactly with the Topology badge on old-form List snapshots. null = could not evaluate (flatten failed) — counted as unknown, never as stale.
  • PendingUpdatesEvaluator (scoped, DeploymentManager) → PendingUpdatesSnapshot: ComputedAt, per site SitePendingUpdates(SiteId, SiteIdentifier, SiteName, ArtifactState {NeverDeployed|Current|OutOfDate}, ArtifactsDeployedAt, DeployedInstanceCount, StaleInstanceCount, UnknownInstanceCount), plus totals StaleInstanceCount and StaleArtifactSiteCount. Instances are those with a deployed snapshot (the same gate Topology uses: State != NotDeployed).
  • IPendingUpdatesCache / PendingUpdatesCache (CentralUI, singleton, mirrors KpiSnapshotCache): one SingleFlightMemo<PendingUpdatesSnapshot> with TTL DeploymentManagerOptions.PendingUpdatesCacheTtl (default 60 s, validated > 0), running the evaluator in its own DI scope. GetAsync(forceRefresh). Consequence: however many circuits poll, the fleet is flattened at most once per TTL, and not at all when nobody is looking.
  • Not in scope (register rows): a template-graph-watermark short-circuit so an unchanged fleet costs no re-flatten; a deploy CLI/Health KPI surface for the counts.

3.3 Sites page (Admin)

  • Each site card shows an Artifacts badge next to the identifier: Current (light), Out of date (warning, bi-exclamation-triangle, tooltip = deployed-at UTC), Never deployed (secondary). Below it a muted line N of M instances out of date (link to /deployment/topology) when M > 0, since the snapshot already carries it. Badges match Topology's Bootstrap .badge convention (the kit's StatusPill is unused in this app).
  • Header gains Re-check (forces GetAsync(forceRefresh:true), disabled while running) and an as of HH:mm:ss UTC caption. A successful Deploy Artifacts (single or all) forces a refresh so the badge flips to Current immediately.
  • The snapshot is loaded after the site list, never blocking it: cards render with a checking… placeholder until it arrives; a snapshot failure shows unavailable on the badge and logs.

3.4 Rail indicator (PendingUpdatesIndicator, MainLayout RailFooter, above .rail-user)

  • Rendered only for a user holding Deployer or Administrator (the two roles that can act on it); hidden otherwise. Polls IPendingUpdatesCache.GetAsync() every 30 s through PollGate (cache TTL 60 s ⇒ polling is cheap), disposes its timer with the circuit, renders via InvokeAsync.
  • States: Checking for updates… (first load) · Up to date (muted, check icon) · Pending updates with a warning count pill and a breakdown line: 3 instances (links to Topology when Deployer) · 1 site (links to /admin/sites when Administrator, plain text otherwise) · Updates unavailable (error, tooltip has the message). Tooltip on the block: as of HH:mm UTC.
  • Site-scoped users see only their permitted sites' counts (SiteScopeService), consistent with Topology.

3.5 Scrollable nav

  • NavMenu.razor wraps its items in <div class="rail-scroll">; site.css adds, ≥ lg only: #theme-rail > .side-rail { height: 100%; } (fill the sticky 100 vh rail so .rail-foot's margin-top:auto actually pins) and .rail-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }. Below lg the rail is inline and scrolls with the page, unchanged. Kit untouched; a register row proposes upstreaming it into ZB.MOM.WW.Theme for the other consumers.

4. Tests

  • Unit: hash determinism (order-insensitive, field-sensitive, alphabetical-declaration guard); stamping on success only; GetInstanceStalenessAsync (shared session used once, mismatch-only normalisation, null on flatten failure); evaluator totals; cache single-flight/TTL/force; bUnit for the indicator (all states, role gating, scope filter), Sites page (badges, re-check, refresh-after-deploy), NavMenu wrapper; Transport round-trip guard exclusions; migration snapshot.
  • Rig: docker/deploy.sh (migration auto-applies), docker/verify-pending-updates.sh (CLI: site list shows artifactRevisionHash null → deploy artifacts → set; edit a shared script → central UI shows out of date; redeploy → current), plus a Playwright smoke for the indicator + Sites badge.

5. Operational notes

  • Production/QA: the migration ships as a delta script (dotnet ef migrations script AddAlarmMesReportable AddSiteArtifactRevision). After upgrade every site reads Never deployed until artifacts are deployed once — deliberate; one Deploy Artifacts to All Sites clears it.
  • Cost: one fleet flatten per PendingUpdatesCacheTtl while any Deployer/Admin circuit is open.