Merge branch 'feat/admin-technical-light-design'

Restyle the Admin web UI with the technical-light design system,
and fix the LDAP sign-in path so it actually authenticates against
GLAuth (form binding, service-account DN, user-search attribute).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 02:49:35 -04:00
43 changed files with 1855 additions and 1210 deletions

View File

@@ -7,6 +7,7 @@
<title>OtOpcUa Admin</title> <title>OtOpcUa Admin</title>
<base href="/"/> <base href="/"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="theme.css"/>
<link rel="stylesheet" href="app.css"/> <link rel="stylesheet" href="app.css"/>
<HeadOutlet/> <HeadOutlet/>
</head> </head>

View File

@@ -1,38 +1,58 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<div class="d-flex" style="min-height: 100vh;"> <header class="app-bar">
<nav class="bg-dark text-light p-3" style="width: 220px;"> <span class="brand"><span class="mark">&#9646;</span> OtOpcUa</span>
<h5 class="mb-4">OtOpcUa Admin</h5> <span class="crumb">&rsaquo;</span>
<ul class="nav flex-column"> <span class="crumb">admin console</span>
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li> <span class="spacer"></span>
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/hosts">Host status</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/role-grants">Role grants</a></li>
</ul>
<div class="mt-5">
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<div class="small text-light"> <span class="meta">@context.User.Identity?.Name</span>
Signed in as <a class="text-light" href="/account"><strong>@context.User.Identity?.Name</strong></a> <span class="conn-pill" data-state="connected">
</div> <span class="dot"></span><span>signed in</span>
<div class="small text-muted"> </span>
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value)) </Authorized>
<NotAuthorized>
<span class="conn-pill" data-state="disconnected">
<span class="dot"></span><span>signed out</span>
</span>
</NotAuthorized>
</AuthorizeView>
</header>
<div class="app-shell">
<nav class="side-rail">
<div class="rail-eyebrow">Navigation</div>
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
<div class="rail-foot">
<AuthorizeView>
<Authorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
<div class="rail-roles">
@string.Join(", ", context.User.Claims
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
</div> </div>
<form method="post" action="/auth/logout"> <form method="post" action="/auth/logout">
<button class="btn btn-sm btn-outline-light mt-2" type="submit">Sign out</button> <button class="rail-btn" type="submit">Sign out</button>
</form> </form>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<a class="btn btn-sm btn-outline-light" href="/login">Sign in</a> <div class="rail-eyebrow">Session</div>
<a class="rail-btn" href="/login">Sign in</a>
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </AuthorizeView>
</div> </div>
</nav> </nav>
<main class="flex-grow-1 p-4">
<main class="page">
@Body @Body
</main> </main>
</div> </div>

View File

@@ -3,7 +3,7 @@
@using System.Security.Claims @using System.Security.Claims
@using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Admin.Services
<h1 class="mb-4">My account</h1> <h1 class="page-title">My account</h1>
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
@@ -16,51 +16,44 @@
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList(); .Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
} }
<div class="row g-4"> <section class="card-grid rise" style="animation-delay:.02s">
<div class="col-md-6"> <div class="metric-card">
<div class="card"> <div class="panel-head">Identity</div>
<div class="card-body"> <div class="kv"><span class="k">Username</span><span class="v mono">@username</span></div>
<h5 class="card-title">Identity</h5> <div class="kv"><span class="k">Display name</span><span class="v">@displayName</span></div>
<dl class="row mb-0">
<dt class="col-sm-4">Username</dt><dd class="col-sm-8"><code>@username</code></dd>
<dt class="col-sm-4">Display name</dt><dd class="col-sm-8">@displayName</dd>
</dl>
</div>
</div>
</div> </div>
<div class="col-md-6"> <div class="metric-card">
<div class="card"> <div class="panel-head">Admin roles</div>
<div class="card-body">
<h5 class="card-title">Admin roles</h5>
@if (roles.Count == 0) @if (roles.Count == 0)
{ {
<p class="text-muted mb-0">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</p> <div class="kv"><span class="k">Roles</span><span class="v text-muted">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</span></div>
} }
else else
{ {
<div class="mb-2"> <div class="kv">
<span class="k">Roles</span>
<span class="v">
@foreach (var r in roles) @foreach (var r in roles)
{ {
<span class="badge bg-primary me-1">@r</span> <span class="chip chip-idle me-1">@r</span>
}
</span>
</div>
<div class="kv"><span class="k">LDAP groups</span><span class="v">@(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</span></div>
} }
</div> </div>
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small> </section>
}
</div>
</div>
</div>
<div class="col-12"> <section class="panel rise" style="animation-delay:.08s">
<div class="card"> <div class="panel-head">Capabilities</div>
<div class="card-body"> <p class="px-3 pt-2 text-muted small">
<h5 class="card-title">Capabilities</h5> Each Admin role grants a fixed capability set per <span class="mono">admin-ui.md</span> §Admin Roles.
<p class="text-muted small"> Pages below reflect what this session can access; the route's <span class="mono">[Authorize]</span> guard
Each Admin role grants a fixed capability set per <code>admin-ui.md</code> §Admin Roles.
Pages below reflect what this session can access; the route's <code>[Authorize]</code> guard
is the ground truth — this table mirrors it for readability. is the ground truth — this table mirrors it for readability.
</p> </p>
<table class="table table-sm align-middle mb-0"> <div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Capability</th> <th>Capability</th>
@@ -78,11 +71,11 @@
<td class="text-end"> <td class="text-end">
@if (has) @if (has)
{ {
<span class="badge bg-success">Yes</span> <span class="chip chip-ok">Yes</span>
} }
else else
{ {
<span class="badge bg-secondary">No</span> <span class="chip chip-idle">No</span>
} }
</td> </td>
</tr> </tr>
@@ -90,9 +83,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </section>
</div>
</div>
<div class="mt-4"> <div class="mt-4">
<form method="post" action="/auth/logout"> <form method="post" action="/auth/logout">

View File

@@ -3,40 +3,36 @@
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian @using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
@inject HistorianDiagnosticsService Diag @inject HistorianDiagnosticsService Diag
<h1>Alarm historian</h1> <h1 class="page-title">Alarm historian</h1>
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p> <p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
<div class="card mb-3"> <section class="agg-grid rise" style="animation-delay:.02s">
<div class="card-body"> <div class="agg-card">
<div class="row"> <div class="agg-label">Drain state</div>
<div class="col-md-3"> <div class="agg-value"><span class="chip @BadgeFor(_status.DrainState)">@_status.DrainState</span></div>
<small class="text-muted">Drain state</small>
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
</div> </div>
<div class="col-md-3"> <div class="agg-card">
<small class="text-muted">Queue depth</small> <div class="agg-label">Queue depth</div>
<h4>@_status.QueueDepth.ToString("N0")</h4> <div class="agg-value numeric">@_status.QueueDepth.ToString("N0")</div>
</div> </div>
<div class="col-md-3"> <div class="agg-card">
<small class="text-muted">Dead-letter depth</small> <div class="agg-label">Dead-letter depth</div>
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4> <div class="agg-value numeric">@_status.DeadLetterDepth.ToString("N0")</div>
</div>
<div class="col-md-3">
<small class="text-muted">Last success</small>
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
</div> </div>
<div class="agg-card">
<div class="agg-label">Last success</div>
<div class="agg-value">@(_status.LastSuccessUtc?.ToString("u") ?? "—")</div>
</div> </div>
</section>
@if (!string.IsNullOrEmpty(_status.LastError)) @if (!string.IsNullOrEmpty(_status.LastError))
{ {
<div class="alert alert-warning mt-3 mb-0"> <section class="panel notice rise" style="animation-delay:.08s">
<strong>Last error:</strong> @_status.LastError <strong>Last error:</strong> @_status.LastError
</div> </section>
} }
</div>
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2 mt-3">
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button> <button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync"> <button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
Retry dead-lettered (@_status.DeadLetterDepth) Retry dead-lettered (@_status.DeadLetterDepth)
@@ -45,7 +41,7 @@
@if (_retryResult is not null) @if (_retryResult is not null)
{ {
<div class="alert alert-success mt-3">Requeued @_retryResult row(s) for retry.</div> <section class="panel notice rise" style="animation-delay:.08s">Requeued @_retryResult row(s) for retry.</section>
} }
@code { @code {
@@ -70,10 +66,10 @@
private static string BadgeFor(HistorianDrainState s) => s switch private static string BadgeFor(HistorianDrainState s) => s switch
{ {
HistorianDrainState.Idle => "bg-success", HistorianDrainState.Idle => "chip-ok",
HistorianDrainState.Draining => "bg-info", HistorianDrainState.Draining => "chip-idle",
HistorianDrainState.BackingOff => "bg-warning text-dark", HistorianDrainState.BackingOff => "chip-warn",
HistorianDrainState.Disabled => "bg-secondary", HistorianDrainState.Disabled => "chip-idle",
_ => "bg-secondary", _ => "chip-idle",
}; };
} }

View File

@@ -5,11 +5,11 @@
@inject AuthenticationStateProvider AuthState @inject AuthenticationStateProvider AuthState
@inject ILogger<Certificates> Log @inject ILogger<Certificates> Log
<h1 class="mb-4">Certificate trust</h1> <h1 class="page-title">Certificate trust</h1>
<div class="alert alert-info small mb-4"> <section class="panel notice rise" style="animation-delay:.02s">
PKI store root <code>@Certs.PkiStoreRoot</code>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting. PKI store root <span class="mono">@Certs.PkiStoreRoot</span>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
</div> </section>
@if (_status is not null) @if (_status is not null)
{ {
@@ -19,14 +19,16 @@
</div> </div>
} }
<h2 class="h4">Rejected (@_rejected.Count)</h2> <section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Rejected (@_rejected.Count)</div>
@if (_rejected.Count == 0) @if (_rejected.Count == 0)
{ {
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p> <p class="px-3 py-2 text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
} }
else else
{ {
<table class="table table-sm align-middle"> <div class="table-wrap">
<table class="data-table">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead> <thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<tbody> <tbody>
@foreach (var c in _rejected) @foreach (var c in _rejected)
@@ -34,7 +36,7 @@ else
<tr> <tr>
<td>@c.Subject</td> <td>@c.Subject</td>
<td>@c.Issuer</td> <td>@c.Issuer</td>
<td><code class="small">@c.Thumbprint</code></td> <td><span class="mono small">@c.Thumbprint</span></td>
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td> <td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button> <button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
@@ -44,16 +46,20 @@ else
} }
</tbody> </tbody>
</table> </table>
</div>
} }
</section>
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2> <section class="panel rise" style="animation-delay:.14s">
<div class="panel-head">Trusted (@_trusted.Count)</div>
@if (_trusted.Count == 0) @if (_trusted.Count == 0)
{ {
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p> <p class="px-3 py-2 text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <span class="mono">own/</span> and is not listed here.</p>
} }
else else
{ {
<table class="table table-sm align-middle"> <div class="table-wrap">
<table class="data-table">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead> <thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<tbody> <tbody>
@foreach (var c in _trusted) @foreach (var c in _trusted)
@@ -61,7 +67,7 @@ else
<tr> <tr>
<td>@c.Subject</td> <td>@c.Subject</td>
<td>@c.Issuer</td> <td>@c.Issuer</td>
<td><code class="small">@c.Thumbprint</code></td> <td><span class="mono small">@c.Thumbprint</span></td>
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td> <td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button> <button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
@@ -70,7 +76,9 @@ else
} }
</tbody> </tbody>
</table> </table>
</div>
} }
</section>
@code { @code {
private IReadOnlyList<CertInfo> _rejected = []; private IReadOnlyList<CertInfo> _rejected = [];

View File

@@ -10,7 +10,7 @@
@implements IAsyncDisposable @implements IAsyncDisposable
<div class="d-flex justify-content-between mb-3"> <div class="d-flex justify-content-between mb-3">
<h4>Access-control grants</h4> <h4 class="panel-head">Access-control grants</h4>
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add grant</button> <button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add grant</button>
</div> </div>
@@ -18,7 +18,10 @@
else if (_acls.Count == 0) { <p class="text-muted">No ACL grants in this draft. Publish will result in a cluster with no external access.</p> } else if (_acls.Count == 0) { <p class="text-muted">No ACL grants in this draft. Publish will result in a cluster with no external access.</p> }
else else
{ {
<table class="table table-sm"> <section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Grants</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>LDAP group</th><th>Scope</th><th>Scope ID</th><th>Permissions</th><th></th></tr></thead> <thead><tr><th>LDAP group</th><th>Scope</th><th>Scope ID</th><th>Permissions</th><th></th></tr></thead>
<tbody> <tbody>
@foreach (var a in _acls) @foreach (var a in _acls)
@@ -26,19 +29,21 @@ else
<tr> <tr>
<td>@a.LdapGroup</td> <td>@a.LdapGroup</td>
<td>@a.ScopeKind</td> <td>@a.ScopeKind</td>
<td><code>@(a.ScopeId ?? "-")</code></td> <td><span class="mono">@(a.ScopeId ?? "-")</span></td>
<td><code>@a.PermissionFlags</code></td> <td><span class="mono">@a.PermissionFlags</span></td>
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td> <td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@* Probe-this-permission — task #196 slice 1 *@ @* Probe-this-permission — task #196 slice 1 *@
<div class="card mt-4 mb-3"> <section class="panel rise" style="animation-delay:.08s">
<div class="card-header"> <div class="panel-head">
<strong>Probe this permission</strong> Probe this permission
<span class="small text-muted ms-2"> <span class="small text-muted ms-2">
Ask the trie "if LDAP group X asks for permission Y on node Z, would it be granted?" — Ask the trie "if LDAP group X asks for permission Y on node Z, would it be granted?" —
answers the same way the live server does at request time. answers the same way the live server does at request time.
@@ -88,64 +93,67 @@ else
<span class="ms-3"> <span class="ms-3">
@if (_probeResult.Granted) @if (_probeResult.Granted)
{ {
<span class="badge bg-success">Granted</span> <span class="chip chip-ok">Granted</span>
} }
else else
{ {
<span class="badge bg-danger">Denied</span> <span class="chip chip-bad">Denied</span>
} }
<span class="small ms-2"> <span class="small ms-2">
Required <code>@_probeResult.Required</code>, Required <span class="mono">@_probeResult.Required</span>,
Effective <code>@_probeResult.Effective</code> Effective <span class="mono">@_probeResult.Effective</span>
</span> </span>
</span> </span>
} }
</div> </div>
@if (_probeResult is not null && _probeResult.Matches.Count > 0) @if (_probeResult is not null && _probeResult.Matches.Count > 0)
{ {
<table class="table table-sm mt-3 mb-0"> <div class="table-wrap mt-3">
<table class="data-table">
<thead><tr><th>LDAP group matched</th><th>Level</th><th>Flags contributed</th></tr></thead> <thead><tr><th>LDAP group matched</th><th>Level</th><th>Flags contributed</th></tr></thead>
<tbody> <tbody>
@foreach (var m in _probeResult.Matches) @foreach (var m in _probeResult.Matches)
{ {
<tr> <tr>
<td><code>@m.LdapGroup</code></td> <td><span class="mono">@m.LdapGroup</span></td>
<td>@m.Scope</td> <td>@m.Scope</td>
<td><code>@m.PermissionFlags</code></td> <td><span class="mono">@m.PermissionFlags</span></td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
} }
else if (_probeResult is not null) else if (_probeResult is not null)
{ {
<div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <code>None</code>.</div> <div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <span class="mono">None</span>.</div>
} }
</div> </div>
</div> </section>
@if (_showForm) @if (_showForm)
{ {
<div class="card"> <section class="panel rise" style="animation-delay:.14s">
<div class="panel-head">Add grant</div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">LDAP group</label> <label class="form-label">LDAP group</label>
<input class="form-control" @bind="_group"/> <input class="form-control form-control-sm" @bind="_group"/>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Scope kind</label> <label class="form-label">Scope kind</label>
<select class="form-select" @bind="_scopeKind"> <select class="form-select form-select-sm" @bind="_scopeKind">
@foreach (var k in Enum.GetValues<NodeAclScopeKind>()) { <option value="@k">@k</option> } @foreach (var k in Enum.GetValues<NodeAclScopeKind>()) { <option value="@k">@k</option> }
</select> </select>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Scope ID (empty for Cluster-wide)</label> <label class="form-label">Scope ID (empty for Cluster-wide)</label>
<input class="form-control" @bind="_scopeId"/> <input class="form-control form-control-sm" @bind="_scopeId"/>
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Permissions (bundled presets — per-flag editor in v2.1)</label> <label class="form-label">Permissions (bundled presets — per-flag editor in v2.1)</label>
<select class="form-select" @bind="_preset"> <select class="form-select form-select-sm" @bind="_preset">
<option value="Read">Read (Browse + Read)</option> <option value="Read">Read (Browse + Read)</option>
<option value="WriteOperate">Read + Write Operate</option> <option value="WriteOperate">Read + Write Operate</option>
<option value="Engineer">Read + Write Tune + Write Configure</option> <option value="Engineer">Read + Write Tune + Write Configure</option>
@@ -154,13 +162,13 @@ else
</select> </select>
</div> </div>
</div> </div>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> } @if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
<div class="mt-3"> <div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button> <button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button> <button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div> </div>
</div> </div>
</div> </section>
} }
@code { @code {

View File

@@ -2,28 +2,33 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject AuditLogService AuditSvc @inject AuditLogService AuditSvc
<h4>Recent audit log</h4> <h4 class="panel-head">Recent audit log</h4>
@if (_entries is null) { <p>Loading…</p> } @if (_entries is null) { <p>Loading…</p> }
else if (_entries.Count == 0) { <p class="text-muted">No audit entries for this cluster yet.</p> } else if (_entries.Count == 0) { <p class="text-muted">No audit entries for this cluster yet.</p> }
else else
{ {
<table class="table table-sm"> <section class="panel rise" style="animation-delay:.02s">
<thead><tr><th>When</th><th>Principal</th><th>Event</th><th>Node</th><th>Generation</th><th>Details</th></tr></thead> <div class="panel-head">Entries</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>When</th><th>Principal</th><th>Event</th><th>Node</th><th class="num">Generation</th><th>Details</th></tr></thead>
<tbody> <tbody>
@foreach (var a in _entries) @foreach (var a in _entries)
{ {
<tr> <tr>
<td>@a.Timestamp.ToString("u")</td> <td>@a.Timestamp.ToString("u")</td>
<td>@a.Principal</td> <td>@a.Principal</td>
<td><code>@a.EventType</code></td> <td><span class="mono">@a.EventType</span></td>
<td>@a.NodeId</td> <td>@a.NodeId</td>
<td>@a.GenerationId</td> <td class="num">@a.GenerationId</td>
<td><small class="text-muted">@a.DetailsJson</small></td> <td><small class="text-muted">@a.DetailsJson</small></td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@code { @code {

View File

@@ -19,16 +19,16 @@ else
{ {
@if (_liveBanner is not null) @if (_liveBanner is not null)
{ {
<div class="alert alert-info py-2 small"> <section class="panel notice rise" style="animation-delay:.02s">
<strong>Live update:</strong> @_liveBanner <strong>Live update:</strong> @_liveBanner
<button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button> <button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button>
</div> </section>
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">@_cluster.Name</h1> <h1 class="page-title mb-0">@_cluster.Name</h1>
<code class="text-muted">@_cluster.ClusterId</code> <span class="mono text-muted">@_cluster.ClusterId</span>
@if (!_cluster.Enabled) { <span class="badge bg-secondary ms-2">Disabled</span> } @if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
</div> </div>
<div> <div>
@if (_currentDraft is not null) @if (_currentDraft is not null)
@@ -59,16 +59,21 @@ else
@if (_tab == "overview") @if (_tab == "overview")
{ {
<dl class="row"> <section class="card-grid rise" style="animation-delay:.08s">
<dt class="col-sm-3">Enterprise / Site</dt><dd class="col-sm-9">@_cluster.Enterprise / @_cluster.Site</dd> <div class="metric-card">
<dt class="col-sm-3">Redundancy</dt><dd class="col-sm-9">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</dd> <div class="panel-head">Cluster details</div>
<dt class="col-sm-3">Current published</dt> <div class="kv"><span class="k">Enterprise / Site</span><span class="v">@_cluster.Enterprise / @_cluster.Site</span></div>
<dd class="col-sm-9"> <div class="kv"><span class="k">Redundancy</span><span class="v">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</span></div>
<div class="kv">
<span class="k">Current published</span>
<span class="v">
@if (_currentPublished is not null) { <span>@_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u"))</span> } @if (_currentPublished is not null) { <span>@_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u"))</span> }
else { <span class="text-muted">none published yet</span> } else { <span class="text-muted">none published yet</span> }
</dd> </span>
<dt class="col-sm-3">Created</dt><dd class="col-sm-9">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</dd> </div>
</dl> <div class="kv"><span class="k">Created</span><span class="v">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</span></div>
</div>
</section>
} }
else if (_tab == "generations") else if (_tab == "generations")
{ {
@@ -108,7 +113,7 @@ else
} }
else else
{ {
<p class="text-muted">Open a draft to edit this cluster's content.</p> <section class="panel notice rise" style="animation-delay:.02s">Open a draft to edit this cluster's content.</section>
} }
} }

View File

@@ -4,7 +4,7 @@
@inject ClusterService ClusterSvc @inject ClusterService ClusterSvc
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1>Clusters</h1> <h1 class="page-title">Clusters</h1>
<a href="/clusters/new" class="btn btn-primary">New cluster</a> <a href="/clusters/new" class="btn btn-primary">New cluster</a>
</div> </div>
@@ -18,32 +18,37 @@ else if (_clusters.Count == 0)
} }
else else
{ {
<table class="table table-hover"> <section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">All clusters</div>
<div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>ClusterId</th><th>Name</th><th>Enterprise</th><th>Site</th> <th>ClusterId</th><th>Name</th><th>Enterprise</th><th>Site</th>
<th>RedundancyMode</th><th>NodeCount</th><th>Enabled</th><th></th> <th>RedundancyMode</th><th class="num">NodeCount</th><th>Enabled</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var c in _clusters) @foreach (var c in _clusters)
{ {
<tr> <tr>
<td><code>@c.ClusterId</code></td> <td><span class="mono">@c.ClusterId</span></td>
<td>@c.Name</td> <td>@c.Name</td>
<td>@c.Enterprise</td> <td>@c.Enterprise</td>
<td>@c.Site</td> <td>@c.Site</td>
<td>@c.RedundancyMode</td> <td>@c.RedundancyMode</td>
<td>@c.NodeCount</td> <td class="num">@c.NodeCount</td>
<td> <td>
@if (c.Enabled) { <span class="badge bg-success">Active</span> } @if (c.Enabled) { <span class="chip chip-ok">Active</span> }
else { <span class="badge bg-secondary">Disabled</span> } else { <span class="chip chip-idle">Disabled</span> }
</td> </td>
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td> <td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@code { @code {

View File

@@ -4,49 +4,49 @@
output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the
Blazor render; overflow banner tells operator how many rows were hidden. *@ Blazor render; overflow banner tells operator how many rows were hidden. *@
<div class="card mb-3"> <section class="panel rise mb-3" style="animation-delay:.02s">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="panel-head d-flex justify-content-between align-items-center">
<div> <div>
<strong>@Title</strong> <strong>@Title</strong>
<small class="text-muted ms-2">@Description</small> <small class="text-muted ms-2">@Description</small>
</div> </div>
<div> <div>
@if (_added > 0) { <span class="badge bg-success me-1">+@_added</span> } @if (_added > 0) { <span class="chip chip-ok me-1">+@_added</span> }
@if (_removed > 0) { <span class="badge bg-danger me-1">@_removed</span> } @if (_removed > 0) { <span class="chip chip-bad me-1">@_removed</span> }
@if (_modified > 0) { <span class="badge bg-warning text-dark me-1">~@_modified</span> } @if (_modified > 0) { <span class="chip chip-warn me-1">~@_modified</span> }
@if (_total == 0) { <span class="badge bg-secondary">no changes</span> } @if (_total == 0) { <span class="chip chip-idle">no changes</span> }
</div> </div>
</div> </div>
@if (_total == 0) @if (_total == 0)
{ {
<div class="card-body text-muted small">No changes in this section.</div> <p class="p-3 text-muted small mb-0">No changes in this section.</p>
} }
else else
{ {
@if (_total > RowCap) @if (_total > RowCap)
{ {
<div class="alert alert-warning mb-0 small rounded-0"> <div class="p-2 small text-muted border-bottom">
Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class <span class="s-warn">Showing the first @RowCap of @_total rows</span> — cap protects the browser from megabyte-class
diffs. Inspect the remainder via the SQL <code>sp_ComputeGenerationDiff</code> directly. diffs. Inspect the remainder via the SQL <span class="mono">sp_ComputeGenerationDiff</span> directly.
</div> </div>
} }
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;"> <div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
<table class="table table-sm table-hover mb-0"> <table class="data-table">
<thead class="table-light"> <thead>
<tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr> <tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var r in _visibleRows) @foreach (var r in _visibleRows)
{ {
<tr> <tr>
<td><code>@r.LogicalId</code></td> <td><span class="mono">@r.LogicalId</span></td>
<td> <td>
@switch (r.ChangeKind) @switch (r.ChangeKind)
{ {
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break; case "Added": <span class="chip chip-ok">@r.ChangeKind</span> break;
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break; case "Removed": <span class="chip chip-bad">@r.ChangeKind</span> break;
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break; case "Modified": <span class="chip chip-warn">@r.ChangeKind</span> break;
default: <span class="badge bg-secondary">@r.ChangeKind</span> break; default: <span class="chip chip-idle">@r.ChangeKind</span> break;
} }
</td> </td>
</tr> </tr>
@@ -55,7 +55,7 @@
</table> </table>
</div> </div>
} }
</div> </section>
@code { @code {
/// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary> /// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary>

View File

@@ -6,9 +6,9 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">Draft diff</h1> <h1 class="page-title mb-0">Draft diff</h1>
<small class="text-muted"> <small class="text-muted">
Cluster <code>@ClusterId</code> — from last published (@(_fromLabel)) → to draft @GenerationId Cluster <span class="mono">@ClusterId</span> — from last published (@(_fromLabel)) → to draft @GenerationId
</small> </small>
</div> </div>
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to editor</a> <a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to editor</a>
@@ -20,7 +20,7 @@
} }
else if (_error is not null) else if (_error is not null)
{ {
<div class="alert alert-danger">@_error</div> <section class="panel notice rise" style="animation-delay:.02s"><span class="s-bad">@_error</span></section>
} }
else if (_rows.Count == 0) else if (_rows.Count == 0)
{ {

View File

@@ -7,8 +7,8 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">Draft editor</h1> <h1 class="page-title mb-0">Draft editor</h1>
<small class="text-muted">Cluster <code>@ClusterId</code> · generation @GenerationId</small> <small class="text-muted">Cluster <span class="mono">@ClusterId</span> · generation @GenerationId</small>
</div> </div>
<div> <div>
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId">Back to cluster</a> <a class="btn btn-outline-secondary" href="/clusters/@ClusterId">Back to cluster</a>
@@ -36,32 +36,32 @@
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> } else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card sticky-top"> <section class="panel rise sticky-top" style="animation-delay:.02s">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="panel-head d-flex justify-content-between align-items-center">
<strong>Validation</strong> <strong>Validation</strong>
<button class="btn btn-sm btn-outline-secondary" @onclick="RevalidateAsync">Re-run</button> <button class="btn btn-sm btn-outline-secondary" @onclick="RevalidateAsync">Re-run</button>
</div> </div>
<div class="card-body"> <div class="p-3">
@if (_validating) { <p class="text-muted">Checking…</p> } @if (_validating) { <p class="text-muted">Checking…</p> }
else if (_errors.Count == 0) { <div class="alert alert-success mb-0">No validation errors — safe to publish.</div> } else if (_errors.Count == 0) { <p class="s-ok mb-0">No validation errors — safe to publish.</p> }
else else
{ {
<div class="alert alert-danger mb-2">@_errors.Count error@(_errors.Count == 1 ? "" : "s")</div> <p class="s-bad mb-2">@_errors.Count error@(_errors.Count == 1 ? "" : "s")</p>
<ul class="list-unstyled"> <ul class="list-unstyled">
@foreach (var e in _errors) @foreach (var e in _errors)
{ {
<li class="mb-2"> <li class="mb-2">
<span class="badge bg-danger me-1">@e.Code</span> <span class="chip chip-bad me-1">@e.Code</span>
<small>@e.Message</small> <small>@e.Message</small>
@if (!string.IsNullOrEmpty(e.Context)) { <div class="text-muted"><code>@e.Context</code></div> } @if (!string.IsNullOrEmpty(e.Context)) { <div class="text-muted"><span class="mono">@e.Context</span></div> }
</li> </li>
} }
</ul> </ul>
} }
</div> </div>
</div> </section>
@if (_publishError is not null) { <div class="alert alert-danger mt-3">@_publishError</div> } @if (_publishError is not null) { <section class="panel notice rise mt-2" style="animation-delay:.08s"><span class="s-bad">@_publishError</span></section> }
</div> </div>
</div> </div>

View File

@@ -6,7 +6,7 @@
@inject NamespaceService NsSvc @inject NamespaceService NsSvc
<div class="d-flex justify-content-between mb-3"> <div class="d-flex justify-content-between mb-3">
<h4>DriverInstances</h4> <h4 class="panel-head">DriverInstances</h4>
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add driver</button> <button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add driver</button>
</div> </div>
@@ -14,13 +14,16 @@
else if (_drivers.Count == 0) { <p class="text-muted">No drivers configured in this draft.</p> } else if (_drivers.Count == 0) { <p class="text-muted">No drivers configured in this draft.</p> }
else else
{ {
<table class="table table-sm"> <section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Configured drivers</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>DriverInstanceId</th><th>Name</th><th>Type</th><th>Namespace</th></tr></thead> <thead><tr><th>DriverInstanceId</th><th>Name</th><th>Type</th><th>Namespace</th></tr></thead>
<tbody> <tbody>
@foreach (var d in _drivers) @foreach (var d in _drivers)
{ {
<tr> <tr>
<td><code>@d.DriverInstanceId</code></td> <td><span class="mono">@d.DriverInstanceId</span></td>
<td>@d.Name</td> <td>@d.Name</td>
<td> <td>
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase)) @if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
@@ -32,25 +35,28 @@ else
@d.DriverType @d.DriverType
} }
</td> </td>
<td><code>@d.NamespaceId</code></td> <td><span class="mono">@d.NamespaceId</span></td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@if (_showForm && _namespaces is not null) @if (_showForm && _namespaces is not null)
{ {
<div class="card"> <section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Add driver</div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Name</label> <label class="form-label">Name</label>
<input class="form-control" @bind="_name"/> <input class="form-control form-control-sm" @bind="_name"/>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">DriverType</label> <label class="form-label">DriverType</label>
<select class="form-select" @bind="_type"> <select class="form-select form-select-sm" @bind="_type">
<option>Galaxy</option> <option>Galaxy</option>
<option>Modbus</option> <option>Modbus</option>
<option>AbCip</option> <option>AbCip</option>
@@ -63,7 +69,7 @@ else
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Namespace</label> <label class="form-label">Namespace</label>
<select class="form-select" @bind="_nsId"> <select class="form-select form-select-sm" @bind="_nsId">
@foreach (var n in _namespaces) { <option value="@n.NamespaceId">@n.Kind — @n.NamespaceUri</option> } @foreach (var n in _namespaces) { <option value="@n.NamespaceId">@n.Kind — @n.NamespaceUri</option> }
</select> </select>
</div> </div>
@@ -78,18 +84,18 @@ else
else else
{ {
<label class="form-label">DriverConfig JSON (schemaless per driver type)</label> <label class="form-label">DriverConfig JSON (schemaless per driver type)</label>
<textarea class="form-control font-monospace" rows="6" @bind="_config"></textarea> <textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_config"></textarea>
<div class="form-text">Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).</div> <div class="form-text">Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).</div>
} }
</div> </div>
</div> </div>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> } @if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
<div class="mt-3"> <div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button> <button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button> <button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div> </div>
</div> </div>
</div> </section>
} }
@code { @code {

View File

@@ -5,7 +5,7 @@
@inject NavigationManager Nav @inject NavigationManager Nav
<div class="d-flex justify-content-between mb-3"> <div class="d-flex justify-content-between mb-3">
<h4>Equipment (draft gen @GenerationId)</h4> <h4 class="panel-head">Equipment (draft gen @GenerationId)</h4>
<div> <div>
<button class="btn btn-outline-primary btn-sm me-2" @onclick="GoImport">Import CSV…</button> <button class="btn btn-outline-primary btn-sm me-2" @onclick="GoImport">Import CSV…</button>
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button> <button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
@@ -22,7 +22,10 @@ else if (_equipment.Count == 0 && !_showForm)
} }
else if (_equipment.Count > 0) else if (_equipment.Count > 0)
{ {
<table class="table table-sm table-hover"> <section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Equipment list</div>
<div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th> <th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
@@ -33,7 +36,7 @@ else if (_equipment.Count > 0)
@foreach (var e in _equipment) @foreach (var e in _equipment)
{ {
<tr> <tr>
<td><code>@e.EquipmentId</code></td> <td><span class="mono">@e.EquipmentId</span></td>
<td>@e.Name</td> <td>@e.Name</td>
<td>@e.MachineCode</td> <td>@e.MachineCode</td>
<td>@e.ZTag</td> <td>@e.ZTag</td>
@@ -48,46 +51,48 @@ else if (_equipment.Count > 0)
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@if (_showForm) @if (_showForm)
{ {
<div class="card mt-3"> <section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@(_editMode ? "Edit equipment" : "New equipment")</div>
<div class="card-body"> <div class="card-body">
<h5>@(_editMode ? "Edit equipment" : "New equipment")</h5>
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form"> <EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form">
<DataAnnotationsValidator/> <DataAnnotationsValidator/>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Name (UNS segment)</label> <label class="form-label">Name (UNS segment)</label>
<InputText @bind-Value="_draft.Name" class="form-control"/> <InputText @bind-Value="_draft.Name" class="form-control form-control-sm"/>
<ValidationMessage For="() => _draft.Name"/> <ValidationMessage For="() => _draft.Name"/>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">MachineCode</label> <label class="form-label">MachineCode</label>
<InputText @bind-Value="_draft.MachineCode" class="form-control"/> <InputText @bind-Value="_draft.MachineCode" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">DriverInstanceId</label> <label class="form-label">DriverInstanceId</label>
<InputText @bind-Value="_draft.DriverInstanceId" class="form-control"/> <InputText @bind-Value="_draft.DriverInstanceId" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">UnsLineId</label> <label class="form-label">UnsLineId</label>
<InputText @bind-Value="_draft.UnsLineId" class="form-control"/> <InputText @bind-Value="_draft.UnsLineId" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">ZTag</label> <label class="form-label">ZTag</label>
<InputText @bind-Value="_draft.ZTag" class="form-control"/> <InputText @bind-Value="_draft.ZTag" class="form-control form-control-sm"/>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">SAPID</label> <label class="form-label">SAPID</label>
<InputText @bind-Value="_draft.SAPID" class="form-control"/> <InputText @bind-Value="_draft.SAPID" class="form-control form-control-sm"/>
</div> </div>
</div> </div>
<IdentificationFields Equipment="_draft"/> <IdentificationFields Equipment="_draft"/>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> } @if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
<div class="mt-3"> <div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button> <button type="submit" class="btn btn-primary btn-sm">Save</button>
@@ -95,7 +100,7 @@ else if (_equipment.Count > 0)
</div> </div>
</EditForm> </EditForm>
</div> </div>
</div> </section>
} }
@code { @code {

View File

@@ -4,21 +4,22 @@
@inject GenerationService GenerationSvc @inject GenerationService GenerationSvc
@inject NavigationManager Nav @inject NavigationManager Nav
<h4>Generations</h4>
@if (_generations is null) { <p>Loading…</p> } @if (_generations is null) { <p>Loading…</p> }
else if (_generations.Count == 0) { <p class="text-muted">No generations in this cluster yet.</p> } else if (_generations.Count == 0) { <p class="text-muted">No generations in this cluster yet.</p> }
else else
{ {
<table class="table table-sm"> <section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Generations</div>
<div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr><th>ID</th><th>Status</th><th>Created</th><th>Published</th><th>PublishedBy</th><th>Notes</th><th></th></tr> <tr><th class="num">ID</th><th>Status</th><th>Created</th><th>Published</th><th>PublishedBy</th><th>Notes</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var g in _generations) @foreach (var g in _generations)
{ {
<tr> <tr>
<td><code>@g.GenerationId</code></td> <td class="num mono">@g.GenerationId</td>
<td>@StatusBadge(g.Status)</td> <td>@StatusBadge(g.Status)</td>
<td><small>@g.CreatedAt.ToString("u") by @g.CreatedBy</small></td> <td><small>@g.CreatedAt.ToString("u") by @g.CreatedBy</small></td>
<td><small>@(g.PublishedAt?.ToString("u") ?? "-")</small></td> <td><small>@(g.PublishedAt?.ToString("u") ?? "-")</small></td>
@@ -38,9 +39,11 @@ else
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@if (_error is not null) { <div class="alert alert-danger">@_error</div> } @if (_error is not null) { <section class="panel notice rise" style="animation-delay:.02s">@_error</section> }
@code { @code {
[Parameter] public string ClusterId { get; set; } = string.Empty; [Parameter] public string ClusterId { get; set; } = string.Empty;
@@ -65,9 +68,9 @@ else
private static MarkupString StatusBadge(GenerationStatus s) => s switch private static MarkupString StatusBadge(GenerationStatus s) => s switch
{ {
GenerationStatus.Draft => new MarkupString("<span class='badge bg-info'>Draft</span>"), GenerationStatus.Draft => new MarkupString("<span class='chip chip-idle'>Draft</span>"),
GenerationStatus.Published => new MarkupString("<span class='badge bg-success'>Published</span>"), GenerationStatus.Published => new MarkupString("<span class='chip chip-ok'>Published</span>"),
GenerationStatus.Superseded => new MarkupString("<span class='badge bg-secondary'>Superseded</span>"), GenerationStatus.Superseded => new MarkupString("<span class='chip chip-idle'>Superseded</span>"),
_ => new MarkupString($"<span class='badge bg-light text-dark'>{s}</span>"), _ => new MarkupString($"<span class='chip chip-idle'>{s}</span>"),
}; };
} }

View File

@@ -4,7 +4,7 @@
nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's
create + edit forms so the same UI renders regardless of which flow opened it. *@ create + edit forms so the same UI renders regardless of which flow opened it. *@
<h6 class="mt-4">OPC 40010 Identification</h6> <div class="panel-head mt-4">OPC 40010 Identification</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Manufacturer</label> <label class="form-label">Manufacturer</label>

View File

@@ -10,37 +10,38 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="mb-0">Equipment CSV import</h1> <h1 class="page-title mb-0">Equipment CSV import</h1>
<small class="text-muted">Cluster <code>@ClusterId</code> · draft generation @GenerationId</small> <small class="text-muted">Cluster <span class="mono">@ClusterId</span> · draft generation @GenerationId</small>
</div> </div>
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a> <a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
</div> </div>
<div class="alert alert-info small mb-3"> <section class="panel notice rise" style="animation-delay:.02s">
Accepts <code>@EquipmentCsvImporter.VersionMarker</code>-headered CSV per Stream B.3. Accepts <span class="mono">@EquipmentCsvImporter.VersionMarker</span>-headered CSV per Stream B.3.
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns). Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
Optional columns cover the OPC 40010 Identification fields. Paste the file contents Optional columns cover the OPC 40010 Identification fields. Paste the file contents
or upload directly — the parser runs client-stream-side and shows a row-level preview or upload directly — the parser runs client-stream-side and shows a row-level preview
before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT
enforced here yet (see task #197); for now the finalise may fail at commit time if a enforced here yet (see task #197); for now the finalise may fail at commit time if a
reservation conflict exists. reservation conflict exists.
</div> </section>
<div class="alert alert-secondary small mb-3"> <section class="panel notice rise mt-2" style="animation-delay:.08s">
<strong>Per-tag addressing for Modbus drivers</strong> isn't part of equipment import — <strong>Per-tag addressing for Modbus drivers</strong> isn't part of equipment import —
tags are configured at the driver-instance level via the tags are configured at the driver-instance level via the
<a href="/clusters/@ClusterId/draft/@GenerationId">Drivers tab</a>. Use the <a href="/clusters/@ClusterId/draft/@GenerationId">Drivers tab</a>. Use the
<a href="/modbus/address-preview" target="_blank">address-preview tool</a> to sanity-check <a href="/modbus/address-preview" target="_blank">address-preview tool</a> to sanity-check
grammar strings (<code>40001:F:CDAB</code>, <code>HR1:I</code>, <code>V2000</code> for grammar strings (<span class="mono">40001:F:CDAB</span>, <span class="mono">HR1:I</span>, <span class="mono">V2000</span> for
DL205 family, etc.) before pasting them into the driver config. DL205 family, etc.) before pasting them into the driver config.
</div> </section>
<div class="card mb-3"> <section class="panel rise mt-2" style="animation-delay:.14s">
<div class="card-body"> <div class="panel-head">Import configuration</div>
<div class="p-3">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-5"> <div class="col-md-5">
<label class="form-label">Target driver instance (for every accepted row)</label> <label class="form-label">Target driver instance (for every accepted row)</label>
<select class="form-select" @bind="_driverInstanceId"> <select class="form-select form-select-sm" @bind="_driverInstanceId">
<option value="">-- select driver --</option> <option value="">-- select driver --</option>
@if (_drivers is not null) @if (_drivers is not null)
{ {
@@ -50,7 +51,7 @@
</div> </div>
<div class="col-md-5"> <div class="col-md-5">
<label class="form-label">Target UNS line (for every accepted row)</label> <label class="form-label">Target UNS line (for every accepted row)</label>
<select class="form-select" @bind="_unsLineId"> <select class="form-select form-select-sm" @bind="_unsLineId">
<option value="">-- select line --</option> <option value="">-- select line --</option>
@if (_unsLines is not null) @if (_unsLines is not null)
{ {
@@ -64,7 +65,7 @@
</div> </div>
<div class="mt-3"> <div class="mt-3">
<label class="form-label">CSV content (paste or uploaded)</label> <label class="form-label">CSV content (paste or uploaded)</label>
<textarea class="form-control font-monospace" rows="8" @bind="_csvText" <textarea class="form-control mono" rows="8" @bind="_csvText"
placeholder="# OtOpcUaCsv v1&#10;ZTag,MachineCode,SAPID,EquipmentId,…"/> placeholder="# OtOpcUaCsv v1&#10;ZTag,MachineCode,SAPID,EquipmentId,…"/>
</div> </div>
<div class="mt-3"> <div class="mt-3">
@@ -73,28 +74,26 @@
disabled="@(_parseResult is null || _parseResult.AcceptedRows.Count == 0 || string.IsNullOrWhiteSpace(_driverInstanceId) || string.IsNullOrWhiteSpace(_unsLineId) || _busy)"> disabled="@(_parseResult is null || _parseResult.AcceptedRows.Count == 0 || string.IsNullOrWhiteSpace(_driverInstanceId) || string.IsNullOrWhiteSpace(_unsLineId) || _busy)">
Stage + Finalise Stage + Finalise
</button> </button>
@if (_parseError is not null) { <span class="alert alert-danger ms-3 py-1 px-2 small">@_parseError</span> } @if (_parseError is not null) { <span class="chip chip-bad ms-3">@_parseError</span> }
@if (_result is not null) { <span class="alert alert-success ms-3 py-1 px-2 small">@_result</span> } @if (_result is not null) { <span class="chip chip-ok ms-3">@_result</span> }
</div>
</div> </div>
</div> </div>
</section>
@if (_parseResult is not null) @if (_parseResult is not null)
{ {
<div class="row g-3"> <div class="row g-3 mt-1">
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <section class="panel rise" style="animation-delay:.02s">
<div class="card-header bg-success text-white"> <div class="panel-head"><span class="s-ok">Accepted (@_parseResult.AcceptedRows.Count)</span></div>
Accepted (@_parseResult.AcceptedRows.Count) <div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
@if (_parseResult.AcceptedRows.Count == 0) @if (_parseResult.AcceptedRows.Count == 0)
{ {
<p class="text-muted p-3 mb-0">No accepted rows.</p> <p class="text-muted p-3 mb-0">No accepted rows.</p>
} }
else else
{ {
<table class="table table-sm table-striped mb-0"> <table class="data-table">
<thead> <thead>
<tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr> <tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr>
</thead> </thead>
@@ -102,7 +101,7 @@
@foreach (var r in _parseResult.AcceptedRows) @foreach (var r in _parseResult.AcceptedRows)
{ {
<tr> <tr>
<td><code>@r.ZTag</code></td> <td><span class="mono">@r.ZTag</span></td>
<td>@r.MachineCode</td> <td>@r.MachineCode</td>
<td>@r.Name</td> <td>@r.Name</td>
<td>@r.UnsLineName</td> <td>@r.UnsLineName</td>
@@ -112,35 +111,33 @@
</table> </table>
} }
</div> </div>
</div> </section>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <section class="panel rise" style="animation-delay:.08s">
<div class="card-header bg-danger text-white"> <div class="panel-head"><span class="s-bad">Rejected (@_parseResult.RejectedRows.Count)</span></div>
Rejected (@_parseResult.RejectedRows.Count) <div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
@if (_parseResult.RejectedRows.Count == 0) @if (_parseResult.RejectedRows.Count == 0)
{ {
<p class="text-muted p-3 mb-0">No rejections.</p> <p class="text-muted p-3 mb-0">No rejections.</p>
} }
else else
{ {
<table class="table table-sm table-striped mb-0"> <table class="data-table">
<thead><tr><th>Line</th><th>Reason</th></tr></thead> <thead><tr><th class="num">Line</th><th>Reason</th></tr></thead>
<tbody> <tbody>
@foreach (var e in _parseResult.RejectedRows) @foreach (var e in _parseResult.RejectedRows)
{ {
<tr> <tr>
<td>@e.LineNumber</td> <td class="num">@e.LineNumber</td>
<td class="small">@e.Reason</td> <td><span class="s-bad">@e.Reason</span></td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
} }
</div> </div>
</div> </section>
</div> </div>
</div> </div>
} }

View File

@@ -4,7 +4,7 @@
@inject NamespaceService NsSvc @inject NamespaceService NsSvc
<div class="d-flex justify-content-between mb-3"> <div class="d-flex justify-content-between mb-3">
<h4>Namespaces</h4> <h4 class="panel-head">Namespaces</h4>
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add namespace</button> <button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add namespace</button>
</div> </div>
@@ -12,26 +12,37 @@
else if (_namespaces.Count == 0) { <p class="text-muted">No namespaces defined in this draft.</p> } else if (_namespaces.Count == 0) { <p class="text-muted">No namespaces defined in this draft.</p> }
else else
{ {
<table class="table table-sm"> <section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Defined namespaces</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>NamespaceId</th><th>Kind</th><th>URI</th><th>Enabled</th></tr></thead> <thead><tr><th>NamespaceId</th><th>Kind</th><th>URI</th><th>Enabled</th></tr></thead>
<tbody> <tbody>
@foreach (var n in _namespaces) @foreach (var n in _namespaces)
{ {
<tr><td><code>@n.NamespaceId</code></td><td>@n.Kind</td><td>@n.NamespaceUri</td><td>@(n.Enabled ? "yes" : "no")</td></tr> <tr>
<td><span class="mono">@n.NamespaceId</span></td>
<td>@n.Kind</td>
<td>@n.NamespaceUri</td>
<td>@(n.Enabled ? "yes" : "no")</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@if (_showForm) @if (_showForm)
{ {
<div class="card"> <section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Add namespace</div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"><label class="form-label">NamespaceUri</label><input class="form-control" @bind="_uri"/></div> <div class="col-md-6"><label class="form-label">NamespaceUri</label><input class="form-control form-control-sm" @bind="_uri"/></div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Kind</label> <label class="form-label">Kind</label>
<select class="form-select" @bind="_kind"> <select class="form-select form-select-sm" @bind="_kind">
<option value="@NamespaceKind.Equipment">Equipment</option> <option value="@NamespaceKind.Equipment">Equipment</option>
<option value="@NamespaceKind.SystemPlatform">SystemPlatform (Galaxy)</option> <option value="@NamespaceKind.SystemPlatform">SystemPlatform (Galaxy)</option>
</select> </select>
@@ -42,7 +53,7 @@ else
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button> <button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div> </div>
</div> </div>
</div> </section>
} }
@code { @code {

View File

@@ -7,7 +7,7 @@
@inject GenerationService GenerationSvc @inject GenerationService GenerationSvc
@inject NavigationManager Nav @inject NavigationManager Nav
<h1 class="mb-4">New cluster</h1> <h1 class="page-title mb-4">New cluster</h1>
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster"> <EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
<DataAnnotationsValidator/> <DataAnnotationsValidator/>
@@ -44,7 +44,7 @@
@if (!string.IsNullOrEmpty(_error)) @if (!string.IsNullOrEmpty(_error))
{ {
<div class="alert alert-danger mt-3">@_error</div> <section class="panel notice mt-3">@_error</section>
} }
<div class="mt-4"> <div class="mt-4">

View File

@@ -7,18 +7,18 @@
@inject NavigationManager Nav @inject NavigationManager Nav
@implements IAsyncDisposable @implements IAsyncDisposable
<h4>Redundancy topology</h4> <h4 class="panel-head">Redundancy topology</h4>
@if (_roleChangedBanner is not null) @if (_roleChangedBanner is not null)
{ {
<div class="alert alert-info small mb-2">@_roleChangedBanner</div> <section class="panel notice rise" style="animation-delay:.02s">@_roleChangedBanner</section>
} }
<p class="text-muted small"> <p class="text-muted small">
One row per <code>ClusterNode</code> in this cluster. Role, <code>ApplicationUri</code>, One row per <span class="mono">ClusterNode</span> in this cluster. Role, <span class="mono">ApplicationUri</span>,
and <code>ServiceLevelBase</code> are authored separately; the Admin UI shows them read-only and <span class="mono">ServiceLevelBase</span> are authored separately; the Admin UI shows them read-only
here so operators can confirm the published topology without touching it. LastSeen older than here so operators can confirm the published topology without touching it. LastSeen older than
@((int)ClusterNodeService.StaleThreshold.TotalSeconds)s is flagged Stale — the node has @((int)ClusterNodeService.StaleThreshold.TotalSeconds)s is flagged Stale — the node has
stopped heart-beating and is likely down. Role swap goes through the server-side stopped heart-beating and is likely down. Role swap goes through the server-side
<code>RedundancyCoordinator</code> apply-lease flow, not direct DB edits. <span class="mono">RedundancyCoordinator</span> apply-lease flow, not direct DB edits.
</p> </p>
@if (_nodes is null) @if (_nodes is null)
@@ -27,10 +27,10 @@
} }
else if (_nodes.Count == 0) else if (_nodes.Count == 0)
{ {
<div class="alert alert-warning"> <section class="panel notice rise" style="animation-delay:.02s">
No ClusterNode rows for this cluster. The server process needs at least one entry No ClusterNode rows for this cluster. The server process needs at least one entry
(with a non-blank <code>ApplicationUri</code>) before it can start up per OPC UA spec. (with a non-blank <span class="mono">ApplicationUri</span>) before it can start up per OPC UA spec.
</div> </section>
} }
else else
{ {
@@ -39,49 +39,51 @@ else
var standalone = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Standalone); var standalone = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Standalone);
var staleCount = _nodes.Count(ClusterNodeService.IsStale); var staleCount = _nodes.Count(ClusterNodeService.IsStale);
<div class="row g-3 mb-4"> <section class="agg-grid rise" style="animation-delay:.02s">
<div class="col-md-3"><div class="card"><div class="card-body"> <div class="agg-card">
<h6 class="text-muted mb-1">Nodes</h6> <div class="agg-label">Nodes</div>
<div class="fs-3">@_nodes.Count</div> <div class="agg-value numeric">@_nodes.Count</div>
</div></div></div>
<div class="col-md-3"><div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Primary</h6>
<div class="fs-3 text-success">@primaries</div>
</div></div></div>
<div class="col-md-3"><div class="card border-info"><div class="card-body">
<h6 class="text-muted mb-1">Secondary</h6>
<div class="fs-3 text-info">@secondaries</div>
</div></div></div>
<div class="col-md-3"><div class="card @(staleCount > 0 ? "border-warning" : "")"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 @(staleCount > 0 ? "text-warning" : "")">@staleCount</div>
</div></div></div>
</div> </div>
<div class="agg-card">
<div class="agg-label">Primary</div>
<div class="agg-value numeric @(primaries > 0 ? "s-ok" : "")">@primaries</div>
</div>
<div class="agg-card">
<div class="agg-label">Secondary</div>
<div class="agg-value numeric">@secondaries</div>
</div>
<div class="agg-card">
<div class="agg-label">Stale</div>
<div class="agg-value numeric @(staleCount > 0 ? "s-warn" : "")">@staleCount</div>
</div>
</section>
@if (primaries == 0 && standalone == 0) @if (primaries == 0 && standalone == 0)
{ {
<div class="alert alert-danger small mb-3"> <section class="panel notice rise" style="animation-delay:.08s">
No Primary or Standalone node — the cluster has no authoritative write target. Secondaries <span class="s-bad">No Primary or Standalone node — the cluster has no authoritative write target. Secondaries
stay read-only until one of them gets promoted via <code>RedundancyCoordinator</code>. stay read-only until one of them gets promoted via <span class="mono">RedundancyCoordinator</span>.</span>
</div> </section>
} }
else if (primaries > 1) else if (primaries > 1)
{ {
<div class="alert alert-danger small mb-3"> <section class="panel notice rise" style="animation-delay:.08s">
<strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease <span class="s-bad"><strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease
enforcement should have made this impossible at the coordinator level. Investigate enforcement should have made this impossible at the coordinator level. Investigate
immediately — one of the rows was likely hand-edited. immediately — one of the rows was likely hand-edited.</span>
</div> </section>
} }
<table class="table table-sm table-hover align-middle"> <section class="panel rise" style="animation-delay:.14s">
<div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Node</th> <th>Node</th>
<th>Role</th> <th>Role</th>
<th>Host</th> <th>Host</th>
<th class="text-end">OPC UA port</th> <th class="num">OPC UA port</th>
<th class="text-end">ServiceLevel base</th> <th class="num">ServiceLevel base</th>
<th>ApplicationUri</th> <th>ApplicationUri</th>
<th>Enabled</th> <th>Enabled</th>
<th>Last seen</th> <th>Last seen</th>
@@ -90,25 +92,27 @@ else
<tbody> <tbody>
@foreach (var n in _nodes) @foreach (var n in _nodes)
{ {
<tr class="@RowClass(n)"> <tr>
<td><code>@n.NodeId</code></td> <td><span class="mono">@n.NodeId</span></td>
<td><span class="badge @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td> <td><span class="chip @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
<td>@n.Host</td> <td>@n.Host</td>
<td class="text-end"><code>@n.OpcUaPort</code></td> <td class="num mono">@n.OpcUaPort</td>
<td class="text-end">@n.ServiceLevelBase</td> <td class="num">@n.ServiceLevelBase</td>
<td class="small text-break"><code>@n.ApplicationUri</code></td> <td class="mono">@n.ApplicationUri</td>
<td> <td>
@if (n.Enabled) { <span class="badge bg-success">Enabled</span> } @if (n.Enabled) { <span class="chip chip-ok">Enabled</span> }
else { <span class="badge bg-secondary">Disabled</span> } else { <span class="chip chip-idle">Disabled</span> }
</td> </td>
<td class="small @(ClusterNodeService.IsStale(n) ? "text-warning fw-bold" : "")"> <td class="@(ClusterNodeService.IsStale(n) ? "s-warn" : "")">
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value)) @(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
@if (ClusterNodeService.IsStale(n)) { <span class="badge bg-warning text-dark ms-1">Stale</span> } @if (ClusterNodeService.IsStale(n)) { <span class="chip chip-warn ms-1">Stale</span> }
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@code { @code {
@@ -158,10 +162,10 @@ else
private static string RoleBadge(RedundancyRole r) => r switch private static string RoleBadge(RedundancyRole r) => r switch
{ {
RedundancyRole.Primary => "bg-success", RedundancyRole.Primary => "chip-ok",
RedundancyRole.Secondary => "bg-info", RedundancyRole.Secondary => "chip-idle",
RedundancyRole.Standalone => "bg-primary", RedundancyRole.Standalone => "chip-idle",
_ => "bg-secondary", _ => "chip-idle",
}; };
private static string FormatAge(DateTime t) private static string FormatAge(DateTime t)

View File

@@ -13,7 +13,7 @@
*@ *@
<div class="script-editor"> <div class="script-editor">
<textarea class="form-control font-monospace" rows="14" spellcheck="false" <textarea class="form-control mono" rows="14" spellcheck="false"
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea> @bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
</div> </div>

View File

@@ -7,7 +7,7 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h4 class="mb-0">Scripts</h4> <h4 class="panel-head mb-0">Scripts</h4>
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small> <small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
</div> </div>
<button class="btn btn-primary" @onclick="StartNew">+ New script</button> <button class="btn btn-primary" @onclick="StartNew">+ New script</button>
@@ -18,7 +18,7 @@
@if (_loading) { <p class="text-muted">Loading…</p> } @if (_loading) { <p class="text-muted">Loading…</p> }
else if (_scripts.Count == 0 && _editing is null) else if (_scripts.Count == 0 && _editing is null)
{ {
<div class="alert alert-info">No scripts yet in this draft.</div> <section class="panel notice rise" style="animation-delay:.02s">No scripts yet in this draft.</section>
} }
else else
{ {
@@ -30,7 +30,7 @@ else
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")" <button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
@onclick="() => Open(s)"> @onclick="() => Open(s)">
<strong>@s.Name</strong> <strong>@s.Name</strong>
<div class="small text-muted font-monospace">@s.ScriptId</div> <div class="small text-muted mono">@s.ScriptId</div>
</button> </button>
} }
</div> </div>
@@ -38,8 +38,8 @@ else
<div class="col-md-8"> <div class="col-md-8">
@if (_editing is not null) @if (_editing is not null)
{ {
<div class="card"> <section class="panel rise" style="animation-delay:.02s">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="panel-head d-flex justify-content-between align-items-center">
<strong>@(_isNew ? "New script" : _editing.Name)</strong> <strong>@(_isNew ? "New script" : _editing.Name)</strong>
<div> <div>
@if (!_isNew) @if (!_isNew)
@@ -49,10 +49,10 @@ else
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button> <button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
</div> </div>
</div> </div>
<div class="card-body"> <div class="p-3">
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Name</label> <label class="form-label">Name</label>
<input class="form-control" @bind="_editing.Name"/> <input class="form-control form-control-sm" @bind="_editing.Name"/>
</div> </div>
<label class="form-label">Source</label> <label class="form-label">Source</label>
<ScriptEditor @bind-Source="_editing.SourceCode"/> <ScriptEditor @bind-Source="_editing.SourceCode"/>
@@ -70,7 +70,7 @@ else
else else
{ {
<ul class="mb-1"> <ul class="mb-1">
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> } @foreach (var r in _dependencies.Reads) { <li><span class="mono">@r</span></li> }
</ul> </ul>
} }
<strong>Inferred writes</strong> <strong>Inferred writes</strong>
@@ -78,17 +78,17 @@ else
else else
{ {
<ul class="mb-1"> <ul class="mb-1">
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> } @foreach (var w in _dependencies.Writes) { <li><span class="mono">@w</span></li> }
</ul> </ul>
} }
@if (_dependencies.Rejections.Count > 0) @if (_dependencies.Rejections.Count > 0)
{ {
<div class="alert alert-danger mt-2"> <section class="panel notice mt-2">
<strong>Non-literal paths rejected:</strong> <strong>Non-literal paths rejected:</strong>
<ul class="mb-0"> <ul class="mb-0">
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> } @foreach (var r in _dependencies.Rejections) { <li><span class="s-bad">@r.Message</span></li> }
</ul> </ul>
</div> </section>
} }
</div> </div>
} }
@@ -96,24 +96,24 @@ else
@if (_testResult is not null) @if (_testResult is not null)
{ {
<div class="mt-3 border-top pt-3"> <div class="mt-3 border-top pt-3">
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span> <strong>Harness result:</strong> <span class="chip chip-idle">@_testResult.Outcome</span>
@if (_testResult.Outcome == ScriptTestOutcome.Success) @if (_testResult.Outcome == ScriptTestOutcome.Success)
{ {
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div> <div>Output: <span class="mono">@(_testResult.Output?.ToString() ?? "null")</span></div>
@if (_testResult.Writes.Count > 0) @if (_testResult.Writes.Count > 0)
{ {
<div class="mt-1"><strong>Writes:</strong> <div class="mt-1"><strong>Writes:</strong>
<ul class="mb-0"> <ul class="mb-0">
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> } @foreach (var kv in _testResult.Writes) { <li><span class="mono">@kv.Key</span> = <span class="mono">@(kv.Value?.ToString() ?? "null")</span></li> }
</ul> </ul>
</div> </div>
} }
} }
@if (_testResult.Errors.Count > 0) @if (_testResult.Errors.Count > 0)
{ {
<div class="alert alert-warning mt-2 mb-0"> <section class="panel notice mt-2 mb-0">
@foreach (var e in _testResult.Errors) { <div>@e</div> } @foreach (var e in _testResult.Errors) { <div><span class="s-warn">@e</span></div> }
</div> </section>
} }
@if (_testResult.LogEvents.Count > 0) @if (_testResult.LogEvents.Count > 0)
{ {
@@ -126,7 +126,7 @@ else
</div> </div>
} }
</div> </div>
</div> </section>
} }
</div> </div>
</div> </div>

View File

@@ -15,15 +15,13 @@
a generic JSON textarea, matching the DriversTab pattern from #147. a generic JSON textarea, matching the DriversTab pattern from #147.
*@ *@
<div class="d-flex justify-content-between mb-3"> <section class="panel rise" style="animation-delay:.02s">
<h4>Tags (draft gen @GenerationId)</h4> <div class="panel-head d-flex justify-content-between align-items-center">
<span>Tags (draft gen @GenerationId)</span>
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add tag</button> <button class="btn btn-primary btn-sm" @onclick="StartAdd">Add tag</button>
</div> </div>
<div class="toolbar">
<div class="row g-3 mb-3"> <select class="form-select form-select-sm tb-state" @bind="_filterDriverId" @bind:after="ReloadAsync">
<div class="col-md-4">
<label class="form-label small text-muted">Filter by driver</label>
<select class="form-select form-select-sm" @bind="_filterDriverId" @bind:after="ReloadAsync">
<option value="">— all drivers —</option> <option value="">— all drivers —</option>
@if (_drivers is not null) @if (_drivers is not null)
{ {
@@ -33,14 +31,15 @@
} }
} }
</select> </select>
<span class="spacer"></span>
@if (_tags is not null) { <span class="tb-count">@_tags.Count tags</span> }
</div> </div>
</div> @if (_tags is null) { <p class="p-3">Loading…</p> }
else if (_tags.Count == 0 && !_showForm) { <p class="p-3 text-muted">No tags in this filter.</p> }
@if (_tags is null) { <p>Loading…</p> }
else if (_tags.Count == 0 && !_showForm) { <p class="text-muted">No tags in this filter.</p> }
else if (_tags.Count > 0) else if (_tags.Count > 0)
{ {
<table class="table table-sm"> <div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr><th>Name</th><th>Driver</th><th>Equipment</th><th>DataType</th><th>Access</th><th>TagConfig</th><th></th></tr> <tr><th>Name</th><th>Driver</th><th>Equipment</th><th>DataType</th><th>Access</th><th>TagConfig</th><th></th></tr>
</thead> </thead>
@@ -49,11 +48,11 @@ else if (_tags.Count > 0)
{ {
<tr> <tr>
<td>@t.Name</td> <td>@t.Name</td>
<td><code>@t.DriverInstanceId</code></td> <td><span class="mono">@t.DriverInstanceId</span></td>
<td>@(t.EquipmentId ?? "—")</td> <td>@(t.EquipmentId ?? "—")</td>
<td>@t.DataType</td> <td>@t.DataType</td>
<td>@t.AccessLevel</td> <td>@t.AccessLevel</td>
<td class="font-monospace small text-truncate" style="max-width:18rem">@t.TagConfig</td> <td class="mono" style="max-width:18rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">@t.TagConfig</td>
<td> <td>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(t)">Edit</button> <button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(t)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(t.TagRowId)">Remove</button> <button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(t.TagRowId)">Remove</button>
@@ -62,13 +61,15 @@ else if (_tags.Count > 0)
} }
</tbody> </tbody>
</table> </table>
</div>
} }
</section>
@if (_showForm) @if (_showForm)
{ {
<div class="card mt-3"> <section class="panel rise mt-3" style="animation-delay:.08s">
<div class="card-body"> <div class="panel-head">@(_editMode ? "Edit tag" : "New tag")</div>
<h5>@(_editMode ? "Edit tag" : "New tag")</h5> <div class="p-3">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Name</label> <label class="form-label">Name</label>
@@ -176,14 +177,14 @@ else if (_tags.Count > 0)
} }
} }
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> } @if (_error is not null) { <section class="panel notice mt-3"><span class="s-bad">@_error</span></section> }
<div class="mt-3"> <div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button> <button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="Cancel">Cancel</button> <button class="btn btn-sm btn-secondary ms-2" @onclick="Cancel">Cancel</button>
</div> </div>
</div> </div>
</div> </section>
} }
@code { @code {

View File

@@ -2,26 +2,28 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject UnsService UnsSvc @inject UnsService UnsSvc
<div class="alert alert-info small mb-3"> <section class="panel notice rise" style="animation-delay:.02s">
Drag any line in the <strong>UNS Lines</strong> table onto an area row in <strong>UNS Areas</strong> Drag any line in the <strong>UNS Lines</strong> table onto an area row in <strong>UNS Areas</strong>
to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm
or cancel. If another operator modifies the draft while you're confirming, you'll see a 409 or cancel. If another operator modifies the draft while you're confirming, you'll see a 409
refresh-required modal instead of clobbering their work. refresh-required modal instead of clobbering their work.
</div> </section>
<div class="row"> <div class="row mt-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex justify-content-between mb-2"> <section class="panel rise" style="animation-delay:.08s">
<h4>UNS Areas</h4> <div class="panel-head d-flex justify-content-between align-items-center">
<span>UNS Areas</span>
<button class="btn btn-sm btn-primary" @onclick="() => _showAreaForm = true">Add area</button> <button class="btn btn-sm btn-primary" @onclick="() => _showAreaForm = true">Add area</button>
</div> </div>
@if (_areas is null) { <p>Loading…</p> } @if (_areas is null) { <p class="p-3">Loading…</p> }
else if (_areas.Count == 0) { <p class="text-muted">No areas yet.</p> } else if (_areas.Count == 0) { <p class="p-3 text-muted">No areas yet.</p> }
else else
{ {
<table class="table table-sm"> <div class="table-wrap">
<thead><tr><th>AreaId</th><th>Name</th><th class="small text-muted">(drop target)</th></tr></thead> <table class="data-table">
<thead><tr><th>AreaId</th><th>Name</th><th class="text-muted">(drop target)</th></tr></thead>
<tbody> <tbody>
@foreach (var a in _areas) @foreach (var a in _areas)
{ {
@@ -31,38 +33,40 @@
@ondragleave="() => _hoverAreaId = null" @ondragleave="() => _hoverAreaId = null"
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)" @ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
@ondrop:preventDefault> @ondrop:preventDefault>
<td><code>@a.UnsAreaId</code></td> <td><span class="mono">@a.UnsAreaId</span></td>
<td>@a.Name</td> <td>@a.Name</td>
<td class="small text-muted">drop here</td> <td class="text-muted">drop here</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
} }
@if (_showAreaForm) @if (_showAreaForm)
{ {
<div class="card"> <div class="p-3 border-top">
<div class="card-body"> <div class="mb-2"><label class="form-label">Name (lowercase segment)</label><input class="form-control form-control-sm" @bind="_newAreaName"/></div>
<div class="mb-2"><label class="form-label">Name (lowercase segment)</label><input class="form-control" @bind="_newAreaName"/></div>
<button class="btn btn-sm btn-primary" @onclick="AddAreaAsync">Save</button> <button class="btn btn-sm btn-primary" @onclick="AddAreaAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showAreaForm = false">Cancel</button> <button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showAreaForm = false">Cancel</button>
</div> </div>
</div>
} }
</section>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex justify-content-between mb-2"> <section class="panel rise" style="animation-delay:.14s">
<h4>UNS Lines</h4> <div class="panel-head d-flex justify-content-between align-items-center">
<span>UNS Lines</span>
<button class="btn btn-sm btn-primary" @onclick="() => _showLineForm = true" disabled="@(_areas is null || _areas.Count == 0)">Add line</button> <button class="btn btn-sm btn-primary" @onclick="() => _showLineForm = true" disabled="@(_areas is null || _areas.Count == 0)">Add line</button>
</div> </div>
@if (_lines is null) { <p>Loading…</p> } @if (_lines is null) { <p class="p-3">Loading…</p> }
else if (_lines.Count == 0) { <p class="text-muted">No lines yet.</p> } else if (_lines.Count == 0) { <p class="p-3 text-muted">No lines yet.</p> }
else else
{ {
<table class="table table-sm"> <div class="table-wrap">
<table class="data-table">
<thead><tr><th>LineId</th><th>Area</th><th>Name</th></tr></thead> <thead><tr><th>LineId</th><th>Area</th><th>Name</th></tr></thead>
<tbody> <tbody>
@foreach (var l in _lines) @foreach (var l in _lines)
@@ -71,31 +75,31 @@
@ondragstart="() => _dragLineId = l.UnsLineId" @ondragstart="() => _dragLineId = l.UnsLineId"
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }" @ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
style="cursor: grab;"> style="cursor: grab;">
<td><code>@l.UnsLineId</code></td> <td><span class="mono">@l.UnsLineId</span></td>
<td><code>@l.UnsAreaId</code></td> <td><span class="mono">@l.UnsAreaId</span></td>
<td>@l.Name</td> <td>@l.Name</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
} }
@if (_showLineForm && _areas is not null) @if (_showLineForm && _areas is not null)
{ {
<div class="card"> <div class="p-3 border-top">
<div class="card-body">
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Area</label> <label class="form-label">Area</label>
<select class="form-select" @bind="_newLineAreaId"> <select class="form-select form-select-sm" @bind="_newLineAreaId">
@foreach (var a in _areas) { <option value="@a.UnsAreaId">@a.Name (@a.UnsAreaId)</option> } @foreach (var a in _areas) { <option value="@a.UnsAreaId">@a.Name (@a.UnsAreaId)</option> }
</select> </select>
</div> </div>
<div class="mb-2"><label class="form-label">Name</label><input class="form-control" @bind="_newLineName"/></div> <div class="mb-2"><label class="form-label">Name</label><input class="form-control form-control-sm" @bind="_newLineName"/></div>
<button class="btn btn-sm btn-primary" @onclick="AddLineAsync">Save</button> <button class="btn btn-sm btn-primary" @onclick="AddLineAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showLineForm = false">Cancel</button> <button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showLineForm = false">Cancel</button>
</div> </div>
</div>
} }
</section>
</div> </div>
</div> </div>
@@ -117,11 +121,11 @@
</p> </p>
@if (_pendingPreview.CascadeWarnings.Count > 0) @if (_pendingPreview.CascadeWarnings.Count > 0)
{ {
<div class="alert alert-warning small mb-0"> <section class="panel notice small mb-0">
<ul class="mb-0"> <ul class="mb-0">
@foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> } @foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> }
</ul> </ul>
</div> </section>
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@@ -2,7 +2,7 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Admin.Services
@inject FocasDriverDetailService DetailSvc @inject FocasDriverDetailService DetailSvc
<h1 class="mb-3">FOCAS driver <code>@InstanceId</code></h1> <h1 class="page-title">FOCAS driver <span class="mono">@InstanceId</span></h1>
@if (_loading) @if (_loading)
{ {
@@ -10,149 +10,168 @@
} }
else if (_detail is null) else if (_detail is null)
{ {
<div class="alert alert-warning"> <section class="panel notice">
No FOCAS driver instance with id <code>@InstanceId</code> was found. No FOCAS driver instance with id <span class="mono">@InstanceId</span> was found.
<div class="small text-muted mt-1"> <div class="small text-muted mt-1">
Either the id is wrong, or the instance's <code>DriverType</code> is not "Focas". The list of drivers per cluster draft is on the <a href="/clusters">Clusters</a> page. 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>
</div> </div>
</section>
} }
else else
{ {
<div class="row g-3 mb-4"> <section class="agg-grid rise" style="animation-delay:.02s">
<div class="col-md-3"><div class="card"><div class="card-body"> <div class="agg-card">
<h6 class="text-muted mb-1">Name</h6> <div class="agg-label">Name</div>
<div class="fs-5">@_detail.Instance.Name</div> <div class="agg-value">@_detail.Instance.Name</div>
</div></div></div>
<div class="col-md-3"><div class="card"><div class="card-body">
<h6 class="text-muted mb-1">Cluster</h6>
<div class="fs-5"><code>@_detail.Instance.ClusterId</code></div>
</div></div></div>
<div class="col-md-3"><div class="card"><div class="card-body">
<h6 class="text-muted mb-1">Namespace</h6>
<div class="fs-5"><code>@_detail.Instance.NamespaceId</code></div>
</div></div></div>
<div class="col-md-3"><div class="card @(_detail.Instance.Enabled ? "border-success" : "border-secondary")"><div class="card-body">
<h6 class="text-muted mb-1">Enabled</h6>
<div class="fs-5">@(_detail.Instance.Enabled ? "Yes" : "No")</div>
</div></div></div>
</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) @if (_detail.ParseError is not null)
{ {
<div class="alert alert-danger"> <section class="panel notice rise" style="animation-delay:.08s">
<strong>DriverConfig JSON failed to parse:</strong> @_detail.ParseError <strong>DriverConfig JSON failed to parse:</strong> @_detail.ParseError
<div class="small text-muted mt-1"> <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. Falling back to raw-JSON view below; the per-section tables are hidden because the shape couldn't be projected.
</div> </div>
</div> </section>
} }
else if (_detail.Config is not null) else if (_detail.Config is not null)
{ {
<h2 class="h5 mt-4">Devices</h2>
@if (_detail.Config.Devices is null || _detail.Config.Devices.Count == 0) @if (_detail.Config.Devices is null || _detail.Config.Devices.Count == 0)
{ {
<p class="text-muted">No devices configured.</p> <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 else
{ {
<table class="table table-sm align-middle"> <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> <thead><tr><th>HostAddress</th><th>DeviceName</th><th>Series</th></tr></thead>
<tbody> <tbody>
@foreach (var d in _detail.Config.Devices) @foreach (var d in _detail.Config.Devices)
{ {
<tr> <tr>
<td><code>@d.HostAddress</code></td> <td class="mono">@d.HostAddress</td>
<td>@(d.DeviceName ?? "—")</td> <td>@(d.DeviceName ?? "—")</td>
<td>@(string.IsNullOrEmpty(d.Series) ? "Unknown" : d.Series)</td> <td>@(string.IsNullOrEmpty(d.Series) ? "Unknown" : d.Series)</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
<h2 class="h5 mt-4">Tags</h2>
@if (_detail.Config.Tags is null || _detail.Config.Tags.Count == 0) @if (_detail.Config.Tags is null || _detail.Config.Tags.Count == 0)
{ {
<p class="text-muted">No tags configured.</p> <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 else
{ {
<p class="small text-muted">@_detail.Config.Tags.Count tag(s) configured.</p> <section class="panel rise" style="animation-delay:.14s">
<table class="table table-sm align-middle"> <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> <thead><tr><th>Name</th><th>Device</th><th>Address</th><th>DataType</th><th>Writable</th></tr></thead>
<tbody> <tbody>
@foreach (var t in _detail.Config.Tags) @foreach (var t in _detail.Config.Tags)
{ {
<tr> <tr>
<td>@t.Name</td> <td>@t.Name</td>
<td><code class="small">@t.DeviceHostAddress</code></td> <td class="mono">@t.DeviceHostAddress</td>
<td><code>@t.Address</code></td> <td class="mono">@t.Address</td>
<td>@t.DataType</td> <td>@t.DataType</td>
<td>@(t.Writable ? "Yes" : "No")</td> <td>@(t.Writable ? "Yes" : "No")</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
<h2 class="h5 mt-4">Driver behaviour</h2> <section class="card-grid rise" style="animation-delay:.20s">
<table class="table table-sm align-middle" style="max-width: 640px;"> <div class="metric-card">
<tbody> <div class="panel-head">Driver behaviour</div>
<tr> <div class="kv">
<th style="width: 30%;">Probe</th> <span class="k">Probe</span>
<td> <span class="v">
@if (_detail.Config.Probe is { } probe) @if (_detail.Config.Probe is { } probe)
{ {
<span class="badge @(probe.Enabled ? "bg-success" : "bg-secondary")">@(probe.Enabled ? "Enabled" : "Disabled")</span> <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> <span class="ms-2 small text-muted">Interval: @(probe.Interval ?? "default")</span>
} }
else { <span class="text-muted">default (enabled)</span> } else { <span class="text-muted">default (enabled)</span> }
</td> </span>
</tr> </div>
<tr> <div class="kv">
<th>Alarm projection</th> <span class="k">Alarm projection</span>
<td> <span class="v">
@if (_detail.Config.AlarmProjection is { } ap) @if (_detail.Config.AlarmProjection is { } ap)
{ {
<span class="badge @(ap.Enabled ? "bg-success" : "bg-secondary")">@(ap.Enabled ? "Enabled" : "Disabled")</span> <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> <span class="ms-2 small text-muted">PollInterval: @(ap.PollInterval ?? "default")</span>
} }
else { <span class="text-muted">disabled (default)</span> } else { <span class="text-muted">disabled (default)</span> }
</td> </span>
</tr> </div>
<tr> <div class="kv">
<th>Handle recycling</th> <span class="k">Handle recycling</span>
<td> <span class="v">
@if (_detail.Config.HandleRecycle is { } hr) @if (_detail.Config.HandleRecycle is { } hr)
{ {
<span class="badge @(hr.Enabled ? "bg-warning text-dark" : "bg-secondary")">@(hr.Enabled ? "Enabled" : "Disabled")</span> <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> <span class="ms-2 small text-muted">Interval: @(hr.Interval ?? "default (01:00:00)")</span>
} }
else { <span class="text-muted">disabled (default)</span> } else { <span class="text-muted">disabled (default)</span> }
</td> </span>
</tr> </div>
</tbody> </div>
</table> </section>
} }
<h2 class="h5 mt-4">Host status</h2>
@if (_detail.HostStatuses.Count == 0) @if (_detail.HostStatuses.Count == 0)
{ {
<div class="alert alert-secondary small"> <section class="panel notice rise" style="animation-delay:.26s">
No <code>DriverHostStatus</code> rows yet for this instance. The Server publishes its first 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. 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.
</div> </section>
} }
else else
{ {
<table class="table table-sm table-hover align-middle"> <section class="panel rise" style="animation-delay:.26s">
<div class="panel-head">Host status</div>
<div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Node</th> <th>Node</th>
<th>Host</th> <th>Host</th>
<th>State</th> <th>State</th>
<th class="text-end" title="Consecutive failures">Fail#</th> <th class="num" title="Consecutive failures">Fail#</th>
<th>Breaker last opened</th> <th>Breaker last opened</th>
<th>Last recycled</th> <th>Last recycled</th>
<th>Last seen</th> <th>Last seen</th>
@@ -162,26 +181,30 @@ else
<tbody> <tbody>
@foreach (var r in _detail.HostStatuses) @foreach (var r in _detail.HostStatuses)
{ {
<tr class="@(IsStale(r) ? "table-warning" : "")"> <tr>
<td><code>@r.NodeId</code></td> <td class="mono">@r.NodeId</td>
<td>@r.HostName</td> <td>@r.HostName</td>
<td><span class="badge @StateBadge(r.State)">@r.State</span></td> <td><span class="chip @StateBadge(r.State)">@r.State</span></td>
<td class="text-end small">@r.ConsecutiveFailures</td> <td class="num small">@r.ConsecutiveFailures</td>
<td class="small">@FormatUtc(r.LastCircuitBreakerOpenUtc)</td> <td class="small">@FormatUtc(r.LastCircuitBreakerOpenUtc)</td>
<td class="small">@FormatUtc(r.LastRecycleUtc)</td> <td class="small">@FormatUtc(r.LastRecycleUtc)</td>
<td class="small @(IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</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> <td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
<h2 class="h5 mt-4">Raw DriverConfig JSON</h2> <section class="panel rise" style="animation-delay:.32s">
<pre class="small bg-light border p-3"><code>@_detail.Instance.DriverConfig</code></pre> <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"> <div class="mt-4 small text-muted">
Docs: <code>docs/drivers/FOCAS.md</code> (getting started) · <code>docs/v2/focas-deployment.md</code> (NSSM + pipe ACL) · <code>docs/drivers/FOCAS-Test-Fixture.md</code> (test coverage). 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> </div>
} }
@@ -203,11 +226,11 @@ else
private static string StateBadge(string state) => state switch private static string StateBadge(string state) => state switch
{ {
"Running" => "bg-success", "Running" => "chip-ok",
"Faulted" => "bg-danger", "Faulted" => "chip-bad",
"Starting" => "bg-info", "Starting" => "chip-idle",
"Stopped" => "bg-secondary", "Stopped" => "chip-idle",
_ => "bg-secondary", _ => "chip-idle",
}; };
private static string FormatUtc(DateTime? utc) => private static string FormatUtc(DateTime? utc) =>

View File

@@ -5,7 +5,7 @@
@inject IServiceScopeFactory ScopeFactory @inject IServiceScopeFactory ScopeFactory
@implements IDisposable @implements IDisposable
<h1 class="mb-4">Fleet status</h1> <h1 class="page-title">Fleet status</h1>
<div class="d-flex align-items-center mb-3 gap-2"> <div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing"> <button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@@ -23,46 +23,41 @@
} }
else if (_rows.Count == 0) else if (_rows.Count == 0)
{ {
<div class="alert alert-info"> <section class="panel notice" style="animation-delay:.02s">
No node state recorded yet. Nodes publish their state to the central DB on each poll; if No node state recorded yet. Nodes publish their state to the central DB on each poll; if
this list is empty, either no nodes have been registered or the poller hasn't run yet. this list is empty, either no nodes have been registered or the poller hasn't run yet.
</div> </section>
} }
else else
{ {
<div class="row g-3 mb-4"> <section class="agg-grid rise" style="animation-delay:.02s">
<div class="col-md-3"> <div class="agg-card">
<div class="card"><div class="card-body"> <div class="agg-label">Nodes</div>
<h6 class="text-muted mb-1">Nodes</h6> <div class="agg-value numeric">@_rows.Count</div>
<div class="fs-3">@_rows.Count</div>
</div></div>
</div> </div>
<div class="col-md-3"> <div class="agg-card">
<div class="card border-success"><div class="card-body"> <div class="agg-label">Applied</div>
<h6 class="text-muted mb-1">Applied</h6> <div class="agg-value numeric">@_rows.Count(r => r.Status == "Applied")</div>
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
</div></div>
</div> </div>
<div class="col-md-3"> <div class="agg-card caution">
<div class="card border-warning"><div class="card-body"> <div class="agg-label">Stale</div>
<h6 class="text-muted mb-1">Stale</h6> <div class="agg-value numeric">@_rows.Count(r => IsStale(r))</div>
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-danger"><div class="card-body">
<h6 class="text-muted mb-1">Failed</h6>
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
</div></div>
</div> </div>
<div class="agg-card alert">
<div class="agg-label">Failed</div>
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Failed")</div>
</div> </div>
</section>
<table class="table table-hover align-middle"> <section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Nodes</div>
<div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Node</th> <th>Node</th>
<th>Cluster</th> <th>Cluster</th>
<th>Generation</th> <th class="num">Generation</th>
<th>Status</th> <th>Status</th>
<th>Last applied</th> <th>Last applied</th>
<th>Last seen</th> <th>Last seen</th>
@@ -72,20 +67,22 @@ else
<tbody> <tbody>
@foreach (var r in _rows) @foreach (var r in _rows)
{ {
<tr class="@RowClass(r)"> <tr>
<td><code>@r.NodeId</code></td> <td><span class="mono">@r.NodeId</span></td>
<td>@r.ClusterId</td> <td>@r.ClusterId</td>
<td>@(r.GenerationId?.ToString() ?? "—")</td> <td class="num">@(r.GenerationId?.ToString() ?? "—")</td>
<td> <td>
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span> <span class="chip @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
</td> </td>
<td>@FormatAge(r.AppliedAt)</td> <td>@FormatAge(r.AppliedAt)</td>
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td> <td>@FormatAge(r.SeenAt)</td>
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td> <td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@code { @code {
@@ -115,13 +112,16 @@ else
{ {
using var scope = ScopeFactory.CreateScope(); using var scope = ScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>(); var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
// Order on the join's plain columns before projecting into FleetNodeRow —
// EF Core cannot translate OrderBy over a property of a constructed record.
var rows = await db.ClusterNodeGenerationStates.AsNoTracking() var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow( .Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n })
s.NodeId, n.ClusterId, s.CurrentGenerationId, .OrderBy(x => x.n.ClusterId)
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null, .ThenBy(x => x.s.NodeId)
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt)) .Select(x => new FleetNodeRow(
.OrderBy(r => r.ClusterId) x.s.NodeId, x.n.ClusterId, x.s.CurrentGenerationId,
.ThenBy(r => r.NodeId) x.s.LastAppliedStatus != null ? x.s.LastAppliedStatus.ToString() : null,
x.s.LastAppliedError, x.s.LastAppliedAt, x.s.LastSeenAt))
.ToListAsync(); .ToListAsync();
_rows = rows; _rows = rows;
_lastRefreshUtc = DateTime.UtcNow; _lastRefreshUtc = DateTime.UtcNow;
@@ -148,10 +148,10 @@ else
private static string StatusBadge(string? status) => status switch private static string StatusBadge(string? status) => status switch
{ {
"Applied" => "bg-success", "Applied" => "chip-ok",
"Failed" => "bg-danger", "Failed" => "chip-bad",
"Applying" => "bg-info", "Applying" => "chip-idle",
_ => "bg-secondary", _ => "chip-idle",
}; };
private static string FormatAge(DateTime? t) private static string FormatAge(DateTime? t)

View File

@@ -5,62 +5,93 @@
@inject GenerationService GenerationSvc @inject GenerationService GenerationSvc
@inject NavigationManager Nav @inject NavigationManager Nav
<h1 class="mb-4">Fleet overview</h1> <h1 class="page-title">Fleet overview</h1>
@if (_clusters is null) @if (_clusters is null)
{ {
<p>Loading…</p> <section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Loading</div>
<div style="padding:1.4rem;color:var(--ink-faint);font-style:italic">Loading fleet&hellip;</div>
</section>
} }
else if (_clusters.Count == 0) else if (_clusters.Count == 0)
{ {
<div class="alert alert-info"> <section class="panel notice rise" style="animation-delay:.02s">
No clusters configured yet. <a href="/clusters/new">Create the first cluster</a>. No clusters configured yet. <a href="/clusters/new">Create the first cluster</a>.
</div> </section>
} }
else else
{ {
<div class="row g-3 mb-4"> <section class="agg-grid rise" style="animation-delay:.02s">
<div class="col-md-3"> <div class="agg-card">
<div class="card"><div class="card-body"><h6 class="text-muted">Clusters</h6><div class="fs-2">@_clusters.Count</div></div></div> <div class="agg-label">Clusters</div>
<div class="agg-value numeric">@_clusters.Count</div>
</div> </div>
<div class="col-md-3"> <div class="agg-card">
<div class="card"><div class="card-body"><h6 class="text-muted">Active drafts</h6><div class="fs-2">@_activeDraftCount</div></div></div> <div class="agg-label">Active drafts</div>
<div class="agg-value numeric">@_activeDraftCount</div>
</div> </div>
<div class="col-md-3"> <div class="agg-card">
<div class="card"><div class="card-body"><h6 class="text-muted">Published generations</h6><div class="fs-2">@_publishedCount</div></div></div> <div class="agg-label">Published generations</div>
</div> <div class="agg-value numeric">@_publishedCount</div>
<div class="col-md-3">
<div class="card"><div class="card-body"><h6 class="text-muted">Disabled clusters</h6><div class="fs-2">@_clusters.Count(c => !c.Enabled)</div></div></div>
</div> </div>
<div class="agg-card @(_disabledCount > 0 ? "caution" : "")">
<div class="agg-label">Disabled clusters</div>
<div class="agg-value numeric">@_disabledCount</div>
</div> </div>
</section>
<h4 class="mt-4 mb-3">Clusters</h4> <section class="panel rise" style="animation-delay:.08s">
<table class="table table-hover"> <div class="panel-head">Clusters</div>
<thead><tr><th>ClusterId</th><th>Name</th><th>Enterprise / Site</th><th>Redundancy</th><th>Enabled</th><th></th></tr></thead> <div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Cluster ID</th>
<th>Name</th>
<th>Enterprise / Site</th>
<th>Redundancy</th>
<th>State</th>
<th></th>
</tr>
</thead>
<tbody> <tbody>
@foreach (var c in _clusters) @foreach (var c in _clusters)
{ {
<tr style="cursor: pointer;"> <tr @onclick="@(() => Nav.NavigateTo($"/clusters/{c.ClusterId}"))">
<td><code>@c.ClusterId</code></td> <td class="mono">@c.ClusterId</td>
<td>@c.Name</td> <td>@c.Name</td>
<td>@c.Enterprise / @c.Site</td> <td>@c.Enterprise / @c.Site</td>
<td>@c.RedundancyMode</td> <td class="mono">@c.RedundancyMode</td>
<td>@(c.Enabled ? "Yes" : "No")</td> <td>
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td> @if (c.Enabled)
{
<span class="chip chip-ok">enabled</span>
}
else
{
<span class="chip chip-idle">disabled</span>
}
</td>
<td><a href="/clusters/@c.ClusterId">Open</a></td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@code { @code {
private List<ServerCluster>? _clusters; private List<ServerCluster>? _clusters;
private int _activeDraftCount; private int _activeDraftCount;
private int _publishedCount; private int _publishedCount;
private int _disabledCount;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_clusters = await ClusterSvc.ListAsync(CancellationToken.None); _clusters = await ClusterSvc.ListAsync(CancellationToken.None);
_disabledCount = _clusters.Count(c => !c.Enabled);
foreach (var c in _clusters) foreach (var c in _clusters)
{ {

View File

@@ -8,7 +8,7 @@
@inject NavigationManager Nav @inject NavigationManager Nav
@implements IAsyncDisposable @implements IAsyncDisposable
<h1 class="mb-4">Driver host status</h1> <h1 class="page-title">Driver host status</h1>
<div class="d-flex align-items-center mb-3 gap-2"> <div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing"> <button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@@ -20,13 +20,13 @@
</span> </span>
</div> </div>
<div class="alert alert-info small mb-4"> <section class="panel notice rise" style="animation-delay:.02s">
Each row is one host reported by a driver instance on a server node. Galaxy drivers report Each row is one host reported by a driver instance on a server node. Galaxy drivers report
per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out
of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than
30s are flagged Stale, which usually means the owning Server process has crashed or lost 30s are flagged Stale, which usually means the owning Server process has crashed or lost
its DB connection. its DB connection.
</div> </section>
@if (_rows is null) @if (_rows is null)
{ {
@@ -34,53 +34,55 @@
} }
else if (_rows.Count == 0) else if (_rows.Count == 0)
{ {
<div class="alert alert-secondary"> <section class="panel notice" style="animation-delay:.08s">
No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements <code>IHostConnectivityProbe</code>. No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements <span class="mono">IHostConnectivityProbe</span>.
</div> </section>
} }
else else
{ {
<div class="row g-3 mb-4"> <section class="agg-grid rise" style="animation-delay:.08s">
<div class="col-md-3"><div class="card"><div class="card-body"> <div class="agg-card">
<h6 class="text-muted mb-1">Hosts</h6> <div class="agg-label">Hosts</div>
<div class="fs-3">@_rows.Count</div> <div class="agg-value numeric">@_rows.Count</div>
</div></div></div>
<div class="col-md-3"><div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Running</h6>
<div class="fs-3 text-success">@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))</div>
</div></div></div>
<div class="col-md-3"><div class="card border-warning"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 text-warning">@_rows.Count(HostStatusService.IsStale)</div>
</div></div></div>
<div class="col-md-3"><div class="card border-danger"><div class="card-body">
<h6 class="text-muted mb-1">Faulted</h6>
<div class="fs-3 text-danger">@_rows.Count(r => r.State == DriverHostState.Faulted)</div>
</div></div></div>
</div> </div>
<div class="agg-card">
<div class="agg-label">Running</div>
<div class="agg-value numeric">@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))</div>
</div>
<div class="agg-card caution">
<div class="agg-label">Stale</div>
<div class="agg-value numeric">@_rows.Count(HostStatusService.IsStale)</div>
</div>
<div class="agg-card alert">
<div class="agg-label">Faulted</div>
<div class="agg-value numeric">@_rows.Count(r => r.State == DriverHostState.Faulted)</div>
</div>
</section>
@if (_rows.Any(HostStatusService.IsFlagged)) @if (_rows.Any(HostStatusService.IsFlagged))
{ {
var flaggedCount = _rows.Count(HostStatusService.IsFlagged); var flaggedCount = _rows.Count(HostStatusService.IsFlagged);
<div class="alert alert-danger small mb-3"> <section class="panel notice rise" style="animation-delay:.14s">
<strong>@flaggedCount host@(flaggedCount == 1 ? "" : "s")</strong> <strong>@flaggedCount host@(flaggedCount == 1 ? "" : "s")</strong>
reporting ≥ @HostStatusService.FailureFlagThreshold consecutive failures — circuit breaker reporting ≥ @HostStatusService.FailureFlagThreshold consecutive failures — circuit breaker
may trip soon. Inspect the resilience columns below to locate. may trip soon. Inspect the resilience columns below to locate.
</div> </section>
} }
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key)) @foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
{ {
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2> <section class="panel rise" style="animation-delay:.14s">
<table class="table table-sm table-hover align-middle"> <div class="panel-head">Cluster: <span class="mono">@cluster.Key</span></div>
<div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Node</th> <th>Node</th>
<th>Driver</th> <th>Driver</th>
<th>Host</th> <th>Host</th>
<th>State</th> <th>State</th>
<th class="text-end" title="Consecutive failures — resets when a call succeeds or the breaker closes">Fail#</th> <th class="num" title="Consecutive failures — resets when a call succeeds or the breaker closes">Fail#</th>
<th class="text-end" title="In-flight capability calls (bulkhead-depth proxy)">In-flight</th> <th class="num" title="In-flight capability calls (bulkhead-depth proxy)">In-flight</th>
<th>Breaker opened</th> <th>Breaker opened</th>
<th>Last transition</th> <th>Last transition</th>
<th>Last seen</th> <th>Last seen</th>
@@ -90,35 +92,37 @@ else
<tbody> <tbody>
@foreach (var r in cluster) @foreach (var r in cluster)
{ {
<tr class="@RowClass(r)"> <tr>
<td><code>@r.NodeId</code></td> <td><span class="mono">@r.NodeId</span></td>
<td><code>@r.DriverInstanceId</code></td> <td><span class="mono">@r.DriverInstanceId</span></td>
<td>@r.HostName</td> <td>@r.HostName</td>
<td> <td>
<span class="badge @StateBadge(r.State)">@r.State</span> <span class="chip @StateBadge(r.State)">@r.State</span>
@if (HostStatusService.IsStale(r)) @if (HostStatusService.IsStale(r))
{ {
<span class="badge bg-warning text-dark ms-1">Stale</span> <span class="chip chip-warn ms-1">Stale</span>
} }
@if (HostStatusService.IsFlagged(r)) @if (HostStatusService.IsFlagged(r))
{ {
<span class="badge bg-danger ms-1" title="≥ @HostStatusService.FailureFlagThreshold consecutive failures">Flagged</span> <span class="chip chip-bad ms-1" title="≥ @HostStatusService.FailureFlagThreshold consecutive failures">Flagged</span>
} }
</td> </td>
<td class="text-end small @(HostStatusService.IsFlagged(r) ? "text-danger fw-bold" : "")"> <td class="num small @(HostStatusService.IsFlagged(r) ? "s-bad fw-bold" : "")">
@r.ConsecutiveFailures @r.ConsecutiveFailures
</td> </td>
<td class="text-end small">@r.CurrentBulkheadDepth</td> <td class="num small">@r.CurrentBulkheadDepth</td>
<td class="small"> <td class="small">
@(r.LastCircuitBreakerOpenUtc is null ? "—" : FormatAge(r.LastCircuitBreakerOpenUtc.Value)) @(r.LastCircuitBreakerOpenUtc is null ? "—" : FormatAge(r.LastCircuitBreakerOpenUtc.Value))
</td> </td>
<td class="small">@FormatAge(r.StateChangedUtc)</td> <td class="small">@FormatAge(r.StateChangedUtc)</td>
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td> <td class="small @(HostStatusService.IsStale(r) ? "s-warn" : "")">@FormatAge(r.LastSeenUtc)</td>
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td> <td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
} }
@@ -207,10 +211,10 @@ else
private static string StateBadge(DriverHostState s) => s switch private static string StateBadge(DriverHostState s) => s switch
{ {
DriverHostState.Running => "bg-success", DriverHostState.Running => "chip-ok",
DriverHostState.Stopped => "bg-secondary", DriverHostState.Stopped => "chip-idle",
DriverHostState.Faulted => "bg-danger", DriverHostState.Faulted => "chip-bad",
_ => "bg-secondary", _ => "chip-idle",
}; };
private static string FormatAge(DateTime t) private static string FormatAge(DateTime t)

View File

@@ -7,37 +7,37 @@
@inject ILdapAuthService LdapAuth @inject ILdapAuthService LdapAuth
@inject NavigationManager Nav @inject NavigationManager Nav
<div class="row justify-content-center mt-5"> <div class="login-wrap rise" style="animation-delay:.02s">
<div class="col-md-5"> <section class="panel">
<div class="card"> <div class="panel-head">OtOpcUa Admin &mdash; sign in</div>
<div class="card-body"> <div style="padding:1.1rem 1.1rem 1.25rem">
<h4 class="mb-4">OtOpcUa Admin — sign in</h4>
<EditForm Model="_input" OnValidSubmit="SignInAsync" FormName="login"> <EditForm Model="_input" OnValidSubmit="SignInAsync" FormName="login">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Username</label> <label class="form-label">Username</label>
<InputText @bind-Value="_input.Username" class="form-control" autocomplete="username"/> <InputText @bind-Value="_input.Username" class="form-control form-control-sm" autocomplete="username"/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label">Password</label>
<InputText type="password" @bind-Value="_input.Password" class="form-control" autocomplete="current-password"/> <InputText type="password" @bind-Value="_input.Password" class="form-control form-control-sm" autocomplete="current-password"/>
</div> </div>
@if (_error is not null) { <div class="alert alert-danger">@_error</div> } @if (_error is not null)
{
<div class="panel notice" style="margin-bottom:.85rem">@_error</div>
}
<button class="btn btn-primary w-100" type="submit" disabled="@_busy"> <button class="btn btn-primary w-100" type="submit" disabled="@_busy">
@(_busy ? "Signing in…" : "Sign in") @(_busy ? "Signing in…" : "Sign in")
</button> </button>
</EditForm> </EditForm>
<hr/> <div style="margin-top:1rem;padding-top:.85rem;border-top:1px solid var(--rule);
<small class="text-muted"> font-size:.78rem;color:var(--ink-faint)">
LDAP bind against the configured directory. Dev defaults to GLAuth on LDAP bind against the configured directory. Dev defaults to GLAuth on
<code>localhost:3893</code>. <span class="mono">localhost:3893</span>.
</small>
</div>
</div> </div>
</div> </div>
</section>
</div> </div>
@code { @code {
@@ -47,10 +47,17 @@
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
} }
private Input _input = new(); // Static-SSR form post: the model must be [SupplyParameterFromForm] or the
// submitted username/password never bind back onto _input. The property
// cannot carry an initializer (BL0008) — seed it in OnInitialized instead.
[SupplyParameterFromForm]
private Input _input { get; set; } = default!;
private string? _error; private string? _error;
private bool _busy; private bool _busy;
protected override void OnInitialized() => _input ??= new();
private async Task SignInAsync() private async Task SignInAsync()
{ {
_error = null; _error = null;

View File

@@ -13,7 +13,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Address string</label> <label class="form-label">Address string</label>
<input type="text" class="form-control @(IsValid ? "is-valid" : Diagnostic is null ? "" : "is-invalid")" <input type="text" class="form-control form-control-sm @(IsValid ? "is-valid" : Diagnostic is null ? "" : "is-invalid")"
value="@AddressString" value="@AddressString"
@oninput="@OnInputChanged" @oninput="@OnInputChanged"
placeholder="e.g. 40001:F:CDAB:5"/> placeholder="e.g. 40001:F:CDAB:5"/>
@@ -21,13 +21,13 @@
{ {
<div class="form-text text-success"> <div class="form-text text-success">
<strong>Parsed:</strong> <strong>Parsed:</strong>
Region=<code>@_parsed.Region</code> Region=<span class="mono">@_parsed.Region</span>
Offset=<code>@_parsed.Offset</code> Offset=<span class="mono">@_parsed.Offset</span>
Type=<code>@_parsed.DataType</code> Type=<span class="mono">@_parsed.DataType</span>
@if (_parsed.Bit.HasValue) { <text>Bit=<code>@_parsed.Bit</code></text> } @if (_parsed.Bit.HasValue) { <text>Bit=<span class="mono">@_parsed.Bit</span></text> }
@if (_parsed.ByteOrder != ModbusByteOrder.BigEndian) { <text>Order=<code>@_parsed.ByteOrder</code></text> } @if (_parsed.ByteOrder != ModbusByteOrder.BigEndian) { <text>Order=<span class="mono">@_parsed.ByteOrder</span></text> }
@if (_parsed.ArrayCount.HasValue) { <text>Array[<code>@_parsed.ArrayCount</code>]</text> } @if (_parsed.ArrayCount.HasValue) { <text>Array[<span class="mono">@_parsed.ArrayCount</span>]</text> }
@if (_parsed.StringLength > 0) { <text>StrLen=<code>@_parsed.StringLength</code></text> } @if (_parsed.StringLength > 0) { <text>StrLen=<span class="mono">@_parsed.StringLength</span></text> }
</div> </div>
} }
else if (Diagnostic is not null) else if (Diagnostic is not null)

View File

@@ -14,18 +14,20 @@
<PageTitle>Modbus address preview</PageTitle> <PageTitle>Modbus address preview</PageTitle>
<div class="container py-4"> <h1 class="page-title">Modbus address preview</h1>
<h1>Modbus address preview</h1>
<p class="text-muted"> <p class="text-muted">
Paste an address string and watch the parser break it down field by field. Useful for Paste an address string and watch the parser break it down field by field. Useful for
sanity-checking a tag spreadsheet row before adding it to a driver's <code>DriverConfig</code>. sanity-checking a tag spreadsheet row before adding it to a driver's <span class="mono">DriverConfig</span>.
Full grammar: <a href="https://github.com/" target="_blank">docs/v2/modbus-addressing.md</a>. Full grammar: <a href="https://github.com/" target="_blank">docs/v2/modbus-addressing.md</a>.
</p> </p>
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Family</div>
<div style="padding:.75rem 1rem">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">PLC family hint (drives the family-native branch)</label> <label class="form-label">PLC family hint (drives the family-native branch)</label>
<select class="form-select" @bind="_family"> <select class="form-select form-select-sm" @bind="_family">
@foreach (var f in Enum.GetValues<ModbusFamily>()) @foreach (var f in Enum.GetValues<ModbusFamily>())
{ {
<option value="@f">@f</option> <option value="@f">@f</option>
@@ -36,7 +38,7 @@
{ {
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">MELSEC sub-family</label> <label class="form-label">MELSEC sub-family</label>
<select class="form-select" @bind="_melsecSubFamily"> <select class="form-select form-select-sm" @bind="_melsecSubFamily">
@foreach (var f in Enum.GetValues<MelsecFamily>()) @foreach (var f in Enum.GetValues<MelsecFamily>())
{ {
<option value="@f">@f</option> <option value="@f">@f</option>
@@ -46,15 +48,18 @@
} }
</div> </div>
<div class="mt-4"> <div class="mt-3">
<ModbusAddressEditor @bind-AddressString="_address" <ModbusAddressEditor @bind-AddressString="_address"
Family="_family" Family="_family"
MelsecSubFamily="_melsecSubFamily"/> MelsecSubFamily="_melsecSubFamily"/>
</div> </div>
<h3 class="mt-5">Quick-reference grammar</h3>
<pre class="bg-light p-3 rounded small">@_grammarReference</pre>
</div> </div>
</section>
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Quick-reference grammar</div>
<pre class="small" style="padding:1rem;margin:0;overflow-x:auto">@_grammarReference</pre>
</section>
@code { @code {
private string? _address; private string? _address;

View File

@@ -10,14 +10,13 @@
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle> <PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
<div class="container py-4"> <h1 class="page-title">Modbus auto-prohibitions</h1>
<h1>Modbus auto-prohibitions</h1>
<p class="text-muted"> <p class="text-muted">
Driver instance <code>@DriverInstanceId</code>. Live snapshot of coalesced ranges Driver instance <span class="mono">@DriverInstanceId</span>. Live snapshot of coalesced ranges
the planner has learned to read individually (#148 / #150 / #151 / #152). the planner has learned to read individually (#148 / #150 / #151 / #152).
</p> </p>
<div class="mb-3"> <div class="toolbar" style="margin-bottom:.75rem">
<button class="btn btn-sm btn-outline-primary" @onclick="LoadAsync" disabled="@_loading"> <button class="btn btn-sm btn-outline-primary" @onclick="LoadAsync" disabled="@_loading">
@(_loading ? "Loading…" : "Refresh") @(_loading ? "Loading…" : "Refresh")
</button> </button>
@@ -25,11 +24,12 @@
{ {
<span class="text-muted ms-3 small">Last refreshed @_lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span> <span class="text-muted ms-3 small">Last refreshed @_lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
} }
<span class="spacer"></span>
</div> </div>
@if (_error is not null) @if (_error is not null)
{ {
<div class="alert alert-danger">@_error</div> <section class="panel notice rise" style="animation-delay:.02s">@_error</section>
} }
else if (_response is null) else if (_response is null)
{ {
@@ -37,18 +37,21 @@
} }
else if (_response.Count == 0) else if (_response.Count == 0)
{ {
<div class="alert alert-success">No auto-prohibitions. The planner is coalescing freely.</div> <section class="panel notice rise" style="animation-delay:.02s">No auto-prohibitions. The planner is coalescing freely.</section>
} }
else else
{ {
<table class="table table-sm"> <section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Prohibited ranges</div>
<div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Unit</th> <th>Unit</th>
<th>Region</th> <th>Region</th>
<th>Start</th> <th class="num">Start</th>
<th>End</th> <th class="num">End</th>
<th>Span</th> <th class="num">Span</th>
<th>Status</th> <th>Status</th>
<th>Last probed</th> <th>Last probed</th>
</tr> </tr>
@@ -57,19 +60,19 @@
@foreach (var r in _response.Ranges.OrderBy(r => r.UnitId).ThenBy(r => r.Region).ThenBy(r => r.StartAddress)) @foreach (var r in _response.Ranges.OrderBy(r => r.UnitId).ThenBy(r => r.Region).ThenBy(r => r.StartAddress))
{ {
<tr> <tr>
<td><code>@r.UnitId</code></td> <td class="mono">@r.UnitId</td>
<td><code>@r.Region</code></td> <td class="mono">@r.Region</td>
<td><code>@r.StartAddress</code></td> <td class="num mono">@r.StartAddress</td>
<td><code>@r.EndAddress</code></td> <td class="num mono">@r.EndAddress</td>
<td>@(r.EndAddress - r.StartAddress + 1)</td> <td class="num">@(r.EndAddress - r.StartAddress + 1)</td>
<td> <td>
@if (r.BisectionPending) @if (r.BisectionPending)
{ {
<span class="badge bg-warning text-dark">BISECTING</span> <span class="chip chip-warn">BISECTING</span>
} }
else else
{ {
<span class="badge bg-danger">ISOLATED</span> <span class="chip chip-bad">ISOLATED</span>
} }
</td> </td>
<td class="small text-muted">@FormatTimeSince(r.LastProbedUtc)</td> <td class="small text-muted">@FormatTimeSince(r.LastProbedUtc)</td>
@@ -77,8 +80,9 @@
} }
</tbody> </tbody>
</table> </table>
}
</div> </div>
</section>
}
@code { @code {
[Parameter] public string DriverInstanceId { get; set; } = string.Empty; [Parameter] public string DriverInstanceId { get; set; } = string.Empty;

View File

@@ -9,27 +9,27 @@
<div class="modbus-options-editor"> <div class="modbus-options-editor">
<h5>Connection</h5> <div class="panel-head">Connection</div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-6"> <div class="col-sm-6">
<label class="form-label">Host</label> <label class="form-label">Host</label>
<input class="form-control" @bind="Model.Host"/> <input class="form-control form-control-sm" @bind="Model.Host"/>
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label class="form-label">Port</label> <label class="form-label">Port</label>
<input type="number" class="form-control" @bind="Model.Port"/> <input type="number" class="form-control form-control-sm" @bind="Model.Port"/>
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label class="form-label">Default UnitId</label> <label class="form-label">Default UnitId</label>
<input type="number" class="form-control" @bind="Model.UnitId"/> <input type="number" class="form-control form-control-sm" @bind="Model.UnitId"/>
</div> </div>
</div> </div>
<h5>Family (#144)</h5> <div class="panel-head">Family (#144)</div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-6"> <div class="col-sm-6">
<label class="form-label">PLC family</label> <label class="form-label">PLC family</label>
<select class="form-select" @bind="Model.Family"> <select class="form-select form-select-sm" @bind="Model.Family">
@foreach (var f in Enum.GetValues<ModbusFamily>()) @foreach (var f in Enum.GetValues<ModbusFamily>())
{ {
<option value="@f">@f</option> <option value="@f">@f</option>
@@ -40,7 +40,7 @@
{ {
<div class="col-sm-6"> <div class="col-sm-6">
<label class="form-label">MELSEC sub-family</label> <label class="form-label">MELSEC sub-family</label>
<select class="form-select" @bind="Model.MelsecSubFamily"> <select class="form-select form-select-sm" @bind="Model.MelsecSubFamily">
@foreach (var f in Enum.GetValues<MelsecFamily>()) @foreach (var f in Enum.GetValues<MelsecFamily>())
{ {
<option value="@f">@f</option> <option value="@f">@f</option>
@@ -50,7 +50,7 @@
} }
</div> </div>
<h5>Keep-alive (#139)</h5> <div class="panel-head">Keep-alive (#139)</div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-3"> <div class="col-sm-3">
<div class="form-check mt-4"> <div class="form-check mt-4">
@@ -60,51 +60,51 @@
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label class="form-label">Time (s)</label> <label class="form-label">Time (s)</label>
<input type="number" class="form-control" @bind="Model.KeepAliveTimeSec"/> <input type="number" class="form-control form-control-sm" @bind="Model.KeepAliveTimeSec"/>
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label class="form-label">Interval (s)</label> <label class="form-label">Interval (s)</label>
<input type="number" class="form-control" @bind="Model.KeepAliveIntervalSec"/> <input type="number" class="form-control form-control-sm" @bind="Model.KeepAliveIntervalSec"/>
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label class="form-label">Retry count</label> <label class="form-label">Retry count</label>
<input type="number" class="form-control" @bind="Model.KeepAliveRetryCount"/> <input type="number" class="form-control form-control-sm" @bind="Model.KeepAliveRetryCount"/>
</div> </div>
</div> </div>
<h5>Reconnect (#139)</h5> <div class="panel-head">Reconnect (#139)</div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-4"> <div class="col-sm-4">
<label class="form-label">Initial delay (ms)</label> <label class="form-label">Initial delay (ms)</label>
<input type="number" class="form-control" @bind="Model.ReconnectInitialDelayMs"/> <input type="number" class="form-control form-control-sm" @bind="Model.ReconnectInitialDelayMs"/>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-4">
<label class="form-label">Max delay (ms)</label> <label class="form-label">Max delay (ms)</label>
<input type="number" class="form-control" @bind="Model.ReconnectMaxDelayMs"/> <input type="number" class="form-control form-control-sm" @bind="Model.ReconnectMaxDelayMs"/>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-4">
<label class="form-label">Backoff multiplier</label> <label class="form-label">Backoff multiplier</label>
<input type="number" step="0.1" class="form-control" @bind="Model.ReconnectBackoffMultiplier"/> <input type="number" step="0.1" class="form-control form-control-sm" @bind="Model.ReconnectBackoffMultiplier"/>
</div> </div>
</div> </div>
<h5>Protocol (#140)</h5> <div class="panel-head">Protocol (#140)</div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-3"> <div class="col-sm-3">
<label class="form-label">Max regs / read</label> <label class="form-label">Max regs / read</label>
<input type="number" class="form-control" @bind="Model.MaxRegistersPerRead"/> <input type="number" class="form-control form-control-sm" @bind="Model.MaxRegistersPerRead"/>
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label class="form-label">Max regs / write</label> <label class="form-label">Max regs / write</label>
<input type="number" class="form-control" @bind="Model.MaxRegistersPerWrite"/> <input type="number" class="form-control form-control-sm" @bind="Model.MaxRegistersPerWrite"/>
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label class="form-label">Max coils / read</label> <label class="form-label">Max coils / read</label>
<input type="number" class="form-control" @bind="Model.MaxCoilsPerRead"/> <input type="number" class="form-control form-control-sm" @bind="Model.MaxCoilsPerRead"/>
</div> </div>
<div class="col-sm-3"> <div class="col-sm-3">
<label class="form-label">Max read gap (#143)</label> <label class="form-label">Max read gap (#143)</label>
<input type="number" class="form-control" @bind="Model.MaxReadGap"/> <input type="number" class="form-control form-control-sm" @bind="Model.MaxReadGap"/>
</div> </div>
</div> </div>

View File

@@ -5,27 +5,29 @@
@attribute [Authorize(Policy = "CanPublish")] @attribute [Authorize(Policy = "CanPublish")]
@inject ReservationService ReservationSvc @inject ReservationService ReservationSvc
<h1 class="mb-4">External-ID reservations</h1> <h1 class="page-title">External-ID reservations</h1>
<p class="text-muted"> <p class="text-muted">
Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a
FleetAdmin-only audit-logged action — only release when the physical asset is permanently FleetAdmin-only audit-logged action — only release when the physical asset is permanently
retired and its ID needs to be reused by a different equipment. retired and its ID needs to be reused by a different equipment.
</p> </p>
<h4 class="mt-4">Active</h4> <section class="panel rise" style="animation-delay:.02s">
@if (_active is null) { <p>Loading…</p> } <div class="panel-head">Active</div>
else if (_active.Count == 0) { <p class="text-muted">No active reservations.</p> } @if (_active is null) { <p class="px-3 py-2">Loading…</p> }
else if (_active.Count == 0) { <p class="px-3 py-2 text-muted">No active reservations.</p> }
else else
{ {
<table class="table table-sm"> <div class="table-wrap">
<table class="data-table">
<thead><tr><th>Kind</th><th>Value</th><th>EquipmentUuid</th><th>Cluster</th><th>First published</th><th>Last published</th><th></th></tr></thead> <thead><tr><th>Kind</th><th>Value</th><th>EquipmentUuid</th><th>Cluster</th><th>First published</th><th>Last published</th><th></th></tr></thead>
<tbody> <tbody>
@foreach (var r in _active) @foreach (var r in _active)
{ {
<tr> <tr>
<td><code>@r.Kind</code></td> <td><span class="mono">@r.Kind</span></td>
<td><code>@r.Value</code></td> <td><span class="mono">@r.Value</span></td>
<td><code>@r.EquipmentUuid</code></td> <td><span class="mono">@r.EquipmentUuid</span></td>
<td>@r.ClusterId</td> <td>@r.ClusterId</td>
<td><small>@r.FirstPublishedAt.ToString("u") by @r.FirstPublishedBy</small></td> <td><small>@r.FirstPublishedAt.ToString("u") by @r.FirstPublishedBy</small></td>
<td><small>@r.LastPublishedAt.ToString("u")</small></td> <td><small>@r.LastPublishedAt.ToString("u")</small></td>
@@ -34,23 +36,29 @@ else
} }
</tbody> </tbody>
</table> </table>
</div>
} }
</section>
<h4 class="mt-4">Released (most recent 100)</h4> <section class="panel rise" style="animation-delay:.08s">
@if (_released is null) { <p>Loading…</p> } <div class="panel-head">Released (most recent 100)</div>
else if (_released.Count == 0) { <p class="text-muted">No released reservations yet.</p> } @if (_released is null) { <p class="px-3 py-2">Loading…</p> }
else if (_released.Count == 0) { <p class="px-3 py-2 text-muted">No released reservations yet.</p> }
else else
{ {
<table class="table table-sm"> <div class="table-wrap">
<table class="data-table">
<thead><tr><th>Kind</th><th>Value</th><th>Released at</th><th>By</th><th>Reason</th></tr></thead> <thead><tr><th>Kind</th><th>Value</th><th>Released at</th><th>By</th><th>Reason</th></tr></thead>
<tbody> <tbody>
@foreach (var r in _released) @foreach (var r in _released)
{ {
<tr><td><code>@r.Kind</code></td><td><code>@r.Value</code></td><td>@r.ReleasedAt?.ToString("u")</td><td>@r.ReleasedBy</td><td>@r.ReleaseReason</td></tr> <tr><td><span class="mono">@r.Kind</span></td><td><span class="mono">@r.Value</span></td><td>@r.ReleasedAt?.ToString("u")</td><td>@r.ReleasedBy</td><td>@r.ReleaseReason</td></tr>
} }
</tbody> </tbody>
</table> </table>
</div>
} }
</section>
@if (_releasing is not null) @if (_releasing is not null)
{ {
@@ -58,13 +66,13 @@ else
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Release reservation <code>@_releasing.Kind</code> = <code>@_releasing.Value</code></h5> <h5 class="modal-title">Release reservation <span class="mono">@_releasing.Kind</span> = <span class="mono">@_releasing.Value</span></h5>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>This makes the (Kind, Value) pair available for a different EquipmentUuid in a future publish. Audit-logged.</p> <p>This makes the (Kind, Value) pair available for a different EquipmentUuid in a future publish. Audit-logged.</p>
<label class="form-label">Reason (required)</label> <label class="form-label">Reason (required)</label>
<textarea class="form-control" rows="3" @bind="_reason"></textarea> <textarea class="form-control form-control-sm" rows="3" @bind="_reason"></textarea>
@if (_error is not null) { <div class="alert alert-danger mt-2">@_error</div> } @if (_error is not null) { <section class="panel notice mt-2">@_error</section> }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" @onclick='() => _releasing = null'>Cancel</button> <button class="btn btn-secondary" @onclick='() => _releasing = null'>Cancel</button>

View File

@@ -12,15 +12,15 @@
@inject NavigationManager Nav @inject NavigationManager Nav
@implements IAsyncDisposable @implements IAsyncDisposable
<h1 class="mb-4">LDAP group → Admin role grants</h1> <h1 class="page-title">LDAP group → Admin role grants</h1>
<div class="alert alert-info small mb-4"> <section class="panel notice rise" style="animation-delay:.02s">
Maps LDAP groups to Admin UI roles (ConfigViewer / ConfigEditor / FleetAdmin). Control-plane Maps LDAP groups to Admin UI roles (ConfigViewer / ConfigEditor / FleetAdmin). Control-plane
only — OPC UA data-path authorization reads <code>NodeAcl</code> rows directly and is only — OPC UA data-path authorization reads <span class="mono">NodeAcl</span> rows directly and is
unaffected by these mappings (see decision #150). A fleet-wide grant applies across every unaffected by these mappings (see decision #150). A fleet-wide grant applies across every
cluster; a cluster-scoped grant only binds within the named cluster. The same LDAP group cluster; a cluster-scoped grant only binds within the named cluster. The same LDAP group
may hold different roles on different clusters. may hold different roles on different clusters.
</div> </section>
<div class="d-flex justify-content-end mb-3"> <div class="d-flex justify-content-end mb-3">
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button> <button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
@@ -37,7 +37,10 @@ else if (_rows.Count == 0)
} }
else else
{ {
<table class="table table-sm table-hover"> <section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Grants</div>
<div class="table-wrap">
<table class="data-table">
<thead> <thead>
<tr><th>LDAP group</th><th>Role</th><th>Scope</th><th>Created</th><th>Notes</th><th></th></tr> <tr><th>LDAP group</th><th>Role</th><th>Scope</th><th>Created</th><th>Notes</th><th></th></tr>
</thead> </thead>
@@ -45,8 +48,8 @@ else
@foreach (var r in _rows) @foreach (var r in _rows)
{ {
<tr> <tr>
<td><code>@r.LdapGroup</code></td> <td><span class="mono">@r.LdapGroup</span></td>
<td><span class="badge bg-secondary">@r.Role</span></td> <td><span class="chip chip-idle">@r.Role</span></td>
<td>@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")</td> <td>@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")</td>
<td class="small">@r.CreatedAtUtc.ToString("yyyy-MM-dd")</td> <td class="small">@r.CreatedAtUtc.ToString("yyyy-MM-dd")</td>
<td class="small text-muted">@r.Notes</td> <td class="small text-muted">@r.Notes</td>
@@ -55,21 +58,23 @@ else
} }
</tbody> </tbody>
</table> </table>
</div>
</section>
} }
@if (_showForm) @if (_showForm)
{ {
<div class="card mt-3"> <section class="panel rise" style="animation-delay:.14s">
<div class="card-body"> <div class="panel-head">New role grant</div>
<h5>New role grant</h5> <div class="p-3">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">LDAP group (DN)</label> <label class="form-label">LDAP group (DN)</label>
<input class="form-control" @bind="_group" placeholder="cn=fleet-admin,ou=groups,dc=…"/> <input class="form-control form-control-sm" @bind="_group" placeholder="cn=fleet-admin,ou=groups,dc=…"/>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Role</label> <label class="form-label">Role</label>
<select class="form-select" @bind="_role"> <select class="form-select form-select-sm" @bind="_role">
@foreach (var r in Enum.GetValues<AdminRole>()) @foreach (var r in Enum.GetValues<AdminRole>())
{ {
<option value="@r">@r</option> <option value="@r">@r</option>
@@ -84,7 +89,7 @@ else
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Cluster @(_isSystemWide ? "(disabled)" : "")</label> <label class="form-label">Cluster @(_isSystemWide ? "(disabled)" : "")</label>
<select class="form-select" @bind="_clusterId" disabled="@_isSystemWide"> <select class="form-select form-select-sm" @bind="_clusterId" disabled="@_isSystemWide">
<option value="">-- select --</option> <option value="">-- select --</option>
@if (_clusters is not null) @if (_clusters is not null)
{ {
@@ -97,16 +102,16 @@ else
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Notes (optional)</label> <label class="form-label">Notes (optional)</label>
<input class="form-control" @bind="_notes"/> <input class="form-control form-control-sm" @bind="_notes"/>
</div> </div>
</div> </div>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> } @if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
<div class="mt-3"> <div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button> <button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button> <button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div> </div>
</div> </div>
</div> </section>
} }
@code { @code {

View File

@@ -108,7 +108,7 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
await Task.Run(() => await Task.Run(() =>
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct); conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
var filter = $"(uid={EscapeLdapFilter(username)})"; var filter = $"({_options.UserNameAttribute}={EscapeLdapFilter(username)})";
var results = await Task.Run(() => var results = await Task.Run(() =>
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct); conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
@@ -116,7 +116,7 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
return results.Next().Dn; return results.Next().Dn;
throw new LdapException("User not found", LdapException.NoSuchObject, throw new LdapException("User not found", LdapException.NoSuchObject,
$"No entry for uid={username}"); $"No entry for {filter}");
} }
return string.IsNullOrWhiteSpace(_options.SearchBase) return string.IsNullOrWhiteSpace(_options.SearchBase)

View File

@@ -29,6 +29,13 @@ public sealed class LdapOptions
public string DisplayNameAttribute { get; set; } = "cn"; public string DisplayNameAttribute { get; set; } = "cn";
public string GroupAttribute { get; set; } = "memberOf"; public string GroupAttribute { get; set; } = "memberOf";
/// <summary>
/// Attribute the service-account search matches the login name against to resolve the
/// user's DN. <c>cn</c> for GLAuth (the dev default); set <c>sAMAccountName</c> for
/// Active Directory.
/// </summary>
public string UserNameAttribute { get; set; } = "cn";
/// <summary> /// <summary>
/// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every /// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every
/// role whose source group is in their membership list. Example dev mapping: /// role whose source group is in their membership list. Example dev mapping:

View File

@@ -10,7 +10,7 @@
"UseTls": false, "UseTls": false,
"AllowInsecureLdap": true, "AllowInsecureLdap": true,
"SearchBase": "dc=lmxopcua,dc=local", "SearchBase": "dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,ou=svcaccts,dc=lmxopcua,dc=local", "ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123", "ServiceAccountPassword": "serviceaccount123",
"DisplayNameAttribute": "cn", "DisplayNameAttribute": "cn",
"GroupAttribute": "memberOf", "GroupAttribute": "memberOf",

View File

@@ -1,3 +1,105 @@
/* OtOpcUa Admin — ScadaLink-parity palette. Keep it minimal here; lean on Bootstrap 5. */ /* OtOpcUa Admin — view-specific layer over the technical-light theme (theme.css).
body { background-color: #f5f6fa; } Tokens live in theme.css; this sheet only carries layout + the side rail. */
.nav-link.active { background-color: rgba(255,255,255,0.1); border-radius: 4px; }
/* ── App shell: side rail + page ─────────────────────────────────────────── */
.app-shell {
display: flex;
align-items: stretch;
min-height: calc(100vh - 3.3rem);
}
.app-shell .page {
flex: 1;
min-width: 0;
}
/* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail {
width: 218px;
flex: 0 0 218px;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 1rem 0.7rem;
background: var(--card);
border-right: 1px solid var(--rule-strong);
}
.rail-eyebrow {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
padding: 0.3rem 0.6rem;
}
.rail-link {
display: block;
padding: 0.4rem 0.6rem;
border-radius: 4px;
border-left: 2px solid transparent;
font-size: 0.86rem;
color: var(--ink-soft);
}
.rail-link:hover {
background: #f3f6fd;
color: var(--ink);
text-decoration: none;
}
.rail-link.active {
background: #eef2fc;
border-left-color: var(--accent);
color: var(--accent-deep);
font-weight: 600;
}
/* ── Session block, pinned to the rail foot ──────────────────────────────── */
.rail-foot {
margin-top: auto;
padding-top: 0.6rem;
border-top: 1px solid var(--rule);
}
.rail-user {
display: block;
padding: 0 0.6rem;
font-weight: 600;
font-size: 0.88rem;
}
.rail-roles {
padding: 0.1rem 0.6rem 0.5rem;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--ink-faint);
}
.rail-btn {
display: inline-block;
margin: 0 0.6rem;
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-soft);
background: var(--card);
border: 1px solid var(--rule-strong);
border-radius: 4px;
cursor: pointer;
}
.rail-btn:hover {
border-color: var(--accent);
color: var(--accent);
text-decoration: none;
}
/* ── Page headings — uppercase eyebrow, calm spacing ─────────────────────── */
.page-title {
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 1rem;
color: var(--ink);
}
/* ── Login card centring ─────────────────────────────────────────────────── */
.login-wrap {
max-width: 380px;
margin: 3.5rem auto 0;
}

View File

@@ -0,0 +1,379 @@
/* ============================================================================
Technical-Light design system — portable theme layer
----------------------------------------------------------------------------
A refined technical-light aesthetic: warm-neutral paper, hairline rules,
IBM Plex type, monospace tabular numerics, status carried by colour. Built
to layer over Bootstrap 5 via --bs-* overrides, but every rule below works
standalone — Bootstrap is optional.
HOW TO ADOPT
1. Serve the three IBM Plex woff2 files (shipped in fonts/) and fix the
@font-face url() paths below to wherever you serve them.
2. Include this file once, globally. Add view-specific rules in a separate
stylesheet — never edit the token block per-view.
3. Status is colour, not iconography. Use the .s-* / .chip-* / .kv .v.*
helpers; do not hand-pick hex values in feature CSS.
========================================================================= */
/* ── Vendored fonts (embedded woff2, no network/CDN fetch) ───────────────────
Adjust these url()s to your asset route. If you cannot vendor the fonts the
--sans / --mono fallback stacks below degrade gracefully to system fonts. */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal; font-weight: 400; font-display: swap;
src: url('fonts/ibm-plex-sans-400.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal; font-weight: 600; font-display: swap;
src: url('fonts/ibm-plex-sans-600.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal; font-weight: 500; font-display: swap;
src: url('fonts/ibm-plex-mono-500.woff2') format('woff2');
}
/* ── Design tokens ───────────────────────────────────────────────────────────
The single source of truth. Re-theme by editing only this block. */
:root {
/* Surfaces & ink */
--paper: #f4f4f1; /* page background — warm off-white, never pure */
--card: #ffffff; /* raised surfaces: cards, bars, table heads */
--ink: #1b1d21; /* primary text */
--ink-soft: #5a6066; /* secondary text, labels */
--ink-faint: #8b9097; /* tertiary text, captions, units */
--rule: #e4e4df; /* hairline borders / row dividers */
--rule-strong: #d2d2cb; /* emphasised hairlines: bar underline, pills */
/* Accent */
--accent: #2f5fd0; /* links, sort arrows, primary actions */
--accent-deep: #1e3f99; /* hover / pressed accent, raw-value emphasis */
/* Status — foreground */
--ok: #2f9e44;
--warn: #e8920c;
--bad: #e03131;
--idle: #868e96;
/* Status — tinted backgrounds (pair with the matching foreground) */
--ok-bg: #e9f6ec;
--warn-bg: #fdf1dd;
--bad-bg: #fceaea;
--idle-bg: #eef0f2;
/* Type stacks — Plex first, graceful system fallback */
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
--sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
/* Bootstrap 5 overrides — harmless if Bootstrap is absent */
--bs-body-bg: var(--paper);
--bs-body-color: var(--ink);
--bs-body-font-family: var(--sans);
--bs-body-font-size: 0.9rem;
--bs-primary: var(--accent);
--bs-border-color: var(--rule);
--bs-emphasis-color: var(--ink);
}
/* ── Base ────────────────────────────────────────────────────────────────────
The faint top-right radial is the one deliberate flourish — a soft sheen,
not a gradient wash. Keep it subtle. */
body {
background:
radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%),
var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 0.9rem;
-webkit-font-smoothing: antialiased;
}
/* Any numeric / fixed-width text. Tabular figures so columns of digits align. */
.numeric,
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-deep); text-decoration: underline; }
/* ── App chrome: top bar ─────────────────────────────────────────────────────
One bar across the top: brand, breadcrumb crumbs, a flex spacer, then meta
text and any status pill pushed hard right. */
.app-bar {
display: flex;
align-items: baseline;
gap: 1rem;
padding: 0.85rem 1.25rem;
background: var(--card);
border-bottom: 1px solid var(--rule-strong);
}
.app-bar .brand {
font-weight: 600;
font-size: 1.05rem;
letter-spacing: 0.02em;
}
.app-bar .brand .mark { color: var(--accent); } /* the one accent glyph */
.app-bar .crumb { color: var(--ink-faint); font-size: 0.85rem; }
.app-bar .spacer { flex: 1; } /* pushes meta/pill right */
.app-bar .meta {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--ink-soft);
}
/* ── Connection / liveness pill ──────────────────────────────────────────────
A rounded pill with a dot, driven entirely by data-state. Use for any
live-link health indicator (websocket, SSE, polling). */
.conn-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--rule-strong);
color: var(--ink-soft);
background: var(--card);
}
.conn-pill .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--idle);
}
.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); }
.conn-pill[data-state="connected"] .dot { background: var(--ok); }
.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); }
.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); }
.conn-pill[data-state="disconnected"] .dot { background: var(--bad); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
/* ── Status text helpers ─────────────────────────────────────────────────────
Recolour a value in place — counts, ratios, error totals. */
.s-ok { color: var(--ok); }
.s-warn { color: var(--warn); }
.s-bad { color: var(--bad); }
.s-idle { color: var(--idle); }
/* ── State chip ──────────────────────────────────────────────────────────────
Compact rectangular badge for an enumerated state (bound/recovering/…).
Squarer than the pill; use the pill for liveness, the chip for state. */
.chip {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.5rem;
border-radius: 4px;
border: 1px solid transparent;
}
.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; }
.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); }
/* ── Panel — the base raised surface ─────────────────────────────────────────
A white card with a hairline border and 8px radius. .panel-head is the
uppercase eyebrow label that sits on top. */
.panel {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
}
.panel-head {
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--rule);
}
/* ── Page wrapper ────────────────────────────────────────────────────────────
Centred, capped width, even gutter. */
.page { padding: 1.25rem; max-width: 1680px; margin: 0 auto; }
/* ── Reveal-on-paint ─────────────────────────────────────────────────────────
Add .rise to top-level sections; stagger with inline animation-delay
(.02s, .08s, .14s …) so panels settle in sequence, not all at once. */
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.rise { animation: rise 0.4s ease both; }
/* ════════════════════════════════════════════════════════════════════════════
COMPONENT LIBRARY
Generic, reusable pieces. View-specific layout belongs in a separate sheet.
════════════════════════════════════════════════════════════════════════════ */
/* ── KPI / aggregate cards ───────────────────────────────────────────────────
A responsive strip of headline numbers. .agg-card.alert / .caution tint the
whole card when a watched metric goes non-zero. */
.agg-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 1100px) { .agg-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 620px) { .agg-grid { grid-template-columns: repeat(2, 1fr); } }
.agg-card {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
padding: 0.7rem 0.9rem;
}
.agg-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
}
.agg-value {
margin-top: 0.25rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.1;
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.agg-sub { /* trailing "/ 54", "ms" etc. — quieter */
font-size: 0.85rem;
font-weight: 400;
color: var(--ink-faint);
}
.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); }
.agg-card.alert .agg-value { color: var(--bad); }
.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); }
.agg-card.caution .agg-value { color: #b56a00; }
/* ── Metric card + key/value rows ────────────────────────────────────────────
A .panel-head over a stack of .kv rows: label left, monospace value right.
Zebra striping on even rows. .v.warn / .v.bad / .v.ok recolour a value. */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 0.85rem;
margin-bottom: 1rem;
}
.metric-card {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
overflow: hidden;
}
.metric-card .panel-head { margin: 0; }
.kv {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 0.32rem 0.9rem;
font-size: 0.85rem;
}
.kv:nth-child(even) { background: #fbfbf9; }
.kv .k { color: var(--ink-soft); }
.kv .v {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
text-align: right;
}
.kv .v.warn { color: var(--warn); }
.kv .v.bad { color: var(--bad); }
.kv .v.ok { color: var(--ok); }
/* ── Toolbar ─────────────────────────────────────────────────────────────────
Filter/search row that sits inside a .panel above a table. */
.toolbar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--rule);
}
.toolbar .spacer { flex: 1; }
.tb-search { max-width: 280px; }
.tb-state { max-width: 150px; }
.tb-check {
display: flex; align-items: center; gap: 0.35rem;
font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap;
user-select: none;
}
.tb-count { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-faint); }
/* ── Data table ──────────────────────────────────────────────────────────────
Dense, hairline-ruled table. Uppercase sticky head on a faint fill; numeric
columns get .num (right-aligned, monospace). Rows are clickable by default —
drop the cursor/hover rules if yours are not. */
.table-wrap { overflow-x: auto; }
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 0.45rem 0.8rem;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--rule);
}
.data-table th {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-faint);
background: #fbfbf9;
position: sticky;
top: 0;
}
.data-table th.num,
.data-table td.num { text-align: right; font-family: var(--mono); }
.data-table th.sortable { cursor: pointer; user-select: none; }
.data-table th.sortable:hover { color: var(--ink); }
.data-table th.sorted-asc::after { content: ' \2191'; color: var(--accent); }
.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); }
.data-table tbody tr { cursor: pointer; transition: background 0.08s; }
.data-table tbody tr:hover { background: #f3f6fd; }
.data-table tbody tr:last-child td { border-bottom: none; }
.empty-row {
text-align: center !important;
color: var(--ink-faint);
padding: 1.6rem !important;
font-style: italic;
}
/* ── Direction / category tag ────────────────────────────────────────────────
Tiny inline tag for a per-row category (e.g. read vs write). */
.dir-tag {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
.dir-read { color: var(--accent-deep); background: #e7ecfb; }
.dir-write { color: #8a5a00; background: var(--warn-bg); }
/* ── Inline notice ───────────────────────────────────────────────────────────
A .panel with a warning tint — for "this thing is gone / degraded" banners. */
.notice {
padding: 0.85rem 1.1rem;
margin-bottom: 1rem;
color: #b56a00;
background: var(--warn-bg);
border-color: #efd6a6;
}