@omercnet/paseo-top 0.0.0-stage → 0.0.1-alpha
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 +21 -0
- package/OVERVIEW.md +15 -0
- package/README.md +27 -2
- package/client/dial.ts +29 -0
- package/client/footer.tsx +55 -0
- package/client/format.ts +56 -0
- package/client/gauge.tsx +187 -0
- package/client/screen.tsx +165 -0
- package/client/sessions.tsx +91 -0
- package/client/usage.ts +16 -0
- package/index.client.tsx +17 -0
- package/index.server.ts +15 -0
- package/package.json +60 -4
- package/paseo-plugin.json +5 -0
- package/server/names.ts +143 -0
- package/server/processes.ts +265 -0
- package/server/usage.ts +149 -0
- package/shared/usage.ts +56 -0
package/index.client.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { TopFooter } from "./client/footer";
|
|
3
|
+
import { TopScreen } from "./client/screen";
|
|
4
|
+
import { SCREEN_ID } from "./client/usage";
|
|
5
|
+
|
|
6
|
+
export default function contribute(client: PluginClientContext) {
|
|
7
|
+
const removeScreen = client.addScreen({ id: SCREEN_ID, title: "Top", Component: TopScreen });
|
|
8
|
+
const removeFooter = client.addSidebarFooterItem({
|
|
9
|
+
id: SCREEN_ID,
|
|
10
|
+
title: "Top",
|
|
11
|
+
Component: TopFooter,
|
|
12
|
+
});
|
|
13
|
+
return () => {
|
|
14
|
+
removeFooter();
|
|
15
|
+
removeScreen();
|
|
16
|
+
};
|
|
17
|
+
}
|
package/index.server.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { createOwnership } from "./server/names";
|
|
3
|
+
import { startSampler } from "./server/usage";
|
|
4
|
+
import { getUsage } from "./shared/usage";
|
|
5
|
+
|
|
6
|
+
export default function contribute(server: PluginServerContext) {
|
|
7
|
+
const sampler = startSampler();
|
|
8
|
+
const ownSessions = createOwnership();
|
|
9
|
+
server.handle(getUsage, async (_input, { paseo }) => {
|
|
10
|
+
const usage = sampler.usage();
|
|
11
|
+
if (!usage.sessions) return usage;
|
|
12
|
+
return { ...usage, ...(await ownSessions(paseo, usage.sessions, usage.other)) };
|
|
13
|
+
});
|
|
14
|
+
return sampler.stop;
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-top",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1-alpha",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A Paseo plugin that shows host CPU, memory and disk usage in the sidebar footer.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/omercnet/paseo-plugins.git",
|
|
10
|
+
"directory": "top"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/omercnet/paseo-plugins/tree/main/top#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/omercnet/paseo-plugins/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"packageManager": "bun@1.4.2",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=24"
|
|
22
|
+
},
|
|
23
|
+
"author": "Omer Cohen",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"paseo",
|
|
26
|
+
"paseo-plugin",
|
|
27
|
+
"top",
|
|
28
|
+
"host-monitor"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"index.client.tsx",
|
|
32
|
+
"index.server.ts",
|
|
33
|
+
"client",
|
|
34
|
+
"server",
|
|
35
|
+
"shared",
|
|
36
|
+
"paseo-plugin.json",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"OVERVIEW.md"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"check": "biome check .",
|
|
43
|
+
"check:write": "biome check --write .",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@biomejs/biome": "^2.5.10",
|
|
49
|
+
"@getpaseo/cli": "0.11.1",
|
|
50
|
+
"@getpaseo/client": "0.11.1",
|
|
51
|
+
"@getpaseo/plugin": "0.11.1",
|
|
52
|
+
"@getpaseo/protocol": "0.11.1",
|
|
53
|
+
"@tanstack/react-query": "^5.102.3",
|
|
54
|
+
"@types/node": "^24.10.1",
|
|
55
|
+
"@types/react": "^19.2.18",
|
|
56
|
+
"react": "19.1.0",
|
|
57
|
+
"react-native": "0.81.5",
|
|
58
|
+
"typescript": "^7.0.2",
|
|
59
|
+
"vitest": "^5.0.0",
|
|
60
|
+
"zod": "4.4.3"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/server/names.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { PaseoApi } from "@getpaseo/client";
|
|
2
|
+
import type { Session } from "../shared/usage";
|
|
3
|
+
|
|
4
|
+
/** Re-reading every agent, archived ones included, is not free, so it is done sparingly. */
|
|
5
|
+
const FULL_REFRESH_MS = 30_000;
|
|
6
|
+
/** A session whose agent is not yet known may be a brand new agent; look again, but not constantly. */
|
|
7
|
+
const EARLY_REFRESH_MS = 5000;
|
|
8
|
+
/** Only the first ever fetch is waited for, and not forever. */
|
|
9
|
+
const FIRST_FETCH_TIMEOUT_MS = 3000;
|
|
10
|
+
const PAGE_SIZE = 200;
|
|
11
|
+
export const MAX_PAGES = 25;
|
|
12
|
+
export const MAX_SESSIONS = 10;
|
|
13
|
+
|
|
14
|
+
interface Load {
|
|
15
|
+
cores: number;
|
|
16
|
+
rss: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Known {
|
|
20
|
+
agents: Map<string, { title: string | null; workspaceId: string | null }>;
|
|
21
|
+
workspaces: Map<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Page<T> {
|
|
25
|
+
entries: T[];
|
|
26
|
+
pageInfo: { hasMore: boolean; nextCursor: string | null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The daemon pages its lists, and an agent left off the first page must still be recognised. */
|
|
30
|
+
async function collect<T>(fetchPage: (cursor: string | undefined) => Promise<Page<T>>) {
|
|
31
|
+
const entries: T[] = [];
|
|
32
|
+
let cursor: string | undefined;
|
|
33
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
34
|
+
const result = await fetchPage(cursor);
|
|
35
|
+
entries.push(...result.entries);
|
|
36
|
+
if (!result.pageInfo.hasMore || !result.pageInfo.nextCursor) break;
|
|
37
|
+
cursor = result.pageInfo.nextCursor;
|
|
38
|
+
}
|
|
39
|
+
return entries;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The process scan sees every process the daemon's user owns, including agents that belong to other
|
|
44
|
+
* daemons on the same machine. This keeps only the sessions of agents this daemon knows, names them
|
|
45
|
+
* with the user's own agent and workspace names, and folds everything else into `other` so the
|
|
46
|
+
* numbers still add up to the processes that were scanned.
|
|
47
|
+
*
|
|
48
|
+
* The daemon's lists are read in the background and shared between callers, so a slow daemon never
|
|
49
|
+
* holds up the readings. Only the very first read is waited for, for a few seconds at most. Until
|
|
50
|
+
* then, and whenever the lists have never been available, no session can be shown as this host's and
|
|
51
|
+
* all of its load is reported as other load. Once read, a failed refresh keeps the last lists.
|
|
52
|
+
*/
|
|
53
|
+
export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETCH_TIMEOUT_MS) {
|
|
54
|
+
let known: Known | null = null;
|
|
55
|
+
/** Agents confirmed absent from the latest read, so they do not trigger another one. */
|
|
56
|
+
let foreign = new Set<string>();
|
|
57
|
+
let fetchedAt = Number.NEGATIVE_INFINITY;
|
|
58
|
+
let inflight: Promise<void> | null = null;
|
|
59
|
+
|
|
60
|
+
const refresh = (paseo: PaseoApi, candidates: string[]) => {
|
|
61
|
+
if (inflight) return inflight;
|
|
62
|
+
fetchedAt = now();
|
|
63
|
+
inflight = (async () => {
|
|
64
|
+
try {
|
|
65
|
+
const [agents, workspaces] = await Promise.all([
|
|
66
|
+
collect((cursor) =>
|
|
67
|
+
paseo.agents.list({
|
|
68
|
+
filter: { includeArchived: true },
|
|
69
|
+
// Oldest first: an agent's creation time never changes, so paging cannot skip one that
|
|
70
|
+
// is busy and being updated while the pages are read.
|
|
71
|
+
sort: [{ key: "created_at", direction: "asc" }],
|
|
72
|
+
page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) },
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
collect((cursor) =>
|
|
76
|
+
paseo.workspaces.list({ page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) } }),
|
|
77
|
+
),
|
|
78
|
+
]);
|
|
79
|
+
const next: Known = {
|
|
80
|
+
agents: new Map(
|
|
81
|
+
agents.map(({ agent }) => [
|
|
82
|
+
agent.id,
|
|
83
|
+
{ title: agent.title ?? null, workspaceId: agent.workspaceId ?? null },
|
|
84
|
+
]),
|
|
85
|
+
),
|
|
86
|
+
workspaces: new Map(
|
|
87
|
+
workspaces.map((workspace) => [workspace.id, workspace.title ?? workspace.name]),
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
known = next;
|
|
91
|
+
foreign = new Set(candidates.filter((id) => !next.agents.has(id)));
|
|
92
|
+
} catch {
|
|
93
|
+
// Keep the previous lists.
|
|
94
|
+
}
|
|
95
|
+
})().finally(() => {
|
|
96
|
+
inflight = null;
|
|
97
|
+
});
|
|
98
|
+
return inflight;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return async (paseo: PaseoApi, sessions: Session[], other: Load) => {
|
|
102
|
+
if (sessions.length === 0) return { sessions, other };
|
|
103
|
+
|
|
104
|
+
const ids = sessions.map((session) => session.agentId);
|
|
105
|
+
const age = now() - fetchedAt;
|
|
106
|
+
const unseen = ids.some((id) => !known?.agents.has(id) && !foreign.has(id));
|
|
107
|
+
const pending =
|
|
108
|
+
age >= FULL_REFRESH_MS || (unseen && age >= EARLY_REFRESH_MS)
|
|
109
|
+
? refresh(paseo, ids)
|
|
110
|
+
: inflight;
|
|
111
|
+
if (known === null && pending) {
|
|
112
|
+
await new Promise<void>((resolve) => {
|
|
113
|
+
const timer = setTimeout(resolve, firstFetchTimeoutMs);
|
|
114
|
+
pending.then(() => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
resolve();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rest = { ...other };
|
|
122
|
+
const own: Session[] = [];
|
|
123
|
+
for (const session of sessions) {
|
|
124
|
+
const agent = known?.agents.get(session.agentId);
|
|
125
|
+
if (!agent) {
|
|
126
|
+
rest.cores += session.cores;
|
|
127
|
+
rest.rss += session.rss;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
own.push({
|
|
131
|
+
...session,
|
|
132
|
+
title: agent.title,
|
|
133
|
+
workspace: (agent.workspaceId && known?.workspaces.get(agent.workspaceId)) || null,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Callers hand sessions over flagged first, then busiest, so the cap drops the least important.
|
|
137
|
+
for (const session of own.slice(MAX_SESSIONS)) {
|
|
138
|
+
rest.cores += session.cores;
|
|
139
|
+
rest.rss += session.rss;
|
|
140
|
+
}
|
|
141
|
+
return { sessions: own.slice(0, MAX_SESSIONS), other: rest };
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import type { Session } from "../shared/usage";
|
|
4
|
+
|
|
5
|
+
const CLOCK_TICKS = 100;
|
|
6
|
+
const CPUS = Math.max(1, os.availableParallelism());
|
|
7
|
+
const MAX_DEPTH = 64;
|
|
8
|
+
const DEFAULT_PAGE_BYTES = 4096;
|
|
9
|
+
const AT_PAGESZ = 6;
|
|
10
|
+
|
|
11
|
+
/** The kernel's page size from the auxiliary vector; arm64 and ppc64 kernels often use 16K or 64K. */
|
|
12
|
+
export function readPageSize(auxvPath = "/proc/self/auxv"): number {
|
|
13
|
+
try {
|
|
14
|
+
const auxv = readFileSync(auxvPath);
|
|
15
|
+
const little = os.endianness() === "LE";
|
|
16
|
+
// Entries are (type, value) pairs of machine words; 64-bit hosts are the only ones in practice.
|
|
17
|
+
for (let at = 0; at + 16 <= auxv.length; at += 16) {
|
|
18
|
+
const type = Number(little ? auxv.readBigUInt64LE(at) : auxv.readBigUInt64BE(at));
|
|
19
|
+
if (type !== AT_PAGESZ) continue;
|
|
20
|
+
const value = Number(little ? auxv.readBigUInt64LE(at + 8) : auxv.readBigUInt64BE(at + 8));
|
|
21
|
+
const power = value > 0 && (value & (value - 1)) === 0;
|
|
22
|
+
return power && value >= DEFAULT_PAGE_BYTES && value <= 2 ** 20 ? value : DEFAULT_PAGE_BYTES;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// Not Linux, or not readable.
|
|
26
|
+
}
|
|
27
|
+
return DEFAULT_PAGE_BYTES;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PAGE_BYTES = readPageSize();
|
|
31
|
+
|
|
32
|
+
export interface ProcInfo {
|
|
33
|
+
/** `pid:starttime`, unique even after the kernel reuses a pid. */
|
|
34
|
+
key: string;
|
|
35
|
+
pid: number;
|
|
36
|
+
ppid: number;
|
|
37
|
+
/** User plus system CPU time so far, in ticks. */
|
|
38
|
+
ticks: number;
|
|
39
|
+
rss: number;
|
|
40
|
+
comm: string;
|
|
41
|
+
/** Agent that launched this process, from the environment Paseo gave it, if any. */
|
|
42
|
+
agentId: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Stat {
|
|
46
|
+
ppid: number;
|
|
47
|
+
ticks: number;
|
|
48
|
+
start: number;
|
|
49
|
+
rss: number;
|
|
50
|
+
comm: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parses /proc/<pid>/stat. The command may contain spaces and parentheses, so split at the last `)`. */
|
|
54
|
+
export function parseStat(text: string): Stat | null {
|
|
55
|
+
const open = text.indexOf("(");
|
|
56
|
+
const close = text.lastIndexOf(")");
|
|
57
|
+
if (open < 0 || close < open) return null;
|
|
58
|
+
// Fields after the command start at number 3, so field n is at index n - 3.
|
|
59
|
+
const fields = text.slice(close + 2).split(" ");
|
|
60
|
+
const numbers = {
|
|
61
|
+
ppid: Number(fields[1]),
|
|
62
|
+
ticks: Number(fields[11]) + Number(fields[12]),
|
|
63
|
+
start: Number(fields[19]),
|
|
64
|
+
rss: Number(fields[21]) * PAGE_BYTES,
|
|
65
|
+
};
|
|
66
|
+
if (!Object.values(numbers).every(Number.isFinite)) return null;
|
|
67
|
+
// The kernel reports rss as a signed number; a negative one must not poison the whole reading.
|
|
68
|
+
return { comm: text.slice(open + 1, close), ...numbers, rss: Math.max(0, numbers.rss) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Only the agent id is read out of the environment; the rest of it can hold secrets and is dropped. */
|
|
72
|
+
export function parseAgentId(environ: string): string | null {
|
|
73
|
+
for (const entry of environ.split("\0")) {
|
|
74
|
+
if (entry.startsWith("PASEO_AGENT_ID=")) return entry.slice("PASEO_AGENT_ID=".length) || null;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every process the daemon's user can see. A process's environment never changes after it starts, so
|
|
81
|
+
* it is read once and remembered in `agentIds`; entries for processes that are gone are dropped.
|
|
82
|
+
*/
|
|
83
|
+
export function readProcs(agentIds: Map<string, string | null>, root = "/proc"): ProcInfo[] {
|
|
84
|
+
const procs: ProcInfo[] = [];
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
for (const name of readdirSync(root)) {
|
|
87
|
+
if (!/^\d+$/.test(name)) continue;
|
|
88
|
+
try {
|
|
89
|
+
const stat = parseStat(readFileSync(`${root}/${name}/stat`, "utf8"));
|
|
90
|
+
if (!stat) continue;
|
|
91
|
+
const key = `${name}:${stat.start}`;
|
|
92
|
+
seen.add(key);
|
|
93
|
+
let agentId = agentIds.get(key);
|
|
94
|
+
if (agentId === undefined) {
|
|
95
|
+
try {
|
|
96
|
+
agentId = parseAgentId(readFileSync(`${root}/${name}/environ`, "utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
agentId = null; // another user's process
|
|
99
|
+
}
|
|
100
|
+
agentIds.set(key, agentId);
|
|
101
|
+
}
|
|
102
|
+
procs.push({ key, pid: Number(name), agentId, ...stat });
|
|
103
|
+
} catch {
|
|
104
|
+
// The process exited while it was being read.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const key of agentIds.keys()) if (!seen.has(key)) agentIds.delete(key);
|
|
108
|
+
return procs;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface SessionLoad {
|
|
112
|
+
agentId: string;
|
|
113
|
+
cores: number;
|
|
114
|
+
rss: number;
|
|
115
|
+
command: string;
|
|
116
|
+
processes: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface Attribution {
|
|
120
|
+
sessions: SessionLoad[];
|
|
121
|
+
other: { cores: number; rss: number };
|
|
122
|
+
/** CPU ticks per process, to diff against on the next scan. */
|
|
123
|
+
ticks: Map<string, number>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Charges each process to the agent that launched it. Tools often scrub or reset their
|
|
128
|
+
* environment, so an untagged process inherits from its nearest tagged ancestor.
|
|
129
|
+
*
|
|
130
|
+
* `previous` is the last scan's ticks, or null for the very first scan, which has nothing to diff
|
|
131
|
+
* against and so charges no CPU. After that, a process that is new since the last scan is charged
|
|
132
|
+
* all the CPU it has used, bounded by what every CPU could have given in the interval.
|
|
133
|
+
*/
|
|
134
|
+
export function attribute(
|
|
135
|
+
procs: readonly ProcInfo[],
|
|
136
|
+
previous: ReadonlyMap<string, number> | null,
|
|
137
|
+
elapsedSeconds: number,
|
|
138
|
+
): Attribution {
|
|
139
|
+
const byPid = new Map(procs.map((proc) => [proc.pid, proc]));
|
|
140
|
+
const ticks = new Map<string, number>();
|
|
141
|
+
const sessions = new Map<string, SessionLoad & { topCores: number; topRss: number }>();
|
|
142
|
+
const other = { cores: 0, rss: 0 };
|
|
143
|
+
const budget = elapsedSeconds * CLOCK_TICKS * CPUS;
|
|
144
|
+
|
|
145
|
+
for (const proc of procs) {
|
|
146
|
+
ticks.set(proc.key, proc.ticks);
|
|
147
|
+
let owner = proc;
|
|
148
|
+
for (let depth = 0; !owner.agentId && depth < MAX_DEPTH; depth++) {
|
|
149
|
+
const parent = byPid.get(owner.ppid);
|
|
150
|
+
if (!parent || parent === owner) break;
|
|
151
|
+
owner = parent;
|
|
152
|
+
}
|
|
153
|
+
let cores = 0;
|
|
154
|
+
if (previous && elapsedSeconds > 0) {
|
|
155
|
+
const earlier = previous.get(proc.key);
|
|
156
|
+
// A process seen before has a monotonic counter. One that is new since the last scan has used
|
|
157
|
+
// all its ticks within the interval; the bound only guards against a process missed earlier.
|
|
158
|
+
const used = earlier === undefined ? Math.min(proc.ticks, budget) : proc.ticks - earlier;
|
|
159
|
+
cores = Math.max(0, used) / CLOCK_TICKS / elapsedSeconds;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!owner.agentId) {
|
|
163
|
+
other.cores += cores;
|
|
164
|
+
other.rss += proc.rss;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
let session = sessions.get(owner.agentId);
|
|
168
|
+
if (!session) {
|
|
169
|
+
session = {
|
|
170
|
+
agentId: owner.agentId,
|
|
171
|
+
cores: 0,
|
|
172
|
+
rss: 0,
|
|
173
|
+
command: proc.comm,
|
|
174
|
+
processes: 0,
|
|
175
|
+
topCores: -1,
|
|
176
|
+
topRss: -1,
|
|
177
|
+
};
|
|
178
|
+
sessions.set(owner.agentId, session);
|
|
179
|
+
}
|
|
180
|
+
session.cores += cores;
|
|
181
|
+
session.rss += proc.rss;
|
|
182
|
+
session.processes++;
|
|
183
|
+
// The busiest process names the load; memory breaks ties between equally busy ones.
|
|
184
|
+
if (cores > session.topCores || (cores === session.topCores && proc.rss > session.topRss)) {
|
|
185
|
+
session.topCores = cores;
|
|
186
|
+
session.topRss = proc.rss;
|
|
187
|
+
session.command = proc.comm;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
sessions: [...sessions.values()].map(
|
|
193
|
+
({ topCores: _cores, topRss: _rss, ...session }) => session,
|
|
194
|
+
),
|
|
195
|
+
other,
|
|
196
|
+
ticks,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const SCAN_EVERY_MS = 10_000;
|
|
201
|
+
/** A session is heavy at one busy core held for three scans (30 seconds), or a quarter of memory. */
|
|
202
|
+
export const HEAVY_CORES = 1;
|
|
203
|
+
export const HEAVY_SCANS = 3;
|
|
204
|
+
export const HEAVY_MEMORY_SHARE = 0.25;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Rescans every `SCAN_EVERY_MS` and remembers a short window of each session's CPU, so a one-scan
|
|
208
|
+
* spike does not flag it. `scan` returns null where processes cannot be listed: for good on a host
|
|
209
|
+
* that cannot do it at all (not Linux), and for one scan on a host that failed once.
|
|
210
|
+
*/
|
|
211
|
+
export function trackSessions(scan: () => ProcInfo[] | null, startedAt: number) {
|
|
212
|
+
const first = scan();
|
|
213
|
+
const supported = first !== null;
|
|
214
|
+
let previous = first ? attribute(first, null, 0).ticks : new Map<string, number>();
|
|
215
|
+
let latest: Pick<Attribution, "sessions" | "other"> = {
|
|
216
|
+
sessions: [],
|
|
217
|
+
other: { cores: 0, rss: 0 },
|
|
218
|
+
};
|
|
219
|
+
let scannedAt = startedAt;
|
|
220
|
+
let failures = 0;
|
|
221
|
+
let recent = new Map<string, number[]>();
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
update(at: number) {
|
|
225
|
+
if (!supported || at - scannedAt < SCAN_EVERY_MS) return;
|
|
226
|
+
const procs = scan();
|
|
227
|
+
if (!procs) {
|
|
228
|
+
// Keep the last readings through a hiccup, but not through a lasting failure. The next
|
|
229
|
+
// good scan diffs against the last good one, over the longer interval.
|
|
230
|
+
if (++failures >= HEAVY_SCANS) {
|
|
231
|
+
latest = { sessions: [], other: { cores: 0, rss: 0 } };
|
|
232
|
+
recent = new Map();
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
failures = 0;
|
|
237
|
+
const next = attribute(procs, previous, (at - scannedAt) / 1000);
|
|
238
|
+
previous = next.ticks;
|
|
239
|
+
scannedAt = at;
|
|
240
|
+
latest = { sessions: next.sessions, other: next.other };
|
|
241
|
+
recent = new Map(
|
|
242
|
+
next.sessions.map(({ agentId, cores }) => [
|
|
243
|
+
agentId,
|
|
244
|
+
[...(recent.get(agentId) ?? []), cores].slice(-HEAVY_SCANS),
|
|
245
|
+
]),
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
read(totalMemory: number): {
|
|
249
|
+
sessions: Session[] | null;
|
|
250
|
+
other: { cores: number; rss: number };
|
|
251
|
+
} {
|
|
252
|
+
if (!supported) return { sessions: null, other: { cores: 0, rss: 0 } };
|
|
253
|
+
const sessions = latest.sessions
|
|
254
|
+
.map((session): Session => {
|
|
255
|
+
const window = recent.get(session.agentId) ?? [];
|
|
256
|
+
// Held for the whole window, not averaged, so one burst does not flag a session.
|
|
257
|
+
const sustained = window.length === HEAVY_SCANS && window.every((c) => c >= HEAVY_CORES);
|
|
258
|
+
const heavy = sustained || session.rss >= HEAVY_MEMORY_SHARE * totalMemory;
|
|
259
|
+
return { ...session, title: null, workspace: null, heavy };
|
|
260
|
+
})
|
|
261
|
+
.sort((a, b) => Number(b.heavy) - Number(a.heavy) || b.cores - a.cores || b.rss - a.rss);
|
|
262
|
+
return { sessions, other: latest.other };
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
package/server/usage.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statfsSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { HISTORY_EVERY_MS, HISTORY_LENGTH, type Sample } from "../shared/usage";
|
|
4
|
+
import { type ProcInfo, readProcs, trackSessions } from "./processes";
|
|
5
|
+
|
|
6
|
+
export interface CpuTimes {
|
|
7
|
+
idle: number;
|
|
8
|
+
total: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readCpuTimes(cpus: ReadonlyArray<Pick<os.CpuInfo, "times">> = os.cpus()): CpuTimes {
|
|
12
|
+
let idle = 0;
|
|
13
|
+
let total = 0;
|
|
14
|
+
for (const { times } of cpus) {
|
|
15
|
+
idle += times.idle;
|
|
16
|
+
total += times.user + times.nice + times.sys + times.idle + times.irq;
|
|
17
|
+
}
|
|
18
|
+
return { idle, total };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Busy share between two readings, or null when no time elapsed or the counters went backwards. */
|
|
22
|
+
export function cpuPercent(previous: CpuTimes, current: CpuTimes): number | null {
|
|
23
|
+
const total = current.total - previous.total;
|
|
24
|
+
const idle = current.idle - previous.idle;
|
|
25
|
+
if (total <= 0 || idle < 0 || idle > total) return null;
|
|
26
|
+
return (1 - idle / total) * 100;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The filesystem holding the daemon user's home, the one agent checkouts normally fill up. */
|
|
30
|
+
export function diskPercent(
|
|
31
|
+
path = os.homedir(),
|
|
32
|
+
stat: (path: string) => { blocks: number; bfree: number; bavail: number } = statfsSync,
|
|
33
|
+
): number | null {
|
|
34
|
+
try {
|
|
35
|
+
const { blocks, bfree, bavail } = stat(path);
|
|
36
|
+
const used = blocks - bfree;
|
|
37
|
+
const usable = used + bavail;
|
|
38
|
+
return usable > 0 ? (used / usable) * 100 : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whole-disk bytes read plus written since boot (Linux only); null where /proc is unavailable. */
|
|
45
|
+
export function readDiskBytes(
|
|
46
|
+
sysBlock = "/sys/block",
|
|
47
|
+
diskstats = "/proc/diskstats",
|
|
48
|
+
): number | null {
|
|
49
|
+
try {
|
|
50
|
+
// Partitions, RAID, device-mapper and loop devices would count the same bytes twice.
|
|
51
|
+
const disks = new Set(
|
|
52
|
+
readdirSync(sysBlock).filter((name) => !/^(loop|ram|dm-|zram|md)/.test(name)),
|
|
53
|
+
);
|
|
54
|
+
let sectors = 0;
|
|
55
|
+
for (const line of readFileSync(diskstats, "utf8").split("\n")) {
|
|
56
|
+
const fields = line.trim().split(/\s+/);
|
|
57
|
+
if (disks.has(fields[2] ?? "")) sectors += Number(fields[5]) + Number(fields[9]);
|
|
58
|
+
}
|
|
59
|
+
return sectors * 512;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Bytes per second between two readings, or null when the counter is missing or went backwards. */
|
|
66
|
+
export function bytesPerSecond(
|
|
67
|
+
previous: number | null,
|
|
68
|
+
current: number | null,
|
|
69
|
+
elapsedMs: number,
|
|
70
|
+
): number | null {
|
|
71
|
+
if (previous === null || current === null || current < previous || elapsedMs <= 0) return null;
|
|
72
|
+
return ((current - previous) * 1000) / elapsedMs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface SamplerOptions {
|
|
76
|
+
intervalMs?: number;
|
|
77
|
+
readCpu?: () => CpuTimes;
|
|
78
|
+
readIo?: () => number | null;
|
|
79
|
+
readMemory?: () => number;
|
|
80
|
+
readDisk?: () => number | null;
|
|
81
|
+
/** Lists processes, or returns null where that is not possible. */
|
|
82
|
+
scan?: () => ProcInfo[] | null;
|
|
83
|
+
totalMemory?: () => number;
|
|
84
|
+
now?: () => number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* CPU and disk I/O are rates, so a timer keeps the latest ones, and a ring of past samples gives
|
|
89
|
+
* every client a timeline no matter when it connected. The RPC only reads this.
|
|
90
|
+
*/
|
|
91
|
+
export function startSampler({
|
|
92
|
+
intervalMs = 2000,
|
|
93
|
+
readCpu = readCpuTimes,
|
|
94
|
+
readIo = readDiskBytes,
|
|
95
|
+
// Linux reports reclaimable cache as free, so this is memory programs cannot get back.
|
|
96
|
+
readMemory = () => (1 - os.freemem() / os.totalmem()) * 100,
|
|
97
|
+
readDisk = diskPercent,
|
|
98
|
+
scan = defaultScan(),
|
|
99
|
+
totalMemory = os.totalmem,
|
|
100
|
+
now = Date.now,
|
|
101
|
+
}: SamplerOptions = {}) {
|
|
102
|
+
let previousCpu = readCpu();
|
|
103
|
+
let previousIo = readIo();
|
|
104
|
+
let previousAt = now();
|
|
105
|
+
let lastHistoryAt = Number.NEGATIVE_INFINITY;
|
|
106
|
+
let cpu: number | null = null;
|
|
107
|
+
let io: number | null = null;
|
|
108
|
+
const history: Sample[] = [];
|
|
109
|
+
const sessions = trackSessions(scan, previousAt);
|
|
110
|
+
const current = (): Sample => ({ cpu, memory: readMemory(), disk: readDisk(), io });
|
|
111
|
+
|
|
112
|
+
const timer = setInterval(() => {
|
|
113
|
+
const currentCpu = readCpu();
|
|
114
|
+
const currentIo = readIo();
|
|
115
|
+
const at = now();
|
|
116
|
+
cpu = cpuPercent(previousCpu, currentCpu) ?? cpu;
|
|
117
|
+
io = bytesPerSecond(previousIo, currentIo, at - previousAt) ?? io;
|
|
118
|
+
previousCpu = currentCpu;
|
|
119
|
+
previousIo = currentIo;
|
|
120
|
+
previousAt = at;
|
|
121
|
+
sessions.update(at);
|
|
122
|
+
if (at - lastHistoryAt >= HISTORY_EVERY_MS) {
|
|
123
|
+
lastHistoryAt = at;
|
|
124
|
+
history.push(current());
|
|
125
|
+
if (history.length > HISTORY_LENGTH) history.shift();
|
|
126
|
+
}
|
|
127
|
+
}, intervalMs);
|
|
128
|
+
timer.unref();
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
usage: () => {
|
|
132
|
+
const total = totalMemory();
|
|
133
|
+
return { ...current(), history: [...history], totalMemory: total, ...sessions.read(total) };
|
|
134
|
+
},
|
|
135
|
+
stop: () => clearInterval(timer),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Reads /proc; the environment cache lives as long as the sampler. */
|
|
140
|
+
function defaultScan(): () => ProcInfo[] | null {
|
|
141
|
+
const agentIds = new Map<string, string | null>();
|
|
142
|
+
return () => {
|
|
143
|
+
try {
|
|
144
|
+
return readProcs(agentIds);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|