Volumes page: on-demand volume sizes (0.18.0)
Docker's volume list has no size, so add a "Compute sizes" button that runs
`docker system df` (via client.df()) and shows per-volume size in a new Size
column. The df walk is expensive (seconds), so results are cached ~60s and
loaded on demand instead of on every poll.
- volume_service.volume_sizes(force) with a 60s TTL cache; GET /api/volumes/sizes
+ agent /agent/volumes/sizes + proxy /api/agents/{id}/volumes/sizes.
- Frontend: volumesApi.sizes(force, agentId); Volumes page gained a Size column
and a Compute sizes button (per host) that triggers the lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8f6e354b3f
commit
56450efd82
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.17.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -8,6 +8,10 @@ const base = (agentId?: number) =>
|
||||
export const volumesApi = {
|
||||
list: (agentId?: number) =>
|
||||
api.get<VolumeInfo[]>(base(agentId)).then((r) => r.data),
|
||||
sizes: (force = false, agentId?: number) =>
|
||||
api
|
||||
.get<Record<string, number | null>>(`${base(agentId)}/sizes`, { params: { force } })
|
||||
.then((r) => r.data),
|
||||
remove: (name: string, force = false, agentId?: number) =>
|
||||
api.delete(`${base(agentId)}/${name}?force=${force}`).then((r) => r.data),
|
||||
prune: (agentId?: number) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Database, Trash2, Eraser } from "lucide-react";
|
||||
import { Database, Trash2, Eraser, HardDrive } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Spinner } from "@/components/ui";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
@@ -9,6 +9,7 @@ import { volumesApi } from "@/api/volumes";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
import type { Agent, VolumeInfo } from "@/types";
|
||||
|
||||
export function Volumes() {
|
||||
@@ -52,6 +53,13 @@ function VolumesSection({
|
||||
refetchInterval: 10000,
|
||||
enabled: online,
|
||||
});
|
||||
// Sizes are expensive (docker system df walks volume contents), so they are
|
||||
// loaded on demand via the "Compute sizes" button rather than polled.
|
||||
const sizes = useQuery({
|
||||
queryKey: ["volume-sizes", agentId ?? "local"],
|
||||
queryFn: () => volumesApi.sizes(false, agentId),
|
||||
enabled: false,
|
||||
});
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["volumes", agentId ?? "local"] });
|
||||
|
||||
const prune = useMutation({
|
||||
@@ -75,7 +83,7 @@ function VolumesSection({
|
||||
});
|
||||
|
||||
const rows = (data ?? []).filter((v) => (onlyUnused ? !v.in_use : true));
|
||||
const colSpan = isAdmin ? 5 : 4;
|
||||
const colSpan = isAdmin ? 6 : 5;
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -90,6 +98,16 @@ function VolumesSection({
|
||||
/>
|
||||
Only unused
|
||||
</label>
|
||||
{online && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => sizes.refetch()}
|
||||
loading={sizes.isFetching}
|
||||
title="Runs docker system df — can take a few seconds"
|
||||
>
|
||||
<HardDrive className="h-4 w-4" /> Compute sizes
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && online && (
|
||||
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
|
||||
<Eraser className="h-4 w-4" /> Prune unused
|
||||
@@ -113,6 +131,7 @@ function VolumesSection({
|
||||
<tr>
|
||||
<th className="px-4 py-2">Name</th>
|
||||
<th className="px-4 py-2">Driver</th>
|
||||
<th className="px-4 py-2">Size</th>
|
||||
<th className="px-4 py-2">In use</th>
|
||||
<th className="px-4 py-2">Mountpoint</th>
|
||||
{isAdmin && <th className="px-4 py-2"></th>}
|
||||
@@ -129,6 +148,15 @@ function VolumesSection({
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-500">{v.driver}</td>
|
||||
<td className="px-4 py-2 tabular-nums text-slate-500">
|
||||
{sizes.data
|
||||
? sizes.data[v.name] != null
|
||||
? formatBytes(sizes.data[v.name]!)
|
||||
: "—"
|
||||
: sizes.isFetching
|
||||
? "…"
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{v.in_use ? (
|
||||
<span title={v.used_by.join(", ")} className="text-slate-600 dark:text-slate-300">
|
||||
|
||||
Reference in New Issue
Block a user