Compare commits
10 Commits
1a067e609c
...
2b75ce3876
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b75ce3876 | |||
| 8b4de8080b | |||
| fa1d685ccd | |||
| e2b357f89a | |||
| eb4280b7eb | |||
| 8a1f97b27f | |||
| f167808a2c | |||
| b83f099394 | |||
| f022499e7f | |||
| 26d8f2f620 |
@@ -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<FleetStatusHub></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>
|
||||
@@ -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<Program></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()
|
||||
|
||||
Reference in New Issue
Block a user