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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c017b422d5
commit
634e286796
@@ -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
|
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
|
**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).
|
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,
|
- **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
|
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||||
|
|||||||
+88
-6
@@ -135,10 +135,44 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return rows
|
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,
|
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
|
||||||
"images": list("images", "--format", "{{json .}}"),
|
"images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")})
|
||||||
"volumes": list("volume", "ls", "--format", "{{json .}}"),
|
|
||||||
"networks": list("network", "ls", "--format", "{{json .}}")})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
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)
|
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) {
|
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()
|
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 {
|
if err != nil {
|
||||||
jsonErr(w, 500, err.Error())
|
jsonErr(w, 500, err.Error())
|
||||||
return
|
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"),
|
"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]})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/pull", s.auth(s.imagePull))
|
||||||
m.HandleFunc("POST /api/docker/images/remove", s.auth(s.imageRemove))
|
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/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("GET /api/docker/stacks/{name}", s.auth(s.stackFile))
|
||||||
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
|
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
|
||||||
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
||||||
|
|||||||
+15
-2
@@ -212,8 +212,10 @@ export default function Docker() {
|
|||||||
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
|
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
|
||||||
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||||
<button className="btn on" disabled={!pull.trim()}>Pull</button>
|
<button className="btn on" disabled={!pull.trim()}>Pull</button>
|
||||||
|
{(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && <Btn className="btn danger" title="remove every image no container uses" confirm={`Remove all ${unused} image${unused === 1 ? '' : 's'} that no container uses? They can be pulled again, but a stack that is down will have to download its images before it next starts.`} onClick={async () => { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused}</Btn> })()}
|
||||||
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
|
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
|
||||||
</form>}
|
</form>}
|
||||||
|
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
||||||
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
||||||
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -239,9 +241,20 @@ export default function Docker() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{shownTab === 'containers' && byStack.size === 0 && <div className="muted small" style={{ padding: 12 }}>No container matches.</div>}
|
{shownTab === 'containers' && byStack.size === 0 && <div className="muted small" style={{ padding: 12 }}>No container matches.</div>}
|
||||||
{shownTab !== 'containers' && shownTab !== 'stacks' && (
|
{shownTab === 'volumes' && (
|
||||||
|
<table className="dtable"><thead><tr><th>Volume</th><th>Used by</th><th /></tr></thead>
|
||||||
|
<tbody>{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 <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
|
||||||
|
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
||||||
|
)}
|
||||||
|
{shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && (
|
||||||
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <th />}</tr></thead>
|
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <th />}</tr></thead>
|
||||||
<tbody>{d[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map((row, i) => <tr key={i}>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}{shownTab === 'images' && <td><Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${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') } }}>✕</Btn></td>}</tr>)}</tbody></table>
|
<tbody>{d[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map((row, i) => <tr key={i}>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}{shownTab === 'images' && <td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${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') } }}>✕</Btn>}</td>}</tr>)}</tbody></table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-1
@@ -202,7 +202,9 @@ export const api = {
|
|||||||
deleteStack: (name: string) => req<{ trash: string; moved: number }>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/delete`, { confirm: name }),
|
deleteStack: (name: string) => req<{ trash: string; moved: number }>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/delete`, { confirm: name }),
|
||||||
pullImage: (image: string) => req<TaskInfo>('POST', '/api/docker/images/pull', { image }),
|
pullImage: (image: string) => req<TaskInfo>('POST', '/api/docker/images/pull', { image }),
|
||||||
removeImage: (image: string) => req<void>('POST', '/api/docker/images/remove', { image }),
|
removeImage: (image: string) => req<void>('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<void>('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`),
|
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' : ''}`),
|
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}`),
|
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
|
||||||
|
|||||||
@@ -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-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; }
|
.stack-preview { max-height: 180px; overflow: auto; opacity: .85; }
|
||||||
@media (max-width: 800px) { .stack-new .two { grid-template-columns: 1fr; } }
|
@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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user