Paste an address string and watch the parser break it down field by field. Useful for
sanity-checking a tag spreadsheet row before adding it to a driver's DriverConfig.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor
index fee2944..1d33eba 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor
@@ -13,7 +13,9 @@
Modbus diagnostics — @DriverInstanceId
-
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
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor
index f10d4c0..852f556 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor
@@ -15,9 +15,8 @@
@inject AdminHubConnectionFactory HubFactory
@implements IAsyncDisposable
-
+
+
LDAP group → Admin role grants
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