From 45740578c95c34167df521fbc04d651b425dd313 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 08:18:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(adminui):=20F15.2=20batch=202=20=E2=80=94?= =?UTF-8?q?=20topology=20entity=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same single-page edit-or-create pattern as batch 1, applied to the foundational topology entities. After this batch the whole hierarchy (cluster → nodes → UNS areas → UNS lines → namespaces → drivers) is fully editable through the UI. - ClusterEdit.razor /clusters/{id}/edit Update + delete for an existing cluster. NodeCount stays coupled to RedundancyMode (None→1, Warm/Hot→2). ModifiedBy taken from AuthenticationStateProvider. - NodeEdit.razor /clusters/{id}/nodes/{new|nodeId} Full ClusterNode CRUD. ApplicationUri uniqueness is enforced by EF index; ServiceLevelBase defaults to 200 (primary preference) on create; per-node DriverConfigOverridesJson validated as JSON. - UnsAreaEdit.razor /clusters/{id}/uns/areas/{new|id} - UnsLineEdit.razor /clusters/{id}/uns/lines/{new|id} UNS structure CRUD; Lines pick their parent Area from a select that loads the cluster's areas. List pages updated: - ClusterOverview now shows an "Edit cluster" button + a "New node" action on the nodes panel + per-row Edit buttons. - ClusterUns gains New/Edit affordances for both Areas and Lines. All 9 integration tests still green; no regressions. --- .../Pages/Clusters/ClusterEdit.razor | 200 +++++++++++++ .../Pages/Clusters/ClusterOverview.razor | 10 +- .../Pages/Clusters/ClusterUns.razor | 16 +- .../Components/Pages/Clusters/NodeEdit.razor | 268 ++++++++++++++++++ .../Pages/Clusters/UnsAreaEdit.razor | 167 +++++++++++ .../Pages/Clusters/UnsLineEdit.razor | 187 ++++++++++++ 6 files changed, 842 insertions(+), 6 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsAreaEdit.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsLineEdit.razor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor new file mode 100644 index 0000000..a159819 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor @@ -0,0 +1,200 @@ +@page "/clusters/{ClusterId}/edit" +@* Edit page for an existing ServerCluster. The /clusters/new route lives in NewCluster.razor; + this page handles only the update case so the form can disable ClusterId (immutable). *@ +@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 +@inject AuthenticationStateProvider AuthState + +
+

Edit cluster · @ClusterId

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

Loading…

+} +else if (_existing is null) +{ +
+ Cluster @ClusterId was not found. +
+} +else +{ + + +
+
Identity
+
+
+ + +
Immutable after creation. Operator-visible everywhere; renames would invalidate every downstream reference.
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
Topology
+
+
+ + + + + + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + + private FormModel _form = new(); + private ServerCluster? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.ServerClusters.AsNoTracking() + .FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (_existing is not null) + { + _form = new FormModel + { + Name = _existing.Name, + Enterprise = _existing.Enterprise, + Site = _existing.Site, + RedundancyMode = _existing.RedundancyMode, + Enabled = _existing.Enabled, + Notes = _existing.Notes, + }; + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.ServerClusters.FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (entity is null) { _error = "Cluster no longer exists."; return; } + + var auth = await AuthState.GetAuthenticationStateAsync(); + entity.Name = _form.Name; + entity.Enterprise = _form.Enterprise; + entity.Site = _form.Site; + entity.RedundancyMode = _form.RedundancyMode; + entity.NodeCount = _form.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2; + entity.Enabled = _form.Enabled; + entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + entity.ModifiedAt = DateTime.UtcNow; + entity.ModifiedBy = auth.User.Identity?.Name ?? "(anonymous)"; + + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}"); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private async Task DeleteAsync() + { + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.ServerClusters.FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (entity is null) { Nav.NavigateTo("/clusters"); return; } + db.ServerClusters.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo("/clusters"); + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. Likely because nodes, namespaces, drivers, or other rows still reference this cluster — remove them first."; + } + finally + { + _busy = false; + } + } + + private sealed class FormModel + { + [Required] public string Name { get; set; } = ""; + [Required] public string Enterprise { get; set; } = ""; + [Required] public string Site { get; set; } = ""; + public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None; + public bool Enabled { get; set; } = true; + public string? Notes { get; set; } + } +} 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 04d0223..d5d7c28 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 @@ -26,7 +26,8 @@ else @_cluster.ClusterId @if (!_cluster.Enabled) { Disabled } -
+
@@ -69,7 +70,10 @@ else
-
Nodes
+
+ Nodes + New node +
@if (_nodes is null || _nodes.Count == 0) {
No nodes registered.
@@ -85,6 +89,7 @@ else OPC UA port ApplicationUri ServiceLevel base + @@ -96,6 +101,7 @@ else @n.OpcUaPort @n.ApplicationUri @n.ServiceLevelBase + Edit } 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 index 927f43e..55734e3 100644 --- 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 @@ -25,7 +25,10 @@ else
-
Areas (level 3) · @_areas.Count
+
+ Areas (level 3) · @_areas.Count + New area +
@if (_areas.Count == 0) {
No areas defined.
@@ -34,7 +37,7 @@ else {
- + @foreach (var a in _areas) { @@ -42,6 +45,7 @@ else + } @@ -51,7 +55,10 @@ else
-
Lines (level 4) · @_lines.Count
+
+ Lines (level 4) · @_lines.Count + New line +
@if (_lines.Count == 0) {
No lines defined.
@@ -60,7 +67,7 @@ else {
UnsAreaIdNameNotes
UnsAreaIdNameNotes
@a.UnsAreaId @a.Name @(a.Notes ?? "")Edit
- + @foreach (var l in _lines) { @@ -69,6 +76,7 @@ else + } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor new file mode 100644 index 0000000..4be4b61 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor @@ -0,0 +1,268 @@ +@page "/clusters/{ClusterId}/nodes/new" +@page "/clusters/{ClusterId}/nodes/{NodeId}" +@* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this + at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@ +@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 +@inject AuthenticationStateProvider AuthState + +
+

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

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Node @NodeId was not found in cluster @ClusterId. +
+} +else +{ + + +
+
Identity
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
Must be unique fleet-wide. Clients pin trust here — never silently rewrite based on Host.
+
+
+
+ +
+
Redundancy + behaviour
+
+
+
+ + +
200 = primary preference, 150 = secondary preference. Live ServiceLevel adjusts down on faults.
+
+
+ +
+ + +
+
+
+
+ + +
Per-node merge over cluster-level DriverInstance.DriverConfig. Minimal by design — heavy node-specific config is a smell.
+
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? NodeId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(NodeId); + + private FormModel _form = new(); + private ClusterNode? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (IsNew) + { + _form = new FormModel + { + NodeId = "", + Host = "", + OpcUaPort = 4840, + DashboardPort = 8081, + ApplicationUri = "", + ServiceLevelBase = 200, + Enabled = true, + }; + } + else + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.ClusterNodes.AsNoTracking() + .FirstOrDefaultAsync(n => n.ClusterId == ClusterId && n.NodeId == NodeId); + if (_existing is not null) + { + _form = new FormModel + { + NodeId = _existing.NodeId, + Host = _existing.Host, + OpcUaPort = _existing.OpcUaPort, + DashboardPort = _existing.DashboardPort, + ApplicationUri = _existing.ApplicationUri, + ServiceLevelBase = _existing.ServiceLevelBase, + Enabled = _existing.Enabled, + DriverConfigOverridesJson = _existing.DriverConfigOverridesJson, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; + _error = null; + try + { + if (!string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson)) + { + try { using var _ = System.Text.Json.JsonDocument.Parse(_form.DriverConfigOverridesJson); } + catch { _error = "DriverConfigOverridesJson is not valid JSON."; return; } + } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId)) + { + _error = $"Node '{_form.NodeId}' already exists."; + return; + } + var auth = await AuthState.GetAuthenticationStateAsync(); + db.ClusterNodes.Add(new ClusterNode + { + NodeId = _form.NodeId, + ClusterId = ClusterId, + Host = _form.Host, + OpcUaPort = _form.OpcUaPort, + DashboardPort = _form.DashboardPort, + ApplicationUri = _form.ApplicationUri, + ServiceLevelBase = _form.ServiceLevelBase, + Enabled = _form.Enabled, + DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson, + CreatedAt = DateTime.UtcNow, + CreatedBy = auth.User.Identity?.Name ?? "(anonymous)", + }); + } + else + { + var entity = await db.ClusterNodes.FirstOrDefaultAsync( + n => n.ClusterId == ClusterId && n.NodeId == NodeId); + if (entity is null) { _error = "Row no longer exists."; return; } + entity.Host = _form.Host; + entity.OpcUaPort = _form.OpcUaPort; + entity.DashboardPort = _form.DashboardPort; + entity.ApplicationUri = _form.ApplicationUri; + entity.ServiceLevelBase = _form.ServiceLevelBase; + entity.Enabled = _form.Enabled; + entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}"); + } + 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.ClusterNodes.FirstOrDefaultAsync( + n => n.ClusterId == ClusterId && n.NodeId == NodeId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}"); return; } + db.ClusterNodes.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}"); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] + public string NodeId { get; set; } = ""; + [Required] public string Host { get; set; } = ""; + [Range(1, 65535)] public int OpcUaPort { get; set; } = 4840; + [Range(1, 65535)] public int DashboardPort { get; set; } = 8081; + [Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")] + public string ApplicationUri { get; set; } = ""; + [Range(0, 255)] public byte ServiceLevelBase { get; set; } = 200; + public bool Enabled { get; set; } = true; + public string? DriverConfigOverridesJson { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsAreaEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsAreaEdit.razor new file mode 100644 index 0000000..632a698 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsAreaEdit.razor @@ -0,0 +1,167 @@ +@page "/clusters/{ClusterId}/uns/areas/new" +@page "/clusters/{ClusterId}/uns/areas/{UnsAreaId}" +@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 UNS area" : "Edit UNS area") · @ClusterId

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Area @UnsAreaId was not found. +
+} +else +{ + + +
+
UNS area (level 3)
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? UnsAreaId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(UnsAreaId); + + private FormModel _form = new(); + private UnsArea? _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.UnsAreas.AsNoTracking() + .FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId); + if (_existing is not null) + { + _form = new FormModel + { + UnsAreaId = _existing.UnsAreaId, + Name = _existing.Name, + 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.UnsAreas.AnyAsync(a => a.UnsAreaId == _form.UnsAreaId)) + { _error = $"Area '{_form.UnsAreaId}' already exists."; return; } + db.UnsAreas.Add(new UnsArea + { + UnsAreaId = _form.UnsAreaId, + ClusterId = ClusterId, + Name = _form.Name, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + }); + } + else + { + var entity = await db.UnsAreas.FirstOrDefaultAsync( + a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId); + 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.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/uns"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were editing. Reload to see the latest values."; } + 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.UnsAreas.FirstOrDefaultAsync( + a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.UnsAreas.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/uns"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were viewing it."; } + catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because lines still reference this area — remove them first."; } + finally { _busy = false; } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsAreaId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + public string? Notes { get; set; } + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsLineEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsLineEdit.razor new file mode 100644 index 0000000..729ae6d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsLineEdit.razor @@ -0,0 +1,187 @@ +@page "/clusters/{ClusterId}/uns/lines/new" +@page "/clusters/{ClusterId}/uns/lines/{UnsLineId}" +@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 UNS line" : "Edit UNS line") · @ClusterId

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Line @UnsLineId was not found. +
+} +else +{ + + +
+
UNS line (level 4)
+
+
+ + +
+
+ + + @foreach (var area in _areas) + { + + } + +
+
+ + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? UnsLineId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(UnsLineId); + + private FormModel _form = new(); + private UnsLine? _existing; + private List _areas = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + 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(); + + if (!IsNew) + { + _existing = await db.UnsLines.AsNoTracking() + .FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId); + if (_existing is not null) + { + _form = new FormModel + { + UnsLineId = _existing.UnsLineId, + UnsAreaId = _existing.UnsAreaId, + Name = _existing.Name, + Notes = _existing.Notes, + RowVersion = _existing.RowVersion, + }; + } + } + else + { + _form.UnsAreaId = _areas.FirstOrDefault()?.UnsAreaId ?? ""; + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.UnsLines.AnyAsync(l => l.UnsLineId == _form.UnsLineId)) + { _error = $"Line '{_form.UnsLineId}' already exists."; return; } + db.UnsLines.Add(new UnsLine + { + UnsLineId = _form.UnsLineId, + UnsAreaId = _form.UnsAreaId, + Name = _form.Name, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + }); + } + else + { + var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.UnsAreaId = _form.UnsAreaId; + entity.Name = _form.Name; + entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/uns"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this line 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.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.UnsLines.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/uns"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this line while you were viewing it."; } + catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first."; } + finally { _busy = false; } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsLineId { get; set; } = ""; + [Required] public string UnsAreaId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + public string? Notes { get; set; } + public byte[] RowVersion { get; set; } = []; + } +}
UnsLineIdNameAreaNotes
UnsLineIdNameAreaNotes
@l.Name @l.UnsAreaId @(l.Notes ?? "")Edit