From 634e286796ba65406b1381a8a1e244417ebc33d3 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sun, 20 Sep 2026 08:01:57 -0700 Subject: [PATCH] Docker: delete volumes nothing uses, prune anonymous ones; images marked in use, remove unused A named volume is deleted by typing its name, one at a time; the API wants it named twice; nothing is forced. Prune touches anonymous volumes only. Co-Authored-By: Claude Fable 5.1 --- PLAN.md | 8 +++- internal/api/docker.go | 94 +++++++++++++++++++++++++++++++++++++++--- internal/api/server.go | 2 + web/src/Docker.tsx | 17 +++++++- web/src/api.ts | 4 +- web/src/styles.css | 1 + 6 files changed, 116 insertions(+), 10 deletions(-) diff --git a/PLAN.md b/PLAN.md index e1391bb..4b045f2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -239,7 +239,13 @@ something long in one, switch away and back, refresh — it's all still there. not erased; volumes and bind-mounted data are never touched. A stopped container can be **removed** (a running one must be stopped first, on purpose). Images: **pull** (a followed task), **remove** (unforced: Docker refuses one still in use) and **prune** (dangling only). - Still absent on purpose: removing volumes and networks, and anything forced. + **Volumes (2026-09-20):** each says which containers use it; one that nothing uses can be + deleted — a named one by typing its name (it may be the data of a stack that is only + down), an anonymous one by confirming its full name — and "Prune anonymous" clears the + nameless leftovers of recreated containers, never named volumes. Images are marked "in + use", and "Remove unused" takes every image no container uses. Nothing is ever forced: + Docker refuses what is still in use, and that refusal is the answer. Still absent: + removing networks. - **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark, Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; diff --git a/internal/api/docker.go b/internal/api/docker.go index a409d8a..e384eca 100644 --- a/internal/api/docker.go +++ b/internal/api/docker.go @@ -135,10 +135,44 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) { } return rows } + // Who uses what, from one inspect of every container: an image or a volume nothing uses is + // the only kind this page offers to remove. + imageUsed, volumeUsers := map[string]bool{}, map[string][]string{} + if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 { + const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}` + if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil { + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + p := strings.SplitN(line, "|", 3) + if len(p) < 3 { + continue + } + if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 { + imageUsed[id[:12]] = true + } + for _, v := range strings.Split(p[2], ",") { + if v != "" { + volumeUsers[v] = append(volumeUsers[v], strings.TrimPrefix(p[0], "/")) + } + } + } + } + } + images := list("images", "--format", "{{json .}}") + for _, im := range images { + im["in_use"] = imageUsed[dstr(im, "ID")] + } + volumes := list("volume", "ls", "--format", "{{json .}}") + for _, v := range volumes { + users := volumeUsers[dstr(v, "Name")] + if users == nil { + users = []string{} + } + sort.Strings(users) + v["used_by"] = users + v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous") + } jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers, - "images": list("images", "--format", "{{json .}}"), - "volumes": list("volume", "ls", "--format", "{{json .}}"), - "networks": list("network", "ls", "--format", "{{json .}}")}) + "images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")}) } var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`) @@ -203,11 +237,18 @@ func (s *Server) imageRemove(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } -// imagePrune removes dangling images only — layers no tag points at any more, left behind by pulls. +// imagePrune removes dangling images — layers no tag points at any more, left behind by pulls — +// or, asked for {"all": true}, every image no container uses. Those can all be pulled again. func (s *Server) imagePrune(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() - out, err := dockerRun(ctx, "image", "prune", "-f") + var body struct{ All bool } + _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) + args := []string{"image", "prune", "-f"} + if body.All { + args = append(args, "-a") + } + out, err := dockerRun(ctx, args...) if err != nil { jsonErr(w, 500, err.Error()) return @@ -312,3 +353,44 @@ func (s *Server) dockerInspect(w http.ResponseWriter, r *http.Request) { "compose_file": dig(c, "Config", "Labels", "com.docker.compose.project.config_files"), }) } + +var volumeRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$`) + +// volumeRemove deletes one volume and what is in it, which nothing brings back. So it is never +// forced (Docker refuses a volume a container still uses), it is one at a time, and the request +// has to name the volume twice — a slip of a script can't delete one by accident. +func (s *Server) volumeRemove(w http.ResponseWriter, r *http.Request) { + var body struct{ Name, Confirm string } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) { + jsonErr(w, 400, "which volume?") + return + } + if body.Confirm != body.Name { + jsonErr(w, 400, "name the volume again in \"confirm\" to delete it") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + defer cancel() + if _, err := dockerRun(ctx, "volume", "rm", body.Name); err != nil { + jsonErr(w, 409, err.Error()) + return + } + log.Printf("docker: volume rm %s (from %s)", body.Name, clientIP(r)) + w.WriteHeader(204) +} + +// volumePrune removes unused *anonymous* volumes only: the nameless ones an image's VOLUME line +// leaves behind each time its container is recreated. Named volumes are somebody's data, and a +// stack that is merely down leaves its volumes "unused" — those go one at a time, by name. +func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + out, err := dockerRun(ctx, "volume", "prune", "-f") + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + log.Printf("docker: volume prune (from %s)", clientIP(r)) + jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]}) +} diff --git a/internal/api/server.go b/internal/api/server.go index cf2eb3f..2250476 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -136,6 +136,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("POST /api/docker/images/pull", s.auth(s.imagePull)) m.HandleFunc("POST /api/docker/images/remove", s.auth(s.imageRemove)) m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune)) + m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove)) + m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune)) m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile)) m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave)) m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction)) diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index a0e3b33..b519f53 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -212,8 +212,10 @@ export default function Docker() { {shownTab === 'images' &&
{ e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}> setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" /> + {(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused} })()} { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune
} + {shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return
Deleting a volume deletes its data. Only one nothing uses can be deleted.{n > 0 && { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}}
})()} {shownTab === 'images' && pullTask && load()} />} {shownTab === 'containers' && setFilter(e.target.value)} placeholder="filter containers, images, stacks" />} @@ -239,9 +241,20 @@ export default function Docker() { ))} {shownTab === 'containers' && byStack.size === 0 &&
No container matches.
} - {shownTab !== 'containers' && shownTab !== 'stacks' && ( + {shownTab === 'volumes' && ( + + {d.volumes.map((row) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], anon = !!row.anonymous + const remove = async () => { + if (anon) { if (!(await askYes(`Delete this anonymous volume and everything in it?\n\n${name}\n\nNothing uses it. It can't be brought back.`, 'Delete'))) return } + else { const typed = await askText(`Delete the volume "${name}" and everything in it?\n\nNothing uses it right now — but if it belongs to a stack that is only down, this is that stack's data. It can't be brought back.\n\nType its name to confirm.`, '', 'Delete'); if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return } } + try { await api.removeVolume(name); toast('volume deleted'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } + } + return })} + {d.volumes.length === 0 && }
VolumeUsed by
{anon ? <>{name.slice(0, 12)}… anonymous : name}{users.length ? users.join(', ') : nothing}{users.length ? in use : }
No volumes.
+ )} + {shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && ( {cols[shownTab as Exclude].map(([, h]) => )}{shownTab === 'images' && - {d[shownTab as Exclude].map((row, i) => {cols[shownTab as Exclude].map(([k]) => )}{shownTab === 'images' && })}
{h}}
{String(row[k] ?? '')} { const ref = row.Tag && row.Tag !== '' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕
+ {d[shownTab as Exclude].map((row, i) => {cols[shownTab as Exclude].map(([k]) => {String(row[k] ?? '')})}{shownTab === 'images' && {row.in_use ? in use : { const ref = row.Tag && row.Tag !== '' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕}})} )} diff --git a/web/src/api.ts b/web/src/api.ts index d6c5e63..ea06fdf 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -202,7 +202,9 @@ export const api = { deleteStack: (name: string) => req<{ trash: string; moved: number }>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/delete`, { confirm: name }), pullImage: (image: string) => req('POST', '/api/docker/images/pull', { image }), removeImage: (image: string) => req('POST', '/api/docker/images/remove', { image }), - pruneImages: () => req<{ result: string }>('POST', '/api/docker/images/prune'), + pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }), + removeVolume: (name: string) => req('POST', '/api/docker/volumes/remove', { name, confirm: name }), + pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'), stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`), stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`), stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`), diff --git a/web/src/styles.css b/web/src/styles.css index c7db0ca..e25da74 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -504,3 +504,4 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, .stack-new .check { display: flex; flex-direction: row; align-items: flex-start; gap: 8px; font-size: 13px; } .stack-new .check input { margin-top: 3px; } .stack-preview { max-height: 180px; overflow: auto; opacity: .85; } @media (max-width: 800px) { .stack-new .two { grid-template-columns: 1fr; } } +.dtable td.row-end { text-align: right; white-space: nowrap; width: 1%; } .dtable .badge { margin-left: 0; }