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

11 KiB
Raw Blame History

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

@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 UnsNodes 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.