@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.
- package/LICENSE +183 -0
- package/README.md +118 -0
- package/bin/k8s-node-debugger.js +168 -0
- package/package.json +28 -0
- package/public/app.js +547 -0
- package/public/conntrack-view.js +569 -0
- package/public/health-view.js +462 -0
- package/public/index.html +90 -0
- package/public/iptables-view.js +523 -0
- package/public/style.css +866 -0
- package/src/k8s.js +196 -0
- package/src/probes.js +255 -0
- package/src/server.js +187 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* ── state ─────────────────────────────────────────────────────────────── */
|
|
4
|
+
let session = null;
|
|
5
|
+
let activePanel = 'overview';
|
|
6
|
+
const probeCache = {}; // id → { output, error, command, ok }
|
|
7
|
+
let termHistory = [];
|
|
8
|
+
let termHistIdx = -1;
|
|
9
|
+
let wsRunning = false;
|
|
10
|
+
|
|
11
|
+
/* ── xterm ──────────────────────────────────────────────────────────────── */
|
|
12
|
+
const term = new Terminal({
|
|
13
|
+
theme: {
|
|
14
|
+
background: '#0a0d12',
|
|
15
|
+
foreground: '#c9d4e2',
|
|
16
|
+
cursor: '#4f9dff',
|
|
17
|
+
selectionBackground: '#2a3a55',
|
|
18
|
+
},
|
|
19
|
+
fontFamily: 'ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace',
|
|
20
|
+
fontSize: 13,
|
|
21
|
+
lineHeight: 1.45,
|
|
22
|
+
convertEol: true,
|
|
23
|
+
scrollback: 10000,
|
|
24
|
+
});
|
|
25
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
26
|
+
term.loadAddon(fitAddon);
|
|
27
|
+
term.open(document.getElementById('term'));
|
|
28
|
+
fitAddon.fit();
|
|
29
|
+
window.addEventListener('resize', () => fitAddon.fit());
|
|
30
|
+
|
|
31
|
+
/* ── WebSocket terminal ────────────────────────────────────────────────── */
|
|
32
|
+
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/term`;
|
|
33
|
+
let ws = null;
|
|
34
|
+
|
|
35
|
+
function wsConnect() {
|
|
36
|
+
ws = new WebSocket(wsUrl);
|
|
37
|
+
ws.onopen = () => term.writeln('\x1b[2m[connected]\x1b[0m');
|
|
38
|
+
ws.onclose = () => {
|
|
39
|
+
term.writeln('\x1b[31m[disconnected — reload to reconnect]\x1b[0m');
|
|
40
|
+
setRunning(false);
|
|
41
|
+
};
|
|
42
|
+
ws.onerror = () => term.writeln('\x1b[31m[ws error]\x1b[0m');
|
|
43
|
+
ws.onmessage = (evt) => {
|
|
44
|
+
const msg = JSON.parse(evt.data);
|
|
45
|
+
switch (msg.type) {
|
|
46
|
+
case 'started':
|
|
47
|
+
setRunning(true);
|
|
48
|
+
term.writeln(`\x1b[2m$ ${msg.data}\x1b[0m`);
|
|
49
|
+
break;
|
|
50
|
+
case 'stdout':
|
|
51
|
+
term.write(msg.data);
|
|
52
|
+
break;
|
|
53
|
+
case 'stderr':
|
|
54
|
+
term.write(`\x1b[33m${msg.data}\x1b[0m`);
|
|
55
|
+
break;
|
|
56
|
+
case 'exit':
|
|
57
|
+
setRunning(false);
|
|
58
|
+
term.writeln(`\x1b[2m[exit ${msg.data}]\x1b[0m`);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function setRunning(v) {
|
|
65
|
+
wsRunning = v;
|
|
66
|
+
document.getElementById('term-run').disabled = v;
|
|
67
|
+
document.getElementById('term-stop').disabled = !v;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function wsSend(obj) {
|
|
71
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runTermCmd(cmd) {
|
|
75
|
+
if (!cmd) return;
|
|
76
|
+
if (termHistory[0] !== cmd) termHistory.unshift(cmd);
|
|
77
|
+
if (termHistory.length > 100) termHistory.pop();
|
|
78
|
+
termHistIdx = -1;
|
|
79
|
+
if (activePanel !== 'terminal') showPanel('terminal');
|
|
80
|
+
wsSend({ type: 'run', command: cmd });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
document.getElementById('term-run').addEventListener('click', () => {
|
|
84
|
+
const input = document.getElementById('term-input');
|
|
85
|
+
runTermCmd(input.value.trim());
|
|
86
|
+
input.value = '';
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
document.getElementById('term-stop').addEventListener('click', () =>
|
|
90
|
+
wsSend({ type: 'signal', signal: 'SIGINT' })
|
|
91
|
+
);
|
|
92
|
+
document.getElementById('term-clear').addEventListener('click', () => term.clear());
|
|
93
|
+
|
|
94
|
+
const termInput = document.getElementById('term-input');
|
|
95
|
+
termInput.addEventListener('keydown', (e) => {
|
|
96
|
+
if (e.key === 'Enter') {
|
|
97
|
+
runTermCmd(termInput.value.trim());
|
|
98
|
+
termInput.value = '';
|
|
99
|
+
} else if (e.key === 'ArrowUp') {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
termHistIdx = Math.min(termHistIdx + 1, termHistory.length - 1);
|
|
102
|
+
termInput.value = termHistory[termHistIdx] || '';
|
|
103
|
+
} else if (e.key === 'ArrowDown') {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
termHistIdx = Math.max(termHistIdx - 1, -1);
|
|
106
|
+
termInput.value = termHistIdx < 0 ? '' : termHistory[termHistIdx];
|
|
107
|
+
} else if (e.key === 'c' && e.ctrlKey) {
|
|
108
|
+
wsSend({ type: 'signal', signal: 'SIGINT' });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
setRunning(false);
|
|
113
|
+
wsConnect();
|
|
114
|
+
|
|
115
|
+
/* ── navigation ─────────────────────────────────────────────────────────── */
|
|
116
|
+
function showPanel(id) {
|
|
117
|
+
activePanel = id;
|
|
118
|
+
document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
|
|
119
|
+
document.querySelectorAll('.nav-item').forEach((n) => {
|
|
120
|
+
n.classList.toggle('active', n.dataset.panel === id);
|
|
121
|
+
});
|
|
122
|
+
let el = document.getElementById(id + '-panel');
|
|
123
|
+
if (!el) {
|
|
124
|
+
// probe panels live inside #probe-panels container
|
|
125
|
+
el = document.getElementById('probe-' + id);
|
|
126
|
+
}
|
|
127
|
+
if (el) el.classList.add('active');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ── overview ────────────────────────────────────────────────────────────── */
|
|
131
|
+
function renderOverview(nodes) {
|
|
132
|
+
const card = document.getElementById('node-card');
|
|
133
|
+
if (!session) return;
|
|
134
|
+
|
|
135
|
+
// session info — two-line topbar layout
|
|
136
|
+
const sessionDiv = document.getElementById('session');
|
|
137
|
+
sessionDiv.innerHTML = `
|
|
138
|
+
<div class="session-main">
|
|
139
|
+
<span class="tb-ctx">ctx: ${esc(session.context || 'default')}</span>
|
|
140
|
+
<span class="tb-arrow">›</span>
|
|
141
|
+
<span class="tb-node" title="${esc(session.node)}">${esc(session.node)}</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="session-sub">
|
|
144
|
+
<span class="tb-badge">pod: <b>${esc(session.namespace)}/${esc(session.podName)}</b></span>
|
|
145
|
+
<span class="tb-dot">●</span>
|
|
146
|
+
<span class="tb-badge">image: <b>${esc(session.image)}</b></span>
|
|
147
|
+
</div>`;
|
|
148
|
+
|
|
149
|
+
const n = nodes.find((x) => x.name === session.node);
|
|
150
|
+
if (!n) {
|
|
151
|
+
card.innerHTML = `<div class="kv"><span class="k">Node</span><span class="v">${esc(session.node)}</span></div>`;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
card.innerHTML = `
|
|
156
|
+
<div class="kv">
|
|
157
|
+
<span class="k">Name</span><span class="v">${esc(n.name)}</span>
|
|
158
|
+
<span class="k">Status</span><span class="v" style="color:${n.ready ? 'var(--ok)' : 'var(--err)'}">
|
|
159
|
+
${n.ready ? '● Ready' : '○ Not Ready'}</span>
|
|
160
|
+
<span class="k">Roles</span><span class="v">${esc(n.roles.join(', ') || '—')}</span>
|
|
161
|
+
<span class="k">Internal IP</span><span class="v">${esc(n.internalIP || '—')}</span>
|
|
162
|
+
<span class="k">OS</span><span class="v">${esc(n.os || '—')}</span>
|
|
163
|
+
<span class="k">Kernel</span><span class="v">${esc(n.kernel || '—')}</span>
|
|
164
|
+
<span class="k">Container runtime</span><span class="v">${esc(n.runtime || '—')}</span>
|
|
165
|
+
<span class="k">Kubelet</span><span class="v">${esc(n.kubelet || '—')}</span>
|
|
166
|
+
</div>
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ── sidebar ─────────────────────────────────────────────────────────────── */
|
|
171
|
+
function buildSidebar(probes) {
|
|
172
|
+
const nav = document.getElementById('sidebar');
|
|
173
|
+
nav.innerHTML = '';
|
|
174
|
+
|
|
175
|
+
const overviewItem = navItem('overview', 'Overview', 'overview');
|
|
176
|
+
overviewItem.querySelector('.dot').style.display = 'none';
|
|
177
|
+
nav.appendChild(overviewItem);
|
|
178
|
+
|
|
179
|
+
const groups = {};
|
|
180
|
+
for (const p of probes) {
|
|
181
|
+
(groups[p.group] = groups[p.group] || []).push(p);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const [grp, items] of Object.entries(groups)) {
|
|
185
|
+
const hdr = document.createElement('div');
|
|
186
|
+
hdr.className = 'nav-group';
|
|
187
|
+
hdr.textContent = grp;
|
|
188
|
+
nav.appendChild(hdr);
|
|
189
|
+
|
|
190
|
+
const GROUP_PANELS = {
|
|
191
|
+
'Health': { panelId: 'health', label: 'Node Health' },
|
|
192
|
+
'Firewall': { panelId: 'firewall', label: 'Firewall' },
|
|
193
|
+
'Conntrack': { panelId: 'conntrack', label: 'Conntrack' },
|
|
194
|
+
};
|
|
195
|
+
if (GROUP_PANELS[grp]) {
|
|
196
|
+
const { panelId, label } = GROUP_PANELS[grp];
|
|
197
|
+
const item = document.createElement('div');
|
|
198
|
+
item.className = 'nav-item';
|
|
199
|
+
item.dataset.panel = panelId;
|
|
200
|
+
item.textContent = label;
|
|
201
|
+
item.addEventListener('click', () => {
|
|
202
|
+
showPanel(panelId);
|
|
203
|
+
const uncached = items.filter(p => !probeCache[p.id]);
|
|
204
|
+
if (uncached.length) uncached.forEach(p => runProbe(p.id));
|
|
205
|
+
});
|
|
206
|
+
nav.appendChild(item);
|
|
207
|
+
} else {
|
|
208
|
+
for (const p of items) nav.appendChild(navItem(p.id, p.label, 'probe'));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Connectivity (not a probe group — special panel)
|
|
213
|
+
const connHdr = document.createElement('div');
|
|
214
|
+
connHdr.className = 'nav-group';
|
|
215
|
+
connHdr.textContent = 'Connectivity';
|
|
216
|
+
nav.appendChild(connHdr);
|
|
217
|
+
const connItem = navItem('connectivity', 'Connectivity prober', 'connectivity');
|
|
218
|
+
connItem.querySelector('.dot').style.display = 'none';
|
|
219
|
+
nav.appendChild(connItem);
|
|
220
|
+
|
|
221
|
+
// Terminal
|
|
222
|
+
const termHdr = document.createElement('div');
|
|
223
|
+
termHdr.className = 'nav-group';
|
|
224
|
+
termHdr.textContent = 'Terminal';
|
|
225
|
+
nav.appendChild(termHdr);
|
|
226
|
+
const termItem = navItem('terminal', 'Terminal', 'terminal');
|
|
227
|
+
termItem.querySelector('.dot').style.display = 'none';
|
|
228
|
+
nav.appendChild(termItem);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function navItem(id, label, type) {
|
|
232
|
+
const el = document.createElement('div');
|
|
233
|
+
el.className = 'nav-item';
|
|
234
|
+
el.dataset.panel = id;
|
|
235
|
+
const dot = document.createElement('span');
|
|
236
|
+
dot.className = 'dot';
|
|
237
|
+
dot.id = 'dot-' + id;
|
|
238
|
+
el.appendChild(document.createTextNode(label));
|
|
239
|
+
el.appendChild(dot);
|
|
240
|
+
el.addEventListener('click', () => {
|
|
241
|
+
showPanel(id);
|
|
242
|
+
if (type === 'probe' && !probeCache[id]) runProbe(id);
|
|
243
|
+
});
|
|
244
|
+
return el;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* ── probe panels ────────────────────────────────────────────────────────── */
|
|
248
|
+
|
|
249
|
+
// Display order for the combined health page
|
|
250
|
+
const HEALTH_ORDER = ['cpu-stat', 'mem-info', 'disk-usage', 'mem-pressure', 'oom-kills', 'kubelet-logs'];
|
|
251
|
+
const FIREWALL_ORDER = ['iptables', 'iptables-nat', 'nftables', 'ipvs'];
|
|
252
|
+
const CONNTRACK_ORDER = ['conntrack', 'conntrack-stats', 'conntrack-count'];
|
|
253
|
+
|
|
254
|
+
// Probes that get a rich custom renderer instead of a plain <pre>.
|
|
255
|
+
const FANCY_PROBES = new Set([
|
|
256
|
+
'iptables', 'iptables-nat',
|
|
257
|
+
'conntrack', 'conntrack-stats', 'conntrack-count',
|
|
258
|
+
'mem-info', 'mem-pressure', 'oom-kills', 'kubelet-logs', 'disk-usage', 'cpu-stat',
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
function buildProbePanel(probe) {
|
|
262
|
+
const container = document.getElementById('probe-panels');
|
|
263
|
+
const section = document.createElement('section');
|
|
264
|
+
section.id = 'probe-' + probe.id;
|
|
265
|
+
section.className = 'panel';
|
|
266
|
+
const fancy = FANCY_PROBES.has(probe.id);
|
|
267
|
+
section.innerHTML = `
|
|
268
|
+
<h2>${esc(probe.label)}</h2>
|
|
269
|
+
<div class="probe-head">
|
|
270
|
+
<span class="desc">${esc(probe.desc)}</span>
|
|
271
|
+
<button id="run-btn-${esc(probe.id)}">↻ Re-run</button>
|
|
272
|
+
</div>
|
|
273
|
+
<div id="probe-cmd-${esc(probe.id)}" class="probe-head" style="margin-bottom:8px;"></div>
|
|
274
|
+
${fancy
|
|
275
|
+
? `<div id="probe-out-${esc(probe.id)}" class="ipt-container"></div>`
|
|
276
|
+
: `<pre id="probe-out-${esc(probe.id)}" class="output"><span class="empty">Not yet loaded.</span></pre>`
|
|
277
|
+
}
|
|
278
|
+
`;
|
|
279
|
+
container.appendChild(section);
|
|
280
|
+
section.querySelector(`#run-btn-${probe.id}`).addEventListener('click', () => runProbe(probe.id));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildGroupPanel(groupId, title, probes, displayOrder) {
|
|
284
|
+
const container = document.getElementById('probe-panels');
|
|
285
|
+
const section = document.createElement('section');
|
|
286
|
+
section.id = 'probe-' + groupId;
|
|
287
|
+
section.className = 'panel';
|
|
288
|
+
|
|
289
|
+
const ordered = displayOrder
|
|
290
|
+
.map(id => probes.find(p => p.id === id))
|
|
291
|
+
.filter(Boolean)
|
|
292
|
+
.concat(probes.filter(p => !displayOrder.includes(p.id)));
|
|
293
|
+
|
|
294
|
+
const runBtnId = `run-btn-${groupId}`;
|
|
295
|
+
section.innerHTML = `
|
|
296
|
+
<div class="hp-header">
|
|
297
|
+
<h2>${esc(title)}</h2>
|
|
298
|
+
<button id="${runBtnId}">↻ Refresh all</button>
|
|
299
|
+
</div>
|
|
300
|
+
<div class="hp-tabs">
|
|
301
|
+
${ordered.map((p, i) => `
|
|
302
|
+
<button class="hp-tab${i === 0 ? ' active' : ''}" data-probe="${esc(p.id)}">
|
|
303
|
+
<span class="hp-tab-label">${esc(p.label)}</span>
|
|
304
|
+
<span class="hp-tab-dot dot" id="dot-${esc(p.id)}"></span>
|
|
305
|
+
</button>`).join('')}
|
|
306
|
+
</div>
|
|
307
|
+
<div class="hp-panes">
|
|
308
|
+
${ordered.map((p, i) => `
|
|
309
|
+
<div class="hp-pane${i === 0 ? ' active' : ''}" id="hpane-${esc(p.id)}">
|
|
310
|
+
<div class="hp-pane-meta">
|
|
311
|
+
<span class="hp-pane-desc">${esc(p.desc)}</span>
|
|
312
|
+
<div id="probe-cmd-${esc(p.id)}" class="hp-section-cmd"></div>
|
|
313
|
+
<button class="hp-section-rerun" data-probe="${esc(p.id)}" title="Re-run this probe">↻ Re-run</button>
|
|
314
|
+
</div>
|
|
315
|
+
<div id="probe-out-${esc(p.id)}" class="ipt-container">
|
|
316
|
+
<span class="empty" style="padding:12px;display:block">Loading…</span>
|
|
317
|
+
</div>
|
|
318
|
+
</div>`).join('')}
|
|
319
|
+
</div>`;
|
|
320
|
+
|
|
321
|
+
container.appendChild(section);
|
|
322
|
+
|
|
323
|
+
section.querySelectorAll('.hp-tab').forEach(tab => {
|
|
324
|
+
tab.addEventListener('click', () => {
|
|
325
|
+
section.querySelectorAll('.hp-tab').forEach(t => t.classList.remove('active'));
|
|
326
|
+
section.querySelectorAll('.hp-pane').forEach(p => p.classList.remove('active'));
|
|
327
|
+
tab.classList.add('active');
|
|
328
|
+
section.querySelector(`#hpane-${tab.dataset.probe}`).classList.add('active');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
section.querySelector('#' + runBtnId).addEventListener('click', () => {
|
|
333
|
+
for (const p of ordered) runProbe(p.id);
|
|
334
|
+
});
|
|
335
|
+
section.querySelectorAll('.hp-section-rerun').forEach(btn => {
|
|
336
|
+
btn.addEventListener('click', () => runProbe(btn.dataset.probe));
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function runProbe(id) {
|
|
341
|
+
const dot = document.getElementById('dot-' + id);
|
|
342
|
+
const out = document.getElementById('probe-out-' + id);
|
|
343
|
+
const cmdEl = document.getElementById('probe-cmd-' + id);
|
|
344
|
+
if (!out) return;
|
|
345
|
+
if (dot) dot.className = 'dot loading';
|
|
346
|
+
out.innerHTML = '<span class="empty" style="padding:12px;display:block">Running…</span>';
|
|
347
|
+
cmdEl.innerHTML = '';
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const r = await api('/api/probe/' + id);
|
|
351
|
+
probeCache[id] = r;
|
|
352
|
+
if (cmdEl) cmdEl.innerHTML = `<span class="cmd">$ ${esc(r.command)}</span>`;
|
|
353
|
+
|
|
354
|
+
if (r.ok && r.output.trim()) {
|
|
355
|
+
const rendered = tryFancyRender(id, r.output, out);
|
|
356
|
+
if (!rendered) {
|
|
357
|
+
out.className = 'output';
|
|
358
|
+
out.textContent = r.output;
|
|
359
|
+
}
|
|
360
|
+
if (dot) dot.className = 'dot ok';
|
|
361
|
+
} else {
|
|
362
|
+
const text = r.error || r.output || '(no output)';
|
|
363
|
+
out.className = 'output error';
|
|
364
|
+
out.textContent = text;
|
|
365
|
+
if (dot) dot.className = 'dot err';
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
out.className = 'output error';
|
|
369
|
+
out.textContent = String(err);
|
|
370
|
+
if (dot) dot.className = 'dot err';
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* ── fancy renderer dispatch ─────────────────────────────────────────────── */
|
|
375
|
+
function tryFancyRender(id, output, container) {
|
|
376
|
+
const renderers = {
|
|
377
|
+
'iptables': () => renderIptablesView(output, container),
|
|
378
|
+
'iptables-nat': () => renderIptablesView(output, container),
|
|
379
|
+
'conntrack': () => renderConntrackView(output, container),
|
|
380
|
+
'conntrack-stats': () => renderConntrackStats(output, container),
|
|
381
|
+
'conntrack-count': () => renderConntrackCount(output, container),
|
|
382
|
+
'mem-info': () => renderMemInfoView(output, container),
|
|
383
|
+
'mem-pressure': () => renderMemPressureView(output, container),
|
|
384
|
+
'oom-kills': () => renderOomKillsView(output, container),
|
|
385
|
+
'kubelet-logs': () => renderKubeletLogsView(output, container),
|
|
386
|
+
'disk-usage': () => renderDiskView(output, container),
|
|
387
|
+
'cpu-stat': () => renderCpuView(output, container),
|
|
388
|
+
};
|
|
389
|
+
if (!renderers[id]) return false;
|
|
390
|
+
try { renderers[id](); return true; } catch (e) { console.error('[fancy render]', id, e); return false; }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function runAllProbes() {
|
|
394
|
+
if (!session) return;
|
|
395
|
+
for (const p of session.probes) runProbe(p.id);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
document.getElementById('refresh-all').addEventListener('click', runAllProbes);
|
|
399
|
+
|
|
400
|
+
/* ── connectivity prober ─────────────────────────────────────────────────── */
|
|
401
|
+
function initConnectivity() {
|
|
402
|
+
const runBtn = document.getElementById('conn-run');
|
|
403
|
+
const targetEl = document.getElementById('conn-target');
|
|
404
|
+
const portEl = document.getElementById('conn-port');
|
|
405
|
+
const protoEl = document.getElementById('conn-proto');
|
|
406
|
+
const resultsEl= document.getElementById('conn-results');
|
|
407
|
+
if (!runBtn) return;
|
|
408
|
+
|
|
409
|
+
async function runConnTest() {
|
|
410
|
+
const target = targetEl.value.trim();
|
|
411
|
+
const port = portEl.value.trim();
|
|
412
|
+
const protocol = protoEl.value;
|
|
413
|
+
if (!target) { targetEl.focus(); return; }
|
|
414
|
+
|
|
415
|
+
runBtn.disabled = true;
|
|
416
|
+
resultsEl.innerHTML = '<div class="conn-loading">Running tests…</div>';
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const data = await apiPost('/api/connectivity', { target, port, protocol });
|
|
420
|
+
renderConnResults(data, resultsEl);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
resultsEl.innerHTML = `<div class="conn-error">${esc(String(err))}</div>`;
|
|
423
|
+
} finally {
|
|
424
|
+
runBtn.disabled = false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
runBtn.addEventListener('click', runConnTest);
|
|
429
|
+
targetEl.addEventListener('keydown', e => { if (e.key === 'Enter') runConnTest(); });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function renderConnResults(data, container) {
|
|
433
|
+
const { target, port, protocol, results } = data;
|
|
434
|
+
|
|
435
|
+
function statusBadge(ok, label) {
|
|
436
|
+
return `<span class="conn-badge ${ok ? 'conn-ok' : 'conn-fail'}">${ok ? '✔' : '✖'} ${esc(label)}</span>`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function section(title, ok, output) {
|
|
440
|
+
if (!output && output !== '') return '';
|
|
441
|
+
return `
|
|
442
|
+
<div class="conn-section">
|
|
443
|
+
<div class="conn-sec-title">${statusBadge(ok, title)}</div>
|
|
444
|
+
${output.trim() ? `<pre class="conn-output">${esc(output.trim())}</pre>` : ''}
|
|
445
|
+
</div>`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
container.innerHTML = `
|
|
449
|
+
<div class="conn-target-line">Testing: <code>${esc(target)}${port ? ':' + esc(port) : ''}</code> via <b>${esc(protocol.toUpperCase())}</b></div>
|
|
450
|
+
${section('Ping (ICMP reachability)', results.ping?.ok, results.ping?.output || '')}
|
|
451
|
+
${results.nc ? section(`TCP connect :${port}`, results.nc.ok, results.nc.output || '') : ''}
|
|
452
|
+
${results.curl ? section(`HTTP ${results.curl.url}`, results.curl.ok, results.curl.output || '') : ''}
|
|
453
|
+
${section('DNS resolution', results.dns?.ok, results.dns?.output || '')}
|
|
454
|
+
${section('Traceroute', results.traceroute?.ok, results.traceroute?.output || '')}
|
|
455
|
+
${results.conntrack?.output?.trim()
|
|
456
|
+
? `<div class="conn-section"><div class="conn-sec-title"><span class="conn-badge conn-info">⬡ Matching conntrack entries</span></div><pre class="conn-output">${esc(results.conntrack.output.trim())}</pre></div>`
|
|
457
|
+
: '<div class="conn-section conn-no-ct">No matching conntrack entries — connection may not have been attempted or was rejected before tracking.</div>'}
|
|
458
|
+
`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* ── snapshot export ─────────────────────────────────────────────────────── */
|
|
462
|
+
function downloadSnapshot() {
|
|
463
|
+
const snap = {
|
|
464
|
+
exported_at: new Date().toISOString(),
|
|
465
|
+
session: {
|
|
466
|
+
node: session?.node,
|
|
467
|
+
pod: session?.podName,
|
|
468
|
+
namespace: session?.namespace,
|
|
469
|
+
context: session?.context,
|
|
470
|
+
image: session?.image,
|
|
471
|
+
},
|
|
472
|
+
probes: Object.fromEntries(
|
|
473
|
+
Object.entries(probeCache).map(([id, r]) => [id, {
|
|
474
|
+
id, label: r.label, command: r.command,
|
|
475
|
+
ok: r.ok, output: r.output, error: r.error,
|
|
476
|
+
}])
|
|
477
|
+
),
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const node = session?.node?.replace(/[^a-z0-9-]/gi, '-') || 'node';
|
|
481
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
482
|
+
const blob = new Blob([JSON.stringify(snap, null, 2)], { type: 'application/json' });
|
|
483
|
+
const url = URL.createObjectURL(blob);
|
|
484
|
+
const a = document.createElement('a');
|
|
485
|
+
a.href = url;
|
|
486
|
+
a.download = `node-debug-${node}-${ts}.json`;
|
|
487
|
+
a.click();
|
|
488
|
+
URL.revokeObjectURL(url);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/* ── helpers ─────────────────────────────────────────────────────────────── */
|
|
492
|
+
async function api(path) {
|
|
493
|
+
const r = await fetch(path);
|
|
494
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
495
|
+
return r.json();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function apiPost(path, body) {
|
|
499
|
+
const r = await fetch(path, {
|
|
500
|
+
method: 'POST',
|
|
501
|
+
headers: { 'Content-Type': 'application/json' },
|
|
502
|
+
body: JSON.stringify(body),
|
|
503
|
+
});
|
|
504
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
505
|
+
return r.json();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function esc(str) {
|
|
509
|
+
return String(str ?? '')
|
|
510
|
+
.replace(/&/g, '&')
|
|
511
|
+
.replace(/</g, '<')
|
|
512
|
+
.replace(/>/g, '>')
|
|
513
|
+
.replace(/"/g, '"');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/* ── init ────────────────────────────────────────────────────────────────── */
|
|
517
|
+
async function init() {
|
|
518
|
+
session = await api('/api/session');
|
|
519
|
+
const nodes = await api('/api/nodes');
|
|
520
|
+
|
|
521
|
+
const GROUP_IDS = new Set(['Health', 'Firewall', 'Conntrack']);
|
|
522
|
+
const healthProbes = session.probes.filter(p => p.group === 'Health');
|
|
523
|
+
const firewallProbes = session.probes.filter(p => p.group === 'Firewall');
|
|
524
|
+
const conntrackProbes = session.probes.filter(p => p.group === 'Conntrack');
|
|
525
|
+
const otherProbes = session.probes.filter(p => !GROUP_IDS.has(p.group));
|
|
526
|
+
|
|
527
|
+
buildSidebar(session.probes);
|
|
528
|
+
for (const p of otherProbes) buildProbePanel(p);
|
|
529
|
+
buildGroupPanel('health', 'Node Health', healthProbes, HEALTH_ORDER);
|
|
530
|
+
buildGroupPanel('firewall', 'Firewall', firewallProbes, FIREWALL_ORDER);
|
|
531
|
+
buildGroupPanel('conntrack', 'Conntrack', conntrackProbes, CONNTRACK_ORDER);
|
|
532
|
+
|
|
533
|
+
renderOverview(nodes);
|
|
534
|
+
showPanel('overview');
|
|
535
|
+
|
|
536
|
+
initConnectivity();
|
|
537
|
+
document.getElementById('snapshot-btn')?.addEventListener('click', downloadSnapshot);
|
|
538
|
+
|
|
539
|
+
// auto-run all probes on load
|
|
540
|
+
runAllProbes();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
init().catch((err) => {
|
|
544
|
+
document.getElementById('main').innerHTML = `<div style="color:var(--err);padding:24px">
|
|
545
|
+
Failed to connect: ${esc(String(err))}. Make sure the server is running.
|
|
546
|
+
</div>`;
|
|
547
|
+
});
|