Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Drivers/FocasDetail.razor
Joseph Doherty 866dc03fac style(ui): align admin styling with ScadaLink master conventions
- Move CSS into wwwroot/css/ (theme.css, site.css); sidebar 218 -> 220px
- Add hamburger + Bootstrap collapse for <lg viewports
- Add Components/Shared/ with LoadingSpinner, ToastNotification, StatusBadge
- Replace .page-title with flex + <h4 class="mb-0"> across 20 pages
- Convert NewCluster + IdentificationFields forms to card + h6 subsection pattern
2026-05-26 01:12:57 -04:00

251 lines
11 KiB
Plaintext

@page "/drivers/focas/{InstanceId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@inject FocasDriverDetailService DetailSvc
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">FOCAS driver <span class="mono">@InstanceId</span></h4>
</div>
@if (_loading)
{
<p>Loading…</p>
}
else if (_detail is null)
{
<section class="panel notice">
No FOCAS driver instance with id <span class="mono">@InstanceId</span> was found.
<div class="small text-muted mt-1">
Either the id is wrong, or the instance's <span class="mono">DriverType</span> is not "Focas". The list of drivers per cluster draft is on the <a href="/clusters">Clusters</a> page.
</div>
</section>
}
else
{
<section class="agg-grid rise" style="animation-delay:.02s">
<div class="agg-card">
<div class="agg-label">Name</div>
<div class="agg-value">@_detail.Instance.Name</div>
</div>
<div class="agg-card">
<div class="agg-label">Cluster</div>
<div class="agg-value mono">@_detail.Instance.ClusterId</div>
</div>
<div class="agg-card">
<div class="agg-label">Namespace</div>
<div class="agg-value mono">@_detail.Instance.NamespaceId</div>
</div>
<div class="agg-card">
<div class="agg-label">Enabled</div>
<div class="agg-value">@(_detail.Instance.Enabled ? "Yes" : "No")</div>
</div>
</section>
@if (_detail.ParseError is not null)
{
<section class="panel notice rise" style="animation-delay:.08s">
<strong>DriverConfig JSON failed to parse:</strong> @_detail.ParseError
<div class="small text-muted mt-1">
Falling back to raw-JSON view below; the per-section tables are hidden because the shape couldn't be projected.
</div>
</section>
}
else if (_detail.Config is not null)
{
@if (_detail.Config.Devices is null || _detail.Config.Devices.Count == 0)
{
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Devices</div>
<p class="text-muted" style="padding:.75rem 1rem 0">No devices configured.</p>
</section>
}
else
{
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Devices</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>HostAddress</th><th>DeviceName</th><th>Series</th></tr></thead>
<tbody>
@foreach (var d in _detail.Config.Devices)
{
<tr>
<td class="mono">@d.HostAddress</td>
<td>@(d.DeviceName ?? "—")</td>
<td>@(string.IsNullOrEmpty(d.Series) ? "Unknown" : d.Series)</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@if (_detail.Config.Tags is null || _detail.Config.Tags.Count == 0)
{
<section class="panel rise" style="animation-delay:.14s">
<div class="panel-head">Tags</div>
<p class="text-muted" style="padding:.75rem 1rem 0">No tags configured.</p>
</section>
}
else
{
<section class="panel rise" style="animation-delay:.14s">
<div class="panel-head">Tags</div>
<div class="toolbar">
<span class="spacer"></span>
<span class="tb-count">@_detail.Config.Tags.Count tag(s)</span>
</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Name</th><th>Device</th><th>Address</th><th>DataType</th><th>Writable</th></tr></thead>
<tbody>
@foreach (var t in _detail.Config.Tags)
{
<tr>
<td>@t.Name</td>
<td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.Address</td>
<td>@t.DataType</td>
<td>@(t.Writable ? "Yes" : "No")</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
<section class="card-grid rise" style="animation-delay:.20s">
<div class="metric-card">
<div class="panel-head">Driver behaviour</div>
<div class="kv">
<span class="k">Probe</span>
<span class="v">
@if (_detail.Config.Probe is { } probe)
{
<span class="chip @(probe.Enabled ? "chip-ok" : "chip-idle")">@(probe.Enabled ? "Enabled" : "Disabled")</span>
<span class="ms-2 small text-muted">Interval: @(probe.Interval ?? "default")</span>
}
else { <span class="text-muted">default (enabled)</span> }
</span>
</div>
<div class="kv">
<span class="k">Alarm projection</span>
<span class="v">
@if (_detail.Config.AlarmProjection is { } ap)
{
<span class="chip @(ap.Enabled ? "chip-ok" : "chip-idle")">@(ap.Enabled ? "Enabled" : "Disabled")</span>
<span class="ms-2 small text-muted">PollInterval: @(ap.PollInterval ?? "default")</span>
}
else { <span class="text-muted">disabled (default)</span> }
</span>
</div>
<div class="kv">
<span class="k">Handle recycling</span>
<span class="v">
@if (_detail.Config.HandleRecycle is { } hr)
{
<span class="chip @(hr.Enabled ? "chip-warn" : "chip-idle")">@(hr.Enabled ? "Enabled" : "Disabled")</span>
<span class="ms-2 small text-muted">Interval: @(hr.Interval ?? "default (01:00:00)")</span>
}
else { <span class="text-muted">disabled (default)</span> }
</span>
</div>
</div>
</section>
}
@if (_detail.HostStatuses.Count == 0)
{
<section class="panel notice rise" style="animation-delay:.26s">
No <span class="mono">DriverHostStatus</span> rows yet for this instance. The Server publishes its first
tick ~2 s after the driver starts — if this stays empty after a minute, check that the Server is running and the instance is in a published generation.
</section>
}
else
{
<section class="panel rise" style="animation-delay:.26s">
<div class="panel-head">Host status</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Node</th>
<th>Host</th>
<th>State</th>
<th class="num" title="Consecutive failures">Fail#</th>
<th>Breaker last opened</th>
<th>Last recycled</th>
<th>Last seen</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
@foreach (var r in _detail.HostStatuses)
{
<tr>
<td class="mono">@r.NodeId</td>
<td>@r.HostName</td>
<td><span class="chip @StateBadge(r.State)">@r.State</span></td>
<td class="num small">@r.ConsecutiveFailures</td>
<td class="small">@FormatUtc(r.LastCircuitBreakerOpenUtc)</td>
<td class="small">@FormatUtc(r.LastRecycleUtc)</td>
<td class="small @(IsStale(r) ? "s-warn" : "")">@FormatAge(r.LastSeenUtc)</td>
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
<section class="panel rise" style="animation-delay:.32s">
<div class="panel-head">Raw DriverConfig JSON</div>
<pre class="small" style="padding:1rem;margin:0;overflow-x:auto"><code>@_detail.Instance.DriverConfig</code></pre>
</section>
<div class="mt-4 small text-muted">
Docs: <span class="mono">docs/drivers/FOCAS.md</span> (getting started) · <span class="mono">docs/v2/focas-deployment.md</span> (NSSM + pipe ACL) · <span class="mono">docs/drivers/FOCAS-Test-Fixture.md</span> (test coverage).
</div>
}
@code {
[Parameter] public string InstanceId { get; set; } = string.Empty;
private FocasDriverDetail? _detail;
private bool _loading = true;
protected override async Task OnParametersSetAsync()
{
_loading = true;
try { _detail = await DetailSvc.GetAsync(InstanceId, CancellationToken.None); }
finally { _loading = false; }
}
private static bool IsStale(FocasHostStatusRow r) =>
DateTime.UtcNow - r.LastSeenUtc > TimeSpan.FromSeconds(30);
private static string StateBadge(string state) => state switch
{
"Running" => "chip-ok",
"Faulted" => "chip-bad",
"Starting" => "chip-idle",
"Stopped" => "chip-idle",
_ => "chip-idle",
};
private static string FormatUtc(DateTime? utc) =>
utc is null ? "—" : utc.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
private static string FormatAge(DateTime utc)
{
var age = DateTime.UtcNow - utc;
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
if (age.TotalHours < 48) return $"{(int)age.TotalHours}h ago";
return utc.ToString("yyyy-MM-dd HH:mm 'UTC'");
}
}