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