diff --git a/docs/plans/2026-06-16-stillpending-phase-7-client-alarm-buttons-design.md b/docs/plans/2026-06-16-stillpending-phase-7-client-alarm-buttons-design.md new file mode 100644 index 00000000..16cf53af --- /dev/null +++ b/docs/plans/2026-06-16-stillpending-phase-7-client-alarm-buttons-design.md @@ -0,0 +1,116 @@ +# Still-Pending Phase 7 — Client.UI alarm Ack/Shelve/Confirm buttons — design + +> **Status:** approved 2026-06-16 (UI surface = "extend context menu", Confirm gating = "simple", +> both via AskUserQuestion). Parent roadmap: `docs/plans/2026-06-15-stillpending-backlog-design.md` +> (Phase 7). Branch `feat/stillpending-phase-7-client-alarm-buttons` off master `ad3ec9d9`. +> Phases 0–6 already shipped. + +## Goal + +Give the Avalonia desktop client (`Client.UI`) operator-invokable **Acknowledge / Shelve / Confirm** +on the AlarmsView, via the existing right-click context menu, backed by the already-implemented +`IOpcUaClientService` methods (Client.CLI already has all three). Closes the `stillpending.md` §4 +Client.UI gaps: "AlarmsView has no per-row Acknowledge button" + "Shelve/Confirm have no UI surface". + +## Grounding (verified against master `ad3ec9d9`) + +- **AlarmsView** (`src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/AlarmsView.axaml{,.cs}`) is a `DataGrid`; + its code-behind builds a **runtime context menu in `OnLoaded`** with ONE item, "Acknowledge…", shown + when `CanAcknowledge`, opening `AckAlarmWindow` → `AlarmsViewModel.AcknowledgeAlarmAsync`. +- MVVM = **CommunityToolkit.Mvvm** (`[RelayCommand]`, `ObservableObject`). +- **`IOpcUaClientService`** (`Client.Shared/IOpcUaClientService.cs`) ALREADY has: + `AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment, ct)`, + `ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment, ct)`, + `ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0, ct)`. + `ShelveKind` = OneShot / Timed / Unshelve. **Shelve takes NO comment**; duration is in **seconds** + (the service converts to ms). All three return `Task`. +- **Client.CLI** `ack`/`confirm`/`shelve` commands already complete — the reference param shapes. +- **`AlarmEventViewModel`** exposes `ConditionNodeId`, `EventId`, `ActiveState`, `AckedState`, `Retain`, + `Severity`, `Message`, `Time`, and `CanAcknowledge` (`ActiveState && !AckedState && EventId != null && + ConditionNodeId != null`). NO `CanShelve`/`CanConfirm`, NO `ShelvedState`/`ConfirmedState`. +- **`AlarmsViewModel.AcknowledgeAlarmAsync(alarm, comment)`** exists and returns `(bool Success, + string Message)`; NO Shelve/Confirm equivalents. +- Tests: `tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/` (`AlarmsViewModelTests` + a UI-side + `FakeOpcUaClientService` test double). CLI side has `ShelveCommandTests`/`ConfirmCommandTests`. + +## Locked decisions + +1. **UI surface = extend the existing right-click context menu** — add "Shelve…" + "Confirm…" beside + "Acknowledge…", mirroring the existing wiring. (Not inline-button columns nor a toolbar.) +2. **Confirm gating = simple** — `CanConfirm = AckedState && EventId != null && ConditionNodeId != null`. + **No `ConfirmedState` event-parsing tracking** (the server rejects a redundant Confirm; the result + banner surfaces the Bad). Avoids touching the Client.Shared event-mapping path. + +## Components + +1. **`AlarmsViewModel`** (`…/ViewModels/AlarmsViewModel.cs`) — add two methods mirroring + `AcknowledgeAlarmAsync`: + - `Task<(bool Success, string Message)> ShelveAlarmAsync(AlarmEventViewModel alarm, ShelveKind kind, + double durationSeconds)` — guard `IsConnected && alarm.ConditionNodeId != null` (EventId NOT needed + for shelve); guard Timed ⇒ `durationSeconds > 0` (friendly fail BEFORE the call); call + `_service.ShelveAlarmAsync(alarm.ConditionNodeId, kind, durationSeconds)`; map `StatusCode` → + `(Success, Message)`. + - `Task<(bool Success, string Message)> ConfirmAlarmAsync(AlarmEventViewModel alarm, string comment)` + — guard `IsConnected && ConditionNodeId != null && EventId != null`; call + `_service.ConfirmAlarmAsync(nodeId, eventId, comment)`; map result. (Byte-for-byte mirror of + `AcknowledgeAlarmAsync`.) +2. **`AlarmEventViewModel`** (`…/ViewModels/AlarmEventViewModel.cs`) — add computed: + - `bool CanShelve => ConditionNodeId != null;` + - `bool CanConfirm => AckedState && EventId != null && ConditionNodeId != null;` +3. **`ShelveAlarmWindow`** (NEW — `…/Views/ShelveAlarmWindow.axaml{,.cs}`) — mirror `AckAlarmWindow`'s + shape (source/condition header + result banner). Inputs: a **ShelveKind selector** (ComboBox or + RadioButtons: OneShot / Timed / Unshelve, default OneShot) + a **Duration (seconds)** input enabled + ONLY when Timed. "Shelve" button → `_alarmsVm.ShelveAlarmAsync(alarm, kind, duration)`; on Good close, + else show the Bad in the result banner and stay open. "Cancel" closes. +4. **`ConfirmAlarmWindow`** (NEW — `…/Views/ConfirmAlarmWindow.axaml{,.cs}`) — **clone `AckAlarmWindow`** + (comment box + result banner) → `_alarmsVm.ConfirmAlarmAsync(alarm, comment)`. Cloning (vs. + parametrizing AckAlarmWindow) keeps the codebase's explicit one-window-per-action style (~40 lines). +5. **`AlarmsView.axaml.cs`** — in the runtime context-menu builder, add two `MenuItem`s: "Shelve…" + (enabled/shown when `CanShelve`) and "Confirm…" (when `CanConfirm`), each opening its dialog for the + right-clicked alarm — mirror the existing `OnAcknowledgeClicked` handler + the `CanAcknowledge` gate. + +## Data flow + +right-click row → context menu (items gated on `Can*`) → dialog collects inputs → +`AlarmsViewModel.{Shelve,Confirm}AlarmAsync` → `IOpcUaClientService.{Shelve,Confirm}AlarmAsync` → +OPC UA Part 9 method call → `StatusCode` → result banner; on Good close the dialog. The grid updates +through the existing alarm-event subscription (no manual row mutation). + +## Error handling + +- Not connected / missing required ids → friendly `(false, message)`, NO service call (mirror Ack). +- Timed shelve with `durationSeconds <= 0` → friendly fail before the call. +- Service returns a Bad `StatusCode` → surface its text in the dialog result banner, keep the dialog + open (mirror Ack). + +## Testing + +- **Unit (`Client.UI.Tests`, mirror `AlarmsViewModelTests` + the UI `FakeOpcUaClientService`):** + `ShelveAlarmAsync` — connected + OneShot/Timed/Unshelve calls the service with the right kind/duration; + not-connected ⇒ no call + fail; Timed with duration ≤ 0 ⇒ no call + fail; service Bad ⇒ fail message. + `ConfirmAlarmAsync` — connected calls service with nodeId/eventId/comment; not-connected / missing id ⇒ + no call + fail; service Bad ⇒ fail. **Enable predicates:** `CanShelve` / `CanConfirm` truth tables. + Extend the UI `FakeOpcUaClientService` with `ShelveAlarmCalls` / `ConfirmAlarmCalls` + configurable + result codes (if not already present). +- `dotnet build` clean + `dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests` green. +- **Live `/run` (honest constraint):** the Avalonia desktop GUI is **not agent-drivable** (no display; + the Chrome browser tools are web-only). Live verification = build + a **launch/connect smoke** against + the running rig (`dotnet run --project src/Client/…Client.UI`, connect to `opc.tcp://localhost:4840` + which carries 1 scripted alarm) to confirm the app starts, connects, and the Alarms grid + context + menu render. The actual Ack/Shelve/Confirm **click-through is operator-gated** — the VM-method → + service round-trip is fully unit-proven. (Recorded as an honest deferral, like prior operator-gated + gates.) + +## Out of scope + +- `ConfirmedState` event-parsing tracking (chose "simple"). +- The Galaxy sub-attribute operator-comment collapse (`stillpending.md` §4 — "verify, may be + acceptable") — left as a noted follow-up. +- NO Client.CLI changes; NO `IOpcUaClientService` changes; NO Commons/EF change. + +## Hard constraints (carried from the parent roadmap) + +- Stage by path — never `git add .`. Never stage `sql_login.txt`, `src/Server/.../Host/pki/`, + `pending.md`, `current.md`, `docker-dev/docker-compose.yml`, `stillpending.md`. Never echo/commit + secrets. No `--no-verify`, no force-push. **NO EF migration, NO Commons contract change.** + Finish = merge to master + push.