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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user