From 396052a126160518e51891c7ee8f6515e6da5a05 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 07:56:39 -0400 Subject: [PATCH] =?UTF-8?q?feat(adminui):=20F15=20Phase=20C=20=E2=80=94=20?= =?UTF-8?q?config-tab=20read=20views=20(Equipment/UNS/Namespaces/Drivers/T?= =?UTF-8?q?ags/ACLs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Q3 of the rebuild plan, each v1 ClusterDetail tab becomes a separate route under /clusters/{id}/. This batch adds read-only table views for the six core config entity types; live-edit forms with RowVersion concurrency land in Phase C.2 once the read-view shape is reviewed. - ClusterEquipment /clusters/{id}/equipment — joins via DriverInstance so the cluster scope works - ClusterUns /clusters/{id}/uns — Areas + Lines tables - ClusterNamespaces /clusters/{id}/namespaces — Kind + URI + Enabled chip - ClusterDrivers /clusters/{id}/drivers — collapsed list with JSON config expandable per Q1 (typed editors deferred) - ClusterTags /clusters/{id}/tags — first 200 by name + filter - ClusterAcls /clusters/{id}/acls — LDAP group + scope + NodePermissions bits Shared ClusterNav.razor extracted; ClusterOverview + ClusterRedundancy updated to use it. _Imports.razor adds Components.Shared so the shared nav is in scope across pages. --- .../Pages/Clusters/ClusterAcls.razor | 96 ++++++++++++++++ .../Pages/Clusters/ClusterDrivers.razor | 86 +++++++++++++++ .../Pages/Clusters/ClusterEquipment.razor | 85 +++++++++++++++ .../Pages/Clusters/ClusterNamespaces.razor | 79 ++++++++++++++ .../Pages/Clusters/ClusterOverview.razor | 5 +- .../Pages/Clusters/ClusterRedundancy.razor | 5 +- .../Pages/Clusters/ClusterTags.razor | 103 ++++++++++++++++++ .../Pages/Clusters/ClusterUns.razor | 99 +++++++++++++++++ .../Components/Shared/ClusterNav.razor | 24 ++++ 9 files changed, 574 insertions(+), 8 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterTags.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterUns.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ClusterNav.razor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor new file mode 100644 index 0000000..6a31313 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor @@ -0,0 +1,96 @@ +@page "/clusters/{ClusterId}/acls" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

ACLs · @ClusterId

+
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ ACL rows grant LDAP groups specific NodePermissions on a scope + (a folder, an equipment, a tag). Q4 of the AdminUI rebuild plan dropped per-cluster role + grants in favour of fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained + per-node scope. Live editing lands in a Phase C.2 follow-up. +
+ +
+
@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No ACL rows for this cluster — default permissions from the fleet-wide LDAP group mapping apply.
+ } + else + { +
+ + + + + + + + + + + + + @foreach (var a in _rows) + { + + + + + + + + + } + +
NodeAclIdLDAP groupScopeScope targetPermissionsNotes
@a.NodeAclId@a.LdapGroup@a.ScopeKind@(a.ScopeId ?? "—") + @foreach (var perm in PermissionChips(a.PermissionFlags)) + { + @perm + } + @(a.Notes ?? "")
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.NodeAcls.AsNoTracking() + .Where(a => a.ClusterId == ClusterId) + .OrderBy(a => a.NodeAclId) + .ToListAsync(); + } + + private static IEnumerable PermissionChips(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions flags) + { + foreach (var v in Enum.GetValues()) + { + // Skip None (zero) and composite values that aren't single bits. + var n = (int)v; + if (n == 0) continue; + if ((n & (n - 1)) != 0) continue; + if (flags.HasFlag(v)) yield return v.ToString(); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor new file mode 100644 index 0000000..e7edcf0 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor @@ -0,0 +1,86 @@ +@page "/clusters/{ClusterId}/drivers" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Drivers · @ClusterId

+
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Per Q1 of the AdminUI rebuild plan, typed driver editors (Modbus, FOCAS) are deferred. + The expanded view below shows raw JSON config. Live editing — including a generic JSON + editor and per-driver-type forms when operators ask — lands in a Phase C.2 follow-up. +
+ +
+
@_rows.Count driver instance@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No driver instances for this cluster.
+ } + else + { + @foreach (var d in _rows) + { +
+ + @d.DriverInstanceId + · @d.Name + · @d.DriverType + @if (!d.Enabled) { Disabled } + ns=@d.NamespaceId + +
+
@FormatJson(d.DriverConfig)
+ @if (!string.IsNullOrWhiteSpace(d.ResilienceConfig)) + { +
Resilience overrides:
+
@FormatJson(d.ResilienceConfig)
+ } +
+
+ } + } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId) + .OrderBy(d => d.DriverInstanceId) + .ToListAsync(); + } + + private static string FormatJson(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return ""; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(raw); + return System.Text.Json.JsonSerializer.Serialize(doc.RootElement, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + catch + { + return raw; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor new file mode 100644 index 0000000..ca55d15 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor @@ -0,0 +1,85 @@ +@page "/clusters/{ClusterId}/equipment" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Equipment · @ClusterId

+
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Equipment rows are scoped to a UNS line and bound to a single driver. EquipmentId is + system-generated (decision #125); browse identifiers are MachineCode (operator) + ZTag + (ERP). Live editing lands in a Phase C.2 follow-up. +
+ +
+
@_rows.Count equipment row@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No equipment defined for this cluster.
+ } + else + { +
+ + + + + + + + + + + + + + @foreach (var e in _rows) + { + + + + + + + + + + } + +
EquipmentIdNameMachineCodeZTagDriverUNS lineIdentification
@e.EquipmentId@e.Name@e.MachineCode@(e.ZTag ?? "—")@e.DriverInstanceId@e.UnsLineId + @if (!string.IsNullOrWhiteSpace(e.Manufacturer)) { @e.Manufacturer } + @if (!string.IsNullOrWhiteSpace(e.Model)) { / @e.Model } +
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + var driversInCluster = db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId).Select(d => d.DriverInstanceId); + _rows = await db.Equipment.AsNoTracking() + .Where(e => driversInCluster.Contains(e.DriverInstanceId)) + .OrderBy(e => e.Name) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor new file mode 100644 index 0000000..9787afc --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor @@ -0,0 +1,79 @@ +@page "/clusters/{ClusterId}/namespaces" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Namespaces · @ClusterId

+
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Namespaces are content (decision #123) — they're served at the OPC UA endpoint and bound + to driver instances. NamespaceUri must be unique fleet-wide. Live editing lands in a + Phase C.2 follow-up. +
+ +
+
@_rows.Count namespace@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No namespaces defined for this cluster.
+ } + else + { +
+ + + + + + + + + + + + @foreach (var n in _rows) + { + + + + + + + + } + +
NamespaceIdKindURIStatusNotes
@n.NamespaceId@n.Kind@n.NamespaceUri + @if (n.Enabled) { Enabled } + else { Disabled } + @(n.Notes ?? "")
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor index 605ede8..04d0223 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor @@ -31,10 +31,7 @@ else - +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor index ded86d0..e5c0028 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor @@ -26,10 +26,7 @@ else
- +
v2 redundancy is computed at runtime by RedundancyStateActor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterTags.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterTags.razor new file mode 100644 index 0000000..3183ebe --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterTags.razor @@ -0,0 +1,103 @@ +@page "/clusters/{ClusterId}/tags" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Tags · @ClusterId

+
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Tags are bound to a driver instance and (optionally) an equipment + poll group. The view + below shows the first @PageSize tags by Name; full pagination + search land in Phase C.2. +
+ +
+ + + Showing @VisibleRows.Count of @_rows.Count + +
+ +
+
Tags
+ @if (VisibleRows.Count == 0) + { +
No tags match the current filter.
+ } + else + { +
+ + + + + + + + + + + + + + + @foreach (var t in VisibleRows) + { + + + + + + + + + + + } + +
TagIdNameDriverEquipmentData typeAccessFolderPoll group
@t.TagId@t.Name@t.DriverInstanceId@(t.EquipmentId ?? "—")@t.DataType@t.AccessLevel@(t.FolderPath ?? "")@(t.PollGroupId ?? "—")
+
+ } +
+} + +@code { + private const int PageSize = 200; + + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + private string _filter = ""; + + private List VisibleRows => (_rows ?? new()) + .Where(t => string.IsNullOrWhiteSpace(_filter) + || t.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + .Take(PageSize) + .ToList(); + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + // Tags don't carry ClusterId; resolve via DriverInstance scoping. + var driverIds = db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId) + .Select(d => d.DriverInstanceId); + _rows = await db.Tags.AsNoTracking() + .Where(t => driverIds.Contains(t.DriverInstanceId)) + .OrderBy(t => t.Name) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterUns.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterUns.razor new file mode 100644 index 0000000..927f43e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterUns.razor @@ -0,0 +1,99 @@ +@page "/clusters/{ClusterId}/uns" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

UNS structure · @ClusterId

+
+ + + +@if (_areas is null || _lines is null) +{ +

Loading…

+} +else +{ +
+ UNS levels: Enterprise (cluster) → Site (cluster) → Area → Line → Equipment. Areas and + lines are cluster-scoped; equipment hangs under a single line. Live editing lands in a + Phase C.2 follow-up. +
+ +
+
Areas (level 3) · @_areas.Count
+ @if (_areas.Count == 0) + { +
No areas defined.
+ } + else + { +
+ + + + @foreach (var a in _areas) + { + + + + + + } + +
UnsAreaIdNameNotes
@a.UnsAreaId@a.Name@(a.Notes ?? "")
+
+ } +
+ +
+
Lines (level 4) · @_lines.Count
+ @if (_lines.Count == 0) + { +
No lines defined.
+ } + else + { +
+ + + + @foreach (var l in _lines) + { + + + + + + + } + +
UnsLineIdNameAreaNotes
@l.UnsLineId@l.Name@l.UnsAreaId@(l.Notes ?? "")
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _areas; + private List? _lines; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _areas = await db.UnsAreas.AsNoTracking() + .Where(a => a.ClusterId == ClusterId) + .OrderBy(a => a.UnsAreaId) + .ToListAsync(); + var areaIds = _areas.Select(a => a.UnsAreaId).ToList(); + _lines = await db.UnsLines.AsNoTracking() + .Where(l => areaIds.Contains(l.UnsAreaId)) + .OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ClusterNav.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ClusterNav.razor new file mode 100644 index 0000000..6b4364b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ClusterNav.razor @@ -0,0 +1,24 @@ +@* Shared nav strip rendered above every cluster-scoped page. Per Q3 of the AdminUI rebuild + plan, the v1 monolithic ClusterDetail tab host is split into separate routes — these are + `` links, not Blazor router transitions, so each page bootstraps its own data + independently and can opt into a heavier render mode without dragging the others. *@ + +@code { + [Parameter, EditorRequired] public string ClusterId { get; set; } = ""; + [Parameter, EditorRequired] public string ActiveTab { get; set; } = ""; +} + + + +@code { + private string Active(string tab) => tab == ActiveTab ? "active" : ""; +}