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 <lg viewports
- Add Components/Shared/ with LoadingSpinner, ToastNotification, StatusBadge
- Replace .page-title with flex + <h4 class="mb-0"> across 20 pages
- Convert NewCluster + IdentificationFields forms to card + h6 subsection pattern
This commit is contained in:
Joseph Doherty
2026-05-26 01:12:57 -04:00
parent c6082aa0b9
commit 866dc03fac
29 changed files with 374 additions and 144 deletions

View File

@@ -9,8 +9,8 @@
@* Admin-010: Bootstrap 5 is vendored under wwwroot/lib/bootstrap/ per admin-ui.md @* 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. *@ "Tech Stack" — no public-CDN dependency so air-gapped fleet deployments work. *@
<link rel="stylesheet" href="lib/bootstrap/css/bootstrap.min.css"/> <link rel="stylesheet" href="lib/bootstrap/css/bootstrap.min.css"/>
<link rel="stylesheet" href="theme.css"/> <link rel="stylesheet" href="css/theme.css"/>
<link rel="stylesheet" href="app.css"/> <link rel="stylesheet" href="css/site.css"/>
<HeadOutlet/> <HeadOutlet/>
</head> </head>
<body> <body>

View File

@@ -20,7 +20,20 @@
</AuthorizeView> </AuthorizeView>
</header> </header>
<div class="app-shell"> <div class="app-shell d-flex flex-column flex-lg-row">
@* Hamburger toggle: visible only on viewports <lg.
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-collapse"
aria-controls="sidebar-collapse"
aria-expanded="false"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="collapse d-lg-block" id="sidebar-collapse">
<nav class="side-rail"> <nav class="side-rail">
<div class="rail-eyebrow">Navigation</div> <div class="rail-eyebrow">Navigation</div>
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink> <NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
@@ -56,6 +69,7 @@
</AuthorizeView> </AuthorizeView>
</div> </div>
</nav> </nav>
</div>
<main class="page"> <main class="page">
@Body @Body

View File

@@ -4,7 +4,9 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Security @using ZB.MOM.WW.OtOpcUa.Admin.Security
@using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Admin.Services
<h1 class="page-title">My account</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">My account</h4>
</div>
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>

View File

@@ -6,7 +6,9 @@
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@inject HistorianDiagnosticsService Diag @inject HistorianDiagnosticsService Diag
<h1 class="page-title">Alarm historian</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alarm historian</h4>
</div>
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p> <p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
<section class="agg-grid rise" style="animation-delay:.02s"> <section class="agg-grid rise" style="animation-delay:.02s">

View File

@@ -7,7 +7,9 @@
@inject AuthenticationStateProvider AuthState @inject AuthenticationStateProvider AuthState
@inject ILogger<Certificates> Log @inject ILogger<Certificates> Log
<h1 class="page-title">Certificate trust</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Certificate trust</h4>
</div>
<section class="panel notice rise" style="animation-delay:.02s"> <section class="panel notice rise" style="animation-delay:.02s">
PKI store root <span class="mono">@Certs.PkiStoreRoot</span>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake. PKI store root <span class="mono">@Certs.PkiStoreRoot</span>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake.

View File

@@ -44,7 +44,7 @@ else
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="page-title mb-0">@_cluster.Name</h1> <h4 class="mb-0">@_cluster.Name</h4>
<span class="mono text-muted">@_cluster.ClusterId</span> <span class="mono text-muted">@_cluster.ClusterId</span>
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> } @if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
</div> </div>

View File

@@ -4,8 +4,8 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject ClusterService ClusterSvc @inject ClusterService ClusterSvc
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="page-title">Clusters</h1> <h4 class="mb-0">Clusters</h4>
<a href="/clusters/new" class="btn btn-primary">New cluster</a> <a href="/clusters/new" class="btn btn-primary">New cluster</a>
</div> </div>

View File

@@ -16,7 +16,7 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="page-title mb-0">Draft diff</h1> <h4 class="mb-0">Draft diff</h4>
<small class="text-muted"> <small class="text-muted">
Cluster <span class="mono">@ClusterId</span> — from last published (@(_fromLabel)) → to draft @GenerationId Cluster <span class="mono">@ClusterId</span> — from last published (@(_fromLabel)) → to draft @GenerationId
</small> </small>

View File

@@ -20,7 +20,7 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="page-title mb-0">Draft editor</h1> <h4 class="mb-0">Draft editor</h4>
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · generation @GenerationId</small> <small class="text-muted">Cluster <span class="mono">@ClusterId</span> · generation @GenerationId</small>
</div> </div>
<div> <div>

View File

@@ -4,43 +4,45 @@
nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's 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. *@ create + edit forms so the same UI renders regardless of which flow opened it. *@
<div class="panel-head mt-4">OPC 40010 Identification</div> <div class="card mb-3">
<div class="row g-3"> <div class="card-body">
<div class="col-md-4"> <h6 class="text-muted border-bottom pb-1">OPC 40010 Identification</h6>
<label class="form-label">Manufacturer</label> <div class="mb-2">
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control"/> <label class="form-label small">Manufacturer</label>
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="mb-2">
<label class="form-label">Model</label> <label class="form-label small">Model</label>
<InputText @bind-Value="Equipment!.Model" class="form-control"/> <InputText @bind-Value="Equipment!.Model" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="mb-2">
<label class="form-label">Serial number</label> <label class="form-label small">Serial number</label>
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control"/> <InputText @bind-Value="Equipment!.SerialNumber" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="mb-2">
<label class="form-label">Hardware rev</label> <label class="form-label small">Hardware rev</label>
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control"/> <InputText @bind-Value="Equipment!.HardwareRevision" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="mb-2">
<label class="form-label">Software rev</label> <label class="form-label small">Software rev</label>
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control"/> <InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="mb-2">
<label class="form-label">Year of construction</label> <label class="form-label small">Year of construction</label>
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control"/> <InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="mb-2">
<label class="form-label">Asset location</label> <label class="form-label small">Asset location</label>
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control"/> <InputText @bind-Value="Equipment!.AssetLocation" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="mb-2">
<label class="form-label">Manufacturer URI</label> <label class="form-label small">Manufacturer URI</label>
<InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control" placeholder="https://"/> <InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control form-control-sm" placeholder="https://&hellip;"/>
</div>
<div class="mb-2">
<label class="form-label small">Device manual URI</label>
<InputText @bind-Value="Equipment!.DeviceManualUri" class="form-control form-control-sm" placeholder="https://&hellip;"/>
</div> </div>
<div class="col-md-4">
<label class="form-label">Device manual URI</label>
<InputText @bind-Value="Equipment!.DeviceManualUri" class="form-control" placeholder="https://…"/>
</div> </div>
</div> </div>

View File

@@ -23,7 +23,7 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="page-title mb-0">Equipment CSV import</h1> <h4 class="mb-0">Equipment CSV import</h4>
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · draft generation @GenerationId</small> <small class="text-muted">Cluster <span class="mono">@ClusterId</span> · draft generation @GenerationId</small>
</div> </div>
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a> <a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>

View File

@@ -15,49 +15,58 @@
@inject GenerationService GenerationSvc @inject GenerationService GenerationSvc
@inject NavigationManager Nav @inject NavigationManager Nav
<h1 class="page-title mb-4">New cluster</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">New cluster</h4>
</div>
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster"> <EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
<DataAnnotationsValidator/> <DataAnnotationsValidator/>
<div class="row g-3"> <div class="card mb-3">
<div class="col-md-6"> <div class="card-body">
<label class="form-label">ClusterId <span class="text-danger">*</span></label> <h6 class="text-muted border-bottom pb-1">Identity</h6>
<InputText @bind-Value="_input.ClusterId" class="form-control"/> <div class="mb-2">
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.</div> <label class="form-label small">ClusterId <span class="text-danger">*</span></label>
<InputText @bind-Value="_input.ClusterId" class="form-control form-control-sm"/>
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; &le; 64 chars.</div>
<ValidationMessage For="() => _input.ClusterId"/> <ValidationMessage For="() => _input.ClusterId"/>
</div> </div>
<div class="col-md-6"> <div class="mb-3">
<label class="form-label">Display name <span class="text-danger">*</span></label> <label class="form-label small">Display name <span class="text-danger">*</span></label>
<InputText @bind-Value="_input.Name" class="form-control"/> <InputText @bind-Value="_input.Name" class="form-control form-control-sm"/>
<ValidationMessage For="() => _input.Name"/> <ValidationMessage For="() => _input.Name"/>
</div> </div>
<div class="col-md-4">
<label class="form-label">Enterprise</label> <h6 class="text-muted border-bottom pb-1">Placement</h6>
<InputText @bind-Value="_input.Enterprise" class="form-control"/> <div class="mb-2">
<label class="form-label small">Enterprise</label>
<InputText @bind-Value="_input.Enterprise" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="mb-3">
<label class="form-label">Site</label> <label class="form-label small">Site</label>
<InputText @bind-Value="_input.Site" class="form-control"/> <InputText @bind-Value="_input.Site" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4">
<label class="form-label">Redundancy</label> <h6 class="text-muted border-bottom pb-1">Topology</h6>
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select"> <div class="mb-2">
<label class="form-label small">Redundancy</label>
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select form-select-sm">
<option value="@RedundancyMode.None">None (single node)</option> <option value="@RedundancyMode.None">None (single node)</option>
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option> <option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option> <option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
</InputSelect> </InputSelect>
</div> </div>
</div>
@if (!string.IsNullOrEmpty(_error)) @if (!string.IsNullOrEmpty(_error))
{ {
<section class="panel notice mt-3">@_error</section> <div class="text-danger small mt-2">@_error</div>
} }
<div class="mt-4"> <div class="mt-3">
<button type="submit" class="btn btn-primary" disabled="@_submitting">Create cluster</button> <button type="submit" class="btn btn-success btn-sm me-1" disabled="@_submitting">Save</button>
<a href="/clusters" class="btn btn-secondary ms-2">Cancel</a> <a href="/clusters" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
</div>
</div> </div>
</EditForm> </EditForm>

View File

@@ -3,7 +3,9 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Admin.Services
@inject FocasDriverDetailService DetailSvc @inject FocasDriverDetailService DetailSvc
<h1 class="page-title">FOCAS driver <span class="mono">@InstanceId</span></h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">FOCAS driver <span class="mono">@InstanceId</span></h4>
</div>
@if (_loading) @if (_loading)
{ {

View File

@@ -8,7 +8,9 @@
@inject IServiceScopeFactory ScopeFactory @inject IServiceScopeFactory ScopeFactory
@implements IDisposable @implements IDisposable
<h1 class="page-title">Fleet status</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Fleet status</h4>
</div>
<div class="d-flex align-items-center mb-3 gap-2"> <div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing"> <button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">

View File

@@ -8,7 +8,9 @@
@inject GenerationService GenerationSvc @inject GenerationService GenerationSvc
@inject NavigationManager Nav @inject NavigationManager Nav
<h1 class="page-title">Fleet overview</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Fleet overview</h4>
</div>
@if (_clusters is null) @if (_clusters is null)
{ {

View File

@@ -12,7 +12,9 @@
@inject AdminHubConnectionFactory HubFactory @inject AdminHubConnectionFactory HubFactory
@implements IAsyncDisposable @implements IAsyncDisposable
<h1 class="page-title">Driver host status</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Driver host status</h4>
</div>
<div class="d-flex align-items-center mb-3 gap-2"> <div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing"> <button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">

View File

@@ -17,7 +17,9 @@
<PageTitle>Modbus address preview</PageTitle> <PageTitle>Modbus address preview</PageTitle>
<h1 class="page-title">Modbus address preview</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Modbus address preview</h4>
</div>
<p class="text-muted"> <p class="text-muted">
Paste an address string and watch the parser break it down field by field. Useful for 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 <span class="mono">DriverConfig</span>. sanity-checking a tag spreadsheet row before adding it to a driver's <span class="mono">DriverConfig</span>.

View File

@@ -13,7 +13,9 @@
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle> <PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
<h1 class="page-title">Modbus auto-prohibitions</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Modbus auto-prohibitions</h4>
</div>
<p class="text-muted"> <p class="text-muted">
Driver instance <span class="mono">@DriverInstanceId</span>. Live snapshot of coalesced ranges Driver instance <span class="mono">@DriverInstanceId</span>. Live snapshot of coalesced ranges
the planner has learned to read individually (#148 / #150 / #151 / #152). the planner has learned to read individually (#148 / #150 / #151 / #152).

View File

@@ -9,7 +9,9 @@
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@inject ReservationService ReservationSvc @inject ReservationService ReservationSvc
<h1 class="page-title">External-ID reservations</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">External-ID reservations</h4>
</div>
<p class="text-muted"> <p class="text-muted">
Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a 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 FleetAdmin-only audit-logged action — only release when the physical asset is permanently

View File

@@ -15,9 +15,8 @@
@inject AdminHubConnectionFactory HubFactory @inject AdminHubConnectionFactory HubFactory
@implements IAsyncDisposable @implements IAsyncDisposable
<h1 class="page-title">LDAP group → Admin role grants</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">LDAP group &rarr; Admin role grants</h4>
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button> <button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
</div> </div>

View File

@@ -8,7 +8,9 @@
@inject AdminHubConnectionFactory HubFactory @inject AdminHubConnectionFactory HubFactory
@implements IAsyncDisposable @implements IAsyncDisposable
<h1 class="page-title">Script log viewer</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Script log viewer</h4>
</div>
<p class="text-muted"> <p class="text-muted">
Live tail of the <code class="mono">scripts-*.log</code> file produced by the OPC UA Server's Live tail of the <code class="mono">scripts-*.log</code> file produced by the OPC UA Server's
Roslyn script runtime. Useful for diagnosing virtual-tag and scripted-alarm script errors in production. Roslyn script runtime. Useful for diagnosing virtual-tag and scripted-alarm script errors in production.

View File

@@ -10,7 +10,9 @@
@inject ClusterService ClusterSvc @inject ClusterService ClusterSvc
@inject NavigationManager Nav @inject NavigationManager Nav
<h1 class="page-title">Scripted Alarms</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Scripted Alarms</h4>
</div>
<p class="text-muted"> <p class="text-muted">
OPC UA Part 9 alarms raised by C# predicate scripts. To author scripted alarms, open a cluster OPC UA Part 9 alarms raised by C# predicate scripts. To author scripted alarms, open a cluster
draft and use the <strong>Scripted Alarms</strong> tab in the draft editor. draft and use the <strong>Scripted Alarms</strong> tab in the draft editor.

View File

@@ -9,7 +9,9 @@
@inject ClusterService ClusterSvc @inject ClusterService ClusterSvc
@inject NavigationManager Nav @inject NavigationManager Nav
<h1 class="page-title">Virtual Tags</h1> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Virtual Tags</h4>
</div>
<p class="text-muted"> <p class="text-muted">
Computed tags driven by C# scripts. To author virtual tags, open a cluster draft and use the Computed tags driven by C# scripts. To author virtual tags, open a cluster draft and use the
<strong>Virtual Tags</strong> tab in the draft editor. <strong>Virtual Tags</strong> tab in the draft editor.

View File

@@ -0,0 +1,17 @@
@* Reusable loading spinner *@
@if (IsLoading)
{
<div class="d-flex align-items-center text-secondary @CssClass">
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<span>@Message</span>
</div>
}
@code {
[Parameter] public bool IsLoading { get; set; }
[Parameter] public string Message { get; set; } = "Loading...";
[Parameter] public string CssClass { get; set; } = "";
}

View File

@@ -0,0 +1,7 @@
@* Status chip — wraps the theme.css .chip / .chip-ok / .chip-warn / .chip-bad / .chip-idle classes. *@
<span class="chip @CssClass">@Text</span>
@code {
[Parameter] public string Text { get; set; } = "";
[Parameter] public string CssClass { get; set; } = "chip-idle";
}

View File

@@ -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
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1090;" aria-live="polite" aria-atomic="true">
@foreach (var toast in _toasts)
{
<div class="toast show mb-2" role="alert">
<div class="toast-header @GetHeaderClass(toast.Type)">
<strong class="me-auto">@toast.Title</strong>
<button type="button" class="btn-close btn-close-white" @onclick="() => Dismiss(toast)"></button>
</div>
<div class="toast-body">@toast.Message</div>
</div>
}
</div>
@code {
private const int DefaultAutoDismissMs = 5000;
private readonly List<ToastItem> _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();
/// <summary>Number of toasts currently displayed.</summary>
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);
}
/// <summary>
/// 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 <see cref="ObjectDisposedException"/> escapes.
/// </summary>
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; }
}
}

View File

@@ -12,4 +12,5 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout @using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages @using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters @using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Shared
@using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Admin.Services

View File

@@ -2,8 +2,10 @@
Tokens live in theme.css; this sheet only carries layout + the side rail. */ Tokens live in theme.css; this sheet only carries layout + the side rail. */
/* ── App shell: side rail + page ─────────────────────────────────────────── */ /* ── 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 <lg viewports and the rail sits beside the page on lg+. */
.app-shell { .app-shell {
display: flex;
align-items: stretch; align-items: stretch;
min-height: calc(100vh - 3.3rem); min-height: calc(100vh - 3.3rem);
} }
@@ -15,8 +17,8 @@
/* ── Side rail ───────────────────────────────────────────────────────────── */ /* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail { .side-rail {
width: 218px; width: 220px;
flex: 0 0 218px; flex: 0 0 220px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.15rem; gap: 0.15rem;
@@ -25,6 +27,28 @@
border-right: 1px solid var(--rule-strong); border-right: 1px solid var(--rule-strong);
} }
/* On lg+ keep the side rail pinned so it stays visible when content scrolls. */
@media (min-width: 992px) {
#sidebar-collapse {
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
z-index: 1020;
}
}
/* When the side rail is collapsed under <lg viewports the Bootstrap collapse
container removes the fixed width; restore full width on mobile. */
@media (max-width: 991.98px) {
.side-rail {
width: 100%;
min-width: 100%;
max-width: 100%;
height: auto;
}
}
.rail-eyebrow { .rail-eyebrow {
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 600; font-weight: 600;
@@ -90,14 +114,6 @@
text-decoration: none; text-decoration: none;
} }
/* ── Page headings — uppercase eyebrow, calm spacing ─────────────────────── */
.page-title {
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 1rem;
color: var(--ink);
}
/* ── Login card centring ─────────────────────────────────────────────────── */ /* ── Login card centring ─────────────────────────────────────────────────── */
.login-wrap { .login-wrap {
max-width: 380px; max-width: 380px;