chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin SignalR push helper for ACL + role-grant invalidation — slice 2 of task #196.
|
||||
/// Lets the Admin services + razor pages invalidate connected peers' views without each
|
||||
/// one having to know the hub wiring. Two message kinds: <c>NodeAclChanged</c> (cluster-scoped)
|
||||
/// and <c>RoleGrantsChanged</c> (fleet-wide — role mappings cross cluster boundaries).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intentionally fire-and-forget — a failed hub send doesn't rollback the DB write that
|
||||
/// triggered it. Worst-case an operator sees stale data until their next poll or manual
|
||||
/// refresh; better than a transient hub blip blocking the authoritative write path.
|
||||
/// </remarks>
|
||||
public sealed class AclChangeNotifier(IHubContext<FleetStatusHub> fleetHub, ILogger<AclChangeNotifier> logger)
|
||||
{
|
||||
public async Task NotifyNodeAclChangedAsync(string clusterId, long generationId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = new NodeAclChangedMessage(ClusterId: clusterId, GenerationId: generationId, ObservedAtUtc: DateTime.UtcNow);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.GroupName(clusterId))
|
||||
.SendAsync("NodeAclChanged", msg, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "NodeAclChanged push failed for cluster {ClusterId} gen {GenerationId}", clusterId, generationId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyRoleGrantsChangedAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = new RoleGrantsChangedMessage(ObservedAtUtc: DateTime.UtcNow);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
|
||||
.SendAsync("RoleGrantsChanged", msg, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "RoleGrantsChanged push failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NodeAclChangedMessage(string ClusterId, long GenerationId, DateTime ObservedAtUtc);
|
||||
public sealed record RoleGrantsChangedMessage(DateTime ObservedAtUtc);
|
||||
16
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
16
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The three admin roles per <c>admin-ui.md</c> §"Admin Roles" — mapped from LDAP groups at
|
||||
/// sign-in. Each role has a fixed set of capabilities (cluster CRUD, draft → publish, fleet
|
||||
/// admin). The ACL-driven runtime permissions (<c>NodePermissions</c>) govern OPC UA clients;
|
||||
/// these roles govern the Admin UI itself.
|
||||
/// </summary>
|
||||
public static class AdminRoles
|
||||
{
|
||||
public const string ConfigViewer = "ConfigViewer";
|
||||
public const string ConfigEditor = "ConfigEditor";
|
||||
public const string FleetAdmin = "FleetAdmin";
|
||||
|
||||
public static IReadOnlyList<string> All => [ConfigViewer, ConfigEditor, FleetAdmin];
|
||||
}
|
||||
@@ -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,22 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Points the Admin UI at the OPC UA Server's PKI store root so
|
||||
/// <see cref="CertTrustService"/> can list and move certs between the
|
||||
/// <c>rejected/</c> and <c>trusted/</c> directories the server maintains. Must match the
|
||||
/// <c>OpcUaServer:PkiStoreRoot</c> the Server process is configured with.
|
||||
/// </summary>
|
||||
public sealed class CertTrustOptions
|
||||
{
|
||||
public const string SectionName = "CertTrust";
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the PKI root. Defaults to
|
||||
/// <c>%ProgramData%\OtOpcUa\pki</c> — matches <c>OpcUaServerOptions.PkiStoreRoot</c>'s
|
||||
/// default so a standard side-by-side install needs no override.
|
||||
/// </summary>
|
||||
public string PkiStoreRoot { get; init; } =
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"OtOpcUa", "pki");
|
||||
}
|
||||
135
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
135
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
|
||||
/// <see cref="FilePath"/> is the absolute path of the DER/CRT file the stack created when it
|
||||
/// rejected the cert (for <see cref="CertStoreKind.Rejected"/>) or when an operator trusted
|
||||
/// it (for <see cref="CertStoreKind.Trusted"/>).
|
||||
/// </summary>
|
||||
public sealed record CertInfo(
|
||||
string Thumbprint,
|
||||
string Subject,
|
||||
string Issuer,
|
||||
DateTime NotBefore,
|
||||
DateTime NotAfter,
|
||||
string FilePath,
|
||||
CertStoreKind Store);
|
||||
|
||||
public enum CertStoreKind
|
||||
{
|
||||
Rejected,
|
||||
Trusted,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
|
||||
/// Directory-typed store — each cert is a <c>.der</c> file under <c>{root}/{store}/certs/</c>
|
||||
/// with a filename derived from subject + thumbprint. This service exposes operators for the
|
||||
/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
|
||||
/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Admin process is separate from the Server process; this service deliberately has no
|
||||
/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
|
||||
/// host even when the Server isn't installed locally, as long as the PKI root is reachable
|
||||
/// (typical deployment has Admin + Server side-by-side on the same machine).
|
||||
///
|
||||
/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
|
||||
/// the Directory store on each new incoming connection, so there's no explicit signal
|
||||
/// needed — the next client handshake picks up the change. Operators should retry the
|
||||
/// rejected client's connection after trusting.
|
||||
/// </remarks>
|
||||
public sealed class CertTrustService
|
||||
{
|
||||
private readonly CertTrustOptions _options;
|
||||
private readonly ILogger<CertTrustService> _logger;
|
||||
|
||||
public CertTrustService(IOptions<CertTrustOptions> options, ILogger<CertTrustService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string PkiStoreRoot => _options.PkiStoreRoot;
|
||||
|
||||
public IReadOnlyList<CertInfo> ListRejected() => ListStore(CertStoreKind.Rejected);
|
||||
public IReadOnlyList<CertInfo> ListTrusted() => ListStore(CertStoreKind.Trusted);
|
||||
|
||||
/// <summary>
|
||||
/// Move the cert with <paramref name="thumbprint"/> from the rejected store to the
|
||||
/// trusted store. No-op returns false if the rejected file doesn't exist (already moved
|
||||
/// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
|
||||
/// silently — idempotent.
|
||||
/// </summary>
|
||||
public bool TrustRejected(string thumbprint)
|
||||
{
|
||||
var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
|
||||
if (cert is null) return false;
|
||||
|
||||
var trustedDir = CertsDir(CertStoreKind.Trusted);
|
||||
Directory.CreateDirectory(trustedDir);
|
||||
var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
|
||||
File.Move(cert.FilePath, destPath, overwrite: true);
|
||||
_logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
|
||||
cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
|
||||
public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
|
||||
|
||||
private bool DeleteFromStore(CertStoreKind store, string thumbprint)
|
||||
{
|
||||
var cert = FindInStore(store, thumbprint);
|
||||
if (cert is null) return false;
|
||||
File.Delete(cert.FilePath);
|
||||
_logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
|
||||
cert.Thumbprint, cert.Subject, store);
|
||||
return true;
|
||||
}
|
||||
|
||||
private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
|
||||
ListStore(store).FirstOrDefault(c =>
|
||||
string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private IReadOnlyList<CertInfo> ListStore(CertStoreKind store)
|
||||
{
|
||||
var dir = CertsDir(store);
|
||||
if (!Directory.Exists(dir)) return [];
|
||||
|
||||
var results = new List<CertInfo>();
|
||||
foreach (var path in Directory.EnumerateFiles(dir))
|
||||
{
|
||||
// Skip CRL sidecars + private-key files — trust operations only concern public certs.
|
||||
var ext = Path.GetExtension(path);
|
||||
if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
|
||||
!ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
|
||||
!ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cert = X509CertificateLoader.LoadCertificateFromFile(path);
|
||||
results.Add(new CertInfo(
|
||||
cert.Thumbprint, cert.Subject, cert.Issuer,
|
||||
cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
|
||||
path, store));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A malformed file in the store shouldn't take down the page. Surface it in logs
|
||||
// but skip — operators see the other certs and can clean the bad file manually.
|
||||
_logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private string CertsDir(CertStoreKind store) =>
|
||||
Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for ClusterNode rows + their cluster-scoped redundancy view. Consumed
|
||||
/// by the RedundancyTab on the cluster detail page. Writes (role swap, node enable/disable)
|
||||
/// are not supported here — role swap happens through the RedundancyCoordinator apply-lease
|
||||
/// flow on the server side and would conflict with any direct DB mutation from Admin.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Stale-threshold matching <c>HostStatusService.StaleThreshold</c> — 30s of clock
|
||||
/// tolerance covers a missed heartbeat plus publisher GC pauses.</summary>
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
public Task<List<ClusterNode>> ListByClusterAsync(string clusterId, CancellationToken ct) =>
|
||||
db.ClusterNodes.AsNoTracking()
|
||||
.Where(n => n.ClusterId == clusterId)
|
||||
.OrderByDescending(n => n.ServiceLevelBase)
|
||||
.ThenBy(n => n.NodeId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public static bool IsStale(ClusterNode node) =>
|
||||
node.LastSeenAt is null || DateTime.UtcNow - node.LastSeenAt.Value > StaleThreshold;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster CRUD surface used by the Blazor pages. Writes go through stored procs in later
|
||||
/// phases; Phase 1 reads via EF Core directly (DENY SELECT on <c>dbo</c> schema means this
|
||||
/// service connects as a DB owner during dev — production swaps in a read-only view grant).
|
||||
/// </summary>
|
||||
public sealed class ClusterService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ServerCluster>> ListAsync(CancellationToken ct) =>
|
||||
db.ServerClusters.AsNoTracking().OrderBy(c => c.ClusterId).ToListAsync(ct);
|
||||
|
||||
public Task<ServerCluster?> FindAsync(string clusterId, CancellationToken ct) =>
|
||||
db.ServerClusters.AsNoTracking().FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
|
||||
|
||||
public async Task<ServerCluster> CreateAsync(ServerCluster cluster, string createdBy, CancellationToken ct)
|
||||
{
|
||||
cluster.CreatedAt = DateTime.UtcNow;
|
||||
cluster.CreatedBy = createdBy;
|
||||
db.ServerClusters.Add(cluster);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return cluster;
|
||||
}
|
||||
}
|
||||
@@ -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,61 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #154 — Admin-side client for the Server's driver-diagnostics HTTP endpoints. Wraps
|
||||
/// <see cref="HttpClient"/> so Blazor pages can fetch per-driver runtime state from a
|
||||
/// remote Server process. The base URL is configured at registration time
|
||||
/// (typically read from <c>appsettings.json</c> at startup).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One client instance per Server endpoint. Multi-server deployments register multiple
|
||||
/// keyed clients. Errors propagate as exceptions; pages catch and surface to the
|
||||
/// operator rather than swallowing.
|
||||
/// </remarks>
|
||||
public sealed class DriverDiagnosticsClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public DriverDiagnosticsClient(HttpClient http) => _http = http;
|
||||
|
||||
/// <summary>
|
||||
/// Fetch the current Modbus auto-prohibition list for the named driver instance.
|
||||
/// Returns null when the Server reports the driver doesn't exist or isn't a Modbus
|
||||
/// driver. Throws on transport / serialization failures.
|
||||
/// </summary>
|
||||
public async Task<ModbusAutoProhibitionsResponse?> GetModbusAutoProhibitedRangesAsync(
|
||||
string driverInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
var resp = await _http.GetAsync(
|
||||
$"/diagnostics/drivers/{Uri.EscapeDataString(driverInstanceId)}/modbus/auto-prohibited", ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (resp.StatusCode is System.Net.HttpStatusCode.NotFound or System.Net.HttpStatusCode.BadRequest)
|
||||
return null;
|
||||
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return await resp.Content.ReadFromJsonAsync<ModbusAutoProhibitionsResponse>(cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server response shape for the Modbus auto-prohibition diagnostic. Mirrors the JSON the
|
||||
/// <c>HealthEndpointsHost</c> serialises; fields are flat strings/numbers so the
|
||||
/// Admin-side client doesn't take a dependency on the Driver.Modbus assembly's
|
||||
/// <c>ModbusAutoProhibition</c> record.
|
||||
/// </summary>
|
||||
public sealed class ModbusAutoProhibitionsResponse
|
||||
{
|
||||
public string DriverInstanceId { get; set; } = string.Empty;
|
||||
public int Count { get; set; }
|
||||
public List<ModbusAutoProhibitionRow> Ranges { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ModbusAutoProhibitionRow
|
||||
{
|
||||
public byte UnitId { get; set; }
|
||||
public string Region { get; set; } = string.Empty;
|
||||
public ushort StartAddress { get; set; }
|
||||
public ushort EndAddress { get; set; }
|
||||
public DateTime LastProbedUtc { get; set; }
|
||||
public bool BisectionPending { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4180 CSV parser for equipment import per decision #95 and Phase 6.4 Stream B.1.
|
||||
/// Produces a validated <see cref="EquipmentCsvParseResult"/> the caller (CSV import
|
||||
/// modal + staging tables) consumes. Pure-parser concern — no DB access, no staging
|
||||
/// writes; those live in the follow-up Stream B.2 work.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Header contract</b>: line 1 must be exactly <c># OtOpcUaCsv v1</c> (version
|
||||
/// marker). Line 2 is the column header row. Unknown columns are rejected; required
|
||||
/// columns must all be present. The version bump handshake lets future shapes parse
|
||||
/// without ambiguity — v2 files go through a different parser variant.</para>
|
||||
///
|
||||
/// <para><b>Required columns</b> per decision #117: ZTag, MachineCode, SAPID,
|
||||
/// EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName.</para>
|
||||
///
|
||||
/// <para><b>Optional columns</b> per decision #139: Manufacturer, Model, SerialNumber,
|
||||
/// HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation,
|
||||
/// ManufacturerUri, DeviceManualUri.</para>
|
||||
///
|
||||
/// <para><b>Row validation</b>: blank required field → rejected; duplicate ZTag within
|
||||
/// the same file → rejected. Duplicate against the DB isn't detected here — the
|
||||
/// staged-import finalize step (Stream B.4) catches that.</para>
|
||||
/// </remarks>
|
||||
public static class EquipmentCsvImporter
|
||||
{
|
||||
public const string VersionMarker = "# OtOpcUaCsv v1";
|
||||
|
||||
public static IReadOnlyList<string> RequiredColumns { get; } = new[]
|
||||
{
|
||||
"ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid",
|
||||
"Name", "UnsAreaName", "UnsLineName",
|
||||
};
|
||||
|
||||
public static IReadOnlyList<string> OptionalColumns { get; } = new[]
|
||||
{
|
||||
"Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri",
|
||||
};
|
||||
|
||||
public static EquipmentCsvParseResult Parse(string csvText)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(csvText);
|
||||
|
||||
var rows = SplitLines(csvText);
|
||||
if (rows.Count == 0)
|
||||
throw new InvalidCsvFormatException("CSV is empty.");
|
||||
|
||||
if (!string.Equals(rows[0].Trim(), VersionMarker, StringComparison.Ordinal))
|
||||
throw new InvalidCsvFormatException(
|
||||
$"CSV header line 1 must be exactly '{VersionMarker}' — got '{rows[0]}'. " +
|
||||
"Files without the version marker are rejected so future-format files don't parse ambiguously.");
|
||||
|
||||
if (rows.Count < 2)
|
||||
throw new InvalidCsvFormatException("CSV has no column header row (line 2) or data rows.");
|
||||
|
||||
var headerCells = SplitCsvRow(rows[1]);
|
||||
ValidateHeader(headerCells);
|
||||
|
||||
var accepted = new List<EquipmentCsvRow>();
|
||||
var rejected = new List<EquipmentCsvRowError>();
|
||||
var ztagsSeen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var colIndex = headerCells
|
||||
.Select((name, idx) => (name, idx))
|
||||
.ToDictionary(t => t.name, t => t.idx, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 2; i < rows.Count; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rows[i])) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var cells = SplitCsvRow(rows[i]);
|
||||
if (cells.Length != headerCells.Length)
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(
|
||||
LineNumber: i + 1,
|
||||
Reason: $"Column count {cells.Length} != header count {headerCells.Length}."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var row = BuildRow(cells, colIndex);
|
||||
var missing = RequiredColumns.Where(c => string.IsNullOrWhiteSpace(GetCell(row, c))).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(i + 1, $"Blank required column(s): {string.Join(", ", missing)}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ztagsSeen.Add(row.ZTag))
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(i + 1, $"Duplicate ZTag '{row.ZTag}' within file."));
|
||||
continue;
|
||||
}
|
||||
|
||||
accepted.Add(row);
|
||||
}
|
||||
catch (InvalidCsvFormatException ex)
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(i + 1, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return new EquipmentCsvParseResult(accepted, rejected);
|
||||
}
|
||||
|
||||
private static void ValidateHeader(string[] headerCells)
|
||||
{
|
||||
var seen = new HashSet<string>(headerCells, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Missing required
|
||||
var missingRequired = RequiredColumns.Where(r => !seen.Contains(r)).ToList();
|
||||
if (missingRequired.Count > 0)
|
||||
throw new InvalidCsvFormatException($"Header is missing required column(s): {string.Join(", ", missingRequired)}");
|
||||
|
||||
// Unknown columns (not in required ∪ optional)
|
||||
var known = new HashSet<string>(RequiredColumns.Concat(OptionalColumns), StringComparer.OrdinalIgnoreCase);
|
||||
var unknown = headerCells.Where(c => !known.Contains(c)).ToList();
|
||||
if (unknown.Count > 0)
|
||||
throw new InvalidCsvFormatException(
|
||||
$"Header has unknown column(s): {string.Join(", ", unknown)}. " +
|
||||
"Bump the version marker to define a new shape before adding columns.");
|
||||
|
||||
// Duplicates
|
||||
var dupe = headerCells.GroupBy(c => c, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
|
||||
if (dupe is not null)
|
||||
throw new InvalidCsvFormatException($"Header has duplicate column '{dupe.Key}'.");
|
||||
}
|
||||
|
||||
private static EquipmentCsvRow BuildRow(string[] cells, Dictionary<string, int> colIndex) => new()
|
||||
{
|
||||
ZTag = cells[colIndex["ZTag"]],
|
||||
MachineCode = cells[colIndex["MachineCode"]],
|
||||
SAPID = cells[colIndex["SAPID"]],
|
||||
EquipmentId = cells[colIndex["EquipmentId"]],
|
||||
EquipmentUuid = cells[colIndex["EquipmentUuid"]],
|
||||
Name = cells[colIndex["Name"]],
|
||||
UnsAreaName = cells[colIndex["UnsAreaName"]],
|
||||
UnsLineName = cells[colIndex["UnsLineName"]],
|
||||
Manufacturer = colIndex.TryGetValue("Manufacturer", out var mi) ? cells[mi] : null,
|
||||
Model = colIndex.TryGetValue("Model", out var moi) ? cells[moi] : null,
|
||||
SerialNumber = colIndex.TryGetValue("SerialNumber", out var si) ? cells[si] : null,
|
||||
HardwareRevision = colIndex.TryGetValue("HardwareRevision", out var hi) ? cells[hi] : null,
|
||||
SoftwareRevision = colIndex.TryGetValue("SoftwareRevision", out var swi) ? cells[swi] : null,
|
||||
YearOfConstruction = colIndex.TryGetValue("YearOfConstruction", out var yi) ? cells[yi] : null,
|
||||
AssetLocation = colIndex.TryGetValue("AssetLocation", out var ai) ? cells[ai] : null,
|
||||
ManufacturerUri = colIndex.TryGetValue("ManufacturerUri", out var mui) ? cells[mui] : null,
|
||||
DeviceManualUri = colIndex.TryGetValue("DeviceManualUri", out var dui) ? cells[dui] : null,
|
||||
};
|
||||
|
||||
private static string GetCell(EquipmentCsvRow row, string colName) => colName switch
|
||||
{
|
||||
"ZTag" => row.ZTag,
|
||||
"MachineCode" => row.MachineCode,
|
||||
"SAPID" => row.SAPID,
|
||||
"EquipmentId" => row.EquipmentId,
|
||||
"EquipmentUuid" => row.EquipmentUuid,
|
||||
"Name" => row.Name,
|
||||
"UnsAreaName" => row.UnsAreaName,
|
||||
"UnsLineName" => row.UnsLineName,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>Split the raw text on line boundaries. Handles \r\n + \n + \r.</summary>
|
||||
private static List<string> SplitLines(string csv) =>
|
||||
csv.Split(["\r\n", "\n", "\r"], StringSplitOptions.None).ToList();
|
||||
|
||||
/// <summary>Split one CSV row with RFC 4180 quoted-field handling.</summary>
|
||||
private static string[] SplitCsvRow(string row)
|
||||
{
|
||||
var cells = new List<string>();
|
||||
var sb = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var i = 0; i < row.Length; i++)
|
||||
{
|
||||
var ch = row[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
// Escaped quote "" inside quoted field.
|
||||
if (i + 1 < row.Length && row[i + 1] == '"')
|
||||
{
|
||||
sb.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ch == ',')
|
||||
{
|
||||
cells.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
else if (ch == '"' && sb.Length == 0)
|
||||
{
|
||||
inQuotes = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells.Add(sb.ToString());
|
||||
return cells.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One parsed equipment row with required + optional fields.</summary>
|
||||
public sealed class EquipmentCsvRow
|
||||
{
|
||||
// Required (decision #117)
|
||||
public required string ZTag { get; init; }
|
||||
public required string MachineCode { get; init; }
|
||||
public required string SAPID { get; init; }
|
||||
public required string EquipmentId { get; init; }
|
||||
public required string EquipmentUuid { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string UnsAreaName { get; init; }
|
||||
public required string UnsLineName { get; init; }
|
||||
|
||||
// Optional (decision #139 — OPC 40010 Identification fields)
|
||||
public string? Manufacturer { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public string? SerialNumber { get; init; }
|
||||
public string? HardwareRevision { get; init; }
|
||||
public string? SoftwareRevision { get; init; }
|
||||
public string? YearOfConstruction { get; init; }
|
||||
public string? AssetLocation { get; init; }
|
||||
public string? ManufacturerUri { get; init; }
|
||||
public string? DeviceManualUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One row-level rejection captured by the parser. Line-number is 1-based in the source file.</summary>
|
||||
public sealed record EquipmentCsvRowError(int LineNumber, string Reason);
|
||||
|
||||
/// <summary>Parser output — accepted rows land in staging; rejected rows surface in the preview modal.</summary>
|
||||
public sealed record EquipmentCsvParseResult(
|
||||
IReadOnlyList<EquipmentCsvRow> AcceptedRows,
|
||||
IReadOnlyList<EquipmentCsvRowError> RejectedRows);
|
||||
|
||||
/// <summary>Thrown for file-level format problems (missing version marker, bad header, etc.).</summary>
|
||||
public sealed class InvalidCsvFormatException(string message) : Exception(message);
|
||||
@@ -0,0 +1,324 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Staged-import orchestrator per Phase 6.4 Stream B.2-B.4. Covers the four operator
|
||||
/// actions: CreateBatch → StageRows (chunked) → FinaliseBatch (atomic apply into
|
||||
/// <see cref="Equipment"/>) → DropBatch (rollback of pre-finalise state).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>FinaliseBatch runs inside one EF transaction + bulk-inserts accepted rows into
|
||||
/// <see cref="Equipment"/>. Rejected rows stay behind as audit evidence; the batch row
|
||||
/// gains <see cref="EquipmentImportBatch.FinalisedAtUtc"/> so future writes know it's
|
||||
/// archived. DropBatch removes the batch + its cascaded rows.</para>
|
||||
///
|
||||
/// <para>Idempotence: calling FinaliseBatch twice throws <see cref="ImportBatchAlreadyFinalisedException"/>
|
||||
/// rather than double-inserting. Operator refreshes the admin page to see the first
|
||||
/// finalise completed.</para>
|
||||
///
|
||||
/// <para>ExternalIdReservation merging (ZTag + SAPID uniqueness) is NOT done here — a
|
||||
/// narrower follow-up wires it once the concurrent-insert test matrix is green.</para>
|
||||
/// </remarks>
|
||||
public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Create a new empty batch header. Returns the row with Id populated.</summary>
|
||||
public async Task<EquipmentImportBatch> CreateBatchAsync(string clusterId, string createdBy, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(createdBy);
|
||||
|
||||
var batch = new EquipmentImportBatch
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClusterId = clusterId,
|
||||
CreatedBy = createdBy,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
db.EquipmentImportBatches.Add(batch);
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
return batch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stage one chunk of rows into the batch. Caller usually feeds
|
||||
/// <see cref="EquipmentCsvImporter.Parse"/> output here — each
|
||||
/// <see cref="EquipmentCsvRow"/> becomes one accepted <see cref="EquipmentImportRow"/>,
|
||||
/// each rejected parser error becomes one row with <see cref="EquipmentImportRow.IsAccepted"/> false.
|
||||
/// </summary>
|
||||
public async Task StageRowsAsync(
|
||||
Guid batchId,
|
||||
IReadOnlyList<EquipmentCsvRow> acceptedRows,
|
||||
IReadOnlyList<EquipmentCsvRowError> rejectedRows,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false)
|
||||
?? throw new ImportBatchNotFoundException($"Batch {batchId} not found.");
|
||||
|
||||
if (batch.FinalisedAtUtc is not null)
|
||||
throw new ImportBatchAlreadyFinalisedException(
|
||||
$"Batch {batchId} finalised at {batch.FinalisedAtUtc:o}; no more rows can be staged.");
|
||||
|
||||
foreach (var row in acceptedRows)
|
||||
{
|
||||
db.EquipmentImportRows.Add(new EquipmentImportRow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
IsAccepted = true,
|
||||
ZTag = row.ZTag,
|
||||
MachineCode = row.MachineCode,
|
||||
SAPID = row.SAPID,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = row.EquipmentUuid,
|
||||
Name = row.Name,
|
||||
UnsAreaName = row.UnsAreaName,
|
||||
UnsLineName = row.UnsLineName,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
SerialNumber = row.SerialNumber,
|
||||
HardwareRevision = row.HardwareRevision,
|
||||
SoftwareRevision = row.SoftwareRevision,
|
||||
YearOfConstruction = row.YearOfConstruction,
|
||||
AssetLocation = row.AssetLocation,
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var error in rejectedRows)
|
||||
{
|
||||
db.EquipmentImportRows.Add(new EquipmentImportRow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
IsAccepted = false,
|
||||
RejectReason = error.Reason,
|
||||
LineNumberInFile = error.LineNumber,
|
||||
// Required columns need values for EF; reject rows use sentinel placeholders.
|
||||
ZTag = "", MachineCode = "", SAPID = "", EquipmentId = "", EquipmentUuid = "",
|
||||
Name = "", UnsAreaName = "", UnsLineName = "",
|
||||
});
|
||||
}
|
||||
|
||||
batch.RowsStaged += acceptedRows.Count + rejectedRows.Count;
|
||||
batch.RowsAccepted += acceptedRows.Count;
|
||||
batch.RowsRejected += rejectedRows.Count;
|
||||
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Drop the batch (pre-finalise rollback). Cascaded row delete removes staged rows.</summary>
|
||||
public async Task DropBatchAsync(Guid batchId, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false);
|
||||
if (batch is null) return;
|
||||
if (batch.FinalisedAtUtc is not null)
|
||||
throw new ImportBatchAlreadyFinalisedException(
|
||||
$"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}; cannot drop.");
|
||||
|
||||
db.EquipmentImportBatches.Remove(batch);
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic finalise. Inserts every accepted row into the live
|
||||
/// <see cref="Equipment"/> table under the target generation + stamps
|
||||
/// <see cref="EquipmentImportBatch.FinalisedAtUtc"/>. Failure rolls the whole tx
|
||||
/// back — <see cref="Equipment"/> never partially mutates.
|
||||
/// </summary>
|
||||
public async Task FinaliseBatchAsync(
|
||||
Guid batchId, long generationId, string driverInstanceIdForRows, string unsLineIdForRows, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.EquipmentImportBatches
|
||||
.Include(b => b.Rows)
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId, ct)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new ImportBatchNotFoundException($"Batch {batchId} not found.");
|
||||
|
||||
if (batch.FinalisedAtUtc is not null)
|
||||
throw new ImportBatchAlreadyFinalisedException(
|
||||
$"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}.");
|
||||
|
||||
// EF InMemory provider doesn't honour BeginTransaction; SQL Server provider does.
|
||||
// Tests run the happy path under in-memory; production SQL Server runs the atomic tx.
|
||||
var supportsTx = db.Database.IsRelational();
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (supportsTx)
|
||||
tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Snapshot active reservations that overlap this batch's ZTag + SAPID set — one
|
||||
// round-trip instead of N. Released rows (ReleasedAt IS NOT NULL) are ignored so
|
||||
// an explicitly-released value can be reused.
|
||||
var accepted = batch.Rows.Where(r => r.IsAccepted).ToList();
|
||||
var zTags = accepted.Where(r => !string.IsNullOrWhiteSpace(r.ZTag))
|
||||
.Select(r => r.ZTag).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var sapIds = accepted.Where(r => !string.IsNullOrWhiteSpace(r.SAPID))
|
||||
.Select(r => r.SAPID).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var existingReservations = await db.ExternalIdReservations
|
||||
.Where(r => r.ReleasedAt == null &&
|
||||
((r.Kind == ReservationKind.ZTag && zTags.Contains(r.Value)) ||
|
||||
(r.Kind == ReservationKind.SAPID && sapIds.Contains(r.Value))))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
var resByKey = existingReservations.ToDictionary(
|
||||
r => (r.Kind, r.Value.ToLowerInvariant()),
|
||||
r => r);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var firstPublishedBy = batch.CreatedBy;
|
||||
|
||||
foreach (var row in accepted)
|
||||
{
|
||||
var equipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid();
|
||||
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = generationId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = equipmentUuid,
|
||||
DriverInstanceId = driverInstanceIdForRows,
|
||||
UnsLineId = unsLineIdForRows,
|
||||
Name = row.Name,
|
||||
MachineCode = row.MachineCode,
|
||||
ZTag = row.ZTag,
|
||||
SAPID = row.SAPID,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
SerialNumber = row.SerialNumber,
|
||||
HardwareRevision = row.HardwareRevision,
|
||||
SoftwareRevision = row.SoftwareRevision,
|
||||
YearOfConstruction = short.TryParse(row.YearOfConstruction, out var y) ? y : null,
|
||||
AssetLocation = row.AssetLocation,
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
});
|
||||
|
||||
MergeReservation(row.ZTag, ReservationKind.ZTag, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
MergeReservation(row.SAPID, ReservationKind.SAPID, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
}
|
||||
|
||||
batch.FinalisedAtUtc = nowUtc;
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsReservationUniquenessViolation(ex))
|
||||
{
|
||||
throw new ExternalIdReservationConflictException(
|
||||
"Finalise rejected: one or more ZTag/SAPID values were reserved by another operator " +
|
||||
"between batch preview and commit. Inspect active reservations + retry after resolving the conflict.",
|
||||
ex);
|
||||
}
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge one external-ID reservation for an equipment row. Three outcomes:
|
||||
/// (1) value is empty → skip; (2) reservation exists for same <paramref name="equipmentUuid"/>
|
||||
/// → bump <c>LastPublishedAt</c>; (3) reservation exists for a different EquipmentUuid
|
||||
/// → throw <see cref="ExternalIdReservationConflictException"/> with the conflicting UUID
|
||||
/// so the caller sees which equipment already owns the value; (4) no reservation → create new.
|
||||
/// </summary>
|
||||
private void MergeReservation(
|
||||
string? value,
|
||||
ReservationKind kind,
|
||||
Guid equipmentUuid,
|
||||
string clusterId,
|
||||
string firstPublishedBy,
|
||||
DateTime nowUtc,
|
||||
Dictionary<(ReservationKind, string), ExternalIdReservation> cache)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return;
|
||||
|
||||
var key = (kind, value.ToLowerInvariant());
|
||||
if (cache.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (existing.EquipmentUuid != equipmentUuid)
|
||||
throw new ExternalIdReservationConflictException(
|
||||
$"{kind} '{value}' is already reserved by EquipmentUuid {existing.EquipmentUuid} " +
|
||||
$"(first published {existing.FirstPublishedAt:u} on cluster '{existing.ClusterId}'). " +
|
||||
$"Refusing to re-assign to {equipmentUuid}.");
|
||||
|
||||
existing.LastPublishedAt = nowUtc;
|
||||
return;
|
||||
}
|
||||
|
||||
var fresh = new ExternalIdReservation
|
||||
{
|
||||
ReservationId = Guid.NewGuid(),
|
||||
Kind = kind,
|
||||
Value = value,
|
||||
EquipmentUuid = equipmentUuid,
|
||||
ClusterId = clusterId,
|
||||
FirstPublishedAt = nowUtc,
|
||||
FirstPublishedBy = firstPublishedBy,
|
||||
LastPublishedAt = nowUtc,
|
||||
};
|
||||
db.ExternalIdReservations.Add(fresh);
|
||||
cache[key] = fresh;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the <see cref="DbUpdateException"/> root-cause was the filtered-unique
|
||||
/// index <c>UX_ExternalIdReservation_KindValue_Active</c> — i.e. another transaction
|
||||
/// won the race between our cache-load + commit. SQL Server surfaces this as 2601 / 2627.
|
||||
/// </summary>
|
||||
private static bool IsReservationUniquenessViolation(DbUpdateException ex)
|
||||
{
|
||||
for (Exception? inner = ex; inner is not null; inner = inner.InnerException)
|
||||
{
|
||||
if (inner is Microsoft.Data.SqlClient.SqlException sql &&
|
||||
(sql.Number == 2601 || sql.Number == 2627) &&
|
||||
sql.Message.Contains("UX_ExternalIdReservation_KindValue_Active", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>List batches created by the given user. Finalised batches are archived; include them on demand.</summary>
|
||||
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
|
||||
{
|
||||
var query = db.EquipmentImportBatches.AsNoTracking().Where(b => b.CreatedBy == createdBy);
|
||||
if (!includeFinalised)
|
||||
query = query.Where(b => b.FinalisedAtUtc == null);
|
||||
return await query.OrderByDescending(b => b.CreatedAtUtc).ToListAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ImportBatchNotFoundException(string message) : Exception(message);
|
||||
public sealed class ImportBatchAlreadyFinalisedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a <c>FinaliseBatchAsync</c> call detects that one of its ZTag/SAPID values is
|
||||
/// already reserved by a different EquipmentUuid — either from a prior published generation
|
||||
/// or a concurrent finalise that won the race. The operator sees the message + the conflicting
|
||||
/// equipment ownership so they can resolve the conflict (pick a new ZTag, release the existing
|
||||
/// reservation via <c>sp_ReleaseExternalIdReservation</c>, etc.) and retry the finalise.
|
||||
/// </summary>
|
||||
public sealed class ExternalIdReservationConflictException : Exception
|
||||
{
|
||||
public ExternalIdReservationConflictException(string message) : base(message) { }
|
||||
public ExternalIdReservationConflictException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Per-instance detail view for FOCAS driver rows. Loads the latest
|
||||
/// <see cref="DriverInstance"/> row for the requested <c>DriverInstanceId</c> (most-recent
|
||||
/// draft wins when multiple rows exist across generations), parses the schemaless
|
||||
/// <c>DriverConfig</c> JSON into <see cref="FocasDriverConfigView"/>, and joins the
|
||||
/// per-device <see cref="DriverHostStatus"/> rows so the Admin page can render host
|
||||
/// state + consecutive-failure counters next to each configured device.
|
||||
/// </summary>
|
||||
public sealed class FocasDriverDetailService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
public async Task<FocasDriverDetail?> GetAsync(string driverInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverInstanceId)) return null;
|
||||
|
||||
var instance = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.DriverInstanceId == driverInstanceId
|
||||
&& d.DriverType.ToLower() == "focas")
|
||||
.OrderByDescending(d => d.GenerationId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (instance is null) return null;
|
||||
|
||||
FocasDriverConfigView? config = null;
|
||||
string? parseError = null;
|
||||
try { config = JsonSerializer.Deserialize<FocasDriverConfigView>(instance.DriverConfig, JsonOpts); }
|
||||
catch (JsonException ex) { parseError = ex.Message; }
|
||||
|
||||
var hostStatuses = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
where s.DriverInstanceId == driverInstanceId
|
||||
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
|
||||
on new { s.DriverInstanceId, s.HostName }
|
||||
equals new { r.DriverInstanceId, r.HostName } into rj
|
||||
from r in rj.DefaultIfEmpty()
|
||||
orderby s.HostName
|
||||
select new FocasHostStatusRow(
|
||||
s.NodeId,
|
||||
s.HostName,
|
||||
s.State.ToString(),
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail,
|
||||
r != null ? r.ConsecutiveFailures : 0,
|
||||
r != null ? r.LastCircuitBreakerOpenUtc : null,
|
||||
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
|
||||
|
||||
return new FocasDriverDetail(instance, config, parseError, hostStatuses);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Projected view of a FOCAS driver's parsed config. Unknown fields are ignored.</summary>
|
||||
public sealed record FocasDriverConfigView
|
||||
{
|
||||
public List<FocasDeviceView>? Devices { get; set; }
|
||||
public List<FocasTagView>? Tags { get; set; }
|
||||
public FocasProbeView? Probe { get; set; }
|
||||
public FocasAlarmProjectionView? AlarmProjection { get; set; }
|
||||
public FocasHandleRecycleView? HandleRecycle { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasDeviceView
|
||||
{
|
||||
public string? HostAddress { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
public string? Series { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasTagView
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? DeviceHostAddress { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? DataType { get; set; }
|
||||
public bool Writable { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed record FocasProbeView
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Interval { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasAlarmProjectionView
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? PollInterval { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasHandleRecycleView
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? Interval { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Composite payload returned to the Admin page.</summary>
|
||||
public sealed record FocasDriverDetail(
|
||||
DriverInstance Instance,
|
||||
FocasDriverConfigView? Config,
|
||||
string? ParseError,
|
||||
IReadOnlyList<FocasHostStatusRow> HostStatuses);
|
||||
|
||||
public sealed record FocasHostStatusRow(
|
||||
string NodeId,
|
||||
string HostName,
|
||||
string State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
DateTime? LastRecycleUtc);
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the draft → diff → publish workflow (decision #89). Publish + rollback call into the
|
||||
/// stored procedures; diff queries <c>sp_ComputeGenerationDiff</c>.
|
||||
/// </summary>
|
||||
public sealed class GenerationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public async Task<ConfigGeneration> CreateDraftAsync(string clusterId, string createdBy, CancellationToken ct)
|
||||
{
|
||||
var gen = new ConfigGeneration
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
Status = GenerationStatus.Draft,
|
||||
CreatedBy = createdBy,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.ConfigGenerations.Add(gen);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return gen;
|
||||
}
|
||||
|
||||
public Task<List<ConfigGeneration>> ListRecentAsync(string clusterId, int limit, CancellationToken ct) =>
|
||||
db.ConfigGenerations.AsNoTracking()
|
||||
.Where(g => g.ClusterId == clusterId)
|
||||
.OrderByDescending(g => g.GenerationId)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task PublishAsync(string clusterId, long draftGenerationId, string? notes, CancellationToken ct)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_PublishGeneration @ClusterId = {0}, @DraftGenerationId = {1}, @Notes = {2}",
|
||||
[clusterId, draftGenerationId, (object?)notes ?? DBNull.Value],
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task RollbackAsync(string clusterId, long targetGenerationId, string? notes, CancellationToken ct)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_RollbackToGeneration @ClusterId = {0}, @TargetGenerationId = {1}, @Notes = {2}",
|
||||
[clusterId, targetGenerationId, (object?)notes ?? DBNull.Value],
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<List<DiffRow>> ComputeDiffAsync(long from, long to, CancellationToken ct)
|
||||
{
|
||||
var results = new List<DiffRow>();
|
||||
await using var conn = (SqlConnection)db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_ComputeGenerationDiff @FromGenerationId = @f, @ToGenerationId = @t";
|
||||
cmd.Parameters.AddWithValue("@f", from);
|
||||
cmd.Parameters.AddWithValue("@t", to);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(new DiffRow(reader.GetString(0), reader.GetString(1), reader.GetString(2)));
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DiffRow(string TableName, string LogicalId, string ChangeKind);
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Surfaces the local-node historian queue health on the Admin UI's
|
||||
/// <c>/alarms/historian</c> diagnostics page (Phase 7 plan decisions #16/#21).
|
||||
/// Exposes queue depth / drain state / last-error, and lets the operator retry
|
||||
/// dead-lettered rows without restarting the node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sink injected here is the server-process <see cref="IAlarmHistorianSink"/>.
|
||||
/// When <see cref="NullAlarmHistorianSink"/> is bound (historian disabled for this
|
||||
/// deployment), <see cref="TryRetryDeadLettered"/> silently returns 0 and
|
||||
/// <see cref="GetStatus"/> reports <see cref="HistorianDrainState.Disabled"/>.
|
||||
/// </remarks>
|
||||
public sealed class HistorianDiagnosticsService(IAlarmHistorianSink sink)
|
||||
{
|
||||
public HistorianSinkStatus GetStatus() => sink.GetStatus();
|
||||
|
||||
/// <summary>
|
||||
/// Operator action from the UI's "Retry dead-lettered" button. Returns the number
|
||||
/// of rows revived so the UI can flash a confirmation. When the live sink doesn't
|
||||
/// implement retry (test doubles, Null sink), returns 0.
|
||||
/// </summary>
|
||||
public int TryRetryDeadLettered()
|
||||
{
|
||||
if (sink is SqliteStoreAndForwardSink concrete)
|
||||
return concrete.RetryDeadLettered();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
|
||||
/// <c>ClusterNode.ClusterId</c> (left-join) + the per-<c>(DriverInstanceId, HostName)</c>
|
||||
/// <see cref="DriverInstanceResilienceStatus"/> counters (also left-join) so the Admin
|
||||
/// <c>/hosts</c> page renders the resilience surface inline with host state.
|
||||
/// </summary>
|
||||
public sealed record HostStatusRow(
|
||||
string NodeId,
|
||||
string? ClusterId,
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
DriverHostState State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for the Admin UI's per-host drill-down. Loads
|
||||
/// <see cref="DriverHostStatus"/> rows (written by the Server process's
|
||||
/// <c>HostStatusPublisher</c>) and left-joins <c>ClusterNode</c> so each row knows which
|
||||
/// cluster it belongs to — the Admin UI groups by cluster for the fleet-wide view.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The publisher heartbeat is 10s (<c>HostStatusPublisher.HeartbeatInterval</c>). The
|
||||
/// Admin page also polls every ~10s and treats rows with <c>LastSeenUtc</c> older than
|
||||
/// <c>StaleThreshold</c> (30s) as stale — covers a missed heartbeat tolerance plus
|
||||
/// a generous buffer for clock skew and publisher GC pauses.
|
||||
/// </remarks>
|
||||
public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Consecutive-failure threshold at which <see cref="IsFlagged"/> returns <c>true</c>
|
||||
/// so the Admin UI can paint a red badge. Matches Phase 6.1 decision #143's conservative
|
||||
/// half-of-breaker-threshold convention — flags before the breaker actually opens.</summary>
|
||||
public const int FailureFlagThreshold = 3;
|
||||
|
||||
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Two LEFT JOINs:
|
||||
// 1. ClusterNodes on NodeId — row persists even when its owning ClusterNode row
|
||||
// hasn't been created yet (first-boot bootstrap case).
|
||||
// 2. DriverInstanceResilienceStatuses on (DriverInstanceId, HostName) — resilience
|
||||
// counters haven't been sampled yet for brand-new hosts, so a missing row means
|
||||
// zero failures + never-opened breaker.
|
||||
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
join n in db.ClusterNodes.AsNoTracking()
|
||||
on s.NodeId equals n.NodeId into nodeJoin
|
||||
from n in nodeJoin.DefaultIfEmpty()
|
||||
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
|
||||
on new { s.DriverInstanceId, s.HostName } equals new { r.DriverInstanceId, r.HostName } into resilJoin
|
||||
from r in resilJoin.DefaultIfEmpty()
|
||||
orderby s.NodeId, s.DriverInstanceId, s.HostName
|
||||
select new HostStatusRow(
|
||||
s.NodeId,
|
||||
n != null ? n.ClusterId : null,
|
||||
s.DriverInstanceId,
|
||||
s.HostName,
|
||||
s.State,
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail,
|
||||
r != null ? r.ConsecutiveFailures : 0,
|
||||
r != null ? r.LastCircuitBreakerOpenUtc : null,
|
||||
r != null ? r.CurrentBulkheadDepth : 0,
|
||||
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static bool IsStale(HostStatusRow row) =>
|
||||
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
|
||||
|
||||
/// <summary>
|
||||
/// Red-badge predicate — <c>true</c> when the host has accumulated enough consecutive
|
||||
/// failures that an operator should take notice before the breaker trips.
|
||||
/// </summary>
|
||||
public static bool IsFlagged(HostStatusRow row) =>
|
||||
row.ConsecutiveFailures >= FailureFlagThreshold;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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, AclChangeNotifier? notifier = null)
|
||||
{
|
||||
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);
|
||||
|
||||
if (notifier is not null)
|
||||
await notifier.NotifyNodeAclChangedAsync(clusterId, draftId, 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);
|
||||
|
||||
if (notifier is not null)
|
||||
await notifier.NotifyNodeAclChangedAsync(row.ClusterId, row.GenerationId, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs an ad-hoc permission probe against a draft or published generation's NodeAcl rows —
|
||||
/// "if LDAP group X asks for permission Y on node Z, would the trie grant it, and which
|
||||
/// rows contributed?" Powers the AclsTab "Probe this permission" form per the #196 sub-slice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Thin wrapper over <see cref="PermissionTrieBuilder"/> + <see cref="PermissionTrie.CollectMatches"/> —
|
||||
/// the same code path the Server's dispatch layer uses at request time, so a probe result
|
||||
/// is guaranteed to match what the live server would decide. The probe is read-only + has
|
||||
/// no side effects; failing probes do NOT generate audit log rows.
|
||||
/// </remarks>
|
||||
public sealed class PermissionProbeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="required"/> against the NodeAcl rows of
|
||||
/// <paramref name="generationId"/> for a request by <paramref name="ldapGroup"/> at
|
||||
/// <paramref name="scope"/>. Returns whether the permission would be granted + the list
|
||||
/// of matching grants so the UI can show *why*.
|
||||
/// </summary>
|
||||
public async Task<PermissionProbeResult> ProbeAsync(
|
||||
long generationId,
|
||||
string ldapGroup,
|
||||
NodeScope scope,
|
||||
NodePermissions required,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup);
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
var rows = await db.NodeAcls.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId && a.ClusterId == scope.ClusterId)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var trie = PermissionTrieBuilder.Build(scope.ClusterId, generationId, rows);
|
||||
var matches = trie.CollectMatches(scope, [ldapGroup]);
|
||||
|
||||
var effective = NodePermissions.None;
|
||||
foreach (var m in matches)
|
||||
effective |= m.PermissionFlags;
|
||||
|
||||
var granted = (effective & required) == required;
|
||||
return new PermissionProbeResult(
|
||||
Granted: granted,
|
||||
Required: required,
|
||||
Effective: effective,
|
||||
Matches: matches);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a <see cref="PermissionProbeService.ProbeAsync"/> call.</summary>
|
||||
public sealed record PermissionProbeResult(
|
||||
bool Granted,
|
||||
NodePermissions Required,
|
||||
NodePermissions Effective,
|
||||
IReadOnlyList<MatchedGrant> Matches);
|
||||
102
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs
Normal file
102
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry-compatible instrumentation for the redundancy surface. Uses in-box
|
||||
/// <see cref="System.Diagnostics.Metrics"/> so no NuGet dependency is required to emit —
|
||||
/// any MeterListener (dotnet-counters, OpenTelemetry.Extensions.Hosting OTLP exporter,
|
||||
/// Prometheus exporter, etc.) picks up the instruments by the <see cref="MeterName"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exporter configuration (OTLP, Prometheus, etc.) is intentionally NOT wired here —
|
||||
/// that's a deployment-ops decision that belongs in <c>Program.cs</c> behind an
|
||||
/// <c>appsettings</c> toggle. This class owns only the Meter + instruments so the
|
||||
/// production data stream exists regardless of exporter availability.
|
||||
///
|
||||
/// Counter + gauge names follow the otel-semantic-conventions pattern:
|
||||
/// <c>otopcua.redundancy.*</c> with tags for ClusterId + (for transitions) FromRole/ToRole/NodeId.
|
||||
/// </remarks>
|
||||
public sealed class RedundancyMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "ZB.MOM.WW.OtOpcUa.Redundancy";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _roleTransitions;
|
||||
private readonly object _gaugeLock = new();
|
||||
private readonly Dictionary<string, ClusterGaugeState> _gaugeState = new();
|
||||
|
||||
public RedundancyMetrics()
|
||||
{
|
||||
_meter = new Meter(MeterName, version: "1.0.0");
|
||||
_roleTransitions = _meter.CreateCounter<long>(
|
||||
"otopcua.redundancy.role_transition",
|
||||
unit: "{transition}",
|
||||
description: "Observed RedundancyRole changes per node — tagged FromRole, ToRole, NodeId, ClusterId.");
|
||||
|
||||
// Observable gauges — the callback reports whatever the last Observe*Count call stashed.
|
||||
_meter.CreateObservableGauge(
|
||||
"otopcua.redundancy.primary_count",
|
||||
ObservePrimaryCounts,
|
||||
unit: "{node}",
|
||||
description: "Count of Primary-role nodes per cluster (should be 1 for N+1 redundant clusters, 0 during failover).");
|
||||
_meter.CreateObservableGauge(
|
||||
"otopcua.redundancy.secondary_count",
|
||||
ObserveSecondaryCounts,
|
||||
unit: "{node}",
|
||||
description: "Count of Secondary-role nodes per cluster.");
|
||||
_meter.CreateObservableGauge(
|
||||
"otopcua.redundancy.stale_count",
|
||||
ObserveStaleCounts,
|
||||
unit: "{node}",
|
||||
description: "Count of cluster nodes whose LastSeenAt is older than StaleThreshold.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the per-cluster snapshot consumed by the ObservableGauges. Poller calls this
|
||||
/// at the end of every tick so the collectors see fresh numbers on the next observation
|
||||
/// window (by default 1s for dotnet-counters, configurable per exporter).
|
||||
/// </summary>
|
||||
public void SetClusterCounts(string clusterId, int primary, int secondary, int stale)
|
||||
{
|
||||
lock (_gaugeLock)
|
||||
{
|
||||
_gaugeState[clusterId] = new ClusterGaugeState(primary, secondary, stale);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increment the role_transition counter when a node's RedundancyRole changes. Tags
|
||||
/// allow breakdowns by from/to roles (e.g. Primary → Secondary for planned failover vs
|
||||
/// Primary → Standalone for emergency recovery) + by cluster for multi-site fleets.
|
||||
/// </summary>
|
||||
public void RecordRoleTransition(string clusterId, string nodeId, string fromRole, string toRole)
|
||||
{
|
||||
_roleTransitions.Add(1,
|
||||
new KeyValuePair<string, object?>("cluster.id", clusterId),
|
||||
new KeyValuePair<string, object?>("node.id", nodeId),
|
||||
new KeyValuePair<string, object?>("from_role", fromRole),
|
||||
new KeyValuePair<string, object?>("to_role", toRole));
|
||||
}
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
|
||||
private IEnumerable<Measurement<long>> ObservePrimaryCounts() => SnapshotGauge(s => s.Primary);
|
||||
private IEnumerable<Measurement<long>> ObserveSecondaryCounts() => SnapshotGauge(s => s.Secondary);
|
||||
private IEnumerable<Measurement<long>> ObserveStaleCounts() => SnapshotGauge(s => s.Stale);
|
||||
|
||||
private IEnumerable<Measurement<long>> SnapshotGauge(Func<ClusterGaugeState, int> selector)
|
||||
{
|
||||
List<Measurement<long>> results;
|
||||
lock (_gaugeLock)
|
||||
{
|
||||
results = new List<Measurement<long>>(_gaugeState.Count);
|
||||
foreach (var (cluster, state) in _gaugeState)
|
||||
results.Add(new Measurement<long>(selector(state),
|
||||
new KeyValuePair<string, object?>("cluster.id", cluster)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private readonly record struct ClusterGaugeState(int Primary, int Secondary, int Stale);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
66
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
66
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Draft-generation CRUD for <see cref="Script"/> rows — the C# source code referenced
|
||||
/// by Phase 7 virtual tags and scripted alarms. <see cref="Script.SourceHash"/> is
|
||||
/// recomputed on every save so Core.Scripting's compile cache sees a fresh key when
|
||||
/// source changes and reuses the compile when it doesn't.
|
||||
/// </summary>
|
||||
public sealed class ScriptService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Script>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Scripts.AsNoTracking()
|
||||
.Where(s => s.GenerationId == generationId)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<Script?> GetAsync(long generationId, string scriptId, CancellationToken ct) =>
|
||||
db.Scripts.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.GenerationId == generationId && s.ScriptId == scriptId, ct);
|
||||
|
||||
public async Task<Script> AddAsync(long generationId, string name, string sourceCode, CancellationToken ct)
|
||||
{
|
||||
var s = new Script
|
||||
{
|
||||
GenerationId = generationId,
|
||||
ScriptId = $"scr-{Guid.NewGuid():N}"[..20],
|
||||
Name = name,
|
||||
SourceCode = sourceCode,
|
||||
SourceHash = ComputeHash(sourceCode),
|
||||
};
|
||||
db.Scripts.Add(s);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return s;
|
||||
}
|
||||
|
||||
public async Task<Script> UpdateAsync(long generationId, string scriptId, string name, string sourceCode, CancellationToken ct)
|
||||
{
|
||||
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct)
|
||||
?? throw new InvalidOperationException($"Script '{scriptId}' not found in generation {generationId}");
|
||||
s.Name = name;
|
||||
s.SourceCode = sourceCode;
|
||||
s.SourceHash = ComputeHash(sourceCode);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return s;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string scriptId, CancellationToken ct)
|
||||
{
|
||||
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct);
|
||||
if (s is null) return;
|
||||
db.Scripts.Remove(s);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
internal static string ComputeHash(string source)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source ?? string.Empty));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Serilog; // resolves Serilog.ILogger explicitly in signatures
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
|
||||
/// map + evaluates once, returns the output (or rejection / exception) plus any
|
||||
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
|
||||
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
|
||||
/// the harness can't prove statically surfaces as a harness error, not a runtime
|
||||
/// surprise later.
|
||||
/// </summary>
|
||||
public sealed class ScriptTestHarnessService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
|
||||
/// tag's new value). <paramref name="inputs"/> supplies synthetic
|
||||
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
|
||||
/// </summary>
|
||||
public async Task<ScriptTestResult> RunVirtualTagAsync(
|
||||
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
|
||||
{
|
||||
var deps = DependencyExtractor.Extract(source);
|
||||
if (!deps.IsValid)
|
||||
return ScriptTestResult.DependencyRejections(deps.Rejections);
|
||||
|
||||
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
|
||||
if (missing.Length > 0)
|
||||
return ScriptTestResult.MissingInputs(missing);
|
||||
|
||||
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
|
||||
if (extra.Length > 0)
|
||||
return ScriptTestResult.UnknownInputs(extra);
|
||||
|
||||
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
|
||||
}
|
||||
catch (Exception compileEx)
|
||||
{
|
||||
return ScriptTestResult.Threw(compileEx.Message, []);
|
||||
}
|
||||
var capturing = new CapturingSink();
|
||||
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
|
||||
var ctx = new HarnessVirtualTagContext(inputs, logger);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await evaluator.RunAsync(ctx, ct);
|
||||
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ScriptTestResult.Threw(ex.Message, capturing.Events);
|
||||
}
|
||||
}
|
||||
|
||||
// Public so Roslyn's script compilation can reference the context type through the
|
||||
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
|
||||
public sealed class HarnessVirtualTagContext(
|
||||
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
|
||||
{
|
||||
public Dictionary<string, object?> Writes { get; } = [];
|
||||
public override DataValueSnapshot GetTag(string path) =>
|
||||
inputs.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
|
||||
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
|
||||
public override DateTime Now => DateTime.UtcNow;
|
||||
public override Serilog.ILogger Logger => logger;
|
||||
}
|
||||
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent e) => Events.Add(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
|
||||
public sealed record ScriptTestResult(
|
||||
ScriptTestOutcome Outcome,
|
||||
object? Output,
|
||||
IReadOnlyDictionary<string, object?> Writes,
|
||||
IReadOnlyList<LogEvent> LogEvents,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
|
||||
new(ScriptTestOutcome.Success, output, writes, logs, []);
|
||||
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
|
||||
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
|
||||
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
|
||||
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
|
||||
rejs.Select(r => r.Message).ToArray());
|
||||
public static ScriptTestResult MissingInputs(string[] paths) =>
|
||||
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
|
||||
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
|
||||
public static ScriptTestResult UnknownInputs(string[] paths) =>
|
||||
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
|
||||
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
|
||||
}
|
||||
|
||||
public enum ScriptTestOutcome
|
||||
{
|
||||
Success,
|
||||
Threw,
|
||||
DependencyRejected,
|
||||
MissingInputs,
|
||||
UnknownInputs,
|
||||
}
|
||||
|
||||
file static class Ua
|
||||
{
|
||||
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
|
||||
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
|
||||
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.ScriptedAlarms.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<ScriptedAlarm> AddAsync(
|
||||
long generationId, string equipmentId, string name, string alarmType,
|
||||
int severity, string messageTemplate, string predicateScriptId,
|
||||
bool historizeToAveva, bool retain, CancellationToken ct)
|
||||
{
|
||||
var a = new ScriptedAlarm
|
||||
{
|
||||
GenerationId = generationId,
|
||||
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
AlarmType = alarmType,
|
||||
Severity = severity,
|
||||
MessageTemplate = messageTemplate,
|
||||
PredicateScriptId = predicateScriptId,
|
||||
HistorizeToAveva = historizeToAveva,
|
||||
Retain = retain,
|
||||
};
|
||||
db.ScriptedAlarms.Add(a);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return a;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
|
||||
{
|
||||
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
if (a is null) return;
|
||||
db.ScriptedAlarms.Remove(a);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
|
||||
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
|
||||
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
|
||||
/// </summary>
|
||||
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
|
||||
db.ScriptedAlarmStates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
}
|
||||
71
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/TagService.cs
Normal file
71
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/TagService.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #155 — Tag CRUD scoped to a draft generation. Tags are the canonical signal definitions
|
||||
/// (one row per OPC UA variable) the Server materialises into the address space at startup.
|
||||
/// Mirrors the shape of <see cref="EquipmentService"/>; writes are restricted to draft
|
||||
/// generations only (published generations are immutable per the validation pipeline).
|
||||
/// </summary>
|
||||
public sealed class TagService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Lists all tags in a generation, ordered by name. Optional driver / equipment filter.</summary>
|
||||
public Task<List<Tag>> ListAsync(long generationId,
|
||||
string? driverInstanceId = null,
|
||||
string? equipmentId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = db.Tags.AsNoTracking().Where(t => t.GenerationId == generationId);
|
||||
if (!string.IsNullOrWhiteSpace(driverInstanceId))
|
||||
query = query.Where(t => t.DriverInstanceId == driverInstanceId);
|
||||
if (!string.IsNullOrWhiteSpace(equipmentId))
|
||||
query = query.Where(t => t.EquipmentId == equipmentId);
|
||||
return query.OrderBy(t => t.Name).ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new tag row in the given draft. TagId is auto-derived as a GUID — the
|
||||
/// human-friendly Name is the user-facing identifier.
|
||||
/// </summary>
|
||||
public async Task<Tag> CreateAsync(long draftId, Tag input, CancellationToken ct)
|
||||
{
|
||||
input.GenerationId = draftId;
|
||||
if (string.IsNullOrWhiteSpace(input.TagId))
|
||||
input.TagId = Guid.NewGuid().ToString("N");
|
||||
db.Tags.Add(input);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return input;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Tag updated, CancellationToken ct)
|
||||
{
|
||||
var existing = await db.Tags
|
||||
.FirstOrDefaultAsync(t => t.TagRowId == updated.TagRowId, ct)
|
||||
?? throw new InvalidOperationException($"Tag row {updated.TagRowId} not found");
|
||||
|
||||
// Editable fields. TagId / GenerationId are immutable; the Validation pipeline rejects
|
||||
// changes that would break referential integrity (sp_ValidateDraft per decision #110).
|
||||
existing.Name = updated.Name;
|
||||
existing.DriverInstanceId = updated.DriverInstanceId;
|
||||
existing.DeviceId = updated.DeviceId;
|
||||
existing.EquipmentId = updated.EquipmentId;
|
||||
existing.FolderPath = updated.FolderPath;
|
||||
existing.DataType = updated.DataType;
|
||||
existing.AccessLevel = updated.AccessLevel;
|
||||
existing.WriteIdempotent = updated.WriteIdempotent;
|
||||
existing.PollGroupId = updated.PollGroupId;
|
||||
existing.TagConfig = updated.TagConfig;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid tagRowId, CancellationToken ct)
|
||||
{
|
||||
var existing = await db.Tags.FirstOrDefaultAsync(t => t.TagRowId == tagRowId, ct);
|
||||
if (existing is null) return;
|
||||
db.Tags.Remove(existing);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
213
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs
Normal file
213
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-function impact preview for UNS structural moves per Phase 6.4 Stream A.2. Given
|
||||
/// a <see cref="UnsMoveOperation"/> plus a snapshot of the draft's UNS tree and its
|
||||
/// equipment + tag counts, returns an <see cref="UnsImpactPreview"/> the Admin UI shows
|
||||
/// in a confirmation modal before committing the move.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Stateless + deterministic — testable without EF or a live draft. The caller
|
||||
/// (Razor page) loads the draft's snapshot via the normal Configuration services, passes
|
||||
/// it in, and the analyzer counts + categorises the impact. The returned
|
||||
/// <see cref="UnsImpactPreview.RevisionToken"/> is the token the caller must re-check at
|
||||
/// confirm time; a mismatch means another operator mutated the draft between preview +
|
||||
/// confirm and the operation needs to be refreshed (decision on concurrent-edit safety
|
||||
/// in Phase 6.4 Scope).</para>
|
||||
///
|
||||
/// <para>Cross-cluster moves are rejected here (decision #82) — equipment is
|
||||
/// cluster-scoped; the UI disables the drop target and surfaces an Export/Import workflow
|
||||
/// toast instead.</para>
|
||||
/// </remarks>
|
||||
public static class UnsImpactAnalyzer
|
||||
{
|
||||
/// <summary>Run the analyzer. Returns a populated preview or throws for invalid operations.</summary>
|
||||
public static UnsImpactPreview Analyze(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(move);
|
||||
|
||||
// Cross-cluster guard — the analyzer refuses rather than silently re-homing.
|
||||
if (!string.Equals(move.SourceClusterId, move.TargetClusterId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new CrossClusterMoveRejectedException(
|
||||
"Equipment is cluster-scoped (decision #82). Use Export → Import to migrate equipment " +
|
||||
"across clusters; drag/drop rejected.");
|
||||
|
||||
return move.Kind switch
|
||||
{
|
||||
UnsMoveKind.LineMove => AnalyzeLineMove(snapshot, move),
|
||||
UnsMoveKind.AreaRename => AnalyzeAreaRename(snapshot, move),
|
||||
UnsMoveKind.LineMerge => AnalyzeLineMerge(snapshot, move),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(move), move.Kind, $"Unsupported move kind {move.Kind}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsImpactPreview AnalyzeLineMove(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
var line = snapshot.FindLine(move.SourceLineId!)
|
||||
?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found in draft {snapshot.DraftGenerationId}.");
|
||||
|
||||
var targetArea = snapshot.FindArea(move.TargetAreaId!)
|
||||
?? throw new UnsMoveValidationException($"Target area '{move.TargetAreaId}' not found in draft {snapshot.DraftGenerationId}.");
|
||||
|
||||
var warnings = new List<string>();
|
||||
if (targetArea.LineIds.Contains(line.LineId, StringComparer.OrdinalIgnoreCase))
|
||||
warnings.Add($"Target area '{targetArea.Name}' already contains line '{line.Name}' — dropping a no-op move.");
|
||||
|
||||
// If the target area has a line with the same display name as the mover, warn about
|
||||
// visual ambiguity even though the IDs differ (operators frequently reuse line names).
|
||||
if (targetArea.LineIds.Any(lid =>
|
||||
snapshot.FindLine(lid) is { } sibling &&
|
||||
string.Equals(sibling.Name, line.Name, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(sibling.LineId, line.LineId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
warnings.Add($"Target area '{targetArea.Name}' already has a line named '{line.Name}'. Consider renaming before the move.");
|
||||
}
|
||||
|
||||
return new UnsImpactPreview
|
||||
{
|
||||
AffectedEquipmentCount = line.EquipmentCount,
|
||||
AffectedTagCount = line.TagCount,
|
||||
CascadeWarnings = warnings,
|
||||
RevisionToken = snapshot.RevisionToken,
|
||||
HumanReadableSummary =
|
||||
$"Moving line '{line.Name}' from area '{snapshot.FindAreaByLineId(line.LineId)?.Name ?? "?"}' " +
|
||||
$"to '{targetArea.Name}' will re-home {line.EquipmentCount} equipment + re-parent {line.TagCount} tags.",
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsImpactPreview AnalyzeAreaRename(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
var area = snapshot.FindArea(move.SourceAreaId!)
|
||||
?? throw new UnsMoveValidationException($"Source area '{move.SourceAreaId}' not found in draft {snapshot.DraftGenerationId}.");
|
||||
|
||||
var affectedEquipment = area.LineIds
|
||||
.Select(lid => snapshot.FindLine(lid)?.EquipmentCount ?? 0)
|
||||
.Sum();
|
||||
var affectedTags = area.LineIds
|
||||
.Select(lid => snapshot.FindLine(lid)?.TagCount ?? 0)
|
||||
.Sum();
|
||||
|
||||
return new UnsImpactPreview
|
||||
{
|
||||
AffectedEquipmentCount = affectedEquipment,
|
||||
AffectedTagCount = affectedTags,
|
||||
CascadeWarnings = [],
|
||||
RevisionToken = snapshot.RevisionToken,
|
||||
HumanReadableSummary =
|
||||
$"Renaming area '{area.Name}' → '{move.NewName}' cascades to {area.LineIds.Count} lines / " +
|
||||
$"{affectedEquipment} equipment / {affectedTags} tags.",
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsImpactPreview AnalyzeLineMerge(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
var src = snapshot.FindLine(move.SourceLineId!)
|
||||
?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found.");
|
||||
var dst = snapshot.FindLine(move.TargetLineId!)
|
||||
?? throw new UnsMoveValidationException($"Target line '{move.TargetLineId}' not found.");
|
||||
|
||||
var warnings = new List<string>();
|
||||
if (!string.Equals(snapshot.FindAreaByLineId(src.LineId)?.AreaId,
|
||||
snapshot.FindAreaByLineId(dst.LineId)?.AreaId,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add($"Lines '{src.Name}' and '{dst.Name}' are in different areas. The merge will re-parent equipment + tags into '{dst.Name}'s area.");
|
||||
}
|
||||
|
||||
return new UnsImpactPreview
|
||||
{
|
||||
AffectedEquipmentCount = src.EquipmentCount,
|
||||
AffectedTagCount = src.TagCount,
|
||||
CascadeWarnings = warnings,
|
||||
RevisionToken = snapshot.RevisionToken,
|
||||
HumanReadableSummary =
|
||||
$"Merging line '{src.Name}' into '{dst.Name}': {src.EquipmentCount} equipment + {src.TagCount} tags re-parent. " +
|
||||
$"The source line is deleted at commit.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kind of UNS structural move the analyzer understands.</summary>
|
||||
public enum UnsMoveKind
|
||||
{
|
||||
/// <summary>Drag a whole line from one area to another.</summary>
|
||||
LineMove,
|
||||
|
||||
/// <summary>Rename an area (cascades to the UNS paths of every equipment + tag below it).</summary>
|
||||
AreaRename,
|
||||
|
||||
/// <summary>Merge two lines into one; source line's equipment + tags are re-parented.</summary>
|
||||
LineMerge,
|
||||
}
|
||||
|
||||
/// <summary>One UNS structural move request.</summary>
|
||||
/// <param name="Kind">Move variant — selects which source + target fields are required.</param>
|
||||
/// <param name="SourceClusterId">Cluster of the source node. Must match <see cref="TargetClusterId"/> (decision #82).</param>
|
||||
/// <param name="TargetClusterId">Cluster of the target node.</param>
|
||||
/// <param name="SourceAreaId">Source area id for <see cref="UnsMoveKind.AreaRename"/>.</param>
|
||||
/// <param name="SourceLineId">Source line id for <see cref="UnsMoveKind.LineMove"/> / <see cref="UnsMoveKind.LineMerge"/>.</param>
|
||||
/// <param name="TargetAreaId">Target area id for <see cref="UnsMoveKind.LineMove"/>.</param>
|
||||
/// <param name="TargetLineId">Target line id for <see cref="UnsMoveKind.LineMerge"/>.</param>
|
||||
/// <param name="NewName">New display name for <see cref="UnsMoveKind.AreaRename"/>.</param>
|
||||
public sealed record UnsMoveOperation(
|
||||
UnsMoveKind Kind,
|
||||
string SourceClusterId,
|
||||
string TargetClusterId,
|
||||
string? SourceAreaId = null,
|
||||
string? SourceLineId = null,
|
||||
string? TargetAreaId = null,
|
||||
string? TargetLineId = null,
|
||||
string? NewName = null);
|
||||
|
||||
/// <summary>Snapshot of the UNS tree + counts the analyzer walks.</summary>
|
||||
public sealed class UnsTreeSnapshot
|
||||
{
|
||||
public required long DraftGenerationId { get; init; }
|
||||
public required DraftRevisionToken RevisionToken { get; init; }
|
||||
public required IReadOnlyList<UnsAreaSummary> Areas { get; init; }
|
||||
public required IReadOnlyList<UnsLineSummary> Lines { get; init; }
|
||||
|
||||
public UnsAreaSummary? FindArea(string areaId) =>
|
||||
Areas.FirstOrDefault(a => string.Equals(a.AreaId, areaId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public UnsLineSummary? FindLine(string lineId) =>
|
||||
Lines.FirstOrDefault(l => string.Equals(l.LineId, lineId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public UnsAreaSummary? FindAreaByLineId(string lineId) =>
|
||||
Areas.FirstOrDefault(a => a.LineIds.Contains(lineId, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public sealed record UnsAreaSummary(string AreaId, string Name, IReadOnlyList<string> LineIds);
|
||||
|
||||
public sealed record UnsLineSummary(string LineId, string Name, int EquipmentCount, int TagCount);
|
||||
|
||||
/// <summary>
|
||||
/// Opaque per-draft revision fingerprint. Preview fetches the current token + stores it
|
||||
/// in the <see cref="UnsImpactPreview.RevisionToken"/>. Confirm compares the token against
|
||||
/// the draft's live value; mismatch means another operator mutated the draft between
|
||||
/// preview + commit — raise <c>409 Conflict / refresh-required</c> in the UI.
|
||||
/// </summary>
|
||||
public sealed record DraftRevisionToken(string Value)
|
||||
{
|
||||
/// <summary>Compare two tokens for equality; null-safe.</summary>
|
||||
public bool Matches(DraftRevisionToken? other) =>
|
||||
other is not null &&
|
||||
string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Output of <see cref="UnsImpactAnalyzer.Analyze"/>.</summary>
|
||||
public sealed class UnsImpactPreview
|
||||
{
|
||||
public required int AffectedEquipmentCount { get; init; }
|
||||
public required int AffectedTagCount { get; init; }
|
||||
public required IReadOnlyList<string> CascadeWarnings { get; init; }
|
||||
public required DraftRevisionToken RevisionToken { get; init; }
|
||||
public required string HumanReadableSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a move targets a different cluster than the source (decision #82).</summary>
|
||||
public sealed class CrossClusterMoveRejectedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>Thrown when the move operation references a source / target that doesn't exist in the draft.</summary>
|
||||
public sealed class UnsMoveValidationException(string message) : Exception(message);
|
||||
180
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
180
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft
|
||||
/// and counts equipment + tags per line. Returns the snapshot plus a deterministic
|
||||
/// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples —
|
||||
/// stable across processes + changes whenever any row is added / modified / deleted.
|
||||
/// </summary>
|
||||
public async Task<UnsTreeSnapshot> LoadSnapshotAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
var areas = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var equipmentCounts = await db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId == generationId)
|
||||
.GroupBy(e => e.UnsLineId)
|
||||
.Select(g => new { LineId = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var lineSummaries = lines.Select(l =>
|
||||
new UnsLineSummary(
|
||||
LineId: l.UnsLineId,
|
||||
Name: l.Name,
|
||||
EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId),
|
||||
TagCount: 0)).ToList();
|
||||
|
||||
var areaSummaries = areas.Select(a =>
|
||||
new UnsAreaSummary(
|
||||
AreaId: a.UnsAreaId,
|
||||
Name: a.Name,
|
||||
LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(l => l.UnsLineId).ToList())).ToList();
|
||||
|
||||
return new UnsTreeSnapshot
|
||||
{
|
||||
DraftGenerationId = generationId,
|
||||
RevisionToken = ComputeRevisionToken(areas, lines),
|
||||
Areas = areaSummaries,
|
||||
Lines = lineSummaries,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic re-parent of a line to a new area inside the same draft. The caller must pass
|
||||
/// the revision token it observed at preview time — a mismatch raises
|
||||
/// <see cref="DraftRevisionConflictException"/> so the UI can show the 409 concurrent-edit
|
||||
/// modal instead of silently overwriting a peer's work.
|
||||
/// </summary>
|
||||
public async Task MoveLineAsync(
|
||||
long generationId,
|
||||
DraftRevisionToken expected,
|
||||
string lineId,
|
||||
string targetAreaId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expected);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(lineId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId);
|
||||
|
||||
var supportsTx = db.Database.IsRelational();
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var areas = await db.UnsAreas
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var current = ComputeRevisionToken(areas, lines);
|
||||
if (!current.Matches(expected))
|
||||
throw new DraftRevisionConflictException(
|
||||
$"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " +
|
||||
"Refresh + redo the move.");
|
||||
|
||||
var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}.");
|
||||
|
||||
if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}.");
|
||||
|
||||
if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
return; // no-op drop — same area
|
||||
|
||||
line.UnsAreaId = targetAreaId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList<UnsArea> areas, IReadOnlyList<UnsLine> lines)
|
||||
{
|
||||
var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80);
|
||||
foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal))
|
||||
sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';');
|
||||
foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal))
|
||||
sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';');
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a UNS move's expected revision token no longer matches the live draft
|
||||
/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style
|
||||
/// "refresh required" modal in the Admin UI.</summary>
|
||||
public sealed class DraftRevisionConflictException(string message) : Exception(message);
|
||||
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Draft-aware write surface over <see cref="NodeAcl"/>. Replaces direct
|
||||
/// <see cref="NodeAclService"/> CRUD for Admin UI grant authoring; the raw service stays
|
||||
/// as the read / delete surface. Enforces the invariants listed in Phase 6.2 Stream D.2:
|
||||
/// scope-uniqueness per (LdapGroup, ScopeKind, ScopeId, GenerationId), grant shape
|
||||
/// consistency, and no empty permission masks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Per decision #129 grants are additive — <see cref="NodePermissions.None"/> is
|
||||
/// rejected at write time. Explicit Deny is v2.1 and is not representable in the current
|
||||
/// <c>NodeAcl</c> row; attempts to express it (e.g. empty permission set) surface as
|
||||
/// <see cref="InvalidNodeAclGrantException"/>.</para>
|
||||
///
|
||||
/// <para>Draft scope: writes always target an unpublished (Draft-state) generation id.
|
||||
/// Once a generation publishes, its rows are frozen.</para>
|
||||
/// </remarks>
|
||||
public sealed class ValidatedNodeAclAuthoringService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Add a new grant row to the given draft generation.</summary>
|
||||
public async Task<NodeAcl> GrantAsync(
|
||||
long draftGenerationId,
|
||||
string clusterId,
|
||||
string ldapGroup,
|
||||
NodeAclScopeKind scopeKind,
|
||||
string? scopeId,
|
||||
NodePermissions permissions,
|
||||
string? notes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup);
|
||||
|
||||
ValidateGrantShape(scopeKind, scopeId, permissions);
|
||||
await EnsureNoDuplicate(draftGenerationId, clusterId, ldapGroup, scopeKind, scopeId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var row = new NodeAcl
|
||||
{
|
||||
GenerationId = draftGenerationId,
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
LdapGroup = ldapGroup,
|
||||
ScopeKind = scopeKind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = permissions,
|
||||
Notes = notes,
|
||||
};
|
||||
db.NodeAcls.Add(row);
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace an existing grant's permission set in place. Validates the new shape;
|
||||
/// rejects attempts to blank-out to None (that's a Revoke via <see cref="NodeAclService"/>).
|
||||
/// </summary>
|
||||
public async Task<NodeAcl> UpdatePermissionsAsync(
|
||||
Guid nodeAclRowId,
|
||||
NodePermissions newPermissions,
|
||||
string? notes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (newPermissions == NodePermissions.None)
|
||||
throw new InvalidNodeAclGrantException(
|
||||
"Permission set cannot be None — revoke the row instead of writing an empty grant.");
|
||||
|
||||
var row = await db.NodeAcls.FirstOrDefaultAsync(a => a.NodeAclRowId == nodeAclRowId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidNodeAclGrantException($"NodeAcl row {nodeAclRowId} not found.");
|
||||
|
||||
row.PermissionFlags = newPermissions;
|
||||
if (notes is not null) row.Notes = notes;
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static void ValidateGrantShape(NodeAclScopeKind scopeKind, string? scopeId, NodePermissions permissions)
|
||||
{
|
||||
if (permissions == NodePermissions.None)
|
||||
throw new InvalidNodeAclGrantException(
|
||||
"Permission set cannot be None — grants must carry at least one flag (decision #129, additive only).");
|
||||
|
||||
if (scopeKind == NodeAclScopeKind.Cluster && !string.IsNullOrEmpty(scopeId))
|
||||
throw new InvalidNodeAclGrantException(
|
||||
"Cluster-scope grants must have null ScopeId. ScopeId only applies to sub-cluster scopes.");
|
||||
|
||||
if (scopeKind != NodeAclScopeKind.Cluster && string.IsNullOrEmpty(scopeId))
|
||||
throw new InvalidNodeAclGrantException(
|
||||
$"ScopeKind={scopeKind} requires a populated ScopeId.");
|
||||
}
|
||||
|
||||
private async Task EnsureNoDuplicate(
|
||||
long generationId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await db.NodeAcls.AsNoTracking()
|
||||
.AnyAsync(a => a.GenerationId == generationId
|
||||
&& a.ClusterId == clusterId
|
||||
&& a.LdapGroup == ldapGroup
|
||||
&& a.ScopeKind == scopeKind
|
||||
&& a.ScopeId == scopeId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (exists)
|
||||
throw new InvalidNodeAclGrantException(
|
||||
$"A grant for (LdapGroup={ldapGroup}, ScopeKind={scopeKind}, ScopeId={scopeId}) already exists in generation {generationId}. " +
|
||||
"Update the existing row's permissions instead of inserting a duplicate.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a <see cref="NodeAcl"/> grant authoring request violates an invariant.</summary>
|
||||
public sealed class InvalidNodeAclGrantException(string message) : Exception(message);
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>Draft-generation CRUD for <see cref="VirtualTag"/> rows.</summary>
|
||||
public sealed class VirtualTagService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<VirtualTag>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.VirtualTags.AsNoTracking()
|
||||
.Where(v => v.GenerationId == generationId)
|
||||
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<VirtualTag> AddAsync(
|
||||
long generationId, string equipmentId, string name, string dataType, string scriptId,
|
||||
bool changeTriggered, int? timerIntervalMs, bool historize, CancellationToken ct)
|
||||
{
|
||||
var v = new VirtualTag
|
||||
{
|
||||
GenerationId = generationId,
|
||||
VirtualTagId = $"vt-{Guid.NewGuid():N}"[..20],
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = dataType,
|
||||
ScriptId = scriptId,
|
||||
ChangeTriggered = changeTriggered,
|
||||
TimerIntervalMs = timerIntervalMs,
|
||||
Historize = historize,
|
||||
};
|
||||
db.VirtualTags.Add(v);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return v;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string virtualTagId, CancellationToken ct)
|
||||
{
|
||||
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct);
|
||||
if (v is null) return;
|
||||
db.VirtualTags.Remove(v);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<VirtualTag> UpdateEnabledAsync(long generationId, string virtualTagId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct)
|
||||
?? throw new InvalidOperationException($"VirtualTag '{virtualTagId}' not found in generation {generationId}");
|
||||
v.Enabled = enabled;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user