@omercnet/paseo-top 0.0.0-stage → 0.0.1-next.250.1
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 +31 -2
- package/client/dial.ts +29 -0
- package/client/footer.tsx +72 -0
- package/client/format.ts +56 -0
- package/client/gauge.tsx +189 -0
- package/client/screen.tsx +169 -0
- package/client/sessions.tsx +91 -0
- package/client/usage.ts +16 -0
- package/index.client.tsx +17 -0
- package/index.server.ts +18 -0
- package/package.json +60 -4
- package/paseo-plugin.json +8 -0
- package/server/names.ts +177 -0
- package/server/processes.ts +260 -0
- package/server/usage.ts +175 -0
- package/shared/usage.ts +56 -0
package/server/usage.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
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, the counters went backwards or are not numbers. */
|
|
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 (!Number.isFinite(total) || !Number.isFinite(idle)) return null;
|
|
26
|
+
if (total <= 0 || idle < 0 || idle > total) return null;
|
|
27
|
+
return (1 - idle / total) * 100;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The filesystem holding the daemon user's home, the one agent checkouts normally fill up. */
|
|
31
|
+
export function diskPercent(
|
|
32
|
+
path = os.homedir(),
|
|
33
|
+
stat: (path: string) => { blocks: number; bfree: number; bavail: number } = statfsSync,
|
|
34
|
+
): number | null {
|
|
35
|
+
try {
|
|
36
|
+
const { blocks, bfree, bavail } = stat(path);
|
|
37
|
+
const used = blocks - bfree;
|
|
38
|
+
const usable = used + bavail;
|
|
39
|
+
const percent = (used / usable) * 100;
|
|
40
|
+
return usable > 0 && Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whole-disk bytes read plus written since boot (Linux only). Null where /proc is unavailable, and
|
|
48
|
+
* when any whole disk has a malformed counter: a partial total would show up as a throughput spike
|
|
49
|
+
* once the counter reads again.
|
|
50
|
+
*/
|
|
51
|
+
export function readDiskBytes(
|
|
52
|
+
sysBlock = "/sys/block",
|
|
53
|
+
diskstats = "/proc/diskstats",
|
|
54
|
+
): number | null {
|
|
55
|
+
try {
|
|
56
|
+
// Partitions, RAID, device-mapper and loop devices would count the same bytes twice.
|
|
57
|
+
const disks = new Set(
|
|
58
|
+
readdirSync(sysBlock).filter((name) => !/^(loop|ram|dm-|zram|md)/.test(name)),
|
|
59
|
+
);
|
|
60
|
+
let sectors = 0;
|
|
61
|
+
let matched = false;
|
|
62
|
+
for (const line of readFileSync(diskstats, "utf8").split("\n")) {
|
|
63
|
+
const fields = line.trim().split(/\s+/);
|
|
64
|
+
if (!disks.has(fields[2] ?? "")) continue;
|
|
65
|
+
const read = Number(fields[5]);
|
|
66
|
+
const written = Number(fields[9]);
|
|
67
|
+
if (!(Number.isFinite(read) && read >= 0 && Number.isFinite(written) && written >= 0)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
sectors += read + written;
|
|
71
|
+
matched = true;
|
|
72
|
+
}
|
|
73
|
+
return matched && Number.isFinite(sectors) ? sectors * 512 : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Bytes per second between two readings, or null when a counter is missing, not finite, or went backwards. */
|
|
80
|
+
export function bytesPerSecond(
|
|
81
|
+
previous: number | null,
|
|
82
|
+
current: number | null,
|
|
83
|
+
elapsedMs: number,
|
|
84
|
+
): number | null {
|
|
85
|
+
if (previous === null || current === null) return null;
|
|
86
|
+
if (!(Number.isFinite(previous) && Number.isFinite(current) && Number.isFinite(elapsedMs))) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (current < previous || elapsedMs <= 0) return null;
|
|
90
|
+
const rate = ((current - previous) * 1000) / elapsedMs;
|
|
91
|
+
return Number.isFinite(rate) ? rate : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Used share of memory, clamped to 0-100; null when the host reports no usable total. Linux reports
|
|
96
|
+
* reclaimable cache as free, so this is memory programs cannot get back.
|
|
97
|
+
*/
|
|
98
|
+
export function memoryPercent(total = os.totalmem(), free = os.freemem()): number | null {
|
|
99
|
+
if (!Number.isFinite(total) || !Number.isFinite(free) || total <= 0) return null;
|
|
100
|
+
return Math.min(100, Math.max(0, (1 - free / total) * 100));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface SamplerOptions {
|
|
104
|
+
intervalMs?: number;
|
|
105
|
+
readCpu?: () => CpuTimes;
|
|
106
|
+
readIo?: () => number | null;
|
|
107
|
+
readMemory?: () => number | null;
|
|
108
|
+
readDisk?: () => number | null;
|
|
109
|
+
/** Lists processes, or returns null where that is not possible. */
|
|
110
|
+
scan?: () => ProcInfo[] | null;
|
|
111
|
+
totalMemory?: () => number;
|
|
112
|
+
now?: () => number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* CPU and disk I/O are rates, so a timer keeps the latest ones, and a ring of past samples gives
|
|
117
|
+
* every client a timeline no matter when it connected. The RPC only reads this.
|
|
118
|
+
*/
|
|
119
|
+
export function startSampler({
|
|
120
|
+
intervalMs = 2000,
|
|
121
|
+
readCpu = readCpuTimes,
|
|
122
|
+
readIo = readDiskBytes,
|
|
123
|
+
readMemory = memoryPercent,
|
|
124
|
+
readDisk = diskPercent,
|
|
125
|
+
scan = defaultScan(),
|
|
126
|
+
totalMemory = os.totalmem,
|
|
127
|
+
now = Date.now,
|
|
128
|
+
}: SamplerOptions = {}) {
|
|
129
|
+
let previousCpu = readCpu();
|
|
130
|
+
let previousIo = readIo();
|
|
131
|
+
let previousAt = now();
|
|
132
|
+
let lastHistoryAt = Number.NEGATIVE_INFINITY;
|
|
133
|
+
let cpu: number | null = null;
|
|
134
|
+
let io: number | null = null;
|
|
135
|
+
const history: Sample[] = [];
|
|
136
|
+
const sessions = trackSessions(scan, previousAt);
|
|
137
|
+
const current = (): Sample => ({ cpu, memory: readMemory(), disk: readDisk(), io });
|
|
138
|
+
|
|
139
|
+
const timer = setInterval(() => {
|
|
140
|
+
const currentCpu = readCpu();
|
|
141
|
+
const currentIo = readIo();
|
|
142
|
+
const at = now();
|
|
143
|
+
cpu = cpuPercent(previousCpu, currentCpu) ?? cpu;
|
|
144
|
+
io = bytesPerSecond(previousIo, currentIo, at - previousAt) ?? io;
|
|
145
|
+
previousCpu = currentCpu;
|
|
146
|
+
previousIo = currentIo;
|
|
147
|
+
previousAt = at;
|
|
148
|
+
sessions.update(at);
|
|
149
|
+
if (at - lastHistoryAt >= HISTORY_EVERY_MS) {
|
|
150
|
+
lastHistoryAt = at;
|
|
151
|
+
history.push(current());
|
|
152
|
+
if (history.length > HISTORY_LENGTH) history.shift();
|
|
153
|
+
}
|
|
154
|
+
}, intervalMs);
|
|
155
|
+
timer.unref();
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
usage: () => {
|
|
159
|
+
const total = totalMemory();
|
|
160
|
+
return { ...current(), history: [...history], totalMemory: total, ...sessions.read(total) };
|
|
161
|
+
},
|
|
162
|
+
stop: () => clearInterval(timer),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Reads /proc; returns null for a scan where it cannot be listed. */
|
|
167
|
+
function defaultScan(): () => ProcInfo[] | null {
|
|
168
|
+
return () => {
|
|
169
|
+
try {
|
|
170
|
+
return readProcs();
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
package/shared/usage.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/** One history point every this often, `HISTORY_LENGTH` of them: five minutes in all. */
|
|
5
|
+
export const HISTORY_EVERY_MS = 10_000;
|
|
6
|
+
export const HISTORY_LENGTH = 30;
|
|
7
|
+
|
|
8
|
+
const percent = z.number().min(0).max(100);
|
|
9
|
+
|
|
10
|
+
/** Usage of the daemon host. Percentages are 0-100; `null` means the host cannot report it yet. */
|
|
11
|
+
const sample = z.object({
|
|
12
|
+
cpu: percent.nullable(),
|
|
13
|
+
memory: percent.nullable(),
|
|
14
|
+
disk: percent.nullable(),
|
|
15
|
+
/** Disk read plus write throughput in bytes per second. */
|
|
16
|
+
io: z.number().min(0).nullable(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type Sample = z.infer<typeof sample>;
|
|
20
|
+
|
|
21
|
+
/** What one agent's whole process tree is costing, including everything its tools launched. */
|
|
22
|
+
const session = z.object({
|
|
23
|
+
agentId: z.string(),
|
|
24
|
+
title: z.string().nullable(),
|
|
25
|
+
workspace: z.string().nullable(),
|
|
26
|
+
/** CPU in cores: 1.5 means one and a half cores busy, summed over the tree. */
|
|
27
|
+
cores: z.number().min(0),
|
|
28
|
+
/** Resident memory in bytes, summed over the tree. */
|
|
29
|
+
rss: z.number().min(0),
|
|
30
|
+
/** The busiest process in the tree, which is usually what is actually heavy. */
|
|
31
|
+
command: z.string(),
|
|
32
|
+
processes: z.number().int().min(1),
|
|
33
|
+
heavy: z.boolean(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type Session = z.infer<typeof session>;
|
|
37
|
+
|
|
38
|
+
const load = z.object({ cores: z.number().min(0), rss: z.number().min(0) });
|
|
39
|
+
|
|
40
|
+
export const getUsage = defineRpc({
|
|
41
|
+
name: "top.usage",
|
|
42
|
+
input: z.object({}),
|
|
43
|
+
output: sample.extend({
|
|
44
|
+
/** Earlier samples, oldest first, `HISTORY_EVERY_MS` apart, so a fresh client has a timeline. */
|
|
45
|
+
history: z.array(sample).max(HISTORY_LENGTH),
|
|
46
|
+
totalMemory: z.number().positive(),
|
|
47
|
+
/** Busiest first. `null` where the host cannot attribute processes (anything but Linux). */
|
|
48
|
+
sessions: z.array(session).nullable(),
|
|
49
|
+
/**
|
|
50
|
+
* Everything scanned that is not listed in `sessions`: processes of no agent, of other daemons'
|
|
51
|
+
* agents, and of sessions past the list cap. Time spent in the kernel, exited processes and the
|
|
52
|
+
* page cache are not processes, so sessions plus this are not the whole host's usage.
|
|
53
|
+
*/
|
|
54
|
+
other: load,
|
|
55
|
+
}),
|
|
56
|
+
});
|