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.
@@ -0,0 +1,514 @@
1
+ import { useRpc, type PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import React, { useMemo, useState } from "react";
4
+ import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
5
+ import { ColumnChart, Legend, Meter, Sparkbars, StackedColumnChart, StatTile } from "./charts";
6
+ import { ServicesTab } from "./services";
7
+ import { CleanupSection } from "./cleanup";
8
+ import { DockerTab } from "./docker";
9
+ import { ContextMenuProvider } from "./menu";
10
+ import { readMetrics, type Metrics } from "../shared/contracts";
11
+ import { formatBytes, formatDuration, formatMinutes, formatPercent } from "../shared/format";
12
+ import { ProcessesSection } from "./processes";
13
+ import { ActionButton, Card, MetricRow, useChromeStyles, type Chrome } from "./ui";
14
+ import {
15
+ paletteFor,
16
+ SEVERITY_LABEL,
17
+ severityOf,
18
+ spanLabel,
19
+ STATUS,
20
+ } from "../shared/viz";
21
+ import { cpuSeverity as cpuSeverityOf, memorySeverity as memorySeverityOf } from "../shared/pill";
22
+
23
+ type Expanded = "cpu" | "memory" | "swap" | "disk" | null;
24
+
25
+ const THERMAL_LABEL: Record<string, string> = {
26
+ nominal: "Nominal",
27
+ fair: "Fair",
28
+ serious: "Serious",
29
+ critical: "Critical",
30
+ unknown: "Not reported",
31
+ };
32
+
33
+ const GB = 1024 ** 3;
34
+
35
+ function OverviewTab({ theme, layout, host }: PluginSurfaceProps) {
36
+ const chrome: Chrome = { theme, compact: layout.compact };
37
+ const styles = useChromeStyles(chrome);
38
+ const palette = useMemo(() => paletteFor(theme.colors.surface0), [theme.colors.surface0]);
39
+
40
+ const [paused, setPaused] = useState(false);
41
+ const [expanded, setExpanded] = useState<Expanded>(null);
42
+ const toggle = (key: Exclude<Expanded, null>) =>
43
+ setExpanded((current) => (current === key ? null : key));
44
+
45
+ const read = useRpc(readMetrics);
46
+ const metrics = useQuery({
47
+ queryKey: ["machine-status", "metrics", host.id],
48
+ queryFn: () => read({}),
49
+ refetchInterval: paused ? false : 3_000,
50
+ });
51
+ const data = metrics.data;
52
+ const history = data?.history ?? [];
53
+
54
+ // Fixed plot widths: the chart occupies the whole card and fills from the right.
55
+ const sparkSlots = layout.compact ? 20 : 40;
56
+ const chartSlots = layout.compact ? 45 : 90;
57
+ const stackSlots = layout.compact ? 24 : 45;
58
+ const span = spanLabel(history[0]?.at, history[history.length - 1]?.at);
59
+
60
+ const cpuSeries = useMemo(() => history.map((sample) => sample.cpuLoadPercent), [history]);
61
+ const pressureSeries = useMemo(
62
+ () => history.map((sample) => sample.pressurePercent ?? 0),
63
+ [history],
64
+ );
65
+ const swapSeries = useMemo(
66
+ () => history.map((sample) => sample.swapUsedBytes / GB),
67
+ [history],
68
+ );
69
+ const memColumns = useMemo(
70
+ () =>
71
+ history.map((sample) => ({
72
+ parts: [sample.appBytes, sample.wiredBytes, sample.compressedBytes],
73
+ total: data?.memory.totalBytes ?? 1,
74
+ })),
75
+ [history, data?.memory.totalBytes],
76
+ );
77
+
78
+ // The pill and its card colour by the same thresholds; so does this tab.
79
+ const cpuSeverity = cpuSeverityOf(data?.cpu.loadPercent ?? null);
80
+ const memorySeverity = memorySeverityOf(data?.memory.pressurePercent ?? null);
81
+ const diskSeverity = severityOf(data?.disk.usedPercent ?? null, 85, 95);
82
+ const swapPercent =
83
+ data && data.swap.totalBytes > 0 ? (data.swap.usedBytes / data.swap.totalBytes) * 100 : 0;
84
+
85
+ return (
86
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
87
+ <View style={styles.row}>
88
+ <View style={{ gap: 2, flexShrink: 1 }}>
89
+ <Text style={styles.heading}>{data?.hostname ?? host.label}</Text>
90
+ <Text style={styles.muted}>
91
+ {data
92
+ ? `${data.platform} · up ${formatDuration(data.uptimeSeconds)}`
93
+ : "Reading metrics…"}
94
+ </Text>
95
+ </View>
96
+ <View style={{ alignItems: "flex-end", gap: 6 }}>
97
+ <Text style={styles.axis}>
98
+ {paused ? "Paused" : `Live · every 3s · ${span}`}
99
+ </Text>
100
+ <Pressable
101
+ accessibilityRole="button"
102
+ accessibilityLabel={paused ? "Resume sampling" : "Pause sampling"}
103
+ onPress={() => setPaused((value) => !value)}
104
+ style={({ pressed }) => [styles.chip, { opacity: pressed ? 0.7 : 1 }]}
105
+ >
106
+ <Text style={styles.value}>{paused ? "Resume" : "Pause"}</Text>
107
+ </Pressable>
108
+ </View>
109
+ </View>
110
+
111
+ {metrics.error ? (
112
+ <Card chrome={chrome} title="Metrics unavailable">
113
+ <Text style={styles.danger}>{(metrics.error as Error).message}</Text>
114
+ </Card>
115
+ ) : null}
116
+
117
+ {data ? (
118
+ <>
119
+ <View style={styles.tiles}>
120
+ <StatTile
121
+ chrome={chrome}
122
+ label="CPU"
123
+ labelColor={palette.series[0]}
124
+ meta={`${data.cpu.cores} cores`}
125
+ value={data.cpu.loadAvg1.toFixed(2)}
126
+ valueSuffix="load, 1 min"
127
+ valueColor={cpuSeverity === "good" ? undefined : STATUS[cpuSeverity]}
128
+ expanded={expanded === "cpu"}
129
+ onPress={() => toggle("cpu")}
130
+ footer={
131
+ <Text style={styles.muted}>
132
+ 5 min {data.cpu.loadAvg5.toFixed(2)} · 15 min {data.cpu.loadAvg15.toFixed(2)} ·{" "}
133
+ {SEVERITY_LABEL[cpuSeverity]}
134
+ </Text>
135
+ }
136
+ >
137
+ <Sparkbars
138
+ values={cpuSeries}
139
+ min={0}
140
+ max={100}
141
+ slots={sparkSlots}
142
+ color={palette.series[0]}
143
+ />
144
+ </StatTile>
145
+
146
+ <StatTile
147
+ chrome={chrome}
148
+ label="MEMORY"
149
+ labelColor={palette.series[1]}
150
+ meta={formatBytes(data.memory.totalBytes)}
151
+ value={formatPercent(data.memory.pressurePercent)}
152
+ valueSuffix="pressure"
153
+ valueColor={memorySeverity === "good" ? undefined : STATUS[memorySeverity]}
154
+ expanded={expanded === "memory"}
155
+ onPress={() => toggle("memory")}
156
+ footer={
157
+ <Text style={styles.muted}>
158
+ {formatBytes(data.memory.usedBytes)} used · {SEVERITY_LABEL[memorySeverity]}
159
+ </Text>
160
+ }
161
+ >
162
+ <Sparkbars
163
+ values={pressureSeries}
164
+ min={0}
165
+ max={100}
166
+ slots={sparkSlots}
167
+ color={palette.series[1]}
168
+ />
169
+ </StatTile>
170
+
171
+ <StatTile
172
+ chrome={chrome}
173
+ label="SWAP"
174
+ labelColor={palette.series[2]}
175
+ meta={formatBytes(data.swap.totalBytes)}
176
+ value={formatBytes(data.swap.usedBytes)}
177
+ valueSuffix="in use"
178
+ expanded={expanded === "swap"}
179
+ onPress={() => toggle("swap")}
180
+ footer={<Text style={styles.muted}>{formatBytes(data.swap.freeBytes)} free</Text>}
181
+ >
182
+ <Sparkbars
183
+ values={swapSeries}
184
+ min={0}
185
+ max={Math.max(data.swap.totalBytes / GB, 0.1)}
186
+ slots={sparkSlots}
187
+ color={palette.series[2]}
188
+ />
189
+ </StatTile>
190
+
191
+ <StatTile
192
+ chrome={chrome}
193
+ label="DISK"
194
+ labelColor={palette.series[3]}
195
+ meta={data.disk.mountedOn}
196
+ value={formatPercent(data.disk.usedPercent)}
197
+ valueSuffix="used"
198
+ valueColor={diskSeverity === "good" ? undefined : STATUS[diskSeverity]}
199
+ expanded={expanded === "disk"}
200
+ onPress={() => toggle("disk")}
201
+ footer={<Text style={styles.muted}>{formatBytes(data.disk.freeBytes)} free</Text>}
202
+ >
203
+ <View style={{ height: 34, justifyContent: "center" }}>
204
+ <Meter
205
+ percent={data.disk.usedPercent}
206
+ color={diskSeverity === "good" ? palette.series[3] : STATUS[diskSeverity]}
207
+ palette={palette}
208
+ />
209
+ </View>
210
+ </StatTile>
211
+ </View>
212
+
213
+ {expanded === "cpu" ? (
214
+ <DetailCard chrome={chrome} title="CPU · detail" onClose={() => setExpanded(null)}>
215
+ <MetricRow chrome={chrome} label="Load 1 min" value={data.cpu.loadAvg1.toFixed(2)} />
216
+ <MetricRow chrome={chrome} label="Load 5 min" value={data.cpu.loadAvg5.toFixed(2)} />
217
+ <MetricRow chrome={chrome} label="Load 15 min" value={data.cpu.loadAvg15.toFixed(2)} />
218
+ <MetricRow
219
+ chrome={chrome}
220
+ label="Per core"
221
+ value={`${formatPercent(data.cpu.loadPercent)} · ${SEVERITY_LABEL[cpuSeverity]}`}
222
+ />
223
+ <Text style={styles.muted}>
224
+ Per-core utilisation needs a native call macOS does not expose to the shell, so it is
225
+ not shown rather than estimated.
226
+ </Text>
227
+ </DetailCard>
228
+ ) : null}
229
+
230
+ {expanded === "memory" ? (
231
+ <DetailCard chrome={chrome} title="Memory · detail" onClose={() => setExpanded(null)}>
232
+ {[
233
+ { label: "App", bytes: data.memory.appBytes, color: palette.series[0] },
234
+ { label: "Wired", bytes: data.memory.wiredBytes, color: palette.series[1] },
235
+ { label: "Compressed", bytes: data.memory.compressedBytes, color: palette.series[2] },
236
+ { label: "Cached files", bytes: data.memory.cachedBytes, color: palette.neutral },
237
+ ].map((row) => (
238
+ <View key={row.label} style={{ gap: 4 }}>
239
+ <View style={styles.row}>
240
+ <Text style={styles.muted}>{row.label}</Text>
241
+ <Text style={styles.value}>{formatBytes(row.bytes)}</Text>
242
+ </View>
243
+ <Meter
244
+ percent={(row.bytes / data.memory.totalBytes) * 100}
245
+ color={row.color}
246
+ palette={palette}
247
+ height={6}
248
+ />
249
+ </View>
250
+ ))}
251
+ </DetailCard>
252
+ ) : null}
253
+
254
+ {expanded === "swap" ? (
255
+ <DetailCard chrome={chrome} title="Swap · detail" onClose={() => setExpanded(null)}>
256
+ <ColumnChart
257
+ chrome={chrome}
258
+ values={swapSeries}
259
+ min={0}
260
+ max={Math.max(data.swap.totalBytes / GB, 0.1)}
261
+ color={palette.series[2]}
262
+ palette={palette}
263
+ slots={chartSlots}
264
+ height={110}
265
+ topLabel={`${(data.swap.totalBytes / GB).toFixed(1)}G`}
266
+ midLabel={`${(data.swap.totalBytes / GB / 2).toFixed(1)}G`}
267
+ bottomLabel="0"
268
+ caption={span}
269
+ />
270
+ <MetricRow chrome={chrome} label="In use" value={formatBytes(data.swap.usedBytes)} />
271
+ <MetricRow chrome={chrome} label="Free" value={formatBytes(data.swap.freeBytes)} />
272
+ </DetailCard>
273
+ ) : null}
274
+
275
+ {expanded === "disk" ? (
276
+ <DetailCard chrome={chrome} title="Disk · detail" onClose={() => setExpanded(null)}>
277
+ <Meter
278
+ percent={data.disk.usedPercent}
279
+ color={diskSeverity === "good" ? palette.series[3] : STATUS[diskSeverity]}
280
+ palette={palette}
281
+ height={12}
282
+ />
283
+ <MetricRow chrome={chrome} label="Volume" value={data.disk.mountedOn} />
284
+ <MetricRow chrome={chrome} label="Used" value={formatBytes(data.disk.usedBytes)} />
285
+ <MetricRow chrome={chrome} label="Free" value={formatBytes(data.disk.freeBytes)} />
286
+ </DetailCard>
287
+ ) : null}
288
+
289
+ {/* The design pairs the two time charts on one band, 1.15fr : 1fr. */}
290
+ <View style={styles.band}>
291
+ <View style={{ flex: 1.15 }}>
292
+ <Card
293
+ chrome={chrome}
294
+ title="CPU load over time"
295
+ subtitle={`${formatPercent(data.cpu.loadPercent)} · ${SEVERITY_LABEL[cpuSeverity]}`}
296
+ >
297
+ <ColumnChart
298
+ chrome={chrome}
299
+ values={cpuSeries}
300
+ min={0}
301
+ max={100}
302
+ color={palette.series[0]}
303
+ palette={palette}
304
+ slots={chartSlots}
305
+ height={layout.compact ? 110 : 180}
306
+ topLabel="100%"
307
+ midLabel="50%"
308
+ bottomLabel="0%"
309
+ caption={span}
310
+ />
311
+ </Card>
312
+ </View>
313
+ <View style={{ flex: 1 }}>
314
+ <Card
315
+ chrome={chrome}
316
+ title="Memory over time"
317
+ subtitle={`${formatBytes(data.memory.usedBytes)} of ${formatBytes(data.memory.totalBytes)}`}
318
+ >
319
+ <StackedColumnChart
320
+ chrome={chrome}
321
+ columns={memColumns}
322
+ colors={[palette.series[0], palette.series[1], palette.series[2]]}
323
+ palette={palette}
324
+ slots={stackSlots}
325
+ height={layout.compact ? 110 : 180}
326
+ topLabel={formatBytes(data.memory.totalBytes, 0)}
327
+ midLabel={formatBytes(data.memory.totalBytes / 2, 0)}
328
+ bottomLabel="0"
329
+ caption={span}
330
+ />
331
+ <Legend
332
+ chrome={chrome}
333
+ items={[
334
+ { label: "App", value: formatBytes(data.memory.appBytes), color: palette.series[0] },
335
+ { label: "Wired", value: formatBytes(data.memory.wiredBytes), color: palette.series[1] },
336
+ {
337
+ label: "Compressed",
338
+ value: formatBytes(data.memory.compressedBytes),
339
+ color: palette.series[2],
340
+ },
341
+ ]}
342
+ />
343
+ <Text style={styles.muted}>
344
+ Headroom above the stack is unused memory · cached files{" "}
345
+ {formatBytes(data.memory.cachedBytes)}
346
+ </Text>
347
+ </Card>
348
+ </View>
349
+ </View>
350
+ </>
351
+ ) : null}
352
+
353
+ {/* Bottom band, 360px : 1fr — resources on the left, processes on the right. */}
354
+ <View style={styles.band}>
355
+ <View style={[{ gap: layout.compact ? 10 : 14 }, layout.compact ? null : { width: 360 }]}>
356
+ {data ? (
357
+ <Card chrome={chrome} title="Disk & swap" subtitle={data.disk.mountedOn}>
358
+ <View style={{ gap: 6 }}>
359
+ <View style={styles.row}>
360
+ <Text style={styles.muted}>Capacity used</Text>
361
+ <Text style={styles.valueStrong}>{formatPercent(data.disk.usedPercent)}</Text>
362
+ </View>
363
+ <Meter
364
+ percent={data.disk.usedPercent}
365
+ color={diskSeverity === "good" ? palette.series[3] : STATUS[diskSeverity]}
366
+ palette={palette}
367
+ height={10}
368
+ />
369
+ </View>
370
+ <MetricRow chrome={chrome} label="Used" value={formatBytes(data.disk.usedBytes)} />
371
+ <MetricRow chrome={chrome} label="Free" value={formatBytes(data.disk.freeBytes)} />
372
+
373
+ <View style={styles.divider} />
374
+
375
+ <View style={{ gap: 6 }}>
376
+ <View style={styles.row}>
377
+ <Text style={styles.muted}>Swap in use</Text>
378
+ <Text style={styles.valueStrong}>
379
+ {formatBytes(data.swap.usedBytes)} of {formatBytes(data.swap.totalBytes)}
380
+ </Text>
381
+ </View>
382
+ <Meter percent={swapPercent} color={palette.series[2]} palette={palette} height={10} />
383
+ </View>
384
+ <MetricRow chrome={chrome} label="Free" value={formatBytes(data.swap.freeBytes)} />
385
+
386
+ <View style={styles.divider} />
387
+
388
+ <MetricRow
389
+ chrome={chrome}
390
+ label="Power"
391
+ value={
392
+ data.power.source === "ac"
393
+ ? `AC power${data.power.percent === null ? "" : ` · ${formatPercent(data.power.percent)}`}`
394
+ : data.power.source === "battery"
395
+ ? `Battery · ${formatPercent(data.power.percent)}`
396
+ : "Unknown"
397
+ }
398
+ />
399
+ {data.power.timeRemainingMinutes !== null ? (
400
+ <MetricRow
401
+ chrome={chrome}
402
+ label="Remaining"
403
+ value={formatMinutes(data.power.timeRemainingMinutes)}
404
+ />
405
+ ) : null}
406
+ <MetricRow
407
+ chrome={chrome}
408
+ label="Thermal"
409
+ value={THERMAL_LABEL[data.thermal.level] ?? data.thermal.level}
410
+ />
411
+ </Card>
412
+ ) : null}
413
+ <CleanupSection chrome={chrome} hostId={host.id} />
414
+ </View>
415
+ <View style={{ flex: 1 }}>
416
+ <ProcessesSection chrome={chrome} hostId={host.id} palette={palette} />
417
+ </View>
418
+ </View>
419
+ </ScrollView>
420
+ );
421
+ }
422
+
423
+ function DetailCard({
424
+ chrome,
425
+ title,
426
+ onClose,
427
+ children,
428
+ }: {
429
+ chrome: Chrome;
430
+ title: string;
431
+ onClose: () => void;
432
+ children: React.ReactNode;
433
+ }) {
434
+ const styles = useChromeStyles(chrome);
435
+ return (
436
+ <View style={[styles.card, { borderColor: chrome.theme.colors.accent }]}>
437
+ <View style={styles.row}>
438
+ <Text style={styles.cardTitle}>{title}</Text>
439
+ <ActionButton chrome={chrome} variant="ghost" label="Close" onPress={onClose} />
440
+ </View>
441
+ {children}
442
+ </View>
443
+ );
444
+ }
445
+
446
+
447
+ type TabId = "overview" | "services" | "docker";
448
+
449
+ const TABS: readonly { id: TabId; title: string }[] = [
450
+ { id: "overview", title: "Overview" },
451
+ { id: "services", title: "Ports & services" },
452
+ { id: "docker", title: "Docker" },
453
+ ];
454
+
455
+ /**
456
+ * Only the selected tab is mounted. The services tab shells out to netstat, lsof
457
+ * and docker on a timer, and mounting it in the background would keep that
458
+ * running for a panel nobody is looking at.
459
+ */
460
+ export function MachineStatusSurface(props: PluginSurfaceProps) {
461
+ const chrome: Chrome = { theme: props.theme, compact: props.layout.compact };
462
+ const styles = useChromeStyles(chrome);
463
+ const [tab, setTab] = useState<TabId>("overview");
464
+
465
+ return (
466
+ <ContextMenuProvider chrome={chrome}>
467
+ <View style={styles.screen}>
468
+ <View
469
+ style={{
470
+ flexDirection: "row",
471
+ gap: props.layout.compact ? 16 : 24,
472
+ paddingHorizontal: props.layout.compact ? 12 : 20,
473
+ paddingTop: props.layout.compact ? 10 : 14,
474
+ borderBottomWidth: StyleSheet.hairlineWidth,
475
+ borderBottomColor: paletteFor(props.theme.colors.surface0).border,
476
+ }}
477
+ >
478
+ {TABS.map((entry) => {
479
+ const active = entry.id === tab;
480
+ return (
481
+ <Pressable
482
+ key={entry.id}
483
+ accessibilityRole="tab"
484
+ accessibilityState={{ selected: active }}
485
+ accessibilityLabel={entry.title}
486
+ onPress={() => setTab(entry.id)}
487
+ style={({ pressed }) => ({
488
+ paddingBottom: 10,
489
+ borderBottomWidth: 2,
490
+ borderBottomColor: active ? props.theme.colors.accent : "transparent",
491
+ opacity: pressed ? 0.7 : 1,
492
+ })}
493
+ >
494
+ <Text
495
+ style={[
496
+ styles.value,
497
+ { fontWeight: "600" },
498
+ active ? null : { color: props.theme.colors.foregroundMuted },
499
+ ]}
500
+ >
501
+ {entry.title}
502
+ </Text>
503
+ </Pressable>
504
+ );
505
+ })}
506
+ </View>
507
+
508
+ {tab === "overview" ? <OverviewTab {...props} /> : null}
509
+ {tab === "services" ? <ServicesTab {...props} /> : null}
510
+ {tab === "docker" ? <DockerTab {...props} /> : null}
511
+ </View>
512
+ </ContextMenuProvider>
513
+ );
514
+ }