Stacks: deploy with every compose file (labels from inspect, not comma-joined ps); refuse a stale overwrite; empty list without Docker. Alerts: firing state survives a restart without re-announcing; vanished alerts are recorded as cleared with the reason; rule values validated; 200 rules. Docker: 404 for a removed container's logs. Skins: four palettes lifted to readable contrast. Phone: bigger toggles and swatches. Installer: deploy republishes; an older published build is never installed silently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
254 lines
8.4 KiB
Go
254 lines
8.4 KiB
Go
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.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlab/internal/priv"
|
|
)
|
|
|
|
type dockerContainer struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Image string `json:"image"`
|
|
State string `json:"state"` // running | exited | paused | restarting | created | dead
|
|
Status string `json:"status"` // "Up 3 hours (healthy)"
|
|
Health string `json:"health"` // healthy | unhealthy | starting | none
|
|
Ports string `json:"ports"`
|
|
Stack string `json:"stack"` // compose project, "" when started by hand
|
|
Service string `json:"service"` // compose service
|
|
Created string `json:"created"`
|
|
Mem float64 `json:"mem"` // bytes, from the container's cgroup; 0 when unknown
|
|
}
|
|
|
|
func dockerTool() string {
|
|
for _, t := range []string{"docker", "podman"} {
|
|
if _, err := exec.LookPath(t); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// dockerRun runs the tool as this user, and again as root if the socket said no.
|
|
func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
|
tool := dockerTool()
|
|
if tool == "" {
|
|
return nil, errors.New("neither docker nor podman is installed here")
|
|
}
|
|
out, err := exec.CommandContext(ctx, tool, args...).CombinedOutput()
|
|
if err != nil && priv.Available() && strings.Contains(strings.ToLower(string(out)), "permission denied") {
|
|
full := priv.Wrap(append([]string{tool}, args...)...)
|
|
out, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput()
|
|
}
|
|
if err != nil {
|
|
msg := strings.TrimSpace(string(out))
|
|
if i := strings.LastIndex(msg, "\n"); i >= 0 {
|
|
msg = msg[i+1:]
|
|
}
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return out, errors.New(msg)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func jsonLines(out []byte, each func(map[string]any)) {
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
var m map[string]any
|
|
if json.Unmarshal([]byte(line), &m) == nil {
|
|
each(m)
|
|
}
|
|
}
|
|
}
|
|
|
|
func dstr(m map[string]any, k string) string { s, _ := m[k].(string); return s }
|
|
|
|
func labelOf(labels, key string) string {
|
|
for _, kv := range strings.Split(labels, ",") {
|
|
if v, ok := strings.CutPrefix(kv, key+"="); ok {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
|
if dockerTool() == "" {
|
|
jsonOK(w, 200, map[string]any{"available": false, "message": "Docker isn't installed on this machine."})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
out, err := dockerRun(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}")
|
|
if err != nil {
|
|
jsonOK(w, 200, map[string]any{"available": false, "message": err.Error()})
|
|
return
|
|
}
|
|
containers := []dockerContainer{}
|
|
jsonLines(out, func(m map[string]any) {
|
|
c := dockerContainer{ID: dstr(m, "ID"), Name: strings.Split(dstr(m, "Names"), ",")[0], Image: dstr(m, "Image"), State: dstr(m, "State"), Status: dstr(m, "Status"),
|
|
Ports: dstr(m, "Ports"), Created: dstr(m, "CreatedAt"), Health: "none", Stack: labelOf(dstr(m, "Labels"), "com.docker.compose.project"), Service: labelOf(dstr(m, "Labels"), "com.docker.compose.service")}
|
|
for _, h := range []string{"unhealthy", "healthy", "starting"} {
|
|
if strings.Contains(c.Status, "("+h+")") || strings.Contains(c.Status, "health: "+h) {
|
|
c.Health = h
|
|
break
|
|
}
|
|
}
|
|
for _, dir := range []string{"/sys/fs/cgroup/system.slice/docker-" + c.ID + ".scope", "/sys/fs/cgroup/docker/" + c.ID, "/sys/fs/cgroup/machine.slice/libpod-" + c.ID + ".scope"} {
|
|
if b, err := os.ReadFile(dir + "/memory.current"); err == nil {
|
|
c.Mem, _ = strconv.ParseFloat(strings.TrimSpace(string(b)), 64)
|
|
break
|
|
}
|
|
}
|
|
containers = append(containers, c)
|
|
})
|
|
sort.Slice(containers, func(i, j int) bool {
|
|
if containers[i].Stack != containers[j].Stack {
|
|
return containers[i].Stack < containers[j].Stack
|
|
}
|
|
return containers[i].Name < containers[j].Name
|
|
})
|
|
list := func(args ...string) []map[string]any {
|
|
rows := []map[string]any{}
|
|
if out, err := dockerRun(ctx, args...); err == nil {
|
|
jsonLines(out, func(m map[string]any) { rows = append(rows, m) })
|
|
}
|
|
return rows
|
|
}
|
|
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 .}}")})
|
|
}
|
|
|
|
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
|
|
|
func (s *Server) dockerAction(w http.ResponseWriter, r *http.Request) {
|
|
id, action := r.PathValue("id"), r.PathValue("action")
|
|
if !dockerRef.MatchString(id) || (action != "start" && action != "stop" && action != "restart") {
|
|
jsonErr(w, 400, "start, stop or restart a container by name or ID")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
|
defer cancel()
|
|
if _, err := dockerRun(ctx, action, id); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// dockerLogs is the tail of a container's log; ?since=<RFC3339> asks only for what is new,
|
|
// which is how the page follows a log without holding a connection open.
|
|
func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if !dockerRef.MatchString(id) {
|
|
jsonErr(w, 400, "bad container")
|
|
return
|
|
}
|
|
args := []string{"logs", "--timestamps"}
|
|
if since := r.URL.Query().Get("since"); since != "" {
|
|
if _, err := time.Parse(time.RFC3339Nano, since); err != nil {
|
|
jsonErr(w, 400, "since is an RFC 3339 time")
|
|
return
|
|
}
|
|
args = append(args, "--since", since)
|
|
} else {
|
|
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
|
|
if n <= 0 || n > 5000 {
|
|
n = 300
|
|
}
|
|
args = append(args, "--tail", strconv.Itoa(n))
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
out, err := dockerRun(ctx, append(args, id)...)
|
|
if err != nil {
|
|
code := 500
|
|
if strings.Contains(strings.ToLower(err.Error()), "no such container") {
|
|
code = 404
|
|
}
|
|
jsonErr(w, code, err.Error())
|
|
return
|
|
}
|
|
type line struct {
|
|
T string `json:"t"`
|
|
M string `json:"m"`
|
|
}
|
|
lines := []line{}
|
|
for _, l := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
|
|
if l == "" {
|
|
continue
|
|
}
|
|
t, m, ok := strings.Cut(l, " ")
|
|
if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil {
|
|
t, m = "", l
|
|
}
|
|
lines = append(lines, line{t, m})
|
|
}
|
|
jsonOK(w, 200, map[string]any{"lines": lines})
|
|
}
|
|
|
|
// dockerInspect is the parts of `docker inspect` worth reading on a phone.
|
|
func (s *Server) dockerInspect(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if !dockerRef.MatchString(id) {
|
|
jsonErr(w, 400, "bad container")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
out, err := dockerRun(ctx, "inspect", id)
|
|
var all []map[string]any
|
|
if err != nil || json.Unmarshal(out, &all) != nil || len(all) == 0 {
|
|
jsonErr(w, 500, fmt.Sprint("inspect failed: ", err))
|
|
return
|
|
}
|
|
c := all[0]
|
|
dig := func(m map[string]any, path ...string) any {
|
|
var cur any = m
|
|
for _, p := range path {
|
|
mm, ok := cur.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
cur = mm[p]
|
|
}
|
|
return cur
|
|
}
|
|
env := []string{}
|
|
if list, ok := dig(c, "Config", "Env").([]any); ok {
|
|
for _, e := range list {
|
|
k, _, _ := strings.Cut(fmt.Sprint(e), "=")
|
|
env = append(env, k) // names only: values are where the secrets live
|
|
}
|
|
}
|
|
sort.Strings(env)
|
|
jsonOK(w, 200, map[string]any{
|
|
"image": dig(c, "Config", "Image"), "command": dig(c, "Config", "Cmd"), "entrypoint": dig(c, "Config", "Entrypoint"),
|
|
"restart": dig(c, "HostConfig", "RestartPolicy", "Name"), "network_mode": dig(c, "HostConfig", "NetworkMode"),
|
|
"memory_limit": dig(c, "HostConfig", "Memory"), "started": dig(c, "State", "StartedAt"), "exit_code": dig(c, "State", "ExitCode"),
|
|
"error": dig(c, "State", "Error"), "restarts": c["RestartCount"], "mounts": c["Mounts"], "ports": dig(c, "NetworkSettings", "Ports"),
|
|
"networks": dig(c, "NetworkSettings", "Networks"), "env_names": env,
|
|
"compose_file": dig(c, "Config", "Labels", "com.docker.compose.project.config_files"),
|
|
})
|
|
}
|