mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mbproxy.Options;
|
||||
|
||||
namespace Mbproxy.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that owns the Kestrel-backed admin HTTP endpoint.
|
||||
///
|
||||
/// <para>Lifecycle:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="StartAsync"/> builds a <see cref="WebApplication"/> bound to
|
||||
/// <c>Mbproxy.AdminPort</c> and starts it non-blocking.</item>
|
||||
/// <item>If the bind fails (port in use, etc.), logs <c>mbproxy.admin.bind.failed</c>
|
||||
/// at Error and continues — the proxy listeners are unaffected.</item>
|
||||
/// <item>If <c>AdminPort</c> changes via hot-reload, the current app is stopped and a
|
||||
/// new one is started on the new port. Other config changes are ignored here.</item>
|
||||
/// <item><see cref="StopAsync"/> shuts down the current Kestrel app with a 2 s deadline.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Routes: exactly two — <c>GET /</c> (HTML) and <c>GET /status.json</c> (JSON).</para>
|
||||
/// </summary>
|
||||
internal sealed partial class AdminEndpointHost : IHostedService, IAsyncDisposable
|
||||
{
|
||||
private readonly IOptionsMonitor<MbproxyOptions> _optionsMonitor;
|
||||
private readonly StatusSnapshotBuilder _builder;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<AdminEndpointHost> _logger;
|
||||
|
||||
// The currently-running Kestrel app; null when stopped or when bind failed.
|
||||
private WebApplication? _app;
|
||||
|
||||
// Protects concurrent Start/Stop calls (hot-reload + StopAsync racing).
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
// Current configured port — used to detect changes on hot-reload.
|
||||
private int _currentPort;
|
||||
|
||||
// Subscription token for IOptionsMonitor.OnChange.
|
||||
private IDisposable? _optionsChangeRegistration;
|
||||
|
||||
public AdminEndpointHost(
|
||||
IOptionsMonitor<MbproxyOptions> optionsMonitor,
|
||||
StatusSnapshotBuilder builder,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor;
|
||||
_builder = builder;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<AdminEndpointHost>();
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_currentPort = _optionsMonitor.CurrentValue.AdminPort;
|
||||
|
||||
await StartAppAsync(_currentPort, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Subscribe to config changes: if AdminPort changes, re-bind.
|
||||
_optionsChangeRegistration = _optionsMonitor.OnChange(opts =>
|
||||
{
|
||||
int newPort = opts.AdminPort;
|
||||
if (newPort == _currentPort) return; // Only care about AdminPort changes.
|
||||
|
||||
// Fire-and-forget: re-bind is async; we can't await in OnChange.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await _lock.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (newPort == _currentPort) return; // double-check under lock
|
||||
|
||||
// Stop the old app.
|
||||
await StopCurrentAppAsync().ConfigureAwait(false);
|
||||
|
||||
_currentPort = newPort;
|
||||
|
||||
// Start on the new port.
|
||||
await StartAppAsync(newPort, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_optionsChangeRegistration?.Dispose();
|
||||
_optionsChangeRegistration = null;
|
||||
|
||||
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await StopCurrentAppAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds and starts a Kestrel <see cref="WebApplication"/> on <paramref name="port"/>.
|
||||
/// On bind failure, logs the error and sets <c>_app = null</c> — does NOT throw.
|
||||
/// Caller must hold <c>_lock</c> or be in a single-threaded context (StartAsync).
|
||||
/// </summary>
|
||||
private async Task StartAppAsync(int port, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use CreateSlimBuilder with explicit args (empty) to avoid inheriting
|
||||
// process-level environment variables like ASPNETCORE_URLS.
|
||||
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
|
||||
{
|
||||
Args = [],
|
||||
});
|
||||
|
||||
// Suppress Kestrel/ASP.NET Core built-in logging; forward to the outer host's
|
||||
// logger factory so that admin-endpoint errors appear in the proxy's log stream.
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddProvider(new ForwardingLoggerProvider(_loggerFactory));
|
||||
|
||||
// Explicit Kestrel listen — overrides any ASPNETCORE_URLS that leaked in.
|
||||
builder.WebHost.UseKestrel(k =>
|
||||
{
|
||||
k.Listen(System.Net.IPAddress.Any, port);
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ── Routes ───────────────────────────────────────────────────────
|
||||
app.MapGet("/", (HttpContext ctx) =>
|
||||
{
|
||||
var snapshot = _builder.Build();
|
||||
string html = StatusHtmlRenderer.Render(snapshot);
|
||||
return Results.Content(html, "text/html; charset=utf-8");
|
||||
});
|
||||
|
||||
app.MapGet("/status.json", (HttpContext ctx) =>
|
||||
{
|
||||
var snapshot = _builder.Build();
|
||||
string json = JsonSerializer.Serialize(snapshot, StatusJsonContext.Default.StatusResponse);
|
||||
return Results.Content(json, "application/json");
|
||||
});
|
||||
|
||||
await app.StartAsync(ct).ConfigureAwait(false);
|
||||
_app = app;
|
||||
|
||||
LogAdminStarted(_logger, port);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Bind failed — log and continue. Proxy listeners are unaffected.
|
||||
LogAdminBindFailed(_logger, port, ex.Message);
|
||||
_app = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the current <see cref="WebApplication"/> with a 2 s deadline, then disposes it.
|
||||
/// </summary>
|
||||
private async Task StopCurrentAppAsync()
|
||||
{
|
||||
if (_app is null) return;
|
||||
|
||||
var app = _app;
|
||||
_app = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await app.StopAsync(stopCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort.
|
||||
}
|
||||
|
||||
await app.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ── IAsyncDisposable ─────────────────────────────────────────────────────
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_optionsChangeRegistration?.Dispose();
|
||||
_lock.Dispose();
|
||||
|
||||
if (_app is { } app)
|
||||
{
|
||||
_app = null;
|
||||
await app.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logging ──────────────────────────────────────────────────────────────
|
||||
|
||||
[LoggerMessage(EventId = 70, EventName = "mbproxy.admin.started",
|
||||
Level = LogLevel.Information,
|
||||
Message = "Admin endpoint started on port {Port}")]
|
||||
private static partial void LogAdminStarted(ILogger logger, int port);
|
||||
|
||||
[LoggerMessage(EventId = 71, EventName = "mbproxy.admin.bind.failed",
|
||||
Level = LogLevel.Error,
|
||||
Message = "Admin endpoint bind failed — admin page will be unavailable: Port={Port} Reason={Reason}")]
|
||||
private static partial void LogAdminBindFailed(ILogger logger, int port, string reason);
|
||||
|
||||
// ── Inner logger provider (forwards Kestrel/ASP.NET logs to the proxy's factory) ────
|
||||
|
||||
private sealed class ForwardingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly ILoggerFactory _factory;
|
||||
public ForwardingLoggerProvider(ILoggerFactory factory) => _factory = factory;
|
||||
public ILogger CreateLogger(string categoryName) => _factory.CreateLogger(categoryName);
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Mbproxy.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Reads <see cref="AssemblyInformationalVersionAttribute"/> once at startup and caches the
|
||||
/// result as a string. Used for the <c>service.version</c> field on the status page.
|
||||
///
|
||||
/// <para>Note: <see cref="Assembly.Location"/> is unreliable under single-file publish
|
||||
/// (Phase 08). We use <c>Assembly.GetExecutingAssembly().GetCustomAttribute<>()</c>
|
||||
/// which works correctly regardless of publish mode.</para>
|
||||
/// </summary>
|
||||
internal sealed class AssemblyVersionAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// The cached informational version string, e.g. <c>"1.2.3+gitsha"</c>.
|
||||
/// Falls back to <c>"0.0.0"</c> when the attribute is absent (e.g., unit-test host).
|
||||
/// </summary>
|
||||
public string Version { get; } =
|
||||
Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion
|
||||
?? "0.0.0";
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Mbproxy.Admin;
|
||||
|
||||
// ── Wire DTOs for GET /status.json ───────────────────────────────────────────
|
||||
// Field names must match design.md "Status page" tables EXACTLY (camelCase via
|
||||
// JsonKnownNamingPolicy.CamelCase on the source-gen context).
|
||||
|
||||
/// <summary>
|
||||
/// Top-level response envelope for <c>GET /status.json</c>.
|
||||
/// </summary>
|
||||
public sealed record StatusResponse(
|
||||
ServiceFields Service,
|
||||
ListenersAggregate Listeners,
|
||||
IReadOnlyList<PlcStatus> Plcs);
|
||||
|
||||
/// <summary>Service-wide identity and reload counters.</summary>
|
||||
public sealed record ServiceFields(
|
||||
long UptimeSeconds,
|
||||
string Version,
|
||||
DateTimeOffset? ConfigLastReloadUtc,
|
||||
int ConfigReloadCount,
|
||||
int ConfigReloadRejectedCount);
|
||||
|
||||
/// <summary>Aggregate listener state across all configured PLCs.</summary>
|
||||
public sealed record ListenersAggregate(int Bound, int Configured);
|
||||
|
||||
/// <summary>Per-PLC status row.</summary>
|
||||
public sealed record PlcStatus(
|
||||
string Name,
|
||||
string Host,
|
||||
int ListenPort,
|
||||
PlcListenerStatus Listener,
|
||||
PlcClientsStatus Clients,
|
||||
PlcPdusStatus Pdus,
|
||||
PlcBackendStatus Backend,
|
||||
PlcBytesStatus Bytes);
|
||||
|
||||
/// <summary>Listener state sub-object.</summary>
|
||||
public sealed record PlcListenerStatus(
|
||||
string State,
|
||||
string? LastBindError,
|
||||
int RecoveryAttempts);
|
||||
|
||||
/// <summary>Connected-clients sub-object.</summary>
|
||||
public sealed record PlcClientsStatus(
|
||||
int Connected,
|
||||
IReadOnlyList<ClientSnapshot> RemoteEndpoints);
|
||||
|
||||
/// <summary>Per-connection-pair snapshot for the status page.</summary>
|
||||
public sealed record ClientSnapshot(
|
||||
string Remote,
|
||||
DateTimeOffset ConnectedAtUtc,
|
||||
long PdusForwarded);
|
||||
|
||||
/// <summary>PDU counters sub-object.</summary>
|
||||
public sealed record PlcPdusStatus(
|
||||
long Forwarded,
|
||||
FcCounts ByFc,
|
||||
long RewrittenSlots,
|
||||
long PartialBcdWarnings);
|
||||
|
||||
/// <summary>Per-function-code request counts.</summary>
|
||||
public sealed record FcCounts(
|
||||
long Fc03,
|
||||
long Fc04,
|
||||
long Fc06,
|
||||
long Fc16,
|
||||
long Other);
|
||||
|
||||
/// <summary>
|
||||
/// Backend connect, exception, and multiplexer telemetry. Phase 9 added
|
||||
/// <c>InFlight</c>, <c>MaxInFlight</c>, <c>TxIdWraps</c>, <c>DisconnectCascades</c>, and
|
||||
/// <c>QueueDepth</c> to surface the live state of the per-PLC TxId-multiplexed connection.
|
||||
/// </summary>
|
||||
public sealed record PlcBackendStatus(
|
||||
long ConnectsSuccess,
|
||||
long ConnectsFailed,
|
||||
ExceptionCounts ExceptionsByCode,
|
||||
double LastRoundTripMs,
|
||||
long InFlight,
|
||||
long MaxInFlight,
|
||||
long TxIdWraps,
|
||||
long DisconnectCascades,
|
||||
long QueueDepth);
|
||||
|
||||
/// <summary>Modbus exception counts by code.</summary>
|
||||
public sealed record ExceptionCounts(
|
||||
long Code01,
|
||||
long Code02,
|
||||
long Code03,
|
||||
long Code04);
|
||||
|
||||
/// <summary>Byte-transfer counters.</summary>
|
||||
public sealed record PlcBytesStatus(
|
||||
long UpstreamIn,
|
||||
long UpstreamOut);
|
||||
|
||||
// ── Source-generation context ─────────────────────────────────────────────────
|
||||
// TreatWarningsAsErrors is on, so the context must include every reachable type.
|
||||
|
||||
[JsonSerializable(typeof(StatusResponse))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
internal partial class StatusJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Mbproxy.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a <see cref="StatusResponse"/> as a self-contained HTML page.
|
||||
///
|
||||
/// <para>Constraints (from design.md Phase 07):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>No external assets (CSS/JS/fonts/favicons) — firewalled networks only.</item>
|
||||
/// <item><c><meta http-equiv="refresh" content="5"></c> for auto-refresh.</item>
|
||||
/// <item>Page weight ≤ 50 KB for a 54-PLC fleet.</item>
|
||||
/// <item>Listener state colour-coded: bound=green, recovering=orange, stopped=grey.</item>
|
||||
/// <item>Connected clients rendered as compact <c>[remote (n PDUs)]</c> list (not nested table).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static class StatusHtmlRenderer
|
||||
{
|
||||
private const string Css = """
|
||||
body{font-family:monospace;font-size:13px;margin:1em}
|
||||
h1{font-size:1.1em;margin-bottom:.3em}
|
||||
.meta{color:#555;margin-bottom:.8em;font-size:12px}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
th,td{border:1px solid #ccc;padding:3px 6px;white-space:nowrap}
|
||||
th{background:#f0f0f0;text-align:left}
|
||||
tr:nth-child(even)td{background:#fafafa}
|
||||
.bound{color:green;font-weight:bold}
|
||||
.recovering{color:darkorange;font-weight:bold}
|
||||
.stopped{color:grey}
|
||||
.err{font-size:11px;color:#a00}
|
||||
.clients{font-size:11px;color:#333}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Renders the status page as a complete HTML document string.
|
||||
/// May allocate; intended for the status-page read path only.
|
||||
/// </summary>
|
||||
public static string Render(StatusResponse status)
|
||||
{
|
||||
var sb = new StringBuilder(4096);
|
||||
|
||||
sb.Append("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">");
|
||||
sb.Append("<meta http-equiv=\"refresh\" content=\"5\">");
|
||||
sb.Append("<title>mbproxy status</title>");
|
||||
sb.Append("<style>").Append(Css).Append("</style>");
|
||||
sb.Append("</head><body>");
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
sb.Append("<h1>mbproxy status</h1>");
|
||||
sb.Append("<div class=\"meta\">");
|
||||
sb.Append("Version: ").Append(HtmlEncode(status.Service.Version));
|
||||
sb.Append(" | Uptime: ").Append(FormatUptime(status.Service.UptimeSeconds));
|
||||
sb.Append(" | Listeners: ")
|
||||
.Append(status.Listeners.Bound).Append('/').Append(status.Listeners.Configured)
|
||||
.Append(" bound");
|
||||
if (status.Service.ConfigLastReloadUtc.HasValue)
|
||||
{
|
||||
sb.Append(" | Last reload: ")
|
||||
.Append(HtmlEncode(status.Service.ConfigLastReloadUtc.Value.ToString("yyyy-MM-dd HH:mm:ss") + "Z"));
|
||||
}
|
||||
sb.Append(" | Reloads: ").Append(status.Service.ConfigReloadCount);
|
||||
if (status.Service.ConfigReloadRejectedCount > 0)
|
||||
sb.Append(" (").Append(status.Service.ConfigReloadRejectedCount).Append(" rejected)");
|
||||
sb.Append("</div>");
|
||||
|
||||
// ── PLC table ─────────────────────────────────────────────────────────
|
||||
if (status.Plcs.Count == 0)
|
||||
{
|
||||
sb.Append("<p><em>No PLCs configured.</em></p>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("<table>");
|
||||
sb.Append("<thead><tr>");
|
||||
sb.Append("<th>Name</th><th>Host</th><th>Port</th><th>State</th>");
|
||||
sb.Append("<th>Clients</th><th>PDUs fwd</th><th>FC03</th><th>FC04</th>");
|
||||
sb.Append("<th>FC06</th><th>FC16</th><th>FC?</th><th>BCD slots</th>");
|
||||
sb.Append("<th>Partial BCD</th><th>Ex 01</th><th>Ex 02</th><th>Ex 03</th><th>Ex 04</th>");
|
||||
sb.Append("<th>RTT ms</th><th>Bytes in</th><th>Bytes out</th>");
|
||||
// Phase 9: multiplexer telemetry columns.
|
||||
sb.Append("<th>In-flight</th><th>Max in-flight</th><th>TxId wraps</th>");
|
||||
sb.Append("<th>Cascades</th><th>Queue</th>");
|
||||
sb.Append("</tr></thead><tbody>");
|
||||
|
||||
foreach (var plc in status.Plcs)
|
||||
{
|
||||
sb.Append("<tr>");
|
||||
sb.Append("<td>").Append(HtmlEncode(plc.Name)).Append("</td>");
|
||||
sb.Append("<td>").Append(HtmlEncode(plc.Host)).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.ListenPort).Append("</td>");
|
||||
|
||||
// State cell with colour coding
|
||||
string stateClass = plc.Listener.State switch
|
||||
{
|
||||
"bound" => "bound",
|
||||
"recovering" => "recovering",
|
||||
_ => "stopped",
|
||||
};
|
||||
sb.Append("<td><span class=\"").Append(stateClass).Append("\">")
|
||||
.Append(HtmlEncode(plc.Listener.State)).Append("</span>");
|
||||
if (plc.Listener.State == "recovering" && plc.Listener.LastBindError is { } err)
|
||||
{
|
||||
sb.Append("<br><span class=\"err\">")
|
||||
.Append(HtmlEncode(err))
|
||||
.Append(" (attempt ").Append(plc.Listener.RecoveryAttempts).Append(")")
|
||||
.Append("</span>");
|
||||
}
|
||||
sb.Append("</td>");
|
||||
|
||||
// Connected clients
|
||||
sb.Append("<td><span class=\"clients\">");
|
||||
sb.Append(plc.Clients.Connected);
|
||||
if (plc.Clients.RemoteEndpoints.Count > 0)
|
||||
{
|
||||
sb.Append("<br>");
|
||||
bool first = true;
|
||||
foreach (var c in plc.Clients.RemoteEndpoints)
|
||||
{
|
||||
if (!first) sb.Append(", ");
|
||||
sb.Append(HtmlEncode(c.Remote))
|
||||
.Append(" (").Append(c.PdusForwarded).Append(')');
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
sb.Append("</span></td>");
|
||||
|
||||
// Counter cells
|
||||
sb.Append("<td>").Append(plc.Pdus.Forwarded).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc03).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc04).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc06).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc16).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Pdus.ByFc.Other).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Pdus.RewrittenSlots).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Pdus.PartialBcdWarnings).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code01).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code02).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code03).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code04).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Backend.LastRoundTripMs.ToString("F1")).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Bytes.UpstreamIn).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Bytes.UpstreamOut).Append("</td>");
|
||||
// Phase 9: multiplexer telemetry cells.
|
||||
sb.Append("<td>").Append(plc.Backend.InFlight).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Backend.MaxInFlight).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Backend.TxIdWraps).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Backend.DisconnectCascades).Append("</td>");
|
||||
sb.Append("<td>").Append(plc.Backend.QueueDepth).Append("</td>");
|
||||
sb.Append("</tr>");
|
||||
}
|
||||
|
||||
sb.Append("</tbody></table>");
|
||||
}
|
||||
|
||||
sb.Append("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string FormatUptime(long seconds)
|
||||
{
|
||||
var ts = TimeSpan.FromSeconds(seconds);
|
||||
if (ts.TotalHours >= 1)
|
||||
return $"{(int)ts.TotalHours}h {ts.Minutes:D2}m {ts.Seconds:D2}s";
|
||||
if (ts.TotalMinutes >= 1)
|
||||
return $"{ts.Minutes}m {ts.Seconds:D2}s";
|
||||
return $"{seconds}s";
|
||||
}
|
||||
|
||||
private static string HtmlEncode(string s)
|
||||
{
|
||||
// Fast path: no special chars.
|
||||
if (!ContainsHtmlSpecial(s)) return s;
|
||||
|
||||
return s
|
||||
.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """);
|
||||
}
|
||||
|
||||
private static bool ContainsHtmlSpecial(string s)
|
||||
{
|
||||
foreach (char c in s)
|
||||
if (c is '&' or '<' or '>' or '"') return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Mbproxy.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Pure orchestration: reads live state from injected singletons and builds a
|
||||
/// <see cref="StatusResponse"/> for <c>GET /</c> and <c>GET /status.json</c>.
|
||||
///
|
||||
/// <para>No I/O; no side effects. Constructed once via DI; <see cref="Build"/> is the
|
||||
/// only operation and may be called on any thread at any time.</para>
|
||||
/// </summary>
|
||||
internal sealed class StatusSnapshotBuilder
|
||||
{
|
||||
private readonly IOptionsMonitor<MbproxyOptions> _options;
|
||||
private readonly ServiceCounters _serviceCounters;
|
||||
private readonly AssemblyVersionAccessor _version;
|
||||
private readonly ProxyWorker _proxyWorker;
|
||||
|
||||
public StatusSnapshotBuilder(
|
||||
IOptionsMonitor<MbproxyOptions> options,
|
||||
ServiceCounters serviceCounters,
|
||||
AssemblyVersionAccessor version,
|
||||
ProxyWorker proxyWorker)
|
||||
{
|
||||
_options = options;
|
||||
_serviceCounters = serviceCounters;
|
||||
_version = version;
|
||||
_proxyWorker = proxyWorker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a point-in-time <see cref="StatusResponse"/>.
|
||||
/// Each counter is read atomically; no locks are held across the build.
|
||||
/// </summary>
|
||||
public StatusResponse Build()
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var started = _serviceCounters.StartedAtUtc;
|
||||
var uptime = (long)(now - started).TotalSeconds;
|
||||
var supervisors = _proxyWorker.Supervisors;
|
||||
|
||||
// ── Build per-PLC status rows ─────────────────────────────────────────
|
||||
var plcStatuses = new List<PlcStatus>(opts.Plcs.Count);
|
||||
int boundCount = 0;
|
||||
|
||||
foreach (var plc in opts.Plcs)
|
||||
{
|
||||
supervisors.TryGetValue(plc.Name, out var supervisor);
|
||||
|
||||
// Supervisor state
|
||||
SupervisorSnapshot? snap = supervisor?.Snapshot();
|
||||
string stateStr = snap?.State switch
|
||||
{
|
||||
SupervisorState.Bound => "bound",
|
||||
SupervisorState.Recovering => "recovering",
|
||||
_ => "stopped",
|
||||
};
|
||||
if (snap?.State == SupervisorState.Bound) boundCount++;
|
||||
|
||||
// Per-client snapshots
|
||||
var activeUpstreams = supervisor?.ActiveUpstreams ?? Array.Empty<UpstreamPipe>();
|
||||
var clientSnapshots = activeUpstreams
|
||||
.Select(p => new ClientSnapshot(
|
||||
Remote: p.RemoteEp?.ToString() ?? p.RemoteEp?.Address.ToString() ?? "?",
|
||||
ConnectedAtUtc: p.ConnectedAtUtc,
|
||||
PdusForwarded: p.PdusForwardedCount))
|
||||
.ToList();
|
||||
|
||||
// Counter snapshot
|
||||
var counters = supervisor?.CurrentCounters.Snapshot()
|
||||
?? new CounterSnapshot(
|
||||
PdusForwarded: 0,
|
||||
Fc03: 0,
|
||||
Fc04: 0,
|
||||
Fc06: 0,
|
||||
Fc16: 0,
|
||||
FcOther: 0,
|
||||
RewrittenSlots: 0,
|
||||
PartialBcdWarnings: 0,
|
||||
InvalidBcdWarnings: 0,
|
||||
BackendException01: 0,
|
||||
BackendException02: 0,
|
||||
BackendException03: 0,
|
||||
BackendException04: 0,
|
||||
BackendExceptionOther: 0,
|
||||
BytesUpstreamIn: 0,
|
||||
BytesUpstreamOut: 0,
|
||||
RecoveryAttempts: 0,
|
||||
LastBindError: null,
|
||||
LastRoundTripMs: 0.0,
|
||||
ConnectsSuccess: 0,
|
||||
ConnectsFailed: 0,
|
||||
InFlightCount: 0,
|
||||
MaxInFlight: 0,
|
||||
TxIdWraps: 0,
|
||||
BackendDisconnectCascades: 0,
|
||||
BackendQueueDepth: 0);
|
||||
|
||||
// Phase 08: ConnectsSuccess / ConnectsFailed are now tracked in ProxyCounters.
|
||||
long connectsSuccess = counters.ConnectsSuccess;
|
||||
long connectsFailed = counters.ConnectsFailed;
|
||||
|
||||
plcStatuses.Add(new PlcStatus(
|
||||
Name: plc.Name,
|
||||
Host: plc.Host,
|
||||
ListenPort: plc.ListenPort,
|
||||
Listener: new PlcListenerStatus(
|
||||
State: stateStr,
|
||||
LastBindError: snap?.LastBindError,
|
||||
RecoveryAttempts: snap?.RecoveryAttempts ?? 0),
|
||||
Clients: new PlcClientsStatus(
|
||||
Connected: clientSnapshots.Count,
|
||||
RemoteEndpoints: clientSnapshots),
|
||||
Pdus: new PlcPdusStatus(
|
||||
Forwarded: counters.PdusForwarded,
|
||||
ByFc: new FcCounts(counters.Fc03, counters.Fc04, counters.Fc06, counters.Fc16, counters.FcOther),
|
||||
RewrittenSlots: counters.RewrittenSlots,
|
||||
PartialBcdWarnings: counters.PartialBcdWarnings),
|
||||
Backend: new PlcBackendStatus(
|
||||
ConnectsSuccess: connectsSuccess,
|
||||
ConnectsFailed: connectsFailed,
|
||||
ExceptionsByCode: new ExceptionCounts(
|
||||
counters.BackendException01,
|
||||
counters.BackendException02,
|
||||
counters.BackendException03,
|
||||
counters.BackendException04),
|
||||
LastRoundTripMs: counters.LastRoundTripMs,
|
||||
InFlight: counters.InFlightCount,
|
||||
MaxInFlight: counters.MaxInFlight,
|
||||
TxIdWraps: counters.TxIdWraps,
|
||||
DisconnectCascades: counters.BackendDisconnectCascades,
|
||||
QueueDepth: counters.BackendQueueDepth),
|
||||
Bytes: new PlcBytesStatus(
|
||||
UpstreamIn: counters.BytesUpstreamIn,
|
||||
UpstreamOut: counters.BytesUpstreamOut)));
|
||||
}
|
||||
|
||||
// ── Service-wide fields ───────────────────────────────────────────────
|
||||
var service = new ServiceFields(
|
||||
UptimeSeconds: uptime,
|
||||
Version: _version.Version,
|
||||
ConfigLastReloadUtc: _serviceCounters.LastReloadUtc,
|
||||
ConfigReloadCount: _serviceCounters.ReloadAppliedCount,
|
||||
ConfigReloadRejectedCount: _serviceCounters.ReloadRejectedCount);
|
||||
|
||||
var listeners = new ListenersAggregate(
|
||||
Bound: boundCount,
|
||||
Configured: opts.Plcs.Count);
|
||||
|
||||
return new StatusResponse(service, listeners, plcStatuses);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user