Show live progress while pruning volumes, and ask first (0.62.0)
The volume prune gets the same treatment as the image prune in 0.61.0:
the candidates are listed and removed one by one in the background, and
the Volumes page shows a progress bar (x / y, the volume being removed,
failures) followed by a toast with the space freed.
The button also used to delete without asking, and a bare docker volume
prune only removes anonymous volumes on Engine 23+ with no way to include
named ones. It now opens a confirmation dialog with an explicit -a option
that warns it also takes the data of every stack that is down.
- Candidates: volumes no container (running or stopped) mounts, local
scope only, and anonymous (com.docker.volume.anonymous) unless -a.
Volumes from pre-23 daemons carry no such label, so they only go with
-a, which errs on the side of keeping data.
- Removed without force, so a volume mounted since the listing is
refused; one refusal does not stop the rest.
- No closing daemon prune: nothing is left over, and on a pre-23 daemon an
unfiltered one would delete named volumes too.
- Freed space is the sum of the deleted volumes' sizes (volumes share no
data), from the size cache, which is invalidated afterwards.
- POST /api/volumes/prune takes all= and background=; without background
it still waits and returns {VolumesDeleted, SpaceReclaimed}.
GET /api/volumes/prune-status is admin-only.
- volume_sizes() ("Compute sizes") now uses the long-timeout client; it
walks every volume and could hit the same 60s limit.
The progress machinery moved into services/prune_job.py (PruneJob,
PrunePlan) and, on the frontend, the usePruneJob hook and PruneProgress
component, shared by both pages. The status now carries a phase
(preparing / removing / finishing) so the parts without a count say what
they are doing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
faff2be944
commit
57231e4a67
18 files changed
+816
-286
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.61.0",
|
||||
"version": "0.62.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import api from "./client";
|
||||
import type { PruneStatus } from "@/types";
|
||||
|
||||
export interface UpdateStatus {
|
||||
image: string;
|
||||
@@ -54,23 +55,6 @@ export interface ScanDetail extends ScanSummary {
|
||||
|
||||
const base = "/api/images";
|
||||
|
||||
/** Progress of the running image prune, or the outcome of the last one. */
|
||||
export interface PruneStatus {
|
||||
active: boolean;
|
||||
/** Increments per prune, so a client can tell "its" run from an older one. */
|
||||
run: number;
|
||||
all: boolean;
|
||||
total: number;
|
||||
done: number;
|
||||
current: string | null;
|
||||
deleted: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
/** Measured once the prune is through; null while it runs. */
|
||||
reclaimed: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const imagesApi = {
|
||||
list: () => api.get<ImageRow[]>(base).then((r) => r.data),
|
||||
updates: () => api.get<Record<string, UpdateStatus>>(`${base}/updates`).then((r) => r.data),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import api from "./client";
|
||||
import type { HostPathResult, VolumeInfo } from "@/types";
|
||||
import type { HostPathResult, PruneStatus, VolumeInfo } from "@/types";
|
||||
|
||||
const base = "/api/volumes";
|
||||
|
||||
@@ -11,8 +11,14 @@ export const volumesApi = {
|
||||
.then((r) => r.data),
|
||||
remove: (name: string, force = false) =>
|
||||
api.delete(`${base}/${name}?force=${force}`).then((r) => r.data),
|
||||
prune: () =>
|
||||
api.post<{ VolumesDeleted: string[] | null }>(`${base}/prune`).then((r) => r.data),
|
||||
// Runs in the background on the server; poll pruneStatus() for progress.
|
||||
startPrune: (allUnused: boolean) =>
|
||||
api
|
||||
.post<PruneStatus>(`${base}/prune`, null, {
|
||||
params: { all: allUnused, background: true },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
pruneStatus: () => api.get<PruneStatus>(`${base}/prune-status`).then((r) => r.data),
|
||||
generateYaml: (spec: Record<string, unknown>) =>
|
||||
api
|
||||
.post<{ yaml: string }>("/api/volumes/generate-yaml", spec)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Card } from "@/components/ui";
|
||||
import type { PruneStatus } from "@/types";
|
||||
|
||||
const PHASE_DETAIL = {
|
||||
preparing: "Measuring disk usage…",
|
||||
finishing: "Finishing up…",
|
||||
} as const;
|
||||
|
||||
/** Live progress of a running prune, shown above the page's table. */
|
||||
export function PruneProgress({ status, title }: { status: PruneStatus; title: string }) {
|
||||
const measurable = status.total > 0;
|
||||
const pct = measurable ? Math.min((status.done / status.total) * 100, 100) : 0;
|
||||
const detail =
|
||||
status.phase === "removing"
|
||||
? status.current
|
||||
? `Removing ${status.current}`
|
||||
: ""
|
||||
: status.phase
|
||||
? PHASE_DETAIL[status.phase]
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Card className="mb-3 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="font-medium">{title}</span>
|
||||
{measurable && (
|
||||
<span className="shrink-0 text-xs tabular-nums text-sp-text-2">
|
||||
{status.done} / {status.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-sp-surface-2 ring-1 ring-inset ring-sp-border">
|
||||
{measurable && status.phase === "removing" ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-300 ease-out dark:bg-accent-dark"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
) : (
|
||||
// Measuring and finishing have nothing to count — slide instead.
|
||||
<div className="h-full w-1/3 rounded-full bg-accent animate-indeterminate dark:bg-accent-dark" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-3 text-[11px] text-sp-text-3">
|
||||
<span className="min-w-0 truncate">{detail}</span>
|
||||
{status.failed > 0 && (
|
||||
<span className="shrink-0 text-amber-600 dark:text-amber-400">
|
||||
{status.failed} failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
import type { PruneStatus } from "@/types";
|
||||
|
||||
/**
|
||||
* A server-side prune that runs in the background (images, volumes).
|
||||
*
|
||||
* Polls the status while a prune is active. It is fetched once on mount too,
|
||||
* so a prune started in another tab, or before a reload, still shows up. The
|
||||
* outcome of the run this page saw active is announced exactly once, and
|
||||
* never the stale result of an older run on first load.
|
||||
*/
|
||||
export function usePruneJob({
|
||||
noun,
|
||||
queryKey,
|
||||
fetchStatus,
|
||||
start,
|
||||
enabled,
|
||||
onFinished,
|
||||
}: {
|
||||
/** Singular, for the toast: "image", "volume". */
|
||||
noun: string;
|
||||
queryKey: string;
|
||||
fetchStatus: () => Promise<PruneStatus>;
|
||||
start: (allUnused: boolean) => Promise<PruneStatus>;
|
||||
enabled: boolean;
|
||||
onFinished: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const status = useQuery({
|
||||
queryKey: [queryKey],
|
||||
queryFn: fetchStatus,
|
||||
enabled,
|
||||
refetchInterval: (q) => (q.state.data?.active ? 1000 : false),
|
||||
});
|
||||
|
||||
const watchedRun = useRef<number | null>(null);
|
||||
const finished = useRef(onFinished);
|
||||
finished.current = onFinished;
|
||||
useEffect(() => {
|
||||
const s = status.data;
|
||||
if (!s) return;
|
||||
if (s.active) {
|
||||
watchedRun.current = s.run;
|
||||
return;
|
||||
}
|
||||
if (watchedRun.current !== s.run) return;
|
||||
watchedRun.current = null;
|
||||
finished.current();
|
||||
announcePrune(s, noun);
|
||||
}, [status.data, noun]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: start,
|
||||
onSuccess: (s) => {
|
||||
watchedRun.current = s.run;
|
||||
qc.setQueryData([queryKey], s);
|
||||
},
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
return {
|
||||
/** The running prune, or null. */
|
||||
running: status.data?.active ? status.data : null,
|
||||
start: mutation,
|
||||
};
|
||||
}
|
||||
|
||||
/** Toast for a finished prune: what went, what it freed, what refused to go. */
|
||||
function announcePrune(s: PruneStatus, noun: string) {
|
||||
if (s.error) {
|
||||
toast.error(`Prune failed: ${s.error}`);
|
||||
return;
|
||||
}
|
||||
if (!s.deleted && !s.failed) {
|
||||
toast.success(`No unused ${noun}s`);
|
||||
return;
|
||||
}
|
||||
const freed = s.reclaimed ? `, freed ${formatBytes(s.reclaimed)}` : "";
|
||||
const summary = `Removed ${s.deleted} ${noun}${s.deleted === 1 ? "" : "s"}${freed}`;
|
||||
if (s.failed) {
|
||||
toast.warning(`${summary}; ${s.failed} could not be removed`, {
|
||||
description: s.errors.slice(0, 3).join("\n"),
|
||||
});
|
||||
} else {
|
||||
toast.success(summary);
|
||||
}
|
||||
}
|
||||
+27
-106
@@ -1,10 +1,12 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle, Eraser, ShieldAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Card, Spinner } from "@/components/ui";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { imagesApi, type ImageRow, type PruneStatus } from "@/api/images";
|
||||
import { imagesApi, type ImageRow } from "@/api/images";
|
||||
import { PruneProgress } from "@/components/ui/PruneProgress";
|
||||
import { usePruneJob } from "@/hooks/usePruneJob";
|
||||
import { ScanBadge, ScanDetailDialog } from "@/components/stacks/ScanFindings";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { groupByStack, type StackGroup } from "@/lib/stackGroups";
|
||||
@@ -108,48 +110,20 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
// The prune runs on the server in the background; this polls its progress
|
||||
// while it is active. Fetched once on mount too, so a prune started in
|
||||
// another tab, or before a reload, still shows up here.
|
||||
const pruneStatus = useQuery({
|
||||
queryKey: ["image-prune-status"],
|
||||
queryFn: imagesApi.pruneStatus,
|
||||
const prune = usePruneJob({
|
||||
noun: "image",
|
||||
queryKey: "image-prune-status",
|
||||
fetchStatus: imagesApi.pruneStatus,
|
||||
start: imagesApi.startPrune,
|
||||
enabled: isAdmin,
|
||||
refetchInterval: (q) => (q.state.data?.active ? 1000 : false),
|
||||
onFinished: () => qc.invalidateQueries({ queryKey: ["images"] }),
|
||||
});
|
||||
// The run this page has seen active, so its outcome is announced exactly
|
||||
// once — and never the stale result of an older run on first load.
|
||||
const watchedRun = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const s = pruneStatus.data;
|
||||
if (!s) return;
|
||||
if (s.active) {
|
||||
watchedRun.current = s.run;
|
||||
return;
|
||||
}
|
||||
if (watchedRun.current !== s.run) return;
|
||||
watchedRun.current = null;
|
||||
qc.invalidateQueries({ queryKey: ["images"] });
|
||||
announcePrune(s);
|
||||
}, [pruneStatus.data, qc]);
|
||||
|
||||
const prune = useMutation({
|
||||
mutationFn: () => imagesApi.startPrune(pruneAll),
|
||||
onSuccess: (s) => {
|
||||
watchedRun.current = s.run;
|
||||
qc.setQueryData(["image-prune-status"], s);
|
||||
setPruneOpen(false);
|
||||
setPruneAll(false);
|
||||
},
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
const pruning = pruneStatus.data?.active ? pruneStatus.data : null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
{isAdmin && (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setPruneOpen(true)} loading={!!pruning}>
|
||||
<Button variant="outline" onClick={() => setPruneOpen(true)} loading={!!prune.running}>
|
||||
<Eraser className="h-4 w-4" /> Prune
|
||||
</Button>
|
||||
<Button variant="outline" onClick={scanAll} loading={scanning}>
|
||||
@@ -161,7 +135,12 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pruning && <PruneProgress status={pruning} />}
|
||||
{prune.running && (
|
||||
<PruneProgress
|
||||
status={prune.running}
|
||||
title={`Pruning ${prune.running.all ? "unused" : "dangling"} images…`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
@@ -236,8 +215,15 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
confirmLabel="Prune images"
|
||||
danger
|
||||
busy={prune.isPending}
|
||||
onConfirm={() => prune.mutate()}
|
||||
busy={prune.start.isPending}
|
||||
onConfirm={() =>
|
||||
prune.start.mutate(pruneAll, {
|
||||
onSuccess: () => {
|
||||
setPruneOpen(false);
|
||||
setPruneAll(false);
|
||||
},
|
||||
})
|
||||
}
|
||||
onCancel={() => {
|
||||
setPruneOpen(false);
|
||||
setPruneAll(false);
|
||||
@@ -257,71 +243,6 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Toast for a finished prune: what went, what it freed, what refused to go. */
|
||||
function announcePrune(s: PruneStatus) {
|
||||
if (s.error) {
|
||||
toast.error(`Prune failed: ${s.error}`);
|
||||
return;
|
||||
}
|
||||
if (!s.deleted && !s.failed) {
|
||||
toast.success("No unused images");
|
||||
return;
|
||||
}
|
||||
const freed = s.reclaimed ? `, freed ${formatBytes(s.reclaimed)}` : "";
|
||||
const summary = `Removed ${s.deleted} image${s.deleted === 1 ? "" : "s"}${freed}`;
|
||||
if (s.failed) {
|
||||
toast.warning(`${summary}; ${s.failed} could not be removed`, {
|
||||
description: s.errors.slice(0, 3).join("\n"),
|
||||
});
|
||||
} else {
|
||||
toast.success(summary);
|
||||
}
|
||||
}
|
||||
|
||||
/** Live progress of a running prune, above the image table. */
|
||||
function PruneProgress({ status }: { status: PruneStatus }) {
|
||||
const measurable = status.total > 0;
|
||||
const pct = measurable ? Math.min((status.done / status.total) * 100, 100) : 0;
|
||||
const detail = status.current
|
||||
? `Removing ${status.current}`
|
||||
: status.done < status.total
|
||||
? "Starting…"
|
||||
: "Cleaning up and measuring freed space…";
|
||||
|
||||
return (
|
||||
<Card className="mb-3 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
Pruning {status.all ? "unused" : "dangling"} images…
|
||||
</span>
|
||||
{measurable && (
|
||||
<span className="shrink-0 text-xs tabular-nums text-sp-text-2">
|
||||
{status.done} / {status.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-sp-surface-2 ring-1 ring-inset ring-sp-border">
|
||||
{measurable ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-300 ease-out dark:bg-accent-dark"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-1/3 rounded-full bg-accent animate-indeterminate dark:bg-accent-dark" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-3 text-[11px] text-sp-text-3">
|
||||
<span className="min-w-0 truncate">{detail}</span>
|
||||
{status.failed > 0 && (
|
||||
<span className="shrink-0 text-amber-600 dark:text-amber-400">
|
||||
{status.failed} failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** What an image group can say for itself: how many, how big, how stale. */
|
||||
function groupMeta(group: StackGroup<ImageRow>): string {
|
||||
const parts = [`${group.items.length} image${group.items.length > 1 ? "s" : ""}`];
|
||||
|
||||
@@ -5,6 +5,8 @@ import { toast } from "sonner";
|
||||
import { Button, Card, Spinner } from "@/components/ui";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { volumesApi } from "@/api/volumes";
|
||||
import { PruneProgress } from "@/components/ui/PruneProgress";
|
||||
import { usePruneJob } from "@/hooks/usePruneJob";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
@@ -23,6 +25,8 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [onlyUnused, setOnlyUnused] = useState(false);
|
||||
const [toDelete, setToDelete] = useState<VolumeInfo | null>(null);
|
||||
const [force, setForce] = useState(false);
|
||||
const [pruneOpen, setPruneOpen] = useState(false);
|
||||
const [pruneAll, setPruneAll] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["volumes"],
|
||||
@@ -38,14 +42,17 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
});
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["volumes"] });
|
||||
|
||||
const prune = useMutation({
|
||||
mutationFn: () => volumesApi.prune(),
|
||||
onSuccess: (r) => {
|
||||
const n = r.VolumesDeleted?.length ?? 0;
|
||||
toast.success(n ? `Pruned ${n} volume(s)` : "No unused volumes");
|
||||
const prune = usePruneJob({
|
||||
noun: "volume",
|
||||
queryKey: "volume-prune-status",
|
||||
fetchStatus: volumesApi.pruneStatus,
|
||||
start: volumesApi.startPrune,
|
||||
enabled: isAdmin,
|
||||
onFinished: () => {
|
||||
invalidate();
|
||||
// Whatever was measured before includes volumes that are gone now.
|
||||
if (sizes.data) sizes.refetch();
|
||||
},
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (v: VolumeInfo) => volumesApi.remove(v.name, force),
|
||||
@@ -104,12 +111,19 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
<HardDrive className="h-4 w-4" /> Compute sizes
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
|
||||
<Button variant="outline" onClick={() => setPruneOpen(true)} loading={!!prune.running}>
|
||||
<Eraser className="h-4 w-4" /> Prune unused
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{prune.running && (
|
||||
<PruneProgress
|
||||
status={prune.running}
|
||||
title={`Pruning ${prune.running.all ? "unused" : "anonymous"} volumes…`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
@@ -161,6 +175,41 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pruneOpen && (
|
||||
<ConfirmDialog
|
||||
title="Prune volumes"
|
||||
message={
|
||||
pruneAll
|
||||
? "Remove ALL volumes no container uses, including named ones. That includes the data of every stack that is currently down (containers removed). It is deleted permanently."
|
||||
: "Remove anonymous volumes no container uses. Named volumes are kept."
|
||||
}
|
||||
confirmLabel="Prune volumes"
|
||||
danger
|
||||
busy={prune.start.isPending}
|
||||
onConfirm={() =>
|
||||
prune.start.mutate(pruneAll, {
|
||||
onSuccess: () => {
|
||||
setPruneOpen(false);
|
||||
setPruneAll(false);
|
||||
},
|
||||
})
|
||||
}
|
||||
onCancel={() => {
|
||||
setPruneOpen(false);
|
||||
setPruneAll(false);
|
||||
}}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pruneAll}
|
||||
onChange={(e) => setPruneAll(e.target.checked)}
|
||||
/>
|
||||
Also remove unused named volumes (<code>-a</code>)
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
|
||||
{toDelete && (
|
||||
<ConfirmDialog
|
||||
title={`Delete volume “${toDelete.name}”?`}
|
||||
|
||||
@@ -149,3 +149,21 @@ export interface TokenPair {
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/** Progress of a running image/volume prune, or the outcome of the last one. */
|
||||
export interface PruneStatus {
|
||||
active: boolean;
|
||||
/** Increments per prune, so a client can tell "its" run from an older one. */
|
||||
run: number;
|
||||
all: boolean;
|
||||
phase: "preparing" | "removing" | "finishing" | null;
|
||||
total: number;
|
||||
done: number;
|
||||
current: string | null;
|
||||
deleted: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
/** Measured once the prune is through; null while it runs or if unknown. */
|
||||
reclaimed: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
Reference in new issue
Block a user