diff --git a/PLAN.md b/PLAN.md index 96f9285..3ba47b8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -189,6 +189,29 @@ something long in one, switch away and back, refresh — it's all still there. tab read the sensor API too (`sampleAdapter.ts`); `/api/metrics` is retired. Also per-service and per-container sensors (memory and CPU from cgroups), tokens limited to ID patterns, and `/api/fleet/sensors/query` for the whole fleet in one request. +- **Alerts** — **shipped 2026-09-19** (`internal/api/alerts.go`, `Alerts.tsx`): rules over + sensors (`container.*.running == false for 120 s`), evaluated by the hub every 15 s for + itself and every managed machine in one shaped query each; `hamlab.reachable` is supplied + by the hub so "a machine stopped answering" is a rule like any other. Ten defaults + (machine offline, container down/unhealthy, failed service, disk full, pool unhealthy, + SMART failing, UPS on battery, CPU hot, memory). Firing and resolved are events: kept + (last 200, in `state.json`), shown on the hub-only Alerts tab with a count on the rail, + and POSTed to channels — **ntfy** (title/priority headers) or a **webhook** (JSON; Home + Assistant, Slack). The rule editor shows what a selector matches across the fleet as + you type. Not done: quiet hours, per-rule channels, Web Push straight to the PWA. +- **Docker tab** — **shipped 2026-09-19** (`internal/api/docker.go`, `Docker.tsx`): + containers grouped by compose stack with live memory and CPU from the container + 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. +- **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; + each repaints the terminal too. **Each machine wears a colour** (`accents.ts`): a stripe + across the header and its name in the switcher — picked from ten swatches or any colour + on the Machines page, else derived from its name — saved with the theme on the hub. - **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`, `MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose `config.json` lists `nodes` is a hub; any other is a standalone node with its full UI. diff --git a/internal/api/alerts.go b/internal/api/alerts.go new file mode 100644 index 0000000..6011f12 --- /dev/null +++ b/internal/api/alerts.go @@ -0,0 +1,444 @@ +package api + +// Alerts: rules over sensors, watched by the hub for itself and every machine it manages. +// +// A rule is a selector, a comparison and a patience: `container.*.running == false for +// 120 s`. Every alertEvery the hub asks each machine one shaped query for all the rules' +// selectors, compares, and keeps a little state per (rule, machine, sensor): ok, pending +// since, or firing since. Going to firing, and coming back, is an event — kept, shown on +// the Alerts tab, and sent to whatever channels are set (ntfy, or a webhook: Home +// Assistant, Slack, anything that takes a POST). `hamlab.reachable` is a sensor the hub +// supplies itself, so "that machine stopped answering" is a rule like any other. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "hamlab/internal/sensors" + "hamlab/internal/store" +) + +const ( + alertEvery = 15 * time.Second + maxEvents = 200 +) + +type AlertRule struct { + ID string `json:"id"` + Name string `json:"name"` + Sensor string `json:"sensor"` // an ID or a pattern + Op string `json:"op"` // > >= < <= == != + Value any `json:"value"` // number, bool or string, to match the sensor + ForSec int `json:"for_sec"` + Severity string `json:"severity"` // info | warn | crit + Enabled bool `json:"enabled"` + Machines string `json:"machines"` // "*" or comma-separated names; "" is "*" +} + +type AlertChannel struct { + Type string `json:"type"` // ntfy | webhook + URL string `json:"url"` +} + +type AlertEvent struct { + T int64 `json:"t"` + Kind string `json:"kind"` // firing | resolved + Rule string `json:"rule"` + RuleID string `json:"rule_id"` + Machine string `json:"machine"` + Sensor string `json:"sensor"` + Value any `json:"value"` + Severity string `json:"severity"` +} + +type alertFiring struct { + AlertEvent + Since int64 `json:"since"` +} + +type alertSaved struct { + Rules []AlertRule `json:"rules"` + Channels []AlertChannel `json:"channels"` + Events []AlertEvent `json:"events"` + Seeded bool `json:"seeded"` +} + +type alertState struct { + pending time.Time + firing bool + since time.Time + value any +} + +type alertEngine struct { + s *Server + mu sync.Mutex + saved alertSaved + state map[string]*alertState // ruleID|machine|sensor + last time.Time +} + +var defaultRules = []AlertRule{ + {ID: "machine-offline", Name: "Machine not answering", Sensor: "hamlab.reachable", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true}, + {ID: "container-down", Name: "Container not running", Sensor: "container.*.running", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true}, + {ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true}, + {ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true}, + {ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true}, + {ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true}, + {ID: "drive-failing", Name: "Drive failing SMART", Sensor: "smart.*.passed", Op: "==", Value: false, ForSec: 0, Severity: "crit", Enabled: true}, + {ID: "on-battery", Name: "UPS on battery", Sensor: "ups.on_battery", Op: "==", Value: true, ForSec: 30, Severity: "crit", Enabled: true}, + {ID: "hot", Name: "CPU running hot", Sensor: "cpu.temp", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true}, + {ID: "memory", Name: "Memory nearly exhausted", Sensor: "mem.used_pct", Op: ">=", Value: 95.0, ForSec: 300, Severity: "warn", Enabled: true}, +} + +func newAlertEngine(s *Server) *alertEngine { + e := &alertEngine{s: s, state: map[string]*alertState{}} + var raw json.RawMessage + s.store.View(func(st *store.State) { raw = st.Alerts }) + _ = json.Unmarshal(raw, &e.saved) + if !e.saved.Seeded { + e.saved.Rules, e.saved.Seeded = append([]AlertRule{}, defaultRules...), true + } + go e.run() + return e +} + +func (e *alertEngine) persist() { + b, _ := json.Marshal(e.saved) + _ = e.s.store.Update(func(st *store.State) { st.Alerts = b }) +} + +func (e *alertEngine) run() { + time.Sleep(20 * time.Second) // let the status sensors take their first readings + for { + e.evaluate() + time.Sleep(alertEvery) + } +} + +func wants(r AlertRule, machine string) bool { + if r.Machines == "" || r.Machines == "*" { + return true + } + for _, m := range strings.Split(r.Machines, ",") { + if strings.TrimSpace(m) == machine { + return true + } + } + return false +} + +// holds reports whether `got want`, comparing like with like. +func holds(got any, op string, want any) bool { + switch g := got.(type) { + case float64: + w, ok := want.(float64) + if !ok { + if s, isStr := want.(string); isStr { + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return false + } + w = f + } else { + return false + } + } + switch op { + case ">": + return g > w + case ">=": + return g >= w + case "<": + return g < w + case "<=": + return g <= w + case "==": + return g == w + case "!=": + return g != w + } + case bool: + w, ok := want.(bool) + if !ok { + w = fmt.Sprint(want) == "true" + } + return (op == "==" && g == w) || (op == "!=" && g != w) + case string: + w := fmt.Sprint(want) + return (op == "==" && g == w) || (op == "!=" && g != w) + } + return false +} + +func (e *alertEngine) evaluate() { + e.mu.Lock() + rules := append([]AlertRule{}, e.saved.Rules...) + e.mu.Unlock() + query := map[string]string{} + for _, r := range rules { + if r.Enabled && r.Sensor != "hamlab.reachable" { + query[r.ID] = r.Sensor + } + } + qb, _ := json.Marshal(query) + machines := append([]string{e.s.host}, e.s.nodes.Names()...) + results := map[string]map[string]any{} // machine → ruleID → value | {id: value} + reach := map[string]bool{} + var wg sync.WaitGroup + var mu sync.Mutex + for _, m := range machines { + wg.Add(1) + go func(m string) { + defer wg.Done() + var res map[string]any + ok := false + if m == e.s.host { + if out, err := sensors.Shape(e.s.sensors.All(), qb, false); err == nil { + res, ok = out.(map[string]any) + } + } else { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + code, b, err := e.s.nodes.Do(ctx, m, "POST", "/api/sensors/query", qb, http.Header{"Content-Type": {"application/json"}}) + cancel() + ok = err == nil && code == 200 && json.Unmarshal(b, &res) == nil + } + mu.Lock() + results[m], reach[m] = res, ok + mu.Unlock() + }(m) + } + wg.Wait() + + now := time.Now() + seen := map[string]bool{} + var events []AlertEvent + e.mu.Lock() + for _, r := range rules { + if !r.Enabled { + continue + } + for _, m := range machines { + if !wants(r, m) { + continue + } + readings := map[string]any{} + if r.Sensor == "hamlab.reachable" { + readings["hamlab.reachable"] = reach[m] + } else if !reach[m] { + continue // no reading is not a reading: an unreachable machine is the offline rule's business + } else if v, ok := results[m][r.ID]; ok { + if many, isMap := v.(map[string]any); isMap { + readings = many + } else if v != nil { + readings[r.Sensor] = v + } + } + for id, val := range readings { + key := r.ID + "|" + m + "|" + id + seen[key] = true + st := e.state[key] + if st == nil { + st = &alertState{} + e.state[key] = st + } + st.value = val + if holds(val, r.Op, r.Value) { + if st.pending.IsZero() { + st.pending = now + } + if !st.firing && now.Sub(st.pending) >= time.Duration(r.ForSec)*time.Second { + st.firing, st.since = true, now + events = append(events, AlertEvent{T: now.UnixMilli(), Kind: "firing", Rule: r.Name, RuleID: r.ID, Machine: m, Sensor: id, Value: val, Severity: r.Severity}) + } + } else { + if st.firing { + events = append(events, AlertEvent{T: now.UnixMilli(), Kind: "resolved", Rule: r.Name, RuleID: r.ID, Machine: m, Sensor: id, Value: val, Severity: r.Severity}) + } + st.pending, st.firing = time.Time{}, false + } + } + } + } + for key := range e.state { // a sensor that is gone (a removed container, a deleted rule) takes its state with it + if !seen[key] { + delete(e.state, key) + } + } + e.last = now + if len(events) > 0 { + e.saved.Events = append(e.saved.Events, events...) + if len(e.saved.Events) > maxEvents { + e.saved.Events = e.saved.Events[len(e.saved.Events)-maxEvents:] + } + e.persist() + } + channels := append([]AlertChannel{}, e.saved.Channels...) + e.mu.Unlock() + for _, ev := range events { + e.notify(channels, ev) + } +} + +func (e *alertEngine) firing() []alertFiring { + out := []alertFiring{} + names := map[string]AlertRule{} + for _, r := range e.saved.Rules { + names[r.ID] = r + } + for key, st := range e.state { + if !st.firing { + continue + } + p := strings.SplitN(key, "|", 3) + r := names[p[0]] + out = append(out, alertFiring{AlertEvent: AlertEvent{T: st.since.UnixMilli(), Kind: "firing", Rule: r.Name, RuleID: r.ID, Machine: p[1], Sensor: p[2], Value: st.value, Severity: r.Severity}, Since: st.since.UnixMilli()}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Since > out[j].Since }) + return out +} + +func describe(ev AlertEvent) (title, body string) { + mark := map[string]string{"firing": "▲", "resolved": "✓"}[ev.Kind] + title = fmt.Sprintf("%s %s — %s", mark, ev.Rule, ev.Machine) + body = fmt.Sprintf("%s = %v", ev.Sensor, ev.Value) + if ev.Kind == "resolved" { + body = "back to normal: " + body + } + return +} + +func (e *alertEngine) notify(channels []AlertChannel, ev AlertEvent) { + title, body := describe(ev) + for _, c := range channels { + var req *http.Request + switch c.Type { + case "ntfy": + req, _ = http.NewRequest("POST", c.URL, strings.NewReader(body)) + req.Header.Set("Title", title) + req.Header.Set("Priority", map[string]string{"crit": "urgent", "warn": "high"}[ev.Severity]) + req.Header.Set("Tags", map[string]string{"firing": "warning", "resolved": "white_check_mark"}[ev.Kind]) + if ev.Kind == "resolved" { + req.Header.Set("Priority", "default") + } + case "webhook": + b, _ := json.Marshal(map[string]any{"title": title, "message": body, "event": ev}) + req, _ = http.NewRequest("POST", c.URL, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + } + if req == nil { + continue + } + go func(req *http.Request, url string) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + log.Printf("alert: notifying %s: %v", url, err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode >= 300 { + log.Printf("alert: %s answered %d", url, resp.StatusCode) + } + }(req, c.URL) + } +} + +// ---- API ---- + +func (s *Server) alertsGet(w http.ResponseWriter, _ *http.Request) { + e := s.alerts + e.mu.Lock() + defer e.mu.Unlock() + ev := append([]AlertEvent{}, e.saved.Events...) + for i, j := 0, len(ev)-1; i < j; i, j = i+1, j-1 { + ev[i], ev[j] = ev[j], ev[i] + } + rules, channels := e.saved.Rules, e.saved.Channels + if rules == nil { // an empty list is a list: never hand the page a null to take the length of + rules = []AlertRule{} + } + if channels == nil { + channels = []AlertChannel{} + } + jsonOK(w, 200, map[string]any{"rules": rules, "channels": channels, "events": ev, "firing": e.firing(), "checked": e.last.UnixMilli(), "every_sec": int(alertEvery.Seconds())}) +} + +func (s *Server) alertsPut(w http.ResponseWriter, r *http.Request) { + var body struct { + Rules *[]AlertRule `json:"rules"` + Channels *[]AlertChannel `json:"channels"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 256<<10)).Decode(&body); err != nil { + jsonErr(w, 400, "bad request") + return + } + if body.Rules != nil { + ids := map[string]bool{} + for i := range *body.Rules { + ru := &(*body.Rules)[i] + ru.Name, ru.Sensor = strings.TrimSpace(ru.Name), strings.TrimSpace(ru.Sensor) + if ru.ID == "" || ids[ru.ID] || ru.Name == "" || len(ru.Name) > maxNameLen || ru.ForSec < 0 || ru.ForSec > 86400 { + jsonErr(w, 400, "every rule needs a unique id, a name (64 characters at most) and a patience between 0 and 86400 s") + return + } + ids[ru.ID] = true + if _, err := sensors.Matcher(ru.Sensor); err != nil || ru.Sensor == "" { + jsonErr(w, 400, fmt.Sprintf("rule %q: %q is not a sensor ID or pattern", ru.Name, ru.Sensor)) + return + } + if !strings.Contains(" > >= < <= == != ", " "+ru.Op+" ") { + jsonErr(w, 400, fmt.Sprintf("rule %q: the comparison must be one of > >= < <= == !=", ru.Name)) + return + } + if ru.Severity != "info" && ru.Severity != "crit" { + ru.Severity = "warn" + } + } + } + if body.Channels != nil { + for _, c := range *body.Channels { + if (c.Type != "ntfy" && c.Type != "webhook") || !(strings.HasPrefix(c.URL, "https://") || strings.HasPrefix(c.URL, "http://")) { + jsonErr(w, 400, "a channel is {type: ntfy|webhook, url: http(s)://…}") + return + } + } + } + e := s.alerts + e.mu.Lock() + if body.Rules != nil { + e.saved.Rules = *body.Rules + } + if body.Channels != nil { + e.saved.Channels = *body.Channels + } + e.persist() + e.mu.Unlock() + w.WriteHeader(204) +} + +// alertsTest sends one clearly-marked test notification through every channel. +func (s *Server) alertsTest(w http.ResponseWriter, _ *http.Request) { + e := s.alerts + e.mu.Lock() + channels := append([]AlertChannel{}, e.saved.Channels...) + e.mu.Unlock() + if len(channels) == 0 { + jsonErr(w, 400, "no channels to test") + return + } + e.notify(channels, AlertEvent{T: time.Now().UnixMilli(), Kind: "firing", Rule: "Test alert", Machine: s.host, Sensor: "hamlab.test", Value: "this is only a test", Severity: "info"}) + w.WriteHeader(204) +} diff --git a/internal/api/docker.go b/internal/api/docker.go new file mode 100644 index 0000000..6be66e1 --- /dev/null +++ b/internal/api/docker.go @@ -0,0 +1,249 @@ +package api + +// 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. +// 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 +// (Dockhand, compose) stays the one place that changes what exists. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "hamlab/internal/priv" +) + +type dockerContainer struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` // running | exited | paused | restarting | created | dead + Status string `json:"status"` // "Up 3 hours (healthy)" + Health string `json:"health"` // healthy | unhealthy | starting | none + Ports string `json:"ports"` + Stack string `json:"stack"` // compose project, "" when started by hand + Service string `json:"service"` // compose service + Created string `json:"created"` + Mem float64 `json:"mem"` // bytes, from the container's cgroup; 0 when unknown +} + +func dockerTool() string { + for _, t := range []string{"docker", "podman"} { + if _, err := exec.LookPath(t); err == nil { + return t + } + } + return "" +} + +// dockerRun runs the tool as this user, and again as root if the socket said no. +func dockerRun(ctx context.Context, args ...string) ([]byte, error) { + tool := dockerTool() + if tool == "" { + return nil, errors.New("neither docker nor podman is installed here") + } + out, err := exec.CommandContext(ctx, tool, args...).CombinedOutput() + if err != nil && priv.Available() && strings.Contains(strings.ToLower(string(out)), "permission denied") { + full := priv.Wrap(append([]string{tool}, args...)...) + out, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput() + } + if err != nil { + msg := strings.TrimSpace(string(out)) + if i := strings.LastIndex(msg, "\n"); i >= 0 { + msg = msg[i+1:] + } + if msg == "" { + msg = err.Error() + } + return out, errors.New(msg) + } + return out, nil +} + +func jsonLines(out []byte, each func(map[string]any)) { + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + var m map[string]any + if json.Unmarshal([]byte(line), &m) == nil { + each(m) + } + } +} + +func dstr(m map[string]any, k string) string { s, _ := m[k].(string); return s } + +func labelOf(labels, key string) string { + for _, kv := range strings.Split(labels, ",") { + if v, ok := strings.CutPrefix(kv, key+"="); ok { + return v + } + } + return "" +} + +func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) { + if dockerTool() == "" { + jsonOK(w, 200, map[string]any{"available": false, "message": "Docker isn't installed on this machine."}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + out, err := dockerRun(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}") + if err != nil { + jsonOK(w, 200, map[string]any{"available": false, "message": err.Error()}) + return + } + containers := []dockerContainer{} + jsonLines(out, func(m map[string]any) { + c := dockerContainer{ID: dstr(m, "ID"), Name: strings.Split(dstr(m, "Names"), ",")[0], Image: dstr(m, "Image"), State: dstr(m, "State"), Status: dstr(m, "Status"), + Ports: dstr(m, "Ports"), Created: dstr(m, "CreatedAt"), Health: "none", Stack: labelOf(dstr(m, "Labels"), "com.docker.compose.project"), Service: labelOf(dstr(m, "Labels"), "com.docker.compose.service")} + for _, h := range []string{"unhealthy", "healthy", "starting"} { + if strings.Contains(c.Status, "("+h+")") || strings.Contains(c.Status, "health: "+h) { + c.Health = h + break + } + } + for _, dir := range []string{"/sys/fs/cgroup/system.slice/docker-" + c.ID + ".scope", "/sys/fs/cgroup/docker/" + c.ID, "/sys/fs/cgroup/machine.slice/libpod-" + c.ID + ".scope"} { + if b, err := os.ReadFile(dir + "/memory.current"); err == nil { + c.Mem, _ = strconv.ParseFloat(strings.TrimSpace(string(b)), 64) + break + } + } + containers = append(containers, c) + }) + sort.Slice(containers, func(i, j int) bool { + if containers[i].Stack != containers[j].Stack { + return containers[i].Stack < containers[j].Stack + } + return containers[i].Name < containers[j].Name + }) + list := func(args ...string) []map[string]any { + rows := []map[string]any{} + if out, err := dockerRun(ctx, args...); err == nil { + jsonLines(out, func(m map[string]any) { rows = append(rows, m) }) + } + return rows + } + jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers, + "images": list("images", "--format", "{{json .}}"), + "volumes": list("volume", "ls", "--format", "{{json .}}"), + "networks": list("network", "ls", "--format", "{{json .}}")}) +} + +var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`) + +func (s *Server) dockerAction(w http.ResponseWriter, r *http.Request) { + id, action := r.PathValue("id"), r.PathValue("action") + if !dockerRef.MatchString(id) || (action != "start" && action != "stop" && action != "restart") { + jsonErr(w, 400, "start, stop or restart a container by name or ID") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + defer cancel() + if _, err := dockerRun(ctx, action, id); err != nil { + jsonErr(w, 500, err.Error()) + return + } + w.WriteHeader(204) +} + +// dockerLogs is the tail of a container's log; ?since= asks only for what is new, +// which is how the page follows a log without holding a connection open. +func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !dockerRef.MatchString(id) { + jsonErr(w, 400, "bad container") + return + } + args := []string{"logs", "--timestamps"} + 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 { + n, _ := strconv.Atoi(r.URL.Query().Get("n")) + if n <= 0 || n > 5000 { + n = 300 + } + args = append(args, "--tail", strconv.Itoa(n)) + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + out, err := dockerRun(ctx, append(args, id)...) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + type line struct { + T string `json:"t"` + M string `json:"m"` + } + lines := []line{} + for _, l := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { + if l == "" { + continue + } + t, m, ok := strings.Cut(l, " ") + if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil { + t, m = "", l + } + lines = append(lines, line{t, m}) + } + jsonOK(w, 200, map[string]any{"lines": lines}) +} + +// dockerInspect is the parts of `docker inspect` worth reading on a phone. +func (s *Server) dockerInspect(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !dockerRef.MatchString(id) { + jsonErr(w, 400, "bad container") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + out, err := dockerRun(ctx, "inspect", id) + var all []map[string]any + if err != nil || json.Unmarshal(out, &all) != nil || len(all) == 0 { + jsonErr(w, 500, fmt.Sprint("inspect failed: ", err)) + return + } + c := all[0] + dig := func(m map[string]any, path ...string) any { + var cur any = m + for _, p := range path { + mm, ok := cur.(map[string]any) + if !ok { + return nil + } + cur = mm[p] + } + return cur + } + env := []string{} + if list, ok := dig(c, "Config", "Env").([]any); ok { + for _, e := range list { + k, _, _ := strings.Cut(fmt.Sprint(e), "=") + env = append(env, k) // names only: values are where the secrets live + } + } + sort.Strings(env) + jsonOK(w, 200, map[string]any{ + "image": dig(c, "Config", "Image"), "command": dig(c, "Config", "Cmd"), "entrypoint": dig(c, "Config", "Entrypoint"), + "restart": dig(c, "HostConfig", "RestartPolicy", "Name"), "network_mode": dig(c, "HostConfig", "NetworkMode"), + "memory_limit": dig(c, "HostConfig", "Memory"), "started": dig(c, "State", "StartedAt"), "exit_code": dig(c, "State", "ExitCode"), + "error": dig(c, "State", "Error"), "restarts": c["RestartCount"], "mounts": c["Mounts"], "ports": dig(c, "NetworkSettings", "Ports"), + "networks": dig(c, "NetworkSettings", "Networks"), "env_names": env, + "compose_file": dig(c, "Config", "Labels", "com.docker.compose.project.config_files"), + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index 48375cc..198bd33 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -59,6 +59,7 @@ type Server struct { metrics *metrics.Collector sensors *sensors.Registry streams streamGate + alerts *alertEngine store *store.Store svc *services.Manager cron *cron.Manager @@ -88,6 +89,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me s.user = "?" } s.sensors.Add(newStatusSensors(s)) + s.alerts = newAlertEngine(s) // The saved dashboard layout carries the sampling interval; apply it on boot. var raw json.RawMessage st.View(func(x *store.State) { raw = x.Dashboard }) @@ -126,6 +128,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("GET /api/nodes/{name}/sensors/{rest...}", s.sensorAuth(s.proxyNodeSensors)) 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("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)) + m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet)) + m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut)) + m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest)) m.HandleFunc("GET /api/fleet/sensors/query", s.sensorAuth(s.fleetQuery)) m.HandleFunc("POST /api/fleet/sensors/query", s.sensorAuth(s.fleetQuery)) m.HandleFunc("GET /api/services", s.auth(s.listServices)) diff --git a/internal/store/store.go b/internal/store/store.go index c7eaf3d..946efe5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -21,6 +21,7 @@ type State struct { Sessions map[string]time.Time `json:"sessions"` // token -> expiry Dashboard json.RawMessage `json:"dashboard,omitempty"` // UI layout prefs, opaque to the server UI json.RawMessage `json:"ui,omitempty"` // theme etc., opaque to the server + Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go) } type Store struct { diff --git a/web/src/Alerts.tsx b/web/src/Alerts.tsx new file mode 100644 index 0000000..8e3829c --- /dev/null +++ b/web/src/Alerts.tsx @@ -0,0 +1,130 @@ +// Alerts: rules over sensors, watched by this hub for itself and every machine it manages. +import { useCallback, useEffect, useState } from 'react' +import { api, AlertChannel, AlertEvent, AlertRule, AlertsInfo } from './api' +import { accentFor } from './accents' +import { Btn, Loading, askYes, toast, useEscape } from './ui' + +const OPS = ['>', '>=', '<', '<=', '==', '!='] +const ago = (ms: number) => { const s = Math.max(0, (Date.now() - ms) / 1000); return s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : s < 129600 ? `${Math.round(s / 3600)} h` : `${Math.round(s / 86400)} d` } +const patience = (s: number) => (s === 0 ? 'at once' : s < 120 ? `for ${s} s` : `for ${Math.round(s / 60)} min`) +const show = (v: unknown) => (typeof v === 'number' ? String(Math.round(v * 10) / 10) : String(v)) +const parse = (raw: string): number | boolean | string => (raw === 'true' ? true : raw === 'false' ? false : raw.trim() !== '' && !isNaN(Number(raw)) ? Number(raw) : raw) + +function RuleDialog({ rule, machines, onSave, onClose }: { rule: AlertRule; machines: string[]; onSave: (r: AlertRule) => void; onClose: () => void }) { + const [r, setR] = useState({ ...rule, value: String(rule.value) }) + const [hits, setHits] = useState('') + useEscape(onClose) + useEffect(() => { // what the selector matches right now, across the fleet + if (!r.sensor.trim()) { setHits(''); return } + const t = window.setTimeout(async () => { + try { + const res = await fetch('/api/fleet/sensors/query', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ machines: '*', query: { x: r.sensor.trim() } }) }) + if (!res.ok) { setHits((await res.json().catch(() => ({}))).error ?? 'not a valid pattern'); return } + const by = await res.json() as Record + const n = Object.entries(by).map(([m, v]) => [m, v.error ? 0 : v.x && typeof v.x === 'object' ? Object.keys(v.x).length : v.x === null || v.x === undefined ? 0 : 1] as const) + setHits(r.sensor.trim() === 'hamlab.reachable' ? 'every machine: whether it answers the hub' : n.every(([, c]) => c === 0) ? 'matches nothing right now' : 'matches ' + n.filter(([, c]) => c).map(([m, c]) => `${c} on ${m}`).join(', ')) + } catch { setHits('') } + }, 350) + return () => clearTimeout(t) + }, [r.sensor]) + const all = !r.machines || r.machines === '*' + const picked = all ? [] : r.machines.split(',').map((x) => x.trim()).filter(Boolean) + return ( +
+
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onSave({ ...r, name: r.name.trim(), sensor: r.sensor.trim(), value: parse(r.value) }) }}> +

{rule.name ? 'Edit rule' : 'New rule'}

+ + + {hits &&
{hits}
} +
+ + setR({ ...r, value: e.target.value })} placeholder="90 · true · unhealthy" spellCheck={false} autoCapitalize="off" /> + for + setR({ ...r, for_sec: Math.max(0, Number(e.target.value) || 0) })} style={{ width: 90 }} />s +
+
{(['info', 'warn', 'crit'] as const).map((s) => )}
+
+ + {machines.map((m) => )} +
+
+
+
+ ) +} + +export default function Alerts({ machines }: { machines: string[] }) { + const [a, setA] = useState(null) + const [err, setErr] = useState('') + const [editing, setEditing] = useState(null) + const [chan, setChan] = useState({ type: 'ntfy', url: '' }) + const load = useCallback(async () => { try { const r = await api.alerts(); setA({ ...r, rules: r.rules ?? [], channels: r.channels ?? [], events: r.events ?? [], firing: r.firing ?? [] }); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) + useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 5000); return () => clearInterval(t) }, [load]) + if (!a) return
{err ?
{err}
: }
+ + const save = async (patch: { rules?: AlertRule[]; channels?: AlertChannel[] }, done: string) => { + try { await api.saveAlerts(patch); toast(done); window.dispatchEvent(new Event('hamlab:alerts')); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + const saveRule = (r: AlertRule) => { const exists = a.rules.some((x) => x.id === r.id); save({ rules: exists ? a.rules.map((x) => (x.id === r.id ? r : x)) : [...a.rules, r] }, exists ? 'rule saved' : 'rule added'); setEditing(null) } + const sevClass = (s: string) => (s === 'crit' ? 'bad' : s === 'warn' ? 'warn' : '') + + return ( +
+

Now

checked {a.checked > 0 ? `${ago(a.checked)} ago` : 'not yet'} · every {a.every_sec} s · all machines
+ {a.firing.length === 0 ?
✓ All clear — {a.rules.filter((r) => r.enabled).length} rules watching {machines.length + 1} machine{machines.length ? 's' : ''}
: ( +
+ {a.firing.map((f) => ( +
+
{f.rule}{f.machine}
+
{f.sensor} = {show(f.value)}
+
for {ago(f.since)}
+
+ ))} +
+ )} + +

Rules

+ +
+
+ {a.rules.length === 0 &&
No rules. Nothing is being watched.
} + {a.rules.map((r) => ( +
+ +
setEditing(r)}> +
{r.name}{a.firing.some((f) => f.rule_id === r.id) && firing}
+
{r.sensor} {r.op} {show(r.value)} · {patience(r.for_sec)}{r.machines && r.machines !== '*' ? ` · ${r.machines}` : ''}
+
+ +
+ ))} +
+ +

Where alerts go

{a.channels.length > 0 && { try { await api.testAlerts(); toast('test sent') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>Send a test}
+
+
Always here, and on the rail. To reach your phone: an ntfy topic URL (the ntfy app, from ntfy.sh or your own server), or a webhook — Home Assistant, Slack, anything that takes a POST. Sent when an alert fires and again when it clears.
+ {a.channels.map((c, i) =>
{c.type}{c.url} save({ channels: a.channels.filter((_, j) => j !== i) }, 'channel removed')}>Remove
)} +
{ e.preventDefault(); if (!/^https?:\/\/./.test(chan.url.trim())) { toast('a channel needs an http(s) URL', 'err'); return } save({ channels: [...a.channels, { type: chan.type, url: chan.url.trim() }] }, 'channel added'); setChan({ ...chan, url: '' }) }}> + + setChan({ ...chan, url: e.target.value })} placeholder={chan.type === 'ntfy' ? 'https://ntfy.sh/your-secret-topic' : 'https://…/api/webhook/…'} spellCheck={false} autoCapitalize="off" /> + +
+
+ +

History

the last {a.events.length}
+
+ {a.events.length === 0 &&
Nothing has fired yet.
} + {a.events.map((e: AlertEvent, i) => ( +
+ {e.kind === 'firing' ? '▲' : '✓'} + {e.rule} · {e.machine} · {e.sensor} = {show(e.value)} + {ago(e.t)} ago +
+ ))} +
+ {editing && setEditing(null)} />} +
+ ) +} diff --git a/web/src/App.tsx b/web/src/App.tsx index b967452..4dded9e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import Shell from './Shell' import { Dialogs } from './ui' import { applyTheme, currentThemeId } from './themes' import { getRefreshMs, setRefreshMs } from './prefs' +import { setAccents } from './accents' export default function App() { const [me, setMe] = useState(undefined) @@ -15,6 +16,7 @@ export default function App() { api.uiPrefs().then((p) => { if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs) + if (p.accents && typeof p.accents === 'object') setAccents(p.accents, false) }).catch(() => { /* keep local */ }) } catch (e) { if (e instanceof Unauthenticated) setMe(null) } }, []) diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx new file mode 100644 index 0000000..f11b91d --- /dev/null +++ b/web/src/Docker.tsx @@ -0,0 +1,143 @@ +// Docker: containers grouped by the stack that deployed them, with start / stop / restart, +// 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 { Sensor, fmtValue, useSensorStream } from './sensors' +import { useHashSel } from './hashsel' +import { useRefreshMs } from './prefs' +import { Btn, Loading, toast } from './ui' + +type Tab = 'containers' | '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(' ') + +function LogView({ id }: { id: string }) { + const [lines, setLines] = useState<{ t: string; m: string }[]>([]) + const [err, setErr] = useState('') + const [follow, setFollow] = useState(true) + const box = useRef(null) + const last = useRef('') + useEffect(() => { + let stop = false; setLines([]); last.current = '' + const tick = async () => { + if (stop) return + if (document.visibilityState === 'visible') { + try { + const r = await api.dockerLogs(id, last.current) + const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines + if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) } + setErr('') + } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + } + window.setTimeout(tick, 2000) + } + tick() + return () => { stop = true } + }, [id]) + useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow]) + return ( +
+
{lines.length} lines
+ {err &&
{err}
} +
{ const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}> + {lines.map((l, i) =>
{l.t ? new Date(l.t).toLocaleTimeString() : ''}{l.m}
)} + {lines.length === 0 && !err &&
No output yet.
} +
+
+ ) +} + +function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record; onBack: () => void; onChanged: () => void }) { + const [tab, setTab] = useState<'logs' | 'inspect'>('logs') + const [info, setInfo] = useState | null>(null) + useEffect(() => { setInfo(null); if (tab === 'inspect') api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, c.name]) + const act = (a: 'start' | 'stop' | 'restart') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } } + const running = c.state === 'running' + const s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`] + const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? [] + return ( +
+
{c.name}
+
+
Image{c.image}
+
Status{c.status}
+ {c.stack &&
Stack{c.stack}{c.service && c.service !== c.name ? ` · ${c.service}` : ''}
} + {running &&
Using{fmtValue(s('cpu')?.value, '%')} of a core · {fmtValue(s('mem')?.value ?? c.mem, 'B')}
} + {ports(c.ports) &&
Ports{ports(c.ports)}
} +
+
+ {!running && Start} + {running && Restart} + {running && Stop} +
+
+ {tab === 'logs' && } + {tab === 'inspect' && (!info ? : ( +
+
Restarts{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×
+
Network{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}
+ {Number(info.memory_limit) > 0 &&
Mem limit{fmtValue(Number(info.memory_limit), 'B')}
} + {!!info.error &&
Error{String(info.error)}
} + {!!info.compose_file &&
Compose{String(info.compose_file)}
} + {mounts.map((m) =>
Mount{m.Source} → {m.Destination}{m.RW ? '' : ' (ro)'}
)} +
Env{((info.env_names as string[]) ?? []).join(' ')} (names only)
+
+ ))} +
+ ) +} + +export default function Docker() { + const [d, setD] = useState(null) + const [err, setErr] = useState('') + const [tab, setTab] = useState('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') } }, []) + useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate]) + const live = useSensorStream<{ c: Record }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) }) + const sensors = live.data?.c ?? {} + const byStack = useMemo(() => { + const f = filter.trim().toLowerCase(); const out = new Map() + for (const c of d?.containers ?? []) { if (f && !`${c.name} ${c.image} ${c.stack}`.toLowerCase().includes(f)) continue; const k = c.stack || 'started by hand'; out.set(k, [...(out.get(k) ?? []), c]) } + return out + }, [d, filter]) + if (!d) return
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}
: }
+ if (!d.available) return
{d.message}
+ const cur = d.containers.find((c) => c.name === sel) + 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, [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 ( +
+
+
+
{(['containers', 'images', 'volumes', 'networks'] as Tab[]).map((t) => )}
+ {tab === 'containers' && setFilter(e.target.value)} placeholder="filter containers, images, stacks" />} +
+
+ {tab === 'containers' && [...byStack].map(([stack, list]) => ( +
+

{stack} · {list.filter((c) => c.state === 'running').length}/{list.length}

+ {list.map((c) => ( +
setSel(c.name)}> + +
{c.name}
{c.image}
+
{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}
{fmtValue(sid(c, 'cpu')?.value, '%')} : c.state}
+
+ ))} +
+ ))} + {tab === 'containers' && byStack.size === 0 &&
No container matches.
} + {tab !== 'containers' && ( + {cols[tab].map(([, h]) => )} + {d[tab].map((row, i) => {cols[tab].map(([k]) => )})}
{h}
{String(row[k] ?? '')}
+ )} +
+
+ {tab === 'containers' && (cur ? setSel('')} onChanged={load} /> :
Select a container.
)} +
+ ) +} diff --git a/web/src/MachineSwitcher.tsx b/web/src/MachineSwitcher.tsx index 4fcacf3..ce04b7a 100644 --- a/web/src/MachineSwitcher.tsx +++ b/web/src/MachineSwitcher.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { MachineStatus } from './api' import { useEscape } from './ui' +import { accentFor } from './accents' const hostOf = (url: string) => { try { return new URL(url).hostname } catch { return url } } @@ -30,11 +31,11 @@ export default function MachineSwitcher({ hub, hubAddr, current, machines, onCha {open && (
{machines.map((m) => ( ))} diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index d269dd4..50a88a5 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -2,6 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from 'react' import { api, MachineStatus, Me } from './api' import { Btn, Loading, askYes, toast } from './ui' import { LIST_FLOOR, useRefreshMs } from './prefs' +import { ACCENTS, accentFor, getAccents, setAccents } from './accents' const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` } const ago = (iso?: string) => { if (!iso) return 'never'; const s = (Date.now() - Date.parse(iso)) / 1000; return s < 90 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` } @@ -67,8 +68,9 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange

This host

-
{hub.host}hubv{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}
+
{hub.host}hubv{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}
{fp ? <>Certificate {fp} : 'Serving plain HTTP (behind a reverse proxy). Set "tls": "auto" in config.json for a self-signed certificate if another hub should manage this host.'}
+

Managed machines · {list.length}

{list.some(outdated) && Update {list.filter(outdated).length} outdated}
@@ -79,7 +81,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
- {m.name} + {m.name} {m.url} {m.reboot_needed && ⟳} @@ -93,6 +95,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange {m.fingerprint &&
Cert{shortFp(m.fingerprint)}
}
Added{new Date(m.added).toLocaleDateString()}
+
{outdated(m) && update(m)}>Update} @@ -116,3 +119,16 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
) } + +// The colour a machine wears in the header stripe and the switcher: ten to tap, or any other. +export function Swatches({ name }: { name: string }) { + const [, bump] = useState(0) + const cur = accentFor(name) + const pick = (c: string) => { setAccents({ ...getAccents(), [name]: c }); bump((n) => n + 1) } + return ( +
+ {ACCENTS.map((c) =>
+ ) +} diff --git a/web/src/Rail.tsx b/web/src/Rail.tsx index 15e92e4..aed3096 100644 --- a/web/src/Rail.tsx +++ b/web/src/Rail.tsx @@ -4,9 +4,9 @@ import { ReactNode } from 'react' // for the roadmap. export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean } -interface Props { groups: Group[]; view: string; header?: ReactNode; onView: (id: string) => void } +interface Props { groups: Group[]; view: string; header?: ReactNode; badges?: Record; onView: (id: string) => void } -export default function Rail({ groups, view, header, onView }: Props) { +export default function Rail({ groups, view, header, badges, onView }: Props) { return (
))} diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index ab5dc1f..a1a8a22 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -1,6 +1,7 @@ import { FormEvent, useCallback, useEffect, useState } from 'react' import { fetchCatalog } from './sensors' import { api, Me } from './api' +import { savePrefs } from './accents' import { applyTheme, currentThemeId, THEMES } from './themes' import { Btn, Loading, askYes, toast, useEscape } from './ui' import { fmtMs, getRefreshMs, REFRESH_OPTIONS, setRefreshMs } from './prefs' @@ -45,11 +46,11 @@ export default function Settings({ me }: { me: Me }) { const pick = async (id: string) => { applyTheme(id); setTheme(id) - try { await api.saveUIPrefs({ theme: id, refreshMs: getRefreshMs() }) } catch { /* local still applied */ } + await savePrefs() } const pickRefresh = async (ms: number) => { setRefreshMs(ms); setRefresh(ms) - try { await api.saveUIPrefs({ theme: currentThemeId(), refreshMs: ms }) } catch { /* local still applied */ } + await savePrefs() } async function changePassword(e: FormEvent) { diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 7d94458..ee518cd 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -6,6 +6,8 @@ import TermTabs from './TermTabs' import KeyBar from './KeyBar' import Dashboard from './Dashboard' import Gallery from './Gallery' +import Docker from './Docker' +import Alerts from './Alerts' import Services from './Services' import Cron from './Cron' import Updates from './Updates' @@ -18,6 +20,7 @@ import MachineSwitcher from './MachineSwitcher' import { MachineStatus, setMachine } from './api' import { LIST_FLOOR, useRefreshMs } from './prefs' import Power from './Power' +import { applyAccent } from './accents' import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui' // Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour). @@ -34,23 +37,36 @@ const ClockIcon = ( ) +const DockerIcon = ( + +) +const BellIcon = ( + +) + const GROUPS: Group[] = [ { id: 'dashboard', label: 'Monitors', icon: '◔' }, { id: 'procs', label: 'Processes', icon: '≡' }, { id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'services', label: 'Services', icon: '◈' }, + { id: 'docker', label: 'Docker', icon: DockerIcon }, { id: 'cron', label: 'Cron', icon: ClockIcon }, { id: 'updates', label: 'Updates', icon: '⇪' }, { id: 'disks', label: 'Disks', icon: '▤' }, { id: 'files', label: 'Files', icon: FolderIcon }, + { id: 'alerts', label: 'Alerts', icon: BellIcon }, { id: 'machines', label: 'Machines', icon: '⌂' }, { id: 'widgets', label: 'Widgets', icon: '▦' }, { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'dashboard' | 'widgets' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' -const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings'] -const HUB_ONLY: View[] = ['machines', 'settings'] +type View = 'terminal' | 'dashboard' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' +const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings'] +const HUB_ONLY: View[] = ['machines', 'settings', 'alerts'] // URL hash is the source of truth for where you are: #//. Back and // forward work, reload restores the tab, and links can point at a page on a machine. @@ -106,6 +122,14 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: } // keep the hash in step, and follow it when the browser navigates + const [firing, setFiring] = useState(0) + useEffect(() => { + const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) } + tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick) + return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) } + }, []) + // The machine's colour: set on , so the header stripe and the switcher pick it up. + useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host]) // The tab's title says where you are, so history entries and open tabs can be told apart. useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${machine ?? hubMe.host} — Ham Lab` }, [view, machine, hubMe.host]) useEffect(() => { @@ -216,7 +240,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
- { setView('machines'); setDrawer(false) }} />} onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
setDrawer(false)} /> @@ -226,6 +250,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'machines' && { switchMachine(n); setView('dashboard') }} />} {view === 'dashboard' && } {view === 'widgets' && } + {view === 'docker' && } + {view === 'alerts' && m.name)} />} {view === 'procs' && } {view === 'services' && } {view === 'cron' && } @@ -257,7 +283,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'dashboard' ? 'Monitors' : view === 'widgets' ? 'Widgets' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} + {view === 'dashboard' ? 'Monitors' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}
) diff --git a/web/src/accents.ts b/web/src/accents.ts new file mode 100644 index 0000000..290ac0b --- /dev/null +++ b/web/src/accents.ts @@ -0,0 +1,25 @@ +// Each machine wears a colour — a stripe across the header, its name in the switcher — so +// you always know which box you are typing into. A colour you pick wins; otherwise the +// machine's name picks one, the same one on every device. +import { api } from './api' +import { currentThemeId } from './themes' +import { getRefreshMs } from './prefs' + +export const ACCENTS = ['#e3a33b', '#3fb950', '#58a6ff', '#f778ba', '#ff7b72', '#a371f7', '#39c5cf', '#d4d44a', '#ff9e64', '#9aa7b4'] +const KEY = 'hamlab.accents' + +export function getAccents(): Record { try { return JSON.parse(localStorage.getItem(KEY) ?? '{}') ?? {} } catch { return {} } } +export function accentFor(name: string): string { + const picked = getAccents()[name] + if (picked && /^#[0-9a-f]{6}$/i.test(picked)) return picked + let h = 0; for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0 + return ACCENTS[h % ACCENTS.length] +} +export function applyAccent(name: string) { document.documentElement.style.setProperty('--machine', accentFor(name)) } +export function setAccents(all: Record, save = true) { + localStorage.setItem(KEY, JSON.stringify(all)) + window.dispatchEvent(new Event('hamlab:accents')) + if (save) savePrefs() +} +// Everything the hub remembers about how the console looks, saved as one object. +export function savePrefs() { return api.saveUIPrefs({ theme: currentThemeId(), refreshMs: getRefreshMs(), accents: getAccents() }).catch(() => { /* local still applied */ }) } diff --git a/web/src/api.ts b/web/src/api.ts index 95f78f7..005c79c 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -101,6 +101,12 @@ export interface StoragePart { name: string; type: string; fstype?: string; moun export interface StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[]; namespaces?: string[] } export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean } 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[]; volumes: Record[]; networks: Record[] } +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 } +export interface AlertsInfo { rules: AlertRule[]; channels: AlertChannel[]; events: AlertEvent[]; firing: (AlertEvent & { since: number })[]; checked: number; every_sec: number } export interface ZFSSnapshot { name: string; used: number; created: number } export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] } @@ -182,9 +188,16 @@ export const api = { changePassword: (current: string, password: string) => req('POST', '/api/password', { current, password }, true), tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true), createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), + docker: () => req('GET', '/api/docker'), + dockerAction: (id: string, action: 'start' | 'stop' | 'restart') => req('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>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`), + alerts: () => req('GET', '/api/alerts', undefined, true), + saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req('PUT', '/api/alerts', p, true), + testAlerts: () => req('POST', '/api/alerts/test', undefined, true), revokeToken: (name: string) => req('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), - uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true), - saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req('PUT', '/api/prefs/ui', p, true), + uiPrefs: () => req<{ theme?: string; refreshMs?: number; accents?: Record }>('GET', '/api/prefs/ui', undefined, true), + saveUIPrefs: (p: { theme?: string; refreshMs?: number; accents?: Record }) => req('PUT', '/api/prefs/ui', p, true), removeMachine: (name: string) => req('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true), terminals: () => req('GET', '/api/terminals'), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), diff --git a/web/src/styles.css b/web/src/styles.css index 2f82c22..927faa8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -438,3 +438,48 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } .ttab.drop { box-shadow: inset 3px 0 0 var(--accent2); } .ttab-move { display: none; } .ttab-move .icon-btn:disabled { opacity: .3; } @media (pointer: coarse) { .ttab-move { display: inline-flex; } } /* no drag and drop on a touch screen */ + +/* each machine's colour */ +.header { box-shadow: inset 0 3px 0 var(--machine, var(--accent)); } +.mswitch-btn .mname, .header .host { color: var(--machine, var(--accent)); } +.swatches { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; } +.swatch { width: 26px; height: 26px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; } +.swatch.on { border-color: var(--fg); box-shadow: 0 0 0 2px var(--bg2) inset; } +.swatches input[type=color] { width: 30px; height: 30px; padding: 0; border: 1px solid var(--border); border-radius: 50%; background: none; cursor: pointer; } +.themes { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); } + +/* HUD: light drawn on glass — hairlines, no fills, thin type */ +[data-theme="hud"] .tile, [data-theme="hud"] .card, [data-theme="hud"] .box, [data-theme="hud"] .modal { background: rgba(53, 214, 255, .03); border-color: rgba(53, 214, 255, .28); border-radius: 2px; box-shadow: 0 0 0 1px rgba(53, 214, 255, .05), inset 0 0 24px rgba(53, 214, 255, .04); } +[data-theme="hud"] .tile::before, [data-theme="hud"] .card::before { content: ''; position: absolute; top: -1px; left: -1px; width: 12px; height: 12px; border-top: 2px solid var(--accent); border-left: 2px solid var(--accent); } +[data-theme="hud"] .tile, [data-theme="hud"] .card { position: relative; } +[data-theme="hud"] body { font-weight: 300; letter-spacing: .2px; } +[data-theme="hud"] .tile .value, [data-theme="hud"] .brand { font-weight: 300; letter-spacing: 1.5px; text-shadow: 0 0 10px rgba(53, 214, 255, .55); } +[data-theme="hud"] .tile .label, [data-theme="hud"] .card h2, [data-theme="hud"] h2.section { letter-spacing: 2px; color: var(--accent); opacity: .8; } +[data-theme="hud"] .meter { height: 3px; border-radius: 0; } [data-theme="hud"] .meter .fill { border-radius: 0; box-shadow: 0 0 8px var(--s1); } +[data-theme="hud"] .rail, [data-theme="hud"] .header, [data-theme="hud"] .footer { background: rgba(3, 7, 12, .92); border-color: rgba(53, 214, 255, .22); } +[data-theme="hud"] .group-head.active { background: linear-gradient(90deg, rgba(53, 214, 255, .16), transparent); box-shadow: inset 2px 0 0 var(--accent); } +[data-theme="hud"] .btn, [data-theme="hud"] .chip { background: transparent; border-color: rgba(53, 214, 255, .35); border-radius: 2px; } [data-theme="hud"] .btn.on, [data-theme="hud"] .chip.on { background: rgba(53, 214, 255, .16); color: var(--accent2); border-color: var(--accent); } +[data-theme="hud"] .app { background: radial-gradient(ellipse at 50% -20%, rgba(53, 214, 255, .10), transparent 60%), var(--bg); } +[data-theme="hud"] .gauge-svg path:first-child { stroke-width: 3; } [data-theme="hud"] .gauge-svg path:nth-child(2) { stroke-width: 5; filter: drop-shadow(0 0 4px var(--s1)); } + +/* alerts */ +.card.all-clear { padding: 16px; } .card.firing { border-color: var(--s2); } .card.firing.bad { border-color: var(--bad); box-shadow: 0 0 0 1px var(--bad) inset; } +.rule { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--border); } .rule:first-child { border-top: 0; padding-top: 0; } .rule.off .rule-body { opacity: .5; } +.rule-body { flex: 1; min-width: 0; cursor: pointer; } .rule-name { display: flex; align-items: center; gap: 8px; } .rule-body .mono { overflow-wrap: anywhere; margin-top: 2px; } +.toggle { width: 38px; height: 22px; border-radius: 11px; background: var(--bg3); border: 1px solid var(--border); position: relative; flex-shrink: 0; padding: 0; } .toggle i { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); transition: left .15s, background .15s; } +.toggle.on { background: color-mix(in srgb, var(--ok) 25%, var(--bg3)); border-color: var(--ok); } .toggle.on i { left: 18px; background: var(--ok); } +.rule-cond { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .rule-cond select, .rule-cond input, .chan-add select, .chan-add input { padding: 8px 9px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; } .rule-cond input.mono { flex: 1 1 120px; min-width: 0; } +.chan-add { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } .chan-add input { flex: 1 1 220px; min-width: 0; } +.ev { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 13px; } .ev:first-child { border-top: 0; } .ev-main { flex: 1; min-width: 0; overflow-wrap: anywhere; } .bad-text { color: var(--bad) !important; } +.rail-badge { margin-left: auto; min-width: 18px; padding: 0 5px; border-radius: 9px; background: var(--bad); color: #fff; font-size: 11px; font-weight: 700; text-align: center; line-height: 18px; } +@media (pointer: coarse) { .toggle { width: 46px; height: 28px; border-radius: 14px; } .toggle i { width: 22px; height: 22px; } .toggle.on i { left: 20px; } } + +/* docker */ +.stack-head { margin: 0; padding: 8px 12px 4px; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; background: var(--bg2); position: sticky; top: 0; z-index: 1; } +.docker .svc-state { text-align: right; line-height: 1.35; } +.dtable { width: 100%; border-collapse: collapse; } .dtable th { text-align: left; padding: 8px 12px; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; position: sticky; top: 0; background: var(--bg2); } +.dtable td { padding: 7px 12px; border-top: 1px solid var(--border); overflow-wrap: anywhere; } +.docker:not(.has-sel) .svc-list { flex: 1; width: auto; max-width: 760px; } +.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; } diff --git a/web/src/themes.ts b/web/src/themes.ts index c620eb3..8f04eac 100644 --- a/web/src/themes.ts +++ b/web/src/themes.ts @@ -16,6 +16,20 @@ const base = { '--mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', } +// A palette in one line: surfaces, text, the two accents, status colours, and the sixteen +// terminal colours (black red green yellow blue magenta cyan white, then the bright eight). +function mk(id: string, name: string, blurb: string, ui: string[], term: string[], extra: Record = {}): Theme { + const [bg, bg2, bg3, border, fg, muted, accent, accent2, ok, bad, s1, s2, termBg] = ui + const k = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite'] + const light = parseInt(bg.slice(1, 3), 16) > 128 + return { + id, name, blurb, + vars: { ...base, '--bg': bg, '--bg2': bg2, '--bg3': bg3, '--border': border, '--fg': fg, '--muted': muted, '--accent': accent, '--accent2': accent2, '--ok': ok, '--bad': bad, '--s1': s1, '--s2': s2, + '--track': light ? 'rgba(0, 0, 0, .10)' : 'rgba(255, 255, 255, .09)', '--term-bg': termBg, '--btn-fg': light ? '#fff' : '#111', ...extra }, + xterm: { background: termBg, foreground: fg, cursor: accent, selectionBackground: light ? 'rgba(0,0,0,.18)' : 'rgba(255,255,255,.22)', ...Object.fromEntries(k.map((name, i) => [name, term[i]])) }, + } +} + export const THEMES: Theme[] = [ { id: 'dark', name: 'Dark', blurb: 'The default. Graphite surfaces, amber brand, blue accents.', @@ -57,6 +71,33 @@ export const THEMES: Theme[] = [ brightBlack: '#6b4e14', brightRed: '#ff8c5a', brightGreen: '#ffe08a', brightYellow: '#ffe066', brightBlue: '#f0b040', brightMagenta: '#ffb870', brightCyan: '#ffdd99', brightWhite: '#fff3cc', }, }, + mk('hud', 'HUD', 'Hairlines of cyan light on near-black. Thin type, no fills. At your service.', + ['#03070c', '#050c14', '#0a1a28', '#123047', '#bfe9ff', '#5b8ba6', '#35d6ff', '#7df9ff', '#4dffb5', '#ff5470', '#35d6ff', '#ffb547', '#02050a'], + ['#02050a', '#ff5470', '#4dffb5', '#ffb547', '#35a8ff', '#b28dff', '#35d6ff', '#bfe9ff', '#3a5a70', '#ff8398', '#8dffd0', '#ffd08a', '#7cc6ff', '#cdb5ff', '#7df9ff', '#ffffff']), + mk('nord', 'Nord', 'Arctic blue-greys. Calm, low contrast, easy at night.', + ['#2e3440', '#3b4252', '#434c5e', '#4c566a', '#d8dee9', '#8f9bb3', '#88c0d0', '#81a1c1', '#a3be8c', '#bf616a', '#81a1c1', '#ebcb8b', '#2b303b'], + ['#3b4252', '#bf616a', '#a3be8c', '#ebcb8b', '#81a1c1', '#b48ead', '#88c0d0', '#e5e9f0', '#4c566a', '#bf616a', '#a3be8c', '#ebcb8b', '#81a1c1', '#b48ead', '#8fbcbb', '#eceff4']), + mk('gruvbox', 'Gruvbox', 'Warm retro earth tones. Cream on charcoal.', + ['#282828', '#32302f', '#3c3836', '#504945', '#ebdbb2', '#a89984', '#fabd2f', '#83a598', '#b8bb26', '#fb4934', '#83a598', '#fe8019', '#1d2021'], + ['#282828', '#cc241d', '#98971a', '#d79921', '#458588', '#b16286', '#689d6a', '#a89984', '#928374', '#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#ebdbb2']), + mk('catppuccin', 'Catppuccin', 'Mocha: soft pastels on deep mauve-grey.', + ['#1e1e2e', '#181825', '#313244', '#45475a', '#cdd6f4', '#9399b2', '#cba6f7', '#89b4fa', '#a6e3a1', '#f38ba8', '#89b4fa', '#fab387', '#11111b'], + ['#45475a', '#f38ba8', '#a6e3a1', '#f9e2af', '#89b4fa', '#f5c2e7', '#94e2d5', '#bac2de', '#585b70', '#f38ba8', '#a6e3a1', '#f9e2af', '#89b4fa', '#f5c2e7', '#94e2d5', '#a6adc8']), + mk('latte', 'Catppuccin Latte', 'The same pastels, by daylight.', + ['#eff1f5', '#e6e9ef', '#dce0e8', '#bcc0cc', '#4c4f69', '#7c7f93', '#8839ef', '#1e66f5', '#40a02b', '#d20f39', '#1e66f5', '#fe640b', '#e6e9ef'], + ['#5c5f77', '#d20f39', '#40a02b', '#df8e1d', '#1e66f5', '#ea76cb', '#179299', '#acb0be', '#6c6f85', '#d20f39', '#40a02b', '#df8e1d', '#1e66f5', '#ea76cb', '#179299', '#bcc0cc']), + mk('dracula', 'Dracula', 'Purple, pink and green on midnight.', + ['#282a36', '#21222c', '#343746', '#44475a', '#f8f8f2', '#8b93b8', '#bd93f9', '#8be9fd', '#50fa7b', '#ff5555', '#8be9fd', '#ffb86c', '#1e1f29'], + ['#21222c', '#ff5555', '#50fa7b', '#f1fa8c', '#bd93f9', '#ff79c6', '#8be9fd', '#f8f8f2', '#6272a4', '#ff6e6e', '#69ff94', '#ffffa5', '#d6acff', '#ff92df', '#a4ffff', '#ffffff']), + mk('tokyo', 'Tokyo Night', 'Neon signs over a dark blue city.', + ['#1a1b26', '#16161e', '#24283b', '#343b58', '#c0caf5', '#737aa2', '#7aa2f7', '#bb9af7', '#9ece6a', '#f7768e', '#7aa2f7', '#ff9e64', '#13141c'], + ['#15161e', '#f7768e', '#9ece6a', '#e0af68', '#7aa2f7', '#bb9af7', '#7dcfff', '#a9b1d6', '#414868', '#f7768e', '#9ece6a', '#e0af68', '#7aa2f7', '#bb9af7', '#7dcfff', '#c0caf5']), + mk('solarized', 'Solarized Dark', 'The precise, low-glare classic.', + ['#002b36', '#073642', '#0a4352', '#1a5666', '#93a1a1', '#657b83', '#b58900', '#268bd2', '#859900', '#dc322f', '#268bd2', '#cb4b16', '#00212b'], + ['#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', '#586e75', '#cb4b16', '#93a1a1', '#b58900', '#839496', '#6c71c4', '#2aa198', '#fdf6e3']), + mk('solarized-light', 'Solarized Light', 'The same sixteen colours on warm paper.', + ['#fdf6e3', '#eee8d5', '#e4ddc8', '#d3cbb4', '#586e75', '#839496', '#b58900', '#268bd2', '#859900', '#dc322f', '#268bd2', '#cb4b16', '#eee8d5'], + ['#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', '#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3']), ] export const DEFAULT_THEME = 'dark'