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:
@@ -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 1–1000, 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 (1–1000) / 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**.
|
||||
Reference in New Issue
Block a user