Clusters
- -@if (_clusters is null) -{ -Loading…
-} -else if (_clusters.Count == 0) -{ -No clusters yet. Use the stored-proc sp_PublishGeneration workflow to bootstrap.
| ClusterId | Name | Enterprise/Site | RedundancyMode | Enabled |
|---|---|---|---|---|
@c.ClusterId |
- @c.Name | -@c.Enterprise / @c.Site | -@c.RedundancyMode | -@(c.Enabled ? "Yes" : "No") | -
Access-control grants
+ +Loading…
} +else if (_acls.Count == 0) {No ACL grants in this draft. Publish will result in a cluster with no external access.
} +else +{ +| LDAP group | Scope | Scope ID | Permissions | |
|---|---|---|---|---|
| @a.LdapGroup | +@a.ScopeKind | +@(a.ScopeId ?? "-") |
+ @a.PermissionFlags |
+ + |
Recent audit log
+ +@if (_entries is null) {Loading…
} +else if (_entries.Count == 0) {No audit entries for this cluster yet.
} +else +{ +| When | Principal | Event | Node | Generation | Details |
|---|---|---|---|---|---|
| @a.Timestamp.ToString("u") | +@a.Principal | +@a.EventType |
+ @a.NodeId | +@a.GenerationId | +@a.DetailsJson | +
Loading…
+} +else +{ +@_cluster.Name
+@_cluster.ClusterId
+ @if (!_cluster.Enabled) { Disabled }
+ -
+
- Enterprise / Site
- @_cluster.Enterprise / @_cluster.Site +
- Redundancy
- @_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s")) +
- Current published +
- + @if (_currentPublished is not null) { @_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u")) } + else { none published yet } + +
- Created
- @_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy +
Open a draft to edit this cluster's content.
+ } +} + +@code { + [Parameter] public string ClusterId { get; set; } = string.Empty; + private ServerCluster? _cluster; + private ConfigGeneration? _currentDraft; + private ConfigGeneration? _currentPublished; + private string _tab = "overview"; + private bool _busy; + + private string Tab(string key) => _tab == key ? "active" : string.Empty; + + protected override async Task OnInitializedAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _cluster = await ClusterSvc.FindAsync(ClusterId, CancellationToken.None); + var gens = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None); + _currentDraft = gens.FirstOrDefault(g => g.Status == GenerationStatus.Draft); + _currentPublished = gens.FirstOrDefault(g => g.Status == GenerationStatus.Published); + } + + private async Task CreateDraftAsync() + { + _busy = true; + try + { + var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: "admin-ui", CancellationToken.None); + Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}"); + } + finally { _busy = false; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor new file mode 100644 index 0000000..8448328 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor @@ -0,0 +1,56 @@ +@page "/clusters" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject ClusterService ClusterSvc + +Clusters
+ New cluster +Loading…
+} +else if (_clusters.Count == 0) +{ +No clusters yet. Create the first one.
+} +else +{ +| ClusterId | Name | Enterprise | Site | +RedundancyMode | NodeCount | Enabled | + |
|---|---|---|---|---|---|---|---|
@c.ClusterId |
+ @c.Name | +@c.Enterprise | +@c.Site | +@c.RedundancyMode | +@c.NodeCount | ++ @if (c.Enabled) { Active } + else { Disabled } + | +Open | +
Draft diff
+ + Cluster@ClusterId — from last published (@(_fromLabel)) → to draft @GenerationId
+
+ Computing diff…
+} +else if (_error is not null) +{ +No differences — draft is structurally identical to the last published generation.
+} +else +{ +| Table | LogicalId | ChangeKind |
|---|---|---|
| @r.TableName | +@r.LogicalId |
+ + @switch (r.ChangeKind) + { + case "Added": @r.ChangeKind break; + case "Removed": @r.ChangeKind break; + case "Modified": @r.ChangeKind break; + default: @r.ChangeKind break; + } + | +
Draft editor
+ Cluster@ClusterId · generation @GenerationId
+ Checking…
} + else if (_errors.Count == 0) {-
+ @foreach (var e in _errors)
+ {
+
-
+ @e.Code
+ @e.Message
+ @if (!string.IsNullOrEmpty(e.Context)) { } +
@e.Context
+ }
+
DriverInstances
+ +Loading…
} +else if (_drivers.Count == 0) {No drivers configured in this draft.
} +else +{ +| DriverInstanceId | Name | Type | Namespace |
|---|---|---|---|
@d.DriverInstanceId | @d.Name | @d.DriverType | @d.NamespaceId |
Equipment (draft gen @GenerationId)
+ +Loading…
+} +else if (_equipment.Count == 0 && !_showForm) +{ +No equipment in this draft yet.
+} +else if (_equipment.Count > 0) +{ +| EquipmentId | Name | MachineCode | ZTag | SAPID | +Manufacturer / Model | Serial | + |
|---|---|---|---|---|---|---|---|
@e.EquipmentId |
+ @e.Name | +@e.MachineCode | +@e.ZTag | +@e.SAPID | +@e.Manufacturer / @e.Model | +@e.SerialNumber | ++ |
New equipment
+OPC 40010 Identification
+Generations
+ +@if (_generations is null) {Loading…
} +else if (_generations.Count == 0) {No generations in this cluster yet.
} +else +{ +| ID | Status | Created | Published | PublishedBy | Notes | |
|---|---|---|---|---|---|---|
@g.GenerationId |
+ @StatusBadge(g.Status) | +@g.CreatedAt.ToString("u") by @g.CreatedBy | +@(g.PublishedAt?.ToString("u") ?? "-") | +@g.PublishedBy | +@g.Notes | ++ @if (g.Status == GenerationStatus.Draft) + { + Open + } + else if (g.Status is GenerationStatus.Published or GenerationStatus.Superseded) + { + + } + | +
Namespaces
+ +Loading…
} +else if (_namespaces.Count == 0) {No namespaces defined in this draft.
} +else +{ +| NamespaceId | Kind | URI | Enabled |
|---|---|---|---|
@n.NamespaceId | @n.Kind | @n.NamespaceUri | @(n.Enabled ? "yes" : "no") |
New cluster
+ +UNS Areas
+ +Loading…
} + else if (_areas.Count == 0) {No areas yet.
} + else + { +| AreaId | Name |
|---|---|
@a.UnsAreaId | @a.Name |
UNS Lines
+ +Loading…
} + else if (_lines.Count == 0) {No lines yet.
} + else + { +| LineId | Area | Name |
|---|---|---|
@l.UnsLineId | @l.UnsAreaId | @l.Name |
OtOpcUa fleet overview
-Phase 1 scaffold — full dashboard lands in Phase 1 Stream E completion.
+Fleet overview
-Clusters
ManageLoading…
+} +else if (_clusters.Count == 0) +{ +Generations
ManageClusters
Active drafts
Published generations
Disabled clusters
Equipment
ManageClusters
+| ClusterId | Name | Enterprise / Site | Redundancy | Enabled | |
|---|---|---|---|---|---|
@c.ClusterId |
+ @c.Name | +@c.Enterprise / @c.Site | +@c.RedundancyMode | +@(c.Enabled ? "Yes" : "No") | +Open | +
OtOpcUa Admin — sign in
+ ++ + Phase 1 note: real LDAP bind is deferred. This scaffold accepts + any non-empty credentials and issues a
FleetAdmin cookie. Replace the
+ LdapAuthService stub with the ScadaLink-parity implementation before
+ production deployment.
+
+ External-ID reservations
++ Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a + FleetAdmin-only audit-logged action — only release when the physical asset is permanently + retired and its ID needs to be reused by a different equipment. +
+ +Active
+@if (_active is null) {Loading…
} +else if (_active.Count == 0) {No active reservations.
} +else +{ +| Kind | Value | EquipmentUuid | Cluster | First published | Last published | |
|---|---|---|---|---|---|---|
@r.Kind |
+ @r.Value |
+ @r.EquipmentUuid |
+ @r.ClusterId | +@r.FirstPublishedAt.ToString("u") by @r.FirstPublishedBy | +@r.LastPublishedAt.ToString("u") | ++ |
Released (most recent 100)
+@if (_released is null) {Loading…
} +else if (_released.Count == 0) {No released reservations yet.
} +else +{ +| Kind | Value | Released at | By | Reason |
|---|---|---|---|---|
@r.Kind | @r.Value | @r.ReleasedAt?.ToString("u") | @r.ReleasedBy | @r.ReleaseReason |
- > ListRecentAsync(string? clusterId, int limit, CancellationToken ct)
+ {
+ var q = db.ConfigAuditLogs.AsNoTracking();
+ if (clusterId is not null) q = q.Where(a => a.ClusterId == clusterId);
+ return q.OrderByDescending(a => a.Timestamp).Take(limit).ToListAsync(ct);
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DraftValidationService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DraftValidationService.cs
new file mode 100644
index 0000000..1aba5ba
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DraftValidationService.cs
@@ -0,0 +1,45 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+///
- > ListAsync(long generationId, CancellationToken ct) =>
+ db.DriverInstances.AsNoTracking()
+ .Where(d => d.GenerationId == generationId)
+ .OrderBy(d => d.DriverInstanceId)
+ .ToListAsync(ct);
+
+ public async Task
- > ListAsync(long generationId, CancellationToken ct) =>
+ db.Equipment.AsNoTracking()
+ .Where(e => e.GenerationId == generationId)
+ .OrderBy(e => e.Name)
+ .ToListAsync(ct);
+
+ public Task
- > ListAsync(long generationId, CancellationToken ct) =>
+ db.Namespaces.AsNoTracking()
+ .Where(n => n.GenerationId == generationId)
+ .OrderBy(n => n.NamespaceId)
+ .ToListAsync(ct);
+
+ public async Task
- > ListAsync(long generationId, CancellationToken ct) =>
+ db.NodeAcls.AsNoTracking()
+ .Where(a => a.GenerationId == generationId)
+ .OrderBy(a => a.LdapGroup)
+ .ThenBy(a => a.ScopeKind)
+ .ToListAsync(ct);
+
+ public async Task
- > ListActiveAsync(CancellationToken ct) =>
+ db.ExternalIdReservations.AsNoTracking()
+ .Where(r => r.ReleasedAt == null)
+ .OrderBy(r => r.Kind).ThenBy(r => r.Value)
+ .ToListAsync(ct);
+
+ public Task
- > ListReleasedAsync(CancellationToken ct) =>
+ db.ExternalIdReservations.AsNoTracking()
+ .Where(r => r.ReleasedAt != null)
+ .OrderByDescending(r => r.ReleasedAt)
+ .Take(100)
+ .ToListAsync(ct);
+
+ public async Task ReleaseAsync(string kind, string value, string reason, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(reason))
+ throw new ArgumentException("ReleaseReason is required (audit invariant)", nameof(reason));
+
+ await db.Database.ExecuteSqlRawAsync(
+ "EXEC dbo.sp_ReleaseExternalIdReservation @Kind = {0}, @Value = {1}, @ReleaseReason = {2}",
+ [kind, value, reason],
+ ct);
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
new file mode 100644
index 0000000..c66ff17
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
@@ -0,0 +1,50 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+public sealed class UnsService(OtOpcUaConfigDbContext db)
+{
+ public Task
- > ListAreasAsync(long generationId, CancellationToken ct) =>
+ db.UnsAreas.AsNoTracking()
+ .Where(a => a.GenerationId == generationId)
+ .OrderBy(a => a.Name)
+ .ToListAsync(ct);
+
+ public Task
- > ListLinesAsync(long generationId, CancellationToken ct) =>
+ db.UnsLines.AsNoTracking()
+ .Where(l => l.GenerationId == generationId)
+ .OrderBy(l => l.Name)
+ .ToListAsync(ct);
+
+ public async Task