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:
@@ -7,6 +7,7 @@
|
||||
<title>OtOpcUa Admin</title>
|
||||
<base href="/"/>
|
||||
<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"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
|
||||
@@ -1,38 +1,58 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex" style="min-height: 100vh;">
|
||||
<nav class="bg-dark text-light p-3" style="width: 220px;">
|
||||
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
|
||||
<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">
|
||||
<header class="app-bar">
|
||||
<span class="brand"><span class="mark">▮</span> OtOpcUa</span>
|
||||
<span class="crumb">›</span>
|
||||
<span class="crumb">admin console</span>
|
||||
<span class="spacer"></span>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="small text-light">
|
||||
Signed in as <a class="text-light" href="/account"><strong>@context.User.Identity?.Name</strong></a>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||
<span class="meta">@context.User.Identity?.Name</span>
|
||||
<span class="conn-pill" data-state="connected">
|
||||
<span class="dot"></span><span>signed in</span>
|
||||
</span>
|
||||
</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>
|
||||
<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>
|
||||
</Authorized>
|
||||
<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>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-grow-1 p-4">
|
||||
|
||||
<main class="page">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@using System.Security.Claims
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
|
||||
<h1 class="mb-4">My account</h1>
|
||||
<h1 class="page-title">My account</h1>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@@ -16,51 +16,44 @@
|
||||
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Identity</h5>
|
||||
<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>
|
||||
<section class="card-grid rise" style="animation-delay:.02s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div class="kv"><span class="k">Username</span><span class="v mono">@username</span></div>
|
||||
<div class="kv"><span class="k">Display name</span><span class="v">@displayName</span></div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Admin roles</h5>
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Admin roles</div>
|
||||
@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
|
||||
{
|
||||
<div class="mb-2">
|
||||
<div class="kv">
|
||||
<span class="k">Roles</span>
|
||||
<span class="v">
|
||||
@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>
|
||||
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Capabilities</h5>
|
||||
<p class="text-muted small">
|
||||
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
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Capabilities</div>
|
||||
<p class="px-3 pt-2 text-muted small">
|
||||
Each Admin role grants a fixed capability set per <span class="mono">admin-ui.md</span> §Admin Roles.
|
||||
Pages below reflect what this session can access; the route's <span class="mono">[Authorize]</span> guard
|
||||
is the ground truth — this table mirrors it for readability.
|
||||
</p>
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
@@ -78,11 +71,11 @@
|
||||
<td class="text-end">
|
||||
@if (has)
|
||||
{
|
||||
<span class="badge bg-success">Yes</span>
|
||||
<span class="chip chip-ok">Yes</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">No</span>
|
||||
<span class="chip chip-idle">No</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -90,9 +83,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mt-4">
|
||||
<form method="post" action="/auth/logout">
|
||||
|
||||
@@ -3,40 +3,36 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
|
||||
@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>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Drain state</small>
|
||||
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Drain state</div>
|
||||
<div class="agg-value"><span class="chip @BadgeFor(_status.DrainState)">@_status.DrainState</span></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Queue depth</small>
|
||||
<h4>@_status.QueueDepth.ToString("N0")</h4>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Queue depth</div>
|
||||
<div class="agg-value numeric">@_status.QueueDepth.ToString("N0")</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Dead-letter depth</small>
|
||||
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Last success</small>
|
||||
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Dead-letter depth</div>
|
||||
<div class="agg-value numeric">@_status.DeadLetterDepth.ToString("N0")</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Last success</div>
|
||||
<div class="agg-value">@(_status.LastSuccessUtc?.ToString("u") ?? "—")</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_status.LastError))
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
@if (!string.IsNullOrEmpty(_status.LastError))
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
<strong>Last error:</strong> @_status.LastError
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<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-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
|
||||
Retry dead-lettered (@_status.DeadLetterDepth)
|
||||
@@ -45,7 +41,7 @@
|
||||
|
||||
@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 {
|
||||
@@ -70,10 +66,10 @@
|
||||
|
||||
private static string BadgeFor(HistorianDrainState s) => s switch
|
||||
{
|
||||
HistorianDrainState.Idle => "bg-success",
|
||||
HistorianDrainState.Draining => "bg-info",
|
||||
HistorianDrainState.BackingOff => "bg-warning text-dark",
|
||||
HistorianDrainState.Disabled => "bg-secondary",
|
||||
_ => "bg-secondary",
|
||||
HistorianDrainState.Idle => "chip-ok",
|
||||
HistorianDrainState.Draining => "chip-idle",
|
||||
HistorianDrainState.BackingOff => "chip-warn",
|
||||
HistorianDrainState.Disabled => "chip-idle",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@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">
|
||||
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.
|
||||
</div>
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
PKI store root <span class="mono">@Certs.PkiStoreRoot</span>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
|
||||
</section>
|
||||
|
||||
@if (_status is not null)
|
||||
{
|
||||
@@ -19,14 +19,16 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<h2 class="h4">Rejected (@_rejected.Count)</h2>
|
||||
@if (_rejected.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm align-middle">
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Rejected (@_rejected.Count)</div>
|
||||
@if (_rejected.Count == 0)
|
||||
{
|
||||
<p class="px-3 py-2 text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
<tbody>
|
||||
@foreach (var c in _rejected)
|
||||
@@ -34,7 +36,7 @@ else
|
||||
<tr>
|
||||
<td>@c.Subject</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="text-end">
|
||||
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
|
||||
@@ -44,16 +46,20 @@ else
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2>
|
||||
@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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm align-middle">
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">Trusted (@_trusted.Count)</div>
|
||||
@if (_trusted.Count == 0)
|
||||
{
|
||||
<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
|
||||
{
|
||||
<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>
|
||||
<tbody>
|
||||
@foreach (var c in _trusted)
|
||||
@@ -61,7 +67,7 @@ else
|
||||
<tr>
|
||||
<td>@c.Subject</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="text-end">
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
|
||||
@@ -70,7 +76,9 @@ else
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<CertInfo> _rejected = [];
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<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>
|
||||
</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
|
||||
{
|
||||
<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>
|
||||
<tbody>
|
||||
@foreach (var a in _acls)
|
||||
@@ -26,19 +29,21 @@ else
|
||||
<tr>
|
||||
<td>@a.LdapGroup</td>
|
||||
<td>@a.ScopeKind</td>
|
||||
<td><code>@(a.ScopeId ?? "-")</code></td>
|
||||
<td><code>@a.PermissionFlags</code></td>
|
||||
<td><span class="mono">@(a.ScopeId ?? "-")</span></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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@* Probe-this-permission — task #196 slice 1 *@
|
||||
<div class="card mt-4 mb-3">
|
||||
<div class="card-header">
|
||||
<strong>Probe this permission</strong>
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">
|
||||
Probe this permission
|
||||
<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?" —
|
||||
answers the same way the live server does at request time.
|
||||
@@ -88,64 +93,67 @@ else
|
||||
<span class="ms-3">
|
||||
@if (_probeResult.Granted)
|
||||
{
|
||||
<span class="badge bg-success">Granted</span>
|
||||
<span class="chip chip-ok">Granted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Denied</span>
|
||||
<span class="chip chip-bad">Denied</span>
|
||||
}
|
||||
<span class="small ms-2">
|
||||
Required <code>@_probeResult.Required</code>,
|
||||
Effective <code>@_probeResult.Effective</code>
|
||||
Required <span class="mono">@_probeResult.Required</span>,
|
||||
Effective <span class="mono">@_probeResult.Effective</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@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>
|
||||
<tbody>
|
||||
@foreach (var m in _probeResult.Matches)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@m.LdapGroup</code></td>
|
||||
<td><span class="mono">@m.LdapGroup</span></td>
|
||||
<td>@m.Scope</td>
|
||||
<td><code>@m.PermissionFlags</code></td>
|
||||
<td><span class="mono">@m.PermissionFlags</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
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>
|
||||
</section>
|
||||
|
||||
@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="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">LDAP group</label>
|
||||
<input class="form-control" @bind="_group"/>
|
||||
<input class="form-control form-control-sm" @bind="_group"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<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> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<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 class="col-12">
|
||||
<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="WriteOperate">Read + Write Operate</option>
|
||||
<option value="Engineer">Read + Write Tune + Write Configure</option>
|
||||
@@ -154,13 +162,13 @@ else
|
||||
</select>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -2,28 +2,33 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject AuditLogService AuditSvc
|
||||
|
||||
<h4>Recent audit log</h4>
|
||||
<h4 class="panel-head">Recent audit log</h4>
|
||||
|
||||
@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
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>When</th><th>Principal</th><th>Event</th><th>Node</th><th>Generation</th><th>Details</th></tr></thead>
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<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>
|
||||
@foreach (var a in _entries)
|
||||
{
|
||||
<tr>
|
||||
<td>@a.Timestamp.ToString("u")</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.GenerationId</td>
|
||||
<td class="num">@a.GenerationId</td>
|
||||
<td><small class="text-muted">@a.DetailsJson</small></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -19,16 +19,16 @@ else
|
||||
{
|
||||
@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
|
||||
<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>
|
||||
<h1 class="mb-0">@_cluster.Name</h1>
|
||||
<code class="text-muted">@_cluster.ClusterId</code>
|
||||
@if (!_cluster.Enabled) { <span class="badge bg-secondary ms-2">Disabled</span> }
|
||||
<h1 class="page-title mb-0">@_cluster.Name</h1>
|
||||
<span class="mono text-muted">@_cluster.ClusterId</span>
|
||||
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
|
||||
</div>
|
||||
<div>
|
||||
@if (_currentDraft is not null)
|
||||
@@ -59,16 +59,21 @@ else
|
||||
|
||||
@if (_tab == "overview")
|
||||
{
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Enterprise / Site</dt><dd class="col-sm-9">@_cluster.Enterprise / @_cluster.Site</dd>
|
||||
<dt class="col-sm-3">Redundancy</dt><dd class="col-sm-9">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</dd>
|
||||
<dt class="col-sm-3">Current published</dt>
|
||||
<dd class="col-sm-9">
|
||||
<section class="card-grid rise" style="animation-delay:.08s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Cluster details</div>
|
||||
<div class="kv"><span class="k">Enterprise / Site</span><span class="v">@_cluster.Enterprise / @_cluster.Site</span></div>
|
||||
<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> }
|
||||
else { <span class="text-muted">none published yet</span> }
|
||||
</dd>
|
||||
<dt class="col-sm-3">Created</dt><dd class="col-sm-9">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</dd>
|
||||
</dl>
|
||||
</span>
|
||||
</div>
|
||||
<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")
|
||||
{
|
||||
@@ -108,7 +113,7 @@ 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>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@inject ClusterService ClusterSvc
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -18,32 +18,37 @@ else if (_clusters.Count == 0)
|
||||
}
|
||||
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>
|
||||
<tr>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@c.ClusterId</code></td>
|
||||
<td><span class="mono">@c.ClusterId</span></td>
|
||||
<td>@c.Name</td>
|
||||
<td>@c.Enterprise</td>
|
||||
<td>@c.Site</td>
|
||||
<td>@c.RedundancyMode</td>
|
||||
<td>@c.NodeCount</td>
|
||||
<td class="num">@c.NodeCount</td>
|
||||
<td>
|
||||
@if (c.Enabled) { <span class="badge bg-success">Active</span> }
|
||||
else { <span class="badge bg-secondary">Disabled</span> }
|
||||
@if (c.Enabled) { <span class="chip chip-ok">Active</span> }
|
||||
else { <span class="chip chip-idle">Disabled</span> }
|
||||
</td>
|
||||
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -4,49 +4,49 @@
|
||||
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. *@
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<section class="panel rise mb-3" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>@Title</strong>
|
||||
<small class="text-muted ms-2">@Description</small>
|
||||
</div>
|
||||
<div>
|
||||
@if (_added > 0) { <span class="badge bg-success me-1">+@_added</span> }
|
||||
@if (_removed > 0) { <span class="badge bg-danger me-1">−@_removed</span> }
|
||||
@if (_modified > 0) { <span class="badge bg-warning text-dark me-1">~@_modified</span> }
|
||||
@if (_total == 0) { <span class="badge bg-secondary">no changes</span> }
|
||||
@if (_added > 0) { <span class="chip chip-ok me-1">+@_added</span> }
|
||||
@if (_removed > 0) { <span class="chip chip-bad me-1">−@_removed</span> }
|
||||
@if (_modified > 0) { <span class="chip chip-warn me-1">~@_modified</span> }
|
||||
@if (_total == 0) { <span class="chip chip-idle">no changes</span> }
|
||||
</div>
|
||||
</div>
|
||||
@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
|
||||
{
|
||||
@if (_total > RowCap)
|
||||
{
|
||||
<div class="alert alert-warning mb-0 small rounded-0">
|
||||
Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class
|
||||
diffs. Inspect the remainder via the SQL <code>sp_ComputeGenerationDiff</code> directly.
|
||||
<div class="p-2 small text-muted border-bottom">
|
||||
<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 <span class="mono">sp_ComputeGenerationDiff</span> directly.
|
||||
</div>
|
||||
}
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _visibleRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td><span class="mono">@r.LogicalId</span></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
case "Added": <span class="chip chip-ok">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="chip chip-bad">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="chip chip-warn">@r.ChangeKind</span> break;
|
||||
default: <span class="chip chip-idle">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -55,7 +55,7 @@
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Draft diff</h1>
|
||||
<h1 class="page-title mb-0">Draft diff</h1>
|
||||
<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>
|
||||
</div>
|
||||
<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)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Draft editor</h1>
|
||||
<small class="text-muted">Cluster <code>@ClusterId</code> · generation @GenerationId</small>
|
||||
<h1 class="page-title mb-0">Draft editor</h1>
|
||||
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · generation @GenerationId</small>
|
||||
</div>
|
||||
<div>
|
||||
<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"/> }
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card sticky-top">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<section class="panel rise sticky-top" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<strong>Validation</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="RevalidateAsync">Re-run</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-3">
|
||||
@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
|
||||
{
|
||||
<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">
|
||||
@foreach (var e in _errors)
|
||||
{
|
||||
<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>
|
||||
@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>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@inject NamespaceService NsSvc
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -14,13 +14,16 @@
|
||||
else if (_drivers.Count == 0) { <p class="text-muted">No drivers configured in this draft.</p> }
|
||||
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>
|
||||
<tbody>
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@d.DriverInstanceId</code></td>
|
||||
<td><span class="mono">@d.DriverInstanceId</span></td>
|
||||
<td>@d.Name</td>
|
||||
<td>
|
||||
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -32,25 +35,28 @@ else
|
||||
@d.DriverType
|
||||
}
|
||||
</td>
|
||||
<td><code>@d.NamespaceId</code></td>
|
||||
<td><span class="mono">@d.NamespaceId</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@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="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="_name"/>
|
||||
<input class="form-control form-control-sm" @bind="_name"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<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>Modbus</option>
|
||||
<option>AbCip</option>
|
||||
@@ -63,7 +69,7 @@ else
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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> }
|
||||
</select>
|
||||
</div>
|
||||
@@ -78,18 +84,18 @@ else
|
||||
else
|
||||
{
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<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>
|
||||
<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>
|
||||
@@ -22,7 +22,10 @@ else if (_equipment.Count == 0 && !_showForm)
|
||||
}
|
||||
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>
|
||||
<tr>
|
||||
<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)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@e.EquipmentId</code></td>
|
||||
<td><span class="mono">@e.EquipmentId</span></td>
|
||||
<td>@e.Name</td>
|
||||
<td>@e.MachineCode</td>
|
||||
<td>@e.ZTag</td>
|
||||
@@ -48,46 +51,48 @@ else if (_equipment.Count > 0)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@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">
|
||||
<h5>@(_editMode ? "Edit equipment" : "New equipment")</h5>
|
||||
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form">
|
||||
<DataAnnotationsValidator/>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<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"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<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 class="col-md-4">
|
||||
<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 class="col-md-4">
|
||||
<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 class="col-md-4">
|
||||
<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 class="col-md-4">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
@@ -95,7 +100,7 @@ else if (_equipment.Count > 0)
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -4,21 +4,22 @@
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<h4>Generations</h4>
|
||||
|
||||
@if (_generations is null) { <p>Loading…</p> }
|
||||
else if (_generations.Count == 0) { <p class="text-muted">No generations in this cluster yet.</p> }
|
||||
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>
|
||||
<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>
|
||||
<tbody>
|
||||
@foreach (var g in _generations)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@g.GenerationId</code></td>
|
||||
<td class="num mono">@g.GenerationId</td>
|
||||
<td>@StatusBadge(g.Status)</td>
|
||||
<td><small>@g.CreatedAt.ToString("u") by @g.CreatedBy</small></td>
|
||||
<td><small>@(g.PublishedAt?.ToString("u") ?? "-")</small></td>
|
||||
@@ -38,9 +39,11 @@ else
|
||||
}
|
||||
</tbody>
|
||||
</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 {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
@@ -65,9 +68,9 @@ else
|
||||
|
||||
private static MarkupString StatusBadge(GenerationStatus s) => s switch
|
||||
{
|
||||
GenerationStatus.Draft => new MarkupString("<span class='badge bg-info'>Draft</span>"),
|
||||
GenerationStatus.Published => new MarkupString("<span class='badge bg-success'>Published</span>"),
|
||||
GenerationStatus.Superseded => new MarkupString("<span class='badge bg-secondary'>Superseded</span>"),
|
||||
_ => new MarkupString($"<span class='badge bg-light text-dark'>{s}</span>"),
|
||||
GenerationStatus.Draft => new MarkupString("<span class='chip chip-idle'>Draft</span>"),
|
||||
GenerationStatus.Published => new MarkupString("<span class='chip chip-ok'>Published</span>"),
|
||||
GenerationStatus.Superseded => new MarkupString("<span class='chip chip-idle'>Superseded</span>"),
|
||||
_ => new MarkupString($"<span class='chip chip-idle'>{s}</span>"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
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. *@
|
||||
|
||||
<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="col-md-4">
|
||||
<label class="form-label">Manufacturer</label>
|
||||
|
||||
@@ -10,37 +10,38 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Equipment CSV import</h1>
|
||||
<small class="text-muted">Cluster <code>@ClusterId</code> · draft generation @GenerationId</small>
|
||||
<h1 class="page-title mb-0">Equipment CSV import</h1>
|
||||
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · draft generation @GenerationId</small>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small mb-3">
|
||||
Accepts <code>@EquipmentCsvImporter.VersionMarker</code>-headered CSV per Stream B.3.
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Accepts <span class="mono">@EquipmentCsvImporter.VersionMarker</span>-headered CSV per Stream B.3.
|
||||
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
|
||||
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
|
||||
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
|
||||
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 —
|
||||
tags are configured at the driver-instance level via 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
|
||||
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.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<section class="panel rise mt-2" style="animation-delay:.14s">
|
||||
<div class="panel-head">Import configuration</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<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>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@@ -50,7 +51,7 @@
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<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>
|
||||
@if (_unsLines is not null)
|
||||
{
|
||||
@@ -64,7 +65,7 @@
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<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 ZTag,MachineCode,SAPID,EquipmentId,…"/>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
@@ -73,28 +74,26 @@
|
||||
disabled="@(_parseResult is null || _parseResult.AcceptedRows.Count == 0 || string.IsNullOrWhiteSpace(_driverInstanceId) || string.IsNullOrWhiteSpace(_unsLineId) || _busy)">
|
||||
Stage + Finalise
|
||||
</button>
|
||||
@if (_parseError is not null) { <span class="alert alert-danger ms-3 py-1 px-2 small">@_parseError</span> }
|
||||
@if (_result is not null) { <span class="alert alert-success ms-3 py-1 px-2 small">@_result</span> }
|
||||
@if (_parseError is not null) { <span class="chip chip-bad ms-3">@_parseError</span> }
|
||||
@if (_result is not null) { <span class="chip chip-ok ms-3">@_result</span> }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_parseResult is not null)
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
Accepted (@_parseResult.AcceptedRows.Count)
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head"><span class="s-ok">Accepted (@_parseResult.AcceptedRows.Count)</span></div>
|
||||
<div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.AcceptedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No accepted rows.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr>
|
||||
</thead>
|
||||
@@ -102,7 +101,7 @@
|
||||
@foreach (var r in _parseResult.AcceptedRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.ZTag</code></td>
|
||||
<td><span class="mono">@r.ZTag</span></td>
|
||||
<td>@r.MachineCode</td>
|
||||
<td>@r.Name</td>
|
||||
<td>@r.UnsLineName</td>
|
||||
@@ -112,35 +111,33 @@
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
Rejected (@_parseResult.RejectedRows.Count)
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head"><span class="s-bad">Rejected (@_parseResult.RejectedRows.Count)</span></div>
|
||||
<div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.RejectedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No rejections.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead><tr><th>Line</th><th>Reason</th></tr></thead>
|
||||
<table class="data-table">
|
||||
<thead><tr><th class="num">Line</th><th>Reason</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _parseResult.RejectedRows)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.LineNumber</td>
|
||||
<td class="small">@e.Reason</td>
|
||||
<td class="num">@e.LineNumber</td>
|
||||
<td><span class="s-bad">@e.Reason</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@inject NamespaceService NsSvc
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -12,26 +12,37 @@
|
||||
else if (_namespaces.Count == 0) { <p class="text-muted">No namespaces defined in this draft.</p> }
|
||||
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>
|
||||
<tbody>
|
||||
@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>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@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="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">
|
||||
<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.SystemPlatform">SystemPlatform (Galaxy)</option>
|
||||
</select>
|
||||
@@ -42,7 +53,7 @@ else
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@inject GenerationService GenerationSvc
|
||||
@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">
|
||||
<DataAnnotationsValidator/>
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
@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">
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h4>Redundancy topology</h4>
|
||||
<h4 class="panel-head">Redundancy topology</h4>
|
||||
@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">
|
||||
One row per <code>ClusterNode</code> in this cluster. Role, <code>ApplicationUri</code>,
|
||||
and <code>ServiceLevelBase</code> are authored separately; the Admin UI shows them read-only
|
||||
One row per <span class="mono">ClusterNode</span> in this cluster. Role, <span class="mono">ApplicationUri</span>,
|
||||
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
|
||||
@((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
|
||||
<code>RedundancyCoordinator</code> apply-lease flow, not direct DB edits.
|
||||
<span class="mono">RedundancyCoordinator</span> apply-lease flow, not direct DB edits.
|
||||
</p>
|
||||
|
||||
@if (_nodes is null)
|
||||
@@ -27,10 +27,10 @@
|
||||
}
|
||||
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
|
||||
(with a non-blank <code>ApplicationUri</code>) before it can start up per OPC UA spec.
|
||||
</div>
|
||||
(with a non-blank <span class="mono">ApplicationUri</span>) before it can start up per OPC UA spec.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -39,49 +39,51 @@ else
|
||||
var standalone = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Standalone);
|
||||
var staleCount = _nodes.Count(ClusterNodeService.IsStale);
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Nodes</h6>
|
||||
<div class="fs-3">@_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>
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Nodes</div>
|
||||
<div class="agg-value numeric">@_nodes.Count</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)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
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>.
|
||||
</div>
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
<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 <span class="mono">RedundancyCoordinator</span>.</span>
|
||||
</section>
|
||||
}
|
||||
else if (primaries > 1)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
<strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
<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
|
||||
immediately — one of the rows was likely hand-edited.
|
||||
</div>
|
||||
immediately — one of the rows was likely hand-edited.</span>
|
||||
</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>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Role</th>
|
||||
<th>Host</th>
|
||||
<th class="text-end">OPC UA port</th>
|
||||
<th class="text-end">ServiceLevel base</th>
|
||||
<th class="num">OPC UA port</th>
|
||||
<th class="num">ServiceLevel base</th>
|
||||
<th>ApplicationUri</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last seen</th>
|
||||
@@ -90,25 +92,27 @@ else
|
||||
<tbody>
|
||||
@foreach (var n in _nodes)
|
||||
{
|
||||
<tr class="@RowClass(n)">
|
||||
<td><code>@n.NodeId</code></td>
|
||||
<td><span class="badge @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
|
||||
<tr>
|
||||
<td><span class="mono">@n.NodeId</span></td>
|
||||
<td><span class="chip @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
|
||||
<td>@n.Host</td>
|
||||
<td class="text-end"><code>@n.OpcUaPort</code></td>
|
||||
<td class="text-end">@n.ServiceLevelBase</td>
|
||||
<td class="small text-break"><code>@n.ApplicationUri</code></td>
|
||||
<td class="num mono">@n.OpcUaPort</td>
|
||||
<td class="num">@n.ServiceLevelBase</td>
|
||||
<td class="mono">@n.ApplicationUri</td>
|
||||
<td>
|
||||
@if (n.Enabled) { <span class="badge bg-success">Enabled</span> }
|
||||
else { <span class="badge bg-secondary">Disabled</span> }
|
||||
@if (n.Enabled) { <span class="chip chip-ok">Enabled</span> }
|
||||
else { <span class="chip chip-idle">Disabled</span> }
|
||||
</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))
|
||||
@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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -158,10 +162,10 @@ else
|
||||
|
||||
private static string RoleBadge(RedundancyRole r) => r switch
|
||||
{
|
||||
RedundancyRole.Primary => "bg-success",
|
||||
RedundancyRole.Secondary => "bg-info",
|
||||
RedundancyRole.Standalone => "bg-primary",
|
||||
_ => "bg-secondary",
|
||||
RedundancyRole.Primary => "chip-ok",
|
||||
RedundancyRole.Secondary => "chip-idle",
|
||||
RedundancyRole.Standalone => "chip-idle",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*@
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<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>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
|
||||
@@ -18,7 +18,7 @@
|
||||
@if (_loading) { <p class="text-muted">Loading…</p> }
|
||||
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
|
||||
{
|
||||
@@ -30,7 +30,7 @@ else
|
||||
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
|
||||
@onclick="() => Open(s)">
|
||||
<strong>@s.Name</strong>
|
||||
<div class="small text-muted font-monospace">@s.ScriptId</div>
|
||||
<div class="small text-muted mono">@s.ScriptId</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -38,8 +38,8 @@ else
|
||||
<div class="col-md-8">
|
||||
@if (_editing is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
|
||||
<div>
|
||||
@if (!_isNew)
|
||||
@@ -49,10 +49,10 @@ else
|
||||
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="_editing.Name"/>
|
||||
<input class="form-control form-control-sm" @bind="_editing.Name"/>
|
||||
</div>
|
||||
<label class="form-label">Source</label>
|
||||
<ScriptEditor @bind-Source="_editing.SourceCode"/>
|
||||
@@ -70,7 +70,7 @@ else
|
||||
else
|
||||
{
|
||||
<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>
|
||||
}
|
||||
<strong>Inferred writes</strong>
|
||||
@@ -78,17 +78,17 @@ else
|
||||
else
|
||||
{
|
||||
<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>
|
||||
}
|
||||
@if (_dependencies.Rejections.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger mt-2">
|
||||
<section class="panel notice mt-2">
|
||||
<strong>Non-literal paths rejected:</strong>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -96,24 +96,24 @@ else
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<div class="mt-1"><strong>Writes:</strong>
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (_testResult.Errors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning mt-2 mb-0">
|
||||
@foreach (var e in _testResult.Errors) { <div>@e</div> }
|
||||
</div>
|
||||
<section class="panel notice mt-2 mb-0">
|
||||
@foreach (var e in _testResult.Errors) { <div><span class="s-warn">@e</span></div> }
|
||||
</section>
|
||||
}
|
||||
@if (_testResult.LogEvents.Count > 0)
|
||||
{
|
||||
@@ -126,7 +126,7 @@ else
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,15 +15,13 @@
|
||||
a generic JSON textarea, matching the DriversTab pattern from #147.
|
||||
*@
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Tags (draft gen @GenerationId)</h4>
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<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">
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<select class="form-select form-select-sm tb-state" @bind="_filterDriverId" @bind:after="ReloadAsync">
|
||||
<option value="">— all drivers —</option>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@@ -33,14 +31,15 @@
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<span class="spacer"></span>
|
||||
@if (_tags is not null) { <span class="tb-count">@_tags.Count tags</span> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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)
|
||||
{
|
||||
<table class="table table-sm">
|
||||
@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> }
|
||||
else if (_tags.Count > 0)
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Driver</th><th>Equipment</th><th>DataType</th><th>Access</th><th>TagConfig</th><th></th></tr>
|
||||
</thead>
|
||||
@@ -49,11 +48,11 @@ else if (_tags.Count > 0)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Name</td>
|
||||
<td><code>@t.DriverInstanceId</code></td>
|
||||
<td><span class="mono">@t.DriverInstanceId</span></td>
|
||||
<td>@(t.EquipmentId ?? "—")</td>
|
||||
<td>@t.DataType</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>
|
||||
<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>
|
||||
@@ -62,13 +61,15 @@ else if (_tags.Count > 0)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>@(_editMode ? "Edit tag" : "New tag")</h5>
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@(_editMode ? "Edit tag" : "New tag")</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<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">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="Cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -2,26 +2,28 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@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>
|
||||
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
|
||||
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="d-flex justify-content-between mb-2">
|
||||
<h4>UNS Areas</h4>
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@if (_areas is null) { <p>Loading…</p> }
|
||||
else if (_areas.Count == 0) { <p class="text-muted">No areas yet.</p> }
|
||||
@if (_areas is null) { <p class="p-3">Loading…</p> }
|
||||
else if (_areas.Count == 0) { <p class="p-3 text-muted">No areas yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>AreaId</th><th>Name</th><th class="small text-muted">(drop target)</th></tr></thead>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>AreaId</th><th>Name</th><th class="text-muted">(drop target)</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _areas)
|
||||
{
|
||||
@@ -31,38 +33,40 @@
|
||||
@ondragleave="() => _hoverAreaId = null"
|
||||
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
|
||||
@ondrop:preventDefault>
|
||||
<td><code>@a.UnsAreaId</code></td>
|
||||
<td><span class="mono">@a.UnsAreaId</span></td>
|
||||
<td>@a.Name</td>
|
||||
<td class="small text-muted">drop here</td>
|
||||
<td class="text-muted">drop here</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_showAreaForm)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-2"><label class="form-label">Name (lowercase segment)</label><input class="form-control" @bind="_newAreaName"/></div>
|
||||
<div class="p-3 border-top">
|
||||
<div class="mb-2"><label class="form-label">Name (lowercase segment)</label><input class="form-control form-control-sm" @bind="_newAreaName"/></div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h4>UNS Lines</h4>
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@if (_lines is null) { <p>Loading…</p> }
|
||||
else if (_lines.Count == 0) { <p class="text-muted">No lines yet.</p> }
|
||||
@if (_lines is null) { <p class="p-3">Loading…</p> }
|
||||
else if (_lines.Count == 0) { <p class="p-3 text-muted">No lines yet.</p> }
|
||||
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>
|
||||
<tbody>
|
||||
@foreach (var l in _lines)
|
||||
@@ -71,31 +75,31 @@
|
||||
@ondragstart="() => _dragLineId = l.UnsLineId"
|
||||
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
|
||||
style="cursor: grab;">
|
||||
<td><code>@l.UnsLineId</code></td>
|
||||
<td><code>@l.UnsAreaId</code></td>
|
||||
<td><span class="mono">@l.UnsLineId</span></td>
|
||||
<td><span class="mono">@l.UnsAreaId</span></td>
|
||||
<td>@l.Name</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_showLineForm && _areas is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="p-3 border-top">
|
||||
<div class="mb-2">
|
||||
<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> }
|
||||
</select>
|
||||
</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-secondary ms-2" @onclick="() => _showLineForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,11 +121,11 @@
|
||||
</p>
|
||||
@if (_pendingPreview.CascadeWarnings.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning small mb-0">
|
||||
<section class="panel notice small mb-0">
|
||||
<ul class="mb-0">
|
||||
@foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> }
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@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)
|
||||
{
|
||||
@@ -10,149 +10,168 @@
|
||||
}
|
||||
else if (_detail is null)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
No FOCAS driver instance with id <code>@InstanceId</code> was found.
|
||||
<section class="panel notice">
|
||||
No FOCAS driver instance with id <span class="mono">@InstanceId</span> was found.
|
||||
<div class="small text-muted mt-1">
|
||||
Either the id is wrong, or the instance's <code>DriverType</code> is not "Focas". The list of drivers per cluster draft is on the <a href="/clusters">Clusters</a> page.
|
||||
</div>
|
||||
Either the id is wrong, or the instance's <span class="mono">DriverType</span> is not "Focas". The list of drivers per cluster draft is on the <a href="/clusters">Clusters</a> page.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Name</h6>
|
||||
<div class="fs-5">@_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>
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Name</div>
|
||||
<div class="agg-value">@_detail.Instance.Name</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Cluster</div>
|
||||
<div class="agg-value mono">@_detail.Instance.ClusterId</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Namespace</div>
|
||||
<div class="agg-value mono">@_detail.Instance.NamespaceId</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Enabled</div>
|
||||
<div class="agg-value">@(_detail.Instance.Enabled ? "Yes" : "No")</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_detail.ParseError is not null)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
<strong>DriverConfig JSON failed to parse:</strong> @_detail.ParseError
|
||||
<div class="small text-muted mt-1">
|
||||
Falling back to raw-JSON view below; the per-section tables are hidden because the shape couldn't be projected.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
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)
|
||||
{
|
||||
<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
|
||||
{
|
||||
<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>
|
||||
<tbody>
|
||||
@foreach (var d in _detail.Config.Devices)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@d.HostAddress</code></td>
|
||||
<td class="mono">@d.HostAddress</td>
|
||||
<td>@(d.DeviceName ?? "—")</td>
|
||||
<td>@(string.IsNullOrEmpty(d.Series) ? "Unknown" : d.Series)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Tags</h2>
|
||||
@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
|
||||
{
|
||||
<p class="small text-muted">@_detail.Config.Tags.Count tag(s) configured.</p>
|
||||
<table class="table table-sm align-middle">
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<div class="toolbar">
|
||||
<span class="spacer"></span>
|
||||
<span class="tb-count">@_detail.Config.Tags.Count tag(s)</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Name</th><th>Device</th><th>Address</th><th>DataType</th><th>Writable</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var t in _detail.Config.Tags)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Name</td>
|
||||
<td><code class="small">@t.DeviceHostAddress</code></td>
|
||||
<td><code>@t.Address</code></td>
|
||||
<td class="mono">@t.DeviceHostAddress</td>
|
||||
<td class="mono">@t.Address</td>
|
||||
<td>@t.DataType</td>
|
||||
<td>@(t.Writable ? "Yes" : "No")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Driver behaviour</h2>
|
||||
<table class="table table-sm align-middle" style="max-width: 640px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width: 30%;">Probe</th>
|
||||
<td>
|
||||
<section class="card-grid rise" style="animation-delay:.20s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Driver behaviour</div>
|
||||
<div class="kv">
|
||||
<span class="k">Probe</span>
|
||||
<span class="v">
|
||||
@if (_detail.Config.Probe is { } probe)
|
||||
{
|
||||
<span class="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>
|
||||
}
|
||||
else { <span class="text-muted">default (enabled)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Alarm projection</th>
|
||||
<td>
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span class="k">Alarm projection</span>
|
||||
<span class="v">
|
||||
@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>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Handle recycling</th>
|
||||
<td>
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span class="k">Handle recycling</span>
|
||||
<span class="v">
|
||||
@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>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Host status</h2>
|
||||
@if (_detail.HostStatuses.Count == 0)
|
||||
{
|
||||
<div class="alert alert-secondary small">
|
||||
No <code>DriverHostStatus</code> rows yet for this instance. The Server publishes its first
|
||||
<section class="panel notice rise" style="animation-delay:.26s">
|
||||
No <span class="mono">DriverHostStatus</span> rows yet for this instance. The Server publishes its first
|
||||
tick ~2 s after the driver starts — if this stays empty after a minute, check that the Server is running and the instance is in a published generation.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
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>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Host</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>Last recycled</th>
|
||||
<th>Last seen</th>
|
||||
@@ -162,26 +181,30 @@ else
|
||||
<tbody>
|
||||
@foreach (var r in _detail.HostStatuses)
|
||||
{
|
||||
<tr class="@(IsStale(r) ? "table-warning" : "")">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<tr>
|
||||
<td class="mono">@r.NodeId</td>
|
||||
<td>@r.HostName</td>
|
||||
<td><span class="badge @StateBadge(r.State)">@r.State</span></td>
|
||||
<td class="text-end small">@r.ConsecutiveFailures</td>
|
||||
<td><span class="chip @StateBadge(r.State)">@r.State</span></td>
|
||||
<td class="num small">@r.ConsecutiveFailures</td>
|
||||
<td class="small">@FormatUtc(r.LastCircuitBreakerOpenUtc)</td>
|
||||
<td class="small">@FormatUtc(r.LastRecycleUtc)</td>
|
||||
<td class="small @(IsStale(r) ? "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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Raw DriverConfig JSON</h2>
|
||||
<pre class="small bg-light border p-3"><code>@_detail.Instance.DriverConfig</code></pre>
|
||||
<section class="panel rise" style="animation-delay:.32s">
|
||||
<div class="panel-head">Raw DriverConfig JSON</div>
|
||||
<pre class="small" style="padding:1rem;margin:0;overflow-x:auto"><code>@_detail.Instance.DriverConfig</code></pre>
|
||||
</section>
|
||||
|
||||
<div class="mt-4 small text-muted">
|
||||
Docs: <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>
|
||||
}
|
||||
|
||||
@@ -203,11 +226,11 @@ else
|
||||
|
||||
private static string StateBadge(string state) => state switch
|
||||
{
|
||||
"Running" => "bg-success",
|
||||
"Faulted" => "bg-danger",
|
||||
"Starting" => "bg-info",
|
||||
"Stopped" => "bg-secondary",
|
||||
_ => "bg-secondary",
|
||||
"Running" => "chip-ok",
|
||||
"Faulted" => "chip-bad",
|
||||
"Starting" => "chip-idle",
|
||||
"Stopped" => "chip-idle",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string FormatUtc(DateTime? utc) =>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@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">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||
@@ -23,46 +23,41 @@
|
||||
}
|
||||
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
|
||||
this list is empty, either no nodes have been registered or the poller hasn't run yet.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Nodes</h6>
|
||||
<div class="fs-3">@_rows.Count</div>
|
||||
</div></div>
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Nodes</div>
|
||||
<div class="agg-value numeric">@_rows.Count</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Applied</h6>
|
||||
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
|
||||
</div></div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Applied</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Applied")</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(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 class="agg-card caution">
|
||||
<div class="agg-label">Stale</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => IsStale(r))</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>
|
||||
</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>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Cluster</th>
|
||||
<th>Generation</th>
|
||||
<th class="num">Generation</th>
|
||||
<th>Status</th>
|
||||
<th>Last applied</th>
|
||||
<th>Last seen</th>
|
||||
@@ -72,20 +67,22 @@ else
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr class="@RowClass(r)">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<tr>
|
||||
<td><span class="mono">@r.NodeId</span></td>
|
||||
<td>@r.ClusterId</td>
|
||||
<td>@(r.GenerationId?.ToString() ?? "—")</td>
|
||||
<td class="num">@(r.GenerationId?.ToString() ?? "—")</td>
|
||||
<td>
|
||||
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
|
||||
<span class="chip @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
|
||||
</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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -115,13 +112,16 @@ else
|
||||
{
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
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()
|
||||
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
|
||||
s.NodeId, n.ClusterId, s.CurrentGenerationId,
|
||||
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
|
||||
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
|
||||
.OrderBy(r => r.ClusterId)
|
||||
.ThenBy(r => r.NodeId)
|
||||
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n })
|
||||
.OrderBy(x => x.n.ClusterId)
|
||||
.ThenBy(x => x.s.NodeId)
|
||||
.Select(x => new FleetNodeRow(
|
||||
x.s.NodeId, x.n.ClusterId, x.s.CurrentGenerationId,
|
||||
x.s.LastAppliedStatus != null ? x.s.LastAppliedStatus.ToString() : null,
|
||||
x.s.LastAppliedError, x.s.LastAppliedAt, x.s.LastSeenAt))
|
||||
.ToListAsync();
|
||||
_rows = rows;
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
@@ -148,10 +148,10 @@ else
|
||||
|
||||
private static string StatusBadge(string? status) => status switch
|
||||
{
|
||||
"Applied" => "bg-success",
|
||||
"Failed" => "bg-danger",
|
||||
"Applying" => "bg-info",
|
||||
_ => "bg-secondary",
|
||||
"Applied" => "chip-ok",
|
||||
"Failed" => "chip-bad",
|
||||
"Applying" => "chip-idle",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime? t)
|
||||
|
||||
@@ -5,62 +5,93 @@
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<h1 class="mb-4">Fleet overview</h1>
|
||||
<h1 class="page-title">Fleet overview</h1>
|
||||
|
||||
@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…</div>
|
||||
</section>
|
||||
}
|
||||
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>.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Clusters</h6><div class="fs-2">@_clusters.Count</div></div></div>
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Clusters</div>
|
||||
<div class="agg-value numeric">@_clusters.Count</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<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-card">
|
||||
<div class="agg-label">Active drafts</div>
|
||||
<div class="agg-value numeric">@_activeDraftCount</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Published generations</h6><div class="fs-2">@_publishedCount</div></div></div>
|
||||
</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 class="agg-card">
|
||||
<div class="agg-label">Published generations</div>
|
||||
<div class="agg-value numeric">@_publishedCount</div>
|
||||
</div>
|
||||
<div class="agg-card @(_disabledCount > 0 ? "caution" : "")">
|
||||
<div class="agg-label">Disabled clusters</div>
|
||||
<div class="agg-value numeric">@_disabledCount</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h4 class="mt-4 mb-3">Clusters</h4>
|
||||
<table class="table table-hover">
|
||||
<thead><tr><th>ClusterId</th><th>Name</th><th>Enterprise / Site</th><th>Redundancy</th><th>Enabled</th><th></th></tr></thead>
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Clusters</div>
|
||||
<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>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<tr style="cursor: pointer;">
|
||||
<td><code>@c.ClusterId</code></td>
|
||||
<tr @onclick="@(() => Nav.NavigateTo($"/clusters/{c.ClusterId}"))">
|
||||
<td class="mono">@c.ClusterId</td>
|
||||
<td>@c.Name</td>
|
||||
<td>@c.Enterprise / @c.Site</td>
|
||||
<td>@c.RedundancyMode</td>
|
||||
<td>@(c.Enabled ? "Yes" : "No")</td>
|
||||
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
|
||||
<td class="mono">@c.RedundancyMode</td>
|
||||
<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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _clusters;
|
||||
private int _activeDraftCount;
|
||||
private int _publishedCount;
|
||||
private int _disabledCount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
_disabledCount = _clusters.Count(c => !c.Enabled);
|
||||
|
||||
foreach (var c in _clusters)
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@inject NavigationManager Nav
|
||||
@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">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||
@@ -20,13 +20,13 @@
|
||||
</span>
|
||||
</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
|
||||
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
|
||||
30s are flagged Stale, which usually means the owning Server process has crashed or lost
|
||||
its DB connection.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
@@ -34,53 +34,55 @@
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<div class="alert alert-secondary">
|
||||
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>.
|
||||
</div>
|
||||
<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 <span class="mono">IHostConnectivityProbe</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Hosts</h6>
|
||||
<div class="fs-3">@_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>
|
||||
<section class="agg-grid rise" style="animation-delay:.08s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Hosts</div>
|
||||
<div class="agg-value numeric">@_rows.Count</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))
|
||||
{
|
||||
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>
|
||||
reporting ≥ @HostStatusService.FailureFlagThreshold consecutive failures — circuit breaker
|
||||
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))
|
||||
{
|
||||
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">Cluster: <span class="mono">@cluster.Key</span></div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Driver</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="text-end" 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="Consecutive failures — resets when a call succeeds or the breaker closes">Fail#</th>
|
||||
<th class="num" title="In-flight capability calls (bulkhead-depth proxy)">In-flight</th>
|
||||
<th>Breaker opened</th>
|
||||
<th>Last transition</th>
|
||||
<th>Last seen</th>
|
||||
@@ -90,35 +92,37 @@ else
|
||||
<tbody>
|
||||
@foreach (var r in cluster)
|
||||
{
|
||||
<tr class="@RowClass(r)">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td><code>@r.DriverInstanceId</code></td>
|
||||
<tr>
|
||||
<td><span class="mono">@r.NodeId</span></td>
|
||||
<td><span class="mono">@r.DriverInstanceId</span></td>
|
||||
<td>@r.HostName</td>
|
||||
<td>
|
||||
<span class="badge @StateBadge(r.State)">@r.State</span>
|
||||
<span class="chip @StateBadge(r.State)">@r.State</span>
|
||||
@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))
|
||||
{
|
||||
<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 class="text-end small @(HostStatusService.IsFlagged(r) ? "text-danger fw-bold" : "")">
|
||||
<td class="num small @(HostStatusService.IsFlagged(r) ? "s-bad fw-bold" : "")">
|
||||
@r.ConsecutiveFailures
|
||||
</td>
|
||||
<td class="text-end small">@r.CurrentBulkheadDepth</td>
|
||||
<td class="num small">@r.CurrentBulkheadDepth</td>
|
||||
<td class="small">
|
||||
@(r.LastCircuitBreakerOpenUtc is null ? "—" : FormatAge(r.LastCircuitBreakerOpenUtc.Value))
|
||||
</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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,10 +211,10 @@ else
|
||||
|
||||
private static string StateBadge(DriverHostState s) => s switch
|
||||
{
|
||||
DriverHostState.Running => "bg-success",
|
||||
DriverHostState.Stopped => "bg-secondary",
|
||||
DriverHostState.Faulted => "bg-danger",
|
||||
_ => "bg-secondary",
|
||||
DriverHostState.Running => "chip-ok",
|
||||
DriverHostState.Stopped => "chip-idle",
|
||||
DriverHostState.Faulted => "chip-bad",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
|
||||
@@ -7,37 +7,37 @@
|
||||
@inject ILdapAuthService LdapAuth
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="mb-4">OtOpcUa Admin — sign in</h4>
|
||||
|
||||
<div class="login-wrap rise" style="animation-delay:.02s">
|
||||
<section class="panel">
|
||||
<div class="panel-head">OtOpcUa Admin — sign in</div>
|
||||
<div style="padding:1.1rem 1.1rem 1.25rem">
|
||||
<EditForm Model="_input" OnValidSubmit="SignInAsync" FormName="login">
|
||||
<div class="mb-3">
|
||||
<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 class="mb-3">
|
||||
<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>
|
||||
|
||||
@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">
|
||||
@(_busy ? "Signing in…" : "Sign in")
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<hr/>
|
||||
<small class="text-muted">
|
||||
<div style="margin-top:1rem;padding-top:.85rem;border-top:1px solid var(--rule);
|
||||
font-size:.78rem;color:var(--ink-faint)">
|
||||
LDAP bind against the configured directory. Dev defaults to GLAuth on
|
||||
<code>localhost:3893</code>.
|
||||
</small>
|
||||
</div>
|
||||
<span class="mono">localhost:3893</span>.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -47,10 +47,17 @@
|
||||
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 bool _busy;
|
||||
|
||||
protected override void OnInitialized() => _input ??= new();
|
||||
|
||||
private async Task SignInAsync()
|
||||
{
|
||||
_error = null;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<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"
|
||||
@oninput="@OnInputChanged"
|
||||
placeholder="e.g. 40001:F:CDAB:5"/>
|
||||
@@ -21,13 +21,13 @@
|
||||
{
|
||||
<div class="form-text text-success">
|
||||
<strong>Parsed:</strong>
|
||||
Region=<code>@_parsed.Region</code>
|
||||
Offset=<code>@_parsed.Offset</code>
|
||||
Type=<code>@_parsed.DataType</code>
|
||||
@if (_parsed.Bit.HasValue) { <text>Bit=<code>@_parsed.Bit</code></text> }
|
||||
@if (_parsed.ByteOrder != ModbusByteOrder.BigEndian) { <text>Order=<code>@_parsed.ByteOrder</code></text> }
|
||||
@if (_parsed.ArrayCount.HasValue) { <text>Array[<code>@_parsed.ArrayCount</code>]</text> }
|
||||
@if (_parsed.StringLength > 0) { <text>StrLen=<code>@_parsed.StringLength</code></text> }
|
||||
Region=<span class="mono">@_parsed.Region</span>
|
||||
Offset=<span class="mono">@_parsed.Offset</span>
|
||||
Type=<span class="mono">@_parsed.DataType</span>
|
||||
@if (_parsed.Bit.HasValue) { <text>Bit=<span class="mono">@_parsed.Bit</span></text> }
|
||||
@if (_parsed.ByteOrder != ModbusByteOrder.BigEndian) { <text>Order=<span class="mono">@_parsed.ByteOrder</span></text> }
|
||||
@if (_parsed.ArrayCount.HasValue) { <text>Array[<span class="mono">@_parsed.ArrayCount</span>]</text> }
|
||||
@if (_parsed.StringLength > 0) { <text>StrLen=<span class="mono">@_parsed.StringLength</span></text> }
|
||||
</div>
|
||||
}
|
||||
else if (Diagnostic is not null)
|
||||
|
||||
@@ -14,18 +14,20 @@
|
||||
|
||||
<PageTitle>Modbus address preview</PageTitle>
|
||||
|
||||
<div class="container py-4">
|
||||
<h1>Modbus address preview</h1>
|
||||
<p class="text-muted">
|
||||
<h1 class="page-title">Modbus address preview</h1>
|
||||
<p class="text-muted">
|
||||
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>.
|
||||
</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="col-md-4">
|
||||
<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>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
@@ -36,7 +38,7 @@
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<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>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
@@ -46,15 +48,18 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mt-3">
|
||||
<ModbusAddressEditor @bind-AddressString="_address"
|
||||
Family="_family"
|
||||
MelsecSubFamily="_melsecSubFamily"/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h3 class="mt-5">Quick-reference grammar</h3>
|
||||
<pre class="bg-light p-3 rounded small">@_grammarReference</pre>
|
||||
</div>
|
||||
<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 {
|
||||
private string? _address;
|
||||
|
||||
@@ -10,14 +10,13 @@
|
||||
|
||||
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
|
||||
|
||||
<div class="container py-4">
|
||||
<h1>Modbus auto-prohibitions</h1>
|
||||
<p class="text-muted">
|
||||
Driver instance <code>@DriverInstanceId</code>. Live snapshot of coalesced ranges
|
||||
<h1 class="page-title">Modbus auto-prohibitions</h1>
|
||||
<p class="text-muted">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span>. Live snapshot of coalesced ranges
|
||||
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">
|
||||
@(_loading ? "Loading…" : "Refresh")
|
||||
</button>
|
||||
@@ -25,30 +24,34 @@
|
||||
{
|
||||
<span class="text-muted ms-3 small">Last refreshed @_lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
|
||||
}
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
else if (_response is null)
|
||||
{
|
||||
@if (_error is not null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">@_error</section>
|
||||
}
|
||||
else if (_response is null)
|
||||
{
|
||||
<p class="text-muted">Click <strong>Refresh</strong> to load.</p>
|
||||
}
|
||||
else if (_response.Count == 0)
|
||||
{
|
||||
<div class="alert alert-success">No auto-prohibitions. The planner is coalescing freely.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
}
|
||||
else if (_response.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">No auto-prohibitions. The planner is coalescing freely.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Prohibited ranges</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Unit</th>
|
||||
<th>Region</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Span</th>
|
||||
<th class="num">Start</th>
|
||||
<th class="num">End</th>
|
||||
<th class="num">Span</th>
|
||||
<th>Status</th>
|
||||
<th>Last probed</th>
|
||||
</tr>
|
||||
@@ -57,19 +60,19 @@
|
||||
@foreach (var r in _response.Ranges.OrderBy(r => r.UnitId).ThenBy(r => r.Region).ThenBy(r => r.StartAddress))
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.UnitId</code></td>
|
||||
<td><code>@r.Region</code></td>
|
||||
<td><code>@r.StartAddress</code></td>
|
||||
<td><code>@r.EndAddress</code></td>
|
||||
<td>@(r.EndAddress - r.StartAddress + 1)</td>
|
||||
<td class="mono">@r.UnitId</td>
|
||||
<td class="mono">@r.Region</td>
|
||||
<td class="num mono">@r.StartAddress</td>
|
||||
<td class="num mono">@r.EndAddress</td>
|
||||
<td class="num">@(r.EndAddress - r.StartAddress + 1)</td>
|
||||
<td>
|
||||
@if (r.BisectionPending)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">BISECTING</span>
|
||||
<span class="chip chip-warn">BISECTING</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">ISOLATED</span>
|
||||
<span class="chip chip-bad">ISOLATED</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small text-muted">@FormatTimeSince(r.LastProbedUtc)</td>
|
||||
@@ -77,8 +80,9 @@
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string DriverInstanceId { get; set; } = string.Empty;
|
||||
|
||||
@@ -9,27 +9,27 @@
|
||||
|
||||
<div class="modbus-options-editor">
|
||||
|
||||
<h5>Connection</h5>
|
||||
<div class="panel-head">Connection</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-6">
|
||||
<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 class="col-sm-3">
|
||||
<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 class="col-sm-3">
|
||||
<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>
|
||||
|
||||
<h5>Family (#144)</h5>
|
||||
<div class="panel-head">Family (#144)</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-6">
|
||||
<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>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
@@ -40,7 +40,7 @@
|
||||
{
|
||||
<div class="col-sm-6">
|
||||
<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>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
@@ -50,7 +50,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<h5>Keep-alive (#139)</h5>
|
||||
<div class="panel-head">Keep-alive (#139)</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-3">
|
||||
<div class="form-check mt-4">
|
||||
@@ -60,51 +60,51 @@
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<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 class="col-sm-3">
|
||||
<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 class="col-sm-3">
|
||||
<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>
|
||||
|
||||
<h5>Reconnect (#139)</h5>
|
||||
<div class="panel-head">Reconnect (#139)</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4">
|
||||
<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 class="col-sm-4">
|
||||
<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 class="col-sm-4">
|
||||
<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>
|
||||
|
||||
<h5>Protocol (#140)</h5>
|
||||
<div class="panel-head">Protocol (#140)</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-3">
|
||||
<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 class="col-sm-3">
|
||||
<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 class="col-sm-3">
|
||||
<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 class="col-sm-3">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -5,27 +5,29 @@
|
||||
@attribute [Authorize(Policy = "CanPublish")]
|
||||
@inject ReservationService ReservationSvc
|
||||
|
||||
<h1 class="mb-4">External-ID reservations</h1>
|
||||
<h1 class="page-title">External-ID reservations</h1>
|
||||
<p class="text-muted">
|
||||
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
|
||||
retired and its ID needs to be reused by a different equipment.
|
||||
</p>
|
||||
|
||||
<h4 class="mt-4">Active</h4>
|
||||
@if (_active is null) { <p>Loading…</p> }
|
||||
else if (_active.Count == 0) { <p class="text-muted">No active reservations.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Active</div>
|
||||
@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
|
||||
{
|
||||
<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>
|
||||
<tbody>
|
||||
@foreach (var r in _active)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.Kind</code></td>
|
||||
<td><code>@r.Value</code></td>
|
||||
<td><code>@r.EquipmentUuid</code></td>
|
||||
<td><span class="mono">@r.Kind</span></td>
|
||||
<td><span class="mono">@r.Value</span></td>
|
||||
<td><span class="mono">@r.EquipmentUuid</span></td>
|
||||
<td>@r.ClusterId</td>
|
||||
<td><small>@r.FirstPublishedAt.ToString("u") by @r.FirstPublishedBy</small></td>
|
||||
<td><small>@r.LastPublishedAt.ToString("u")</small></td>
|
||||
@@ -34,23 +36,29 @@ else
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<h4 class="mt-4">Released (most recent 100)</h4>
|
||||
@if (_released is null) { <p>Loading…</p> }
|
||||
else if (_released.Count == 0) { <p class="text-muted">No released reservations yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Released (most recent 100)</div>
|
||||
@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
|
||||
{
|
||||
<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>
|
||||
<tbody>
|
||||
@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>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (_releasing is not null)
|
||||
{
|
||||
@@ -58,13 +66,13 @@ else
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<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 class="modal-body">
|
||||
<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>
|
||||
<textarea class="form-control" rows="3" @bind="_reason"></textarea>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-2">@_error</div> }
|
||||
<textarea class="form-control form-control-sm" rows="3" @bind="_reason"></textarea>
|
||||
@if (_error is not null) { <section class="panel notice mt-2">@_error</section> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick='() => _releasing = null'>Cancel</button>
|
||||
|
||||
@@ -12,15 +12,15 @@
|
||||
@inject NavigationManager Nav
|
||||
@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
|
||||
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
|
||||
cluster; a cluster-scoped grant only binds within the named cluster. The same LDAP group
|
||||
may hold different roles on different clusters.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
|
||||
@@ -37,7 +37,10 @@ else if (_rows.Count == 0)
|
||||
}
|
||||
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>
|
||||
<tr><th>LDAP group</th><th>Role</th><th>Scope</th><th>Created</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
@@ -45,8 +48,8 @@ else
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LdapGroup</code></td>
|
||||
<td><span class="badge bg-secondary">@r.Role</span></td>
|
||||
<td><span class="mono">@r.LdapGroup</span></td>
|
||||
<td><span class="chip chip-idle">@r.Role</span></td>
|
||||
<td>@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")</td>
|
||||
<td class="small">@r.CreatedAtUtc.ToString("yyyy-MM-dd")</td>
|
||||
<td class="small text-muted">@r.Notes</td>
|
||||
@@ -55,21 +58,23 @@ else
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>New role grant</h5>
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">New role grant</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<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 class="col-md-3">
|
||||
<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>())
|
||||
{
|
||||
<option value="@r">@r</option>
|
||||
@@ -84,7 +89,7 @@ else
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<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>
|
||||
@if (_clusters is not null)
|
||||
{
|
||||
@@ -97,16 +102,16 @@ else
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes (optional)</label>
|
||||
<input class="form-control" @bind="_notes"/>
|
||||
<input class="form-control form-control-sm" @bind="_notes"/>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -108,7 +108,7 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
|
||||
await Task.Run(() =>
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var filter = $"(uid={EscapeLdapFilter(username)})";
|
||||
var filter = $"({_options.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
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;
|
||||
|
||||
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||
$"No entry for uid={username}");
|
||||
$"No entry for {filter}");
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(_options.SearchBase)
|
||||
|
||||
@@ -29,6 +29,13 @@ public sealed class LdapOptions
|
||||
public string DisplayNameAttribute { get; set; } = "cn";
|
||||
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>
|
||||
/// 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:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"UseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,ou=svcaccts,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"DisplayNameAttribute": "cn",
|
||||
"GroupAttribute": "memberOf",
|
||||
|
||||
@@ -1,3 +1,105 @@
|
||||
/* OtOpcUa Admin — ScadaLink-parity palette. Keep it minimal here; lean on Bootstrap 5. */
|
||||
body { background-color: #f5f6fa; }
|
||||
.nav-link.active { background-color: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||
/* OtOpcUa Admin — view-specific layer over the technical-light theme (theme.css).
|
||||
Tokens live in theme.css; this sheet only carries layout + the side rail. */
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
379
src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/theme.css
Normal file
379
src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/theme.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user