Docker: new stacks and containers, stack down/delete, container remove, image pull/remove/prune

A new stack is a folder in a home (where stacks are already kept: Dockhand's group here,
~/stacks elsewhere) with compose.yaml and an optional root-only .env, validated by compose
before it is kept and deployed the way its neighbours were — inside dockhand, or to a node
over tcp from the hub. New container is the same dialog on a form that writes the compose.
Homes are scanned, so undeployed stacks stay listed. Delete moves a stack's files to
.hamlab-trash; nothing is forced, and volumes and data are never touched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-20 07:50:43 -07:00
co-authored by Claude Fable 5.1
parent df6c1dcebf
commit c017b422d5
8 changed files with 605 additions and 17 deletions
+17 -1
View File
@@ -223,7 +223,23 @@ something long in one, switch away and back, refresh — it's all still there.
last deploy from here, `<file>.hamlab-deployed`, else the copy the last save replaced; last deploy from here, `<file>.hamlab-deployed`, else the copy the last save replaced;
`.env` values never shown), and **image update checks** — asked for, never polled, since `.env` values never shown), and **image update checks** — asked for, never polled, since
each is a registry request Docker Hub counts; remembered six hours. A save refuses a each is a registry request Docker Hub counts; remembered six hours. A save refuses a
file changed on disk meanwhile. Removing containers, images and volumes is still absent. file changed on disk meanwhile.
**Making and unmaking (2026-09-20):** "+ New stack" — a name, a folder, compose text and
an optional `.env` (written root-only), checked by compose before it is kept, then
"Create and deploy" or "Create only". "+ New container" is the same dialog opened on a
form (image, ports, volumes, environment, restart, command, host networking) that writes
the compose for you: a container made here is a one-service stack, so it stays editable
and redeployable, where a bare `docker run` could only be thrown away and typed again.
New stacks go in a **home** — a folder where two or more stacks are already deployed from
(Dockhand's `stacks/core` here; `~/stacks` on a machine with none) — and are deployed the
way their neighbours were (inside `dockhand`, or over tcp to a node from the hub). Homes
are also scanned, so a stack with no containers ("not deployed": never deployed, or taken
down) is still listed. **Down** removes a stack's containers; **Delete…** (type its name)
takes it down and moves its own files to `<home>/.hamlab-trash/<name>-<time>/` — moved,
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.
- **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;
+64 -3
View File
@@ -11,6 +11,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@@ -143,19 +145,78 @@ var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
func (s *Server) dockerAction(w http.ResponseWriter, r *http.Request) { func (s *Server) dockerAction(w http.ResponseWriter, r *http.Request) {
id, action := r.PathValue("id"), r.PathValue("action") id, action := r.PathValue("id"), r.PathValue("action")
if !dockerRef.MatchString(id) || (action != "start" && action != "stop" && action != "restart") { if !dockerRef.MatchString(id) || (action != "start" && action != "stop" && action != "restart" && action != "remove") {
jsonErr(w, 400, "start, stop or restart a container by name or ID") jsonErr(w, 400, "start, stop, restart or remove a container by name or ID")
return return
} }
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel() defer cancel()
if _, err := dockerRun(ctx, action, id); err != nil { verb := action
if action == "remove" { // never forced: a running container has to be stopped first, on purpose
verb = "rm"
}
if _, err := dockerRun(ctx, verb, id); err != nil {
code := 500
if action == "remove" {
code = 409
}
jsonErr(w, code, err.Error())
return
}
log.Printf("docker: %s %s (from %s)", action, id, clientIP(r))
w.WriteHeader(204)
}
var imageRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,254}$`)
// imagePull runs `docker pull` as a task the page follows.
func (s *Server) imagePull(w http.ResponseWriter, r *http.Request) {
var body struct{ Image string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !imageRef.MatchString(strings.TrimSpace(body.Image)) {
jsonErr(w, 400, "an image looks like nginx:alpine or ghcr.io/owner/name:tag")
return
}
ref := strings.TrimSpace(body.Image)
t, err := s.tasks.Start("docker:pull", "pull "+ref, exec.Command(dockerTool(), "pull", ref))
if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
} }
log.Printf("docker: pull %s as task %s (from %s)", ref, t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
// imageRemove is `docker rmi`, unforced: an image something still uses is refused by Docker, and that is the answer.
func (s *Server) imageRemove(w http.ResponseWriter, r *http.Request) {
var body struct{ Image string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !imageRef.MatchString(strings.TrimSpace(body.Image)) {
jsonErr(w, 400, "which image?")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if _, err := dockerRun(ctx, "rmi", strings.TrimSpace(body.Image)); err != nil {
jsonErr(w, 409, err.Error())
return
}
log.Printf("docker: rmi %s (from %s)", body.Image, clientIP(r))
w.WriteHeader(204) w.WriteHeader(204)
} }
// imagePrune removes dangling images only — layers no tag points at any more, left behind by pulls.
func (s *Server) imagePrune(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
out, err := dockerRun(ctx, "image", "prune", "-f")
if err != nil {
jsonErr(w, 500, err.Error())
return
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
log.Printf("docker: image prune (from %s)", clientIP(r))
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
}
// dockerLogs is the tail of a container's log; ?since=<RFC3339> asks only for what is new, // 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. // which is how the page follows a log without holding a connection open.
func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) { func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) {
+9
View File
@@ -130,6 +130,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/tokens/read", s.auth(s.issueReadToken)) m.HandleFunc("POST /api/tokens/read", s.auth(s.issueReadToken))
m.HandleFunc("GET /api/docker", s.auth(s.dockerOverview)) m.HandleFunc("GET /api/docker", s.auth(s.dockerOverview))
m.HandleFunc("GET /api/docker/stacks", s.auth(s.stacksList)) m.HandleFunc("GET /api/docker/stacks", s.auth(s.stacksList))
m.HandleFunc("POST /api/docker/stacks", s.auth(s.stackCreate))
m.HandleFunc("GET /api/docker/stacks/homes", s.auth(s.stackHomes))
m.HandleFunc("POST /api/docker/stacks/{name}/delete", s.auth(s.stackDelete))
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("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))
@@ -138,6 +144,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
// The same, asked of a node through this hub: the hub answers for the stacks whose files // The same, asked of a node through this hub: the hub answers for the stacks whose files
// it holds (deployed to the node over tcp) and passes everything else to the node. // it holds (deployed to the node over tcp) and passes everything else to the node.
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList)) m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
m.HandleFunc("POST /api/nodes/{node}/docker/stacks", s.auth(s.stackCreate))
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/homes", s.auth(s.stackHomes))
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/delete", s.auth(s.stackDelete))
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackFile)) m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackFile))
m.HandleFunc("PUT /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackSave)) m.HandleFunc("PUT /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackSave))
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/diff", s.auth(s.stackDiff)) m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/diff", s.auth(s.stackDiff))
+371 -1
View File
@@ -33,6 +33,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -179,10 +180,151 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
st.resolve(maps) st.resolve(maps)
stacks = append(stacks, *st) stacks = append(stacks, *st)
} }
stacks = append(stacks, scanHomes(ctx, s.homesOf(stacks, true), stacks)...)
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name }) sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
return stacks, nil return stacks, nil
} }
// ---- where stacks live ----
// A home is a directory of stacks, one folder each: where new ones are made, and where ones
// that aren't deployed (never were, or were taken down) are still found. Homes aren't
// configured; they are wherever this machine's stacks already are. With none at all, ~/stacks.
type stackHome struct {
Dir string `json:"dir"` // on the machine that holds the files
Inner string `json:"inner"` // the same directory as compose sees it (inside Via); Dir otherwise
Via string `json:"via"` // container compose runs inside
Remote string `json:"remote"` // DOCKER_HOST, when the containers run on another machine
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
Stacks int `json:"stacks"`
Default bool `json:"default"`
}
func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
type tally struct {
home stackHome
deployed int
byVia map[string]int
inner map[string]string
}
by := map[string]*tally{}
for _, st := range stacks {
if !st.Editable || len(st.HostFiles) == 0 || len(st.Files) == 0 {
continue
}
dir := filepath.Dir(filepath.Dir(st.HostFiles[0]))
if strings.Count(dir, "/") < 2 { // "/" or "/tmp": not somewhere to make folders in
continue
}
key := dir + "|" + st.Remote
t := by[key]
if t == nil {
t = &tally{home: stackHome{Dir: dir, Remote: st.Remote, HeldBy: st.HeldBy}, byVia: map[string]int{}, inner: map[string]string{}}
by[key] = t
}
t.home.Stacks++
if len(st.Containers) > 0 {
t.deployed++
t.byVia[st.Via]++
}
t.inner[st.Via] = dir
if st.Via != "" {
t.inner[st.Via] = filepath.Dir(filepath.Dir(st.Files[0]))
}
}
// A folder is a home when stacks are kept there as a habit: two or more deployed from it.
// One stack on its own (Dockhand's own compose file in ~/apps/dockhand) says nothing about
// where the next should go, and would have every compose file near it listed as a stack.
// New stacks are deployed the way most of their neighbours were.
var homes, lone []stackHome
for _, t := range by {
best, n := "", -1
for via, c := range t.byVia {
if c > n || (c == n && via > best) {
best, n = via, c
}
}
if n < 0 {
for via := range t.inner {
best = via
}
}
t.home.Via, t.home.Inner = best, t.inner[best]
if t.home.Inner == "" {
t.home.Inner = t.home.Dir
}
if t.deployed >= 2 {
homes = append(homes, t.home)
} else {
lone = append(lone, t.home)
}
}
if len(homes) == 0 {
homes = lone
}
sort.Slice(homes, func(i, j int) bool {
if homes[i].Stacks != homes[j].Stacks {
return homes[i].Stacks > homes[j].Stacks
}
return homes[i].Dir < homes[j].Dir
})
if len(homes) == 0 && withFallback {
if home, err := os.UserHomeDir(); err == nil {
homes = append(homes, stackHome{Dir: filepath.Join(home, "stacks"), Inner: filepath.Join(home, "stacks")})
}
}
if len(homes) > 0 {
homes[0].Default = true
}
return homes
}
var composeNames = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}
// scanHomes finds the stacks in these homes that have no containers: a folder with a compose file.
func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
have := map[string]bool{}
for _, st := range known {
have[st.Name] = true
for _, f := range st.HostFiles {
have[filepath.Dir(f)] = true
}
}
var out []stack
for _, h := range homes {
args := []string{h.Dir, "-mindepth", "2", "-maxdepth", "2", "-type", "f", "("}
for i, n := range composeNames {
if i > 0 {
args = append(args, "-o")
}
args = append(args, "-name", n)
}
args = append(args, ")")
found, err := exec.CommandContext(ctx, "find", args...).Output()
if err != nil && priv.Available() {
full := priv.Wrap(append([]string{"find"}, args...)...)
found, _ = exec.CommandContext(ctx, full[0], full[1:]...).Output()
}
for _, f := range strings.Split(strings.TrimSpace(string(found)), "\n") {
if f == "" {
continue
}
dir := filepath.Dir(f)
name := strings.ToLower(filepath.Base(dir))
if have[name] || have[dir] || strings.HasPrefix(filepath.Base(dir), ".") {
continue
}
have[name], have[dir] = true, true
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{f}, HostFile: f, Via: h.Via, Remote: h.Remote, HeldBy: h.HeldBy, Editable: true, Note: "not deployed"}
st.Files = []string{filepath.Join(h.Inner, filepath.Base(dir), filepath.Base(f))}
st.File = st.Files[0]
st.HasEnv = readable(st.envPath())
out = append(out, st)
}
}
return out
}
func contains(list []string, s string) bool { func contains(list []string, s string) bool {
for _, x := range list { for _, x := range list {
if x == s { if x == s {
@@ -223,6 +365,14 @@ func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
} }
st.Remote, st.HeldBy = remote, s.host st.Remote, st.HeldBy = remote, s.host
} }
var held []stack
for _, st := range res.Stacks {
if st.HeldBy != "" {
held = append(held, st)
}
}
res.Stacks = append(res.Stacks, scanHomes(ctx, s.homesOf(held, false), res.Stacks)...)
sort.Slice(res.Stacks, func(i, j int) bool { return res.Stacks[i].Name < res.Stacks[j].Name })
return res.Stacks, nil return res.Stacks, nil
} }
@@ -584,8 +734,10 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
script = base("restart") script = base("restart")
case "stop": case "stop":
script = base("stop") script = base("stop")
case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay
script = base("down --remove-orphans")
default: default:
jsonErr(w, 400, "deploy, update, restart or stop") jsonErr(w, 400, "deploy, update, restart, stop or down")
return return
} }
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy { if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
@@ -704,3 +856,221 @@ func (s *Server) taskOnHub(local http.HandlerFunc) http.HandlerFunc {
s.proxyNode(w, r) s.proxyNode(w, r)
} }
} }
// ---- making and unmaking stacks ----
var stackNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
// homesFor is where a new stack could go, seen from this request: this machine's homes, or —
// on a hub route — the node's own homes plus the ones this hub holds for it.
func (s *Server) homesFor(ctx context.Context, r *http.Request) ([]stackHome, error) {
node := r.PathValue("node")
if node == "" {
stacks, err := s.findStacks(ctx)
if err != nil {
return nil, err
}
return s.homesOf(stacks, true), nil
}
stacks, err := s.hubStacks(ctx, node)
if err != nil {
return nil, err
}
var held []stack
for _, st := range stacks {
if st.HeldBy != "" {
held = append(held, st)
}
}
homes := s.homesOf(held, false)
if code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks/homes", nil, nil); err == nil && code == 200 {
var res struct{ Homes []stackHome }
if json.Unmarshal(b, &res) == nil {
for _, h := range res.Homes {
if len(homes) > 0 && h.Stacks == 0 {
continue // the node's empty ~/stacks is only worth offering when there is nothing else
}
h.Default = false
homes = append(homes, h)
}
}
}
if len(homes) > 0 {
homes[0].Default = true
}
return homes, nil
}
func (s *Server) stackHomes(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
if dockerTool() == "" && r.PathValue("node") == "" {
jsonOK(w, 200, map[string]any{"homes": []stackHome{}})
return
}
homes, err := s.homesFor(ctx, r)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if homes == nil { // a machine with no Docker and nothing held for it: an empty list, never null
homes = []stackHome{}
}
jsonOK(w, 200, map[string]any{"homes": homes})
}
// stackCreate makes a stack: a folder in a home with compose.yaml (and .env) in it, checked by
// compose before it is kept. It deploys nothing; Deploy is the next, separate act.
func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
var body struct{ Name, Text, Env, Home string }
if err != nil || json.Unmarshal(raw, &body) != nil {
jsonErr(w, 400, "bad request")
return
}
name := strings.TrimSpace(body.Name)
if !stackNameRe.MatchString(name) || name == "homes" {
jsonErr(w, 400, "a stack name is lower-case letters, digits, - and _, starting with a letter or digit")
return
}
if strings.TrimSpace(body.Text) == "" {
jsonErr(w, 400, "the compose file can't be empty")
return
}
homes, err := s.homesFor(ctx, r)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
var home *stackHome
for i := range homes {
if homes[i].Dir == body.Home || (body.Home == "" && homes[i].Default) {
home = &homes[i]
}
}
if home == nil {
jsonErr(w, 400, "that isn't one of the places this machine keeps stacks")
return
}
if r.PathValue("node") != "" && home.HeldBy == "" { // the node's own folder: the node makes it
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
s.proxyNode(w, r)
return
}
var all []stack
if node := r.PathValue("node"); node != "" {
all, _ = s.hubStacks(ctx, node)
} else {
all, _ = s.findStacks(ctx)
}
for _, st := range all {
if st.Name == name {
jsonErr(w, 409, "there is already a stack called "+name)
return
}
}
dir := filepath.Join(home.Dir, name)
if readable(dir) || exec.Command("test", "-e", dir).Run() == nil {
jsonErr(w, 409, dir+" already exists")
return
}
sudo := func(args ...string) error {
if msg, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput(); err != nil {
full := priv.Wrap(args...)
if msg2, err2 := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err2 != nil {
return errors.New(strings.TrimSpace(string(msg) + " " + string(msg2)))
}
}
return nil
}
if err := sudo("mkdir", "-p", dir); err != nil {
jsonErr(w, 500, err.Error())
return
}
undo := func() {
_ = sudo("rm", "-f", filepath.Join(dir, "compose.yaml"), filepath.Join(dir, ".env"))
_ = sudo("rmdir", dir)
}
host := filepath.Join(dir, "compose.yaml")
if err := s.writeAsOwner(ctx, host, body.Text); err != nil {
undo()
jsonErr(w, 500, err.Error())
return
}
if strings.TrimSpace(body.Env) != "" {
if err := s.writeAsOwner(ctx, filepath.Join(dir, ".env"), body.Env); err != nil {
undo()
jsonErr(w, 500, err.Error())
return
}
_ = sudo("chmod", "600", filepath.Join(dir, ".env")) // it is where the secrets go
}
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{host}, HostFile: host, Files: []string{filepath.Join(home.Inner, name, "compose.yaml")},
Via: home.Via, Remote: home.Remote, HeldBy: home.HeldBy, Editable: true, Note: "not deployed", HasEnv: strings.TrimSpace(body.Env) != ""}
st.File = st.Files[0]
if msg, err := composeCmd(ctx, st, seenFiles(st, -1, ""), "config", "-q").CombinedOutput(); err != nil {
undo()
jsonErr(w, 400, "compose rejects it: "+strings.TrimSpace(string(msg)))
return
}
log.Printf("stack %s: created in %s (from %s)", name, home.Dir, clientIP(r))
jsonOK(w, 201, map[string]any{"stack": st})
}
// stackDelete takes a stack down and puts its files in <home>/.hamlab-trash/<name>-<time>/ —
// moved, not erased, because a compose file is often the only record of how a service was set
// up. Only the stack's own files move (compose files, .env, this console's copies); anything
// else in the folder, its volumes and its bind-mounted data are left exactly where they are.
func (s *Server) stackDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
defer cancel()
raw, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
st, handled, err := s.stackFor(ctx, r)
if !handled {
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
s.proxyNode(w, r)
return
}
if err != nil || !st.Editable {
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
return
}
var body struct{ Confirm string }
if json.Unmarshal(raw, &body) != nil || body.Confirm != st.Name {
jsonErr(w, 400, "type the stack's name to confirm")
return
}
if len(st.Containers) > 0 {
if msg, err := composeCmd(ctx, st, seenFiles(st, -1, ""), "down", "--remove-orphans").CombinedOutput(); err != nil {
jsonErr(w, 500, "couldn't take it down, so nothing was deleted: "+strings.TrimSpace(string(msg)))
return
}
}
dir := filepath.Dir(st.HostFiles[0])
trash := filepath.Join(filepath.Dir(dir), ".hamlab-trash", st.Name+"-"+time.Now().Format("20060102-150405"))
run := func(args ...string) error {
full := priv.Wrap(args...)
if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil {
return errors.New(strings.TrimSpace(string(msg)))
}
return nil
}
if err := run("mkdir", "-p", trash); err != nil {
jsonErr(w, 500, err.Error())
return
}
moved := 0
for _, f := range append(append([]string{}, st.HostFiles...), st.envPath()) {
for _, suffix := range []string{"", ".hamlab-bak", ".hamlab-deployed", ".hamlab-new"} {
if filepath.Dir(f) == dir && readable(f+suffix) && run("mv", f+suffix, trash+"/") == nil {
moved++
}
}
}
_ = run("rmdir", dir) // only if nothing else was in it
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
log.Printf("stack %s: taken down and %d file(s) moved to %s (from %s)", st.Name, moved, trash, clientIP(r))
jsonOK(w, 200, map[string]any{"trash": trash, "moved": moved})
}
+33 -10
View File
@@ -4,10 +4,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api' import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import StackNew from './StackNew'
import { Sensor, fmtValue, useSensorStream } from './sensors' import { Sensor, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs' import { useRefreshMs } from './prefs'
import { Btn, Loading, askYes, setUnsaved, toast } from './ui' import { Btn, Loading, askText, askYes, setUnsaved, toast } from './ui'
type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks' type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks'
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok') const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
@@ -53,7 +54,7 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
const [tab, setTab] = useState<'logs' | 'inspect'>('logs') const [tab, setTab] = useState<'logs' | 'inspect'>('logs')
const [info, setInfo] = useState<Record<string, unknown> | null>(null) const [info, setInfo] = useState<Record<string, unknown> | null>(null)
useEffect(() => { setInfo(null); if (tab === 'inspect') api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, c.name]) useEffect(() => { setInfo(null); if (tab === 'inspect') api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, c.name])
const act = (a: 'start' | 'stop' | 'restart') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } } const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } }
const running = c.state === 'running' const running = c.state === 'running'
const s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`] const s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? [] const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
@@ -69,6 +70,7 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
</div> </div>
<div className="svc-actions"> <div className="svc-actions">
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>} {!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
{!running && <Btn className="btn danger" confirm={`Remove the container ${c.name}? Its image, volumes and bind-mounted data stay; anything written inside the container itself is lost.${c.stack ? ` Deploying the ${c.stack} stack makes it again.` : ''}`} onClick={async () => { await act('remove')(); onBack() }}>Remove</Btn>}
{running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>} {running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
{running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>} {running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>}
</div> </div>
@@ -117,7 +119,7 @@ const maskEnv = (text: string) => text.split('\n').map((l) => { const i = l.inde
// One stack: its compose file to edit, and the four things to do with it. Saving validates // One stack: its compose file to edit, and the four things to do with it. Saving validates
// with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act. // with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act.
function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () => void; onChanged: () => void }) { function StackDetail({ name, startTask, onBack, onChanged }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void }) {
const [st, setSt] = useState<Stack | null>(null) const [st, setSt] = useState<Stack | null>(null)
const [text, setText] = useState(''); const [orig, setOrig] = useState('') const [text, setText] = useState(''); const [orig, setOrig] = useState('')
const [err, setErr] = useState(''); const [task, setTask] = useState('') const [err, setErr] = useState(''); const [task, setTask] = useState('')
@@ -127,11 +129,16 @@ function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () =>
useEffect(() => { setUpd(null); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }, [name]) useEffect(() => { setUpd(null); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }, [name])
useEffect(() => { setReveal(false) }, [file, name]) useEffect(() => { setReveal(false) }, [file, name])
const load = useCallback(async () => { try { const r = await api.stackFile(name, file); setSt(r.stack); setText(r.text); setOrig(r.text); setSum(r.sum); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name, file]) const load = useCallback(async () => { try { const r = await api.stackFile(name, file); setSt(r.stack); setText(r.text); setOrig(r.text); setSum(r.sum); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name, file])
useEffect(() => { setSt(null); setTask(''); load() }, [load]) useEffect(() => { setSt(null); setTask(startTask ?? ''); load() }, [load]) // eslint-disable-line react-hooks/exhaustive-deps
const dirty = text !== orig const dirty = text !== orig
useEffect(() => { setUnsaved(dirty ? `${name}'s compose file` : ''); return () => setUnsaved('') }, [dirty, name]) useEffect(() => { setUnsaved(dirty ? `${name}'s compose file` : ''); return () => setUnsaved('') }, [dirty, name])
const save = async () => { try { await api.saveStack(name, file, text, sum); setOrig(text); setErr(''); setDiff(null); api.stackFile(name, file).then((r) => setSum(r.sum)).catch(() => {}); toast('saved — the running containers are unchanged until you deploy') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } const save = async () => { try { await api.saveStack(name, file, text, sum); setOrig(text); setErr(''); setDiff(null); api.stackFile(name, file).then((r) => setSum(r.sum)).catch(() => {}); toast('saved — the running containers are unchanged until you deploy') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }
const run = (a: 'deploy' | 'update' | 'restart' | 'stop') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const remove = async () => {
const typed = await askText(`Delete the stack "${name}"?\n\nIts containers are removed and its compose file${st?.has_env ? ' and .env' : ''} moved to .hamlab-trash beside it — moved, not erased. Volumes and bind-mounted data are not touched.\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 { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const run = (a: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
if (err && !st) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div> if (err && !st) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div> if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
return ( return (
@@ -151,6 +158,11 @@ function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () =>
<Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn> <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>
<Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn> <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>
</div> </div>
<div className="svc-actions">
<span className="muted small">{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''}</span><span className="spacer" />
{st.containers.length > 0 && <Btn className="btn danger" title="docker compose down" confirm={`Take ${st.name} down? Its ${st.containers.length} container${st.containers.length === 1 ? ' is' : 's are'} removed (not just stopped). The compose file, volumes and bind-mounted data stay, and Deploy brings it back.`} onClick={run('down')}>Down</Btn>}
<button className="btn danger" onClick={remove}>Delete…</button>
</div>
{task && <TaskOutput id={task} onDone={() => { onChanged(); load(); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }} />} {task && <TaskOutput id={task} onDone={() => { onChanged(); load(); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }} />}
<div className="stack-extra"> <div className="stack-extra">
<div className="sub-head"><span className="k">What Deploy would change</span><span className="spacer" /><button className="link" onClick={async () => { try { setDiff((await api.stackDiff(name)).files) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>{diff ? 'refresh' : 'show'}</button></div> <div className="sub-head"><span className="k">What Deploy would change</span><span className="spacer" /><button className="link" onClick={async () => { try { setDiff((await api.stackDiff(name)).files) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>{diff ? 'refresh' : 'show'}</button></div>
@@ -170,6 +182,9 @@ export default function Docker() {
const [tab, setTab] = useState<Tab>('containers') const [tab, setTab] = useState<Tab>('containers')
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const [sel, setSel] = useHashSel() const [sel, setSel] = useHashSel()
const [making, setMaking] = useState<'' | 'stack' | 'container'>('')
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('')
const rate = useRefreshMs() const rate = useRefreshMs()
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, []) const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, [])
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate]) useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
@@ -193,14 +208,21 @@ export default function Docker() {
<div className="svc-list"> <div className="svc-list">
<div className="svc-tools"> <div className="svc-tools">
<div className="chips">{(['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : t === 'stacks' ? (stacks?.length ?? '…') : d[t].length}</button>)}</div> <div className="chips">{(['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : t === 'stacks' ? (stacks?.length ?? '…') : d[t].length}</button>)}</div>
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
{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" />
<button className="btn on" disabled={!pull.trim()}>Pull</button>
<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>}
{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>
<div className="svc-rows"> <div className="svc-rows">
{shownTab === 'stacks' && (stacks ?? []).map((st) => ( {shownTab === 'stacks' && (stacks ?? []).map((st) => (
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}> <div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
<span className={'sdot ' + (st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /> <span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div> <div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
<div className="svc-state">{st.running}/{st.containers.length}{!st.editable && <><br />read-only</>}</div> <div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
</div> </div>
))} ))}
{shownTab === 'stacks' && stacks?.length === 0 && <div className="muted small" style={{ padding: 12 }}>No compose stacks here.</div>} {shownTab === 'stacks' && stacks?.length === 0 && <div className="muted small" style={{ padding: 12 }}>No compose stacks here.</div>}
@@ -218,14 +240,15 @@ export default function Docker() {
))} ))}
{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 !== 'containers' && shownTab !== 'stacks' && (
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([, h]) => <th key={h}>{h}</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>)}</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><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>
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>} {shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))} {shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
{shownTab === 'stacks' && (selStack ? <StackDetail name={selStack} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)} {making && <StackNew mode={making} onClose={() => setMaking('')} onCreated={async (name, deploy) => { setMaking(''); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
</div> </div>
) )
} }
+93
View File
@@ -0,0 +1,93 @@
// A new stack — written as compose, or filled in as a form that writes the compose for you.
// "New container" is the same dialog opened on the form: a container made here is a
// one-service stack, so it can be edited, redeployed and updated like everything else on the
// page, where a bare `docker run` could only ever be thrown away and typed again.
import { useEffect, useMemo, useState } from 'react'
import { api, StackHome } from './api'
import { Btn, Loading, useEscape } from './ui'
interface Form { image: string; ports: string; volumes: string; env: string; restart: string; command: string; host: boolean }
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', restart: 'unless-stopped', command: '', host: false }
const lines = (t: string) => t.split('\n').map((x) => x.trim()).filter(Boolean)
const q = (v: string) => JSON.stringify(v) // a JSON string is a valid YAML string, with every awkward character already dealt with
export function composeOf(name: string, f: Form): string {
const svc = name || 'app'
const out = ['services:', ` ${svc}:`, ` image: ${q(f.image.trim() || 'nginx:alpine')}`, ` container_name: ${q(svc)}`]
if (f.restart !== 'no') out.push(` restart: ${f.restart}`)
if (f.command.trim()) out.push(` command: ${q(f.command.trim())}`)
if (f.host) out.push(' network_mode: host')
else if (lines(f.ports).length) out.push(' ports:', ...lines(f.ports).map((p) => ` - ${q(p)}`))
if (lines(f.volumes).length) out.push(' volumes:', ...lines(f.volumes).map((v) => ` - ${q(v)}`))
// a literal $ would be read by compose as a variable: doubled, it is a dollar again
if (lines(f.env).length) out.push(' environment:', ...lines(f.env).map((e) => ` - ${q(e.replace(/\$/g, '$$$$'))}`))
return out.join('\n') + '\n'
}
export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' | 'container'; onClose: () => void; onCreated: (name: string, deploy: boolean) => void }) {
const [homes, setHomes] = useState<StackHome[] | null>(null)
const [home, setHome] = useState('')
const [name, setName] = useState('')
const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose')
const [form, setForm] = useState<Form>(EMPTY)
const [text, setText] = useState('services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n')
const [env, setEnv] = useState('')
const [err, setErr] = useState('')
useEscape(onClose)
useEffect(() => { api.stackHomes().then((r) => { const hs = r.homes ?? []; setHomes(hs); setHome(hs.find((h) => h.default)?.dir ?? hs[0]?.dir ?? '') }).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
const okName = /^[a-z0-9][a-z0-9_-]{0,62}$/.test(name)
const built = useMemo(() => composeOf(name, form), [name, form])
const compose = how === 'form' ? built : text
const picked = homes?.find((h) => h.dir === home)
const create = (deploy: boolean) => async () => {
try { await api.createStack({ name, text: compose, env, home }); onCreated(name, deploy) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
const set = (k: keyof Form) => (e: { target: { value: string } }) => setForm({ ...form, [k]: e.target.value })
return (
<div className="modal-back" onClick={onClose}>
<div className="modal stack-new" onClick={(e) => e.stopPropagation()}>
<h3>{mode === 'container' ? 'New container' : 'New stack'}</h3>
{mode === 'container' && <div className="muted small">Made as a one-service stack, so you can edit it, redeploy it and update its image later from the Stacks tab.</div>}
{!homes && !err && <Loading label="Finding where this machine keeps its stacks…" />}
{homes && homes.length === 0 && <div className="notice">Docker isn't installed on this machine.</div>}
{homes && homes.length > 0 && <>
<label className="muted">Name
<input autoFocus className="mono" value={name} onChange={(e) => { setName(e.target.value.toLowerCase()); setErr('') }} placeholder={mode === 'container' ? 'whoami' : 'my-stack'} maxLength={63} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
</label>
{name && !okName && <div className="error">lower-case letters, digits, - and _ — starting with a letter or digit</div>}
{homes.length > 1 && <label className="muted">Where
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{h.dir}{h.held_by ? ` (on ${h.held_by})` : ''} · {h.stacks} stack{h.stacks === 1 ? '' : 's'}</option>)}</select>
</label>}
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.held_by && ` — kept on ${picked.held_by}, deployed here over ${picked.remote}`}{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
<div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={() => { if (how === 'form' && form.image.trim()) setText(built); setHow('compose') }}>Compose</button></div>
{how === 'form' ? <>
<label className="muted">Image<input className="mono" value={form.image} onChange={set('image')} placeholder="traefik/whoami:latest" spellCheck={false} autoCapitalize="off" autoCorrect="off" /></label>
<div className="two">
<label className="muted">Ports — host:container, one per line<textarea className="mono" rows={2} value={form.ports} onChange={set('ports')} placeholder={'8080:80'} disabled={form.host} spellCheck={false} /></label>
<label className="muted">Volumes — host path or name : container path<textarea className="mono" rows={2} value={form.volumes} onChange={set('volumes')} placeholder={'/home/core/apps/whoami:/data'} spellCheck={false} /></label>
</div>
<label className="muted">Environment — KEY=value, one per line<textarea className="mono" rows={2} value={form.env} onChange={set('env')} placeholder={'TZ=America/Los_Angeles'} spellCheck={false} /></label>
<div className="two">
<label className="muted">Restart<select value={form.restart} onChange={set('restart')}><option value="unless-stopped">unless-stopped</option><option value="always">always</option><option value="on-failure">on-failure</option><option value="no">no</option></select></label>
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
</div>
<label className="check"><input type="checkbox" checked={form.host} onChange={(e) => setForm({ ...form, host: e.target.checked })} /> <span>Host networking <span className="muted">— the container uses this machine's network directly; ports don't apply</span></span></label>
<pre className="unit-file stack-preview">{built}</pre>
</> : <>
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
<label className="muted">.env (optional) — variables the compose file reads; kept readable by root only
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
</label>
</>}
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}</div>}
<div className="svc-actions">
<Btn className="btn on" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(true)}>Create and deploy</Btn>
<Btn className="btn" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(false)}>Create only</Btn>
<span className="spacer" /><button className="btn" onClick={onClose}>Cancel</button>
</div>
</>}
{err && !homes && <div className="error">{err}</div>}
</div>
</div>
)
}
+9 -2
View File
@@ -104,6 +104,7 @@ export interface SmartInfo { device: string; available: boolean; supported: bool
export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number } export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number }
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] } export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean } export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
export interface ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: string } export interface ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: string }
export interface StackDiff { name: string; base: '' | 'deployed' | 'saved'; old: string; new: string } export interface StackDiff { name: string; base: '' | 'deployed' | 'saved'; old: string; new: string }
export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string } export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string }
@@ -192,15 +193,21 @@ export const api = {
tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true), tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
docker: () => req<DockerInfo>('GET', '/api/docker'), docker: () => req<DockerInfo>('GET', '/api/docker'),
dockerAction: (id: string, action: 'start' | 'stop' | 'restart') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`), dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`), dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`), dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'), stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'),
createStack: (p: { name: string; text: string; env: string; home: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),
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 }),
removeImage: (image: string) => req<void>('POST', '/api/docker/images/remove', { image }),
pruneImages: () => req<{ result: string }>('POST', '/api/docker/images/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}`),
saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }), saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
stackAction: (name: string, action: 'deploy' | 'update' | 'restart' | 'stop') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`), stackAction: (name: string, action: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true), alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true), saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true), testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
+9
View File
@@ -495,3 +495,12 @@ pre.diff { margin: 4px 0 8px; padding: 8px 10px; background: var(--term-bg); bor
pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, transparent); } pre.diff .del { color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, transparent); } pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, transparent); } pre.diff .del { color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, transparent); }
.img-row { display: flex; align-items: center; gap: 8px; min-width: 0; } .img-row .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .badge.attn { color: var(--s2); border-color: var(--s2); } .img-row { display: flex; align-items: center; gap: 8px; min-width: 0; } .img-row .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .badge.attn { color: var(--s2); border-color: var(--s2); }
.env-masked { position: relative; display: flex; flex-direction: column; flex: 1; } .env-masked pre { margin: 0; min-height: 30vh; opacity: .8; } .env-masked .btn { position: absolute; top: 10px; right: 10px; } .env-masked { position: relative; display: flex; flex-direction: column; flex: 1; } .env-masked pre { margin: 0; min-height: 30vh; opacity: .8; } .env-masked .btn { position: absolute; top: 10px; right: 10px; }
/* new stack / new container */
.modal.stack-new { width: min(680px, 100%); max-height: 92vh; overflow-y: auto; }
.stack-new textarea, .stack-new select, .stack-new input:not([type=checkbox]) { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; width: 100%; box-sizing: border-box; }
.stack-new textarea.mono, .stack-new input.mono { font-family: var(--mono); font-size: 13px; } .stack-new textarea { resize: vertical; }
.stack-new .two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .stack-new .stack-editor { min-height: 32vh; }
.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; } }