claude-code-hub 1.3.2 → 1.3.3
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/lib/port-health.js +34 -0
- package/package.json +1 -1
- package/public/app.js +108 -21
- package/public/index.html +12 -0
- package/server.js +50 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Some loopback ports on Windows answer with TCP retransmit delays (0.3 s, 0.9 s, 2 s, 4.5 s ...)
|
|
4
|
+
// while the app behind them is idle; a restart moves the app to a fresh port and the delays go.
|
|
5
|
+
// A probe pairs an HTTP request with an IPC ping sent at the same moment: a slow HTTP answer with a
|
|
6
|
+
// fast pong is the port, both slow is a busy app.
|
|
7
|
+
const SLOW_HTTP_MS = 300;
|
|
8
|
+
const FAST_IPC_MS = 100;
|
|
9
|
+
const STRIKES = 3;
|
|
10
|
+
const WINDOW_MS = 5 * 60_000;
|
|
11
|
+
|
|
12
|
+
// Keyed by the child process: a restart brings a new child on a new port, so its strikes start over.
|
|
13
|
+
function createPortHealth({ now = Date.now } = {}) {
|
|
14
|
+
const strikesOf = new WeakMap();
|
|
15
|
+
|
|
16
|
+
function recent(child) {
|
|
17
|
+
const since = now() - WINDOW_MS;
|
|
18
|
+
return (strikesOf.get(child) ?? []).filter((s) => s.at >= since);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function record(child, { httpMs, ipcMs }) {
|
|
22
|
+
if (httpMs < SLOW_HTTP_MS || ipcMs == null || ipcMs >= FAST_IPC_MS) return;
|
|
23
|
+
strikesOf.set(child, [...recent(child), { at: now(), ms: Math.round(httpMs) }]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function flagged(child) {
|
|
27
|
+
const strikes = recent(child);
|
|
28
|
+
return strikes.length < STRIKES ? null : { worstMs: Math.max(...strikes.map((s) => s.ms)) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { record, flagged };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { createPortHealth, STRIKES, WINDOW_MS };
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -236,6 +236,7 @@ async function init() {
|
|
|
236
236
|
bindPalette();
|
|
237
237
|
registerSW();
|
|
238
238
|
bindErrorToast();
|
|
239
|
+
bindSlowToast();
|
|
239
240
|
showUpdateToast();
|
|
240
241
|
}
|
|
241
242
|
|
|
@@ -263,8 +264,7 @@ async function showUpdateToast() {
|
|
|
263
264
|
let timer = 0;
|
|
264
265
|
const hide = () => {
|
|
265
266
|
clearTimeout(timer);
|
|
266
|
-
toast
|
|
267
|
-
setTimeout(() => (toast.hidden = true), 200);
|
|
267
|
+
hideToast(toast);
|
|
268
268
|
};
|
|
269
269
|
const arm = () => (timer = setTimeout(hide, UPDATE_TOAST_MS));
|
|
270
270
|
toast.addEventListener('mouseenter', () => clearTimeout(timer));
|
|
@@ -276,18 +276,110 @@ async function showUpdateToast() {
|
|
|
276
276
|
} catch {}
|
|
277
277
|
hide();
|
|
278
278
|
});
|
|
279
|
-
toast
|
|
279
|
+
showToast(toast);
|
|
280
280
|
arm();
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
function showToast(toast) {
|
|
284
|
+
toast.classList.remove('leaving');
|
|
285
|
+
toast.hidden = false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// A show during the fade cancels the hide.
|
|
289
|
+
function hideToast(toast) {
|
|
290
|
+
toast.classList.add('leaving');
|
|
291
|
+
setTimeout(() => {
|
|
292
|
+
if (toast.classList.contains('leaving')) toast.hidden = true;
|
|
293
|
+
}, 200);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The stalls belong to the port, so a dismissal holds while the app stays on it.
|
|
297
|
+
const SLOW_POLL_MS = 30_000;
|
|
298
|
+
const slowDismissed = new Map();
|
|
299
|
+
const slowToast = { id: null, port: 0, confirm: false, busy: false };
|
|
300
|
+
|
|
301
|
+
function hideSlowToast() {
|
|
302
|
+
slowToast.id = null;
|
|
303
|
+
hideToast(document.getElementById('slow-toast'));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderSlowToast(app) {
|
|
307
|
+
const restart = document.getElementById('slow-restart');
|
|
308
|
+
document.getElementById('slow-title').textContent = `${app.name} answers slowly`;
|
|
309
|
+
document.getElementById('slow-detail').textContent =
|
|
310
|
+
`Its port delays requests by up to ${(app.worstMs / 1000).toFixed(1)} s. A restart moves it to a new port.`;
|
|
311
|
+
restart.textContent = 'Restart';
|
|
312
|
+
restart.disabled = false;
|
|
313
|
+
slowToast.id = app.id;
|
|
314
|
+
slowToast.port = app.port;
|
|
315
|
+
slowToast.confirm = false;
|
|
316
|
+
showToast(document.getElementById('slow-toast'));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function pollSlowApps() {
|
|
320
|
+
if (document.hidden || slowToast.busy) return;
|
|
321
|
+
let slow;
|
|
322
|
+
try {
|
|
323
|
+
({ slow } = await sendJson('GET', '/api/apps/health'));
|
|
324
|
+
} catch {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (slowToast.id && !slow.some((a) => a.id === slowToast.id)) hideSlowToast();
|
|
328
|
+
const next = slow.find((a) => slowDismissed.get(a.id) !== a.port);
|
|
329
|
+
if (next && next.id !== slowToast.id) renderSlowToast(next);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Null on success. A 409 with live terminals asks for force: true on the next call.
|
|
333
|
+
async function requestRestart(id, force) {
|
|
334
|
+
try {
|
|
335
|
+
await sendJson('POST', `/api/apps/${encodeURIComponent(id)}/restart`, { force });
|
|
336
|
+
return null;
|
|
337
|
+
} catch (err) {
|
|
338
|
+
const name = apps[id]?.name ?? id;
|
|
339
|
+
const n = err.status === 409 && err.data?.terminals;
|
|
340
|
+
if (!n) return { message: `${name} did not restart: ${err.message}` };
|
|
341
|
+
const them = n === 1 ? 'it' : 'them';
|
|
342
|
+
return { terminals: n, message: `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A restart ends ${them}.` };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function restartSlowApp() {
|
|
347
|
+
const { id } = slowToast;
|
|
348
|
+
if (!id) return;
|
|
349
|
+
const restart = document.getElementById('slow-restart');
|
|
350
|
+
slowToast.busy = true;
|
|
351
|
+
restart.disabled = true;
|
|
352
|
+
restart.textContent = 'Restarting…';
|
|
353
|
+
const failed = await requestRestart(id, slowToast.confirm);
|
|
354
|
+
slowToast.busy = false;
|
|
355
|
+
if (!failed) {
|
|
356
|
+
hideSlowToast();
|
|
357
|
+
replaceIframe(id);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
slowToast.confirm = !!failed.terminals;
|
|
361
|
+
document.getElementById('slow-detail').textContent = failed.message;
|
|
362
|
+
restart.textContent = failed.terminals ? 'Restart anyway' : 'Restart';
|
|
363
|
+
restart.disabled = false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function bindSlowToast() {
|
|
367
|
+
document.getElementById('slow-restart').addEventListener('click', restartSlowApp);
|
|
368
|
+
document.getElementById('slow-close').addEventListener('click', () => {
|
|
369
|
+
if (slowToast.id) slowDismissed.set(slowToast.id, slowToast.port);
|
|
370
|
+
hideSlowToast();
|
|
371
|
+
});
|
|
372
|
+
setInterval(pollSlowApps, SLOW_POLL_MS);
|
|
373
|
+
document.addEventListener('visibilitychange', pollSlowApps);
|
|
374
|
+
pollSlowApps();
|
|
375
|
+
}
|
|
376
|
+
|
|
283
377
|
const ERROR_TOAST_MS = 8_000;
|
|
284
378
|
let errorToastTimer = 0;
|
|
285
379
|
|
|
286
380
|
function hideErrorToast() {
|
|
287
381
|
clearTimeout(errorToastTimer);
|
|
288
|
-
|
|
289
|
-
toast.classList.add('leaving');
|
|
290
|
-
setTimeout(() => (toast.hidden = true), 200);
|
|
382
|
+
hideToast(document.getElementById('error-toast'));
|
|
291
383
|
}
|
|
292
384
|
|
|
293
385
|
// A 401 means the hub_token cookie no longer matches the token file. Opening /?token=… sets a new
|
|
@@ -299,9 +391,7 @@ function showErrorToast(action, err) {
|
|
|
299
391
|
? `This browser has an old hub token. Open the link the hub printed in the terminal (${location.origin}/?token=…) to renew it.`
|
|
300
392
|
: err.message;
|
|
301
393
|
document.getElementById('error-actions').hidden = !unauthorized;
|
|
302
|
-
|
|
303
|
-
toast.classList.remove('leaving');
|
|
304
|
-
toast.hidden = false;
|
|
394
|
+
showToast(document.getElementById('error-toast'));
|
|
305
395
|
clearTimeout(errorToastTimer);
|
|
306
396
|
if (!unauthorized) errorToastTimer = setTimeout(hideErrorToast, ERROR_TOAST_MS);
|
|
307
397
|
}
|
|
@@ -864,20 +954,17 @@ function setPaletteHint(text) {
|
|
|
864
954
|
|
|
865
955
|
// The terminal provider answers 409 with a terminal count; the second Ctrl+R on the same app sends force.
|
|
866
956
|
async function restartFromPalette(id) {
|
|
867
|
-
const name = apps[id]?.name ?? id;
|
|
868
957
|
const force = palette.confirmRestart === id;
|
|
869
958
|
palette.confirmRestart = null;
|
|
870
|
-
setPaletteHint(`Restarting ${name}…`);
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
);
|
|
880
|
-
} else setPaletteHint(`${name} did not restart: ${err.message}`);
|
|
959
|
+
setPaletteHint(`Restarting ${apps[id]?.name ?? id}…`);
|
|
960
|
+
const failed = await withPaletteBusy(() => requestRestart(id, force));
|
|
961
|
+
if (failed?.terminals) {
|
|
962
|
+
palette.confirmRestart = id;
|
|
963
|
+
setPaletteHint(`${failed.message} Press Ctrl+R again to restart.`);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (failed) {
|
|
967
|
+
setPaletteHint(failed.message);
|
|
881
968
|
return;
|
|
882
969
|
}
|
|
883
970
|
closePalette();
|
package/public/index.html
CHANGED
|
@@ -385,6 +385,7 @@
|
|
|
385
385
|
|
|
386
386
|
.update-toast-close:hover { color: var(--text); }
|
|
387
387
|
.error-toast { z-index: 25; border-left-color: #e5484d; }
|
|
388
|
+
.slow-toast { z-index: 20; border-left-color: #f5a524; }
|
|
388
389
|
</style>
|
|
389
390
|
</head>
|
|
390
391
|
<body>
|
|
@@ -457,6 +458,17 @@
|
|
|
457
458
|
<button class="update-toast-close" id="update-close" type="button" aria-label="Close">×</button>
|
|
458
459
|
</div>
|
|
459
460
|
|
|
461
|
+
<div class="update-toast slow-toast" id="slow-toast" role="status" hidden>
|
|
462
|
+
<div class="update-toast-body">
|
|
463
|
+
<div class="update-toast-title" id="slow-title"></div>
|
|
464
|
+
<div class="update-toast-meta" id="slow-detail"></div>
|
|
465
|
+
<div class="update-toast-actions">
|
|
466
|
+
<button class="update-toast-skip" id="slow-restart" type="button">Restart</button>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
<button class="update-toast-close" id="slow-close" type="button" aria-label="Close">×</button>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
460
472
|
<div class="update-toast error-toast" id="error-toast" role="alert" hidden>
|
|
461
473
|
<div class="update-toast-body">
|
|
462
474
|
<div class="update-toast-title" id="error-title"></div>
|
package/server.js
CHANGED
|
@@ -14,6 +14,7 @@ const { stripCookie } = require('./lib/cookies');
|
|
|
14
14
|
const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
|
|
15
15
|
const { ping, stats } = require('./lib/ipc');
|
|
16
16
|
const { killChild } = require('./lib/kill-child');
|
|
17
|
+
const { createPortHealth } = require('./lib/port-health');
|
|
17
18
|
const { readRegistry, themeConfig } = require('./lib/themes');
|
|
18
19
|
const { createUpdateCheck } = require('./lib/update-check');
|
|
19
20
|
const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
|
|
@@ -962,6 +963,54 @@ app.get('/api/apps/stats', async (_req, res) => {
|
|
|
962
963
|
res.json(Object.fromEntries(entries));
|
|
963
964
|
});
|
|
964
965
|
|
|
966
|
+
// The stalls hit a few percent of requests, so each round sends several probes per app.
|
|
967
|
+
const HEALTH_INTERVAL_MS = 30_000;
|
|
968
|
+
const HEALTH_PROBES = 5;
|
|
969
|
+
const HEALTH_TIMEOUT_MS = 5000;
|
|
970
|
+
const portHealth = createPortHealth();
|
|
971
|
+
let healthRunning = false;
|
|
972
|
+
|
|
973
|
+
async function msUntil(promise) {
|
|
974
|
+
const t = performance.now();
|
|
975
|
+
await promise;
|
|
976
|
+
return performance.now() - t;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function probeApp(id) {
|
|
980
|
+
const port = activePool()?.ports[id];
|
|
981
|
+
for (let i = 0; port && i < HEALTH_PROBES; i++) {
|
|
982
|
+
const child = currentChild(id, port);
|
|
983
|
+
if (!child) return;
|
|
984
|
+
const answered = fetch(`http://127.0.0.1:${port}/hub-config`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
|
|
985
|
+
.then((r) => r.arrayBuffer())
|
|
986
|
+
.catch(() => {});
|
|
987
|
+
const pong = ping(child, PING_TIMEOUT_MS);
|
|
988
|
+
const [httpMs, ipcMs, ponged] = await Promise.all([msUntil(answered), msUntil(pong), pong]);
|
|
989
|
+
portHealth.record(child, { httpMs, ipcMs: ponged ? ipcMs : null });
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function checkPortHealth() {
|
|
994
|
+
if (healthRunning) return;
|
|
995
|
+
healthRunning = true;
|
|
996
|
+
try {
|
|
997
|
+
await Promise.all(ENABLED_APPS.map((a) => probeApp(a.id)));
|
|
998
|
+
} finally {
|
|
999
|
+
healthRunning = false;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
app.get('/api/apps/health', (_req, res) => {
|
|
1004
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1005
|
+
const pool = activePool();
|
|
1006
|
+
const slow = [];
|
|
1007
|
+
for (const a of ENABLED_APPS) {
|
|
1008
|
+
const f = pool?.children.has(a.id) && portHealth.flagged(pool.children.get(a.id));
|
|
1009
|
+
if (f) slow.push({ id: a.id, name: a.name, port: pool.ports[a.id], worstMs: f.worstMs });
|
|
1010
|
+
}
|
|
1011
|
+
res.json({ slow });
|
|
1012
|
+
});
|
|
1013
|
+
|
|
965
1014
|
// Restarting the terminal provider ends its live terminals, so that takes force: true.
|
|
966
1015
|
const restarting = new Set();
|
|
967
1016
|
app.post('/api/apps/:id/restart', async (req, res) => {
|
|
@@ -1050,6 +1099,7 @@ const onReady = (actual) => {
|
|
|
1050
1099
|
writeRecord(HUB_DIR, actual);
|
|
1051
1100
|
ensurePool(hubConfig.activeConfigDir);
|
|
1052
1101
|
updateCheck.refresh();
|
|
1102
|
+
setInterval(checkPortHealth, HEALTH_INTERVAL_MS).unref();
|
|
1053
1103
|
printBanner(actual);
|
|
1054
1104
|
if (process.argv.includes('--open')) {
|
|
1055
1105
|
import('open').then((m) => m.default(accessUrl(actual)));
|