Phase 1 Stream E Admin UI — finish Blazor pages so operators can run the draft → publish → rollback workflow end-to-end without hand-executing SQL. Adds eight new scoped services that wrap the Configuration stored procs + managed validators: EquipmentService (CRUD with auto-derived EquipmentId per decision #125), UnsService (areas + lines), NamespaceService, DriverInstanceService (generic JSON DriverConfig editor per decision #94 — per-driver schema validation lands in each driver's phase), NodeAclService (grant + revoke with bundled-preset permission sets; full per-flag editor + bulk-grant + permission simulator deferred to v2.1), ReservationService (fleet-wide active + released reservation inspector + FleetAdmin-only sp_ReleaseExternalIdReservation wrapper with required-reason invariant), DraftValidationService (hydrates a DraftSnapshot from the draft's rows plus prior-cluster Equipment + active reservations, runs the managed DraftValidator to surface every rule in one pass for inline validation panel), AuditLogService (recent ConfigAuditLog reader). Pages: /clusters list with create-new shortcut; /clusters/new wizard that creates the cluster row + initial empty draft in one go; /clusters/{id} detail with 8 tabs (Overview / Generations / Equipment / UNS Structure / Namespaces / Drivers / ACLs / Audit) — tabs that write always target the active draft, published generations stay read-only; /clusters/{id}/draft/{gen} editor with live validation panel (errors list with stable code + message + context; publish button disabled while any error exists) and tab-embedded sub-components; /clusters/{id}/draft/{gen}/diff three-column view backed by sp_ComputeGenerationDiff with Added/Removed/Modified badges; Generations tab with per-row rollback action wired to sp_RollbackToGeneration; /reservations FleetAdmin-only page (CanPublish policy) with active + released lists and a modal release dialog that enforces non-empty reason and round-trips through sp_ReleaseExternalIdReservation; /login scaffold with stub credential accept + FleetAdmin-role cookie issuance (real LDAP bind via the ScadaLink-parity LdapAuthService is deferred until live GLAuth integration — marked in the login view and in the Phase 1 partial-exit TODO). Layout: sidebar gets Overview / Clusters / Reservations + AuthorizeView with signed-in username + roles + sign-out POST to /auth/logout; cascading authentication state registered for <AuthorizeView> to work in RenderMode.InteractiveServer. Integration testing: AdminServicesIntegrationTests creates a throwaway per-run database (same pattern as the Configuration test fixture), applies all three migrations, and exercises (1) create-cluster → add-namespace+UNS+driver+equipment → validate (expects zero errors) → publish (expects Published status) → rollback (expects one new Published + at least one Superseded); (2) cross-cluster namespace binding draft → validates to BadCrossClusterNamespaceBinding per decision #122. Old flat Components/Pages/Clusters.razor moved to Components/Pages/Clusters/ClustersList.razor so the Clusters folder can host tab sub-components without the razor generator creating a type-and-namespace collision. Dev appsettings.json connection string switched from Integrated Security to sa auth to match the otopcua-mssql container on port 14330 (remapped from 1433 to coexist with the native MSSQL14 Galaxy ZB instance). Browser smoke test completed: home page, clusters list, new-cluster form, cluster detail with a seeded row, reservations (redirected to login for anon user) all return 200 / 302-to-login as expected; full solution 928 pass / 1 pre-existing Phase 0 baseline failure. Phase 1 Stream E items explicitly deferred with TODOs: CSV import for Equipment, SignalR FleetStatusHub + AlertHub real-time push, bulk-grant workflow, permission-simulator trie, merge-equipment draft, AppServer-via-OI-Gateway end-to-end smoke test (decision #142), and the real LDAP bind replacing the Login page stub.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject NodeAclService AclSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Access-control grants</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add grant</button>
|
||||
</div>
|
||||
|
||||
@if (_acls is null) { <p>Loading…</p> }
|
||||
else if (_acls.Count == 0) { <p class="text-muted">No ACL grants in this draft. Publish will result in a cluster with no external access.</p> }
|
||||
else
|
||||
{
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card">
|
||||
<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"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Scope kind</label>
|
||||
<select class="form-select" @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"/>
|
||||
</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">
|
||||
<option value="Read">Read (Browse + Read)</option>
|
||||
<option value="WriteOperate">Read + Write Operate</option>
|
||||
<option value="Engineer">Read + Write Tune + Write Configure</option>
|
||||
<option value="AlarmAck">Read + Alarm Ack</option>
|
||||
<option value="Full">Full (every flag)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
<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>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<NodeAcl>? _acls;
|
||||
private bool _showForm;
|
||||
private string _group = string.Empty;
|
||||
private NodeAclScopeKind _scopeKind = NodeAclScopeKind.Cluster;
|
||||
private string _scopeId = string.Empty;
|
||||
private string _preset = "Read";
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
private NodePermissions ResolvePreset() => _preset switch
|
||||
{
|
||||
"Read" => NodePermissions.Browse | NodePermissions.Read,
|
||||
"WriteOperate" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate,
|
||||
"Engineer" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune | NodePermissions.WriteConfigure,
|
||||
"AlarmAck" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge,
|
||||
"Full" => unchecked((NodePermissions)(-1)),
|
||||
_ => NodePermissions.Browse | NodePermissions.Read,
|
||||
};
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
if (string.IsNullOrWhiteSpace(_group)) { _error = "LDAP group is required"; return; }
|
||||
|
||||
var scopeId = _scopeKind == NodeAclScopeKind.Cluster ? null
|
||||
: string.IsNullOrWhiteSpace(_scopeId) ? null : _scopeId;
|
||||
|
||||
if (_scopeKind != NodeAclScopeKind.Cluster && scopeId is null)
|
||||
{
|
||||
_error = $"ScopeId required for {_scopeKind}";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await AclSvc.GrantAsync(GenerationId, ClusterId, _group, _scopeKind, scopeId,
|
||||
ResolvePreset(), notes: null, CancellationToken.None);
|
||||
_group = string.Empty; _scopeId = string.Empty;
|
||||
_showForm = false;
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task RevokeAsync(Guid rowId)
|
||||
{
|
||||
await AclSvc.RevokeAsync(rowId, CancellationToken.None);
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject AuditLogService AuditSvc
|
||||
|
||||
<h4>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>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<ConfigAuditLog>? _entries;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_entries = await AuditSvc.ListRecentAsync(ClusterId, limit: 100, CancellationToken.None);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
@page "/clusters/{ClusterId}"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@if (_cluster is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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> }
|
||||
</div>
|
||||
<div>
|
||||
@if (_currentDraft is not null)
|
||||
{
|
||||
<a href="/clusters/@ClusterId/draft/@_currentDraft.GenerationId" class="btn btn-outline-primary">
|
||||
Edit current draft (gen @_currentDraft.GenerationId)
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="CreateDraftAsync" disabled="@_busy">New draft</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button class="nav-link @Tab("overview")" @onclick='() => _tab = "overview"'>Overview</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("generations")" @onclick='() => _tab = "generations"'>Generations</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("uns")" @onclick='() => _tab = "uns"'>UNS Structure</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
|
||||
</ul>
|
||||
|
||||
@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>
|
||||
}
|
||||
else if (_tab == "generations")
|
||||
{
|
||||
<Generations ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "equipment" && _currentDraft is not null)
|
||||
{
|
||||
<EquipmentTab GenerationId="@_currentDraft.GenerationId"/>
|
||||
}
|
||||
else if (_tab == "uns" && _currentDraft is not null)
|
||||
{
|
||||
<UnsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "namespaces" && _currentDraft is not null)
|
||||
{
|
||||
<NamespacesTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "drivers" && _currentDraft is not null)
|
||||
{
|
||||
<DriversTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "acls" && _currentDraft is not null)
|
||||
{
|
||||
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "audit")
|
||||
{
|
||||
<AuditTab ClusterId="@ClusterId"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">Open a draft to edit this cluster's content.</p>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private ServerCluster? _cluster;
|
||||
private ConfigGeneration? _currentDraft;
|
||||
private ConfigGeneration? _currentPublished;
|
||||
private string _tab = "overview";
|
||||
private bool _busy;
|
||||
|
||||
private string Tab(string key) => _tab == key ? "active" : string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_cluster = await ClusterSvc.FindAsync(ClusterId, CancellationToken.None);
|
||||
var gens = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None);
|
||||
_currentDraft = gens.FirstOrDefault(g => g.Status == GenerationStatus.Draft);
|
||||
_currentPublished = gens.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
}
|
||||
|
||||
private async Task CreateDraftAsync()
|
||||
{
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: "admin-ui", CancellationToken.None);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}");
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
@page "/clusters"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject ClusterService ClusterSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Clusters</h1>
|
||||
<a href="/clusters/new" class="btn btn-primary">New cluster</a>
|
||||
</div>
|
||||
|
||||
@if (_clusters is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_clusters.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No clusters yet. Create the first one.</p>
|
||||
}
|
||||
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>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _clusters;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/diff"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject GenerationService GenerationSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Draft diff</h1>
|
||||
<small class="text-muted">
|
||||
Cluster <code>@ClusterId</code> — from last published (@(_fromLabel)) → to draft @GenerationId
|
||||
</small>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to editor</a>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Computing diff…</p>
|
||||
}
|
||||
else if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No differences — draft is structurally identical to the last published generation.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover table-sm">
|
||||
<thead><tr><th>Table</th><th>LogicalId</th><th>ChangeKind</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.TableName</td>
|
||||
<td><code>@r.LogicalId</code></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;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private List<DiffRow>? _rows;
|
||||
private string _fromLabel = "(empty)";
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var all = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None);
|
||||
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
||||
_rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
@page "/clusters/{ClusterId}/draft/{GenerationId:long}"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject DraftValidationService ValidationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId">Back to cluster</a>
|
||||
<a class="btn btn-outline-primary ms-2" href="/clusters/@ClusterId/draft/@GenerationId/diff">View diff</a>
|
||||
<button class="btn btn-primary ms-2" disabled="@(_errors.Count != 0 || _busy)" @onclick="PublishAsync">Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button class="nav-link @Active("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("uns")" @onclick='() => _tab = "uns"'>UNS</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId"/> }
|
||||
else if (_tab == "uns") { <UnsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "acls") { <AclsTab 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">
|
||||
<strong>Validation</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="RevalidateAsync">Re-run</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@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
|
||||
{
|
||||
<div class="alert alert-danger mb-2">@_errors.Count error@(_errors.Count == 1 ? "" : "s")</div>
|
||||
<ul class="list-unstyled">
|
||||
@foreach (var e in _errors)
|
||||
{
|
||||
<li class="mb-2">
|
||||
<span class="badge bg-danger me-1">@e.Code</span>
|
||||
<small>@e.Message</small>
|
||||
@if (!string.IsNullOrEmpty(e.Context)) { <div class="text-muted"><code>@e.Context</code></div> }
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_publishError is not null) { <div class="alert alert-danger mt-3">@_publishError</div> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private string _tab = "equipment";
|
||||
private List<ValidationError> _errors = [];
|
||||
private bool _validating;
|
||||
private bool _busy;
|
||||
private string? _publishError;
|
||||
|
||||
private string Active(string k) => _tab == k ? "active" : string.Empty;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await RevalidateAsync();
|
||||
|
||||
private async Task RevalidateAsync()
|
||||
{
|
||||
_validating = true;
|
||||
try
|
||||
{
|
||||
var errors = await ValidationSvc.ValidateAsync(GenerationId, CancellationToken.None);
|
||||
_errors = errors.ToList();
|
||||
}
|
||||
finally { _validating = false; }
|
||||
}
|
||||
|
||||
private async Task PublishAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_publishError = null;
|
||||
try
|
||||
{
|
||||
await GenerationSvc.PublishAsync(ClusterId, GenerationId, notes: "Published via Admin UI", CancellationToken.None);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}");
|
||||
}
|
||||
catch (Exception ex) { _publishError = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject DriverInstanceService DriverSvc
|
||||
@inject NamespaceService NsSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>DriverInstances</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add driver</button>
|
||||
</div>
|
||||
|
||||
@if (_drivers is null) { <p>Loading…</p> }
|
||||
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>@d.DriverType</td><td><code>@d.NamespaceId</code></td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm && _namespaces is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<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"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">DriverType</label>
|
||||
<select class="form-select" @bind="_type">
|
||||
<option>Galaxy</option>
|
||||
<option>ModbusTcp</option>
|
||||
<option>AbCip</option>
|
||||
<option>AbLegacy</option>
|
||||
<option>S7</option>
|
||||
<option>Focas</option>
|
||||
<option>OpcUaClient</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Namespace</label>
|
||||
<select class="form-select" @bind="_nsId">
|
||||
@foreach (var n in _namespaces) { <option value="@n.NamespaceId">@n.Kind — @n.NamespaceUri</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">DriverConfig JSON (schemaless per driver type)</label>
|
||||
<textarea class="form-control 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> }
|
||||
<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>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<Namespace>? _namespaces;
|
||||
private bool _showForm;
|
||||
private string _name = string.Empty;
|
||||
private string _type = "ModbusTcp";
|
||||
private string _nsId = string.Empty;
|
||||
private string _config = "{}";
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_nsId = _namespaces.FirstOrDefault()?.NamespaceId ?? string.Empty;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_nsId))
|
||||
{
|
||||
_error = "Name and Namespace are required";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, _config, CancellationToken.None);
|
||||
_name = string.Empty; _config = "{}";
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@inject EquipmentService EquipmentSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Equipment (draft gen @GenerationId)</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||
</div>
|
||||
|
||||
@if (_equipment is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_equipment.Count == 0 && !_showForm)
|
||||
{
|
||||
<p class="text-muted">No equipment in this draft yet.</p>
|
||||
}
|
||||
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-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>New equipment</h5>
|
||||
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="new-equipment">
|
||||
<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"/>
|
||||
<ValidationMessage For="() => _draft.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">MachineCode</label>
|
||||
<InputText @bind-Value="_draft.MachineCode" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DriverInstanceId</label>
|
||||
<InputText @bind-Value="_draft.DriverInstanceId" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">UnsLineId</label>
|
||||
<InputText @bind-Value="_draft.UnsLineId" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">ZTag</label>
|
||||
<InputText @bind-Value="_draft.ZTag" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">SAPID</label>
|
||||
<InputText @bind-Value="_draft.SAPID" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4">OPC 40010 Identification</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><label class="form-label">Manufacturer</label><InputText @bind-Value="_draft.Manufacturer" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Model</label><InputText @bind-Value="_draft.Model" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Serial number</label><InputText @bind-Value="_draft.SerialNumber" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Hardware rev</label><InputText @bind-Value="_draft.HardwareRevision" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Software rev</label><InputText @bind-Value="_draft.SoftwareRevision" class="form-control"/></div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Year of construction</label>
|
||||
<InputNumber @bind-Value="_draft.YearOfConstruction" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
private List<Equipment>? _equipment;
|
||||
private bool _showForm;
|
||||
private Equipment _draft = NewBlankDraft();
|
||||
private string? _error;
|
||||
|
||||
private static Equipment NewBlankDraft() => new()
|
||||
{
|
||||
EquipmentId = string.Empty, DriverInstanceId = string.Empty,
|
||||
UnsLineId = string.Empty, Name = string.Empty, MachineCode = string.Empty,
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void StartAdd()
|
||||
{
|
||||
_draft = NewBlankDraft();
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
_draft.EquipmentUuid = Guid.NewGuid();
|
||||
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
|
||||
_draft.GenerationId = GenerationId;
|
||||
try
|
||||
{
|
||||
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await EquipmentSvc.DeleteAsync(id, CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@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>
|
||||
}
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<ConfigGeneration>? _generations;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync() =>
|
||||
_generations = await GenerationSvc.ListRecentAsync(ClusterId, 100, CancellationToken.None);
|
||||
|
||||
private async Task RollbackAsync(long targetId)
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await GenerationSvc.RollbackAsync(ClusterId, targetId, notes: $"Rollback via Admin UI", CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
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>"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject NamespaceService NsSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Namespaces</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add namespace</button>
|
||||
</div>
|
||||
|
||||
@if (_namespaces is null) { <p>Loading…</p> }
|
||||
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>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card">
|
||||
<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">Kind</label>
|
||||
<select class="form-select" @bind="_kind">
|
||||
<option value="@NamespaceKind.Equipment">Equipment</option>
|
||||
<option value="@NamespaceKind.SystemPlatform">SystemPlatform (Galaxy)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<Namespace>? _namespaces;
|
||||
private bool _showForm;
|
||||
private string _uri = string.Empty;
|
||||
private NamespaceKind _kind = NamespaceKind.Equipment;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync() =>
|
||||
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_uri)) return;
|
||||
await NsSvc.AddAsync(GenerationId, ClusterId, _uri, _kind, CancellationToken.None);
|
||||
_uri = string.Empty;
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
@page "/clusters/new"
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<h1 class="mb-4">New cluster</h1>
|
||||
|
||||
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">ClusterId <span class="text-danger">*</span></label>
|
||||
<InputText @bind-Value="_input.ClusterId" class="form-control"/>
|
||||
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.</div>
|
||||
<ValidationMessage For="() => _input.ClusterId"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Display name <span class="text-danger">*</span></label>
|
||||
<InputText @bind-Value="_input.Name" class="form-control"/>
|
||||
<ValidationMessage For="() => _input.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Enterprise</label>
|
||||
<InputText @bind-Value="_input.Enterprise" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Site</label>
|
||||
<InputText @bind-Value="_input.Site" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Redundancy</label>
|
||||
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select">
|
||||
<option value="@RedundancyMode.None">None (single node)</option>
|
||||
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
|
||||
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_error))
|
||||
{
|
||||
<div class="alert alert-danger mt-3">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_submitting">Create cluster</button>
|
||||
<a href="/clusters" class="btn btn-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private sealed class Input
|
||||
{
|
||||
[Required, RegularExpression("^[a-z0-9-]{1,64}$", ErrorMessage = "Lowercase alphanumeric + hyphens only")]
|
||||
public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(32)] public string Enterprise { get; set; } = "zb";
|
||||
[StringLength(32)] public string Site { get; set; } = "dev";
|
||||
public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None;
|
||||
}
|
||||
|
||||
private Input _input = new();
|
||||
private bool _submitting;
|
||||
private string? _error;
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
_submitting = true;
|
||||
_error = null;
|
||||
|
||||
try
|
||||
{
|
||||
var cluster = new ServerCluster
|
||||
{
|
||||
ClusterId = _input.ClusterId,
|
||||
Name = _input.Name,
|
||||
Enterprise = _input.Enterprise,
|
||||
Site = _input.Site,
|
||||
RedundancyMode = _input.RedundancyMode,
|
||||
NodeCount = _input.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2,
|
||||
Enabled = true,
|
||||
CreatedBy = "admin-ui",
|
||||
};
|
||||
|
||||
await ClusterSvc.CreateAsync(cluster, createdBy: "admin-ui", CancellationToken.None);
|
||||
await GenerationSvc.CreateDraftAsync(cluster.ClusterId, createdBy: "admin-ui", CancellationToken.None);
|
||||
|
||||
Nav.NavigateTo($"/clusters/{cluster.ClusterId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally { _submitting = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject UnsService UnsSvc
|
||||
|
||||
<div class="row">
|
||||
<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>
|
||||
|
||||
@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></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _areas)
|
||||
{
|
||||
<tr><td><code>@a.UnsAreaId</code></td><td>@a.Name</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@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>
|
||||
<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>
|
||||
}
|
||||
</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>
|
||||
|
||||
@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><td><code>@l.UnsLineId</code></td><td><code>@l.UnsAreaId</code></td><td>@l.Name</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showLineForm && _areas is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Area</label>
|
||||
<select class="form-select" @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>
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<UnsArea>? _areas;
|
||||
private List<UnsLine>? _lines;
|
||||
private bool _showAreaForm;
|
||||
private bool _showLineForm;
|
||||
private string _newAreaName = string.Empty;
|
||||
private string _newLineName = string.Empty;
|
||||
private string _newLineAreaId = string.Empty;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_areas = await UnsSvc.ListAreasAsync(GenerationId, CancellationToken.None);
|
||||
_lines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AddAreaAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newAreaName)) return;
|
||||
await UnsSvc.AddAreaAsync(GenerationId, ClusterId, _newAreaName, notes: null, CancellationToken.None);
|
||||
_newAreaName = string.Empty;
|
||||
_showAreaForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private async Task AddLineAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newLineName) || string.IsNullOrWhiteSpace(_newLineAreaId)) return;
|
||||
await UnsSvc.AddLineAsync(GenerationId, _newLineAreaId, _newLineName, notes: null, CancellationToken.None);
|
||||
_newLineName = string.Empty;
|
||||
_showLineForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user