From 2662ac08e493d354066b10c141885adbcfed2501 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 08:22:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(adminui):=20F15.2=20batch=203=20=E2=80=94?= =?UTF-8?q?=20Equipment=20+=20Tag=20CRUD=20(operator=20surfaces)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two most-edited entities for daily operator workflows. Both follow the same single-page edit-or-create pattern from batches 1 + 2 with RowVersion optimistic concurrency. - EquipmentEdit.razor /clusters/{id}/equipment/{new|EquipmentId} - EquipmentId is system-generated on create (decision #125): EQ-{first 12 hex chars of a new EquipmentUuid}. - UNS line + driver instance selects are scoped to the cluster. - All 9 OPC 40010 identification fields surfaced as an optional panel. - MachineCode uniqueness checked client-side before EF unique index enforces it server-side. - TagEdit.razor /clusters/{id}/tags/{new|TagId} - Equipment vs FolderPath input switches based on the selected driver's namespace kind — Equipment-kind requires EquipmentId, SystemPlatform-kind requires FolderPath (decision #110 invariant enforced client-side; sp_ValidateDraft re-enforces server-side at deploy). - DataType select uses the OPC UA built-in primitive type names. - TagConfig validated as JSON pre-flight. ClusterEquipment + ClusterTags list pages get New / Edit affordances. All 9 integration tests still green. --- .../Pages/Clusters/ClusterEquipment.razor | 3 + .../Pages/Clusters/ClusterTags.razor | 3 + .../Pages/Clusters/EquipmentEdit.razor | 310 +++++++++++++++++ .../Components/Pages/Clusters/TagEdit.razor | 320 ++++++++++++++++++ 4 files changed, 636 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor 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 index ca55d15..51ddd73 100644 --- 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 @@ -8,6 +8,7 @@

Equipment · @ClusterId

+ New equipment
@@ -43,6 +44,7 @@ else Driver UNS line Identification + @@ -59,6 +61,7 @@ else @if (!string.IsNullOrWhiteSpace(e.Manufacturer)) { @e.Manufacturer } @if (!string.IsNullOrWhiteSpace(e.Model)) { / @e.Model } + Edit } 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 index 3183ebe..1385930 100644 --- 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 @@ -8,6 +8,7 @@

Tags · @ClusterId

+ New tag
@@ -52,6 +53,7 @@ else Access Folder Poll group + @@ -66,6 +68,7 @@ else @t.AccessLevel @(t.FolderPath ?? "") @(t.PollGroupId ?? "—") + Edit } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor new file mode 100644 index 0000000..eacb279 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor @@ -0,0 +1,310 @@ +@page "/clusters/{ClusterId}/equipment/new" +@page "/clusters/{ClusterId}/equipment/{EquipmentId}" +@* Equipment CRUD. EquipmentId is system-generated (decision #125) — operator picks Name + + MachineCode + UnsLine + Driver; the EquipmentId is derived from the EquipmentUuid on create. + OPC 40010 identification fields (Manufacturer, Model, etc.) are all optional. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New equipment" : "Edit equipment") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Equipment @EquipmentId was not found. +
+} +else +{ + + +
+
Identity
+
+ @if (!IsNew) + { +
+ + +
System-generated; never operator-edited.
+
+ } +
+
+ + +
UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.
+
+
+ + +
+
+
+
+ + + + @foreach (var l in _lines) + { + + } + +
+
+ + + + @foreach (var d in _drivers) + { + + } + +
+
+
+
+ + +
Unique fleet-wide via ExternalIdReservation.
+
+
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
OPC 40010 identification (optional)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? EquipmentId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(EquipmentId); + + private FormModel _form = new(); + private Equipment? _existing; + private List _lines = new(); + private List _drivers = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + var areaIds = await db.UnsAreas.AsNoTracking() + .Where(a => a.ClusterId == ClusterId).Select(a => a.UnsAreaId).ToListAsync(); + _lines = await db.UnsLines.AsNoTracking() + .Where(l => areaIds.Contains(l.UnsAreaId)) + .OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId) + .ToListAsync(); + _drivers = await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId) + .OrderBy(d => d.DriverInstanceId) + .ToListAsync(); + + if (!IsNew) + { + _existing = await db.Equipment.AsNoTracking() + .FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId); + if (_existing is not null) + { + _form = new FormModel + { + Name = _existing.Name, + MachineCode = _existing.MachineCode, + UnsLineId = _existing.UnsLineId, + DriverInstanceId = _existing.DriverInstanceId, + ZTag = _existing.ZTag, + SAPID = _existing.SAPID, + Manufacturer = _existing.Manufacturer, + Model = _existing.Model, + SerialNumber = _existing.SerialNumber, + HardwareRevision = _existing.HardwareRevision, + SoftwareRevision = _existing.SoftwareRevision, + YearOfConstruction = _existing.YearOfConstruction, + AssetLocation = _existing.AssetLocation, + ManufacturerUri = _existing.ManufacturerUri, + DeviceManualUri = _existing.DeviceManualUri, + Enabled = _existing.Enabled, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + if (string.IsNullOrEmpty(_form.UnsLineId)) { _error = "Pick a UNS line."; return; } + if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver instance."; return; } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + var uuid = Guid.NewGuid(); + var equipmentId = $"EQ-{uuid.ToString("N")[..12]}"; + if (await db.Equipment.AnyAsync(e => e.MachineCode == _form.MachineCode)) + { _error = $"MachineCode '{_form.MachineCode}' already exists in this fleet."; return; } + db.Equipment.Add(new Equipment + { + EquipmentId = equipmentId, + EquipmentUuid = uuid, + DriverInstanceId = _form.DriverInstanceId, + UnsLineId = _form.UnsLineId, + Name = _form.Name, + MachineCode = _form.MachineCode, + ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag, + SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID, + Manufacturer = _form.Manufacturer, + Model = _form.Model, + SerialNumber = _form.SerialNumber, + HardwareRevision = _form.HardwareRevision, + SoftwareRevision = _form.SoftwareRevision, + YearOfConstruction = _form.YearOfConstruction, + AssetLocation = _form.AssetLocation, + ManufacturerUri = _form.ManufacturerUri, + DeviceManualUri = _form.DeviceManualUri, + Enabled = _form.Enabled, + }); + } + else + { + var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.DriverInstanceId = _form.DriverInstanceId; + entity.UnsLineId = _form.UnsLineId; + entity.Name = _form.Name; + entity.MachineCode = _form.MachineCode; + entity.ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag; + entity.SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID; + entity.Manufacturer = _form.Manufacturer; + entity.Model = _form.Model; + entity.SerialNumber = _form.SerialNumber; + entity.HardwareRevision = _form.HardwareRevision; + entity.SoftwareRevision = _form.SoftwareRevision; + entity.YearOfConstruction = _form.YearOfConstruction; + entity.AssetLocation = _form.AssetLocation; + entity.ManufacturerUri = _form.ManufacturerUri; + entity.DeviceManualUri = _form.DeviceManualUri; + entity.Enabled = _form.Enabled; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were editing."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.Equipment.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were viewing it."; } + catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first."; } + finally { _busy = false; } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")] + public string Name { get; set; } = ""; + [Required] public string MachineCode { get; set; } = ""; + [Required] public string UnsLineId { get; set; } = ""; + [Required] public string DriverInstanceId { get; set; } = ""; + public string? ZTag { get; set; } + public string? SAPID { get; set; } + public string? Manufacturer { get; set; } + public string? Model { get; set; } + public string? SerialNumber { get; set; } + public string? HardwareRevision { get; set; } + public string? SoftwareRevision { get; set; } + public short? YearOfConstruction { get; set; } + public string? AssetLocation { get; set; } + public string? ManufacturerUri { get; set; } + public string? DeviceManualUri { get; set; } + public bool Enabled { get; set; } = true; + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor new file mode 100644 index 0000000..1f069f2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor @@ -0,0 +1,320 @@ +@page "/clusters/{ClusterId}/tags/new" +@page "/clusters/{ClusterId}/tags/{TagId}" +@* Tag CRUD. EquipmentId is required when the chosen driver's namespace is Equipment-kind, + forbidden when SystemPlatform-kind (decision #110); the form switches between + "pick equipment" and "FolderPath input" based on namespace kind. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New tag" : "Edit tag") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Tag @TagId was not found. +
+} +else +{ + + +
+
Identity
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + @foreach (var d in _drivers) + { + + } + +
+
+ + + @foreach (var dt in DataTypes) + { + + } + +
+
+ + @{ var driverNamespace = ResolveDriverNamespace(_form.DriverInstanceId); } + @if (driverNamespace?.Kind == NamespaceKind.Equipment) + { +
+ + + + @foreach (var e in _equipment.Where(e => e.DriverInstanceId == _form.DriverInstanceId)) + { + + } + +
+ } + else if (driverNamespace?.Kind == NamespaceKind.SystemPlatform) + { +
+ + +
Galaxy hierarchy preserved as v1 expressed it — no UNS rule.
+
+ } + else if (!string.IsNullOrEmpty(_form.DriverInstanceId)) + { +
Pick a driver to see its namespace kind.
+ } + +
+
+ + + + + +
+
+ +
+ + +
+
+
+
+ + +
+
+
+ +
+
Tag config (JSON)
+
+ +
Schemaless per driver type — register / address / scaling / byte-order. Validated server-side at deploy.
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + private static readonly string[] DataTypes = + ["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", + "Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"]; + + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? TagId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(TagId); + + private FormModel _form = new(); + private Tag? _existing; + private List _drivers = new(); + private List _equipment = new(); + private Dictionary _namespacesByDriverInstance = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _drivers = await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId) + .OrderBy(d => d.DriverInstanceId) + .ToListAsync(); + var namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .ToListAsync(); + var nsById = namespaces.ToDictionary(n => n.NamespaceId); + _namespacesByDriverInstance = _drivers.ToDictionary( + d => d.DriverInstanceId, + d => nsById.TryGetValue(d.NamespaceId, out var ns) ? ns : namespaces.First()); + var driverIds = _drivers.Select(d => d.DriverInstanceId).ToHashSet(); + _equipment = await db.Equipment.AsNoTracking() + .Where(e => driverIds.Contains(e.DriverInstanceId)) + .OrderBy(e => e.MachineCode) + .ToListAsync(); + + if (!IsNew) + { + _existing = await db.Tags.AsNoTracking() + .FirstOrDefaultAsync(t => t.TagId == TagId); + if (_existing is not null) + { + _form = new FormModel + { + TagId = _existing.TagId, + Name = _existing.Name, + DriverInstanceId = _existing.DriverInstanceId, + EquipmentId = _existing.EquipmentId, + FolderPath = _existing.FolderPath, + DataType = _existing.DataType, + AccessLevel = _existing.AccessLevel, + WriteIdempotent = _existing.WriteIdempotent, + PollGroupId = _existing.PollGroupId, + TagConfig = _existing.TagConfig, + RowVersion = _existing.RowVersion, + }; + } + } + else + { + _form.DataType = "Float"; + _form.AccessLevel = TagAccessLevel.Read; + _form.TagConfig = "{}"; + } + _loaded = true; + } + + private Namespace? ResolveDriverNamespace(string driverId) => + string.IsNullOrEmpty(driverId) ? null + : _namespacesByDriverInstance.TryGetValue(driverId, out var ns) ? ns : null; + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver."; return; } + var ns = ResolveDriverNamespace(_form.DriverInstanceId); + if (ns?.Kind == NamespaceKind.Equipment && string.IsNullOrEmpty(_form.EquipmentId)) + { _error = "Driver lives in an Equipment-kind namespace — pick an equipment."; return; } + if (ns?.Kind == NamespaceKind.SystemPlatform && !string.IsNullOrEmpty(_form.EquipmentId)) + { _error = "Driver lives in a SystemPlatform namespace — EquipmentId must be empty (use FolderPath)."; return; } + + try { using var _ = System.Text.Json.JsonDocument.Parse(_form.TagConfig); } + catch { _error = "TagConfig is not valid JSON."; return; } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.Tags.AnyAsync(t => t.TagId == _form.TagId)) + { _error = $"Tag '{_form.TagId}' already exists."; return; } + db.Tags.Add(new Tag + { + TagId = _form.TagId, + DriverInstanceId = _form.DriverInstanceId, + EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId, + Name = _form.Name, + FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath, + DataType = _form.DataType, + AccessLevel = _form.AccessLevel, + WriteIdempotent = _form.WriteIdempotent, + PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId, + TagConfig = _form.TagConfig, + }); + } + else + { + var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.DriverInstanceId = _form.DriverInstanceId; + entity.EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId; + entity.Name = _form.Name; + entity.FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath; + entity.DataType = _form.DataType; + entity.AccessLevel = _form.AccessLevel; + entity.WriteIdempotent = _form.WriteIdempotent; + entity.PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId; + entity.TagConfig = _form.TagConfig; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/tags"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were editing."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/tags"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.Tags.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/tags"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were viewing it."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + [Required] public string DriverInstanceId { get; set; } = ""; + public string? EquipmentId { get; set; } + public string? FolderPath { get; set; } + [Required] public string DataType { get; set; } = "Float"; + public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read; + public bool WriteIdempotent { get; set; } + public string? PollGroupId { get; set; } + [Required] public string TagConfig { get; set; } = "{}"; + public byte[] RowVersion { get; set; } = []; + } +}