From 866dc03fac9b036dcdb46058fc1d8e5bf78e22ad Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 01:12:57 -0400 Subject: [PATCH] style(ui): align admin styling with ScadaLink master conventions - Move CSS into wwwroot/css/ (theme.css, site.css); sidebar 218 -> 220px - Add hamburger + Bootstrap collapse for across 20 pages - Convert NewCluster + IdentificationFields forms to card + h6 subsection pattern --- .../Components/App.razor | 4 +- .../Components/Layout/MainLayout.razor | 84 ++++++----- .../Components/Pages/Account.razor | 4 +- .../Components/Pages/AlarmsHistorian.razor | 4 +- .../Components/Pages/Certificates.razor | 4 +- .../Pages/Clusters/ClusterDetail.razor | 2 +- .../Pages/Clusters/ClustersList.razor | 4 +- .../Pages/Clusters/DiffViewer.razor | 2 +- .../Pages/Clusters/DraftEditor.razor | 2 +- .../Pages/Clusters/IdentificationFields.razor | 76 +++++----- .../Pages/Clusters/ImportEquipment.razor | 2 +- .../Pages/Clusters/NewCluster.razor | 83 ++++++----- .../Pages/Drivers/FocasDetail.razor | 4 +- .../Components/Pages/Fleet.razor | 4 +- .../Components/Pages/Home.razor | 4 +- .../Components/Pages/Hosts.razor | 4 +- .../Pages/Modbus/ModbusAddressPreview.razor | 4 +- .../Pages/Modbus/ModbusDiagnostics.razor | 4 +- .../Components/Pages/Reservations.razor | 4 +- .../Components/Pages/RoleGrants.razor | 5 +- .../Components/Pages/ScriptLog.razor | 4 +- .../Components/Pages/ScriptedAlarms.razor | 4 +- .../Components/Pages/VirtualTags.razor | 4 +- .../Components/Shared/LoadingSpinner.razor | 17 +++ .../Components/Shared/StatusBadge.razor | 7 + .../Components/Shared/ToastNotification.razor | 139 ++++++++++++++++++ .../Components/_Imports.razor | 1 + .../wwwroot/{app.css => css/site.css} | 38 +++-- .../wwwroot/{ => css}/theme.css | 0 29 files changed, 374 insertions(+), 144 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/LoadingSpinner.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/StatusBadge.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/ToastNotification.razor rename src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/{app.css => css/site.css} (75%) rename src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/{ => css}/theme.css (100%) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor index e1fb24e..db62d4a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor @@ -9,8 +9,8 @@ @* 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/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor index 85d991c..10f394d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor @@ -20,42 +20,56 @@ -
- +
@Body 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 index b121ca7..ca8e0df 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor @@ -4,7 +4,9 @@ @using ZB.MOM.WW.OtOpcUa.Admin.Security @using ZB.MOM.WW.OtOpcUa.Admin.Services -

My account

+
+

My account

+
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 index 34b1424..09e18cb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor @@ -6,7 +6,9 @@ @rendermode RenderMode.InteractiveServer @inject HistorianDiagnosticsService Diag -

Alarm historian

+
+

Alarm historian

+

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

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 index a037bea..a2819f1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor @@ -7,7 +7,9 @@ @inject AuthenticationStateProvider AuthState @inject ILogger Log -

Certificate trust

+
+

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. 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 index 87f6e26..56c4bba 100644 --- 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 @@ -44,7 +44,7 @@ else }
-

@_cluster.Name

+

@_cluster.Name

@_cluster.ClusterId @if (!_cluster.Enabled) { Disabled }
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 index 577d5a5..f3cd96b 100644 --- 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 @@ -4,8 +4,8 @@ @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @inject ClusterService ClusterSvc -
-

Clusters

+
+

Clusters

New cluster
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 index 1c81532..8bd3aa3 100644 --- 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 @@ -16,7 +16,7 @@
-

Draft diff

+

Draft diff

Cluster @ClusterId — from last published (@(_fromLabel)) → to draft @GenerationId 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 index 444e611..87f7363 100644 --- 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 @@ -20,7 +20,7 @@
-

Draft editor

+

Draft editor

Cluster @ClusterId · generation @GenerationId
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 index 268ea6c..5a1a94c 100644 --- 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 @@ -4,43 +4,45 @@ 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
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - +
+
+
OPC 40010 Identification
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
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 index e640c8e..13d9d63 100644 --- 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 @@ -23,7 +23,7 @@
-

Equipment CSV import

+

Equipment CSV import

Cluster @ClusterId · draft generation @GenerationId
Back to draft diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor index 37aa5ea..2fee6ad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor @@ -15,49 +15,58 @@ @inject GenerationService GenerationSvc @inject NavigationManager Nav -

New cluster

+
+

New cluster

+
-
-
- - -
Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.
- -
-
- - - -
-
- - -
-
- - -
-
- - - - - - -
-
+
+
+
Identity
+
+ + +
Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.
+ +
+
+ + + +
- @if (!string.IsNullOrEmpty(_error)) - { -
@_error
- } +
Placement
+
+ + +
+
+ + +
-
- - Cancel +
Topology
+
+ + + + + + +
+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + +
+ + Cancel +
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Drivers/FocasDetail.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Drivers/FocasDetail.razor index b35266f..9263430 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Drivers/FocasDetail.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Drivers/FocasDetail.razor @@ -3,7 +3,9 @@ @using ZB.MOM.WW.OtOpcUa.Admin.Services @inject FocasDriverDetailService DetailSvc -

FOCAS driver @InstanceId

+
+

FOCAS driver @InstanceId

+
@if (_loading) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor index d5779d0..b84f74b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor @@ -8,7 +8,9 @@ @inject IServiceScopeFactory ScopeFactory @implements IDisposable -

Fleet status

+
+

Fleet status

+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptLog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptLog.razor index 324a735..dc65eca 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptLog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptLog.razor @@ -8,7 +8,9 @@ @inject AdminHubConnectionFactory HubFactory @implements IAsyncDisposable -

Script log viewer

+
+

Script log viewer

+

Live tail of the scripts-*.log file produced by the OPC UA Server's Roslyn script runtime. Useful for diagnosing virtual-tag and scripted-alarm script errors in production. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptedAlarms.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptedAlarms.razor index e84c838..768982a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptedAlarms.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptedAlarms.razor @@ -10,7 +10,9 @@ @inject ClusterService ClusterSvc @inject NavigationManager Nav -

Scripted Alarms

+
+

Scripted Alarms

+

OPC UA Part 9 alarms raised by C# predicate scripts. To author scripted alarms, open a cluster draft and use the Scripted Alarms tab in the draft editor. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/VirtualTags.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/VirtualTags.razor index e8059bf..7edea35 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/VirtualTags.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/VirtualTags.razor @@ -9,7 +9,9 @@ @inject ClusterService ClusterSvc @inject NavigationManager Nav -

Virtual Tags

+
+

Virtual Tags

+

Computed tags driven by C# scripts. To author virtual tags, open a cluster draft and use the Virtual Tags tab in the draft editor. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/LoadingSpinner.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/LoadingSpinner.razor new file mode 100644 index 0000000..59384ad --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/LoadingSpinner.razor @@ -0,0 +1,17 @@ +@* Reusable loading spinner *@ + +@if (IsLoading) +{ +

+
+ Loading... +
+ @Message +
+} + +@code { + [Parameter] public bool IsLoading { get; set; } + [Parameter] public string Message { get; set; } = "Loading..."; + [Parameter] public string CssClass { get; set; } = ""; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/StatusBadge.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/StatusBadge.razor new file mode 100644 index 0000000..55096fa --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/StatusBadge.razor @@ -0,0 +1,7 @@ +@* Status chip — wraps the theme.css .chip / .chip-ok / .chip-warn / .chip-bad / .chip-idle classes. *@ +@Text + +@code { + [Parameter] public string Text { get; set; } = ""; + [Parameter] public string CssClass { get; set; } = "chip-idle"; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/ToastNotification.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/ToastNotification.razor new file mode 100644 index 0000000..1deda1a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Shared/ToastNotification.razor @@ -0,0 +1,139 @@ +@* + Reusable toast notification component. + + Toasts intentionally float above modal dialogs so confirmation feedback + (Success/Error) is visible even while a dialog is open. +*@ +@implements IDisposable + +
+ @foreach (var toast in _toasts) + { + + } +
+ +@code { + private const int DefaultAutoDismissMs = 5000; + + private readonly List _toasts = new(); + private readonly object _lock = new(); + + // Cancels all pending auto-dismiss delays when the component is disposed + // so their continuations never touch a disposed component. + private readonly CancellationTokenSource _disposalCts = new(); + + /// Number of toasts currently displayed. + public int ToastCount + { + get { lock (_lock) { return _toasts.Count; } } + } + + public void ShowSuccess(string message, string title = "Success", int? autoDismissMs = null) + { + AddToast(title, message, ToastType.Success, autoDismissMs); + } + + public void ShowError(string message, string title = "Error", int? autoDismissMs = null) + { + AddToast(title, message, ToastType.Error, autoDismissMs); + } + + public void ShowWarning(string message, string title = "Warning", int? autoDismissMs = null) + { + AddToast(title, message, ToastType.Warning, autoDismissMs); + } + + public void ShowInfo(string message, string title = "Info", int? autoDismissMs = null) + { + AddToast(title, message, ToastType.Info, autoDismissMs); + } + + private void AddToast(string title, string message, ToastType type, int? autoDismissMs) + { + // If the component is already disposed, do not add or schedule anything. + if (_disposalCts.IsCancellationRequested) return; + + var toast = new ToastItem { Title = title, Message = message, Type = type }; + lock (_lock) + { + _toasts.Add(toast); + } + StateHasChanged(); + + var dismissMs = autoDismissMs ?? DefaultAutoDismissMs; + _ = AutoDismissAsync(toast, dismissMs, _disposalCts.Token); + } + + /// + /// Removes a toast after its dismiss delay. The delay is bound to the + /// component's disposal token: if the host page is disposed first, the + /// delay is cancelled and the continuation never touches the disposed + /// component — no escapes. + /// + private async Task AutoDismissAsync(ToastItem toast, int dismissMs, CancellationToken token) + { + try + { + await Task.Delay(dismissMs, token); + } + catch (OperationCanceledException) + { + return; + } + + if (token.IsCancellationRequested) return; + + lock (_lock) + { + _toasts.Remove(toast); + } + + try + { + await InvokeAsync(StateHasChanged); + } + catch (ObjectDisposedException) + { + // Component disposed between the token check and the render — ignore. + } + } + + private void Dismiss(ToastItem toast) + { + lock (_lock) + { + _toasts.Remove(toast); + } + } + + private static string GetHeaderClass(ToastType type) => type switch + { + ToastType.Success => "bg-success text-white", + ToastType.Error => "bg-danger text-white", + ToastType.Warning => "bg-warning text-dark", + ToastType.Info => "bg-info text-dark", + _ => "bg-secondary text-white" + }; + + public void Dispose() + { + _disposalCts.Cancel(); + _disposalCts.Dispose(); + } + + private enum ToastType { Success, Error, Warning, Info } + + private class ToastItem + { + public string Title { get; init; } = ""; + public string Message { get; init; } = ""; + public ToastType Type { get; init; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor index ba6320b..93655ad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor @@ -12,4 +12,5 @@ @using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout @using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages @using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters +@using ZB.MOM.WW.OtOpcUa.Admin.Components.Shared @using ZB.MOM.WW.OtOpcUa.Admin.Services diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/css/site.css similarity index 75% rename from src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css rename to src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/css/site.css index 43c8937..d1f485d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/css/site.css @@ -2,8 +2,10 @@ Tokens live in theme.css; this sheet only carries layout + the side rail. */ /* ── App shell: side rail + page ─────────────────────────────────────────── */ +/* The outer flex direction is supplied by Bootstrap utilities on the wrapper + (`d-flex flex-column flex-lg-row`) so the mobile hamburger row stacks above + the rail on