paseo-plugin-machine-status 0.1.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/README.md +367 -0
- package/client/charts.tsx +335 -0
- package/client/cleanup.tsx +169 -0
- package/client/docker.tsx +494 -0
- package/client/menu.tsx +181 -0
- package/client/pill-card.tsx +195 -0
- package/client/pill.tsx +155 -0
- package/client/processes.tsx +321 -0
- package/client/services.tsx +251 -0
- package/client/status.tsx +514 -0
- package/client/ui.tsx +263 -0
- package/index.client.tsx +31 -0
- package/index.server.ts +41 -0
- package/package.json +45 -0
- package/paseo-plugin.json +4 -0
- package/server/cleanup.ts +222 -0
- package/server/docker.ts +260 -0
- package/server/exec.ts +62 -0
- package/server/explain-rules.ts +188 -0
- package/server/explain.ts +95 -0
- package/server/metrics.ts +312 -0
- package/server/processes.ts +165 -0
- package/server/services.ts +428 -0
- package/shared/contracts.ts +170 -0
- package/shared/docker.ts +95 -0
- package/shared/explain.ts +24 -0
- package/shared/format.ts +39 -0
- package/shared/pill.ts +63 -0
- package/shared/services-classify.ts +190 -0
- package/shared/services.ts +52 -0
- package/shared/viz.ts +159 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import { describeError, run, tryRun } from "./exec";
|
|
6
|
+
import { ancestorsOf, parsePsTable, type RawProcess } from "./processes";
|
|
7
|
+
import {
|
|
8
|
+
classifyRelevance,
|
|
9
|
+
friendlyName,
|
|
10
|
+
httpUrlFor,
|
|
11
|
+
normalizeAgents,
|
|
12
|
+
parseLsofAddress,
|
|
13
|
+
parseNetstatAddress,
|
|
14
|
+
portKey,
|
|
15
|
+
scopeOf,
|
|
16
|
+
} from "../shared/services-classify";
|
|
17
|
+
import type { PortRow, ServiceRow, readServices } from "../shared/services";
|
|
18
|
+
|
|
19
|
+
type Output = z.output<typeof readServices.output>;
|
|
20
|
+
|
|
21
|
+
/* -------------------------------------------------------------------------- */
|
|
22
|
+
/* Parsers */
|
|
23
|
+
/* -------------------------------------------------------------------------- */
|
|
24
|
+
|
|
25
|
+
export interface Listener {
|
|
26
|
+
protocol: "tcp" | "udp";
|
|
27
|
+
address: string;
|
|
28
|
+
port: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* netstat sees every listener on the machine, including other users'. lsof sees
|
|
33
|
+
* only what this user may inspect. netstat therefore defines the list and lsof
|
|
34
|
+
* fills in ownership, so a root-owned port is reported as unattributable rather
|
|
35
|
+
* than omitted.
|
|
36
|
+
*/
|
|
37
|
+
export function parseNetstatListeners(raw: string): Listener[] {
|
|
38
|
+
const listeners: Listener[] = [];
|
|
39
|
+
for (const line of raw.split("\n")) {
|
|
40
|
+
const match = line.match(/^(tcp|udp)\S*\s+\d+\s+\d+\s+(\S+)\s+(\S+)\s*(\S*)\s*$/);
|
|
41
|
+
if (!match) continue;
|
|
42
|
+
const [, protocol, local, foreign, state] = match;
|
|
43
|
+
if (protocol === "tcp" && state !== "LISTEN") continue;
|
|
44
|
+
if (protocol === "udp" && foreign !== "*.*") continue;
|
|
45
|
+
const parsed = parseNetstatAddress(local);
|
|
46
|
+
if (!parsed) continue;
|
|
47
|
+
listeners.push({ protocol: protocol as "tcp" | "udp", ...parsed });
|
|
48
|
+
}
|
|
49
|
+
return listeners;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LsofSocket extends Listener {
|
|
53
|
+
pid: number;
|
|
54
|
+
command: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reads `lsof -F` field output. Fields arrive as a stream: `p` opens a process,
|
|
59
|
+
* `c` names it, and every following `n` is one socket belonging to it.
|
|
60
|
+
* `+c 0` is required, or commands are truncated to nine characters.
|
|
61
|
+
*/
|
|
62
|
+
export function parseLsofSockets(raw: string, protocol: "tcp" | "udp"): LsofSocket[] {
|
|
63
|
+
const sockets: LsofSocket[] = [];
|
|
64
|
+
let pid: number | null = null;
|
|
65
|
+
let command = "";
|
|
66
|
+
|
|
67
|
+
for (const line of raw.split("\n")) {
|
|
68
|
+
const tag = line[0];
|
|
69
|
+
const value = line.slice(1);
|
|
70
|
+
if (tag === "p") {
|
|
71
|
+
pid = Number(value);
|
|
72
|
+
command = "";
|
|
73
|
+
} else if (tag === "c") {
|
|
74
|
+
command = value;
|
|
75
|
+
} else if (tag === "n" && pid !== null) {
|
|
76
|
+
const parsed = parseLsofAddress(value);
|
|
77
|
+
if (parsed) sockets.push({ protocol, pid, command, ...parsed });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return sockets;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DockerContainer {
|
|
84
|
+
name: string;
|
|
85
|
+
image: string;
|
|
86
|
+
ports: number[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseDockerPs(raw: string): DockerContainer[] {
|
|
90
|
+
const containers: DockerContainer[] = [];
|
|
91
|
+
for (const line of raw.split("\n")) {
|
|
92
|
+
const [name, image, ports] = line.split("\t");
|
|
93
|
+
if (!name || !image) continue;
|
|
94
|
+
const published = new Set<number>();
|
|
95
|
+
// "0.0.0.0:5653->5432/tcp, [::]:5653->5432/tcp" — only the published side matters.
|
|
96
|
+
for (const match of (ports ?? "").matchAll(/:(\d+)->/g)) published.add(Number(match[1]));
|
|
97
|
+
containers.push({ name, image, ports: [...published].sort((a, b) => a - b) });
|
|
98
|
+
}
|
|
99
|
+
return containers;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* -------------------------------------------------------------------------- */
|
|
103
|
+
/* Process-tree attribution */
|
|
104
|
+
/* -------------------------------------------------------------------------- */
|
|
105
|
+
|
|
106
|
+
/** Every process below `pid`, excluding `pid` itself. */
|
|
107
|
+
export function descendantsOf(pid: number, table: readonly RawProcess[]): RawProcess[] {
|
|
108
|
+
const children = new Map<number, RawProcess[]>();
|
|
109
|
+
for (const entry of table) {
|
|
110
|
+
const bucket = children.get(entry.ppid);
|
|
111
|
+
if (bucket) bucket.push(entry);
|
|
112
|
+
else children.set(entry.ppid, [entry]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const found: RawProcess[] = [];
|
|
116
|
+
const seen = new Set<number>([pid]);
|
|
117
|
+
const queue = [pid];
|
|
118
|
+
while (queue.length > 0) {
|
|
119
|
+
for (const child of children.get(queue.shift()!) ?? []) {
|
|
120
|
+
if (seen.has(child.pid)) continue;
|
|
121
|
+
seen.add(child.pid);
|
|
122
|
+
found.push(child);
|
|
123
|
+
queue.push(child.pid);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A provider name must match the binary as a whole word, not as a prefix.
|
|
131
|
+
* `ps` on this machine contains AMPDeviceDiscoveryAgent, Campo and
|
|
132
|
+
* CursorUIViewService, all of which a substring test happily reports as the
|
|
133
|
+
* "amp" and "cursor" agents.
|
|
134
|
+
*/
|
|
135
|
+
export function binaryMatchesProvider(binary: string, provider: string): boolean {
|
|
136
|
+
if (provider.length === 0) return false;
|
|
137
|
+
if (binary === provider) return true;
|
|
138
|
+
if (!binary.startsWith(provider)) return false;
|
|
139
|
+
return /^[^a-z0-9]/.test(binary.slice(provider.length));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface AgentRoot {
|
|
143
|
+
pid: number;
|
|
144
|
+
provider: string;
|
|
145
|
+
title: string;
|
|
146
|
+
cwd: string;
|
|
147
|
+
command: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Identifies which daemon children are coding agents.
|
|
152
|
+
*
|
|
153
|
+
* Matching on the command name alone does not work: a substring search for
|
|
154
|
+
* "amp" or "cursor" hits AMPDeviceDiscoveryAgent and CursorUIViewService. The
|
|
155
|
+
* reliable signal is structural — Paseo starts each agent as its own child — and
|
|
156
|
+
* it is confirmed against the daemon's own agent list, so anything else under the
|
|
157
|
+
* daemon (esbuild, Paseo's internal helpers, this plugin) is left out.
|
|
158
|
+
*/
|
|
159
|
+
export function matchAgentRoots(
|
|
160
|
+
daemonChildren: readonly (RawProcess & { cwd: string })[],
|
|
161
|
+
agents: readonly { id: string; provider: string; cwd: string; title: string }[],
|
|
162
|
+
excluded: ReadonlySet<number>,
|
|
163
|
+
): AgentRoot[] {
|
|
164
|
+
const roots: AgentRoot[] = [];
|
|
165
|
+
const claimed = new Set<string>();
|
|
166
|
+
|
|
167
|
+
for (const child of daemonChildren) {
|
|
168
|
+
if (excluded.has(child.pid)) continue;
|
|
169
|
+
const binary = (child.command.split("/").pop() ?? "").toLowerCase();
|
|
170
|
+
|
|
171
|
+
const agent = agents.find(
|
|
172
|
+
(candidate) =>
|
|
173
|
+
!claimed.has(candidate.id) &&
|
|
174
|
+
candidate.provider.length > 0 &&
|
|
175
|
+
binaryMatchesProvider(binary, candidate.provider) &&
|
|
176
|
+
// cwd disambiguates two agents of the same provider in different workspaces
|
|
177
|
+
(candidate.cwd === "" || child.cwd === "" || candidate.cwd === child.cwd),
|
|
178
|
+
);
|
|
179
|
+
if (!agent) continue;
|
|
180
|
+
|
|
181
|
+
claimed.add(agent.id);
|
|
182
|
+
roots.push({
|
|
183
|
+
pid: child.pid,
|
|
184
|
+
provider: agent.provider,
|
|
185
|
+
title: agent.title,
|
|
186
|
+
cwd: child.cwd || agent.cwd,
|
|
187
|
+
command: child.command,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return roots;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const SCOPE_WIDTH = { public: 3, lan: 2, local: 1 } as const;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One process bound to one port on both IPv4 and IPv6 is one service, and
|
|
197
|
+
* `kubectl port-forward` produces exactly that. Rows are collapsed per process,
|
|
198
|
+
* keeping the widest bind, because that is the one that decides reachability.
|
|
199
|
+
* Sockets with no visible owner stay separate: without a PID there is no evidence
|
|
200
|
+
* they are the same thing.
|
|
201
|
+
*/
|
|
202
|
+
export function collapseDuplicateBinds<T extends { protocol: string; port: number; pid: number | null; address: string; scope: "public" | "lan" | "local" }>(
|
|
203
|
+
rows: readonly T[],
|
|
204
|
+
): T[] {
|
|
205
|
+
const widest = new Map<string, T>();
|
|
206
|
+
for (const row of rows) {
|
|
207
|
+
const key = `${row.protocol}:${row.port}:${row.pid ?? `@${row.address}`}`;
|
|
208
|
+
const current = widest.get(key);
|
|
209
|
+
if (!current) {
|
|
210
|
+
widest.set(key, row);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const better =
|
|
214
|
+
SCOPE_WIDTH[row.scope] > SCOPE_WIDTH[current.scope] ||
|
|
215
|
+
(SCOPE_WIDTH[row.scope] === SCOPE_WIDTH[current.scope] &&
|
|
216
|
+
!row.address.includes(":") &&
|
|
217
|
+
current.address.includes(":"));
|
|
218
|
+
if (better) widest.set(key, row);
|
|
219
|
+
}
|
|
220
|
+
return [...widest.values()];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* -------------------------------------------------------------------------- */
|
|
224
|
+
/* Collection */
|
|
225
|
+
/* -------------------------------------------------------------------------- */
|
|
226
|
+
|
|
227
|
+
async function cwdOf(pid: number): Promise<string> {
|
|
228
|
+
const raw = await tryRun("/usr/sbin/lsof", ["-a", "-d", "cwd", "-p", String(pid), "-Fn"], 3_000);
|
|
229
|
+
const line = raw?.split("\n").find((entry) => entry.startsWith("n"));
|
|
230
|
+
return line ? line.slice(1) : "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function readServicesHandler(
|
|
234
|
+
_input: unknown,
|
|
235
|
+
context: PluginHandlerContext,
|
|
236
|
+
): Promise<Output> {
|
|
237
|
+
if (process.platform !== "darwin") {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`machine-status inspects ports through macOS tools; this daemon runs on ${process.platform}.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const notes: string[] = [];
|
|
244
|
+
|
|
245
|
+
const [netstatTcp, netstatUdp, lsofTcp, lsofUdp, psRaw, dockerRaw] = await Promise.all([
|
|
246
|
+
tryRun("/usr/sbin/netstat", ["-an", "-p", "tcp"], 8_000),
|
|
247
|
+
tryRun("/usr/sbin/netstat", ["-an", "-p", "udp"], 8_000),
|
|
248
|
+
tryRun("/usr/sbin/lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "+c", "0", "-Fpcn"], 8_000),
|
|
249
|
+
tryRun("/usr/sbin/lsof", ["-iUDP", "-P", "-n", "+c", "0", "-Fpcn"], 8_000),
|
|
250
|
+
run("/bin/ps", ["-Ao", "pid=,ppid=,pcpu=,rss=,comm="], 10_000),
|
|
251
|
+
tryRun("/usr/local/bin/docker", ["ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Ports}}"], 4_000),
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
const table = parsePsTable(psRaw);
|
|
255
|
+
// lsof's command column is a bare name; `ps` has the full path, and the path is
|
|
256
|
+
// what separates /System/Library/... from ~/.venv/bin/python.
|
|
257
|
+
const commandByPid = new Map(table.map((entry) => [entry.pid, entry.command]));
|
|
258
|
+
const home = os.homedir();
|
|
259
|
+
const sockets = [
|
|
260
|
+
...parseLsofSockets(lsofTcp ?? "", "tcp"),
|
|
261
|
+
...parseLsofSockets(lsofUdp ?? "", "udp"),
|
|
262
|
+
];
|
|
263
|
+
const byKey = new Map(sockets.map((socket) => [portKey(socket.protocol, socket.address, socket.port), socket]));
|
|
264
|
+
|
|
265
|
+
// --- agent roots -----------------------------------------------------------
|
|
266
|
+
const daemonPid = process.ppid;
|
|
267
|
+
const ownChain = ancestorsOf(process.pid, table);
|
|
268
|
+
|
|
269
|
+
let agents: ReturnType<typeof normalizeAgents> = [];
|
|
270
|
+
try {
|
|
271
|
+
agents = normalizeAgents(await context.paseo.agents.list());
|
|
272
|
+
} catch (error) {
|
|
273
|
+
notes.push(`Agent list unavailable, so ports are not attributed to agents: ${describeError(error)}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const children = table.filter((entry) => entry.ppid === daemonPid);
|
|
277
|
+
const withCwd = await Promise.all(
|
|
278
|
+
children.map(async (child) => ({ ...child, cwd: await cwdOf(child.pid) })),
|
|
279
|
+
);
|
|
280
|
+
const roots = matchAgentRoots(withCwd, agents, ownChain);
|
|
281
|
+
|
|
282
|
+
/** pid -> the agent it ultimately belongs to. */
|
|
283
|
+
const ownerByPid = new Map<number, AgentRoot>();
|
|
284
|
+
const childrenByRoot = new Map<number, RawProcess[]>();
|
|
285
|
+
for (const root of roots) {
|
|
286
|
+
ownerByPid.set(root.pid, root);
|
|
287
|
+
const descendants = descendantsOf(root.pid, table);
|
|
288
|
+
childrenByRoot.set(root.pid, descendants);
|
|
289
|
+
for (const descendant of descendants) ownerByPid.set(descendant.pid, root);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const containers = parseDockerPs(dockerRaw ?? "");
|
|
293
|
+
const dockerByPort = new Map<number, DockerContainer>();
|
|
294
|
+
for (const container of containers) {
|
|
295
|
+
for (const port of container.ports) dockerByPort.set(port, container);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- ports -----------------------------------------------------------------
|
|
299
|
+
const listeners = [
|
|
300
|
+
...parseNetstatListeners(netstatTcp ?? ""),
|
|
301
|
+
...parseNetstatListeners(netstatUdp ?? ""),
|
|
302
|
+
];
|
|
303
|
+
const seen = new Set<string>();
|
|
304
|
+
const ports: PortRow[] = [];
|
|
305
|
+
let hidden = 0;
|
|
306
|
+
|
|
307
|
+
for (const listener of listeners) {
|
|
308
|
+
const key = portKey(listener.protocol, listener.address, listener.port);
|
|
309
|
+
if (seen.has(key)) continue;
|
|
310
|
+
seen.add(key);
|
|
311
|
+
|
|
312
|
+
const socket = byKey.get(key) ?? null;
|
|
313
|
+
const scope = scopeOf(listener.address);
|
|
314
|
+
const owner = socket ? ("you" as const) : ("other" as const);
|
|
315
|
+
if (!socket) hidden += 1;
|
|
316
|
+
|
|
317
|
+
const agent = socket ? ownerByPid.get(socket.pid) : undefined;
|
|
318
|
+
const container = dockerByPort.get(listener.port);
|
|
319
|
+
|
|
320
|
+
const attribution = agent
|
|
321
|
+
? {
|
|
322
|
+
kind: (agent.pid === socket?.pid ? "agent" : "agent-child") as "agent" | "agent-child",
|
|
323
|
+
label: agent.title || agent.provider,
|
|
324
|
+
detail:
|
|
325
|
+
agent.pid === socket?.pid
|
|
326
|
+
? `${agent.provider} agent`
|
|
327
|
+
: `started by the ${agent.provider} agent in ${path.basename(agent.cwd) || agent.cwd}`,
|
|
328
|
+
}
|
|
329
|
+
: container
|
|
330
|
+
? { kind: "docker" as const, label: container.name, detail: container.image }
|
|
331
|
+
: socket
|
|
332
|
+
? { kind: "process" as const, label: friendlyName(socket.command), detail: socket.command }
|
|
333
|
+
: null;
|
|
334
|
+
|
|
335
|
+
const resolvedCommand =
|
|
336
|
+
(socket ? commandByPid.get(socket.pid) : undefined) ?? socket?.command ?? "";
|
|
337
|
+
|
|
338
|
+
ports.push({
|
|
339
|
+
id: key,
|
|
340
|
+
port: listener.port,
|
|
341
|
+
protocol: listener.protocol,
|
|
342
|
+
address: listener.address,
|
|
343
|
+
scope,
|
|
344
|
+
pid: socket?.pid ?? null,
|
|
345
|
+
command: socket?.command ?? null,
|
|
346
|
+
owner,
|
|
347
|
+
relevance: classifyRelevance({
|
|
348
|
+
kind: attribution?.kind ?? null,
|
|
349
|
+
command: resolvedCommand,
|
|
350
|
+
owner,
|
|
351
|
+
home,
|
|
352
|
+
}),
|
|
353
|
+
attribution,
|
|
354
|
+
url: httpUrlFor({ protocol: listener.protocol, scope, port: listener.port }),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const collapsed = collapseDuplicateBinds(ports).sort((left, right) => left.port - right.port);
|
|
358
|
+
|
|
359
|
+
if (hidden > 0) {
|
|
360
|
+
notes.push(
|
|
361
|
+
`${hidden} listening socket${hidden === 1 ? "" : "s"} belong to another user; run with elevated rights to attribute them.`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (dockerRaw === null) notes.push("Docker was not reachable, so containers are not listed.");
|
|
365
|
+
|
|
366
|
+
// --- services --------------------------------------------------------------
|
|
367
|
+
const portsByPid = new Map<number, number[]>();
|
|
368
|
+
for (const row of collapsed) {
|
|
369
|
+
if (row.pid === null) continue;
|
|
370
|
+
portsByPid.set(row.pid, [...(portsByPid.get(row.pid) ?? []), row.port]);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const services: ServiceRow[] = [];
|
|
374
|
+
for (const root of roots) {
|
|
375
|
+
services.push({
|
|
376
|
+
id: `agent-${root.pid}`,
|
|
377
|
+
name: root.title || `${root.provider} agent`,
|
|
378
|
+
subtitle: `${root.provider} · ${path.basename(root.cwd) || root.cwd}`,
|
|
379
|
+
ports: portsByPid.get(root.pid) ?? [],
|
|
380
|
+
status: "running",
|
|
381
|
+
source: "agent",
|
|
382
|
+
relevance: "dev",
|
|
383
|
+
pid: root.pid,
|
|
384
|
+
});
|
|
385
|
+
for (const child of childrenByRoot.get(root.pid) ?? []) {
|
|
386
|
+
services.push({
|
|
387
|
+
id: `agent-child-${child.pid}`,
|
|
388
|
+
name: friendlyName(child.command),
|
|
389
|
+
subtitle: `background process of the ${root.provider} agent`,
|
|
390
|
+
ports: portsByPid.get(child.pid) ?? [],
|
|
391
|
+
status: "running",
|
|
392
|
+
source: "agent-child",
|
|
393
|
+
relevance: "dev",
|
|
394
|
+
pid: child.pid,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const container of containers) {
|
|
399
|
+
services.push({
|
|
400
|
+
id: `docker-${container.name}`,
|
|
401
|
+
name: container.name,
|
|
402
|
+
subtitle: container.image,
|
|
403
|
+
ports: container.ports,
|
|
404
|
+
status: "running",
|
|
405
|
+
source: "docker",
|
|
406
|
+
relevance: "dev",
|
|
407
|
+
pid: null,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
for (const [pid, list] of portsByPid) {
|
|
411
|
+
if (ownerByPid.has(pid)) continue;
|
|
412
|
+
const socket = sockets.find((entry) => entry.pid === pid);
|
|
413
|
+
if (!socket) continue;
|
|
414
|
+
const command = commandByPid.get(pid) ?? socket.command;
|
|
415
|
+
services.push({
|
|
416
|
+
id: `listener-${pid}`,
|
|
417
|
+
name: friendlyName(command),
|
|
418
|
+
subtitle: `PID ${pid}`,
|
|
419
|
+
ports: [...new Set(list)].sort((a, b) => a - b),
|
|
420
|
+
status: "running",
|
|
421
|
+
source: "listener",
|
|
422
|
+
relevance: classifyRelevance({ kind: "process", command, owner: "you", home }),
|
|
423
|
+
pid,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return { collectedAt: new Date().toISOString(), ports: collapsed, services, notes };
|
|
428
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every contract in this file is validated on both sides of the plugin boundary:
|
|
6
|
+
* the client validates what it sends, the daemon handler validates what it returns.
|
|
7
|
+
* Keep this module free of Node and React Native code so both bundles can import it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CpuSchema = z.object({
|
|
11
|
+
cores: z.number(),
|
|
12
|
+
loadAvg1: z.number(),
|
|
13
|
+
loadAvg5: z.number(),
|
|
14
|
+
loadAvg15: z.number(),
|
|
15
|
+
/** loadAvg1 relative to core count, so 100 means "one runnable thread per core". */
|
|
16
|
+
loadPercent: z.number(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const MemorySchema = z.object({
|
|
20
|
+
totalBytes: z.number(),
|
|
21
|
+
/** Activity Monitor's "App Memory": anonymous pages minus purgeable. */
|
|
22
|
+
appBytes: z.number(),
|
|
23
|
+
wiredBytes: z.number(),
|
|
24
|
+
compressedBytes: z.number(),
|
|
25
|
+
/** File-backed pages the kernel can reclaim on demand. */
|
|
26
|
+
cachedBytes: z.number(),
|
|
27
|
+
usedBytes: z.number(),
|
|
28
|
+
/** Reported by macOS itself via `memory_pressure -Q`; null when unavailable. */
|
|
29
|
+
freePercent: z.number().nullable(),
|
|
30
|
+
pressurePercent: z.number().nullable(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const SwapSchema = z.object({
|
|
34
|
+
totalBytes: z.number(),
|
|
35
|
+
usedBytes: z.number(),
|
|
36
|
+
freeBytes: z.number(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const DiskSchema = z.object({
|
|
40
|
+
mountedOn: z.string(),
|
|
41
|
+
totalBytes: z.number(),
|
|
42
|
+
usedBytes: z.number(),
|
|
43
|
+
freeBytes: z.number(),
|
|
44
|
+
/** Taken from df's own Capacity column: APFS containers make used/total misleading. */
|
|
45
|
+
usedPercent: z.number(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const PowerSchema = z.object({
|
|
49
|
+
source: z.enum(["ac", "battery", "unknown"]),
|
|
50
|
+
percent: z.number().nullable(),
|
|
51
|
+
state: z.string().nullable(),
|
|
52
|
+
timeRemainingMinutes: z.number().nullable(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const ThermalSchema = z.object({
|
|
56
|
+
level: z.enum(["nominal", "fair", "serious", "critical", "unknown"]),
|
|
57
|
+
cpuSpeedLimitPercent: z.number().nullable(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** One point in the rolling history the charts draw. Kept small: it ships on every poll. */
|
|
61
|
+
export const HistorySampleSchema = z.object({
|
|
62
|
+
at: z.string(),
|
|
63
|
+
cpuLoadPercent: z.number(),
|
|
64
|
+
pressurePercent: z.number().nullable(),
|
|
65
|
+
appBytes: z.number(),
|
|
66
|
+
wiredBytes: z.number(),
|
|
67
|
+
compressedBytes: z.number(),
|
|
68
|
+
swapUsedBytes: z.number(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const MetricsSchema = z.object({
|
|
72
|
+
collectedAt: z.string(),
|
|
73
|
+
hostname: z.string(),
|
|
74
|
+
platform: z.string(),
|
|
75
|
+
uptimeSeconds: z.number(),
|
|
76
|
+
cpu: CpuSchema,
|
|
77
|
+
memory: MemorySchema,
|
|
78
|
+
swap: SwapSchema,
|
|
79
|
+
disk: DiskSchema,
|
|
80
|
+
power: PowerSchema,
|
|
81
|
+
thermal: ThermalSchema,
|
|
82
|
+
history: z.array(HistorySampleSchema),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const readMetrics = defineRpc({
|
|
86
|
+
name: "metrics.read",
|
|
87
|
+
input: z.object({}),
|
|
88
|
+
output: MetricsSchema,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const CleanupTargetSchema = z.object({
|
|
92
|
+
id: z.string(),
|
|
93
|
+
label: z.string(),
|
|
94
|
+
hint: z.string(),
|
|
95
|
+
path: z.string(),
|
|
96
|
+
exists: z.boolean(),
|
|
97
|
+
sizeBytes: z.number(),
|
|
98
|
+
error: z.string().nullable(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const planCleanup = defineRpc({
|
|
102
|
+
name: "cleanup.plan",
|
|
103
|
+
input: z.object({}),
|
|
104
|
+
output: z.object({
|
|
105
|
+
scannedAt: z.string(),
|
|
106
|
+
targets: z.array(CleanupTargetSchema),
|
|
107
|
+
totalBytes: z.number(),
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const applyCleanup = defineRpc({
|
|
112
|
+
name: "cleanup.apply",
|
|
113
|
+
input: z.object({ targetIds: z.array(z.string()).min(1) }),
|
|
114
|
+
output: z.object({
|
|
115
|
+
appliedAt: z.string(),
|
|
116
|
+
freedBytes: z.number(),
|
|
117
|
+
results: z.array(
|
|
118
|
+
z.object({
|
|
119
|
+
id: z.string(),
|
|
120
|
+
label: z.string(),
|
|
121
|
+
ok: z.boolean(),
|
|
122
|
+
freedBytes: z.number(),
|
|
123
|
+
error: z.string().nullable(),
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const ProcessSchema = z.object({
|
|
130
|
+
pid: z.number(),
|
|
131
|
+
ppid: z.number(),
|
|
132
|
+
cpuPercent: z.number(),
|
|
133
|
+
memoryBytes: z.number(),
|
|
134
|
+
name: z.string(),
|
|
135
|
+
command: z.string(),
|
|
136
|
+
/** True when killing this process would take Paseo itself down. */
|
|
137
|
+
guarded: z.boolean(),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const listProcesses = defineRpc({
|
|
141
|
+
name: "processes.list",
|
|
142
|
+
input: z.object({
|
|
143
|
+
sortBy: z.enum(["cpu", "memory"]).default("cpu"),
|
|
144
|
+
limit: z.number().int().min(1).max(50).default(8),
|
|
145
|
+
}),
|
|
146
|
+
output: z.object({
|
|
147
|
+
collectedAt: z.string(),
|
|
148
|
+
items: z.array(ProcessSchema),
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export const killProcess = defineRpc({
|
|
153
|
+
name: "processes.kill",
|
|
154
|
+
input: z.object({
|
|
155
|
+
pid: z.number().int().positive(),
|
|
156
|
+
signal: z.enum(["TERM", "KILL"]).default("TERM"),
|
|
157
|
+
}),
|
|
158
|
+
output: z.object({
|
|
159
|
+
ok: z.boolean(),
|
|
160
|
+
pid: z.number(),
|
|
161
|
+
signal: z.string(),
|
|
162
|
+
stillRunning: z.boolean(),
|
|
163
|
+
error: z.string().nullable(),
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export type Metrics = z.output<typeof MetricsSchema>;
|
|
168
|
+
export type HistorySample = z.output<typeof HistorySampleSchema>;
|
|
169
|
+
export type CleanupTarget = z.output<typeof CleanupTargetSchema>;
|
|
170
|
+
export type ProcessEntry = z.output<typeof ProcessSchema>;
|
package/shared/docker.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const DockerContainerSchema = z.object({
|
|
5
|
+
id: z.string(),
|
|
6
|
+
name: z.string(),
|
|
7
|
+
image: z.string(),
|
|
8
|
+
/** Docker's own word: running, exited, paused, created, restarting, dead. */
|
|
9
|
+
state: z.string(),
|
|
10
|
+
status: z.string(),
|
|
11
|
+
ports: z.array(z.number()),
|
|
12
|
+
running: z.boolean(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const DockerUsageRowSchema = z.object({
|
|
16
|
+
label: z.string(),
|
|
17
|
+
total: z.number(),
|
|
18
|
+
active: z.number(),
|
|
19
|
+
sizeBytes: z.number(),
|
|
20
|
+
reclaimableBytes: z.number(),
|
|
21
|
+
/** Volumes hold data; reclaiming them is never offered as a one-click action. */
|
|
22
|
+
safeToReclaim: z.boolean(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const DockerImageSchema = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
reference: z.string(),
|
|
28
|
+
sizeBytes: z.number(),
|
|
29
|
+
created: z.string(),
|
|
30
|
+
dangling: z.boolean(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const EngineSchema = z.object({
|
|
34
|
+
/** `missing` means no engine is installed at all, not merely stopped. */
|
|
35
|
+
state: z.enum(["running", "stopped", "missing"]),
|
|
36
|
+
version: z.string().nullable(),
|
|
37
|
+
/** The app this panel can launch, when it found one. */
|
|
38
|
+
app: z.string().nullable(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const readDocker = defineRpc({
|
|
42
|
+
name: "docker.read",
|
|
43
|
+
input: z.object({}),
|
|
44
|
+
output: z.object({
|
|
45
|
+
engine: EngineSchema,
|
|
46
|
+
available: z.boolean(),
|
|
47
|
+
reason: z.string().nullable(),
|
|
48
|
+
collectedAt: z.string(),
|
|
49
|
+
containers: z.array(DockerContainerSchema),
|
|
50
|
+
images: z.array(DockerImageSchema),
|
|
51
|
+
usage: z.array(DockerUsageRowSchema),
|
|
52
|
+
notes: z.array(z.string()),
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const controlEngine = defineRpc({
|
|
57
|
+
name: "docker.engine",
|
|
58
|
+
input: z.object({ action: z.enum(["start", "quit"]) }),
|
|
59
|
+
output: z.object({ ok: z.boolean(), error: z.string().nullable() }),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const readContainerLogs = defineRpc({
|
|
63
|
+
name: "docker.logs",
|
|
64
|
+
input: z.object({
|
|
65
|
+
id: z.string().min(1),
|
|
66
|
+
lines: z.number().int().min(10).max(500).default(120),
|
|
67
|
+
}),
|
|
68
|
+
output: z.object({ ok: z.boolean(), text: z.string(), error: z.string().nullable() }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const removeImage = defineRpc({
|
|
72
|
+
name: "docker.image.remove",
|
|
73
|
+
input: z.object({ id: z.string().min(1) }),
|
|
74
|
+
output: z.object({ ok: z.boolean(), error: z.string().nullable() }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const controlContainer = defineRpc({
|
|
78
|
+
name: "docker.control",
|
|
79
|
+
input: z.object({
|
|
80
|
+
id: z.string().min(1),
|
|
81
|
+
action: z.enum(["start", "stop", "restart", "remove"]),
|
|
82
|
+
}),
|
|
83
|
+
output: z.object({ ok: z.boolean(), error: z.string().nullable() }),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const pruneDocker = defineRpc({
|
|
87
|
+
name: "docker.prune",
|
|
88
|
+
input: z.object({ target: z.enum(["containers", "images"]) }),
|
|
89
|
+
output: z.object({ ok: z.boolean(), reclaimedBytes: z.number(), error: z.string().nullable() }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export type DockerContainer = z.output<typeof DockerContainerSchema>;
|
|
93
|
+
export type DockerImage = z.output<typeof DockerImageSchema>;
|
|
94
|
+
export type DockerEngine = z.output<typeof EngineSchema>;
|
|
95
|
+
export type DockerUsageRow = z.output<typeof DockerUsageRowSchema>;
|