diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 54a6676..2da7dc3 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -18,8 +18,6 @@ - - @@ -67,9 +65,6 @@ - - - diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor deleted file mode 100644 index db62d4a..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor +++ /dev/null @@ -1,21 +0,0 @@ -@* Root Blazor component. *@ - - - - - - OtOpcUa Admin - - @* Admin-010: Bootstrap 5 is vendored under wwwroot/lib/bootstrap/ per admin-ui.md - "Tech Stack" — no public-CDN dependency so air-gapped fleet deployments work. *@ - - - - - - - - - - - diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/ClusterAuthorizeView.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/ClusterAuthorizeView.razor deleted file mode 100644 index 61bff56..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/ClusterAuthorizeView.razor +++ /dev/null @@ -1,44 +0,0 @@ -@* Cluster-scoped counterpart of . Renders Authorized/ChildContent only when the - signed-in user's effective role for ClusterId meets MinRole; otherwise renders NotAuthorized. - Effective role combines fleet-wide and cluster-scoped grants — see ClaimsPrincipalClusterExtensions. *@ -@using System.Security.Claims -@using ZB.MOM.WW.OtOpcUa.Admin.Security -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums - -@if (_authorized) -{ - @(Authorized ?? ChildContent) -} -else -{ - @NotAuthorized -} - -@code { - [CascadingParameter] private Task? AuthState { get; set; } - - /// Cluster the grant is evaluated against. - [Parameter, EditorRequired] public string ClusterId { get; set; } = string.Empty; - - /// Minimum effective role required to render the authorized content. - [Parameter] public AdminRole MinRole { get; set; } = AdminRole.ConfigViewer; - - /// Content shown when authorized (alias-friendly: use this or ). - [Parameter] public RenderFragment? Authorized { get; set; } - - /// Default content slot — shown when authorized if is unset. - [Parameter] public RenderFragment? ChildContent { get; set; } - - /// Content shown when the user lacks the required role; renders nothing when unset. - [Parameter] public RenderFragment? NotAuthorized { get; set; } - - private bool _authorized; - - protected override async Task OnParametersSetAsync() - { - _authorized = false; - if (AuthState is null) return; - var user = (await AuthState).User; - _authorized = user.HasClusterRole(ClusterId, MinRole); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor deleted file mode 100644 index ca8e0df..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor +++ /dev/null @@ -1,152 +0,0 @@ -@page "/account" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using System.Security.Claims -@using ZB.MOM.WW.OtOpcUa.Admin.Security -@using ZB.MOM.WW.OtOpcUa.Admin.Services - -
-

My account

-
- - - - @{ - var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—"; - var displayName = context.User.Identity?.Name ?? "—"; - var roles = context.User.Claims - .Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList(); - var ldapGroups = context.User.Claims - .Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList(); - var clusterGrants = context.User.Claims - .Where(c => c.Type == ClusterRoleClaims.ClaimType) - .Select(c => ClusterRoleClaims.Decode(c.Value)) - .Where(d => d is not null) - .Select(d => d!.Value) - .OrderBy(d => d.ClusterId, StringComparer.OrdinalIgnoreCase) - .ThenBy(d => d.Role) - .ToList(); - } - -
-
-
Identity
-
Username@username
-
Display name@displayName
-
- -
-
Admin roles
- @if (roles.Count == 0 && clusterGrants.Count == 0) - { -
RolesNo Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.
- } - else - { -
- Fleet-wide roles - - @if (roles.Count == 0) - { - none - } - else - { - @foreach (var r in roles) - { - @r - } - } - -
- @if (clusterGrants.Count > 0) - { -
- Cluster-scoped roles - - @foreach (var g in clusterGrants) - { - @g.ClusterId: @g.Role - } - -
- } -
LDAP groups@(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))
- } -
-
- -
-
Capabilities
-

- Each Admin role grants a fixed capability set per admin-ui.md §Admin Roles. - Pages below reflect what this session can access; the route's [Authorize] guard - is the ground truth — this table mirrors it for readability. This table covers - fleet-wide capabilities only — a cluster-scoped grant unlocks the same actions inside its - named cluster without satisfying these fleet-wide policies. -

-
- - - - - - - - - - @foreach (var cap in Capabilities) - { - var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase)); - - - - - - } - -
CapabilityRequired role(s)You have it?
@cap.Name
@cap.Description
@string.Join(" or ", cap.RequiredRoles) - @if (has) - { - Yes - } - else - { - No - } -
-
-
- -
-
- -
-
-
-
- -@code { - private sealed record Capability(string Name, string Description, string[] RequiredRoles); - - // Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute. - // When a new page or policy is added, extend this list so operators can self-service check - // whether their session has access without trial-and-error navigation. - private static readonly IReadOnlyList Capabilities = - [ - new("View clusters + fleet status", - "Read-only access to the cluster list, fleet dashboard, and generation history.", - [AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]), - new("Edit configuration drafts", - "Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.", - [AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]), - new("Publish generations", - "Promote a draft to Published — triggers node roll-out. CanPublish policy.", - [AdminRoles.FleetAdmin]), - new("Manage certificate trust", - "Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.", - [AdminRoles.FleetAdmin]), - new("Manage external-ID reservations", - "Reserve / release external IDs that map into Galaxy contained names.", - [AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]), - ]; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor deleted file mode 100644 index 09e18cb..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor +++ /dev/null @@ -1,80 +0,0 @@ -@page "/alarms/historian" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using Microsoft.AspNetCore.Components.Web -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian -@rendermode RenderMode.InteractiveServer -@inject HistorianDiagnosticsService Diag - -
-

Alarm historian

-
-

Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.

- -
-
-
Drain state
-
@_status.DrainState
-
-
-
Queue depth
-
@_status.QueueDepth.ToString("N0")
-
-
-
Dead-letter depth
-
@_status.DeadLetterDepth.ToString("N0")
-
-
-
Last success
-
@(_status.LastSuccessUtc?.ToString("u") ?? "—")
-
-
- -@if (!string.IsNullOrEmpty(_status.LastError)) -{ -
- Last error: @_status.LastError -
-} - -
- - -
- -@if (_retryResult is not null) -{ -
Requeued @_retryResult row(s) for retry.
-} - -@code { - private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled); - private int? _retryResult; - - protected override void OnInitialized() => _status = Diag.GetStatus(); - - private Task RefreshAsync() - { - _status = Diag.GetStatus(); - _retryResult = null; - return Task.CompletedTask; - } - - private Task RetryDeadLetteredAsync() - { - _retryResult = Diag.TryRetryDeadLettered(); - _status = Diag.GetStatus(); - return Task.CompletedTask; - } - - private static string BadgeFor(HistorianDrainState s) => s switch - { - HistorianDrainState.Idle => "chip-ok", - HistorianDrainState.Draining => "chip-idle", - HistorianDrainState.BackingOff => "chip-warn", - HistorianDrainState.Disabled => "chip-idle", - _ => "chip-idle", - }; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor deleted file mode 100644 index a2819f1..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor +++ /dev/null @@ -1,166 +0,0 @@ -@page "/certificates" -@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)] -@using Microsoft.AspNetCore.Components.Web -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@rendermode RenderMode.InteractiveServer -@inject CertTrustService Certs -@inject AuthenticationStateProvider AuthState -@inject ILogger Log - -
-

Certificate trust

-
- -
- PKI store root @Certs.PkiStoreRoot. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake. -
- -@if (_status is not null) -{ -
- @_status - -
-} - -
-
Rejected (@_rejected.Count)
- @if (_rejected.Count == 0) - { -

No rejected certificates. Clients that fail to handshake with an untrusted cert land here.

- } - else - { -
- - - - @foreach (var c in _rejected) - { - - - - - - - - } - -
SubjectIssuerThumbprintValidActions
@c.Subject@c.Issuer@c.Thumbprint@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd") - - -
-
- } -
- -
-
Trusted (@_trusted.Count)
- @if (_trusted.Count == 0) - { -

No client certs have been explicitly trusted. The server's own application cert lives in own/ and is not listed here.

- } - else - { -
- - - - @foreach (var c in _trusted) - { - - - - - - - - } - -
SubjectIssuerThumbprintValidActions
@c.Subject@c.Issuer@c.Thumbprint@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd") - -
-
- } -
- -@code { - private IReadOnlyList _rejected = []; - private IReadOnlyList _trusted = []; - private string? _status; - private string _statusKind = "success"; - - protected override void OnInitialized() => Reload(); - - private void Reload() - { - _rejected = Certs.ListRejected(); - _trusted = Certs.ListTrusted(); - } - - private async Task TrustAsync(CertInfo c) - { - if (Certs.TrustRejected(c.Thumbprint)) - { - await LogActionAsync("cert.trust", c); - Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success"); - } - else - { - Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning"); - } - Reload(); - } - - private async Task DeleteRejectedAsync(CertInfo c) - { - if (Certs.DeleteRejected(c.Thumbprint)) - { - await LogActionAsync("cert.delete.rejected", c); - Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success"); - } - else - { - Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning"); - } - Reload(); - } - - private async Task UntrustAsync(CertInfo c) - { - if (Certs.UntrustCert(c.Thumbprint)) - { - await LogActionAsync("cert.untrust", c); - Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success"); - } - else - { - Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning"); - } - Reload(); - } - - private async Task LogActionAsync(string action, CertInfo c) - { - // Cert trust changes are operator-initiated and security-sensitive — Serilog captures the - // user + thumbprint trail. CertTrustService also logs at Information on each filesystem - // move/delete; this line ties the action to the authenticated admin user so the two logs - // correlate. DB-level ConfigAuditLog persistence is deferred — its schema is - // cluster-scoped and cert actions are cluster-agnostic. - var state = await AuthState.GetAuthenticationStateAsync(); - var user = state.User.Identity?.Name ?? "(anonymous)"; - Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}", - user, action, c.Thumbprint, c.Subject); - } - - private void Set(string message, string kind) - { - _status = message; - _statusKind = kind; - } - - private void ClearStatus() => _status = null; - - private static string Short(string thumbprint) => - thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor deleted file mode 100644 index 077109b..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor +++ /dev/null @@ -1,295 +0,0 @@ -@using Microsoft.AspNetCore.SignalR.Client -@using ZB.MOM.WW.OtOpcUa.Admin.Hubs -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@using ZB.MOM.WW.OtOpcUa.Core.Authorization -@inject NodeAclService AclSvc -@inject PermissionProbeService ProbeSvc -@inject NavigationManager Nav -@inject AdminHubConnectionFactory HubFactory -@implements IAsyncDisposable - -
-

Access-control grants

- -
- -@if (_acls is null) {

Loading…

} -else if (_acls.Count == 0) {

No ACL grants in this draft. Publish will result in a cluster with no external access.

} -else -{ -
-
Grants
-
- - - - @foreach (var a in _acls) - { - - - - - - - - } - -
LDAP groupScopeScope IDPermissions
@a.LdapGroup@a.ScopeKind@(a.ScopeId ?? "-")@a.PermissionFlags
-
-
-} - -@* Probe-this-permission — task #196 slice 1 *@ -
-
- Probe this permission - - Ask the trie "if LDAP group X asks for permission Y on node Z, would it be granted?" — - answers the same way the live server does at request time. - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - @if (_probeResult is not null) - { - - @if (_probeResult.Granted) - { - Granted - } - else - { - Denied - } - - Required @_probeResult.Required, - Effective @_probeResult.Effective - - - } -
- @if (_probeResult is not null && _probeResult.Matches.Count > 0) - { -
- - - - @foreach (var m in _probeResult.Matches) - { - - - - - - } - -
LDAP group matchedLevelFlags contributed
@m.LdapGroup@m.Scope@m.PermissionFlags
-
- } - else if (_probeResult is not null) - { -
No matching grants for this (group, scope) — effective permission is None.
- } -
-
- -@if (_showForm) -{ -
-
Add grant
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- @if (_error is not null) {
@_error
} -
- - -
-
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private List? _acls; - private bool _showForm; - private string _group = string.Empty; - private NodeAclScopeKind _scopeKind = NodeAclScopeKind.Cluster; - private string _scopeId = string.Empty; - private string _preset = "Read"; - private string? _error; - - // Probe-this-permission state - private string _probeGroup = string.Empty; - private string _probeNamespaceId = string.Empty; - private string _probeUnsAreaId = string.Empty; - private string _probeUnsLineId = string.Empty; - private string _probeEquipmentId = string.Empty; - private string _probeTagId = string.Empty; - private NodePermissions _probePermission = NodePermissions.Read; - private PermissionProbeResult? _probeResult; - private bool _probing; - - private async Task RunProbeAsync() - { - if (string.IsNullOrWhiteSpace(_probeGroup)) { _probeResult = null; return; } - _probing = true; - try - { - var scope = new NodeScope - { - ClusterId = ClusterId, - NamespaceId = NullIfBlank(_probeNamespaceId), - UnsAreaId = NullIfBlank(_probeUnsAreaId), - UnsLineId = NullIfBlank(_probeUnsLineId), - EquipmentId = NullIfBlank(_probeEquipmentId), - TagId = NullIfBlank(_probeTagId), - Kind = NodeHierarchyKind.Equipment, - }; - _probeResult = await ProbeSvc.ProbeAsync(GenerationId, _probeGroup.Trim(), scope, _probePermission, CancellationToken.None); - } - finally { _probing = false; } - } - - private static string? NullIfBlank(string s) => string.IsNullOrWhiteSpace(s) ? null : s; - - private HubConnection? _hub; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender || _hub is not null) return; - _hub = HubFactory.Create("/hubs/fleet"); - _hub.On("NodeAclChanged", async msg => - { - if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return; - _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); - await InvokeAsync(StateHasChanged); - }); - // Best-effort: FleetStatusHub requires an authenticated caller, and the server-side - // HubConnection cannot forward the browser auth cookie — swallow connect failures so - // the tab still renders. Live ACL-change updates degrade. - try - { - await _hub.StartAsync(); - await _hub.SendAsync("SubscribeCluster", ClusterId); - } - catch - { - // best-effort live updates — see comment above - } - } - - public async ValueTask DisposeAsync() - { - if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; } - } - - protected override async Task OnParametersSetAsync() => - _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); - - private NodePermissions ResolvePreset() => _preset switch - { - "Read" => NodePermissions.Browse | NodePermissions.Read, - "WriteOperate" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate, - "Engineer" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune | NodePermissions.WriteConfigure, - "AlarmAck" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge, - "Full" => unchecked((NodePermissions)(-1)), - _ => NodePermissions.Browse | NodePermissions.Read, - }; - - private async Task SaveAsync() - { - _error = null; - if (string.IsNullOrWhiteSpace(_group)) { _error = "LDAP group is required"; return; } - - var scopeId = _scopeKind == NodeAclScopeKind.Cluster ? null - : string.IsNullOrWhiteSpace(_scopeId) ? null : _scopeId; - - if (_scopeKind != NodeAclScopeKind.Cluster && scopeId is null) - { - _error = $"ScopeId required for {_scopeKind}"; - return; - } - - try - { - await AclSvc.GrantAsync(GenerationId, ClusterId, _group, _scopeKind, scopeId, - ResolvePreset(), notes: null, CancellationToken.None); - _group = string.Empty; _scopeId = string.Empty; - _showForm = false; - _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); - } - catch (Exception ex) { _error = ex.Message; } - } - - private async Task RevokeAsync(Guid rowId) - { - await AclSvc.RevokeAsync(rowId, CancellationToken.None); - _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AuditTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AuditTab.razor deleted file mode 100644 index 76d1234..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AuditTab.razor +++ /dev/null @@ -1,40 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@inject AuditLogService AuditSvc - -

Recent audit log

- -@if (_entries is null) {

Loading…

} -else if (_entries.Count == 0) {

No audit entries for this cluster yet.

} -else -{ -
-
Entries
-
- - - - @foreach (var a in _entries) - { - - - - - - - - - } - -
WhenPrincipalEventNodeGenerationDetails
@a.Timestamp.ToString("u")@a.Principal@a.EventType@a.NodeId@a.GenerationId@a.DetailsJson
-
-
-} - -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - private List? _entries; - - protected override async Task OnParametersSetAsync() => - _entries = await AuditSvc.ListRecentAsync(ClusterId, limit: 100, CancellationToken.None); -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor deleted file mode 100644 index 56c4bba..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor +++ /dev/null @@ -1,227 +0,0 @@ -@page "/clusters/{ClusterId}" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using System.Security.Claims -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.SignalR.Client -@using ZB.MOM.WW.OtOpcUa.Admin.Hubs -@using ZB.MOM.WW.OtOpcUa.Admin.Security -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@implements IAsyncDisposable -@rendermode RenderMode.InteractiveServer -@inject ClusterService ClusterSvc -@inject GenerationService GenerationSvc -@inject NavigationManager Nav -@inject AdminHubConnectionFactory HubFactory - -@if (!_loaded) -{ -

Loading…

-} -else if (!_canView) -{ -
- You don't have access to cluster @ClusterId. A fleet-wide or - cluster-scoped Admin role grant is required — ask a fleet admin to add one on the - role grants page. -
-} -else if (_cluster is null) -{ -
- Cluster @ClusterId was not found. -
-} -else -{ - @if (_liveBanner is not null) - { -
- Live update: @_liveBanner - -
- } -
-
-

@_cluster.Name

- @_cluster.ClusterId - @if (!_cluster.Enabled) { Disabled } -
-
- @if (!_canEdit) - { - Read-only access - } - else if (_currentDraft is not null) - { - - Edit current draft (gen @_currentDraft.GenerationId) - - } - else - { - - } -
-
- - - - @if (_tab == "overview") - { -
-
-
Cluster details
-
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
-
-
- } - else if (_tab == "generations") - { - - } - else if (_tab == "equipment" && _currentDraft is not null) - { - - } - else if (_tab == "uns" && _currentDraft is not null) - { - - } - else if (_tab == "namespaces" && _currentDraft is not null) - { - - } - else if (_tab == "drivers" && _currentDraft is not null) - { - - } - else if (_tab == "tags" && _currentDraft is not null) - { - - } - else if (_tab == "acls" && _currentDraft is not null) - { - - } - else if (_tab == "redundancy") - { - - } - else if (_tab == "audit") - { - - } - else - { -
Open a draft to edit this cluster's content.
- } -} - -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - [CascadingParameter] private Task? AuthState { get; set; } - private ServerCluster? _cluster; - private ConfigGeneration? _currentDraft; - private ConfigGeneration? _currentPublished; - private string _tab = "overview"; - private bool _busy; - private bool _loaded; - private bool _canView; - private bool _canEdit; - private HubConnection? _hub; - private string? _liveBanner; - - private string Tab(string key) => _tab == key ? "active" : string.Empty; - - protected override async Task OnInitializedAsync() - { - if (AuthState is not null) - { - var user = (await AuthState).User; - _canView = user.HasClusterRole(ClusterId, AdminRole.ConfigViewer); - _canEdit = user.HasClusterRole(ClusterId, AdminRole.ConfigEditor); - } - _loaded = true; - if (!_canView) return; - - await LoadAsync(); - await ConnectHubAsync(); - } - - 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 ConnectHubAsync() - { - _hub = HubFactory.Create("/hubs/fleet"); - - _hub.On("NodeStateChanged", async msg => - { - if (msg.ClusterId != ClusterId) return; - _liveBanner = $"Node {msg.NodeId}: {msg.LastAppliedStatus ?? "seen"} at {msg.LastAppliedAt?.ToString("u") ?? msg.LastSeenAt?.ToString("u") ?? "-"}"; - await LoadAsync(); - await InvokeAsync(StateHasChanged); - }); - - // Best-effort: FleetStatusHub requires an authenticated caller, and the server-side - // HubConnection cannot forward the browser auth cookie — a connect failure must not - // crash the page. Live banner updates degrade; the page still renders. - try - { - await _hub.StartAsync(); - await _hub.SendAsync("SubscribeCluster", ClusterId); - } - catch - { - // best-effort live updates — see comment above - } - } - - private async Task CreateDraftAsync() - { - _busy = true; - try - { - // Admin-007: record the authenticated operator's name, not a static literal. - var user = AuthState is not null ? (await AuthState).User : null; - var operatorName = user?.FindFirstValue(ClaimTypes.Name) - ?? user?.FindFirstValue(ClaimTypes.NameIdentifier) - ?? "unknown"; - var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: operatorName, CancellationToken.None); - Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}"); - } - finally { _busy = false; } - } - - public async ValueTask DisposeAsync() - { - if (_hub is not null) await _hub.DisposeAsync(); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor deleted file mode 100644 index f3cd96b..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor +++ /dev/null @@ -1,62 +0,0 @@ -@page "/clusters" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@inject ClusterService ClusterSvc - -
-

Clusters

- New cluster -
- -@if (_clusters is null) -{ -

Loading…

-} -else if (_clusters.Count == 0) -{ -

No clusters yet. Create the first one.

-} -else -{ -
-
All clusters
-
- - - - - - - - - @foreach (var c in _clusters) - { - - - - - - - - - - - } - -
ClusterIdNameEnterpriseSiteRedundancyModeNodeCountEnabled
@c.ClusterId@c.Name@c.Enterprise@c.Site@c.RedundancyMode@c.NodeCount - @if (c.Enabled) { Active } - else { Disabled } - Open
-
-
-} - -@code { - private List? _clusters; - - protected override async Task OnInitializedAsync() - { - _clusters = await ClusterSvc.ListAsync(CancellationToken.None); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor deleted file mode 100644 index 1c5dfe0..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor +++ /dev/null @@ -1,90 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Admin.Services - -@* Per-section diff renderer — the base used by DiffViewer for every known TableName. Caps - output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the - Blazor render; overflow banner tells operator how many rows were hidden. *@ - -
-
-
- @Title - @Description -
-
- @if (_added > 0) { +@_added } - @if (_removed > 0) { −@_removed } - @if (_modified > 0) { ~@_modified } - @if (_total == 0) { no changes } -
-
- @if (_total == 0) - { -

No changes in this section.

- } - else - { - @if (_total > RowCap) - { -
- Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class - diffs. Inspect the remainder via the SQL sp_ComputeGenerationDiff directly. -
- } -
- - - - - - @foreach (var r in _visibleRows) - { - - - - - } - -
LogicalIdChange
@r.LogicalId - @switch (r.ChangeKind) - { - case "Added": @r.ChangeKind break; - case "Removed": @r.ChangeKind break; - case "Modified": @r.ChangeKind break; - default: @r.ChangeKind break; - } -
-
- } -
- -@code { - /// Default row-cap per section — matches task #156's acceptance criterion. - public const int DefaultRowCap = 1000; - - [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; - [Parameter] public string Description { get; set; } = string.Empty; - [Parameter, EditorRequired] public IReadOnlyList Rows { get; set; } = []; - [Parameter] public int RowCap { get; set; } = DefaultRowCap; - - private int _total; - private int _added; - private int _removed; - private int _modified; - private List _visibleRows = []; - - protected override void OnParametersSet() - { - _total = Rows.Count; - _added = 0; _removed = 0; _modified = 0; - foreach (var r in Rows) - { - switch (r.ChangeKind) - { - case "Added": _added++; break; - case "Removed": _removed++; break; - case "Modified": _modified++; break; - } - } - _visibleRows = _total > RowCap ? Rows.Take(RowCap).ToList() : Rows.ToList(); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor deleted file mode 100644 index 8bd3aa3..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor +++ /dev/null @@ -1,100 +0,0 @@ -@page "/clusters/{ClusterId}/draft/{GenerationId:long}/diff" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@inject GenerationService GenerationSvc - - - -
- Viewing cluster @ClusterId requires a fleet-wide or - cluster-scoped Admin role grant. -
-
- - -
-
-

Draft diff

- - Cluster @ClusterId — from last published (@(_fromLabel)) → to draft @GenerationId - -
- Back to editor -
- -@if (_rows is null) -{ -

Computing diff…

-} -else if (_error is not null) -{ -
@_error
-} -else if (_rows.Count == 0) -{ -

No differences — draft is structurally identical to the last published generation.

-} -else -{ -

- @_rows.Count row@(_rows.Count == 1 ? "" : "s") across @_sectionsWithChanges of @Sections.Count sections. - Each section is capped at @DiffSection.DefaultRowCap rows to keep the browser responsive on pathological drafts. -

- - @foreach (var sec in Sections) - { - - } -} - -
-
- -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - [Parameter] public long GenerationId { get; set; } - - /// - /// Ordered section definitions — each maps a TableName emitted by - /// sp_ComputeGenerationDiff to a human label + description. The proc currently - /// emits Namespace/DriverInstance/Equipment/Tag; UnsLine + NodeAcl entries render as - /// empty "no changes" cards until the proc is extended (tracked in tasks #196 + #156 - /// follow-up). Six sections total matches the task #156 target. - /// - private static readonly IReadOnlyList Sections = new[] - { - new SectionDef("Namespace", "Namespaces", "OPC UA namespace URIs + enablement"), - new SectionDef("DriverInstance", "Driver instances","Per-cluster driver configuration rows"), - new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"), - new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"), - new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"), - new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (logical id = LdapGroup|ScopeKind|ScopeId)"), - }; - - private List? _rows; - private string _fromLabel = "(empty)"; - private string? _error; - private int _sectionsWithChanges; - - protected override async Task OnParametersSetAsync() - { - try - { - var all = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None); - var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published); - _fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}"; - _rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None); - _sectionsWithChanges = Sections.Count(s => _rows.Any(r => r.TableName == s.TableName)); - } - catch (Exception ex) { _error = ex.Message; } - } - - private IReadOnlyList RowsFor(string tableName) => - _rows?.Where(r => r.TableName == tableName).ToList() ?? []; - - private sealed record SectionDef(string TableName, string Title, string Description); -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor deleted file mode 100644 index 87f7363..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor +++ /dev/null @@ -1,127 +0,0 @@ -@page "/clusters/{ClusterId}/draft/{GenerationId:long}" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using Microsoft.AspNetCore.Components.Web -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@using ZB.MOM.WW.OtOpcUa.Configuration.Validation -@rendermode RenderMode.InteractiveServer -@inject GenerationService GenerationSvc -@inject DraftValidationService ValidationSvc -@inject NavigationManager Nav - - - -
- Editing cluster @ClusterId requires the - ConfigEditor role for this cluster. -
-
- - -
-
-

Draft editor

- Cluster @ClusterId · generation @GenerationId -
-
- Back to cluster - View diff - - - -
-
- - - -
-
- @if (_tab == "equipment") { } - else if (_tab == "uns") { } - else if (_tab == "namespaces") { } - else if (_tab == "drivers") { } - else if (_tab == "acls") { } - else if (_tab == "scripts") { } - else if (_tab == "virtual-tags") { } - else if (_tab == "scripted-alarms") { } -
-
-
-
- Validation - -
-
- @if (_validating) {

Checking…

} - else if (_errors.Count == 0) {

No validation errors — safe to publish.

} - else - { -

@_errors.Count error@(_errors.Count == 1 ? "" : "s")

-
    - @foreach (var e in _errors) - { -
  • - @e.Code - @e.Message - @if (!string.IsNullOrEmpty(e.Context)) {
    @e.Context
    } -
  • - } -
- } -
-
- - @if (_publishError is not null) {
@_publishError
} -
-
- -
-
- -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - [Parameter] public long GenerationId { get; set; } - - private string _tab = "equipment"; - private List _errors = []; - private bool _validating; - private bool _busy; - private string? _publishError; - - private string Active(string k) => _tab == k ? "active" : string.Empty; - - protected override async Task OnParametersSetAsync() => await RevalidateAsync(); - - private async Task RevalidateAsync() - { - _validating = true; - try - { - var errors = await ValidationSvc.ValidateAsync(GenerationId, CancellationToken.None); - _errors = errors.ToList(); - } - finally { _validating = false; } - } - - private async Task PublishAsync() - { - _busy = true; - _publishError = null; - try - { - await GenerationSvc.PublishAsync(ClusterId, GenerationId, notes: "Published via Admin UI", CancellationToken.None); - Nav.NavigateTo($"/clusters/{ClusterId}"); - } - catch (Exception ex) { _publishError = ex.Message; } - finally { _busy = false; } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor deleted file mode 100644 index 4e7b2a5..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor +++ /dev/null @@ -1,192 +0,0 @@ -@using System.Text.Json -@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@inject DriverInstanceService DriverSvc -@inject NamespaceService NsSvc - -
-

DriverInstances

- -
- -@if (_drivers is null) {

Loading…

} -else if (_drivers.Count == 0) {

No drivers configured in this draft.

} -else -{ -
-
Configured drivers
-
- - - - @foreach (var d in _drivers) - { - - - - - - - } - -
DriverInstanceIdNameTypeNamespace
@d.DriverInstanceId@d.Name - @if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase)) - { - @d.DriverType - } - else - { - @d.DriverType - } - @d.NamespaceId
-
-
-} - -@if (_showForm && _namespaces is not null) -{ -
-
Add driver
-
-
-
- - -
-
- - -
Type string must match the driver's registered factory name; this dropdown wraps the canonical names.
-
-
- - -
-
- @if (string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase)) - { - @* #147 — typed editor for Modbus drivers. The generic textarea is a fall-back - for driver types that haven't yet shipped a typed editor. *@ - - - } - else - { - - -
Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).
- } -
-
- @if (_error is not null) {
@_error
} -
- - -
-
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private List? _drivers; - private List? _namespaces; - private bool _showForm; - private string _name = string.Empty; - private string _type = "Modbus"; - private string _nsId = string.Empty; - private string _config = "{}"; - private string? _error; - - // #147 — typed editor model for Modbus drivers. Defaults match ModbusDriverOptions - // defaults so an unedited form produces config equivalent to the historical - // pre-typed-editor wire output. Serialised to _config on Save when type=Modbus. - private ModbusOptionsEditor.ModbusOptionsViewModel _modbusOptions = new(); - private static readonly JsonSerializerOptions ModbusJsonOptions = new() { WriteIndented = true }; - - protected override async Task OnParametersSetAsync() => await ReloadAsync(); - - private async Task ReloadAsync() - { - _drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None); - _namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None); - _nsId = _namespaces.FirstOrDefault()?.NamespaceId ?? string.Empty; - } - - private async Task SaveAsync() - { - _error = null; - if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_nsId)) - { - _error = "Name and Namespace are required"; - return; - } - try - { - // #147 — for Modbus drivers serialize the typed editor model into the DriverConfig - // JSON column. Other driver types still use the raw textarea contents until each - // ships its own typed editor (decision #94 — per-driver schema validation arrives - // per driver phase). - var configJson = string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase) - ? SerializeModbusOptions(_modbusOptions) - : _config; - - await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, configJson, CancellationToken.None); - _name = string.Empty; _config = "{}"; - _modbusOptions = new(); - _showForm = false; - await ReloadAsync(); - } - catch (Exception ex) { _error = ex.Message; } - } - - /// - /// Maps the view-model field names onto the JSON shape ModbusDriverFactoryExtensions - /// consumes. Hand-rolled because the DTO uses millisecond / byte field flavours that the - /// view model exposes as TimeSpan-derived integers; a System.Text.Json round-trip would - /// emit the .NET-native names instead. - /// - private static string SerializeModbusOptions(ModbusOptionsEditor.ModbusOptionsViewModel m) => - JsonSerializer.Serialize(new - { - host = m.Host, - port = m.Port, - unitId = m.UnitId, - family = m.Family.ToString(), - melsecSubFamily = m.MelsecSubFamily.ToString(), - keepAlive = new - { - enabled = m.KeepAliveEnabled, - timeMs = m.KeepAliveTimeSec * 1000, - intervalMs = m.KeepAliveIntervalSec * 1000, - retryCount = m.KeepAliveRetryCount, - }, - reconnect = new - { - initialDelayMs = m.ReconnectInitialDelayMs, - maxDelayMs = m.ReconnectMaxDelayMs, - backoffMultiplier = m.ReconnectBackoffMultiplier, - }, - maxRegistersPerRead = m.MaxRegistersPerRead, - maxRegistersPerWrite = m.MaxRegistersPerWrite, - maxCoilsPerRead = m.MaxCoilsPerRead, - maxReadGap = m.MaxReadGap, - useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites, - useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites, - writeOnChangeOnly = m.WriteOnChangeOnly, - tags = Array.Empty(), - }, ModbusJsonOptions); -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor deleted file mode 100644 index 550b801..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor +++ /dev/null @@ -1,332 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Validation -@inject EquipmentService EquipmentSvc -@inject NavigationManager Nav - -
-

Equipment (draft gen @GenerationId)

-
- - -
-
- -@* Five-identifier search — decision #117: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid *@ -
-
Search equipment
-
-
-
- - -
-
-
- - -
-
-
- - @if (_searchHits is not null) - { - - } -
-
- @if (_searchError is not null) - { -

@_searchError

- } -
- - @if (_searchHits is not null) - { - @if (_searchHits.Count == 0) - { -

No matches.

- } - else - { -
- - - - - - - - - @foreach (var hit in _searchHits) - { - - - - - - - - - - } - -
EquipmentIdNameMachineCodeZTagSAPIDMatchedGen
@hit.Equipment.EquipmentId@hit.Equipment.Name@hit.Equipment.MachineCode@hit.Equipment.ZTag@hit.Equipment.SAPID - @if (hit.MatchedField is not null) - { - var chipClass = hit.Score switch - { - 100 => "chip chip-ok", - 50 => "chip chip-warn", - _ => "chip chip-idle", - }; - @hit.MatchedField - } - - @if (hit.IsPublished) - { pub } - else - { draft } -
-
-

- @_searchHits.Count result@(_searchHits.Count == 1 ? "" : "s"). - Exact = green, prefix = amber, fuzzy = grey. - Fuzzy matching requires the "Fuzzy" checkbox. -

- } - } -
- -@if (_equipment is null) -{ -

Loading…

-} -else if (_equipment.Count == 0 && !_showForm) -{ -

No equipment in this draft yet.

-} -else if (_equipment.Count > 0) -{ -
-
Equipment list
-
- - - - - - - - - @foreach (var e in _equipment) - { - - - - - - - - - - - } - -
EquipmentIdNameMachineCodeZTagSAPIDManufacturer / ModelSerial
@e.EquipmentId@e.Name@e.MachineCode@e.ZTag@e.SAPID@e.Manufacturer / @e.Model@e.SerialNumber - - -
-
-
-} - -@if (_showForm) -{ -
-
@(_editMode ? "Edit equipment" : "New equipment")
-
- - -
-
- - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - @if (_error is not null) {
@_error
} - -
- - -
-
-
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private void GoImport() => Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}/import-equipment"); - private List? _equipment; - private bool _showForm; - private bool _editMode; - private Equipment _draft = NewBlankDraft(); - private string? _error; - - // ── Five-identifier search ────────────────────────────────────────── - private string _searchQuery = string.Empty; - private bool _searchFuzzy; - private IReadOnlyList? _searchHits; - private bool _searchBusy; - private string? _searchError; - - private async Task RunSearchAsync() - { - _searchError = null; - if (string.IsNullOrWhiteSpace(_searchQuery)) { _searchHits = null; return; } - _searchBusy = true; - try - { - _searchHits = await EquipmentSvc.SearchAsync( - _searchQuery, ClusterId, CancellationToken.None, - maxResults: 50, allowFuzzy: _searchFuzzy); - } - catch (Exception ex) { _searchError = ex.Message; } - finally { _searchBusy = false; } - } - - private void ClearSearch() - { - _searchQuery = string.Empty; - _searchHits = null; - _searchError = null; - } - - private async Task OnSearchKeyDown(KeyboardEventArgs e) - { - if (e.Key == "Enter") await RunSearchAsync(); - } - // ─────────────────────────────────────────────────────────────────── - - private static Equipment NewBlankDraft() => new() - { - EquipmentId = string.Empty, DriverInstanceId = string.Empty, - UnsLineId = string.Empty, Name = string.Empty, MachineCode = string.Empty, - }; - - protected override async Task OnParametersSetAsync() => await ReloadAsync(); - - private async Task ReloadAsync() - { - _equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None); - } - - private void StartAdd() - { - _draft = NewBlankDraft(); - _editMode = false; - _error = null; - _showForm = true; - } - - private void StartEdit(Equipment row) - { - // Shallow-clone so Cancel doesn't mutate the list-displayed row with in-flight form edits. - _draft = new Equipment - { - EquipmentRowId = row.EquipmentRowId, - GenerationId = row.GenerationId, - EquipmentId = row.EquipmentId, - EquipmentUuid = row.EquipmentUuid, - DriverInstanceId = row.DriverInstanceId, - DeviceId = row.DeviceId, - UnsLineId = row.UnsLineId, - Name = row.Name, - MachineCode = row.MachineCode, - ZTag = row.ZTag, - SAPID = row.SAPID, - Manufacturer = row.Manufacturer, - Model = row.Model, - SerialNumber = row.SerialNumber, - HardwareRevision = row.HardwareRevision, - SoftwareRevision = row.SoftwareRevision, - YearOfConstruction = row.YearOfConstruction, - AssetLocation = row.AssetLocation, - ManufacturerUri = row.ManufacturerUri, - DeviceManualUri = row.DeviceManualUri, - EquipmentClassRef = row.EquipmentClassRef, - Enabled = row.Enabled, - }; - _editMode = true; - _error = null; - _showForm = true; - } - - private void Cancel() - { - _showForm = false; - _editMode = false; - } - - private async Task SaveAsync() - { - _error = null; - try - { - if (_editMode) - { - await EquipmentSvc.UpdateAsync(_draft, CancellationToken.None); - } - else - { - _draft.EquipmentUuid = Guid.NewGuid(); - _draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid); - _draft.GenerationId = GenerationId; - await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None); - } - _showForm = false; - _editMode = false; - await ReloadAsync(); - } - catch (Exception ex) { _error = ex.Message; } - } - - private async Task DeleteAsync(Guid id) - { - await EquipmentSvc.DeleteAsync(id, CancellationToken.None); - await ReloadAsync(); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/Generations.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/Generations.razor deleted file mode 100644 index 3efc8e6..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/Generations.razor +++ /dev/null @@ -1,76 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@inject GenerationService GenerationSvc -@inject NavigationManager Nav - -@if (_generations is null) {

Loading…

} -else if (_generations.Count == 0) {

No generations in this cluster yet.

} -else -{ -
-
Generations
-
- - - - - - @foreach (var g in _generations) - { - - - - - - - - - - } - -
IDStatusCreatedPublishedPublishedByNotes
@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) - { - - } -
-
-
-} - -@if (_error is not null) {
@_error
} - -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - private List? _generations; - private string? _error; - - protected override async Task OnParametersSetAsync() => await ReloadAsync(); - - private async Task ReloadAsync() => - _generations = await GenerationSvc.ListRecentAsync(ClusterId, 100, CancellationToken.None); - - private async Task RollbackAsync(long targetId) - { - _error = null; - try - { - await GenerationSvc.RollbackAsync(ClusterId, targetId, notes: $"Rollback via Admin UI", CancellationToken.None); - await ReloadAsync(); - } - catch (Exception ex) { _error = ex.Message; } - } - - private static MarkupString StatusBadge(GenerationStatus s) => s switch - { - GenerationStatus.Draft => new MarkupString("Draft"), - GenerationStatus.Published => new MarkupString("Published"), - GenerationStatus.Superseded => new MarkupString("Superseded"), - _ => new MarkupString($"{s}"), - }; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/IdentificationFields.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/IdentificationFields.razor deleted file mode 100644 index 5a1a94c..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/IdentificationFields.razor +++ /dev/null @@ -1,51 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities - -@* Reusable OPC 40010 Machinery Identification editor. Binds to an Equipment row and renders the - nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's - create + edit forms so the same UI renders regardless of which flow opened it. *@ - -
-
-
OPC 40010 Identification
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -@code { - [Parameter, EditorRequired] public Equipment? Equipment { get; set; } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor deleted file mode 100644 index 13d9d63..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor +++ /dev/null @@ -1,228 +0,0 @@ -@page "/clusters/{ClusterId}/draft/{GenerationId:long}/import-equipment" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using Microsoft.AspNetCore.Components.Authorization -@using Microsoft.AspNetCore.Components.Web -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@rendermode RenderMode.InteractiveServer -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@inject DriverInstanceService DriverSvc -@inject UnsService UnsSvc -@inject EquipmentImportBatchService BatchSvc -@inject NavigationManager Nav -@inject AuthenticationStateProvider AuthProvider - - - -
- Importing equipment into cluster @ClusterId requires the - ConfigEditor role for this cluster. -
-
- - -
-
-

Equipment CSV import

- Cluster @ClusterId · draft generation @GenerationId -
- Back to draft -
- -
- Accepts @EquipmentCsvImporter.VersionMarker-headered CSV per Stream B.3. - Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns). - Optional columns cover the OPC 40010 Identification fields. Paste the file contents - or upload directly — the parser runs client-stream-side and shows a row-level preview - before anything lands in the draft. ZTag + SAPID reservation conflicts (task #197) are - checked at parse time: rows whose ZTag or SAPID is already reserved by a different - EquipmentUuid appear in the Rejected list so you can resolve them before finalising. -
- -
- Per-tag addressing for Modbus drivers isn't part of equipment import — - tags are configured at the driver-instance level via the - Drivers tab. Use the - address-preview tool to sanity-check - grammar strings (40001:F:CDAB, HR1:I, V2000 for - DL205 family, etc.) before pasting them into the driver config. -
- -
-
Import configuration
-
-
-
- - -
-
- - -
-
- -
-
-
- - -
- -@code { - [Parameter] public string Source { get; set; } = string.Empty; - [Parameter] public EventCallback SourceChanged { get; set; } - - private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}"; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - try - { - await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId); - } - catch (JSException) - { - // Monaco bundle not yet loaded on this page — textarea fallback is - // still functional. - } - } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptedAlarmsTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptedAlarmsTab.razor deleted file mode 100644 index 19bbe25..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptedAlarmsTab.razor +++ /dev/null @@ -1,260 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@inject ScriptedAlarmService AlarmSvc -@inject ScriptService ScriptSvc - -
-
-

Scripted Alarms

- OPC UA Part 9 alarms raised by C# predicate scripts. Additive to driver-native alarm streams. -
- -
- -@if (_loading) -{ -

Loading…

-} -else if (_alarms.Count == 0 && !_showForm) -{ -
No scripted alarms yet in this draft.
-} -else -{ - @if (_alarms.Count > 0) - { -
-
- Scripted alarms in draft gen @GenerationId - @_alarms.Count alarm@(_alarms.Count == 1 ? "" : "s") -
-
- - - - - - - - - - - - - - - - @foreach (var a in _alarms) - { - - - - - - - - - - - - } - -
NameEquipmentTypeSeverityPredicate scriptHistorizeRetainEnabled
@a.Name@a.EquipmentId@a.AlarmType@a.Severity @SeverityBand(a.Severity)@(ScriptName(a.PredicateScriptId)) - @if (a.HistorizeToAveva) { Aveva } - else { } - - @if (a.Retain) { yes } - else { } - - @if (a.Enabled) { enabled } - else { disabled } - - -
-
-
- } -} - -@if (_showForm) -{ -
-
- New scripted alarm - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - @if (_scripts.Count == 0) - { -
No scripts in this draft — create one in the Scripts tab first.
- } -
-
- - -
-
-
- - -
-
-
-
- - -
-
-
- - @if (_error is not null) - { -
@_error
- } - -
- - -
-
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private static readonly string[] AlarmTypes = - ["AlarmCondition", "LimitAlarm", "OffNormalAlarm", "DiscreteAlarm"]; - - private bool _loading = true; - private bool _busy; - private bool _showForm; - private List _alarms = []; - private List - -@if (_loading) {

Loading…

} -else if (_scripts.Count == 0 && _editing is null) -{ -
No scripts yet in this draft.
-} -else -{ -
-
-
- @foreach (var s in _scripts) - { - - } -
-
-
- @if (_editing is not null) - { -
-
- @(_isNew ? "New script" : _editing.Name) -
- @if (!_isNew) - { - - } - -
-
-
-
- - -
- - - -
- - -
- - @if (_dependencies is not null) - { -
- Inferred reads - @if (_dependencies.Reads.Count == 0) { none } - else - { -
    - @foreach (var r in _dependencies.Reads) {
  • @r
  • } -
- } - Inferred writes - @if (_dependencies.Writes.Count == 0) { none } - else - { -
    - @foreach (var w in _dependencies.Writes) {
  • @w
  • } -
- } - @if (_dependencies.Rejections.Count > 0) - { -
- Non-literal paths rejected: -
    - @foreach (var r in _dependencies.Rejections) {
  • @r.Message
  • } -
-
- } -
- } - - @if (_testResult is not null) - { -
- Harness result: @_testResult.Outcome - @if (_testResult.Outcome == ScriptTestOutcome.Success) - { -
Output: @(_testResult.Output?.ToString() ?? "null")
- @if (_testResult.Writes.Count > 0) - { -
Writes: -
    - @foreach (var kv in _testResult.Writes) {
  • @kv.Key = @(kv.Value?.ToString() ?? "null")
  • } -
-
- } - } - @if (_testResult.Errors.Count > 0) - { -
- @foreach (var e in _testResult.Errors) {
@e
} -
- } - @if (_testResult.LogEvents.Count > 0) - { -
Script log output: -
    - @foreach (var e in _testResult.LogEvents) {
  • [@e.Level] @e.RenderMessage()
  • } -
-
- } -
- } -
-
- } -
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private bool _loading = true; - private bool _busy; - private bool _harnessBusy; - private bool _isNew; - private List