docs(uns): design for tabbed equipment detail page

Replace the modal-based equipment editor on /uns with a dedicated
/uns/equipment/{id} page carrying Details/Tags/Virtual Tags/Alarms
tabs; trim the UNS tree so Equipment is a leaf that links to the page;
remove the standalone /scripted-alarms pages in favour of the per-
equipment Alarms tab. Reuses TagModal + VirtualTagModal unchanged; only
the alarm editor is new. No entity/EF-migration change.
This commit is contained in:
Joseph Doherty
2026-06-11 14:03:21 -04:00
parent 6208304a44
commit df2a488b81
@@ -0,0 +1,219 @@
# Equipment Detail Page (UNS) — Design
**Date:** 2026-06-11
**Status:** Approved (brainstorming) — ready for implementation plan
**Author:** design session
## Goal
Replace the modal-based equipment editor on the global UNS page with a
dedicated, tabbed **Equipment detail page**. The page owns the equipment's
identity form plus its child collections — **Tags**, **Virtual Tags**, and
**Alarms** — each on its own tab. The `/uns` tree is simplified so that
Equipment is a leaf that links to this page; tags and virtual tags no longer
appear inline in the tree, and the standalone scripted-alarm pages are removed.
## Motivation
Today `GlobalUns.razor` (`/uns`) is a single page juggling seven modals
(Area / Line / Equipment / Tag / VirtualTag / Import / delete-confirm) and a
lazy-expanding tree whose Equipment nodes load Tags + VirtualTags as leaf
children. Scripted-alarm *definitions* live on a separate pair of pages
(`/scripted-alarms`, `/scripted-alarms/{id}`) that the UNS tree never links to,
so an equipment's alarms are managed in a different place from its tags. This
spreads one equipment's configuration across a deep tree and three unrelated
surfaces. Consolidating everything for a single equipment onto one tabbed page
makes the per-equipment workflow coherent and trims the `/uns` tree back to the
pure hierarchy (Area → Line → Equipment).
## Decisions (settled in brainstorming)
1. **Tab set:** `Details · Tags · Virtual Tags · Alarms`. *Details* is the first
tab and hosts the equipment identity form + save.
2. **"Scripts" = Virtual Tags with their inline bound script source** — i.e.
today's `VirtualTagModal` behaviour (the inline Monaco editor) surfaced under
the *Virtual Tags* tab.
3. **Route:** `/uns/equipment/{equipmentId}` (nested under UNS), plus
`/uns/equipment/new` for create.
4. **Alarms:** the standalone global pages (`/scripted-alarms` list +
`/scripted-alarms/{id}` editor) are **removed**; scripted-alarm definitions
are managed only from the equipment page's *Alarms* tab. Tradeoff accepted:
no fleet-wide alarm list — alarms are reached per equipment.
5. **In-page tabs, single route** (not route-per-tab): one page, a thin
`nav-tabs` strip flips `_activeTab`, content switches via `@if`. No state loss
on tab switch; no reusable `<Tabs>` component is introduced (YAGNI).
6. **Maximum reuse of the risky UI:** the existing `TagModal` (driver-typed
`TagConfigEditorMap` editors) and `VirtualTagModal` (inline Monaco script
editor) are reused **unchanged** as the add/edit dialogs on their tabs — they
already accept a fixed `EquipmentId`. Only the *Alarms* editor is genuinely
new.
## Approach (chosen) vs alternatives
- **A — Single page, in-page tabs, reuse existing modals (CHOSEN).** Faithful to
"tabs on the new page"; reuses the two hardest pieces (driver-typed tag
editors, Monaco) with zero rework; keeps everything testable through
`IUnsTreeService`; one route, no tab-switch state loss.
- **B — Route-per-tab (the `ClusterNav` precedent).** `/uns/equipment/{id}`,
`/…/tags`, `/…/virtual-tags`, `/…/alarms` as separate routed pages sharing an
`EquipmentNav`. Rejected: every tab switch is a full navigation (unsaved
Details edits lost), create flow is awkward before an id exists, and more
files for no functional gain.
- **C — Keep modals, add a thin detail page.** Rejected: doesn't satisfy moving
tags/scripts/alarms into tabs.
## Architecture
### New page — `Components/Pages/Uns/EquipmentPage.razor`
```razor
@page "/uns/equipment/new"
@page "/uns/equipment/{EquipmentId}"
@attribute [Authorize] // mirror GlobalUns
@rendermode RenderMode.InteractiveServer
@inject IUnsTreeService Svc
@inject NavigationManager Nav
```
- `[Parameter] public string? EquipmentId`; `IsNew => string.IsNullOrEmpty(EquipmentId)`.
- `[SupplyParameterFromQuery] public string? LineId` — carries the parent line on
create (set by the tree's "Add equipment").
- `OnParametersSetAsync` loads the `EquipmentEditDto` (when editing), the line
options, the driver options, then the active tab's list.
- **Tab mechanism:** plain `_activeTab` field (default `"details"`); a Bootstrap
`nav-tabs` strip flips it; content is `@if (_activeTab == "...")`. While
`IsNew`, the Tags / Virtual Tags / Alarms tabs render a "Save the equipment
first" placeholder (no id to scope children to yet).
### Create → edit flow (ScriptEdit `/new` → `/{id}` precedent)
- On *create*, the Details tab's `SaveAsync` calls `CreateEquipmentAsync(input)`
then `Nav.NavigateTo($"/uns/equipment/{createdId}")`, landing on the edit page
with all tabs live.
- This requires the system-generated `EQ-…` id back from the create. Add a
nullable **`CreatedId`** to `UnsMutationResult` (populated by the create
methods); `Ok` / `Error` / concurrency semantics are unchanged.
- Edit saves call `UpdateEquipmentAsync(id, input, rowVersion)`;
`DbUpdateConcurrencyException` surfaces the existing reload message.
### Details tab
The `FormModel` + DataAnnotations + `SaveAsync` are lifted **verbatim** out of
`EquipmentModal.razor` (Name regex `^[a-z0-9-]{1,32}$`, MachineCode, UnsLineId
select, DriverInstanceId select, ZTag/SAPID, the OPC 40010 identity fields,
Enabled). Once this lands, `EquipmentModal.razor` is deleted.
### Tags tab
- Table of the equipment's tags (Name, driver, DataType, AccessLevel) with
Add / Edit / Delete.
- Add/Edit **reuse `TagModal` unchanged** (fixed `EquipmentId`, `Existing
TagEditDto`, driver options via `LoadTagDriversForEquipmentAsync`); `OnSaved`
reloads the list and closes the modal.
- Delete → `DeleteTagAsync(tagId, rowVersion)`.
- New service method `LoadTagsForEquipmentAsync(equipmentId)` returns a small
row-DTO list with the table columns (the existing `LoadEquipmentChildrenAsync`
returns tree `UnsNode`s without driver/type columns).
### Virtual Tags tab
- Table of VirtualTags (Name, DataType, bound Script, triggers, Enabled);
Add/Edit **reuse `VirtualTagModal` unchanged** (inline Monaco source editor
comes along for free); Delete → `DeleteVirtualTagAsync`.
- New `LoadVirtualTagsForEquipmentAsync(equipmentId)` for the table; script
options via existing `LoadScriptsAsync()`.
### Alarms tab
Mirrors the Tags / Virtual Tags shape (table + modal + delete). The standalone
alarm pages used EF directly; that logic moves **into `IUnsTreeService`** so the
tab is service-tested like the rest:
- `LoadAlarmsForEquipmentAsync(equipmentId)` → row DTOs (Name, AlarmType,
Severity, predicate script, Enabled, RowVersion).
- `LoadScriptedAlarmAsync(scriptedAlarmId)` → full `ScriptedAlarmEditDto`.
- `CreateScriptedAlarmAsync(equipmentId, ScriptedAlarmInput)` (returns
`CreatedId`), `UpdateScriptedAlarmAsync(id, input, rowVersion)`,
`DeleteScriptedAlarmAsync(id, rowVersion)` — same `RowVersion` guard pattern as
the rest of the service.
- New AdminUI types `ScriptedAlarmInput` (Name, AlarmType, Severity,
MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) and
`ScriptedAlarmEditDto`. **No equipment picker** — equipment is fixed by the tab
(the one field the old page had that we drop).
- New `Components/Shared/Uns/ScriptedAlarmModal.razor` (shell modeled on
`TagModal`): the alarm fields, a script `<select>` from `LoadScriptsAsync()`,
client validation (Name required, Severity 11000, AlarmType ∈ allowed set,
PredicateScriptId required), `HistorizeToAveva` default **true** (decision #15
preserved).
- The `ScriptedAlarm` **entity** and all runtime / historian wiring are
untouched.
### `/uns` tree surgery (`GlobalUns.razor` + `UnsTree.razor`)
- Equipment node loses its expand caret and the `LoadEquipmentChildrenAsync`
lazy path — it becomes a **leaf** whose row navigates to
`/uns/equipment/{id}` (row click / "Open" affordance).
- "Add equipment" under a Line → `Nav.NavigateTo($"/uns/equipment/new?lineId={lineId}")`
instead of opening a modal.
- Remove from `GlobalUns`: the `EquipmentModal`, `TagModal`, `VirtualTagModal`
markup + their state/handlers + the tag/vtag delete-confirm + children-refresh
code. **Area / Line modals + CSV import stay.**
- `LoadEquipmentChildrenAsync` becomes dead → removed, replaced by the two
scoped list methods (its `UnsTreeServiceLazyTests` coverage is ported onto
those).
### Removals (global alarm surface)
- Delete `Components/Pages/ScriptedAlarms.razor` and
`Components/Pages/ScriptedAlarmEdit.razor`.
- Drop the `/scripted-alarms` nav-menu link; grep-sweep any remaining
`/scripted-alarms` references. The `/alerts` runtime view is separate and
stays.
## Net service / type surface (no schema change)
New on `IUnsTreeService`:
- `LoadTagsForEquipmentAsync`, `LoadVirtualTagsForEquipmentAsync`
- `LoadAlarmsForEquipmentAsync`, `LoadScriptedAlarmAsync`,
`CreateScriptedAlarmAsync`, `UpdateScriptedAlarmAsync`, `DeleteScriptedAlarmAsync`
New AdminUI types: `ScriptedAlarmInput`, `ScriptedAlarmEditDto`,
`UnsMutationResult.CreatedId` (nullable field).
**No Configuration entity change and no EF migration** — the `ScriptedAlarm`
entity already exists; everything new is AdminUI service methods + DTOs + one
nullable result field.
## Testing
No bUnit in the repo — component behaviour is proven live; service logic is unit
tested.
- New / updated `IUnsTreeService` tests under
`tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/`:
- `LoadTagsForEquipmentAsync` / `LoadVirtualTagsForEquipmentAsync` — scoped to
one equipment, ordered.
- `UnsTreeServiceScriptedAlarmTests` — alarm CRUD + severity (11000) / name /
AlarmType validation + `HistorizeToAveva` default true.
- `UnsMutationResult.CreatedId` populated on creates.
- Ported lazy-test coverage (replacing `LoadEquipmentChildrenAsync`).
- Caveat (pre-existing): EF InMemory doesn't enforce `RowVersion`, so the
`DbUpdateConcurrencyException` branch stays live-only.
- **Live docker-dev `/run`** (user drives; agent does **not** sign in): equipment
leaf → page; each tab lists + add/edit/delete round-trips; create → redirect to
`/uns/equipment/{newId}`; `/scripted-alarms` is gone.
**Done gate:** solution builds clean + `dotnet test` green + live `/run` pass.
## Docs
- `docs/Uns.md` — tree now stops at Equipment; document the equipment page +
tabs; remove the "tags under equipment in the tree" description.
- `docs/ScriptedAlarms.md` / `docs/AlarmTracking.md` — repoint the AdminUI-surface
notes from `/scripted-alarms` to the equipment Alarms tab.
## Guardrails
Feature branch off master; stage by explicit path (never `git add .`); never
stage `sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; no force-push;
no `--no-verify`; **no Configuration entity or EF migration change**.