ui: Central UI density/consistency sweep + Theme 0.4.1

Applies the family-wide admin-UI cleanup playbook to the Central UI so the
Blazor surfaces stop diverging from the shared kit: buttons are grouped rather
than individually sized, long cell values are contained instead of widening
tables, and hard-coded colours give way to theme tokens.

The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell,
which the kit emits as an inline style on the shell root. Being a descendant of
<html>, it beat the [data-bs-theme="dark"] override for the entire app, so the
dark accent had never rendered. Declaring --accent in site.css :root instead
lets both schemes resolve; light is unchanged because the value already matched
the kit's light default.

Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so
that block is deleted here rather than duplicated. Verified byte-identical
before removal; the repo now declares no --bs-btn-* anywhere.

NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal
surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages,
SiteCallsReport) were additionally refactored from holding the selected record
to holding its id and re-resolving from the current page each render, with the
resolve doubling as the visibility gate. A background refresh that drops the
row now closes the modal instead of showing a stale snapshot. This is a
behaviour change and is called out rather than buried: a full-suite run turned
up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack
(GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler
was disposed between render and click — a window the previous field-held record
made structurally impossible. Treat the modal lifecycle here as unreviewed.

Build 0/0; suite green apart from that one intermittent failure.
This commit is contained in:
Joseph Doherty
2026-08-11 05:50:12 -04:00
parent b6f383a225
commit 9e243493fb
77 changed files with 2197 additions and 1292 deletions
@@ -162,10 +162,12 @@
</div>
<div class="col-auto ms-auto">
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="ClearFilters" data-test="filter-clear">Clear</button>
<button class="btn btn-primary btn-sm"
@onclick="Apply" data-test="filter-apply">Apply</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary"
@onclick="ClearFilters" data-test="filter-clear">Clear</button>
<button class="btn btn-primary"
@onclick="Apply" data-test="filter-apply">Apply</button>
</div>
</div>
</div>
</div>
@@ -124,7 +124,7 @@
<span data-test="status-badge-@row.EventId" class="badge @StatusBadgeClass(row.Status)">@row.Status</span>
break;
case "Target":
<span class="small">@(row.Target ?? "—")</span>
<span class="small" title="@row.Target">@TruncateTarget(row.Target)</span>
break;
case "Actor":
<span class="small">@(row.Actor ?? "—")</span>
@@ -488,6 +488,34 @@ public partial class AuditResultsGrid : IAsyncDisposable
_ => "badge bg-secondary",
};
/// <summary>
/// Caps the rendered Target text, with the full value kept on the cell's
/// <c>title</c>.
/// </summary>
/// <remarks>
/// Target is free text this app does not control (external-system URLs, SQL
/// statements, method names) and its column is UNBOUNDED until the operator
/// resizes it: the scoped stylesheet clips <c>.audit-grid-td</c> only when the
/// inline <c>--audit-col-width</c> custom property is present, and
/// <see cref="ColumnWidthStyle"/> emits nothing for a column with no persisted
/// width — so on a fresh session one long target sets the table's width.
/// The cap is applied to the STRING rather than by pinning a CSS width, because
/// a <c>max-width</c>/<c>nowrap</c> clip would raise the cell's min-content
/// width and stop a resized column from shrinking — i.e. it would break
/// drag-to-resize at exactly the widths it is used at. Truncating characters
/// leaves the cell's min-content word-based, so resize keeps working unchanged.
/// Mirrors <see cref="TruncateError"/>, this grid's existing idiom.
/// </remarks>
private static string TruncateTarget(string? target)
{
if (string.IsNullOrEmpty(target))
{
return "—";
}
const int max = 60;
return target.Length <= max ? target : string.Concat(target.AsSpan(0, max), "…");
}
private static string TruncateError(string? message)
{
if (string.IsNullOrEmpty(message))
@@ -47,17 +47,27 @@
@foreach (var match in _searchResults)
{
var node = match.Node;
@* Name (+ type badge) on the first line, the full
address-space path clipped beneath it — an OPC UA
path is long enough to push the badge off-screen
when everything sits inline. *@
<li data-test="node-search-result" class="py-1">
<a href="javascript:void(0)"
class="@(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => SelectBrowseNode(node)">
@node.DisplayName
</a>
<small class="text-muted ms-1">@match.Path</small>
@if (!string.IsNullOrEmpty(node.DataType))
{
<span class="badge bg-body-secondary text-body-secondary border ms-1">@node.DataType</span>
}
<div>
@* A button, not an href="javascript:void(0)" anchor:
this selects a node rather than navigating, and the
pseudo-href was a CSP/a11y smell. Matches the tree
label in TreeRow.razor. *@
<button type="button"
class="btn btn-link p-0 text-start align-baseline @(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => SelectBrowseNode(node)">
@node.DisplayName
</button>
@if (!string.IsNullOrEmpty(node.DataType))
{
<span class="badge bg-body-secondary text-body-secondary border ms-1">@node.DataType</span>
}
</div>
<small class="text-muted cell-clip cell-clip-lg" title="@match.Path">@match.Path</small>
</li>
}
</ul>
@@ -104,8 +114,10 @@
</div>
<div class="modal-footer">
<span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span>
<button class="btn btn-secondary" @onclick="Cancel">Cancel</button>
<button class="btn btn-primary" @onclick="Confirm" disabled="@string.IsNullOrWhiteSpace(_selectedNodeId)">Select</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
<button class="btn btn-primary" @onclick="Confirm" disabled="@string.IsNullOrWhiteSpace(_selectedNodeId)">Select</button>
</div>
</div>
</div>
</div>
@@ -59,7 +59,14 @@
<tr>
<td class="small">@row.AttributeName</td>
<td class="small text-muted">@row.ConnectionName</td>
<td class="small font-monospace text-break">@row.EffectiveTagPath</td>
@* Tag path, live tag value and adapter error are all
unbounded (OPC UA node ids, string-typed PLC tags,
remote exception text). Clip/clamp them so one wide
row can't stretch the dialog's table; the title
attribute keeps the full text reachable. *@
<td class="small font-monospace">
<span class="cell-clip" title="@row.EffectiveTagPath">@row.EffectiveTagPath</span>
</td>
<td class="small font-monospace">
@if (_loading)
{
@@ -71,12 +78,13 @@
}
else if (outcome.Success)
{
@FormatValue(outcome.Value)
var formatted = FormatValue(outcome.Value);
<span class="cell-clip" title="@formatted">@formatted</span>
}
else
{
<span class="text-muted">—</span>
<div class="text-danger small">error: @outcome.ErrorMessage</div>
<div class="text-danger small cell-clamp-2" title="@outcome.ErrorMessage">error: @outcome.ErrorMessage</div>
}
</td>
<td class="small">
@@ -13,16 +13,26 @@
@if (Node.NodeClass == BrowseNodeClass.Variable)
{
<a href="javascript:void(0)"
class="@(Node.NodeId == SelectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => OnSelect.InvokeAsync(Node)"
@ondblclick="() => OnSelect.InvokeAsync(Node)">
@Node.DisplayName <small class="text-muted">(@Node.NodeId)</small>
</a>
@if (!string.IsNullOrEmpty(Node.DataType))
{
<span class="badge bg-body-secondary text-body-secondary border ms-1" data-test="node-type">@Node.DataType</span>
}
@* Display name (+ type badge) on the first line, the node id clipped
beneath it: an OPC UA node id runs 60120 characters and inline it
pushed the badge out of view on every deep node. The picker label
stays visually a link — it is a selection affordance, not an action —
but is a real <button> so there is no javascript: URL to reason about. *@
<span class="d-inline-block align-top">
<span class="d-block">
<button type="button"
class="btn btn-link p-0 text-start @(Node.NodeId == SelectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => OnSelect.InvokeAsync(Node)"
@ondblclick="() => OnSelect.InvokeAsync(Node)">
@Node.DisplayName
</button>
@if (!string.IsNullOrEmpty(Node.DataType))
{
<span class="badge bg-body-secondary text-body-secondary border ms-1" data-test="node-type">@Node.DataType</span>
}
</span>
<span class="font-monospace small text-muted cell-clip cell-clip-lg" title="@Node.NodeId">@Node.NodeId</span>
</span>
}
else
{
@@ -44,7 +54,7 @@
@if (!string.IsNullOrEmpty(Node.ContinuationToken))
{
<li>
<button class="btn btn-sm btn-link p-0" data-test="node-load-more"
<button class="btn btn-sm btn-outline-secondary" data-test="node-load-more"
@onclick="() => OnLoadMore.InvokeAsync(Node)" disabled="@Node.Loading">
@(Node.Loading ? "Loading…" : "Load more")
</button>
@@ -5,70 +5,78 @@
<div class="mxgateway-endpoint-editor">
<h6 class="text-muted border-bottom pb-1">@Title</h6>
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Gateway endpoint</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.Endpoint"
placeholder="http://host:5000" />
@RenderFieldError("Endpoint")
</div>
<div class="col-md-5">
<label class="form-label small">API key</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.ApiKey"
placeholder="gateway API key" />
@RenderFieldError("ApiKey")
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-5">
<label class="form-label small">Client name</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.ClientName"
placeholder="(defaults to scadabridge)" />
</div>
<div class="col-md-3">
<label class="form-label small">Write user id</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.WriteUserId" min="0" />
</div>
<div class="col-md-4">
<label class="form-label small">Read timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.ReadTimeoutMs" min="1" />
@RenderFieldError("ReadTimeoutMs")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Transport security</div>
<div class="row g-2 mb-2">
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-usetls")"
@bind="Config.UseTls" />
<label class="form-check-label small"
for="@($"{IdPrefix}-usetls")">Use TLS</label>
@* Groups get a real boundary (fieldset + legend) rather than an unstyled muted
caption. Fieldsets — not nested cards — because this editor is embedded inside
DataConnectionForm's card; same idiom as TransportExport. *@
<fieldset class="mb-3">
<legend class="h6">Connection</legend>
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Gateway endpoint</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.Endpoint"
placeholder="http://host:5000" />
@RenderFieldError("Endpoint")
</div>
<div class="col-md-5">
<label class="form-label small">API key</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.ApiKey"
placeholder="gateway API key" />
@RenderFieldError("ApiKey")
</div>
</div>
@if (Config.UseTls)
{
<div class="col-md-6">
<label class="form-label small">CA certificate path</label>
<div class="row g-2 mb-2">
<div class="col-md-5">
<label class="form-label small">Client name</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.CaFile"
placeholder="/etc/scadabridge/pki/gateway-ca.pem" />
@bind="Config.ClientName"
placeholder="(defaults to scadabridge)" />
</div>
<div class="col-md-3">
<label class="form-label small">Write user id</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.WriteUserId" min="0" />
</div>
<div class="col-md-4">
<label class="form-label small">Server name override</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.ServerName"
placeholder="gateway.example.local" />
<label class="form-label small">Read timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.ReadTimeoutMs" min="1" />
@RenderFieldError("ReadTimeoutMs")
</div>
}
</div>
</div>
</fieldset>
<fieldset class="mb-0">
<legend class="h6">Transport security</legend>
<div class="row g-2 mb-2">
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-usetls")"
@bind="Config.UseTls" />
<label class="form-check-label small"
for="@($"{IdPrefix}-usetls")">Use TLS</label>
</div>
</div>
@if (Config.UseTls)
{
<div class="col-md-6">
<label class="form-label small">CA certificate path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.CaFile"
placeholder="/etc/scadabridge/pki/gateway-ca.pem" />
</div>
<div class="col-md-4">
<label class="form-label small">Server name override</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.ServerName"
placeholder="gateway.example.local" />
</div>
}
</div>
</fieldset>
</div>
@code {
@@ -82,8 +90,10 @@
var match = Errors?.Errors.FirstOrDefault(e =>
e.EntityName != null
&& (e.EntityName == field || e.EntityName.EndsWith("." + field)));
// Validator text can carry raw, unbounded detail — clamp it and keep the full
// message reachable via the title so it cannot reflow the surrounding row.
return match is null
? null
: @<div class="text-danger small">@match.Message</div>;
: @<div class="text-danger small cell-clamp-2" title="@match.Message">@match.Message</div>;
}
}
@@ -18,328 +18,357 @@
</div>
}
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Endpoint URL</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.EndpointUrl"
placeholder="opc.tcp://host:4840" />
@RenderFieldError("EndpointUrl")
</div>
<div class="col-md-3">
<label class="form-label small">Security Mode</label>
<select class="form-select form-select-sm" @bind="Config.SecurityMode">
<option value="@OpcUaSecurityMode.None">None</option>
<option value="@OpcUaSecurityMode.Sign">Sign</option>
<option value="@OpcUaSecurityMode.SignAndEncrypt">Sign &amp; Encrypt</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-autoaccept")"
@bind="Config.AutoAcceptUntrustedCerts" />
<label class="form-check-label small"
for="@($"{IdPrefix}-autoaccept")">Auto-accept certs</label>
</div>
</div>
</div>
<div class="mb-2">
<button type="button" class="btn btn-outline-primary btn-sm"
data-test="verify-endpoint-btn"
disabled="@_verifying"
@onclick="VerifyEndpoint">
@if (_verifying)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Verifying…</span>
}
else
{
<span>Verify endpoint</span>
}
</button>
@if (_verifyResult is { } result)
{
@if (result.Success)
{
<span class="text-success small ms-2" data-test="verify-success">
&#10003; Endpoint reachable
</span>
}
else
{
<span class="text-danger small ms-2" data-test="verify-failure">
@result.FailureKind: @result.Error
</span>
}
@if (result.FailureKind == VerifyFailureKind.UntrustedCertificate
&& result.Cert is { } cert)
{
<div class="border rounded bg-body-secondary p-2 mt-2 small" data-test="verify-cert-panel">
<div class="text-muted mb-1">Untrusted server certificate</div>
<dl class="row mb-1 small">
<dt class="col-sm-3">Subject</dt>
<dd class="col-sm-9"><code>@cert.Subject</code></dd>
<dt class="col-sm-3">Issuer</dt>
<dd class="col-sm-9"><code>@cert.Issuer</code></dd>
<dt class="col-sm-3">Thumbprint</dt>
<dd class="col-sm-9"><code>@cert.Thumbprint</code></dd>
<dt class="col-sm-3">Not before</dt>
<dd class="col-sm-9">@cert.NotBeforeUtc.ToString("u")</dd>
<dt class="col-sm-3">Not after</dt>
<dd class="col-sm-9">@cert.NotAfterUtc.ToString("u")</dd>
</dl>
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized>
<button type="button" class="btn btn-outline-warning btn-sm mt-1"
data-test="trust-cert-btn"
disabled="@_trusting"
@onclick="() => TrustCert(cert)">
@if (_trusting)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Trusting…</span>
}
else
{
<span>Trust certificate</span>
}
</button>
<div class="text-muted fst-italic mt-1">
Trusting adds this certificate to every node of the site's
trusted-peer store (node-wide), then re-runs Verify.
</div>
</Authorized>
<NotAuthorized>
<div class="text-muted fst-italic">
An Administrator must trust this certificate (cert management).
</div>
</NotAuthorized>
</AuthorizeView>
@if (_trustError is { } trustError)
{
<div class="text-danger small mt-1" data-test="trust-cert-error">@trustError</div>
}
@if (_trustSucceeded)
{
<div class="text-success small mt-1" data-test="trust-cert-success">
&#10003; Certificate trusted.
</div>
}
</div>
}
}
</div>
<div class="text-muted small mt-2 mb-1">Authentication</div>
@if (Config.UserIdentity is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableAuthentication">Enable Authentication</button>
}
else
{
@* Each settings group gets a real boundary (fieldset + legend) rather than an
unstyled muted caption. Fieldsets — not nested cards — because this editor is
embedded inside DataConnectionForm's card; same idiom as TransportExport. *@
<fieldset class="mb-3">
<legend class="h6">Connection</legend>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Token type</label>
<select class="form-select form-select-sm" @bind="Config.UserIdentity.TokenType">
<option value="@OpcUaUserTokenType.Anonymous">Anonymous</option>
<option value="@OpcUaUserTokenType.UsernamePassword">Username / Password</option>
<option value="@OpcUaUserTokenType.X509Certificate">X.509 Certificate</option>
</select>
</div>
@if (Config.UserIdentity.TokenType == OpcUaUserTokenType.UsernamePassword)
{
<div class="col-md-3">
<label class="form-label small">Username</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.UserIdentity.Username" />
@RenderFieldError("UserIdentity.Username")
</div>
<div class="col-md-3">
<label class="form-label small">Password</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.UserIdentity.Password" />
</div>
}
else if (Config.UserIdentity.TokenType == OpcUaUserTokenType.X509Certificate)
{
<div class="col-md-4">
<label class="form-label small">Certificate path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.UserIdentity.CertificatePath"
placeholder="/etc/scadabridge/pki/client.pfx" />
@RenderFieldError("UserIdentity.CertificatePath")
</div>
<div class="col-md-3">
<label class="form-label small">Certificate password</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.UserIdentity.CertificatePassword" />
</div>
}
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.UserIdentity = null">
Remove Authentication
</button>
</div>
</div>
}
<div class="text-muted small mt-2 mb-1">Timing</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Session timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SessionTimeoutMs" min="1" />
@RenderFieldError("SessionTimeoutMs")
</div>
<div class="col-md-3">
<label class="form-label small">Operation timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.OperationTimeoutMs" min="1" />
@RenderFieldError("OperationTimeoutMs")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Subscription</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Publishing interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.PublishingIntervalMs" min="1" />
@RenderFieldError("PublishingIntervalMs")
</div>
<div class="col-md-3">
<label class="form-label small">Sampling interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SamplingIntervalMs" min="1" />
@RenderFieldError("SamplingIntervalMs")
</div>
<div class="col-md-2">
<label class="form-label small">Queue size</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.QueueSize" min="1" />
@RenderFieldError("QueueSize")
</div>
<div class="col-md-2">
<label class="form-label small">Keep-alive count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.KeepAliveCount" min="1" />
@RenderFieldError("KeepAliveCount")
</div>
<div class="col-md-2">
<label class="form-label small">Lifetime count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.LifetimeCount" min="1" />
@RenderFieldError("LifetimeCount")
</div>
<div class="col-md-3">
<label class="form-label small">Max notifications / publish</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.MaxNotificationsPerPublish" min="1" />
@RenderFieldError("MaxNotificationsPerPublish")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Advanced subscription</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Subscription display name</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.SubscriptionDisplayName" />
@RenderFieldError("SubscriptionDisplayName")
</div>
<div class="col-md-2">
<label class="form-label small">Subscription priority</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SubscriptionPriority" min="0" max="255" />
</div>
<div class="col-md-3">
<label class="form-label small">Timestamps to return</label>
<select class="form-select form-select-sm" @bind="Config.TimestampsToReturn">
<option value="@OpcUaTimestampsToReturn.Source">Source</option>
<option value="@OpcUaTimestampsToReturn.Server">Server</option>
<option value="@OpcUaTimestampsToReturn.Both">Both</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-discardoldest")"
@bind="Config.DiscardOldest" />
<label class="form-check-label small"
for="@($"{IdPrefix}-discardoldest")">Discard oldest</label>
</div>
</div>
</div>
<div class="text-muted small mt-2 mb-1">Deadband filter</div>
@if (Config.Deadband is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableDeadband">Enable Deadband</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" @bind="Config.Deadband.Type">
<option value="@OpcUaDeadbandType.Absolute">Absolute</option>
<option value="@OpcUaDeadbandType.Percent">Percent</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Value</label>
<input type="number" step="0.01" class="form-control form-control-sm"
@bind="Config.Deadband.Value" min="0" />
@RenderFieldError("Deadband.Value")
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.Deadband = null">
Remove Deadband
</button>
</div>
</div>
}
<div class="text-muted small mt-2 mb-1">Heartbeat</div>
@if (Config.Heartbeat is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableHeartbeat">Enable Heartbeat</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Tag path</label>
<div class="col-md-7">
<label class="form-label small">Endpoint URL</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.Heartbeat.TagPath"
placeholder="Sensors.Heartbeat" />
@RenderFieldError("Heartbeat.TagPath")
@bind="Config.EndpointUrl"
placeholder="opc.tcp://host:4840" />
@RenderFieldError("EndpointUrl")
</div>
<div class="col-md-3">
<label class="form-label small">Max silence (s)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.Heartbeat.MaxSilenceSeconds" min="1" />
@RenderFieldError("Heartbeat.MaxSilenceSeconds")
<label class="form-label small">Security Mode</label>
<select class="form-select form-select-sm" @bind="Config.SecurityMode">
<option value="@OpcUaSecurityMode.None">None</option>
<option value="@OpcUaSecurityMode.Sign">Sign</option>
<option value="@OpcUaSecurityMode.SignAndEncrypt">Sign &amp; Encrypt</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.Heartbeat = null">
Remove Heartbeat
</button>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-autoaccept")"
@bind="Config.AutoAcceptUntrustedCerts" />
<label class="form-check-label small"
for="@($"{IdPrefix}-autoaccept")">Auto-accept certs</label>
</div>
</div>
</div>
}
<div class="mb-2">
<button type="button" class="btn btn-outline-primary btn-sm"
data-test="verify-endpoint-btn"
disabled="@_verifying"
@onclick="VerifyEndpoint">
@if (_verifying)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Verifying…</span>
}
else
{
<span>Verify endpoint</span>
}
</button>
@if (_verifyResult is { } result)
{
@if (result.Success)
{
<span class="text-success small ms-2" data-test="verify-success">
&#10003; Endpoint reachable
</span>
}
else
{
@* The failure text is a raw driver exception message. Give it its own
block below the button (rather than inline beside it) and clamp it,
so a long message cannot reflow the whole button row. Full text
stays reachable via the title. *@
<div class="mt-1">
<span class="text-danger small cell-clamp-2"
data-test="verify-failure"
title="@result.FailureKind: @result.Error">@result.FailureKind: @result.Error</span>
</div>
}
@if (result.FailureKind == VerifyFailureKind.UntrustedCertificate
&& result.Cert is { } cert)
{
<div class="border rounded bg-body-secondary p-2 mt-2 small" data-test="verify-cert-panel">
<div class="text-muted mb-1">Untrusted server certificate</div>
@* Server-supplied X.509 DNs / thumbprints: clip so a long DN cannot
stretch the panel; the full value stays in the title. *@
<dl class="row mb-1 small">
<dt class="col-sm-3">Subject</dt>
<dd class="col-sm-9"><code class="cell-clip" title="@cert.Subject">@cert.Subject</code></dd>
<dt class="col-sm-3">Issuer</dt>
<dd class="col-sm-9"><code class="cell-clip" title="@cert.Issuer">@cert.Issuer</code></dd>
<dt class="col-sm-3">Thumbprint</dt>
<dd class="col-sm-9"><code class="cell-clip" title="@cert.Thumbprint">@cert.Thumbprint</code></dd>
<dt class="col-sm-3">Not before</dt>
<dd class="col-sm-9">@cert.NotBeforeUtc.ToString("u")</dd>
<dt class="col-sm-3">Not after</dt>
<dd class="col-sm-9">@cert.NotAfterUtc.ToString("u")</dd>
</dl>
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized>
<button type="button" class="btn btn-outline-warning btn-sm mt-1"
data-test="trust-cert-btn"
disabled="@_trusting"
@onclick="() => TrustCert(cert)">
@if (_trusting)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Trusting…</span>
}
else
{
<span>Trust certificate</span>
}
</button>
<div class="text-muted fst-italic mt-1">
Trusting adds this certificate to every node of the site's
trusted-peer store (node-wide), then re-runs Verify.
</div>
</Authorized>
<NotAuthorized>
<div class="text-muted fst-italic">
An Administrator must trust this certificate (cert management).
</div>
</NotAuthorized>
</AuthorizeView>
@if (_trustError is { } trustError)
{
@* Raw exception text from the trust call — clamped, full text in title. *@
<div class="text-danger small mt-1 cell-clamp-2"
data-test="trust-cert-error"
title="@trustError">@trustError</div>
}
@if (_trustSucceeded)
{
<div class="text-success small mt-1" data-test="trust-cert-success">
&#10003; Certificate trusted.
</div>
}
</div>
}
}
</div>
</fieldset>
<fieldset class="mb-3">
<legend class="h6">Authentication</legend>
@if (Config.UserIdentity is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableAuthentication">Enable Authentication</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Token type</label>
<select class="form-select form-select-sm" @bind="Config.UserIdentity.TokenType">
<option value="@OpcUaUserTokenType.Anonymous">Anonymous</option>
<option value="@OpcUaUserTokenType.UsernamePassword">Username / Password</option>
<option value="@OpcUaUserTokenType.X509Certificate">X.509 Certificate</option>
</select>
</div>
@if (Config.UserIdentity.TokenType == OpcUaUserTokenType.UsernamePassword)
{
<div class="col-md-3">
<label class="form-label small">Username</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.UserIdentity.Username" />
@RenderFieldError("UserIdentity.Username")
</div>
<div class="col-md-3">
<label class="form-label small">Password</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.UserIdentity.Password" />
</div>
}
else if (Config.UserIdentity.TokenType == OpcUaUserTokenType.X509Certificate)
{
<div class="col-md-4">
<label class="form-label small">Certificate path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.UserIdentity.CertificatePath"
placeholder="/etc/scadabridge/pki/client.pfx" />
@RenderFieldError("UserIdentity.CertificatePath")
</div>
<div class="col-md-3">
<label class="form-label small">Certificate password</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.UserIdentity.CertificatePassword" />
</div>
}
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.UserIdentity = null">
Remove Authentication
</button>
</div>
</div>
}
</fieldset>
<fieldset class="mb-3">
<legend class="h6">Timing</legend>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Session timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SessionTimeoutMs" min="1" />
@RenderFieldError("SessionTimeoutMs")
</div>
<div class="col-md-3">
<label class="form-label small">Operation timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.OperationTimeoutMs" min="1" />
@RenderFieldError("OperationTimeoutMs")
</div>
</div>
</fieldset>
<fieldset class="mb-3">
<legend class="h6">Subscription</legend>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Publishing interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.PublishingIntervalMs" min="1" />
@RenderFieldError("PublishingIntervalMs")
</div>
<div class="col-md-3">
<label class="form-label small">Sampling interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SamplingIntervalMs" min="1" />
@RenderFieldError("SamplingIntervalMs")
</div>
<div class="col-md-2">
<label class="form-label small">Queue size</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.QueueSize" min="1" />
@RenderFieldError("QueueSize")
</div>
<div class="col-md-2">
<label class="form-label small">Keep-alive count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.KeepAliveCount" min="1" />
@RenderFieldError("KeepAliveCount")
</div>
<div class="col-md-2">
<label class="form-label small">Lifetime count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.LifetimeCount" min="1" />
@RenderFieldError("LifetimeCount")
</div>
<div class="col-md-3">
<label class="form-label small">Max notifications / publish</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.MaxNotificationsPerPublish" min="1" />
@RenderFieldError("MaxNotificationsPerPublish")
</div>
</div>
</fieldset>
<fieldset class="mb-3">
<legend class="h6">Advanced subscription</legend>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Subscription display name</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.SubscriptionDisplayName" />
@RenderFieldError("SubscriptionDisplayName")
</div>
<div class="col-md-2">
<label class="form-label small">Subscription priority</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SubscriptionPriority" min="0" max="255" />
</div>
<div class="col-md-3">
<label class="form-label small">Timestamps to return</label>
<select class="form-select form-select-sm" @bind="Config.TimestampsToReturn">
<option value="@OpcUaTimestampsToReturn.Source">Source</option>
<option value="@OpcUaTimestampsToReturn.Server">Server</option>
<option value="@OpcUaTimestampsToReturn.Both">Both</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-discardoldest")"
@bind="Config.DiscardOldest" />
<label class="form-check-label small"
for="@($"{IdPrefix}-discardoldest")">Discard oldest</label>
</div>
</div>
</div>
</fieldset>
<fieldset class="mb-3">
<legend class="h6">Deadband filter</legend>
@if (Config.Deadband is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableDeadband">Enable Deadband</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" @bind="Config.Deadband.Type">
<option value="@OpcUaDeadbandType.Absolute">Absolute</option>
<option value="@OpcUaDeadbandType.Percent">Percent</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Value</label>
<input type="number" step="0.01" class="form-control form-control-sm"
@bind="Config.Deadband.Value" min="0" />
@RenderFieldError("Deadband.Value")
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.Deadband = null">
Remove Deadband
</button>
</div>
</div>
}
</fieldset>
<fieldset class="mb-0">
<legend class="h6">Heartbeat</legend>
@if (Config.Heartbeat is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableHeartbeat">Enable Heartbeat</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Tag path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.Heartbeat.TagPath"
placeholder="Sensors.Heartbeat" />
@RenderFieldError("Heartbeat.TagPath")
</div>
<div class="col-md-3">
<label class="form-label small">Max silence (s)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.Heartbeat.MaxSilenceSeconds" min="1" />
@RenderFieldError("Heartbeat.MaxSilenceSeconds")
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.Heartbeat = null">
Remove Heartbeat
</button>
</div>
</div>
}
</fieldset>
</div>
@code {
@@ -2,8 +2,13 @@
@* The side-rail chassis (brand bar + responsive hamburger) is the shared
ZB.MOM.WW.Theme ThemeShell. NavMenu fills the rail's <Nav> slot with the
policy-gated nav groups; the session/sign-out block fills <RailFooter>. *@
<ThemeShell Product="ScadaBridge" Accent="#2f5fd0">
policy-gated nav groups; the session/sign-out block fills <RailFooter>.
No Accent="…" here on purpose: the kit emits that parameter as an inline
style="--accent: …" on the shell root, which outranks the dark-mode token
override on <html> and silently killed the dark accent app-wide. The accent
is declared as a :root token in site.css instead. *@
<ThemeShell Product="ScadaBridge">
<Nav>
<NavMenu />
</Nav>
@@ -55,7 +55,7 @@
<div class="small text-muted mt-1">Key ID: <code>@_newlyCreatedKeyId</code></div>
<div class="d-flex align-items-center mt-2">
<code class="me-2" data-test="created-token">@_newlyCreatedToken</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this token now — it will not be shown again.</small>
</div>
@@ -105,8 +105,10 @@
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-success" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
}
</div>
@@ -55,19 +55,24 @@
<tr @key="key.KeyId">
<td><code>@TruncateKeyId(key.KeyId)</code></td>
<td>
@key.Name
@if (!key.Enabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
@* Operator-supplied name: clip it so a long value cannot push the
actions column off-screen. .cell-clip is display:block, hence the
inner span; the badge stays outside it so status is never clipped. *@
<div class="d-flex align-items-center">
<span class="cell-clip" title="@key.Name">@key.Name</span>
@if (!key.Enabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
</div>
</td>
<td>@key.Methods.Count</td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2"
<button class="btn btn-outline-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.KeyId}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-label="@($"More actions for {key.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
@@ -44,8 +44,10 @@
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-success" @onclick="SaveMapping">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
</div>
@@ -55,7 +55,9 @@
var ruleCount = rules?.Count ?? 0;
<tr @key="mapping.Id">
<td>@mapping.Id</td>
<td>@mapping.LdapGroupName</td>
@* Externally sourced DN (CN=…,OU=…,DC=…): clip so it cannot set the
table's width. .cell-clip is display:block, hence the span. *@
<td><span class="cell-clip" title="@mapping.LdapGroupName">@mapping.LdapGroupName</span></td>
<td><span class="badge bg-secondary">@mapping.Role</span></td>
<td>
@if (ruleCount > 0)
@@ -69,10 +71,10 @@
</td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2"
<button class="btn btn-outline-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-label="@($"More actions for {mapping.LdapGroupName}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
@@ -19,21 +19,21 @@
<ToastNotification @ref="_toast" />
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">@(IsEditMode ? "Edit Site" : "Add Site")</span>
@* Drill-in: deep-link into the central Audit
Log pre-filtered to this site's events. AuditEvent.SourceSiteId
stores the SiteIdentifier (string), so we pass that through. *@
@if (IsEditMode && !string.IsNullOrWhiteSpace(_formIdentifier))
{
<a class="btn btn-outline-secondary btn-sm"
href="/audit/log?site=@Uri.EscapeDataString(_formIdentifier)"
data-test="audit-link">
Recent audit activity
</a>
}
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6>
@* Drill-in: deep-link into the central Audit
Log pre-filtered to this site's events. AuditEvent.SourceSiteId
stores the SiteIdentifier (string), so we pass that through. *@
@if (IsEditMode && !string.IsNullOrWhiteSpace(_formIdentifier))
{
<a class="btn btn-outline-secondary btn-sm"
href="/audit/log?site=@Uri.EscapeDataString(_formIdentifier)"
data-test="audit-link">
Recent audit activity
</a>
}
</div>
<div class="mb-2">
<label class="form-label small">Identifier</label>
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
@@ -43,43 +43,65 @@
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="mb-3">
<div class="mb-0">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_formDescription" />
</div>
</div>
</div>
<h6 class="text-muted border-bottom pb-1">Node A</h6>
<div class="card mb-3">
<div class="card-header">
<span class="fw-semibold">Node A</span>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<label class="form-label small">Akka Address (legacy — unused)</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeAAddress"
placeholder="akka.tcp://scadabridge@host:port/user/site-communication" />
<div class="form-text">
Retained for historical configuration only — nothing reads it at runtime.
Central dials the gRPC address below.
</div>
</div>
<div class="mb-3">
<div class="mb-0">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
placeholder="http://host:8083" />
</div>
</div>
</div>
<h6 class="text-muted border-bottom pb-1">Node B</h6>
<div class="card mb-3">
<div class="card-header">
<span class="fw-semibold">Node B</span>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<label class="form-label small">Akka Address (legacy — unused)</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeBAddress"
placeholder="akka.tcp://scadabridge@host:port/user/site-communication" />
<div class="form-text">
Retained for historical configuration only — nothing reads it at runtime.
Central dials the gRPC address below.
</div>
</div>
<div class="mb-3">
<div class="mb-0">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
placeholder="http://host:8083" />
</div>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSite">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mb-3">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-success" @onclick="SaveSite">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@@ -146,8 +146,10 @@
Cluster nodes (Akka, gRPC)
</button>
<div class="collapse mt-2" id="@collapseId">
@ClusterRow("Node A", site.NodeAAddress)
@ClusterRow("Node B", site.NodeBAddress)
@* Akka node addresses are legacy: no runtime consumer since the
ClusterClient→gRPC migration. Central dials the gRPC pair below. *@
@ClusterRow("Node A (legacy)", site.NodeAAddress)
@ClusterRow("Node B (legacy)", site.NodeBAddress)
@ClusterRow("gRPC A", site.GrpcNodeAAddress)
@ClusterRow("gRPC B", site.GrpcNodeBAddress)
</div>
@@ -63,14 +63,16 @@
To="_filterTo" ToChanged="(v => _filterTo = v)"
IdPrefix="audit-filter" />
</div>
<div class="col-md-2 d-flex gap-1">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters" disabled="@_searching">
Clear filters
</button>
<div class="col-md-2">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-primary" @onclick="Search" disabled="@_searching">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
<button class="btn btn-outline-secondary" @onclick="ClearFilters" disabled="@_searching">
Clear filters
</button>
</div>
</div>
</div>
@@ -107,7 +109,7 @@
var isLarge = hasState && entry.AfterStateJson!.Length > 1024;
<tr>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small">@entry.User</td>
<td class="small"><span class="cell-clip cell-clip-sm" title="@entry.User">@entry.User</span></td>
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
<td class="small">@entry.EntityType</td>
<td class="small">
@@ -124,13 +126,24 @@
<span class="text-muted">—</span>
}
</td>
<td class="small">@entry.EntityName</td>
<td class="small">
@* Entity names are operator/bundle supplied — guarded and
bounded exactly like the Entity ID neighbour above. *@
@if (!string.IsNullOrEmpty(entry.EntityName))
{
<span class="cell-clip" title="@entry.EntityName">@entry.EntityName</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (hasState)
{
if (isLarge)
{
<button class="btn btn-outline-info btn-sm py-0 px-1"
<button class="btn btn-outline-info btn-sm"
@onclick="() => ShowStateModal(entry)"
aria-label="Open state details in modal for audit entry @entry.Id">
View in modal
@@ -138,7 +151,7 @@
}
else
{
<button class="btn btn-outline-info btn-sm py-0 px-1"
<button class="btn btn-outline-info btn-sm"
@onclick="() => ToggleStateView(entry.Id)"
aria-label="Toggle state details for audit entry @entry.Id">
@(_expandedEntryId == entry.Id ? "Hide" : "View")
@@ -166,7 +179,12 @@
<OffsetPager Page="_page" PageChanged="OnPageChanged" HasNextPage="HasMore" TotalCount="_totalCount" PageSize="_pageSize" />
}
@if (_modalEntry != null)
@* The state modal holds only the entry's id and re-resolves the row from the
page currently on screen on every render (ModalEntry()). A refetch that
replaces the entry list therefore never leaves this surface rendering a
stale record, and an entry that has left the page closes the modal instead
of stranding it. *@
@if (ModalEntry() is { } modalEntry)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog">
@@ -174,12 +192,12 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Audit entry @_modalEntry.Id — @_modalEntry.EntityType state
Audit entry @modalEntry.Id — @modalEntry.EntityType state
</h5>
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(modalEntry.AfterStateJson!)</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
@@ -217,12 +235,29 @@
private bool _searching;
private string? _errorMessage;
private int? _expandedEntryId;
private AuditLogEntry? _modalEntry;
// State modal. Only the entry's IDENTIFIER is held — never the AuditLogEntry
// record itself — so the modal cannot outlive the row it was opened for.
// ModalEntry() re-resolves it from the page currently on screen on every
// render; an entry that has left the page resolves to null and the modal
// renders nothing (it self-closes) rather than showing a stale snapshot.
private int? _modalEntryId;
private ToastNotification _toast = default!;
private bool HasMore => _page * _pageSize < _totalCount;
/// <summary>
/// Resolves the entry the state modal is open for from the page currently on
/// screen. Returns null when no entry is selected, when the list has not
/// loaded, or when the selected entry is no longer in the page — the modal's
/// visibility gate.
/// </summary>
private AuditLogEntry? ModalEntry() =>
_modalEntryId is { } id
? _entries?.FirstOrDefault(e => e.Id == id)
: null;
// Tracks the BundleImportId we last fetched against so a re-render with the
// same query param doesn't re-run the query on every parameter set.
private Guid? _lastFetchedBundleImportId;
@@ -327,12 +362,12 @@
private void ShowStateModal(AuditLogEntry entry)
{
_modalEntry = entry;
_modalEntryId = entry.Id;
}
private void CloseStateModal()
{
_modalEntry = null;
_modalEntryId = null;
}
private async Task CopyAsync(string text)
@@ -18,13 +18,7 @@
just the Audit Log + Configuration Audit Log pages). *@
<div class="container-fluid mt-3">
<h1 class="h4 mb-1">Execution Chain</h1>
<p class="text-muted small mb-3">
The full chain of script / inbound-request executions linked by
<span class="font-monospace">ParentExecutionId</span>, rooted at the
topmost ancestor. Select an execution to open the Audit Log filtered to
its rows.
</p>
<h1 class="h4 mb-3">Execution Chain</h1>
@if (_executionId is null)
{
@@ -18,7 +18,6 @@
</Authorized>
</AuthorizeView>
</div>
<p class="text-muted">Central management console for the ScadaBridge SCADA system.</p>
@* KPI row *@
<div class="row g-3 mb-4">
@@ -42,8 +42,10 @@
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
<button class="btn btn-primary" @onclick="Submit">Create</button>
</div>
</div>
</div>
</div>
@@ -14,12 +14,12 @@
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Deployment Status</h4>
<div class="d-flex gap-2 align-items-center">
<button class="btn btn-outline-secondary btn-sm" @onclick="ToggleAutoRefresh"
<div class="btn-group btn-group-sm" role="group" aria-label="Deployment list actions">
<button class="btn btn-outline-secondary" @onclick="ToggleAutoRefresh"
aria-label="@(_autoRefresh ? "Pause auto-refresh" : "Resume auto-refresh")">
@(_autoRefresh ? "⏸ Pause updates" : "▶ Resume updates")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
<button class="btn btn-outline-secondary" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
</div>
</div>
@@ -99,7 +99,14 @@
var revShort = record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)];
<tr id="@rowId" class="@GetRowClass(record.Status)">
<td>
<code class="small">@idShort@(string.IsNullOrEmpty(revShort) ? "" : $"@{revShort}")</code>
@* Deployment id and revision hash are two distinct
identifiers — kept on separate lines so neither
reads as part of the other. *@
<code class="small" title="@record.DeploymentId">@idShort</code>
@if (!string.IsNullOrEmpty(revShort))
{
<div class="font-monospace small text-muted" title="@record.RevisionHash">@revShort</div>
}
</td>
<td>@GetInstanceName(record.InstanceId)</td>
<td>
@@ -134,7 +141,7 @@
<td class="small text-end">
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage))
{
<button class="btn btn-link btn-sm p-0" type="button"
<button class="btn btn-sm btn-outline-secondary" type="button"
@onclick="() => ToggleErrorExpansion(record.DeploymentId)"
aria-expanded="@(IsErrorExpanded(record.DeploymentId) ? "true" : "false")"
aria-controls="@errorCollapseId">
@@ -149,7 +156,7 @@
<td colspan="7">
<div class="small">
<strong>Error:</strong>
<pre class="mb-0 mt-1 small" style="white-space: pre-wrap; word-break: break-word;">@record.ErrorMessage</pre>
<pre class="detail-pre mb-0 mt-1 small">@record.ErrorMessage</pre>
</div>
</td>
</tr>
@@ -123,7 +123,12 @@
var isBrowsable = IsBrowsable(connId);
<tr>
<td class="small">@attr.Name</td>
<td class="small text-muted font-monospace">@attr.DataSourceReference</td>
@* Tag paths are OPC UA node ids (nsu=…;s=…) — unbounded
text that would otherwise set the table's width.
.cell-clip is display:block, hence the span. *@
<td class="small text-muted font-monospace">
<span class="cell-clip" title="@attr.DataSourceReference">@attr.DataSourceReference</span>
</td>
<td>
<select class="form-select form-select-sm"
value="@connId"
@@ -156,19 +161,21 @@
}
</tbody>
</table>
<div class="p-2 d-flex gap-2">
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
@* Test Bindings: one-shot live read of every bound attribute
whose row has a connection picked AND an effective tag
path. Disabled when no testable rows. Protocol-agnostic —
any connection whose adapter implements ReadBatchAsync
(OPC UA and MxGateway today) round-trips through
ReadTagValuesCommand. *@
<button class="btn btn-outline-primary btn-sm"
@onclick="OpenTestBindings"
disabled="@(!HasTestableBindings())">
Test Bindings
</button>
<div class="p-2">
<div class="btn-group btn-group-sm" role="group" aria-label="Binding actions">
<button class="btn btn-success" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
@* Test Bindings: one-shot live read of every bound attribute
whose row has a connection picked AND an effective tag
path. Disabled when no testable rows. Protocol-agnostic —
any connection whose adapter implements ReadBatchAsync
(OPC UA and MxGateway today) round-trips through
ReadTagValuesCommand. *@
<button class="btn btn-outline-primary"
@onclick="OpenTestBindings"
disabled="@(!HasTestableBindings())">
Test Bindings
</button>
</div>
</div>
}
</div>
@@ -252,7 +259,11 @@
</span>
}
</td>
<td class="small text-muted">@(attr.Value ?? "—")</td>
@* Template values are unbounded — canonical JSON for a
List attribute. Clip, full value on the title. *@
<td class="small text-muted">
<span class="cell-clip" title="@attr.Value">@(attr.Value ?? "—")</span>
</td>
<td>
@if (attr.DataType == DataType.List)
{
@@ -265,7 +276,10 @@
ShowElementType="false" />
@if (_overrideErrors.TryGetValue(attr.Name, out var listErr))
{
<div class="alert alert-danger small mt-2 mb-0"
@* Bounded: this alert lives inside a table cell,
so an unbounded message would widen the column. *@
<div class="alert alert-danger small mt-2 mb-0 text-break"
style="max-width: 20rem;"
data-test="override-list-error">@listErr</div>
}
@if (HasOverrideRow(attr.Name))
@@ -298,10 +312,6 @@
<div class="card mb-3">
<div class="card-header py-2">
<strong>Alarm Overrides</strong>
<small class="text-muted ms-2">
Click <em>Edit</em> to override an alarm's trigger configuration or priority.
HiLo overrides merge into the inherited setpoints; other trigger types replace the whole config.
</small>
</div>
<div class="card-body p-0">
@if (_overridableAlarms.Count == 0)
@@ -323,6 +333,9 @@
<tbody>
@foreach (var alarm in _overridableAlarms)
{
// Comma-joined list of overridden JSON keys — unbounded key
// count, so it is clipped with the full text on the title.
var overrideSummary = HasOverride(alarm.Name) ? OverrideSummary(alarm.Name) : null;
<tr data-test="alarm-override-row-@alarm.Name">
<td class="small">@alarm.Name</td>
<td>
@@ -335,8 +348,12 @@
<td class="small">
@if (HasOverride(alarm.Name))
{
<span class="badge bg-warning text-dark me-1" data-test="alarm-override-badge" title="Override is set">●</span>
<span class="text-muted">@OverrideSummary(alarm.Name)</span>
@* .cell-clip is display:block, so the badge and the
clipped summary share a flex line. *@
<span class="d-flex align-items-center">
<span class="badge bg-warning text-dark me-1" data-test="alarm-override-badge" title="Override is set">●</span>
<span class="text-muted cell-clip cell-clip-sm" title="@overrideSummary">@overrideSummary</span>
</span>
}
else
{
@@ -344,17 +361,19 @@
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm me-1"
data-test="alarm-edit-btn"
@onclick="() => BeginEditOverride(alarm)"
disabled="@_saving">Edit</button>
@if (HasOverride(alarm.Name))
{
<button class="btn btn-outline-danger btn-sm"
data-test="alarm-clear-btn"
@onclick="() => ClearAlarmOverride(alarm.Name)"
disabled="@_saving">Clear</button>
}
<div class="btn-group btn-group-sm" role="group" aria-label="@($"Override actions for {alarm.Name}")">
<button class="btn btn-outline-primary"
data-test="alarm-edit-btn"
@onclick="() => BeginEditOverride(alarm)"
disabled="@_saving">Edit</button>
@if (HasOverride(alarm.Name))
{
<button class="btn btn-outline-danger"
data-test="alarm-clear-btn"
@onclick="() => ClearAlarmOverride(alarm.Name)"
disabled="@_saving">Clear</button>
}
</div>
</td>
</tr>
}
@@ -371,10 +390,12 @@
<div class="modal-dialog modal-dialog-scrollable modal-lg">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">
Edit override: @_editingAlarm.Name
<span class="badge bg-secondary-subtle text-secondary-emphasis border ms-1">@_editingAlarm.TriggerType</span>
</h6>
@* Name and trigger-type badge separated so the heading is a
single identifier rather than an identifier run. *@
<div>
<h6 class="modal-title mb-1">Edit override: @_editingAlarm.Name</h6>
<span class="badge bg-secondary-subtle text-secondary-emphasis border">@_editingAlarm.TriggerType</span>
</div>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelEditOverride"></button>
</div>
<div class="modal-body">
@@ -419,9 +440,9 @@
disabled="@_saving">Clear Override</button>
}
</div>
<div>
<button class="btn btn-outline-secondary btn-sm" data-test="alarm-cancel-override" @onclick="CancelEditOverride">Cancel</button>
<button class="btn btn-success btn-sm" data-test="alarm-save-override" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
<div class="btn-group btn-group-sm" role="group" aria-label="Override edit actions">
<button class="btn btn-outline-secondary" data-test="alarm-cancel-override" @onclick="CancelEditOverride">Cancel</button>
<button class="btn btn-success" data-test="alarm-save-override" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
</div>
</div>
</div>
@@ -434,8 +455,9 @@
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<div>
<strong>Native Alarm Source Overrides</strong>
@* Kept: the blank-means-inherited rule is the only in-context
decoder for the three override inputs below. *@
<small class="text-muted ms-2">
Retarget an inherited native alarm source binding for this instance.
Leave a field blank to keep the inherited value.
</small>
</div>
@@ -512,9 +534,12 @@
<span class="badge bg-warning text-dark ms-1" title="Override is set">●</span>
}
</td>
<td class="small text-muted font-monospace text-truncate" style="max-width: 200px;"
@* Two identifiers — connection then source reference —
stacked rather than slammed together with a slash. *@
<td class="small" style="max-width: 200px;"
title="@($"{src.ConnectionName} / {src.SourceReference}")">
@src.ConnectionName / @src.SourceReference
<div class="text-truncate">@src.ConnectionName</div>
<div class="text-muted font-monospace text-truncate">@src.SourceReference</div>
</td>
<td>
<select class="form-select form-select-sm"
@@ -540,13 +565,15 @@
@onchange="e => _nasFilterEdit[src.Name] = string.IsNullOrWhiteSpace((string?)e.Value) ? null : ((string?)e.Value)!.Trim()" />
</td>
<td>
<button class="btn btn-success btn-sm me-1"
@onclick="() => SaveNativeOverride(src.Name)" disabled="@_saving">Save</button>
@if (HasNativeOverride(src.Name))
{
<button class="btn btn-outline-danger btn-sm"
@onclick="() => ClearNativeOverride(src.Name)" disabled="@_saving">Clear</button>
}
<div class="btn-group btn-group-sm" role="group" aria-label="@($"Override actions for {src.Name}")">
<button class="btn btn-success"
@onclick="() => SaveNativeOverride(src.Name)" disabled="@_saving">Save</button>
@if (HasNativeOverride(src.Name))
{
<button class="btn btn-outline-danger"
@onclick="() => ClearNativeOverride(src.Name)" disabled="@_saving">Clear</button>
}
</div>
</td>
</tr>
}
@@ -65,9 +65,9 @@
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Create instance actions">
<button class="btn btn-success" @onclick="CreateInstance">Create</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@@ -17,8 +17,10 @@
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
<button class="btn btn-primary" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
@@ -17,8 +17,10 @@
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
<button class="btn btn-primary" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
@@ -72,7 +72,7 @@
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
placeholder="Search sites, areas, instances..."
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
<div class="btn-group btn-group-sm">
<div class="btn-group btn-group-sm" role="group" aria-label="Topology actions">
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
<button class="btn btn-outline-secondary"
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
@@ -902,7 +902,12 @@
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
// Revision hashes are normally 64 hex chars, but the slice must
// not assume it — a shorter hash would throw out of the toast
// path and abort the post-deploy refresh.
var deployedRev = result.Value.RevisionHash;
var deployedRevShort = deployedRev is { Length: > 8 } ? deployedRev[..8] : deployedRev;
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {deployedRevShort}).");
await LoadDataAsync();
}
else
@@ -959,9 +964,13 @@
builder.CloseElement();
builder.OpenElement(5, "span");
builder.AddAttribute(6, "class", "text-muted small ms-2");
// Guarded slices: a hash shorter than 8 chars would throw
// ArgumentOutOfRangeException out of the diff render.
var deployedHash = diffResult.DeployedRevisionHash;
var currentHash = diffResult.CurrentRevisionHash;
builder.AddContent(7,
$"Deployed: {diffResult.DeployedRevisionHash[..8]} | " +
$"Current: {diffResult.CurrentRevisionHash[..8]} | " +
$"Deployed: {(deployedHash.Length > 8 ? deployedHash[..8] : deployedHash)} | " +
$"Current: {(currentHash.Length > 8 ? currentHash[..8] : currentHash)} | " +
$"Deployed at: {diffResult.DeployedAt.LocalDateTime:yyyy-MM-dd HH:mm}");
builder.CloseElement();
builder.CloseElement();
@@ -13,7 +13,7 @@
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<button class="btn btn-outline-secondary btn-sm mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit API Method" : "Add API Method")</h4>
@@ -25,77 +25,93 @@
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" disabled="@Id.HasValue" />
</div>
<div class="mb-3">
<label class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Approved API Keys</label>
@if (_allKeys.Count == 0)
{
<div class="form-text">
No API keys configured.
<a href="/admin/api-keys">Create one</a> to authorize callers for this method.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var key in _allKeys)
{
var checkboxId = $"approved-key-{key.KeyId}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedKeyIds.Contains(key.KeyId)"
@onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">
@key.Name
@if (!key.Enabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
</label>
</div>
}
</div>
<div class="form-text">
Callers must present a checked key in the <code>X-API-Key</code> header to invoke this method.
</div>
}
</div>
<div class="mb-3">
<label class="form-label">Parameters</label>
<SchemaBuilder Mode="object"
Value="@_params"
ValueChanged="@(v => _params = v)" />
</div>
<div class="mb-3">
<label class="form-label">Return value</label>
<SchemaBuilder Mode="value"
Value="@_returns"
ValueChanged="@(v => _returns = v)" />
</div>
<div class="mb-3">
<label class="form-label">Script</label>
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
Language="csharp" Height="320px"
ScriptKind="ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
</div>
@* The body used to stack six unrelated groups with nothing but a label
between them. Fieldset + legend gives each stack a real boundary —
same idiom as TransportExport. *@
<fieldset class="mb-4">
<legend class="h6">Method</legend>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" disabled="@Id.HasValue" />
</div>
<div class="mb-0">
<label class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
</div>
</fieldset>
<fieldset class="mb-4">
<legend class="h6">Approved API Keys</legend>
<div class="mb-0">
@if (_allKeys.Count == 0)
{
<div class="form-text">
No API keys configured.
<a href="/admin/api-keys">Create one</a> to authorize callers for this method.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var key in _allKeys)
{
var checkboxId = $"approved-key-{key.KeyId}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedKeyIds.Contains(key.KeyId)"
@onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">
@key.Name
@if (!key.Enabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
</label>
</div>
}
</div>
<div class="form-text">
Callers must present a checked key in the <code>X-API-Key</code> header to invoke this method.
</div>
}
</div>
</fieldset>
<fieldset class="mb-4">
<legend class="h6">Contract</legend>
<div class="mb-3">
<label class="form-label">Parameters</label>
<SchemaBuilder Mode="object"
Value="@_params"
ValueChanged="@(v => _params = v)" />
</div>
<div class="mb-0">
<label class="form-label">Return value</label>
<SchemaBuilder Mode="value"
Value="@_returns"
ValueChanged="@(v => _returns = v)" />
</div>
</fieldset>
<fieldset class="mb-4">
<legend class="h6">Script</legend>
<div class="mb-0">
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
Language="csharp" Height="320px"
ScriptKind="ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
</div>
</fieldset>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<div class="btn-group" role="group">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-primary" @onclick="ToggleTestRunPanel">
@(_showTestRun ? "Hide Test Run" : "Test Run")
@@ -111,13 +127,15 @@
<div class="card-header py-2">
<span class="fw-semibold">Test Run</span>
</div>
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
<strong>Heads up:</strong>
runs the script as typed (unsaved edits included) against the supplied
<code>Parameters</code>. <code>Route</code> calls throw — cross-site
routing needs a deployed site reachable over the cluster transport.
</div>
<div class="card-body">
@* Lives inside the body, not between header and body, where it rendered
as a third band. Wording unchanged — pinned by TestRunWarningTests. *@
<div class="alert alert-warning py-1 mb-3 small">
<strong>Heads up:</strong>
runs the script as typed (unsaved edits included) against the supplied
<code>Parameters</code>. <code>Route</code> calls throw — cross-site
routing needs a deployed site reachable over the cluster transport.
</div>
<div class="mb-3">
<label class="form-label small">Parameter values</label>
<ParameterValueForm ParameterDefinitions="@_params"
@@ -60,8 +60,12 @@
@foreach (var cert in _certs)
{
<tr data-test="cert-row">
<td class="small"><code>@cert.Subject</code></td>
<td class="small"><code>@cert.Issuer</code></td>
@* Subject/Issuer are X.509 DNs read off a remote OPC UA server —
routinely 100+ chars, and side by side they used to push Remove
off-screen. Clip both (title carries the full DN). The thumbprint
is fixed-length SHA-1, so it needs no containment. *@
<td class="small"><code class="cell-clip" title="@cert.Subject">@cert.Subject</code></td>
<td class="small"><code class="cell-clip" title="@cert.Issuer">@cert.Issuer</code></td>
<td class="small"><code>@cert.Thumbprint</code></td>
<td class="small">@cert.NotBeforeUtc.ToString("u")</td>
<td class="small">@cert.NotAfterUtc.ToString("u")</td>
@@ -27,6 +27,9 @@
else
{
<div class="card mb-3">
<div class="card-header">
<span class="fw-semibold">Connection</span>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Site</label>
@@ -86,8 +89,16 @@
</div>
</div>
}
</div>
</div>
<h6 class="text-muted mt-3">Primary endpoint</h6>
@* Each endpoint editor is a heavyweight sub-form, so it gets its own card
rather than sharing one body separated only by a muted <h6>. *@
<div class="card mb-3">
<div class="card-header">
<span class="fw-semibold">Primary endpoint</span>
</div>
<div class="card-body">
@if (_protocol == "MxGateway")
{
<MxGatewayEndpointEditor Title="Primary Endpoint"
@@ -106,17 +117,21 @@
Protocol="@_protocol"
Errors="_primaryErrors" />
}
</div>
</div>
<h6 class="text-muted mt-3">
Backup endpoint
@if (!_showBackup)
{
<span class="badge bg-body-secondary text-body-secondary border ms-2">Optional</span>
}
</h6>
<div class="card mb-3">
<div class="card-header d-flex align-items-center">
<span class="fw-semibold">Backup endpoint</span>
@if (!_showBackup)
{
<div class="mb-3">
<span class="badge bg-body-secondary text-body-secondary border ms-2">Optional</span>
}
</div>
<div class="card-body">
@if (!_showBackup)
{
<div class="mb-0">
<button type="button" class="btn btn-outline-secondary btn-sm"
@onclick="EnableBackup">Add Backup Endpoint</button>
</div>
@@ -147,20 +162,22 @@
min="1" max="20" @bind="_formFailoverRetryCount" />
<div class="form-text">Retries before failing over to backup endpoint.</div>
</div>
<div class="mb-3">
<div class="mb-0">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="RemoveBackup">Remove Backup</button>
</div>
}
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mb-3">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-success" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
}
@@ -8,7 +8,7 @@
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<button class="btn btn-outline-secondary btn-sm mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit Database Connection" : "Add Database Connection")</h4>
@@ -43,7 +43,7 @@
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<div class="btn-group" role="group">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
@@ -8,7 +8,7 @@
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<button class="btn btn-outline-secondary btn-sm mb-2" @onclick="GoBack">&larr; Back</button>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(Id.HasValue ? "Edit External System" : "Add External System")</h4>
@@ -73,7 +73,7 @@
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<div class="btn-group" role="group">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
@@ -52,9 +52,9 @@
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSchema" disabled="@_busy">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelEdit" disabled="@_busy">Cancel</button>
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Schema editor actions">
<button class="btn btn-success" @onclick="SaveSchema" disabled="@_busy">Save</button>
<button class="btn btn-outline-secondary" @onclick="CancelEdit" disabled="@_busy">Cancel</button>
</div>
</div>
</div>
@@ -90,16 +90,23 @@
{
<tr @key="s.Id">
<td class="fw-semibold">@s.Name</td>
<td>@(string.IsNullOrWhiteSpace(s.Scope) ? "—" : s.Scope)</td>
@* Scope is operator free-text — clip it so a long value
can't push the Reference/Actions columns off-screen.
The title keeps the full value reachable. *@
<td>
<span class="cell-clip cell-clip-sm" title="@s.Scope">@(string.IsNullOrWhiteSpace(s.Scope) ? "—" : s.Scope)</span>
</td>
<td><code>lib:@s.Name</code></td>
<td class="text-end">
@* Row actions are disabled while the editor is open so the
row under edit (and its siblings) can't be deleted out from
under the form, and while a delete is in flight (_busy). *@
<button class="btn btn-outline-primary btn-sm me-1"
@onclick="() => BeginEdit(s)" disabled="@(_editing || _busy)">Edit</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DeleteSchema(s)" disabled="@(_editing || _busy)">Delete</button>
<div class="btn-group btn-group-sm" role="group" aria-label="@($"Actions for {s.Name}")">
<button class="btn btn-outline-primary"
@onclick="() => BeginEdit(s)" disabled="@(_editing || _busy)">Edit</button>
<button class="btn btn-outline-danger"
@onclick="() => DeleteSchema(s)" disabled="@(_editing || _busy)">Delete</button>
</div>
</td>
</tr>
}
@@ -87,13 +87,13 @@
{
<div class="@(_syntaxCheckPassed ? "text-success" : "text-danger") small mt-1">@_syntaxCheckResult</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
<button class="btn btn-outline-primary btn-sm me-1" @onclick="ToggleTestRunPanel">
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Shared script actions">
<button class="btn btn-success" @onclick="SaveScript">Save</button>
<button class="btn btn-outline-info" @onclick="CheckCompilation">Check Syntax</button>
<button class="btn btn-outline-primary" @onclick="ToggleTestRunPanel">
@(_showTestRun ? "Hide Test Run" : "Test Run")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@@ -104,13 +104,15 @@
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
</div>
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
<strong>Heads up:</strong>
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
<code>Attributes</code> and <code>CallScript</code> still throw.
</div>
<div class="card-body">
@* Lives inside the body rather than between header and body,
where it rendered as a third card band. *@
<div class="alert alert-warning py-1 mb-3 small">
<strong>Heads up:</strong>
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
<code>Attributes</code> and <code>CallScript</code> still throw.
</div>
<div class="mb-3">
<label class="form-label small">Parameter values</label>
<ParameterValueForm ParameterDefinitions="@_formParameters"
@@ -48,8 +48,10 @@
</div>
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
<button class="btn btn-success" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
</div>
</div>
@code {
@@ -66,8 +66,10 @@
</div>
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
<button class="btn btn-success" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
</div>
</div>
@code {
@@ -48,8 +48,8 @@
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success me-1" @onclick="CreateTemplate">Create</button>
<div class="btn-group mt-3" role="group" aria-label="Create template actions">
<button class="btn btn-success" @onclick="CreateTemplate">Create</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
@@ -262,22 +262,15 @@
</div>
}
@* M9: the FULL transitively-resolved inherited member set — the whole
chain (grandparent + further ancestors) plus base members added after
this template was created, which the immediate-base tables below cannot
show. Read-only preview; each row carries its origin template + lock
state, and alarms surface the merged effective trigger configuration. *@
@if (_resolved != null)
{
@RenderInheritedSet()
}
<div class="d-flex justify-content-between align-items-center mb-3">
@* Identity block: the template name leads, with the qualified path /
inheritance relation stacked beneath it rather than run together on
one heading line. *@
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h4 class="d-inline mb-0">@_selectedTemplate.Name</h4>
<h4 class="mb-0">@_selectedTemplate.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue && !_selectedTemplate.IsDerived)
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
<div class="text-muted small">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</div>
}
@if (_selectedTemplate.IsDerived)
{
@@ -286,18 +279,30 @@
<div class="text-muted small font-monospace">@QualifiedTemplateName(_selectedTemplate)</div>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
<div class="btn-group btn-group-sm" role="group" aria-label="Template actions">
<button class="btn btn-outline-info" @onclick="RunValidation" disabled="@_validating">
@if (_validating)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Validate
</button>
<button class="btn btn-outline-danger btn-sm" @onclick="DeleteTemplate">Delete</button>
<button class="btn btn-outline-danger" @onclick="DeleteTemplate">Delete</button>
</div>
</div>
@* M9: the FULL transitively-resolved inherited member set — the whole
chain (grandparent + further ancestors) plus base members added after
this template was created, which the immediate-base tables below cannot
show. Read-only preview; each row carries its origin template + lock
state, and alarms surface the merged effective trigger configuration.
Rendered BELOW the identity block so the template's own name leads the
page. *@
@if (_resolved != null)
{
@RenderInheritedSet()
}
@* Validation results *@
@if (_validationResult != null)
{
@@ -469,64 +474,76 @@
{
@if (members.Count > 0)
{
<h6 class="mt-2 mb-2">@title <span class="badge bg-secondary">@members.Count</span></h6>
<table class="table table-sm table-striped mb-3">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Effective value</th>
@if (showTrigger)
{
<th>Trigger config (effective)</th>
}
<th>Source</th>
<th>Lock</th>
</tr>
</thead>
<tbody>
@foreach (var m in members)
{
@* Each member kind gets its own card so four heading+table groups do
not run together in one undivided body. *@
<div class="card mb-3">
<div class="card-header py-2">
<span class="fw-semibold">@title</span>
<span class="badge bg-secondary ms-1">@members.Count</span>
</div>
<table class="table table-sm table-striped mb-0">
<thead class="table-light">
<tr>
<td>@m.Name</td>
<td class="small">@(m.EffectiveValue ?? "—")</td>
<th>Name</th>
<th>Effective value</th>
@if (showTrigger)
{
<td class="small text-muted font-monospace text-truncate"
style="max-width: 220px;" title="@m.EffectiveTriggerConfiguration">
@(string.IsNullOrEmpty(m.EffectiveTriggerConfiguration) ? "—" : m.EffectiveTriggerConfiguration)
</td>
<th>Trigger config (effective)</th>
}
<td>
@if (m.IsInherited)
{
<span class="badge bg-secondary"
title="@($"Inherited from {m.OriginTemplateName}")">
Inherited from @m.OriginTemplateName
</span>
}
else
{
<span class="badge bg-light text-dark">Local</span>
}
</td>
<td>
@if (m.IsBaseLocked)
{
<span class="badge bg-warning text-dark" title="A base template forbids overriding this member.">Base-locked</span>
}
else if (m.IsLocked)
{
<span class="badge bg-danger" aria-label="Locked">Locked</span>
}
else
{
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td>
<th>Source</th>
<th>Lock</th>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@foreach (var m in members)
{
<tr>
<td>@m.Name</td>
@* Effective values are author-supplied and unbounded —
bound them the same way the trigger-config sibling is. *@
<td class="small">
<span class="cell-clip" title="@m.EffectiveValue">@(m.EffectiveValue ?? "—")</span>
</td>
@if (showTrigger)
{
<td class="small text-muted font-monospace text-truncate"
style="max-width: 220px;" title="@m.EffectiveTriggerConfiguration">
@(string.IsNullOrEmpty(m.EffectiveTriggerConfiguration) ? "—" : m.EffectiveTriggerConfiguration)
</td>
}
<td>
@if (m.IsInherited)
{
@* Short static badge + the (unbounded) origin
template name as separate clipped text. *@
<span class="badge bg-secondary">Inherited</span>
<span class="cell-clip cell-clip-sm small text-muted"
title="@($"Inherited from {m.OriginTemplateName}")">from @m.OriginTemplateName</span>
}
else
{
<span class="badge bg-light text-dark">Local</span>
}
</td>
<td>
@if (m.IsBaseLocked)
{
<span class="badge bg-warning text-dark" title="A base template forbids overriding this member.">Base-locked</span>
}
else if (m.IsLocked)
{
<span class="badge bg-danger" aria-label="Locked">Locked</span>
}
else
{
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
};
@@ -681,8 +698,16 @@
<tr>
<td>@attr.Name</td>
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
<td class="small">@(effectiveValue ?? "—")</td>
<td class="small text-muted">@(effectiveDataSource ?? "—")</td>
@* Attribute default values and — especially — OPC UA /
MxAccess node paths are unbounded author-supplied text;
clip them so they cannot push the actions column
off-screen, with the full value on the title. *@
<td class="small">
<span class="cell-clip cell-clip-sm" title="@effectiveValue">@(effectiveValue ?? "—")</span>
</td>
<td class="small text-muted">
<span class="cell-clip font-monospace" title="@effectiveDataSource">@(effectiveDataSource ?? "—")</span>
</td>
<td>
@if (attr.IsLocked)
{
@@ -726,7 +751,7 @@
}
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {attr.Name}")">⋮</button>
@@ -890,7 +915,10 @@
<td>@alarm.Name</td>
<td><span class="badge bg-light text-dark">@alarm.TriggerType</span></td>
<td>@alarm.PriorityLevel</td>
<td class="small text-muted text-truncate" style="max-width: 200px;">@(alarm.TriggerConfiguration ?? "—")</td>
@* Already width-bounded — the title makes the clipped
config readable instead of merely hidden. *@
<td class="small text-muted text-truncate" style="max-width: 200px;"
title="@alarm.TriggerConfiguration">@(alarm.TriggerConfiguration ?? "—")</td>
<td>
@if (alarm.IsLocked)
{
@@ -903,7 +931,7 @@
</td>
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {alarm.Name}")">⋮</button>
@@ -932,10 +960,6 @@
<h5 class="mb-0">Native Alarm Sources</h5>
<button class="btn btn-primary btn-sm" @onclick="OpenAddNativeSourceDialog">Add Source</button>
</div>
<p class="text-muted small">
Read-only mirror of alarms from an OPC UA Alarms &amp; Conditions server or the
MxAccess Gateway. Discovered at runtime and shown live in the Debug View — no ack-back.
</p>
@if (_nativeSources.Count == 0)
{
@@ -961,7 +985,11 @@
<td>@src.Name</td>
<td><span class="badge bg-light text-dark">@src.ConnectionName</span></td>
<td class="small font-monospace text-truncate" style="max-width: 220px;" title="@src.SourceReference">@src.SourceReference</td>
<td class="small text-muted">@(string.IsNullOrEmpty(src.ConditionFilter) ? "—" : src.ConditionFilter)</td>
@* Condition filters are free-text expressions — clip
with the full value on the title. *@
<td class="small text-muted">
<span class="cell-clip" title="@src.ConditionFilter">@(string.IsNullOrEmpty(src.ConditionFilter) ? "—" : src.ConditionFilter)</span>
</td>
<td>
@if (src.IsLocked)
{
@@ -974,7 +1002,7 @@
</td>
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown" aria-expanded="false"
aria-label="@($"More actions for {src.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
@@ -1175,7 +1203,7 @@
}
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {script.Name}")">⋮</button>
@@ -58,8 +58,10 @@
</div>
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
<button class="btn btn-success" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
</div>
</div>
@code {
@@ -264,8 +264,10 @@
<button class="btn btn-outline-primary btn-sm me-auto" @onclick="ToggleTestRunPanel">
@(_showTestRun ? "Hide Test Run" : "Test Run")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="Cancel" disabled="@_busy">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="Cancel" disabled="@_busy">Cancel</button>
<button class="btn btn-success" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
</div>
</div>
@code {
@@ -44,12 +44,16 @@
</li>
</ul>
</div>
<button class="btn btn-outline-secondary btn-sm"
title="New folder at root"
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
<button class="btn btn-primary btn-sm"
title="New template at root"
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
@* The Bulk actions dropdown keeps its own .dropdown wrapper, so
only the two plain create buttons are grouped. *@
<div class="btn-group btn-group-sm" role="group" aria-label="Create actions">
<button class="btn btn-outline-secondary"
title="New folder at root"
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
<button class="btn btn-primary"
title="New template at root"
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
</div>
</div>
</div>
@@ -125,12 +125,9 @@
<fieldset class="mb-4" data-testid="group-notification-lists">
<legend class="h6">Notification Lists</legend>
@* The "a notification list does not pull in its SMTP config" rule now lives in
Component-Transport.md §Export Flow (dependency-edge list), not on the page. *@
@RenderCheckboxList(_notificationLists, n => n.Id, n => n.Name, _selectedNotificationLists)
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="smtp-hint">
Selecting a notification list does <strong>not</strong> automatically include its
SMTP configuration. SMTP configurations are environment-specific and must be
selected separately if you want them in the bundle.
</div>
</fieldset>
<fieldset class="mb-4" data-testid="group-smtp-configs">
@@ -147,24 +144,15 @@
<fieldset class="mb-4" data-testid="group-api-methods">
<legend class="h6">API Methods</legend>
@* Methods only — inbound API keys are never transported. Recorded (with the
re-create-and-re-grant recovery hint) in Component-Transport.md §Responsibilities. *@
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="api-keys-not-transported">
<strong>API keys are not part of config transport.</strong> Inbound API keys
live in each environment's own secret store and cannot be exported. After
importing, re-create the keys on the destination and re-grant their method
scopes via the admin UI/CLI.
</div>
</fieldset>
<fieldset class="mb-4" data-testid="group-sites">
<legend class="h6">Sites &amp; Instances</legend>
@* Site-vs-instance selection semantics: Component-Transport.md §UI Export Wizard. *@
@RenderSitesList()
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="sites-hint">
Selecting a <strong>site</strong> includes its data connections and all of its
instances. Expand a site to pick individual <strong>instances</strong> instead.
Each instance pulls in its template (and that template's dependency closure)
when <em>Include all dependencies</em> is on.
</div>
</fieldset>
<div class="d-flex justify-content-end gap-2 mt-4">
@@ -319,13 +307,8 @@
var autoInstances = AutoIncluded(_resolved.Instances, seedInstanceIds, i => i.Id);
<div>
<p class="text-body-secondary">
The resolver walked your selection's dependency graph and produced the closure
below. Items under <em>Auto-included</em> were pulled in because the items you
ticked reference them; unticking
<em>Include all dependencies</em> exports the seed alone.
</p>
@* No lede here — the "Selected by you" / "Auto-included (dependencies)" column
headings plus the labelled toggle below already say what the resolver did. *@
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="include-deps"
checked="@_includeDependencies"
@@ -496,10 +479,12 @@
}
</div>
@* Arms the confirm block below — it mutates page state, so it is a button,
not a javascript:void(0) anchor. Still a two-step: arm, then confirm. *@
<p class="small">
<a href="javascript:void(0)" class="link-danger" @onclick="OpenUnencryptedConfirm">
<button type="button" class="btn btn-sm btn-outline-danger" @onclick="OpenUnencryptedConfirm">
Export without encryption…
</a>
</button>
</p>
@if (_showUnencryptedConfirm)
@@ -509,11 +494,11 @@
in plaintext. Anyone with the file can read external-system credentials, SMTP
passwords, SMS auth tokens, and database connection strings. The audit log will record this as
<code>UnencryptedBundleExport</code>.
<div class="mt-2 d-flex gap-2">
<button class="btn btn-sm btn-danger" @onclick="ConfirmUnencryptedExport">
<div class="mt-2 btn-group btn-group-sm" role="group">
<button class="btn btn-danger" @onclick="ConfirmUnencryptedExport">
Yes, export without encryption
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelUnencryptedConfirm">
<button class="btn btn-outline-secondary" @onclick="CancelUnencryptedConfirm">
Cancel
</button>
</div>
@@ -83,12 +83,8 @@
private RenderFragment RenderStepUpload() => __builder =>
{
<div>
<p class="text-body-secondary">
Select a <code>.scadabundle</code> file produced by an exporter on this
or another cluster. The bundle's manifest will be validated immediately;
encrypted bundles will prompt for a passphrase on the next step.
</p>
@* No lede — the step pills above and the InputFile's own accept filter +
size hint already say what belongs here. *@
<div class="mb-3">
<label for="bundle-input" class="form-label">Bundle file</label>
<InputFile id="bundle-input" OnChange="OnFileSelectedAsync"
@@ -208,16 +204,14 @@
<div>
@RenderMapSection();
<p class="text-body-secondary">
Review each artifact in the bundle and choose how it should be applied
to this environment. Identical items are skipped automatically; new
items default to Add; modified items require an explicit choice.
</p>
@* Per-kind resolution defaults are documented in Component-Transport.md
§Diff classification; the Status badge shows each row's kind in place. *@
<div class="mb-3 d-flex flex-wrap gap-2 align-items-center" data-testid="bulk-actions">
<span class="small text-body-secondary">Apply to all modified:</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Skip)">Skip</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Overwrite)">Overwrite</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Skip)">Skip</button>
<button class="btn btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Overwrite)">Overwrite</button>
</div>
</div>
<div class="table-responsive" style="max-height: 480px; overflow-y: auto;">
@@ -239,7 +233,9 @@
var current = _resolutions[key];
<tr data-testid="diff-row">
<td><span class="badge bg-secondary">@item.EntityType</span></td>
<td>@item.Name</td>
@* Bundle-supplied (external) name — clip so it can't set the
table's width. .cell-clip is display:block, hence the span. *@
<td><span class="cell-clip" title="@item.Name">@item.Name</span></td>
<td>@RenderKindBadge(item)</td>
<td>@(item.ExistingVersion?.ToString() ?? "—")</td>
<td>@(item.IncomingVersion?.ToString() ?? "—")</td>
@@ -261,7 +257,10 @@
}
else
{
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
@* Raw JSON from an externally produced bundle —
wrap + cap the height rather than forcing the
already-scroll-boxed table to scroll sideways. *@
<pre class="small mb-0 detail-pre"><code>@item.FieldDiffJson</code></pre>
}
</details>
</td>
@@ -321,12 +320,10 @@
}
<div class="card mb-4" data-testid="map-section">
@* The Source identifier / Source name / Map to target headings and the
"Create new" option below carry the explanation this header used to. *@
<div class="card-header bg-body-tertiary">
<strong>Resolve site &amp; connection references</strong>
<span class="small text-body-secondary ms-2">
This bundle references sites/connections from its source environment.
Map each to an existing target, or create a new one.
</span>
</div>
<div class="card-body">
@if (hasSiteMappings)
@@ -346,7 +343,7 @@
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c) ? c : CreateNewValue;
<tr data-testid="map-site-row">
<td><code>@rsm.SourceSiteIdentifier</code></td>
<td>@rsm.SourceSiteName</td>
<td><span class="cell-clip" title="@rsm.SourceSiteName">@rsm.SourceSiteName</span></td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
@@ -396,7 +393,7 @@
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
var chosenConn = _connectionChoices.TryGetValue(key, out var cc) ? cc : CreateNewValue;
<tr data-testid="map-conn-row">
<td>@rcm.SourceConnectionName</td>
<td><span class="cell-clip" title="@rcm.SourceConnectionName">@rcm.SourceConnectionName</span></td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
@@ -563,7 +560,8 @@
<dd class="col-sm-9"><code>@_result.BundleImportId</code></dd>
</dl>
<div class="d-flex gap-3">
@* Two follow-on drill-ins for the same result — anchors are valid group members. *@
<div class="btn-group" role="group">
<a class="btn btn-outline-primary" href="/deployment/deployments">
View on Deployments →
</a>
@@ -100,8 +100,13 @@
@if (_notReporting.Count > 0)
{
<div class="text-muted small mb-2" data-test="alarm-summary-not-reporting">
Not reporting (@_notReporting.Count): @string.Join(", ", _notReporting)
@* The instance list is unbounded — a site with many silent instances would
otherwise push a wall of names across the page. Clamp to two lines and
keep the full list reachable via the title. *@
var notReportingList = string.Join(", ", _notReporting);
<div class="text-muted small mb-2 cell-clamp-2" data-test="alarm-summary-not-reporting"
title="@notReportingList">
Not reporting (@_notReporting.Count): @notReportingList
</div>
}
@@ -111,18 +111,17 @@
{
<tr><td colspan="7" class="text-muted text-center">No events found.</td></tr>
}
@for (int i = 0; i < _entries.Count; i++)
@foreach (var entry in _entries)
{
var idx = i;
var entry = _entries[idx];
var entryId = entry.Id;
var rowClass = entry.Severity == "Error" ? "table-danger"
: entry.Severity == "Warning" ? "table-warning"
: "";
var expanded = _expandedRows.Contains(idx);
var expanded = _expandedRows.Contains(entryId);
<tr class="@rowClass">
<td>
<button class="btn btn-link btn-sm p-0"
@onclick="() => ToggleRow(idx)"
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => ToggleRow(entryId)"
aria-label="@(expanded ? "Hide full message" : "View full message")">
@(expanded ? "Hide" : "View")
</button>
@@ -135,14 +134,16 @@
</span>
</td>
<td class="small">@(entry.InstanceId ?? "—")</td>
<td class="small">@entry.Source</td>
<td class="small text-truncate" style="max-width: 380px;">@entry.Message</td>
<td class="small">
<span class="cell-clip cell-clip-sm" title="@entry.Source">@entry.Source</span>
</td>
<td class="small text-truncate" style="max-width: 380px;" title="@entry.Message">@entry.Message</td>
</tr>
@if (expanded)
{
<tr class="@rowClass">
<td colspan="7">
<pre class="small mb-0">@entry.Message</pre>
<pre class="small mb-0 detail-pre">@entry.Message</pre>
</td>
</tr>
}
@@ -185,7 +186,13 @@
private bool _searching;
private string? _errorMessage;
private ToastNotification _toast = default!;
private readonly HashSet<int> _expandedRows = new();
// Keyed by EventLogEntry.Id — the site-minted GUID string — NOT by the row's
// position in _entries. LoadMore() appends a page without clearing this set, so
// index keys only survived because appends happen to keep earlier indices stable;
// any change to that (re-sort, in-place refresh, prepend) would have silently
// moved every expansion onto the wrong row. The id is stable by construction.
private readonly HashSet<string> _expandedRows = new();
private int ActiveFilterCount
{
@@ -225,11 +232,11 @@
private async Task LoadMore() => await FetchPage();
private void ToggleRow(int idx)
private void ToggleRow(string entryId)
{
if (!_expandedRows.Add(idx))
if (!_expandedRows.Add(entryId))
{
_expandedRows.Remove(idx);
_expandedRows.Remove(entryId);
}
}
@@ -29,55 +29,77 @@
</div>
</div>
@* Headline KPI groups. Each is a self-contained panel, so each gets its own card
boundary — the same treatment the Site Health Trends group below already uses,
including tiles-that-are-cards nested in a card body (Trends does exactly that
with KpiTrendChart). The two component-rendered groups own their heading and
"View details" link internally, so the Notification Outbox group keeps its
heading inside the card body too rather than promoting it to a card-header:
uniformity across the panels beats matching the Trends card, whose card-header
exists to carry the site selector and window toggle. card-body pb-0 lets each
tile row's own mb-3 supply the bottom padding instead of doubling it. *@
@* Notification Outbox headline KPIs — a central concern, shown regardless of site reports *@
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Notification Outbox</h6>
<a class="small" href="/notifications/kpis">View details &rarr;</a>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<h3 class="mb-0">@OutboxTileValue(_outboxKpi.QueueDepth)</h3>
<small class="text-muted">Queue Depth</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "border-warning" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "text-warning" : "")">@OutboxTileValue(_outboxKpi.StuckCount)</h3>
<small class="text-muted">Stuck</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "border-danger" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "text-danger" : "")">@OutboxTileValue(_outboxKpi.ParkedCount)</h3>
<small class="text-muted">Parked</small>
<div class="card mb-3">
<div class="card-body pb-0">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Notification Outbox</h6>
<a class="small" href="/notifications/kpis">View details &rarr;</a>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<h3 class="mb-0">@OutboxTileValue(_outboxKpi.QueueDepth)</h3>
<small class="text-muted">Queue Depth</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "border-warning" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "text-warning" : "")">@OutboxTileValue(_outboxKpi.StuckCount)</h3>
<small class="text-muted">Stuck</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "border-danger" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "text-danger" : "")">@OutboxTileValue(_outboxKpi.ParkedCount)</h3>
<small class="text-muted">Parked</small>
</div>
</div>
</div>
</div>
@if (!_outboxKpiAvailable && _outboxKpiError != null)
{
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
}
</div>
</div>
@if (!_outboxKpiAvailable && _outboxKpiError != null)
{
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
}
@* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel
(buffered / stuck / parked). Refreshed alongside the site states. *@
<SiteCallKpiTiles Snapshot="@_siteCallKpi"
IsAvailable="@_siteCallKpiAvailable"
ErrorMessage="@_siteCallKpiError"
PerNodeSnapshots="@_siteCallNodeKpis"
PerNodeAvailable="@_siteCallNodeKpiAvailable" />
<div class="card mb-3">
<div class="card-body pb-0">
<SiteCallKpiTiles Snapshot="@_siteCallKpi"
IsAvailable="@_siteCallKpiAvailable"
ErrorMessage="@_siteCallKpiError"
PerNodeSnapshots="@_siteCallNodeKpis"
PerNodeAvailable="@_siteCallNodeKpiAvailable" />
</div>
</div>
@* Three KPI tiles for the Audit channel
(volume / error rate / backlog). Refreshed alongside the site states. *@
<AuditKpiTiles Snapshot="@_auditKpi"
IsAvailable="@_auditKpiAvailable"
ErrorMessage="@_auditKpiError" />
<div class="card mb-3">
<div class="card-body pb-0">
<AuditKpiTiles Snapshot="@_auditKpi"
IsAvailable="@_auditKpiAvailable"
ErrorMessage="@_auditKpiError" />
</div>
</div>
@* Site Health Trends (M6) — per-site Site Health KPI history. Loads on a
separate path from the 10s tile-refresh timer so a trend-query fault can
@@ -160,29 +182,38 @@
}
else
{
@* Overview cards *@
<div class="row g-3 mb-3">
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-success h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-success">@_siteStates.Values.Count(s => s.IsOnline)</h3>
<small class="text-muted">Sites Online</small>
</div>
@* Overview cards — the fourth headline tile row, so it takes the same card
boundary as the three above it. It was also the only tile row on the page
with no heading at all; it now carries one for the same reason. *@
<div class="card mb-3">
<div class="card-body pb-0">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Sites</h6>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-danger h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-danger">@_siteStates.Values.Count(s => !s.IsOnline)</h3>
<small class="text-muted">Sites Offline</small>
<div class="row g-3 mb-3">
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-success h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-success">@_siteStates.Values.Count(s => s.IsOnline)</h3>
<small class="text-muted">Sites Online</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-warning h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-warning">@_siteStates.Values.Count(SiteHasActiveErrors)</h3>
<small class="text-muted">Sites with active errors</small>
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-danger h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-danger">@_siteStates.Values.Count(s => !s.IsOnline)</h3>
<small class="text-muted">Sites Offline</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-warning h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-warning">@_siteStates.Values.Count(SiteHasActiveErrors)</h3>
<small class="text-muted">Sites with active errors</small>
</div>
</div>
</div>
</div>
</div>
@@ -196,26 +227,36 @@
var detailsCollapseId = $"site-details-{siteId}";
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center py-2">
@* Display name and machine id are two different things and are read
for two different reasons, so they get two lines: chips + name on
the first, the site identifier on its own beneath it. Reference
shape: Pages/Admin/Sites.razor (card title + monospace identifier). *@
<div>
@if (state.IsOnline)
<div class="d-flex align-items-center flex-wrap gap-2">
@if (state.IsOnline)
{
<span class="badge bg-success" aria-label="State: Online">@OnlineGlyph Online</span>
}
else
{
<span class="badge bg-danger" aria-label="State: Offline">@OfflineGlyph Offline</span>
}
<strong class="fs-5">@siteName</strong>
@if (state.IsOnline && state.IsMetricsStale)
{
<span class="badge bg-warning text-dark"
title="Site is heartbeating but has sent no health report for over @StaleTimeoutDisplay — the metrics pipeline may be dead.">
Metrics stale
</span>
}
@if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt)
{
<small class="text-muted">offline since @changedAt.ToString("u")</small>
}
</div>
@if (!isCentral)
{
<span class="badge bg-success me-2" aria-label="State: Online">@OnlineGlyph Online</span>
}
else
{
<span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
}
<strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong>
@if (state.IsOnline && state.IsMetricsStale)
{
<span class="badge bg-warning text-dark ms-2"
title="Site is heartbeating but has sent no health report for over @StaleTimeoutDisplay — the metrics pipeline may be dead.">
Metrics stale
</span>
}
@if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt)
{
<small class="text-muted ms-2">offline since @changedAt.ToString("u")</small>
<div class="small text-muted font-monospace">@siteId</div>
}
</div>
<div class="d-flex align-items-center gap-3">
@@ -236,193 +277,230 @@
{
var report = state.LatestReport;
<div class="row g-3">
@* Each logical group below sits in its own card, with the group's
head promoted to a real card-header, instead of h6 rules stacked
inside one undifferentiated body. Columns 3 and 4 keep their
existing single collapse region (and its id) spanning both of
their sub-sections: the disclosure toggle IS the card-header and
the two sub-heads live inside the one card body. *@
@* Column 1: Nodes *@
<div class="col-md-6">
<h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
@if (report.ClusterNodes is { Count: > 0 })
{
@foreach (var node in report.ClusterNodes)
{
<tr>
<td class="small">@node.Hostname</td>
<td>
<span class="badge @(node.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(node.IsOnline ? "Online" : "Offline")">
@(node.IsOnline ? OnlineGlyph : OfflineGlyph) @(node.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
<span class="badge @(node.Role == "Primary" ? "bg-primary" : "bg-secondary")"
aria-label="State: @node.Role">
@(node.Role == "Primary" ? PrimaryGlyph : StandbyGlyph) @node.Role
</span>
</td>
</tr>
}
}
else
{
<tr>
<td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td>
<td>
<span class="badge @(state.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(state.IsOnline ? "Online" : "Offline")">
@(state.IsOnline ? OnlineGlyph : OfflineGlyph) @(state.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
@{
var roleLabel = report.NodeRole == "Active" ? "Primary" : "Standby";
<div class="card h-100">
<div class="card-header py-2">
<h6 class="text-muted mb-0">Nodes (@NodeRowCount(state))</h6>
</div>
<div class="card-body p-2">
<table class="table table-sm table-borderless mb-0">
<tbody>
@if (report.ClusterNodes is { Count: > 0 })
{
@foreach (var node in report.ClusterNodes)
{
<tr>
<td class="small">@node.Hostname</td>
<td>
<span class="badge @(node.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(node.IsOnline ? "Online" : "Offline")">
@(node.IsOnline ? OnlineGlyph : OfflineGlyph) @(node.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
<span class="badge @(node.Role == "Primary" ? "bg-primary" : "bg-secondary")"
aria-label="State: @node.Role">
@(node.Role == "Primary" ? PrimaryGlyph : StandbyGlyph) @node.Role
</span>
</td>
</tr>
}
<span class="badge @(report.NodeRole == "Active" ? "bg-primary" : "bg-secondary")"
aria-label="State: @roleLabel">
@(roleLabel == "Primary" ? PrimaryGlyph : StandbyGlyph) @roleLabel
</span>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<tr>
<td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td>
<td>
<span class="badge @(state.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(state.IsOnline ? "Online" : "Offline")">
@(state.IsOnline ? OnlineGlyph : OfflineGlyph) @(state.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
@{
var roleLabel = report.NodeRole == "Active" ? "Primary" : "Standby";
}
<span class="badge @(report.NodeRole == "Active" ? "bg-primary" : "bg-secondary")"
aria-label="State: @roleLabel">
@(roleLabel == "Primary" ? PrimaryGlyph : StandbyGlyph) @roleLabel
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@* Column 2: Data Connections (collapsible) *@
<div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-conns")"
aria-expanded="false">
Data Connections (@report.DataConnectionStatuses.Count)
</button>
<div class="collapse" id="@($"{detailsCollapseId}-conns")">
@if (report.DataConnectionStatuses.Count == 0)
{
<span class="text-muted small">None</span>
}
else
{
@foreach (var (connName, health) in report.DataConnectionStatuses)
{
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
<div class="mb-2">
<div class="d-flex justify-content-between">
<strong class="small">@connName</strong>
<span class="small">@(endpoint ?? health.ToString())</span>
</div>
@if (quality != null)
<div class="card h-100">
<div class="card-header py-2">
<button class="btn btn-link btn-sm p-0 text-decoration-none"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-conns")"
aria-expanded="false">
Data Connections (@report.DataConnectionStatuses.Count)
</button>
</div>
<div class="collapse" id="@($"{detailsCollapseId}-conns")">
<div class="card-body p-2">
@if (report.DataConnectionStatuses.Count == 0)
{
<span class="text-muted small">None</span>
}
else
{
@foreach (var (connName, health) in report.DataConnectionStatuses)
{
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small text-muted py-0">Tags good</td>
<td class="small text-end py-0">@quality.Good.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags bad</td>
<td class="small text-end py-0">@quality.Bad.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags uncertain</td>
<td class="small text-end py-0">@quality.Uncertain.ToString("N0")</td>
</tr>
</tbody>
</table>
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
var endpointText = endpoint ?? health.ToString();
<div class="mb-2">
@* Both halves are site-supplied strings — a long connection
name or endpoint URL would otherwise wrap this flex row
into several lines. Clip both, full value on the title. *@
<div class="d-flex justify-content-between gap-2">
<strong class="small cell-clip cell-clip-sm" title="@connName">@connName</strong>
<span class="small cell-clip cell-clip-sm text-end" title="@endpointText">@endpointText</span>
</div>
@if (quality != null)
{
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small text-muted py-0">Tags good</td>
<td class="small text-end py-0">@quality.Good.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags bad</td>
<td class="small text-end py-0">@quality.Bad.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags uncertain</td>
<td class="small text-end py-0">@quality.Uncertain.ToString("N0")</td>
</tr>
</tbody>
</table>
}
</div>
}
</div>
}
}
}
</div>
</div>
</div>
</div>
@* Column 3: Instances + Store-and-Forward (collapsible) *@
<div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-queues")"
aria-expanded="false">
Instances &amp; Queues
</button>
<div class="collapse" id="@($"{detailsCollapseId}-queues")">
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Deployed</td>
<td class="text-end">@report.DeployedInstanceCount</td>
</tr>
<tr>
<td class="small">Enabled</td>
<td class="text-end text-success">@report.EnabledInstanceCount</td>
</tr>
<tr>
<td class="small">Disabled</td>
<td class="text-end">@report.DisabledInstanceCount</td>
</tr>
</tbody>
</table>
<div class="card h-100">
<div class="card-header py-2">
<button class="btn btn-link btn-sm p-0 text-decoration-none"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-queues")"
aria-expanded="false">
Instances &amp; Queues
</button>
</div>
<div class="collapse" id="@($"{detailsCollapseId}-queues")">
<div class="card-body p-2">
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Deployed</td>
<td class="text-end">@report.DeployedInstanceCount</td>
</tr>
<tr>
<td class="small">Enabled</td>
<td class="text-end text-success">@report.EnabledInstanceCount</td>
</tr>
<tr>
<td class="small">Disabled</td>
<td class="text-end">@report.DisabledInstanceCount</td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Store-and-Forward Buffers</h6>
@if (report.StoreAndForwardBufferDepths.Count == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@category</span>
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
</div>
}
}
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">
Store-and-Forward Buffers (@report.StoreAndForwardBufferDepths.Count)
</h6>
@if (report.StoreAndForwardBufferDepths.Count == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@category</span>
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
</div>
}
}
</div>
</div>
</div>
</div>
@* Column 4: Error Counts + Parked Messages (collapsible) *@
<div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-errors")"
aria-expanded="false">
Errors &amp; Parked Messages
</button>
<div class="collapse" id="@($"{detailsCollapseId}-errors")">
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Script Errors</td>
<td class="text-end">
<span class="@(report.ScriptErrorCount > 0 ? "text-danger fw-bold" : "")">@report.ScriptErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Alarm Eval Errors</td>
<td class="text-end">
<span class="@(report.AlarmEvaluationErrorCount > 0 ? "text-warning fw-bold" : "")">@report.AlarmEvaluationErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Dead Letters</td>
<td class="text-end">
<span class="@(report.DeadLetterCount > 0 ? "text-danger fw-bold" : "")">@report.DeadLetterCount</span>
</td>
</tr>
</tbody>
</table>
<div class="card h-100">
<div class="card-header py-2">
<button class="btn btn-link btn-sm p-0 text-decoration-none"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-errors")"
aria-expanded="false">
Errors &amp; Parked Messages
</button>
</div>
<div class="collapse" id="@($"{detailsCollapseId}-errors")">
<div class="card-body p-2">
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Script Errors</td>
<td class="text-end">
<span class="@(report.ScriptErrorCount > 0 ? "text-danger fw-bold" : "")">@report.ScriptErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Alarm Eval Errors</td>
<td class="text-end">
<span class="@(report.AlarmEvaluationErrorCount > 0 ? "text-warning fw-bold" : "")">@report.AlarmEvaluationErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Dead Letters</td>
<td class="text-end">
<span class="@(report.DeadLetterCount > 0 ? "text-danger fw-bold" : "")">@report.DeadLetterCount</span>
</td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
@if (report.ParkedMessageCount == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
}
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
@if (report.ParkedMessageCount == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
}
</div>
</div>
</div>
</div>
</div>
@@ -458,6 +536,13 @@
? nodes.Count(n => n.IsOnline)
: (state.IsOnline ? 1 : 0);
// Rows the Nodes group actually renders: the reported cluster-node list when the
// site sends one, otherwise the single synthesized fallback row. Shown in that
// group's card-header so the count is legible without reading the table — the same
// metadata-in-the-header treatment the Data Connections group already had inline.
private static int NodeRowCount(SiteHealthState state) =>
state.LatestReport?.ClusterNodes is { Count: > 0 } nodes ? nodes.Count : 1;
private static string FormatDuration(TimeSpan span) =>
span.TotalMinutes >= 1 && span == TimeSpan.FromMinutes(Math.Round(span.TotalMinutes))
? $"{span.TotalMinutes:0} minute{(span.TotalMinutes == 1 ? "" : "s")}"
@@ -115,16 +115,20 @@
{
<div class="alert alert-secondary py-2 d-flex align-items-center mb-3">
<strong class="me-3">@_selectedIds.Count selected</strong>
<button class="btn btn-outline-success btn-sm me-2"
@onclick="BulkRetry" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry selected
</button>
<button class="btn btn-outline-danger btn-sm me-2"
@onclick="BulkDiscard" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard selected
</button>
<div class="btn-group btn-group-sm" role="group" aria-label="Bulk actions">
<button class="btn btn-outline-success"
@onclick="BulkRetry" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry selected
</button>
<button class="btn btn-outline-danger"
@onclick="BulkDiscard" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard selected
</button>
</div>
@* The dismiss control is not a bulk action — it stays outside the group so
it keeps its ms-auto right-alignment and its btn-close styling. *@
<button type="button" class="btn-close ms-auto"
aria-label="Clear selection" @onclick="ClearSelection"></button>
</div>
@@ -236,10 +240,10 @@
<span class="text-muted small">
Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total
</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1"
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
<button class="btn btn-outline-secondary"
@onclick="PrevPage" disabled="@(_pageNumber <= 1)">Previous</button>
<button class="btn btn-outline-secondary btn-sm"
<button class="btn btn-outline-secondary"
@onclick="NextPage" disabled="@(_messages.Count < _pageSize)">Next</button>
</div>
</div>
@@ -247,15 +251,19 @@
}
</div>
@if (_drawerMessage != null)
@* Re-resolved from the CURRENT page every render (see DrawerMessage). A row that
has been retried, discarded or paged away simply resolves to null and the drawer
renders nothing — it can never keep showing a row that no longer exists. *@
@{ var drawer = DrawerMessage; }
@if (drawer != null)
{
<div class="offcanvas-backdrop fade show" @onclick="CloseDrawer"></div>
<div class="offcanvas offcanvas-end show parked-drawer" tabindex="-1" style="visibility: visible;">
<div class="offcanvas-header border-bottom">
<div>
<div class="text-muted small text-uppercase">Parked message</div>
<h5 class="offcanvas-title mb-0">@_drawerMessage.TargetSystem</h5>
<div class="small text-muted">@_drawerMessage.MethodName</div>
<h5 class="offcanvas-title mb-0">@drawer.TargetSystem</h5>
<div class="small text-muted">@drawer.MethodName</div>
</div>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseDrawer"></button>
</div>
@@ -263,17 +271,17 @@
<dl class="row mb-3">
<dt class="col-4 text-muted fw-normal">Message ID</dt>
<dd class="col-8 d-flex align-items-center gap-2">
<code class="text-truncate" style="min-width: 0;">@_drawerMessage.MessageId</code>
<code class="text-truncate" style="min-width: 0;">@drawer.MessageId</code>
<button class="btn btn-link btn-sm p-0" title="Copy message ID"
@onclick="() => CopyAsync(_drawerMessage.MessageId)">📋</button>
@onclick="() => CopyAsync(drawer.MessageId)">📋</button>
</dd>
<dt class="col-4 text-muted fw-normal">Category</dt>
<dd class="col-8">@CategoryLabel(_drawerMessage.Category)</dd>
<dd class="col-8">@CategoryLabel(drawer.Category)</dd>
<dt class="col-4 text-muted fw-normal">Origin instance</dt>
<dd class="col-8">
@if (!string.IsNullOrEmpty(_drawerMessage.OriginInstance))
@if (!string.IsNullOrEmpty(drawer.OriginInstance))
{
<code>@_drawerMessage.OriginInstance</code>
<code>@drawer.OriginInstance</code>
}
else
{
@@ -281,21 +289,21 @@
}
</dd>
<dt class="col-4 text-muted fw-normal">Attempts</dt>
<dd class="col-8 font-monospace">@_drawerMessage.AttemptCount / @_drawerMessage.MaxAttempts</dd>
<dd class="col-8 font-monospace">@drawer.AttemptCount / @drawer.MaxAttempts</dd>
<dt class="col-4 text-muted fw-normal">Originally enqueued</dt>
<dd class="col-8">
@Relative(_drawerMessage.OriginalTimestamp)
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.OriginalTimestamp)</span>
@Relative(drawer.OriginalTimestamp)
<span class="text-muted">· @AbsoluteUtc(drawer.OriginalTimestamp)</span>
</dd>
<dt class="col-4 text-muted fw-normal">Last attempt</dt>
<dd class="col-8">
@Relative(_drawerMessage.LastAttemptTimestamp)
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.LastAttemptTimestamp)</span>
@Relative(drawer.LastAttemptTimestamp)
<span class="text-muted">· @AbsoluteUtc(drawer.LastAttemptTimestamp)</span>
</dd>
</dl>
<div class="text-muted text-uppercase small fw-semibold mb-1">Error</div>
<pre class="bg-body-secondary border rounded p-2 small mb-0 parked-error-pre">@_drawerMessage.ErrorMessage</pre>
<pre class="bg-body-secondary border rounded p-2 small mb-0 parked-error-pre">@drawer.ErrorMessage</pre>
</div>
<div class="border-top p-3 d-flex gap-2">
<button class="btn btn-outline-success btn-sm flex-grow-1"
@@ -355,8 +363,21 @@
private bool _actionInProgress;
private string? _activeAction;
// Drawer
private ParkedMessageEntry? _drawerMessage;
// Drawer — holds the row's ID, never the row object. FetchPage() replaces
// _messages wholesale after every Retry/Discard and on every page change, so a
// captured ParkedMessageEntry would outlive its row and keep rendering values
// (attempt count, last-attempt time, error) that no longer exist server-side;
// PrevPage/NextPage do not clear the drawer, so it could even sit open over a
// page that never contained it. Mirrors ExecutionTreePage's _modalExecutionId.
private string? _drawerMessageId;
// Re-resolves the open drawer's row from the CURRENT page on every render.
// A row that has been retried, discarded or paged away resolves to null, which
// renders the drawer away — the drawer self-closes rather than going stale.
private ParkedMessageEntry? DrawerMessage =>
_drawerMessageId is null
? null
: _messages?.FirstOrDefault(m => m.MessageId == _drawerMessageId);
private ToastNotification _toast = default!;
@@ -384,7 +405,7 @@
{
_messages = null;
_selectedIds.Clear();
_drawerMessage = null;
_drawerMessageId = null;
}
}
@@ -392,7 +413,7 @@
{
_pageNumber = 1;
_selectedIds.Clear();
_drawerMessage = null;
_drawerMessageId = null;
await FetchPage();
}
@@ -551,21 +572,23 @@
// ── Drawer ──
private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessage = msg;
private void CloseDrawer() => _drawerMessage = null;
private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessageId = msg.MessageId;
private void CloseDrawer() => _drawerMessageId = null;
private async Task RetryFromDrawer()
{
if (_drawerMessage == null) return;
var msg = _drawerMessage;
// Snapshot the resolved row before acting — RetrySingle awaits FetchPage(),
// after which DrawerMessage no longer resolves.
var msg = DrawerMessage;
if (msg == null) return;
await RetrySingle(msg);
CloseDrawer();
}
private async Task DiscardFromDrawer()
{
if (_drawerMessage == null) return;
var msg = _drawerMessage;
var msg = DrawerMessage;
if (msg == null) return;
var ok = await DiscardSingle(msg);
if (ok) CloseDrawer();
}
@@ -107,11 +107,12 @@
{
@foreach (var r in _recipients)
{
var contact = _type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress;
<tr>
<td>@r.Name</td>
<td>@(_type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)</td>
<td><span class="cell-clip cell-clip-sm" title="@r.Name">@r.Name</span></td>
<td><span class="cell-clip" title="@contact">@contact</span></td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => DeleteRecipient(r)">Delete</button>
</td>
</tr>
}
@@ -57,7 +57,7 @@
var recipients = _recipients.GetValueOrDefault(list.Id)
?? (IReadOnlyList<NotificationRecipient>)Array.Empty<NotificationRecipient>();
<tr @key="list.Id">
<td>@list.Name</td>
<td><span class="cell-clip" title="@list.Name">@list.Name</span></td>
<td>@list.Type</td>
<td>
@if (recipients.Count == 0)
@@ -66,27 +66,42 @@
}
else
{
@foreach (var r in recipients)
@foreach (var r in recipients.Take(MaxVisibleRecipients))
{
@* Type-aware contact: SMS recipients carry a PhoneNumber and a null
EmailAddress, so an email-shaped badge would render "Name <>". Fall
back to whichever contact field is populated. *@
back to whichever contact field is populated. The two identifiers
are separated inside the chip — name, then contact in a lighter
weight — rather than fused into one "Name <contact>" run. *@
var contact = list.Type == NotificationType.Sms
? r.PhoneNumber
: r.EmailAddress;
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1">@r.Name &lt;@contact&gt;</span>
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1"
title="@r.Name — @contact">@r.Name<span class="fw-normal opacity-75 ms-1">@contact</span></span>
}
@if (recipients.Count > MaxVisibleRecipients)
{
@* Long lists roll up rather than filling the cell with chips.
The remainder stays readable on the tooltip, and the full set
is always available on the list's Edit page. *@
<span class="badge bg-body-secondary text-body-secondary border"
title="@RemainingRecipientsTitle(list, recipients)">
+@(recipients.Count - MaxVisibleRecipients) more
</span>
}
}
</td>
<td class="text-end">
<button class="btn btn-outline-primary btn-sm me-1"
@onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
Edit
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DeleteList(list)">
Delete
</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary"
@onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
Edit
</button>
<button class="btn btn-outline-danger"
@onclick="() => DeleteList(list)">
Delete
</button>
</div>
</td>
</tr>
}
@@ -97,6 +112,13 @@
</div>
@code {
/// <summary>
/// Recipient chips rendered inline before the "+N more" roll-up. A list may
/// hold dozens of recipients and the cell is uncapped, so the grid would
/// otherwise render one chip per recipient into a single row.
/// </summary>
private const int MaxVisibleRecipients = 5;
private bool _loading = true;
private string? _errorMessage;
private List<NotificationList> _lists = new();
@@ -125,6 +147,16 @@
_loading = false;
}
/// <summary>
/// Tooltip for the "+N more" roll-up chip: the recipients that were not given
/// a chip of their own, using the same type-aware contact field as the chips.
/// </summary>
private static string RemainingRecipientsTitle(
NotificationList list, IReadOnlyList<NotificationRecipient> recipients) =>
string.Join(", ", recipients
.Skip(MaxVisibleRecipients)
.Select(r => $"{r.Name} — {(list.Type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)}"));
private async Task DeleteList(NotificationList list)
{
if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true))
@@ -153,7 +153,9 @@
<td>@n.Type</td>
<td>@n.ListName</td>
<td>
@n.Subject
@* Subject is script-authored free text, so it is bounded
the same way the Last error line below it is. *@
<span class="cell-clip" title="@n.Subject">@n.Subject</span>
@if (!string.IsNullOrEmpty(n.LastError))
{
<div class="small text-danger text-truncate" style="max-width: 320px;"
@@ -176,22 +178,24 @@
@* NotificationId is the audit
CorrelationId, so the link deep-links into the central Audit
Log pre-filtered to this notification's lifecycle events. *@
<a class="btn btn-outline-secondary btn-sm me-1"
href="/audit/log?correlationId=@n.NotificationId"
data-test="audit-link-@n.NotificationId">
View audit history
</a>
@if (n.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm me-1"
@onclick="() => RetryNotification(n)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardNotification(n)" disabled="@_actionInProgress">
Discard
</button>
}
<div class="btn-group btn-group-sm" role="group">
<a class="btn btn-outline-secondary"
href="/audit/log?correlationId=@n.NotificationId"
data-test="audit-link-@n.NotificationId">
View audit history
</a>
@if (n.Status == "Parked")
{
<button class="btn btn-outline-success"
@onclick="() => RetryNotification(n)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger"
@onclick="() => DiscardNotification(n)" disabled="@_actionInProgress">
Discard
</button>
}
</div>
</td>
</tr>
}
@@ -209,10 +213,14 @@
}
</div>
@* ── Row detail modal ── *@
@if (_detailNotification != null)
@* ── Row detail modal ──
The modal holds only the row's NotificationId and re-resolves the summary from
the page currently on screen on every render (DetailRow()). A refresh that
replaces the row list therefore never leaves this surface rendering a stale
record, and a row that has left the page closes the modal instead of stranding
it. *@
@if (DetailRow() is { } d)
{
var d = _detailNotification;
<div class="modal show d-block sb-modal-backdrop" tabindex="-1"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
@@ -334,14 +342,16 @@
<div class="modal-footer">
@if (d.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
</div>
}
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
</div>
@@ -370,12 +380,28 @@
private string? _listError;
private bool _actionInProgress;
// Row detail modal
private NotificationSummary? _detailNotification;
// Row detail modal. Only the row's IDENTIFIER is held — never the
// NotificationSummary record itself — so the modal cannot outlive the row it
// was opened for. DetailRow() re-resolves it from the page currently on screen
// on every render; a row that has left the page resolves to null and the modal
// renders nothing (it self-closes) rather than showing a stale snapshot.
private string? _detailNotificationId;
private NotificationDetail? _detail;
private bool _detailLoading;
private string? _detailError;
/// <summary>
/// Resolves the row the detail modal is open for from the page currently on
/// screen. Returns null when no row is selected, when the list has not loaded,
/// or when the selected row is no longer in the page — the modal's visibility
/// gate, so a refresh that drops the row closes the surface instead of leaving
/// it stranded on a stale record.
/// </summary>
private NotificationSummary? DetailRow() =>
_detailNotificationId is { } id
? _notifications?.FirstOrDefault(n => n.NotificationId == id)
: null;
// Filters
private string _statusFilter = string.Empty;
private string _typeFilter = string.Empty;
@@ -544,7 +570,7 @@
{
// The summary fields render immediately; Body + recipients fill in once the
// full-detail fetch completes.
_detailNotification = n;
_detailNotificationId = n.NotificationId;
_detail = null;
_detailError = null;
_detailLoading = true;
@@ -572,7 +598,7 @@
private void CloseDetail()
{
_detailNotification = null;
_detailNotificationId = null;
_detail = null;
_detailError = null;
_detailLoading = false;
@@ -53,7 +53,11 @@
<div class="col-md-4 text-muted">Messaging Service SID</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.MessagingServiceSid) ? "(not set)" : sms.MessagingServiceSid)</div>
<div class="col-md-4 text-muted">API Base URL</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.ApiBaseUrl) ? "(provider default)" : sms.ApiBaseUrl)</div>
@* An override base URL is an operator-supplied URL with no
break opportunities — clip it so it cannot widen the card. *@
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@sms.ApiBaseUrl">@(string.IsNullOrWhiteSpace(sms.ApiBaseUrl) ? "(provider default)" : sms.ApiBaseUrl)</span>
</div>
<div class="col-md-4 text-muted">Auth Token</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.AuthToken) ? "(not set)" : "(stored)")</div>
<div class="col-md-4 text-muted">Connection Timeout</div>
@@ -122,8 +126,10 @@
<div class="col-12"><div class="text-danger small">@_formError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
<div class="btn-group" role="group">
<button class="btn btn-outline-secondary" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
</div>
</div>
</div>
</div>
@@ -38,7 +38,10 @@
{
<div class="card mb-3" @key="smtp.Id">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>@smtp.Host</strong>
@* Under the Ews transport Host is a full EWS endpoint URL, not a
host name — clip the card title so it cannot push the Edit
button off the header. *@
<strong class="cell-clip cell-clip-lg" title="@smtp.Host">@smtp.Host</strong>
@if (_editingSmtp?.Id != smtp.Id || !_showForm)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartEdit(smtp)">Edit</button>
@@ -51,12 +54,16 @@
@if (IsEws(smtp.Transport))
{
<div class="col-md-4 text-muted">EWS URL</div>
<div class="col-md-8">@smtp.Host</div>
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@smtp.Host">@smtp.Host</span>
</div>
}
else
{
<div class="col-md-4 text-muted">Host</div>
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@($"{smtp.Host}:{smtp.Port}")">@smtp.Host:@smtp.Port</span>
</div>
}
<div class="col-md-4 text-muted">Auth Type</div>
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
@@ -66,15 +73,23 @@
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
}
<div class="col-md-4 text-muted">From Address</div>
<div class="col-md-8">@smtp.FromAddress</div>
<div class="col-md-8">
<span class="cell-clip" title="@smtp.FromAddress">@smtp.FromAddress</span>
</div>
<div class="col-md-4 text-muted">Credentials</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
@if (string.Equals(smtp.AuthType, "OAuth2", StringComparison.OrdinalIgnoreCase))
{
@* Authority/Scope are operator-supplied URLs/URIs with no
break opportunities — clipped for the same reason. *@
<div class="col-md-4 text-muted">OAuth2 Authority</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</div>
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@smtp.OAuth2Authority">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</span>
</div>
<div class="col-md-4 text-muted">OAuth2 Scope</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</div>
<div class="col-md-8">
<span class="cell-clip cell-clip-lg" title="@smtp.OAuth2Scope">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</span>
</div>
}
</div>
</div>
@@ -152,8 +167,10 @@
<div class="col-12"><div class="text-danger small">@_formError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
<div class="btn-group" role="group">
<button class="btn btn-outline-secondary" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
</div>
</div>
</div>
</div>
@@ -30,7 +30,6 @@
<div class="d-flex align-items-baseline flex-wrap mb-3">
<h4 class="mb-0 me-3">Secured Writes</h4>
<span class="text-muted small">Two-person MxGateway writes — operator submits, a different verifier approves.</span>
</div>
@* ── Operator region: submit form ───────────────────────────────────── *@
@@ -142,8 +141,11 @@
<tr @key="row.Id" data-test="secured-write-pending-row">
<td>@row.SiteId</td>
<td>@row.ConnectionName</td>
<td><code>@row.TagPath</code></td>
<td>@row.ValueJson</td>
@* Tag path and value are device/operator supplied and unbounded.
.cell-clip is display:block, so it goes on an inner element —
putting it on the <td> would drop the cell out of the row. *@
<td><code class="cell-clip" title="@row.TagPath">@row.TagPath</code></td>
<td><span class="cell-clip" title="@row.ValueJson">@row.ValueJson</span></td>
<td>@row.ValueType</td>
<td>@row.OperatorUser</td>
<td class="text-nowrap">@row.SubmittedAtUtc.ToString("u")</td>
@@ -152,18 +154,20 @@
{
<span class="text-muted small me-2" data-test="secured-write-own-note">your submission</span>
}
<button class="btn btn-outline-success btn-sm me-1"
@onclick="() => Approve(row)"
disabled="@(isOwn || _actionInProgress)"
data-test="secured-write-approve">
Approve
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => Reject(row)"
disabled="@(isOwn || _actionInProgress)"
data-test="secured-write-reject">
Reject
</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success"
@onclick="() => Approve(row)"
disabled="@(isOwn || _actionInProgress)"
data-test="secured-write-approve">
Approve
</button>
<button class="btn btn-outline-danger"
@onclick="() => Reject(row)"
disabled="@(isOwn || _actionInProgress)"
data-test="secured-write-reject">
Reject
</button>
</div>
</td>
</tr>
}
@@ -207,15 +211,21 @@
<tr @key="row.Id" data-test="secured-write-history-row">
<td>@row.SiteId</td>
<td>@row.ConnectionName</td>
<td><code>@row.TagPath</code></td>
<td>@row.ValueJson</td>
@* 11 columns here, and the three timestamps to the right are
text-nowrap — so the free-text cells clip narrow (-sm). *@
<td><code class="cell-clip cell-clip-sm" title="@row.TagPath">@row.TagPath</code></td>
<td><span class="cell-clip cell-clip-sm" title="@row.ValueJson">@row.ValueJson</span></td>
<td><span class="badge @StatusBadgeClass(row.Status)">@row.Status</span></td>
<td>@row.OperatorUser</td>
<td>@(row.VerifierUser ?? "—")</td>
<td class="text-nowrap">@row.SubmittedAtUtc.ToString("u")</td>
<td class="text-nowrap">@(row.DecidedAtUtc?.ToString("u") ?? "—")</td>
<td class="text-nowrap">@(row.ExecutedAtUtc?.ToString("u") ?? "—")</td>
<td class="text-danger small">@(row.ExecutionError ?? "")</td>
@* Raw device/MxGateway exception text in the last column — clamp to
two lines so one bad message can't set the whole table's width. *@
<td class="text-danger small">
<span class="cell-clamp-2" title="@row.ExecutionError">@(row.ExecutionError ?? "")</span>
</td>
</tr>
}
</tbody>
@@ -229,7 +239,7 @@
Showing @(_historyPage * HistoryPageSize + 1)@Math.Min((_historyPage + 1) * HistoryPageSize, _historyTotalCount)
of @_historyTotalCount
</span>
<div class="btn-group btn-group-sm">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="HistoryPrev"
disabled="@(_historyPage == 0)" data-test="secured-write-history-prev">Prev</button>
<button class="btn btn-outline-secondary" @onclick="HistoryNext"
@@ -156,7 +156,10 @@
<td><span class="small">@SiteName(c.SourceSite)</span></td>
<td><span class="small">@(c.SourceNode ?? "—")</span></td>
<td>@c.Channel</td>
<td>@c.Target</td>
@* Target is an external-system URL/method the site supplies —
free text this page does not control, so it is bounded the
same way the Last error cell below is. *@
<td><span class="cell-clip" title="@c.Target">@c.Target</span></td>
<td>
<span class="badge @StatusBadgeClass(c.Status)">@c.Status</span>
@if (c.IsStuck)
@@ -182,25 +185,27 @@
@* The TrackedOperationId is the audit CorrelationId, so the
link deep-links into the central Audit Log pre-filtered to
this cached call's lifecycle events. *@
<a class="btn btn-outline-secondary btn-sm me-1"
href="/audit/log?correlationId=@c.TrackedOperationId"
data-test="audit-link-@c.TrackedOperationId">
View audit history
</a>
@* Retry/Discard relay only on Parked rows — central relays the
action to the owning site; Failed and other statuses are not
actionable from central. *@
@if (c.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm me-1"
@onclick="() => RetrySiteCall(c)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardSiteCall(c)" disabled="@_actionInProgress">
Discard
</button>
}
<div class="btn-group btn-group-sm" role="group">
<a class="btn btn-outline-secondary"
href="/audit/log?correlationId=@c.TrackedOperationId"
data-test="audit-link-@c.TrackedOperationId">
View audit history
</a>
@* Retry/Discard relay only on Parked rows — central relays the
action to the owning site; Failed and other statuses are not
actionable from central. *@
@if (c.Status == "Parked")
{
<button class="btn btn-outline-success"
@onclick="() => RetrySiteCall(c)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger"
@onclick="() => DiscardSiteCall(c)" disabled="@_actionInProgress">
Discard
</button>
}
</div>
</td>
</tr>
}
@@ -284,10 +289,14 @@
</div>
</div>
@* ── Row detail modal ── *@
@if (_detailSiteCall != null)
@* ── Row detail modal ──
The modal holds only the row's TrackedOperationId and re-resolves the summary
from the page currently on screen on every render (DetailRow()). A refresh that
replaces the row list therefore never leaves this surface rendering a stale
record, and a row that has left the page closes the modal instead of stranding
it. *@
@if (DetailRow() is { } d)
{
var d = _detailSiteCall;
<div class="modal show d-block sb-modal-backdrop" tabindex="-1"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
@@ -372,14 +381,16 @@
<div class="modal-footer">
@if (d.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
</div>
}
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
</div>
@@ -82,8 +82,12 @@ public partial class SiteCallsReport
private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null);
private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor;
// Row detail modal
private SiteCallSummary? _detailSiteCall;
// Row detail modal. Only the row's IDENTIFIER is held — never the
// SiteCallSummary record itself — so the modal cannot outlive the row it was
// opened for. DetailRow() re-resolves it from the page currently on screen on
// every render; a row that has left the page resolves to null and the modal
// renders nothing (it self-closes) rather than showing a stale snapshot.
private Guid? _detailSiteCallId;
private SiteCallDetail? _detail;
private bool _detailLoading;
private string? _detailError;
@@ -123,6 +127,18 @@ public partial class SiteCallsReport
private bool HasNextPage => _nextCursor is not null;
/// <summary>
/// Resolves the row the detail modal is open for from the page currently on
/// screen. Returns null when no row is selected, when the list has not loaded,
/// or when the selected row is no longer in the page — the modal's visibility
/// gate, so a refresh that drops the row closes the surface instead of leaving
/// it stranded on a stale record.
/// </summary>
private SiteCallSummary? DetailRow() =>
_detailSiteCallId is { } id
? _siteCalls?.FirstOrDefault(c => c.TrackedOperationId == id)
: null;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
@@ -401,7 +417,7 @@ public partial class SiteCallsReport
{
// The summary fields render immediately from the grid row; the full detail
// (HttpStatus, all timestamps, LastError) fills in once the fetch completes.
_detailSiteCall = c;
_detailSiteCallId = c.TrackedOperationId;
_detail = null;
_detailError = null;
_detailLoading = true;
@@ -429,7 +445,7 @@ public partial class SiteCallsReport
private void CloseDetail()
{
_detailSiteCall = null;
_detailSiteCallId = null;
_detail = null;
_detailError = null;
_detailLoading = false;
@@ -278,9 +278,12 @@
<div class="mt-3" aria-hidden="true">
<svg viewBox="0 0 200 12" preserveAspectRatio="none"
style="width:100%; height:10px; border-radius:5px; overflow:hidden;">
<rect x="0" y="0" width="20" height="12" fill="#f8d7da" />
<rect x="20" y="0" width="160" height="12" fill="#d1e7dd" />
<rect x="180" y="0" width="20" height="12" fill="#f8d7da" />
@* Token-driven fills, not literal hex: this preview renders on a
#1b1d21 surface under [data-bs-theme="dark"], where Bootstrap's
light subtle tints read as bright pastel bars. *@
<rect x="0" y="0" width="20" height="12" fill="var(--bad-bg)" />
<rect x="20" y="0" width="160" height="12" fill="var(--ok-bg)" />
<rect x="180" y="0" width="20" height="12" fill="var(--bad-bg)" />
</svg>
<div class="d-flex justify-content-between small text-muted mt-1">
<span>alarm</span>
@@ -25,9 +25,11 @@
</div>
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit"
disabled="@(_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName))">Compose</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary" @onclick="Submit"
disabled="@(_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName))">Compose</button>
</div>
</div>
@code {
@@ -47,12 +47,14 @@
@if (state.Kind != DialogKind.Custom)
{
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Cancel">Cancel</button>
<button type="button"
class="btn @(state.Danger ? "btn-danger" : "btn-primary") btn-sm"
@onclick="Confirm">
@ConfirmLabel(state)
</button>
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
<button type="button"
class="btn @(state.Danger ? "btn-danger" : "btn-primary")"
@onclick="Confirm">
@ConfirmLabel(state)
</button>
</div>
</div>
}
</div>
@@ -5,15 +5,19 @@
@if (ShowToolbar)
{
<div class="d-flex justify-content-end align-items-center gap-3 mb-1 small text-muted">
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="FormatAsync"
title="Format document (Ctrl/Cmd+Shift+F)">Format</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleWrap"
title="Word wrap">@(_wrap ? "Wrap on" : "Wrap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleMinimap"
title="Toggle minimap">@(_minimap ? "Minimap on" : "Minimap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleTheme"
title="Toggle theme">@(_dark ? "Dark" : "Light")</button>
@* Four editor-state toggles: actions on the editor, not navigation — so they
are outline buttons in one segmented group, not link buttons. *@
<div class="d-flex justify-content-end align-items-center mb-1">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" @onclick="FormatAsync"
title="Format document (Ctrl/Cmd+Shift+F)">Format</button>
<button type="button" class="btn btn-outline-secondary" @onclick="ToggleWrap"
title="Word wrap">@(_wrap ? "Wrap on" : "Wrap off")</button>
<button type="button" class="btn btn-outline-secondary" @onclick="ToggleMinimap"
title="Toggle minimap">@(_minimap ? "Minimap on" : "Minimap off")</button>
<button type="button" class="btn btn-outline-secondary" @onclick="ToggleTheme"
title="Toggle theme">@(_dark ? "Dark" : "Light")</button>
</div>
</div>
}
@@ -31,9 +31,11 @@ else
}
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit"
disabled="@(_busy || !SiteOptions.Any())">Move</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
<button class="btn btn-primary" @onclick="Submit"
disabled="@(_busy || !SiteOptions.Any())">Move</button>
</div>
</div>
@code {
@@ -15,8 +15,10 @@
</select>
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary" @onclick="Submit">Move</button>
</div>
</div>
@code {
@@ -13,8 +13,10 @@
</select>
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary" @onclick="Submit">Move</button>
</div>
</div>
@code {
@@ -9,9 +9,11 @@
<input class="form-control form-control-sm" @bind="_name" @bind:event="oninput" />
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit"
disabled="@string.IsNullOrWhiteSpace(_name)">Save</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary" @onclick="Submit"
disabled="@string.IsNullOrWhiteSpace(_name)">Save</button>
</div>
</div>
@code {
@@ -135,7 +135,7 @@ else
</div>
<button type="button"
class="btn btn-link btn-sm text-danger p-0 ms-auto"
class="btn btn-sm btn-outline-danger ms-auto"
title="Remove" aria-label="Remove field"
@onclick="() => RemoveProperty(parent, prop)">
<i class="bi bi-x-lg"></i>
@@ -6,9 +6,70 @@
/* ── App-level tokens (light defaults) ─────────────────────────────────────
--sb-backdrop: semi-transparent overlay used by DialogHost and ad-hoc
modal backdrops throughout the app. */
modal backdrops throughout the app.
--accent: ScadaBridge's product accent. Declared HERE rather than as
ThemeShell's Accent="…" parameter: that parameter emits an inline
style="--accent: …" on the shell root, which is a descendant of <html> and
therefore beat the [data-bs-theme="dark"] override below for the whole app
— the dark accent was dead. As a :root declaration it loses to the dark
block (equal specificity, later in this file) and wins over theme.css
(equal specificity, earlier sheet), so both schemes resolve correctly.
The value matches theme.css's own light default, so light mode is
unchanged. */
:root {
--sb-backdrop: rgba(0, 0, 0, 0.4);
--accent: #2f5fd0;
}
/* Button sizing lives in ZB.MOM.WW.Theme's layout.css as of 0.4.0 (the local
copy that used to sit here was upstreamed verbatim), so there is deliberately
no `.btn` rule in this sheet. Do not reintroduce one — and if you ever need
to, override the CSS *variables* only, never the box properties, because
.btn-group's seam and border-radius machinery is built on them. */
/* Bootstrap ships .form-label and .col-form-label-sm but no .form-label-sm,
so the small-label intent next to a form-select-sm/form-control-sm was a
silent no-op wherever it was used. Give it the rule it always implied. */
.form-label-sm {
font-size: .8rem;
margin-bottom: .2rem;
}
/* ── Table cell containment ─────────────────────────────────────────────────
Text this app does not control — remote exception messages, X.509 DNs, OPC UA
node ids, serialized payloads, bundle-supplied names — must never be allowed
to set a table's width. A single long value otherwise pushes the actions
column off-screen exactly when an operator most needs it. Apply .cell-clip
(optionally with a width modifier) to the cell or an inner block, and ALWAYS
pair it with a title="…" so the full value stays reachable; where a fuller
view exists, keep it in the row's detail drawer/dialog too.
Modelled on the existing .parked-error-clamp pattern in ParkedMessages. */
.cell-clip {
display: block;
max-width: 22rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-clip-sm { max-width: 11rem; }
.cell-clip-lg { max-width: 34rem; }
/* Two-line clamp for message-shaped text that deserves more than one line. */
.cell-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
max-width: 34rem;
}
/* For <pre>/<code> detail blocks: wrap instead of forcing horizontal scroll. */
.detail-pre {
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
/* Reusable backdrop class — applied by DialogHost and standalone modal