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:
15
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AuditLogService.cs
Normal file
15
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AuditLogService.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class AuditLogService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ConfigAuditLog>> ListRecentAsync(string? clusterId, int limit, CancellationToken ct)
|
||||
{
|
||||
var q = db.ConfigAuditLogs.AsNoTracking();
|
||||
if (clusterId is not null) q = q.Where(a => a.ClusterId == clusterId);
|
||||
return q.OrderByDescending(a => a.Timestamp).Take(limit).ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the managed <see cref="DraftValidator"/> against a draft's snapshot loaded from the
|
||||
/// Configuration DB. Used by the draft editor's inline validation panel and by the publish
|
||||
/// dialog's pre-check. Structural-only SQL checks live in <c>sp_ValidateDraft</c>; this layer
|
||||
/// owns the content / cross-generation / regex rules.
|
||||
/// </summary>
|
||||
public sealed class DraftValidationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<ValidationError>> ValidateAsync(long draftId, CancellationToken ct)
|
||||
{
|
||||
var draft = await db.ConfigGenerations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(g => g.GenerationId == draftId, ct)
|
||||
?? throw new InvalidOperationException($"Draft {draftId} not found");
|
||||
|
||||
var snapshot = new DraftSnapshot
|
||||
{
|
||||
GenerationId = draft.GenerationId,
|
||||
ClusterId = draft.ClusterId,
|
||||
Namespaces = await db.Namespaces.AsNoTracking().Where(n => n.GenerationId == draftId).ToListAsync(ct),
|
||||
DriverInstances = await db.DriverInstances.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct),
|
||||
Devices = await db.Devices.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct),
|
||||
UnsAreas = await db.UnsAreas.AsNoTracking().Where(a => a.GenerationId == draftId).ToListAsync(ct),
|
||||
UnsLines = await db.UnsLines.AsNoTracking().Where(l => l.GenerationId == draftId).ToListAsync(ct),
|
||||
Equipment = await db.Equipment.AsNoTracking().Where(e => e.GenerationId == draftId).ToListAsync(ct),
|
||||
Tags = await db.Tags.AsNoTracking().Where(t => t.GenerationId == draftId).ToListAsync(ct),
|
||||
PollGroups = await db.PollGroups.AsNoTracking().Where(p => p.GenerationId == draftId).ToListAsync(ct),
|
||||
|
||||
PriorEquipment = await db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId != draftId
|
||||
&& db.ConfigGenerations.Any(g => g.GenerationId == e.GenerationId && g.ClusterId == draft.ClusterId))
|
||||
.ToListAsync(ct),
|
||||
ActiveReservations = await db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt == null)
|
||||
.ToListAsync(ct),
|
||||
};
|
||||
|
||||
return DraftValidator.Validate(snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class DriverInstanceService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<DriverInstance>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.GenerationId == generationId)
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<DriverInstance> AddAsync(
|
||||
long draftId, string clusterId, string namespaceId, string name, string driverType,
|
||||
string driverConfigJson, CancellationToken ct)
|
||||
{
|
||||
var di = new DriverInstance
|
||||
{
|
||||
GenerationId = draftId,
|
||||
DriverInstanceId = $"drv-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
NamespaceId = namespaceId,
|
||||
Name = name,
|
||||
DriverType = driverType,
|
||||
DriverConfig = driverConfigJson,
|
||||
};
|
||||
db.DriverInstances.Add(di);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return di;
|
||||
}
|
||||
}
|
||||
75
src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs
Normal file
75
src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Equipment CRUD scoped to a generation. The Admin app writes against Draft generations only;
|
||||
/// Published generations are read-only (to create changes, clone to a new draft via
|
||||
/// <see cref="GenerationService.CreateDraftAsync"/>).
|
||||
/// </summary>
|
||||
public sealed class EquipmentService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Equipment>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId == generationId)
|
||||
.OrderBy(e => e.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<Equipment?> FindAsync(long generationId, string equipmentId, CancellationToken ct) =>
|
||||
db.Equipment.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new equipment row in the given draft. The EquipmentId is auto-derived from
|
||||
/// a fresh EquipmentUuid per decision #125; operator-supplied IDs are rejected upstream.
|
||||
/// </summary>
|
||||
public async Task<Equipment> CreateAsync(long draftId, Equipment input, CancellationToken ct)
|
||||
{
|
||||
input.GenerationId = draftId;
|
||||
input.EquipmentUuid = input.EquipmentUuid == Guid.Empty ? Guid.NewGuid() : input.EquipmentUuid;
|
||||
input.EquipmentId = DraftValidator.DeriveEquipmentId(input.EquipmentUuid);
|
||||
db.Equipment.Add(input);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return input;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Equipment updated, CancellationToken ct)
|
||||
{
|
||||
// Only editable fields are persisted; EquipmentId + EquipmentUuid are immutable once set.
|
||||
var existing = await db.Equipment
|
||||
.FirstOrDefaultAsync(e => e.EquipmentRowId == updated.EquipmentRowId, ct)
|
||||
?? throw new InvalidOperationException($"Equipment row {updated.EquipmentRowId} not found");
|
||||
|
||||
existing.Name = updated.Name;
|
||||
existing.MachineCode = updated.MachineCode;
|
||||
existing.ZTag = updated.ZTag;
|
||||
existing.SAPID = updated.SAPID;
|
||||
existing.Manufacturer = updated.Manufacturer;
|
||||
existing.Model = updated.Model;
|
||||
existing.SerialNumber = updated.SerialNumber;
|
||||
existing.HardwareRevision = updated.HardwareRevision;
|
||||
existing.SoftwareRevision = updated.SoftwareRevision;
|
||||
existing.YearOfConstruction = updated.YearOfConstruction;
|
||||
existing.AssetLocation = updated.AssetLocation;
|
||||
existing.ManufacturerUri = updated.ManufacturerUri;
|
||||
existing.DeviceManualUri = updated.DeviceManualUri;
|
||||
existing.DriverInstanceId = updated.DriverInstanceId;
|
||||
existing.DeviceId = updated.DeviceId;
|
||||
existing.UnsLineId = updated.UnsLineId;
|
||||
existing.EquipmentClassRef = updated.EquipmentClassRef;
|
||||
existing.Enabled = updated.Enabled;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid equipmentRowId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentRowId == equipmentRowId, ct);
|
||||
if (row is null) return;
|
||||
db.Equipment.Remove(row);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
31
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NamespaceService.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NamespaceService.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class NamespaceService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Namespace>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Namespaces.AsNoTracking()
|
||||
.Where(n => n.GenerationId == generationId)
|
||||
.OrderBy(n => n.NamespaceId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<Namespace> AddAsync(
|
||||
long draftId, string clusterId, string namespaceUri, NamespaceKind kind, CancellationToken ct)
|
||||
{
|
||||
var ns = new Namespace
|
||||
{
|
||||
GenerationId = draftId,
|
||||
NamespaceId = $"ns-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
NamespaceUri = namespaceUri,
|
||||
Kind = kind,
|
||||
};
|
||||
db.Namespaces.Add(ns);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return ns;
|
||||
}
|
||||
}
|
||||
44
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs
Normal file
44
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class NodeAclService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<NodeAcl>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.NodeAcls.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.LdapGroup)
|
||||
.ThenBy(a => a.ScopeKind)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<NodeAcl> GrantAsync(
|
||||
long draftId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId,
|
||||
NodePermissions permissions, string? notes, CancellationToken ct)
|
||||
{
|
||||
var acl = new NodeAcl
|
||||
{
|
||||
GenerationId = draftId,
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
LdapGroup = ldapGroup,
|
||||
ScopeKind = scopeKind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = permissions,
|
||||
Notes = notes,
|
||||
};
|
||||
db.NodeAcls.Add(acl);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return acl;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(Guid nodeAclRowId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.NodeAcls.FirstOrDefaultAsync(a => a.NodeAclRowId == nodeAclRowId, ct);
|
||||
if (row is null) return;
|
||||
db.NodeAcls.Remove(row);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
38
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ReservationService.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ReservationService.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fleet-wide external-ID reservation inspector + FleetAdmin-only release flow per
|
||||
/// <c>admin-ui.md §"Release an external-ID reservation"</c>. Release is audit-logged
|
||||
/// (<see cref="ConfigAuditLog"/>) via <c>sp_ReleaseExternalIdReservation</c>.
|
||||
/// </summary>
|
||||
public sealed class ReservationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ExternalIdReservation>> ListActiveAsync(CancellationToken ct) =>
|
||||
db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt == null)
|
||||
.OrderBy(r => r.Kind).ThenBy(r => r.Value)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<List<ExternalIdReservation>> ListReleasedAsync(CancellationToken ct) =>
|
||||
db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt != null)
|
||||
.OrderByDescending(r => r.ReleasedAt)
|
||||
.Take(100)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task ReleaseAsync(string kind, string value, string reason, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
throw new ArgumentException("ReleaseReason is required (audit invariant)", nameof(reason));
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_ReleaseExternalIdReservation @Kind = {0}, @Value = {1}, @ReleaseReason = {2}",
|
||||
[kind, value, reason],
|
||||
ct);
|
||||
}
|
||||
}
|
||||
50
src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
50
src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class UnsService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<UnsArea>> ListAreasAsync(long generationId, CancellationToken ct) =>
|
||||
db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<List<UnsLine>> ListLinesAsync(long generationId, CancellationToken ct) =>
|
||||
db.UnsLines.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<UnsArea> AddAreaAsync(long draftId, string clusterId, string name, string? notes, CancellationToken ct)
|
||||
{
|
||||
var area = new UnsArea
|
||||
{
|
||||
GenerationId = draftId,
|
||||
UnsAreaId = $"area-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
};
|
||||
db.UnsAreas.Add(area);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return area;
|
||||
}
|
||||
|
||||
public async Task<UnsLine> AddLineAsync(long draftId, string unsAreaId, string name, string? notes, CancellationToken ct)
|
||||
{
|
||||
var line = new UnsLine
|
||||
{
|
||||
GenerationId = draftId,
|
||||
UnsLineId = $"line-{Guid.NewGuid():N}"[..20],
|
||||
UnsAreaId = unsAreaId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
};
|
||||
db.UnsLines.Add(line);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return line;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user