ccdeck 3.24.1 → 3.25.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-Chcet-7r.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BpWy0lAC.js → UsageHistoryModal-BIdS0lCL.js} +1 -1
- package/dist/web/assets/index-7a7ISatq.css +1 -0
- package/dist/web/assets/index-BoR2dMqR.js +297 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/src/server/index.mjs +5 -3
- package/src/server/network-metrics.mjs +190 -0
- package/src/server/system-metrics.mjs +262 -4
- package/dist/web/assets/index-2fgjjBhR.css +0 -1
- package/dist/web/assets/index-CeRtWRS-.js +0 -296
package/dist/web/index.html
CHANGED
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
45
45
|
})();
|
|
46
46
|
</script>
|
|
47
|
-
<script type="module" crossorigin src="/assets/index-
|
|
48
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
47
|
+
<script type="module" crossorigin src="/assets/index-BoR2dMqR.js"></script>
|
|
48
|
+
<link rel="stylesheet" crossorigin href="/assets/index-7a7ISatq.css">
|
|
49
49
|
</head>
|
|
50
50
|
<body>
|
|
51
51
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.0",
|
|
4
4
|
"description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -45,6 +45,24 @@
|
|
|
45
45
|
"3.23.3",
|
|
46
46
|
"3.23.4"
|
|
47
47
|
],
|
|
48
|
+
"3.25.0": [
|
|
49
|
+
{
|
|
50
|
+
"title": "🖱️ A click goes to the session, a double-click opens the details",
|
|
51
|
+
"body": "Clicking a card frames it with the rest of its session and leaves the detail panel shut. Double-click a card to open its details; `j`/`k`, the session list, Enter and `D` still open them too."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"title": "🔭 Cards stay readable when you zoom out",
|
|
55
|
+
"body": "At every zoom a card shows its name, its state and anything waiting on you, instead of shrinking to a coloured pill. Hover or focus a card to see it whole. Recap notes read at a distance too, and show the full recap on hover."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"title": "🌐 The Machine panel shows your network",
|
|
59
|
+
"body": "A new Network section shows download, upload and the round trip to Claude's API, and names the route when traffic goes through a VPN or a Tailscale exit node. Those checks run only while the panel is open. The panel is regrouped into CPU, Memory, Network and Thermal, with a flag at the top when something needs attention."
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"title": "🎨 See each theme before you pick it",
|
|
63
|
+
"body": "The Appearance menu shows a small preview of the deck in Light and in Dark. Claude FM also keeps its volume when a notification chime plays, instead of dipping under it."
|
|
64
|
+
}
|
|
65
|
+
],
|
|
48
66
|
"3.24.1": [
|
|
49
67
|
{
|
|
50
68
|
"title": "🎛️ Appearance is one small menu now",
|
package/src/server/index.mjs
CHANGED
|
@@ -7393,7 +7393,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7393
7393
|
// An allowlist, not a pass-through: the parameter names a section of this
|
|
7394
7394
|
// panel and nothing else, so an unknown one is a 400 rather than an empty
|
|
7395
7395
|
// chart that looks like a machine with nothing to report.
|
|
7396
|
-
if (!["thermal", "cores", "memory", "load"].includes(group)) {
|
|
7396
|
+
if (!["thermal", "cores", "memory", "load", "network"].includes(group)) {
|
|
7397
7397
|
return send(res, 400, { ok: false, error: "unknown_group" });
|
|
7398
7398
|
}
|
|
7399
7399
|
return send(res, 200, historySnapshot(group));
|
|
@@ -7509,8 +7509,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7509
7509
|
// reads `transcript_path`, which Codex hooks never send — and unref'd
|
|
7510
7510
|
// like its neighbours.
|
|
7511
7511
|
startOutputWatch();
|
|
7512
|
-
//
|
|
7513
|
-
|
|
7512
|
+
// Every timer is unref'd, so this never holds the process open. `probe`
|
|
7513
|
+
// lets the network section time the API and read the route while the
|
|
7514
|
+
// Machine panel is open — the only caller allowed to reach out.
|
|
7515
|
+
startSystemMetrics({ probe: true });
|
|
7514
7516
|
// LAN sync, from the prefs the import read — and only from here, so a
|
|
7515
7517
|
// deck that had it on comes back with it on, and a launcher that only
|
|
7516
7518
|
// asked the registry never binds a port it is about to walk away from.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The network section's readings, as pure functions of what the platform prints.
|
|
2
|
+
//
|
|
3
|
+
// Everything here is text in, numbers out, so a Linux answer can be checked from
|
|
4
|
+
// a Mac and a Windows one from either — the same reason every parser in
|
|
5
|
+
// system-metrics.mjs is exported and pure. The sampler that runs the commands,
|
|
6
|
+
// times the connection and keeps the history lives there; this file only reads.
|
|
7
|
+
//
|
|
8
|
+
// THREE READINGS, AND WHY THESE THREE (#network). The first build of this idea
|
|
9
|
+
// was a speed test, and a speed test is the wrong instrument twice over: it
|
|
10
|
+
// fills the line for as long as it runs — which is exactly the stall a person
|
|
11
|
+
// opens this section to explain — and the maximum a line can carry is not what
|
|
12
|
+
// was wrong the day this was designed. That day the Wi-Fi measured 270 Mbit/s
|
|
13
|
+
// and Claude FM still went silent for twenty seconds at a time, because every
|
|
14
|
+
// packet was leaving through a Tailscale exit node on another continent's relay.
|
|
15
|
+
// So the section reports what is MOVING (throughput), how FAR Claude is
|
|
16
|
+
// (latency), and which WAY the traffic goes (the route) — the three things a
|
|
17
|
+
// person needs to tell "the network is slow" from "the network is fine and
|
|
18
|
+
// something else is".
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Bytes received and sent across the interfaces that are wires, out of
|
|
22
|
+
* `/proc/net/dev`.
|
|
23
|
+
*
|
|
24
|
+
* `include` decides which interfaces count, and the sampler passes the set of
|
|
25
|
+
* physical ones (those with a `device` under /sys/class/net). Counting every
|
|
26
|
+
* interface would count a VPN's traffic twice — once inside the tunnel and once
|
|
27
|
+
* again, encrypted, on the Wi-Fi that carries it — and loopback's traffic,
|
|
28
|
+
* which never leaves the machine, would read as the deck downloading.
|
|
29
|
+
*
|
|
30
|
+
* Returns null when nothing matched, which is not the same as zero: a machine
|
|
31
|
+
* whose counters read zero moved nothing, and one this could not read did not
|
|
32
|
+
* say.
|
|
33
|
+
*/
|
|
34
|
+
export function parseProcNetDev(text, include = name => name !== "lo") {
|
|
35
|
+
if (typeof text !== "string") return null;
|
|
36
|
+
let rx = 0;
|
|
37
|
+
let tx = 0;
|
|
38
|
+
const ifaces = [];
|
|
39
|
+
for (const line of text.split("\n")) {
|
|
40
|
+
const colon = line.indexOf(":");
|
|
41
|
+
if (colon < 0) continue;
|
|
42
|
+
const name = line.slice(0, colon).trim();
|
|
43
|
+
if (!name || name.includes("|") || !include(name)) continue;
|
|
44
|
+
const f = line.slice(colon + 1).trim().split(/\s+/).map(Number);
|
|
45
|
+
// Receive is the first eight columns and transmit the next eight; bytes
|
|
46
|
+
// lead each group.
|
|
47
|
+
if (f.length < 9 || !Number.isFinite(f[0]) || !Number.isFinite(f[8])) continue;
|
|
48
|
+
rx += f[0];
|
|
49
|
+
tx += f[8];
|
|
50
|
+
ifaces.push(name);
|
|
51
|
+
}
|
|
52
|
+
return ifaces.length ? { rx, tx, ifaces } : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The same two totals out of macOS's `netstat -ibn`.
|
|
57
|
+
*
|
|
58
|
+
* Only the `enN` rows, which are the Mac's Ethernet and Wi-Fi ports, and only
|
|
59
|
+
* their `<Link#N>` row — netstat prints one row per address family per
|
|
60
|
+
* interface, and summing all of them would count every byte two or three times.
|
|
61
|
+
* `utun` (every VPN, Tailscale included), `lo`, `awdl`, `llw`, `bridge` and the
|
|
62
|
+
* rest are left out for the reason the Linux reader leaves virtual interfaces
|
|
63
|
+
* out. Read from the END of the row, because the Address column is empty on
|
|
64
|
+
* some interfaces and a count from the front would shift by one.
|
|
65
|
+
*/
|
|
66
|
+
export function parseNetstatIbn(text) {
|
|
67
|
+
if (typeof text !== "string") return null;
|
|
68
|
+
let rx = 0;
|
|
69
|
+
let tx = 0;
|
|
70
|
+
let seen = 0;
|
|
71
|
+
for (const line of text.split("\n")) {
|
|
72
|
+
const f = line.trim().split(/\s+/);
|
|
73
|
+
if (f.length < 8 || !/^en\d+$/.test(f[0]) || !f.some(c => c.startsWith("<Link#"))) continue;
|
|
74
|
+
// … Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll
|
|
75
|
+
const ibytes = Number(f[f.length - 5]);
|
|
76
|
+
const obytes = Number(f[f.length - 2]);
|
|
77
|
+
if (!Number.isFinite(ibytes) || !Number.isFinite(obytes)) continue;
|
|
78
|
+
rx += ibytes;
|
|
79
|
+
tx += obytes;
|
|
80
|
+
seen++;
|
|
81
|
+
}
|
|
82
|
+
return seen ? { rx, tx } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The two totals out of Windows' `netstat -e`.
|
|
87
|
+
*
|
|
88
|
+
* Found by shape rather than by label: the first row that is a word followed by
|
|
89
|
+
* exactly two integers is the Bytes row, in every language Windows ships —
|
|
90
|
+
* "Bytes" is "Octets" on a French machine, and a parser that matched the word
|
|
91
|
+
* would read nothing there. The totals cover every adapter; Windows offers no
|
|
92
|
+
* cheaper per-adapter figure than a PowerShell start, which costs more than
|
|
93
|
+
* this whole section is worth every five seconds.
|
|
94
|
+
*/
|
|
95
|
+
export function parseNetstatE(text) {
|
|
96
|
+
if (typeof text !== "string") return null;
|
|
97
|
+
for (const line of text.split(/\r?\n/)) {
|
|
98
|
+
const m = /^\s*\S[^\d]*?\s+(\d+)\s+(\d+)\s*$/.exec(line);
|
|
99
|
+
if (m) return { rx: Number(m[1]), tx: Number(m[2]) };
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Bytes per second between two readings, or null when the pair cannot say.
|
|
106
|
+
*
|
|
107
|
+
* A counter that went BACKWARDS is an adapter that was reset — unplugged,
|
|
108
|
+
* resumed from sleep, a driver reloaded — and the difference across a reset is
|
|
109
|
+
* not a rate, it is a large negative number. Too short a gap is refused for the
|
|
110
|
+
* opposite reason: two reads a few milliseconds apart turn one packet into a
|
|
111
|
+
* wild figure.
|
|
112
|
+
*/
|
|
113
|
+
export function rateBetween(prev, next) {
|
|
114
|
+
if (!prev || !next) return null;
|
|
115
|
+
const seconds = (next.at - prev.at) / 1000;
|
|
116
|
+
if (!(seconds >= 0.5)) return null;
|
|
117
|
+
const drx = next.rx - prev.rx;
|
|
118
|
+
const dtx = next.tx - prev.tx;
|
|
119
|
+
if (drx < 0 || dtx < 0) return null;
|
|
120
|
+
return { down: Math.round(drx / seconds), up: Math.round(dtx / seconds) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The interface Linux would send a packet for this address out of, from
|
|
124
|
+
* `ip -o route get <address>`. Asking sends nothing; it reads the routing
|
|
125
|
+
* table, including the policy tables a Tailscale exit node installs, which
|
|
126
|
+
* `/proc/net/route` does not show. */
|
|
127
|
+
export function routeIfaceLinux(text) {
|
|
128
|
+
if (typeof text !== "string") return null;
|
|
129
|
+
return /\bdev\s+(\S+)/.exec(text)?.[1] ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The same question on macOS, from `route -n get <address>`. */
|
|
133
|
+
export function routeIfaceDarwin(text) {
|
|
134
|
+
if (typeof text !== "string") return null;
|
|
135
|
+
return /^\s*interface:\s*(\S+)/m.exec(text)?.[1] ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What kind of path an interface name is.
|
|
140
|
+
*
|
|
141
|
+
* Direct is the ordinary case and draws nothing at all in the panel: only a
|
|
142
|
+
* route that is not the machine's own connection is worth a line. Names, not
|
|
143
|
+
* probes, because the kernel already named them — `tailscale0`, `wg0`,
|
|
144
|
+
* `nordlynx`, `utun4`, `tun0`, `ppp0`, `zt…` — and asking each VPN client
|
|
145
|
+
* whether it is running would cost a process per client per sample.
|
|
146
|
+
*/
|
|
147
|
+
export function classifyRoute(iface) {
|
|
148
|
+
if (typeof iface !== "string" || !iface) return null;
|
|
149
|
+
if (/^tailscale\d*$/.test(iface)) return { kind: "tailscale", iface };
|
|
150
|
+
if (/^(wg\d|nordlynx|mullvad|proton|wgpia)/.test(iface)) return { kind: "vpn", iface, name: "WireGuard" };
|
|
151
|
+
if (/^zt/.test(iface)) return { kind: "vpn", iface, name: "ZeroTier" };
|
|
152
|
+
if (/^(utun|tun|tap|ppp|ipsec|gpd|cscotun|vpn)/.test(iface)) return { kind: "vpn", iface, name: null };
|
|
153
|
+
return { kind: "direct", iface };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The exit node carrying this machine's traffic, and whether it is reached
|
|
158
|
+
* directly or through one of Tailscale's relays, from `tailscale status --json`.
|
|
159
|
+
*
|
|
160
|
+
* The relay is the half that matters. A direct path to an exit node costs a
|
|
161
|
+
* hop; a relayed one sends every byte through a DERP server — `nue` is
|
|
162
|
+
* Nuremberg — and adds the round trip to wherever that is, twice. That is what
|
|
163
|
+
* made a live stream buffer every few minutes on the day this was written, on a
|
|
164
|
+
* line that was otherwise fine.
|
|
165
|
+
*/
|
|
166
|
+
export function tailscaleExitFromStatus(json) {
|
|
167
|
+
let status = json;
|
|
168
|
+
if (typeof json === "string") {
|
|
169
|
+
try { status = JSON.parse(json); } catch { return null; }
|
|
170
|
+
}
|
|
171
|
+
if (!status || typeof status !== "object" || !status.ExitNodeStatus) return null;
|
|
172
|
+
const peer = Object.values(status.Peer ?? {}).find(p => p && p.ExitNode);
|
|
173
|
+
const node = peer?.HostName || peer?.DNSName?.split(".")[0] || null;
|
|
174
|
+
// CurAddr is set when the path is direct; an empty one with a Relay named is
|
|
175
|
+
// every packet going through that relay.
|
|
176
|
+
const relay = peer && !peer.CurAddr && peer.Relay ? String(peer.Relay) : null;
|
|
177
|
+
return { node, relay };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One line for a route, the same words the panel and the history use, so the
|
|
181
|
+
* two can never describe one path differently. */
|
|
182
|
+
export function routeLabel(route) {
|
|
183
|
+
if (!route) return null;
|
|
184
|
+
if (route.kind === "direct") return "direct";
|
|
185
|
+
if (route.kind === "tailscale-exit") {
|
|
186
|
+
return `through Tailscale exit node${route.node ? ` ${route.node}` : ""}${route.relay ? `, relayed via ${route.relay}` : ""}`;
|
|
187
|
+
}
|
|
188
|
+
if (route.kind === "tailscale") return "through Tailscale";
|
|
189
|
+
return `through ${route.name ? `${route.name} ` : "a "}VPN${route.iface ? ` (${route.iface})` : ""}`;
|
|
190
|
+
}
|
|
@@ -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
|
};
|