Compare commits
20 Commits
9d529b82b2
...
ac833f5ace
| Author | SHA1 | Date | |
|---|---|---|---|
| ac833f5ace | |||
| 3a21cfc2f5 | |||
| 623c6d0fe8 | |||
| af9f14a668 | |||
| 6dc784bca4 | |||
| a672f91d90 | |||
| 94e1d71ba2 | |||
| d8979664f1 | |||
| c724ed6ee2 | |||
| 5d6bd03868 | |||
| 3d22f272d3 | |||
| ada91bb82c | |||
| 0287f8f64f | |||
| fc48db480c | |||
| 3b55d60ef9 | |||
| 5214932447 | |||
| dc60ba1212 | |||
| dd202e5dde | |||
| 0762d69132 | |||
| 47e20dd8a8 |
@@ -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.
|
||||
|
||||
Executable
+304
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+14
@@ -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();
|
||||
|
||||
+2149
File diff suppressed because it is too large
Load Diff
+40
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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)");
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+24
@@ -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);
|
||||
}
|
||||
+183
@@ -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();
|
||||
}
|
||||
+89
@@ -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);
|
||||
}
|
||||
}
|
||||
+182
-2
@@ -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>
|
||||
|
||||
+288
@@ -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)}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+345
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+16
-4
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user