ccdeck 3.24.1 → 3.26.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/README.md +2 -2
- package/dist/web/assets/{BrowserWatchModal-tavADxex.js → BrowserWatchModal-C5ouM-hi.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BpWy0lAC.js → UsageHistoryModal-D5mC-WNb.js} +1 -1
- package/dist/web/assets/index-DfvLgdJz.js +297 -0
- package/dist/web/assets/index-DlaHgzye.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -1
- package/src/server/app-host.mjs +37 -0
- package/src/server/block-notify.mjs +106 -32
- package/src/server/browser-react.mjs +16 -1
- package/src/server/deck-prefs.mjs +16 -1
- package/src/server/index.mjs +72 -8
- package/src/server/installer.mjs +4 -1
- package/src/server/lan-engine.mjs +193 -16
- package/src/server/lan-socket.mjs +250 -67
- package/src/server/lan-sync.mjs +15 -2
- package/src/server/login-service.mjs +4 -0
- package/src/server/network-metrics.mjs +190 -0
- package/src/server/port-holder.mjs +66 -0
- package/src/server/route-via.mjs +109 -0
- package/src/server/self-update.mjs +6 -2
- package/src/server/system-metrics.mjs +262 -4
- package/src/server/tailscale.mjs +269 -0
- package/dist/web/assets/index-2fgjjBhR.css +0 -1
- package/dist/web/assets/index-CeRtWRS-.js +0 -296
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Which program holds a UDP port, in the words the LAN panel prints.
|
|
2
|
+
//
|
|
3
|
+
// A deck that cannot bind the discovery port says so, and "another program"
|
|
4
|
+
// is a sentence nobody can act on. On the Mac where this was measured the
|
|
5
|
+
// program was Tailscale, and finding that out took netstat and an hour. The
|
|
6
|
+
// operating system will usually say, without elevation, and when it will not
|
|
7
|
+
// the sentence stays the one it was.
|
|
8
|
+
//
|
|
9
|
+
// Pure readers per platform, and one runner that picks the command; nothing
|
|
10
|
+
// here is ever a reason for the deck to fail.
|
|
11
|
+
import { run as runCommand } from "./exec.mjs";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* macOS: `netstat -anv -p udp` lists every UDP socket with its owner as
|
|
15
|
+
* `name:pid` in the process column — readable by a plain user for sockets of
|
|
16
|
+
* any user, which is the reason this is the Mac's answer.
|
|
17
|
+
*/
|
|
18
|
+
export function holderFromNetstat(text, port) {
|
|
19
|
+
const at = new RegExp(`[.:]${port}\\s`);
|
|
20
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
21
|
+
if (!/^udp/.test(line) || !at.test(line.split(/\s+/).slice(0, 5).join(" ") + " ")) continue;
|
|
22
|
+
const owner = line.split(/\s+/).find(f => /^[A-Za-z][\w.-]*:\d+$/.test(f));
|
|
23
|
+
if (owner) return owner.replace(/:\d+$/, "");
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Linux: `ss -H -uanp 'sport = :PORT'` — `users:(("name",pid=…))`, shown
|
|
29
|
+
* only for this user's own processes; somebody else's is an unnamed row. */
|
|
30
|
+
export function holderFromSs(text) {
|
|
31
|
+
const m = /users:\(\("([^"]+)"/.exec(String(text ?? ""));
|
|
32
|
+
return m ? m[1] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Windows: the owning process's name, one line, from the PowerShell below. */
|
|
36
|
+
export function holderFromPowershell(text) {
|
|
37
|
+
const name = String(text ?? "").split(/\r?\n/).map(l => l.trim()).find(Boolean);
|
|
38
|
+
return name && /^[\w .()-]{1,64}$/.test(name) ? name : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The name a person knows the program by. Tailscale is the one measured,
|
|
42
|
+
* and it goes by a different process name on each platform. */
|
|
43
|
+
export function friendlyHolder(name) {
|
|
44
|
+
if (!name) return null;
|
|
45
|
+
if (/tailscale/i.test(name)) return "Tailscale";
|
|
46
|
+
return name;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Who holds UDP `port` here, or null when the machine will not say. */
|
|
50
|
+
export async function portHolder(port, { platform = process.platform, run = runCommand } = {}) {
|
|
51
|
+
try {
|
|
52
|
+
if (platform === "darwin") {
|
|
53
|
+
const r = await run("netstat", ["-anv", "-p", "udp"], { timeout: 5_000 });
|
|
54
|
+
return friendlyHolder(r?.ok ? holderFromNetstat(r.stdout, port) : null);
|
|
55
|
+
}
|
|
56
|
+
if (platform === "win32") {
|
|
57
|
+
const ps = `Get-NetUDPEndpoint -LocalPort ${Number(port)} -ErrorAction SilentlyContinue | Select-Object -First 1 | ForEach-Object { (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }`;
|
|
58
|
+
const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 15_000 });
|
|
59
|
+
return friendlyHolder(r?.ok ? holderFromPowershell(r.stdout) : null);
|
|
60
|
+
}
|
|
61
|
+
const r = await run("ss", ["-H", "-uanp", `sport = :${Number(port)}`], { timeout: 5_000 });
|
|
62
|
+
return friendlyHolder(r?.ok ? holderFromSs(r.stdout) : null);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Which interface the machine would send a packet to an address by.
|
|
2
|
+
//
|
|
3
|
+
// A BEACON FOR THE LOCAL NETWORK MUST STAY ON IT. A laptop using a Tailscale
|
|
4
|
+
// exit node, or any VPN that takes the local network too, routes even a
|
|
5
|
+
// broadcast for its own Wi-Fi into the tunnel — and on the far end it comes out
|
|
6
|
+
// on somebody else's network: an office Mac that is the exit node for a home
|
|
7
|
+
// laptop put the laptop's beacons on the office LAN, where a colleague whose
|
|
8
|
+
// network uses the same addresses could see the name of a machine that is
|
|
9
|
+
// nothing to do with them. So before a beacon goes, the machine is asked where
|
|
10
|
+
// it would go, and a beacon that would leave through a tunnel does not leave.
|
|
11
|
+
//
|
|
12
|
+
// Asked of the operating system rather than reasoned from interface names,
|
|
13
|
+
// because only the routing table knows what a VPN did to it: `route get` on
|
|
14
|
+
// macOS, `ip route get` on Linux, Find-NetRoute on Windows. The answers are
|
|
15
|
+
// cached, since routes change when networks do and not every thirty seconds,
|
|
16
|
+
// and a lookup that fails or cannot be made answers nothing — the beacon then
|
|
17
|
+
// goes as it always did.
|
|
18
|
+
import { run as runCommand } from "./exec.mjs";
|
|
19
|
+
|
|
20
|
+
/** How long one answer is kept. Long enough that a Windows machine starts one
|
|
21
|
+
* PowerShell every couple of minutes rather than every beacon; short enough
|
|
22
|
+
* that turning an exit node on stops the beacons into it soon after. */
|
|
23
|
+
export const ROUTE_TTL_MS = 120_000;
|
|
24
|
+
|
|
25
|
+
/** `route -n get <ip>` on macOS: the `interface:` line. */
|
|
26
|
+
export function viaFromRouteGet(text) {
|
|
27
|
+
const m = /^\s*interface:\s*(\S+)/m.exec(String(text ?? ""));
|
|
28
|
+
return m ? m[1] : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `ip -o route get <ip>` on Linux: the device after `dev`. */
|
|
32
|
+
export function viaFromIpRoute(text) {
|
|
33
|
+
const m = /\bdev\s+(\S+)/.exec(String(text ?? ""));
|
|
34
|
+
return m ? m[1] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The PowerShell below prints `address|alias` per line on Windows. */
|
|
38
|
+
export function viaFromFindNetRoute(text) {
|
|
39
|
+
const out = new Map();
|
|
40
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
41
|
+
const at = line.indexOf("|");
|
|
42
|
+
if (at <= 0) continue;
|
|
43
|
+
const alias = line.slice(at + 1).trim();
|
|
44
|
+
if (alias) out.set(line.slice(0, at).trim(), alias);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether an interface is a tunnel rather than a network a person is on.
|
|
51
|
+
*
|
|
52
|
+
* Used only for the limited broadcast, which belongs to no one interface: a
|
|
53
|
+
* directed broadcast is judged by whether it leaves by the interface whose
|
|
54
|
+
* subnet it is, which needs no names at all. These are the names tunnels go
|
|
55
|
+
* by on the three platforms — macOS numbers every VPN `utun`, and a Windows
|
|
56
|
+
* adapter is called whatever its VPN named it.
|
|
57
|
+
*/
|
|
58
|
+
export function looksLikeTunnel(name) {
|
|
59
|
+
if (typeof name !== "string" || !name) return false;
|
|
60
|
+
return /^(utun|tun|tap|wg|ppp|ipsec|gpd|zt)\d*$/i.test(name)
|
|
61
|
+
|| /tailscale|wireguard|nordlynx|openvpn|vpn/i.test(name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const QUAD = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
65
|
+
|
|
66
|
+
/** Ask the machine, for every address in `targets`, which interface it would
|
|
67
|
+
* use. A Map of address to interface name; missing where it would not say. */
|
|
68
|
+
export async function lookupRoutes(targets, { platform = process.platform, run = runCommand } = {}) {
|
|
69
|
+
const list = targets.filter(t => typeof t === "string" && QUAD.test(t));
|
|
70
|
+
const out = new Map();
|
|
71
|
+
if (!list.length) return out;
|
|
72
|
+
if (platform === "win32") {
|
|
73
|
+
const quoted = list.map(t => `'${t}'`).join(",");
|
|
74
|
+
const ps = `foreach($t in @(${quoted})){ $r = Find-NetRoute -RemoteIPAddress $t -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceAlias } | Select-Object -First 1; $t + '|' + $r.InterfaceAlias }`;
|
|
75
|
+
const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 15_000 });
|
|
76
|
+
return r?.ok ? viaFromFindNetRoute(r.stdout) : out;
|
|
77
|
+
}
|
|
78
|
+
for (const t of list) {
|
|
79
|
+
const r = platform === "darwin"
|
|
80
|
+
? await run("route", ["-n", "get", t], { timeout: 3_000 })
|
|
81
|
+
: await run("ip", ["-o", "route", "get", t], { timeout: 3_000 });
|
|
82
|
+
const via = r?.ok ? (platform === "darwin" ? viaFromRouteGet(r.stdout) : viaFromIpRoute(r.stdout)) : null;
|
|
83
|
+
if (via) out.set(t, via);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The cache the beacon reads synchronously: `via(address)` is the last answer
|
|
90
|
+
* or undefined, and `want(addresses)` starts one lookup for whichever of them
|
|
91
|
+
* are missing or stale. Never more than one lookup at a time.
|
|
92
|
+
*/
|
|
93
|
+
export function createRouteCheck({ platform = process.platform, run = runCommand, now = Date.now, ttlMs = ROUTE_TTL_MS } = {}) {
|
|
94
|
+
const cache = new Map();
|
|
95
|
+
let busy = null;
|
|
96
|
+
return {
|
|
97
|
+
via: to => cache.get(to)?.via,
|
|
98
|
+
want(targets) {
|
|
99
|
+
if (busy) return busy;
|
|
100
|
+
const stale = (targets ?? []).filter(t => { const c = cache.get(t); return !c || now() - c.at > ttlMs; });
|
|
101
|
+
if (!stale.length) return Promise.resolve();
|
|
102
|
+
busy = lookupRoutes(stale, { platform, run })
|
|
103
|
+
.then(found => { for (const t of stale) cache.set(t, { via: found.get(t) ?? null, at: now() }); })
|
|
104
|
+
.catch(() => { /* nothing learned; the beacon goes as it always did */ })
|
|
105
|
+
.finally(() => { busy = null; });
|
|
106
|
+
return busy;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// it by name and only where it can actually work: a global install, on a
|
|
23
23
|
// directory we can write, outside a git checkout and outside an npx cache.
|
|
24
24
|
// Everywhere else this stays what it has always been — a printed command.
|
|
25
|
+
import { inApp } from "./app-host.mjs";
|
|
25
26
|
import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
26
27
|
import { spawn } from "node:child_process";
|
|
27
28
|
import { homedir } from "node:os";
|
|
@@ -1085,7 +1086,9 @@ export function upgradeBlock(pkgRoot) {
|
|
|
1085
1086
|
// npm -g rewrites the package directory and its parent (the global
|
|
1086
1087
|
// node_modules), so both have to be ours to write.
|
|
1087
1088
|
writable: dirWritable(target) && dirWritable(resolve(target, "..")),
|
|
1088
|
-
|
|
1089
|
+
// Inside the desktop app the app updates itself; an `npm i -g` here would
|
|
1090
|
+
// install a copy it never runs (app-host.mjs).
|
|
1091
|
+
optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1" || inApp(),
|
|
1089
1092
|
// Whether the package that write would fetch is still a deck. Kept out of
|
|
1090
1093
|
// the pure rule above, like every other input here, so the policy stays one
|
|
1091
1094
|
// readable expression and this file owns the filesystem half of it.
|
|
@@ -1461,7 +1464,8 @@ export async function versionReport({ running, pkgRoot, name = PUBLISHED_NAME, n
|
|
|
1461
1464
|
// isOlder decides that, not this.
|
|
1462
1465
|
const skipRegistry =
|
|
1463
1466
|
process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
|
|
1464
|
-
process.env.AGENTS_DECK_NO_INSTALL === "1"
|
|
1467
|
+
process.env.AGENTS_DECK_NO_INSTALL === "1" ||
|
|
1468
|
+
inApp();
|
|
1465
1469
|
const latest = skipRegistry ? null : await latestOnNpm(asked, now, force);
|
|
1466
1470
|
const marker = skipRegistry ? null : readMarker(asked);
|
|
1467
1471
|
const blocked = upgradeBlock(pkgRoot);
|
|
@@ -18,8 +18,15 @@
|
|
|
18
18
|
// readout the loudest producer in the application. So this is a plain poll
|
|
19
19
|
// endpoint, exactly like /api/quota and /api/codex-usage already are.
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
|
-
import {
|
|
21
|
+
import { lookup } from "node:dns/promises";
|
|
22
|
+
import { access, readdir, readFile } from "node:fs/promises";
|
|
23
|
+
import net from "node:net";
|
|
22
24
|
import os from "node:os";
|
|
25
|
+
import { hasClaudeInstalled } from "./claude-dir.mjs";
|
|
26
|
+
import {
|
|
27
|
+
classifyRoute, parseNetstatE, parseNetstatIbn, parseProcNetDev, rateBetween,
|
|
28
|
+
routeIfaceDarwin, routeIfaceLinux, routeLabel, tailscaleExitFromStatus,
|
|
29
|
+
} from "./network-metrics.mjs";
|
|
23
30
|
|
|
24
31
|
/** CPU is the metric with spikes, so it is sampled often enough to catch one. */
|
|
25
32
|
const CPU_INTERVAL_MS = 3_000;
|
|
@@ -1690,9 +1697,44 @@ function seriesFor(group) {
|
|
|
1690
1697
|
}];
|
|
1691
1698
|
}
|
|
1692
1699
|
|
|
1700
|
+
if (group === "network") {
|
|
1701
|
+
// Download and upload are two charts, not two lines on one: upload is
|
|
1702
|
+
// usually a hundredth of download, and on a shared scale it is a line along
|
|
1703
|
+
// the floor. Each gets a top fitted to its own peak. No bands: there is no
|
|
1704
|
+
// throughput that is too much, only more than usual, and a threshold here
|
|
1705
|
+
// would be a number this module made up.
|
|
1706
|
+
const down = at("net:down");
|
|
1707
|
+
const up = at("net:up");
|
|
1708
|
+
const api = at("net:api");
|
|
1709
|
+
const series = [
|
|
1710
|
+
// Floors of 100 KB/s and 100 ms, so a quiet line is drawn as quiet.
|
|
1711
|
+
{ key: "net:down", label: "Download", unit: "B/s", top: niceTop(down, 100_000), warnAt: null, critAt: null, restsAtZero: false, points: down },
|
|
1712
|
+
{ key: "net:up", label: "Upload", unit: "B/s", top: niceTop(up, 100_000), warnAt: null, critAt: null, restsAtZero: false, points: up },
|
|
1713
|
+
{ key: "net:api", label: "Claude API latency", unit: "ms", top: niceTop(api, 100), warnAt: null, critAt: null, restsAtZero: false, points: api },
|
|
1714
|
+
].filter(s => s.points.length);
|
|
1715
|
+
// The path the traffic took, as marks on the last chart — the question the
|
|
1716
|
+
// section exists for is "was it the VPN", and a line that jumps where a
|
|
1717
|
+
// mark sits answers it without a legend.
|
|
1718
|
+
const since = history.length ? history[0].m * BUCKET_MS : 0;
|
|
1719
|
+
const changes = routeLog.filter(c => c.t >= since).map(c => ({ t: c.t, label: c.label, from: c.from }));
|
|
1720
|
+
if (series.length && changes.length) series[series.length - 1].changes = changes;
|
|
1721
|
+
return series;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1693
1724
|
return [];
|
|
1694
1725
|
}
|
|
1695
1726
|
|
|
1727
|
+
/** A scale top a person would choose — 1, 2 or 5 times a power of ten — just
|
|
1728
|
+
* above the peak, and never below `floor`, so an idle line is not drawn as a
|
|
1729
|
+
* dramatic climb. */
|
|
1730
|
+
function niceTop(points, floor) {
|
|
1731
|
+
const peak = points.reduce((a, p) => Math.max(a, p.v), 0);
|
|
1732
|
+
const want = Math.max(floor, peak * 1.15);
|
|
1733
|
+
const pow = 10 ** Math.floor(Math.log10(want));
|
|
1734
|
+
for (const m of [1, 2, 5, 10]) if (m * pow >= want) return m * pow;
|
|
1735
|
+
return want;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1696
1738
|
/**
|
|
1697
1739
|
* Whether this machine has been held back AT ALL since the deck started, and
|
|
1698
1740
|
* when it last was.
|
|
@@ -1854,23 +1896,219 @@ function sampleCpu() {
|
|
|
1854
1896
|
if (process.platform !== "win32") record("load:1m", Math.round(load[0] * 100) / 100);
|
|
1855
1897
|
}
|
|
1856
1898
|
|
|
1899
|
+
// ── the network ────────────────────────────────────────────────────────────
|
|
1900
|
+
//
|
|
1901
|
+
// Three readings, taken at two very different costs (network-metrics.mjs says
|
|
1902
|
+
// why these three). THROUGHPUT is two counters the kernel already keeps, so it
|
|
1903
|
+
// rides its own five-second timer for the life of the process like CPU does,
|
|
1904
|
+
// and its history is as complete as every other section's. LATENCY and ROUTE
|
|
1905
|
+
// leave the machine or spawn a process, so they are taken only while somebody
|
|
1906
|
+
// is looking: the panel is the only thing that polls /api/system, and a probe
|
|
1907
|
+
// runs only within NET_ASKED_MS of that poll. A deck left open in a background
|
|
1908
|
+
// tab all day makes no connection it was not asked for — the rule Claude FM's
|
|
1909
|
+
// probe already follows ("makes no request until a page asks for one").
|
|
1910
|
+
|
|
1911
|
+
/** Counters on Linux are a file read; on macOS and Windows one short command.
|
|
1912
|
+
* Five seconds is often enough to catch a burst and rare enough that the
|
|
1913
|
+
* command costs nothing anybody could measure. */
|
|
1914
|
+
const NET_INTERVAL_MS = 5_000;
|
|
1915
|
+
/** How often the far end is asked, while the panel is open. */
|
|
1916
|
+
const NET_PROBE_MS = 30_000;
|
|
1917
|
+
/** How recently a poll must have come for the probe to run at all. */
|
|
1918
|
+
const NET_ASKED_MS = 20_000;
|
|
1919
|
+
/** A probe older than this is from before the panel was last closed, and is
|
|
1920
|
+
* not reported as the present. */
|
|
1921
|
+
const NET_STALE_MS = NET_PROBE_MS * 2 + 5_000;
|
|
1922
|
+
/** What "how far is Claude" is measured against: the host every request Claude
|
|
1923
|
+
* Code makes goes to, and the one quota.mjs already reads from. */
|
|
1924
|
+
const API_HOST = "api.anthropic.com";
|
|
1925
|
+
const API_PORT = 443;
|
|
1926
|
+
const CONNECT_TIMEOUT_MS = 4_000;
|
|
1927
|
+
/** Where the route is asked for when there is no API address to ask about. A
|
|
1928
|
+
* routing-table lookup sends no packet, so this contacts nobody. */
|
|
1929
|
+
const ROUTE_FALLBACK = "1.1.1.1";
|
|
1930
|
+
/** Route changes kept for the history's markers. A path changes a handful of
|
|
1931
|
+
* times a day; forty is several days of it. */
|
|
1932
|
+
const ROUTE_LOG = 40;
|
|
1933
|
+
|
|
1934
|
+
let netTimer = null;
|
|
1935
|
+
let netInFlight = false;
|
|
1936
|
+
let prevNet = null;
|
|
1937
|
+
let netRate = null;
|
|
1938
|
+
let physical = null;
|
|
1939
|
+
let physicalAt = 0;
|
|
1940
|
+
let probeTimer = null;
|
|
1941
|
+
/** Whether this process may reach out at all. Off unless the server says so:
|
|
1942
|
+
* the suite starts this module in dozens of cases, and none of them should
|
|
1943
|
+
* open a connection to Anthropic or spawn `ip` and `tailscale` to do it. */
|
|
1944
|
+
let probeEnabled = false;
|
|
1945
|
+
let probeInFlight = false;
|
|
1946
|
+
let probeAt = 0;
|
|
1947
|
+
let askedAt = 0;
|
|
1948
|
+
/** Undefined until measured, null when the host could not be reached. */
|
|
1949
|
+
let apiMs;
|
|
1950
|
+
let route = null;
|
|
1951
|
+
const routeLog = [];
|
|
1952
|
+
|
|
1953
|
+
/** Interfaces that are hardware, by the one test the kernel offers: a
|
|
1954
|
+
* `device` link under /sys/class/net. Rechecked each minute, because a dock or
|
|
1955
|
+
* a phone tethered over USB is a new wire. */
|
|
1956
|
+
async function physicalInterfaces(root = "/sys/class/net") {
|
|
1957
|
+
try {
|
|
1958
|
+
const names = await readdir(root);
|
|
1959
|
+
const real = await Promise.all(names.map(n => access(`${root}/${n}/device`).then(() => n, () => null)));
|
|
1960
|
+
return new Set(real.filter(Boolean));
|
|
1961
|
+
} catch { return null; }
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
async function readNetCounters(platform = process.platform) {
|
|
1965
|
+
if (platform === "linux") {
|
|
1966
|
+
if (!physical || Date.now() - physicalAt > 60_000) {
|
|
1967
|
+
physical = await physicalInterfaces();
|
|
1968
|
+
physicalAt = Date.now();
|
|
1969
|
+
}
|
|
1970
|
+
try {
|
|
1971
|
+
const include = physical?.size ? name => physical.has(name) : name => name !== "lo";
|
|
1972
|
+
return parseProcNetDev(await readFile("/proc/net/dev", "utf8"), include);
|
|
1973
|
+
} catch { return null; }
|
|
1974
|
+
}
|
|
1975
|
+
if (platform === "darwin") return parseNetstatIbn(await run("netstat", ["-ibn"]));
|
|
1976
|
+
if (platform === "win32") return parseNetstatE(await run("netstat", ["-e"]));
|
|
1977
|
+
return null;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
async function sampleNetwork() {
|
|
1981
|
+
if (netInFlight) return;
|
|
1982
|
+
netInFlight = true;
|
|
1983
|
+
try {
|
|
1984
|
+
const counters = await readNetCounters();
|
|
1985
|
+
if (!counters) return;
|
|
1986
|
+
const next = { rx: counters.rx, tx: counters.tx, at: Date.now() };
|
|
1987
|
+
const rate = rateBetween(prevNet, next);
|
|
1988
|
+
prevNet = next;
|
|
1989
|
+
if (!rate) return;
|
|
1990
|
+
netRate = rate;
|
|
1991
|
+
record("net:down", rate.down);
|
|
1992
|
+
record("net:up", rate.up);
|
|
1993
|
+
} finally { netInFlight = false; }
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/** Milliseconds for a TCP handshake with `address`, or null. The connection
|
|
1997
|
+
* is closed the moment it opens: nothing is sent, so this is a round trip and
|
|
1998
|
+
* nothing else — no TLS, no request, nothing the far end has to answer. */
|
|
1999
|
+
function connectMs(address, port = API_PORT, timeoutMs = CONNECT_TIMEOUT_MS) {
|
|
2000
|
+
return new Promise(resolve => {
|
|
2001
|
+
const t0 = performance.now();
|
|
2002
|
+
let done = false;
|
|
2003
|
+
const socket = net.connect({ host: address, port });
|
|
2004
|
+
const finish = ms => {
|
|
2005
|
+
if (done) return;
|
|
2006
|
+
done = true;
|
|
2007
|
+
socket.destroy();
|
|
2008
|
+
resolve(ms);
|
|
2009
|
+
};
|
|
2010
|
+
socket.setTimeout(timeoutMs, () => finish(null));
|
|
2011
|
+
socket.once("connect", () => finish(Math.round(performance.now() - t0)));
|
|
2012
|
+
socket.once("error", () => finish(null));
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/** Which way traffic to `address` leaves, and — for a Tailscale exit node —
|
|
2017
|
+
* through which machine and whether through a relay. */
|
|
2018
|
+
async function readRoute(address, platform, runner) {
|
|
2019
|
+
let iface = null;
|
|
2020
|
+
if (platform === "linux") iface = routeIfaceLinux(await runner("ip", ["-o", "route", "get", address]));
|
|
2021
|
+
else if (platform === "darwin") iface = routeIfaceDarwin(await runner("route", ["-n", "get", address]));
|
|
2022
|
+
const kind = classifyRoute(iface);
|
|
2023
|
+
if (!kind || kind.kind === "direct") return kind;
|
|
2024
|
+
// Tailscale on a Mac is a utun like every other VPN there, so the question is
|
|
2025
|
+
// asked of any tunnel rather than only of one named tailscale0. A machine
|
|
2026
|
+
// without the CLI answers null and keeps the generic label.
|
|
2027
|
+
const exit = tailscaleExitFromStatus(await runner("tailscale", ["status", "--json"], 3_000));
|
|
2028
|
+
if (exit) return { kind: "tailscale-exit", iface, ...exit };
|
|
2029
|
+
return kind;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* One probe: how far the API is and which way the traffic to it goes.
|
|
2034
|
+
*
|
|
2035
|
+
* Guarded twice, because a GET can start it (see systemSnapshot) and a read's
|
|
2036
|
+
* cost must have a ceiling (#544): never two at once, and never more often than
|
|
2037
|
+
* half the probe interval however many tabs poll. `deps` is the seam the tests
|
|
2038
|
+
* use — nothing else here can be made to answer "unreachable" on demand.
|
|
2039
|
+
*/
|
|
2040
|
+
export async function probeNetwork(deps = {}) {
|
|
2041
|
+
const now = deps.now ?? Date.now;
|
|
2042
|
+
if (!probeEnabled && !deps.force) return;
|
|
2043
|
+
if (probeInFlight || now() - probeAt < NET_PROBE_MS / 2) return;
|
|
2044
|
+
if (!deps.force && now() - askedAt > NET_ASKED_MS) return;
|
|
2045
|
+
probeInFlight = true;
|
|
2046
|
+
probeAt = now();
|
|
2047
|
+
try {
|
|
2048
|
+
const platform = deps.platform ?? process.platform;
|
|
2049
|
+
const runner = deps.run ?? run;
|
|
2050
|
+
const measureApi = (deps.hasClaude ?? hasClaudeInstalled)();
|
|
2051
|
+
let address = null;
|
|
2052
|
+
if (measureApi) {
|
|
2053
|
+
try { address = (await (deps.lookup ?? lookup)(API_HOST)).address; } catch { address = null; }
|
|
2054
|
+
apiMs = address ? await (deps.connect ?? connectMs)(address) : null;
|
|
2055
|
+
if (apiMs != null) record("net:api", apiMs);
|
|
2056
|
+
}
|
|
2057
|
+
const next = await readRoute(address ?? ROUTE_FALLBACK, platform, runner);
|
|
2058
|
+
if (next) {
|
|
2059
|
+
const label = routeLabel(next);
|
|
2060
|
+
const prev = routeLog[routeLog.length - 1]?.label ?? null;
|
|
2061
|
+
if (prev !== label) {
|
|
2062
|
+
// `from` null is the first route this process saw — the state when it
|
|
2063
|
+
// started looking, not a change, and the chart draws no mark for it.
|
|
2064
|
+
routeLog.push({ t: now(), label, from: prev });
|
|
2065
|
+
while (routeLog.length > ROUTE_LOG) routeLog.shift();
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
route = next;
|
|
2069
|
+
} finally { probeInFlight = false; }
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** What the panel shows for the network, or null before anything is known. */
|
|
2073
|
+
function networkSnapshot(nowMs = Date.now()) {
|
|
2074
|
+
const fresh = nowMs - probeAt <= NET_STALE_MS;
|
|
2075
|
+
const api = fresh && apiMs !== undefined ? { host: API_HOST, ms: apiMs } : null;
|
|
2076
|
+
// `to` says what the route was asked about: the API itself when Claude Code
|
|
2077
|
+
// is here, the open internet otherwise — a split tunnel can send one and not
|
|
2078
|
+
// the other, and the panel should not claim more than was measured.
|
|
2079
|
+
const via = fresh && route && route.kind !== "direct"
|
|
2080
|
+
? { ...route, label: routeLabel(route), to: apiMs !== undefined ? "claude" : "internet" }
|
|
2081
|
+
: null;
|
|
2082
|
+
if (!netRate && !api && !via) return null;
|
|
2083
|
+
return { down: netRate?.down ?? null, up: netRate?.up ?? null, api, route: via };
|
|
2084
|
+
}
|
|
2085
|
+
|
|
1857
2086
|
/**
|
|
1858
|
-
* Begin sampling. Idempotent, and
|
|
2087
|
+
* Begin sampling. Idempotent, and every timer is unref'd so this can never be
|
|
1859
2088
|
* the reason the process stays alive.
|
|
2089
|
+
*
|
|
2090
|
+
* `probe` is the network section's permission to leave the machine — latency to
|
|
2091
|
+
* the API and the route there. The server passes it; nothing else should.
|
|
1860
2092
|
*/
|
|
1861
|
-
export function startSystemMetrics() {
|
|
2093
|
+
export function startSystemMetrics({ probe = false } = {}) {
|
|
1862
2094
|
if (cpuTimer) return;
|
|
2095
|
+
probeEnabled = probe;
|
|
1863
2096
|
prevTicks = readTicks(); // baseline, so the first tick has a delta
|
|
1864
2097
|
prevCoreTicks = readCoreTicks();
|
|
1865
2098
|
sampleMemory();
|
|
1866
2099
|
historySince = Date.now();
|
|
1867
2100
|
sampleThermal();
|
|
2101
|
+
sampleNetwork();
|
|
1868
2102
|
cpuTimer = setInterval(sampleCpu, CPU_INTERVAL_MS);
|
|
1869
2103
|
memTimer = setInterval(sampleMemory, MEM_INTERVAL_MS);
|
|
1870
2104
|
thermalTimer = setInterval(sampleThermal, THERMAL_INTERVAL_MS);
|
|
2105
|
+
netTimer = setInterval(sampleNetwork, NET_INTERVAL_MS);
|
|
2106
|
+
if (probeEnabled) probeTimer = setInterval(() => { probeNetwork(); }, NET_PROBE_MS);
|
|
1871
2107
|
cpuTimer.unref?.();
|
|
1872
2108
|
memTimer.unref?.();
|
|
1873
2109
|
thermalTimer.unref?.();
|
|
2110
|
+
netTimer.unref?.();
|
|
2111
|
+
probeTimer?.unref?.();
|
|
1874
2112
|
}
|
|
1875
2113
|
|
|
1876
2114
|
/**
|
|
@@ -1894,7 +2132,19 @@ export function stopSystemMetrics() {
|
|
|
1894
2132
|
if (cpuTimer) clearInterval(cpuTimer);
|
|
1895
2133
|
if (memTimer) clearInterval(memTimer);
|
|
1896
2134
|
if (thermalTimer) clearInterval(thermalTimer);
|
|
1897
|
-
|
|
2135
|
+
if (netTimer) clearInterval(netTimer);
|
|
2136
|
+
if (probeTimer) clearInterval(probeTimer);
|
|
2137
|
+
cpuTimer = memTimer = thermalTimer = netTimer = probeTimer = null;
|
|
2138
|
+
probeEnabled = false;
|
|
2139
|
+
prevNet = null;
|
|
2140
|
+
netRate = null;
|
|
2141
|
+
physical = null;
|
|
2142
|
+
physicalAt = 0;
|
|
2143
|
+
probeAt = 0;
|
|
2144
|
+
askedAt = 0;
|
|
2145
|
+
apiMs = undefined;
|
|
2146
|
+
route = null;
|
|
2147
|
+
routeLog.length = 0;
|
|
1898
2148
|
thermal = null;
|
|
1899
2149
|
thermalMisses = 0;
|
|
1900
2150
|
thermalEverAnswered = false;
|
|
@@ -1924,6 +2174,13 @@ export function stopSystemMetrics() {
|
|
|
1924
2174
|
*/
|
|
1925
2175
|
export function systemSnapshot() {
|
|
1926
2176
|
const cpu = cpuHistory.length ? cpuHistory[cpuHistory.length - 1] : null;
|
|
2177
|
+
// The panel is the only poller, so a poll is the signal somebody is looking.
|
|
2178
|
+
// The first one after the panel was closed asks at once rather than waiting
|
|
2179
|
+
// out the probe timer, so the latency is on screen by the second poll; the
|
|
2180
|
+
// guards inside probeNetwork are what keep a busy poller from paying twice.
|
|
2181
|
+
const idle = Date.now() - askedAt > NET_ASKED_MS;
|
|
2182
|
+
askedAt = Date.now();
|
|
2183
|
+
if (idle && probeEnabled) void probeNetwork();
|
|
1927
2184
|
const load = os.loadavg();
|
|
1928
2185
|
// Platform alone, for the reason spelled out beside the `load:1m` record in
|
|
1929
2186
|
// sampleCpu: Node's own contract makes the platform test sufficient, and the
|
|
@@ -1945,6 +2202,7 @@ export function systemSnapshot() {
|
|
|
1945
2202
|
uptimeSec: Math.round(os.uptime()),
|
|
1946
2203
|
platform: process.platform,
|
|
1947
2204
|
loadavg: hasLoad ? load.map(n => Math.round(n * 100) / 100) : null,
|
|
2205
|
+
network: networkSnapshot(),
|
|
1948
2206
|
intervalMs: CPU_INTERVAL_MS,
|
|
1949
2207
|
sampledAt: Date.now(),
|
|
1950
2208
|
};
|