0868613890
The DL205/DL260 ECOM emits no TCP keepalives, so an idle backend socket can be silently dropped by a middlebox (switch, firewall, NAT) after 2-5 minutes. Enable OS SO_KEEPALIVE on backend and accepted upstream sockets, and drive a periodic synthetic FC03 heartbeat on each idle backend socket so a dead path is detected before a real client request hits it. Controlled by Connection.Keepalive (ON by default). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
12 KiB
C#
250 lines
12 KiB
C#
using System.Text;
|
|
|
|
namespace Mbproxy.Admin;
|
|
|
|
/// <summary>
|
|
/// Renders a <see cref="StatusResponse"/> as a self-contained HTML page.
|
|
///
|
|
/// <para>Constraints (see <c>docs/Operations/StatusPage.md</c>):</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>Invalid BCD</th><th>Ex 01</th><th>Ex 02</th><th>Ex 03</th><th>Ex 04</th><th>Ex ?</th>");
|
|
sb.Append("<th>RTT ms</th><th>Bytes in</th><th>Bytes out</th>");
|
|
// 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>");
|
|
// Coalescing column. Single cell carries hit / (hit + miss) ratio as a
|
|
// percentage plus the raw hit count for context. Kept compact (one cell) to
|
|
// stay under the 50 KB page-weight budget.
|
|
sb.Append("<th>Coal</th>");
|
|
// Cache column. Single cell carries hit-ratio percent plus raw hit count;
|
|
// an em-dash when no cache-eligible reads have occurred. Page-weight budget
|
|
// assertion stays under 50 KB for the 54-PLC fleet.
|
|
sb.Append("<th>Cache</th>");
|
|
// Keepalive column — heartbeats sent, with failure / idle-disconnect counts
|
|
// shown only when non-zero.
|
|
sb.Append("<th>Keepalive</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.Pdus.InvalidBcdWarnings).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.ExceptionsByCode.CodeOther).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>");
|
|
// 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>");
|
|
// Coalescing ratio cell — "<pct>% (<hit>)". When no coalesced reads have
|
|
// been seen, render an em-dash to keep the cell narrow.
|
|
long coalHit = plc.Backend.CoalescedHitCount;
|
|
long coalMiss = plc.Backend.CoalescedMissCount;
|
|
sb.Append("<td>");
|
|
if (coalHit + coalMiss == 0)
|
|
{
|
|
sb.Append("—");
|
|
}
|
|
else
|
|
{
|
|
int pct = (int)Math.Round(100.0 * coalHit / (coalHit + coalMiss));
|
|
sb.Append(pct).Append("% (").Append(coalHit).Append(')');
|
|
}
|
|
sb.Append("</td>");
|
|
// Cache ratio cell — same pattern as coalescing.
|
|
long cacheHit = plc.Backend.CacheHitCount;
|
|
long cacheMiss = plc.Backend.CacheMissCount;
|
|
sb.Append("<td>");
|
|
if (cacheHit + cacheMiss == 0)
|
|
{
|
|
sb.Append("—");
|
|
}
|
|
else
|
|
{
|
|
int pct = (int)Math.Round(100.0 * cacheHit / (cacheHit + cacheMiss));
|
|
sb.Append(pct).Append("% (").Append(cacheHit).Append(')');
|
|
}
|
|
sb.Append("</td>");
|
|
// Keepalive cell — heartbeats sent; failures + idle-disconnects appended
|
|
// only when non-zero to keep the cell narrow.
|
|
long hbSent = plc.Backend.BackendHeartbeatsSent;
|
|
long hbFailed = plc.Backend.BackendHeartbeatsFailed;
|
|
long hbIdle = plc.Backend.BackendIdleDisconnects;
|
|
sb.Append("<td>");
|
|
if (hbSent == 0 && hbFailed == 0 && hbIdle == 0)
|
|
{
|
|
sb.Append("—");
|
|
}
|
|
else
|
|
{
|
|
sb.Append(hbSent);
|
|
if (hbFailed > 0 || hbIdle > 0)
|
|
sb.Append(" (fail ").Append(hbFailed)
|
|
.Append(", idle-disc ").Append(hbIdle).Append(')');
|
|
}
|
|
sb.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;
|
|
}
|
|
}
|