df2a488b81
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.
220 lines
11 KiB
Markdown
220 lines
11 KiB
Markdown
# 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**.
|