Files
hamlab/internal/api/docker_events.go
Mitchell-JohnsonandClaude Opus 5 2f59a54678 A Network tab, with an internet speed test
Per machine like every other tab: the interfaces as the kernel has them
(addresses, MAC, MTU, negotiated link speed, carrier, totals, live rates from
the sampler's own sensors), the default route and resolvers, what the internet
sees of this connection, and a speed test with the last fifty results kept.

The test runs in the daemon, not the browser, so the figure is the line of the
machine in the switcher — the router's, if you switch to gate — and it carries
on when the phone locks. It measures against Cloudflare's public speed
endpoints, so there is nothing to install on any host, which is what makes it
work the same on Debian and on OpenWrt where flash is scarce.

What the measurement does and does not count:

  - Several streams, HTTP/2 deliberately off. One stream over one connection
    measures a congestion window, and HTTP/2 would put every stream back onto
    one connection and measure the same thing.
  - The first second of each phase is thrown away as the ramp.
  - A phase ends on 64 MiB as well as on four seconds, so a fast line says what
    it is without moving a gigabyte. A minimum window keeps a very fast line
    from filling the cap before there is anything to divide by.
  - Latency is the round trip less the far end's own Server-Timing. A zero-byte
    response is mostly Cloudflare's worker thinking: left in, it reported 31 ms
    for a line the edge itself measures at 3.5. Jitter is the mean change
    between consecutive probes, so one slow probe cannot run away with it.
  - The far end rate-limits by volume. So: a 20 s cooldown counted from the last
    attempt rather than the last success, streams that retry twice, the rate
    computed over the window in which bytes were actually still arriving, and a
    plain sentence when it was refused. Coasting to the end of the clock with
    nothing arriving reported a third of the truth.

The last result is published as net.internet.* sensors, each stamped with when
the test ran, so the line can be a widget, a graph, an alert or a Prometheus
series like anything else. The public address is refreshed on its own quarter
hour, which is worth having here: it is a DHCP lease and it moves.

Tests are offline — the window, the byte cap, the refusal and the cancellation
run against a local server; the route parser's byte order is pinned by a case
that would have caught the bug it did. The live measurement is behind
HAMLAB_NET_TEST=1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:11:03 -07:00

230 lines
5.6 KiB
Go

package api
// A container's history.
//
// `docker events` is a live stream with only a short in-memory backlog — ask it for the
// last thirty days and you get the last few minutes, because the daemon keeps events in a
// small ring and loses them all when it restarts. That is why Dockhand kept its own copy,
// and why this does too: a watcher follows the stream and appends the lifecycle events to a
// ring kept beside the config, so "what happened to this container last Tuesday" has an
// answer.
//
// Only lifecycle events are kept. A health check produces three exec events every probe,
// which is thousands a day and tells you nothing that the health panel does not.
import (
"bufio"
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"hamlab/internal/priv"
)
const (
eventsKept = 4000
eventsSaveEvery = 30 * time.Second
)
// ContainerEvent is one thing that happened to one container.
type ContainerEvent struct {
T int64 `json:"t"` // unix ms
Name string `json:"name"`
Action string `json:"action"`
Image string `json:"image,omitempty"`
ExitCode string `json:"exit_code,omitempty"`
Signal string `json:"signal,omitempty"`
Health string `json:"health,omitempty"`
}
type eventLog struct {
mu sync.Mutex
ring []ContainerEvent
dirty bool
path string
}
func (e *eventLog) load(path string) {
e.mu.Lock()
defer e.mu.Unlock()
e.path = path
b, err := os.ReadFile(path)
if err != nil {
return
}
var list []ContainerEvent
if json.Unmarshal(b, &list) == nil {
e.ring = list
}
}
func (e *eventLog) add(ev ContainerEvent) {
e.mu.Lock()
defer e.mu.Unlock()
e.ring = append(e.ring, ev)
if len(e.ring) > eventsKept {
e.ring = append([]ContainerEvent(nil), e.ring[len(e.ring)-eventsKept:]...)
}
e.dirty = true
}
// forName is one container's events, newest first.
func (e *eventLog) forName(name string, since time.Time, limit int) []ContainerEvent {
e.mu.Lock()
defer e.mu.Unlock()
out := []ContainerEvent{}
for _, ev := range e.ring {
if ev.Name == name && ev.T >= since.UnixMilli() {
out = append(out, ev)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].T > out[j].T })
if len(out) > limit {
out = out[:limit]
}
return out
}
func (e *eventLog) save() {
e.mu.Lock()
if !e.dirty || e.path == "" {
e.mu.Unlock()
return
}
b, err := json.Marshal(e.ring)
e.dirty = false
path := e.path
e.mu.Unlock()
if err != nil {
return
}
_ = os.MkdirAll(filepath.Dir(path), 0o700)
tmp := path + ".tmp"
if os.WriteFile(tmp, b, 0o600) == nil {
_ = os.Rename(tmp, path)
}
}
// watchDockerEvents follows the stream, restarting if Docker goes away. It is the only
// thing that writes the log, so nothing contends with it.
func (s *Server) watchDockerEvents(ctx context.Context) {
s.events.load(filepath.Join(s.cfg.DataDir(), "docker-events.json"))
go func() { // a periodic flush, so a crash loses at most half a minute of history
t := time.NewTicker(eventsSaveEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
s.events.save()
return
case <-t.C:
s.events.save()
}
}
}()
for {
if ctx.Err() != nil {
return
}
if dockerTool() == "" {
time.Sleep(time.Minute) // no Docker here; look again later in case it is installed
continue
}
s.followEvents(ctx)
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second): // the daemon restarted, or the pipe broke
}
}
}
func (s *Server) followEvents(ctx context.Context) {
args := []string{"events", "--format", "{{json .}}", "--filter", "type=container"}
for _, e := range []string{"create", "start", "restart", "stop", "kill", "die", "destroy", "oom", "pause", "unpause", "rename", "update", "health_status"} {
args = append(args, "--filter", "event="+e)
}
tool := dockerTool()
full := append([]string{tool}, args...)
if !dockerReachable() && priv.Available() {
full = priv.Wrap(full...)
}
cmd := exec.CommandContext(ctx, full[0], full[1:]...)
out, err := cmd.StdoutPipe()
if err != nil {
return
}
if err := cmd.Start(); err != nil {
return
}
sc := bufio.NewScanner(out)
sc.Buffer(make([]byte, 64<<10), 1<<20)
n := 0
for sc.Scan() {
var e struct {
Action string `json:"Action"`
Time int64 `json:"time"`
TimeNano int64 `json:"timeNano"`
Actor struct {
Attributes map[string]string `json:"Attributes"`
} `json:"Actor"`
}
if json.Unmarshal(sc.Bytes(), &e) != nil || e.Action == "" {
continue
}
at := e.Actor.Attributes
ev := ContainerEvent{
T: e.Time * 1000, Name: at["name"], Image: at["image"],
Action: e.Action, ExitCode: at["exitCode"], Signal: at["signal"],
}
if e.TimeNano > 0 {
ev.T = e.TimeNano / 1e6
}
// health_status comes through as "health_status: healthy"
if a, h, ok := strings.Cut(e.Action, ": "); ok {
ev.Action, ev.Health = a, h
}
if ev.Name == "" {
continue
}
s.events.add(ev)
n++
}
_ = cmd.Wait()
if n > 0 {
log.Printf("docker events: stream ended after %d events; reconnecting", n)
}
}
// dockerEvents answers from Ham Lab's own history, not Docker's short memory.
func (s *Server) dockerEvents(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
since := r.URL.Query().Get("since")
d := 7 * 24 * time.Hour
switch since {
case "24h":
d = 24 * time.Hour
case "30d":
d = 30 * 24 * time.Hour
default:
since = "7d"
}
ev := s.events.forName(id, time.Now().Add(-d), 500)
s.events.mu.Lock()
total := len(s.events.ring)
s.events.mu.Unlock()
jsonOK(w, 200, map[string]any{"events": ev, "since": since, "kept": total, "capacity": eventsKept})
}