10 Commits

21 changed files with 614 additions and 14 deletions
@@ -57,14 +57,14 @@
{"id": 45, "subject": "Task 45: HistorianAdapter + PeerOpcUaProbe + DbHealthProbe actors", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [37,40], "commit": "28639cb"},
{"id": 46, "subject": "Task 46: Extract OpcUaApplicationHost + Phase7Composer", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [6], "commit": "2877a88"},
{"id": 47, "subject": "Task 47: Phase7Composer purity + property tests", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [48,49,50,51,52], "blockedBy": [46], "commit": "b7c117a"},
{"id": 48, "subject": "Task 48: Move Blazor components into AdminUI library", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [47], "blockedBy": [7]},
{"id": 49, "subject": "Task 49: Move SignalR hubs and rewire to FleetStatusBroadcaster", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [50,51,52], "blockedBy": [34,48]},
{"id": 50, "subject": "Task 50: IAdminOperationsClient via ClusterSingletonProxy", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,51,52], "blockedBy": [18,32,48]},
{"id": 51, "subject": "Task 51: Replace DriverDiagnosticsClient with IFleetDiagnosticsClient", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,52], "blockedBy": [18,48]},
{"id": 52, "subject": "Task 52: Drift indicator + Deploy button UI", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,51], "blockedBy": [50,48]},
{"id": 53, "subject": "Task 53: Host Program.cs role-gated startup", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [54,55], "blockedBy": [8,15,20,21,22,26,36,40,45,46,48,49]},
{"id": 54, "subject": "Task 54: Health endpoints + appsettings layout", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,55], "blockedBy": [8,22]},
{"id": 55, "subject": "Task 55: Mac dev mode + DEV-STUB drivers", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,54], "blockedBy": [41]},
{"id": 48, "subject": "Task 48: Move Blazor components into AdminUI library", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [47], "blockedBy": [7], "commit": "1a067e6"},
{"id": 49, "subject": "Task 49: Move SignalR hubs and rewire to FleetStatusBroadcaster", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [50,51,52], "blockedBy": [34,48], "commit": "26d8f2f"},
{"id": 50, "subject": "Task 50: IAdminOperationsClient via ClusterSingletonProxy", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,51,52], "blockedBy": [18,32,48], "commit": "f022499"},
{"id": 51, "subject": "Task 51: Replace DriverDiagnosticsClient with IFleetDiagnosticsClient", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,52], "blockedBy": [18,48], "commit": "b83f099"},
{"id": 52, "subject": "Task 52: Drift indicator + Deploy button UI", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,51], "blockedBy": [50,48], "commit": "f167808"},
{"id": 53, "subject": "Task 53: Host Program.cs role-gated startup", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [54,55], "blockedBy": [8,15,20,21,22,26,36,40,45,46,48,49], "commit": "e2b357f"},
{"id": 54, "subject": "Task 54: Health endpoints + appsettings layout", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,55], "blockedBy": [8,22], "commit": "fa1d685"},
{"id": 55, "subject": "Task 55: Mac dev mode + DEV-STUB drivers", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,54], "blockedBy": [41], "commit": "8b4de80"},
{"id": 56, "subject": "Task 56: Delete OtOpcUa.Server + OtOpcUa.Admin projects", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [53,54,55]},
{"id": 57, "subject": "Task 57: Build & test green check", "status": "pending", "classification": "trivial", "estMinutes": 3, "parallelizableWith": [], "blockedBy": [56]},
{"id": 58, "subject": "Task 58: 2-node integration test harness", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [57]},
@@ -88,6 +88,12 @@
{"id": "F11", "subject": "Follow-up: HistorianAdapterActor named-pipe IPC + SqliteStoreAndForwardSink wiring", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 45 — stub buffers in-memory; named-pipe + SQLite store-and-forward not wired."},
{"id": "F12", "subject": "Follow-up: PeerOpcUaProbeActor real opc.tcp ping (replace Ok=true stub)", "status": "pending", "classification": "small", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 45 — RunProbe always returns Ok=true; replace with OPC UA Client connect."},
{"id": "F13", "subject": "Follow-up: Full OpcUaApplicationHost extraction (security/alarms/history/observability)", "status": "pending", "classification": "high-risk", "estMinutes": 120, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 46 — facade only boots ApplicationInstance + StandardServer. Legacy 391-line file pulls Server.Security/Alarms/History/Observability. Pull those into thin OpcUaServer interfaces."},
{"id": "F14", "subject": "Follow-up: Migrate side-effecting Phase7Composer (EquipmentNodeWalker, trace logs, node cache)", "status": "pending", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 47 — pure version covers the projection. Walker + alarm sink registration + cache mutation stay in legacy until split into Phase7Applier."}
{"id": "F14", "subject": "Follow-up: Migrate side-effecting Phase7Composer (EquipmentNodeWalker, trace logs, node cache)", "status": "pending", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 47 — pure version covers the projection. Walker + alarm sink registration + cache mutation stay in legacy until split into Phase7Applier."},
{"id": "F15", "subject": "Follow-up: Migrate 47 legacy Admin Blazor components into AdminUI library", "status": "pending", "classification": "high-risk", "estMinutes": 180, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 48 — only MapAdminUI scaffold + 1 new page (Deployments). 47 pages stay in legacy Admin (accepted-broken) until Task 56."},
{"id": "F16", "subject": "Follow-up: Bridge FleetStatusBroadcaster → SignalR hubs (FleetStatusHub / AlertHub / ScriptLogHub)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 49 — hubs are passive Hub subclasses; the bridge from FleetStatusBroadcaster.broadcast → IHubContext is not wired."},
{"id": "F17", "subject": "Follow-up: FleetDiagnosticsClient real Akka ActorSelection round-trip (GetDiagnosticsRequest)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 51 — client returns an empty snapshot stub. Add GetDiagnosticsRequest contract + DriverHostActor handler + real Ask/Reply."},
{"id": "F18", "subject": "Follow-up: Thread HttpContext.User.Identity.Name into Deployments page (createdBy)", "status": "pending", "classification": "small", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 52 — Deployments.razor hardcodes createdBy=\"(current user)\"; needs @inject AuthenticationStateProvider."},
{"id": "F19", "subject": "Follow-up: RuntimeStartup extension for driver-role node-actor spawn", "status": "pending", "classification": "standard", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 53 — only admin-role singletons are wired via WithOtOpcUaControlPlaneSingletons. Driver-role nodes need a parallel WithOtOpcUaRuntimeActors that spawns DriverHostActor."},
{"id": "F20", "subject": "Follow-up: Wire DriverInstanceActor.ShouldStub() into DriverHostActor child spawn", "status": "pending", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F7"], "blockedBy": [], "origin": "Self-review of Task 55 — ShouldStub helper exists but nothing calls it. Folds into F7 when DriverHostActor learns to spawn DriverInstanceActor children."}
]
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.OtOpcUa.Configuration;
public static class ServiceCollectionExtensions
{
public const string ConnectionStringName = "ConfigDb";
/// <summary>
/// Registers <see cref="IDbContextFactory{TContext}"/> for <see cref="OtOpcUaConfigDbContext"/>
/// using the connection string named <c>ConfigDb</c> from <see cref="IConfiguration"/>.
/// </summary>
public static IServiceCollection AddOtOpcUaConfigDb(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(ConnectionStringName)
?? throw new InvalidOperationException(
$"Connection string '{ConnectionStringName}' is required. Add it to appsettings.json or the OTOPCUA_CONFIG_CONNECTION env var.");
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(connectionString));
return services;
}
}
@@ -0,0 +1,33 @@
using Akka.Actor;
using Akka.Hosting;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// <summary>
/// <see cref="IAdminOperationsClient"/> backed by the cluster singleton registered in
/// <c>AddOtOpcUaControlPlane</c>. Resolves the singleton proxy from <see cref="ActorRegistry"/>
/// at construction time; each call <c>Ask</c>s the proxy with a 10s timeout.
/// </summary>
public sealed class AdminOperationsClient : IAdminOperationsClient
{
private static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(10);
private readonly IActorRef _proxy;
public AdminOperationsClient(ActorRegistry registry)
{
_proxy = registry.Get<AdminOperationsActorKey>();
}
public async Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct)
{
var msg = new StartDeployment(createdBy, CorrelationId.NewId());
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(AskTimeout);
return await _proxy.Ask<StartDeploymentResult>(msg, AskTimeout, linked.Token);
}
}
@@ -0,0 +1,36 @@
using Akka.Actor;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// <summary>
/// <see cref="IFleetDiagnosticsClient"/> that targets a named node's <c>DriverHostActor</c> over
/// Akka cluster <see cref="ActorSelection"/>.
///
/// The actual <c>GetDiagnosticsRequest</c>/<c>NodeDiagnosticsSnapshot</c> round-trip on the
/// driver side is staged for follow-up F17 (depends on DriverHostActor exposing the request
/// handler; right now it only handles DispatchDeployment). For now the client returns an empty
/// snapshot so the UI can render a "no data yet" state.
/// </summary>
public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
{
private readonly ActorSystem _system;
public FleetDiagnosticsClient(ActorSystem system)
{
_system = system;
}
public Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct)
{
// F17: ActorSelection at $"akka.tcp://{system}@{nodeId.Value}:4053/user/driver-host"
// → Ask<NodeDiagnosticsSnapshot>(new GetDiagnostics(), timeout).
var snapshot = new NodeDiagnosticsSnapshot(
nodeId,
CurrentRevision: null,
Drivers: Array.Empty<DriverInstanceDiagnostics>(),
AsOfUtc: DateTime.UtcNow);
return Task.FromResult(snapshot);
}
}
@@ -0,0 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddOtOpcUaAdminClients(this IServiceCollection services)
{
services.AddScoped<IAdminOperationsClient, AdminOperationsClient>();
services.AddScoped<IFleetDiagnosticsClient, FleetDiagnosticsClient>();
return services;
}
}
@@ -0,0 +1,132 @@
@page "/deployments"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@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.ControlPlane.AdminOperations
@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")]
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAdminOperationsClient AdminOps
@rendermode InteractiveServer
<PageTitle>Deployments</PageTitle>
<h1>Deployments</h1>
<div class="d-flex align-items-center gap-3 mb-3">
<button class="btn btn-primary" @onclick="StartDeploymentAsync" disabled="@_busy">
@(_busy ? "Deploying…" : "Deploy current configuration")
</button>
@if (_drift is not null)
{
<span class="badge @(_drift.Value ? "bg-warning text-dark" : "bg-success")">
@(_drift.Value ? "Configuration drift" : "In sync")
</span>
}
</div>
@if (_lastMessage is not null)
{
<div class="alert @(_lastSuccess ? "alert-success" : "alert-danger")">
@_lastMessage
</div>
}
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Deployment</th>
<th>Revision</th>
<th>Status</th>
<th>Created by</th>
<th>Created (UTC)</th>
<th>Sealed (UTC)</th>
</tr>
</thead>
<tbody>
@foreach (var d in _deployments)
{
<tr>
<td><code>@Short(d.DeploymentId)</code></td>
<td><code>@d.RevisionHash[..12]…</code></td>
<td>@d.Status</td>
<td>@d.CreatedBy</td>
<td>@d.CreatedAtUtc.ToString("u")</td>
<td>@(d.SealedAtUtc?.ToString("u") ?? "—")</td>
</tr>
}
</tbody>
</table>
@code {
private IReadOnlyList<Deployment> _deployments = Array.Empty<Deployment>();
private bool _busy;
private bool _lastSuccess;
private string? _lastMessage;
private bool? _drift;
protected override async Task OnInitializedAsync()
{
await ReloadAsync();
}
private async Task ReloadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_deployments = await db.Deployments
.AsNoTracking()
.OrderByDescending(d => d.CreatedAtUtc)
.Take(50)
.ToListAsync();
// Drift: if no sealed deployment yet, no drift to report. Otherwise compare the latest
// sealed revision hash to a fresh snapshot of the live-edit state.
var latestSealed = _deployments.FirstOrDefault(d => d.Status == DeploymentStatus.Sealed);
if (latestSealed is null)
{
_drift = null;
return;
}
var current = await ConfigComposer.SnapshotAndFlattenAsync(db);
_drift = !string.Equals(current.RevisionHash, latestSealed.RevisionHash, StringComparison.Ordinal);
}
private async Task StartDeploymentAsync()
{
_busy = true;
_lastMessage = null;
try
{
var result = await AdminOps.StartDeploymentAsync(
createdBy: "(current user)", // F18: thread HttpContext.User.Identity.Name through
ct: CancellationToken.None);
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
_lastMessage = result.Outcome switch
{
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).",
StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.",
StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.",
_ => result.Message ?? "Deployment rejected.",
};
await ReloadAsync();
}
catch (Exception ex)
{
_lastSuccess = false;
_lastMessage = $"Deploy failed: {ex.Message}";
}
finally
{
_busy = false;
}
}
private static string Short(Guid id) => id.ToString("N")[..8];
}
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>Browser-facing alert / toast push channel. Bridge wiring staged for F16.</summary>
public sealed class AlertHub : Hub
{
public const string Endpoint = "/hubs/alerts";
}
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.SignalR;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>
/// Browser-facing fleet-status push channel. Subscribers receive <see cref="FleetStatusChanged"/>
/// snapshots whenever the admin-role <c>FleetStatusBroadcaster</c> publishes a diff.
///
/// Server-side bridge from <c>FleetStatusBroadcaster.broadcast</c> → <c>IHubContext&lt;FleetStatusHub&gt;</c>
/// is staged for follow-up F16. For now the hub is a passive channel; SignalR clients connect
/// and stay idle until the bridge lands.
/// </summary>
public sealed class FleetStatusHub : Hub
{
public const string Endpoint = "/hubs/fleet-status";
public const string MethodName = "fleetStatusChanged";
}
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
public static class HubRouteBuilderExtensions
{
public static IEndpointRouteBuilder MapOtOpcUaHubs(this IEndpointRouteBuilder app)
{
app.MapHub<FleetStatusHub>(FleetStatusHub.Endpoint);
app.MapHub<AlertHub>(AlertHub.Endpoint);
return app;
}
}
@@ -0,0 +1,7 @@
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.JSInterop
@@ -0,0 +1,19 @@
@* Root Blazor component for the fused OtOpcUa.Host. Pulls in the AdminUI library's
_Imports + the Deployments page. The full layout (sidebar, top bar, etc.) is part of
the legacy Admin migration tracked as F15 — for now this is the bare minimum that lets
the Razor pipeline render. *@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -0,0 +1,33 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
/// <summary>
/// Reports Healthy on the admin-role leader, Degraded on a non-leader admin member. Used by
/// the <c>/health/active</c> endpoint so external load balancers can route admin-singleton
/// traffic to the current leader (cookie sessions still work on either node — DataProtection
/// keys are shared).
/// </summary>
public sealed class AdminRoleLeaderHealthCheck : IHealthCheck
{
private readonly IClusterRoleInfo _roleInfo;
public AdminRoleLeaderHealthCheck(IClusterRoleInfo roleInfo)
{
_roleInfo = roleInfo;
}
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
if (!_roleInfo.HasRole("admin"))
return Task.FromResult(HealthCheckResult.Healthy("Node does not carry admin role"));
var leader = _roleInfo.RoleLeader("admin");
var isLeader = leader is not null && leader.Value.Equals(_roleInfo.LocalNode);
return Task.FromResult(isLeader
? HealthCheckResult.Healthy($"Admin leader ({_roleInfo.LocalNode})")
: HealthCheckResult.Degraded($"Admin member but not leader (leader={leader?.Value ?? "<unknown>"})"));
}
}
@@ -0,0 +1,26 @@
using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
public sealed class AkkaClusterHealthCheck : IHealthCheck
{
private readonly ActorSystem _system;
public AkkaClusterHealthCheck(ActorSystem system)
{
_system = system;
}
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
var cluster = Akka.Cluster.Cluster.Get(_system);
var selfUp = cluster.State.Members.Any(m =>
m.Address == cluster.SelfAddress && m.Status == MemberStatus.Up);
return Task.FromResult(selfUp
? HealthCheckResult.Healthy($"Self Up; {cluster.State.Members.Count} member(s)")
: HealthCheckResult.Degraded("Self not yet Up in cluster"));
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
public sealed class DatabaseHealthCheck : IHealthCheck
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
public DatabaseHealthCheck(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
await db.Deployments.AsNoTracking().Take(1).ToListAsync(cancellationToken);
return HealthCheckResult.Healthy("ConfigDb reachable");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("ConfigDb unreachable", ex);
}
}
}
@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
public static class HealthEndpoints
{
/// <summary>
/// Registers the standard ASP.NET Core health-check infrastructure plus the OtOpcUa-specific
/// probes. Mirrors ScadaLink's three-tier pattern: <c>ready</c> = boot ok; <c>active</c> =
/// fully serving traffic; <c>healthz</c> = bare process liveness.
/// </summary>
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services)
{
services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("configdb", tags: new[] { "ready", "active" })
.AddCheck<AkkaClusterHealthCheck>("akka", tags: new[] { "ready", "active" })
.AddCheck<AdminRoleLeaderHealthCheck>("admin-leader", tags: new[] { "active" });
return services;
}
public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app)
{
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
});
app.MapHealthChecks("/health/active", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("active"),
});
app.MapHealthChecks("/healthz", new HealthCheckOptions
{
Predicate = _ => false, // process-liveness only — no probes run.
});
return app;
}
}
@@ -0,0 +1,11 @@
@inherits LayoutComponentBase
<div class="container mt-3">
<nav class="d-flex gap-3 mb-3 border-bottom pb-2">
<strong>OtOpcUa</strong>
<a href="/deployments">Deployments</a>
</nav>
<main>
@Body
</main>
</div>
+84 -1
View File
@@ -1,4 +1,87 @@
using Akka.Hosting;
using Serilog;
using ZB.MOM.WW.OtOpcUa.AdminUI;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.Host;
using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Security;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
var hasAdmin = roles.Contains("admin");
var hasDriver = roles.Contains("driver");
var builder = WebApplication.CreateBuilder(args);
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
if (roleSuffix is not null)
builder.Configuration.AddJsonFile($"appsettings.{roleSuffix}.json", optional: true, reloadOnChange: true);
// Serilog — rolling daily file sink per CLAUDE.md. Console for local dev.
builder.Host.UseSerilog((ctx, lc) => lc
.ReadFrom.Configuration(ctx.Configuration)
.WriteTo.Console()
.WriteTo.File("logs/otopcua-.log", rollingInterval: RollingInterval.Day));
// Windows-service registration is handled at install time by scripts/install/Install-Services.ps1
// (Task 62) rather than in-process, so the binary stays cross-platform-compilable.
// Shared services — always registered regardless of role. ConfigDb is required for everything.
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
builder.Services.AddOtOpcUaCluster(builder.Configuration);
// Akka cluster bootstrap. Role-specific singletons are registered on the AkkaConfigurationBuilder
// from inside the configurator lambda. AddAkka spins the ActorSystem at host start.
builder.Services.AddAkka("otopcua", (ab, _) =>
{
if (hasAdmin)
ab.WithOtOpcUaControlPlaneSingletons();
// Driver-role startup (DriverHostActor spawn + child probes) is wired in F19 once a
// RuntimeStartup contract is added — the actor itself exists (Phase 6), the registration
// extension does not yet. Without it, driver-role nodes still join the cluster and serve
// health/redundancy traffic but won't auto-spawn DriverHostActor.
});
if (hasAdmin)
{
// Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI.
builder.Services.AddOtOpcUaAuth(builder.Configuration);
builder.Services.AddAdminUI();
builder.Services.AddSignalR();
builder.Services.AddOtOpcUaAdminClients();
}
builder.Services.AddOtOpcUaHealth();
var app = builder.Build();
app.MapGet("/", () => "OtOpcUa.Host scaffold");
app.UseSerilogRequestLogging();
if (hasAdmin)
{
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapOtOpcUaAuth();
app.MapAdminUI<App>();
app.MapOtOpcUaHubs();
}
app.MapOtOpcUaHealth();
Log.Information("OtOpcUa.Host starting with roles=[{Roles}] (admin={HasAdmin}, driver={HasDriver})",
string.Join(",", roles), hasAdmin, hasDriver);
await app.RunAsync();
namespace ZB.MOM.WW.OtOpcUa.Host
{
/// <summary>Re-exported for <c>WebApplicationFactory&lt;Program&gt;</c> integration tests (F1).</summary>
public partial class Program;
}
@@ -0,0 +1,12 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI
<Router AppAssembly="@typeof(EndpointRouteBuilderExtensions).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Page not found.</p>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using ZB.MOM.WW.OtOpcUa.Host
@@ -0,0 +1,15 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Akka": "Information"
}
}
},
"Security": {
"Ldap": {
"DevStubMode": true
}
}
}
@@ -45,15 +45,48 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
public ITimerScheduler Timers { get; set; } = null!;
public static Props Props(IDriver driver, TimeSpan? reconnectInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(driver, reconnectInterval ?? DefaultReconnectInterval));
public static Props Props(IDriver driver, TimeSpan? reconnectInterval = null, bool startStubbed = false) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(driver, reconnectInterval ?? DefaultReconnectInterval, startStubbed));
public DriverInstanceActor(IDriver driver, TimeSpan reconnectInterval)
/// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
/// configured roles. Mirrors plan §Task 55: Windows-only driver types (Galaxy, Wonderware
/// Historian) are stubbed when running on non-Windows OR when the host carries the
/// <c>dev</c> role.
/// </summary>
public static bool ShouldStub(string driverType, IEnumerable<string> roles)
{
var isWindowsOnly = driverType is "Galaxy" or "Historian.Wonderware";
if (!OperatingSystem.IsWindows() && isWindowsOnly) return true;
if (roles.Contains("dev") && isWindowsOnly) return true;
return false;
}
public DriverInstanceActor(IDriver driver, TimeSpan reconnectInterval, bool startStubbed = false)
{
_driver = driver;
_driverInstanceId = driver.DriverInstanceId;
_reconnectInterval = reconnectInterval;
Become(Connecting);
if (startStubbed)
{
Context.GetLogger().Info("[DEV-STUB] driver={Name} type={Type}",
_driverInstanceId, driver.DriverType);
Become(Stubbed);
}
else
{
Become(Connecting);
}
}
private void Stubbed()
{
// Stubbed drivers accept the standard message contracts but return deterministic
// success without touching real hardware. Read returns null; Write succeeds.
Receive<InitializeRequested>(_ => { /* no-op */ });
Receive<ApplyDelta>(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation)));
Receive<WriteAttribute>(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed")));
Receive<DisconnectObserved>(_ => { /* stubbed drivers don't disconnect */ });
}
private void Connecting()