Compare commits

...

20 Commits

Author SHA1 Message Date
Joseph Doherty ac833f5ace docs: pending-updates plan — all 12 tasks completed; rig gate 13/13 + Playwright 3/3 on the merged tip
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:38:32 -04:00
Joseph Doherty 3a21cfc2f5 fix(central-ui): pending-updates indicator starts polling once the async auth resolve completes
OnParametersSetAsync awaits the principal. On a real cookie session that await
suspends, so the FIRST render ran with _visible still false and
OnAfterRenderAsync(firstRender: true) skipped EnsurePollingStarted(); the
render that followed the resolve was firstRender: false, and nothing else
started the poll unless AuthenticationStateChanged happened to fire. The block
therefore rendered and sat on 'Checking for updates…' for the life of the
circuit. OnParametersSetAsync now starts polling itself when visible (the
start is idempotent and the OnAfterRender call is kept).

Also corrects the comments that called the non-forced
GetAuthenticationStateAsync cheap: on CookieAuthenticationStateProvider both
entry points revalidate against the database and differ only in failClosed, so
the saving is not asking the provider on a tick and reusing the cascaded state
the router already resolved. The provider fallback is a real refresh, is only
taken when no cascade is present (AddCascadingAuthenticationState is
registered, so it is dead in production), and is now resolved at most once per
auth state rather than on every parameter set.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:29:36 -04:00
Joseph Doherty 623c6d0fe8 fix(rig): verify-pending-updates.sh cannot pass on an empty site list; numeric-safe comparison; earlier cleanup trap
The gate could pass vacuously. Every site-list read went through a bare
`ROWS=$(site_rows)` with neither the exit status nor the row count checked, and
an empty result satisfied every downstream assertion for free: no rows means no
malformed hash for B1/B2 to report, and a diff of two empty strings is no
difference at all for C1/D2. A down management API would therefore have read as
a clean gate.

Reads now go through read_rows, which fails loudly on a non-zero CLI exit
(site_rows exits 2 on an empty list, and pipefail surfaces a failed call) and
aborts unless the line count equals the site count established in step A —
keeping the running tally so the abort still prints PASS/FAIL. Verified against
an unreachable URL: FAIL 1, exit 1.

The join(1)/cut/diff comparisons assumed lexicographic id order matches numeric
order, which breaks once a site id reaches 10 alongside single-digit ids (and BSD
join silently drops the unmatched rows rather than complaining). Replaced with a
python3 comparison keyed by integer id that also asserts the id SETS match, so a
site appearing or vanishing between two reads invalidates the comparison instead
of quietly shrinking it. Same for the summary table.

Also: set -euo pipefail like every sibling script (the cmd && pass || fail guards
keep -e safe, and -e does not cover var=$(cmd) — hence the explicit checks
above); the cleanup trap is armed BEFORE the shared-script create and falls back
to finding the probe by its unique name, so a create that succeeds server-side
but whose id we fail to parse can no longer orphan a probe; mktemp instead of
/tmp/pu-deploy.$$.

PendingUpdatesTests: the rail-scroll check asserted scrollHeight >= clientHeight,
which holds for every element. It now asserts what the pane is actually for —
the pane is bounded by the viewport and the rail footer holding the indicator
stays on screen, so a nav that grows the rail instead of scrolling fails. Live
values are genuinely overflowing (scrollHeight 1196 vs clientHeight 680, footer
bottom 765 of 900), so the assertion has real content.

Re-run against the rig: PASS 13 / FAIL 0; Playwright 3/3.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:20:15 -04:00
Joseph Doherty af9f14a668 fix(central-ui): pending-updates indicator resolves roles/scope once per auth state, not per poll
SiteScopeService's instance methods resolve the AUTHORITATIVE state — a
fail-closed, DB-backed group-to-role re-mapping. The indicator called them on
every 30s tick, and it lives in MainLayout, so that was one or two forced
session revalidations per open circuit per page, forever: exactly the
per-circuit cost IPendingUpdatesCache exists to avoid.

Extracts the pure rule as SiteScopeService.Resolve(ClaimsPrincipal) (no I/O;
ResolveAsync now delegates to it, no behaviour change for existing callers).
The indicator takes the cascaded AuthenticationState — falling back to a
NON-forced GetAuthenticationStateAsync, as Templates/FlowDebugger do — and
computes roles plus scope once per auth state, re-running on
AuthenticationStateChanged so a mid-session revocation hides the block
instead of leaving it stale for the life of the circuit. Also routes the
first load through PollGate, adds aria-hidden to the icons, and comments the
off-renderer field writes and the InvalidOperationException catch.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:15:49 -04:00
Joseph Doherty 6dc784bca4 docs(cli): site list omits the artifact-revision fields until the first deploy (not null)
Found by docker/verify-pending-updates.sh step A: the management API
serializer uses WhenWritingNull, so a never-deployed site has no such keys.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:13:01 -04:00
Joseph Doherty a672f91d90 test(rig): verify-pending-updates.sh + Playwright smoke for the indicator and Sites badge
Rig rebuilt from this branch (image built at d8979664; migration
20260914173147_AddSiteArtifactRevision applied by central-b on boot).

verify-pending-updates.sh drives the CLI end to end: the site list carries the
artifact-revision fields, a fleet-wide deploy stamps every site with a
sha256:<64 hex> hash and a fresh timestamp, creating a shared script leaves the
stamp alone until the next deploy and then moves EVERY site's hash, and deleting
it again returns every hash byte-for-byte to its pre-create value — so the hash
is content-addressed, not deploy-addressed. PASS 13 / FAIL 0.

Step A asserts the SHAPE rather than key presence: the management API serializer
omits null properties, so on a never-deployed site the two keys are absent, not
present-and-null. Step E is a soft reachability check only; the badge text and
the rail indicator land after the Blazor circuit's post-render load.

PendingUpdatesTests covers that browser half — the rail footer settles off
"Checking for updates…", the Sites page carries an Artifacts badge per site card
plus a working Re-check control, and the rail nav's scroll pane computes
overflow-y: auto at lg+. 3/3 passed against the rig (rail read "Up to date",
3 badges over 3 cards, all "Artifacts current").

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:10:24 -04:00
Joseph Doherty 94e1d71ba2 fix(central-ui): Sites page re-check no longer holds the deploy controls; pluralise; clear stale caption on error
The forced refresh after a successful Deploy Artifacts ran inside the try, so
the Bulk-actions toggle and every per-site Deploy item stayed disabled for a
whole fleet evaluation after the success toast; it now runs after the finally,
behind a re-render, with the Re-check spinner showing it in flight. Also:
pluralise the instance line's noun, drop a stale "as of" caption when a
re-check faults, skip the fleet evaluation when the site load itself failed,
and render the badge tooltip through UtcDateTime so the UTC label is honest.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:08:52 -04:00
Joseph Doherty d8979664f1 docs: site artifact revision, pending-updates indicator, rail scroll pane
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:01:07 -04:00
Joseph Doherty c724ed6ee2 feat(central-ui): rail-footer pending-updates indicator
Adds PendingUpdatesIndicator to MainLayout's RailFooter, above .rail-user.
Self-gating on the Deployer/Administrator role claims, it polls the
process-wide single-flight IPendingUpdatesCache every 30s through PollGate
and renders four states: checking / up to date / pending (warning pill plus
an 'N instances · M sites' breakdown) / unavailable. Each half of the
breakdown links only for the role that can act on it (Topology => Deployer,
Sites => Administrator), and a site-scoped operator's totals are narrowed
through SiteScopeService so they never count a site they cannot see.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:00:16 -04:00
Joseph Doherty 5d6bd03868 feat(central-ui): Sites page shows whether a site's deployed artifacts are current
Each site card carries an Artifacts badge (current / out of date / never
deployed) plus an "N of M instances out of date" line linking to Topology,
both read from the shared pending-updates snapshot. The snapshot loads after
the site list so a slow fleet evaluation never holds the cards, and a failure
degrades the badge to "unavailable" instead of taking the page down. Header
gains a Re-check button and an as-of caption; a successful Deploy Artifacts
forces a refresh so the badge flips immediately.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:59:54 -04:00
Joseph Doherty 3d22f272d3 refactor(deploy): evaluator groups instances by site once; doc corrections
Tallying filtered the whole deployed-instance set inside the site loop, making the
aggregate O(sites x instances) — the one per-entity loop in an otherwise batched
pass. Group once into a lookup and index per site. Also: ResolveArtifactState's
missing-hash branch is a defensive default, not the race the comment described
(the same site list feeds the hash computation), and the staleness batch omits an
instance undeployed mid-batch as well as one that never had a snapshot.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:55:26 -04:00
Joseph Doherty ada91bb82c feat(central-ui): process-wide single-flight cache for the pending-updates snapshot
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:52:54 -04:00
Joseph Doherty 0287f8f64f fix(deploy): persist the artifact deployment record before the best-effort site stamp
Both repositories are scoped over one DbContext, so a failed stamp save left the
Site entries tracked as Modified and the deployment record's (uncaught)
SaveChangesAsync rethrew it — losing the audit row and failing a deploy in which
every site succeeded. Ordering rule now: audit row first, stamp after, inside its
own try/catch. Same in RetryForSiteAsync, where the site load is caught too so
nothing on the stamp path can reach the method's catch.

Also: the method-definitions canonicaliser matches the name property
case-insensitively (a naming-policy change at the producer would otherwise
silently turn the sort into a no-op), and ComputeCurrentArtifactHashesAsync
documents that a per-site query failure aborts the whole batch by design.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:51:34 -04:00
Joseph Doherty fc48db480c feat(deploy): PendingUpdatesEvaluator — one fleet snapshot of stale instances and stale-artifact sites
One scoped read that answers "is anything waiting to be deployed?" for the whole
fleet: each site's stored artifact revision hash compared against what a deploy
would send right now (NeverDeployed / Current / OutOfDate), plus per-site
deployed/stale/unknown instance tallies from the batched staleness evaluation.
NeverDeployed is deliberately NOT a pending update — the operator has not deployed
yet — and exceptions propagate so the caller's single-flight cache can render
"unavailable" rather than invent an "up to date". Adds the validated
PendingUpdatesCacheTtl (60 s) that TTL will key off.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:49:54 -04:00
Joseph Doherty 3b55d60ef9 fix(deploy): staleness batch logs one Warning per batch, not one per instance
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:48:06 -04:00
Joseph Doherty 5214932447 feat(deploy): hash the per-site artifact set and stamp it on a successful artifact deploy
ArtifactRevisionHashService (deterministic SHA-256 of the per-site
DeployArtifactsCommand, DeploymentId/timestamp excluded) stamps
Site.ArtifactRevisionHash/ArtifactsDeployedAt for every site whose deploy
succeeded; failed sites keep their previous stamp.
ComputeCurrentArtifactHashesAsync answers "what would it be now".

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:42:51 -04:00
Joseph Doherty dc60ba1212 feat(deploy): batch instance staleness over one flatten session
DeploymentService.GetInstanceStalenessAsync shares a FlattenSession across
the batch, reads only the (InstanceId, RevisionHash) projection and pays the
snapshot read + I-1 List normalisation only for a mismatch, so the fleet
aggregate agrees exactly with the per-instance Topology badge. The I-1 logic
is extracted from GetDeploymentComparisonAsync unchanged.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:40:19 -04:00
Joseph Doherty dd202e5dde feat(sites): record the artifact revision last deployed to a site
Site.ArtifactRevisionHash / ArtifactsDeployedAt (nullable, migration
AddSiteArtifactRevision) so the Sites page can tell whether a site's
system-wide artifacts are current. Not transported. Adds the
GetDeployedRevisionHashesAsync projection for the fleet-wide staleness batch.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:33:59 -04:00
Joseph Doherty 0762d69132 fix(central-ui): rail nav scrolls in its own pane so the footer stays pinned
NavMenu wraps its items in .rail-scroll; site.css fills the sticky rail on
lg+ and lets that wrapper scroll. Kit untouched.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:31:19 -04:00
Joseph Doherty 47e20dd8a8 docs(plans): pending-updates design + plan (site artifact staleness, rail indicator, scrollable nav)
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:28:49 -04:00
49 changed files with 7126 additions and 52 deletions
+1
View File
@@ -138,6 +138,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- Deployment identity: unique deployment ID + revision hash for idempotency.
- Per-instance operation lock covers all mutating commands (deploy, disable, enable, delete).
- **Bulk site deployment (`DeploymentService.DeploySiteAsync`, arch-review WP2.5)** redeploys every out-of-date instance at a site through one flatten session (a template chain shared by N instances is walked once) in three phases — serial Prepare, bounded-parallel Send (`SiteDeploymentMaxParallelism`, default 4), serial Finalize — still **not all-or-nothing**: any one instance's failure doesn't block the rest. **Disabled instances are SKIPPED by bulk deploy as of 2026-08-19 (arch-review P7-2)** — they are reported in the summary's additive `SkippedInstances` list, never flattened/sent/re-enabled (single-instance deploy remains the explicit operator path that deploys+enables a Disabled instance); **Up-to-date instances are ALSO skipped as of 2026-08-20 (arch-review P7-6)** — a redeploy is not idempotent at the site (it resets static overrides and clears native alarm state), so `DeploySiteAsync` pre-filters through `IStaleInstanceProbe` (trustworthy since P7-4 fixed its memo timing) and reports unchanged instances in `SkippedInstances` with reason `up-to-date`; the filter is conservative (every uncertainty deploys: missing snapshot, `ShouldQuerySiteBeforeRedeploy`, null/faulting probe). Additive `MgmtDeploySiteCommand.Force` / CLI `deploy site --force` restores the unconditional push for what central cannot see (restored volume, rebuilt node) — **Disabled still wins under `--force`**. Also P7-5: the three phases share one scoped DbContext and a failed `SaveChanges` leaves the entity tracked, so one permanent failure used to cascade to every later instance and strand records `InProgress`; `MarkDeploymentFailedAsync` now detaches pending changes and retries the `Failed` write once on the clean tracker; Prepare's per-instance reconcile also runs under a short `SiteDeploymentReconcileTimeout` (5s) with a once-per-batch unreachable-site circuit-breaker, so a down site no longer costs 30s × N serial before the first send (P7-3). The `PendingDeployment` staging row (5-minute TTL) is written per-instance immediately before that instance's round-trip in the Send phase, not upfront in Prepare, so a large batch's tail instances don't have their TTL clock already half-spent by the time they're reached.
- **Site artifact revision + fleet-wide pending-updates indicator (2026-09-14, `docs/plans/2026-09-14-pending-updates-design.md`).** `Site.ArtifactRevisionHash`/`ArtifactsDeployedAt` (migration `AddSiteArtifactRevision`) are stamped by `ArtifactDeploymentService` only on a site whose artifact deploy succeeds, so a redeployed fleet's artifacts are trackable per site the same way instance staleness already is. `PendingUpdatesEvaluator` combines that with `DeploymentService.GetInstanceStalenessAsync` (one shared `FlattenSession` for the whole fleet) into a `PendingUpdatesSnapshot`, held behind a process-wide singleton `IPendingUpdatesCache` (`ScadaBridge:DeploymentManager:PendingUpdatesCacheTtl`, default 60s) so N Blazor circuits cost one fleet flatten per TTL, not one each. Surfaces: the Sites page's per-site **Artifacts** badge (`Current`/`Out of date`/`Never deployed`) + instance-staleness line + header **Re-check**, and a rail-footer **pending updates** indicator (Deployer/Administrator only, 30s poll, site-scoped). **Upgrade consequence:** every site reads *Never deployed* until one artifact deploy runs post-upgrade — deliberate, not a bug. Also shipped: the rail nav now sits in its own scroll pane (`NavMenu`'s `.rail-scroll` + `site.css`) so a long nav can't push the footer/indicator off-screen; the `ZB.MOM.WW.Theme` kit itself is untouched (register row 123 proposes upstreaming it).
- **Create Instance picks its template from the Templates folder tree** (2026-08-21): the flat `<select>` is gone. The Template field is a read-only folder-qualified path box (`TemplatePathFormatter`, "Folder / Sub / Name") + a **Choose…** button, both opening `TemplatePickerDialog` — an `IDialogService.ShowAsync` body wrapping the **same shared `TemplateFolderTree`** the Templates page renders, with its own search, expand/collapse-all, templates-only selection and double-click-to-pick. It opens on the current pick via the tree's additive **`RevealKeys`** parameter (ancestors of the named `f:`/`t:` keys start expanded when no `Filter` is active; an active filter's reveal set still wins; callers passing nothing are byte-identical). `InstanceCreate` gained `[SupplyParameterFromQuery] TemplateId`, which the Templates tree's new **Deployer-gated "New Instance…"** context-menu item navigates with; combined with `?siteId=` it populates the Connection Bindings card on first render. **The trap for anyone extending the picker:** in `TreeViewSelectionMode.Single` the inner `TreeView` reads the *singular* `SelectedKey`/`SelectedKeyChanged` pair, but `TemplateFolderTree` forwards only the plural (Checkbox-mode) `SelectedKeys` pair — so a Single-mode caller cannot get a row highlight through the shared tree and must own selection in its own `NodeContent`, as the picker does. Design: `docs/plans/2026-08-21-template-picker-design.md`.
- **Create Instance binds module groups at creation** (2026-08-21): the create page's Connection Bindings card flattens template+site through the additive `IFlatteningPipeline.PreviewAsync` (an unsaved instance — no validation, no hash, nothing persisted, instance repo never touched) and reuses `BindingGroupBuilder`; `NodeBrowserDialog.SelectableNodeClasses` lets it pick **Object** nodes (default stays Variable-only for every existing caller). Create = `CreateInstanceAsync` → `SetConnectionBindingsAsync` (the existing upsert, no new server surface) → always navigate to Configure with `?created=1&bound=N|&bindError=…`, because the row exists whether or not the second call succeeded. Partial binding is legal: a group missing a connection or an Object is skipped and stays unbound. Design: `docs/plans/2026-08-21-create-instance-bindings-design.md`.
- Site-side apply is all-or-nothing per instance.
+304
View File
@@ -0,0 +1,304 @@
#!/bin/bash
set -euo pipefail
# Live gate for the PER-SITE ARTIFACT REVISION that backs the pending-updates indicator
# (design docs/plans/2026-09-14-pending-updates-design.md, plan task 10). Drives the real
# CLI -> Traefik -> active central -> ArtifactDeploymentService path, so every assertion
# below is end-to-end against the running rig.
#
# Steps:
# A site list carries artifactRevisionHash / artifactsDeployedAt (the columns are live)
# CAVEAT: the management API serializer omits null properties, so on a site that has
# never had an artifact deploy the two keys are ABSENT rather than present-and-null.
# A therefore asserts the shape (absent, or a well-formed sha256) and B is the proof.
# B deploy artifacts fleet-wide -> every site stamped sha256:<64 hex>, deployed-at is now
# C create a throwaway shared script -> hashes UNCHANGED until a deploy, then ALL CHANGED
# D delete it, redeploy -> every hash is byte-identical to B again (determinism)
# E Central UI /admin/sites answers 200 and serves the Artifacts badge markup (soft)
#
# NOTE on step E: the Sites page badge and the rail-footer indicator are Blazor Server
# interactive content — with login ENABLED the badge text only lands after the circuit's
# post-render LoadUpdatesAsync, which curl never sees. E is therefore a reachability +
# static-markup smoke only; the real browser coverage is
# tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs.
#
# EVERY read of the site list is checked for both a failed CLI call and a short row count
# (read_rows below). Without that an empty list would pass the whole gate vacuously: "no
# rows" means no malformed hash to report and no difference between two row sets.
#
# Usage: bash docker/verify-pending-updates.sh
# SCADABRIDGE_URL=http://localhost:9000 bash docker/verify-pending-updates.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="${SCADABRIDGE_CLI:-$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge}"
URL="${SCADABRIDGE_URL:-http://localhost:9000}"
A=(--url "$URL" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
PROBE_NAME="PendingUpdatesProbe_$(date +%s)"
PASS=0; FAIL=0
pass() { PASS=$((PASS+1)); echo " PASS $*"; }
fail() { FAIL=$((FAIL+1)); echo " FAIL $*"; }
# Bail out mid-run without losing the tally. Used for the failures that make every later
# assertion meaningless (no sites, an unreachable management API, a short row read).
abort() { echo ""; echo "PASS $PASS / FAIL $FAIL"; exit 1; }
[ -x "$CLI" ] || { echo "CLI not found at $CLI — build it: dotnet build src/ZB.MOM.WW.ScadaBridge.CLI"; exit 1; }
# The CLI's JSON envelope varies by command (bare array vs {"data": [...]}); normalise once.
UNWRAP='
import json,sys
d=json.load(sys.stdin)
if isinstance(d, dict):
d = d.get("data", d)
'
# Prints one "<siteId>\t<identifier>\t<hash|->\t<deployedAt|->" line per site, id-sorted
# NUMERICALLY. Exits non-zero if the CLI call fails (pipefail) or the list is empty.
site_rows() {
"$CLI" site list "${A[@]}" | python3 -c "$UNWRAP"'
import sys
if not d:
sys.exit(2)
for s in sorted(d, key=lambda x: int(x["id"])):
print("%s\t%s\t%s\t%s" % (s["id"], s.get("siteIdentifier"),
s.get("artifactRevisionHash") or "-",
s.get("artifactsDeployedAt") or "-"))
'
}
# read_rows <label> -> sets ROWS. Aborts the run on a failed call or a row count that does
# not match the site count established in step A — either would let later steps pass on
# nothing at all.
ROWS=""
read_rows() {
local label="$1" out="" rc=0 n
out=$(site_rows) || rc=$?
if [ "$rc" -ne 0 ]; then
fail "$label could not read the site list (exit $rc)"
abort
fi
n=$(printf '%s\n' "$out" | grep -c . || true)
if [ "$n" -eq 0 ] || [ "$n" -ne "$SITE_COUNT" ]; then
fail "$label expected $SITE_COUNT site rows, got $n"
abort
fi
ROWS="$out"
}
# compare_rows <same|different> <rowsBefore> <rowsAfter> -> "OK" or a description.
# Keyed by site id (numeric), and the id SETS must match too: a site appearing or vanishing
# between two reads invalidates the comparison rather than silently shrinking it. Replaces a
# join(1)/cut/diff pipeline, which assumed lexicographic id order == numeric order.
compare_rows() {
python3 -c '
import sys
mode, before, after = sys.argv[1], sys.argv[2], sys.argv[3]
def parse(text):
rows = {}
for line in text.splitlines():
if not line.strip():
continue
sid, ident, h, at = line.split("\t")
rows[int(sid)] = (ident, h, at)
return rows
b, a = parse(before), parse(after)
if set(b) != set(a):
print("site id sets differ between reads: %s vs %s" % (sorted(b), sorted(a)))
sys.exit(0)
if not b:
print("no site rows to compare")
sys.exit(0)
bad = []
for sid in sorted(b):
unchanged = b[sid][1] == a[sid][1]
if mode == "same" and not unchanged:
bad.append("%s moved %s -> %s" % (a[sid][0], b[sid][1][7:19], a[sid][1][7:19]))
elif mode == "different" and unchanged:
bad.append("%s still %s" % (a[sid][0], a[sid][1][7:19]))
print("OK" if not bad else "; ".join(bad))
' "$1" "$2" "$3"
}
deploy_all() {
local out rc=0
out=$(mktemp)
"$CLI" site deploy-artifacts "${A[@]}" >"$out" 2>&1 || rc=$?
if [ "$rc" -ne 0 ]; then
echo " deploy-artifacts exited $rc:"; sed 's/^/ /' "$out"
fi
rm -f "$out"
return "$rc"
}
echo "=== ScadaBridge pending-updates live gate ==="
echo "management url: $URL"
echo ""
echo "=== A: site list exposes the artifact-revision columns ==="
RAW=""; RC=0
RAW=$("$CLI" site list "${A[@]}" 2>&1) || RC=$?
if [ "$RC" -ne 0 ]; then
fail "A site list failed (exit $RC): $(printf '%s' "$RAW" | head -2)"
abort
fi
OUT=$(printf '%s' "$RAW" | python3 -c "$UNWRAP"'
import re
bad=[]
stamped=0
for s in d:
h=s.get("artifactRevisionHash")
at=s.get("artifactsDeployedAt")
if h is None and at is None:
continue # never deployed: both keys omitted together
if h is not None and at is not None and re.fullmatch(r"sha256:[0-9a-f]{64}", h):
stamped+=1
continue
bad.append("%s(hash=%r,at=%r)" % (s.get("siteIdentifier"), h, at))
print("EMPTY" if not d else
("OK %d site(s), %d already stamped" % (len(d), stamped) if not bad
else "malformed on " + ",".join(bad)))
' 2>&1) || OUT="could not parse the site list"
case "$OUT" in
OK*) pass "A ${OUT#OK }: artifact-revision fields present and well-formed (or absent = never deployed)" ;;
EMPTY) fail "A site list returned no sites — run docker/seed-sites.sh"; abort ;;
*) fail "A $OUT"; printf '%s\n' "$RAW" | head -5; abort ;;
esac
SITE_COUNT=$(printf '%s' "$RAW" | python3 -c "$UNWRAP"'
print(len(d))
')
echo " $SITE_COUNT sites"
echo ""
echo "=== B: fleet-wide deploy stamps every site ==="
deploy_all && pass "B0 site deploy-artifacts (fleet-wide) succeeded" \
|| fail "B0 site deploy-artifacts (fleet-wide) failed"
read_rows "B"; B_ROWS="$ROWS"
OUT=$(printf '%s\n' "$B_ROWS" | python3 -c '
import sys,re,datetime
bad_hash=[]; bad_time=[]
now=datetime.datetime.now(datetime.timezone.utc)
rows=[l.rstrip("\n").split("\t") for l in sys.stdin if l.strip()]
for sid,ident,h,at in rows:
if not re.fullmatch(r"sha256:[0-9a-f]{64}", h or ""):
bad_hash.append("%s=%r" % (ident, h))
try:
t=datetime.datetime.fromisoformat((at or "").replace("Z","+00:00"))
if t.tzinfo is None: t=t.replace(tzinfo=datetime.timezone.utc)
age=(now-t).total_seconds()
if not (-60 <= age <= 300): bad_time.append("%s=%.0fs old" % (ident, age))
except Exception:
bad_time.append("%s=%r" % (ident, at))
print("HASH " + ("OK" if not bad_hash else ",".join(bad_hash)))
print("TIME " + ("OK" if not bad_time else ",".join(bad_time)))
')
HASH_LINE=$(printf '%s\n' "$OUT" | sed -n 's/^HASH //p')
TIME_LINE=$(printf '%s\n' "$OUT" | sed -n 's/^TIME //p')
[ "$HASH_LINE" = "OK" ] && pass "B1 all $SITE_COUNT site hashes are sha256:<64 hex> (71 chars)" \
|| fail "B1 bad hash(es): $HASH_LINE"
[ "$TIME_LINE" = "OK" ] && pass "B2 all $SITE_COUNT artifactsDeployedAt values are within the last 5 minutes" \
|| fail "B2 stale/unparseable deployed-at: $TIME_LINE"
echo ""
echo "=== C: a new shared script moves the hash — but only on deploy ==="
# Armed BEFORE the create: a create that succeeds server-side but whose id we fail to read
# must still be cleaned up, so the fallback re-finds the probe by its unique name.
PROBE_ID=""
cleanup() {
local id="$PROBE_ID"
if [ -z "$id" ]; then
id=$("$CLI" shared-script list "${A[@]}" 2>/dev/null | python3 -c "$UNWRAP"'
import sys
print(next((str(s["id"]) for s in d if s.get("name")==sys.argv[1]), ""))
' "$PROBE_NAME" 2>/dev/null) || id=""
fi
if [ -n "$id" ]; then
"$CLI" shared-script delete "${A[@]}" --id "$id" >/dev/null 2>&1 \
&& echo " cleanup: removed probe shared script $PROBE_NAME (id=$id)" || true
fi
}
trap cleanup EXIT
CREATE=""; RC=0
CREATE=$("$CLI" shared-script create "${A[@]}" --name "$PROBE_NAME" \
--code 'var pendingUpdatesProbe = 1; return pendingUpdatesProbe;' 2>&1) || RC=$?
# `shared-script create` echoes the created entity, so the id comes straight back.
PROBE_ID=$(printf '%s' "$CREATE" | python3 -c "$UNWRAP"'
print(d.get("id","") if isinstance(d, dict) else "")
' 2>/dev/null) || PROBE_ID=""
if [ "$RC" -eq 0 ] && [ -n "$PROBE_ID" ]; then
pass "C0 created shared script $PROBE_NAME (id=$PROBE_ID)"
else
fail "C0 shared-script create failed (exit $RC): $(printf '%s' "$CREATE" | head -2)"
abort
fi
read_rows "C1"; C_BEFORE="$ROWS"
OUT=$(compare_rows same "$B_ROWS" "$C_BEFORE")
[ "$OUT" = "OK" ] && pass "C1 all $SITE_COUNT hashes UNCHANGED by the create (the stamp only moves on deploy)" \
|| fail "C1 a hash moved without a deploy: $OUT"
deploy_all && pass "C2 redeploy after the create succeeded" || fail "C2 redeploy after the create failed"
read_rows "C3"; C_ROWS="$ROWS"
OUT=$(compare_rows different "$B_ROWS" "$C_ROWS")
[ "$OUT" = "OK" ] && pass "C3 EVERY one of the $SITE_COUNT site hashes changed after deploying the new shared script" \
|| fail "C3 hash did not move for: $OUT"
echo ""
echo "=== D: deleting it again restores the exact B hashes (determinism) ==="
if "$CLI" shared-script delete "${A[@]}" --id "$PROBE_ID" >/dev/null 2>&1; then
pass "D0 deleted shared script $PROBE_NAME"
PROBE_ID=""
else
fail "D0 shared-script delete failed"
fi
deploy_all && pass "D1 redeploy after the delete succeeded" || fail "D1 redeploy after the delete failed"
read_rows "D2"; D_ROWS="$ROWS"
OUT=$(compare_rows same "$B_ROWS" "$D_ROWS")
[ "$OUT" = "OK" ] \
&& pass "D2 all $SITE_COUNT hashes are byte-identical to B — the hash is content-addressed, not deploy-addressed" \
|| fail "D2 hashes did not return to their B values: $OUT"
echo ""
echo "=== E: Central UI Sites page reachability (soft — see the header note) ==="
BODY=""; RC=0
BODY=$(curl -sS -w '\n%{http_code}' "$URL/admin/sites" 2>/dev/null) || RC=$?
CODE=$(printf '%s' "$BODY" | tail -1)
if [ "$RC" -eq 0 ] && [ "$CODE" = "200" ]; then
pass "E1 GET /admin/sites -> 200"
if printf '%s' "$BODY" | grep -q 'Re-check pending updates'; then
pass "E2 prerendered markup carries the Re-check pending updates control"
else
echo " NOTE E2 the Re-check control is not in the prerendered HTML (interactive render) — covered by PendingUpdatesTests"
fi
else
echo " NOTE E1 GET /admin/sites -> ${CODE:-curl exit $RC} (login enabled on this rig?) — covered by PendingUpdatesTests"
fi
echo ""
echo "=== Summary ==="
python3 -c '
import sys
def parse(text):
rows = {}
for line in text.splitlines():
if not line.strip():
continue
sid, ident, h, at = line.split("\t")
rows[int(sid)] = (ident, h)
return rows
b, c, dd = (parse(t) for t in sys.argv[1:4])
print("%-4s %-14s %-14s %-14s %-14s" % ("id", "site", "B hash", "C hash", "D hash"))
for sid in sorted(b):
print("%-4d %-14s %-14s %-14s %-14s" % (
sid, b[sid][0], b[sid][1][7:19], c.get(sid, ("", "?"))[1][7:19], dd.get(sid, ("", "?"))[1][7:19]))
' "$B_ROWS" "$C_ROWS" "$D_ROWS"
echo ""
echo "PASS $PASS / FAIL $FAIL"
[ "$FAIL" -eq 0 ] || exit 1
@@ -116,6 +116,9 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
| 120 | **Data-sourced SCALAR writes reach the OPC UA server as String Variants — every non-String node rejects them with BadTypeMismatch (0x80740000)** *(Medium)*. `InstanceActor.HandleSetDataAttribute` / `HandleWriteAttributeBatch` decode only List values before the DCL write ("Scalars are unchanged"), so the codec-encoded string (`"True"`, `"42.5"`) reaches `RealOpcUaClient.WriteValuesAsync`, which wraps it in `new Variant(value)` — a String variant sent to a Boolean/Double node. Found live 2026-09-10 running `docker/seed-handshake-demo.sh` against opc-plc `Motor.Running`/`Pump.Running`/`Motor.Speed` (`SetAttribute('Complete') failed: OPC UA write failed with status: 0x80740000`); the seed was retargeted onto the rig's only two writable String nodes. The MxGateway path is unaffected in practice: prod's Delmia/MES handshakes write the same strings through the gateway today. Fix candidates: (a) decode scalars by declared `DataType` in the InstanceActor before the DCL write — also changes what MxGateway receives, so it needs a gateway-side compatibility check; (b) adapter-local: `RealOpcUaClient` coerces to the node's DataType (it already resolves `DataTypeIds` in `EnrichVariableTypeInfoAsync`), leaving the MxGateway wire untouched — preferred. Pin with a `RealOpcUaClientArrayWriteTests`-style test for a Boolean scalar. | Found live 2026-09-10 during the #36 rig gate | Out of scope for #36 (handshake ordering); touches the production write path and needs the MxGateway compatibility question answered first. | Before any OPC UA-bound template relies on script writes to non-String nodes (none does today — until this gate the rig's script writes to OPC UA were static-only). |
| 121 | **No source-level `IsMesReportable` on native alarm source bindings** *(Low)*. `TemplateNativeAlarmSource` / `InstanceNativeAlarmSourceOverride` carry no per-alarm MES-relevance flag — "every condition under this binding is reportable" is not expressible, only the 900–999 severity-band convention. | `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md` | Native alarms have no per-alarm config row, and the design chose not to invent one for this slice. | A native-source machine needs per-alarm MES relevance beyond the severity band. |
| 122 | **`IsMesReportable` does not ride the wire** *(Low)*. `AlarmStateUpdate` (vendored proto) and `DebugAlarmStateDto` have no `IsMesReportable` field, so central-side surfaces (Debug View live stream, Alarm Summary) always see `false` even though the site-side snapshot stamps the real value. Adding it is additive-only: `AlarmStateUpdate` field 25, `DebugAlarmStateDto` field 20, via a manual `regen-proto.sh` toggle-build-copy-untoggle. | `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md` | No consumer needed it in this slice — the flag exists to serve `Alarms.MesReportableAsync()` for scripts, not a central UI surface. | Operators ask to see the MES-reportable flag live on Debug View / Alarm Summary. |
| 123 | **Rail nav scroll pane (`.rail-scroll` + the `#theme-rail > .side-rail { height: 100% }` fill rule) lives in app-local `site.css`/`NavMenu.razor`, not the `ZB.MOM.WW.Theme` kit** *(Low)*. Every other kit consumer (OtOpcUa, MxGateway, HistorianGateway, ignitionoee) has the same unbounded-nav-pushes-the-footer-off-screen shape and would need the identical fix copy-pasted. | `docs/plans/2026-09-14-pending-updates-design.md` §3.5 | Scoped to ScadaBridge for this change; upstreaming into the shared kit is a separate cross-repo release, not a one-app CSS tweak. | A second kit consumer hits the same long-nav clipping and copies the fix instead of consuming it from the kit. |
| 124 | **No template-graph-watermark short-circuit on the pending-updates fleet flatten** *(Low)*. `PendingUpdatesEvaluator.EvaluateAsync` re-flattens every deployed instance through `DeploymentService.GetInstanceStalenessAsync` on every cache-TTL expiry (`PendingUpdatesCacheTtl`, default 60 s) while any Deployer/Admin circuit is open, even when nothing in the template/instance/artifact graph has changed since the last evaluation. | `docs/plans/2026-09-14-pending-updates-design.md` §3.2 ("Not in scope") | The single-flight TTL cache already bounds the cost to one fleet flatten per TTL fleet-wide (not per viewer); a watermark short-circuit is a further optimization, not a correctness need, and needs a cheap "has anything changed" signal that does not exist yet. | The fleet flatten's cost (instance count x template depth) becomes measurable against `PendingUpdatesCacheTtl` on a large deployment. |
| 125 | **Pending-updates counts have no Health-dashboard tile or CLI surface** *(Low)*. The aggregate (`PendingUpdatesSnapshot.StaleInstanceCount` / `StaleArtifactSiteCount`) is consumed only by the Central UI Sites page and rail indicator; there is no Health dashboard KPI tile and no `deploy`/`site` CLI command surfacing it — `site list` shows only the raw `artifactRevisionHash`/`artifactsDeployedAt` fields on each site, not whether they are current. | `docs/plans/2026-09-14-pending-updates-design.md` §3.2 ("Not in scope") | Out of scope for the UI-focused ask; the CLI/Health surfaces are a separate consumer of the same `IPendingUpdatesCache`. | An operator wants the pending-updates counts without opening the Central UI (headless monitoring, CLI scripting). |
## Resolved (verified against the code 2026-07-10)
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.
@@ -0,0 +1,127 @@
# 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.
+217
View File
@@ -0,0 +1,217 @@
# Pending Updates Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Show on the Sites page whether each site's deployed artifacts are out of date, add one rail-footer indicator summarising pending updates (stale instances + stale-artifact sites), and put the rail nav in a scroll pane.
**Architecture:** Two nullable columns on `Site` record the revision hash of the last successful artifact deploy (stamped by `ArtifactDeploymentService`); a new `PendingUpdatesEvaluator` (DeploymentManager) computes one `PendingUpdatesSnapshot` per fleet (artifact hash comparison per site + one shared-session instance-staleness batch), cached process-wide by a singleton `PendingUpdatesCache` (CentralUI, `SingleFlightMemo`) that the Sites page and the rail indicator both read. The scroll pane is an app-level wrapper + CSS; the theme kit is untouched.
**Tech Stack:** .NET 10, EF Core (SQL Server migration), Blazor Server + Bootstrap 5 + bUnit, NSubstitute, Playwright, bash + `scadabridge` CLI on the docker rig.
**Design:** `docs/plans/2026-09-14-pending-updates-design.md`.
**Conventions for every task** (from `CLAUDE.md` + memory):
- Work in the dedicated worktree `/Users/dohertj2/Desktop/ScadaBridge-pending-updates` (Task 0). Use absolute paths in every command — Bash cwd drifts.
- Run **targeted** tests per task (`dotnet test <test proj> --filter …`) and build the affected projects; the full solution build/test runs only in Task 11.
- Never `git stash` (shared across worktrees). Commit with pathspecs. End every commit message with
`Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB`.
- Every public member needs an XML doc comment (`checkdocs` enforces it).
- Never `await` an audit write on a script path; never block on a Task from a Blazor lifecycle/event path.
- Bootstrap `.badge` markup for badges (matches Topology); no third-party component libraries.
---
### Task 0: Worktree
**Classification:** trivial · **Estimated implement time:** ~2 min · **Parallelizable with:** none
```bash
cd /Users/dohertj2/Desktop/ScadaBridge
git worktree add -b feat/pending-updates ../ScadaBridge-pending-updates main
cd /Users/dohertj2/Desktop/ScadaBridge-pending-updates && dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | tail -3
```
Expected: `0 Error(s)`.
---
### Task 1: Site artifact revision columns, migration, repository projection, Transport guard
**Classification:** high-risk (schema + data contract) · **Estimated implement time:** ~5 min · **Parallelizable with:** Task 8
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Sites/Site.cs` — add `public string? ArtifactRevisionHash { get; set; }` and `public DateTimeOffset? ArtifactsDeployedAt { get; set; }` with XML docs ("revision hash of the per-site artifact set last deployed successfully; null = never deployed; NOT transported").
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SiteConfiguration.cs` — `ArtifactRevisionHash` `HasMaxLength(100)`.
- Create: migration `AddSiteArtifactRevision` in `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/` — **build first, then** `dotnet ef migrations add AddSiteArtifactRevision --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase --startup-project src/ZB.MOM.WW.ScadaBridge.Host` (check how `AddAlarmMesReportable` was generated: `git log -1 --stat 4cfa645b^..` / the previous plan; NO `--no-build`). Verify the migration is non-empty (two `AddColumn`).
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IDeploymentManagerRepository.cs` — add `Task<IReadOnlyDictionary<int, string>> GetDeployedRevisionHashesAsync(CancellationToken cancellationToken = default);` ("InstanceId → RevisionHash for every DeployedConfigSnapshot; a projection — never materialises ConfigurationJson").
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs` — implement with `.AsNoTracking().Select(s => new { s.InstanceId, s.RevisionHash })`.
- Modify: Transport round-trip guard exclusions (find with `grep -rn "GrpcNodeBAddress\|RoundTrip" tests/ZB.MOM.WW.ScadaBridge.Transport.Tests tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests`) — add the two new `Site` properties to the *deliberately-not-transported* list with the reason "environment-specific deploy state, like Instance.State".
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/` — a repository test for `GetDeployedRevisionHashesAsync` (SQLite in-memory pattern used by neighbouring tests): two snapshots → dictionary of two; empty → empty.
**Steps:** write the failing repo test → implement → migration → run `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter "FullyQualifiedName~DeployedRevisionHashes"` and the Transport guard tests (`--filter "FullyQualifiedName~RoundTrip"`) → commit `feat(sites): record the artifact revision last deployed to a site`.
---
### Task 2: ArtifactRevisionHashService + stamping on successful deploy
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** Task 3, Task 8
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs` — `public sealed class ArtifactRevisionHashService { public string ComputeHash(DeployArtifactsCommand command) }`. Canonical projection: private `Hashable*` records with **alphabetically declared** properties, each artifact list `OrderBy(Name, StringComparer.Ordinal)`, `DeploymentId` and the timestamp excluded, `JsonNamingPolicy.CamelCase`, `WhenWritingNull`, SHA-256 hex (lower-case, like `RevisionHashService`). Cover every field of `SharedScriptArtifact`, `ExternalSystemArtifact`, `DatabaseConnectionArtifact`, `DataConnectionArtifact` (read the record definitions under `Commons/Messages/Artifacts/`).
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs`:
- inject `ArtifactRevisionHashService`;
- `DeployCoreAsync`: after `Task.WhenAll`, for each site whose result `Success`, set `site.ArtifactRevisionHash = hash(siteCommands[site.Id])`, `site.ArtifactsDeployedAt = DateTimeOffset.UtcNow`, then persist through `ISiteRepository` (use its existing update + save methods — check the interface; the `Site` entities came from `_siteRepo.GetAllSitesAsync` and are tracked, so an `UpdateSiteAsync`/`SaveChangesAsync` pair is enough). Failed sites are NOT stamped.
- `RetryForSiteAsync`: same stamp on success (load the site by id).
- add `public async Task<IReadOnlyDictionary<int, string>> ComputeCurrentArtifactHashesAsync(IReadOnlyList<Site> sites, CancellationToken ct)` — one `FetchGlobalArtifactsAsync`, then per site `BuildDeployArtifactsCommandAsync(site.Id, globals, ct, deploymentId: "current")` → hash. (`deploymentId` is excluded from the hash, so any constant is fine.)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs` — register `ArtifactRevisionHashService` singleton.
- Modify: every test that constructs `ArtifactDeploymentService` directly (grep `new ArtifactDeploymentService(` under `tests/` — at least `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs` and the DeploymentManager tests) — pass `new ArtifactRevisionHashService()`.
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs` — same input twice → same hash; lists reordered → same hash; one field changed (per artifact class) → different hash; different `DeploymentId`/timestamp → same hash; `HashableRecords_PropertiesDeclaredAlphabetically` (reflection, mirror `RevisionHashServiceTests`). `ArtifactDeploymentServiceTests` — success stamps hash + timestamp; failure leaves the previous stamp; `ComputeCurrentArtifactHashesAsync` equals the hash stamped by a deploy of the same definitions.
**Steps:** failing tests → implement → `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter "FullyQualifiedName~ArtifactRevisionHash|FullyQualifiedName~ArtifactDeploymentService"` → commit `feat(deploy): hash the per-site artifact set and stamp it on a successful artifact deploy`.
---
### Task 3: DeploymentService.GetInstanceStalenessAsync (shared session, mismatch-only normalisation)
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** Task 2, Task 8
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs`:
- extract from `GetDeploymentComparisonAsync` a private `string ComputeDeployedHash(DeployedConfigSnapshot snapshot, out FlattenedConfiguration? normalized)` that applies the I-1 List normalisation and recomputes the hash (returns `snapshot.RevisionHash` when the JSON cannot be deserialised) — `GetDeploymentComparisonAsync` must keep behaving byte-for-byte (its existing tests pin it).
- add `public async Task<IReadOnlyDictionary<int, bool?>> GetInstanceStalenessAsync(IReadOnlyCollection<int> instanceIds, CancellationToken cancellationToken = default)`: read `GetDeployedRevisionHashesAsync()` once; `var session = _flatteningPipeline.CreateSession();` for each id with a snapshot hash: `FlattenAndValidateAsync(id, ct, validateScripts:false, session)`; failure → `null`; equal hash → `false`; mismatch → read the full snapshot (`GetDeployedSnapshotByInstanceIdAsync`), `ComputeDeployedHash`, compare again → `true`/`false`. Ids without a snapshot are omitted from the result. Sequential (the repository DbContext is not thread-safe); add a doc comment saying so.
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs` (or extend the existing DeploymentService test class; check how it is constructed — `IFlatteningPipeline` is substitutable): shared session passed to every flatten call (`Received` with the same `FlattenSession` instance, `CreateSession` called once); equal → false; mismatch that normalises equal → false; real mismatch → true; flatten failure → null; no snapshot → absent.
**Steps:** failing tests → implement → `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter "FullyQualifiedName~Staleness|FullyQualifiedName~DeploymentComparison"` → commit `feat(deploy): batch instance staleness over one flatten session`.
---
### Task 4: PendingUpdatesEvaluator + snapshot types + options
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** none (needs 2 + 3)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesSnapshot.cs` — `public enum ArtifactDeploymentState { NeverDeployed, Current, OutOfDate }`; `public sealed record SitePendingUpdates(int SiteId, string SiteIdentifier, string SiteName, ArtifactDeploymentState ArtifactState, DateTimeOffset? ArtifactsDeployedAt, int DeployedInstanceCount, int StaleInstanceCount, int UnknownInstanceCount)`; `public sealed record PendingUpdatesSnapshot(DateTimeOffset ComputedAt, IReadOnlyList<SitePendingUpdates> Sites)` with computed `StaleInstanceCount`, `StaleArtifactSiteCount`, `HasPendingUpdates`, and `ForSites(IReadOnlySet<int> siteIds)` returning a filtered copy (for site-scoped users).
- Create: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/IPendingUpdatesEvaluator.cs` + `PendingUpdatesEvaluator.cs` (scoped): `Task<PendingUpdatesSnapshot> EvaluateAsync(CancellationToken)`. Reads all sites (`ISiteRepository.GetAllSitesAsync`), all instances (`ITemplateEngineRepository.GetAllInstancesAsync`), `ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync(sites)`, `DeploymentService.GetInstanceStalenessAsync(ids of instances with State != NotDeployed)`. Artifact state: null stored hash → NeverDeployed; equal → Current; else OutOfDate. Per site: deployed = instances of that site present in the staleness result; stale = `true`; unknown = `null`. A per-site artifact-hash failure must not sink the whole snapshot — log and mark that site `OutOfDate`? **No:** mark it *unknown* is not a state we have; keep it simple: let the exception propagate (the cache discards faulted flights and the UI shows *unavailable*).
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptions.cs` — `public TimeSpan PendingUpdatesCacheTtl { get; set; } = TimeSpan.FromSeconds(60);` + validation `> TimeSpan.Zero` wherever the other TimeSpans are validated (find the validator; §1.5 eager validation).
- Modify: `ServiceCollectionExtensions.cs` (DeploymentManager) — `services.AddScoped<IPendingUpdatesEvaluator, PendingUpdatesEvaluator>()`.
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs` — build with substitutes for the repositories and (if `DeploymentService`/`ArtifactDeploymentService` are not substitutable) small interface seams: prefer adding `IArtifactRevisionSource` / reuse the real services with substituted repos as `TopologyPageTests` does. Cases: never-deployed site; current; out-of-date; instance counts (2 deployed, 1 stale, 1 unknown); `ForSites` filter; options validation rejects zero TTL.
**Steps:** failing tests → implement → `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter "FullyQualifiedName~PendingUpdates"` → commit `feat(deploy): PendingUpdatesEvaluator — one fleet snapshot of stale instances and stale-artifact sites`.
---
### Task 5: PendingUpdatesCache (CentralUI singleton)
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** none (needs 4)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IPendingUpdatesCache.cs` — `Task<PendingUpdatesSnapshot> GetAsync(bool forceRefresh = false, CancellationToken cancellationToken = default);`
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/PendingUpdatesCache.cs` — mirrors `KpiSnapshotCache`: ctor `(IServiceScopeFactory, IOptions<DeploymentManagerOptions>)`, one `SingleFlightMemo<PendingUpdatesSnapshot>(options.Value.PendingUpdatesCacheTtl)`, factory opens a scope and resolves `IPendingUpdatesEvaluator`.
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs` — `services.AddSingleton<IPendingUpdatesCache, PendingUpdatesCache>();` next to `IKpiSnapshotCache`.
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs` — two concurrent `GetAsync` → evaluator invoked once; `forceRefresh:true` after completion → invoked again; a faulted evaluation is not cached (next call re-invokes).
**Steps:** failing tests → implement → `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~PendingUpdatesCache"` → commit `feat(central-ui): process-wide single-flight cache for the pending-updates snapshot`.
---
### Task 6: Rail indicator component
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** Task 7
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/PendingUpdatesIndicator.razor` (+ `.razor.cs` if the file grows). Injects `IPendingUpdatesCache`, `SiteScopeService`, `AuthenticationStateProvider`, `ILogger<>`. Renders nothing unless the user has the `Deployer` or `Administrator` role claim (`JwtTokenService.RoleClaimType`, like `NavMenu`'s Notifications block). Markup (all inside `<div class="rail-updates" title="as of HH:mm UTC">`):
- loading: `<span class="rail-updates-muted"><i class="bi bi-arrow-repeat"></i> Checking for updates…</span>`
- up to date: `<span class="rail-updates-muted"><i class="bi bi-check2-circle"></i> Up to date</span>`
- pending: `<span class="rail-updates-head"><i class="bi bi-exclamation-triangle"></i> Pending updates <span class="badge bg-warning text-dark">N</span></span>` (N = stale instances + stale-artifact sites) then `<span class="rail-updates-detail">` with `X instance(s)` (an `<a href="/deployment/topology">` when Deployer, else text) · `Y site(s)` (an `<a href="/admin/sites">` when Administrator, else text); omit a zero part.
- error: `<span class="rail-updates-muted" title="@error"><i class="bi bi-question-circle"></i> Updates unavailable</span>`.
- Site-scoped user: `snapshot.ForSites(await SiteScope.PermittedSiteIdsAsync())` unless `IsSystemWideAsync()`.
- Poll: `System.Threading.Timer` every 30 s through a `PollGate`, first load in `OnInitializedAsync`; `IDisposable` disposes the timer and sets `_disposed`; every re-render via `InvokeAsync(StateHasChanged)` guarded by `_disposed`; catch `ObjectDisposedException`/`InvalidOperationException` from a torn-down circuit.
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor` — inside `<RailFooter>` `<Authorized>`, place `<PendingUpdatesIndicator />` immediately **above** `<span class="rail-user">`.
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css` — append a `/* ── Rail pending-updates indicator ── */` block: `.rail-updates { padding: 0 0.6rem 0.5rem; font-size: 0.8rem; display:flex; flex-direction:column; gap:0.15rem; }`, `.rail-updates-muted { color: var(--ink-faint); }`, `.rail-updates-head { font-weight: 600; color: var(--ink-soft); display:flex; align-items:center; gap:0.35rem; }`, `.rail-updates-detail { color: var(--ink-faint); }`, `.rail-updates a { color: inherit; text-decoration: underline dotted; }`. Use tokens only (dark mode follows).
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs` — bUnit with `Substitute.For<IPendingUpdatesCache>()`, `TestAuthStateProvider` (copy `NavMenuTests`' claims helper), a `SiteScopeService` over that provider: hidden for Viewer; "Up to date" for empty snapshot; "Pending updates" + `3` + links for Deployer+Admin; Deployer-only renders the site count as text (no `/admin/sites` href); faulted cache → "Updates unavailable"; scoped user sees filtered counts. Use `WaitForAssertion`.
**Steps:** failing tests → implement → `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~PendingUpdatesIndicator"` → commit `feat(central-ui): rail-footer pending-updates indicator`.
---
### Task 7: Sites page artifact badges, instance line, re-check
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor`:
- `@inject IPendingUpdatesCache PendingUpdates`; fields `PendingUpdatesSnapshot? _updates; string? _updatesError; bool _recheckingUpdates;`.
- `LoadDataAsync` loads sites/connections as today, then (not blocking the card render) `await LoadUpdatesAsync(force:false)`; `LoadUpdatesAsync` catches, sets `_updatesError`, logs a Warning.
- Header: after the title, a small muted caption `Updates as of HH:mm:ss UTC` when `_updates` is set, and a `btn btn-outline-secondary btn-sm` **Re-check** button (spinner + disabled while `_recheckingUpdates`) calling `LoadUpdatesAsync(force:true)`.
- Card: next to `<code>@site.SiteIdentifier</code>` render `ArtifactBadge(site.Id)`: `_updates is null && _updatesError is null` → `<span class="badge bg-light text-dark ms-2">Artifacts: checking…</span>`; error → `<span class="badge bg-light text-dark ms-2" title="@_updatesError">Artifacts: unavailable</span>`; else by state: `Current` → `bg-light text-dark`, `OutOfDate` → `bg-warning text-dark` with `<i class="bi bi-exclamation-triangle me-1"></i>` and `title="Artifacts deployed <ArtifactsDeployedAt:u>"`, `NeverDeployed` → `bg-secondary`. All carry `aria-label="Artifacts: <state>"`. Text: `Artifacts current` / `Artifacts out of date` / `Artifacts never deployed`.
- Under the description: when the site's `DeployedInstanceCount > 0`, `<div class="small text-muted mb-2">@stale of @deployed instances out of date — <a href="/deployment/topology">Topology</a></div>` (say `all current` when stale == 0 and unknown == 0; append `, N unknown` when unknown > 0).
- `DeployArtifacts` / `DeployArtifactsToAllSites`: on success call `LoadUpdatesAsync(force:true)`.
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs` — register `Substitute.For<IPendingUpdatesCache>()` (and `ArtifactRevisionHashService` in the `ArtifactDeploymentService` ctor if Task 2 added it); new tests: badge text per state; instance line; Re-check calls `GetAsync(true)`; cache fault → "unavailable" and the site list still renders.
**Steps:** failing tests → implement → `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~SitesPage"` → commit `feat(central-ui): Sites page shows whether a site's deployed artifacts are current`.
---
### Task 8: Scrollable rail nav
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** Task 1, 2, 3 (must land BEFORE Task 6 — both append to site.css)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor` — wrap everything (the Dashboard item and the `<AuthorizeView>`) in `<div class="rail-scroll">…</div>`; extend the header comment (scroll pane rationale, kit untouched).
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css` — append:
```css
/* ── Rail nav scroll pane ────────────────────────────────────────────────
ThemeShell's nav.side-rail is a flex column inside the sticky 100vh
#theme-rail (lg+), but it never fills that height and nothing inside it
scrolls, so a long nav grows past the viewport and carries the footer
with it. Fill the rail and let the nav items scroll while the brand bar
and .rail-foot stay put. Below lg the rail is inline and scrolls with the
page, so the rule is scoped to lg+. The kit is untouched (deferred-work
register: upstream into ZB.MOM.WW.Theme). */
@media (min-width: 992px) {
#theme-rail > .side-rail { height: 100%; }
.rail-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
}
```
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs` — one test: markup contains `class="rail-scroll"` and the `rail-section` details sit inside it.
**Steps:** test → change → `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~NavMenuTests"` → commit `fix(central-ui): rail nav scrolls in its own pane so the footer stays pinned`.
---
### Task 9: Documentation
**Classification:** small · **Estimated implement time:** ~4 min · **Parallelizable with:** Task 10
**Files:**
- `docs/requirements/Component-CentralUI.md`: *Site & Data Connection Management* — artifact badge, instance line, Re-check; *System-Wide Artifact Deployment* — "Track per-site deployment status" now includes the revision stamp + Current/Out of date/Never deployed; new bullet under *Technology* (or near the nav description) for the rail **Pending updates** indicator (roles, 30 s poll over the 60 s cache, scope filter) and the rail scroll pane.
- `docs/requirements/Component-DeploymentManager.md`: *Deployable Artifacts* — `ArtifactRevisionHashService`, stamping rule (success only), `ComputeCurrentArtifactHashesAsync`, `GetInstanceStalenessAsync` (shared session, mismatch-only normalisation), `PendingUpdatesEvaluator`, `PendingUpdatesCacheTtl` option, not transported.
- `docs/requirements/Component-ConfigurationDatabase.md`: migration list entry `AddSiteArtifactRevision` (mirror how `AddAlarmMesReportable` is listed).
- `CLAUDE.md` (repo root): one bullet under *Templates & Deployment* after the bulk-deploy bullet: the site artifact revision + pending-updates indicator (short; link the design doc).
- `docs/plans/2026-07-08-deferred-work-register.md`: rows 123 (upstream the rail scroll pane into `ZB.MOM.WW.Theme`), 124 (watermark short-circuit so an unchanged fleet costs no re-flatten), 125 (expose the pending-updates counts on the Health dashboard / CLI).
- `src/ZB.MOM.WW.ScadaBridge.CLI/README.md`: note that `site list --detail` now shows `artifactRevisionHash` / `artifactsDeployedAt` (only if the projection shows them; otherwise skip).
Commit `docs: site artifact revision, pending-updates indicator, rail scroll pane`.
---
### Task 10: Rig — rebuild, verify script, Playwright smoke
**Classification:** standard · **Estimated implement time:** ~5 min (+ rig build time) · **Parallelizable with:** Task 9
**Files:**
- Create: `docker/verify-pending-updates.sh` — uses the rig CLI (see `.claude/skills/scadabridge-cluster-ops/SKILL.md` and `docker/verify-mes-alarm-demo.sh` for the conventions: management URL, `scadabridge` CLI invocation, PASS/FAIL counters). Steps: (A) `site list` → every site has `artifactRevisionHash` (null allowed on a fresh DB); (B) deploy artifacts to all sites via CLI (`artifact deploy` — find the command in the CLI README) → every site's hash non-null and `artifactsDeployedAt` recent; (C) create/update a throwaway shared script (`shared-script …`) → GET the Central UI `/admin/sites` is auth-gated, so instead verify via CLI: `site list` hashes unchanged **and** a fresh artifact deploy changes the hash (proves the hash tracks content); (D) delete the throwaway script, redeploy → hash returns to the value from (B). Print PASS/FAIL summary, exit non-zero on FAIL.
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs` — copy the fixture-login pattern from `FlowDebuggerTests`: after login the rail contains either "Up to date" or "Pending updates"; `/admin/sites` renders an `aria-label^="Artifacts:"` badge per card and a Re-check button.
- Run: `bash docker/deploy.sh` (from the worktree — check the script builds from its own checkout), wait for `/health/ready`, `bash docker/verify-pending-updates.sh`, then the Playwright test (`dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~PendingUpdates"`). Also load `/admin/sites` and the rail once through the browser tools and confirm the indicator sits above the user name and the nav scrolls at a short viewport (record what was seen in the commit message).
Commit `test(rig): verify-pending-updates.sh + Playwright smoke for the indicator and Sites badge`.
---
### Task 11: Finish — sweep, review, merge, memory
**Classification:** standard · **Estimated implement time:** ~5 min (+ test time) · **Parallelizable with:** none
1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 warnings/0 errors; `dotnet test ZB.MOM.WW.ScadaBridge.slnx` (skip Playwright unless the rig is up) → all green; note any pre-existing flake by name.
2. Final code review of `git diff main..HEAD` (subagent).
3. Fast-forward merge into `main` in the primary checkout (`git -C /Users/dohertj2/Desktop/ScadaBridge merge --ff-only feat/pending-updates`), remove the worktree, delete the branch.
4. Update the plan's `.tasks.json`, memory (`pending-updates-state.md` + `MEMORY.md` index), and the ScadaBridge row of `../scadaproj/CLAUDE.md` (tip changed; NOT pushed unless asked).
@@ -0,0 +1,104 @@
{
"planPath": "docs/plans/2026-09-14-pending-updates.md",
"tasks": [
{
"id": 0,
"subject": "Task 0: Worktree",
"status": "completed"
},
{
"id": 1,
"subject": "Task 1: Site artifact revision columns, migration, repository projection, Transport guard",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 2,
"subject": "Task 2: ArtifactRevisionHashService + stamping on successful deploy",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Task 3: DeploymentService.GetInstanceStalenessAsync",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 4,
"subject": "Task 4: PendingUpdatesEvaluator + snapshot types + options",
"status": "completed",
"blockedBy": [
2,
3
]
},
{
"id": 5,
"subject": "Task 5: PendingUpdatesCache (CentralUI singleton)",
"status": "completed",
"blockedBy": [
4
]
},
{
"id": 6,
"subject": "Task 6: Rail indicator component",
"status": "completed",
"blockedBy": [
5,
8
]
},
{
"id": 7,
"subject": "Task 7: Sites page artifact badges, instance line, re-check",
"status": "completed",
"blockedBy": [
5
]
},
{
"id": 8,
"subject": "Task 8: Scrollable rail nav",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 9,
"subject": "Task 9: Documentation",
"status": "completed",
"blockedBy": [
6,
7
]
},
{
"id": 10,
"subject": "Task 10: Rig \u2014 rebuild, verify script, Playwright smoke",
"status": "completed",
"blockedBy": [
6,
7
]
},
{
"id": 11,
"subject": "Task 11: Finish \u2014 sweep, review, merge, memory",
"status": "completed",
"blockedBy": [
9,
10
]
}
],
"lastUpdated": "2026-09-14T18:38:32Z"
}
+5 -1
View File
@@ -19,6 +19,8 @@ Central cluster only. Sites have no user interface.
- **The decision.** *Approve* is `Caution` (amber): approving executes a real write to live plant equipment, so it is not a benign primary action, but it is not destroying anything either. *Reject* **keeps** `Destructive` (red): it is terminal and irreversible for the request — the Operator must resubmit — and holding it red is what keeps the two opposite decisions visually distinct. Dropping *Reject* to a neutral button was considered and rejected: it would simply invert the bias, making declining look like the safe default.
- Colour is never the only signal — the button always carries its verb (see the label contract above), so the mapping is an accessibility affordance, not the meaning.
- **`DialogSeverity` is an additive overload**, not a replacement: the boolean form remains the signature for the ~34 existing call sites, which are genuinely two-state. Adding a fifth optional parameter to the boolean form instead would have silently un-matched every existing four-argument NSubstitute stub — the row-28 gotcha, where a stub that stops matching returns a null `Task` and the caller NREs with no compile error. Reach for the overload only when `Caution` is what you mean.
- **Pending updates rail indicator (2026-09-14, `PendingUpdatesIndicator`, design `docs/plans/2026-09-14-pending-updates-design.md`).** A block in the rail footer, placed above the user name (`MainLayout`'s `RailFooter`, `.rail-user`), summarising the fleet-wide `PendingUpdatesSnapshot` from Component-DeploymentManager.md's *Pending-updates aggregate*. Rendered only for a user holding **Deployer or Administrator** — the two roles that can act on it — and hidden for everyone else. States: *Checking for updates…* (first load), *Up to date* (muted, check icon), **Pending updates** with a warning count pill and a breakdown line (`N instances`, linking to `/deployment/topology` for a Deployer; `M sites`, linking to `/admin/sites` for an Administrator, plain text otherwise), or *Updates unavailable* (error styling, tooltip carries the failure message) on a faulted evaluation. A tooltip on the block reads *as of HH:mm UTC*. It polls `IPendingUpdatesCache.GetAsync()` every 30 s through `PollGate` — cheap, since the cache's own 60 s TTL means the fleet is actually re-flattened at most once per TTL fleet-wide, no matter how many circuits (or how often each one) poll it — disposes its timer with the circuit, and renders through `InvokeAsync`. A **site-scoped** user sees counts narrowed to their permitted sites only (`PendingUpdatesSnapshot.ForSites`, the same scope `SiteScopeService` applies to Topology), so the indicator and the Sites page never disagree about which sites a given operator can see.
- **Rail nav scroll pane (2026-09-14).** `NavMenu.razor` wraps its items in a `<div class="rail-scroll">`; `site.css` (≥ lg only) fills the sticky `100vh` `#theme-rail` (`#theme-rail > .side-rail { height: 100% }`) and makes `.rail-scroll` the flexible, independently-scrolling region (`flex: 1 1 auto; min-height: 0; overflow-y: auto`), so a long expanded nav scrolls under the brand bar instead of pushing the rail footer (and now the pending-updates indicator) off-screen. Below lg the rail is inline and scrolls with the page, unchanged. The `ZB.MOM.WW.Theme` kit itself is untouched — this is app-local CSS/markup layered on top of it (deferred-work register row 123 proposes upstreaming it for the kit's other consumers).
## Review hardening (2026-09-09)
@@ -107,6 +109,8 @@ Central cluster only. Sites have no user interface.
- **Move data connection between sites** (M9/T24): a **Move…** action on a data connection opens `MoveDataConnectionDialog`. The underlying `MoveDataConnectionCommand` guards against: target site does not exist, name collision at the target, any `InstanceConnectionBinding` referencing the connection, and any `InstanceNativeAlarmSourceOverride.ConnectionNameOverride` reference at the source site's templates. Blocked connections display the blocking reason.
- **Server certificate management** (`/design/connections/{id}/certificates`, Admin role): a per-connection page that lists the contents of the site's OPC UA trusted-peer and rejected certificate stores (Subject / Issuer / Thumbprint / validity / Trusted-or-Rejected status) with a **Remove** action. The page makes clear the store is **node-wide for the site** (shared by every site node), not per data connection — trusting or removing a certificate affects all OPC UA connections at that site. Trust and Remove are central commands relayed to **both** site nodes so the node-local PKI stores stay consistent across failover (see Component-SiteRuntime.md, Component-DataConnectionLayer.md).
- The site detail page exposes a new **"Audit feed"** tab that hosts the Audit Log page pre-filtered to `Site = <site>` — an in-context view of every operational audit event for that site.
- **Artifacts badge (2026-09-14, design `docs/plans/2026-09-14-pending-updates-design.md`).** Each site card carries an **Artifacts** badge next to the identifier, one of three states: `Current` (light — the site's `Site.ArtifactRevisionHash` matches what a deploy would send today), `Out of date` (warning, `bi-exclamation-triangle`, tooltip = the last successful deploy's UTC timestamp), or `Never deployed` (secondary — the site has no stamp at all, including every site immediately after the migration that added the columns, until one artifact deploy). Below the badge a muted `N of M instances out of date` line links to `/deployment/topology` whenever the site has any deployed instances, since the same fleet snapshot already carries the count.
- The page header gains a **Re-check** button (forces a fresh fleet evaluation, disabled while running) and an **"Updates as of HH:mm:ss UTC"** caption next to the page title. The snapshot loads **after** the site list, never blocking it — cards render immediately with a *checking…* placeholder for the badge/line, and a snapshot failure renders **unavailable** on the badge rather than hiding it or failing the page. A successful **Deploy Artifacts** (single-site or all-sites) forces a refresh of the snapshot so the badge flips to `Current` immediately rather than waiting out the cache TTL.
### Schema Library (Design Role)
- A **Schema Library** page (M9/T32, under the Design nav group) provides CRUD for `SharedSchema` entries — named, reusable JSON Schema definitions. Each entry has a name (globally unique) and a JSON Schema body.
@@ -184,7 +188,7 @@ Central cluster only. Sites have no user interface.
- **Per-site deployment**: A "Deploy Artifacts" button on the Sites admin page allows deploying all artifacts to an individual site.
- **Deploy all**: A bulk action deploys artifacts to all sites at once.
- This is a **separate action** from instance deployment — system-wide artifacts are not automatically pushed when definitions change.
- Track per-site deployment status.
- **Track per-site deployment status**, backed by `Site.ArtifactRevisionHash` / `ArtifactsDeployedAt` (Component-DeploymentManager.md → *Per-site artifact revision*), stamped **only on a successful deploy to that site**. A failed site keeps its previous stamp (or none) and keeps reading out of date on the Sites page; after an upgrade that ships the stamp columns, every site reads *Never deployed* until one artifact deploy runs — see the Artifacts badge above.
### Debug View (Deployment Role)
- Select a deployed instance and open a live debug view — the instance picked through the same shared **`InstancePicker`** combobox as the Flow Debugger, with an `ItemLabel` suffix of `(State)` on each row.
@@ -359,6 +359,7 @@ A CI grep guard (`AuditLogAppendOnlyGuardTests`) fails the build on any `UPDATE`
- New tables are introduced as their own migration — for example, the `Notifications` table for the Notification Outbox ships as a dedicated EF Core migration that creates the table, its `Type`/`Status` value conversions, and its dispatcher and KPI indexes.
- The native alarm source tables ship in a dedicated `AddNativeAlarmSources` migration, parallel in shape to the existing `AddInstanceAlarmOverrides` migration: it creates `NativeAlarmSources` and `InstanceNativeAlarmSourceOverrides` with their columns, the `TemplateId` → `Templates` and `InstanceId` → `Instances` cascade-delete foreign keys, and the `(TemplateId, Name)` / `(InstanceId, SourceCanonicalName)` unique indexes.
- **`20260911135501_AddAlarmMesReportable`** (2026-09-11) adds `TemplateAlarms.IsMesReportable` (`bit`, default `0`) and `InstanceAlarmOverrides.IsMesReportableOverride` (`bit` NULL, null = inherit the template value). No new tables, no index changes — see `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md`.
- **`20260914173147_AddSiteArtifactRevision`** (2026-09-14) adds `Sites.ArtifactRevisionHash` (`nvarchar(100)` NULL) and `Sites.ArtifactsDeployedAt` (`datetimeoffset` NULL) — both null on an upgraded row until that site's next successful artifact deploy stamps them. No new tables, no index changes — see `docs/plans/2026-09-14-pending-updates-design.md`.
- The initial `AuditLog` migration creates the monthly partition function `pf_AuditLog_Month` and partition scheme `ps_AuditLog_Month`, then creates the `AuditLog` table aligned to that scheme on `OccurredAtUtc`, along with the indexes listed under Database Schema. The migration also creates the `scadabridge_audit_writer` and `scadabridge_audit_purger` DB roles with the grants described in Database Roles. The ongoing **partition-maintenance job** that rolls the scheme forward each month (creating the next month's partition ahead of time) and switches out expired partitions is owned by the **Audit Log component** (`AuditLogPurgeActor` and its monthly roll-forward step), not by the Configuration Database component — this component is responsible only for the initial schema, roles, and any EF migrations against the table going forward.
### Development Environment
@@ -281,6 +281,56 @@ Component-NotificationService.md). Notification delivery happens on the central
cluster, so no notification artifact or SMTP credential is ever distributed to
sites.
### Per-site artifact revision (2026-09-14)
`Site` carries two nullable columns — `ArtifactRevisionHash` (`string?`, max 100)
and `ArtifactsDeployedAt` (`DateTimeOffset?`) — recording the artifact set last
successfully deployed to that site (migration `AddSiteArtifactRevision`; see
Component-ConfigurationDatabase.md). Both are `null` on a site that has never had
artifacts deployed, including every site immediately after an upgrade that ships
this migration — a redeploy landed the columns, it did not retroactively stamp
them, so the Sites page reads every site **Never deployed** until one artifact
deploy runs. **The two columns are deliberately not transported**: like an
imported instance landing `NotDeployed`, they are environment-specific runtime
state and the Transport round-trip guard excludes them by name. An import that
changes a site's data connections therefore correctly makes the site read out of
date until artifacts are redeployed there.
`ArtifactRevisionHashService` computes a deterministic SHA-256 over a canonical
projection of a per-site `DeployArtifactsCommand` — every artifact class sorted by
`Name` (ordinal), `DeploymentId`/timestamp excluded (so two deploys of identical
definitions hash identically), an external system's `MethodDefinitionsJson`
re-canonicalised with its methods sorted by name, and `TimeSpan` retry intervals
carried as ticks rather than a formatted string. Internal `Hashable*` records MUST
declare their properties in alphabetical (ordinal) order — `System.Text.Json`
serializes in declaration order and does not sort — pinned by a
`HashableRecords_PropertiesDeclaredAlphabetically` test, mirroring
`RevisionHashService`'s own determinism contract. Because a data connection's
configuration is plaintext in memory (the ciphertext is an EF value converter
applied only at rest), a no-op re-save that merely re-encrypts does not change the
hash.
`ArtifactDeploymentService` stamps `ArtifactRevisionHash` + `ArtifactsDeployedAt`
on the `Site` row **only for a site whose deploy succeeded**, on both the
all-sites/single-site sweep (`DeployCoreAsync`) and the per-site retry
(`RetryForSiteAsync`) paths. A failed site keeps its previous stamp (or none), so
it keeps reading out of date. **Ordering is deliberate**: the
`SystemArtifactDeploymentRecord` audit row is persisted first, and the stamp is
applied — and saved — afterward, inside its own try/catch. Stamping first would
let a stamp-save failure (concurrency conflict, constraint violation) escape
uncaught through the record's own `SaveChangesAsync` and turn a wholly successful
deploy into a reported failure; running the record's save first keeps a stamp
failure best-effort — logged at Warning, deploy still reports success, the site
simply keeps reading out of date until the next deploy.
`ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync(sites)` returns
`siteId → hash` for what a deploy would send *right now* — the global artifact
queries are hoisted once for the whole batch, mirroring `DeployCoreAsync`'s own
hoist. A failing per-site data-connection query **aborts the whole batch by
design**: a partial map would silently report the unqueried sites as up to date,
so the pending-updates cache instead discards the faulted flight and the UI shows
the aggregate as unavailable.
## Site-Side Apply Atomicity
Applying a deployment at the site is **all-or-nothing per instance**:
@@ -293,7 +343,67 @@ Applying a deployment at the site is **all-or-nothing per instance**:
- Cross-site version skew for artifacts (shared scripts, external system definitions, data connection definitions, etc.) is **supported** — sites can temporarily run different artifact versions after a partial deployment.
- Artifacts are self-contained and site-independent. A site running an older version of shared scripts continues to operate correctly with its current instance configurations.
- The Central UI clearly indicates which sites have pending artifact updates so engineers can remediate.
- The Central UI clearly indicates which sites have pending artifact updates so engineers can remediate — see *Per-site artifact revision* above and *Pending-updates aggregate* below for the concrete mechanism (the Sites-page Artifacts badge and the rail indicator).
## Pending-updates aggregate (2026-09-14)
`DeploymentService.GetInstanceStalenessAsync(instanceIds)` answers instance
staleness for a whole batch cheaply enough to run fleet-wide on a cache-TTL
cadence, agreeing exactly with the per-instance Topology badge
(`GetDeploymentComparisonAsync`):
- All the flattens share **one `FlattenSession`** for the batch, so a template
chain used by N instances is walked once — the same trick `DeploySiteAsync`
uses.
- The deployed side is read as the `(InstanceId, RevisionHash)` **projection**
(`IDeploymentManagerRepository.GetDeployedRevisionHashesAsync`), never the fat
`ConfigurationJson` for the whole fleet.
- **Mismatch-only normalisation**: only when the projected hash disagrees with
the re-flatten is the full `DeployedConfigSnapshot` read and the I-1 List-value
normalisation applied (see *Deployed vs. Template-Derived State* above) — the
same normalisation `GetDeploymentComparisonAsync` applies, so the aggregate
never disagrees with the Topology badge on an old-form List snapshot, without
paying the fat read fleet-wide.
- `null` means "could not evaluate" (the instance's flatten failed) — counted as
**unknown**, never as stale; a fleet-wide read must not manufacture a pending
update out of a read failure. An instance with no deployed snapshot at all is
omitted, not counted as unknown.
- Per-instance failures are **not** logged individually — the whole batch runs at
most once per cache TTL, so one broken shared template fanning out to N
instances would otherwise emit N Warning lines every minute forever. A single
Warning summarises the batch's failure count instead; per-instance detail stays
at Debug.
`PendingUpdatesEvaluator` (scoped) combines that with
`ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync` into one
`PendingUpdatesSnapshot`: `ComputedAt`, plus per-site `SitePendingUpdates`
(`ArtifactState` — `NeverDeployed` / `Current` / `OutOfDate` —,
`ArtifactsDeployedAt`, `DeployedInstanceCount`, `StaleInstanceCount`,
`UnknownInstanceCount`) and fleet totals `StaleInstanceCount` /
`StaleArtifactSiteCount`. Only instances with a deployed snapshot (`State !=
NotDeployed`, the same gate Topology uses) are evaluated. **`NeverDeployed` is
deliberately not a pending update** — a freshly-created site the operator has not
gotten to yet has nothing drifted, so it is surfaced on the Sites page but never
counted toward the fleet totals or the rail indicator's badge count. Nothing in
the evaluator swallows an exception: a faulted evaluation propagates so the
cache's single-flight memo discards the flight and the Central UI renders the
aggregate as unavailable, rather than manufacturing a false "up to date".
The Central UI holds the snapshot behind a process-wide singleton
`IPendingUpdatesCache` (`PendingUpdatesCache`, mirroring `KpiSnapshotCache`): one
`SingleFlightMemo<PendingUpdatesSnapshot>` keyed by
`ScadaBridge:DeploymentManager:PendingUpdatesCacheTtl` (default 60 s, validated
positive by `DeploymentManagerOptionsValidator`), each flight running the
evaluator in its own DI scope. However many Blazor circuits poll
`GetAsync(forceRefresh)`, the fleet is flattened **at most once per TTL**, and not
at all when no Deployer/Admin circuit is open. See
`docs/plans/2026-09-14-pending-updates-design.md` for the full design, including
the Sites-page badge/re-check UI and the rail-footer indicator
(Component-CentralUI.md → *Site & Data Connection Management* and *Technology*).
Not in scope for this slice (deferred-work register rows 124–125): a
template-graph-watermark short-circuit so an unchanged fleet costs no re-flatten
per TTL, and a `deploy` CLI / Health-dashboard KPI surface for the counts.
## Instance Lifecycle Commands
+2
View File
@@ -902,6 +902,8 @@ List all registered sites.
scadabridge --url <url> site list
```
`site list` has no table projector, so both `--format json` and `--format table` show the full `Site` entity for every row, unfiltered. As of 2026-09-14 that includes `artifactRevisionHash` and `artifactsDeployedAt` (both are OMITTED from the JSON, not emitted as `null`, until artifacts have been deployed to that site — the management serializer drops null fields; after the first successful deploy both are present) — see `docs/requirements/Component-DeploymentManager.md` → *Per-site artifact revision*.
#### `site create`
Register a new site.
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -89,20 +90,52 @@ public sealed class SiteScopeService
return isSystemWide || allowed.Contains(siteId);
}
/// <summary>
/// The site-scope rule as a <b>pure</b> function of an already-resolved principal —
/// no I/O, no authentication-state fetch, no session revalidation.
/// <para>
/// Every instance method here resolves the state through the provider first, which
/// on <c>CookieAuthenticationStateProvider</c> re-maps groups to roles against the
/// database. Note that the non-authoritative overload is NOT a cheap alternative —
/// both entry points refresh, differing only in whether a mapping fault denies or
/// reuses the old grants — so the way to avoid the cost is to not ask the provider
/// at all. A display-gating caller that already HOLDS a principal (the cascading
/// <see cref="AuthenticationState"/> the router resolved for the page) calls this
/// instead and triggers no refresh of its own; that is what makes the rule usable
/// from a component that re-decides on a timer. Anything that ACTS on a site must
/// still go through <see cref="IsSiteAllowedAsync"/> so the check is made against
/// freshly revalidated claims.
/// </para>
/// </summary>
/// <param name="user">The principal to classify. An unauthenticated principal is scoped to nothing.</param>
/// <returns>
/// Whether the user is unrestricted, and — when they are not — the set of
/// <c>Site.Id</c> values they may see. The set is always empty for a system-wide user.
/// </returns>
public static (bool IsSystemWide, IReadOnlySet<int> Sites) Resolve(ClaimsPrincipal user)
{
ArgumentNullException.ThrowIfNull(user);
return ResolveCore(user);
}
private async Task<(bool IsSystemWide, IReadOnlySet<int> Sites)> ResolveAsync()
{
var state = await _authStateProvider.GetAuthoritativeStateAsync();
return ResolveCore(state.User);
}
private static (bool IsSystemWide, IReadOnlySet<int> Sites) ResolveCore(ClaimsPrincipal user)
{
// Administrator => system-wide, whatever SiteId claims are present. Read
// the role CLAIM rather than IsInRole: the principal's role claim type is
// only bound to JwtTokenService.RoleClaimType when the identity was built
// by SessionClaimBuilder, and the comparison is OrdinalIgnoreCase to match
// ManagementActor's — IsInRole compares claim VALUES ordinally, which would
// make the two sides disagree on a differently-cased role name.
if (state.User.Identity?.IsAuthenticated != true)
if (user.Identity?.IsAuthenticated != true)
return (false, new HashSet<int>());
var isAdministrator = state.User
var isAdministrator = user
.FindAll(JwtTokenService.RoleClaimType)
.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
@@ -114,7 +147,7 @@ public sealed class SiteScopeService
// holds for every caller, so a consumer that reads the id set without
// first consulting IsSystemWide cannot silently narrow an Administrator
// to a subset of sites.
foreach (var claim in state.User.FindAll(JwtTokenService.SiteIdClaimType))
foreach (var claim in user.FindAll(JwtTokenService.SiteIdClaimType))
{
if (int.TryParse(claim.Value, out var id))
ids.Add(id);
@@ -19,6 +19,9 @@
<DarkModeToggle />
<AuthorizeView>
<Authorized>
@* Fleet "pending updates" block. Self-gating — it renders nothing
unless the signed-in user holds Deployer or Administrator. *@
<PendingUpdatesIndicator />
@* CentralUI-024: claim type resolved via JwtTokenService. *@
<span class="rail-user">@context.User.GetDisplayName()</span>
<form method="post" action="/auth/logout" data-enhance="false">
@@ -5,8 +5,12 @@
(brand bar + responsive hamburger) belongs to ThemeShell; this component
contributes only the nav items. Collapsible sections use the kit's
NavRailSection (<details>); their open/closed state is persisted client-side
by the kit's nav-state.js (localStorage, keyed by Key) — no JS interop here. *@
by the kit's nav-state.js (localStorage, keyed by Key) — no JS interop here.
Everything below is wrapped in .rail-scroll, the scroll pane defined in
site.css: a long nav scrolls under the brand bar while the rail footer
stays pinned, and the kit itself is untouched. *@
<div class="rail-scroll">
<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
<AuthorizeView>
@@ -153,3 +157,4 @@
</AuthorizeView>
</Authorized>
</AuthorizeView>
</div>
@@ -0,0 +1,352 @@
@implements IDisposable
@using System.Security.Claims
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates
@using ZB.MOM.WW.ScadaBridge.Security
@inject IPendingUpdatesCache PendingUpdates
@inject AuthenticationStateProvider AuthStateProvider
@inject ILogger<PendingUpdatesIndicator> Logger
@* Rail-footer "is anything waiting to be deployed?" block (design §3.4).
Rendered only for Deployer/Administrator — the two roles that can act on it —
and quiet by default: one muted line when the fleet is current, two lines with
a warning pill when something is pending. Reads the process-wide single-flight
IPendingUpdatesCache, so N circuits polling here cost at most one fleet
flatten per cache TTL.
Roles and site scope are resolved from the CASCADED authentication state the
router already supplies (AddCascadingAuthenticationState), once per auth state
— never per poll. That matters because this component lives in MainLayout, so
it is on every page of every circuit: note that on this app's
CookieAuthenticationStateProvider BOTH entry points revalidate the session
against the database (they differ only in failClosed, not in whether they
refresh), so the saving is not "use the cheap overload" — it is that the
component never asks the provider at all on a tick, and reuses the principal
the router resolved rather than triggering a refresh of its own. Visibility
still follows the session — it recomputes on AuthenticationStateChanged, so a
mid-session role revocation takes effect immediately instead of persisting for
the life of the circuit. *@
@if (_visible)
{
<div class="rail-updates" title="@AsOfCaption">
@if (_error is not null)
{
@* A read failure is shown as a failure, never as a clean bill of health;
the exception message is the tooltip so it stays out of the rail. *@
<span class="rail-updates-muted" title="@_error"><i class="bi bi-question-circle" aria-hidden="true"></i> Updates unavailable</span>
}
else if (_snapshot is null)
{
<span class="rail-updates-muted"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Checking for updates…</span>
}
else if (!_snapshot.HasPendingUpdates)
{
<span class="rail-updates-muted"><i class="bi bi-check2-circle" aria-hidden="true"></i> Up to date</span>
}
else
{
<span class="rail-updates-head">
<i class="bi bi-exclamation-triangle" aria-hidden="true"></i> Pending updates
<span class="badge bg-warning text-dark">@(StaleInstances + StaleArtifactSites)</span>
</span>
<span class="rail-updates-detail">
@if (StaleInstances > 0)
{
if (_isDeployer)
{
<a href="/deployment/topology">@Plural(StaleInstances, "instance")</a>
}
else
{
<text>@Plural(StaleInstances, "instance")</text>
}
}
@if (StaleInstances > 0 && StaleArtifactSites > 0)
{
<text> · </text>
}
@if (StaleArtifactSites > 0)
{
if (_isAdministrator)
{
<a href="/admin/sites">@Plural(StaleArtifactSites, "site")</a>
}
else
{
<text>@Plural(StaleArtifactSites, "site")</text>
}
}
</span>
}
</div>
}
@code {
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30);
private readonly PollGate _pollGate = new();
private readonly object _timerLock = new();
private Timer? _timer;
private volatile bool _disposed;
private volatile bool _visible;
private bool _isDeployer;
private bool _isAdministrator;
// null => system-wide (no narrowing). A single reference field rather than a
// (bool, set) pair so the poll thread reads one consistent value even if an
// auth-state change is applying concurrently.
private volatile IReadOnlySet<int>? _scopedSiteIds;
// Result of the provider FALLBACK below, kept so it is resolved at most once per
// auth state instead of on every parameter set.
private ClaimsPrincipal? _fallbackPrincipal;
private PendingUpdatesSnapshot? _snapshot;
private string? _error;
private string? _lastLoggedError;
/// <summary>
/// The ambient authentication state supplied by the app's
/// <c>CascadingAuthenticationState</c> (registered by
/// <c>AddCascadingAuthenticationState</c>, so it is always present in the running
/// app). Preferred over asking the provider because this task is ALREADY resolved
/// for the page: reusing it means this component never triggers a session
/// revalidation of its own, whereas every provider entry point does refresh.
/// </summary>
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
private int StaleInstances => _snapshot?.StaleInstanceCount ?? 0;
private int StaleArtifactSites => _snapshot?.StaleArtifactSiteCount ?? 0;
// Tooltip is the snapshot's own instant, so a stalled poll is visible rather
// than silently passing off an old reading as current. Null (⇒ no attribute)
// until the first snapshot lands.
private string? AsOfCaption => _snapshot is null
? null
: $"as of {_snapshot.ComputedAt.UtcDateTime:HH:mm} UTC";
// "1 instance" / "3 instances" — the counts are read at a glance, so the
// noun has to agree with them.
private static string Plural(int count, string noun)
=> count == 1 ? $"{count} {noun}" : $"{count} {noun}s";
protected override void OnInitialized()
{
// A mid-session role change (revocation, or a re-login as someone else on
// the same circuit) must move this block, not leave it showing what the
// previous principal was allowed to see.
AuthStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
}
protected override async Task OnParametersSetAsync()
{
ClaimsPrincipal user;
if (AuthenticationStateTask is not null)
{
user = (await AuthenticationStateTask).User;
}
else
{
// Fallback for a host with no cascade. AddCascadingAuthenticationState() is
// registered, so this is expected to be dead in production — and it is a
// REAL session refresh (non-forced differs only in failClosed), which is why
// it is taken at most once and then reused until the state changes.
_fallbackPrincipal ??= (await AuthStateProvider.GetAuthenticationStateAsync()).User;
user = _fallbackPrincipal;
}
ApplyPrincipal(user);
// MUST be here, not only in OnAfterRenderAsync: the await above can suspend, in
// which case the FIRST render runs with _visible still false and skips the
// OnAfterRender(firstRender: true) start. The render that follows the resolve is
// firstRender: false, so nothing else would ever start the poll and the block
// would sit on "Checking for updates…" for the life of the circuit. Idempotent.
if (_visible)
{
EnsurePollingStarted();
}
}
// Roles and site scope are both pure functions of the principal's claims, so
// one resolution answers "do I render?", "do I link?" and "which sites count?".
private void ApplyPrincipal(ClaimsPrincipal user)
{
// Role gating is a plain claim check rather than an AuthorizeView: the whole
// component is hidden, and the two breakdown links are gated independently,
// so there is no policy to evaluate — just two booleans. The comparison is
// OrdinalIgnoreCase to match SiteScopeService/ManagementActor.
var roles = user.FindAll(JwtTokenService.RoleClaimType).ToList();
_isDeployer = roles.Any(c => string.Equals(c.Value, Roles.Deployer, StringComparison.OrdinalIgnoreCase));
_isAdministrator = roles.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
_visible = _isDeployer || _isAdministrator;
var (isSystemWide, sites) = SiteScopeService.Resolve(user);
_scopedSiteIds = isSystemWide ? null : sites;
}
private void OnAuthenticationStateChanged(Task<AuthenticationState> task)
=> _ = ApplyAuthenticationStateAsync(task);
private async Task ApplyAuthenticationStateAsync(Task<AuthenticationState> task)
{
if (_disposed)
{
return;
}
try
{
var state = await task;
if (_disposed)
{
return;
}
_fallbackPrincipal = state.User;
ApplyPrincipal(state.User);
// A role GRANTED mid-session has to start the poll that first render
// skipped; a role revoked just stops rendering (LoadAsync no-ops).
if (_visible)
{
EnsurePollingStarted();
}
}
catch (Exception ex)
{
// Never let an auth-state notification tear down the circuit: the worst
// case is that this block keeps its previous visibility.
Logger.LogWarning(ex, "Pending-updates indicator could not apply an authentication state change.");
return;
}
await RenderStateAsync();
}
protected override Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && _visible && !_disposed)
{
EnsurePollingStarted();
}
return Task.CompletedTask;
}
private void EnsurePollingStarted()
{
lock (_timerLock)
{
if (_disposed || _timer is not null)
{
return;
}
_timer = new Timer(_ => _ = RunLoadAsync(), null, PollInterval, PollInterval);
}
// Deliberately NOT awaited and deliberately not done in OnInitializedAsync:
// a cold fleet flatten takes seconds, and this component sits in the layout —
// awaiting it would hold the first render of every page behind it. The rail
// paints "Checking for updates…" and fills in when the snapshot lands. The
// first load goes through the same gate as a tick so nothing can escape as
// an unobserved faulted task.
_ = RunLoadAsync();
}
/// <summary>
/// Test seam: runs one poll cycle exactly as the timer tick does, through the
/// same gate. Exists so a test can pin the load-bearing cost property — that a
/// poll resolves no authentication state — without waiting out the interval.
/// </summary>
internal Task PollOnceAsync() => RunLoadAsync();
private Task RunLoadAsync() => _pollGate.RunAsync(
LoadAsync,
error => Logger.LogDebug(error, "Pending-updates indicator poll tick failed."));
private async Task LoadAsync()
{
if (_disposed || !_visible)
{
return;
}
try
{
var snapshot = await PendingUpdates.GetAsync();
// A site-scoped operator's totals must never include a site they cannot
// see — same rule Topology and the Sites page apply. The scope was
// resolved from the principal, so narrowing costs nothing here.
var scope = _scopedSiteIds;
if (scope is not null)
{
snapshot = snapshot.ForSites(scope);
}
// These writes happen off the renderer's synchronization context by
// design: the fleet read must not occupy the UI thread, PollGate's
// single-flight guarantees there is never a second concurrent writer,
// and only the RE-RENDER is marshalled back via InvokeAsync below.
_snapshot = snapshot;
_error = null;
_lastLoggedError = null;
}
catch (Exception ex)
{
// Log on the TRANSITION only: a site that stays unreachable would
// otherwise write one Warning every 30s per open circuit.
if (!string.Equals(_lastLoggedError, ex.Message, StringComparison.Ordinal))
{
_lastLoggedError = ex.Message;
Logger.LogWarning(ex, "Pending-updates indicator could not read the fleet snapshot.");
}
_error = ex.Message;
}
await RenderStateAsync();
}
private async Task RenderStateAsync()
{
if (_disposed)
{
return;
}
try
{
await InvokeAsync(StateHasChanged);
}
catch (ObjectDisposedException)
{
// Circuit torn down between the _disposed check and the dispatch.
}
catch (InvalidOperationException)
{
// Same race, earlier throw site: dispatching to a renderer whose circuit
// is already tearing down throws this before the ObjectDisposedException
// path is reachable.
}
}
public void Dispose()
{
_disposed = true;
AuthStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
lock (_timerLock)
{
_timer?.Dispose();
_timer = null;
}
}
}
@@ -8,8 +8,10 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Communication
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject IPendingUpdatesCache PendingUpdates
@inject ArtifactDeploymentService ArtifactDeploymentService
@inject CommunicationService CommunicationService
@inject AuthenticationStateProvider AuthStateProvider
@@ -20,8 +22,23 @@
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Management</h4>
<div class="d-flex align-items-baseline">
<h4 class="mb-0">Site Management</h4>
@if (_updates is not null)
{
<span class="small text-muted ms-2">Updates as of @_updates.ComputedAt.ToString("HH:mm:ss") UTC</span>
}
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" aria-label="Re-check pending updates"
title="Re-evaluate artifact and instance staleness for every site"
@onclick="() => LoadUpdatesAsync(true)" disabled="@_recheckingUpdates">
@if (_recheckingUpdates)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
}
Re-check
</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
data-bs-toggle="dropdown" disabled="@_deploying">
@@ -90,7 +107,7 @@
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-1">@site.Name</h5>
<code class="small">@site.SiteIdentifier</code>
<code class="small">@site.SiteIdentifier</code>@ArtifactBadge(site.Id)
</div>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm"
@@ -124,6 +141,11 @@
@(string.IsNullOrWhiteSpace(site.Description) ? "—" : site.Description)
</p>
@if (_updatesBySite.GetValueOrDefault(site.Id) is { DeployedInstanceCount: > 0 } u)
{
<div class="small text-muted mb-2">@InstanceSummary(u) — <a href="/deployment/topology">Topology</a></div>
}
<div class="small text-muted mb-1">Data connections</div>
@if (conns is { Count: > 0 })
{
@@ -178,6 +200,14 @@
private bool _deploying;
private string _search = "";
// Fleet-wide pending-updates snapshot behind the per-card Artifacts badge and the
// "N of M instances out of date" line. Loaded AFTER the site list (§3.3): the first
// evaluation flattens every site and can take seconds, and the cards must not wait.
private PendingUpdatesSnapshot? _updates;
private string? _updatesError;
private bool _recheckingUpdates;
private Dictionary<int, SitePendingUpdates> _updatesBySite = new();
private ToastNotification _toast = default!;
private IEnumerable<Site> FilteredSites =>
@@ -211,6 +241,48 @@
_errorMessage = $"Failed to load sites: {ex.Message}";
}
_loading = false;
// Nothing to badge: the page is showing an error banner, not a card grid, so a
// whole-fleet flatten would buy nothing.
if (_errorMessage != null) return;
// Render the cards now — the pending-updates evaluation below is a whole-fleet
// flatten and must never hold the list behind it.
StateHasChanged();
await LoadUpdatesAsync(force: false);
StateHasChanged();
}
// Loads (or re-evaluates) the fleet-wide pending-updates snapshot. Never throws:
// a failure degrades the badges to "unavailable" and leaves the site list intact.
private async Task LoadUpdatesAsync(bool force)
{
if (force)
{
_recheckingUpdates = true;
}
try
{
var snapshot = await PendingUpdates.GetAsync(force);
_updates = snapshot;
_updatesBySite = snapshot.Sites.ToDictionary(s => s.SiteId);
_updatesError = null;
}
catch (Exception ex)
{
// The error wins over whatever a previous evaluation said: a failed re-check
// must not leave a stale "as of" caption standing next to unavailable badges.
_updates = null;
_updatesBySite = new Dictionary<int, SitePendingUpdates>();
_updatesError = ex.Message;
Logger.LogWarning(ex, "Pending-updates evaluation failed; Sites page badges show unavailable.");
}
finally
{
_recheckingUpdates = false;
}
}
private async Task DeleteSite(Site site)
@@ -237,6 +309,7 @@
private async Task DeployArtifacts(Site site)
{
var succeeded = false;
_deploying = true;
try
{
@@ -244,7 +317,8 @@
var result = await ArtifactDeploymentService.RetryForSiteAsync(
site.Id, site.SiteIdentifier, user);
if (result.IsSuccess)
succeeded = result.IsSuccess;
if (succeeded)
_toast.ShowSuccess($"Artifacts deployed to '{site.Name}'.");
else
_toast.ShowError($"Deploy to '{site.Name}' failed: {result.Error}");
@@ -257,17 +331,28 @@
{
_deploying = false;
}
if (succeeded)
{
// Re-enable the deploy controls FIRST: the forced re-evaluation is a whole-fleet
// flatten, and the operator should not sit behind it once the deploy has landed.
// Its own progress shows on the Re-check button's spinner.
StateHasChanged();
await LoadUpdatesAsync(force: true);
}
}
private async Task DeployArtifactsToAllSites()
{
var succeeded = false;
_deploying = true;
try
{
var user = await GetCurrentUserAsync();
var result = await ArtifactDeploymentService.DeployToAllSitesAsync(user);
if (result.IsSuccess)
succeeded = result.IsSuccess;
if (succeeded)
{
var summary = result.Value!;
_toast.ShowSuccess(
@@ -286,6 +371,65 @@
{
_deploying = false;
}
if (succeeded)
{
// Re-enable the deploy controls FIRST — see DeployArtifacts.
StateHasChanged();
await LoadUpdatesAsync(force: true);
}
}
// Per-site Artifacts badge. Bootstrap .badge markup, matching the Topology page's
// Stale/Current convention. A site absent from the snapshot (created after the
// evaluation ran) reads as "checking…" rather than claiming a state it has not seen.
private RenderFragment ArtifactBadge(int siteId) => __builder =>
{
if (_updatesError is not null)
{
<span class="badge bg-light text-dark ms-2" title="@_updatesError"
aria-label="Artifacts: unavailable">Artifacts: unavailable</span>
}
else if (_updatesBySite.GetValueOrDefault(siteId) is not { } u)
{
<span class="badge bg-light text-dark ms-2" aria-label="Artifacts: checking">Artifacts: checking…</span>
}
else
{
switch (u.ArtifactState)
{
case ArtifactDeploymentState.Current:
<span class="badge bg-light text-dark ms-2" aria-label="Artifacts: current">Artifacts current</span>
break;
case ArtifactDeploymentState.OutOfDate:
<span class="badge bg-warning text-dark ms-2"
title="@(u.ArtifactsDeployedAt is { } at ? $"Artifacts last deployed {at.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss")} UTC" : null)"
aria-label="Artifacts: out of date"><i class="bi bi-exclamation-triangle me-1"></i>Artifacts out of date</span>
break;
case ArtifactDeploymentState.NeverDeployed:
default:
<span class="badge bg-secondary ms-2" aria-label="Artifacts: never deployed">Artifacts never deployed</span>
break;
}
}
};
// "2 of 5 instances out of date, 1 could not be evaluated" — built as one string so
// the rendered text stays contiguous (Razor would interleave layout whitespace).
private static string InstanceSummary(SitePendingUpdates u)
{
var noun = u.DeployedInstanceCount == 1 ? "instance" : "instances";
var text = u.StaleInstanceCount == 0
? $"All {u.DeployedInstanceCount} {noun} current"
: $"{u.StaleInstanceCount} of {u.DeployedInstanceCount} {noun} out of date";
if (u.UnknownInstanceCount > 0)
{
text += $", {u.UnknownInstanceCount} could not be evaluated";
}
return text;
}
private RenderFragment ClusterRow(string label, string? address) => __builder =>
@@ -120,6 +120,10 @@ public static class ServiceCollectionExtensions
// circuit for N dashboards to share a single SQL round per refresh window.
services.AddSingleton<IKpiSnapshotCache, KpiSnapshotCache>();
// Same single-flight TTL shape as IKpiSnapshotCache, over the fleet-wide
// pending-updates flatten (IPendingUpdatesEvaluator) instead of the KPI Asks.
services.AddSingleton<IPendingUpdatesCache, PendingUpdatesCache>();
// Secured Writes: dispatches the two-person secured-write commands
// (submit / approve / reject / list) to the central ManagementActor through the
// in-process ManagementActorHolder seam — the same Ask path the HTTP /management
@@ -0,0 +1,37 @@
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Process-level memoized fleet-wide pending-updates snapshot shared by every Blazor circuit
/// on this node — the same single-flight TTL shape as <see cref="IKpiSnapshotCache"/>, applied
/// to <see cref="IPendingUpdatesEvaluator"/>.
/// <para>
/// <see cref="IPendingUpdatesEvaluator.EvaluateAsync"/> flattens every site and every deployed
/// instance, so it is a whole-fleet read, not a per-circuit one. Without memoization, N
/// Deployer/Administrator circuits watching the rail indicator or the Sites page would each
/// re-flatten the fleet on their own poll. This cache collapses them: the freshness window is
/// <c>DeploymentManagerOptions.PendingUpdatesCacheTtl</c> (default 60s), and however many
/// circuits ask inside one window, the fleet is evaluated at most once.
/// </para>
/// <para>
/// A failed evaluation is never memoized: the fault is surfaced to every caller of that flight,
/// and the next caller starts a fresh one rather than re-serving the fault for the rest of the
/// TTL window.
/// </para>
/// </summary>
public interface IPendingUpdatesCache
{
/// <summary>
/// Returns the memoized fleet-wide pending-updates snapshot, evaluating a fresh one if none
/// is still within the TTL window.
/// </summary>
/// <param name="forceRefresh">
/// When <see langword="true"/>, discards a still-fresh value and starts a new evaluation
/// (the operator's "Re-check" action). An evaluation already in flight is still joined
/// rather than duplicated, so a burst of Re-check clicks remains one fleet flatten.
/// </param>
/// <param name="cancellationToken">Cancels this caller's wait only, never the shared flight.</param>
/// <returns>The memoized (or freshly evaluated) fleet-wide snapshot.</returns>
Task<PendingUpdatesSnapshot> GetAsync(bool forceRefresh = false, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,46 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="IPendingUpdatesCache"/> — a DI <b>singleton</b> holding one
/// <see cref="SingleFlightMemo{T}"/> over <see cref="IPendingUpdatesEvaluator"/>.
/// <para>
/// Registered as a singleton on purpose: the memo has to outlive any one circuit for N
/// dashboards to share a single fleet flatten per refresh window. It depends only on
/// <see cref="IServiceScopeFactory"/> — the evaluator is scoped (it opens repositories), so
/// every flight resolves it from a fresh scope, off any circuit-scoped context.
/// </para>
/// </summary>
public sealed class PendingUpdatesCache : IPendingUpdatesCache
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly SingleFlightMemo<PendingUpdatesSnapshot> _memo;
/// <summary>
/// Initializes the shared pending-updates cache.
/// </summary>
/// <param name="scopeFactory">Opens a fresh DI scope per fleet evaluation.</param>
/// <param name="options">Supplies <see cref="DeploymentManagerOptions.PendingUpdatesCacheTtl"/>.</param>
public PendingUpdatesCache(IServiceScopeFactory scopeFactory, IOptions<DeploymentManagerOptions> options)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
ArgumentNullException.ThrowIfNull(options);
_memo = new SingleFlightMemo<PendingUpdatesSnapshot>(options.Value.PendingUpdatesCacheTtl);
}
/// <inheritdoc/>
public Task<PendingUpdatesSnapshot> GetAsync(
bool forceRefresh = false, CancellationToken cancellationToken = default) =>
_memo.GetAsync(EvaluateAsync, forceRefresh, cancellationToken);
private async Task<PendingUpdatesSnapshot> EvaluateAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IPendingUpdatesEvaluator>().EvaluateAsync();
}
}
@@ -170,3 +170,35 @@
/* Backdrop — slightly darker overlay in dark mode */
--sb-backdrop: rgba(0, 0, 0, 0.6);
}
/* ── Rail nav scroll pane ────────────────────────────────────────────────
ThemeShell's nav.side-rail is a flex column inside the sticky 100vh
#theme-rail (lg+), but it never fills that height and nothing inside it
scrolls, so a long nav grows past the viewport and carries the footer
with it. Fill the rail and let the nav items (NavMenu's .rail-scroll
wrapper) scroll while the brand bar and .rail-foot stay put. Below lg the
rail is inline and scrolls with the page, so the rule is scoped to lg+.
The kit is untouched (deferred-work register: upstream into
ZB.MOM.WW.Theme so the other consumers get it too). */
@media (min-width: 992px) {
#theme-rail > .side-rail {
height: 100%;
}
.rail-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
}
/* ── Rail pending-updates indicator ──────────────────────────────────────
Sits in .rail-foot just above .rail-user. One line when quiet, two when
something is pending; tokens only so dark mode follows for free. */
.rail-updates { display: flex; flex-direction: column; gap: 0.15rem; padding: 0 0.6rem 0.5rem; font-size: 0.8rem; line-height: 1.3; }
.rail-updates-muted { color: var(--ink-faint); }
.rail-updates-head { display: flex; align-items: center; gap: 0.35rem; font-weight: 600; color: var(--ink-soft); }
.rail-updates-head .badge { font-size: 0.7rem; }
.rail-updates-detail { color: var(--ink-faint); }
.rail-updates a { color: inherit; text-decoration: underline dotted; }
.rail-updates a:hover { color: var(--accent); }
@@ -18,6 +18,18 @@ public class Site
public string? GrpcNodeAAddress { get; set; }
/// <summary>gRPC endpoint for site node B used by the central SiteStreamGrpcClient.</summary>
public string? GrpcNodeBAddress { get; set; }
/// <summary>
/// Revision hash (<c>ArtifactRevisionHashService</c>) of the per-site artifact set last
/// deployed to this site successfully; null = artifacts never deployed. Environment-specific
/// deploy state — deliberately NOT carried by Transport bundles (like <c>Instance.State</c>).
/// </summary>
public string? ArtifactRevisionHash { get; set; }
/// <summary>
/// UTC instant at which the artifact set identified by <see cref="ArtifactRevisionHash"/> was
/// last deployed to this site successfully; null = artifacts never deployed. Environment-specific
/// deploy state — deliberately NOT carried by Transport bundles (like <c>Instance.State</c>).
/// </summary>
public DateTimeOffset? ArtifactsDeployedAt { get; set; }
/// <summary>
/// Initializes a new site with the required name and identifier.
@@ -204,6 +204,20 @@ public interface IDeploymentManagerRepository
/// <returns>The deployed config snapshot, or null if not found.</returns>
Task<DeployedConfigSnapshot?> GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>
/// InstanceId → RevisionHash for every <c>DeployedConfigSnapshot</c>. A projection read — never
/// materialises <c>ConfigurationJson</c>, so it is safe to call for the whole fleet (the
/// pending-updates aggregate does, once per cache TTL).
///
/// <para>
/// Deliberately UNFILTERED: it always returns the whole fleet's two-column projection, with no
/// site, state or scope narrowing. A site-scoped or otherwise partial caller must filter the
/// returned map itself rather than assume the read was narrowed for it.
/// </para>
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A dictionary keyed by instance ID; instances with no snapshot are absent.</returns>
Task<IReadOnlyDictionary<int, string>> GetDeployedRevisionHashesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new deployed config snapshot.
/// </summary>
/// <param name="snapshot">The deployed config snapshot to add.</param>
@@ -27,6 +27,7 @@ public class SiteConfiguration : IEntityTypeConfiguration<Site>
builder.Property(s => s.NodeBAddress).HasMaxLength(500);
builder.Property(s => s.GrpcNodeAAddress).HasMaxLength(500);
builder.Property(s => s.GrpcNodeBAddress).HasMaxLength(500);
builder.Property(s => s.ArtifactRevisionHash).HasMaxLength(100);
builder.HasIndex(s => s.Name).IsUnique();
builder.HasIndex(s => s.SiteIdentifier).IsUnique();
@@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddSiteArtifactRevision : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ArtifactRevisionHash",
table: "Sites",
type: "nvarchar(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ArtifactsDeployedAt",
table: "Sites",
type: "datetimeoffset",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ArtifactRevisionHash",
table: "Sites");
migrationBuilder.DropColumn(
name: "ArtifactsDeployedAt",
table: "Sites");
}
}
}
@@ -1379,6 +1379,13 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ArtifactRevisionHash")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ArtifactsDeployedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
@@ -294,6 +294,17 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<int, string>> GetDeployedRevisionHashesAsync(CancellationToken cancellationToken = default)
{
// Projection only: ConfigurationJson is never materialised, so this stays cheap enough to
// read for the whole fleet in one round trip.
return await _dbContext.Set<DeployedConfigSnapshot>()
.AsNoTracking()
.Select(s => new { s.InstanceId, s.RevisionHash })
.ToDictionaryAsync(s => s.InstanceId, s => s.RevisionHash, cancellationToken);
}
/// <inheritdoc />
public async Task AddDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
{
@@ -39,6 +39,7 @@ public class ArtifactDeploymentService
private readonly IAuditService _auditService;
private readonly DeploymentManagerOptions _options;
private readonly ILogger<ArtifactDeploymentService> _logger;
private readonly ArtifactRevisionHashService _hashService;
/// <summary>
/// Initializes a new instance of the ArtifactDeploymentService.
@@ -57,6 +58,11 @@ public class ArtifactDeploymentService
/// <param name="auditService">Service for audit logging.</param>
/// <param name="options">Deployment manager options.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="hashService">
/// Computes the deterministic revision hash of a per-site artifact set, stamped on the
/// <see cref="Site"/> row after a successful deploy. Last on the signature so the
/// addition stays positionally additive for existing call sites.
/// </param>
public ArtifactDeploymentService(
ISiteRepository siteRepo,
IDeploymentManagerRepository deploymentRepo,
@@ -66,7 +72,8 @@ public class ArtifactDeploymentService
CommunicationService communicationService,
IAuditService auditService,
IOptions<DeploymentManagerOptions> options,
ILogger<ArtifactDeploymentService> logger)
ILogger<ArtifactDeploymentService> logger,
ArtifactRevisionHashService hashService)
{
_siteRepo = siteRepo;
_deploymentRepo = deploymentRepo;
@@ -79,6 +86,7 @@ public class ArtifactDeploymentService
_auditService = auditService;
_options = options.Value;
_logger = logger;
_hashService = hashService;
}
/// <summary>
@@ -182,6 +190,11 @@ public class ArtifactDeploymentService
foreach (var es in externalSystems)
{
var methods = await _externalSystemRepo.GetMethodsByExternalSystemIdAsync(es.Id, cancellationToken);
// The property NAMES emitted here are part of a contract:
// ArtifactRevisionHashService.CanonicalizeMethodDefinitions re-orders this array by
// each element's "Name" property (case-insensitively) so repository row order cannot
// move a site's artifact revision hash. Renaming Name here would silently degrade
// that canonicalisation to "leave the order alone".
var methodsJson = methods.Count > 0
? JsonSerializer.Serialize(methods.Select(m => new
{
@@ -371,6 +384,31 @@ public class ArtifactDeploymentService
await _deploymentRepo.AddSystemArtifactDeploymentAsync(record, cancellationToken);
await _deploymentRepo.SaveChangesAsync(cancellationToken);
// ORDERING RULE: audit row first; the stamp is best-effort bookkeeping and its
// save failure must not be able to reach the record's save.
//
// ISiteRepository and IDeploymentManagerRepository are scoped over the SAME
// DbContext, so a failed stamp save leaves the Site entries tracked as Modified.
// Stamping first would therefore let a persistent stamp failure (concurrency
// conflict, constraint violation) rethrow out of the record's own — uncaught —
// SaveChangesAsync: the deployment record would be lost and a wholly successful
// deploy would surface as an exception. Running the record's save first and the
// stamp after keeps the failure inside StampArtifactRevisionAsync's try/catch,
// where it is logged at Warning and the deploy still reports Success.
//
// Only sites whose deploy SUCCEEDED are stamped. A failed site is deliberately
// left untouched so it keeps its previous stamp (or none at all) and keeps
// reading "out of date" on the Sites page.
var successfulSites = sites
.Where(site => perSiteResults.TryGetValue(site.SiteIdentifier, out var r) && r.Success)
.ToList();
if (successfulSites.Count > 0)
{
await StampArtifactRevisionAsync(
successfulSites.Select(site => (site, siteCommands[site.Id])).ToList(),
cancellationToken);
}
var summary = new ArtifactDeploymentSummary(
deploymentId,
results.ToList(),
@@ -412,6 +450,20 @@ public class ArtifactDeploymentService
await _auditService.LogAsync(user, "RetryArtifactDeployment", "SystemArtifact",
command.DeploymentId, siteIdentifier, new { response.Success }, cancellationToken);
// Same stamp-on-success rule as the sweep path: a retry that lands is a
// successful artifact deploy and must move the site's revision stamp; a
// retry that fails leaves the previous stamp in place.
//
// Same ORDERING RULE too — audit row first, best-effort stamp after. Here the
// method's own catch would convert a stamp exception into a Failure result for
// an artifact deploy that actually succeeded, so the stamp must (and does) keep
// its own try/catch inside StampArtifactRevisionAsync rather than lean on this
// one.
if (response.Success)
{
await StampRetriedSiteAsync(siteDbId, command, cancellationToken);
}
return response.Success
? Result<SiteArtifactResult>.Success(result)
: Result<SiteArtifactResult>.Failure(response.ErrorMessage ?? "Retry failed.");
@@ -421,6 +473,115 @@ public class ArtifactDeploymentService
return Result<SiteArtifactResult>.Failure($"Retry failed for site {siteIdentifier}: {ex.Message}");
}
}
/// <summary>
/// Computes what each given site's artifact revision hash WOULD be if the current
/// artifact definitions were deployed right now. Comparing the result against the
/// site's stored <see cref="Site.ArtifactRevisionHash"/> is how the pending-updates
/// aggregate decides whether a site's system-wide artifacts are out of date.
/// </summary>
/// <param name="sites">The sites to compute current hashes for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A map of site database id to the hash of the artifact set that a deploy would send
/// to that site right now.
/// </returns>
/// <remarks>
/// The global artifact queries run ONCE for the whole sweep (the same hoist
/// <see cref="DeployToAllSitesAsync"/> performs); only the per-site data-connection query
/// runs per site. The commands are built with the constant deployment id <c>"current"</c>,
/// which is harmless because <see cref="ArtifactRevisionHashService"/> excludes both the
/// deployment id and the command timestamp from the hash — that exclusion is what makes a
/// computed-now hash comparable with one stamped by a past deploy.
/// <para>
/// A failing per-site data-connection query aborts the WHOLE batch by design: a partial map
/// would silently report unqueried sites as up to date. The pending-updates cache discards a
/// faulted flight and the UI shows the aggregate as unavailable instead.
/// </para>
/// </remarks>
public async Task<IReadOnlyDictionary<int, string>> ComputeCurrentArtifactHashesAsync(
IReadOnlyList<Site> sites,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(sites);
var hashes = new Dictionary<int, string>();
if (sites.Count == 0)
return hashes;
var globals = await FetchGlobalArtifactsAsync(cancellationToken);
foreach (var site in sites)
{
var command = await BuildDeployArtifactsCommandAsync(
site.Id, globals, cancellationToken, deploymentId: "current");
hashes[site.Id] = _hashService.ComputeHash(command);
}
return hashes;
}
/// <summary>
/// Loads the retried site and stamps the artifact revision it just accepted. Best-effort
/// end to end: the load is caught here and the stamp carries its own catch, so nothing on
/// this path can escape into <see cref="RetryForSiteAsync"/>'s catch and report a
/// successful artifact deploy as a failure.
/// </summary>
private async Task StampRetriedSiteAsync(
int siteDbId,
DeployArtifactsCommand command,
CancellationToken cancellationToken)
{
Site? site;
try
{
site = await _siteRepo.GetSiteByIdAsync(siteDbId, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Artifact retry for site {SiteDbId} succeeded but the site row could not be loaded to " +
"stamp its artifact revision; it will report out-of-date artifacts until the next deploy",
siteDbId);
return;
}
if (site is null)
return;
await StampArtifactRevisionAsync([(site, command)], cancellationToken);
}
/// <summary>
/// Stamps <see cref="Site.ArtifactRevisionHash"/> and <see cref="Site.ArtifactsDeployedAt"/>
/// for every (site, deployed command) pair and flushes them in one save. Best-effort: a
/// failure is logged at Warning and swallowed, because the artifacts DID reach the sites —
/// losing the stamp only means the Sites page reports those sites out of date until the
/// next deploy, which is strictly better than reporting a successful deploy as failed.
/// </summary>
private async Task StampArtifactRevisionAsync(
IReadOnlyList<(Site Site, DeployArtifactsCommand Command)> deployed,
CancellationToken cancellationToken)
{
try
{
var stampedAt = DateTimeOffset.UtcNow;
foreach (var (site, command) in deployed)
{
site.ArtifactRevisionHash = _hashService.ComputeHash(command);
site.ArtifactsDeployedAt = stampedAt;
await _siteRepo.UpdateSiteAsync(site, cancellationToken);
}
await _siteRepo.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Artifact deployment succeeded but stamping the artifact revision failed for site(s) {Sites}; " +
"they will report out-of-date artifacts until the next deploy",
string.Join(", ", deployed.Select(d => d.Site.SiteIdentifier)));
}
}
}
/// <summary>
@@ -0,0 +1,321 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// <summary>
/// Produces a deterministic SHA-256 hash of the per-site system-wide artifact set
/// carried by a <see cref="DeployArtifactsCommand"/> (shared scripts, external
/// systems, database connections, and the site's data connections). The hash is
/// stamped on <c>Site.ArtifactRevisionHash</c> after a successful artifact deploy
/// and compared against a freshly-computed hash of the current definitions to tell
/// whether a site's deployed artifacts are out of date.
/// <para>
/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR declaration
/// order and does NOT sort them. Stable output therefore relies on the private
/// <c>Hashable*</c> records below declaring their properties in alphabetical
/// (ordinal) order — a contributor adding a property out of order would silently
/// change every site's artifact hash and flag the whole fleet out of date. The
/// ordering is guarded by
/// <c>ArtifactRevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>,
/// mirroring <c>RevisionHashService</c>. Every artifact list is explicitly sorted by
/// <c>Name</c> (ordinal) before hashing, so repository row order never leaks into the
/// hash, and an external system's <c>MethodDefinitionsJson</c> — itself a serialized
/// collection — is re-canonicalised with its methods sorted by name.
/// </para>
/// <para>
/// EXCLUDED FROM THE HASH: the command's <c>DeploymentId</c> and <c>Timestamp</c>
/// (both volatile per deploy — two deploys of identical definitions must hash the
/// same), and <c>NotificationLists</c>/<c>SmtpConfigurations</c> (notification
/// delivery is central-only; central never populates those fields, so folding them
/// in would only add a permanently-null branch).
/// </para>
/// <para>
/// A no-op re-save of a data connection does NOT change the hash. Connection
/// configuration is encrypted at rest through an EF value converter, so the
/// ciphertext changes on every save, but the entity — and hence
/// <see cref="DataConnectionArtifact"/> and this hash — only ever sees the
/// plaintext. Re-encryption at rest is therefore invisible here, which is exactly
/// what keeps the "out of date" indicator quiet for a save that changed nothing.
/// </para>
/// </summary>
public sealed class ArtifactRevisionHashService
{
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Computes the deterministic SHA-256 hash of the artifact set carried by the given
/// per-site deploy command. Identical artifact definitions always produce an identical
/// hash, regardless of deployment id, timestamp, or the order the artifacts were
/// returned by their repositories.
/// </summary>
/// <param name="command">The per-site artifact deployment command to hash.</param>
/// <returns>
/// The canonical revision hash, encoded as <c>sha256:</c> followed by the lower-case
/// hex digest (the same encoding as <c>RevisionHashService</c>).
/// </returns>
public string ComputeHash(DeployArtifactsCommand command)
{
ArgumentNullException.ThrowIfNull(command);
// Each list is projected null-when-empty and the serializer omits null
// properties, so an absent artifact set and an empty one hash identically.
var hashInput = new HashableArtifactSet
{
DataConnections = command.DataConnections is { Count: > 0 }
? command.DataConnections
.OrderBy(c => c.Name, StringComparer.Ordinal)
.Select(c => new HashableDataConnection
{
BackupConfigurationJson = c.BackupConfigurationJson,
FailoverRetryCount = c.FailoverRetryCount,
Name = c.Name,
PrimaryConfigurationJson = c.PrimaryConfigurationJson,
Protocol = c.Protocol
})
.ToList()
: null,
DatabaseConnections = command.DatabaseConnections is { Count: > 0 }
? command.DatabaseConnections
.OrderBy(d => d.Name, StringComparer.Ordinal)
.Select(d => new HashableDatabaseConnection
{
ConnectionString = d.ConnectionString,
MaxRetries = d.MaxRetries,
Name = d.Name,
RetryDelayTicks = d.RetryDelay.Ticks
})
.ToList()
: null,
ExternalSystems = command.ExternalSystems is { Count: > 0 }
? command.ExternalSystems
.OrderBy(e => e.Name, StringComparer.Ordinal)
.Select(e => new HashableExternalSystem
{
AuthConfiguration = e.AuthConfiguration,
AuthType = e.AuthType,
EndpointUrl = e.EndpointUrl,
MaxRetries = e.MaxRetries,
MethodDefinitionsJson = CanonicalizeMethodDefinitions(e.MethodDefinitionsJson),
Name = e.Name,
RetryDelayTicks = e.RetryDelay.Ticks,
TimeoutSeconds = e.TimeoutSeconds
})
.ToList()
: null,
SharedScripts = command.SharedScripts is { Count: > 0 }
? command.SharedScripts
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => new HashableSharedScript
{
Code = s.Code,
Name = s.Name,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
})
.ToList()
: null
};
var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions);
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
}
/// <summary>
/// Re-serializes an external system's JSON-encoded method list with its methods sorted
/// by name (ordinal), so the repository's row order cannot leak into the hash. Anything
/// that is not a parseable JSON array is returned verbatim — the hash still changes when
/// the payload does, which is the only guarantee this method owes.
/// <para>
/// The name property is matched CASE-INSENSITIVELY. Today's producer
/// (<c>ArtifactDeploymentService.FetchGlobalArtifactsAsync</c>) serializes an anonymous
/// type, so the key is PascalCase <c>Name</c> — but a future camelCase naming policy there
/// would otherwise silently turn this sort into a no-op and let row order move the hash
/// again, with nothing failing.
/// </para>
/// </summary>
private static string? CanonicalizeMethodDefinitions(string? methodDefinitionsJson)
{
if (string.IsNullOrWhiteSpace(methodDefinitionsJson))
return methodDefinitionsJson;
try
{
using var document = JsonDocument.Parse(methodDefinitionsJson);
if (document.RootElement.ValueKind != JsonValueKind.Array)
return methodDefinitionsJson;
var ordered = document.RootElement
.EnumerateArray()
.Select(element => element.Clone())
.OrderBy(MethodSortKey, StringComparer.Ordinal)
.ToList();
return JsonSerializer.Serialize(ordered, CanonicalJsonOptions);
}
catch (JsonException)
{
return methodDefinitionsJson;
}
}
/// <summary>
/// Sort key for one serialized external-system method: its name property, matched
/// case-insensitively so the key survives a naming-policy change at the producer.
/// Elements that are not objects, or carry no string name, sort first under an empty key —
/// their relative order is then whatever the payload gave, which is the best a nameless
/// element allows.
/// </summary>
private static string MethodSortKey(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
return string.Empty;
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, "Name", StringComparison.OrdinalIgnoreCase))
{
return property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: string.Empty;
}
}
return string.Empty;
}
// Internal types for deterministic serialization. Properties MUST be declared in
// alphabetical (ordinal) order — System.Text.Json emits them in declaration order
// and does not sort. See the DETERMINISM CONTRACT note on the class summary.
private sealed record HashableArtifactSet
{
/// <summary>
/// The site's data connections, sorted by name; null when the site has none.
/// </summary>
public List<HashableDataConnection>? DataConnections { get; init; }
/// <summary>
/// The system-wide database connections, sorted by name; null when there are none.
/// </summary>
public List<HashableDatabaseConnection>? DatabaseConnections { get; init; }
/// <summary>
/// The system-wide external systems, sorted by name; null when there are none.
/// </summary>
public List<HashableExternalSystem>? ExternalSystems { get; init; }
/// <summary>
/// The system-wide shared scripts, sorted by name; null when there are none.
/// </summary>
public List<HashableSharedScript>? SharedScripts { get; init; }
}
private sealed record HashableDataConnection
{
/// <summary>
/// Backup connection configuration JSON (plaintext in memory), if any.
/// </summary>
public string? BackupConfigurationJson { get; init; }
/// <summary>
/// Number of failover retries before giving up.
/// </summary>
public int FailoverRetryCount { get; init; }
/// <summary>
/// The connection name — the key scripts and bindings address it by.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Primary connection configuration JSON (plaintext in memory), if any.
/// </summary>
public string? PrimaryConfigurationJson { get; init; }
/// <summary>
/// Protocol name (e.g. "OpcUa").
/// </summary>
public string Protocol { get; init; } = string.Empty;
}
private sealed record HashableDatabaseConnection
{
/// <summary>
/// The database connection string shipped to the site.
/// </summary>
public string ConnectionString { get; init; } = string.Empty;
/// <summary>
/// Store-and-Forward retry budget for this connection's cached writes (0 = unset).
/// </summary>
public int MaxRetries { get; init; }
/// <summary>
/// The database connection name.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather
/// than a <see cref="TimeSpan"/> so the serialized form cannot vary by format.
/// </summary>
public long RetryDelayTicks { get; init; }
}
private sealed record HashableExternalSystem
{
/// <summary>
/// JSON-serialized authentication configuration for <see cref="AuthType"/>.
/// </summary>
public string? AuthConfiguration { get; init; }
/// <summary>
/// Authentication type identifier (e.g. <c>ApiKey</c>, <c>BasicAuth</c>).
/// </summary>
public string AuthType { get; init; } = string.Empty;
/// <summary>
/// Base URL of the system's HTTP endpoint.
/// </summary>
public string EndpointUrl { get; init; } = string.Empty;
/// <summary>
/// Store-and-Forward retry budget for this system's cached calls (0 = unset).
/// </summary>
public int MaxRetries { get; init; }
/// <summary>
/// The system's method list, re-canonicalised with the methods sorted by name;
/// null when the system defines none.
/// </summary>
public string? MethodDefinitionsJson { get; init; }
/// <summary>
/// Unique system name — the key scripts call it by.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather
/// than a <see cref="TimeSpan"/> so the serialized form cannot vary by format.
/// </summary>
public long RetryDelayTicks { get; init; }
/// <summary>
/// Per-system HTTP round-trip timeout in seconds (0 = unset).
/// </summary>
public int TimeoutSeconds { get; init; }
}
private sealed record HashableSharedScript
{
/// <summary>
/// The shared script's source code.
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// The shared script name — the key other scripts call it by.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// JSON representation of the script's parameter definitions.
/// </summary>
public string? ParameterDefinitions { get; init; }
/// <summary>
/// JSON representation of the script's return type definition.
/// </summary>
public string? ReturnDefinition { get; init; }
}
}
@@ -75,4 +75,19 @@ public class DeploymentManagerOptions
/// </para>
/// </summary>
public TimeSpan TerminalDeploymentRecordRetention { get; set; } = TimeSpan.FromDays(365);
/// <summary>
/// How long the Central UI pending-updates snapshot stays fresh; one fleet
/// flatten per TTL while any Deployer/Administrator circuit is open.
///
/// <para>
/// The snapshot is a whole-fleet read (every site's artifact hash plus every
/// deployed instance's staleness), so it is held behind a single-flight TTL
/// cache: however many circuits poll, and however often, the fleet is evaluated
/// at most once per this window — and not at all when nobody is looking. Must be
/// positive; zero would make every poll a cache miss and re-flatten the fleet per
/// circuit, which is precisely the cost the cache exists to avoid.
/// </para>
/// </summary>
public TimeSpan PendingUpdatesCacheTtl { get; set; } = TimeSpan.FromSeconds(60);
}
@@ -43,6 +43,12 @@ public sealed class DeploymentManagerOptionsValidator : OptionsValidatorBase<Dep
$"(was {options.SiteDeploymentReconcileTimeout}); it bounds each query-before-redeploy " +
$"reconciliation inside a bulk site deployment's serial prepare phase.");
builder.RequireThat(options.PendingUpdatesCacheTtl > TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:PendingUpdatesCacheTtl must be a positive duration " +
$"(was {options.PendingUpdatesCacheTtl}); it is the freshness window of the Central UI " +
$"pending-updates snapshot, and a non-positive value would re-flatten the whole fleet on " +
$"every poll of every open circuit.");
builder.RequireThat(options.TerminalDeploymentRecordRetention >= TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:TerminalDeploymentRecordRetention must not be negative " +
$"(was {options.TerminalDeploymentRecordRetention}); use TimeSpan.Zero to disable purging of " +
@@ -1707,40 +1707,16 @@ public class DeploymentService
// A snapshot that cannot be deserialized (corrupt /
// older schema) still yields the frozen-hash staleness result, with a
// null diff.
var deployedRevisionHash = snapshot.RevisionHash;
ConfigurationDiff? diff = null;
try
{
var deployedConfig = JsonSerializer.Deserialize<FlattenedConfiguration>(snapshot.ConfigurationJson);
if (deployedConfig != null)
{
deployedConfig = NormalizeListAttributeValues(deployedConfig);
var deployedRevisionHash = ComputeDeployedHash(snapshot, instanceId, out var deployedConfig);
// Recompute the deployed-side hash from the normalized snapshot so
// an old-form List value is not flagged stale against the native
// re-flatten. For a faithfully-stored scalar-only snapshot this
// reproduces the frozen RevisionHash exactly, so behaviour is
// unchanged outside the List-normalization case.
deployedRevisionHash = _revisionHashService.ComputeHash(deployedConfig);
diff = _diffService.ComputeDiff(
deployedConfig, currentConfig, deployedRevisionHash, currentHash);
}
else
{
_logger.LogWarning(
"Deployed snapshot for instance {InstanceId} deserialized to null; " +
"returning hash-based comparison without a structured diff",
instanceId);
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex,
"Could not deserialize deployed snapshot for instance {InstanceId}; " +
"returning hash-based comparison without a structured diff",
instanceId);
}
// Deliberately outside ComputeDeployedHash's JsonException catch: DiffService
// compares already-deserialized POCOs and does no JSON work of its own, so it
// cannot raise the exception that catch exists for. A DiffService that ever
// starts parsing JSON must revisit this.
var diff = deployedConfig == null
? null
: _diffService.ComputeDiff(
deployedConfig, currentConfig, deployedRevisionHash, currentHash);
var isStale = deployedRevisionHash != currentHash;
@@ -1755,6 +1731,180 @@ public class DeploymentService
return Result<DeploymentComparisonResult>.Success(result);
}
/// <summary>
/// Derives the deployed-side staleness hash from a stored snapshot, applying
/// the I-1 List normalization: the snapshot's <c>ConfigurationJson</c> is
/// deserialized, its <see cref="DataType.List"/> values round-tripped to native
/// form, and the hash recomputed over the result — so a List attribute deployed
/// in the old quoted form is not flagged stale against a native re-flatten. For
/// a faithfully-stored scalar-only snapshot this reproduces the frozen
/// <see cref="DeployedConfigSnapshot.RevisionHash"/> exactly.
///
/// <para>
/// A snapshot that cannot be deserialized (corrupt / older schema) falls back to
/// the frozen hash and yields a <see langword="null"/> normalized config, which
/// the comparison path renders as "no structured diff". Shared by
/// <see cref="GetDeploymentComparisonAsync"/> and
/// <see cref="GetInstanceStalenessAsync"/> so the per-instance badge and the
/// fleet aggregate can never disagree.
/// </para>
/// </summary>
/// <param name="snapshot">The stored deployed-config snapshot.</param>
/// <param name="instanceId">Instance the snapshot belongs to; named in the warning logs.</param>
/// <param name="normalizedDeployedConfig">
/// The normalized deployed configuration, or <see langword="null"/> when the
/// snapshot could not be deserialized.
/// </param>
/// <returns>The hash to compare the current re-flatten's hash against.</returns>
private string ComputeDeployedHash(
DeployedConfigSnapshot snapshot,
int instanceId,
out FlattenedConfiguration? normalizedDeployedConfig)
{
normalizedDeployedConfig = null;
try
{
var deployedConfig = JsonSerializer.Deserialize<FlattenedConfiguration>(snapshot.ConfigurationJson);
if (deployedConfig != null)
{
normalizedDeployedConfig = NormalizeListAttributeValues(deployedConfig);
return _revisionHashService.ComputeHash(normalizedDeployedConfig);
}
_logger.LogWarning(
"Deployed snapshot for instance {InstanceId} deserialized to null; " +
"returning hash-based comparison without a structured diff",
instanceId);
}
catch (JsonException ex)
{
_logger.LogWarning(ex,
"Could not deserialize deployed snapshot for instance {InstanceId}; " +
"returning hash-based comparison without a structured diff",
instanceId);
}
return snapshot.RevisionHash;
}
/// <summary>
/// Evaluates staleness for a batch of instances — the fleet-wide read behind
/// the pending-updates aggregate — agreeing exactly with the per-instance
/// Topology badge (<see cref="GetDeploymentComparisonAsync"/>) while costing a
/// fraction of running that method N times.
///
/// <para>
/// <b>How it stays cheap.</b> All the flattens share ONE
/// <see cref="FlattenSession"/>, so a template chain used by N instances is
/// walked once (the same trick <see cref="DeploySiteAsync"/> uses). The deployed
/// side is read as the <c>(InstanceId, RevisionHash)</c> projection
/// (<see cref="IDeploymentManagerRepository.GetDeployedRevisionHashesAsync"/>),
/// never the fat <c>ConfigurationJson</c> for the whole fleet. Only when that
/// projected hash DISAGREES with the re-flatten is the snapshot read and the
/// I-1 List normalization applied — which is what makes the aggregate agree with
/// the badge on an old-form List snapshot without paying for it fleet-wide.
/// </para>
///
/// <para>
/// <b>Sequential by necessity.</b> The repository's <c>DbContext</c> and the
/// session are both single-threaded (see <see cref="FlattenSession"/>
/// "Threading"), so the loop must not be parallelised.
/// </para>
///
/// <para>
/// <b>Result shape.</b> An instance with no deployed snapshot is OMITTED — it
/// has nothing to be stale against, and a snapshot that vanished between the
/// projection read and the snapshot read means it was undeployed concurrently,
/// which is treated the same way. <see langword="null"/> means "could not
/// evaluate" (its flatten failed): unknown, never counted as stale.
/// </para>
/// </summary>
/// <param name="instanceIds">Instances to evaluate; duplicates are collapsed.</param>
/// <param name="cancellationToken">Cancellation token, honoured between instances.</param>
/// <returns>
/// A map of instance id → <see langword="true"/> (stale), <see langword="false"/>
/// (up to date) or <see langword="null"/> (could not evaluate), omitting
/// instances with no deployed snapshot.
/// </returns>
public async Task<IReadOnlyDictionary<int, bool?>> GetInstanceStalenessAsync(
IReadOnlyCollection<int> instanceIds,
CancellationToken cancellationToken = default)
{
var staleness = new Dictionary<int, bool?>();
if (instanceIds.Count == 0)
return staleness;
var deployedHashes = await _repository.GetDeployedRevisionHashesAsync(cancellationToken);
var session = _flatteningPipeline.CreateSession();
// Failures are summarised into ONE Warning at the end rather than logged
// per instance: this runs for the whole fleet on a cache-TTL cadence, so a
// single broken shared template fanning out to N instances would otherwise
// emit N Warning lines every minute, forever. The per-instance detail stays
// at Debug for whoever is actually diagnosing it.
var evaluated = 0;
var failedCount = 0;
var firstFailureInstanceId = 0;
string? firstFailureError = null;
foreach (var instanceId in instanceIds.Distinct())
{
cancellationToken.ThrowIfCancellationRequested();
if (!deployedHashes.TryGetValue(instanceId, out var deployedHash))
continue;
evaluated++;
// Read-only path: hash only, so skip the Roslyn compile — the deploy
// gate remains the authoritative compile.
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(
instanceId, cancellationToken, validateScripts: false, session);
if (currentResult.IsFailure)
{
_logger.LogDebug(
"Could not evaluate staleness of instance {InstanceId}: {Error}",
instanceId, currentResult.Error);
if (failedCount == 0)
{
firstFailureInstanceId = instanceId;
firstFailureError = currentResult.Error;
}
failedCount++;
staleness[instanceId] = null;
continue;
}
var currentHash = currentResult.Value.RevisionHash;
if (string.Equals(currentHash, deployedHash, StringComparison.Ordinal))
{
staleness[instanceId] = false;
continue;
}
// Mismatch only: pay the fat snapshot read + I-1 normalization, so an
// old-form List snapshot reads exactly as the Topology badge renders it.
var snapshot = await _repository.GetDeployedSnapshotByInstanceIdAsync(instanceId, cancellationToken);
if (snapshot == null)
continue;
var normalizedDeployedHash = ComputeDeployedHash(snapshot, instanceId, out _);
staleness[instanceId] =
!string.Equals(normalizedDeployedHash, currentHash, StringComparison.Ordinal);
}
if (failedCount > 0)
{
_logger.LogWarning(
"Could not evaluate staleness of {FailedCount} of {EvaluatedCount} instances; " +
"first failure: instance {InstanceId}: {Error}",
failedCount, evaluated, firstFailureInstanceId, firstFailureError);
}
return staleness;
}
/// <summary>
/// I-1 (latent): returns a copy of <paramref name="config"/> whose
/// <see cref="DataType.List"/> attribute values have been round-tripped through
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
/// <summary>
/// Computes the fleet-wide "pending updates" aggregate — which sites are running
/// out-of-date system-wide artifacts and how many deployed instances have drifted
/// from their deployed configuration.
///
/// <para>
/// Scoped, and deliberately uncached at this seam: caching belongs to the Central
/// UI's single-flight TTL cache, which runs this evaluator in its own DI scope so
/// the fleet is flattened at most once per TTL regardless of how many circuits are
/// watching. Callers that resolve the evaluator directly pay a full fleet flatten
/// per call.
/// </para>
/// </summary>
public interface IPendingUpdatesEvaluator
{
/// <summary>
/// Evaluates every site and every deployed instance, returning one snapshot.
/// </summary>
/// <param name="cancellationToken">Cancellation token, honoured between instances.</param>
/// <returns>A snapshot of the fleet's pending updates as of now.</returns>
Task<PendingUpdatesSnapshot> EvaluateAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,183 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
/// <summary>
/// Default <see cref="IPendingUpdatesEvaluator"/>: one pass over the fleet that
/// answers both halves of "is anything waiting to be deployed?" — per-site artifact
/// staleness and per-instance configuration staleness.
///
/// <para>
/// <b>How it stays affordable.</b> Both halves are batch reads, not per-entity loops.
/// <see cref="ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync"/> hoists
/// the global artifact queries once for the whole fleet, and
/// <see cref="DeploymentService.GetInstanceStalenessAsync"/> runs every flatten
/// through ONE <see cref="FlattenSession"/> so a template chain shared by N
/// instances is walked once. It is still a whole-fleet flatten, which is why the
/// caller caches it behind a TTL rather than calling it per request.
/// </para>
///
/// <para>
/// <b>Exceptions propagate.</b> Nothing is swallowed and no partial snapshot is
/// invented: the caller's single-flight cache discards a faulted flight and the UI
/// renders <i>unavailable</i>. Degrading a failed read to "up to date" is the one
/// answer that must never be manufactured — it would silently hide a fleet waiting
/// on a deploy.
/// </para>
/// </summary>
public sealed class PendingUpdatesEvaluator : IPendingUpdatesEvaluator
{
private readonly ISiteRepository _siteRepository;
private readonly ITemplateEngineRepository _templateRepository;
private readonly ArtifactDeploymentService _artifactDeploymentService;
private readonly DeploymentService _deploymentService;
private readonly ILogger<PendingUpdatesEvaluator> _logger;
/// <summary>
/// Initializes a new evaluator.
/// </summary>
/// <param name="siteRepository">Repository the site list is read from.</param>
/// <param name="templateRepository">Repository the instance list is read from.</param>
/// <param name="artifactDeploymentService">Computes each site's current artifact revision hash.</param>
/// <param name="deploymentService">Evaluates instance staleness in one batched flatten session.</param>
/// <param name="logger">Logger instance.</param>
public PendingUpdatesEvaluator(
ISiteRepository siteRepository,
ITemplateEngineRepository templateRepository,
ArtifactDeploymentService artifactDeploymentService,
DeploymentService deploymentService,
ILogger<PendingUpdatesEvaluator> logger)
{
_siteRepository = siteRepository;
_templateRepository = templateRepository;
_artifactDeploymentService = artifactDeploymentService;
_deploymentService = deploymentService;
_logger = logger;
}
/// <inheritdoc />
public async Task<PendingUpdatesSnapshot> EvaluateAsync(CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var sites = await _siteRepository.GetAllSitesAsync(cancellationToken);
var instances = await _templateRepository.GetAllInstancesAsync(cancellationToken);
var currentArtifactHashes =
await _artifactDeploymentService.ComputeCurrentArtifactHashesAsync(sites, cancellationToken);
// Only instances the operator has actually deployed can be stale; a
// NotDeployed instance has nothing to compare against, and flattening it
// would be pure cost on every TTL expiry.
var deployedInstances = instances.Where(i => i.State != InstanceState.NotDeployed).ToList();
var staleness = await _deploymentService.GetInstanceStalenessAsync(
deployedInstances.Select(i => i.Id).ToList(), cancellationToken);
// Grouped ONCE, not re-scanned per site: tallying by filtering the whole
// deployed set inside the site loop would make the aggregate O(sites x
// instances) — the one genuinely per-entity loop in an otherwise batched pass.
var instancesBySite = deployedInstances.ToLookup(i => i.SiteId);
var rows = new List<SitePendingUpdates>(sites.Count);
foreach (var site in sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
{
var (deployed, stale, unknown) = TallyInstances(instancesBySite[site.Id], staleness);
rows.Add(new SitePendingUpdates(
site.Id,
site.SiteIdentifier,
site.Name,
ResolveArtifactState(site, currentArtifactHashes),
site.ArtifactsDeployedAt,
deployed,
stale,
unknown));
}
var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, rows);
// One line per evaluation — this runs at most once per cache TTL, so it is
// the cheapest record of what the fleet looked like and what the sweep cost.
_logger.LogInformation(
"Pending-updates snapshot: {SiteCount} site(s), {DeployedInstanceCount} deployed instance(s), " +
"{StaleInstanceCount} stale instance(s), {StaleArtifactSiteCount} site(s) with out-of-date " +
"artifacts, in {ElapsedMs} ms",
rows.Count,
rows.Sum(r => r.DeployedInstanceCount),
snapshot.StaleInstanceCount,
snapshot.StaleArtifactSiteCount,
stopwatch.ElapsedMilliseconds);
return snapshot;
}
/// <summary>
/// Decides a site's artifact state by comparing the hash stamped by its last
/// successful artifact deploy against the hash the current definitions would
/// produce. A site with no stamp has never had artifacts deployed, which is
/// <see cref="ArtifactDeploymentState.NeverDeployed"/>, NOT out of date.
///
/// <para>
/// A stamped site missing from the computed map is reported out of date. That is a
/// defensive default, not a reachable race: this call path hands the SAME site list
/// to <see cref="ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync"/>,
/// which sets an entry for every input site or throws. It is kept so that a future
/// producer of a partial map fails toward VISIBLE (out of date, an operator looks)
/// rather than hidden (current, a stale site silently reads clean).
/// </para>
/// </summary>
/// <param name="site">The site to classify.</param>
/// <param name="currentArtifactHashes">Site id to the hash a deploy would send right now.</param>
/// <returns>The site's artifact deployment state.</returns>
private static ArtifactDeploymentState ResolveArtifactState(
Site site,
IReadOnlyDictionary<int, string> currentArtifactHashes)
{
if (site.ArtifactRevisionHash is null)
return ArtifactDeploymentState.NeverDeployed;
return currentArtifactHashes.TryGetValue(site.Id, out var currentHash)
&& string.Equals(site.ArtifactRevisionHash, currentHash, StringComparison.Ordinal)
? ArtifactDeploymentState.Current
: ArtifactDeploymentState.OutOfDate;
}
/// <summary>
/// Counts one site's deployed, stale and un-evaluatable instances. Only instances
/// present in the staleness result are counted — the batch OMITS an instance with
/// no deployed snapshot at evaluation time (including one that was undeployed
/// mid-batch), which is exactly the "nothing to be stale against" exclusion the
/// counts need.
/// </summary>
/// <param name="siteInstances">
/// This site's instances in a state other than NotDeployed, pre-grouped by the caller.
/// </param>
/// <param name="staleness">Instance id to stale (true) / current (false) / unknown (null).</param>
/// <returns>The deployed, stale and unknown counts for the site.</returns>
private static (int Deployed, int Stale, int Unknown) TallyInstances(
IEnumerable<Instance> siteInstances,
IReadOnlyDictionary<int, bool?> staleness)
{
var deployed = 0;
var stale = 0;
var unknown = 0;
foreach (var instance in siteInstances)
{
if (!staleness.TryGetValue(instance.Id, out var isStale))
continue;
deployed++;
if (isStale == true)
stale++;
else if (isStale is null)
unknown++;
}
return (deployed, stale, unknown);
}
}
@@ -0,0 +1,127 @@
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
/// <summary>
/// State of a site's system-wide artifact set (shared scripts, external systems,
/// database connections, data connections) relative to the definitions central holds
/// right now.
/// </summary>
public enum ArtifactDeploymentState
{
/// <summary>
/// Artifacts have never been deployed to this site (the site row carries no
/// revision hash). Deliberately NOT a pending update: nothing has drifted, the
/// operator simply has not deployed yet. Surfaced on the Sites page so a
/// freshly-created site is obvious, never counted in the fleet totals or
/// escalated by the rail indicator.
/// </summary>
NeverDeployed,
/// <summary>
/// The hash stamped by the last successful artifact deploy equals the hash the
/// current definitions would produce — nothing to deploy.
/// </summary>
Current,
/// <summary>
/// The current definitions hash differently from what was last deployed, so the
/// site is running stale artifacts and a redeploy is pending.
/// </summary>
OutOfDate
}
/// <summary>
/// One site's row in a <see cref="PendingUpdatesSnapshot"/>: its artifact state plus
/// the instance staleness tallies that back the Sites-page badge and the rail
/// indicator's breakdown.
/// </summary>
/// <param name="SiteId">Database identifier of the site.</param>
/// <param name="SiteIdentifier">Machine-readable site identifier (as used in routing).</param>
/// <param name="SiteName">Human-readable site display name.</param>
/// <param name="ArtifactState">Whether this site's system-wide artifacts are current.</param>
/// <param name="ArtifactsDeployedAt">
/// UTC instant of the last successful artifact deploy to this site, or
/// <see langword="null"/> when artifacts have never been deployed. Rendered as the
/// badge tooltip.
/// </param>
/// <param name="DeployedInstanceCount">
/// Instances at this site that HAVE a deployed configuration snapshot — the
/// denominator of "N of M instances out of date". An instance the operator has never
/// deployed has nothing to be stale against and is not counted here.
/// </param>
/// <param name="StaleInstanceCount">
/// Deployed instances at this site whose current flatten differs from what is
/// deployed, i.e. a redeploy is pending.
/// </param>
/// <param name="UnknownInstanceCount">
/// Deployed instances at this site whose staleness could NOT be evaluated (their
/// flatten failed — a missing template, a broken binding). Never counted as stale:
/// the fleet totals must not invent a pending update out of a read failure.
/// </param>
public sealed record SitePendingUpdates(
int SiteId,
string SiteIdentifier,
string SiteName,
ArtifactDeploymentState ArtifactState,
DateTimeOffset? ArtifactsDeployedAt,
int DeployedInstanceCount,
int StaleInstanceCount,
int UnknownInstanceCount);
/// <summary>
/// One fleet-wide answer to "is anything waiting to be deployed?" — every site's
/// artifact state and instance staleness tallies as of a single instant.
///
/// <para>
/// Computed by <see cref="IPendingUpdatesEvaluator"/> and held behind the Central
/// UI's single-flight TTL cache, so however many circuits are watching, the fleet is
/// flattened at most once per TTL. An immutable value: a scoped view is taken with
/// <see cref="ForSites"/> rather than by mutating the snapshot.
/// </para>
/// </summary>
/// <param name="ComputedAt">
/// UTC instant the snapshot was computed. Rendered as the "as of HH:mm UTC" caption
/// and preserved verbatim through <see cref="ForSites"/> — a scoped view is the same
/// observation, not a newer one.
/// </param>
/// <param name="Sites">Per-site rows, ordered by site name (ordinal, ignore-case).</param>
public sealed record PendingUpdatesSnapshot(
DateTimeOffset ComputedAt,
IReadOnlyList<SitePendingUpdates> Sites)
{
/// <summary>
/// Total instances across the snapshot's sites whose deployed configuration has
/// drifted from their current flatten. Instances whose staleness could not be
/// evaluated are excluded.
/// </summary>
public int StaleInstanceCount => Sites.Sum(s => s.StaleInstanceCount);
/// <summary>
/// Number of sites running out-of-date system-wide artifacts.
/// <see cref="ArtifactDeploymentState.NeverDeployed"/> is deliberately NOT counted
/// — the operator has not deployed yet, which is not a pending update.
/// </summary>
public int StaleArtifactSiteCount =>
Sites.Count(s => s.ArtifactState == ArtifactDeploymentState.OutOfDate);
/// <summary>
/// Whether anything in this snapshot is waiting to be deployed — the single
/// predicate the rail indicator switches on.
/// </summary>
public bool HasPendingUpdates => StaleInstanceCount > 0 || StaleArtifactSiteCount > 0;
/// <summary>
/// Returns a copy of this snapshot keeping only the named sites, preserving
/// <see cref="ComputedAt"/>. Used to narrow the fleet snapshot to a site-scoped
/// operator's permitted sites, so the computed totals never include a site the
/// user cannot see.
/// </summary>
/// <param name="siteIds">Database identifiers of the sites to keep.</param>
/// <returns>A snapshot over the retained sites, in the same order.</returns>
public PendingUpdatesSnapshot ForSites(IReadOnlySet<int> siteIds)
{
ArgumentNullException.ThrowIfNull(siteIds);
return new PendingUpdatesSnapshot(
ComputedAt,
Sites.Where(s => siteIds.Contains(s.SiteId)).ToList());
}
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -58,12 +59,24 @@ public static class ServiceCollectionExtensions
services.AddScoped<DeploymentService>();
services.AddScoped<ArtifactDeploymentService>();
// Deterministic hash of a per-site artifact set, stamped on the Site row after a
// successful artifact deploy and recomputed to answer "are this site's artifacts
// current?". Singleton — it holds no state and touches no DbContext.
services.AddSingleton<ArtifactRevisionHashService>();
// Expose the flattening pipeline's revision-hash computation
// to the Transport bundle importer through the Commons IStaleInstanceProbe
// seam, so a template overwrite can enumerate the deployed instances whose
// flattened config has drifted from their deployed snapshot. Scoped so it
// shares the per-request DbContext with the in-flight import.
services.AddScoped<IStaleInstanceProbe, StaleInstanceProbe>();
// Fleet-wide "is anything waiting to be deployed?" aggregate behind the
// Central UI rail indicator and the Sites-page artifact badges. Scoped because
// it reads through the same per-request DbContext as the services it composes;
// the Central UI's singleton single-flight cache runs it in its own DI scope so
// the fleet is flattened at most once per cache TTL.
services.AddScoped<IPendingUpdatesEvaluator, PendingUpdatesEvaluator>();
return services;
}
@@ -0,0 +1,160 @@
using System.Text.RegularExpressions;
using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
using Xunit;
using Xunit.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
/// <summary>
/// Real-browser coverage for the pending-updates surfaces (design
/// docs/plans/2026-09-14-pending-updates-design.md, plan task 10): the rail-footer
/// indicator, the Sites page per-site Artifacts badge and its Re-check control, and the
/// rail nav's scroll pane.
///
/// <para>
/// These are the browser half of the feature's live gate — <c>docker/verify-pending-updates.sh</c>
/// proves the server side (the hash is stamped, moves with content, and is deterministic) through
/// the CLI, but the badge text and the indicator only land after the Blazor circuit's post-render
/// load, which curl never sees.
/// </para>
///
/// <para>
/// The indicator is role-gated to Deployer/Administrator; the fixture's <c>multi-role</c> user has
/// both, and the rig runs with login disabled (every session carries every role), so it renders
/// either way.
/// </para>
/// </summary>
[Collection("Playwright")]
public class PendingUpdatesTests
{
/// <summary>Terminal states of the rail indicator — anything other than "still checking".</summary>
private static readonly Regex SettledIndicator = new("Up to date|Pending updates|Updates unavailable");
private readonly PlaywrightFixture _fixture;
private readonly ITestOutputHelper _output;
public PendingUpdatesTests(PlaywrightFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
}
[SkippableFact]
public async Task RailFooter_ShowsPendingUpdatesIndicator_AndSettles()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.SetViewportSizeAsync(1280, 900);
await page.GotoAsync(PlaywrightFixture.BaseUrl);
// The block itself paints on the first render — before any snapshot exists it reads
// "Checking for updates…", which is exactly the state the component is designed to show
// rather than holding the layout behind a cold fleet flatten.
var indicator = page.Locator(".rail-updates");
await Assertions.Expect(indicator).ToBeVisibleAsync(new() { Timeout = 10_000 });
await Assertions.Expect(indicator).ToContainTextAsync(
new Regex("Checking for updates|Up to date|Pending updates|Updates unavailable"),
new() { Timeout = 10_000 });
// The cache's single-flight evaluation lands well inside a minute on the rig; the
// indicator must not sit on "Checking…" forever.
await Assertions.Expect(indicator).ToContainTextAsync(SettledIndicator, new() { Timeout = 60_000 });
var text = (await indicator.InnerTextAsync()).Trim();
_output.WriteLine($"rail indicator settled on: {text.ReplaceLineEndings(" | ")}");
Assert.False(
text.Contains("Updates unavailable", StringComparison.Ordinal),
$"the fleet snapshot could not be read — indicator says: {text}");
}
[SkippableFact]
public async Task SitesPage_ShowsAnArtifactsBadgePerSite_AndARecheckControl()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.SetViewportSizeAsync(1280, 900);
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await Assertions.Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
// One card per site; the badge hangs off the card's site-identifier line, so there must be
// at least as many badges as cards.
var cards = page.Locator("div.card-body h5.card-title");
await Assertions.Expect(cards.First).ToBeVisibleAsync(new() { Timeout = 15_000 });
int cardCount = await cards.CountAsync();
Assert.True(cardCount >= 1, "expected at least one site card — run docker/seed-sites.sh");
var badges = page.Locator("[aria-label^='Artifacts:']");
await Assertions.Expect(badges.First).ToBeVisibleAsync(new() { Timeout = 60_000 });
int badgeCount = await badges.CountAsync();
Assert.True(
badgeCount >= cardCount,
$"expected an Artifacts badge on every site card: {cardCount} cards but {badgeCount} badges");
// The badge must settle on a real verdict rather than staying on "checking…".
var settled = page.Locator(
"[aria-label='Artifacts: current'], [aria-label='Artifacts: out of date'], [aria-label='Artifacts: never deployed']");
await Assertions.Expect(settled.First).ToBeVisibleAsync(new() { Timeout = 60_000 });
var badgeText = (await settled.First.InnerTextAsync()).Trim();
_output.WriteLine($"{badgeCount} Artifacts badge(s) over {cardCount} site card(s); first reads: {badgeText}");
Assert.Matches("Artifacts current|Artifacts out of date|Artifacts never deployed", badgeText);
// Re-check re-evaluates the whole fleet through the same cache, forced.
var recheck = page.Locator("button[aria-label='Re-check pending updates']");
await Assertions.Expect(recheck).ToBeVisibleAsync();
await Assertions.Expect(recheck).ToBeEnabledAsync(new() { Timeout = 60_000 });
await recheck.ClickAsync();
// After the forced re-evaluation the badges are still there and still settled — a failed
// re-check would degrade them to "Artifacts: unavailable".
await Assertions.Expect(settled.First).ToBeVisibleAsync(new() { Timeout = 60_000 });
Assert.Equal(0, await page.Locator("[aria-label='Artifacts: unavailable']").CountAsync());
}
[SkippableFact]
public async Task NavRail_HasAScrollPane_AtDesktopWidth()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync();
// The rule is scoped to lg+ (min-width: 992px); below that the rail is inline and
// scrolls with the page, so the pane deliberately does not apply.
await page.SetViewportSizeAsync(1280, 900);
await page.GotoAsync(PlaywrightFixture.BaseUrl);
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await PlaywrightFixture.ExpandAllNavSectionsAsync(page);
var scroll = page.Locator("div.rail-scroll");
Assert.Equal(1, await scroll.CountAsync());
var overflowY = await scroll.EvaluateAsync<string>("el => getComputedStyle(el).overflowY");
Assert.Equal("auto", overflowY);
// The point of the pane is that a long nav scrolls INSIDE it instead of growing the rail
// and carrying the footer off-screen. Assert that: the pane is bounded by the viewport,
// and the rail footer (which holds the indicator this feature adds) stays within it.
// scrollHeight >= clientHeight is NOT asserted — that holds for every element.
var metrics = await scroll.EvaluateAsync<int[]>(
"el => [el.scrollHeight, el.clientHeight, Math.round(el.getBoundingClientRect().bottom)]");
_output.WriteLine(
$"rail-scroll: scrollHeight={metrics[0]} clientHeight={metrics[1]} bottom={metrics[2]} (viewport 900)");
Assert.True(
metrics[1] > 0 && metrics[1] <= 900,
$"rail scroll pane should be bounded by the 900px viewport, clientHeight was {metrics[1]}");
Assert.True(
metrics[2] <= 900,
$"rail scroll pane overflows the viewport (bottom {metrics[2]} > 900) — the nav is growing the rail instead of scrolling");
// The footer the indicator lives in must be on-screen, below the pane.
var footer = page.Locator(".rail-updates");
var footerBottom = await footer.EvaluateAsync<int>(
"el => Math.round(el.getBoundingClientRect().bottom)");
Assert.True(
footerBottom <= 900,
$"rail footer is off-screen (bottom {footerBottom} > 900) — the scroll pane is not containing the nav");
}
}
@@ -6,11 +6,13 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
using SitesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.Sites;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
@@ -25,11 +27,18 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
public class SitesPageTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IPendingUpdatesCache _pendingUpdates = Substitute.For<IPendingUpdatesCache>();
private void RegisterServices()
{
Services.AddSingleton(_siteRepo);
// Default: an empty snapshot, so the page's badges resolve without a stub.
// Individual tests re-stub GetAsync before rendering.
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot()));
Services.AddSingleton(_pendingUpdates);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
@@ -44,7 +53,8 @@ public class SitesPageTests : BunitContext
comms,
Substitute.For<IAuditService>(),
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance);
NullLogger<ArtifactDeploymentService>.Instance,
new ArtifactRevisionHashService());
Services.AddSingleton(artifactSvc);
Services.AddSingleton<IDialogService>(Substitute.For<IDialogService>());
@@ -70,6 +80,27 @@ public class SitesPageTests : BunitContext
private static DataConnection Conn(int siteId, string name)
=> new(name, "OpcUa", siteId);
private static PendingUpdatesSnapshot Snapshot(params SitePendingUpdates[] sites)
=> new(new DateTimeOffset(2026, 9, 14, 10, 30, 0, TimeSpan.Zero), sites);
private static SitePendingUpdates Pending(
int siteId,
ArtifactDeploymentState state,
int deployed = 0,
int stale = 0,
int unknown = 0)
=> new(
siteId,
$"SITE-{siteId}",
$"Site{siteId}",
state,
state == ArtifactDeploymentState.NeverDeployed
? null
: new DateTimeOffset(2026, 9, 13, 8, 0, 0, TimeSpan.Zero),
deployed,
stale,
unknown);
[Fact]
public void LoadData_FetchesAllConnectionsInOneQuery_NoPerSiteQueries()
{
@@ -102,4 +133,144 @@ public class SitesPageTests : BunitContext
Assert.Contains("alpha-conn", cut.Markup);
Assert.Contains("beta-conn", cut.Markup);
}
[Fact]
public void ArtifactBadge_ShowsCurrent_OutOfDate_NeverDeployed()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2, 3));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current),
Pending(2, ArtifactDeploymentState.OutOfDate),
Pending(3, ArtifactDeploymentState.NeverDeployed))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("aria-label=\"Artifacts: current\"", cut.Markup);
Assert.Contains("Artifacts current", cut.Markup);
Assert.Contains("aria-label=\"Artifacts: out of date\"", cut.Markup);
Assert.Contains("Artifacts out of date", cut.Markup);
Assert.Contains("aria-label=\"Artifacts: never deployed\"", cut.Markup);
Assert.Contains("Artifacts never deployed", cut.Markup);
});
}
[Fact]
public void InstanceLine_ShowsStaleOfDeployed()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current, deployed: 5, stale: 2, unknown: 1))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("2 of 5 instances out of date", cut.Markup);
Assert.Contains("1 could not be evaluated", cut.Markup);
Assert.Contains("/deployment/topology", cut.Markup);
});
}
[Fact]
public void InstanceLine_PluralisesTheSingleInstanceCase()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current, deployed: 1, stale: 1),
Pending(2, ArtifactDeploymentState.Current, deployed: 1))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("1 of 1 instance out of date", cut.Markup);
Assert.Contains("All 1 instance current", cut.Markup);
Assert.DoesNotContain("1 instances", cut.Markup);
});
}
[Fact]
public void Recheck_ForcesRefresh()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
var cut = Render<SitesPage>();
cut.Find("button[aria-label=\"Re-check pending updates\"]").Click();
cut.WaitForAssertion(() =>
_pendingUpdates.Received().GetAsync(true, Arg.Any<CancellationToken>()));
}
[Fact]
public void SiteLoadFailure_SkipsTheFleetEvaluation()
{
RegisterServices();
_siteRepo.GetAllSitesAsync()
.Returns<Task<IReadOnlyList<Site>>>(_ => throw new InvalidOperationException("db down"));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() => Assert.Contains("Failed to load sites", cut.Markup));
// No cards to badge, so the whole-fleet flatten must not run at all.
_pendingUpdates.DidNotReceive().GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
[Fact]
public void FailedRecheck_ClearsTheStaleAsOfCaption()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(Pending(1, ArtifactDeploymentState.Current))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() => Assert.Contains("Updates as of", cut.Markup));
// The re-check now faults: the error wins, so the caption from the earlier good
// evaluation must not stand next to "unavailable" badges.
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns<Task<PendingUpdatesSnapshot>>(_ => throw new InvalidOperationException("evaluator down"));
cut.Find("button[aria-label=\"Re-check pending updates\"]").Click();
cut.WaitForAssertion(() =>
{
Assert.DoesNotContain("Updates as of", cut.Markup);
Assert.Contains("Artifacts: unavailable", cut.Markup);
Assert.Contains("Site1", cut.Markup);
});
}
[Fact]
public void CacheFault_ShowsUnavailable_AndSitesStillRender()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns<Task<PendingUpdatesSnapshot>>(_ => throw new InvalidOperationException("evaluator down"));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
// The snapshot failing must never take the site list down with it.
Assert.Contains("Site1", cut.Markup);
Assert.Contains("Site2", cut.Markup);
Assert.Contains("Artifacts: unavailable", cut.Markup);
});
}
}
@@ -161,4 +161,67 @@ public class SiteScopeServiceTests
Assert.False(await svc.IsSystemWideAsync());
Assert.False(await svc.IsSiteAllowedAsync(2));
}
// ── Resolve(ClaimsPrincipal): the same rule as a pure function ───────────
// Display-gating callers (the rail pending-updates indicator) hold a principal
// already and must NOT pay for the instance methods' authoritative, fail-closed
// state refresh — which for a polling component would be a forced session
// revalidation per tick per circuit. These pin that the pure overload answers
// identically to the async path.
private static ClaimsPrincipal Principal(params Claim[] claims)
=> new(new ClaimsIdentity(claims, authenticationType: "TestCookie"));
[Fact]
public void Resolve_Administrator_IsSystemWideWithAnEmptySet()
{
// Even carrying SiteId claims: an Administrator's ids are not collected at
// all, so "system-wide => empty set" holds for every caller.
var (isSystemWide, sites) = SiteScopeService.Resolve(
Principal(Role("Administrator"), SiteClaim(1), SiteClaim(2)));
Assert.True(isSystemWide);
Assert.Empty(sites);
}
[Fact]
public void Resolve_SiteClaims_YieldTheScopedSet()
{
var (isSystemWide, sites) = SiteScopeService.Resolve(
Principal(Role("Deployer"), SiteClaim(1), SiteClaim(3)));
Assert.False(isSystemWide);
Assert.Equal([1, 3], sites.OrderBy(id => id));
}
[Fact]
public void Resolve_NoSiteClaims_IsSystemWide()
{
var (isSystemWide, sites) = SiteScopeService.Resolve(Principal(Role("Deployer")));
Assert.True(isSystemWide);
Assert.Empty(sites);
}
[Fact]
public void Resolve_UnauthenticatedPrincipal_IsScopedToNothing()
{
// Fail closed: not system-wide AND no permitted sites.
var (isSystemWide, sites) = SiteScopeService.Resolve(new ClaimsPrincipal(new ClaimsIdentity()));
Assert.False(isSystemWide);
Assert.Empty(sites);
}
[Fact]
public async Task Resolve_AgreesWithTheAsyncPath()
{
var claims = new[] { Role("Deployer"), SiteClaim(2), SiteClaim(5) };
var svc = ForUser(claims);
var (isSystemWide, sites) = SiteScopeService.Resolve(Principal(claims));
Assert.Equal(await svc.IsSystemWideAsync(), isSystemWide);
Assert.Equal((await svc.PermittedSiteIdsAsync()).OrderBy(id => id), sites.OrderBy(id => id));
}
}
@@ -241,4 +241,25 @@ public class NavMenuTests : BunitContext
Assert.DoesNotContain("/audit/log", cut.Markup);
});
}
/// <summary>
/// ThemeShell's <c>nav.side-rail</c> never fills the sticky 100vh rail, so
/// a long expanded nav grows past the viewport and pushes
/// <c>.rail-foot</c> off-screen. <c>NavMenu</c> wraps its items in a
/// <c>div.rail-scroll</c> pane that <c>site.css</c> makes scroll on its
/// own (lg+), leaving the brand bar and rail footer pinned. This asserts
/// the wrapper exists and actually contains the nav sections, not just
/// that the class string appears somewhere in the markup.
/// </summary>
[Fact]
public void NavItems_RenderInsideTheRailScrollPane()
{
var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
cut.WaitForAssertion(() =>
{
Assert.Contains("class=\"rail-scroll\"", cut.Markup);
Assert.NotEmpty(cut.FindAll("div.rail-scroll details.rail-section"));
});
}
}
@@ -0,0 +1,415 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Layout;
/// <summary>
/// bUnit rendering tests for <see cref="PendingUpdatesIndicator"/>, the rail-footer
/// "is anything waiting to be deployed?" block. Three behaviours are load-bearing and
/// pinned here: it is invisible to anyone who is neither Deployer nor Administrator
/// (it advertises fleet state), its two breakdown links are gated independently by the
/// role that can actually act on each (Topology ⇒ Deployer, Sites ⇒ Administrator), and
/// a site-scoped operator's totals count only their permitted sites.
/// <para>
/// A fourth property is pinned for cost rather than correctness: because this block
/// lives in MainLayout, roles and scope must be resolved once per authentication
/// state, NEVER once per 30s poll — a per-poll resolve would be a forced session
/// revalidation on every page of every circuit.
/// </para>
/// </summary>
public class PendingUpdatesIndicatorTests : BunitContext
{
private static Claim Role(string role) => new(JwtTokenService.RoleClaimType, role);
private static Claim SiteClaim(int siteId) => new(JwtTokenService.SiteIdClaimType, siteId.ToString());
private static SitePendingUpdates SiteRow(
int siteId,
int staleInstances,
ArtifactDeploymentState artifacts = ArtifactDeploymentState.Current)
=> new(
siteId,
$"SITE-{siteId}",
$"Site {siteId}",
artifacts,
DateTimeOffset.UtcNow,
DeployedInstanceCount: 10,
StaleInstanceCount: staleInstances,
UnknownInstanceCount: 0);
private static PendingUpdatesSnapshot Snapshot(params SitePendingUpdates[] sites)
=> new(DateTimeOffset.UtcNow, sites);
/// <summary>Builds a cache substitute answering every <c>GetAsync</c> with the given snapshot.</summary>
private static IPendingUpdatesCache CacheReturning(PendingUpdatesSnapshot snapshot)
{
var cache = Substitute.For<IPendingUpdatesCache>();
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult(snapshot));
return cache;
}
/// <summary>Builds a cache substitute whose every <c>GetAsync</c> faults.</summary>
private static IPendingUpdatesCache CacheFaulting(string message)
{
var cache = Substitute.For<IPendingUpdatesCache>();
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<PendingUpdatesSnapshot>(new InvalidOperationException(message)));
return cache;
}
private IRenderedComponent<PendingUpdatesIndicator> RenderFor(IPendingUpdatesCache cache, params Claim[] claims)
{
// "TestAuth" authentication type => IsAuthenticated == true, so SiteScopeService
// collects the SiteId claims instead of short-circuiting to "no scope".
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
return Render<PendingUpdatesIndicator>();
}
/// <summary>An auth provider that records how many times its state was asked for.</summary>
private sealed class CountingAuthStateProvider : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
public CountingAuthStateProvider(ClaimsPrincipal user) => _user = user;
public int Calls { get; private set; }
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
Calls++;
return Task.FromResult(new AuthenticationState(_user));
}
}
/// <summary>
/// Renders the indicator under an explicit cascaded <see cref="AuthenticationState"/> —
/// the same cascade <c>CascadingAuthenticationState</c> supplies in the real app.
/// </summary>
private IRenderedComponent<PendingUpdatesIndicator> RenderCascaded(
IPendingUpdatesCache cache,
AuthenticationStateProvider provider,
ClaimsPrincipal cascadedUser)
{
Services.AddSingleton(provider);
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
var host = Render<CascadingValue<Task<AuthenticationState>>>(parameters => parameters
.Add(p => p.Value, Task.FromResult(new AuthenticationState(cascadedUser)))
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<PendingUpdatesIndicator>(0);
builder.CloseComponent();
})));
return host.FindComponent<PendingUpdatesIndicator>();
}
[Fact]
public void Viewer_RendersNothing()
{
var cut = RenderFor(CacheReturning(Snapshot(SiteRow(1, staleInstances: 3))), Role("Viewer"));
Assert.True(string.IsNullOrWhiteSpace(cut.Markup));
}
[Fact]
public void Deployer_WithNothingPending_ShowsUpToDate()
{
var cut = RenderFor(
CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))),
Role(Roles.Deployer));
cut.WaitForAssertion(() =>
{
Assert.Contains("Up to date", cut.Markup);
Assert.DoesNotContain("Pending updates", cut.Markup);
Assert.DoesNotContain("Checking for updates", cut.Markup);
});
}
[Fact]
public void Snapshot_IsCaptionedWithItsComputedInstant()
{
var cut = RenderFor(
CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))),
Role(Roles.Deployer));
cut.WaitForAssertion(() =>
{
// The block's tooltip is the snapshot's as-of time, not "now".
Assert.Contains("as of ", cut.Markup);
Assert.Contains(" UTC", cut.Markup);
});
}
[Fact]
public void DeployerAndAdministrator_WithPendingWork_ShowsTotalAndBothLinks()
{
// 3 stale instances at site 1 + site 2 running out-of-date artifacts => total 4.
var cut = RenderFor(
CacheReturning(Snapshot(
SiteRow(1, staleInstances: 3),
SiteRow(2, staleInstances: 0, ArtifactDeploymentState.OutOfDate))),
Role(Roles.Deployer),
Role(Roles.Administrator));
cut.WaitForAssertion(() =>
{
Assert.Contains("Pending updates", cut.Markup);
var badge = cut.Find("span.badge");
Assert.Equal("4", badge.TextContent.Trim());
Assert.Contains("3 instances", cut.Markup);
Assert.Contains("1 site", cut.Markup);
Assert.DoesNotContain("1 sites", cut.Markup);
// Both halves present => they are separated, not run together.
Assert.Contains("·", cut.Find("span.rail-updates-detail").TextContent);
Assert.NotNull(cut.Find("a[href='/deployment/topology']"));
Assert.NotNull(cut.Find("a[href='/admin/sites']"));
});
}
[Fact]
public void DeployerWithoutAdministrator_ShowsSiteCountAsPlainText()
{
var cut = RenderFor(
CacheReturning(Snapshot(
SiteRow(1, staleInstances: 1),
SiteRow(2, staleInstances: 0, ArtifactDeploymentState.OutOfDate))),
Role(Roles.Deployer));
cut.WaitForAssertion(() =>
{
// Singular noun for a count of one, and the instance half still links.
Assert.Contains("1 instance", cut.Markup);
Assert.DoesNotContain("1 instances", cut.Markup);
Assert.NotNull(cut.Find("a[href='/deployment/topology']"));
// Administrator-only destination is not offered to a plain Deployer.
Assert.Contains("1 site", cut.Markup);
Assert.Empty(cut.FindAll("a[href='/admin/sites']"));
});
}
[Fact]
public void AdministratorWithoutDeployer_ShowsInstanceCountAsPlainText()
{
var cut = RenderFor(
CacheReturning(Snapshot(
SiteRow(1, staleInstances: 2),
SiteRow(2, staleInstances: 0, ArtifactDeploymentState.OutOfDate))),
Role(Roles.Administrator));
cut.WaitForAssertion(() =>
{
Assert.Contains("2 instances", cut.Markup);
Assert.Empty(cut.FindAll("a[href='/deployment/topology']"));
Assert.NotNull(cut.Find("a[href='/admin/sites']"));
});
}
[Fact]
public void CacheFault_ShowsUnavailableWithTheMessageAsTooltip()
{
var cut = RenderFor(CacheFaulting("evaluator exploded"), Role(Roles.Deployer));
cut.WaitForAssertion(() =>
{
Assert.Contains("Updates unavailable", cut.Markup);
Assert.Contains("evaluator exploded", cut.Markup);
// A failed read must never be rendered as a clean bill of health.
Assert.DoesNotContain("Up to date", cut.Markup);
});
}
[Fact]
public void SiteScopedDeployer_CountsOnlyPermittedSites()
{
// Permitted site 1 has 3 stale instances; site 2 (out of scope) has 5 and
// out-of-date artifacts. Only site 1's tally may reach the badge.
var cut = RenderFor(
CacheReturning(Snapshot(
SiteRow(1, staleInstances: 3),
SiteRow(2, staleInstances: 5, ArtifactDeploymentState.OutOfDate))),
Role(Roles.Deployer),
SiteClaim(1));
cut.WaitForAssertion(() =>
{
var badge = cut.Find("span.badge");
Assert.Equal("3", badge.TextContent.Trim());
Assert.Contains("3 instances", cut.Markup);
// Unscoped, the same snapshot would read "8 instances · 1 site".
Assert.DoesNotContain("8 instances", cut.Markup);
Assert.DoesNotContain("1 site", cut.Markup);
});
}
[Fact]
public void Dispose_AfterRender_DoesNotThrow()
{
var cut = RenderFor(
CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))),
Role(Roles.Deployer));
cut.WaitForAssertion(() => Assert.Contains("Up to date", cut.Markup));
var instance = cut.Instance;
instance.Dispose();
instance.Dispose();
}
[Fact]
public void CascadedAuthenticationState_IsUsedInsteadOfAskingTheProvider()
{
// The provider would report an ANONYMOUS user, so the block can only render
// if the component read the cascaded principal — which is what the app's
// CascadingAuthenticationState supplies.
var anonymous = new CountingAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity()));
var deployer = new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Deployer)], "TestAuth"));
var cut = RenderCascaded(
CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))),
anonymous,
deployer);
cut.WaitForAssertion(() => Assert.Contains("Up to date", cut.Markup));
Assert.Equal(0, anonymous.Calls);
}
[Fact]
public async Task Polling_ResolvesNoFurtherAuthenticationState()
{
// The regression this pins: SiteScopeService's instance methods resolve the
// AUTHORITATIVE state (a fail-closed, DB-backed group→role re-mapping). Doing
// that per tick, from a component that sits in the layout, would cost one or
// two forced session revalidations per circuit every 30s on every page.
var provider = new CountingAuthStateProvider(
new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Deployer), SiteClaim(1)], "TestAuth")));
var cache = CacheReturning(Snapshot(SiteRow(1, staleInstances: 2)));
Services.AddSingleton<AuthenticationStateProvider>(provider);
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
var cut = Render<PendingUpdatesIndicator>();
cut.WaitForAssertion(() => Assert.Contains("2 instances", cut.Markup));
var afterFirstLoad = provider.Calls;
// Three more poll cycles, exactly as the timer drives them.
for (var i = 0; i < 3; i++)
{
await cut.Instance.PollOnceAsync();
}
// Each poll read the fleet cache; none of them re-resolved the principal.
_ = cache.Received(4).GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
Assert.Equal(afterFirstLoad, provider.Calls);
// Scope narrowing still applies on every poll — it reads the resolved fields.
Assert.Contains("2 instances", cut.Markup);
}
[Fact]
public void AuthenticationStateChange_HidesTheBlockWhenTheRoleIsRevoked()
{
var cache = CacheReturning(Snapshot(SiteRow(1, staleInstances: 0)));
var provider = new MutableAuthStateProvider(
new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Deployer)], "TestAuth")));
Services.AddSingleton<AuthenticationStateProvider>(provider);
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
var cut = Render<PendingUpdatesIndicator>();
cut.WaitForAssertion(() => Assert.Contains("Up to date", cut.Markup));
// Role revoked mid-session: the block must go, not linger for the life of
// the circuit showing what the previous principal could see.
provider.SetUser(new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Viewer)], "TestAuth")));
cut.WaitForAssertion(() => Assert.True(string.IsNullOrWhiteSpace(cut.Markup)));
}
/// <summary>An auth provider whose principal can be swapped mid-test, raising the change event.</summary>
private sealed class MutableAuthStateProvider : AuthenticationStateProvider
{
private ClaimsPrincipal _user;
public MutableAuthStateProvider(ClaimsPrincipal user) => _user = user;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
public void SetUser(ClaimsPrincipal user)
{
_user = user;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
[Fact]
public void Polling_StartsAfterAnAsynchronousAuthResolveCompletes()
{
// Regression: OnParametersSetAsync awaits the principal. On a real cookie
// session that await suspends, so the FIRST render happens with _visible
// still false and OnAfterRenderAsync(firstRender: true) skips starting the
// poll; the render that follows the resolve is firstRender: false. Without a
// start from OnParametersSetAsync the block sits on "Checking for updates…"
// for the life of the circuit.
var gate = new TaskCompletionSource<AuthenticationState>(
TaskCreationOptions.RunContinuationsAsynchronously);
var cache = CacheReturning(Snapshot(SiteRow(1, staleInstances: 4)));
Services.AddSingleton<AuthenticationStateProvider>(new DeferredAuthStateProvider(gate.Task));
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
var cut = Render<PendingUpdatesIndicator>();
// Nothing resolved yet: no principal, so nothing rendered and nothing polled.
Assert.True(string.IsNullOrWhiteSpace(cut.Markup));
gate.SetResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Deployer)], "TestAuth"))));
cut.WaitForAssertion(() =>
{
_ = cache.Received().GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
Assert.Contains("4 instances", cut.Markup);
});
}
/// <summary>An auth provider whose state resolves only when the supplied task completes.</summary>
private sealed class DeferredAuthStateProvider : AuthenticationStateProvider
{
private readonly Task<AuthenticationState> _state;
public DeferredAuthStateProvider(Task<AuthenticationState> state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync() => _state;
}
}
@@ -0,0 +1,129 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Unit tests for <see cref="PendingUpdatesCache"/> — the same single-flight TTL contract as
/// <see cref="SingleFlightMemoTests"/> proves for the primitive, exercised here through the
/// full DI-scope-per-flight shape: N concurrent callers before the evaluator resolves must
/// share ONE fleet flatten, a value produced inside the TTL is served without re-evaluating,
/// <c>forceRefresh</c> bypasses a fresh value but still joins an in-flight round, and a
/// faulted evaluation is never memoized.
/// </summary>
public class PendingUpdatesCacheTests : IDisposable
{
private static readonly DateTimeOffset T0 = new(2026, 9, 14, 9, 0, 0, TimeSpan.Zero);
private readonly IPendingUpdatesEvaluator _evaluator = Substitute.For<IPendingUpdatesEvaluator>();
private readonly ServiceProvider _provider;
public PendingUpdatesCacheTests()
{
var services = new ServiceCollection();
services.AddScoped(_ => _evaluator);
_provider = services.BuildServiceProvider();
}
// A generous TTL (1h) so every test's timing is driven by task completion, not the clock.
private static IOptions<DeploymentManagerOptions> Options(TimeSpan? ttl = null) =>
Microsoft.Extensions.Options.Options.Create(
new DeploymentManagerOptions { PendingUpdatesCacheTtl = ttl ?? TimeSpan.FromHours(1) });
private PendingUpdatesCache CreateSut(TimeSpan? ttl = null) =>
new(_provider.GetRequiredService<IServiceScopeFactory>(), Options(ttl));
private static PendingUpdatesSnapshot Snapshot() =>
new(T0, Array.Empty<SitePendingUpdates>());
[Fact]
public async Task ConcurrentCallers_ShareOneEvaluation()
{
var released = new TaskCompletionSource<PendingUpdatesSnapshot>(
TaskCreationOptions.RunContinuationsAsynchronously);
_evaluator.EvaluateAsync(Arg.Any<CancellationToken>()).Returns(released.Task);
var sut = CreateSut();
var waiters = Enumerable.Range(0, 10).Select(_ => sut.GetAsync()).ToArray();
released.SetResult(Snapshot());
var results = await Task.WhenAll(waiters);
await _evaluator.Received(1).EvaluateAsync(Arg.Any<CancellationToken>());
Assert.All(results, r => Assert.Same(results[0], r));
}
[Fact]
public async Task WithinTtl_ServesTheMemoizedValue_WithoutReEvaluating()
{
_evaluator.EvaluateAsync(Arg.Any<CancellationToken>()).Returns(Snapshot());
var sut = CreateSut();
var first = await sut.GetAsync();
var second = await sut.GetAsync();
await _evaluator.Received(1).EvaluateAsync(Arg.Any<CancellationToken>());
Assert.Same(first, second);
}
[Fact]
public async Task ForceRefresh_BypassesAFreshValue()
{
var snapshotA = Snapshot();
var snapshotB = new PendingUpdatesSnapshot(T0.AddMinutes(1), Array.Empty<SitePendingUpdates>());
_evaluator.EvaluateAsync(Arg.Any<CancellationToken>()).Returns(snapshotA, snapshotB);
var sut = CreateSut();
var first = await sut.GetAsync();
var forced = await sut.GetAsync(forceRefresh: true);
await _evaluator.Received(2).EvaluateAsync(Arg.Any<CancellationToken>());
Assert.Same(snapshotA, first);
Assert.Same(snapshotB, forced);
}
[Fact]
public async Task ForceRefresh_StillJoinsAnInFlightRound()
{
var released = new TaskCompletionSource<PendingUpdatesSnapshot>(
TaskCreationOptions.RunContinuationsAsynchronously);
_evaluator.EvaluateAsync(Arg.Any<CancellationToken>()).Returns(released.Task);
var sut = CreateSut();
var first = sut.GetAsync();
var forcedA = sut.GetAsync(forceRefresh: true);
var forcedB = sut.GetAsync(forceRefresh: true);
var snapshot = Snapshot();
released.SetResult(snapshot);
Assert.Same(snapshot, await first);
Assert.Same(snapshot, await forcedA);
Assert.Same(snapshot, await forcedB);
await _evaluator.Received(1).EvaluateAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task FailedEvaluation_SurfacesToTheCaller_AndIsNotMemoized()
{
_evaluator.EvaluateAsync(Arg.Any<CancellationToken>())
.Returns(
Task.FromException<PendingUpdatesSnapshot>(new InvalidOperationException("flatten failed")),
Task.FromResult(Snapshot()));
var sut = CreateSut();
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.GetAsync());
// Same instant, well inside the TTL — a cached fault would starve every dashboard
// for the rest of the window, so the next caller must start a new evaluation.
var recovered = await sut.GetAsync();
Assert.NotNull(recovered);
await _evaluator.Received(2).EvaluateAsync(Arg.Any<CancellationToken>());
}
public void Dispose() => _provider.Dispose();
}
@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Coverage for <c>IDeploymentManagerRepository.GetDeployedRevisionHashesAsync</c> — the
/// fleet-wide InstanceId → RevisionHash projection the pending-updates aggregate reads once
/// per cache TTL. Uses the shared SQLite in-memory fixture with real FK enforcement.
/// </summary>
public class DeployedRevisionHashesRepositoryTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
private readonly DeploymentManagerRepository _repository;
/// <summary>Creates the in-memory context and the repository under test.</summary>
public DeployedRevisionHashesRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new DeploymentManagerRepository(_context);
}
/// <summary>Closes and disposes the in-memory context.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
GC.SuppressFinalize(this);
}
private async Task<int> SeedInstanceAsync(string uniqueName)
{
var site = new Site($"Site-{uniqueName}", $"SI-{uniqueName}");
var template = new Template($"T-{uniqueName}");
_context.Sites.Add(site);
_context.Templates.Add(template);
await _context.SaveChangesAsync();
var instance = new Instance(uniqueName)
{
SiteId = site.Id,
TemplateId = template.Id,
State = InstanceState.Enabled,
};
_context.Instances.Add(instance);
await _context.SaveChangesAsync();
return instance.Id;
}
private async Task SeedSnapshotAsync(int instanceId, string deploymentId, string revisionHash)
{
var snapshot = new DeployedConfigSnapshot(deploymentId, revisionHash, "{\"big\":\"payload\"}")
{
InstanceId = instanceId,
};
_context.Set<DeployedConfigSnapshot>().Add(snapshot);
await _context.SaveChangesAsync();
}
[Fact]
public async Task GetDeployedRevisionHashes_returns_every_snapshot_keyed_by_instance()
{
var first = await SeedInstanceAsync("Pump-01");
var second = await SeedInstanceAsync("Pump-02");
await SeedSnapshotAsync(first, "dep-1", "hash-one");
await SeedSnapshotAsync(second, "dep-2", "hash-two");
var hashes = await _repository.GetDeployedRevisionHashesAsync();
Assert.Equal(2, hashes.Count);
Assert.Equal("hash-one", hashes[first]);
Assert.Equal("hash-two", hashes[second]);
}
[Fact]
public async Task GetDeployedRevisionHashes_returns_empty_when_nothing_is_deployed()
{
await SeedInstanceAsync("Pump-01");
var hashes = await _repository.GetDeployedRevisionHashesAsync();
Assert.Empty(hashes);
}
}
@@ -26,6 +26,7 @@ public class ArtifactDeploymentServiceTests : TestKit
private readonly IExternalSystemRepository _externalSystemRepo;
private readonly INotificationRepository _notificationRepo;
private readonly IAuditService _audit;
private readonly ArtifactRevisionHashService _hashService = new();
public ArtifactDeploymentServiceTests()
{
@@ -301,6 +302,183 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Contains("999", result.Error);
}
// ── Site artifact revision stamp (pending-updates §3.1) ──
[Fact]
public async Task DeployToAllSitesAsync_SuccessfulSite_StampsHashAndTimestampAndSaves()
{
var site = new Site("Site One", "site-1") { Id = 1 };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var before = DateTimeOffset.UtcNow;
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
var expected = _hashService.ComputeHash(Assert.Single(recorder.Received));
Assert.Equal(expected, site.ArtifactRevisionHash);
Assert.NotNull(site.ArtifactsDeployedAt);
Assert.InRange(site.ArtifactsDeployedAt!.Value, before, DateTimeOffset.UtcNow);
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task DeployToAllSitesAsync_FailedSite_KeepsItsPreviousStamp()
{
// A failed site must keep reading "out of date": it keeps the stamp of whatever
// artifact set it last received, never the one it just refused.
var previousStamp = DateTimeOffset.UtcNow.AddDays(-2);
var ok = new Site("Site One", "ok-site") { Id = 1 };
var failed = new Site("Site Two", "fail-site")
{
Id = 2,
ArtifactRevisionHash = "sha256:previous",
ArtifactsDeployedAt = previousStamp
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { ok, failed });
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
Assert.Equal("sha256:previous", failed.ArtifactRevisionHash);
Assert.Equal(previousStamp, failed.ArtifactsDeployedAt);
Assert.NotNull(ok.ArtifactRevisionHash);
await _siteRepo.DidNotReceive().UpdateSiteAsync(failed, Arg.Any<CancellationToken>());
await _siteRepo.Received(1).UpdateSiteAsync(ok, Arg.Any<CancellationToken>());
}
[Fact]
public async Task DeployToAllSitesAsync_StampSaveThrows_DeploymentRecordStillPersistedAndSuccessReported()
{
// ORDERING RULE: audit row first, best-effort stamp after. Both repositories are
// scoped over ONE DbContext, so a failed stamp save leaves the Site entries tracked
// as Modified — stamping first would let that failure rethrow out of the deployment
// record's own (uncaught) SaveChangesAsync and lose the audit row for a deploy where
// every site succeeded. The record must therefore be saved BEFORE the stamp is tried.
var site = new Site("Site One", "site-1") { Id = 1 };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
_siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
.Returns<int>(_ => throw new InvalidOperationException("db down"));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.SuccessCount);
await _deploymentRepo.Received(1).AddSystemArtifactDeploymentAsync(
Arg.Any<SystemArtifactDeploymentRecord>(), Arg.Any<CancellationToken>());
await _deploymentRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
Received.InOrder(() =>
{
_deploymentRepo.SaveChangesAsync(Arg.Any<CancellationToken>());
_siteRepo.UpdateSiteAsync(site, Arg.Any<CancellationToken>());
});
}
[Fact]
public async Task RetryForSiteAsync_Success_StampsTheSite()
{
var site = new Site("Retry Site", "retry-site") { Id = 7 };
_siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>()).Returns(site);
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(7, "retry-site", "admin");
Assert.True(result.IsSuccess);
Assert.Equal(
_hashService.ComputeHash(Assert.Single(recorder.Received)),
site.ArtifactRevisionHash);
Assert.NotNull(site.ArtifactsDeployedAt);
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task RetryForSiteAsync_Failure_DoesNotStampTheSite()
{
var site = new Site("Retry Site", "fail-site") { Id = 7 };
_siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>()).Returns(site);
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(7, "fail-site", "admin");
Assert.True(result.IsFailure);
Assert.Null(site.ArtifactRevisionHash);
Assert.Null(site.ArtifactsDeployedAt);
await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps()
{
// The whole comparison rests on this equality: a "what would it be now" hash of
// unchanged definitions must equal the hash the last successful deploy stamped.
var sites = new List<Site>
{
new("Site One", "site-1") { Id = 1 },
new("Site Two", "site-2") { Id = 2 }
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var deployResult = await service.DeployToAllSitesAsync("admin");
Assert.True(deployResult.IsSuccess);
var current = await service.ComputeCurrentArtifactHashesAsync(sites);
Assert.Equal(2, current.Count);
foreach (var site in sites)
{
Assert.Equal(site.ArtifactRevisionHash, current[site.Id]);
}
}
[Fact]
public async Task ComputeCurrentArtifactHashesAsync_HoistsTheGlobalQueriesOnce()
{
var sites = new List<Site>
{
new("Site One", "site-1") { Id = 1 },
new("Site Two", "site-2") { Id = 2 },
new("Site Three", "site-3") { Id = 3 }
};
var service = CreateService();
var current = await service.ComputeCurrentArtifactHashesAsync(sites);
Assert.Equal(3, current.Count);
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ComputeCurrentArtifactHashesAsync_NoSites_ReturnsEmptyWithoutQuerying()
{
var service = CreateService();
var current = await service.ComputeCurrentArtifactHashesAsync([]);
Assert.Empty(current);
await _templateRepo.DidNotReceive().GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
}
private ArtifactDeploymentService CreateService()
{
var comms = new CommunicationService(
@@ -311,7 +489,8 @@ public class ArtifactDeploymentServiceTests : TestKit
_siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo,
comms, _audit,
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance);
NullLogger<ArtifactDeploymentService>.Instance,
_hashService);
}
private ArtifactDeploymentService CreateServiceWithCommActor(IActorRef commActor)
@@ -331,7 +510,8 @@ public class ArtifactDeploymentServiceTests : TestKit
{
ArtifactDeploymentTimeoutPerSite = TimeSpan.FromSeconds(5)
}),
NullLogger<ArtifactDeploymentService>.Instance);
NullLogger<ArtifactDeploymentService>.Instance,
_hashService);
}
/// <summary>
@@ -0,0 +1,288 @@
using System.Reflection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// Tests for <see cref="ArtifactRevisionHashService"/>, the deterministic hash behind the
/// Sites page's "artifacts out of date" indicator. The hash must depend on the artifact
/// CONTENT only — never on deployment id, timestamp, or repository row order — or the
/// indicator either never clears or never fires.
/// </summary>
public class ArtifactRevisionHashServiceTests
{
private readonly ArtifactRevisionHashService _sut = new();
private static DeployArtifactsCommand Command(
string deploymentId = "dep-1",
DateTimeOffset? timestamp = null,
IReadOnlyList<SharedScriptArtifact>? sharedScripts = null,
IReadOnlyList<ExternalSystemArtifact>? externalSystems = null,
IReadOnlyList<DatabaseConnectionArtifact>? databaseConnections = null,
IReadOnlyList<DataConnectionArtifact>? dataConnections = null) =>
new(deploymentId,
sharedScripts ??
[
new SharedScriptArtifact("Alpha", "return 1;", "{\"p\":1}", "{\"r\":1}"),
new SharedScriptArtifact("Beta", "return 2;", null, null)
],
externalSystems ??
[
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", "{\"key\":\"k\"}",
"[{\"Name\":\"MoveOut\"},{\"Name\":\"MoveIn\"}]", 30, 6, TimeSpan.FromSeconds(120)),
new ExternalSystemArtifact("Erp", "https://erp", "BasicAuth", null, null)
],
databaseConnections ??
[
new DatabaseConnectionArtifact("Historian", "Server=h;", 4, TimeSpan.FromSeconds(30)),
new DatabaseConnectionArtifact("Mes", "Server=m;", 0, TimeSpan.Zero)
],
NotificationLists: null,
dataConnections ??
[
new DataConnectionArtifact("Opc", "OpcUa", "{\"url\":\"opc.tcp://x\"}", null, 3),
new DataConnectionArtifact("Mx", "MxGateway", "{\"host\":\"gw\"}", "{\"host\":\"gw2\"}", 5)
],
SmtpConfigurations: null,
timestamp ?? DateTimeOffset.UtcNow);
[Fact]
public void ComputeHash_SameInputTwice_ProducesSameHash()
{
var timestamp = DateTimeOffset.UtcNow;
Assert.Equal(
_sut.ComputeHash(Command(timestamp: timestamp)),
_sut.ComputeHash(Command(timestamp: timestamp)));
}
[Fact]
public void ComputeHash_IsSha256HexEncoded()
{
var hash = _sut.ComputeHash(Command());
// Same encoding as RevisionHashService: "sha256:" + 64 lower-case hex chars.
Assert.StartsWith("sha256:", hash);
var digest = hash["sha256:".Length..];
Assert.Equal(64, digest.Length);
Assert.All(digest, c => Assert.Contains(c, "0123456789abcdef"));
}
[Fact]
public void ComputeHash_ReorderedArtifactLists_ProducesSameHash()
{
// Repository row order must never leak into the hash — otherwise an unrelated
// insert would flag every site out of date.
var baseline = Command();
var reordered = baseline with
{
SharedScripts = baseline.SharedScripts!.Reverse().ToList(),
ExternalSystems = baseline.ExternalSystems!.Reverse().ToList(),
DatabaseConnections = baseline.DatabaseConnections!.Reverse().ToList(),
DataConnections = baseline.DataConnections!.Reverse().ToList()
};
Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(reordered));
}
[Fact]
public void ComputeHash_ReorderedExternalSystemMethods_ProducesSameHash()
{
// MethodDefinitionsJson is itself a serialized collection; it is re-canonicalised
// with the methods sorted by name so method row order cannot move the hash either.
var a = Command(externalSystems:
[
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null,
"[{\"Name\":\"MoveOut\",\"HttpMethod\":\"POST\"},{\"Name\":\"MoveIn\",\"HttpMethod\":\"POST\"}]")
]);
var b = Command(externalSystems:
[
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null,
"[{\"Name\":\"MoveIn\",\"HttpMethod\":\"POST\"},{\"Name\":\"MoveOut\",\"HttpMethod\":\"POST\"}]")
]);
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
[Fact]
public void ComputeHash_ReorderedCamelCaseMethodNames_ProducesSameHash()
{
// The method-name lookup is case-insensitive so that a naming-policy change at the
// producer (PascalCase anonymous type today) cannot silently degrade the sort into a
// no-op and let repository row order move the hash again.
var a = Command(externalSystems:
[
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null,
"[{\"name\":\"MoveOut\"},{\"name\":\"MoveIn\"}]")
]);
var b = Command(externalSystems:
[
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null,
"[{\"name\":\"MoveIn\"},{\"name\":\"MoveOut\"}]")
]);
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
[Fact]
public void ComputeHash_UnparseableMethodDefinitions_StillParticipatesInTheHash()
{
// Non-array / non-JSON payloads pass through verbatim: the order guarantee is lost
// (there is nothing to sort), but a content change must still move the hash.
var a = Command(externalSystems:
[new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "not json")]);
var b = Command(externalSystems:
[new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "not json either")]);
Assert.NotEqual(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
[Fact]
public void ComputeHash_DifferentDeploymentIdAndTimestamp_ProducesSameHash()
{
// The whole point of the stamp: two deploys of identical definitions must hash the
// same, or every redeploy would look like a change.
var a = Command("dep-1", DateTimeOffset.UtcNow);
var b = Command("dep-2", DateTimeOffset.UtcNow.AddDays(3));
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
[Fact]
public void ComputeHash_EmptyAndNullArtifactLists_HashIdentically()
{
var withNulls = new DeployArtifactsCommand(
"dep", null, null, null, null, null, null, DateTimeOffset.UtcNow);
var withEmpties = new DeployArtifactsCommand(
"dep", [], [], [], [], [], [], DateTimeOffset.UtcNow);
Assert.Equal(_sut.ComputeHash(withNulls), _sut.ComputeHash(withEmpties));
}
[Fact]
public void ComputeHash_NotificationAndSmtpArtifacts_DoNotAffectTheHash()
{
// Central never populates these (notification delivery is central-only), so they
// are excluded from the projection entirely.
var baseline = Command();
var withCentralOnlyPayload = baseline with
{
NotificationLists = [new NotificationListArtifact("Ops", ["a@b.c"])],
SmtpConfigurations =
[new SmtpConfigurationArtifact("Main", "smtp", 25, "Basic", "a@b.c", null, null, null)]
};
Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(withCentralOnlyPayload));
}
public static TheoryData<string, DeployArtifactsCommand> ChangedFieldCases()
{
var data = new TheoryData<string, DeployArtifactsCommand>();
void Add(string field, DeployArtifactsCommand command) => data.Add(field, command);
// SharedScriptArtifact — every field.
Add("script.Name", Command(sharedScripts: [new SharedScriptArtifact("Renamed", "c", "p", "r")]));
Add("script.Code", Command(sharedScripts: [new SharedScriptArtifact("S", "changed", "p", "r")]));
Add("script.ParameterDefinitions", Command(sharedScripts: [new SharedScriptArtifact("S", "c", "changed", "r")]));
Add("script.ReturnDefinition", Command(sharedScripts: [new SharedScriptArtifact("S", "c", "p", "changed")]));
// ExternalSystemArtifact — every field.
Add("externalSystem.Name",
Command(externalSystems: [new ExternalSystemArtifact("Renamed", "u", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))]));
Add("externalSystem.EndpointUrl",
Command(externalSystems: [new ExternalSystemArtifact("E", "changed", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))]));
Add("externalSystem.AuthType",
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "BasicAuth", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))]));
Add("externalSystem.AuthConfiguration",
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "changed", "[]", 1, 2, TimeSpan.FromSeconds(3))]));
Add("externalSystem.MethodDefinitionsJson",
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[{\"Name\":\"M\"}]", 1, 2, TimeSpan.FromSeconds(3))]));
Add("externalSystem.TimeoutSeconds",
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 99, 2, TimeSpan.FromSeconds(3))]));
Add("externalSystem.MaxRetries",
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 1, 99, TimeSpan.FromSeconds(3))]));
Add("externalSystem.RetryDelay",
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(99))]));
// DatabaseConnectionArtifact — every field.
Add("databaseConnection.Name",
Command(databaseConnections: [new DatabaseConnectionArtifact("Renamed", "cs", 1, TimeSpan.FromSeconds(2))]));
Add("databaseConnection.ConnectionString",
Command(databaseConnections: [new DatabaseConnectionArtifact("D", "changed", 1, TimeSpan.FromSeconds(2))]));
Add("databaseConnection.MaxRetries",
Command(databaseConnections: [new DatabaseConnectionArtifact("D", "cs", 99, TimeSpan.FromSeconds(2))]));
Add("databaseConnection.RetryDelay",
Command(databaseConnections: [new DatabaseConnectionArtifact("D", "cs", 1, TimeSpan.FromSeconds(99))]));
// DataConnectionArtifact — every field.
Add("dataConnection.Name",
Command(dataConnections: [new DataConnectionArtifact("Renamed", "OpcUa", "p", "b", 1)]));
Add("dataConnection.Protocol",
Command(dataConnections: [new DataConnectionArtifact("C", "MxGateway", "p", "b", 1)]));
Add("dataConnection.PrimaryConfigurationJson",
Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "changed", "b", 1)]));
Add("dataConnection.BackupConfigurationJson",
Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "p", "changed", 1)]));
Add("dataConnection.FailoverRetryCount",
Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "p", "b", 99)]));
return data;
}
[Theory]
[MemberData(nameof(ChangedFieldCases))]
public void ComputeHash_EveryArtifactField_ParticipatesInTheHash(
string changedField, DeployArtifactsCommand changed)
{
// Each case is the same command with ONE artifact field differing from the
// per-class baseline below; a field silently dropped from the projection would
// make the two hashes equal and leave that edit invisible to the indicator.
var baselines = new Dictionary<string, DeployArtifactsCommand>(StringComparer.Ordinal)
{
["script"] = Command(sharedScripts: [new SharedScriptArtifact("S", "c", "p", "r")]),
["externalSystem"] =
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))]),
["databaseConnection"] =
Command(databaseConnections: [new DatabaseConnectionArtifact("D", "cs", 1, TimeSpan.FromSeconds(2))]),
["dataConnection"] =
Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "p", "b", 1)]),
};
var baseline = baselines[changedField.Split('.')[0]];
Assert.NotEqual(_sut.ComputeHash(baseline), _sut.ComputeHash(changed));
}
[Fact]
public void HashableRecords_PropertiesDeclaredAlphabetically()
{
// Artifact-hash determinism depends entirely on the private Hashable* records
// declaring their properties in alphabetical order (System.Text.Json emits
// properties in CLR declaration order and does not sort). Without this guard a
// contributor could silently change every site's artifact hash — flagging the
// whole fleet out of date — by adding a property out of order. Mirrors
// RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically.
var nested = typeof(ArtifactRevisionHashService)
.GetNestedTypes(BindingFlags.NonPublic)
.Where(t => t.Name.StartsWith("Hashable", StringComparison.Ordinal))
.ToList();
Assert.NotEmpty(nested);
foreach (var type in nested)
{
var propNames = type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.Name != "EqualityContract")
.Select(p => p.Name)
.ToList();
var sorted = propNames.OrderBy(n => n, StringComparer.Ordinal).ToList();
Assert.True(
propNames.SequenceEqual(sorted),
$"{type.Name} properties must be declared alphabetically. " +
$"Declared: [{string.Join(", ", propNames)}] Expected: [{string.Join(", ", sorted)}]");
}
}
}
@@ -72,4 +72,26 @@ public class DeploymentManagerOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("SiteDeploymentReconcileTimeout", result.FailureMessage);
}
[Fact]
public void ZeroPendingUpdatesCacheTtl_IsRejected()
{
// A zero TTL makes every Deployer/Administrator circuit's poll a cache miss,
// so the whole fleet is re-flattened on each one — the exact cost the cache
// exists to avoid.
var result = Validate(new DeploymentManagerOptions { PendingUpdatesCacheTtl = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("PendingUpdatesCacheTtl", result.FailureMessage);
}
[Fact]
public void NegativePendingUpdatesCacheTtl_IsRejected()
{
var result = Validate(new DeploymentManagerOptions
{
PendingUpdatesCacheTtl = TimeSpan.FromSeconds(-1)
});
Assert.True(result.Failed);
Assert.Contains("PendingUpdatesCacheTtl", result.FailureMessage);
}
}
@@ -0,0 +1,345 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// Coverage for <see cref="DeploymentService.GetInstanceStalenessAsync"/> — the
/// fleet-wide batch behind the pending-updates aggregate.
///
/// <para>
/// The three properties under test are the ones that make the batch both cheap
/// and exactly consistent with the per-instance Topology badge: ONE shared
/// <see cref="FlattenSession"/> for the whole batch, a hash-projection read for
/// everyone, and the fat snapshot read + I-1 List normalisation paid ONLY when
/// the projected hash already disagrees.
/// </para>
/// </summary>
public class DeploymentServiceStalenessTests
{
private readonly IDeploymentManagerRepository _repo = Substitute.For<IDeploymentManagerRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly RevisionHashService _hasher = new();
private readonly DeploymentService _service;
/// <summary>The single session every flatten in a batch must share.</summary>
private readonly FlattenSession _session = new(new TemplateGraphWatermark());
/// <summary>Captures the service's log entries so the per-batch Warning can be asserted.</summary>
private readonly CapturingLogger _logger = new();
/// <summary>
/// Builds the service over substituted repository and pipeline seams; every
/// other dependency is the real concrete collaborator (as elsewhere in this
/// suite) because the staleness path only exercises hashing.
/// </summary>
public DeploymentServiceStalenessTests()
{
_pipeline.CreateSession().Returns(_session);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetSiteByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var id = callInfo.ArgAt<int>(0);
return new Site($"Test Site {id}", $"site-{id}") { Id = id };
});
_service = new DeploymentService(
_repo,
siteRepo,
_pipeline,
new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance),
new OperationLockManager(),
Substitute.For<IAuditService>(),
new DiffService(),
_hasher,
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Substitute.For<IStaleInstanceProbe>(),
Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5)
}),
Options.Create(new CommunicationOptions()),
_logger);
}
/// <summary>
/// Captures formatted log entries so the batch's single failure Warning can be
/// asserted (mirrors the CapturingLogger used elsewhere in the test suite).
/// </summary>
private sealed class CapturingLogger : ILogger<DeploymentService>
{
/// <summary>Every entry logged, in order.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
}
/// <summary>Builds a one-attribute flattened config with the given scalar value.</summary>
private static FlattenedConfiguration ScalarConfig(string value) => new()
{
InstanceUniqueName = "TestInst",
Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = value, DataType = "Int32" }]
};
/// <summary>Stubs a successful flatten of <paramref name="instanceId"/>.</summary>
private void ArrangeFlatten(int instanceId, FlattenedConfiguration config, string hash) =>
_pipeline.FlattenAndValidateAsync(
instanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, hash, ValidationResult.Success())));
/// <summary>Stubs the fat snapshot read for <paramref name="instanceId"/>.</summary>
private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash) =>
_repo.GetDeployedSnapshotByInstanceIdAsync(instanceId, Arg.Any<CancellationToken>())
.Returns(new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config))
{
InstanceId = instanceId,
DeployedAt = DateTimeOffset.UtcNow
});
/// <summary>Stubs the (InstanceId, RevisionHash) projection read.</summary>
private void ArrangeDeployedHashes(params (int InstanceId, string Hash)[] rows) =>
_repo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>())
.Returns(rows.ToDictionary(r => r.InstanceId, r => r.Hash));
[Fact]
public async Task GetInstanceStalenessAsync_SharesOneSessionAcrossTheBatch()
{
var config = ScalarConfig("5");
var hash = _hasher.ComputeHash(config);
ArrangeDeployedHashes((1, hash), (2, hash), (3, hash));
ArrangeFlatten(1, config, hash);
ArrangeFlatten(2, config, hash);
ArrangeFlatten(3, config, hash);
var result = await _service.GetInstanceStalenessAsync([1, 2, 3]);
Assert.Equal(3, result.Count);
// One session created, and the SAME instance handed to every flatten —
// that is what makes a template chain shared by N instances walked once.
_pipeline.Received(1).CreateSession();
await _pipeline.Received(1).FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), false, _session);
await _pipeline.Received(1).FlattenAndValidateAsync(2, Arg.Any<CancellationToken>(), false, _session);
await _pipeline.Received(1).FlattenAndValidateAsync(3, Arg.Any<CancellationToken>(), false, _session);
}
[Fact]
public async Task GetInstanceStalenessAsync_HashMatches_NotStaleAndNoSnapshotRead()
{
var config = ScalarConfig("5");
var hash = _hasher.ComputeHash(config);
ArrangeDeployedHashes((1, hash));
ArrangeFlatten(1, config, hash);
var result = await _service.GetInstanceStalenessAsync([1]);
Assert.False(result[1]);
// The whole point of the projection read: an up-to-date fleet never
// deserialises a single ConfigurationJson.
await _repo.DidNotReceive().GetDeployedSnapshotByInstanceIdAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetInstanceStalenessAsync_OldFormListSnapshot_NormalisesToNotStale()
{
// I-1 parity with the Topology badge: a List attribute deployed in the OLD
// quoted form froze a hash that differs from the native re-flatten's, so
// the projection read reports a mismatch. Re-reading the snapshot and
// recomputing over the normalised config collapses old form → native, and
// the instance is correctly NOT stale.
var oldFormConfig = new FlattenedConfiguration
{
InstanceUniqueName = "ListInst",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Thresholds",
DataType = "List",
ElementDataType = "Int32",
Value = "[\"10\",\"20\",\"30\"]" // OLD quoted form
}
]
};
var nativeConfig = oldFormConfig with
{
Attributes = [oldFormConfig.Attributes[0] with { Value = "[10,20,30]" }]
};
var frozenOldFormHash = _hasher.ComputeHash(oldFormConfig);
var nativeHash = _hasher.ComputeHash(nativeConfig);
// Sanity: without the normalisation this instance WOULD read as stale.
Assert.NotEqual(frozenOldFormHash, nativeHash);
ArrangeDeployedHashes((50, frozenOldFormHash));
ArrangeSnapshot(50, oldFormConfig, frozenOldFormHash);
ArrangeFlatten(50, nativeConfig, nativeHash);
var result = await _service.GetInstanceStalenessAsync([50]);
Assert.False(result[50]);
await _repo.Received(1).GetDeployedSnapshotByInstanceIdAsync(50, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetInstanceStalenessAsync_GenuineMismatch_IsStale()
{
var deployedConfig = ScalarConfig("5");
var deployedHash = _hasher.ComputeHash(deployedConfig);
var currentConfig = ScalarConfig("9");
var currentHash = _hasher.ComputeHash(currentConfig);
ArrangeDeployedHashes((1, deployedHash));
ArrangeSnapshot(1, deployedConfig, deployedHash);
ArrangeFlatten(1, currentConfig, currentHash);
var result = await _service.GetInstanceStalenessAsync([1]);
Assert.True(result[1]);
}
[Fact]
public async Task GetInstanceStalenessAsync_FlattenFails_ReportsUnknown()
{
ArrangeDeployedHashes((1, "sha256:deployed"));
_pipeline.FlattenAndValidateAsync(
1, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Failure("template missing"));
var result = await _service.GetInstanceStalenessAsync([1]);
// null = could not evaluate. Never counted as stale.
Assert.True(result.ContainsKey(1));
Assert.Null(result[1]);
}
[Fact]
public async Task GetInstanceStalenessAsync_ManyFlattenFailures_LogsExactlyOneWarning()
{
// The batch runs for the whole fleet on a cache-TTL cadence, so one broken
// shared template fanning out to N instances must NOT produce N Warning
// lines per minute: the per-instance detail drops to Debug and the batch
// emits a single summary Warning naming the first failure.
ArrangeDeployedHashes((1, "sha256:deployed"), (2, "sha256:deployed"));
foreach (var id in new[] { 1, 2 })
{
_pipeline.FlattenAndValidateAsync(
id, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Failure($"broken template {id}"));
}
var result = await _service.GetInstanceStalenessAsync([1, 2]);
Assert.Null(result[1]);
Assert.Null(result[2]);
var warnings = _logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
var warning = Assert.Single(warnings);
Assert.Contains("2 of 2", warning.Message);
Assert.Contains("broken template 1", warning.Message);
// Both per-instance details are still available, one level down.
Assert.Equal(2, _logger.Entries.Count(e => e.Level == LogLevel.Debug));
}
[Fact]
public async Task GetInstanceStalenessAsync_NoFailures_LogsNoWarning()
{
var config = ScalarConfig("5");
var hash = _hasher.ComputeHash(config);
ArrangeDeployedHashes((1, hash));
ArrangeFlatten(1, config, hash);
await _service.GetInstanceStalenessAsync([1]);
Assert.DoesNotContain(_logger.Entries, e => e.Level == LogLevel.Warning);
}
[Fact]
public async Task GetInstanceStalenessAsync_InstanceWithoutSnapshot_IsOmitted()
{
var config = ScalarConfig("5");
var hash = _hasher.ComputeHash(config);
ArrangeDeployedHashes((1, hash));
ArrangeFlatten(1, config, hash);
var result = await _service.GetInstanceStalenessAsync([1, 2]);
Assert.True(result.ContainsKey(1));
// Instance 2 has never been deployed — not stale, not unknown, absent.
Assert.False(result.ContainsKey(2));
await _pipeline.DidNotReceive().FlattenAndValidateAsync(
2, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
}
[Fact]
public async Task GetInstanceStalenessAsync_SnapshotVanishedBetweenReads_IsOmitted()
{
// Undeployed between the projection read and the mismatch's snapshot read:
// there is nothing left to be stale against, so report nothing at all.
ArrangeDeployedHashes((1, "sha256:deployed"));
_repo.GetDeployedSnapshotByInstanceIdAsync(1, Arg.Any<CancellationToken>())
.Returns((DeployedConfigSnapshot?)null);
ArrangeFlatten(1, ScalarConfig("5"), "sha256:current");
var result = await _service.GetInstanceStalenessAsync([1]);
Assert.False(result.ContainsKey(1));
}
[Fact]
public async Task GetInstanceStalenessAsync_DuplicateIds_FlattenedOnce()
{
var config = ScalarConfig("5");
var hash = _hasher.ComputeHash(config);
ArrangeDeployedHashes((1, hash));
ArrangeFlatten(1, config, hash);
var result = await _service.GetInstanceStalenessAsync([1, 1, 1]);
Assert.Single(result);
await _pipeline.Received(1).FlattenAndValidateAsync(
1, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
}
[Fact]
public async Task GetInstanceStalenessAsync_EmptyInput_ReturnsEmptyWithoutReads()
{
var result = await _service.GetInstanceStalenessAsync([]);
Assert.Empty(result);
await _repo.DidNotReceive().GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,415 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// Coverage for <see cref="PendingUpdatesEvaluator"/> — the single fleet-wide read
/// behind the Central UI rail indicator and the Sites page badges.
///
/// <para>
/// The evaluator is built over the REAL <see cref="ArtifactDeploymentService"/> and
/// <see cref="DeploymentService"/> (substituting only their repository and flattening
/// seams, as the sibling suites do), because the two properties worth pinning are
/// exactly the ones a mocked collaborator would paper over: that the artifact state
/// is decided by comparing the site's STORED hash against a hash recomputed from the
/// current definitions, and that only instances the operator has actually deployed
/// are ever handed to the staleness batch.
/// </para>
/// </summary>
public class PendingUpdatesEvaluatorTests
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
private readonly IExternalSystemRepository _externalSystemRepo = Substitute.For<IExternalSystemRepository>();
private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
private readonly IDeploymentManagerRepository _deploymentRepo = Substitute.For<IDeploymentManagerRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly RevisionHashService _hasher = new();
private readonly ArtifactRevisionHashService _artifactHasher = new();
private readonly ArtifactDeploymentService _artifactService;
private readonly DeploymentService _deploymentService;
private readonly PendingUpdatesEvaluator _evaluator;
/// <summary>
/// Builds the evaluator over the real services; every repository and the
/// flattening pipeline are substituted.
/// </summary>
public PendingUpdatesEvaluatorTests()
{
_pipeline.CreateSession().Returns(new FlattenSession(new TemplateGraphWatermark()));
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
_artifactService = new ArtifactDeploymentService(
_siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo,
comms, _audit,
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance,
_artifactHasher);
_deploymentService = new DeploymentService(
_deploymentRepo,
_siteRepo,
_pipeline,
comms,
new OperationLockManager(),
_audit,
new DiffService(),
_hasher,
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Substitute.For<IStaleInstanceProbe>(),
Options.Create(new DeploymentManagerOptions()),
Options.Create(new CommunicationOptions()),
NullLogger<DeploymentService>.Instance);
_evaluator = new PendingUpdatesEvaluator(
_siteRepo, _templateRepo, _artifactService, _deploymentService,
NullLogger<PendingUpdatesEvaluator>.Instance);
}
// ── arrange helpers ──────────────────────────────────────────────────────
/// <summary>Stubs the site list read.</summary>
private void ArrangeSites(params Site[] sites) =>
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
/// <summary>Stubs the instance list read.</summary>
private void ArrangeInstances(params Instance[] instances) =>
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>()).Returns(instances);
/// <summary>Builds an instance in the given lifecycle state at the given site.</summary>
private static Instance MakeInstance(int id, int siteId, InstanceState state) =>
new($"Inst{id}") { Id = id, SiteId = siteId, State = state };
/// <summary>Builds a one-attribute flattened config with the given scalar value.</summary>
private static FlattenedConfiguration ScalarConfig(string value) => new()
{
InstanceUniqueName = "TestInst",
Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = value, DataType = "Int32" }]
};
/// <summary>Stubs a successful flatten of <paramref name="instanceId"/> at the given hash.</summary>
private void ArrangeFlatten(int instanceId, FlattenedConfiguration config, string hash) =>
_pipeline.FlattenAndValidateAsync(
instanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, hash, ValidationResult.Success())));
/// <summary>Stubs a FAILED flatten of <paramref name="instanceId"/> — the "unknown" leg.</summary>
private void ArrangeFlattenFailure(int instanceId) =>
_pipeline.FlattenAndValidateAsync(
instanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Failure("template missing"));
/// <summary>Stubs the (InstanceId, RevisionHash) projection read.</summary>
private void ArrangeDeployedHashes(params (int InstanceId, string Hash)[] rows) =>
_deploymentRepo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>())
.Returns(rows.ToDictionary(r => r.InstanceId, r => r.Hash));
/// <summary>Stubs the fat snapshot read for <paramref name="instanceId"/>.</summary>
private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash) =>
_deploymentRepo.GetDeployedSnapshotByInstanceIdAsync(instanceId, Arg.Any<CancellationToken>())
.Returns(new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config))
{
InstanceId = instanceId,
DeployedAt = DateTimeOffset.UtcNow
});
/// <summary>
/// Asks the artifact service what the site's hash WOULD be right now — the value a
/// successful deploy would have stamped, i.e. what makes a site read Current.
/// </summary>
private async Task<string> CurrentArtifactHashAsync(Site site) =>
(await _artifactService.ComputeCurrentArtifactHashesAsync([site]))[site.Id];
// ── artifact state ───────────────────────────────────────────────────────
[Fact]
public async Task EvaluateAsync_NullStoredHash_IsNeverDeployedAndNotAPendingUpdate()
{
var site = new Site("Alpha", "site-a") { Id = 1 };
ArrangeSites(site);
ArrangeInstances();
var snapshot = await _evaluator.EvaluateAsync();
var row = Assert.Single(snapshot.Sites);
Assert.Equal(ArtifactDeploymentState.NeverDeployed, row.ArtifactState);
Assert.Null(row.ArtifactsDeployedAt);
// NeverDeployed is the operator not having deployed yet, not a pending
// update: it is surfaced on the Sites page but never counted or nagged about.
Assert.Equal(0, snapshot.StaleArtifactSiteCount);
Assert.False(snapshot.HasPendingUpdates);
}
[Fact]
public async Task EvaluateAsync_StoredHashMatchesCurrentDefinitions_IsCurrent()
{
var site = new Site("Alpha", "site-a") { Id = 1 };
var deployedAt = DateTimeOffset.UtcNow.AddHours(-2);
site.ArtifactRevisionHash = await CurrentArtifactHashAsync(site);
site.ArtifactsDeployedAt = deployedAt;
ArrangeSites(site);
ArrangeInstances();
var snapshot = await _evaluator.EvaluateAsync();
var row = Assert.Single(snapshot.Sites);
Assert.Equal(ArtifactDeploymentState.Current, row.ArtifactState);
Assert.Equal(deployedAt, row.ArtifactsDeployedAt);
Assert.Equal(0, snapshot.StaleArtifactSiteCount);
Assert.False(snapshot.HasPendingUpdates);
}
[Fact]
public async Task EvaluateAsync_StoredHashDiffersFromCurrentDefinitions_IsOutOfDateAndCounted()
{
var site = new Site("Alpha", "site-a") { Id = 1 };
site.ArtifactRevisionHash = "a-hash-from-an-older-artifact-set";
site.ArtifactsDeployedAt = DateTimeOffset.UtcNow.AddDays(-3);
ArrangeSites(site);
ArrangeInstances();
var snapshot = await _evaluator.EvaluateAsync();
var row = Assert.Single(snapshot.Sites);
Assert.Equal(ArtifactDeploymentState.OutOfDate, row.ArtifactState);
Assert.Equal(1, snapshot.StaleArtifactSiteCount);
Assert.True(snapshot.HasPendingUpdates);
}
[Fact]
public async Task EvaluateAsync_CarriesSiteIdentityOntoTheRow()
{
var site = new Site("Alpha Plant", "site-a") { Id = 7 };
ArrangeSites(site);
ArrangeInstances();
var snapshot = await _evaluator.EvaluateAsync();
var row = Assert.Single(snapshot.Sites);
Assert.Equal(7, row.SiteId);
Assert.Equal("site-a", row.SiteIdentifier);
Assert.Equal("Alpha Plant", row.SiteName);
}
// ── instance counts ──────────────────────────────────────────────────────
[Fact]
public async Task EvaluateAsync_CountsDeployedStaleAndUnknownInstancesPerSite()
{
var site = new Site("Alpha", "site-a") { Id = 1 };
ArrangeSites(site);
// 1 = deployed + stale, 2 = deployed but un-evaluatable, 3 = never deployed.
ArrangeInstances(
MakeInstance(1, 1, InstanceState.Enabled),
MakeInstance(2, 1, InstanceState.Disabled),
MakeInstance(3, 1, InstanceState.NotDeployed));
var deployedConfig = ScalarConfig("5");
var deployedHash = _hasher.ComputeHash(deployedConfig);
ArrangeDeployedHashes((1, deployedHash), (2, deployedHash));
ArrangeFlatten(1, ScalarConfig("9"), _hasher.ComputeHash(ScalarConfig("9")));
ArrangeSnapshot(1, deployedConfig, deployedHash);
ArrangeFlattenFailure(2);
var snapshot = await _evaluator.EvaluateAsync();
var row = Assert.Single(snapshot.Sites);
Assert.Equal(2, row.DeployedInstanceCount);
Assert.Equal(1, row.StaleInstanceCount);
Assert.Equal(1, row.UnknownInstanceCount);
Assert.Equal(1, snapshot.StaleInstanceCount);
Assert.True(snapshot.HasPendingUpdates);
}
[Fact]
public async Task EvaluateAsync_NotDeployedInstancesAreNeverHandedToTheStalenessBatch()
{
var site = new Site("Alpha", "site-a") { Id = 1 };
ArrangeSites(site);
ArrangeInstances(
MakeInstance(1, 1, InstanceState.Enabled),
MakeInstance(2, 1, InstanceState.NotDeployed));
var config = ScalarConfig("5");
var hash = _hasher.ComputeHash(config);
// Instance 2 deliberately HAS a leftover deployed-hash row (an undeploy that
// left the snapshot behind), so the staleness batch would happily evaluate it
// if its id were passed — the filter under test is the State gate, not the
// batch's own omission of instances with no snapshot.
ArrangeDeployedHashes((1, hash), (2, hash));
ArrangeFlatten(1, config, hash);
ArrangeFlatten(2, config, hash);
var snapshot = await _evaluator.EvaluateAsync();
// A NotDeployed instance has nothing to be stale against; flattening it would
// be pure cost on every TTL expiry, so it must never reach the batch.
await _pipeline.Received(1).FlattenAndValidateAsync(
1, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
await _pipeline.DidNotReceive().FlattenAndValidateAsync(
2, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount);
}
[Fact]
public async Task EvaluateAsync_AttributesInstanceCountsToTheirOwnSite()
{
var alpha = new Site("Alpha", "site-a") { Id = 1 };
var bravo = new Site("Bravo", "site-b") { Id = 2 };
ArrangeSites(alpha, bravo);
ArrangeInstances(
MakeInstance(1, 1, InstanceState.Enabled),
MakeInstance(2, 2, InstanceState.Enabled));
var deployedConfig = ScalarConfig("5");
var deployedHash = _hasher.ComputeHash(deployedConfig);
ArrangeDeployedHashes((1, deployedHash), (2, deployedHash));
ArrangeFlatten(1, deployedConfig, deployedHash);
ArrangeFlatten(2, ScalarConfig("9"), _hasher.ComputeHash(ScalarConfig("9")));
ArrangeSnapshot(2, deployedConfig, deployedHash);
var snapshot = await _evaluator.EvaluateAsync();
Assert.Equal(0, snapshot.Sites.Single(s => s.SiteId == 1).StaleInstanceCount);
Assert.Equal(1, snapshot.Sites.Single(s => s.SiteId == 2).StaleInstanceCount);
Assert.Equal(1, snapshot.StaleInstanceCount);
}
[Fact]
public async Task EvaluateAsync_NothingStale_HasNoPendingUpdates()
{
var site = new Site("Alpha", "site-a") { Id = 1 };
site.ArtifactRevisionHash = await CurrentArtifactHashAsync(site);
ArrangeSites(site);
ArrangeInstances(MakeInstance(1, 1, InstanceState.Enabled));
var config = ScalarConfig("5");
var hash = _hasher.ComputeHash(config);
ArrangeDeployedHashes((1, hash));
ArrangeFlatten(1, config, hash);
var snapshot = await _evaluator.EvaluateAsync();
Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount);
Assert.Equal(0, snapshot.StaleInstanceCount);
Assert.Equal(0, snapshot.StaleArtifactSiteCount);
Assert.False(snapshot.HasPendingUpdates);
}
// ── ordering, ComputedAt, ForSites ───────────────────────────────────────
[Fact]
public async Task EvaluateAsync_OrdersSitesByNameIgnoringCase()
{
ArrangeSites(
new Site("charlie", "site-c") { Id = 3 },
new Site("Alpha", "site-a") { Id = 1 },
new Site("bravo", "site-b") { Id = 2 });
ArrangeInstances();
var snapshot = await _evaluator.EvaluateAsync();
Assert.Equal(["Alpha", "bravo", "charlie"], snapshot.Sites.Select(s => s.SiteName));
}
[Fact]
public async Task EvaluateAsync_StampsComputedAt()
{
var before = DateTimeOffset.UtcNow;
ArrangeSites(new Site("Alpha", "site-a") { Id = 1 });
ArrangeInstances();
var snapshot = await _evaluator.EvaluateAsync();
Assert.InRange(snapshot.ComputedAt, before, DateTimeOffset.UtcNow);
}
[Fact]
public async Task EvaluateAsync_ExceptionsPropagate()
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<Site>>(_ => throw new InvalidOperationException("db down"));
// The caller's single-flight cache discards a faulted flight and the UI
// renders "unavailable"; swallowing here would make a broken fleet read
// "up to date", which is the one answer that must never be invented.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _evaluator.EvaluateAsync());
Assert.Equal("db down", ex.Message);
}
[Fact]
public void ForSites_KeepsOnlyTheNamedSitesAndTheSameComputedAt()
{
var computedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
var snapshot = new PendingUpdatesSnapshot(computedAt,
[
new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 2, 2, 0),
new SitePendingUpdates(2, "site-b", "Bravo", ArtifactDeploymentState.Current, null, 1, 1, 0),
new SitePendingUpdates(3, "site-c", "Charlie", ArtifactDeploymentState.OutOfDate, null, 0, 0, 0)
]);
var scoped = snapshot.ForSites(new HashSet<int> { 2 });
Assert.Equal(computedAt, scoped.ComputedAt);
var row = Assert.Single(scoped.Sites);
Assert.Equal(2, row.SiteId);
// The totals are recomputed over the kept sites, so a site-scoped user is
// never shown a count that includes a site they cannot see.
Assert.Equal(1, scoped.StaleInstanceCount);
Assert.Equal(0, scoped.StaleArtifactSiteCount);
Assert.True(scoped.HasPendingUpdates);
}
[Fact]
public void ForSites_EmptyScope_YieldsNoPendingUpdates()
{
var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow,
[
new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 2, 2, 0)
]);
var scoped = snapshot.ForSites(new HashSet<int>());
Assert.Empty(scoped.Sites);
Assert.False(scoped.HasPendingUpdates);
}
[Fact]
public void Snapshot_Totals_SumAcrossSites()
{
var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow,
[
new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 4, 2, 1),
new SitePendingUpdates(2, "site-b", "Bravo", ArtifactDeploymentState.NeverDeployed, null, 0, 0, 0),
new SitePendingUpdates(3, "site-c", "Charlie", ArtifactDeploymentState.Current, null, 3, 3, 0)
]);
Assert.Equal(5, snapshot.StaleInstanceCount);
// NeverDeployed is deliberately not counted alongside OutOfDate.
Assert.Equal(1, snapshot.StaleArtifactSiteCount);
Assert.True(snapshot.HasPendingUpdates);
}
}
@@ -84,7 +84,8 @@ public class ManagementActorTests : TestKit, IDisposable
comms,
Substitute.For<IAuditService>(),
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance);
NullLogger<ArtifactDeploymentService>.Instance,
new ArtifactRevisionHashService());
_services.AddScoped(_ => _artifactService);
}
@@ -45,8 +45,10 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
/// Tasks 3–5. Excluded properties are limited to: surrogate <c>Id</c>s, FK id
/// columns that are legitimately remapped to the target's own surrogate keys,
/// inheritance-placeholder flags that are re-derived at import (never carried), and
/// the one documented design reset (imported <see cref="Instance.State"/> →
/// <see cref="InstanceState.NotDeployed"/>). Each exclusion is annotated inline.
/// the documented design resets — imported <see cref="Instance.State"/> →
/// <see cref="InstanceState.NotDeployed"/>, and <see cref="Site.ArtifactRevisionHash"/> /
/// <see cref="Site.ArtifactsDeployedAt"/> → null ("artifacts never deployed" in the importing
/// environment). Each exclusion is annotated inline.
/// </para>
/// <para>
/// Both the "source" and "imported" entities are re-read from the DB (AsNoTracking)
@@ -783,6 +785,10 @@ public sealed class RoundTripEquivalenceTests : IDisposable
NodeBAddress = "akka://site@10.0.0.2:2552",
GrpcNodeAAddress = "10.0.0.1:8083",
GrpcNodeBAddress = "10.0.0.2:8083",
// Non-default deploy state, so the imported site is provably reset rather than
// accidentally equal to an unset source.
ArtifactRevisionHash = "artifact-hash-1",
ArtifactsDeployedAt = new DateTimeOffset(2026, 9, 14, 10, 0, 0, TimeSpan.Zero),
};
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
@@ -883,8 +889,14 @@ public sealed class RoundTripEquivalenceTests : IDisposable
var impConn = await ctx.DataConnections.AsNoTracking().SingleAsync(c => c.Name == "OpcUaPrimary");
var impInstance = await LoadInstanceAsync(ctx, "Pump-01");
// Site.
AssertRoundTripEqual(srcSite, impSite, nameof(Site.Id));
// Site. ArtifactRevisionHash/ArtifactsDeployedAt are environment-specific deploy state
// (like Instance.State) — a site reads "artifacts never deployed" in the importing
// environment until artifacts are deployed there, so they are deliberately not
// transported.
AssertRoundTripEqual(srcSite, impSite,
nameof(Site.Id), nameof(Site.ArtifactRevisionHash), nameof(Site.ArtifactsDeployedAt));
Assert.Null(impSite.ArtifactRevisionHash);
Assert.Null(impSite.ArtifactsDeployedAt);
// DataConnection. SiteId remapped; Primary/Backup config ride the SecretsBlock.
AssertRoundTripEqual(srcConn, impConn, nameof(DataConnection.Id), nameof(DataConnection.SiteId));