using System.Text; namespace Mbproxy.Admin; /// /// Renders a as a self-contained HTML page. /// /// Constraints (see docs/Operations/StatusPage.md): /// /// No external assets (CSS/JS/fonts/favicons) — firewalled networks only. /// <meta http-equiv="refresh" content="5"> for auto-refresh. /// Page weight ≤ 50 KB for a 54-PLC fleet. /// Listener state colour-coded: bound=green, recovering=orange, stopped=grey. /// Connected clients rendered as compact [remote (n PDUs)] list (not nested table). /// /// 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} """; /// /// Renders the status page as a complete HTML document string. /// May allocate; intended for the status-page read path only. /// public static string Render(StatusResponse status) { var sb = new StringBuilder(4096); sb.Append(""); sb.Append(""); sb.Append("mbproxy status"); sb.Append(""); sb.Append(""); // ── Header ──────────────────────────────────────────────────────────── sb.Append("

mbproxy status

"); sb.Append("
"); 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("
"); // ── PLC table ───────────────────────────────────────────────────────── if (status.Plcs.Count == 0) { sb.Append("

No PLCs configured.

"); } else { sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); // Multiplexer telemetry columns. sb.Append(""); sb.Append(""); // 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(""); // 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(""); // Keepalive column — heartbeats sent, with failure / idle-disconnect counts // shown only when non-zero. sb.Append(""); sb.Append(""); foreach (var plc in status.Plcs) { sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); // State cell with colour coding string stateClass = plc.Listener.State switch { "bound" => "bound", "recovering" => "recovering", _ => "stopped", }; sb.Append(""); // Connected clients sb.Append(""); // Counter cells sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); // Multiplexer telemetry cells. sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); // Coalescing ratio cell — "% ()". 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(""); // Cache ratio cell — same pattern as coalescing. long cacheHit = plc.Backend.CacheHitCount; long cacheMiss = plc.Backend.CacheMissCount; sb.Append(""); // 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(""); sb.Append(""); } sb.Append("
NameHostPortStateClientsPDUs fwdFC03FC04FC06FC16FC?BCD slotsPartial BCDInvalid BCDEx 01Ex 02Ex 03Ex 04Ex ?RTT msBytes inBytes outIn-flightMax in-flightTxId wrapsCascadesQueueCoalCacheKeepalive
").Append(HtmlEncode(plc.Name)).Append("").Append(HtmlEncode(plc.Host)).Append("").Append(plc.ListenPort).Append("") .Append(HtmlEncode(plc.Listener.State)).Append(""); if (plc.Listener.State == "recovering" && plc.Listener.LastBindError is { } err) { sb.Append("
") .Append(HtmlEncode(err)) .Append(" (attempt ").Append(plc.Listener.RecoveryAttempts).Append(")") .Append(""); } sb.Append("
"); sb.Append(plc.Clients.Connected); if (plc.Clients.RemoteEndpoints.Count > 0) { sb.Append("
"); 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("
").Append(plc.Pdus.Forwarded).Append("").Append(plc.Pdus.ByFc.Fc03).Append("").Append(plc.Pdus.ByFc.Fc04).Append("").Append(plc.Pdus.ByFc.Fc06).Append("").Append(plc.Pdus.ByFc.Fc16).Append("").Append(plc.Pdus.ByFc.Other).Append("").Append(plc.Pdus.RewrittenSlots).Append("").Append(plc.Pdus.PartialBcdWarnings).Append("").Append(plc.Pdus.InvalidBcdWarnings).Append("").Append(plc.Backend.ExceptionsByCode.Code01).Append("").Append(plc.Backend.ExceptionsByCode.Code02).Append("").Append(plc.Backend.ExceptionsByCode.Code03).Append("").Append(plc.Backend.ExceptionsByCode.Code04).Append("").Append(plc.Backend.ExceptionsByCode.CodeOther).Append("").Append(plc.Backend.LastRoundTripMs.ToString("F1")).Append("").Append(plc.Bytes.UpstreamIn).Append("").Append(plc.Bytes.UpstreamOut).Append("").Append(plc.Backend.InFlight).Append("").Append(plc.Backend.MaxInFlight).Append("").Append(plc.Backend.TxIdWraps).Append("").Append(plc.Backend.DisconnectCascades).Append("").Append(plc.Backend.QueueDepth).Append(""); 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(""); 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(""); 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("
"); } sb.Append(""); 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; } }