feat: restyle Admin UI with the technical-light design system

Adopt the technical-light design system across the Admin web UI:

- Vendor theme.css + IBM Plex woff2 fonts into wwwroot; include
  theme.css globally after Bootstrap.
- Rebuild MainLayout: top app-bar (brand mark, breadcrumb, connection
  pill) + hairline-ruled side rail with accent-bordered active link.
- Convert all 33 pages to the component catalog — tables to
  panel + data-table (num/mono columns), KPI cards to agg-grid,
  detail blocks to metric-card/kv rows, badges to chips, alerts to
  panel notice, headings to page-title/panel-head, .rise reveals.
- Buttons/forms stay on Bootstrap; theme.css restyles them via
  --bs-* overrides. View-specific layout lives in app.css; all
  colour/type comes from theme.css tokens.

Also fix a pre-existing /fleet 500: the node-state query ordered on
a property of a constructed FleetNodeRow record, which EF Core
cannot translate. Order the join's columns before projecting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 02:20:09 -04:00
parent 31b9468102
commit 482d5f5637
40 changed files with 1837 additions and 1206 deletions
@@ -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,27 +18,32 @@
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">
<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)
{
<tr>
<td>@a.LdapGroup</td>
<td>@a.ScopeKind</td>
<td><code>@(a.ScopeId ?? "-")</code></td>
<td><code>@a.PermissionFlags</code></td>
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td>
</tr>
}
</tbody>
</table>
<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)
{
<tr>
<td>@a.LdapGroup</td>
<td>@a.ScopeKind</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">
<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>@m.Scope</td>
<td><code>@m.PermissionFlags</code></td>
</tr>
}
</tbody>
</table>
<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><span class="mono">@m.LdapGroup</span></td>
<td>@m.Scope</td>
<td><span class="mono">@m.PermissionFlags</span></td>
</tr>
}
</tbody>
</table>
</div>
}
else if (_probeResult is not null)
{
<div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <code>None</code>.</div>
<div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <span class="mono">None</span>.</div>
}
</div>
</div>
</section>
@if (_showForm)
{
<div class="card">
<section class="panel rise" style="animation-delay:.14s">
<div class="panel-head">Add grant</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">LDAP group</label>
<input class="form-control" @bind="_group"/>
<input class="form-control form-control-sm" @bind="_group"/>
</div>
<div class="col-md-4">
<label class="form-label">Scope kind</label>
<select class="form-select" @bind="_scopeKind">
<select class="form-select form-select-sm" @bind="_scopeKind">
@foreach (var k in Enum.GetValues<NodeAclScopeKind>()) { <option value="@k">@k</option> }
</select>
</div>
<div class="col-md-4">
<label class="form-label">Scope ID (empty for Cluster-wide)</label>
<input class="form-control" @bind="_scopeId"/>
<input class="form-control form-control-sm" @bind="_scopeId"/>
</div>
<div class="col-12">
<label class="form-label">Permissions (bundled presets — per-flag editor in v2.1)</label>
<select class="form-select" @bind="_preset">
<select class="form-select form-select-sm" @bind="_preset">
<option value="Read">Read (Browse + Read)</option>
<option value="WriteOperate">Read + Write Operate</option>
<option value="Engineer">Read + Write Tune + Write Configure</option>
@@ -154,13 +162,13 @@ else
</select>
</div>
</div>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
@if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
<div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
</div>
</section>
}
@code {
@@ -2,28 +2,33 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject AuditLogService AuditSvc
<h4>Recent audit log</h4>
<h4 class="panel-head">Recent audit log</h4>
@if (_entries is null) { <p>Loading…</p> }
else if (_entries.Count == 0) { <p class="text-muted">No audit entries for this cluster yet.</p> }
else
{
<table class="table table-sm">
<thead><tr><th>When</th><th>Principal</th><th>Event</th><th>Node</th><th>Generation</th><th>Details</th></tr></thead>
<tbody>
@foreach (var a in _entries)
{
<tr>
<td>@a.Timestamp.ToString("u")</td>
<td>@a.Principal</td>
<td><code>@a.EventType</code></td>
<td>@a.NodeId</td>
<td>@a.GenerationId</td>
<td><small class="text-muted">@a.DetailsJson</small></td>
</tr>
}
</tbody>
</table>
<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><span class="mono">@a.EventType</span></td>
<td>@a.NodeId</td>
<td class="num">@a.GenerationId</td>
<td><small class="text-muted">@a.DetailsJson</small></td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
@@ -19,16 +19,16 @@ else
{
@if (_liveBanner is not null)
{
<div class="alert alert-info py-2 small">
<section class="panel notice rise" style="animation-delay:.02s">
<strong>Live update:</strong> @_liveBanner
<button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button>
</div>
</section>
}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">@_cluster.Name</h1>
<code class="text-muted">@_cluster.ClusterId</code>
@if (!_cluster.Enabled) { <span class="badge bg-secondary ms-2">Disabled</span> }
<h1 class="page-title mb-0">@_cluster.Name</h1>
<span class="mono text-muted">@_cluster.ClusterId</span>
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
</div>
<div>
@if (_currentDraft is not null)
@@ -59,16 +59,21 @@ else
@if (_tab == "overview")
{
<dl class="row">
<dt class="col-sm-3">Enterprise / Site</dt><dd class="col-sm-9">@_cluster.Enterprise / @_cluster.Site</dd>
<dt class="col-sm-3">Redundancy</dt><dd class="col-sm-9">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</dd>
<dt class="col-sm-3">Current published</dt>
<dd class="col-sm-9">
@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>
<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> }
</span>
</div>
<div class="kv"><span class="k">Created</span><span class="v">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</span></div>
</div>
</section>
}
else if (_tab == "generations")
{
@@ -108,7 +113,7 @@ else
}
else
{
<p class="text-muted">Open a draft to edit this cluster's content.</p>
<section class="panel notice rise" style="animation-delay:.02s">Open a draft to edit this cluster's content.</section>
}
}
@@ -4,7 +4,7 @@
@inject ClusterService ClusterSvc
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Clusters</h1>
<h1 class="page-title">Clusters</h1>
<a href="/clusters/new" class="btn btn-primary">New cluster</a>
</div>
@@ -18,32 +18,37 @@ else if (_clusters.Count == 0)
}
else
{
<table class="table table-hover">
<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>
</tr>
</thead>
<tbody>
@foreach (var c in _clusters)
{
<tr>
<td><code>@c.ClusterId</code></td>
<td>@c.Name</td>
<td>@c.Enterprise</td>
<td>@c.Site</td>
<td>@c.RedundancyMode</td>
<td>@c.NodeCount</td>
<td>
@if (c.Enabled) { <span class="badge bg-success">Active</span> }
else { <span class="badge bg-secondary">Disabled</span> }
</td>
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
</tr>
}
</tbody>
</table>
<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 class="num">NodeCount</th><th>Enabled</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var c in _clusters)
{
<tr>
<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 class="num">@c.NodeCount</td>
<td>
@if (c.Enabled) { <span class="chip chip-ok">Active</span> }
else { <span class="chip chip-idle">Disabled</span> }
</td>
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
@@ -4,49 +4,49 @@
output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the
Blazor render; overflow banner tells operator how many rows were hidden. *@
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<section class="panel rise mb-3" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center">
<div>
<strong>@Title</strong>
<small class="text-muted ms-2">@Description</small>
</div>
<div>
@if (_added > 0) { <span class="badge bg-success me-1">+@_added</span> }
@if (_removed > 0) { <span class="badge bg-danger me-1">@_removed</span> }
@if (_modified > 0) { <span class="badge bg-warning text-dark me-1">~@_modified</span> }
@if (_total == 0) { <span class="badge bg-secondary">no changes</span> }
@if (_added > 0) { <span class="chip chip-ok me-1">+@_added</span> }
@if (_removed > 0) { <span class="chip chip-bad me-1">@_removed</span> }
@if (_modified > 0) { <span class="chip chip-warn me-1">~@_modified</span> }
@if (_total == 0) { <span class="chip chip-idle">no changes</span> }
</div>
</div>
@if (_total == 0)
{
<div class="card-body text-muted small">No changes in this section.</div>
<p class="p-3 text-muted small mb-0">No changes in this section.</p>
}
else
{
@if (_total > RowCap)
{
<div class="alert alert-warning mb-0 small rounded-0">
Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class
diffs. Inspect the remainder via the SQL <code>sp_ComputeGenerationDiff</code> directly.
<div class="p-2 small text-muted border-bottom">
<span class="s-warn">Showing the first @RowCap of @_total rows</span> — cap protects the browser from megabyte-class
diffs. Inspect the remainder via the SQL <span class="mono">sp_ComputeGenerationDiff</span> directly.
</div>
}
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
<table class="data-table">
<thead>
<tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr>
</thead>
<tbody>
@foreach (var r in _visibleRows)
{
<tr>
<td><code>@r.LogicalId</code></td>
<td><span class="mono">@r.LogicalId</span></td>
<td>
@switch (r.ChangeKind)
{
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
case "Added": <span class="chip chip-ok">@r.ChangeKind</span> break;
case "Removed": <span class="chip chip-bad">@r.ChangeKind</span> break;
case "Modified": <span class="chip chip-warn">@r.ChangeKind</span> break;
default: <span class="chip chip-idle">@r.ChangeKind</span> break;
}
</td>
</tr>
@@ -55,7 +55,7 @@
</table>
</div>
}
</div>
</section>
@code {
/// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary>
@@ -6,9 +6,9 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">Draft diff</h1>
<h1 class="page-title mb-0">Draft diff</h1>
<small class="text-muted">
Cluster <code>@ClusterId</code> — from last published (@(_fromLabel)) → to draft @GenerationId
Cluster <span class="mono">@ClusterId</span> — from last published (@(_fromLabel)) → to draft @GenerationId
</small>
</div>
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to editor</a>
@@ -20,7 +20,7 @@
}
else if (_error is not null)
{
<div class="alert alert-danger">@_error</div>
<section class="panel notice rise" style="animation-delay:.02s"><span class="s-bad">@_error</span></section>
}
else if (_rows.Count == 0)
{
@@ -7,8 +7,8 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">Draft editor</h1>
<small class="text-muted">Cluster <code>@ClusterId</code> · generation @GenerationId</small>
<h1 class="page-title mb-0">Draft editor</h1>
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · generation @GenerationId</small>
</div>
<div>
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId">Back to cluster</a>
@@ -36,32 +36,32 @@
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
</div>
<div class="col-md-4">
<div class="card sticky-top">
<div class="card-header d-flex justify-content-between align-items-center">
<section class="panel rise sticky-top" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center">
<strong>Validation</strong>
<button class="btn btn-sm btn-outline-secondary" @onclick="RevalidateAsync">Re-run</button>
</div>
<div class="card-body">
<div class="p-3">
@if (_validating) { <p class="text-muted">Checking…</p> }
else if (_errors.Count == 0) { <div class="alert alert-success mb-0">No validation errors — safe to publish.</div> }
else if (_errors.Count == 0) { <p class="s-ok mb-0">No validation errors — safe to publish.</p> }
else
{
<div class="alert alert-danger mb-2">@_errors.Count error@(_errors.Count == 1 ? "" : "s")</div>
<p class="s-bad mb-2">@_errors.Count error@(_errors.Count == 1 ? "" : "s")</p>
<ul class="list-unstyled">
@foreach (var e in _errors)
{
<li class="mb-2">
<span class="badge bg-danger me-1">@e.Code</span>
<span class="chip chip-bad me-1">@e.Code</span>
<small>@e.Message</small>
@if (!string.IsNullOrEmpty(e.Context)) { <div class="text-muted"><code>@e.Context</code></div> }
@if (!string.IsNullOrEmpty(e.Context)) { <div class="text-muted"><span class="mono">@e.Context</span></div> }
</li>
}
</ul>
}
</div>
</div>
</section>
@if (_publishError is not null) { <div class="alert alert-danger mt-3">@_publishError</div> }
@if (_publishError is not null) { <section class="panel notice rise mt-2" style="animation-delay:.08s"><span class="s-bad">@_publishError</span></section> }
</div>
</div>
@@ -6,7 +6,7 @@
@inject NamespaceService NsSvc
<div class="d-flex justify-content-between mb-3">
<h4>DriverInstances</h4>
<h4 class="panel-head">DriverInstances</h4>
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add driver</button>
</div>
@@ -14,43 +14,49 @@
else if (_drivers.Count == 0) { <p class="text-muted">No drivers configured in this draft.</p> }
else
{
<table class="table table-sm">
<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>@d.Name</td>
<td>
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
{
<a href="/drivers/focas/@d.DriverInstanceId">@d.DriverType</a>
}
else
{
@d.DriverType
}
</td>
<td><code>@d.NamespaceId</code></td>
</tr>
}
</tbody>
</table>
<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><span class="mono">@d.DriverInstanceId</span></td>
<td>@d.Name</td>
<td>
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
{
<a href="/drivers/focas/@d.DriverInstanceId">@d.DriverType</a>
}
else
{
@d.DriverType
}
</td>
<td><span class="mono">@d.NamespaceId</span></td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@if (_showForm && _namespaces is not null)
{
<div class="card">
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Add driver</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Name</label>
<input class="form-control" @bind="_name"/>
<input class="form-control form-control-sm" @bind="_name"/>
</div>
<div class="col-md-3">
<label class="form-label">DriverType</label>
<select class="form-select" @bind="_type">
<select class="form-select form-select-sm" @bind="_type">
<option>Galaxy</option>
<option>Modbus</option>
<option>AbCip</option>
@@ -63,7 +69,7 @@ else
</div>
<div class="col-md-6">
<label class="form-label">Namespace</label>
<select class="form-select" @bind="_nsId">
<select class="form-select form-select-sm" @bind="_nsId">
@foreach (var n in _namespaces) { <option value="@n.NamespaceId">@n.Kind — @n.NamespaceUri</option> }
</select>
</div>
@@ -78,18 +84,18 @@ else
else
{
<label class="form-label">DriverConfig JSON (schemaless per driver type)</label>
<textarea class="form-control font-monospace" rows="6" @bind="_config"></textarea>
<textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_config"></textarea>
<div class="form-text">Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).</div>
}
</div>
</div>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
@if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
<div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
</div>
</section>
}
@code {
@@ -5,7 +5,7 @@
@inject NavigationManager Nav
<div class="d-flex justify-content-between mb-3">
<h4>Equipment (draft gen @GenerationId)</h4>
<h4 class="panel-head">Equipment (draft gen @GenerationId)</h4>
<div>
<button class="btn btn-outline-primary btn-sm me-2" @onclick="GoImport">Import CSV…</button>
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
@@ -22,72 +22,77 @@ else if (_equipment.Count == 0 && !_showForm)
}
else if (_equipment.Count > 0)
{
<table class="table table-sm table-hover">
<thead>
<tr>
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
<th>Manufacturer / Model</th><th>Serial</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var e in _equipment)
{
<tr>
<td><code>@e.EquipmentId</code></td>
<td>@e.Name</td>
<td>@e.MachineCode</td>
<td>@e.ZTag</td>
<td>@e.SAPID</td>
<td>@e.Manufacturer / @e.Model</td>
<td>@e.SerialNumber</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(e)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button>
</td>
</tr>
}
</tbody>
</table>
<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>
<th>Manufacturer / Model</th><th>Serial</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var e in _equipment)
{
<tr>
<td><span class="mono">@e.EquipmentId</span></td>
<td>@e.Name</td>
<td>@e.MachineCode</td>
<td>@e.ZTag</td>
<td>@e.SAPID</td>
<td>@e.Manufacturer / @e.Model</td>
<td>@e.SerialNumber</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(e)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@if (_showForm)
{
<div class="card mt-3">
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@(_editMode ? "Edit equipment" : "New equipment")</div>
<div class="card-body">
<h5>@(_editMode ? "Edit equipment" : "New equipment")</h5>
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form">
<DataAnnotationsValidator/>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Name (UNS segment)</label>
<InputText @bind-Value="_draft.Name" class="form-control"/>
<InputText @bind-Value="_draft.Name" class="form-control form-control-sm"/>
<ValidationMessage For="() => _draft.Name"/>
</div>
<div class="col-md-4">
<label class="form-label">MachineCode</label>
<InputText @bind-Value="_draft.MachineCode" class="form-control"/>
<InputText @bind-Value="_draft.MachineCode" class="form-control form-control-sm"/>
</div>
<div class="col-md-4">
<label class="form-label">DriverInstanceId</label>
<InputText @bind-Value="_draft.DriverInstanceId" class="form-control"/>
<InputText @bind-Value="_draft.DriverInstanceId" class="form-control form-control-sm"/>
</div>
<div class="col-md-4">
<label class="form-label">UnsLineId</label>
<InputText @bind-Value="_draft.UnsLineId" class="form-control"/>
<InputText @bind-Value="_draft.UnsLineId" class="form-control form-control-sm"/>
</div>
<div class="col-md-4">
<label class="form-label">ZTag</label>
<InputText @bind-Value="_draft.ZTag" class="form-control"/>
<InputText @bind-Value="_draft.ZTag" class="form-control form-control-sm"/>
</div>
<div class="col-md-4">
<label class="form-label">SAPID</label>
<InputText @bind-Value="_draft.SAPID" class="form-control"/>
<InputText @bind-Value="_draft.SAPID" class="form-control form-control-sm"/>
</div>
</div>
<IdentificationFields Equipment="_draft"/>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
@if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
@@ -95,7 +100,7 @@ else if (_equipment.Count > 0)
</div>
</EditForm>
</div>
</div>
</section>
}
@code {
@@ -4,43 +4,46 @@
@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">
<thead>
<tr><th>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>@StatusBadge(g.Status)</td>
<td><small>@g.CreatedAt.ToString("u") by @g.CreatedBy</small></td>
<td><small>@(g.PublishedAt?.ToString("u") ?? "-")</small></td>
<td><small>@g.PublishedBy</small></td>
<td><small>@g.Notes</small></td>
<td>
@if (g.Status == GenerationStatus.Draft)
{
<a class="btn btn-sm btn-primary" href="/clusters/@ClusterId/draft/@g.GenerationId">Open</a>
}
else if (g.Status is GenerationStatus.Published or GenerationStatus.Superseded)
{
<button class="btn btn-sm btn-outline-warning" @onclick="() => RollbackAsync(g.GenerationId)">Roll back to this</button>
}
</td>
</tr>
}
</tbody>
</table>
<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 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 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>
<td><small>@g.PublishedBy</small></td>
<td><small>@g.Notes</small></td>
<td>
@if (g.Status == GenerationStatus.Draft)
{
<a class="btn btn-sm btn-primary" href="/clusters/@ClusterId/draft/@g.GenerationId">Open</a>
}
else if (g.Status is GenerationStatus.Published or GenerationStatus.Superseded)
{
<button class="btn btn-sm btn-outline-warning" @onclick="() => RollbackAsync(g.GenerationId)">Roll back to this</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
@if (_error is not null) { <section class="panel notice rise" style="animation-delay:.02s">@_error</section> }
@code {
[Parameter] public string ClusterId { get; set; } = string.Empty;
@@ -65,9 +68,9 @@ else
private static MarkupString StatusBadge(GenerationStatus s) => s switch
{
GenerationStatus.Draft => new MarkupString("<span class='badge bg-info'>Draft</span>"),
GenerationStatus.Published => new MarkupString("<span class='badge bg-success'>Published</span>"),
GenerationStatus.Superseded => new MarkupString("<span class='badge bg-secondary'>Superseded</span>"),
_ => new MarkupString($"<span class='badge bg-light text-dark'>{s}</span>"),
GenerationStatus.Draft => new MarkupString("<span class='chip chip-idle'>Draft</span>"),
GenerationStatus.Published => new MarkupString("<span class='chip chip-ok'>Published</span>"),
GenerationStatus.Superseded => new MarkupString("<span class='chip chip-idle'>Superseded</span>"),
_ => new MarkupString($"<span class='chip chip-idle'>{s}</span>"),
};
}
@@ -4,7 +4,7 @@
nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's
create + edit forms so the same UI renders regardless of which flow opened it. *@
<h6 class="mt-4">OPC 40010 Identification</h6>
<div class="panel-head mt-4">OPC 40010 Identification</div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Manufacturer</label>
@@ -10,37 +10,38 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">Equipment CSV import</h1>
<small class="text-muted">Cluster <code>@ClusterId</code> · draft generation @GenerationId</small>
<h1 class="page-title mb-0">Equipment CSV import</h1>
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · draft generation @GenerationId</small>
</div>
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
</div>
<div class="alert alert-info small mb-3">
Accepts <code>@EquipmentCsvImporter.VersionMarker</code>-headered CSV per Stream B.3.
<section class="panel notice rise" style="animation-delay:.02s">
Accepts <span class="mono">@EquipmentCsvImporter.VersionMarker</span>-headered CSV per Stream B.3.
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
Optional columns cover the OPC 40010 Identification fields. Paste the file contents
or upload directly — the parser runs client-stream-side and shows a row-level preview
before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT
enforced here yet (see task #197); for now the finalise may fail at commit time if a
reservation conflict exists.
</div>
</section>
<div class="alert alert-secondary small mb-3">
<section class="panel notice rise mt-2" style="animation-delay:.08s">
<strong>Per-tag addressing for Modbus drivers</strong> isn't part of equipment import —
tags are configured at the driver-instance level via the
<a href="/clusters/@ClusterId/draft/@GenerationId">Drivers tab</a>. Use the
<a href="/modbus/address-preview" target="_blank">address-preview tool</a> to sanity-check
grammar strings (<code>40001:F:CDAB</code>, <code>HR1:I</code>, <code>V2000</code> for
grammar strings (<span class="mono">40001:F:CDAB</span>, <span class="mono">HR1:I</span>, <span class="mono">V2000</span> for
DL205 family, etc.) before pasting them into the driver config.
</div>
</section>
<div class="card mb-3">
<div class="card-body">
<section class="panel rise mt-2" style="animation-delay:.14s">
<div class="panel-head">Import configuration</div>
<div class="p-3">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Target driver instance (for every accepted row)</label>
<select class="form-select" @bind="_driverInstanceId">
<select class="form-select form-select-sm" @bind="_driverInstanceId">
<option value="">-- select driver --</option>
@if (_drivers is not null)
{
@@ -50,7 +51,7 @@
</div>
<div class="col-md-5">
<label class="form-label">Target UNS line (for every accepted row)</label>
<select class="form-select" @bind="_unsLineId">
<select class="form-select form-select-sm" @bind="_unsLineId">
<option value="">-- select line --</option>
@if (_unsLines is not null)
{
@@ -64,7 +65,7 @@
</div>
<div class="mt-3">
<label class="form-label">CSV content (paste or uploaded)</label>
<textarea class="form-control font-monospace" rows="8" @bind="_csvText"
<textarea class="form-control mono" rows="8" @bind="_csvText"
placeholder="# OtOpcUaCsv v1&#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> }
@if (_parseError is not null) { <span class="chip chip-bad ms-3">@_parseError</span> }
@if (_result is not null) { <span class="chip chip-ok ms-3">@_result</span> }
</div>
</div>
</div>
</section>
@if (_parseResult is not null)
{
<div class="row g-3">
<div class="row g-3 mt-1">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-success text-white">
Accepted (@_parseResult.AcceptedRows.Count)
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head"><span class="s-ok">Accepted (@_parseResult.AcceptedRows.Count)</span></div>
<div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
@if (_parseResult.AcceptedRows.Count == 0)
{
<p class="text-muted p-3 mb-0">No accepted rows.</p>
}
else
{
<table class="table table-sm table-striped mb-0">
<table class="data-table">
<thead>
<tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr>
</thead>
@@ -102,7 +101,7 @@
@foreach (var r in _parseResult.AcceptedRows)
{
<tr>
<td><code>@r.ZTag</code></td>
<td><span class="mono">@r.ZTag</span></td>
<td>@r.MachineCode</td>
<td>@r.Name</td>
<td>@r.UnsLineName</td>
@@ -112,35 +111,33 @@
</table>
}
</div>
</div>
</section>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header bg-danger text-white">
Rejected (@_parseResult.RejectedRows.Count)
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head"><span class="s-bad">Rejected (@_parseResult.RejectedRows.Count)</span></div>
<div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
@if (_parseResult.RejectedRows.Count == 0)
{
<p class="text-muted p-3 mb-0">No rejections.</p>
}
else
{
<table class="table table-sm table-striped mb-0">
<thead><tr><th>Line</th><th>Reason</th></tr></thead>
<table class="data-table">
<thead><tr><th class="num">Line</th><th>Reason</th></tr></thead>
<tbody>
@foreach (var e in _parseResult.RejectedRows)
{
<tr>
<td>@e.LineNumber</td>
<td class="small">@e.Reason</td>
<td class="num">@e.LineNumber</td>
<td><span class="s-bad">@e.Reason</span></td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</section>
</div>
</div>
}
@@ -4,7 +4,7 @@
@inject NamespaceService NsSvc
<div class="d-flex justify-content-between mb-3">
<h4>Namespaces</h4>
<h4 class="panel-head">Namespaces</h4>
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add namespace</button>
</div>
@@ -12,26 +12,37 @@
else if (_namespaces.Count == 0) { <p class="text-muted">No namespaces defined in this draft.</p> }
else
{
<table class="table table-sm">
<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>
}
</tbody>
</table>
<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><span class="mono">@n.NamespaceId</span></td>
<td>@n.Kind</td>
<td>@n.NamespaceUri</td>
<td>@(n.Enabled ? "yes" : "no")</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@if (_showForm)
{
<div class="card">
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Add namespace</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">NamespaceUri</label><input class="form-control" @bind="_uri"/></div>
<div class="col-md-6"><label class="form-label">NamespaceUri</label><input class="form-control form-control-sm" @bind="_uri"/></div>
<div class="col-md-6">
<label class="form-label">Kind</label>
<select class="form-select" @bind="_kind">
<select class="form-select form-select-sm" @bind="_kind">
<option value="@NamespaceKind.Equipment">Equipment</option>
<option value="@NamespaceKind.SystemPlatform">SystemPlatform (Galaxy)</option>
</select>
@@ -42,7 +53,7 @@ else
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
</div>
</section>
}
@code {
@@ -7,7 +7,7 @@
@inject GenerationService GenerationSvc
@inject NavigationManager Nav
<h1 class="mb-4">New cluster</h1>
<h1 class="page-title mb-4">New cluster</h1>
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
<DataAnnotationsValidator/>
@@ -44,7 +44,7 @@
@if (!string.IsNullOrEmpty(_error))
{
<div class="alert alert-danger mt-3">@_error</div>
<section class="panel notice mt-3">@_error</section>
}
<div class="mt-4">
@@ -7,18 +7,18 @@
@inject NavigationManager Nav
@implements IAsyncDisposable
<h4>Redundancy topology</h4>
<h4 class="panel-head">Redundancy topology</h4>
@if (_roleChangedBanner is not null)
{
<div class="alert alert-info small mb-2">@_roleChangedBanner</div>
<section class="panel notice rise" style="animation-delay:.02s">@_roleChangedBanner</section>
}
<p class="text-muted small">
One row per <code>ClusterNode</code> in this cluster. Role, <code>ApplicationUri</code>,
and <code>ServiceLevelBase</code> are authored separately; the Admin UI shows them read-only
One row per <span class="mono">ClusterNode</span> in this cluster. Role, <span class="mono">ApplicationUri</span>,
and <span class="mono">ServiceLevelBase</span> are authored separately; the Admin UI shows them read-only
here so operators can confirm the published topology without touching it. LastSeen older than
@((int)ClusterNodeService.StaleThreshold.TotalSeconds)s is flagged Stale — the node has
stopped heart-beating and is likely down. Role swap goes through the server-side
<code>RedundancyCoordinator</code> apply-lease flow, not direct DB edits.
<span class="mono">RedundancyCoordinator</span> apply-lease flow, not direct DB edits.
</p>
@if (_nodes is null)
@@ -27,10 +27,10 @@
}
else if (_nodes.Count == 0)
{
<div class="alert alert-warning">
<section class="panel notice rise" style="animation-delay:.02s">
No ClusterNode rows for this cluster. The server process needs at least one entry
(with a non-blank <code>ApplicationUri</code>) before it can start up per OPC UA spec.
</div>
(with a non-blank <span class="mono">ApplicationUri</span>) before it can start up per OPC UA spec.
</section>
}
else
{
@@ -39,76 +39,80 @@ 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>
</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">
<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>ApplicationUri</th>
<th>Enabled</th>
<th>Last seen</th>
</tr>
</thead>
<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>
<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>
@if (n.Enabled) { <span class="badge bg-success">Enabled</span> }
else { <span class="badge bg-secondary">Disabled</span> }
</td>
<td class="small @(ClusterNodeService.IsStale(n) ? "text-warning fw-bold" : "")">
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
@if (ClusterNodeService.IsStale(n)) { <span class="badge bg-warning text-dark ms-1">Stale</span> }
</td>
</tr>
}
</tbody>
</table>
<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="num">OPC UA port</th>
<th class="num">ServiceLevel base</th>
<th>ApplicationUri</th>
<th>Enabled</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
@foreach (var n in _nodes)
{
<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="num mono">@n.OpcUaPort</td>
<td class="num">@n.ServiceLevelBase</td>
<td class="mono">@n.ApplicationUri</td>
<td>
@if (n.Enabled) { <span class="chip chip-ok">Enabled</span> }
else { <span class="chip chip-idle">Disabled</span> }
</td>
<td class="@(ClusterNodeService.IsStale(n) ? "s-warn" : "")">
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
@if (ClusterNodeService.IsStale(n)) { <span class="chip chip-warn ms-1">Stale</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
@@ -158,10 +162,10 @@ else
private static string RoleBadge(RedundancyRole r) => r switch
{
RedundancyRole.Primary => "bg-success",
RedundancyRole.Secondary => "bg-info",
RedundancyRole.Standalone => "bg-primary",
_ => "bg-secondary",
RedundancyRole.Primary => "chip-ok",
RedundancyRole.Secondary => "chip-idle",
RedundancyRole.Standalone => "chip-idle",
_ => "chip-idle",
};
private static string FormatAge(DateTime t)
@@ -13,7 +13,7 @@
*@
<div class="script-editor">
<textarea class="form-control font-monospace" rows="14" spellcheck="false"
<textarea class="form-control mono" rows="14" spellcheck="false"
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
</div>
@@ -7,7 +7,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">Scripts</h4>
<h4 class="panel-head mb-0">Scripts</h4>
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
</div>
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
@@ -18,7 +18,7 @@
@if (_loading) { <p class="text-muted">Loading…</p> }
else if (_scripts.Count == 0 && _editing is null)
{
<div class="alert alert-info">No scripts yet in this draft.</div>
<section class="panel notice rise" style="animation-delay:.02s">No scripts yet in this draft.</section>
}
else
{
@@ -30,7 +30,7 @@ else
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
@onclick="() => Open(s)">
<strong>@s.Name</strong>
<div class="small text-muted font-monospace">@s.ScriptId</div>
<div class="small text-muted mono">@s.ScriptId</div>
</button>
}
</div>
@@ -38,8 +38,8 @@ else
<div class="col-md-8">
@if (_editing is not null)
{
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center">
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
<div>
@if (!_isNew)
@@ -49,10 +49,10 @@ else
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
</div>
</div>
<div class="card-body">
<div class="p-3">
<div class="mb-2">
<label class="form-label">Name</label>
<input class="form-control" @bind="_editing.Name"/>
<input class="form-control form-control-sm" @bind="_editing.Name"/>
</div>
<label class="form-label">Source</label>
<ScriptEditor @bind-Source="_editing.SourceCode"/>
@@ -70,7 +70,7 @@ else
else
{
<ul class="mb-1">
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
@foreach (var r in _dependencies.Reads) { <li><span class="mono">@r</span></li> }
</ul>
}
<strong>Inferred writes</strong>
@@ -78,17 +78,17 @@ else
else
{
<ul class="mb-1">
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
@foreach (var w in _dependencies.Writes) { <li><span class="mono">@w</span></li> }
</ul>
}
@if (_dependencies.Rejections.Count > 0)
{
<div class="alert alert-danger mt-2">
<section class="panel notice mt-2">
<strong>Non-literal paths rejected:</strong>
<ul class="mb-0">
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
@foreach (var r in _dependencies.Rejections) { <li><span class="s-bad">@r.Message</span></li> }
</ul>
</div>
</section>
}
</div>
}
@@ -96,24 +96,24 @@ else
@if (_testResult is not null)
{
<div class="mt-3 border-top pt-3">
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
<strong>Harness result:</strong> <span class="chip chip-idle">@_testResult.Outcome</span>
@if (_testResult.Outcome == ScriptTestOutcome.Success)
{
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
<div>Output: <span class="mono">@(_testResult.Output?.ToString() ?? "null")</span></div>
@if (_testResult.Writes.Count > 0)
{
<div class="mt-1"><strong>Writes:</strong>
<ul class="mb-0">
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
@foreach (var kv in _testResult.Writes) { <li><span class="mono">@kv.Key</span> = <span class="mono">@(kv.Value?.ToString() ?? "null")</span></li> }
</ul>
</div>
}
}
@if (_testResult.Errors.Count > 0)
{
<div class="alert alert-warning mt-2 mb-0">
@foreach (var e in _testResult.Errors) { <div>@e</div> }
</div>
<section class="panel notice mt-2 mb-0">
@foreach (var e in _testResult.Errors) { <div><span class="s-warn">@e</span></div> }
</section>
}
@if (_testResult.LogEvents.Count > 0)
{
@@ -126,7 +126,7 @@ else
</div>
}
</div>
</div>
</section>
}
</div>
</div>
@@ -15,15 +15,13 @@
a generic JSON textarea, matching the DriversTab pattern from #147.
*@
<div class="d-flex justify-content-between mb-3">
<h4>Tags (draft gen @GenerationId)</h4>
<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">
<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="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,42 +31,45 @@
}
}
</select>
<span class="spacer"></span>
@if (_tags is not null) { <span class="tb-count">@_tags.Count tags</span> }
</div>
</div>
@if (_tags is null) { <p>Loading…</p> }
else if (_tags.Count == 0 && !_showForm) { <p class="text-muted">No tags in this filter.</p> }
else if (_tags.Count > 0)
{
<table class="table table-sm">
<thead>
<tr><th>Name</th><th>Driver</th><th>Equipment</th><th>DataType</th><th>Access</th><th>TagConfig</th><th></th></tr>
</thead>
<tbody>
@foreach (var t in _tags)
{
<tr>
<td>@t.Name</td>
<td><code>@t.DriverInstanceId</code></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>
<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>
</td>
</tr>
}
</tbody>
</table>
}
@if (_tags is null) { <p class="p-3">Loading…</p> }
else if (_tags.Count == 0 && !_showForm) { <p class="p-3 text-muted">No tags in this filter.</p> }
else if (_tags.Count > 0)
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr><th>Name</th><th>Driver</th><th>Equipment</th><th>DataType</th><th>Access</th><th>TagConfig</th><th></th></tr>
</thead>
<tbody>
@foreach (var t in _tags)
{
<tr>
<td>@t.Name</td>
<td><span class="mono">@t.DriverInstanceId</span></td>
<td>@(t.EquipmentId ?? "—")</td>
<td>@t.DataType</td>
<td>@t.AccessLevel</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>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
@if (_showForm)
{
<div class="card mt-3">
<div class="card-body">
<h5>@(_editMode ? "Edit tag" : "New tag")</h5>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@(_editMode ? "Edit tag" : "New tag")</div>
<div class="p-3">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Name</label>
@@ -176,14 +177,14 @@ else if (_tags.Count > 0)
}
}
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
@if (_error is not null) { <section class="panel notice mt-3"><span class="s-bad">@_error</span></section> }
<div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="Cancel">Cancel</button>
</div>
</div>
</div>
</section>
}
@code {
@@ -2,100 +2,104 @@
@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>
<button class="btn btn-sm btn-primary" @onclick="() => _showAreaForm = true">Add area</button>
</div>
<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> }
else
{
<table class="table table-sm">
<thead><tr><th>AreaId</th><th>Name</th><th class="small text-muted">(drop target)</th></tr></thead>
<tbody>
@foreach (var a in _areas)
{
<tr class="@(_hoverAreaId == a.UnsAreaId ? "table-primary" : "")"
@ondragover="e => OnAreaDragOver(e, a.UnsAreaId)"
@ondragover:preventDefault
@ondragleave="() => _hoverAreaId = null"
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
@ondrop:preventDefault>
<td><code>@a.UnsAreaId</code></td>
<td>@a.Name</td>
<td class="small text-muted">drop here</td>
</tr>
}
</tbody>
</table>
}
@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
{
<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)
{
<tr class="@(_hoverAreaId == a.UnsAreaId ? "table-primary" : "")"
@ondragover="e => OnAreaDragOver(e, a.UnsAreaId)"
@ondragover:preventDefault
@ondragleave="() => _hoverAreaId = null"
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
@ondrop:preventDefault>
<td><span class="mono">@a.UnsAreaId</span></td>
<td>@a.Name</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>
@if (_showAreaForm)
{
<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>
<button class="btn btn-sm btn-primary" @onclick="() => _showLineForm = true" disabled="@(_areas is null || _areas.Count == 0)">Add line</button>
</div>
<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> }
else
{
<table class="table table-sm">
<thead><tr><th>LineId</th><th>Area</th><th>Name</th></tr></thead>
<tbody>
@foreach (var l in _lines)
{
<tr draggable="true"
@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>@l.Name</td>
</tr>
}
</tbody>
</table>
}
@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
{
<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)
{
<tr draggable="true"
@ondragstart="() => _dragLineId = l.UnsLineId"
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
style="cursor: grab;">
<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">
@if (_showLineForm && _areas is not null)
{
<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">