junction/src/Junction.Core/Monitoring/MachineMonitor.cs
dtrentin 6f75f7feb1 feat: machine management UX + PAUL init (v0.2)
M2 "Machine Management UX": full N-machine management from UI plus
runtime resilience. All 3 screens now live (dashboard, detail, config).

Core:
- PollingEngine offline detection: consecutive-fail threshold emits a
  synthetic Disconnected snapshot (no-spam, resets on recovery).
- MachineMonitor dynamic API: AddOrUpdateMachineAsync / RemoveMachineAsync
  start/restart/stop a machine's polling live (no app restart); idempotent.
- MachineMonitor.AvailableProtocols exposes loaded protocol ids.

App:
- Machine detail screen: full current snapshot, reachable from dashboard
  row, live-refreshing, Back nav.
- Config screen: add / edit / delete machines with validation; Save upserts
  + reloads monitor live; Delete two-state confirm + stops polling.
- Dashboard: "Add machine" button, per-row Details, reload after mutation.

Repo hygiene:
- Untrack stray src/Junction.App/plugins/ build artifact (real output goes
  to bin/*/plugins via build target); add to .gitignore.

PAUL: initialized .paul/ (PROJECT/ROADMAP/STATE + paul.json) as cross-session
system-of-record. v0.1 shipped, v0.2 complete, v0.3 OPC UA next.

Build 0 warn/0 err (net48 + net8.0). Tests: 125 unit + 2 docker integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:55:10 +02:00

496 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Concurrent;
using Junction.Core.Plugins;
using Junction.Core.Polling;
using Junction.Domain;
using Junction.Domain.Models;
using Junction.Domain.Persistence;
using Junction.Domain.Protocols;
using Microsoft.Extensions.Logging;
namespace Junction.Core.Monitoring
{
/// <summary>
/// Default <see cref="IMachineMonitor"/>. See the interface for the orchestration contract
/// and partial-start policy.
/// <para>
/// PollingEngine-per-machine: each machine gets its own <see cref="IPollingEngine"/> built
/// from an injected <see cref="Func{IPollingEngine}"/> factory. The factory keeps the
/// monitor decoupled from engine construction (and from <see cref="ILogger{PollingEngine}"/>
/// wiring), which makes the monitor trivially unit-testable with a stub engine. DI registers
/// the default factory as <c>() =&gt; new PollingEngine(loggerFactory.CreateLogger&lt;PollingEngine&gt;())</c>.
/// </para>
/// <para>
/// Thread-safety: snapshots arrive concurrently from multiple poll loops. The latest-snapshot
/// cache is a <see cref="ConcurrentDictionary{Guid, MachineSnapshot}"/> (lock-free reads for
/// the dashboard), and the <see cref="SnapshotUpdated"/> event is raised through a captured
/// local delegate. Start/Stop mutate the running-loop set under a private lock.
/// </para>
/// </summary>
public sealed class MachineMonitor : IMachineMonitor
{
private const string Source = "MachineMonitor";
private readonly IMachineRepository _repository;
private readonly IPluginLoader _pluginLoader;
private readonly Func<IPollingEngine> _engineFactory;
private readonly ILogger<MachineMonitor> _logger;
private readonly ConcurrentDictionary<Guid, MachineSnapshot> _snapshots =
new ConcurrentDictionary<Guid, MachineSnapshot>();
private readonly object _lifecycleLock = new object();
private readonly List<RunningLoop> _running = new List<RunningLoop>();
// Built once by StartAsync and kept so dynamic add/update can resolve drivers at runtime.
// Guarded by _lifecycleLock. Null until StartAsync completes = "monitor not started".
private Dictionary<string, IProtocolDriverFactory>? _factories;
public MachineMonitor(
IMachineRepository repository,
IPluginLoader pluginLoader,
Func<IPollingEngine> engineFactory,
ILogger<MachineMonitor> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_pluginLoader = pluginLoader ?? throw new ArgumentNullException(nameof(pluginLoader));
_engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public event EventHandler<MachineSnapshot>? SnapshotUpdated;
/// <inheritdoc />
public IReadOnlyDictionary<Guid, MachineSnapshot> LatestSnapshots => _snapshots;
/// <inheritdoc />
public IReadOnlyCollection<string> AvailableProtocols
{
get
{
lock (_lifecycleLock)
{
if (_factories is null)
{
return Array.Empty<string>();
}
// Independent snapshot of the keys so callers can't observe later mutations.
return new List<string>(_factories.Keys);
}
}
}
/// <inheritdoc />
public async Task<Result> StartAsync(string pluginsDirectory, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Result.Cancelled();
}
// 1. Load plugins. A failure here (missing/invalid directory) is a hard infra failure.
Result<IReadOnlyList<LoadedPlugin>> loadResult = _pluginLoader.LoadFrom(pluginsDirectory);
if (!loadResult.IsSuccess)
{
_logger.LogError("Plugin load failed; monitor cannot start: {Errors}", DescribeErrors(loadResult.Errors));
return Result.Fail(loadResult.Errors);
}
var factories = BuildFactoryMap(loadResult.Value);
// 2. Load configured machines. A repository failure is a hard infra failure.
Result<IReadOnlyList<Machine>> machinesResult;
try
{
machinesResult = await _repository.GetAllAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return Result.Cancelled();
}
if (machinesResult.WasCancelled)
{
return Result.Cancelled();
}
if (!machinesResult.IsSuccess)
{
_logger.LogError("Machine load failed; monitor cannot start: {Errors}", DescribeErrors(machinesResult.Errors));
return Result.Fail(machinesResult.Errors);
}
// 3. Start one poll loop per machine that resolves a driver. Skips are non-fatal.
int started = 0;
int skipped = 0;
lock (_lifecycleLock)
{
// Retain the factory map so AddOrUpdateMachineAsync can build drivers later.
_factories = factories;
foreach (Machine machine in machinesResult.Value)
{
if (!factories.TryGetValue(machine.ProtocolId, out IProtocolDriverFactory factory))
{
skipped++;
_logger.LogWarning(
"No plugin loaded for protocol '{ProtocolId}'; skipping machine {MachineId} ({MachineName}).",
machine.ProtocolId, machine.Id, machine.Name);
continue;
}
Result<IProtocolDriver> driverResult = factory.Create(machine);
if (!driverResult.IsSuccess)
{
skipped++;
_logger.LogWarning(
"Driver creation failed for machine {MachineId} ({MachineName}) via protocol '{ProtocolId}'; skipping: {Errors}",
machine.Id, machine.Name, machine.ProtocolId, DescribeErrors(driverResult.Errors));
continue;
}
IProtocolDriver driver = driverResult.Value;
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
IPollingEngine engine = _engineFactory();
CancellationToken loopToken = cts.Token;
Task loop = engine.RunAsync(
machine,
driver,
snapshot => OnSnapshot(snapshot, loopToken),
loopToken);
_running.Add(new RunningLoop(machine.Id, cts, loop));
started++;
}
}
_logger.LogInformation(
"MachineMonitor started: {Started} machine(s) polling, {Skipped} skipped.",
started, skipped);
// Partial start is a success.
return Result.Ok();
}
/// <inheritdoc />
public async Task StopAsync()
{
List<RunningLoop> loops;
lock (_lifecycleLock)
{
loops = new List<RunningLoop>(_running);
_running.Clear();
}
if (loops.Count == 0)
{
return;
}
foreach (RunningLoop loop in loops)
{
try
{
loop.Cts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed; ignore.
}
}
var tasks = new Task[loops.Count];
for (int i = 0; i < loops.Count; i++)
{
tasks[i] = loops[i].Loop;
}
try
{
// Poll loops never fault (they swallow driver/cancellation errors), but guard anyway.
await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "A poll loop faulted during shutdown; continuing cleanup.");
}
foreach (RunningLoop loop in loops)
{
loop.Cts.Dispose();
}
_logger.LogInformation("MachineMonitor stopped: {Count} poll loop(s) shut down.", loops.Count);
}
/// <inheritdoc />
public async Task<Result> AddOrUpdateMachineAsync(Machine machine, CancellationToken cancellationToken)
{
if (machine is null)
{
return Result.Fail(OperationError.Of(Source, "machine is null."));
}
if (cancellationToken.IsCancellationRequested)
{
return Result.Cancelled();
}
// Phase 1: detach any existing loop for this id (under lock), enforcing "not started".
RunningLoop? existing;
lock (_lifecycleLock)
{
if (_factories is null)
{
return Result.Fail(OperationError.Of(Source, "Monitor not started; call StartAsync first."));
}
existing = FindAndRemoveLocked(machine.Id);
}
// Phase 2: await the old loop's clean shutdown OUTSIDE the lock (no await under lock).
if (existing != null)
{
await StopLoopAsync(existing).ConfigureAwait(false);
_snapshots.TryRemove(machine.Id, out _);
}
// Phase 3: start a fresh loop (under lock).
lock (_lifecycleLock)
{
if (_factories is null)
{
return Result.Fail(OperationError.Of(Source, "Monitor not started; call StartAsync first."));
}
var cts = new CancellationTokenSource();
Result started = TryStartLoopLocked(machine, cts);
if (!started.IsSuccess)
{
cts.Dispose();
_logger.LogWarning(
"AddOrUpdate failed to start machine {MachineId} ({MachineName}): {Errors}",
machine.Id, machine.Name, DescribeErrors(started.Errors));
return started;
}
_logger.LogInformation(
"Machine {MachineId} ({MachineName}) started/restarted via AddOrUpdate.",
machine.Id, machine.Name);
return Result.Ok();
}
}
/// <inheritdoc />
public async Task<Result> RemoveMachineAsync(Guid machineId, CancellationToken cancellationToken)
{
RunningLoop? existing;
lock (_lifecycleLock)
{
existing = FindAndRemoveLocked(machineId);
}
if (existing != null)
{
await StopLoopAsync(existing).ConfigureAwait(false);
}
_snapshots.TryRemove(machineId, out _);
_logger.LogInformation("Machine {MachineId} removed from monitor (was running: {Running}).", machineId, existing != null);
// Idempotent: Ok even when nothing was running for the id.
return Result.Ok();
}
/// <summary>
/// Removes and returns the tracked loop for <paramref name="machineId"/>, or null.
/// Caller must hold <see cref="_lifecycleLock"/>.
/// </summary>
private RunningLoop? FindAndRemoveLocked(Guid machineId)
{
for (int i = 0; i < _running.Count; i++)
{
if (_running[i].MachineId == machineId)
{
RunningLoop loop = _running[i];
_running.RemoveAt(i);
return loop;
}
}
return null;
}
/// <summary>Cancels, awaits and disposes a single loop. Never throws.</summary>
private async Task StopLoopAsync(RunningLoop loop)
{
try
{
loop.Cts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed; ignore.
}
try
{
await loop.Loop.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Poll loop faulted while stopping machine {MachineId}; continuing.", loop.MachineId);
}
loop.Cts.Dispose();
}
/// <summary>
/// Resolves a driver for <paramref name="machine"/> via the retained factory map and starts
/// a poll loop tracked in <see cref="_running"/>. Caller must hold <see cref="_lifecycleLock"/>
/// and have verified <see cref="_factories"/> is non-null. Returns Fail (unknown protocol /
/// driver-create failure) without adding a loop.
/// </summary>
private Result TryStartLoopLocked(Machine machine, CancellationTokenSource cts)
{
Dictionary<string, IProtocolDriverFactory> factories = _factories!;
if (!factories.TryGetValue(machine.ProtocolId, out IProtocolDriverFactory factory))
{
return Result.Fail(OperationError.Of(
Source,
$"No plugin loaded for protocol '{machine.ProtocolId}'."));
}
Result<IProtocolDriver> driverResult = factory.Create(machine);
if (!driverResult.IsSuccess)
{
return Result.Fail(driverResult.Errors);
}
IProtocolDriver driver = driverResult.Value;
IPollingEngine engine = _engineFactory();
CancellationToken loopToken = cts.Token;
Task loop = engine.RunAsync(
machine,
driver,
snapshot => OnSnapshot(snapshot, loopToken),
loopToken);
_running.Add(new RunningLoop(machine.Id, cts, loop));
return Result.Ok();
}
/// <summary>
/// Snapshot callback invoked from a poll loop: updates the latest cache, raises the
/// event, then fires the persistence write off (fire-and-forget with error logging) so
/// a slow/failed save never stalls or breaks the poll loop.
/// </summary>
private void OnSnapshot(MachineSnapshot snapshot, CancellationToken cancellationToken)
{
_snapshots[snapshot.MachineId] = snapshot;
EventHandler<MachineSnapshot>? handler = SnapshotUpdated;
if (handler != null)
{
try
{
handler(this, snapshot);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SnapshotUpdated subscriber threw for machine {MachineId}; continuing.", snapshot.MachineId);
}
}
_ = PersistAsync(snapshot, cancellationToken);
}
private async Task PersistAsync(MachineSnapshot snapshot, CancellationToken cancellationToken)
{
try
{
Result result = await _repository.SaveSnapshotAsync(snapshot, cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess && !result.WasCancelled)
{
_logger.LogWarning(
"Failed to persist snapshot for machine {MachineId}: {Errors}",
snapshot.MachineId, DescribeErrors(result.Errors));
}
}
catch (OperationCanceledException)
{
// Shutdown in progress; nothing to do.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Persisting snapshot threw for machine {MachineId}; continuing.", snapshot.MachineId);
}
}
/// <summary>
/// Maps ProtocolId (case-insensitive) to factory. On duplicate protocol ids the first
/// loaded plugin wins; the collision is logged.
/// </summary>
private Dictionary<string, IProtocolDriverFactory> BuildFactoryMap(IReadOnlyList<LoadedPlugin> plugins)
{
var map = new Dictionary<string, IProtocolDriverFactory>(StringComparer.OrdinalIgnoreCase);
foreach (LoadedPlugin plugin in plugins)
{
string protocolId = plugin.Factory.ProtocolId ?? "";
if (map.ContainsKey(protocolId))
{
_logger.LogWarning("Duplicate plugin for protocol '{ProtocolId}'; keeping the first loaded.", protocolId);
continue;
}
map[protocolId] = plugin.Factory;
}
return map;
}
private static string DescribeErrors(IReadOnlyList<OperationError> errors)
{
if (errors is null || errors.Count == 0)
{
return "(no detail)";
}
if (errors.Count == 1)
{
return errors[0].ToString();
}
var parts = new string[errors.Count];
for (int i = 0; i < errors.Count; i++)
{
parts[i] = errors[i].ToString();
}
return string.Join("; ", parts);
}
/// <summary>A running per-machine poll loop with its cancellation source.</summary>
private sealed class RunningLoop
{
public Guid MachineId { get; }
public CancellationTokenSource Cts { get; }
public Task Loop { get; }
public RunningLoop(Guid machineId, CancellationTokenSource cts, Task loop)
{
MachineId = machineId;
Cts = cts;
Loop = loop;
}
}
}
}