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,335 @@
1
+ import React, { useMemo } from "react";
2
+ import { Pressable, StyleSheet, Text, View } from "react-native";
3
+ import { columnHeights, padSlots, paletteFor, stackedColumn, type VizPalette } from "../shared/viz";
4
+ import { useChromeStyles, type Chrome } from "./ui";
5
+
6
+ /**
7
+ * React Native has no SVG and Paseo's client bundle cannot import one, so every
8
+ * chart here is built from Views. A line becomes a column per sample, which is a
9
+ * legitimate reading of the same series rather than a downgrade: each sample stays
10
+ * individually visible instead of being interpolated into a curve.
11
+ */
12
+
13
+ const BASELINE_RADIUS = 2;
14
+
15
+ export function Sparkbars({
16
+ values,
17
+ min,
18
+ max,
19
+ color,
20
+ slots,
21
+ height = 34,
22
+ }: {
23
+ values: readonly number[];
24
+ min: number;
25
+ max: number;
26
+ color: string;
27
+ slots: number;
28
+ height?: number;
29
+ }) {
30
+ const cells = useMemo(
31
+ () => padSlots(columnHeights(values, min, max), slots),
32
+ [values, min, max, slots],
33
+ );
34
+ return (
35
+ <View style={{ height, flexDirection: "row", alignItems: "flex-end", gap: 1 }}>
36
+ {cells.map((value, index) =>
37
+ value === null ? (
38
+ <View key={index} style={{ flex: 1 }} />
39
+ ) : (
40
+ <View
41
+ key={index}
42
+ style={{
43
+ flex: 1,
44
+ height: `${Math.max(value, 2)}%`,
45
+ backgroundColor: color,
46
+ borderTopLeftRadius: BASELINE_RADIUS,
47
+ borderTopRightRadius: BASELINE_RADIUS,
48
+ }}
49
+ />
50
+ ),
51
+ )}
52
+ </View>
53
+ );
54
+ }
55
+
56
+ function Gridlines({ color }: { color: string }) {
57
+ return (
58
+ <View style={StyleSheet.absoluteFill} pointerEvents="none">
59
+ {[0, 50, 100].map((position) => (
60
+ <View
61
+ key={position}
62
+ style={{
63
+ position: "absolute",
64
+ top: `${position}%`,
65
+ left: 0,
66
+ right: 0,
67
+ height: StyleSheet.hairlineWidth,
68
+ backgroundColor: color,
69
+ }}
70
+ />
71
+ ))}
72
+ </View>
73
+ );
74
+ }
75
+
76
+ /** Axis captions sit outside the plot; the grid stays recessive behind the marks. */
77
+ export function ColumnChart({
78
+ chrome,
79
+ values,
80
+ min,
81
+ max,
82
+ color,
83
+ palette,
84
+ slots,
85
+ height = 140,
86
+ topLabel,
87
+ midLabel,
88
+ bottomLabel,
89
+ caption,
90
+ }: {
91
+ chrome: Chrome;
92
+ values: readonly number[];
93
+ min: number;
94
+ max: number;
95
+ color: string;
96
+ palette: VizPalette;
97
+ slots: number;
98
+ height?: number;
99
+ topLabel: string;
100
+ midLabel: string;
101
+ bottomLabel: string;
102
+ caption?: string;
103
+ }) {
104
+ const styles = useChromeStyles(chrome);
105
+ const cells = useMemo(
106
+ () => padSlots(columnHeights(values, min, max), slots),
107
+ [values, min, max, slots],
108
+ );
109
+
110
+ return (
111
+ <View style={{ gap: 6 }}>
112
+ <View style={{ flexDirection: "row", gap: 8 }}>
113
+ <View style={{ height, justifyContent: "space-between", alignItems: "flex-end", width: 34 }}>
114
+ <Text style={styles.axis}>{topLabel}</Text>
115
+ <Text style={styles.axis}>{midLabel}</Text>
116
+ <Text style={styles.axis}>{bottomLabel}</Text>
117
+ </View>
118
+ <View style={{ flex: 1, height }}>
119
+ <Gridlines color={palette.grid} />
120
+ <View
121
+ style={{ flex: 1, flexDirection: "row", alignItems: "flex-end", gap: 1 }}
122
+ >
123
+ {cells.map((value, index) =>
124
+ value === null ? (
125
+ <View key={index} style={{ flex: 1 }} />
126
+ ) : (
127
+ <View
128
+ key={index}
129
+ style={{
130
+ flex: 1,
131
+ height: `${Math.max(value, 2)}%`,
132
+ backgroundColor: color,
133
+ borderTopLeftRadius: BASELINE_RADIUS,
134
+ borderTopRightRadius: BASELINE_RADIUS,
135
+ }}
136
+ />
137
+ ),
138
+ )}
139
+ </View>
140
+ </View>
141
+ </View>
142
+ {caption ? <Text style={[styles.axis, { paddingLeft: 42 }]}>{caption}</Text> : null}
143
+ </View>
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Stacked columns for a part-to-whole series over time. Segments carry a 2px
149
+ * surface gap so adjacent fills stay separable without relying on hue alone.
150
+ */
151
+ export function StackedColumnChart({
152
+ chrome,
153
+ columns,
154
+ colors,
155
+ palette,
156
+ slots,
157
+ height = 140,
158
+ topLabel,
159
+ midLabel,
160
+ bottomLabel,
161
+ caption,
162
+ }: {
163
+ chrome: Chrome;
164
+ /** Each entry is one column's parts, plus the total they are measured against. */
165
+ columns: readonly { parts: readonly number[]; total: number }[];
166
+ colors: readonly string[];
167
+ palette: VizPalette;
168
+ slots: number;
169
+ height?: number;
170
+ topLabel: string;
171
+ midLabel: string;
172
+ bottomLabel: string;
173
+ caption?: string;
174
+ }) {
175
+ const styles = useChromeStyles(chrome);
176
+ const stacks = useMemo(
177
+ () => padSlots(columns.map((column) => stackedColumn(column.parts, column.total)), slots),
178
+ [columns, slots],
179
+ );
180
+
181
+ return (
182
+ <View style={{ gap: 6 }}>
183
+ <View style={{ flexDirection: "row", gap: 8 }}>
184
+ <View style={{ height, justifyContent: "space-between", alignItems: "flex-end", width: 34 }}>
185
+ <Text style={styles.axis}>{topLabel}</Text>
186
+ <Text style={styles.axis}>{midLabel}</Text>
187
+ <Text style={styles.axis}>{bottomLabel}</Text>
188
+ </View>
189
+ <View style={{ flex: 1, height }}>
190
+ <Gridlines color={palette.grid} />
191
+ <View style={{ flex: 1, flexDirection: "row", alignItems: "flex-end", gap: 2 }}>
192
+ {stacks.map((segments, index) =>
193
+ segments === null ? (
194
+ <View key={index} style={{ flex: 1 }} />
195
+ ) : (
196
+ <View
197
+ key={index}
198
+ style={{ flex: 1, height: "100%", flexDirection: "column-reverse" }}
199
+ >
200
+ {segments.map((segment, part) => {
201
+ const fill = colors[part];
202
+ // The last segment is the unused remainder: it reads as headroom,
203
+ // so it stays empty rather than becoming a grey category block.
204
+ if (!fill) return <View key={part} style={{ height: `${segment}%` }} />;
205
+ return (
206
+ <View
207
+ key={part}
208
+ style={{
209
+ height: `${segment}%`,
210
+ backgroundColor: fill,
211
+ borderTopWidth: part > 0 ? 2 : 0,
212
+ borderTopColor: chrome.theme.colors.surface0,
213
+ }}
214
+ />
215
+ );
216
+ })}
217
+ </View>
218
+ ),
219
+ )}
220
+ </View>
221
+ </View>
222
+ </View>
223
+ {caption ? <Text style={[styles.axis, { paddingLeft: 42 }]}>{caption}</Text> : null}
224
+ </View>
225
+ );
226
+ }
227
+
228
+ /**
229
+ * A single ratio against a limit. This replaces the donut the design used: a ring
230
+ * encodes one number worse than a track does, and it cannot be drawn without SVG.
231
+ */
232
+ export function Meter({
233
+ percent,
234
+ color,
235
+ palette,
236
+ height = 8,
237
+ }: {
238
+ percent: number;
239
+ color: string;
240
+ palette: VizPalette;
241
+ height?: number;
242
+ }) {
243
+ const width = Math.max(0, Math.min(100, percent));
244
+ return (
245
+ <View
246
+ style={{
247
+ height,
248
+ borderRadius: height / 2,
249
+ backgroundColor: palette.neutral,
250
+ overflow: "hidden",
251
+ }}
252
+ >
253
+ <View style={{ width: `${width}%`, height: "100%", borderRadius: height / 2, backgroundColor: color }} />
254
+ </View>
255
+ );
256
+ }
257
+
258
+ /** Identity never rests on colour alone: every swatch ships with its label and value. */
259
+ export function Legend({
260
+ chrome,
261
+ items,
262
+ }: {
263
+ chrome: Chrome;
264
+ items: readonly { label: string; value: string; color: string }[];
265
+ }) {
266
+ const styles = useChromeStyles(chrome);
267
+ return (
268
+ <View style={styles.rowWrap}>
269
+ {items.map((item) => (
270
+ <View key={item.label} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
271
+ <View style={{ width: 9, height: 9, borderRadius: 2, backgroundColor: item.color }} />
272
+ <Text style={styles.muted}>{item.label}</Text>
273
+ <Text style={styles.value}>{item.value}</Text>
274
+ </View>
275
+ ))}
276
+ </View>
277
+ );
278
+ }
279
+
280
+ export function StatTile({
281
+ chrome,
282
+ label,
283
+ labelColor,
284
+ meta,
285
+ value,
286
+ valueSuffix,
287
+ valueColor,
288
+ expanded,
289
+ onPress,
290
+ children,
291
+ footer,
292
+ }: {
293
+ chrome: Chrome;
294
+ label: string;
295
+ labelColor: string;
296
+ meta: string;
297
+ value: string;
298
+ valueSuffix: string;
299
+ valueColor?: string;
300
+ expanded: boolean;
301
+ onPress: () => void;
302
+ children?: React.ReactNode;
303
+ footer?: React.ReactNode;
304
+ }) {
305
+ const styles = useChromeStyles(chrome);
306
+ const palette = paletteFor(chrome.theme.colors.surface0);
307
+ return (
308
+ <Pressable
309
+ accessibilityRole="button"
310
+ accessibilityState={{ expanded }}
311
+ accessibilityLabel={`${label}, ${value} ${valueSuffix}`}
312
+ onPress={onPress}
313
+ style={({ pressed }) => [
314
+ styles.card,
315
+ {
316
+ flexGrow: 1,
317
+ flexBasis: chrome.compact ? "100%" : 200,
318
+ borderColor: expanded ? chrome.theme.colors.accent : palette.border,
319
+ opacity: pressed ? 0.8 : 1,
320
+ },
321
+ ]}
322
+ >
323
+ <View style={styles.row}>
324
+ <Text style={[styles.cardTitle, { color: labelColor }]}>{label}</Text>
325
+ <Text style={styles.axis}>{meta}</Text>
326
+ </View>
327
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
328
+ <Text style={[styles.figure, valueColor ? { color: valueColor } : null]}>{value}</Text>
329
+ <Text style={styles.muted}>{valueSuffix}</Text>
330
+ </View>
331
+ {children}
332
+ {footer}
333
+ </Pressable>
334
+ );
335
+ }
@@ -0,0 +1,169 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import { useMutation, useQuery } from "@tanstack/react-query";
3
+ import React, { useState } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+ import { applyCleanup, planCleanup, type CleanupTarget } from "../shared/contracts";
6
+ import { formatBytes } from "../shared/format";
7
+ import { ActionButton, Card, useArmedConfirm, useChromeStyles, type Chrome } from "./ui";
8
+ import { paletteFor } from "../shared/viz";
9
+
10
+ /**
11
+ * Scanning walks real directories with `du`, so it never runs on a timer — only
12
+ * when asked. The result is kept in the query cache so reopening the surface
13
+ * shows the last scan instead of re-walking the disk.
14
+ */
15
+ export function CleanupSection({ chrome, hostId }: { chrome: Chrome; hostId: string }) {
16
+ const styles = useChromeStyles(chrome);
17
+ const palette = paletteFor(chrome.theme.colors.surface0);
18
+ const plan = useRpc(planCleanup);
19
+ const apply = useRpc(applyCleanup);
20
+ const confirm = useArmedConfirm();
21
+
22
+ /** Absent entries fall back to "selected when the cache exists". */
23
+ const [overrides, setOverrides] = useState<Record<string, boolean>>({});
24
+
25
+ const scan = useQuery({
26
+ queryKey: ["machine-status", "cleanup", hostId],
27
+ queryFn: () => plan({}),
28
+ enabled: false,
29
+ staleTime: Number.POSITIVE_INFINITY,
30
+ });
31
+
32
+ const targets = scan.data?.targets ?? [];
33
+ const isSelected = (target: CleanupTarget) => overrides[target.id] ?? target.exists;
34
+ const selected = targets.filter((target) => target.exists && isSelected(target));
35
+ const selectedBytes = selected.reduce((sum, target) => sum + target.sizeBytes, 0);
36
+
37
+ const cleanup = useMutation({
38
+ mutationFn: () => apply({ targetIds: selected.map((target) => target.id) }),
39
+ onSettled: () => {
40
+ confirm.disarm();
41
+ setOverrides({});
42
+ void scan.refetch();
43
+ },
44
+ });
45
+
46
+ const toggle = (target: CleanupTarget) => {
47
+ if (!target.exists) return;
48
+ cleanup.reset();
49
+ setOverrides((current) => ({ ...current, [target.id]: !isSelected(target) }));
50
+ };
51
+
52
+ return (
53
+ <Card
54
+ chrome={chrome}
55
+ title="Dev caches"
56
+ subtitle={scan.data ? formatBytes(scan.data.totalBytes) + " total" : undefined}
57
+ >
58
+ {scan.error ? <Text style={styles.danger}>{(scan.error as Error).message}</Text> : null}
59
+
60
+ {!scan.data ? (
61
+ <View style={{ gap: 8 }}>
62
+ <Text style={styles.muted}>
63
+ Measures package-manager and build caches. Nothing is removed until you confirm.
64
+ </Text>
65
+ <ActionButton
66
+ chrome={chrome}
67
+ label="Scan dev caches"
68
+ busy={scan.isFetching}
69
+ onPress={() => void scan.refetch()}
70
+ />
71
+ </View>
72
+ ) : (
73
+ <View style={{ gap: 8 }}>
74
+ {targets.map((target) => {
75
+ const active = isSelected(target);
76
+ return (
77
+ <Pressable
78
+ key={target.id}
79
+ accessibilityRole="checkbox"
80
+ accessibilityState={{ checked: active, disabled: !target.exists }}
81
+ accessibilityLabel={`${target.label}, ${formatBytes(target.sizeBytes)}`}
82
+ disabled={!target.exists}
83
+ onPress={() => toggle(target)}
84
+ style={({ pressed }) => [
85
+ styles.selectableRow,
86
+ {
87
+ borderColor: active ? chrome.theme.colors.accent : palette.border,
88
+ opacity: !target.exists ? 0.45 : pressed ? 0.7 : 1,
89
+ },
90
+ ]}
91
+ >
92
+ <View style={styles.row}>
93
+ <Text style={styles.value}>
94
+ {active && target.exists ? "◉ " : "○ "}
95
+ {target.label}
96
+ </Text>
97
+ <Text style={styles.valueStrong}>
98
+ {target.exists ? formatBytes(target.sizeBytes) : "not present"}
99
+ </Text>
100
+ </View>
101
+ <Text style={styles.muted} numberOfLines={1}>
102
+ {target.path}
103
+ </Text>
104
+ {target.exists ? <Text style={styles.muted}>{target.hint}</Text> : null}
105
+ {target.error ? <Text style={styles.danger}>{target.error}</Text> : null}
106
+ </Pressable>
107
+ );
108
+ })}
109
+
110
+ {cleanup.data ? (
111
+ <View style={{ gap: 4 }}>
112
+ <Text style={styles.valueStrong}>Freed {formatBytes(cleanup.data.freedBytes)}</Text>
113
+ {cleanup.data.results
114
+ .filter((result) => !result.ok)
115
+ .map((result) => (
116
+ <Text key={result.id} style={styles.danger}>
117
+ {result.label}: {result.error}
118
+ </Text>
119
+ ))}
120
+ </View>
121
+ ) : null}
122
+
123
+ {cleanup.error ? (
124
+ <Text style={styles.danger}>{(cleanup.error as Error).message}</Text>
125
+ ) : null}
126
+
127
+ <View style={{ gap: 8 }}>
128
+ {confirm.armed ? (
129
+ <>
130
+ <Text style={styles.danger}>
131
+ Deletes the contents of {selected.length} cache
132
+ {selected.length === 1 ? "" : "s"}. Each tool re-creates its own.
133
+ </Text>
134
+ <ActionButton
135
+ chrome={chrome}
136
+ tone="danger"
137
+ label={`Confirm — delete ${formatBytes(selectedBytes)}`}
138
+ busy={cleanup.isPending}
139
+ onPress={() => cleanup.mutate()}
140
+ />
141
+ <ActionButton chrome={chrome} variant="ghost" label="Cancel" onPress={confirm.disarm} />
142
+ </>
143
+ ) : (
144
+ <>
145
+ <ActionButton
146
+ chrome={chrome}
147
+ label={
148
+ selected.length === 0
149
+ ? "Select a cache to clean"
150
+ : `Clean ${formatBytes(selectedBytes)} in ${selected.length} cache${selected.length === 1 ? "" : "s"}`
151
+ }
152
+ disabled={selected.length === 0}
153
+ onPress={confirm.arm}
154
+ />
155
+ <ActionButton
156
+ chrome={chrome}
157
+ variant="ghost"
158
+ label="Rescan"
159
+ busy={scan.isFetching}
160
+ onPress={() => void scan.refetch()}
161
+ />
162
+ </>
163
+ )}
164
+ </View>
165
+ </View>
166
+ )}
167
+ </Card>
168
+ );
169
+ }