From ae980aef5d1885e1c79cd0976fbbf91cef1a27b5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 08:27:56 -0400 Subject: [PATCH] =?UTF-8?q?feat(adminui):=20F15.2=20batch=204=20=E2=80=94?= =?UTF-8?q?=20closes=20live-edit=20forms=20(Acl/VirtualTag/ScriptedAlarm/S?= =?UTF-8?q?cript)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final batch of F15.2. After this commit every entity surfaced by the Phase A-D read views has a matching new/edit/delete form. - AclEdit.razor /clusters/{id}/acls/{new|aclId} - NodePermissions [Flags] enum surfaced as per-bit checkboxes plus one-click bundle buttons (ReadOnly / Operator / Engineer / Admin) - ScopeKind select + ScopeId free-text target (null = cluster-wide) - VirtualTagEdit.razor /virtual-tags/{new|virtualTagId} - Trigger validation: enforces at least one of ChangeTriggered or TimerIntervalMs is set - ScriptedAlarmEdit.razor /scripted-alarms/{new|scriptedAlarmId} - AlarmType select with OPC UA Part 9 subtypes - MessageTemplate is a textarea (template tokens are server-resolved) - ScriptEdit.razor /scripts/{new|scriptId} - SHA-256 hash computed from SourceCode on save (operator never sees or edits SourceHash directly) - InputTextArea now; Monaco syntax editor is a future enhancement List pages (ClusterAcls / VirtualTags / ScriptedAlarms / Scripts) all gain New + per-row Edit affordances. Tally: F15.2 shipped CRUD for 11 entities — Cluster, ClusterNode, UnsArea, UnsLine, Namespace, DriverInstance, Equipment, Tag, NodeAcl, VirtualTag, ScriptedAlarm, Script. All 9 integration tests still green. --- .../Components/Pages/Clusters/AclEdit.razor | 228 +++++++++++++++++ .../Pages/Clusters/ClusterAcls.razor | 3 + .../Components/Pages/ScriptEdit.razor | 175 +++++++++++++ .../Components/Pages/ScriptedAlarmEdit.razor | 236 ++++++++++++++++++ .../Components/Pages/ScriptedAlarms.razor | 3 + .../Components/Pages/Scripts.razor | 4 + .../Components/Pages/VirtualTagEdit.razor | 231 +++++++++++++++++ .../Components/Pages/VirtualTags.razor | 3 + 8 files changed, 883 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/VirtualTagEdit.razor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor new file mode 100644 index 0000000..aa3390f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -0,0 +1,228 @@ +@page "/clusters/{ClusterId}/acls/new" +@page "/clusters/{ClusterId}/acls/{NodeAclId}" +@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 ACL grant" : "Edit ACL grant") · @ClusterId

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
ACL @NodeAclId not found.
+} +else +{ + + +
+
Grant
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + + + + + + + +
+
+ + +
+
+
+ +
+ @foreach (var bit in PermissionBits) + { +
+ + +
+ } +
+
+ Bundles: + + · + + · + + · + +
+
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) {
@_error
} + +
+ + Cancel + @if (!IsNew) { } +
+
+} + +@code { + private static readonly NodePermissions[] PermissionBits = + [ + NodePermissions.Browse, NodePermissions.Read, NodePermissions.Subscribe, NodePermissions.HistoryRead, + NodePermissions.WriteOperate, NodePermissions.WriteTune, NodePermissions.WriteConfigure, + NodePermissions.AlarmRead, NodePermissions.AlarmAcknowledge, NodePermissions.AlarmConfirm, NodePermissions.AlarmShelve, + NodePermissions.MethodCall, + ]; + + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? NodeAclId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(NodeAclId); + + private FormModel _form = new(); + private NodeAcl? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (!IsNew) + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.NodeAcls.AsNoTracking().FirstOrDefaultAsync( + a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); + if (_existing is not null) + { + _form = new FormModel + { + NodeAclId = _existing.NodeAclId, + LdapGroup = _existing.LdapGroup, + ScopeKind = _existing.ScopeKind, + ScopeId = _existing.ScopeId, + PermissionFlags = _existing.PermissionFlags, + Notes = _existing.Notes, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.NodeAcls.AnyAsync(a => a.NodeAclId == _form.NodeAclId)) + { _error = $"ACL '{_form.NodeAclId}' already exists."; return; } + db.NodeAcls.Add(new NodeAcl + { + NodeAclId = _form.NodeAclId, + ClusterId = ClusterId, + LdapGroup = _form.LdapGroup, + ScopeKind = _form.ScopeKind, + ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId, + PermissionFlags = _form.PermissionFlags, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + }); + } + else + { + var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.LdapGroup = _form.LdapGroup; + entity.ScopeKind = _form.ScopeKind; + entity.ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId; + entity.PermissionFlags = _form.PermissionFlags; + entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/acls"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL 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.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/acls"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.NodeAcls.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/acls"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL 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 NodeAclId { get; set; } = ""; + [Required] public string LdapGroup { get; set; } = ""; + public NodeAclScopeKind ScopeKind { get; set; } = NodeAclScopeKind.Cluster; + public string? ScopeId { get; set; } + public NodePermissions PermissionFlags { get; set; } = NodePermissions.None; + public string? Notes { get; set; } + public byte[] RowVersion { get; set; } = []; + + public bool HasPerm(NodePermissions bit) => PermissionFlags.HasFlag(bit); + public void SetPerm(NodePermissions bit, bool on) + { + if (on) PermissionFlags |= bit; + else PermissionFlags &= ~bit; + } + } +} 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 index 6a31313..5393a72 100644 --- 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 @@ -8,6 +8,7 @@

ACLs · @ClusterId

+ New ACL grant
@@ -43,6 +44,7 @@ else Scope target Permissions Notes + @@ -60,6 +62,7 @@ else } @(a.Notes ?? "") + Edit } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor new file mode 100644 index 0000000..2406c79 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor @@ -0,0 +1,175 @@ +@page "/scripts/new" +@page "/scripts/{ScriptId}" +@* Script CRUD. SourceHash is computed automatically from SourceCode on save so the + integrity check in v2's deployment pipeline doesn't require operator action. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using System.Security.Cryptography +@using System.Text +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New script" : "Edit script")

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
@ScriptId not found.
+} +else +{ + + +
+
Identity
+
+
+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+ +
+
Source
+
+ +
SHA-256 hash is computed automatically on save.
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) {
@_error
} + +
+ + Cancel + @if (!IsNew) { } +
+
+} + +@code { + [Parameter] public string? ScriptId { get; set; } + private bool IsNew => string.IsNullOrEmpty(ScriptId); + + private FormModel _form = new(); + private Script? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (!IsNew) + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.Scripts.AsNoTracking().FirstOrDefaultAsync(s => s.ScriptId == ScriptId); + if (_existing is not null) + { + _form = new FormModel + { + ScriptId = _existing.ScriptId, + Name = _existing.Name, + Language = _existing.Language, + SourceCode = _existing.SourceCode, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var sourceHash = HashSource(_form.SourceCode); + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.Scripts.AnyAsync(s => s.ScriptId == _form.ScriptId)) + { _error = $"Script '{_form.ScriptId}' already exists."; return; } + db.Scripts.Add(new Script + { + ScriptId = _form.ScriptId, + Name = _form.Name, + Language = _form.Language, + SourceCode = _form.SourceCode, + SourceHash = sourceHash, + }); + } + else + { + var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == ScriptId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.Name = _form.Name; + entity.Language = _form.Language; + entity.SourceCode = _form.SourceCode; + entity.SourceHash = sourceHash; + } + await db.SaveChangesAsync(); + Nav.NavigateTo("/scripts"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this script 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.Scripts.FirstOrDefaultAsync(s => s.ScriptId == ScriptId); + if (entity is null) { Nav.NavigateTo("/scripts"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.Scripts.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo("/scripts"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this script while you were viewing it."; } + catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because virtual tags or scripted alarms still reference this script — remove them first."; } + finally { _busy = false; } + } + + private static string HashSource(string source) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source))); + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string ScriptId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + [Required] public string Language { get; set; } = "CSharp"; + [Required] public string SourceCode { get; set; } = ""; + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor new file mode 100644 index 0000000..17aeb62 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor @@ -0,0 +1,236 @@ +@page "/scripted-alarms/new" +@page "/scripted-alarms/{ScriptedAlarmId}" +@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 scripted alarm" : "Edit scripted alarm")

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
@ScriptedAlarmId not found.
+} +else +{ + + +
+
Identity
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + @foreach (var e in _equipment) { } + +
+
+ + + + + + + +
+
+ + +
+
+
+
+ + + + @foreach (var s in _scripts) { } + +
+
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) {
@_error
} + +
+ + Cancel + @if (!IsNew) { } +
+
+} + +@code { + [Parameter] public string? ScriptedAlarmId { get; set; } + private bool IsNew => string.IsNullOrEmpty(ScriptedAlarmId); + + private FormModel _form = new(); + private ScriptedAlarm? _existing; + private List _equipment = new(); + private List