docs(plans): pending-updates design + plan (site artifact staleness, rail indicator, scrollable nav)
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
@@ -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": "pending"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: Site artifact revision columns, migration, repository projection, Transport guard",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: ArtifactRevisionHashService + stamping on successful deploy",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: DeploymentService.GetInstanceStalenessAsync",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: PendingUpdatesEvaluator + snapshot types + options",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: PendingUpdatesCache (CentralUI singleton)",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Rail indicator component",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
5,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: Sites page artifact badges, instance line, re-check",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: Scrollable rail nav",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: Documentation",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
6,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: Rig \u2014 rebuild, verify script, Playwright smoke",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
6,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11: Finish \u2014 sweep, review, merge, memory",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
9,
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-09-14T17:28:49Z"
|
||||
}
|
||||
Reference in New Issue
Block a user