Docker: a Stacks section — edit a compose file, validate, deploy; fix the sideways scroll on phones

Stacks are found from container labels; the file is resolved on the host or through an
orchestrator's mounts, and compose runs where the file makes sense (inside Dockhand here),
so a redeploy is identical to how the stack was deployed. Save validates with compose and
keeps a .hamlab-bak; Deploy / Update / Restart / Stop run as followed tasks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 15:23:50 -07:00
co-authored by Claude Fable 5.1
parent 08fb9d6e38
commit 9c4f9dfa9f
6 changed files with 377 additions and 17 deletions
+12 -2
View File
@@ -204,8 +204,18 @@ something long in one, switch away and back, refresh — it's all still there.
sensors, start / stop / restart (asked first), logs that follow (polled with `--since`,
no held connection), inspect (env *names* only), and images / volumes / networks
tables. Works on every machine through the hub; `#/grid/docker/immich_server` links to
one. Creating, removing and pruning are deliberately absent — Dockhand stays the one
place that changes what exists.
one. **Stacks** (`internal/api/stacks.go`): every compose project, found from its
containers' labels, with its file to edit and Deploy (`up -d`), Update (pull, then
deploy), Restart and Stop as followed tasks. The file is looked for where the label says
*and through every container's mounts*, because an orchestrator that runs compose inside
its own container (Dockhand here; Dockge, Portainer) records a path in its own
filesystem; compose is then run **inside that container**, so `.env`, relative paths
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.
- **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;
+4
View File
@@ -129,6 +129,10 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/nodes/{name}/sensors/query", s.sensorAuth(s.proxyNodeSensors))
m.HandleFunc("POST /api/tokens/read", s.auth(s.issueReadToken))
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/{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("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))
+287
View File
@@ -0,0 +1,287 @@
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
// 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
// (/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.
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"hamlab/internal/priv"
)
type stack struct {
Name string `json:"name"`
Containers []string `json:"containers"`
Running int `json:"running"`
File string `json:"file"` // as the label has it
HostFile string `json:"host_file"` // where it is on this machine; "" when it isn't
Via string `json:"via"` // container compose runs inside; "" = on the host
Editable bool `json:"editable"`
Note string `json:"note,omitempty"`
HasEnv bool `json:"has_env"`
}
type mountMap struct{ container, dest, src string }
func stackMounts(ctx context.Context) []mountMap {
out, err := dockerRun(ctx, "ps", "-q")
if err != nil || len(bytes.TrimSpace(out)) == 0 {
return nil
}
insp, err := dockerRun(ctx, append([]string{"inspect"}, strings.Fields(string(out))...)...)
if err != nil {
return nil
}
var list []struct {
Name string
Mounts []struct{ Type, Source, Destination string }
}
if json.Unmarshal(insp, &list) != nil {
return nil
}
var maps []mountMap
for _, c := range list {
for _, m := range c.Mounts {
if m.Type == "bind" && m.Destination != "/" && m.Source != "" {
maps = append(maps, mountMap{strings.TrimPrefix(c.Name, "/"), filepath.Clean(m.Destination), filepath.Clean(m.Source)})
}
}
}
sort.Slice(maps, func(i, j int) bool { return len(maps[i].dest) > len(maps[j].dest) }) // the most specific mount wins
return maps
}
func readable(p string) bool {
if _, err := os.Stat(p); err == nil {
return true
}
if priv.Available() { // stacks kept by a root-owned orchestrator are often unreadable to us
full := priv.Wrap("test", "-f", p)
return exec.Command(full[0], full[1:]...).Run() == nil
}
return false
}
func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
out, err := dockerRun(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}")
if err != nil {
return nil, err
}
by := map[string]*stack{}
jsonLines(out, func(m map[string]any) {
labels := dstr(m, "Labels")
name := labelOf(labels, "com.docker.compose.project")
if name == "" {
return
}
st := by[name]
if st == nil {
st = &stack{Name: name, File: strings.Split(labelOf(labels, "com.docker.compose.project.config_files"), ",")[0]}
by[name] = st
}
st.Containers = append(st.Containers, strings.Split(dstr(m, "Names"), ",")[0])
if dstr(m, "State") == "running" {
st.Running++
}
})
maps := stackMounts(ctx)
stacks := []stack{}
for _, st := range by {
sort.Strings(st.Containers)
switch {
case st.File == "":
st.Note = "its containers don't say which file deployed them"
case readable(st.File):
st.HostFile = st.File
default:
for _, m := range maps {
if rest, ok := strings.CutPrefix(st.File, m.dest+"/"); ok && readable(filepath.Join(m.src, rest)) {
st.HostFile, st.Via = filepath.Join(m.src, rest), m.container
break
}
}
if st.HostFile == "" {
st.Note = "deployed from somewhere this machine can't see (" + st.File + "); edit it there"
}
}
if st.HostFile != "" {
st.Editable = true
st.HasEnv = readable(filepath.Join(filepath.Dir(st.HostFile), ".env"))
}
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
}
}
return stack{}, errors.New("no such stack")
}
func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
stacks, err := s.findStacks(ctx)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, 200, map[string]any{"stacks": stacks})
}
func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*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")
return
}
b, err := os.ReadFile(st.HostFile)
if err != nil && priv.Available() {
full := priv.Wrap("cat", st.HostFile)
b, err = exec.CommandContext(ctx, full[0], full[1:]...).Output()
}
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, 200, map[string]any{"stack": st, "text": string(b)})
}
// composeCmd is `docker compose …` for this stack, run where its file makes sense.
func composeCmd(ctx context.Context, st stack, file string, args ...string) *exec.Cmd {
tool := dockerTool()
if st.Via != "" {
return exec.CommandContext(ctx, tool, append([]string{"exec", st.Via, "docker", "compose", "-p", st.Name, "-f", file}, args...)...)
}
cmd := exec.CommandContext(ctx, tool, append([]string{"compose", "-p", st.Name, "-f", file}, args...)...)
cmd.Dir = filepath.Dir(file)
return cmd
}
// 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"))
if err != nil || !st.Editable {
jsonErr(w, 404, "that stack's compose file isn't on this machine")
return
}
var body struct {
Text string `json:"text"`
}
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")
return
}
// Validate a candidate beside the real file, so `.env` and relative paths resolve as they will.
cand := st.HostFile + ".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())
return
}
inner := cand
if st.Via != "" {
inner = st.File + ".hamlab-new"
}
if msg, err := composeCmd(ctx, st, inner, "config", "-q").CombinedOutput(); err != nil {
_ = s.fsSudo.Delete(ctx, cand, false)
jsonErr(w, 400, "compose rejects it: "+strings.TrimSpace(string(msg)))
return
}
for _, mv := range [][]string{{"cp", "-p", st.HostFile, st.HostFile + ".hamlab-bak"}, {"mv", cand, st.HostFile}} {
full := priv.Wrap(mv...)
if msg, err := exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil {
jsonErr(w, 500, strings.TrimSpace(string(msg)))
return
}
}
log.Printf("stack %s: compose file saved (from %s)", st.Name, clientIP(r))
w.WriteHeader(204)
}
// 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)
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")
return
}
file := st.HostFile
if st.Via != "" {
file = st.File
}
var script string
base := func(args string) string {
if st.Via != "" {
return fmt.Sprintf("%s exec %s docker compose -p %s -f %s %s", dockerTool(), priv.Quote([]string{st.Via}), priv.Quote([]string{st.Name}), priv.Quote([]string{file}), args)
}
return fmt.Sprintf("cd %s && %s compose -p %s -f %s %s", priv.Quote([]string{filepath.Dir(file)}), dockerTool(), priv.Quote([]string{st.Name}), priv.Quote([]string{file}), args)
}
switch r.PathValue("action") {
case "deploy":
script = base("up -d --remove-orphans")
case "update":
script = base("pull") + " && " + base("up -d --remove-orphans")
case "restart":
script = base("restart")
case "stop":
script = base("stop")
default:
jsonErr(w, 400, "deploy, update, restart or stop")
return
}
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
jsonErr(w, 409, "this stack is already busy (task "+t.ID+")")
return
}
t, err := s.tasks.Start("stack:"+st.Name, r.PathValue("action")+" "+st.Name, exec.Command("sh", "-c", script+" 2>&1"))
if err != nil {
jsonErr(w, 500, err.Error())
return
}
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))
}
+63 -15
View File
@@ -2,13 +2,14 @@
// 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 } from './api'
import { api, DockerContainer, DockerInfo, Stack } from './api'
import TaskOutput from './TaskOutput'
import { Sensor, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs'
import { Btn, Loading, toast } from './ui'
import { Btn, Loading, setUnsaved, toast } from './ui'
type Tab = 'containers' | '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 ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
@@ -88,14 +89,50 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
)
}
// 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 load = useCallback(async () => { try { const r = await api.stackFile(name); setSt(r.stack); setText(r.text); setOrig(r.text); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name])
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, text); setOrig(text); setErr(''); 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_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>
<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}</div>}
<div className="svc-actions">
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
{dirty && <button className="btn" onClick={() => { setText(orig); setErr('') }}>Revert</button>}
<span className="spacer" />
<Btn className="btn" disabled={dirty} title={dirty ? 'save first' : 'recreate what changed: docker compose up -d'} confirm={`Deploy ${st.name}? Containers whose definition changed are recreated (a few seconds down each).`} onClick={run('deploy')}>Deploy</Btn>
<Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</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>
</div>
{task && <TaskOutput id={task} onDone={() => { onChanged(); load() }} />}
<div className="muted small">{st.containers.join(' · ')}</div>
</div>
)
}
export default function Docker() {
const [d, setD] = useState<DockerInfo | null>(null)
const [stacks, setStacks] = useState<Stack[] | null>(null)
const [err, setErr] = useState('')
const [tab, setTab] = useState<Tab>('containers')
const [filter, setFilter] = useState('')
const [sel, setSel] = useHashSel()
const rate = useRefreshMs()
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [])
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])
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) })
const sensors = live.data?.c ?? {}
@@ -106,19 +143,29 @@ export default function Docker() {
}, [d, filter])
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
const cur = d.containers.find((c) => c.name === sel)
const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
const cur = selStack ? undefined : d.containers.find((c) => c.name === sel)
const shownTab: Tab = selStack ? 'stacks' : tab
const up = d.containers.filter((c) => c.state === 'running').length
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'>, [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']] }
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 && tab === 'containers' ? ' has-sel' : '')}>
<div className={'svc docker' + ((cur && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
<div className="svc-list">
<div className="svc-tools">
<div className="chips">{(['containers', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (tab === t ? ' on' : '')} onClick={() => setTab(t)}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : d[t].length}</button>)}</div>
{tab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
<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' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
</div>
<div className="svc-rows">
{tab === 'containers' && [...byStack].map(([stack, list]) => (
{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-state">{st.running}/{st.containers.length}{!st.editable && <><br />read-only</>}</div>
</div>
))}
{shownTab === 'stacks' && stacks?.length === 0 && <div className="muted small" style={{ padding: 12 }}>No compose stacks here.</div>}
{shownTab === 'containers' && [...byStack].map(([stack, list]) => (
<div key={stack}>
<h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3>
{list.map((c) => (
@@ -130,14 +177,15 @@ export default function Docker() {
))}
</div>
))}
{tab === 'containers' && byStack.size === 0 && <div className="muted small" style={{ padding: 12 }}>No container matches.</div>}
{tab !== 'containers' && (
<table className="dtable"><thead><tr>{cols[tab].map(([, h]) => <th key={h}>{h}</th>)}</tr></thead>
<tbody>{d[tab].map((row, i) => <tr key={i}>{cols[tab].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}</tr>)}</tbody></table>
{shownTab === 'containers' && byStack.size === 0 && <div className="muted small" style={{ padding: 12 }}>No container matches.</div>}
{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>
<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>
)}
</div>
</div>
{tab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a container.</div>)}
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : <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>
)
}
+5
View File
@@ -103,6 +103,7 @@ 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; via: string; editable: boolean; note?: string; has_env: boolean }
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 }
@@ -192,6 +193,10 @@ export const api = {
dockerAction: (id: string, action: 'start' | 'stop' | 'restart') => 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'}`),
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) => req<{ stack: Stack; text: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}`),
saveStack: (name: string, text: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}`, { text }),
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),
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
+6
View File
@@ -483,3 +483,9 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
.docker .svc-tools .chips { flex-wrap: wrap; } .docker .svc-name { flex: 1; min-width: 0; } .docker .svc-name .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.docker .svc-row { min-width: 0; } .docker .svc-state { flex-shrink: 0; }
.alerts .row { display: flex; align-items: center; gap: 8px; } .alerts .row .badge { flex: 0 0 auto; } .alerts .row .k { flex: 1; min-width: 0; overflow-wrap: anywhere; }
/* docker: the list must never size itself to its longest name (that was the sideways scroll on phones) */
.docker .svc-list { min-width: 0; max-width: 100%; } .docker .svc-rows { overflow-x: hidden; } .docker .svc-name .d { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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; } }