@checkstack/script-packages-frontend 0.2.0
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/CHANGELOG.md +129 -0
- package/package.json +39 -0
- package/src/components/PackageNameCombobox.tsx +153 -0
- package/src/components/PackageVersionCombobox.tsx +166 -0
- package/src/components/ScriptPackagesMenuItems.tsx +35 -0
- package/src/components/version-autofill.test.ts +43 -0
- package/src/components/version-autofill.ts +34 -0
- package/src/hooks/useDebouncedValue.ts +18 -0
- package/src/index.tsx +45 -0
- package/src/pages/ScriptPackagesSettingsPage.tsx +783 -0
- package/src/useScriptPackageTypeAcquisition.ts +84 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Package, Trash2, Download, RefreshCw, Recycle } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
usePluginClient,
|
|
5
|
+
accessApiRef,
|
|
6
|
+
useApi,
|
|
7
|
+
wrapInSuspense,
|
|
8
|
+
} from "@checkstack/frontend-api";
|
|
9
|
+
import {
|
|
10
|
+
ScriptPackagesApi,
|
|
11
|
+
scriptPackagesAccess,
|
|
12
|
+
PackageVersionSchema,
|
|
13
|
+
} from "@checkstack/script-packages-common";
|
|
14
|
+
import { PackageNameCombobox } from "../components/PackageNameCombobox";
|
|
15
|
+
import { PackageVersionCombobox } from "../components/PackageVersionCombobox";
|
|
16
|
+
import {
|
|
17
|
+
applyLatestDistTag,
|
|
18
|
+
versionFromHit,
|
|
19
|
+
} from "../components/version-autofill";
|
|
20
|
+
import {
|
|
21
|
+
PageLayout,
|
|
22
|
+
Card,
|
|
23
|
+
CardHeader,
|
|
24
|
+
CardTitle,
|
|
25
|
+
CardContent,
|
|
26
|
+
Button,
|
|
27
|
+
Input,
|
|
28
|
+
Label,
|
|
29
|
+
Badge,
|
|
30
|
+
Toggle,
|
|
31
|
+
Alert,
|
|
32
|
+
AlertTitle,
|
|
33
|
+
AlertDescription,
|
|
34
|
+
AccessDenied,
|
|
35
|
+
LoadingSpinner,
|
|
36
|
+
Select,
|
|
37
|
+
SelectTrigger,
|
|
38
|
+
SelectValue,
|
|
39
|
+
SelectContent,
|
|
40
|
+
SelectItem,
|
|
41
|
+
Accordion,
|
|
42
|
+
AccordionItem,
|
|
43
|
+
AccordionTrigger,
|
|
44
|
+
AccordionContent,
|
|
45
|
+
ConfirmationModal,
|
|
46
|
+
usePerformance,
|
|
47
|
+
useInitOnceForKey,
|
|
48
|
+
cn,
|
|
49
|
+
} from "@checkstack/ui";
|
|
50
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
51
|
+
|
|
52
|
+
function mb(bytes: number): string {
|
|
53
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** MB (UI unit) → bytes (the size-cap schema unit). */
|
|
57
|
+
function mbToBytes(value: number): number {
|
|
58
|
+
return Math.round(value * 1024 * 1024);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SettingsContent: React.FC = () => {
|
|
62
|
+
const client = usePluginClient(ScriptPackagesApi);
|
|
63
|
+
const accessApi = useApi(accessApiRef);
|
|
64
|
+
const { allowed, loading: accessLoading } = accessApi.useAccess(
|
|
65
|
+
scriptPackagesAccess.manage,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const packagesQuery = client.listPackages.useQuery();
|
|
69
|
+
const installStateQuery = client.getInstallState.useQuery();
|
|
70
|
+
// gcTime: 0 on the loader queries that seed editable form state via
|
|
71
|
+
// useInitOnceForKey — otherwise stale-while-revalidate can race the
|
|
72
|
+
// one-shot init and reopened editors would show pre-mutation values.
|
|
73
|
+
const registryQuery = client.getRegistryConfig.useQuery(undefined, {
|
|
74
|
+
gcTime: 0,
|
|
75
|
+
});
|
|
76
|
+
const sizeCapQuery = client.getSizeCapConfig.useQuery(undefined, {
|
|
77
|
+
gcTime: 0,
|
|
78
|
+
});
|
|
79
|
+
const storageQuery = client.getStorageConfig.useQuery(undefined, {
|
|
80
|
+
gcTime: 0,
|
|
81
|
+
// Poll while a migration is running so progress + completion show live.
|
|
82
|
+
refetchInterval: (query) =>
|
|
83
|
+
query.state.data?.migrationStatus === "migrating" ? 1500 : false,
|
|
84
|
+
});
|
|
85
|
+
const backendsQuery = client.listStorageBackends.useQuery();
|
|
86
|
+
const satellitesQuery = client.listSatelliteSyncState.useQuery();
|
|
87
|
+
const blobGcQuery = client.getBlobGcState.useQuery();
|
|
88
|
+
|
|
89
|
+
const addMutation = client.addPackage.useMutation();
|
|
90
|
+
const removeMutation = client.removePackage.useMutation();
|
|
91
|
+
const setEnabledMutation = client.setPackageEnabled.useMutation();
|
|
92
|
+
const installMutation = client.installNow.useMutation();
|
|
93
|
+
const migrateMutation = client.migrateStorage.useMutation();
|
|
94
|
+
const gcMutation = client.gcBlobs.useMutation();
|
|
95
|
+
const setRegistryMutation = client.setRegistryConfig.useMutation();
|
|
96
|
+
const setSizeCapMutation = client.setSizeCapConfig.useMutation();
|
|
97
|
+
const setStorageBackendMutation = client.setStorageBackend.useMutation();
|
|
98
|
+
|
|
99
|
+
const { isLowPower } = usePerformance();
|
|
100
|
+
const [name, setName] = React.useState("");
|
|
101
|
+
const [version, setVersion] = React.useState("");
|
|
102
|
+
const [migrateTarget, setMigrateTarget] = React.useState<string>("");
|
|
103
|
+
const [confirmMigrate, setConfirmMigrate] = React.useState(false);
|
|
104
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
105
|
+
|
|
106
|
+
// Editable Advanced-config state, seeded once from the loader queries.
|
|
107
|
+
const [registryUrl, setRegistryUrl] = React.useState("");
|
|
108
|
+
const [ignoreScripts, setIgnoreScripts] = React.useState(true);
|
|
109
|
+
// Write-only: blank = leave the stored token untouched. The schema returns
|
|
110
|
+
// only `hasAuthToken`, never the value, so the input always starts empty.
|
|
111
|
+
const [authTokenInput, setAuthTokenInput] = React.useState("");
|
|
112
|
+
const [warnMb, setWarnMb] = React.useState("");
|
|
113
|
+
const [blockMb, setBlockMb] = React.useState("");
|
|
114
|
+
const [storageBackend, setStorageBackend] = React.useState("");
|
|
115
|
+
|
|
116
|
+
// Seed editable state once per persisted version. Using `updatedAt` as the
|
|
117
|
+
// key re-seeds the form after a successful save (the mutation invalidates
|
|
118
|
+
// the query → a fresh `updatedAt`) without clobbering in-progress edits on
|
|
119
|
+
// unrelated background refetches.
|
|
120
|
+
useInitOnceForKey(
|
|
121
|
+
registryQuery.data,
|
|
122
|
+
registryQuery.data?.updatedAt?.toISOString() ?? "registry",
|
|
123
|
+
(cfg) => {
|
|
124
|
+
setRegistryUrl(cfg.registryUrl);
|
|
125
|
+
setIgnoreScripts(cfg.ignoreScripts);
|
|
126
|
+
setAuthTokenInput("");
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
useInitOnceForKey(sizeCapQuery.data, "size-cap", (cfg) => {
|
|
130
|
+
setWarnMb(String(cfg.warnBytes / (1024 * 1024)));
|
|
131
|
+
setBlockMb(String(cfg.blockBytes / (1024 * 1024)));
|
|
132
|
+
});
|
|
133
|
+
useInitOnceForKey(
|
|
134
|
+
storageQuery.data,
|
|
135
|
+
storageQuery.data?.activeBackend ?? "storage",
|
|
136
|
+
(cfg) => {
|
|
137
|
+
setStorageBackend(cfg.activeBackend);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (accessLoading) return <LoadingSpinner />;
|
|
142
|
+
if (!allowed) {
|
|
143
|
+
return (
|
|
144
|
+
<PageLayout title="Script packages" icon={Package}>
|
|
145
|
+
<AccessDenied />
|
|
146
|
+
</PageLayout>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const packages = packagesQuery.data?.items ?? [];
|
|
151
|
+
const installState = installStateQuery.data;
|
|
152
|
+
const sizeCap = sizeCapQuery.data;
|
|
153
|
+
const storage = storageQuery.data;
|
|
154
|
+
const satellites = satellitesQuery.data?.items ?? [];
|
|
155
|
+
|
|
156
|
+
// Inline pinned-version validation: mirror the backend `addPackage`
|
|
157
|
+
// (PackageVersionSchema) so an invalid free-typed version is surfaced
|
|
158
|
+
// before submit rather than only as a server error. Empty → no message
|
|
159
|
+
// (the Add button is already disabled), non-empty-invalid → the rule text.
|
|
160
|
+
const trimmedVersion = version.trim();
|
|
161
|
+
const versionParse =
|
|
162
|
+
trimmedVersion.length > 0
|
|
163
|
+
? PackageVersionSchema.safeParse(trimmedVersion)
|
|
164
|
+
: undefined;
|
|
165
|
+
const versionError =
|
|
166
|
+
versionParse && !versionParse.success
|
|
167
|
+
? (versionParse.error.issues[0]?.message ?? "Invalid version")
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
const handleAdd = async () => {
|
|
171
|
+
setError(null);
|
|
172
|
+
try {
|
|
173
|
+
await addMutation.mutateAsync({ name: name.trim(), version: trimmedVersion });
|
|
174
|
+
setName("");
|
|
175
|
+
setVersion("");
|
|
176
|
+
} catch (error_) {
|
|
177
|
+
setError(extractErrorMessage(error_));
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleInstall = async () => {
|
|
182
|
+
setError(null);
|
|
183
|
+
try {
|
|
184
|
+
const res = await installMutation.mutateAsync({});
|
|
185
|
+
if (!res.started && res.reason) setError(res.reason);
|
|
186
|
+
} catch (error_) {
|
|
187
|
+
setError(extractErrorMessage(error_));
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleGc = async () => {
|
|
192
|
+
setError(null);
|
|
193
|
+
try {
|
|
194
|
+
const res = await gcMutation.mutateAsync({});
|
|
195
|
+
if (!res.ran && res.reason) setError(res.reason);
|
|
196
|
+
} catch (error_) {
|
|
197
|
+
setError(extractErrorMessage(error_));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleSaveRegistry = async () => {
|
|
202
|
+
setError(null);
|
|
203
|
+
try {
|
|
204
|
+
await setRegistryMutation.mutateAsync({
|
|
205
|
+
registryUrl: registryUrl.trim(),
|
|
206
|
+
// Scoped registries are not editable in this UI yet; preserve them.
|
|
207
|
+
scopedRegistries: registryQuery.data?.scopedRegistries ?? [],
|
|
208
|
+
ignoreScripts,
|
|
209
|
+
// Write-only: only send when the admin typed something. A blank field
|
|
210
|
+
// leaves the stored token untouched.
|
|
211
|
+
...(authTokenInput.length > 0 ? { authToken: authTokenInput } : {}),
|
|
212
|
+
});
|
|
213
|
+
setAuthTokenInput("");
|
|
214
|
+
} catch (error_) {
|
|
215
|
+
setError(extractErrorMessage(error_));
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const handleClearAuthToken = async () => {
|
|
220
|
+
setError(null);
|
|
221
|
+
try {
|
|
222
|
+
// Empty string clears the stored token (per the backend handler).
|
|
223
|
+
await setRegistryMutation.mutateAsync({
|
|
224
|
+
registryUrl: registryUrl.trim(),
|
|
225
|
+
scopedRegistries: registryQuery.data?.scopedRegistries ?? [],
|
|
226
|
+
ignoreScripts,
|
|
227
|
+
authToken: "",
|
|
228
|
+
});
|
|
229
|
+
setAuthTokenInput("");
|
|
230
|
+
} catch (error_) {
|
|
231
|
+
setError(extractErrorMessage(error_));
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handleSaveSizeCap = async () => {
|
|
236
|
+
setError(null);
|
|
237
|
+
const warn = Number(warnMb);
|
|
238
|
+
const block = Number(blockMb);
|
|
239
|
+
if (!Number.isFinite(warn) || !Number.isFinite(block) || warn <= 0 || block <= 0) {
|
|
240
|
+
setError("Size thresholds must be positive numbers (MB).");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await setSizeCapMutation.mutateAsync({
|
|
245
|
+
warnBytes: mbToBytes(warn),
|
|
246
|
+
blockBytes: mbToBytes(block),
|
|
247
|
+
});
|
|
248
|
+
} catch (error_) {
|
|
249
|
+
setError(extractErrorMessage(error_));
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const handleSaveStorageBackend = async () => {
|
|
254
|
+
setError(null);
|
|
255
|
+
if (!storageBackend) return;
|
|
256
|
+
try {
|
|
257
|
+
await setStorageBackendMutation.mutateAsync({ backend: storageBackend });
|
|
258
|
+
} catch (error_) {
|
|
259
|
+
setError(extractErrorMessage(error_));
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const handleMigrate = async () => {
|
|
264
|
+
setError(null);
|
|
265
|
+
setConfirmMigrate(false);
|
|
266
|
+
if (!migrateTarget) return;
|
|
267
|
+
try {
|
|
268
|
+
const res = await migrateMutation.mutateAsync({ target: migrateTarget });
|
|
269
|
+
if (!res.started && res.reason) setError(res.reason);
|
|
270
|
+
} catch (error_) {
|
|
271
|
+
setError(extractErrorMessage(error_));
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const overWarn =
|
|
276
|
+
installState && sizeCap
|
|
277
|
+
? installState.totalSizeBytes > sizeCap.warnBytes
|
|
278
|
+
: false;
|
|
279
|
+
|
|
280
|
+
const blobGc = blobGcQuery.data;
|
|
281
|
+
const availableBackends = backendsQuery.data?.backends ?? [];
|
|
282
|
+
const migrating = storage?.migrationStatus === "migrating";
|
|
283
|
+
const migrationTargets = availableBackends.filter(
|
|
284
|
+
(b) => b !== storage?.activeBackend,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<PageLayout title="Script packages" icon={Package}>
|
|
289
|
+
<div className="space-y-6">
|
|
290
|
+
{error && (
|
|
291
|
+
<Alert variant="error">
|
|
292
|
+
<AlertTitle>Something went wrong</AlertTitle>
|
|
293
|
+
<AlertDescription>{error}</AlertDescription>
|
|
294
|
+
</Alert>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{/* Install state */}
|
|
298
|
+
<Card>
|
|
299
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
300
|
+
<CardTitle className="text-base">Install state</CardTitle>
|
|
301
|
+
<Button
|
|
302
|
+
type="button"
|
|
303
|
+
size="sm"
|
|
304
|
+
onClick={handleInstall}
|
|
305
|
+
disabled={installMutation.isPending || installState?.status === "installing"}
|
|
306
|
+
>
|
|
307
|
+
<Download className="h-4 w-4" />
|
|
308
|
+
{installState?.status === "installing" ? "Installing…" : "Install now"}
|
|
309
|
+
</Button>
|
|
310
|
+
</CardHeader>
|
|
311
|
+
<CardContent className="space-y-2 text-sm">
|
|
312
|
+
<div className="flex items-center gap-2">
|
|
313
|
+
<span className="text-muted-foreground">Status:</span>
|
|
314
|
+
<Badge variant={installState?.status === "error" ? "destructive" : "secondary"}>
|
|
315
|
+
{installState?.status ?? "unknown"}
|
|
316
|
+
</Badge>
|
|
317
|
+
{installState?.totalSizeBytes !== undefined && (
|
|
318
|
+
<Badge variant={overWarn ? "destructive" : "secondary"}>
|
|
319
|
+
{mb(installState.totalSizeBytes)}
|
|
320
|
+
</Badge>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
{installState?.errorMessage && (
|
|
324
|
+
<p className="text-destructive text-xs">{installState.errorMessage}</p>
|
|
325
|
+
)}
|
|
326
|
+
{overWarn && sizeCap && (
|
|
327
|
+
<p className="text-xs text-amber-600">
|
|
328
|
+
Resolved size exceeds the {mb(sizeCap.warnBytes)} warning threshold.
|
|
329
|
+
</p>
|
|
330
|
+
)}
|
|
331
|
+
</CardContent>
|
|
332
|
+
</Card>
|
|
333
|
+
|
|
334
|
+
{/* Allowlist */}
|
|
335
|
+
<Card>
|
|
336
|
+
<CardHeader>
|
|
337
|
+
<CardTitle className="text-base">Allowed packages</CardTitle>
|
|
338
|
+
</CardHeader>
|
|
339
|
+
<CardContent className="space-y-4">
|
|
340
|
+
<div className="flex items-end gap-2">
|
|
341
|
+
<div className="flex-1">
|
|
342
|
+
<Label htmlFor="pkg-name">Package</Label>
|
|
343
|
+
<PackageNameCombobox
|
|
344
|
+
id="pkg-name"
|
|
345
|
+
value={name}
|
|
346
|
+
onValueChange={(next) => {
|
|
347
|
+
// Manual typing only (selecting a suggestion routes through
|
|
348
|
+
// onSelect instead). A manual name edit invalidates the
|
|
349
|
+
// previously chosen version - it belonged to the old package.
|
|
350
|
+
setName(next);
|
|
351
|
+
setVersion("");
|
|
352
|
+
}}
|
|
353
|
+
onSelect={(hit) => {
|
|
354
|
+
// Picking a suggestion fills the name AND seeds the version
|
|
355
|
+
// from the hit (typically the latest published version);
|
|
356
|
+
// `onVersionsLoaded` below upgrades it to the `latest`
|
|
357
|
+
// dist-tag once the full version list resolves.
|
|
358
|
+
setName(hit.name);
|
|
359
|
+
setVersion(versionFromHit(hit));
|
|
360
|
+
}}
|
|
361
|
+
placeholder="lodash or @scope/name"
|
|
362
|
+
/>
|
|
363
|
+
</div>
|
|
364
|
+
<div className="w-40">
|
|
365
|
+
<Label htmlFor="pkg-version">Version</Label>
|
|
366
|
+
<PackageVersionCombobox
|
|
367
|
+
id="pkg-version"
|
|
368
|
+
packageName={name.trim()}
|
|
369
|
+
value={version}
|
|
370
|
+
onValueChange={setVersion}
|
|
371
|
+
onVersionsLoaded={({ latest }) => {
|
|
372
|
+
// Default-select the registry's `latest` only when the field
|
|
373
|
+
// is still empty, so we never clobber a manual pin or a
|
|
374
|
+
// version already seeded from the search hit. Functional
|
|
375
|
+
// update so we read the freshest value, not a stale closure.
|
|
376
|
+
setVersion((cur) => applyLatestDistTag({ current: cur, latest }));
|
|
377
|
+
}}
|
|
378
|
+
placeholder="4.17.21"
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
<Button
|
|
382
|
+
type="button"
|
|
383
|
+
onClick={handleAdd}
|
|
384
|
+
disabled={
|
|
385
|
+
!name.trim() ||
|
|
386
|
+
!trimmedVersion ||
|
|
387
|
+
versionError !== null ||
|
|
388
|
+
addMutation.isPending
|
|
389
|
+
}
|
|
390
|
+
>
|
|
391
|
+
Add
|
|
392
|
+
</Button>
|
|
393
|
+
</div>
|
|
394
|
+
{versionError && (
|
|
395
|
+
<p className="text-xs text-destructive">{versionError}</p>
|
|
396
|
+
)}
|
|
397
|
+
|
|
398
|
+
{packages.length === 0 ? (
|
|
399
|
+
<p className="text-sm text-muted-foreground italic">
|
|
400
|
+
No packages yet. Add pinned, lightweight pure-JS packages.
|
|
401
|
+
</p>
|
|
402
|
+
) : (
|
|
403
|
+
<ul className="divide-y divide-border rounded-md border border-border">
|
|
404
|
+
{packages.map((pkg) => (
|
|
405
|
+
<li
|
|
406
|
+
key={pkg.name}
|
|
407
|
+
className="flex items-center justify-between gap-3 px-3 py-2"
|
|
408
|
+
>
|
|
409
|
+
<span className="flex items-center gap-2 font-mono text-sm">
|
|
410
|
+
<Package className="h-3.5 w-3.5 text-muted-foreground" />
|
|
411
|
+
{pkg.name}@{pkg.version}
|
|
412
|
+
</span>
|
|
413
|
+
<div className="flex items-center gap-3">
|
|
414
|
+
<Toggle
|
|
415
|
+
checked={pkg.enabled}
|
|
416
|
+
onCheckedChange={(enabled) =>
|
|
417
|
+
setEnabledMutation.mutate({ name: pkg.name, enabled })
|
|
418
|
+
}
|
|
419
|
+
/>
|
|
420
|
+
<Button
|
|
421
|
+
type="button"
|
|
422
|
+
variant="ghost"
|
|
423
|
+
size="icon"
|
|
424
|
+
className="text-destructive"
|
|
425
|
+
onClick={() => removeMutation.mutate({ name: pkg.name })}
|
|
426
|
+
>
|
|
427
|
+
<Trash2 className="h-4 w-4" />
|
|
428
|
+
</Button>
|
|
429
|
+
</div>
|
|
430
|
+
</li>
|
|
431
|
+
))}
|
|
432
|
+
</ul>
|
|
433
|
+
)}
|
|
434
|
+
</CardContent>
|
|
435
|
+
</Card>
|
|
436
|
+
|
|
437
|
+
{/* Advanced configuration — collapsed by default so the common case
|
|
438
|
+
(install state + allowlist above) stays the focus. Registry /
|
|
439
|
+
storage are read-only summaries; the storage section also holds
|
|
440
|
+
the rare, destructive migrate flow behind a confirmation modal. */}
|
|
441
|
+
<Card>
|
|
442
|
+
<CardHeader>
|
|
443
|
+
<CardTitle className="text-base">Advanced</CardTitle>
|
|
444
|
+
</CardHeader>
|
|
445
|
+
<CardContent>
|
|
446
|
+
<Accordion type="multiple" className="w-full">
|
|
447
|
+
<AccordionItem value="registry" className="border-b">
|
|
448
|
+
<AccordionTrigger className="text-sm hover:no-underline">
|
|
449
|
+
Registry & storage
|
|
450
|
+
</AccordionTrigger>
|
|
451
|
+
<AccordionContent className="space-y-4 text-sm">
|
|
452
|
+
<div>
|
|
453
|
+
<Label htmlFor="registry-url">Registry URL</Label>
|
|
454
|
+
<Input
|
|
455
|
+
id="registry-url"
|
|
456
|
+
value={registryUrl}
|
|
457
|
+
onChange={(e) => setRegistryUrl(e.target.value)}
|
|
458
|
+
placeholder="https://registry.npmjs.org/"
|
|
459
|
+
className="font-mono"
|
|
460
|
+
/>
|
|
461
|
+
</div>
|
|
462
|
+
<div className="flex items-center gap-2">
|
|
463
|
+
<Toggle
|
|
464
|
+
checked={ignoreScripts}
|
|
465
|
+
onCheckedChange={setIgnoreScripts}
|
|
466
|
+
aria-label="Ignore install scripts"
|
|
467
|
+
/>
|
|
468
|
+
<span className="text-muted-foreground">
|
|
469
|
+
Ignore install scripts
|
|
470
|
+
</span>
|
|
471
|
+
</div>
|
|
472
|
+
<div>
|
|
473
|
+
<Label htmlFor="registry-token">Auth token</Label>
|
|
474
|
+
<Input
|
|
475
|
+
id="registry-token"
|
|
476
|
+
type="password"
|
|
477
|
+
value={authTokenInput}
|
|
478
|
+
onChange={(e) => setAuthTokenInput(e.target.value)}
|
|
479
|
+
placeholder={
|
|
480
|
+
registryQuery.data?.hasAuthToken
|
|
481
|
+
? "Configured. Type to replace, or leave blank to keep."
|
|
482
|
+
: "None. Enter a token to configure."
|
|
483
|
+
}
|
|
484
|
+
/>
|
|
485
|
+
</div>
|
|
486
|
+
<div className="flex items-center gap-2">
|
|
487
|
+
<Button
|
|
488
|
+
type="button"
|
|
489
|
+
size="sm"
|
|
490
|
+
onClick={handleSaveRegistry}
|
|
491
|
+
disabled={
|
|
492
|
+
!registryUrl.trim() || setRegistryMutation.isPending
|
|
493
|
+
}
|
|
494
|
+
>
|
|
495
|
+
Save registry
|
|
496
|
+
</Button>
|
|
497
|
+
{registryQuery.data?.hasAuthToken && (
|
|
498
|
+
<Button
|
|
499
|
+
type="button"
|
|
500
|
+
size="sm"
|
|
501
|
+
variant="outline"
|
|
502
|
+
onClick={handleClearAuthToken}
|
|
503
|
+
disabled={setRegistryMutation.isPending}
|
|
504
|
+
>
|
|
505
|
+
Clear token
|
|
506
|
+
</Button>
|
|
507
|
+
)}
|
|
508
|
+
</div>
|
|
509
|
+
</AccordionContent>
|
|
510
|
+
</AccordionItem>
|
|
511
|
+
|
|
512
|
+
<AccordionItem value="storage" className="border-b">
|
|
513
|
+
<AccordionTrigger className="text-sm hover:no-underline">
|
|
514
|
+
<span className="flex items-center gap-2">
|
|
515
|
+
Storage backend
|
|
516
|
+
{migrating && (
|
|
517
|
+
<Badge variant="secondary" className="font-normal">
|
|
518
|
+
<RefreshCw
|
|
519
|
+
className={cn(
|
|
520
|
+
"mr-1 h-3 w-3",
|
|
521
|
+
!isLowPower && "animate-spin",
|
|
522
|
+
)}
|
|
523
|
+
/>
|
|
524
|
+
migrating
|
|
525
|
+
</Badge>
|
|
526
|
+
)}
|
|
527
|
+
</span>
|
|
528
|
+
</AccordionTrigger>
|
|
529
|
+
<AccordionContent className="space-y-3 text-sm">
|
|
530
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
531
|
+
<div className="w-48">
|
|
532
|
+
<Label htmlFor="active-backend">Active backend</Label>
|
|
533
|
+
<Select
|
|
534
|
+
value={storageBackend}
|
|
535
|
+
onValueChange={setStorageBackend}
|
|
536
|
+
disabled={migrating}
|
|
537
|
+
>
|
|
538
|
+
<SelectTrigger id="active-backend">
|
|
539
|
+
<SelectValue placeholder="Select backend" />
|
|
540
|
+
</SelectTrigger>
|
|
541
|
+
<SelectContent>
|
|
542
|
+
{availableBackends.map((b) => (
|
|
543
|
+
<SelectItem key={b} value={b}>
|
|
544
|
+
{b}
|
|
545
|
+
</SelectItem>
|
|
546
|
+
))}
|
|
547
|
+
</SelectContent>
|
|
548
|
+
</Select>
|
|
549
|
+
</div>
|
|
550
|
+
<Button
|
|
551
|
+
type="button"
|
|
552
|
+
onClick={handleSaveStorageBackend}
|
|
553
|
+
disabled={
|
|
554
|
+
!storageBackend ||
|
|
555
|
+
storageBackend === storage?.activeBackend ||
|
|
556
|
+
migrating ||
|
|
557
|
+
setStorageBackendMutation.isPending
|
|
558
|
+
}
|
|
559
|
+
>
|
|
560
|
+
Set active
|
|
561
|
+
</Button>
|
|
562
|
+
{migrating && (
|
|
563
|
+
<Badge variant="secondary">
|
|
564
|
+
<RefreshCw
|
|
565
|
+
className={cn(
|
|
566
|
+
"mr-1 h-3 w-3",
|
|
567
|
+
!isLowPower && "animate-spin",
|
|
568
|
+
)}
|
|
569
|
+
/>
|
|
570
|
+
migrating to {storage?.migrationTarget} (
|
|
571
|
+
{storage?.migratedCount} copied)
|
|
572
|
+
</Badge>
|
|
573
|
+
)}
|
|
574
|
+
</div>
|
|
575
|
+
<p className="text-xs text-muted-foreground">
|
|
576
|
+
Setting the active backend does not move existing blobs - use
|
|
577
|
+
Migrate below to copy them. Use this only for an initial
|
|
578
|
+
selection or when both backends already hold every blob.
|
|
579
|
+
</p>
|
|
580
|
+
|
|
581
|
+
{storage?.migrationStatus === "error" &&
|
|
582
|
+
storage.migrationError && (
|
|
583
|
+
<p className="text-xs text-destructive">
|
|
584
|
+
Migration failed: {storage.migrationError}
|
|
585
|
+
</p>
|
|
586
|
+
)}
|
|
587
|
+
{storage?.migrationStatus === "completed" && (
|
|
588
|
+
<p className="text-xs text-emerald-600">
|
|
589
|
+
Migration complete. Active backend is now{" "}
|
|
590
|
+
{storage.activeBackend}.
|
|
591
|
+
</p>
|
|
592
|
+
)}
|
|
593
|
+
|
|
594
|
+
{/* Migrate: copy all blobs to a target backend, then flip.
|
|
595
|
+
Guarded by a confirmation modal so the destructive flow
|
|
596
|
+
is never a single stray click. */}
|
|
597
|
+
{migrationTargets.length > 0 && (
|
|
598
|
+
<div className="flex items-end gap-2">
|
|
599
|
+
<div className="w-48">
|
|
600
|
+
<Label htmlFor="migrate-target">Migrate blobs to</Label>
|
|
601
|
+
<Select
|
|
602
|
+
value={migrateTarget}
|
|
603
|
+
onValueChange={setMigrateTarget}
|
|
604
|
+
disabled={migrating}
|
|
605
|
+
>
|
|
606
|
+
<SelectTrigger id="migrate-target">
|
|
607
|
+
<SelectValue placeholder="Select target backend" />
|
|
608
|
+
</SelectTrigger>
|
|
609
|
+
<SelectContent>
|
|
610
|
+
{migrationTargets.map((b) => (
|
|
611
|
+
<SelectItem key={b} value={b}>
|
|
612
|
+
{b}
|
|
613
|
+
</SelectItem>
|
|
614
|
+
))}
|
|
615
|
+
</SelectContent>
|
|
616
|
+
</Select>
|
|
617
|
+
</div>
|
|
618
|
+
<Button
|
|
619
|
+
type="button"
|
|
620
|
+
onClick={() => setConfirmMigrate(true)}
|
|
621
|
+
disabled={
|
|
622
|
+
!migrateTarget || migrating || migrateMutation.isPending
|
|
623
|
+
}
|
|
624
|
+
>
|
|
625
|
+
<RefreshCw className="h-4 w-4" />
|
|
626
|
+
Migrate
|
|
627
|
+
</Button>
|
|
628
|
+
</div>
|
|
629
|
+
)}
|
|
630
|
+
<p className="text-xs text-muted-foreground">
|
|
631
|
+
Migration copies every blob to the target, verifies each by
|
|
632
|
+
content hash, then atomically switches the active backend.
|
|
633
|
+
Reads fall back across both backends while it runs, so
|
|
634
|
+
scripts keep working. Installs are paused during a migration.
|
|
635
|
+
</p>
|
|
636
|
+
</AccordionContent>
|
|
637
|
+
</AccordionItem>
|
|
638
|
+
|
|
639
|
+
<AccordionItem value="size-cap" className="border-b">
|
|
640
|
+
<AccordionTrigger className="text-sm hover:no-underline">
|
|
641
|
+
Size guardrail
|
|
642
|
+
</AccordionTrigger>
|
|
643
|
+
<AccordionContent className="space-y-3 text-sm">
|
|
644
|
+
<p className="text-xs text-muted-foreground">
|
|
645
|
+
Installs warn above the warning threshold and are blocked
|
|
646
|
+
above the block threshold (resolved total size).
|
|
647
|
+
</p>
|
|
648
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
649
|
+
<div className="w-40">
|
|
650
|
+
<Label htmlFor="warn-mb">Warn at (MB)</Label>
|
|
651
|
+
<Input
|
|
652
|
+
id="warn-mb"
|
|
653
|
+
type="number"
|
|
654
|
+
min={1}
|
|
655
|
+
value={warnMb}
|
|
656
|
+
onChange={(e) => setWarnMb(e.target.value)}
|
|
657
|
+
placeholder="150"
|
|
658
|
+
/>
|
|
659
|
+
</div>
|
|
660
|
+
<div className="w-40">
|
|
661
|
+
<Label htmlFor="block-mb">Block at (MB)</Label>
|
|
662
|
+
<Input
|
|
663
|
+
id="block-mb"
|
|
664
|
+
type="number"
|
|
665
|
+
min={1}
|
|
666
|
+
value={blockMb}
|
|
667
|
+
onChange={(e) => setBlockMb(e.target.value)}
|
|
668
|
+
placeholder="300"
|
|
669
|
+
/>
|
|
670
|
+
</div>
|
|
671
|
+
<Button
|
|
672
|
+
type="button"
|
|
673
|
+
onClick={handleSaveSizeCap}
|
|
674
|
+
disabled={
|
|
675
|
+
!warnMb.trim() ||
|
|
676
|
+
!blockMb.trim() ||
|
|
677
|
+
setSizeCapMutation.isPending
|
|
678
|
+
}
|
|
679
|
+
>
|
|
680
|
+
Save thresholds
|
|
681
|
+
</Button>
|
|
682
|
+
</div>
|
|
683
|
+
</AccordionContent>
|
|
684
|
+
</AccordionItem>
|
|
685
|
+
|
|
686
|
+
<AccordionItem value="gc" className="border-b">
|
|
687
|
+
<AccordionTrigger className="text-sm hover:no-underline">
|
|
688
|
+
<span className="flex items-center gap-2">
|
|
689
|
+
<Recycle className="h-3.5 w-3.5 text-muted-foreground" />
|
|
690
|
+
Storage cleanup
|
|
691
|
+
</span>
|
|
692
|
+
</AccordionTrigger>
|
|
693
|
+
<AccordionContent className="space-y-3 text-sm">
|
|
694
|
+
<p className="text-xs text-muted-foreground">
|
|
695
|
+
Garbage collection prunes content-addressed blobs no longer
|
|
696
|
+
referenced by the current or recently-superseded package set,
|
|
697
|
+
reclaiming Postgres / S3 storage. It runs automatically once a
|
|
698
|
+
day; unreferenced blobs are kept for a grace period before
|
|
699
|
+
deletion so in-flight syncs are never disrupted.
|
|
700
|
+
</p>
|
|
701
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
702
|
+
<span className="text-muted-foreground">Last run:</span>
|
|
703
|
+
<Badge variant="secondary">
|
|
704
|
+
{blobGc?.lastRunAt
|
|
705
|
+
? new Date(blobGc.lastRunAt).toLocaleString()
|
|
706
|
+
: "never"}
|
|
707
|
+
</Badge>
|
|
708
|
+
{blobGc && blobGc.lastRunAt && (
|
|
709
|
+
<Badge variant="secondary">
|
|
710
|
+
{blobGc.lastDeleted} blob(s), {mb(blobGc.lastBytesReclaimed)}{" "}
|
|
711
|
+
reclaimed
|
|
712
|
+
</Badge>
|
|
713
|
+
)}
|
|
714
|
+
</div>
|
|
715
|
+
{blobGc && blobGc.totalBytesReclaimed > 0 && (
|
|
716
|
+
<div className="text-xs text-muted-foreground">
|
|
717
|
+
Total reclaimed to date: {mb(blobGc.totalBytesReclaimed)}
|
|
718
|
+
</div>
|
|
719
|
+
)}
|
|
720
|
+
<Button
|
|
721
|
+
type="button"
|
|
722
|
+
size="sm"
|
|
723
|
+
variant="outline"
|
|
724
|
+
onClick={handleGc}
|
|
725
|
+
disabled={gcMutation.isPending || migrating}
|
|
726
|
+
>
|
|
727
|
+
<Recycle className="h-4 w-4" />
|
|
728
|
+
{gcMutation.isPending ? "Cleaning up…" : "Run cleanup now"}
|
|
729
|
+
</Button>
|
|
730
|
+
</AccordionContent>
|
|
731
|
+
</AccordionItem>
|
|
732
|
+
|
|
733
|
+
{satellites.length > 0 && (
|
|
734
|
+
<AccordionItem value="satellites" className="border-b-0">
|
|
735
|
+
<AccordionTrigger className="text-sm hover:no-underline">
|
|
736
|
+
Satellite sync
|
|
737
|
+
</AccordionTrigger>
|
|
738
|
+
<AccordionContent>
|
|
739
|
+
<ul className="divide-y divide-border rounded-md border border-border">
|
|
740
|
+
{satellites.map((s) => (
|
|
741
|
+
<li
|
|
742
|
+
key={s.satelliteId}
|
|
743
|
+
className="flex items-center justify-between px-3 py-2 text-sm"
|
|
744
|
+
>
|
|
745
|
+
<span className="font-mono">{s.satelliteId}</span>
|
|
746
|
+
<Badge
|
|
747
|
+
variant={
|
|
748
|
+
s.status === "error" ? "destructive" : "secondary"
|
|
749
|
+
}
|
|
750
|
+
>
|
|
751
|
+
{s.status}
|
|
752
|
+
</Badge>
|
|
753
|
+
</li>
|
|
754
|
+
))}
|
|
755
|
+
</ul>
|
|
756
|
+
</AccordionContent>
|
|
757
|
+
</AccordionItem>
|
|
758
|
+
)}
|
|
759
|
+
</Accordion>
|
|
760
|
+
</CardContent>
|
|
761
|
+
</Card>
|
|
762
|
+
</div>
|
|
763
|
+
|
|
764
|
+
<ConfirmationModal
|
|
765
|
+
isOpen={confirmMigrate}
|
|
766
|
+
onClose={() => setConfirmMigrate(false)}
|
|
767
|
+
onConfirm={handleMigrate}
|
|
768
|
+
title="Migrate storage backend"
|
|
769
|
+
message={`Copy every blob to "${migrateTarget}", verify by content hash, then atomically switch the active backend. Installs are paused until the migration completes. Continue?`}
|
|
770
|
+
confirmText="Migrate"
|
|
771
|
+
variant="warning"
|
|
772
|
+
isLoading={migrateMutation.isPending}
|
|
773
|
+
/>
|
|
774
|
+
</PageLayout>
|
|
775
|
+
);
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Admin Settings -> Script Packages page. Curates the pinned allowlist,
|
|
780
|
+
* shows install state + size, summarises registry / storage config, and
|
|
781
|
+
* surfaces per-satellite sync status. Gated by `script-packages.manage`.
|
|
782
|
+
*/
|
|
783
|
+
export const ScriptPackagesSettingsPage = wrapInSuspense(SettingsContent);
|