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 new file mode 100644 index 0000000..605ede8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor @@ -0,0 +1,136 @@ +@page "/clusters/{ClusterId}" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +@if (!_loaded) +{ +

Loading…

+} +else if (_cluster is null) +{ +
+ Cluster @ClusterId was not found. + Back to list. +
+} +else +{ +
+
+

@_cluster.Name

+ @_cluster.ClusterId + @if (!_cluster.Enabled) { Disabled } +
+
+ Deployments +
+
+ + + +
+
+
Cluster details
+
Enterprise / Site@_cluster.Enterprise / @_cluster.Site
+
Redundancy@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))
+
Created@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy
+ @if (_cluster.ModifiedAt is not null) + { +
Modified@_cluster.ModifiedAt?.ToString("u") by @(_cluster.ModifiedBy ?? "—")
+ } + @if (!string.IsNullOrWhiteSpace(_cluster.Notes)) + { +
Notes@_cluster.Notes
+ } +
+ +
+
Last deployment
+ @if (_lastDeployment is null) + { +
Statusnone — cluster has never been deployed
+ } + else + { +
Revision@_lastDeployment.RevisionHash[..16]…
+
Status@_lastDeployment.Status
+
Created@_lastDeployment.CreatedAtUtc.ToString("u")
+ @if (_lastDeployment.SealedAtUtc is not null) + { +
Sealed@_lastDeployment.SealedAtUtc?.ToString("u")
+ } + } +
+
+ +
+
Nodes
+ @if (_nodes is null || _nodes.Count == 0) + { +
No nodes registered.
+ } + else + { +
+ + + + + + + + + + + + @foreach (var n in _nodes) + { + + + + + + + + } + +
Node IDHostOPC UA portApplicationUriServiceLevel base
@n.NodeId@n.Host@n.OpcUaPort@n.ApplicationUri@n.ServiceLevelBase
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + + private bool _loaded; + private ServerCluster? _cluster; + private List? _nodes; + private Deployment? _lastDeployment; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _cluster = await db.ServerClusters.AsNoTracking() + .FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (_cluster is not null) + { + _nodes = await db.ClusterNodes.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NodeId) + .ToListAsync(); + _lastDeployment = await db.Deployments.AsNoTracking() + .OrderByDescending(d => d.CreatedAtUtc) + .FirstOrDefaultAsync(); + } + _loaded = true; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor new file mode 100644 index 0000000..ded86d0 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor @@ -0,0 +1,111 @@ +@page "/clusters/{ClusterId}/redundancy" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +@if (!_loaded) +{ +

Loading…

+} +else if (_cluster is null) +{ +
+ Cluster @ClusterId was not found. + Back to list. +
+} +else +{ +
+
+

@_cluster.Name · Redundancy

+ @_cluster.ClusterId +
+
+ + + +
+ v2 redundancy is computed at runtime by RedundancyStateActor + on each admin node. The values below are the static configuration; the resolved live + ServiceLevel for each peer is broadcast on the + redundancy-state DPS topic and consumed by the OPC UA host's + ServerStatus publisher. See + docs/v2/Architecture-v2.md. +
+ +
+
+
Cluster redundancy
+
Mode@_cluster.RedundancyMode
+
Node count@_cluster.NodeCount
+
+
+ +
+
Node service-level configuration
+ @if (_nodes is null || _nodes.Count == 0) + { +
No nodes registered.
+ } + else + { +
+ + + + + + + + + + + @foreach (var n in _nodes) + { + + + + + + + } + +
Node IDApplicationUriServiceLevel baseNotes
@n.NodeId@n.ApplicationUri@n.ServiceLevelBase + @if (n.ServiceLevelBase >= 200) { Primary preference } + else if (n.ServiceLevelBase >= 100) { Secondary preference } + else { Custom } +
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + + private bool _loaded; + private ServerCluster? _cluster; + private List? _nodes; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _cluster = await db.ServerClusters.AsNoTracking() + .FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (_cluster is not null) + { + _nodes = await db.ClusterNodes.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NodeId) + .ToListAsync(); + } + _loaded = true; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor new file mode 100644 index 0000000..31415f2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor @@ -0,0 +1,77 @@ +@page "/clusters" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

Clusters

+ New cluster +
+ +@if (_rows is null) +{ +

Loading…

+} +else if (_rows.Count == 0) +{ +
+ No clusters defined yet. Use New cluster above to create one. +
+} +else +{ +
+
All clusters
+
+ + + + + + + + + + + + + @foreach (var c in _rows) + { + + + + + + + + + } + +
ClusterSiteNodesRedundancyStatusCreated
+ @c.ClusterId +
@c.Name
+
@c.Enterprise / @c.Site@c.NodeCount@c.RedundancyMode + @if (c.Enabled) { Enabled } + else { Disabled } + @c.CreatedAt.ToString("u")
+
+
+} + +@code { + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.ServerClusters.AsNoTracking() + .OrderBy(c => c.ClusterId) + .ToListAsync(); + } + + private void OpenCluster(string clusterId) => Nav.NavigateTo($"/clusters/{clusterId}"); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor new file mode 100644 index 0000000..647cdc5 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor @@ -0,0 +1,141 @@ +@page "/clusters/new" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@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 + +
+

New cluster

+ Cancel +
+ + + +
+
Identity
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
Topology
+
+
+ + + + + + +
NodeCount is implied — 1 for None, 2 for Warm/Hot.
+
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel +
+
+ +@code { + private FormModel _form = new(); + private string? _error; + private bool _busy; + + private async Task SubmitAsync() + { + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + if (await db.ServerClusters.AnyAsync(c => c.ClusterId == _form.ClusterId)) + { + _error = $"Cluster '{_form.ClusterId}' already exists."; + return; + } + + var auth = await AuthState.GetAuthenticationStateAsync(); + var createdBy = auth.User.Identity?.Name ?? "(anonymous)"; + + var entity = new ServerCluster + { + ClusterId = _form.ClusterId, + Name = _form.Name, + Enterprise = _form.Enterprise, + Site = _form.Site, + RedundancyMode = _form.RedundancyMode, + NodeCount = _form.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + Enabled = true, + CreatedAt = DateTime.UtcNow, + CreatedBy = createdBy, + }; + db.ServerClusters.Add(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{entity.ClusterId}"); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private sealed class FormModel + { + [System.ComponentModel.DataAnnotations.Required, System.ComponentModel.DataAnnotations.RegularExpression("^[A-Z0-9_-]+$", ErrorMessage = "Use uppercase letters, digits, dash, underscore.")] + public string ClusterId { get; set; } = ""; + [System.ComponentModel.DataAnnotations.Required] + public string Name { get; set; } = ""; + [System.ComponentModel.DataAnnotations.Required] + public string Enterprise { get; set; } = "zb"; + [System.ComponentModel.DataAnnotations.Required] + public string Site { get; set; } = ""; + public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None; + public string? Notes { get; set; } + } +}