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 index e7edcf0..9c4dc02 100644 --- 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 @@ -8,6 +8,7 @@

Drivers · @ClusterId

+ New driver
@@ -43,6 +44,9 @@ else ns=@d.NamespaceId
+
+ Edit +
@FormatJson(d.DriverConfig)
@if (!string.IsNullOrWhiteSpace(d.ResilienceConfig)) { 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 index 9787afc..30b50d8 100644 --- 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 @@ -8,6 +8,7 @@

Namespaces · @ClusterId

+ New namespace
@@ -41,6 +42,7 @@ else URI Status Notes + @@ -55,6 +57,7 @@ else else { Disabled } @(n.Notes ?? "") + Edit } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor new file mode 100644 index 0000000..585b94a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor @@ -0,0 +1,323 @@ +@page "/clusters/{ClusterId}/drivers/new" +@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}" +@* Per Q1 of the AdminUI rebuild plan — JSON editor only, typed driver editors deferred. + DriverInstance is the keystone for everything downstream (Equipment, Tag, VirtualTag, + ScriptedAlarm all reference DriverInstanceId), so this is the second edit page after + Namespace. *@ +@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 driver instance" : "Edit driver instance") · @ClusterId

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + +
+
@(IsNew ? "Identity" : $"Edit {_form.DriverInstanceId}")
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + + + + + + + + + +
Cannot be changed after creation — drives the actor type that owns this instance.
+
+
+ + + @foreach (var ns in _namespaces) + { + + } + +
+
+
+ +
+ + +
+
+
+
+ +
+
Driver config (JSON)
+
+ +
Schemaless per driver type — validated server-side at deploy time. JSON is reformatted on save.
+
+
+ +
+
Resilience overrides (optional)
+
+ +
Polly pipeline overrides per docs/v2/driver-stability.md — bulkhead, retry counts, breaker thresholds. Null = use the driver type's tier defaults.
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); + + private FormModel _form = new(); + private DriverInstance? _existing; + private List _namespaces = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId) + .ToListAsync(); + + if (IsNew) + { + _form = new FormModel + { + DriverInstanceId = "", + Name = "", + DriverType = "ModbusTcp", + NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", + Enabled = true, + DriverConfig = "{}", + }; + } + else + { + _existing = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (_existing is not null) + { + _form = new FormModel + { + DriverInstanceId = _existing.DriverInstanceId, + Name = _existing.Name, + DriverType = _existing.DriverType, + NamespaceId = _existing.NamespaceId, + Enabled = _existing.Enabled, + DriverConfig = _existing.DriverConfig, + ResilienceConfig = _existing.ResilienceConfig, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; + _error = null; + try + { + var normalizedConfig = NormalizeJson(_form.DriverConfig); + if (normalizedConfig is null) + { + _error = "DriverConfig is not valid JSON."; + return; + } + var normalizedResilience = NormalizeOptionalJson(_form.ResilienceConfig); + if (!string.IsNullOrWhiteSpace(_form.ResilienceConfig) && normalizedResilience is null) + { + _error = "ResilienceConfig is not valid JSON. Leave blank to use defaults."; + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _form.DriverInstanceId)) + { + _error = $"Driver instance '{_form.DriverInstanceId}' already exists."; + return; + } + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = _form.DriverInstanceId, + ClusterId = ClusterId, + NamespaceId = _form.NamespaceId, + Name = _form.Name, + DriverType = _form.DriverType, + Enabled = _form.Enabled, + DriverConfig = normalizedConfig, + ResilienceConfig = normalizedResilience, + }); + } + else + { + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) + { + _error = "Row no longer exists."; + return; + } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.NamespaceId = _form.NamespaceId; + entity.Name = _form.Name; + entity.Enabled = _form.Enabled; + entity.DriverConfig = normalizedConfig; + entity.ResilienceConfig = normalizedResilience; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; + } + 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.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) + { + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + return; + } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.DriverInstances.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; + } + finally + { + _busy = false; + } + } + + private static string? NormalizeJson(string? input) + { + if (string.IsNullOrWhiteSpace(input)) return null; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(input); + return System.Text.Json.JsonSerializer.Serialize(doc.RootElement); + } + catch { return null; } + } + + private static string? NormalizeOptionalJson(string? input) => + string.IsNullOrWhiteSpace(input) ? null : NormalizeJson(input); + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$", ErrorMessage = "Use letters, digits, dash, underscore.")] + public string DriverInstanceId { get; set; } = ""; + [Required] + public string Name { get; set; } = ""; + [Required] + public string DriverType { get; set; } = "ModbusTcp"; + [Required] + public string NamespaceId { get; set; } = ""; + public bool Enabled { get; set; } = true; + [Required] + public string DriverConfig { get; set; } = "{}"; + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor new file mode 100644 index 0000000..68499e8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor @@ -0,0 +1,244 @@ +@page "/clusters/{ClusterId}/namespaces/new" +@page "/clusters/{ClusterId}/namespaces/{NamespaceId}" +@* Live-edit form pattern — one page handles both create (NamespaceId is null) and update. + RowVersion is preserved across post-back so EF Core enforces last-write-wins; concurrency + conflicts surface as a toast and reload the current row. *@ +@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 namespace" : "Edit namespace") · @ClusterId

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Namespace @NamespaceId was not found in cluster @ClusterId. +
+} +else +{ + + +
+
@(IsNew ? "Identity" : $"Edit {_form.NamespaceId}")
+
+
+ + +
+
+
+ + + + + + +
+
+ +
+ + +
+
+
+
+ + +
Must be unique fleet-wide. Clients pin discovery here.
+
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? NamespaceId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(NamespaceId); + + private FormModel _form = new(); + private Namespace? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (IsNew) + { + _form = new FormModel + { + NamespaceId = "", + Kind = NamespaceKind.Equipment, + NamespaceUri = "", + Enabled = true, + }; + } + else + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.Namespaces.AsNoTracking() + .FirstOrDefaultAsync(n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId); + if (_existing is not null) + { + _form = new FormModel + { + NamespaceId = _existing.NamespaceId, + Kind = _existing.Kind, + NamespaceUri = _existing.NamespaceUri, + Enabled = _existing.Enabled, + 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.Namespaces.AnyAsync(n => n.NamespaceId == _form.NamespaceId)) + { + _error = $"Namespace '{_form.NamespaceId}' already exists."; + return; + } + db.Namespaces.Add(new Namespace + { + NamespaceId = _form.NamespaceId, + ClusterId = ClusterId, + Kind = _form.Kind, + NamespaceUri = _form.NamespaceUri, + Enabled = _form.Enabled, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + }); + } + else + { + var entity = await db.Namespaces.FirstOrDefaultAsync( + n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId); + if (entity is null) + { + _error = "Row no longer exists."; + return; + } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.Kind = _form.Kind; + entity.NamespaceUri = _form.NamespaceUri; + entity.Enabled = _form.Enabled; + entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/namespaces"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this namespace while you were editing. Reload to see the latest values, then re-apply your changes."; + } + 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.Namespaces.FirstOrDefaultAsync( + n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId); + if (entity is null) + { + Nav.NavigateTo($"/clusters/{ClusterId}/namespaces"); + return; + } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.Namespaces.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/namespaces"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this namespace while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$", ErrorMessage = "Use letters, digits, dash, underscore.")] + public string NamespaceId { get; set; } = ""; + public NamespaceKind Kind { get; set; } = NamespaceKind.Equipment; + [Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$", ErrorMessage = "Use a URN, e.g. urn:zb:warsaw-west:equipment.")] + public string NamespaceUri { get; set; } = ""; + public bool Enabled { get; set; } = true; + public string? Notes { get; set; } + public byte[] RowVersion { get; set; } = []; + } +}