compares plc.directory with other mirrors
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

init

dawn 2fe430bd

+1363
+1
.gitignore
··· 1 + /data.json
+1
README.md
··· 1 + plc mirror comparer @ compare.plc.klbr.net
+24
UNLICENSE
··· 1 + This is free and unencumbered software released into the public domain. 2 + 3 + Anyone is free to copy, modify, publish, use, compile, sell, or 4 + distribute this software, either in source code form or as a compiled 5 + binary, for any purpose, commercial or non-commercial, and by any 6 + means. 7 + 8 + In jurisdictions that recognize copyright laws, the author or authors 9 + of this software dedicate any and all copyright interest in the 10 + software to the public domain. We make this dedication for the benefit 11 + of the public at large and to the detriment of our heirs and 12 + successors. We intend this dedication to be an overt act of 13 + relinquishment in perpetuity of all present and future rights to this 14 + software under copyright law. 15 + 16 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 + IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 + OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 + OTHER DEALINGS IN THE SOFTWARE. 23 + 24 + For more information, please refer to <https://unlicense.org/>
+951
index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>compare plc</title> 7 + <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script> 8 + <style> 9 + * { box-sizing: border-box; margin: 0; padding: 0; } 10 + 11 + body { 12 + background: #0e0e0e; 13 + color: #c4c4c4; 14 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; 15 + font-size: 14px; 16 + padding: 2rem 1.5rem 4rem; 17 + } 18 + 19 + .wrap { max-width: 980px; margin: 0 auto; } 20 + 21 + /* ── header ── */ 22 + header { margin-bottom: 2.5rem; } 23 + h1 { 24 + color: #e8c840; 25 + font-size: 1.6rem; 26 + font-weight: 700; 27 + letter-spacing: 0.02em; 28 + margin-bottom: 0.25rem; 29 + } 30 + .subtitle { color: #666; font-size: 0.78rem; } 31 + 32 + /* ── sections ── */ 33 + .section { margin-bottom: 3.5rem; } 34 + 35 + .section-head { 36 + display: flex; 37 + align-items: baseline; 38 + gap: 0.75rem; 39 + margin-bottom: 1rem; 40 + padding-bottom: 0.6rem; 41 + border-bottom: 1px solid #242424; 42 + } 43 + .section-head h2 { 44 + font-size: 0.7rem; 45 + font-weight: 600; 46 + text-transform: uppercase; 47 + letter-spacing: 0.1em; 48 + color: #999; 49 + } 50 + .section-head .meta { font-size: 0.72rem; color: #595959; } 51 + .section-head .meta b { color: #888; font-weight: 400; } 52 + 53 + /* ── coverage table ── */ 54 + table { width: 100%; border-collapse: collapse; font-size: 0.8rem; } 55 + 56 + thead th { 57 + text-align: left; 58 + font-size: 0.68rem; 59 + font-weight: 500; 60 + letter-spacing: 0.06em; 61 + text-transform: uppercase; 62 + color: #666; 63 + padding: 0 0 0.5rem; 64 + border-bottom: 1px solid #242424; 65 + position: relative; 66 + } 67 + th:not(:first-child), td:not(:first-child) { text-align: right; } 68 + 69 + /* ── column header tooltips ── */ 70 + th .tip-label { 71 + border-bottom: 1px dashed #444; 72 + cursor: help; 73 + } 74 + th .tip { 75 + display: none; 76 + position: absolute; 77 + top: calc(100% + 10px); 78 + left: 0; 79 + background: #1a1a1a; 80 + border: 1px solid #333; 81 + border-radius: 5px; 82 + color: #c0c0c0; 83 + font-size: 0.72rem; 84 + font-weight: 400; 85 + text-transform: none; 86 + letter-spacing: 0; 87 + line-height: 1.55; 88 + padding: 0.55rem 0.75rem; 89 + width: 230px; 90 + z-index: 200; 91 + white-space: normal; 92 + box-shadow: 0 6px 20px rgba(0,0,0,0.6); 93 + pointer-events: none; 94 + } 95 + th .tip::before { 96 + content: ''; 97 + position: absolute; 98 + top: -5px; 99 + left: 14px; 100 + width: 8px; height: 8px; 101 + background: #1a1a1a; 102 + border-left: 1px solid #333; 103 + border-top: 1px solid #333; 104 + transform: rotate(45deg); 105 + } 106 + th .tip .tip-title { 107 + display: block; 108 + color: #e0e0e0; 109 + font-weight: 600; 110 + margin-bottom: 0.3rem; 111 + text-transform: uppercase; 112 + font-size: 0.68rem; 113 + letter-spacing: 0.06em; 114 + } 115 + th .tip em { 116 + color: #888; 117 + font-style: normal; 118 + } 119 + th:hover .tip { display: block; } 120 + /* right-anchored for right-aligned columns so they don't overflow */ 121 + th.th-r .tip { left: auto; right: 0; } 122 + th.th-r .tip::before { left: auto; right: 14px; } 123 + 124 + tbody td { 125 + padding: 0.38rem 0; 126 + border-bottom: 1px solid #1c1c1c; 127 + color: #bbb; 128 + font-variant-numeric: tabular-nums; 129 + } 130 + tbody tr:last-child td { border-bottom: none; } 131 + 132 + .td-host { text-align: left !important; } 133 + .td-host-inner { 134 + display: inline-flex; 135 + align-items: center; 136 + gap: 0.45rem; 137 + } 138 + .td-name { color: #e8e8e8; font-weight: 500; } 139 + .td-primary .td-name { color: #e8c840; } 140 + 141 + .dot { 142 + width: 6px; height: 6px; 143 + border-radius: 50%; 144 + flex-shrink: 0; 145 + display: inline-block; 146 + } 147 + .dot-ok { background: #5ab05e; } 148 + .dot-err { background: #b05a5e; } 149 + .dot-dim { background: #444; } 150 + 151 + td.lag-ahead { color: #5aaa88; } 152 + td.lag-good { color: #5aaa5e; } 153 + td.lag-med { color: #aaaa44; } 154 + td.lag-bad { color: #aa5a44; } 155 + td.dim { color: #555; } 156 + 157 + .bar-wrap { display: inline-flex; width: 72px; height: 5px; overflow: hidden; vertical-align: middle; border-radius: 1px; } 158 + .bar-seen { background: #4a8a4a; } 159 + .bar-miss { background: #8a4a4a; } 160 + 161 + .empty-row td { text-align: center !important; color: #444; padding: 2rem 0; font-size: 0.8rem; } 162 + 163 + /* ── compare controls ── */ 164 + #hostList { 165 + display: flex; 166 + flex-wrap: wrap; 167 + gap: 0.4rem 1.2rem; 168 + margin-bottom: 0.75rem; 169 + } 170 + 171 + .host-row { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; } 172 + .host-row input[type=checkbox] { accent-color: #7799ff; cursor: pointer; width: 13px; height: 13px; } 173 + .host-row label { cursor: pointer; color: #aaa; } 174 + .host-row .hurl { color: #595959; font-size: 0.72rem; } 175 + 176 + .add-row { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; } 177 + 178 + input[type=text] { 179 + background: #181818; 180 + border: 1px solid #2e2e2e; 181 + color: #ccc; 182 + padding: 0.32rem 0.6rem; 183 + font-size: 0.8rem; 184 + flex: 1; 185 + outline: none; 186 + border-radius: 3px; 187 + max-width: 280px; 188 + } 189 + input[type=text]:focus { border-color: #444; } 190 + input[type=text]::placeholder { color: #3e3e3e; } 191 + 192 + button { 193 + background: #1e1e1e; 194 + border: 1px solid #333; 195 + color: #aaa; 196 + padding: 0.32rem 0.85rem; 197 + font-size: 0.8rem; 198 + cursor: pointer; 199 + border-radius: 3px; 200 + font-family: inherit; 201 + } 202 + button:hover { background: #272727; color: #ccc; } 203 + 204 + .filter-row { 205 + display: flex; 206 + flex-wrap: wrap; 207 + gap: 0.5rem 1rem; 208 + font-size: 0.76rem; 209 + align-items: center; 210 + margin-bottom: 1.5rem; 211 + } 212 + .filter-row span { color: #777; } 213 + .filter-row label { display: flex; align-items: center; gap: 0.3rem; cursor: pointer; color: #999; } 214 + .filter-row input[type=checkbox] { accent-color: #999; cursor: pointer; width: 12px; height: 12px; } 215 + 216 + /* ── throughput ── */ 217 + #tputWrap { 218 + margin-bottom: 2rem; 219 + padding: 0.8rem 0; 220 + border-top: 1px solid #222; 221 + border-bottom: 1px solid #222; 222 + display: none; 223 + } 224 + #tputWrap.show { display: block; } 225 + #tputLabel { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em; color: #666; margin-bottom: 0.6rem; font-weight: 500; } 226 + 227 + /* ── compare panels (no cards) ── */ 228 + #panels { 229 + display: grid; 230 + grid-template-columns: repeat(2, 1fr); 231 + gap: 1.5rem 2.5rem; 232 + align-items: start; 233 + } 234 + .empty-compare { grid-column: 1 / -1; } 235 + .host-panel { min-width: 0; } 236 + 237 + .panel-head { 238 + display: flex; 239 + align-items: center; 240 + gap: 0.5rem; 241 + margin-bottom: 0.3rem; 242 + } 243 + .panel-head::after { 244 + content: ''; 245 + flex: 1; 246 + height: 1px; 247 + background: #242424; 248 + } 249 + .panel-name { font-weight: 600; font-size: 0.88rem; color: #e8e8e8; } 250 + 251 + .panel-stat { font-size: 0.73rem; color: #666; margin-bottom: 0.8rem; } 252 + .panel-stat.ok { color: #5a9a5e; } 253 + .panel-stat.err { color: #9a5a5e; } 254 + 255 + .chart-lbl { 256 + font-size: 0.66rem; 257 + text-transform: uppercase; 258 + letter-spacing: 0.06em; 259 + color: #5a5a5a; 260 + margin-bottom: 0.25rem; 261 + margin-top: 0.9rem; 262 + font-weight: 500; 263 + } 264 + .chart-lbl:first-of-type { margin-top: 0; } 265 + 266 + .empty-compare { text-align: center; color: #444; padding: 2.5rem; font-size: 0.82rem; } 267 + 268 + /* ── coverage history strip ── */ 269 + #histWrap { 270 + margin-top: 1.4rem; 271 + display: none; 272 + } 273 + .hist-header { 274 + display: flex; 275 + justify-content: space-between; 276 + font-size: 0.66rem; 277 + color: #444; 278 + margin-bottom: 0.45rem; 279 + user-select: none; 280 + } 281 + .hist-row { 282 + display: flex; 283 + align-items: center; 284 + margin-bottom: 3px; 285 + } 286 + .hist-label { 287 + width: 110px; 288 + font-size: 0.71rem; 289 + color: #666; 290 + text-align: right; 291 + padding-right: 10px; 292 + flex-shrink: 0; 293 + white-space: nowrap; 294 + overflow: hidden; 295 + text-overflow: ellipsis; 296 + } 297 + .hist-cells { display: flex; gap: 2px; } 298 + .hist-cell { 299 + width: 11px; 300 + height: 18px; 301 + border-radius: 2px; 302 + cursor: default; 303 + flex-shrink: 0; 304 + transition: filter 0.1s; 305 + } 306 + .hist-cell:hover { filter: brightness(1.35); } 307 + 308 + /* ── floating tooltip (used by history cells) ── */ 309 + #floatTip { 310 + position: fixed; 311 + background: #1a1a1a; 312 + border: 1px solid #333; 313 + border-radius: 5px; 314 + color: #c0c0c0; 315 + font-size: 0.72rem; 316 + line-height: 1.6; 317 + padding: 0.5rem 0.75rem; 318 + pointer-events: none; 319 + z-index: 1000; 320 + white-space: nowrap; 321 + box-shadow: 0 6px 20px rgba(0,0,0,0.6); 322 + display: none; 323 + } 324 + #floatTip .ft-time { color: #888; font-size: 0.68rem; margin-bottom: 0.2rem; } 325 + #floatTip .ft-row { display: flex; gap: 0.8rem; justify-content: space-between; } 326 + #floatTip .ft-key { color: #666; } 327 + #floatTip .ft-val { color: #ddd; font-variant-numeric: tabular-nums; } 328 + </style> 329 + </head> 330 + <body> 331 + <div class="wrap"> 332 + 333 + <header> 334 + <h1>compare plc</h1> 335 + <p class="subtitle">mirror coverage and live event comparison</p> 336 + </header> 337 + 338 + <!-- ── Coverage ────────────────────────────────────────────────────────────── --> 339 + <section class="section"> 340 + <div class="section-head"> 341 + <h2>Coverage</h2> 342 + <span class="meta"> 343 + 15 min window &nbsp;·&nbsp; lag = mirror recv − primary recv, same server clock 344 + &nbsp;·&nbsp; <b id="lastUpdated">–</b> 345 + </span> 346 + </div> 347 + <table> 348 + <thead> 349 + <tr> 350 + <th> 351 + <span class="tip-label">host</span> 352 + <span class="tip"> 353 + <span class="tip-title">host</span> 354 + Mirror being tracked by the server. 355 + <em>● green</em> = connected, <em>● red</em> = disconnected (will reconnect automatically). 356 + </span> 357 + </th> 358 + <th class="th-r"> 359 + <span class="tip-label">total events</span> 360 + <span class="tip"> 361 + <span class="tip-title">total events</span> 362 + All operations received from this host since the server started, across all op types. 363 + Not windowed — resets on server restart. 364 + </span> 365 + </th> 366 + <th class="th-r"> 367 + <span class="tip-label">ops (15 min)</span> 368 + <span class="tip"> 369 + <span class="tip-title">ops (15 min)</span> 370 + Operations received from this host in the rolling <em>15-minute window</em>. 371 + Used as the numerator for coverage and missed calculations. 372 + </span> 373 + </th> 374 + <th class="th-r"> 375 + <span class="tip-label">coverage</span> 376 + <span class="tip"> 377 + <span class="tip-title">coverage</span> 378 + Share of plc.directory's ops in the 15-min window that this mirror also delivered. 379 + <em>100%</em> = no ops missed. Calculated as <em>mirror ops ÷ primary ops</em>. 380 + </span> 381 + </th> 382 + <th class="th-r"> 383 + <span class="tip-label">missed</span> 384 + <span class="tip"> 385 + <span class="tip-title">missed</span> 386 + Ops seen by plc.directory in the 15-min window that this mirror has <em>not</em> delivered. 387 + May indicate the mirror is lagging, dropping events, or behind on propagation. 388 + </span> 389 + </th> 390 + <th class="th-r"> 391 + <span class="tip-label">lag p50 / p95 / p99</span> 392 + <span class="tip"> 393 + <span class="tip-title">lag p50 / p95 / p99</span> 394 + How many milliseconds after plc.directory this mirror delivered each op, corrected for network distance. 395 + Correction = <em>mirror OTT − primary OTT</em>, where OTT = TCP RTT ÷ 2 (SYN/SYN-ACK, pure network latency). 396 + Positive = mirror is behind. Small negatives = within noise floor. 397 + </span> 398 + </th> 399 + <th class="th-r"> 400 + <span class="tip-label" style="border:none"> </span> 401 + <span class="tip"> 402 + <span class="tip-title">coverage bar</span> 403 + Visual ratio of <em style="color:#4a8a4a">received</em> (green) vs <em style="color:#8a4a4a">missed</em> (red) ops in the 15-min window. 404 + </span> 405 + </th> 406 + </tr> 407 + </thead> 408 + <tbody id="covBody"> 409 + <tr class="empty-row"><td colspan="7">connecting to server…</td></tr> 410 + </tbody> 411 + </table> 412 + 413 + <div id="histWrap"> 414 + <div class="hist-header"> 415 + <span id="histOldLabel">←</span> 416 + <span style="color:#333">coverage history &nbsp;·&nbsp; non-overlapping 5 min intervals</span> 417 + <span>now →</span> 418 + </div> 419 + <div id="histGrid"></div> 420 + </div> 421 + </section> 422 + 423 + <div id="floatTip"></div> 424 + 425 + <!-- ── Live Compare ─────────────────────────────────────────────────────────── --> 426 + <section class="section"> 427 + <div class="section-head"> 428 + <h2>Live Compare</h2> 429 + <span class="meta">direct WebSocket connections from browser &nbsp;·&nbsp; receive latency = now − event.createdAt</span> 430 + </div> 431 + 432 + <div id="hostList"></div> 433 + 434 + <div class="add-row"> 435 + <input type="text" id="customUrl" placeholder="wss://…"> 436 + <button id="addBtn">add host</button> 437 + </div> 438 + 439 + <div class="filter-row"> 440 + <span>op types:</span> 441 + <label><input type="checkbox" data-op="plc_operation" checked> plc_operation</label> 442 + <label><input type="checkbox" data-op="plc_tombstone" checked> plc_tombstone</label> 443 + <label><input type="checkbox" data-op="unknown" checked> unknown</label> 444 + </div> 445 + 446 + <div id="tputWrap"> 447 + <div id="tputLabel">events / second</div> 448 + <canvas id="tputCanvas" height="95"></canvas> 449 + </div> 450 + 451 + <div id="panels"> 452 + <div class="empty-compare" id="emptyCompare">enable a host above to compare</div> 453 + </div> 454 + </section> 455 + 456 + </div><!-- /wrap --> 457 + <script> 458 + // ── config ───────────────────────────────────────────────────────────────────── 459 + const API_BASE = 'http://localhost:7331'; 460 + const BUCKET_MS = 1500; 461 + const N_BUCKETS = 8; 462 + const COLORS = ['#7799ff','#ffbb44','#44ddaa','#ff7788','#cc88ff','#88ddff','#ffdd88']; 463 + const KNOWN_OPS = new Set(['plc_operation','plc_tombstone']); 464 + 465 + // receive latency bins (ms): 0,50,100,150,...,500,750,1000,2000,+ 466 + const LAT_EDGES = [0,50,100,150,200,250,300,400,500,750,1000,2000]; 467 + const LAT_LABELS = ['<0','50','100','150','200','250','300','400','500','750','1k','2k','+']; 468 + const N_LAT = LAT_LABELS.length; 469 + 470 + // ── state ────────────────────────────────────────────────────────────────────── 471 + const monitors = new Map(); // url -> monitor (compare section) 472 + const enabled = new Set(); 473 + const opFilter = new Set(['plc_operation','plc_tombstone','unknown']); 474 + let colorIdx = 0; 475 + let tputChart = null; 476 + 477 + // ── helpers ──────────────────────────────────────────────────────────────────── 478 + const latBin = ms => { 479 + if (ms < 0) return 0; 480 + for (let i = 0; i < LAT_EDGES.length; i++) if (ms < LAT_EDGES[i]) return i; 481 + return N_LAT - 1; 482 + }; 483 + 484 + const opType = ev => ev?.operation?.type ?? 'unknown'; 485 + const passes = ev => { const t = opType(ev); return KNOWN_OPS.has(t) ? opFilter.has(t) : opFilter.has('unknown'); }; 486 + 487 + function mkMonitor(url, name, color) { 488 + return { 489 + url, name, color, 490 + ws: null, connected: false, totalEvents: 0, 491 + latBins: new Array(N_LAT).fill(0), 492 + buckets: new Array(N_BUCKETS).fill(0), 493 + curCount: 0, 494 + panelEl: null, statusEl: null, latChart: null, 495 + }; 496 + } 497 + 498 + const el = (tag, cls, text) => { 499 + const e = document.createElement(tag); 500 + if (cls != null) e.className = cls; 501 + if (text != null) e.textContent = text; 502 + return e; 503 + }; 504 + 505 + // ── floating tooltip ────────────────────────────────────────────────────────── 506 + const floatTip = document.getElementById('floatTip'); 507 + 508 + function showFloatTip(e, html) { 509 + floatTip.innerHTML = html; 510 + floatTip.style.display = 'block'; 511 + moveFloatTip(e); 512 + } 513 + function moveFloatTip(e) { 514 + const pad = 14; 515 + const w = floatTip.offsetWidth, h = floatTip.offsetHeight; 516 + let x = e.clientX + pad, y = e.clientY + pad; 517 + if (x + w > window.innerWidth - 8) x = e.clientX - w - pad; 518 + if (y + h > window.innerHeight - 8) y = e.clientY - h - pad; 519 + floatTip.style.left = x + 'px'; 520 + floatTip.style.top = y + 'px'; 521 + } 522 + function hideFloatTip() { floatTip.style.display = 'none'; } 523 + 524 + // ── coverage section (server polling) ───────────────────────────────────────── 525 + let lastFetch = 0; 526 + let statsData = null; 527 + 528 + async function fetchStats() { 529 + try { 530 + const res = await fetch(`${API_BASE}/api/stats`); 531 + if (!res.ok) throw new Error(res.statusText); 532 + const body = await res.json(); 533 + const stats = body.mirrors ?? body; // fallback for old format 534 + const snaps = body.snapshots ?? []; 535 + statsData = stats; 536 + lastFetch = Date.now(); 537 + syncMirrorList(stats); 538 + renderCovTable(stats); 539 + renderHistory(stats, snaps); 540 + } catch { 541 + document.getElementById('covBody').innerHTML = 542 + '<tr class="empty-row"><td colspan="7">server unavailable</td></tr>'; 543 + document.getElementById('lastUpdated').textContent = 'offline'; 544 + } 545 + } 546 + 547 + function lagClass(p50) { 548 + if (p50 == null) return ''; 549 + if (p50 < 0) return 'lag-ahead'; 550 + if (p50 < 100) return 'lag-good'; 551 + if (p50 < 500) return 'lag-med'; 552 + return 'lag-bad'; 553 + } 554 + 555 + function fmtLag(v) { 556 + if (v == null) return '?'; 557 + const sign = v >= 0 ? '+' : ''; 558 + if (Math.abs(v) >= 10000) return `${sign}${(v/1000).toFixed(1)}s`; 559 + return `${sign}${v}ms`; 560 + } 561 + 562 + function renderCovTable(stats) { 563 + const entries = Object.values(stats); 564 + if (!entries.length) { 565 + document.getElementById('covBody').innerHTML = 566 + '<tr class="empty-row"><td colspan="7">no mirrors tracked</td></tr>'; 567 + return; 568 + } 569 + 570 + // primary first, then sorted by name 571 + entries.sort((a, b) => { 572 + if (a.isPrimary) return -1; 573 + if (b.isPrimary) return 1; 574 + return a.name.localeCompare(b.name); 575 + }); 576 + 577 + const rows = entries.map(m => { 578 + const dotCls = m.connected ? 'dot-ok' : 'dot-err'; 579 + const covStr = m.isPrimary ? '—' 580 + : m.coverage != null ? (m.coverage * 100).toFixed(2) + '%' : '—'; 581 + const missStr = m.isPrimary ? '<span class="dim">—</span>' : m.missed.toLocaleString(); 582 + 583 + let lagStr = '<span class="dim">—</span>'; 584 + let cls = 'dim'; 585 + if (m.lagStats) { 586 + lagStr = `${fmtLag(m.lagStats.p50)} / ${fmtLag(m.lagStats.p95)} / ${fmtLag(m.lagStats.p99)}`; 587 + cls = lagClass(m.lagStats.p50); 588 + } 589 + 590 + const total = m.primaryOps; 591 + const sW = m.isPrimary ? 72 592 + : total > 0 ? Math.round(m.opsInWindow / total * 72) : 0; 593 + const mW = 72 - sW; 594 + 595 + return `<tr${m.isPrimary ? ' class="td-primary"' : ''}> 596 + <td class="td-host"> 597 + <span class="td-host-inner"> 598 + <span class="dot ${dotCls}"></span> 599 + <span class="td-name">${m.name}</span> 600 + </span> 601 + </td> 602 + <td>${m.totalEvents.toLocaleString()}</td> 603 + <td>${m.opsInWindow.toLocaleString()}</td> 604 + <td>${covStr}</td> 605 + <td>${missStr}</td> 606 + <td class="${cls}"${m.rttMs != null || m.correctionMs != null ? ` data-rtt="${m.rttMs ?? ''}" data-corr="${m.correctionMs ?? ''}"` : ''}>${lagStr}</td> 607 + <td><div class="bar-wrap"> 608 + <div class="bar-seen" style="width:${sW}px"></div> 609 + <div class="bar-miss" style="width:${mW}px"></div> 610 + </div></td> 611 + </tr>`; 612 + }).join(''); 613 + 614 + document.getElementById('covBody').innerHTML = rows; 615 + const ago = Math.round((Date.now() - lastFetch) / 1000); 616 + document.getElementById('lastUpdated').textContent = `updated ${ago}s ago`; 617 + } 618 + 619 + // keep lastUpdated text fresh between polls 620 + setInterval(() => { 621 + if (!lastFetch) return; 622 + const ago = Math.round((Date.now() - lastFetch) / 1000); 623 + const el = document.getElementById('lastUpdated'); 624 + if (el) el.textContent = `updated ${ago}s ago`; 625 + }, 1000); 626 + 627 + // ── coverage history ────────────────────────────────────────────────────────── 628 + 629 + function fmtAgo(ms) { 630 + const s = Math.floor(ms / 1000); 631 + if (s < 90) return `${s}s ago`; 632 + const m = Math.floor(s / 60); 633 + if (m < 90) return `${m}m ago`; 634 + const h = Math.floor(m / 60), rm = m % 60; 635 + return rm ? `${h}h ${rm}m ago` : `${h}h ago`; 636 + } 637 + 638 + function covColor(cov) { 639 + if (cov === null || cov === undefined) return '#222'; 640 + if (cov >= 0.9999) return '#2d6e2d'; 641 + if (cov >= 0.90) return '#7a3022'; 642 + return '#6e2222'; 643 + } 644 + 645 + function renderHistory(stats, snaps) { 646 + const wrap = document.getElementById('histWrap'); 647 + if (!snaps || !snaps.length) { wrap.style.display = 'none'; return; } 648 + wrap.style.display = 'block'; 649 + 650 + const urls = Object.entries(stats) 651 + .filter(([, m]) => !m.isPrimary) 652 + .sort(([, a], [, b]) => a.name.localeCompare(b.name)) 653 + .map(([url]) => url); 654 + if (!urls.length) return; 655 + 656 + const grid = document.getElementById('histGrid'); 657 + grid.innerHTML = ''; 658 + 659 + for (const url of urls) { 660 + const m = stats[url]; 661 + const row = document.createElement('div'); 662 + row.className = 'hist-row'; 663 + 664 + const lbl = document.createElement('div'); 665 + lbl.className = 'hist-label'; 666 + lbl.textContent = m.name; 667 + row.appendChild(lbl); 668 + 669 + const cells = document.createElement('div'); 670 + cells.className = 'hist-cells'; 671 + 672 + for (const snap of snaps) { 673 + const sm = snap.mirrors[url]; 674 + const cell = document.createElement('div'); 675 + cell.className = 'hist-cell'; 676 + cell.style.background = covColor(sm?.coverage); 677 + 678 + const dt = new Date(snap.ts); 679 + const timeStr = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 680 + const covStr = sm?.coverage != null ? (sm.coverage * 100).toFixed(3) + '%' : '—'; 681 + const html = ` 682 + <div class="ft-time">${timeStr} &nbsp;·&nbsp; ${fmtAgo(Date.now() - snap.ts)}</div> 683 + <div class="ft-row"><span class="ft-key">coverage</span><span class="ft-val">${covStr}</span></div> 684 + <div class="ft-row"><span class="ft-key">ops</span><span class="ft-val">${(sm?.ops ?? 0).toLocaleString()} / ${(sm?.primaryOps ?? 0).toLocaleString()}</span></div> 685 + <div class="ft-row"><span class="ft-key">missed</span><span class="ft-val">${(sm?.missed ?? 0).toLocaleString()}</span></div>`; 686 + 687 + cell.addEventListener('mouseenter', e => showFloatTip(e, html)); 688 + cell.addEventListener('mousemove', moveFloatTip); 689 + cell.addEventListener('mouseleave', hideFloatTip); 690 + cells.appendChild(cell); 691 + } 692 + 693 + row.appendChild(cells); 694 + grid.appendChild(row); 695 + } 696 + 697 + const oldest = snaps[0].ts; 698 + document.getElementById('histOldLabel').textContent = 699 + `← ${fmtAgo(Date.now() - oldest)}`; 700 + } 701 + 702 + // ── mirror list sync ────────────────────────────────────────────────────────── 703 + // Adds server-known mirrors to the compare host list. 704 + function syncMirrorList(stats) { 705 + for (const [url, data] of Object.entries(stats)) { 706 + if (!monitors.has(url)) { 707 + addCompareHost(url, data.name); 708 + } 709 + } 710 + } 711 + 712 + // ── compare section ──────────────────────────────────────────────────────────── 713 + function addCompareHost(url, name) { 714 + if (monitors.has(url)) return; 715 + const color = COLORS[colorIdx++ % COLORS.length]; 716 + monitors.set(url, mkMonitor(url, name, color)); 717 + addHostRow(url, name, color); 718 + } 719 + 720 + function addHostRow(url, name, color) { 721 + const id = 'cb-' + btoa(url).replace(/[+=\/]/g, '_'); 722 + const row = el('div', 'host-row'); 723 + const cb = document.createElement('input'); 724 + cb.type = 'checkbox'; cb.id = id; 725 + cb.onchange = () => toggleHost(url, cb.checked); 726 + const lbl = document.createElement('label'); 727 + lbl.htmlFor = id; 728 + lbl.innerHTML = `<span style="color:${color}">${name}</span> <span class="hurl">(${url})</span>`; 729 + row.append(cb, lbl); 730 + document.getElementById('hostList').appendChild(row); 731 + } 732 + 733 + function toggleHost(url, on) { 734 + if (on) { 735 + enabled.add(url); 736 + const m = monitors.get(url); 737 + if (!m.panelEl) addPanel(m); 738 + connectCompare(url); 739 + } else { 740 + enabled.delete(url); 741 + disconnectCompare(url); 742 + removePanel(monitors.get(url)); 743 + } 744 + rebuildTput(); 745 + } 746 + 747 + // ── compare WebSocket ───────────────────────────────────────────────────────── 748 + function connectCompare(url) { 749 + const m = monitors.get(url); 750 + if (!m || m.ws) return; 751 + const ws = new WebSocket(url + '/export/stream'); 752 + m.ws = ws; 753 + setStatus(m, 'connecting…'); 754 + 755 + ws.onopen = () => { m.connected = true; setStatus(m, `connected (${m.totalEvents})`, true); }; 756 + ws.onerror = () => setStatus(m, 'error'); 757 + 758 + ws.onmessage = ({ data }) => { 759 + let ev; try { ev = JSON.parse(data); } catch { return; } 760 + if (!passes(ev)) return; 761 + const lat = Date.now() - new Date(ev.createdAt).getTime(); 762 + m.totalEvents++; 763 + m.curCount++; 764 + m.latBins[latBin(lat)]++; 765 + if (m.totalEvents % 200 === 0) 766 + setStatus(m, `connected (${m.totalEvents.toLocaleString()})`, true); 767 + }; 768 + 769 + ws.onclose = () => { 770 + m.connected = false; m.ws = null; 771 + if (enabled.has(url)) { 772 + setStatus(m, 'disconnected — retrying in 3s'); 773 + setTimeout(() => enabled.has(url) && connectCompare(url), 3000); 774 + } else { 775 + setStatus(m, 'disconnected'); 776 + } 777 + }; 778 + } 779 + 780 + function disconnectCompare(url) { 781 + const m = monitors.get(url); 782 + if (!m?.ws) return; 783 + const ws = m.ws; m.ws = null; ws.close(); 784 + setStatus(m, 'disconnected'); 785 + } 786 + 787 + // ── panel rendering ─────────────────────────────────────────────────────────── 788 + function setStatus(m, text, ok = false) { 789 + if (!m.statusEl) return; 790 + m.statusEl.textContent = text; 791 + m.statusEl.className = 'panel-stat' + (ok ? ' ok' : ''); 792 + } 793 + 794 + function mkChart(canvas, labels, color, logY) { 795 + return new Chart(canvas, { 796 + type: 'bar', 797 + data: { 798 + labels, 799 + datasets: [{ data: new Array(labels.length).fill(null), backgroundColor: color + 'bb', borderColor: color, borderWidth: 1 }] 800 + }, 801 + options: { 802 + animation: false, responsive: true, 803 + plugins: { legend: { display: false }, tooltip: { enabled: false } }, 804 + scales: { 805 + x: { ticks: { color: '#666', font: { size: 9, family: 'system-ui' } }, grid: { color: '#1e1e1e' } }, 806 + y: logY 807 + ? { type: 'logarithmic', min: 0.9, grid: { color: '#1e1e1e' }, 808 + ticks: { color: '#666', font: { size: 9 }, callback: v => [1,10,100,1000,10000].includes(v) ? v : null } } 809 + : { beginAtZero: true, grid: { color: '#1e1e1e' }, 810 + ticks: { color: '#666', font: { size: 9 } } } 811 + } 812 + } 813 + }); 814 + } 815 + 816 + function addPanel(m) { 817 + document.getElementById('emptyCompare')?.remove(); 818 + 819 + const div = el('div', 'host-panel'); 820 + 821 + const head = el('div', 'panel-head'); 822 + const nameSpan = el('span', 'panel-name'); 823 + nameSpan.textContent = m.name; 824 + nameSpan.style.color = m.color; 825 + head.appendChild(nameSpan); 826 + div.appendChild(head); 827 + 828 + const stat = el('div', 'panel-stat', ''); 829 + div.appendChild(stat); 830 + 831 + const latLbl = el('div', 'chart-lbl', 'receive latency (ms since createdAt)'); 832 + const latC = el('canvas'); latC.height = 110; 833 + div.append(latLbl, latC); 834 + 835 + m.panelEl = div; 836 + m.statusEl = stat; 837 + m.latChart = mkChart(latC, LAT_LABELS, m.color, true); 838 + 839 + document.getElementById('panels').appendChild(div); 840 + } 841 + 842 + function removePanel(m) { 843 + m.latChart?.destroy(); m.latChart = null; 844 + m.panelEl?.remove(); m.panelEl = null; m.statusEl = null; 845 + if (!document.getElementById('panels').querySelector('.host-panel')) { 846 + const e = el('div', 'empty-compare', 'enable a host above to compare'); 847 + e.id = 'emptyCompare'; 848 + document.getElementById('panels').appendChild(e); 849 + } 850 + } 851 + 852 + // ── throughput chart ────────────────────────────────────────────────────────── 853 + const tputLabels = () => Array.from({ length: N_BUCKETS }, (_, i) => 854 + i === N_BUCKETS - 1 ? 'now' : `-${((N_BUCKETS - 1 - i) * BUCKET_MS / 1000).toFixed(1)}s` 855 + ); 856 + 857 + const tputDatasets = () => [...enabled].map(url => { 858 + const m = monitors.get(url); 859 + return { 860 + label: m.name, 861 + data: m.buckets.map(c => +(c / (BUCKET_MS / 1000)).toFixed(2)), 862 + backgroundColor: m.color + 'aa', 863 + borderColor: m.color, 864 + borderWidth: 1, 865 + }; 866 + }); 867 + 868 + function rebuildTput() { 869 + const wrap = document.getElementById('tputWrap'); 870 + tputChart?.destroy(); tputChart = null; 871 + if (!enabled.size) { wrap.classList.remove('show'); return; } 872 + wrap.classList.add('show'); 873 + tputChart = new Chart(document.getElementById('tputCanvas'), { 874 + type: 'bar', 875 + data: { labels: tputLabels(), datasets: tputDatasets() }, 876 + options: { 877 + animation: false, responsive: true, 878 + plugins: { legend: { labels: { color: '#999', font: { size: 10, family: 'system-ui' }, boxWidth: 12, padding: 14 } } }, 879 + scales: { 880 + x: { ticks: { color: '#777', font: { size: 10, family: 'system-ui' } }, grid: { color: '#1e1e1e' }, 881 + title: { display: true, text: 'time', color: '#555', font: { size: 10 } } }, 882 + y: { beginAtZero: true, ticks: { color: '#777', font: { size: 10 } }, grid: { color: '#1e1e1e' } } 883 + } 884 + } 885 + }); 886 + } 887 + 888 + // ── tick ────────────────────────────────────────────────────────────────────── 889 + function tick() { 890 + for (const m of monitors.values()) { 891 + m.buckets.shift(); 892 + m.buckets.push(m.curCount); 893 + m.curCount = 0; 894 + if (m.latChart) { m.latChart.data.datasets[0].data = m.latBins.map(v => v || null); m.latChart.update('none'); } 895 + if (m.connected) setStatus(m, `connected (${m.totalEvents.toLocaleString()})`, true); 896 + } 897 + if (tputChart) { 898 + tputChart.data.labels = tputLabels(); 899 + tputChart.data.datasets = tputDatasets(); 900 + tputChart.update('none'); 901 + } 902 + } 903 + 904 + // ── add custom host (client-side compare only) ──────────────────────────────── 905 + function addCustom() { 906 + let raw = document.getElementById('customUrl').value.trim(); 907 + if (!raw) return; 908 + if (!raw.startsWith('ws')) raw = 'wss://' + raw; 909 + raw = raw.replace(/\/+$/, ''); 910 + const inp = document.getElementById('customUrl'); 911 + try { 912 + const name = new URL(raw).hostname; 913 + addCompareHost(raw, name); 914 + inp.value = ''; 915 + } catch { 916 + inp.style.borderColor = '#6a3a3a'; 917 + setTimeout(() => inp.style.borderColor = '', 1200); 918 + } 919 + } 920 + 921 + // ── init ────────────────────────────────────────────────────────────────────── 922 + document.querySelectorAll('[data-op]').forEach(cb => { 923 + cb.onchange = () => cb.checked ? opFilter.add(cb.dataset.op) : opFilter.delete(cb.dataset.op); 924 + }); 925 + 926 + document.getElementById('addBtn').onclick = addCustom; 927 + document.getElementById('customUrl').onkeydown = e => { if (e.key === 'Enter') addCustom(); }; 928 + 929 + // ── lag cell hover tooltip (event delegation — survives innerHTML re-renders) ── 930 + const covBody = document.getElementById('covBody'); 931 + covBody.addEventListener('mouseover', e => { 932 + const td = e.target.closest('td[data-rtt]'); 933 + if (!td) { hideFloatTip(); return; } 934 + const rtt = td.dataset.rtt !== '' ? td.dataset.rtt : null; 935 + const corr = td.dataset.corr !== '' ? parseInt(td.dataset.corr) : null; 936 + const sign = corr != null ? (corr >= 0 ? '+' : '') : ''; 937 + let html = '<div class="ft-time">measurement details</div>'; 938 + if (rtt != null) html += `<div class="ft-row"><span class="ft-key">tcp rtt</span><span class="ft-val">${rtt}ms</span></div>`; 939 + if (corr != null) html += `<div class="ft-row"><span class="ft-key">ott correction</span><span class="ft-val">${sign}${corr}ms</span></div>`; 940 + if (corr != null) html += `<div class="ft-row" style="margin-top:0.3rem;font-size:0.67rem;color:#444"><span>lag = raw − (mirror ott − primary ott)</span></div>`; 941 + showFloatTip(e, html); 942 + }); 943 + covBody.addEventListener('mousemove', e => { if (floatTip.style.display !== 'none') moveFloatTip(e); }); 944 + covBody.addEventListener('mouseleave', hideFloatTip); 945 + 946 + setInterval(tick, BUCKET_MS); 947 + setInterval(fetchStats, 2000); 948 + fetchStats(); // immediate first load 949 + </script> 950 + </body> 951 + </html>
+386
server.ts
··· 1 + // compare-plc server 2 + // Connects to PLC mirrors server-side for accurate lag measurement. 3 + // Serves index.html and exposes /api/stats. 4 + // 5 + // Lag = mirror_recv - primary_recv, corrected for network distance: 6 + // true_lag ≈ raw_lag − (mirror_ott − primary_ott) 7 + // OTT (one-way transit time) = TCP_RTT / 2, measured via SYN/SYN-ACK ping. 8 + // TCP ping is used because the remote OS responds immediately at the network 9 + // stack level — no application code runs, so it's pure network latency. 10 + // DNS is pre-resolved so it doesn't contaminate the timing. 11 + 12 + import net from "net"; 13 + import dns from "dns"; 14 + import { readFileSync, writeFileSync } from "fs"; 15 + 16 + const PRIMARY = "wss://plc.directory"; 17 + const WINDOW_MS = 15 * 60 * 1000; // rolling coverage window for live table 18 + const LAG_KEEP = 10_000; // max lag samples per mirror 19 + const RTT_KEEP = 30; // RTT samples to average over 20 + const PING_MS = 5_000; // TCP ping interval 21 + const SNAP_INTERVAL = 5 * 60 * 1000; // snapshot every 5 min 22 + const SNAP_KEEP = 24; // 24 × 5 min = 2 hours of history 23 + const DATA_FILE = "./data.json"; 24 + const PORT = 7331; 25 + 26 + // ── types ───────────────────────────────────────────────────────────────────── 27 + 28 + interface TrackerEntry { 29 + primaryRecvMs: number | null; 30 + mirrorRecv: Map<string, number>; // url -> server recv timestamp 31 + firstSeen: number; 32 + } 33 + 34 + interface SnapMirror { 35 + coverage: number | null; 36 + missed: number; 37 + ops: number; // ops received by mirror in this interval 38 + primaryOps: number; // ops received by primary in this interval 39 + } 40 + 41 + interface Snapshot { 42 + ts: number; // unix ms when taken 43 + mirrors: Record<string, SnapMirror>; // non-primary mirrors only 44 + } 45 + 46 + interface MirrorState { 47 + name: string; 48 + url: string; 49 + connected: boolean; 50 + totalEvents: number; // persisted lifetime total 51 + lagSamples: number[]; // TCP-OTT-corrected lag in ms 52 + rttSamples: number[]; // TCP SYN/SYN-ACK RTT samples in ms 53 + ws: WebSocket | null; 54 + } 55 + 56 + interface SavedData { 57 + version: number; 58 + savedAt: number; 59 + totals: Record<string, number>; // url -> lifetime totalEvents 60 + snapshots: Snapshot[]; 61 + } 62 + 63 + // ── state ───────────────────────────────────────────────────────────────────── 64 + 65 + const tracker = new Map<string, TrackerEntry>(); 66 + const mirrors = new Map<string, MirrorState>(); 67 + const snapshots: Snapshot[] = []; 68 + const savedTotals = new Map<string, number>(); // populated by loadData() 69 + 70 + // ── persistence ─────────────────────────────────────────────────────────────── 71 + 72 + function loadData(): void { 73 + try { 74 + const raw = readFileSync(DATA_FILE, "utf-8"); 75 + const data = JSON.parse(raw) as SavedData; 76 + if (data.version !== 1) return; 77 + 78 + for (const snap of data.snapshots ?? []) snapshots.push(snap); 79 + for (const [url, n] of Object.entries(data.totals ?? {})) savedTotals.set(url, n); 80 + 81 + console.log(`loaded ${snapshots.length} snapshots and ${savedTotals.size} totals from ${DATA_FILE}`); 82 + } catch { 83 + // no saved data yet — start fresh 84 + } 85 + } 86 + 87 + function saveData(): void { 88 + const totals: Record<string, number> = {}; 89 + for (const [url, m] of mirrors) totals[url] = m.totalEvents; 90 + const data: SavedData = { version: 1, savedAt: Date.now(), totals, snapshots }; 91 + try { 92 + writeFileSync(DATA_FILE, JSON.stringify(data)); 93 + } catch (e) { 94 + console.error("failed to save data:", e); 95 + } 96 + } 97 + 98 + // ── RTT helpers ─────────────────────────────────────────────────────────────── 99 + 100 + function meanRtt(m: MirrorState): number | null { 101 + if (!m.rttSamples.length) return null; 102 + const mean = m.rttSamples.reduce((a, b) => a + b, 0) / m.rttSamples.length; 103 + return Math.round(mean * 10) / 10; 104 + } 105 + 106 + function ott(m: MirrorState): number { 107 + const rtt = meanRtt(m); 108 + return rtt != null ? rtt / 2 : 0; 109 + } 110 + 111 + // ── mirror management ───────────────────────────────────────────────────────── 112 + 113 + function addMirror(url: string, name: string): void { 114 + if (mirrors.has(url)) return; 115 + mirrors.set(url, { 116 + name, url, connected: false, 117 + totalEvents: savedTotals.get(url) ?? 0, 118 + lagSamples: [], rttSamples: [], ws: null, 119 + }); 120 + connect(url); 121 + measureRtt(url); 122 + } 123 + 124 + function connect(url: string): void { 125 + const m = mirrors.get(url); 126 + if (!m || m.ws) return; 127 + 128 + let ws: WebSocket; 129 + try { 130 + ws = new WebSocket(url + "/export/stream"); 131 + } catch (e) { 132 + console.error(`[${url}] WebSocket create failed:`, e); 133 + setTimeout(() => connect(url), 10_000); 134 + return; 135 + } 136 + 137 + m.ws = ws; 138 + 139 + ws.addEventListener("open", () => { 140 + m.connected = true; 141 + console.log(`[${m.name}] connected`); 142 + }); 143 + 144 + ws.addEventListener("error", () => { 145 + // close event fires after and handles reconnect 146 + }); 147 + 148 + ws.addEventListener("close", () => { 149 + m.connected = false; 150 + m.ws = null; 151 + console.log(`[${m.name}] disconnected — retry in 5s`); 152 + setTimeout(() => connect(url), 5_000); 153 + }); 154 + 155 + ws.addEventListener("message", ({ data }: MessageEvent) => { 156 + let ev: Record<string, unknown>; 157 + try { 158 + ev = JSON.parse(typeof data === "string" ? data : Buffer.from(data as ArrayBuffer).toString()) as Record<string, unknown>; 159 + } catch { return; } 160 + 161 + const cid = ev.cid as string | undefined; 162 + if (!cid) return; 163 + 164 + m.totalEvents++; 165 + const now = Date.now(); 166 + 167 + if (!tracker.has(cid)) { 168 + tracker.set(cid, { primaryRecvMs: null, mirrorRecv: new Map(), firstSeen: now }); 169 + } 170 + const entry = tracker.get(cid)!; 171 + 172 + if (url === PRIMARY) { 173 + if (entry.primaryRecvMs === null) { 174 + entry.primaryRecvMs = now; 175 + // retroactively score mirrors that already had this op 176 + for (const [mu, mt] of entry.mirrorRecv) { 177 + const mm = mirrors.get(mu); 178 + if (mm) pushLag(mm, mt - now); 179 + } 180 + } 181 + } else { 182 + if (!entry.mirrorRecv.has(url)) { 183 + entry.mirrorRecv.set(url, now); 184 + if (entry.primaryRecvMs !== null) { 185 + pushLag(m, now - entry.primaryRecvMs); 186 + } 187 + } 188 + } 189 + }); 190 + } 191 + 192 + // ── RTT measurement (TCP ping) ──────────────────────────────────────────────── 193 + 194 + const resolvedIPs = new Map<string, string>(); 195 + 196 + async function resolveHost(hostname: string): Promise<string | null> { 197 + if (resolvedIPs.has(hostname)) return resolvedIPs.get(hostname)!; 198 + try { 199 + const { address } = await dns.promises.lookup(hostname); 200 + resolvedIPs.set(hostname, address); 201 + return address; 202 + } catch { return null; } 203 + } 204 + 205 + async function tcpPing(hostname: string, port = 443, timeoutMs = 3_000): Promise<number | null> { 206 + const ip = await resolveHost(hostname); 207 + if (!ip) return null; 208 + return new Promise(resolve => { 209 + const sock = new net.Socket(); 210 + sock.setTimeout(timeoutMs); 211 + const t0 = performance.now(); 212 + sock.connect(port, ip, () => { 213 + resolve(performance.now() - t0); 214 + sock.destroy(); 215 + }); 216 + sock.on("error", () => { sock.destroy(); resolve(null); }); 217 + sock.on("timeout", () => { sock.destroy(); resolve(null); }); 218 + }); 219 + } 220 + 221 + async function measureRtt(url: string): Promise<void> { 222 + const m = mirrors.get(url); 223 + if (!m) return; 224 + let hostname: string; 225 + try { hostname = new URL(url).hostname; } catch { return; } 226 + const rtt = await tcpPing(hostname); 227 + if (rtt != null) { 228 + m.rttSamples.push(rtt); 229 + if (m.rttSamples.length > RTT_KEEP) m.rttSamples.shift(); 230 + } 231 + } 232 + 233 + function pushLag(m: MirrorState, rawLag: number): void { 234 + const primary = mirrors.get(PRIMARY)!; 235 + const corrected = rawLag - (ott(m) - ott(primary)); 236 + m.lagSamples.push(Math.round(corrected)); 237 + if (m.lagSamples.length > LAG_KEEP) m.lagSamples.shift(); 238 + } 239 + 240 + // ── tracker pruning ─────────────────────────────────────────────────────────── 241 + 242 + function pruneTracker(): void { 243 + const cut = Date.now() - WINDOW_MS; 244 + for (const [cid, e] of tracker) { 245 + if (e.firstSeen < cut) tracker.delete(cid); 246 + } 247 + } 248 + 249 + // ── stats computation ───────────────────────────────────────────────────────── 250 + 251 + function pct(sorted: number[], p: number): number { 252 + if (!sorted.length) return 0; 253 + const i = Math.min(Math.ceil(p / 100 * sorted.length) - 1, sorted.length - 1); 254 + return sorted[Math.max(0, i)]; 255 + } 256 + 257 + // Rolling window stats for the live coverage table. 258 + function computeStats(): Record<string, unknown> { 259 + const cut = Date.now() - WINDOW_MS; 260 + let primaryOps = 0; 261 + const mirrorOps = new Map<string, number>(); 262 + 263 + for (const [, e] of tracker) { 264 + if (e.primaryRecvMs === null || e.primaryRecvMs < cut) continue; 265 + primaryOps++; 266 + for (const [mu] of e.mirrorRecv) { 267 + mirrorOps.set(mu, (mirrorOps.get(mu) ?? 0) + 1); 268 + } 269 + } 270 + 271 + const out: Record<string, unknown> = {}; 272 + for (const [url, m] of mirrors) { 273 + const isPrimary = url === PRIMARY; 274 + const opsInWindow = isPrimary ? primaryOps : (mirrorOps.get(url) ?? 0); 275 + const missed = isPrimary ? 0 : Math.max(0, primaryOps - opsInWindow); 276 + const coverage = (!isPrimary && primaryOps > 0) ? opsInWindow / primaryOps : null; 277 + const rtt = meanRtt(m); 278 + 279 + let lagStats: unknown = null; 280 + if (!isPrimary && m.lagSamples.length > 0) { 281 + const sorted = [...m.lagSamples].sort((a, b) => a - b); 282 + const sum = sorted.reduce((a, b) => a + b, 0); 283 + lagStats = { 284 + p50: pct(sorted, 50), 285 + p95: pct(sorted, 95), 286 + p99: pct(sorted, 99), 287 + mean: Math.round(sum / sorted.length), 288 + min: sorted[0], 289 + max: sorted[sorted.length - 1], 290 + n: sorted.length, 291 + }; 292 + } 293 + 294 + out[url] = { 295 + name: m.name, url, isPrimary, 296 + connected: m.connected, 297 + totalEvents: m.totalEvents, 298 + opsInWindow, primaryOps, missed, coverage, 299 + lagStats, 300 + rttMs: rtt != null ? Math.round(rtt) : null, 301 + correctionMs: isPrimary ? null : Math.round(ott(m) - ott(mirrors.get(PRIMARY)!)), 302 + }; 303 + } 304 + return out; 305 + } 306 + 307 + // Per-interval stats for snapshots — only events where primary received them 308 + // within [now - SNAP_INTERVAL, now]. No overlap between adjacent snapshots. 309 + function computeIntervalStats(): Record<string, SnapMirror> { 310 + const cut = Date.now() - SNAP_INTERVAL; 311 + let primaryOps = 0; 312 + const mirrorOps = new Map<string, number>(); 313 + 314 + for (const [, e] of tracker) { 315 + if (e.primaryRecvMs === null || e.primaryRecvMs < cut) continue; 316 + primaryOps++; 317 + for (const [mu] of e.mirrorRecv) { 318 + mirrorOps.set(mu, (mirrorOps.get(mu) ?? 0) + 1); 319 + } 320 + } 321 + 322 + const out: Record<string, SnapMirror> = {}; 323 + for (const [url] of mirrors) { 324 + if (url === PRIMARY) continue; 325 + const ops = mirrorOps.get(url) ?? 0; 326 + out[url] = { 327 + coverage: primaryOps > 0 ? ops / primaryOps : null, 328 + missed: Math.max(0, primaryOps - ops), 329 + ops, 330 + primaryOps, 331 + }; 332 + } 333 + return out; 334 + } 335 + 336 + // ── snapshots ───────────────────────────────────────────────────────────────── 337 + 338 + function takeSnapshot(): void { 339 + snapshots.push({ ts: Date.now(), mirrors: computeIntervalStats() }); 340 + if (snapshots.length > SNAP_KEEP) snapshots.shift(); 341 + saveData(); 342 + } 343 + 344 + // ── HTTP server ──────────────────────────────────────────────────────────────── 345 + 346 + const CORS_HEADERS = { 347 + "Access-Control-Allow-Origin": "*", 348 + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 349 + "Access-Control-Allow-Headers": "Content-Type", 350 + }; 351 + 352 + Bun.serve({ 353 + port: PORT, 354 + async fetch(req: Request): Promise<Response> { 355 + const { pathname } = new URL(req.url); 356 + 357 + if (req.method === "OPTIONS") { 358 + return new Response(null, { status: 204, headers: CORS_HEADERS }); 359 + } 360 + 361 + if (pathname === "/api/stats") { 362 + return Response.json({ mirrors: computeStats(), snapshots }, { headers: CORS_HEADERS }); 363 + } 364 + 365 + if (pathname === "/" || pathname === "/index.html") { 366 + return new Response(Bun.file("index.html"), { 367 + headers: { "Content-Type": "text/html; charset=utf-8" }, 368 + }); 369 + } 370 + 371 + return new Response("not found", { status: 404 }); 372 + }, 373 + }); 374 + 375 + // ── boot ────────────────────────────────────────────────────────────────────── 376 + 377 + loadData(); 378 + 379 + addMirror(PRIMARY, "plc.directory"); 380 + addMirror("wss://plc.klbr.net", "plc.klbr.net"); 381 + 382 + setInterval(pruneTracker, 30_000); 383 + setInterval(() => { for (const url of mirrors.keys()) measureRtt(url); }, PING_MS); 384 + setInterval(takeSnapshot, SNAP_INTERVAL); 385 + 386 + console.log(`compare-plc → http://localhost:${PORT}`);