fix(central-ui): resolve CentralUI-006 — push-based deployment status via IDeploymentStatusNotifier, remove 10s polling timer

This commit is contained in:
Joseph Doherty
2026-05-17 00:02:45 -04:00
parent a55502254e
commit 34588ae10c
11 changed files with 459 additions and 36 deletions

View File

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-16 |
| Reviewer | claude-agent |
| Commit reviewed | `9c60592` |
| Open findings | 1 |
| Open findings | 0 |
## Summary
@@ -318,7 +318,7 @@ longer imposes a fixed absolute cap. `dotnet build ScadaLink.slnx` clean;
|--|--|
| Severity | Medium |
| Category | Design-document adherence |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor:196-216` |
**Description**
@@ -341,16 +341,59 @@ If polling is kept as a fallback, fetch only changed/in-progress records.
**Resolution**
_Unresolved — a genuine SignalR-push fix requires an event source in another
module._ Verified 2026-05-16: `Deployments.razor` does poll every 10s, contrary
to the design doc. But a real push implementation needs the **Deployment
Manager** module (`ScadaLink.DeploymentManager``DeploymentService` /
`ArtifactDeploymentService` write the `DeploymentRecord` rows) to raise a
status-change event/observable that the page subscribes to; there is no such
event today and no CentralUI-only seam to subscribe to. Building that event
source is out of scope for a CentralUI-only review. Left Open and surfaced for a
follow-up that adds a deployment-status broadcaster in DeploymentManager (or a
design-doc amendment acknowledging the polling fallback).
Resolved 2026-05-16 (commit `<pending>`) — cross-module fix (CentralUI +
DeploymentManager), explicitly authorized. Root cause confirmed against the
source: `Deployments.razor` ran a `Timer` (`OnInitializedAsync``StartTimer`,
10s interval) that, every tick and for every open Blazor circuit, reloaded all
deployment records (`GetAllDeploymentRecordsAsync`) and the full instance map
(`GetAllInstancesAsync`) — contradicting Component-CentralUI "Real-Time Updates"
("transitions push to the UI immediately via SignalR … no polling required").
**Process/DI topology confirmed.** `ScadaLink.Host/Program.cs` calls both
`AddDeploymentManager()` (line 75) and `AddCentralUI()` (line 77) on the same
`builder.Services` — DeploymentManager and the Central UI run **in the same
central Host process**, so a DI singleton is genuinely shared between the
DeploymentManager services and the Blazor circuit's scoped components. The
shared-singleton seam is real; no out-of-process fallback was needed.
**What was implemented — push-based updates.** A new
`IDeploymentStatusNotifier` (`ScadaLink.DeploymentManager/IDeploymentStatusNotifier.cs`)
with a C# `event Action<DeploymentStatusChange>` and a small payload
(`DeploymentStatusChange` = deployment id + instance id + new status). Its
implementation `DeploymentStatusNotifier` invokes each subscriber in isolation
and swallows/logs handler exceptions so a faulting circuit cannot break the
deployment pipeline. It is registered as a **singleton** in `AddDeploymentManager`
(`ServiceCollectionExtensions`). Every place `DeploymentService` writes a
`DeploymentRecord` status now raises the notifier: the `Pending` create, the
`InProgress` update, the site-response terminal update, the `Failed` cleanup
write in the catch block, and the `DeploymentManager-006` reconciled-`Success`
write — five call sites via a private `NotifyStatusChange` helper.
`ArtifactDeploymentService` was inspected and writes only
`SystemArtifactDeploymentRecord` rows, which `Deployments.razor` does not
display, so it correctly raises nothing. `Deployments.razor` no longer has a
`Timer`: `OnInitializedAsync` subscribes to `IDeploymentStatusNotifier.StatusChanged`,
the handler reloads via `InvokeAsync(StateHasChanged)` (the notifier event is
raised on the DeploymentManager service thread), and `Dispose` unsubscribes.
Blazor Server pushes the re-render to the browser over its SignalR circuit
automatically — satisfying the documented design. The existing "Pause/Resume
updates" toggle now gates whether incoming push events are acted on, and
"Refresh" still forces a manual reload. CLAUDE.md UI rules kept: Blazor Server +
Bootstrap, custom components, no third-party frameworks.
Regression tests fail against the pre-fix code and pass after. DeploymentManager
(`DeploymentStatusNotifierTests`): `DeployInstanceAsync_RaisesStatusChange_ForEveryRecordStatusWrite`
(pre-fix: no notifier, fails to compile / silent), plus
`NotifyStatusChanged_WithNoSubscribers_DoesNotThrow` and
`NotifyStatusChanged_ThrowingSubscriber_DoesNotBreakOtherSubscribers`;
`ServiceCollectionExtensionsTests.AddDeploymentManager_RegistersDeploymentStatusNotifier_AsSingleton`
pins the shared-singleton seam. CentralUI (`DeploymentsPushUpdateTests`):
`Deployments_DoesNotPoll_HasNoRefreshTimer` (pre-fix: the `_refreshTimer` field
existed — confirmed failing), `Deployments_StatusChange_TriggersReload`, and
`Deployments_Dispose_UnsubscribesFromNotifier`. `dotnet build ScadaLink.slnx`
clean (0 warnings); `tests/ScadaLink.DeploymentManager.Tests` 76 passed,
`tests/ScadaLink.CentralUI.Tests` 257 passed. (`TopologyPageTests`' DI fixture
was also updated to register the new notifier, since it constructs the real
`DeploymentService`.)
### CentralUI-007 — Monitoring nav links to Deployment-only pages are shown to all roles