docker: logs follow a held connection, and networks can be removed
Two gaps the Docker tab had against Dockhand and against its own Services tab next door. Logs: one `docker logs -f` per viewer over a WebSocket, the shape the Services tab's journal tail already uses, instead of asking again every two seconds. stdout and stderr both, with stderr marked so the page can colour what went wrong. Docker replays the backlog down two pipes in no particular order, so the opening 300 ms is held and sorted by the stamp Docker wrote; after that the log is live and in order. A reconnect resumes from the last line rather than from the top — `--since` is inclusive and two lines can share a nanosecond, so the client drops what it has already shown by line, not by "later than". A container that has stopped ends the stream at once, so reconnects back off to 15 s. Networks: remove and prune, which images and volumes have had all along. The overview now says which containers are attached to each network, so the ✕ appears only on one nothing uses, and never on bridge, host or none. `network rm` prints the real reason and then an aggregate "exit status 1", so the message shown is the first line, not the last. Prune is not exercised against `core`: several networks here belong to stacks that are merely down, and pruning would give them new subnets on their next deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b8c654b61b
commit
09dc846730
@@ -595,11 +595,25 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
pulls it. Its `git_repositories` and `git_stacks` tables are **empty**, so nothing
|
pulls it. Its `git_repositories` and `git_stacks` tables are **empty**, so nothing
|
||||||
here uses it — dropping it is the honest answer unless Mitchell says otherwise.
|
here uses it — dropping it is the honest answer unless Mitchell says otherwise.
|
||||||
- *Webhooks, OIDC, LDAP, vulnerability scans.* All four tables empty. Nothing to move.
|
- *Webhooks, OIDC, LDAP, vulnerability scans.* All four tables empty. Nothing to move.
|
||||||
- **Known gaps in the tab itself**, worth closing whether or not Dockhand goes:
|
|
||||||
- *Removing networks* — volumes and images can be pruned and removed, networks cannot.
|
**Done 2026-09-25 — the two gaps in the tab itself.**
|
||||||
- *Logs are polled with `--since` rather than followed over a held connection.* That was
|
- *Logs are followed over a held connection*, the way the Services tab follows a journal
|
||||||
deliberate and works, but a long tail is choppier than the Services tab's live tail,
|
(`internal/api/docker_logs.go`, `/api/docker/containers/{id}/logs/tail`): one
|
||||||
which does hold a connection. Worth reconciling the two.
|
`docker logs -f` per viewer, stdout and stderr both, stderr coloured as an error. A
|
||||||
|
reconnect resumes from the last line's timestamp, so a container that restarts or a
|
||||||
|
phone that slept doesn't replay what is already on screen; the client dedupes by line
|
||||||
|
rather than by "later than", because `--since` is inclusive and two lines can share a
|
||||||
|
timestamp. Docker replays the opening backlog down two pipes in no particular order, so
|
||||||
|
the first 300 ms is held and sorted before it is sent. The polled `?since=` endpoint
|
||||||
|
stays — it is what the resume is built on. Reconnects back off to 15 s, since a
|
||||||
|
container that has stopped ends the stream immediately.
|
||||||
|
- *Networks can be removed and pruned* — `POST /api/docker/networks/{remove,prune}`, the
|
||||||
|
shape images and volumes already had. The overview says who is attached to each one, so
|
||||||
|
the ✕ is only offered on a network nothing uses and never on Docker's own three. Prune
|
||||||
|
is deliberately untested against this host: `core` has several networks belonging to
|
||||||
|
stacks that are merely down (`pihole_default`, `transmission_default`, `gluetun_default`
|
||||||
|
among them), and pruning would take them and hand them new subnets on the next deploy.
|
||||||
|
|
||||||
- **The `*:2375` question, which this is the moment to settle.** A stack deployed to
|
- **The `*:2375` question, which this is the moment to settle.** A stack deployed to
|
||||||
`grid` or the Mac still goes over `tcp://<node>:2375` from the hub. Decide whether the
|
`grid` or the Mac still goes over `tcp://<node>:2375` from the hub. Decide whether the
|
||||||
hub should instead ask the node's own Ham Lab to deploy locally, which would end the
|
hub should instead ask the node's own Ham Lab to deploy locally, which would end the
|
||||||
|
|||||||
+83
-10
@@ -3,8 +3,8 @@ package api
|
|||||||
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
|
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
|
||||||
// start / stop / restart, logs and inspect — plus images, volumes and networks to look at.
|
// start / stop / restart, logs and inspect — plus images, volumes and networks to look at.
|
||||||
// It drives the docker CLI (or podman), as the user first and as root if that is refused.
|
// It drives the docker CLI (or podman), as the user first and as root if that is refused.
|
||||||
// Creating, removing and pruning are deliberately absent: whatever deployed the stacks
|
// What can be removed here is what nothing uses: an untagged image, a volume no container
|
||||||
// (Dockhand, compose) stays the one place that changes what exists.
|
// holds, a network nothing is attached to. Compose stays the one place a stack is defined.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -76,6 +76,18 @@ func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dockerWhy picks the line worth showing out of a failed command. Some subcommands —
|
||||||
|
// `network rm` among them — print the real reason first and then an aggregated "exit
|
||||||
|
// status 1", which is what dockerRun's last-line rule would otherwise hand back.
|
||||||
|
func dockerWhy(out []byte, err error) string {
|
||||||
|
for _, l := range strings.Split(string(out), "\n") {
|
||||||
|
if l = strings.TrimSpace(l); strings.HasPrefix(l, "Error") || strings.Contains(l, "daemon") {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
func jsonLines(out []byte, each func(map[string]any)) {
|
func jsonLines(out []byte, each func(map[string]any)) {
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
@@ -139,23 +151,30 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
// Who uses what, from one inspect of every container: an image or a volume nothing uses is
|
// Who uses what, from one inspect of every container: an image, a volume or a network
|
||||||
// the only kind this page offers to remove.
|
// nothing uses is the only kind this page offers to remove.
|
||||||
imageUsed, volumeUsers := map[string]bool{}, map[string][]string{}
|
imageUsed := map[string]bool{}
|
||||||
|
volumeUsers, networkUsers := map[string][]string{}, map[string][]string{}
|
||||||
if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 {
|
if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 {
|
||||||
const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}`
|
const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}|{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{end}}`
|
||||||
if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil {
|
if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil {
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
p := strings.SplitN(line, "|", 3)
|
p := strings.SplitN(line, "|", 4)
|
||||||
if len(p) < 3 {
|
if len(p) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
name := strings.TrimPrefix(p[0], "/")
|
||||||
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
|
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
|
||||||
imageUsed[id[:12]] = true
|
imageUsed[id[:12]] = true
|
||||||
}
|
}
|
||||||
for _, v := range strings.Split(p[2], ",") {
|
for _, v := range strings.Split(p[2], ",") {
|
||||||
if v != "" {
|
if v != "" {
|
||||||
volumeUsers[v] = append(volumeUsers[v], strings.TrimPrefix(p[0], "/"))
|
volumeUsers[v] = append(volumeUsers[v], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range strings.Split(p[3], ",") {
|
||||||
|
if n != "" {
|
||||||
|
networkUsers[n] = append(networkUsers[n], name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,8 +194,18 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
v["used_by"] = users
|
v["used_by"] = users
|
||||||
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
|
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
|
||||||
}
|
}
|
||||||
|
networks := list("network", "ls", "--format", "{{json .}}")
|
||||||
|
for _, n := range networks {
|
||||||
|
users := networkUsers[dstr(n, "Name")]
|
||||||
|
if users == nil {
|
||||||
|
users = []string{}
|
||||||
|
}
|
||||||
|
sort.Strings(users)
|
||||||
|
n["used_by"] = users
|
||||||
|
n["predefined"] = predefinedNetwork[dstr(n, "Name")]
|
||||||
|
}
|
||||||
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
|
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
|
||||||
"images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")})
|
"images": images, "volumes": volumes, "networks": networks})
|
||||||
}
|
}
|
||||||
|
|
||||||
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
||||||
@@ -399,6 +428,50 @@ func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The three networks Docker makes itself. They hold nothing, cannot be removed, and
|
||||||
|
// their absence would break every container that asks for one by name.
|
||||||
|
var predefinedNetwork = map[string]bool{"bridge": true, "host": true, "none": true}
|
||||||
|
|
||||||
|
// networkRemove deletes one network. Unlike a volume there is no data in it, and unlike an
|
||||||
|
// image nothing has to be downloaded again — a stack that wants it back gets it recreated
|
||||||
|
// on the next deploy. Docker refuses one a container is still attached to, which is the
|
||||||
|
// check that matters, so this asks for no second confirmation.
|
||||||
|
func (s *Server) networkRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct{ Name string }
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) {
|
||||||
|
jsonErr(w, 400, "which network?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if predefinedNetwork[body.Name] {
|
||||||
|
jsonErr(w, 400, body.Name+" is one of Docker's own networks and can't be removed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := dockerRun(ctx, "network", "rm", body.Name); err != nil {
|
||||||
|
jsonErr(w, 409, dockerWhy(out, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker: network rm %s (from %s)", body.Name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkPrune removes every network no container is attached to. A stack that is merely
|
||||||
|
// down counts as unattached, so its network goes too — and comes back on the next deploy,
|
||||||
|
// with a new subnet. That is the whole cost, and the caller is told it.
|
||||||
|
func (s *Server) networkPrune(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
out, err := dockerRun(ctx, "network", "prune", "-f")
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
log.Printf("docker: network prune (from %s)", clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- a container's health, its recent life, its console, and its limits ----
|
// ---- a container's health, its recent life, its console, and its limits ----
|
||||||
|
|
||||||
// dockerHealth is the health check's verdict and the last few probe results. Docker keeps
|
// dockerHealth is the health check's verdict and the last few probe results. Docker keeps
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Following a container's log over a held connection, the way the Services tab follows a
|
||||||
|
// journal. The polled `?since=` endpoint next door still exists — it is what a reconnect
|
||||||
|
// hands back to pick up from — but the live view holds one `docker logs -f` per viewer
|
||||||
|
// instead of asking again every two seconds.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
|
||||||
|
"hamlab/internal/priv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logLine is one line of a container's output: the time Docker stamped it with, the text,
|
||||||
|
// and which of the two streams it came out of — stderr is where a container says what went
|
||||||
|
// wrong, and the page colours it accordingly.
|
||||||
|
type logLine struct {
|
||||||
|
T string `json:"t"`
|
||||||
|
M string `json:"m"`
|
||||||
|
Err bool `json:"err,omitempty"`
|
||||||
|
at string // when to sort it by: its own stamp, or the last one its stream carried
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitStamp pulls Docker's RFC 3339 prefix off a line. A line without one (a container
|
||||||
|
// that writes bare bytes, or a continuation) keeps the whole text and no time — but is
|
||||||
|
// still sorted where its stream put it, behind the last line that did have one.
|
||||||
|
func splitStamp(l string, carry *string) logLine {
|
||||||
|
t, m, ok := strings.Cut(l, " ")
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil {
|
||||||
|
return logLine{M: l, at: *carry}
|
||||||
|
}
|
||||||
|
*carry = t
|
||||||
|
return logLine{T: t, M: m, at: t}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerTail streams a container's output as JSON text frames until the client goes away.
|
||||||
|
// `?since=<RFC3339>` resumes where a dropped connection left off; otherwise it starts with
|
||||||
|
// the last `?n=` lines.
|
||||||
|
func (s *Server) dockerTail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !dockerRef.MatchString(id) {
|
||||||
|
jsonErr(w, 400, "bad container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tool := dockerTool()
|
||||||
|
if tool == "" {
|
||||||
|
jsonErr(w, 500, "neither docker nor podman is installed here")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args := []string{tool, "logs", "--timestamps", "--follow"}
|
||||||
|
if since := r.URL.Query().Get("since"); since != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, since); err != nil {
|
||||||
|
jsonErr(w, 400, "since is an RFC 3339 time")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, "--since", since)
|
||||||
|
} else {
|
||||||
|
args = append(args, "--tail", strconv.Itoa(logCount(r)))
|
||||||
|
}
|
||||||
|
args = append(args, id)
|
||||||
|
if priv.Available() && !dockerReachable() {
|
||||||
|
args = priv.Wrap(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||||
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||||
|
// Own process group, so the kill reaches the docker client itself and not just a sudo
|
||||||
|
// wrapper — the same reason the journal tail does it.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
pg := "-" + strconv.Itoa(cmd.Process.Pid)
|
||||||
|
if args[0] != tool { // running under sudo: only sudo can signal root's children
|
||||||
|
k := priv.Wrap("kill", "-9", "--", pg)
|
||||||
|
return exec.Command(k[0], k[1:]...).Run()
|
||||||
|
}
|
||||||
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
_ = conn.Write(ctx, websocket.MessageText, mustJSON(logLine{M: err.Error(), Err: true}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both pipes funnel into one writer: a websocket takes one writer at a time.
|
||||||
|
lines := make(chan logLine, 256)
|
||||||
|
read := func(p io.Reader, isErr bool) {
|
||||||
|
sc := bufio.NewScanner(p)
|
||||||
|
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
|
||||||
|
carry := ""
|
||||||
|
for sc.Scan() {
|
||||||
|
l := splitStamp(sc.Text(), &carry)
|
||||||
|
l.Err = isErr
|
||||||
|
select {
|
||||||
|
case lines <- l:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
go func() { read(stdout, false); done <- struct{}{} }()
|
||||||
|
go func() { read(stderr, true); done <- struct{}{} }()
|
||||||
|
go func() { // the command ends when the container does; closing says so, having said why
|
||||||
|
<-done
|
||||||
|
<-done
|
||||||
|
_ = cmd.Wait()
|
||||||
|
close(lines)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() { // reader: only to notice the close
|
||||||
|
defer cancel()
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.Read(ctx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ping := time.NewTicker(25 * time.Second)
|
||||||
|
defer ping.Stop()
|
||||||
|
// stdout and stderr are two pipes, and what Docker replays down them arrives in no
|
||||||
|
// particular order. So the opening burst is held for a moment and sorted by the time
|
||||||
|
// Docker stamped each line with; after that the log is live and arrives in order anyway.
|
||||||
|
const burstFor = 300 * time.Millisecond
|
||||||
|
warm := time.NewTimer(burstFor)
|
||||||
|
defer warm.Stop()
|
||||||
|
warmC := warm.C // nil once the burst has been flushed: a nil channel never fires
|
||||||
|
var burst []logLine
|
||||||
|
write := func(l logLine) bool {
|
||||||
|
return conn.Write(ctx, websocket.MessageText, mustJSON(l)) == nil
|
||||||
|
}
|
||||||
|
flush := func() bool {
|
||||||
|
sort.SliceStable(burst, func(i, j int) bool { return burst[i].at < burst[j].at })
|
||||||
|
for _, l := range burst {
|
||||||
|
if !write(l) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
burst = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case l, ok := <-lines:
|
||||||
|
if !ok { // the container stopped, or never existed: say the last of it and go
|
||||||
|
flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if warmC != nil {
|
||||||
|
burst = append(burst, l)
|
||||||
|
if len(burst) < 10000 { // a container that never stops talking still gets read
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !flush() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !write(l) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-warmC:
|
||||||
|
warmC = nil
|
||||||
|
if !flush() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ping.C:
|
||||||
|
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := conn.Ping(pctx)
|
||||||
|
pc()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
|
||||||
@@ -187,6 +187,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune))
|
m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune))
|
||||||
m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove))
|
m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove))
|
||||||
m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune))
|
m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune))
|
||||||
|
m.HandleFunc("POST /api/docker/networks/remove", s.auth(s.networkRemove))
|
||||||
|
m.HandleFunc("POST /api/docker/networks/prune", s.auth(s.networkPrune))
|
||||||
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))
|
||||||
@@ -213,6 +215,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
|
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("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}/logs", s.auth(s.dockerLogs))
|
||||||
|
m.HandleFunc("GET /api/docker/containers/{id}/logs/tail", s.auth(s.dockerTail))
|
||||||
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
|
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
|
||||||
m.HandleFunc("GET /api/docker/containers/{id}/health", s.auth(s.dockerHealth))
|
m.HandleFunc("GET /api/docker/containers/{id}/health", s.auth(s.dockerHealth))
|
||||||
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents))
|
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents))
|
||||||
|
|||||||
+71
-23
@@ -2,7 +2,7 @@
|
|||||||
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
|
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
|
||||||
// come from the same container sensors everything else uses.
|
// come from the same container sensors everything else uses.
|
||||||
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow } from './api'
|
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api'
|
||||||
import TaskOutput from './TaskOutput'
|
import TaskOutput from './TaskOutput'
|
||||||
import StackNew, { DupFrom } from './StackNew'
|
import StackNew, { DupFrom } from './StackNew'
|
||||||
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
||||||
@@ -16,36 +16,77 @@ type Tab = 'containers' | 'stacks' | 'updates' | 'images' | 'volumes' | 'network
|
|||||||
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')
|
||||||
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
|
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
|
||||||
|
|
||||||
|
// A container's output, followed over a held connection the way the Services tab follows a
|
||||||
|
// journal. A reconnect resumes from the last line's timestamp, so a container that restarts
|
||||||
|
// — or a phone that slept — doesn't replay what is already on screen.
|
||||||
function LogView({ id }: { id: string }) {
|
function LogView({ id }: { id: string }) {
|
||||||
const [lines, setLines] = useState<{ t: string; m: string }[]>([])
|
const [lines, setLines] = useState<DockerLogLine[]>([])
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
const [live, setLive] = useState(false)
|
||||||
const [follow, setFollow] = useState(true)
|
const [follow, setFollow] = useState(true)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
const box = useRef<HTMLDivElement>(null)
|
const box = useRef<HTMLDivElement>(null)
|
||||||
const last = useRef('')
|
const last = useRef('')
|
||||||
|
const pausedRef = useRef(false)
|
||||||
|
pausedRef.current = paused
|
||||||
|
const pending = useRef<DockerLogLine[]>([])
|
||||||
|
// What a resume has already shown: `--since` is inclusive, and two lines can share a
|
||||||
|
// timestamp (a container writing to both streams at once), so the boundary is a set of
|
||||||
|
// lines rather than a moment. Bounded — only the lines around a reconnect can repeat.
|
||||||
|
const seen = useRef<{ set: Set<string>; q: string[] }>({ set: new Set(), q: [] })
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let stop = false; setLines([]); last.current = ''
|
setLines([]); setErr(''); setLive(false); last.current = ''; pending.current = []
|
||||||
const tick = async () => {
|
seen.current = { set: new Set(), q: [] }
|
||||||
if (stop) return
|
let closed = false
|
||||||
if (document.visibilityState === 'visible') {
|
let sock: WebSocket | null = null
|
||||||
try {
|
let retry: number | undefined
|
||||||
const r = await api.dockerLogs(id, last.current)
|
let attempt = 0
|
||||||
const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines
|
const connect = () => {
|
||||||
if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) }
|
if (closed) return
|
||||||
setErr('')
|
const q = last.current ? `since=${encodeURIComponent(last.current)}` : 'n=300'
|
||||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
// fixed when the log opens: a reconnect must not follow the console to another machine
|
||||||
|
const s = new WebSocket(wsURLPath(`/api/docker/containers/${encodeURIComponent(id)}/logs/tail?${q}`))
|
||||||
|
sock = s
|
||||||
|
s.onopen = () => { attempt = 0; setLive(true); setErr('') }
|
||||||
|
s.onmessage = (ev) => {
|
||||||
|
const l = JSON.parse(ev.data) as DockerLogLine
|
||||||
|
if (l.t) {
|
||||||
|
const k = l.t + '|' + (l.err ? 'e' : 'o') + '|' + l.m
|
||||||
|
const { set, q } = seen.current
|
||||||
|
if (set.has(k)) return
|
||||||
|
set.add(k); q.push(k)
|
||||||
|
if (q.length > 1000) for (const old of q.splice(0, 500)) set.delete(old)
|
||||||
|
if (l.t > last.current) last.current = l.t
|
||||||
|
}
|
||||||
|
if (pausedRef.current) { pending.current.push(l); return }
|
||||||
|
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), l] : [...prev, l]))
|
||||||
}
|
}
|
||||||
window.setTimeout(tick, 2000)
|
// a container that has stopped — or was never there — ends the stream at once, so
|
||||||
|
// back off rather than asking Docker the same question twice a second forever
|
||||||
|
s.onclose = () => { if (closed) return; setLive(false); retry = window.setTimeout(connect, Math.min(15000, 1000 * 2 ** attempt++)) }
|
||||||
|
s.onerror = () => { setErr('log stream disconnected'); s.close() }
|
||||||
}
|
}
|
||||||
tick()
|
connect()
|
||||||
return () => { stop = true }
|
return () => { closed = true; clearTimeout(retry); sock?.close() }
|
||||||
}, [id])
|
}, [id])
|
||||||
useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow])
|
// Resume: flush what arrived while paused.
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || pending.current.length === 0) return
|
||||||
|
const p = pending.current; pending.current = []
|
||||||
|
setLines((prev) => [...prev, ...p].slice(-2000))
|
||||||
|
}, [paused])
|
||||||
|
useEffect(() => { if (follow && !paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow, paused])
|
||||||
return (
|
return (
|
||||||
<div className="logs">
|
<div className="logs">
|
||||||
<div className="logs-bar"><span className="muted">{lines.length} lines</span><span className="spacer" /><button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button><button className="chip" onClick={() => setLines([])}>Clear</button></div>
|
<div className="logs-bar">
|
||||||
{err && <div className="error">{err}</div>}
|
<span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'} · {lines.length} lines{err && ` · ${err}`}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button>
|
||||||
|
<button className="chip" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
|
||||||
|
<button className="chip" onClick={() => setLines([])}>Clear</button>
|
||||||
|
</div>
|
||||||
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
|
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
|
||||||
{lines.map((l, i) => <div className="log" key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
{lines.map((l, i) => <div className={'log' + (l.err ? ' err' : '')} key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
||||||
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,7 +422,7 @@ export default function Docker() {
|
|||||||
const shownTab: Tab = selStack ? 'stacks' : tab
|
const shownTab: Tab = selStack ? 'stacks' : tab
|
||||||
const up = d.containers.filter((c) => c.state === 'running').length
|
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 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' | 'updates'>, [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 imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']]
|
||||||
return (
|
return (
|
||||||
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
|
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
|
||||||
<div className="svc-list">
|
<div className="svc-list">
|
||||||
@@ -395,6 +436,7 @@ export default function Docker() {
|
|||||||
<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>
|
<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>}
|
</form>}
|
||||||
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
||||||
|
{shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
|
||||||
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
{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>
|
||||||
@@ -431,9 +473,15 @@ export default function Docker() {
|
|||||||
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
|
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
|
||||||
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
||||||
)}
|
)}
|
||||||
{shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && shownTab !== 'updates' && (
|
{shownTab === 'networks' && (
|
||||||
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks' | 'volumes' | 'updates'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <th />}</tr></thead>
|
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
|
||||||
<tbody>{d[shownTab as Exclude<Tab, 'containers' | 'stacks' | 'volumes' | 'updates'>].map((row, i) => <tr key={i}>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks' | 'volumes' | 'updates'>].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}{shownTab === 'images' && <td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <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>
|
<tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
|
||||||
|
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
|
||||||
|
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
|
||||||
|
)}
|
||||||
|
{shownTab === 'images' && (
|
||||||
|
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
|
||||||
|
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <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>
|
||||||
|
|||||||
+4
-1
@@ -117,6 +117,7 @@ export interface DockerHealthProbe { Start: string; End: string; ExitCode: numbe
|
|||||||
export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] }
|
export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] }
|
||||||
export interface DockerEvent { t: number; name: string; action: string; image?: string; exit_code?: string; signal?: string; health?: string }
|
export interface DockerEvent { t: number; name: string; action: string; image?: string; exit_code?: string; signal?: string; health?: string }
|
||||||
|
|
||||||
|
export interface DockerLogLine { t: string; m: string; err?: boolean }
|
||||||
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 StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
|
||||||
@@ -254,7 +255,7 @@ export const api = {
|
|||||||
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' | 'remove') => 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: DockerLogLine[] }>('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`),
|
||||||
machineUpdates: (refresh = false) => req<MachineUpdates>('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`),
|
machineUpdates: (refresh = false) => req<MachineUpdates>('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`),
|
||||||
updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'),
|
updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'),
|
||||||
@@ -274,6 +275,8 @@ export const api = {
|
|||||||
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
|
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
|
||||||
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
|
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
|
||||||
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
|
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
|
||||||
|
removeNetwork: (name: string) => req<void>('POST', '/api/docker/networks/remove', { name }),
|
||||||
|
pruneNetworks: () => req<{ result: string }>('POST', '/api/docker/networks/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}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user