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,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
|
+
}
|