@gtadi/k8s-node-debugger 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,569 @@
1
+ 'use strict';
2
+
3
+ /* ══════════════════════════════════════════════════════════════════════════
4
+ * k8s-node-debugger — conntrack rich renderer
5
+ * Handles three probe outputs:
6
+ * renderConntrackView(raw, el) — conntrack -L (connection table)
7
+ * renderConntrackStats(raw, el) — conntrack -S (per-CPU stats)
8
+ * renderConntrackCount(raw, el) — nf_conntrack_count / max gauge
9
+ * ══════════════════════════════════════════════════════════════════════════ */
10
+ (function () {
11
+
12
+ /* ── micro helpers ──────────────────────────────────────────────────── */
13
+ function h(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
14
+ function N(n) { return Number(n).toLocaleString(); }
15
+ function pct(a, total) { return total ? Math.round(a / total * 100) : 0; }
16
+
17
+ function hBytes(b) {
18
+ if (!b || b < 1) return null;
19
+ const u = ['B','KB','MB','GB'];
20
+ const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), 3);
21
+ return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + u[i];
22
+ }
23
+
24
+ const PNAMES = {
25
+ 22:'SSH',25:'SMTP',53:'DNS',67:'DHCP',80:'HTTP',110:'POP3',
26
+ 123:'NTP',143:'IMAP',443:'HTTPS',465:'SMTPS',587:'SMTP/TLS',
27
+ 636:'LDAPS',853:'DNS/TLS',993:'IMAPS',995:'POP3S',
28
+ 2379:'etcd',2380:'etcd-peer',3000:'Grafana',3306:'MySQL',
29
+ 4789:'VXLAN',5432:'PgSQL',6379:'Redis',6443:'k8s-API',
30
+ 8080:'HTTP-alt',8443:'HTTPS-alt',9090:'Prometheus',
31
+ 9200:'ES',10250:'kubelet',10256:'kube-proxy',10257:'ctrl-mgr',10259:'scheduler',
32
+ };
33
+ function pLabel(p) { return PNAMES[p] ? `${p} (${PNAMES[p]})` : String(p ?? ''); }
34
+
35
+ /* ── TCP state catalogue ────────────────────────────────────────────── */
36
+ const TCP_STATE_SET = new Set([
37
+ 'ESTABLISHED','SYN_SENT','SYN_SENT2','SYN_RECV',
38
+ 'FIN_WAIT','TIME_WAIT','CLOSE','CLOSE_WAIT','LAST_ACK','LISTEN','NONE',
39
+ ]);
40
+
41
+ const STATE_KIND = {
42
+ ESTABLISHED:'est', TIME_WAIT:'tw', SYN_SENT:'syn', SYN_SENT2:'syn',
43
+ SYN_RECV:'syn', FIN_WAIT:'fin', CLOSE_WAIT:'cw',
44
+ LAST_ACK:'close', CLOSE:'close', LISTEN:'listen', NONE:'none',
45
+ };
46
+
47
+ const STATE_DISPLAY = {
48
+ ESTABLISHED:'ESTABLISHED', TIME_WAIT:'TIME_WAIT', SYN_SENT:'SYN_SENT',
49
+ SYN_SENT2:'SYN_SENT2', SYN_RECV:'SYN_RECV', FIN_WAIT:'FIN_WAIT',
50
+ CLOSE_WAIT:'CLOSE_WAIT', LAST_ACK:'LAST_ACK', CLOSE:'CLOSE',
51
+ LISTEN:'LISTEN', NONE:'NONE',
52
+ };
53
+
54
+ /* ══════════════════════════════════════════════════════════════════════
55
+ * Parser — handles conntrack -L and /proc/net/nf_conntrack
56
+ * ══════════════════════════════════════════════════════════════════════ */
57
+ function parseConntrackOutput(raw) {
58
+ const entries = [];
59
+ for (const line of raw.split('\n')) {
60
+ const t = line.trim();
61
+ if (!t || t.startsWith('#')) continue;
62
+ const e = parseCTLine(t);
63
+ if (e) entries.push(e);
64
+ }
65
+ return entries;
66
+ }
67
+
68
+ function parseCTLine(line) {
69
+ // Strip "ipv4 2 " or "ipv6 10 " prefix from /proc/net/nf_conntrack
70
+ let rest = line.replace(/^ip(?:v4|v6)\s+\d+\s+/, '');
71
+
72
+ const parts = rest.split(/\s+/);
73
+ if (parts.length < 3) return null;
74
+
75
+ let i = 0;
76
+ const proto = parts[i++].toLowerCase();
77
+ if (!proto || /^\d/.test(proto)) return null;
78
+
79
+ const protoNum = +parts[i++];
80
+ if (isNaN(protoNum)) return null;
81
+
82
+ // TTL
83
+ let ttl = null;
84
+ if (i < parts.length && /^\d+$/.test(parts[i])) ttl = +parts[i++];
85
+
86
+ // TCP state (optional)
87
+ let state = null;
88
+ if (i < parts.length && TCP_STATE_SET.has(parts[i])) state = parts[i++];
89
+
90
+ const e = {
91
+ proto, protoNum, ttl, state,
92
+ orig: {}, reply: {},
93
+ assured: false, unreplied: false,
94
+ mark: null,
95
+ origBytes: null, origPkts: null,
96
+ replyBytes: null, replyPkts: null,
97
+ };
98
+
99
+ // Counters to disambiguate first vs second occurrence of src/dst/sport/dport
100
+ const nc = { src:0, dst:0, sport:0, dport:0, bytes:0, packets:0, type:0, code:0, id:0 };
101
+
102
+ for (; i < parts.length; i++) {
103
+ const p = parts[i];
104
+ if (p === '[ASSURED]') { e.assured = true; continue; }
105
+ if (p === '[UNREPLIED]') { e.unreplied = true; continue; }
106
+ if (p.startsWith('[')) continue;
107
+
108
+ const eq = p.indexOf('=');
109
+ if (eq < 0) continue;
110
+ const k = p.slice(0, eq), v = p.slice(eq + 1);
111
+
112
+ switch (k) {
113
+ case 'src': nc.src++ === 0 ? (e.orig.src = v) : (e.reply.src = v); break;
114
+ case 'dst': nc.dst++ === 0 ? (e.orig.dst = v) : (e.reply.dst = v); break;
115
+ case 'sport': nc.sport++ === 0 ? (e.orig.sport = +v) : (e.reply.sport = +v); break;
116
+ case 'dport': nc.dport++ === 0 ? (e.orig.dport = +v) : (e.reply.dport = +v); break;
117
+ case 'bytes': nc.bytes++ === 0 ? (e.origBytes = +v) : (e.replyBytes = +v); break;
118
+ case 'packets':nc.packets++===0 ? (e.origPkts = +v) : (e.replyPkts = +v); break;
119
+ case 'type': nc.type++ === 0 ? (e.orig.type = +v) : (e.reply.type = +v); break;
120
+ case 'code': nc.code++ === 0 ? (e.orig.code = +v) : (e.reply.code = +v); break;
121
+ case 'id': nc.id++ === 0 ? (e.orig.id = +v) : (e.reply.id = +v); break;
122
+ case 'mark': e.mark = +v; break;
123
+ }
124
+ }
125
+ return e;
126
+ }
127
+
128
+ /* ── Aggregate statistics ───────────────────────────────────────────── */
129
+ function computeStats(entries) {
130
+ const byProto = {}, byState = {}, byDport = {}, bySrcIP = {};
131
+ let totalBytes = 0, totalPkts = 0, hasBytes = false;
132
+
133
+ for (const e of entries) {
134
+ byProto[e.proto] = (byProto[e.proto] || 0) + 1;
135
+ if (e.state) byState[e.state] = (byState[e.state] || 0) + 1;
136
+ if (e.orig.dport) byDport[e.orig.dport] = (byDport[e.orig.dport] || 0) + 1;
137
+ if (e.orig.src) bySrcIP[e.orig.src] = (bySrcIP[e.orig.src] || 0) + 1;
138
+ if (e.origBytes) { totalBytes += e.origBytes + (e.replyBytes || 0); hasBytes = true; }
139
+ if (e.origPkts) { totalPkts += e.origPkts + (e.replyPkts || 0); }
140
+ }
141
+
142
+ return {
143
+ total: entries.length, byProto, byState, byDport, bySrcIP,
144
+ totalBytes, totalPkts, hasBytes,
145
+ tcpEstab: byState['ESTABLISHED'] || 0,
146
+ tcpTimeWait: byState['TIME_WAIT'] || 0,
147
+ udp: byProto['udp'] || 0,
148
+ tcp: byProto['tcp'] || 0,
149
+ icmp: (byProto['icmp'] || 0) + (byProto['icmpv6'] || 0),
150
+ topSrcIPs: sortTop(bySrcIP, 8),
151
+ topDports: sortTop(byDport, 8),
152
+ };
153
+ }
154
+
155
+ function sortTop(obj, n) {
156
+ return Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, n);
157
+ }
158
+
159
+ /* ══════════════════════════════════════════════════════════════════════
160
+ * Sub-renderers
161
+ * ══════════════════════════════════════════════════════════════════════ */
162
+
163
+ function renderStatCards(s) {
164
+ const other = s.total - s.tcp - s.udp - s.icmp;
165
+ const cards = [
166
+ { label: 'Total connections', value: N(s.total), cls: 'ct-card-total' },
167
+ { label: 'TCP ESTABLISHED', value: N(s.tcpEstab), cls: 'ct-card-estab' },
168
+ { label: 'TCP TIME_WAIT', value: N(s.tcpTimeWait), cls: 'ct-card-tw' },
169
+ { label: 'UDP', value: N(s.udp), cls: 'ct-card-udp' },
170
+ { label: 'ICMP', value: N(s.icmp), cls: 'ct-card-icmp' },
171
+ ];
172
+ if (other > 0) cards.push({ label: 'Other', value: N(other), cls: 'ct-card-other' });
173
+ if (s.hasBytes) cards.push({ label: 'Total bytes', value: hBytes(s.totalBytes) || '0 B', cls: 'ct-card-bytes' });
174
+
175
+ const div = document.createElement('div');
176
+ div.className = 'ct-cards';
177
+ div.innerHTML = cards.map(c => `
178
+ <div class="ct-card ${c.cls}">
179
+ <div class="ct-card-val">${h(c.value)}</div>
180
+ <div class="ct-card-lbl">${h(c.label)}</div>
181
+ </div>
182
+ `).join('');
183
+ return div;
184
+ }
185
+
186
+ function renderDistBars(s) {
187
+ const div = document.createElement('div');
188
+ div.className = 'ct-dist';
189
+
190
+ // Protocol bar
191
+ const protoSegs = Object.entries(s.byProto)
192
+ .sort((a, b) => b[1] - a[1])
193
+ .map(([p, n]) => ({ label: p.toUpperCase(), n, pct: pct(n, s.total), cls: `ct-seg-${p}` }));
194
+
195
+ // TCP state bar (only if there are TCP connections)
196
+ const stateSegs = Object.entries(s.byState)
197
+ .sort((a, b) => b[1] - a[1])
198
+ .map(([st, n]) => ({ label: st.replace('_',' '), n, pct: pct(n, s.tcp), cls: `ct-seg-state-${STATE_KIND[st] || 'none'}` }));
199
+
200
+ div.innerHTML = `
201
+ <div class="ct-dist-row">
202
+ <span class="ct-dist-label">Protocol</span>
203
+ <div class="ct-segbar">
204
+ ${protoSegs.map(sg => `
205
+ <div class="ct-seg ${sg.cls}" style="width:${sg.pct}%" title="${sg.label}: ${N(sg.n)} (${sg.pct}%)">
206
+ </div>`).join('')}
207
+ </div>
208
+ <div class="ct-seglegend">
209
+ ${protoSegs.map(sg =>
210
+ `<span class="ct-segleg"><span class="ct-segdot ${sg.cls}"></span>${h(sg.label)} <b>${N(sg.n)}</b> <em>${sg.pct}%</em></span>`
211
+ ).join('')}
212
+ </div>
213
+ </div>
214
+ ${s.tcp > 0 ? `
215
+ <div class="ct-dist-row">
216
+ <span class="ct-dist-label">TCP state</span>
217
+ <div class="ct-segbar">
218
+ ${stateSegs.map(sg => `
219
+ <div class="ct-seg ${sg.cls}" style="width:${sg.pct}%" title="${sg.label}: ${N(sg.n)} (${sg.pct}% of TCP)">
220
+ </div>`).join('')}
221
+ </div>
222
+ <div class="ct-seglegend">
223
+ ${stateSegs.map(sg =>
224
+ `<span class="ct-segleg"><span class="ct-segdot ${sg.cls}"></span>${h(sg.label)} <b>${N(sg.n)}</b></span>`
225
+ ).join('')}
226
+ </div>
227
+ </div>` : ''}
228
+ `;
229
+ return div;
230
+ }
231
+
232
+ function renderTopTables(s) {
233
+ const wrap = document.createElement('div');
234
+ wrap.className = 'ct-tops';
235
+
236
+ const ipRows = s.topSrcIPs.map(([ip, n]) =>
237
+ `<tr><td><code>${h(ip)}</code></td><td class="ct-top-n">${N(n)}</td></tr>`
238
+ ).join('');
239
+
240
+ const portRows = s.topDports.map(([port, n]) =>
241
+ `<tr><td><code>${h(pLabel(+port))}</code></td><td class="ct-top-n">${N(n)}</td></tr>`
242
+ ).join('');
243
+
244
+ wrap.innerHTML = `
245
+ <div class="ct-top-box">
246
+ <div class="ct-top-title">Top source IPs</div>
247
+ <table class="ct-mini-table"><tbody>${ipRows || '<tr><td class="ct-empty">—</td></tr>'}</tbody></table>
248
+ </div>
249
+ <div class="ct-top-box">
250
+ <div class="ct-top-title">Top destination ports</div>
251
+ <table class="ct-mini-table"><tbody>${portRows || '<tr><td class="ct-empty">—</td></tr>'}</tbody></table>
252
+ </div>
253
+ `;
254
+ return wrap;
255
+ }
256
+
257
+ /* ── Connection table (paginated) ───────────────────────────────────── */
258
+ const PAGE = 200;
259
+
260
+ function buildConnRow(e) {
261
+ const stateKind = e.state ? (STATE_KIND[e.state] || 'none') : '';
262
+ const stateBadge = e.state
263
+ ? `<span class="ct-state ct-state-${stateKind}">${h(STATE_DISPLAY[e.state] || e.state)}</span>`
264
+ : '';
265
+
266
+ const flagBadge = e.assured
267
+ ? '<span class="ct-flag ct-flag-assured" title="ASSURED: connection seen in both directions">✔</span>'
268
+ : e.unreplied
269
+ ? '<span class="ct-flag ct-flag-unr" title="UNREPLIED: only seen in one direction">⚡</span>'
270
+ : '';
271
+
272
+ const protoBadge = `<span class="ct-proto ct-proto-${e.proto}">${h(e.proto.toUpperCase())}</span>`;
273
+
274
+ // Original direction
275
+ let origCell = '';
276
+ if (e.proto === 'icmp' || e.proto === 'icmpv6') {
277
+ origCell = `<code>${h(e.orig.src)}</code> → <code>${h(e.orig.dst)}</code>`
278
+ + (e.orig.type != null ? ` <small>type ${e.orig.type} code ${e.orig.code ?? 0}</small>` : '');
279
+ } else if (e.orig.src) {
280
+ const dportLabel = e.orig.dport ? pLabel(e.orig.dport) : '';
281
+ origCell = `<code>${h(e.orig.src)}:${e.orig.sport ?? '?'}</code>`
282
+ + ` <span class="ct-arrow">→</span> `
283
+ + `<code>${h(e.orig.dst)}:${e.orig.dport ?? '?'}</code>`
284
+ + (PNAMES[e.orig.dport] ? ` <small class="ct-svc">${h(PNAMES[e.orig.dport])}</small>` : '');
285
+ } else {
286
+ origCell = '<span class="ct-empty">—</span>';
287
+ }
288
+
289
+ // Reply direction (only show if src differs from expected — indicates NAT)
290
+ let natTag = '';
291
+ if (e.reply.src && e.reply.src !== e.orig.dst) {
292
+ natTag = `<span class="ct-nat" title="NAT: reply src differs from original dst">NAT↔${h(e.reply.src)}</span>`;
293
+ }
294
+
295
+ const ttlCell = e.ttl != null ? `<span class="ct-ttl" title="TTL (seconds)">${N(e.ttl)}s</span>` : '—';
296
+
297
+ const bytesCell = e.origBytes != null
298
+ ? `${hBytes(e.origBytes) || '0B'} <span class="ct-dir">↑</span> ${hBytes(e.replyBytes) || '0B'} <span class="ct-dir">↓</span>`
299
+ : '';
300
+
301
+ // Searchable string stored as dataset
302
+ const search = [
303
+ e.proto, e.state, e.orig.src, e.orig.dst,
304
+ e.orig.sport, e.orig.dport, e.reply.src, e.reply.dst,
305
+ ].filter(Boolean).join(' ').toLowerCase();
306
+
307
+ return `<tr class="ct-row" data-s="${h(search)}">
308
+ <td>${stateBadge}${flagBadge}</td>
309
+ <td>${protoBadge}</td>
310
+ <td class="ct-orig">${origCell}${natTag}</td>
311
+ <td class="ct-ttl-cell">${ttlCell}</td>
312
+ ${bytesCell ? `<td class="ct-bytes">${bytesCell}</td>` : ''}
313
+ </tr>`;
314
+ }
315
+
316
+ /* ══════════════════════════════════════════════════════════════════════
317
+ * Main view — conntrack -L
318
+ * ══════════════════════════════════════════════════════════════════════ */
319
+ function renderConntrackView(raw, container) {
320
+ const entries = parseConntrackOutput(raw);
321
+ if (!entries.length) {
322
+ container.innerHTML = '<div class="ct-empty-msg">No conntrack entries found.</div>';
323
+ return;
324
+ }
325
+
326
+ const stats = computeStats(entries);
327
+ const hasBytes = entries.some(e => e.origBytes != null);
328
+
329
+ const wrap = document.createElement('div');
330
+ wrap.className = 'ct-wrap';
331
+
332
+ wrap.appendChild(renderStatCards(stats));
333
+ wrap.appendChild(renderDistBars(stats));
334
+ wrap.appendChild(renderTopTables(stats));
335
+
336
+ // ── Filter controls ──────────────────────────────────────────────
337
+ const filterEl = document.createElement('div');
338
+ filterEl.className = 'ct-filter';
339
+
340
+ const protos = ['all', 'tcp', 'udp', 'icmp'];
341
+ const hasOther = stats.total - stats.tcp - stats.udp - stats.icmp > 0;
342
+ if (hasOther) protos.push('other');
343
+
344
+ const tcpStateList = Object.keys(stats.byState).sort((a, b) => (stats.byState[b] - stats.byState[a]));
345
+
346
+ filterEl.innerHTML = `
347
+ <input class="ct-search" placeholder="🔍 IP, port, state…" type="text" />
348
+ <div class="ct-filter-group">
349
+ ${protos.map(p =>
350
+ `<button class="ct-filter-btn${p === 'all' ? ' active' : ''}" data-proto="${p}">${p.toUpperCase()}</button>`
351
+ ).join('')}
352
+ </div>
353
+ ${tcpStateList.length > 0 ? `
354
+ <div class="ct-filter-group">
355
+ <button class="ct-filter-btn active" data-state="all">All states</button>
356
+ ${tcpStateList.map(s =>
357
+ `<button class="ct-filter-btn ct-filter-state-${STATE_KIND[s]||'none'}" data-state="${s}">${h(s.replace('_',' '))}</button>`
358
+ ).join('')}
359
+ </div>` : ''}
360
+ `;
361
+ wrap.appendChild(filterEl);
362
+
363
+ // ── Connection table ─────────────────────────────────────────────
364
+ const tableWrap = document.createElement('div');
365
+ tableWrap.className = 'ct-table-wrap';
366
+
367
+ const footer = document.createElement('div');
368
+ footer.className = 'ct-footer';
369
+
370
+ wrap.appendChild(tableWrap);
371
+ wrap.appendChild(footer);
372
+
373
+ // State
374
+ let protoFilter = 'all', stateFilter = 'all', query = '', page = 1;
375
+
376
+ function getFiltered() {
377
+ const q = query.toLowerCase();
378
+ return entries.filter(e => {
379
+ if (protoFilter !== 'all') {
380
+ if (protoFilter === 'other') {
381
+ if (['tcp','udp','icmp','icmpv6'].includes(e.proto)) return false;
382
+ } else if (e.proto !== protoFilter) return false;
383
+ }
384
+ if (stateFilter !== 'all' && e.state !== stateFilter) return false;
385
+ if (q && !(
386
+ (e.orig.src || '').includes(q) ||
387
+ (e.orig.dst || '').includes(q) ||
388
+ String(e.orig.sport || '').includes(q) ||
389
+ String(e.orig.dport || '').includes(q) ||
390
+ (e.state || '').toLowerCase().includes(q) ||
391
+ e.proto.includes(q) ||
392
+ (e.reply.src || '').includes(q) ||
393
+ (e.reply.dst || '').includes(q)
394
+ )) return false;
395
+ return true;
396
+ });
397
+ }
398
+
399
+ function render() {
400
+ const filtered = getFiltered();
401
+ const shown = filtered.slice(0, page * PAGE);
402
+ const hasMore = shown.length < filtered.length;
403
+
404
+ tableWrap.innerHTML = `
405
+ <table class="ct-table">
406
+ <thead><tr>
407
+ <th>State &amp; flag</th>
408
+ <th>Proto</th>
409
+ <th>Connection</th>
410
+ <th>TTL</th>
411
+ ${hasBytes ? '<th>Bytes ↑↓</th>' : ''}
412
+ </tr></thead>
413
+ <tbody>${shown.map(buildConnRow).join('')}</tbody>
414
+ </table>`;
415
+
416
+ footer.innerHTML = `
417
+ <span class="ct-footer-count">
418
+ Showing <b>${N(shown.length)}</b> of <b>${N(filtered.length)}</b>
419
+ (${N(entries.length)} total)
420
+ </span>
421
+ ${hasMore
422
+ ? `<button class="ct-showmore">Show ${Math.min(PAGE, filtered.length - shown.length)} more</button>`
423
+ : ''}
424
+ `;
425
+ footer.querySelector('.ct-showmore')?.addEventListener('click', () => { page++; render(); });
426
+ }
427
+
428
+ // Filter wiring
429
+ filterEl.querySelectorAll('[data-proto]').forEach(btn => {
430
+ btn.addEventListener('click', () => {
431
+ protoFilter = btn.dataset.proto;
432
+ page = 1;
433
+ filterEl.querySelectorAll('[data-proto]').forEach(b => b.classList.toggle('active', b === btn));
434
+ render();
435
+ });
436
+ });
437
+ filterEl.querySelectorAll('[data-state]').forEach(btn => {
438
+ btn.addEventListener('click', () => {
439
+ stateFilter = btn.dataset.state;
440
+ page = 1;
441
+ filterEl.querySelectorAll('[data-state]').forEach(b => b.classList.toggle('active', b === btn));
442
+ render();
443
+ });
444
+ });
445
+ filterEl.querySelector('.ct-search').addEventListener('input', e => {
446
+ query = e.target.value;
447
+ page = 1;
448
+ render();
449
+ });
450
+
451
+ render();
452
+
453
+ container.innerHTML = '';
454
+ container.className = '';
455
+ container.appendChild(wrap);
456
+ }
457
+
458
+ /* ══════════════════════════════════════════════════════════════════════
459
+ * conntrack -S — per-CPU stats
460
+ * ══════════════════════════════════════════════════════════════════════ */
461
+ function renderConntrackStats(raw, container) {
462
+ // Parse: "cpu=0 found=0 invalid=0 ignore=174 ..."
463
+ const rows = [];
464
+ for (const line of raw.split('\n')) {
465
+ const t = line.trim();
466
+ if (!t) continue;
467
+ const row = {};
468
+ for (const part of t.split(/\s+/)) {
469
+ const eq = part.indexOf('=');
470
+ if (eq >= 0) row[part.slice(0, eq)] = +part.slice(eq + 1);
471
+ }
472
+ if (row.cpu != null) rows.push(row);
473
+ }
474
+ if (!rows.length) { container.textContent = raw; return; }
475
+
476
+ // Compute totals
477
+ const KEYS = ['found','invalid','ignore','insert','insert_failed','drop','early_drop','error','search_restart'];
478
+ const totals = { cpu: 'TOTAL' };
479
+ for (const k of KEYS) totals[k] = rows.reduce((s, r) => s + (r[k] || 0), 0);
480
+
481
+ const BAD = new Set(['drop','early_drop','error','insert_failed','invalid']);
482
+
483
+ const headerCells = ['CPU', ...KEYS].map(k => `<th>${h(k.replace(/_/g,' '))}</th>`).join('');
484
+ const toRow = (r, isTot) => `<tr class="${isTot ? 'ct-stats-total' : ''}">
485
+ <td><b>${h(r.cpu)}</b></td>
486
+ ${KEYS.map(k => {
487
+ const v = r[k] || 0;
488
+ const bad = BAD.has(k) && v > 0;
489
+ return `<td class="${bad ? 'ct-stats-bad' : v > 0 ? 'ct-stats-nonzero' : ''}">${N(v)}</td>`;
490
+ }).join('')}
491
+ </tr>`;
492
+
493
+ const wrap = document.createElement('div');
494
+ wrap.className = 'ct-stats-wrap';
495
+ wrap.innerHTML = `
496
+ <div class="ct-stats-note">
497
+ Non-zero <span class="ct-stats-bad">drops, errors, or insert_failed</span> indicate conntrack pressure or misconfiguration.
498
+ </div>
499
+ <div style="overflow-x:auto">
500
+ <table class="ct-stats-table">
501
+ <thead><tr>${headerCells}</tr></thead>
502
+ <tbody>
503
+ ${rows.map(r => toRow(r, false)).join('')}
504
+ ${toRow(totals, true)}
505
+ </tbody>
506
+ </table>
507
+ </div>`;
508
+
509
+ container.innerHTML = '';
510
+ container.className = '';
511
+ container.appendChild(wrap);
512
+ }
513
+
514
+ /* ══════════════════════════════════════════════════════════════════════
515
+ * conntrack count / max — capacity gauge
516
+ * ══════════════════════════════════════════════════════════════════════ */
517
+ function renderConntrackCount(raw, container) {
518
+ const countM = raw.match(/count:\s*(\d+)/i);
519
+ const maxM = raw.match(/max:\s*(\d+)/i);
520
+
521
+ if (!countM || !maxM) { container.textContent = raw; return; }
522
+
523
+ const count = +countM[1], max = +maxM[1];
524
+ const used = pct(count, max);
525
+ const cls = used >= 80 ? 'ct-gauge-crit' : used >= 50 ? 'ct-gauge-warn' : 'ct-gauge-ok';
526
+ const msg = used >= 80
527
+ ? '⚠ High usage — risk of dropped connections above 100%.'
528
+ : used >= 50
529
+ ? '↗ Moderate usage — monitor for spikes.'
530
+ : '✔ Healthy — plenty of capacity.';
531
+
532
+ const wrap = document.createElement('div');
533
+ wrap.className = 'ct-gauge-wrap';
534
+ wrap.innerHTML = `
535
+ <div class="ct-gauge-row">
536
+ <span class="ct-gauge-count">${N(count)}</span>
537
+ <span class="ct-gauge-sep">/</span>
538
+ <span class="ct-gauge-max">${N(max)}</span>
539
+ <span class="ct-gauge-pct ${cls}">${used}%</span>
540
+ </div>
541
+ <div class="ct-gauge-bar">
542
+ <div class="ct-gauge-fill ${cls}" style="width:${Math.min(used,100)}%"></div>
543
+ </div>
544
+ <div class="ct-gauge-labels">
545
+ <span>0</span>
546
+ <span>${N(Math.round(max/4))}</span>
547
+ <span>${N(Math.round(max/2))}</span>
548
+ <span>${N(Math.round(max*3/4))}</span>
549
+ <span>${N(max)}</span>
550
+ </div>
551
+ <div class="ct-gauge-msg ${cls}">${msg}</div>
552
+ <div class="ct-gauge-meta">
553
+ <span>Used: <b>${N(count)}</b> entries</span>
554
+ <span>Free: <b>${N(max - count)}</b> entries</span>
555
+ <span>Max: <b>${N(max)}</b></span>
556
+ </div>
557
+ `;
558
+
559
+ container.innerHTML = '';
560
+ container.className = '';
561
+ container.appendChild(wrap);
562
+ }
563
+
564
+ /* ── Exports ────────────────────────────────────────────────────────── */
565
+ window.renderConntrackView = renderConntrackView;
566
+ window.renderConntrackStats = renderConntrackStats;
567
+ window.renderConntrackCount = renderConntrackCount;
568
+
569
+ })();