docker: logs follow a held connection, and networks can be removed

Two gaps the Docker tab had against Dockhand and against its own Services
tab next door.

Logs: one `docker logs -f` per viewer over a WebSocket, the shape the
Services tab's journal tail already uses, instead of asking again every two
seconds. stdout and stderr both, with stderr marked so the page can colour
what went wrong. Docker replays the backlog down two pipes in no particular
order, so the opening 300 ms is held and sorted by the stamp Docker wrote;
after that the log is live and in order. A reconnect resumes from the last
line rather than from the top — `--since` is inclusive and two lines can
share a nanosecond, so the client drops what it has already shown by line,
not by "later than". A container that has stopped ends the stream at once,
so reconnects back off to 15 s.

Networks: remove and prune, which images and volumes have had all along.
The overview now says which containers are attached to each network, so the
✕ appears only on one nothing uses, and never on bridge, host or none.
`network rm` prints the real reason and then an aggregate "exit status 1",
so the message shown is the first line, not the last.

Prune is not exercised against `core`: several networks here belong to
stacks that are merely down, and pruning would give them new subnets on
their next deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-25 21:17:37 -07:00
co-authored by Claude Opus 5
parent b8c654b61b
commit 09dc846730
6 changed files with 386 additions and 39 deletions
+83 -10
View File
@@ -3,8 +3,8 @@ package api
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
// start / stop / restart, logs and inspect — plus images, volumes and networks to look at.
// It drives the docker CLI (or podman), as the user first and as root if that is refused.
// Creating, removing and pruning are deliberately absent: whatever deployed the stacks
// (Dockhand, compose) stays the one place that changes what exists.
// What can be removed here is what nothing uses: an untagged image, a volume no container
// holds, a network nothing is attached to. Compose stays the one place a stack is defined.
import (
"context"
@@ -76,6 +76,18 @@ func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
return out, nil
}
// dockerWhy picks the line worth showing out of a failed command. Some subcommands —
// `network rm` among them — print the real reason first and then an aggregated "exit
// status 1", which is what dockerRun's last-line rule would otherwise hand back.
func dockerWhy(out []byte, err error) string {
for _, l := range strings.Split(string(out), "\n") {
if l = strings.TrimSpace(l); strings.HasPrefix(l, "Error") || strings.Contains(l, "daemon") {
return l
}
}
return err.Error()
}
func jsonLines(out []byte, each func(map[string]any)) {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
var m map[string]any
@@ -139,23 +151,30 @@ 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{}
// Who uses what, from one inspect of every container: an image, a volume or a network
// nothing uses is the only kind this page offers to remove.
imageUsed := map[string]bool{}
volumeUsers, networkUsers := map[string][]string{}, 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}}`
const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}|{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{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 {
p := strings.SplitN(line, "|", 4)
if len(p) < 4 {
continue
}
name := strings.TrimPrefix(p[0], "/")
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], "/"))
volumeUsers[v] = append(volumeUsers[v], name)
}
}
for _, n := range strings.Split(p[3], ",") {
if n != "" {
networkUsers[n] = append(networkUsers[n], name)
}
}
}
@@ -175,8 +194,18 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
v["used_by"] = users
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
}
networks := list("network", "ls", "--format", "{{json .}}")
for _, n := range networks {
users := networkUsers[dstr(n, "Name")]
if users == nil {
users = []string{}
}
sort.Strings(users)
n["used_by"] = users
n["predefined"] = predefinedNetwork[dstr(n, "Name")]
}
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
"images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")})
"images": images, "volumes": volumes, "networks": networks})
}
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
@@ -399,6 +428,50 @@ func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
}
// The three networks Docker makes itself. They hold nothing, cannot be removed, and
// their absence would break every container that asks for one by name.
var predefinedNetwork = map[string]bool{"bridge": true, "host": true, "none": true}
// networkRemove deletes one network. Unlike a volume there is no data in it, and unlike an
// image nothing has to be downloaded again — a stack that wants it back gets it recreated
// on the next deploy. Docker refuses one a container is still attached to, which is the
// check that matters, so this asks for no second confirmation.
func (s *Server) networkRemove(w http.ResponseWriter, r *http.Request) {
var body struct{ Name string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) {
jsonErr(w, 400, "which network?")
return
}
if predefinedNetwork[body.Name] {
jsonErr(w, 400, body.Name+" is one of Docker's own networks and can't be removed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if out, err := dockerRun(ctx, "network", "rm", body.Name); err != nil {
jsonErr(w, 409, dockerWhy(out, err))
return
}
log.Printf("docker: network rm %s (from %s)", body.Name, clientIP(r))
w.WriteHeader(204)
}
// networkPrune removes every network no container is attached to. A stack that is merely
// down counts as unattached, so its network goes too — and comes back on the next deploy,
// with a new subnet. That is the whole cost, and the caller is told it.
func (s *Server) networkPrune(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
out, err := dockerRun(ctx, "network", "prune", "-f")
if err != nil {
jsonErr(w, 500, err.Error())
return
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
log.Printf("docker: network prune (from %s)", clientIP(r))
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
}
// ---- a container's health, its recent life, its console, and its limits ----
// dockerHealth is the health check's verdict and the last few probe results. Docker keeps