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.
11 KiB
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)
- Tab set:
Details · Tags · Virtual Tags · Alarms. Details is the first tab and hosts the equipment identity form + save. - "Scripts" = Virtual Tags with their inline bound script source — i.e.
today's
VirtualTagModalbehaviour (the inline Monaco editor) surfaced under the Virtual Tags tab. - Route:
/uns/equipment/{equipmentId}(nested under UNS), plus/uns/equipment/newfor create. - Alarms: the standalone global pages (
/scripted-alarmslist +/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. - In-page tabs, single route (not route-per-tab): one page, a thin
nav-tabsstrip flips_activeTab, content switches via@if. No state loss on tab switch; no reusable<Tabs>component is introduced (YAGNI). - Maximum reuse of the risky UI: the existing
TagModal(driver-typedTagConfigEditorMapeditors) andVirtualTagModal(inline Monaco script editor) are reused unchanged as the add/edit dialogs on their tabs — they already accept a fixedEquipmentId. 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
ClusterNavprecedent)./uns/equipment/{id},/…/tags,/…/virtual-tags,/…/alarmsas separate routed pages sharing anEquipmentNav. 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").OnParametersSetAsyncloads theEquipmentEditDto(when editing), the line options, the driver options, then the active tab's list.- Tab mechanism: plain
_activeTabfield (default"details"); a Bootstrapnav-tabsstrip flips it; content is@if (_activeTab == "..."). WhileIsNew, 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
SaveAsynccallsCreateEquipmentAsync(input)thenNav.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 nullableCreatedIdtoUnsMutationResult(populated by the create methods);Ok/Error/ concurrency semantics are unchanged. - Edit saves call
UpdateEquipmentAsync(id, input, rowVersion);DbUpdateConcurrencyExceptionsurfaces 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
TagModalunchanged (fixedEquipmentId,Existing TagEditDto, driver options viaLoadTagDriversForEquipmentAsync);OnSavedreloads 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 existingLoadEquipmentChildrenAsyncreturns treeUnsNodes without driver/type columns).
Virtual Tags tab
- Table of VirtualTags (Name, DataType, bound Script, triggers, Enabled);
Add/Edit reuse
VirtualTagModalunchanged (inline Monaco source editor comes along for free); Delete →DeleteVirtualTagAsync. - New
LoadVirtualTagsForEquipmentAsync(equipmentId)for the table; script options via existingLoadScriptsAsync().
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)→ fullScriptedAlarmEditDto.CreateScriptedAlarmAsync(equipmentId, ScriptedAlarmInput)(returnsCreatedId),UpdateScriptedAlarmAsync(id, input, rowVersion),DeleteScriptedAlarmAsync(id, rowVersion)— sameRowVersionguard pattern as the rest of the service.- New AdminUI types
ScriptedAlarmInput(Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) andScriptedAlarmEditDto. 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 onTagModal): the alarm fields, a script<select>fromLoadScriptsAsync(), client validation (Name required, Severity 1–1000, AlarmType ∈ allowed set, PredicateScriptId required),HistorizeToAvevadefault true (decision #15 preserved). - The
ScriptedAlarmentity and all runtime / historian wiring are untouched.
/uns tree surgery (GlobalUns.razor + UnsTree.razor)
- Equipment node loses its expand caret and the
LoadEquipmentChildrenAsynclazy 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: theEquipmentModal,TagModal,VirtualTagModalmarkup + their state/handlers + the tag/vtag delete-confirm + children-refresh code. Area / Line modals + CSV import stay. LoadEquipmentChildrenAsyncbecomes dead → removed, replaced by the two scoped list methods (itsUnsTreeServiceLazyTestscoverage is ported onto those).
Removals (global alarm surface)
- Delete
Components/Pages/ScriptedAlarms.razorandComponents/Pages/ScriptedAlarmEdit.razor. - Drop the
/scripted-alarmsnav-menu link; grep-sweep any remaining/scripted-alarmsreferences. The/alertsruntime view is separate and stays.
Net service / type surface (no schema change)
New on IUnsTreeService:
LoadTagsForEquipmentAsync,LoadVirtualTagsForEquipmentAsyncLoadAlarmsForEquipmentAsync,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
IUnsTreeServicetests undertests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/:LoadTagsForEquipmentAsync/LoadVirtualTagsForEquipmentAsync— scoped to one equipment, ordered.UnsTreeServiceScriptedAlarmTests— alarm CRUD + severity (1–1000) / name / AlarmType validation +HistorizeToAvevadefault true.UnsMutationResult.CreatedIdpopulated on creates.- Ported lazy-test coverage (replacing
LoadEquipmentChildrenAsync). - Caveat (pre-existing): EF InMemory doesn't enforce
RowVersion, so theDbUpdateConcurrencyExceptionbranch 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-alarmsis 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-alarmsto 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.