Stacks: remote stacks editable from the hub, .env editing, deploy diff, image update checks; a gone container says so
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
eddba4250b
commit
1d48267d90
@@ -276,6 +276,21 @@ No native browser dialogs anywhere; no page errors on any of 100 page loads.
|
||||
has the ZFS tools and no pool, so there is nothing to import. Harmless, but it will sit on
|
||||
the rail as "1" until the unit is disabled or the rule is narrowed. Left alone.
|
||||
|
||||
## Quality of life — proposed, not built (waiting on Mitchell)
|
||||
## Quality of life — five chosen by Mitchell, ✅ built and verified 2026-09-19
|
||||
|
||||
See the list sent with this pass.
|
||||
1. ✅ **Stacks on `grid` and the Mac are editable from the hub.** Their files live on `core`
|
||||
(Dockhand deploys over tcp); the hub resolves them and runs compose with `DOCKER_HOST`
|
||||
pointed at the node. Verified on a throwaway stack: saved on `core`, recreated **on
|
||||
`grid`**, same compose labels, nothing created on `core`, output followed on the page.
|
||||
2. ✅ **A stack's `.env` is editable**, values masked until "Reveal and edit". A `.env` that
|
||||
breaks the compose file (a required variable removed) is refused and the old one put back.
|
||||
6. ✅ **A link to a container that no longer exists says so**, by name.
|
||||
9. ✅ **"Update available" per image** — on request, remembered six hours. Verified with an
|
||||
image whose tag had moved on (update available) beside a current one (up to date).
|
||||
10. ✅ **What Deploy would change**: a line diff against what was last deployed from here;
|
||||
after a deploy it says there is nothing left to change. `.env` diffs name the key and
|
||||
never show a value.
|
||||
|
||||
Not chosen, still open: alert acknowledge/snooze; quiet hours and per-rule channels; push
|
||||
straight from Ham Lab; a type hint in the alert editor; switching off the nodes' own alert
|
||||
engines.
|
||||
|
||||
@@ -213,9 +213,17 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
and the compose labels stay exactly as deployed (verified: labels identical after a
|
||||
redeploy). Save validates with `compose config` first, keeps the previous file as
|
||||
`<file>.hamlab-bak`, and never deploys by itself. A stack deployed from another host
|
||||
(Dockhand on `core` driving `grid` over tcp) has no file on that machine: listed
|
||||
read-only, saying so — edit those from the machine that holds the file. Removing
|
||||
containers, images and volumes is still absent.
|
||||
(Dockhand on `core` driving `grid` over tcp) has no file on that machine, so the node
|
||||
alone lists it read-only — but **the hub fills that in** (`hubStacks`): asked about a
|
||||
node, it resolves the files on itself and runs compose here with `DOCKER_HOST` pointed
|
||||
at the node's `tcp://…:2375`, the way it was deployed; the task runs on the hub and the
|
||||
node's page follows it. Also per stack: every file it was deployed from (chips), its
|
||||
**`.env`** (values masked until "Reveal and edit"; validated in place, put back if
|
||||
compose objects), **what Deploy would change** (a line diff against the copy kept at the
|
||||
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
|
||||
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.
|
||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||
|
||||
+17
-1
@@ -133,6 +133,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile))
|
||||
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
|
||||
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
||||
m.HandleFunc("GET /api/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
||||
m.HandleFunc("GET /api/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
|
||||
// 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.
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
|
||||
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("GET /api/nodes/{node}/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
|
||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}", s.auth(s.taskOnHub(s.getTask)))
|
||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
|
||||
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
|
||||
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
|
||||
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
|
||||
@@ -674,7 +686,11 @@ func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.nodes.Proxy(r.PathValue("name"))
|
||||
node := r.PathValue("node") // routes that also carry a {name} of their own call the machine {node}
|
||||
if node == "" {
|
||||
node = r.PathValue("name")
|
||||
}
|
||||
p, ok := s.nodes.Proxy(node)
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such machine")
|
||||
return
|
||||
|
||||
+446
-113
@@ -3,15 +3,20 @@ package api
|
||||
// Stacks: the compose projects behind the containers, with their files to edit and a way to
|
||||
// redeploy them.
|
||||
//
|
||||
// A stack is found from its containers' labels (project, config file). The file may not be
|
||||
// A stack is found from its containers' labels (project, config files). The files may not be
|
||||
// where the label says: an orchestrator that runs compose from inside its own container —
|
||||
// Dockhand, Dockge, Portainer — records a path in *its* filesystem. So the path is looked
|
||||
// up twice: as it stands on this host, and through every container's mounts
|
||||
// Dockhand, Dockge, Portainer — records paths in *its* filesystem. So each path is looked up
|
||||
// twice: as it stands on this host, and through every container's mounts
|
||||
// (/app/data/stacks/… in `dockhand` is /home/core/apps/dockhand/stacks/… here). Compose is
|
||||
// then run where the file makes sense — inside that container when it came from one, so
|
||||
// relative paths, the .env beside it and the labels all stay exactly as they were deployed —
|
||||
// else on the host. A stack whose file is on no path this machine can see (deployed from
|
||||
// another host over tcp) is listed read-only, and says so.
|
||||
// then run where the files make sense — inside that container when they came from one, so
|
||||
// relative paths, the .env beside them and the labels all stay exactly as they were
|
||||
// deployed — else on the host.
|
||||
//
|
||||
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
|
||||
// Docker over tcp, so the node runs the containers and the hub holds the files. The node
|
||||
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
|
||||
// in what the node couldn't (hubStacks): it resolves the files on itself and runs compose
|
||||
// here with DOCKER_HOST pointed at the node.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -24,12 +29,13 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
@@ -38,12 +44,15 @@ import (
|
||||
type stack struct {
|
||||
Name string `json:"name"`
|
||||
Containers []string `json:"containers"`
|
||||
Images []string `json:"images"`
|
||||
Running int `json:"running"`
|
||||
File string `json:"file"` // as the label has it (the first of Files)
|
||||
HostFile string `json:"host_file"` // where it is on this machine; "" when it isn't
|
||||
HostFile string `json:"host_file"` // where it is on the machine that holds it; "" when unknown
|
||||
Files []string `json:"files"` // every file it was deployed from, in order: -f a -f b
|
||||
HostFiles []string `json:"host_files"` // the same, on this machine
|
||||
HostFiles []string `json:"host_files"` // the same, on the machine that holds them
|
||||
Via string `json:"via"` // container compose runs inside; "" = on the host
|
||||
Remote string `json:"remote"` // DOCKER_HOST compose is pointed at; "" = this machine's Docker
|
||||
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
|
||||
Editable bool `json:"editable"`
|
||||
Note string `json:"note,omitempty"`
|
||||
HasEnv bool `json:"has_env"`
|
||||
@@ -90,6 +99,41 @@ func readable(p string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// resolve finds every file of a stack on this machine. Every one must be found — a stack
|
||||
// deployed from `-f compose.yaml -f override.yaml` and redeployed from the first alone would
|
||||
// quietly lose everything the override set.
|
||||
func (st *stack) resolve(maps []mountMap) {
|
||||
st.HostFiles, st.HostFile, st.Via, st.Editable, st.HasEnv = nil, "", "", false, false
|
||||
if st.File == "" {
|
||||
st.Note = "its containers don't say which file deployed them"
|
||||
return
|
||||
}
|
||||
one := func(f string) (host, via string) {
|
||||
if readable(f) {
|
||||
return f, ""
|
||||
}
|
||||
for _, m := range maps {
|
||||
if rest, ok := strings.CutPrefix(f, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) {
|
||||
return filepath.Join(m.src, rest), m.container
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
for i, f := range st.Files {
|
||||
host, via := one(f)
|
||||
if host == "" || (i > 0 && via != st.Via) {
|
||||
st.HostFiles, st.Via = nil, ""
|
||||
st.Note = "deployed from somewhere this machine can't see (" + f + "); edit it there"
|
||||
return
|
||||
}
|
||||
st.HostFiles, st.Via = append(st.HostFiles, host), via
|
||||
}
|
||||
st.HostFile, st.Editable, st.Note = st.HostFiles[0], true, ""
|
||||
st.HasEnv = readable(st.envPath())
|
||||
}
|
||||
|
||||
func (st stack) envPath() string { return filepath.Join(filepath.Dir(st.HostFiles[0]), ".env") }
|
||||
|
||||
func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
// Labels come from inspect, not `ps`: ps joins every label with commas, and the label that
|
||||
// matters here — the list of compose files — is itself a comma-separated list.
|
||||
@@ -99,26 +143,29 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
}
|
||||
by := map[string]*stack{}
|
||||
if len(bytes.TrimSpace(ids)) > 0 {
|
||||
const f = `{{.Name}}|{{.State.Status}}|{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.project.config_files"}}`
|
||||
const f = `{{.Name}}|{{.State.Status}}|{{.Config.Image}}|{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.project.config_files"}}`
|
||||
out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
p := strings.SplitN(line, "|", 4)
|
||||
if len(p) < 4 || p[2] == "" {
|
||||
p := strings.SplitN(line, "|", 5)
|
||||
if len(p) < 5 || p[3] == "" {
|
||||
continue
|
||||
}
|
||||
st := by[p[2]]
|
||||
st := by[p[3]]
|
||||
if st == nil {
|
||||
st = &stack{Name: p[2]}
|
||||
if p[3] != "" {
|
||||
st.Files = strings.Split(p[3], ",")
|
||||
st = &stack{Name: p[3]}
|
||||
if p[4] != "" {
|
||||
st.Files = strings.Split(p[4], ",")
|
||||
st.File = st.Files[0]
|
||||
}
|
||||
by[p[2]] = st
|
||||
by[p[3]] = st
|
||||
}
|
||||
st.Containers = append(st.Containers, strings.TrimPrefix(p[0], "/"))
|
||||
if !contains(st.Images, p[2]) {
|
||||
st.Images = append(st.Images, p[2])
|
||||
}
|
||||
if p[1] == "running" {
|
||||
st.Running++
|
||||
}
|
||||
@@ -128,61 +175,123 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
stacks := []stack{}
|
||||
for _, st := range by {
|
||||
sort.Strings(st.Containers)
|
||||
// Every file must be found — a stack deployed from `-f compose.yaml -f override.yaml` and
|
||||
// redeployed from the first alone would quietly lose everything the override set.
|
||||
resolve := func(f string) (host, via string) {
|
||||
if readable(f) {
|
||||
return f, ""
|
||||
}
|
||||
for _, m := range maps {
|
||||
if rest, ok := strings.CutPrefix(f, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) {
|
||||
return filepath.Join(m.src, rest), m.container
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
if st.File == "" {
|
||||
st.Note = "its containers don't say which file deployed them"
|
||||
} else {
|
||||
for i, f := range st.Files {
|
||||
host, via := resolve(f)
|
||||
if host == "" || (i > 0 && via != st.Via) {
|
||||
st.HostFiles, st.Via = nil, ""
|
||||
st.Note = "deployed from somewhere this machine can't see (" + f + "); edit it there"
|
||||
break
|
||||
}
|
||||
st.HostFiles, st.Via = append(st.HostFiles, host), via
|
||||
}
|
||||
if len(st.HostFiles) > 0 {
|
||||
st.HostFile = st.HostFiles[0]
|
||||
}
|
||||
}
|
||||
if st.HostFile != "" {
|
||||
st.Editable = true
|
||||
st.HasEnv = readable(filepath.Join(filepath.Dir(st.HostFile), ".env"))
|
||||
}
|
||||
sort.Strings(st.Images)
|
||||
st.resolve(maps)
|
||||
stacks = append(stacks, *st)
|
||||
}
|
||||
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
|
||||
return stacks, nil
|
||||
}
|
||||
|
||||
func (s *Server) stackByName(ctx context.Context, name string) (stack, error) {
|
||||
all, err := s.findStacks(ctx)
|
||||
if err != nil {
|
||||
return stack{}, err
|
||||
}
|
||||
for _, st := range all {
|
||||
if st.Name == name {
|
||||
return st, nil
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return stack{}, errors.New("no such stack")
|
||||
return false
|
||||
}
|
||||
|
||||
// hubStacks is a node's stack list with the hub's help: what the node could not find is
|
||||
// looked for here, and if it is here — and the node's Docker answers over tcp, which is how
|
||||
// it was deployed in the first place — the stack becomes editable from this hub.
|
||||
func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
|
||||
code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res struct{ Stacks []stack }
|
||||
if code != 200 || json.Unmarshal(b, &res) != nil {
|
||||
return nil, fmt.Errorf("%s answered %d", node, code)
|
||||
}
|
||||
remote, maps := "", []mountMap(nil)
|
||||
for i := range res.Stacks {
|
||||
st := &res.Stacks[i]
|
||||
if st.Editable || len(st.Files) == 0 {
|
||||
continue
|
||||
}
|
||||
if remote == "" {
|
||||
remote, maps = s.nodeDocker(ctx, node), stackMounts(ctx)
|
||||
}
|
||||
if remote == "-" {
|
||||
continue
|
||||
}
|
||||
note := st.Note
|
||||
if st.resolve(maps); !st.Editable {
|
||||
st.Note = note
|
||||
continue
|
||||
}
|
||||
st.Remote, st.HeldBy = remote, s.host
|
||||
}
|
||||
return res.Stacks, nil
|
||||
}
|
||||
|
||||
var (
|
||||
nodeDockerMu sync.Mutex
|
||||
nodeDockerOK = map[string]string{} // node → "tcp://host:2375", or "-" when it doesn't answer
|
||||
)
|
||||
|
||||
// nodeDocker is the node's Docker endpoint as Dockhand-style deployments use it, if it answers.
|
||||
func (s *Server) nodeDocker(ctx context.Context, node string) string {
|
||||
nodeDockerMu.Lock()
|
||||
defer nodeDockerMu.Unlock()
|
||||
if v, ok := nodeDockerOK[node]; ok && v != "-" {
|
||||
return v
|
||||
}
|
||||
ep := "-"
|
||||
for _, n := range s.nodes.List() {
|
||||
if n.Name != node {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(n.URL); err == nil && u.Hostname() != "" {
|
||||
cand := "tcp://" + u.Hostname() + ":2375"
|
||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
if exec.CommandContext(c, dockerTool(), "-H", cand, "version", "--format", "{{.Server.Version}}").Run() == nil {
|
||||
ep = cand
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
nodeDockerOK[node] = ep
|
||||
return ep
|
||||
}
|
||||
|
||||
// stackFor finds the stack a request means: on this machine, or (on a hub route) one of a
|
||||
// node's stacks that this hub holds the files for. handled is false when the request should
|
||||
// simply be passed on to the node.
|
||||
func (s *Server) stackFor(ctx context.Context, r *http.Request) (st stack, handled bool, err error) {
|
||||
var all []stack
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
all, err = s.hubStacks(ctx, node)
|
||||
} else {
|
||||
all, err = s.findStacks(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return stack{}, true, err
|
||||
}
|
||||
for _, x := range all {
|
||||
if x.Name == r.PathValue("name") {
|
||||
if r.PathValue("node") != "" && x.Remote == "" {
|
||||
return x, false, nil // the node can serve this one itself
|
||||
}
|
||||
return x, true, nil
|
||||
}
|
||||
}
|
||||
return stack{}, true, errors.New("no such stack")
|
||||
}
|
||||
|
||||
func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
stacks, err := s.hubStacks(ctx, node)
|
||||
if err != nil {
|
||||
s.proxyNode(w, r) // let the node's own answer (or failure) through
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"stacks": stacks})
|
||||
return
|
||||
}
|
||||
if dockerTool() == "" { // no Docker is an empty answer, not a failure
|
||||
jsonOK(w, 200, map[string]any{"stacks": []stack{}})
|
||||
return
|
||||
@@ -206,29 +315,41 @@ func readAsRoot(ctx context.Context, path string) ([]byte, error) {
|
||||
|
||||
func sumOf(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:8]) }
|
||||
|
||||
// fileIndex is which of the stack's files a request means: ?file=1 is the first override.
|
||||
func fileIndex(r *http.Request, st stack) (int, bool) {
|
||||
i, err := strconv.Atoi(r.URL.Query().Get("file"))
|
||||
if r.URL.Query().Get("file") == "" {
|
||||
return 0, true
|
||||
// target is which of the stack's files a request means: ?file=1 is the first override,
|
||||
// ?file=env the .env beside the first file.
|
||||
func target(r *http.Request, st stack) (host string, index int, ok bool) {
|
||||
q := r.URL.Query().Get("file")
|
||||
switch {
|
||||
case q == "env":
|
||||
return st.envPath(), -1, st.HasEnv
|
||||
case q == "":
|
||||
return st.HostFiles[0], 0, true
|
||||
}
|
||||
return i, err == nil && i >= 0 && i < len(st.HostFiles)
|
||||
var i int
|
||||
if _, err := fmt.Sscanf(q, "%d", &i); err != nil || i < 0 || i >= len(st.HostFiles) {
|
||||
return "", 0, false
|
||||
}
|
||||
return st.HostFiles[i], i, true
|
||||
}
|
||||
|
||||
func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, err := s.stackByName(ctx, r.PathValue("name"))
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't on this machine")
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
i, ok := fileIndex(r, st)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
}
|
||||
path, i, ok := target(r, st)
|
||||
if !ok {
|
||||
jsonErr(w, 400, "no such file in this stack")
|
||||
return
|
||||
}
|
||||
b, err := readAsRoot(ctx, st.HostFiles[i])
|
||||
b, err := readAsRoot(ctx, path)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -237,8 +358,8 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, 200, map[string]any{"stack": st, "text": string(b), "sum": sumOf(b), "index": i})
|
||||
}
|
||||
|
||||
// composeCmd is `docker compose …` for this stack, run where its file makes sense.
|
||||
// files are given as compose will see them: the label's paths inside a container, host paths otherwise.
|
||||
// composeCmd is `docker compose …` for this stack, run where its files make sense and
|
||||
// pointed at the Docker that runs its containers. files are given as compose will see them.
|
||||
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
|
||||
tool := dockerTool()
|
||||
base := []string{"compose", "-p", st.Name}
|
||||
@@ -246,10 +367,17 @@ func composeCmd(ctx context.Context, st stack, files []string, args ...string) *
|
||||
base = append(base, "-f", f)
|
||||
}
|
||||
if st.Via != "" {
|
||||
return exec.CommandContext(ctx, tool, append(append([]string{"exec", st.Via, "docker"}, base...), args...)...)
|
||||
pre := []string{"exec"}
|
||||
if st.Remote != "" {
|
||||
pre = append(pre, "-e", "DOCKER_HOST="+st.Remote)
|
||||
}
|
||||
return exec.CommandContext(ctx, tool, append(append(append(pre, st.Via, "docker"), base...), args...)...)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
|
||||
cmd.Dir = filepath.Dir(files[0])
|
||||
if st.Remote != "" {
|
||||
cmd.Env = append(os.Environ(), "DOCKER_HOST="+st.Remote)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -265,72 +393,156 @@ func seenFiles(st stack, i int, candidate string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) writeAsOwner(ctx context.Context, path, text string) error {
|
||||
err := s.fsLocal.Write(ctx, path, strings.NewReader(text)) // ours to write, or root's
|
||||
if err != nil {
|
||||
err = s.fsSudo.Write(ctx, path, strings.NewReader(text))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// stackSave validates the new text with compose itself, keeps the old file beside it as
|
||||
// <file>.hamlab-bak, and writes. It does not deploy: that is its own button.
|
||||
func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
st, err := s.stackByName(ctx, r.PathValue("name"))
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't on this machine")
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
Sum string `json:"sum"` // the sum the editor was given when it opened the file
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil || strings.TrimSpace(body.Text) == "" {
|
||||
jsonErr(w, 400, "the file can't be empty")
|
||||
path, i, ok := target(r, st)
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil || !ok || (i >= 0 && strings.TrimSpace(body.Text) == "") {
|
||||
jsonErr(w, 400, "no such file in this stack, or nothing to write")
|
||||
return
|
||||
}
|
||||
i, ok := fileIndex(r, st)
|
||||
if !ok {
|
||||
jsonErr(w, 400, "no such file in this stack")
|
||||
return
|
||||
}
|
||||
target := st.HostFiles[i]
|
||||
if now, err := readAsRoot(ctx, target); err == nil && body.Sum != "" && sumOf(now) != body.Sum {
|
||||
if now, err := readAsRoot(ctx, path); err == nil && body.Sum != "" && sumOf(now) != body.Sum {
|
||||
jsonErr(w, 409, "the file has changed on disk since you opened it (Dockhand, or another editor). Reload it to see the change; your text is still in the box.")
|
||||
return
|
||||
}
|
||||
// Validate a candidate beside the real file, so `.env` and relative paths resolve as they will.
|
||||
cand := target + ".hamlab-new"
|
||||
err = s.fsLocal.Write(ctx, cand, strings.NewReader(body.Text)) // ours to write, or root's
|
||||
if err != nil {
|
||||
err = s.fsSudo.Write(ctx, cand, strings.NewReader(body.Text))
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "couldn't write beside the compose file: "+err.Error())
|
||||
// A candidate goes beside the real file, so `.env` and relative paths resolve as they will.
|
||||
// A compose file is validated as that candidate; a .env has to be in place to be read at all,
|
||||
// so it is swapped in, validated, and swapped back out if compose objects.
|
||||
cand := path + ".hamlab-new"
|
||||
if err := s.writeAsOwner(ctx, cand, body.Text); err != nil {
|
||||
jsonErr(w, 500, "couldn't write beside the file: "+err.Error())
|
||||
return
|
||||
}
|
||||
inner := cand
|
||||
if st.Via != "" {
|
||||
inner = st.Files[i] + ".hamlab-new"
|
||||
sudo := 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 msg, err := composeCmd(ctx, st, seenFiles(st, i, inner), "config", "-q").CombinedOutput(); err != nil {
|
||||
_ = s.fsSudo.Delete(ctx, cand, false)
|
||||
if err := sudo("cp", "-p", path, path+".hamlab-bak"); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
files := seenFiles(st, -1, "")
|
||||
if i >= 0 {
|
||||
inner := cand
|
||||
if st.Via != "" {
|
||||
inner = st.Files[i] + ".hamlab-new"
|
||||
}
|
||||
files = seenFiles(st, i, inner)
|
||||
} else if err := sudo("mv", cand, path); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if msg, err := composeCmd(ctx, st, files, "config", "-q").CombinedOutput(); err != nil {
|
||||
if i >= 0 {
|
||||
_ = s.fsSudo.Delete(ctx, cand, false)
|
||||
} else {
|
||||
_ = sudo("cp", "-p", path+".hamlab-bak", path) // put the .env back
|
||||
}
|
||||
jsonErr(w, 400, "compose rejects it: "+strings.TrimSpace(string(msg)))
|
||||
return
|
||||
}
|
||||
for _, mv := range [][]string{{"cp", "-p", target, target + ".hamlab-bak"}, {"mv", cand, target}} {
|
||||
full := priv.Wrap(mv...)
|
||||
if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil {
|
||||
jsonErr(w, 500, strings.TrimSpace(string(msg)))
|
||||
if i >= 0 {
|
||||
if err := sudo("mv", cand, path); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Printf("stack %s: compose file saved (from %s)", st.Name, clientIP(r))
|
||||
log.Printf("stack %s: %s saved (from %s)", st.Name, filepath.Base(path), clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// stackDiff is what a Deploy would change: each file now, against the copy kept the last
|
||||
// time this console deployed it (<file>.hamlab-deployed), or else the last saved-over copy.
|
||||
func (s *Server) stackDiff(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
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
|
||||
}
|
||||
type fileDiff struct {
|
||||
Name string `json:"name"`
|
||||
Base string `json:"base"` // deployed | saved | ""
|
||||
Old string `json:"old"`
|
||||
New string `json:"new"`
|
||||
}
|
||||
paths := append([]string{}, st.HostFiles...)
|
||||
if st.HasEnv {
|
||||
paths = append(paths, st.envPath())
|
||||
}
|
||||
out := []fileDiff{}
|
||||
for _, p := range paths {
|
||||
now, _ := readAsRoot(ctx, p)
|
||||
d := fileDiff{Name: filepath.Base(p), New: string(now)}
|
||||
for _, b := range [][2]string{{".hamlab-deployed", "deployed"}, {".hamlab-bak", "saved"}} {
|
||||
if old, err := readAsRoot(ctx, p+b[0]); err == nil {
|
||||
d.Base, d.Old = b[1], string(old)
|
||||
break
|
||||
}
|
||||
}
|
||||
if filepath.Base(p) == ".env" { // values are secrets: say which keys changed, not what to
|
||||
d.Old, d.New = maskEnv(d.Old), maskEnv(d.New)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"files": out})
|
||||
}
|
||||
|
||||
// maskEnv keeps a .env's keys and hides its values, tagging each distinct value so a change
|
||||
// still shows as a change.
|
||||
func maskEnv(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, l := range lines {
|
||||
if k, v, ok := strings.Cut(l, "="); ok && !strings.HasPrefix(strings.TrimSpace(l), "#") && v != "" {
|
||||
lines[i] = k + "=•••• (" + sumOf([]byte(v))[:6] + ")"
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// stackAction runs deploy (up -d), update (pull, then up -d), restart or stop as a task
|
||||
// whose output the page follows.
|
||||
func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, err := s.stackByName(ctx, r.PathValue("name"))
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't on this machine")
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
}
|
||||
files := seenFiles(st, -1, "")
|
||||
@@ -339,18 +551,35 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
fl = append(fl, "-f", f)
|
||||
}
|
||||
flags := priv.Quote(append([]string{"-p", st.Name}, fl...))
|
||||
var script string
|
||||
base := func(args string) string {
|
||||
if st.Via != "" {
|
||||
return fmt.Sprintf("%s exec %s docker compose %s %s", dockerTool(), priv.Quote([]string{st.Via}), flags, args)
|
||||
env := ""
|
||||
if st.Remote != "" {
|
||||
env = "-e " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
||||
}
|
||||
return fmt.Sprintf("%s exec %s%s docker compose %s %s", dockerTool(), env, priv.Quote([]string{st.Via}), flags, args)
|
||||
}
|
||||
return fmt.Sprintf("cd %s && %s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), dockerTool(), flags, args)
|
||||
env := ""
|
||||
if st.Remote != "" {
|
||||
env = priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
||||
}
|
||||
return fmt.Sprintf("cd %s && %s%s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), env, dockerTool(), flags, args)
|
||||
}
|
||||
// After a deploy that worked, keep a copy of exactly what was deployed: the next diff is against it.
|
||||
remember := ""
|
||||
paths := append([]string{}, st.HostFiles...)
|
||||
if st.HasEnv {
|
||||
paths = append(paths, st.envPath())
|
||||
}
|
||||
for _, p := range paths {
|
||||
remember += " && " + priv.Quote(priv.Wrap("cp", "-p", p, p+".hamlab-deployed"))
|
||||
}
|
||||
var script string
|
||||
switch r.PathValue("action") {
|
||||
case "deploy":
|
||||
script = base("up -d --remove-orphans")
|
||||
script = base("up -d --remove-orphans") + remember
|
||||
case "update":
|
||||
script = base("pull") + " && " + base("up -d --remove-orphans")
|
||||
script = base("pull") + " && " + base("up -d --remove-orphans") + remember
|
||||
case "restart":
|
||||
script = base("restart")
|
||||
case "stop":
|
||||
@@ -368,6 +597,110 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
||||
log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
// ---- is there a newer image? ----
|
||||
|
||||
type imageCheck struct {
|
||||
Image string `json:"image"`
|
||||
State string `json:"state"` // current | update | unknown
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type updAnswer struct {
|
||||
at time.Time
|
||||
res []imageCheck
|
||||
}
|
||||
|
||||
var (
|
||||
updMu sync.Mutex
|
||||
updCache = map[string]updAnswer{}
|
||||
)
|
||||
|
||||
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
||||
|
||||
// stackUpdates asks each image's registry for the digest its tag points at now and compares
|
||||
// it with the digest that was pulled. It is asked for, never polled: every answer is a
|
||||
// registry request, and Docker Hub counts those. Answers are kept for six hours.
|
||||
func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
key := r.PathValue("node") + "/" + st.Name
|
||||
updMu.Lock()
|
||||
c, ok := updCache[key]
|
||||
updMu.Unlock()
|
||||
refresh := r.URL.Query().Get("refresh") == "1"
|
||||
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
||||
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli()})
|
||||
return
|
||||
}
|
||||
if !refresh {
|
||||
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
||||
return
|
||||
}
|
||||
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
|
||||
if st.Remote != "" {
|
||||
args = append([]string{"-H", st.Remote}, args...)
|
||||
}
|
||||
return dockerRun(ctx, args...)
|
||||
}
|
||||
res := []imageCheck{}
|
||||
for _, img := range st.Images {
|
||||
ic := imageCheck{Image: img, State: "unknown"}
|
||||
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||
var have []string
|
||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
||||
ic.Detail = "built or loaded here, not pulled from a registry"
|
||||
res = append(res, ic)
|
||||
continue
|
||||
}
|
||||
remote, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
|
||||
digest := strings.TrimSpace(string(remote))
|
||||
if i := strings.LastIndex(digest, "\n"); i >= 0 {
|
||||
digest = digest[i+1:]
|
||||
}
|
||||
if err != nil || !strings.HasPrefix(digest, "sha256:") {
|
||||
ic.Detail = "the registry didn't answer"
|
||||
if err != nil {
|
||||
ic.Detail += ": " + err.Error()
|
||||
}
|
||||
res = append(res, ic)
|
||||
continue
|
||||
}
|
||||
ic.State = "update"
|
||||
for _, h := range have {
|
||||
if strings.HasSuffix(h, "@"+digest) {
|
||||
ic.State = "current"
|
||||
}
|
||||
}
|
||||
res = append(res, ic)
|
||||
}
|
||||
updMu.Lock()
|
||||
updCache[key] = updAnswer{time.Now(), res}
|
||||
updMu.Unlock()
|
||||
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli()})
|
||||
}
|
||||
|
||||
// taskOnHub serves a task this hub is running for one of its nodes' stacks; anything else
|
||||
// under /api/nodes/<node>/tasks belongs to the node.
|
||||
func (s *Server) taskOnHub(local http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.tasks.Get(r.PathValue("id")); ok {
|
||||
local(w, r)
|
||||
return
|
||||
}
|
||||
s.proxyNode(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
+48
-10
@@ -2,7 +2,7 @@
|
||||
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
|
||||
// come from the same container sensors everything else uses.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, DockerContainer, DockerInfo, Stack } from './api'
|
||||
import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
||||
import { useHashSel } from './hashsel'
|
||||
@@ -89,27 +89,58 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
|
||||
)
|
||||
}
|
||||
|
||||
// A plain line diff (longest common subsequence): compose files are short, so the simple way is fine.
|
||||
function lineDiff(a: string, b: string): { s: ' ' | '+' | '-'; t: string }[] {
|
||||
const x = a.split('\n'), y = b.split('\n'); const n = x.length, m = y.length
|
||||
const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
|
||||
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1])
|
||||
const out: { s: ' ' | '+' | '-'; t: string }[] = []; let i = 0, j = 0
|
||||
while (i < n && j < m) { if (x[i] === y[j]) { out.push({ s: ' ', t: x[i] }); i++; j++ } else if (L[i + 1][j] >= L[i][j + 1]) out.push({ s: '-', t: x[i++] }); else out.push({ s: '+', t: y[j++] }) }
|
||||
while (i < n) out.push({ s: '-', t: x[i++] }); while (j < m) out.push({ s: '+', t: y[j++] })
|
||||
return out
|
||||
}
|
||||
|
||||
// What a Deploy would change: each file against what this console last deployed (or, before
|
||||
// the first deploy from here, the copy the last save replaced). Only changed lines and a
|
||||
// little around them; a .env shows which keys changed, never their values.
|
||||
function DiffView({ files }: { files: StackDiff[] }) {
|
||||
const changed = files.filter((f) => f.base && f.old !== f.new)
|
||||
if (files.every((f) => !f.base)) return <div className="muted small">Nothing to compare with yet: this console hasn't deployed or saved this stack before.</div>
|
||||
if (changed.length === 0) return <div className="muted small">No changes since {files.some((f) => f.base === 'deployed') ? 'the last deploy from here' : 'the last save'}: Deploy would only recreate what Docker itself thinks has changed.</div>
|
||||
return <>{changed.map((f) => { const d = lineDiff(f.old, f.new); const keep = new Set<number>(); d.forEach((l, i) => { if (l.s !== ' ') for (let k = i - 2; k <= i + 2; k++) keep.add(k) })
|
||||
return <div key={f.name}><div className="muted small">{f.name} · against {f.base === 'deployed' ? 'what was last deployed from here' : 'the copy the last save replaced'}</div>
|
||||
<pre className="diff">{d.map((l, i) => (keep.has(i) ? <div key={i} className={l.s === '+' ? 'add' : l.s === '-' ? 'del' : ''}>{l.s} {l.t}</div> : keep.has(i - 1) ? <div key={i} className="muted"> ⋯</div> : null))}</pre></div> })}</>
|
||||
}
|
||||
|
||||
// A .env is mostly secrets: keys shown, values hidden until asked for.
|
||||
const maskEnv = (text: string) => text.split('\n').map((l) => { const i = l.indexOf('='); return i > 0 && !l.trim().startsWith('#') && l.length > i + 1 ? l.slice(0, i + 1) + '••••••••' : l }).join('\n')
|
||||
|
||||
// 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.
|
||||
function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () => void; onChanged: () => void }) {
|
||||
const [st, setSt] = useState<Stack | null>(null)
|
||||
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
||||
const [err, setErr] = useState(''); const [task, setTask] = useState('')
|
||||
const [file, setFile] = useState(0); const [sum, setSum] = useState('')
|
||||
const [file, setFile] = useState<number | 'env'>(0); const [sum, setSum] = useState('')
|
||||
const [reveal, setReveal] = useState(false); const [diff, setDiff] = useState<StackDiff[] | null>(null)
|
||||
const [upd, setUpd] = useState<{ images: ImageCheck[]; checked: number } | null>(null); const [checking, setChecking] = useState(false)
|
||||
useEffect(() => { setUpd(null); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }, [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])
|
||||
useEffect(() => { setSt(null); setTask(''); load() }, [load])
|
||||
const dirty = text !== orig
|
||||
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(''); 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') } }
|
||||
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>
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
|
||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{st.host_files?.[file] ?? st.host_file}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||
{(st.host_files?.length ?? 0) > 1 && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}</div>}
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
|
||||
{file === 'env' && !reveal ? <div className="env-masked"><pre className="editor stack-editor">{maskEnv(text)}</pre><button className="btn" onClick={() => setReveal(true)}>Reveal and edit</button></div> :
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||||
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}{/changed on disk/.test(err) && <> <button className="link" onClick={async () => { if (await askYes('Reload the file from disk? Your text in the box is replaced.', 'Reload')) load() }}>reload it</button></>}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
|
||||
@@ -120,7 +151,13 @@ 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 danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>
|
||||
</div>
|
||||
{task && <TaskOutput id={task} onDone={() => { onChanged(); load() }} />}
|
||||
{task && <TaskOutput id={task} onDone={() => { onChanged(); load(); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }} />}
|
||||
<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>
|
||||
{diff && <DiffView files={diff} />}
|
||||
<div className="sub-head"><span className="k">Images</span><span className="spacer" /><span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked'}</span> <Btn className="link" busy={checking} onClick={async () => { setChecking(true); try { setUpd(await api.stackUpdates(name, true)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setChecking(false) } }}>check for updates</Btn></div>
|
||||
{(st.images ?? []).map((img) => { const c = upd?.images.find((x) => x.image === img); return <div className="img-row" key={img}><span className="mono small">{img}</span><span className="spacer" />{c ? <span className={'badge' + (c.state === 'update' ? ' attn' : '')} title={c.detail}>{c.state === 'update' ? 'update available' : c.state === 'current' ? 'up to date' : 'unknown'}</span> : <span className="muted small">—</span>}</div> })}
|
||||
</div>
|
||||
<div className="muted small">{st.containers.join(' · ')}</div>
|
||||
</div>
|
||||
)
|
||||
@@ -152,7 +189,7 @@ export default function Docker() {
|
||||
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
||||
const cols: Record<Exclude<Tab, 'containers' | 'stacks'>, [string, string][]> = { images: [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']], volumes: [['Name', 'Name'], ['Driver', 'Driver'], ['Mountpoint', 'Mountpoint']], networks: [['Name', 'Name'], ['Driver', 'Driver'], ['Scope', 'Scope']] }
|
||||
return (
|
||||
<div className={'svc docker' + ((cur && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
|
||||
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
|
||||
<div className="svc-list">
|
||||
<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>
|
||||
@@ -162,7 +199,7 @@ export default function Docker() {
|
||||
{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')}>
|
||||
<span className={'sdot ' + (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.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>
|
||||
))}
|
||||
@@ -186,7 +223,8 @@ export default function Docker() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a container.</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 === 'stacks' && (selStack ? <StackDetail name={selStack} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+7
-3
@@ -103,7 +103,9 @@ export interface SmartAttr { id: number; name: string; value: number; worst: num
|
||||
export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: 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 Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; via: 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 ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: 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 AlertChannel { type: 'ntfy' | 'webhook'; url: string }
|
||||
export interface AlertEvent { t: number; kind: 'firing' | 'resolved'; rule: string; rule_id: string; machine: string; sensor: string; value: unknown; severity: string }
|
||||
@@ -194,8 +196,10 @@ export const api = {
|
||||
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`),
|
||||
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
|
||||
stackFile: (name: string, file = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
|
||||
saveStack: (name: string, file: number, text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
|
||||
stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`),
|
||||
stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
|
||||
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
|
||||
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}`),
|
||||
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
|
||||
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
||||
|
||||
@@ -489,3 +489,10 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
.docker:not(.has-sel) .svc-list { flex: 1 1 0; }
|
||||
.stack-editor { flex: 1; min-height: 46vh; font-size: 13px; tab-size: 2; white-space: pre; overflow: auto; }
|
||||
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } }
|
||||
|
||||
/* stacks: diff, images, .env */
|
||||
.stack-extra { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid var(--border); padding-top: 10px; } .stack-extra .sub-head .k { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
pre.diff { margin: 4px 0 8px; padding: 8px 10px; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 var(--mono); overflow-x: auto; white-space: pre; }
|
||||
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); }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user