Files
lmxopcua/docs/plans/2026-06-11-equipment-page-design.md
T
Joseph Doherty df2a488b81 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.
2026-06-11 14:03:21 -04:00

220 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**.