@omercnet/paseo-top 0.0.1-alpha → 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/OVERVIEW.md +1 -1
- package/README.md +4 -0
- package/client/footer.tsx +25 -8
- package/client/gauge.tsx +4 -2
- package/client/screen.tsx +4 -0
- package/index.server.ts +4 -1
- package/package.json +1 -1
- package/paseo-plugin.json +3 -0
- package/server/names.ts +50 -16
- package/server/processes.ts +12 -17
- package/server/usage.ts +40 -14
- package/shared/usage.ts +1 -1
package/OVERVIEW.md
CHANGED
|
@@ -4,7 +4,7 @@ Usage always describes one host. With several hosts connected, the footer follow
|
|
|
4
4
|
|
|
5
5
|
**Load by session** adds up the CPU and memory of everything an agent started, not only the agent process. A build, test run or dev server launched by a tool counts toward the agent that launched it. Each row shows the agent and workspace names you gave them, the busiest command in its tree, cores and memory in use, and opens the agent when pressed. A session is flagged with a flame, and counted in the footer, when it holds one busy core for 30 seconds or a quarter of the host's memory. Everything not listed is shown on one "Everything else" line.
|
|
6
6
|
|
|
7
|
-
How processes are matched to agents: Paseo gives every agent session an identifier in its environment, and everything the session starts inherits it. Top reads only that identifier from
|
|
7
|
+
How processes are matched to agents: Paseo gives every agent session an identifier in its environment, and everything the session starts inherits it. On every scan Top reads each process's environment into memory, keeps only that identifier and drops the rest at once; nothing else from it is stored, logged or sent anywhere. A process whose environment cannot be read, such as one owned by another user, cannot be tagged. A process with no identifier is charged to the nearest ancestor that has one, since tools often clear it. Only agents that belong to the host you are viewing are listed; processes of agents that belong to other daemons on the same machine are counted under "Everything else", together with untagged processes and sessions beyond the first ten. Per-session load needs Linux. The scan covers every process `/proc` lists, so other users' processes are counted too, under "Everything else". Containers run outside the agent's process tree and show as unattributed.
|
|
8
8
|
|
|
9
9
|
What the numbers are and are not: load is sampled every ten seconds, so a process that starts and exits between two scans is never seen, and one that is still running is charged for the CPU it used since it started. A session is flagged only when it has held a full core across three scans in a row, so a single burst does not flag it. Memory is each process's resident memory added up, so memory shared between processes of one agent is counted more than once, and the quarter-of-memory rule compares against the machine's memory, not a container's limit. Sessions plus "Everything else" cover the processes that were scanned, not the whole host: kernel time, finished processes and the page cache are outside them.
|
|
10
10
|
|
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
A Paseo plugin that shows CPU, memory, disk and disk I/O of a host in the sidebar footer, and which agent sessions are responsible. See [OVERVIEW.md](OVERVIEW.md) for behavior.
|
|
4
4
|
|
|
5
|
+
## Screenshots
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
5
9
|
## Develop
|
|
6
10
|
|
|
7
11
|
```sh
|
package/client/footer.tsx
CHANGED
|
@@ -2,19 +2,26 @@ import type { PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
4
4
|
import { Text, View } from "react-native";
|
|
5
|
-
import { formatPercent, formatRate, levelColor } from "./format";
|
|
5
|
+
import { formatPercent, formatRate, type Level, levelColor, loadLevel } from "./format";
|
|
6
6
|
import { SCREEN_ID, useUsage } from "./usage";
|
|
7
7
|
|
|
8
|
+
const LEVEL_NAME: Record<Level, string> = { ok: "", warn: ", high", hot: ", critical" };
|
|
9
|
+
|
|
10
|
+
/** What a screen reader says: the reading in words, with the level spelled out, not just coloured. */
|
|
11
|
+
function metricLabel(name: string, text: string, level: Level): string {
|
|
12
|
+
return text === "--" ? `${name} unavailable` : `${name} ${text}${LEVEL_NAME[level]}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
export function TopFooter({ theme, host, currentScreen, openScreen }: PluginSidebarItemProps) {
|
|
9
16
|
const usage = useUsage(host.id);
|
|
10
17
|
const heavy = usage.data?.sessions?.filter((session) => session.heavy).length ?? 0;
|
|
11
18
|
const metrics =
|
|
12
19
|
usage.data &&
|
|
13
20
|
([
|
|
14
|
-
["Cpu", formatPercent(usage.data.cpu), usage.data.cpu],
|
|
15
|
-
["MemoryStick", formatPercent(usage.data.memory), usage.data.memory],
|
|
16
|
-
["HardDrive", formatPercent(usage.data.disk), usage.data.disk],
|
|
17
|
-
["Gauge", formatRate(usage.data.io), null],
|
|
21
|
+
["Cpu", "CPU", formatPercent(usage.data.cpu), usage.data.cpu],
|
|
22
|
+
["MemoryStick", "Memory", formatPercent(usage.data.memory), usage.data.memory],
|
|
23
|
+
["HardDrive", "Disk", formatPercent(usage.data.disk), usage.data.disk],
|
|
24
|
+
["Gauge", "Disk I/O", formatRate(usage.data.io), null],
|
|
18
25
|
] as const);
|
|
19
26
|
|
|
20
27
|
return (
|
|
@@ -29,15 +36,25 @@ export function TopFooter({ theme, host, currentScreen, openScreen }: PluginSide
|
|
|
29
36
|
<Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>unavailable</Text>
|
|
30
37
|
)}
|
|
31
38
|
{heavy > 0 && (
|
|
32
|
-
<View
|
|
39
|
+
<View
|
|
40
|
+
accessible
|
|
41
|
+
accessibilityLabel={`${heavy} heavy ${heavy === 1 ? "session" : "sessions"}`}
|
|
42
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 2 }}
|
|
43
|
+
>
|
|
33
44
|
<Icon name="Flame" size={11} color={theme.colors.statusDanger} />
|
|
34
45
|
<Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>{heavy}</Text>
|
|
35
46
|
</View>
|
|
36
47
|
)}
|
|
37
|
-
{metrics?.map(([icon, text, percent]) => {
|
|
48
|
+
{metrics?.map(([icon, name, text, percent]) => {
|
|
38
49
|
const color = levelColor(theme, percent);
|
|
50
|
+
const level = loadLevel(percent);
|
|
39
51
|
return (
|
|
40
|
-
<View
|
|
52
|
+
<View
|
|
53
|
+
key={icon}
|
|
54
|
+
accessible
|
|
55
|
+
accessibilityLabel={metricLabel(name, text, level)}
|
|
56
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 2 }}
|
|
57
|
+
>
|
|
41
58
|
<Icon name={icon} size={11} color={color} />
|
|
42
59
|
<Text
|
|
43
60
|
numberOfLines={1}
|
package/client/gauge.tsx
CHANGED
|
@@ -31,12 +31,14 @@ export function Dial({ theme, size, icon, label, value, text }: DialProps) {
|
|
|
31
31
|
const needleLength = radius * 0.72;
|
|
32
32
|
const degrees = useRef(new Animated.Value(needleDegrees(value))).current;
|
|
33
33
|
useEffect(() => {
|
|
34
|
-
Animated.timing(degrees, {
|
|
34
|
+
const animation = Animated.timing(degrees, {
|
|
35
35
|
toValue: needleDegrees(value),
|
|
36
36
|
duration: 700,
|
|
37
37
|
easing: Easing.out(Easing.cubic),
|
|
38
38
|
useNativeDriver: false,
|
|
39
|
-
})
|
|
39
|
+
});
|
|
40
|
+
animation.start();
|
|
41
|
+
return () => animation.stop();
|
|
40
42
|
}, [degrees, value]);
|
|
41
43
|
const rotate = degrees.interpolate({
|
|
42
44
|
inputRange: [-SWEEP / 2, SWEEP / 2],
|
package/client/screen.tsx
CHANGED
|
@@ -68,6 +68,10 @@ export function TopScreen({ theme, layout, host, navigation }: PluginScreenProps
|
|
|
68
68
|
{host.label}
|
|
69
69
|
</Text>
|
|
70
70
|
<View
|
|
71
|
+
accessible
|
|
72
|
+
accessibilityLabel={
|
|
73
|
+
usage.isError ? "Usage unavailable" : usage.data ? "Usage live" : "Usage loading"
|
|
74
|
+
}
|
|
71
75
|
style={{
|
|
72
76
|
width: 8,
|
|
73
77
|
height: 8,
|
package/index.server.ts
CHANGED
|
@@ -11,5 +11,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
11
11
|
if (!usage.sessions) return usage;
|
|
12
12
|
return { ...usage, ...(await ownSessions(paseo, usage.sessions, usage.other)) };
|
|
13
13
|
});
|
|
14
|
-
return
|
|
14
|
+
return () => {
|
|
15
|
+
sampler.stop();
|
|
16
|
+
ownSessions.stop();
|
|
17
|
+
};
|
|
15
18
|
}
|
package/package.json
CHANGED
package/paseo-plugin.json
CHANGED
package/server/names.ts
CHANGED
|
@@ -7,6 +7,8 @@ const FULL_REFRESH_MS = 30_000;
|
|
|
7
7
|
const EARLY_REFRESH_MS = 5000;
|
|
8
8
|
/** Only the first ever fetch is waited for, and not forever. */
|
|
9
9
|
const FIRST_FETCH_TIMEOUT_MS = 3000;
|
|
10
|
+
/** A read that has not settled by now is abandoned and cancelled, so a hung daemon cannot block later reads. */
|
|
11
|
+
const REFRESH_LIFETIME_MS = 20_000;
|
|
10
12
|
const PAGE_SIZE = 200;
|
|
11
13
|
export const MAX_PAGES = 25;
|
|
12
14
|
export const MAX_SESSIONS = 10;
|
|
@@ -26,12 +28,23 @@ interface Page<T> {
|
|
|
26
28
|
pageInfo: { hasMore: boolean; nextCursor: string | null };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The daemon pages its lists, and an agent left off the first page must still be recognised. Aborting
|
|
33
|
+
* ends the read even when the daemon never answers the page in flight, which is then ignored.
|
|
34
|
+
*/
|
|
35
|
+
async function collect<T>(
|
|
36
|
+
signal: AbortSignal,
|
|
37
|
+
fetchPage: (cursor: string | undefined) => Promise<Page<T>>,
|
|
38
|
+
) {
|
|
39
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
40
|
+
const cancel = () => reject(new Error("Daemon read cancelled"));
|
|
41
|
+
if (signal.aborted) cancel();
|
|
42
|
+
else signal.addEventListener("abort", cancel, { once: true });
|
|
43
|
+
});
|
|
31
44
|
const entries: T[] = [];
|
|
32
45
|
let cursor: string | undefined;
|
|
33
46
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
34
|
-
const result = await fetchPage(cursor);
|
|
47
|
+
const result = await Promise.race([fetchPage(cursor), aborted]);
|
|
35
48
|
entries.push(...result.entries);
|
|
36
49
|
if (!result.pageInfo.hasMore || !result.pageInfo.nextCursor) break;
|
|
37
50
|
cursor = result.pageInfo.nextCursor;
|
|
@@ -40,7 +53,7 @@ async function collect<T>(fetchPage: (cursor: string | undefined) => Promise<Pag
|
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
/**
|
|
43
|
-
* The process scan sees every process
|
|
56
|
+
* The process scan sees every readable process /proc lists, including agents that belong to other
|
|
44
57
|
* daemons on the same machine. This keeps only the sessions of agents this daemon knows, names them
|
|
45
58
|
* with the user's own agent and workspace names, and folds everything else into `other` so the
|
|
46
59
|
* numbers still add up to the processes that were scanned.
|
|
@@ -48,34 +61,48 @@ async function collect<T>(fetchPage: (cursor: string | undefined) => Promise<Pag
|
|
|
48
61
|
* The daemon's lists are read in the background and shared between callers, so a slow daemon never
|
|
49
62
|
* holds up the readings. Only the very first read is waited for, for a few seconds at most. Until
|
|
50
63
|
* 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.
|
|
64
|
+
* all of its load is reported as other load. Once read, a failed refresh keeps the last lists. A read
|
|
65
|
+
* that outlives `REFRESH_LIFETIME_MS` is cancelled and replaced by the next one, and whatever the
|
|
66
|
+
* abandoned read later returns is discarded. `stop` cancels the read in flight.
|
|
52
67
|
*/
|
|
53
68
|
export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETCH_TIMEOUT_MS) {
|
|
54
69
|
let known: Known | null = null;
|
|
55
70
|
/** Agents confirmed absent from the latest read, so they do not trigger another one. */
|
|
56
71
|
let foreign = new Set<string>();
|
|
57
72
|
let fetchedAt = Number.NEGATIVE_INFINITY;
|
|
58
|
-
let inflight: Promise<void
|
|
73
|
+
let inflight: { done: Promise<void>; startedAt: number; abort: AbortController } | null = null;
|
|
59
74
|
|
|
60
75
|
const refresh = (paseo: PaseoApi, candidates: string[]) => {
|
|
61
|
-
if (inflight)
|
|
76
|
+
if (inflight && now() - inflight.startedAt >= REFRESH_LIFETIME_MS) {
|
|
77
|
+
inflight.abort.abort();
|
|
78
|
+
inflight = null;
|
|
79
|
+
}
|
|
80
|
+
if (inflight) return inflight.done;
|
|
62
81
|
fetchedAt = now();
|
|
63
|
-
|
|
82
|
+
const abort = new AbortController();
|
|
83
|
+
const { signal } = abort;
|
|
84
|
+
const run = { done: Promise.resolve(), startedAt: fetchedAt, abort };
|
|
85
|
+
inflight = run;
|
|
86
|
+
run.done = (async () => {
|
|
64
87
|
try {
|
|
65
88
|
const [agents, workspaces] = await Promise.all([
|
|
66
|
-
collect((cursor) =>
|
|
89
|
+
collect(signal, (cursor) =>
|
|
67
90
|
paseo.agents.list({
|
|
68
91
|
filter: { includeArchived: true },
|
|
69
92
|
// Oldest first: an agent's creation time never changes, so paging cannot skip one that
|
|
70
93
|
// is busy and being updated while the pages are read.
|
|
71
94
|
sort: [{ key: "created_at", direction: "asc" }],
|
|
72
95
|
page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) },
|
|
96
|
+
signal,
|
|
73
97
|
}),
|
|
74
98
|
),
|
|
75
|
-
|
|
99
|
+
// The SDK's workspace list takes no signal; the abort stops the paging between pages.
|
|
100
|
+
collect(signal, (cursor) =>
|
|
76
101
|
paseo.workspaces.list({ page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) } }),
|
|
77
102
|
),
|
|
78
103
|
]);
|
|
104
|
+
// An abandoned read must not overwrite what a newer one stored.
|
|
105
|
+
if (inflight !== run) return;
|
|
79
106
|
const next: Known = {
|
|
80
107
|
agents: new Map(
|
|
81
108
|
agents.map(({ agent }) => [
|
|
@@ -91,14 +118,14 @@ export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETC
|
|
|
91
118
|
foreign = new Set(candidates.filter((id) => !next.agents.has(id)));
|
|
92
119
|
} catch {
|
|
93
120
|
// Keep the previous lists.
|
|
121
|
+
} finally {
|
|
122
|
+
if (inflight === run) inflight = null;
|
|
94
123
|
}
|
|
95
|
-
})()
|
|
96
|
-
|
|
97
|
-
});
|
|
98
|
-
return inflight;
|
|
124
|
+
})();
|
|
125
|
+
return run.done;
|
|
99
126
|
};
|
|
100
127
|
|
|
101
|
-
|
|
128
|
+
const ownership = async (paseo: PaseoApi, sessions: Session[], other: Load) => {
|
|
102
129
|
if (sessions.length === 0) return { sessions, other };
|
|
103
130
|
|
|
104
131
|
const ids = sessions.map((session) => session.agentId);
|
|
@@ -107,7 +134,7 @@ export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETC
|
|
|
107
134
|
const pending =
|
|
108
135
|
age >= FULL_REFRESH_MS || (unseen && age >= EARLY_REFRESH_MS)
|
|
109
136
|
? refresh(paseo, ids)
|
|
110
|
-
: inflight;
|
|
137
|
+
: inflight?.done;
|
|
111
138
|
if (known === null && pending) {
|
|
112
139
|
await new Promise<void>((resolve) => {
|
|
113
140
|
const timer = setTimeout(resolve, firstFetchTimeoutMs);
|
|
@@ -140,4 +167,11 @@ export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETC
|
|
|
140
167
|
}
|
|
141
168
|
return { sessions: own.slice(0, MAX_SESSIONS), other: rest };
|
|
142
169
|
};
|
|
170
|
+
|
|
171
|
+
return Object.assign(ownership, {
|
|
172
|
+
stop: () => {
|
|
173
|
+
inflight?.abort.abort();
|
|
174
|
+
inflight = null;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
143
177
|
}
|
package/server/processes.ts
CHANGED
|
@@ -68,7 +68,7 @@ export function parseStat(text: string): Stat | null {
|
|
|
68
68
|
return { comm: text.slice(open + 1, close), ...numbers, rss: Math.max(0, numbers.rss) };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/** The whole environment is read, but only the agent id is kept; the rest can hold secrets and is dropped. */
|
|
72
72
|
export function parseAgentId(environ: string): string | null {
|
|
73
73
|
for (const entry of environ.split("\0")) {
|
|
74
74
|
if (entry.startsWith("PASEO_AGENT_ID=")) return entry.slice("PASEO_AGENT_ID=".length) || null;
|
|
@@ -77,34 +77,29 @@ export function parseAgentId(environ: string): string | null {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* Every process
|
|
81
|
-
*
|
|
80
|
+
* Every process listed under /proc, which includes other users' that are readable. The environment
|
|
81
|
+
* is read whole but only `PASEO_AGENT_ID` is kept, and it is read again on every scan, because a
|
|
82
|
+
* process can replace its environment when it execs without changing its pid or start time. A
|
|
83
|
+
* process whose environment cannot be read (another user's) has no agent.
|
|
82
84
|
*/
|
|
83
|
-
export function readProcs(
|
|
85
|
+
export function readProcs(root = "/proc"): ProcInfo[] {
|
|
84
86
|
const procs: ProcInfo[] = [];
|
|
85
|
-
const seen = new Set<string>();
|
|
86
87
|
for (const name of readdirSync(root)) {
|
|
87
88
|
if (!/^\d+$/.test(name)) continue;
|
|
88
89
|
try {
|
|
89
90
|
const stat = parseStat(readFileSync(`${root}/${name}/stat`, "utf8"));
|
|
90
91
|
if (!stat) continue;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
agentId = parseAgentId(readFileSync(`${root}/${name}/environ`, "utf8"));
|
|
97
|
-
} catch {
|
|
98
|
-
agentId = null; // another user's process
|
|
99
|
-
}
|
|
100
|
-
agentIds.set(key, agentId);
|
|
92
|
+
let agentId: string | null;
|
|
93
|
+
try {
|
|
94
|
+
agentId = parseAgentId(readFileSync(`${root}/${name}/environ`, "utf8"));
|
|
95
|
+
} catch {
|
|
96
|
+
agentId = null;
|
|
101
97
|
}
|
|
102
|
-
procs.push({ key
|
|
98
|
+
procs.push({ key: `${name}:${stat.start}`, pid: Number(name), agentId, ...stat });
|
|
103
99
|
} catch {
|
|
104
100
|
// The process exited while it was being read.
|
|
105
101
|
}
|
|
106
102
|
}
|
|
107
|
-
for (const key of agentIds.keys()) if (!seen.has(key)) agentIds.delete(key);
|
|
108
103
|
return procs;
|
|
109
104
|
}
|
|
110
105
|
|
package/server/usage.ts
CHANGED
|
@@ -18,10 +18,11 @@ export function readCpuTimes(cpus: ReadonlyArray<Pick<os.CpuInfo, "times">> = os
|
|
|
18
18
|
return { idle, total };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** Busy share between two readings, or null when no time elapsed
|
|
21
|
+
/** Busy share between two readings, or null when no time elapsed, the counters went backwards or are not numbers. */
|
|
22
22
|
export function cpuPercent(previous: CpuTimes, current: CpuTimes): number | null {
|
|
23
23
|
const total = current.total - previous.total;
|
|
24
24
|
const idle = current.idle - previous.idle;
|
|
25
|
+
if (!Number.isFinite(total) || !Number.isFinite(idle)) return null;
|
|
25
26
|
if (total <= 0 || idle < 0 || idle > total) return null;
|
|
26
27
|
return (1 - idle / total) * 100;
|
|
27
28
|
}
|
|
@@ -35,13 +36,18 @@ export function diskPercent(
|
|
|
35
36
|
const { blocks, bfree, bavail } = stat(path);
|
|
36
37
|
const used = blocks - bfree;
|
|
37
38
|
const usable = used + bavail;
|
|
38
|
-
|
|
39
|
+
const percent = (used / usable) * 100;
|
|
40
|
+
return usable > 0 && Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : null;
|
|
39
41
|
} catch {
|
|
40
42
|
return null;
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
/**
|
|
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
|
+
*/
|
|
45
51
|
export function readDiskBytes(
|
|
46
52
|
sysBlock = "/sys/block",
|
|
47
53
|
diskstats = "/proc/diskstats",
|
|
@@ -52,31 +58,53 @@ export function readDiskBytes(
|
|
|
52
58
|
readdirSync(sysBlock).filter((name) => !/^(loop|ram|dm-|zram|md)/.test(name)),
|
|
53
59
|
);
|
|
54
60
|
let sectors = 0;
|
|
61
|
+
let matched = false;
|
|
55
62
|
for (const line of readFileSync(diskstats, "utf8").split("\n")) {
|
|
56
63
|
const fields = line.trim().split(/\s+/);
|
|
57
|
-
if (disks.has(fields[2] ?? ""))
|
|
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;
|
|
58
72
|
}
|
|
59
|
-
return sectors * 512;
|
|
73
|
+
return matched && Number.isFinite(sectors) ? sectors * 512 : null;
|
|
60
74
|
} catch {
|
|
61
75
|
return null;
|
|
62
76
|
}
|
|
63
77
|
}
|
|
64
78
|
|
|
65
|
-
/** Bytes per second between two readings, or null when
|
|
79
|
+
/** Bytes per second between two readings, or null when a counter is missing, not finite, or went backwards. */
|
|
66
80
|
export function bytesPerSecond(
|
|
67
81
|
previous: number | null,
|
|
68
82
|
current: number | null,
|
|
69
83
|
elapsedMs: number,
|
|
70
84
|
): number | null {
|
|
71
|
-
if (previous === null || current === null
|
|
72
|
-
|
|
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));
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
interface SamplerOptions {
|
|
76
104
|
intervalMs?: number;
|
|
77
105
|
readCpu?: () => CpuTimes;
|
|
78
106
|
readIo?: () => number | null;
|
|
79
|
-
readMemory?: () => number;
|
|
107
|
+
readMemory?: () => number | null;
|
|
80
108
|
readDisk?: () => number | null;
|
|
81
109
|
/** Lists processes, or returns null where that is not possible. */
|
|
82
110
|
scan?: () => ProcInfo[] | null;
|
|
@@ -92,8 +120,7 @@ export function startSampler({
|
|
|
92
120
|
intervalMs = 2000,
|
|
93
121
|
readCpu = readCpuTimes,
|
|
94
122
|
readIo = readDiskBytes,
|
|
95
|
-
|
|
96
|
-
readMemory = () => (1 - os.freemem() / os.totalmem()) * 100,
|
|
123
|
+
readMemory = memoryPercent,
|
|
97
124
|
readDisk = diskPercent,
|
|
98
125
|
scan = defaultScan(),
|
|
99
126
|
totalMemory = os.totalmem,
|
|
@@ -136,12 +163,11 @@ export function startSampler({
|
|
|
136
163
|
};
|
|
137
164
|
}
|
|
138
165
|
|
|
139
|
-
/** Reads /proc;
|
|
166
|
+
/** Reads /proc; returns null for a scan where it cannot be listed. */
|
|
140
167
|
function defaultScan(): () => ProcInfo[] | null {
|
|
141
|
-
const agentIds = new Map<string, string | null>();
|
|
142
168
|
return () => {
|
|
143
169
|
try {
|
|
144
|
-
return readProcs(
|
|
170
|
+
return readProcs();
|
|
145
171
|
} catch {
|
|
146
172
|
return null;
|
|
147
173
|
}
|
package/shared/usage.ts
CHANGED
|
@@ -10,7 +10,7 @@ const percent = z.number().min(0).max(100);
|
|
|
10
10
|
/** Usage of the daemon host. Percentages are 0-100; `null` means the host cannot report it yet. */
|
|
11
11
|
const sample = z.object({
|
|
12
12
|
cpu: percent.nullable(),
|
|
13
|
-
memory: percent,
|
|
13
|
+
memory: percent.nullable(),
|
|
14
14
|
disk: percent.nullable(),
|
|
15
15
|
/** Disk read plus write throughput in bytes per second. */
|
|
16
16
|
io: z.number().min(0).nullable(),
|