# 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**.
