Phase 16: volumes page, multi-host (0.17.0)
Adds a dedicated Volumes page (sidebar) with per-host sections (local + each
online agent), matching the Networks/Images layout. Lists volumes with driver,
owning stack, in-use containers and mountpoint; admins can delete (with an
in-use warning + force option) and prune unused, plus an "only unused" filter.
- agent_app.py: /agent/volumes (list/delete with in-use 409 guard/prune)
reusing volume_service.
- routers/agents.py: proxy routes /api/agents/{id}/volumes/* (audit-logged
delete/prune).
- Frontend: volumesApi list/remove/prune take an optional agentId; new
pages/Volumes.tsx (VolumesSection per host) + sidebar entry + /volumes route.
The volume wizard (generate-yaml/host paths) stays local and unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
19cc92dc94
commit
8f6e354b3f
@@ -9,7 +9,7 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
> + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX &
|
> + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX &
|
||||||
> network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks &
|
> network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks &
|
||||||
> images) + Phase 14 (Multi-host file browser) + Phase 15 (Dashboard stack
|
> images) + Phase 14 (Multi-host file browser) + Phase 15 (Dashboard stack
|
||||||
> resource usage) complete.
|
> resource usage) + Phase 16 (Volumes page, multi-host) complete.
|
||||||
|
|
||||||
## What works today (Phase 1)
|
## What works today (Phase 1)
|
||||||
|
|
||||||
@@ -147,6 +147,17 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
||||||
/ `/disconnect`).
|
/ `/disconnect`).
|
||||||
|
|
||||||
|
### Phase 16 — Volumes page (multi-host)
|
||||||
|
|
||||||
|
- **New Volumes page** (sidebar) with per-host sections (local + each online
|
||||||
|
agent, like Networks/Images). Lists Docker volumes with driver, owning stack,
|
||||||
|
in-use containers and mountpoint.
|
||||||
|
- Admin actions: delete a volume (with an in-use warning + force option) and
|
||||||
|
**Prune unused**; an *Only unused* filter. New agent endpoints
|
||||||
|
`/agent/volumes` (list/delete/prune), proxied at `/api/agents/{id}/volumes/*`.
|
||||||
|
- The Volume **Wizard** in the stack editor (bind/named/NFS/SMB/tmpfs YAML
|
||||||
|
generation) is unchanged — the new page is for managing/cleaning up volumes.
|
||||||
|
|
||||||
### Phase 15 — Dashboard stack resource usage
|
### Phase 15 — Dashboard stack resource usage
|
||||||
|
|
||||||
- **The dashboard now lists stacks in a table** (status, services) with live
|
- **The dashboard now lists stacks in a table** (status, services) with live
|
||||||
|
|||||||
+32
-1
@@ -44,6 +44,7 @@ from services import (
|
|||||||
image_service,
|
image_service,
|
||||||
network_service,
|
network_service,
|
||||||
update_service,
|
update_service,
|
||||||
|
volume_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("stackpilot.agent")
|
logger = logging.getLogger("stackpilot.agent")
|
||||||
@@ -59,7 +60,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.16.0"
|
AGENT_VERSION = "0.17.0"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -436,6 +437,36 @@ async def image_check() -> dict:
|
|||||||
return await update_service.check_all()
|
return await update_service.check_all()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Volumes
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agent/volumes", dependencies=[Depends(verify_token)])
|
||||||
|
def list_volumes() -> list[dict]:
|
||||||
|
return volume_service.list_volumes()
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/agent/volumes/{name}", dependencies=[Depends(verify_token)])
|
||||||
|
def delete_volume(name: str, force: bool = Query(False)) -> dict:
|
||||||
|
vols = {v["name"]: v for v in volume_service.list_volumes()}
|
||||||
|
if name in vols and vols[name]["in_use"] and not force:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"error": "volume_in_use",
|
||||||
|
"detail": f"Volume '{name}' is used by: {', '.join(vols[name]['used_by'])}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
volume_service.remove_volume(name, force=force)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/agent/volumes/prune", dependencies=[Depends(verify_token)])
|
||||||
|
def prune_volumes() -> dict:
|
||||||
|
return volume_service.prune_volumes()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# File browser (sandboxed by this agent's ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX)
|
# File browser (sandboxed by this agent's ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.16.0", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.17.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -645,6 +645,57 @@ async def agent_image_check(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Volumes (proxied)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/volumes")
|
||||||
|
async def agent_volumes(
|
||||||
|
agent_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> list[dict]:
|
||||||
|
agent = _get_or_404(session, agent_id)
|
||||||
|
return await _proxy(session, agent, "GET", "/agent/volumes") or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{agent_id}/volumes/prune")
|
||||||
|
async def agent_volumes_prune(
|
||||||
|
agent_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
agent = _get_or_404(session, agent_id)
|
||||||
|
result = await _proxy(session, agent, "POST", "/agent/volumes/prune")
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action="agent.volume.prune", target=agent.name,
|
||||||
|
detail=str(result.get("VolumesDeleted") or []), ip=_ip(request),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{agent_id}/volumes/{name}")
|
||||||
|
async def agent_volume_delete(
|
||||||
|
agent_id: int,
|
||||||
|
name: str,
|
||||||
|
request: Request,
|
||||||
|
force: bool = Query(False),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
agent = _get_or_404(session, agent_id)
|
||||||
|
result = await _proxy(
|
||||||
|
session, agent, "DELETE", f"/agent/volumes/{name}", params={"force": force}
|
||||||
|
)
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action="agent.volume.delete",
|
||||||
|
target=f"{agent.name}/{name}", ip=_ip(request),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# File browser (proxied)
|
# File browser (proxied)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.16.0",
|
"version": "0.17.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { StackEditor } from "@/pages/StackEditor";
|
|||||||
import { RemoteStackDetail } from "@/pages/RemoteStackDetail";
|
import { RemoteStackDetail } from "@/pages/RemoteStackDetail";
|
||||||
import { Images } from "@/pages/Images";
|
import { Images } from "@/pages/Images";
|
||||||
import { Files } from "@/pages/Files";
|
import { Files } from "@/pages/Files";
|
||||||
|
import { Volumes } from "@/pages/Volumes";
|
||||||
import { Templates } from "@/pages/Templates";
|
import { Templates } from "@/pages/Templates";
|
||||||
import { Settings } from "@/pages/Settings";
|
import { Settings } from "@/pages/Settings";
|
||||||
import { Audit } from "@/pages/Audit";
|
import { Audit } from "@/pages/Audit";
|
||||||
@@ -48,6 +49,7 @@ export default function App() {
|
|||||||
<Route path="/hosts/:agentId/stacks/:id" element={<RemoteStackDetail />} />
|
<Route path="/hosts/:agentId/stacks/:id" element={<RemoteStackDetail />} />
|
||||||
<Route path="/networks" element={<Networks />} />
|
<Route path="/networks" element={<Networks />} />
|
||||||
<Route path="/images" element={<Images />} />
|
<Route path="/images" element={<Images />} />
|
||||||
|
<Route path="/volumes" element={<Volumes />} />
|
||||||
<Route path="/files" element={<Files />} />
|
<Route path="/files" element={<Files />} />
|
||||||
<Route path="/templates" element={<Templates />} />
|
<Route path="/templates" element={<Templates />} />
|
||||||
<Route path="/audit" element={<Audit />} />
|
<Route path="/audit" element={<Audit />} />
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
import type { HostPathResult, VolumeInfo } from "@/types";
|
import type { HostPathResult, VolumeInfo } from "@/types";
|
||||||
|
|
||||||
|
// Base path for the local host or, when agentId is given, a remote agent.
|
||||||
|
const base = (agentId?: number) =>
|
||||||
|
agentId != null ? `/api/agents/${agentId}/volumes` : "/api/volumes";
|
||||||
|
|
||||||
export const volumesApi = {
|
export const volumesApi = {
|
||||||
list: () => api.get<VolumeInfo[]>("/api/volumes").then((r) => r.data),
|
list: (agentId?: number) =>
|
||||||
orphaned: () =>
|
api.get<VolumeInfo[]>(base(agentId)).then((r) => r.data),
|
||||||
api.get<VolumeInfo[]>("/api/volumes/orphaned").then((r) => r.data),
|
remove: (name: string, force = false, agentId?: number) =>
|
||||||
remove: (name: string, force = false) =>
|
api.delete(`${base(agentId)}/${name}?force=${force}`).then((r) => r.data),
|
||||||
api.delete(`/api/volumes/${name}?force=${force}`).then((r) => r.data),
|
prune: (agentId?: number) =>
|
||||||
prune: () => api.post("/api/volumes/prune").then((r) => r.data),
|
api.post<{ VolumesDeleted: string[] | null }>(`${base(agentId)}/prune`).then((r) => r.data),
|
||||||
generateYaml: (spec: Record<string, unknown>) =>
|
generateYaml: (spec: Record<string, unknown>) =>
|
||||||
api
|
api
|
||||||
.post<{ yaml: string }>("/api/volumes/generate-yaml", spec)
|
.post<{ yaml: string }>("/api/volumes/generate-yaml", spec)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
Network,
|
Network,
|
||||||
Image,
|
Image,
|
||||||
|
Database,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
@@ -23,6 +24,7 @@ const nav = [
|
|||||||
{ to: "/stacks", label: "Stacks", icon: Boxes },
|
{ to: "/stacks", label: "Stacks", icon: Boxes },
|
||||||
{ to: "/networks", label: "Networks", icon: Network },
|
{ to: "/networks", label: "Networks", icon: Network },
|
||||||
{ to: "/images", label: "Images", icon: Image },
|
{ to: "/images", label: "Images", icon: Image },
|
||||||
|
{ to: "/volumes", label: "Volumes", icon: Database },
|
||||||
{ to: "/files", label: "Files", icon: FolderTree },
|
{ to: "/files", label: "Files", icon: FolderTree },
|
||||||
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
|
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
|
||||||
{ to: "/audit", label: "Audit log", icon: ScrollText },
|
{ to: "/audit", label: "Audit log", icon: ScrollText },
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Database, Trash2, Eraser } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge, Button, Card, Spinner } from "@/components/ui";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
|
import { HostHeader } from "@/components/hosts/HostHeader";
|
||||||
|
import { volumesApi } from "@/api/volumes";
|
||||||
|
import { agentsApi } from "@/api/agents";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import type { Agent, VolumeInfo } from "@/types";
|
||||||
|
|
||||||
|
export function Volumes() {
|
||||||
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
|
const agents = useQuery({
|
||||||
|
queryKey: ["agents"],
|
||||||
|
queryFn: () => agentsApi.list(),
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
const hasAgents = (agents.data?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<VolumesSection isAdmin={isAdmin} showHostLabel={hasAgents} />
|
||||||
|
{agents.data?.map((agent) => (
|
||||||
|
<VolumesSection key={agent.id} agent={agent} isAdmin={isAdmin} showHostLabel />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VolumesSection({
|
||||||
|
agent,
|
||||||
|
isAdmin,
|
||||||
|
showHostLabel,
|
||||||
|
}: {
|
||||||
|
agent?: Agent;
|
||||||
|
isAdmin: boolean;
|
||||||
|
showHostLabel: boolean;
|
||||||
|
}) {
|
||||||
|
const agentId = agent?.id;
|
||||||
|
const online = !agent || agent.status === "online";
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [onlyUnused, setOnlyUnused] = useState(false);
|
||||||
|
const [toDelete, setToDelete] = useState<VolumeInfo | null>(null);
|
||||||
|
const [force, setForce] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["volumes", agentId ?? "local"],
|
||||||
|
queryFn: () => volumesApi.list(agentId),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
enabled: online,
|
||||||
|
});
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["volumes", agentId ?? "local"] });
|
||||||
|
|
||||||
|
const prune = useMutation({
|
||||||
|
mutationFn: () => volumesApi.prune(agentId),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
const n = r.VolumesDeleted?.length ?? 0;
|
||||||
|
toast.success(n ? `Pruned ${n} volume(s)` : "No unused volumes");
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (v: VolumeInfo) => volumesApi.remove(v.name, force, agentId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Volume deleted");
|
||||||
|
setToDelete(null);
|
||||||
|
setForce(false);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = (data ?? []).filter((v) => (onlyUnused ? !v.in_use : true));
|
||||||
|
const colSpan = isAdmin ? 5 : 4;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
{(showHostLabel || (isAdmin && online)) && (
|
||||||
|
<HostHeader agent={agent}>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyUnused}
|
||||||
|
onChange={(e) => setOnlyUnused(e.target.checked)}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
Only unused
|
||||||
|
</label>
|
||||||
|
{isAdmin && online && (
|
||||||
|
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
|
||||||
|
<Eraser className="h-4 w-4" /> Prune unused
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</HostHeader>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!online ? (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Host is {agent?.status}. Check it under Settings → Remote hosts.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Card className="overflow-x-auto p-0">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2">Name</th>
|
||||||
|
<th className="px-4 py-2">Driver</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>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||||
|
{rows.map((v) => (
|
||||||
|
<tr key={v.name} className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 shrink-0 text-slate-400" />
|
||||||
|
<span className="break-all font-medium">{v.name}</span>
|
||||||
|
{v.stack && <Badge>{v.stack}</Badge>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-slate-500">{v.driver}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{v.in_use ? (
|
||||||
|
<span title={v.used_by.join(", ")} className="text-slate-600 dark:text-slate-300">
|
||||||
|
{v.used_by.length} container{v.used_by.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-[11px] text-slate-400">
|
||||||
|
<span className="block max-w-[22rem] truncate" title={v.mountpoint}>
|
||||||
|
{v.mountpoint}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
title={v.in_use ? "In use — delete needs force" : "Delete"}
|
||||||
|
onClick={() => {
|
||||||
|
setForce(false);
|
||||||
|
setToDelete(v);
|
||||||
|
}}
|
||||||
|
className="rounded-lg p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-slate-500">
|
||||||
|
{onlyUnused ? "No unused volumes." : "No volumes."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Delete volume “${toDelete.name}”?`}
|
||||||
|
message={
|
||||||
|
toDelete.in_use
|
||||||
|
? `This volume is used by: ${toDelete.used_by.join(", ")}. Its data will be permanently lost.`
|
||||||
|
: "This permanently deletes the volume and its data."
|
||||||
|
}
|
||||||
|
confirmLabel="Delete volume"
|
||||||
|
danger
|
||||||
|
busy={remove.isPending}
|
||||||
|
onConfirm={() => remove.mutate(toDelete)}
|
||||||
|
onCancel={() => {
|
||||||
|
setToDelete(null);
|
||||||
|
setForce(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toDelete.in_use && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={force}
|
||||||
|
onChange={(e) => setForce(e.target.checked)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
Force delete (volume is in use)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user