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

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

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

View File

@@ -7,6 +7,7 @@
<title>OtOpcUa Admin</title>
<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>

View File

@@ -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">&#9646;</span> OtOpcUa</span>
<span class="crumb">&rsaquo;</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>

View File

@@ -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">

View File

@@ -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">
<section class="panel notice rise" style="animation-delay:.08s">
<strong>Last error:</strong> @_status.LastError
</div>
</section>
}
</div>
</div>
<div class="d-flex gap-2">
<div class="d-flex gap-2 mt-3">
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
<button class="btn btn-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",
};
}

View File

@@ -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>
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Rejected (@_rejected.Count)</div>
@if (_rejected.Count == 0)
{
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
<p class="px-3 py-2 text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
}
else
{
<table class="table table-sm align-middle">
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<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>
<section class="panel rise" style="animation-delay:.14s">
<div class="panel-head">Trusted (@_trusted.Count)</div>
@if (_trusted.Count == 0)
{
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p>
<p class="px-3 py-2 text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <span class="mono">own/</span> and is not listed here.</p>
}
else
{
<table class="table table-sm align-middle">
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<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 = [];

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>
}
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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)
{

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>"),
};
}

View File

@@ -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>

View File

@@ -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&#10;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> }
</div>
@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>
</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>
}

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 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> }
@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)
{
<table class="table table-sm">
<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 {

View File

@@ -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">

View File

@@ -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) =>

View File

@@ -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)

View File

@@ -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&hellip;</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)
{

View File

@@ -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)

View File

@@ -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 &mdash; 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;

View File

@@ -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)

View File

@@ -14,18 +14,20 @@
<PageTitle>Modbus address preview</PageTitle>
<div class="container py-4">
<h1>Modbus address preview</h1>
<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>
<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>
<h3 class="mt-5">Quick-reference grammar</h3>
<pre class="bg-light p-3 rounded small">@_grammarReference</pre>
</div>
</section>
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Quick-reference grammar</div>
<pre class="small" style="padding:1rem;margin:0;overflow-x:auto">@_grammarReference</pre>
</section>
@code {
private string? _address;

View File

@@ -10,14 +10,13 @@
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
<div class="container py-4">
<h1>Modbus auto-prohibitions</h1>
<h1 class="page-title">Modbus auto-prohibitions</h1>
<p class="text-muted">
Driver instance <code>@DriverInstanceId</code>. Live snapshot of coalesced ranges
Driver instance <span class="mono">@DriverInstanceId</span>. Live snapshot of coalesced ranges
the planner has learned to read individually (#148 / #150 / #151 / #152).
</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,11 +24,12 @@
{
<span class="text-muted ms-3 small">Last refreshed @_lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
}
<span class="spacer"></span>
</div>
@if (_error is not null)
{
<div class="alert alert-danger">@_error</div>
<section class="panel notice rise" style="animation-delay:.02s">@_error</section>
}
else if (_response is null)
{
@@ -37,18 +37,21 @@
}
else if (_response.Count == 0)
{
<div class="alert alert-success">No auto-prohibitions. The planner is coalescing freely.</div>
<section class="panel notice rise" style="animation-delay:.02s">No auto-prohibitions. The planner is coalescing freely.</section>
}
else
{
<table class="table table-sm">
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Prohibited ranges</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<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>
</section>
}
@code {
[Parameter] public string DriverInstanceId { get; set; } = string.Empty;

View File

@@ -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>

View File

@@ -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> }
<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
{
<table class="table table-sm">
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Kind</th><th>Value</th><th>EquipmentUuid</th><th>Cluster</th><th>First published</th><th>Last published</th><th></th></tr></thead>
<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> }
<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
{
<table class="table table-sm">
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Kind</th><th>Value</th><th>Released at</th><th>By</th><th>Reason</th></tr></thead>
<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>

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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:

View File

@@ -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",

View File

@@ -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;
}

View File

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