- 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
251 lines
11 KiB
Plaintext
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'");
|
|
}
|
|
}
|