@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.
@@ -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 &amp; 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);