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 @@
+
@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; } = [];
+ }
+}