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,195 @@
|
|
|
1
|
+
import { useRpc, type PluginButtonContentProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import React, { useMemo } from "react";
|
|
4
|
+
import { StyleSheet, Text, View, type TextStyle } from "react-native";
|
|
5
|
+
import { listProcesses, readMetrics } from "../shared/contracts";
|
|
6
|
+
import { formatBytes, formatPercent } from "../shared/format";
|
|
7
|
+
import { agentPorts, cpuSeverity, memorySeverity } from "../shared/pill";
|
|
8
|
+
import { readServices } from "../shared/services";
|
|
9
|
+
import { paletteFor, SEVERITY_LABEL, STATUS, takeLast, type Severity } from "../shared/viz";
|
|
10
|
+
import { Sparkbars } from "./charts";
|
|
11
|
+
import { ActionButton, useChromeStyles, type Chrome } from "./ui";
|
|
12
|
+
|
|
13
|
+
/** Enough samples to read a trend in a card a few hundred points wide. */
|
|
14
|
+
const SLOTS = 24;
|
|
15
|
+
const TABULAR: TextStyle = { fontVariant: ["tabular-nums"] };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The pill's popover: more than the two numbers, far less than the tab.
|
|
19
|
+
*
|
|
20
|
+
* It answers "is the machine the reason this feels slow" at a glance: both
|
|
21
|
+
* readings with their recent trend, what is using the CPU right now, and which
|
|
22
|
+
* development ports agents have left listening. Everything else is one press
|
|
23
|
+
* away in the Machine tab.
|
|
24
|
+
*
|
|
25
|
+
* Metrics and services share their query keys with the tab, so a card opened
|
|
26
|
+
* after the tab paints from cache. Polling runs only while the card is open.
|
|
27
|
+
*/
|
|
28
|
+
export function createMachineCard(openMachine: () => void) {
|
|
29
|
+
return function MachineCard({ theme, host, layout, close }: PluginButtonContentProps) {
|
|
30
|
+
const chrome: Chrome = { theme, compact: true };
|
|
31
|
+
const styles = useChromeStyles(chrome);
|
|
32
|
+
const palette = useMemo(() => paletteFor(theme.colors.surface0), [theme.colors.surface0]);
|
|
33
|
+
|
|
34
|
+
const read = useRpc(readMetrics);
|
|
35
|
+
const metrics = useQuery({
|
|
36
|
+
queryKey: ["machine-status", "metrics", host.id],
|
|
37
|
+
queryFn: () => read({}),
|
|
38
|
+
refetchInterval: 3_000,
|
|
39
|
+
});
|
|
40
|
+
const top = useRpc(listProcesses);
|
|
41
|
+
const processes = useQuery({
|
|
42
|
+
queryKey: ["machine-status", "processes", host.id, "card"],
|
|
43
|
+
queryFn: () => top({ sortBy: "cpu", limit: 3 }),
|
|
44
|
+
refetchInterval: 5_000,
|
|
45
|
+
});
|
|
46
|
+
const scan = useRpc(readServices);
|
|
47
|
+
const services = useQuery({
|
|
48
|
+
queryKey: ["machine-status", "services", host.id],
|
|
49
|
+
queryFn: () => scan({}),
|
|
50
|
+
staleTime: 15_000,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const history = metrics.data?.history ?? [];
|
|
54
|
+
const cpuSeries = useMemo(() => takeLast(history.map((sample) => sample.cpuLoadPercent), SLOTS), [history]);
|
|
55
|
+
const pressureSeries = useMemo(
|
|
56
|
+
() => takeLast(history.map((sample) => sample.pressurePercent ?? 0), SLOTS),
|
|
57
|
+
[history],
|
|
58
|
+
);
|
|
59
|
+
const ports = useMemo(() => agentPorts(services.data?.ports ?? []), [services.data]);
|
|
60
|
+
|
|
61
|
+
// A popover sizes to its content; without a width the sparkbars collapse.
|
|
62
|
+
// On compact layouts the card is a bottom sheet and takes the sheet's width.
|
|
63
|
+
const frame = layout.compact ? { alignSelf: "stretch" as const, gap: 12 } : { width: 300, gap: 12 };
|
|
64
|
+
const divider = <View style={{ height: StyleSheet.hairlineWidth, backgroundColor: palette.border }} />;
|
|
65
|
+
const openTab = () => {
|
|
66
|
+
close();
|
|
67
|
+
openMachine();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const data = metrics.data;
|
|
71
|
+
if (!data) {
|
|
72
|
+
return (
|
|
73
|
+
<View style={frame}>
|
|
74
|
+
<Text style={metrics.error ? styles.danger : styles.muted}>
|
|
75
|
+
{metrics.error ? (metrics.error as Error).message : "Reading the machine…"}
|
|
76
|
+
</Text>
|
|
77
|
+
<ActionButton chrome={chrome} label="Open Machine" variant="ghost" onPress={openTab} />
|
|
78
|
+
</View>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const footer = [
|
|
83
|
+
`Disk ${formatPercent(data.disk.usedPercent)}`,
|
|
84
|
+
data.power.percent !== null
|
|
85
|
+
? `Battery ${formatPercent(data.power.percent)}${data.power.source === "ac" ? " on AC" : ""}`
|
|
86
|
+
: null,
|
|
87
|
+
data.thermal.level !== "nominal" && data.thermal.level !== "unknown" ? `Thermal ${data.thermal.level}` : null,
|
|
88
|
+
]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join(" · ");
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<View style={frame}>
|
|
94
|
+
<Reading
|
|
95
|
+
chrome={chrome}
|
|
96
|
+
label="CPU"
|
|
97
|
+
color={palette.series[0]}
|
|
98
|
+
value={formatPercent(data.cpu.loadPercent)}
|
|
99
|
+
severity={cpuSeverity(data.cpu.loadPercent)}
|
|
100
|
+
detail={`load ${data.cpu.loadAvg1.toFixed(2)} · ${data.cpu.cores} cores`}
|
|
101
|
+
series={cpuSeries}
|
|
102
|
+
/>
|
|
103
|
+
<Reading
|
|
104
|
+
chrome={chrome}
|
|
105
|
+
label="MEMORY"
|
|
106
|
+
color={palette.series[1]}
|
|
107
|
+
value={formatPercent(data.memory.pressurePercent)}
|
|
108
|
+
severity={memorySeverity(data.memory.pressurePercent)}
|
|
109
|
+
detail={`${formatBytes(data.memory.appBytes)} app · swap ${formatBytes(data.swap.usedBytes)}`}
|
|
110
|
+
series={pressureSeries}
|
|
111
|
+
/>
|
|
112
|
+
|
|
113
|
+
{divider}
|
|
114
|
+
<View style={{ gap: 4 }}>
|
|
115
|
+
<Text style={styles.muted}>Top processes</Text>
|
|
116
|
+
{(processes.data?.items ?? []).map((item) => (
|
|
117
|
+
<View key={item.pid} style={styles.row}>
|
|
118
|
+
<Text numberOfLines={1} style={[styles.value, { flex: 1 }]}>
|
|
119
|
+
{item.name}
|
|
120
|
+
</Text>
|
|
121
|
+
<Text style={[styles.muted, TABULAR]}>{formatPercent(item.cpuPercent)}</Text>
|
|
122
|
+
<Text style={[styles.muted, TABULAR, { minWidth: 60, textAlign: "right" }]}>
|
|
123
|
+
{formatBytes(item.memoryBytes)}
|
|
124
|
+
</Text>
|
|
125
|
+
</View>
|
|
126
|
+
))}
|
|
127
|
+
{processes.data && processes.data.items.length === 0 ? <Text style={styles.muted}>None</Text> : null}
|
|
128
|
+
</View>
|
|
129
|
+
|
|
130
|
+
{ports.length > 0 ? (
|
|
131
|
+
<>
|
|
132
|
+
{divider}
|
|
133
|
+
<View style={{ gap: 4 }}>
|
|
134
|
+
<Text style={styles.muted}>Started by agents</Text>
|
|
135
|
+
{ports.map((port) => (
|
|
136
|
+
<View key={port.id} style={styles.row}>
|
|
137
|
+
<Text style={[styles.value, TABULAR]}>:{port.port}</Text>
|
|
138
|
+
<Text numberOfLines={1} style={[styles.muted, { flex: 1, textAlign: "right" }]}>
|
|
139
|
+
{port.command ?? "unknown"} · {port.attribution?.label}
|
|
140
|
+
</Text>
|
|
141
|
+
</View>
|
|
142
|
+
))}
|
|
143
|
+
</View>
|
|
144
|
+
</>
|
|
145
|
+
) : null}
|
|
146
|
+
|
|
147
|
+
{divider}
|
|
148
|
+
<Text style={styles.muted}>{footer}</Text>
|
|
149
|
+
<ActionButton chrome={chrome} label="Open Machine" variant="ghost" onPress={openTab} />
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One reading: the figure with its severity in words, and the trend beside it. */
|
|
156
|
+
function Reading({
|
|
157
|
+
chrome,
|
|
158
|
+
label,
|
|
159
|
+
color,
|
|
160
|
+
value,
|
|
161
|
+
severity,
|
|
162
|
+
detail,
|
|
163
|
+
series,
|
|
164
|
+
}: {
|
|
165
|
+
chrome: Chrome;
|
|
166
|
+
label: string;
|
|
167
|
+
color: string;
|
|
168
|
+
value: string;
|
|
169
|
+
severity: Severity;
|
|
170
|
+
detail: string;
|
|
171
|
+
series: readonly number[];
|
|
172
|
+
}) {
|
|
173
|
+
const styles = useChromeStyles(chrome);
|
|
174
|
+
return (
|
|
175
|
+
<View style={{ gap: 6 }}>
|
|
176
|
+
<View style={styles.row}>
|
|
177
|
+
<Text style={[styles.cardTitle, { color }]}>{label}</Text>
|
|
178
|
+
<Text numberOfLines={1} style={styles.muted}>
|
|
179
|
+
{detail}
|
|
180
|
+
</Text>
|
|
181
|
+
</View>
|
|
182
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
183
|
+
<View style={{ minWidth: 64 }}>
|
|
184
|
+
<Text style={[styles.valueStrong, TABULAR, severity === "good" ? null : { color: STATUS[severity] }]}>
|
|
185
|
+
{value}
|
|
186
|
+
</Text>
|
|
187
|
+
<Text style={styles.axis}>{SEVERITY_LABEL[severity]}</Text>
|
|
188
|
+
</View>
|
|
189
|
+
<View style={{ flex: 1 }}>
|
|
190
|
+
<Sparkbars values={series} min={0} max={100} slots={SLOTS} color={color} height={28} />
|
|
191
|
+
</View>
|
|
192
|
+
</View>
|
|
193
|
+
</View>
|
|
194
|
+
);
|
|
195
|
+
}
|
package/client/pill.tsx
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PluginButton,
|
|
3
|
+
PluginButtonIconProps,
|
|
4
|
+
PluginButtonRegistration,
|
|
5
|
+
PluginClientContext,
|
|
6
|
+
} from "@getpaseo/plugin/client";
|
|
7
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
8
|
+
import React, { useSyncExternalStore } from "react";
|
|
9
|
+
import { readMetrics } from "../shared/contracts";
|
|
10
|
+
import { createMachineCard } from "./pill-card";
|
|
11
|
+
import { pillReading, type PillReading } from "../shared/pill";
|
|
12
|
+
import { STATUS } from "../shared/viz";
|
|
13
|
+
|
|
14
|
+
/** Often enough to notice a runaway agent, rarely enough to cost nothing. */
|
|
15
|
+
const REFRESH_MS = 10_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The latest reading, held once for every pill. Paseo draws the label itself, so
|
|
19
|
+
* the numbers reach each pill through `update`; the icon is the plugin's own and
|
|
20
|
+
* reads the severity from here.
|
|
21
|
+
*/
|
|
22
|
+
interface ReadingStore {
|
|
23
|
+
get(): PillReading | null;
|
|
24
|
+
set(next: PillReading): void;
|
|
25
|
+
subscribe(listener: () => void): () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createReadingStore(): ReadingStore {
|
|
29
|
+
let current: PillReading | null = null;
|
|
30
|
+
const listeners = new Set<() => void>();
|
|
31
|
+
return {
|
|
32
|
+
get: () => current,
|
|
33
|
+
set(next) {
|
|
34
|
+
current = next;
|
|
35
|
+
for (const listener of listeners) listener();
|
|
36
|
+
},
|
|
37
|
+
subscribe(listener) {
|
|
38
|
+
listeners.add(listener);
|
|
39
|
+
return () => {
|
|
40
|
+
listeners.delete(listener);
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The pill's icon, tinted by the worse of the two readings. While all is well it
|
|
48
|
+
* keeps the colour Paseo hands it, so the pill only draws the eye when it should.
|
|
49
|
+
*/
|
|
50
|
+
function createIcon(store: ReadingStore) {
|
|
51
|
+
return function MachineLoadIcon({ size, color }: PluginButtonIconProps) {
|
|
52
|
+
const reading = useSyncExternalStore(store.subscribe, store.get);
|
|
53
|
+
const tone = reading && reading.severity !== "good" ? STATUS[reading.severity] : color;
|
|
54
|
+
return <Icon name="Activity" size={size} color={tone} />;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface TrackedAgent {
|
|
59
|
+
id: string;
|
|
60
|
+
workspaceId?: string | null;
|
|
61
|
+
archivedAt?: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* CPU load and memory pressure beside every agent's composer, fed by one poll.
|
|
66
|
+
*
|
|
67
|
+
* Paseo scopes a pill to a single agent, so this follows the agent directory: a
|
|
68
|
+
* pill appears for each agent that has a workspace and goes when the agent is
|
|
69
|
+
* archived or removed. The directory is seeded from `list`, because a
|
|
70
|
+
* subscription reports changes rather than the agents that already exist.
|
|
71
|
+
*/
|
|
72
|
+
export function contributePills(client: PluginClientContext) {
|
|
73
|
+
const store = createReadingStore();
|
|
74
|
+
const icon = createIcon(store);
|
|
75
|
+
const card = createMachineCard(() => client.openSurface("main"));
|
|
76
|
+
const pills = new Map<string, { workspaceId: string; registration: PluginButtonRegistration }>();
|
|
77
|
+
let disposed = false;
|
|
78
|
+
let inFlight = false;
|
|
79
|
+
|
|
80
|
+
function button(): PluginButton {
|
|
81
|
+
const current = store.get();
|
|
82
|
+
return {
|
|
83
|
+
title: current?.label ?? "Machine load",
|
|
84
|
+
icon,
|
|
85
|
+
label: current?.text ?? "Machine",
|
|
86
|
+
// A compact card rather than a jump to the tab: the question beside the
|
|
87
|
+
// composer is "is it the machine", and the answer should not cost the chat.
|
|
88
|
+
behavior: { kind: "popover", Content: card },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function refresh() {
|
|
93
|
+
if (inFlight || disposed || pills.size === 0) return;
|
|
94
|
+
inFlight = true;
|
|
95
|
+
try {
|
|
96
|
+
const metrics = await client.rpc(readMetrics, {});
|
|
97
|
+
if (disposed) return;
|
|
98
|
+
const next = pillReading(metrics.cpu.loadPercent, metrics.memory.pressurePercent);
|
|
99
|
+
store.set(next);
|
|
100
|
+
for (const { registration } of pills.values()) {
|
|
101
|
+
registration.update({ title: next.label, label: next.text });
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// A failed read leaves the last numbers up; the next tick tries again.
|
|
105
|
+
} finally {
|
|
106
|
+
inFlight = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function show(agentId: string, workspaceId: string) {
|
|
111
|
+
const existing = pills.get(agentId);
|
|
112
|
+
if (existing?.workspaceId === workspaceId) return;
|
|
113
|
+
existing?.registration.remove();
|
|
114
|
+
pills.set(agentId, {
|
|
115
|
+
workspaceId,
|
|
116
|
+
registration: client.addComposerPill({ id: "machine", workspaceId, agentId, button: button() }),
|
|
117
|
+
});
|
|
118
|
+
if (store.get() === null) void refresh();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hide(agentId: string) {
|
|
122
|
+
pills.get(agentId)?.registration.remove();
|
|
123
|
+
pills.delete(agentId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function track(agent: TrackedAgent) {
|
|
127
|
+
if (agent.workspaceId && !agent.archivedAt) show(agent.id, agent.workspaceId);
|
|
128
|
+
else hide(agent.id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
|
132
|
+
if (disposed) return;
|
|
133
|
+
if (update.kind === "upsert") track(update.agent);
|
|
134
|
+
else hide(update.agentId);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
client.paseo.agents
|
|
138
|
+
.list()
|
|
139
|
+
.then(({ entries }) => {
|
|
140
|
+
if (!disposed) for (const entry of entries) track(entry.agent);
|
|
141
|
+
})
|
|
142
|
+
.catch(() => {
|
|
143
|
+
// Without the seed, pills appear as each agent next reports a change.
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const timer = setInterval(() => void refresh(), REFRESH_MS);
|
|
147
|
+
|
|
148
|
+
return () => {
|
|
149
|
+
disposed = true;
|
|
150
|
+
clearInterval(timer);
|
|
151
|
+
unsubscribe();
|
|
152
|
+
for (const { registration } of pills.values()) registration.remove();
|
|
153
|
+
pills.clear();
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
3
|
+
import React, { useRef, useState } from "react";
|
|
4
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
5
|
+
import { killProcess, listProcesses, type ProcessEntry } from "../shared/contracts";
|
|
6
|
+
import { formatBytes } from "../shared/format";
|
|
7
|
+
import { explainProcess, type Explanation } from "../shared/explain";
|
|
8
|
+
import { contextMenuTriggers, useContextMenu, type ContextMenuItem } from "./menu";
|
|
9
|
+
import { Card, useChromeStyles, type Chrome } from "./ui";
|
|
10
|
+
import { severityOf, STATUS, type VizPalette } from "../shared/viz";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Auto-refresh pauses while a menu is open: a list that reorders under the
|
|
15
|
+
* cursor is how the wrong PID gets terminated.
|
|
16
|
+
*/
|
|
17
|
+
export function ProcessesSection({
|
|
18
|
+
chrome,
|
|
19
|
+
hostId,
|
|
20
|
+
palette,
|
|
21
|
+
}: {
|
|
22
|
+
chrome: Chrome;
|
|
23
|
+
hostId: string;
|
|
24
|
+
palette: VizPalette;
|
|
25
|
+
}) {
|
|
26
|
+
const styles = useChromeStyles(chrome);
|
|
27
|
+
const list = useRpc(listProcesses);
|
|
28
|
+
const kill = useRpc(killProcess);
|
|
29
|
+
const explain = useRpc(explainProcess);
|
|
30
|
+
const openMenu = useContextMenu();
|
|
31
|
+
|
|
32
|
+
const [sortBy, setSortBy] = useState<"cpu" | "memory">("cpu");
|
|
33
|
+
const [activePid, setActivePid] = useState<number | null>(null);
|
|
34
|
+
/** Where the menu was opened, so a confirmation submenu appears in the same place. */
|
|
35
|
+
const anchor = useRef({ x: 0, y: 0 });
|
|
36
|
+
|
|
37
|
+
const processes = useQuery({
|
|
38
|
+
queryKey: ["machine-status", "processes", hostId, sortBy],
|
|
39
|
+
queryFn: () => list({ sortBy, limit: 8 }),
|
|
40
|
+
refetchInterval: activePid === null ? 5_000 : false,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const items = processes.data?.items ?? [];
|
|
44
|
+
// The bar is scaled to the heaviest process on screen, so the worst offender
|
|
45
|
+
// always reaches full width and the rest are read against it.
|
|
46
|
+
const peakCpu = Math.max(...items.map((item) => item.cpuPercent), 1);
|
|
47
|
+
|
|
48
|
+
const signal = useMutation({
|
|
49
|
+
mutationFn: (variables: { pid: number; signal: "TERM" | "KILL" }) => kill(variables),
|
|
50
|
+
onSettled: () => void processes.refetch(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The panel already knows the parent chain, the elapsed time, the ports and the
|
|
55
|
+
* machine's state. Those facts answer "is this a problem" on their own, so the
|
|
56
|
+
* verdict is computed here rather than delegated.
|
|
57
|
+
*/
|
|
58
|
+
const investigate = useMutation({ mutationFn: (pid: number) => explain({ pid }) });
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A destructive action gets its own menu rather than firing on the first click.
|
|
62
|
+
* Re-opening at the same anchor keeps the two-step guarantee without turning
|
|
63
|
+
* the menu into a dialog.
|
|
64
|
+
*/
|
|
65
|
+
const confirmSignal = (item: ProcessEntry, kind: "TERM" | "KILL") => {
|
|
66
|
+
openMenu({
|
|
67
|
+
...anchor.current,
|
|
68
|
+
title: kind === "TERM" ? `Terminate ${item.name}?` : `Force kill ${item.name}?`,
|
|
69
|
+
items: [
|
|
70
|
+
{
|
|
71
|
+
id: "confirm",
|
|
72
|
+
label: kind === "TERM" ? "Confirm — unsaved work is lost" : "Confirm — no cleanup runs",
|
|
73
|
+
tone: "danger",
|
|
74
|
+
onSelect: () => signal.mutate({ pid: item.pid, signal: kind }),
|
|
75
|
+
},
|
|
76
|
+
{ id: "cancel", label: "Cancel", onSelect: () => setActivePid(null) },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const menuFor = (item: ProcessEntry): ContextMenuItem[] => {
|
|
82
|
+
const actions: ContextMenuItem[] = [
|
|
83
|
+
{
|
|
84
|
+
id: "explain",
|
|
85
|
+
label: "What is this?",
|
|
86
|
+
onSelect: () => investigate.mutate(item.pid),
|
|
87
|
+
},
|
|
88
|
+
{ id: "term", label: "Terminate (SIGTERM)…", tone: "danger", onSelect: () => confirmSignal(item, "TERM") },
|
|
89
|
+
];
|
|
90
|
+
if (signal.data?.pid === item.pid && signal.data.stillRunning) {
|
|
91
|
+
actions.push({
|
|
92
|
+
id: "kill",
|
|
93
|
+
label: "Force kill (SIGKILL)…",
|
|
94
|
+
tone: "danger",
|
|
95
|
+
onSelect: () => confirmSignal(item, "KILL"),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return actions;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const open = (item: ProcessEntry, x: number, y: number) => {
|
|
102
|
+
if (item.guarded) return;
|
|
103
|
+
anchor.current = { x, y };
|
|
104
|
+
setActivePid(item.pid);
|
|
105
|
+
signal.reset();
|
|
106
|
+
investigate.reset();
|
|
107
|
+
openMenu({ x, y, title: `${item.name} · PID ${item.pid}`, items: menuFor(item) });
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Card
|
|
112
|
+
chrome={chrome}
|
|
113
|
+
title="Top processes"
|
|
114
|
+
subtitle={activePid === null ? "refreshing every 5s" : "paused"}
|
|
115
|
+
>
|
|
116
|
+
<View style={styles.rowWrap}>
|
|
117
|
+
{(["cpu", "memory"] as const).map((option) => (
|
|
118
|
+
<Pressable
|
|
119
|
+
key={option}
|
|
120
|
+
accessibilityRole="radio"
|
|
121
|
+
accessibilityState={{ selected: sortBy === option }}
|
|
122
|
+
accessibilityLabel={`Sort by ${option}`}
|
|
123
|
+
onPress={() => setSortBy(option)}
|
|
124
|
+
style={{
|
|
125
|
+
paddingVertical: 6,
|
|
126
|
+
paddingHorizontal: 12,
|
|
127
|
+
borderRadius: 8,
|
|
128
|
+
borderWidth: 1,
|
|
129
|
+
borderColor: sortBy === option ? chrome.theme.colors.accent : palette.borderStrong,
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<Text style={sortBy === option ? styles.value : styles.muted}>
|
|
133
|
+
{option === "cpu" ? "By CPU" : "By memory"}
|
|
134
|
+
</Text>
|
|
135
|
+
</Pressable>
|
|
136
|
+
))}
|
|
137
|
+
<View style={{ flex: 1 }} />
|
|
138
|
+
<Text style={styles.axis}>right-click a row</Text>
|
|
139
|
+
</View>
|
|
140
|
+
|
|
141
|
+
{processes.error ? (
|
|
142
|
+
<Text style={styles.danger}>{(processes.error as Error).message}</Text>
|
|
143
|
+
) : null}
|
|
144
|
+
|
|
145
|
+
{!chrome.compact ? (
|
|
146
|
+
<View style={[styles.processRow, { paddingVertical: 2 }]}>
|
|
147
|
+
<Text style={[styles.axis, { flex: 1 }]}>PROCESS</Text>
|
|
148
|
+
<Text style={[styles.axis, { width: 64 }]}>PID</Text>
|
|
149
|
+
<Text style={[styles.axis, { width: 56, textAlign: "right" }]}>CPU</Text>
|
|
150
|
+
<View style={{ width: 120 }} />
|
|
151
|
+
<Text style={[styles.axis, { width: 84, textAlign: "right" }]}>MEMORY</Text>
|
|
152
|
+
</View>
|
|
153
|
+
) : null}
|
|
154
|
+
|
|
155
|
+
{items.map((item) => {
|
|
156
|
+
const active = item.pid === activePid;
|
|
157
|
+
const severity = severityOf(item.cpuPercent, 50, 80);
|
|
158
|
+
return (
|
|
159
|
+
<Pressable
|
|
160
|
+
key={item.pid}
|
|
161
|
+
accessibilityRole="button"
|
|
162
|
+
accessibilityLabel={`${item.name}, PID ${item.pid}, ${item.cpuPercent.toFixed(1)} percent CPU, ${formatBytes(item.memoryBytes)}. Opens actions.`}
|
|
163
|
+
accessibilityState={{ selected: active, disabled: item.guarded }}
|
|
164
|
+
disabled={item.guarded}
|
|
165
|
+
// A left click opens the same menu, so the actions are reachable
|
|
166
|
+
// without a right button — on a trackpad, or by keyboard focus.
|
|
167
|
+
onPress={(event) => open(item, event.nativeEvent.pageX, event.nativeEvent.pageY)}
|
|
168
|
+
{...contextMenuTriggers((x, y) => open(item, x, y))}
|
|
169
|
+
style={({ pressed }) => [
|
|
170
|
+
styles.processRow,
|
|
171
|
+
{
|
|
172
|
+
backgroundColor: active ? palette.border : "transparent",
|
|
173
|
+
opacity: item.guarded ? 0.45 : pressed ? 0.7 : 1,
|
|
174
|
+
},
|
|
175
|
+
]}
|
|
176
|
+
>
|
|
177
|
+
<Text style={[styles.value, { flex: 1 }]} numberOfLines={1}>
|
|
178
|
+
{item.name}
|
|
179
|
+
</Text>
|
|
180
|
+
<Text style={[styles.muted, { width: 64 }]} numberOfLines={1}>
|
|
181
|
+
{item.guarded ? "protected" : item.pid}
|
|
182
|
+
</Text>
|
|
183
|
+
<Text
|
|
184
|
+
style={[
|
|
185
|
+
styles.value,
|
|
186
|
+
{ width: 56, textAlign: "right", fontWeight: "600" },
|
|
187
|
+
severity === "good" ? null : { color: STATUS[severity] },
|
|
188
|
+
]}
|
|
189
|
+
>
|
|
190
|
+
{item.cpuPercent.toFixed(1)}%
|
|
191
|
+
</Text>
|
|
192
|
+
<View
|
|
193
|
+
style={{
|
|
194
|
+
width: chrome.compact ? 48 : 120,
|
|
195
|
+
height: 5,
|
|
196
|
+
borderRadius: 3,
|
|
197
|
+
backgroundColor: palette.neutral,
|
|
198
|
+
overflow: "hidden",
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
<View
|
|
202
|
+
style={{
|
|
203
|
+
width: `${(item.cpuPercent / peakCpu) * 100}%`,
|
|
204
|
+
height: "100%",
|
|
205
|
+
borderRadius: 3,
|
|
206
|
+
backgroundColor: severity === "good" ? palette.series[0] : STATUS[severity],
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
</View>
|
|
210
|
+
<Text style={[styles.value, { width: 84, textAlign: "right" }]}>
|
|
211
|
+
{formatBytes(item.memoryBytes)}
|
|
212
|
+
</Text>
|
|
213
|
+
</Pressable>
|
|
214
|
+
);
|
|
215
|
+
})}
|
|
216
|
+
|
|
217
|
+
{/* The menu is gone by the time an action finishes, so results land here. */}
|
|
218
|
+
{investigate.isPending ? <Text style={styles.muted}>Gathering evidence…</Text> : null}
|
|
219
|
+
{investigate.error ? (
|
|
220
|
+
<Text style={styles.danger}>{(investigate.error as Error).message}</Text>
|
|
221
|
+
) : null}
|
|
222
|
+
{investigate.isSuccess ? (
|
|
223
|
+
<Verdict
|
|
224
|
+
chrome={chrome}
|
|
225
|
+
palette={palette}
|
|
226
|
+
name={investigate.data.name}
|
|
227
|
+
pid={investigate.data.pid}
|
|
228
|
+
explanation={investigate.data.explanation}
|
|
229
|
+
onDismiss={() => investigate.reset()}
|
|
230
|
+
/>
|
|
231
|
+
) : null}
|
|
232
|
+
|
|
233
|
+
{signal.data ? (
|
|
234
|
+
<Text style={signal.data.ok && !signal.data.stillRunning ? styles.value : styles.danger}>
|
|
235
|
+
{signal.data.error
|
|
236
|
+
? signal.data.error
|
|
237
|
+
: signal.data.stillRunning
|
|
238
|
+
? `PID ${signal.data.pid} ignored SIG${signal.data.signal} and is still running — right-click it again to force kill.`
|
|
239
|
+
: `PID ${signal.data.pid} exited.`}
|
|
240
|
+
</Text>
|
|
241
|
+
) : null}
|
|
242
|
+
</Card>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const CONCERN_LABEL: Record<Explanation["concern"], string> = {
|
|
247
|
+
none: "Nothing to do",
|
|
248
|
+
watch: "Worth watching",
|
|
249
|
+
act: "Worth acting on",
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** The verdict, shown where the question was asked. */
|
|
253
|
+
function Verdict({
|
|
254
|
+
chrome,
|
|
255
|
+
palette,
|
|
256
|
+
name,
|
|
257
|
+
pid,
|
|
258
|
+
explanation,
|
|
259
|
+
onDismiss,
|
|
260
|
+
}: {
|
|
261
|
+
chrome: Chrome;
|
|
262
|
+
palette: VizPalette;
|
|
263
|
+
name: string;
|
|
264
|
+
pid: number;
|
|
265
|
+
explanation: Explanation;
|
|
266
|
+
onDismiss: () => void;
|
|
267
|
+
}) {
|
|
268
|
+
const styles = useChromeStyles(chrome);
|
|
269
|
+
const tone =
|
|
270
|
+
explanation.concern === "act"
|
|
271
|
+
? STATUS.critical
|
|
272
|
+
: explanation.concern === "watch"
|
|
273
|
+
? STATUS.warning
|
|
274
|
+
: palette.series[2];
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<View
|
|
278
|
+
style={{
|
|
279
|
+
marginTop: 4,
|
|
280
|
+
borderRadius: 10,
|
|
281
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
282
|
+
borderColor: palette.borderStrong,
|
|
283
|
+
padding: chrome.compact ? 10 : 12,
|
|
284
|
+
gap: 8,
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
<View style={styles.row}>
|
|
288
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
|
|
289
|
+
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: tone }} />
|
|
290
|
+
{/* The dot never carries the meaning alone. */}
|
|
291
|
+
<Text style={styles.axis}>{CONCERN_LABEL[explanation.concern]}</Text>
|
|
292
|
+
<Text style={styles.axis}>· PID {pid}</Text>
|
|
293
|
+
</View>
|
|
294
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Dismiss" onPress={onDismiss}>
|
|
295
|
+
<Text style={styles.muted}>Dismiss</Text>
|
|
296
|
+
</Pressable>
|
|
297
|
+
</View>
|
|
298
|
+
|
|
299
|
+
<Text style={[styles.value, { fontWeight: "600" }]} selectable>
|
|
300
|
+
{explanation.headline}
|
|
301
|
+
</Text>
|
|
302
|
+
|
|
303
|
+
{explanation.findings.map((finding) => (
|
|
304
|
+
<Text key={finding} style={styles.muted} selectable>
|
|
305
|
+
· {finding}
|
|
306
|
+
</Text>
|
|
307
|
+
))}
|
|
308
|
+
|
|
309
|
+
{explanation.advice.length > 0 ? (
|
|
310
|
+
<>
|
|
311
|
+
<Text style={styles.axis}>WHAT TO DO</Text>
|
|
312
|
+
{explanation.advice.map((step, index) => (
|
|
313
|
+
<Text key={step} style={styles.value} selectable>
|
|
314
|
+
{index + 1}. {step}
|
|
315
|
+
</Text>
|
|
316
|
+
))}
|
|
317
|
+
</>
|
|
318
|
+
) : null}
|
|
319
|
+
</View>
|
|
320
|
+
);
|
|
321
|
+
}
|