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