From 7d0ec5e0189ccf64410233684e152509add9f19b Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Thu, 24 Sep 2026 13:01:53 -0700 Subject: [PATCH] ZFS gets its own tab, with scheduled snapshots and a retention period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pools, datasets and snapshots move out of Disks — Disks is the hardware, this is the filesystem on top of it. New: snapshot schedules, hourly to monthly, with a retention period and a floor that keeps the newest few whatever their age. The naming is zfSnap's, deliberately. grid has run zfSnap from root cron since before this repo existed and it works, so rather than invent a second convention this writes and reads exactly what zfSnap does: 2026-09-24_02.30.01--6w, with the retention in the name. Ham Lab can therefore show and expire the snapshots zfSnap already took, the two cannot disagree about what a snapshot is, and changing a schedule never re-dates old ones — each carries the rule it was taken under. An existing cron job is detected and shown on the page, with a warning if a Ham Lab schedule is running as well. Nothing is destroyed unseen. Every schedule answers what it would delete, with the list, before Run now touches anything, and only names that parse as a stamp and a TTL are ever candidates. A snapshot taken by hand is invisible to it. Co-Authored-By: Claude Opus 5 --- internal/api/server.go | 7 + internal/api/zfs.go | 301 +++++++++++++++++++++++++ internal/storage/snapshots.go | 351 +++++++++++++++++++++++++++++ internal/storage/snapshots_test.go | 107 +++++++++ internal/store/store.go | 3 +- web/src/Disks.tsx | 95 +------- web/src/Shell.tsx | 9 +- web/src/ZFS.tsx | 303 +++++++++++++++++++++++++ web/src/api.ts | 10 + web/src/styles.css | 4 + web/src/ui.tsx | 6 + 11 files changed, 1100 insertions(+), 96 deletions(-) create mode 100644 internal/api/zfs.go create mode 100644 internal/storage/snapshots.go create mode 100644 internal/storage/snapshots_test.go create mode 100644 web/src/ZFS.tsx diff --git a/internal/api/server.go b/internal/api/server.go index e543511..b8dce55 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -137,6 +137,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me go s.keepFleetInStep() } go s.watchForUpdates(context.Background()) + go s.runSchedules(context.Background()) m := http.NewServeMux() m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) @@ -251,6 +252,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots)) m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub)) m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot)) + m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview)) + m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut)) + m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete)) + m.HandleFunc("GET /api/zfs/schedules/{id}/expired", s.auth(s.zfsPreview)) + m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow)) + m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete)) m.HandleFunc("GET /api/space", s.auth(s.spaceOverview)) m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus)) m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan)) diff --git a/internal/api/zfs.go b/internal/api/zfs.go new file mode 100644 index 0000000..e644c00 --- /dev/null +++ b/internal/api/zfs.go @@ -0,0 +1,301 @@ +package api + +// The ZFS tab: pools, datasets, snapshots, and a schedule for taking and expiring them. +// +// grid already runs zfSnap from root cron and has done for longer than this repo has +// existed, so this does not replace it. It reads the same snapshot names, expires by the +// same rule, and surfaces the cron job that drives it — so what is on screen is the truth +// about the machine, whether Ham Lab or zfSnap is doing the work. A Ham Lab schedule is for +// a machine that has no such tool, and the page says plainly when both are running. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "io" + "log" + "net/http" + "strings" + "sync" + "time" + + "hamlab/internal/storage" + "hamlab/internal/store" +) + +type zfsState struct { + Policies []storage.Policy `json:"policies"` +} + +type zfsEngine struct { + s *Server + mu sync.Mutex +} + +func (s *Server) zfsLoad() zfsState { + var raw json.RawMessage + s.store.View(func(st *store.State) { raw = st.ZFS }) + var z zfsState + if len(raw) > 0 { + _ = json.Unmarshal(raw, &z) + } + if z.Policies == nil { + z.Policies = []storage.Policy{} + } + return z +} + +func (s *Server) zfsSave(z zfsState) error { + b, err := json.Marshal(z) + if err != nil { + return err + } + return s.store.Update(func(st *store.State) { st.ZFS = b }) +} + +// externalSnapshotJob finds a cron job that is already taking snapshots on this machine, so +// the page can say so rather than let two schedules quietly double up. +func (s *Server) externalSnapshotJob(ctx context.Context) map[string]any { + if s.cron == nil { + return nil + } + ov := s.cron.Overview(ctx) + for _, j := range ov.Jobs { + if !j.Enabled { + continue + } + c := strings.ToLower(j.Command) + if !strings.Contains(c, "zfsnap") && !strings.Contains(c, "zfs-auto-snapshot") && !strings.Contains(c, "sanoid") && + !(strings.Contains(c, "zfs ") && strings.Contains(c, "snapshot")) { + continue + } + return map[string]any{ + "tool": toolName(c), "schedule": j.Schedule, "command": j.Command, + "user": j.User, "source": j.Source, "id": j.ID, "next": j.Next, "last": j.Last, + } + } + return nil +} + +func toolName(cmd string) string { + switch { + case strings.Contains(cmd, "zfsnap"): + return "zfSnap" + case strings.Contains(cmd, "zfs-auto-snapshot"): + return "zfs-auto-snapshot" + case strings.Contains(cmd, "sanoid"): + return "sanoid" + } + return "a cron job" +} + +// randomID is a short opaque id for a schedule. +func randomID() (string, error) { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// ---- the schedule runs itself ---- + +// runSchedules takes the snapshots that are due. Every 30 s, which is fine for a schedule +// whose finest grain is hourly, and cheap when nothing is due. +func (s *Server) runSchedules(ctx context.Context) { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + z := s.zfsLoad() + now := time.Now() + changed := false + for i := range z.Policies { + p := &z.Policies[i] + if !p.Due(now) { + continue + } + res, err := s.storage.Run(ctx, p) + p.LastRun = now + p.LastTaken, p.Pruned = res.Taken, len(res.Pruned) + if err != nil { + p.LastError = err.Error() + log.Printf("zfs schedule %q: %v", p.Name, err) + } else { + p.LastError = "" + log.Printf("zfs schedule %q: took %s, expired %d", p.Name, res.Taken, len(res.Pruned)) + } + changed = true + } + if changed { + if err := s.zfsSave(z); err != nil { + log.Printf("zfs schedule: saving: %v", err) + } + } + } +} + +// ---- endpoints ---- + +func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) { + z := s.zfsLoad() + now := time.Now() + out := make([]map[string]any, 0, len(z.Policies)) + for i := range z.Policies { + p := z.Policies[i] + m := map[string]any{"policy": p} + if p.Enabled { + m["next"] = p.NextRun(now) + } + out = append(out, m) + } + jsonOK(w, 200, map[string]any{ + "zfs": s.storage.ZFS(r.Context()), "policies": out, "external": s.externalSnapshotJob(r.Context()), + }) +} + +func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) { + var p storage.Policy + if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil { + jsonErr(w, 400, "bad request") + return + } + if err := p.Validate(); err != nil { + jsonErr(w, 400, err.Error()) + return + } + z := s.zfsLoad() + found := false + for i := range z.Policies { + if z.Policies[i].ID == p.ID && p.ID != "" { + p.LastRun, p.LastError, p.LastTaken, p.Pruned = z.Policies[i].LastRun, z.Policies[i].LastError, z.Policies[i].LastTaken, z.Policies[i].Pruned + z.Policies[i] = p + found = true + } + } + if !found { + if p.ID == "" { + id, err := randomID() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + p.ID = id + } + z.Policies = append(z.Policies, p) + } + if err := s.zfsSave(z); err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("zfs schedule %q saved by %s (%s, %s, keep %s)", p.Name, clientIP(r), p.Dataset, p.Every, p.KeepFor) + jsonOK(w, 200, p) +} + +func (s *Server) zfsPolicyDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + z := s.zfsLoad() + kept := z.Policies[:0] + found := false + for _, p := range z.Policies { + if p.ID == id { + found = true + continue + } + kept = append(kept, p) + } + if !found { + jsonErr(w, 404, "no such schedule") + return + } + z.Policies = kept + if err := s.zfsSave(z); err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("zfs schedule %s removed by %s (its snapshots are left alone)", id, clientIP(r)) + w.WriteHeader(204) +} + +func (s *Server) zfsPolicy(id string) (storage.Policy, bool) { + for _, p := range s.zfsLoad().Policies { + if p.ID == id { + return p, true + } + } + return storage.Policy{}, false +} + +// zfsPreview is what this schedule would destroy if it ran now. Nothing is deleted. +func (s *Server) zfsPreview(w http.ResponseWriter, r *http.Request) { + p, ok := s.zfsPolicy(r.PathValue("id")) + if !ok { + jsonErr(w, 404, "no such schedule") + return + } + doomed, err := s.storage.Doomed(r.Context(), &p) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + if doomed == nil { + doomed = []storage.Snapshot{} + } + jsonOK(w, 200, map[string]any{"expired": doomed}) +} + +func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + p, ok := s.zfsPolicy(id) + if !ok { + jsonErr(w, 404, "no such schedule") + return + } + res, err := s.storage.Run(r.Context(), &p) + now := time.Now() + z := s.zfsLoad() + for i := range z.Policies { + if z.Policies[i].ID == id { + z.Policies[i].LastRun, z.Policies[i].LastTaken, z.Policies[i].Pruned = now, res.Taken, len(res.Pruned) + z.Policies[i].LastError = "" + if err != nil { + z.Policies[i].LastError = err.Error() + } + } + } + _ = s.zfsSave(z) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("zfs schedule %q run by %s: took %s, expired %d", p.Name, clientIP(r), res.Taken, len(res.Pruned)) + jsonOK(w, 200, res) +} + +// zfsSnapshotDelete removes one snapshot, named in full and typed back by the caller. +func (s *Server) zfsSnapshotDelete(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Confirm string `json:"confirm"` + Recursive bool `json:"recursive"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { + jsonErr(w, 400, "bad request") + return + } + if body.Confirm != body.Name { + jsonErr(w, 400, "type the snapshot's full name to confirm") + return + } + if err := s.storage.DestroySnapshot(r.Context(), body.Name, body.Recursive); err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("zfs snapshot %s destroyed by %s%s", body.Name, clientIP(r), map[bool]string{true: " (recursive)", false: ""}[body.Recursive]) + w.WriteHeader(204) +} diff --git a/internal/storage/snapshots.go b/internal/storage/snapshots.go new file mode 100644 index 0000000..a3d40ff --- /dev/null +++ b/internal/storage/snapshots.go @@ -0,0 +1,351 @@ +package storage + +// Scheduled snapshots with a retention period: take a snapshot of a dataset on a schedule, +// and drop the ones that have aged out. +// +// **The naming is zfSnap's, deliberately.** grid has run zfSnap v1.11.1 from root cron since +// before this repo existed and it works well, so rather than invent a second convention and +// have two tools disagree about what a snapshot is, this writes and reads exactly what +// zfSnap does: `@2026-09-24_02.30.01--6w`, with the retention encoded in the name. +// That has three consequences worth knowing. Ham Lab can show and manage the snapshots +// zfSnap already took. The two can run side by side without fighting, because both only +// ever destroy snapshots that have passed the expiry written into their own name. And +// changing a policy's retention does not retroactively re-date old snapshots — each one +// carries the rule it was taken under, which is the behaviour you want from a backup. +// +// Two rules make this safe to run unattended. Only a snapshot whose name parses as a stamp +// and a TTL is ever a candidate — anything taken by hand or named anything else is invisible +// to it. And every policy can be asked what it would delete, before it deletes anything. + +import ( + "context" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +// stampLayout is zfSnap's timestamp: 2026-09-24_02.30.01. +const stampLayout = "2006-01-02_15.04.05" + +// ttlSep separates the stamp from the retention in a snapshot name, as zfSnap writes it. +const ttlSep = "--" + +// Policy is a standing instruction: snapshot this dataset on this schedule, keep the +// snapshots this long. +type Policy struct { + ID string `json:"id"` + Name string `json:"name"` + Dataset string `json:"dataset"` + // Recursive takes one atomic snapshot of the dataset and everything under it (zfs -r). + Recursive bool `json:"recursive"` + // Every is hourly | daily | weekly | monthly. At is "HH:MM" (the minute alone is used + // for hourly). Weekday is 0=Sunday, Day is 1–28. + Every string `json:"every"` + At string `json:"at"` + Weekday int `json:"weekday"` + Day int `json:"day"` + // KeepFor is how long a snapshot lives: "6w", "30d", "12h". It is written into the + // snapshot's own name, zfSnap-style, so each one carries the rule it was taken under. + // KeepMin is a floor — that many newest snapshots survive however old they are, so a + // dataset is never left with none. + KeepFor string `json:"keep_for"` + KeepMin int `json:"keep_min"` + Prefix string `json:"prefix"` + Enabled bool `json:"enabled"` + + LastRun time.Time `json:"last_run,omitempty"` + LastError string `json:"last_error,omitempty"` + LastTaken string `json:"last_taken,omitempty"` // the snapshot the last run made + Pruned int `json:"pruned,omitempty"` // how many the last run destroyed +} + +func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) } + +// Validate reports what is wrong with a policy, or nil. +func (p *Policy) Validate() error { + if !datasetRe.MatchString(p.Dataset) { + return errors.New("dataset: a pool or pool/dataset name") + } + if pre := p.prefix(); pre != "" && (!snapRe.MatchString(pre) || strings.Contains(pre, ttlSep)) { + return errors.New("prefix: letters, digits, . _ : and - only, and no --") + } + switch p.Every { + case "hourly", "daily", "weekly", "monthly": + default: + return errors.New("every: hourly, daily, weekly or monthly") + } + if _, _, err := p.clock(); err != nil { + return err + } + if p.Every == "weekly" && (p.Weekday < 0 || p.Weekday > 6) { + return errors.New("weekday: 0 (Sunday) to 6") + } + if p.Every == "monthly" && (p.Day < 1 || p.Day > 28) { + return errors.New("day: 1 to 28, so every month has one") + } + if p.KeepFor != "" { + if _, err := ParseAge(p.KeepFor); err != nil { + return err + } + } + if p.KeepMin < 0 { + return errors.New("keep at least: 0 or more") + } + if p.KeepFor == "" { + return errors.New("a retention period is required — it is written into each snapshot's name, and is what lets it be cleaned up later") + } + return nil +} + +func (p *Policy) clock() (hour, min int, err error) { + at := strings.TrimSpace(p.At) + if at == "" { + return 0, 0, nil + } + h, m, ok := strings.Cut(at, ":") + if !ok { + return 0, 0, errors.New("at: HH:MM") + } + hour, err = strconv.Atoi(h) + if err == nil { + min, err = strconv.Atoi(m) + } + if err != nil || hour < 0 || hour > 23 || min < 0 || min > 59 { + return 0, 0, errors.New("at: HH:MM, 00:00 to 23:59") + } + return hour, min, nil +} + +// ParseAge reads "6w", "30d", "12h", "90m" as a duration. Weeks and days are what a +// retention period is actually written in, and time.ParseDuration knows neither. +func ParseAge(s string) (time.Duration, error) { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return 0, errors.New("empty period") + } + unit := s[len(s)-1] + n, err := strconv.Atoi(s[:len(s)-1]) + if err != nil || n <= 0 { + return 0, fmt.Errorf("%q: a number and a unit, like 6w, 30d or 12h", s) + } + switch unit { + case 'm': + return time.Duration(n) * time.Minute, nil + case 'h': + return time.Duration(n) * time.Hour, nil + case 'd': + return time.Duration(n) * 24 * time.Hour, nil + case 'w': + return time.Duration(n) * 7 * 24 * time.Hour, nil + } + return 0, fmt.Errorf("%q: the unit is m, h, d or w", s) +} + +// lastDue is the most recent moment this policy was supposed to run, at or before now. +// A policy whose LastRun is older than that is due — so a machine that was off through +// its window takes one snapshot when it comes back, rather than skipping silently. +func (p *Policy) lastDue(now time.Time) time.Time { + hour, min, _ := p.clock() + y, mo, d := now.Date() + loc := now.Location() + switch p.Every { + case "hourly": + t := time.Date(y, mo, d, now.Hour(), min, 0, 0, loc) + if t.After(now) { + t = t.Add(-time.Hour) + } + return t + case "daily": + t := time.Date(y, mo, d, hour, min, 0, 0, loc) + if t.After(now) { + t = t.AddDate(0, 0, -1) + } + return t + case "weekly": + t := time.Date(y, mo, d, hour, min, 0, 0, loc) + back := (int(t.Weekday()) - p.Weekday + 7) % 7 + t = t.AddDate(0, 0, -back) + if t.After(now) { + t = t.AddDate(0, 0, -7) + } + return t + case "monthly": + t := time.Date(y, mo, p.Day, hour, min, 0, 0, loc) + if t.After(now) { + t = t.AddDate(0, -1, 0) + } + return t + } + return time.Time{} +} + +// NextRun is when this policy runs next, for the UI to show. +func (p *Policy) NextRun(now time.Time) time.Time { + last := p.lastDue(now) + switch p.Every { + case "hourly": + return last.Add(time.Hour) + case "daily": + return last.AddDate(0, 0, 1) + case "weekly": + return last.AddDate(0, 0, 7) + case "monthly": + return last.AddDate(0, 1, 0) + } + return time.Time{} +} + +// Due reports whether the policy should run now. +func (p *Policy) Due(now time.Time) bool { + if !p.Enabled { + return false + } + d := p.lastDue(now) + return !d.IsZero() && p.LastRun.Before(d) +} + +// SnapTime reads a snapshot's name in zfSnap's convention and returns when it was taken, +// when it expires, and whether the name parsed at all. A name without a TTL never expires. +// An optional prefix in front of the stamp is allowed, so a policy can label its own. +func SnapTime(name string) (taken time.Time, expires time.Time, ok bool) { + _, snap, cut := strings.Cut(name, "@") + if !cut { + return + } + body, ttl, hasTTL := strings.Cut(snap, ttlSep) + // an optional "-" before the stamp + stamp := body + if len(body) > len(stampLayout) { + stamp = body[len(body)-len(stampLayout):] + } + t, err := time.ParseInLocation(stampLayout, stamp, time.Local) + if err != nil { + return time.Time{}, time.Time{}, false + } + taken, ok = t, true + if hasTTL { + if d, err := ParseAge(ttl); err == nil { + expires = t.Add(d) + } + } + return +} + +// Doomed is which of this dataset's snapshots have passed the expiry written into their own +// names, oldest first — exactly what zfSnap -d would remove. This is what the UI shows +// before anything is deleted, and what Run then acts on. KeepMin holds back that many of the +// newest whatever their names say, so a dataset is never left with none. +func (m *Manager) Doomed(ctx context.Context, p *Policy) ([]Snapshot, error) { + if err := p.Validate(); err != nil { + return nil, err + } + all, err := m.Snapshots(ctx, p.Dataset) + if err != nil { + return nil, err + } + var dated []Snapshot + expiry := map[string]time.Time{} + for _, s := range all { + taken, exp, ok := SnapTime(s.Name) + if !ok || exp.IsZero() { + continue // hand-made, or kept forever: not ours to remove + } + _ = taken + dated = append(dated, s) + expiry[s.Name] = exp + } + sort.Slice(dated, func(i, j int) bool { return dated[i].Created > dated[j].Created }) // newest first + now := time.Now() + var doomed []Snapshot + for i, s := range dated { + if i < p.KeepMin { + continue // the floor: these survive however old they are + } + if expiry[s.Name].Before(now) { + doomed = append(doomed, s) + } + } + sort.Slice(doomed, func(i, j int) bool { return doomed[i].Created < doomed[j].Created }) // oldest first + return doomed, nil +} + +// DestroySnapshot removes one snapshot. recursive also removes the same-named snapshot on +// every dataset beneath it, which is what a recursive policy created. +func (m *Manager) DestroySnapshot(ctx context.Context, full string, recursive bool) error { + dataset, snap, ok := strings.Cut(full, "@") + if !ok || !datasetRe.MatchString(dataset) || !snapRe.MatchString(snap) { + return errors.New("bad snapshot name") + } + args := []string{"zfs", "destroy"} + if recursive { + args = append(args, "-r") + } + out, code, err := m.run(ctx, append(args, full)...) + if err != nil { + return err + } + if code != 0 { + return errors.New(strings.TrimSpace(string(out))) + } + return nil +} + +// SnapshotRecursive takes dataset@name, optionally of everything beneath it too. +func (m *Manager) SnapshotRecursive(ctx context.Context, dataset, name string, recursive bool) error { + if !datasetRe.MatchString(dataset) || !snapRe.MatchString(name) { + return errors.New("bad snapshot name") + } + args := []string{"zfs", "snapshot"} + if recursive { + args = append(args, "-r") + } + out, code, err := m.run(ctx, append(args, dataset+"@"+name)...) + if err != nil { + return err + } + if code != 0 { + return errors.New(strings.TrimSpace(string(out))) + } + return nil +} + +// RunResult is what one pass of a policy did. +type RunResult struct { + Taken string `json:"taken"` + Pruned []string `json:"pruned"` + Skipped int `json:"skipped"` // snapshots that are not this policy's, left alone +} + +// Run takes the snapshot and prunes what has aged out. It is the same code the scheduler +// calls, so "Run now" and the timer cannot drift apart. +func (m *Manager) Run(ctx context.Context, p *Policy) (RunResult, error) { + var res RunResult + if err := p.Validate(); err != nil { + return res, err + } + name := time.Now().Format(stampLayout) + if pre := strings.TrimSpace(p.Prefix); pre != "" { + name = pre + "-" + name + } + if p.KeepFor != "" { + name += ttlSep + strings.TrimSpace(strings.ToLower(p.KeepFor)) // zfSnap's convention: the retention lives in the name + } + if err := m.SnapshotRecursive(ctx, p.Dataset, name, p.Recursive); err != nil { + return res, err + } + res.Taken = p.Dataset + "@" + name + doomed, err := m.Doomed(ctx, p) + if err != nil { + return res, err // the snapshot was taken; the prune is what failed + } + for _, s := range doomed { + if err := m.DestroySnapshot(ctx, s.Name, p.Recursive); err != nil { + return res, fmt.Errorf("destroying %s: %w", s.Name, err) + } + res.Pruned = append(res.Pruned, s.Name) + } + return res, nil +} diff --git a/internal/storage/snapshots_test.go b/internal/storage/snapshots_test.go new file mode 100644 index 0000000..ae5c12d --- /dev/null +++ b/internal/storage/snapshots_test.go @@ -0,0 +1,107 @@ +package storage + +import ( + "testing" + "time" +) + +func TestSnapTimeReadsZfSnapNames(t *testing.T) { + // the real thing off grid + taken, exp, ok := SnapTime("tank@2026-09-24_02.30.01--6w") + if !ok { + t.Fatal("zfSnap's own name must parse") + } + if taken.Format("2006-01-02 15:04:05") != "2026-09-24 02:30:01" { + t.Fatalf("taken = %v", taken) + } + if got := exp.Sub(taken); got != 6*7*24*time.Hour { + t.Fatalf("ttl = %v, want 6w", got) + } + // a labelled one from a policy with a prefix + if _, e, ok := SnapTime("tank/apps@nightly-2026-09-24_02.30.01--30d"); !ok || e.IsZero() { + t.Fatal("a prefixed name must parse and expire") + } + // no TTL: never expires + if _, e, ok := SnapTime("tank@2026-09-24_02.30.01"); !ok || !e.IsZero() { + t.Fatal("a name without a TTL must parse and never expire") + } + // things that are not ours + for _, n := range []string{"tank@before-upgrade", "tank@manual", "tank", "tank@2026-13-45_99.99.99--6w"} { + if _, _, ok := SnapTime(n); ok { + t.Errorf("%q must not parse as a scheduled snapshot", n) + } + } +} + +func TestScheduleIsDueAndNext(t *testing.T) { + now := time.Date(2026, 9, 24, 9, 15, 0, 0, time.Local) + daily := &Policy{Every: "daily", At: "02:30", Enabled: true} + if d := daily.lastDue(now); d.Hour() != 2 || d.Day() != 24 { + t.Fatalf("daily lastDue = %v", d) + } + if !daily.Due(now) { + t.Fatal("never run: must be due") + } + daily.LastRun = now.Add(-time.Hour) // ran at 08:15 today, after 02:30 + if daily.Due(now) { + t.Fatal("already ran since the window: not due") + } + if n := daily.NextRun(now); n.Day() != 25 || n.Hour() != 2 { + t.Fatalf("daily NextRun = %v", n) + } + // a machine that was off through its window runs once when it comes back, not never + daily.LastRun = now.AddDate(0, 0, -2) + if !daily.Due(now) { + t.Fatal("a missed window must still be due") + } + // disabled never runs + daily.Enabled = false + if daily.Due(now) { + t.Fatal("disabled must never be due") + } + weekly := &Policy{Every: "weekly", At: "04:00", Weekday: int(time.Sunday), Enabled: true} + if d := weekly.lastDue(now); d.Weekday() != time.Sunday || d.After(now) { + t.Fatalf("weekly lastDue = %v (%v)", d, d.Weekday()) + } + hourly := &Policy{Every: "hourly", At: "00:30", Enabled: true} + if d := hourly.lastDue(now); d.Minute() != 30 || d.Hour() != 8 { + t.Fatalf("hourly lastDue at 09:15 with minute 30 = %v, want 08:30", d) + } +} + +func TestValidateRefusesNonsense(t *testing.T) { + ok := Policy{Dataset: "tank/apps", Every: "daily", At: "02:30", KeepFor: "6w"} + if err := ok.Validate(); err != nil { + t.Fatalf("a sane policy: %v", err) + } + bad := []Policy{ + {Dataset: "", Every: "daily", At: "02:30", KeepFor: "6w"}, + {Dataset: "tank", Every: "yearly", At: "02:30", KeepFor: "6w"}, + {Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"}, + {Dataset: "tank", Every: "daily", At: "02:30"}, // no retention + {Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable + {Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one + {Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"}, + } + for i, p := range bad { + if err := p.Validate(); err == nil { + t.Errorf("bad policy %d was accepted: %+v", i, p) + } + } +} + +func TestParseAge(t *testing.T) { + for s, want := range map[string]time.Duration{ + "6w": 6 * 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour, + "12h": 12 * time.Hour, "90m": 90 * time.Minute, + } { + if got, err := ParseAge(s); err != nil || got != want { + t.Errorf("ParseAge(%q) = %v, %v; want %v", s, got, err, want) + } + } + for _, s := range []string{"", "6", "w", "-1d", "0w", "6y", "six weeks"} { + if _, err := ParseAge(s); err == nil { + t.Errorf("ParseAge(%q) should fail", s) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index ec227d1..958e2ac 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -51,7 +51,8 @@ type State struct { // UI is the pre-users machine-wide look; on first boot with users it moves to the first // account. Kept so an old state file still reads. UI json.RawMessage `json:"ui,omitempty"` - Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go) + Alerts json.RawMessage `json:"alerts,omitempty"` + ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go) // rules, channels and recent events (internal/api/alerts.go) SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one } diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx index c068cef..74e662e 100644 --- a/web/src/Disks.tsx +++ b/web/src/Disks.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' +import { api, SmartInfo, StorageDevice } from './api' import { fmtBytes, fmtRate } from './fmt' import TaskOutput from './TaskOutput' -import { Btn, Loading, Spinner, askText, toast } from './ui' +import { askText, Btn, Loading, Meter, Spinner, toast } from './ui' import { useRefreshMs } from './prefs' import { Sensor, useSensorStream } from './sensors' @@ -11,10 +11,6 @@ const KIND: Record = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`) const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` } -function Meter({ frac, cls }: { frac: number; cls?: string }) { - const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '') - return
-} // ---- SMART panel ---- function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) { @@ -109,82 +105,9 @@ function DeviceCard({ d, live, forceOpen }: { d: StorageDevice; live?: { read_bp ) } -// ---- ZFS ---- -function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) { - const scrubbing = /scrub in progress/i.test(p.scan ?? '') - const paused = /scrub paused/i.test(p.scan ?? '') - const [cfg, setCfg] = useState(false) - const act = (a: 'start' | 'pause' | 'stop') => async () => { - try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } - } - return ( -
-
- - {p.name} - {p.health} - - {fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}% -
- = 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} /> -
-
Free{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}
- {p.scan &&
Scan{p.scan}
} - {p.errors &&
Errors{p.errors}
} -
-
- {!scrubbing && {paused ? 'Resume scrub' : 'Scrub'}} - {scrubbing && Pause scrub} - {(scrubbing || paused) && Stop scrub} - -
- {cfg &&
{p.config}
} -
- ) -} - -function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) { - const [snaps, setSnaps] = useState(null) - const [open, setOpen] = useState(false) - async function toggle() { - setOpen(!open) - if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } } - } - async function snapshot() { - const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2') - const name = await askText(`Snapshot name for ${d.name}@`, def) - if (!name) return - try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() } - catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } - } - const depth = d.name.split('/').length - 1 - return ( - <> -
- {open ? '▾' : '▸'}{d.name.split('/').pop()}{d.type === 'volume' && zvol} - {fmtBytes(d.used)} - {fmtBytes(d.refer)} - {d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'} - {d.snapshots} - {d.compressratio} - {d.mount} -
- {open && ( -
-
Snapshot nowavail {fmtBytes(d.avail)}
- {snaps === null ?
listing…
: snaps.length === 0 ?
No snapshots.
: ( -
{snaps.slice().reverse().map((s) =>
{s.name.split('@')[1]}{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}
)}
- )} -
- )} - - ) -} - // ---- page ---- export default function Disks() { const [devs, setDevs] = useState(null) - const [zfs, setZfs] = useState(null) const [hasSmart, setHasSmart] = useState(true) const [err, setErr] = useState('') const [task, setTask] = useState(null) @@ -194,7 +117,7 @@ export default function Disks() { const load = useCallback(async () => { try { const r = await api.storage() - setDevs(r.devices); setZfs(r.zfs); setHasSmart(r.smartctl); setErr('') + setDevs(r.devices); setHasSmart(r.smartctl); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) useEffect(() => { load() }, [load]) @@ -237,18 +160,6 @@ export default function Disks() { {devs.map((d) => )}
-

ZFS

- {zfs && !zfs.available &&
{zfs.message || 'No ZFS pools on this host.'}
} - {zfs?.available && zfs.pools.length === 0 &&
ZFS is present but no pools are imported.
} - {zfs?.available && zfs.pools.length > 0 && ( - <> -
{zfs.pools.map((p) => )}
-
-
DatasetUsedReferIn snapsSnapsRatioMount
- {zfs.datasets.map((d) => )} -
- - )} ) } diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 4c6aa29..5049ca8 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -11,6 +11,7 @@ import Services from './Services' import Jobs from './Jobs' import Updates from './Updates' import Disks from './Disks' +import ZFS from './ZFS' import Files from './Files' import Top from './Top' import Machines from './Machines' @@ -57,6 +58,7 @@ const GROUPS: Group[] = [ { id: 'jobs', label: 'Jobs', icon: ClockIcon }, { id: 'updates', label: 'Updates', icon: '⇪' }, { id: 'disks', label: 'Disks', icon: '▤' }, + { id: 'zfs', label: 'ZFS', icon: '⛁' }, { id: 'usage', label: 'Usage', icon: '▧' }, { id: 'files', label: 'Files', icon: FolderIcon }, { id: 'alerts', label: 'Alerts', icon: BellIcon }, @@ -66,8 +68,8 @@ const GROUPS: Group[] = [ { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings' -const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'usage', 'files', 'procs', 'machines', 'users', 'settings'] +type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings' +const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] // URL hash is the source of truth for where you are: #//. Back and @@ -277,6 +279,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'jobs' && } {view === 'updates' && } {view === 'disks' && } + {view === 'zfs' && } {view === 'files' && } {termView && {machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} + {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} ) diff --git a/web/src/ZFS.tsx b/web/src/ZFS.tsx new file mode 100644 index 0000000..8d7b171 --- /dev/null +++ b/web/src/ZFS.tsx @@ -0,0 +1,303 @@ +// ZFS: pools, datasets, snapshots, and a schedule for taking and expiring them. +// +// Split out of the Disks tab on 2026-09-24. Disks is the hardware; this is the filesystem +// on top of it, which has enough of its own to say. +// +// On the schedule: `grid` has run zfSnap from root cron since before Ham Lab existed, and it +// works. So this reads and writes the same snapshot names — `2026-09-24_02.30.01--6w`, with +// the retention in the name — rather than inventing a second convention for the two tools to +// disagree over. An existing cron job is detected and shown, so the page tells the truth +// about the machine whether Ham Lab or something else is doing the work. +import { FormEvent, useCallback, useEffect, useState } from 'react' +import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSSnapshot } from './api' +import { fmtBytes } from './fmt' +import { useRefreshMs } from './prefs' +import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui' + +const ago = (t?: string | number) => { + if (!t) return 'never' + const s = Math.max(0, (Date.now() - new Date(t).getTime()) / 1000) + return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago` +} +const soon = (t?: string) => { + if (!t) return '—' + const s = (new Date(t).getTime() - Date.now()) / 1000 + return s < 60 ? 'any moment' : s < 3600 ? `in ${Math.round(s / 60)} min` : s < 86400 ? `in ${Math.round(s / 3600)} h` : `in ${Math.round(s / 86400)} d` +} +const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] +const describe = (p: ZFSPolicy) => { + const when = p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}` + : p.every === 'daily' ? `every day at ${p.at}` + : p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}` + : `on day ${p.day} of the month at ${p.at}` + return `${when} · keep ${p.keep_for}${p.recursive ? ' · recursive' : ''}` +} + +// ---- pools and datasets (moved from the Disks tab) ---- +function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) { + const scrubbing = /scrub in progress/i.test(p.scan ?? '') + const paused = /scrub paused/i.test(p.scan ?? '') + const [cfg, setCfg] = useState(false) + const act = (a: 'start' | 'pause' | 'stop') => async () => { + try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + return ( +
+
+ + {p.name} + {p.health} + + {fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}% +
+ = 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} /> +
+
Free{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}
+ {p.scan &&
Scan{p.scan}
} + {p.errors &&
Errors{p.errors}
} +
+
+ {!scrubbing && {paused ? 'Resume scrub' : 'Scrub'}} + {scrubbing && Pause scrub} + {(scrubbing || paused) && Stop scrub} + +
+ {cfg &&
{p.config}
} +
+ ) +} + +function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) { + const [snaps, setSnaps] = useState(null) + const [open, setOpen] = useState(false) + async function toggle() { + setOpen(!open) + if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } } + } + async function snapshot() { + const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2') + const name = await askText(`Snapshot name for ${d.name}@`, def) + if (!name) return + try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + const depth = d.name.split('/').length - 1 + return ( + <> +
+ {open ? '▾' : '▸'}{d.name.split('/').pop()}{d.type === 'volume' && zvol} + {fmtBytes(d.used)} + {fmtBytes(d.refer)} + {d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'} + {d.snapshots} + {d.compressratio} + {d.mount} +
+ {open && ( +
+
Snapshot nowavail {fmtBytes(d.avail)}
+ {snaps === null ?
listing…
: snaps.length === 0 ?
No snapshots.
: ( +
{snaps.slice().reverse().map((s) =>
{s.name.split('@')[1]}{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}
)}
+ )} +
+ )} + + ) +} + +// ---- schedules ---- + +function ExternalNotice({ x, mine }: { x: ExternalJob; mine: number }) { + return ( +
0 ? ' attn' : '')} style={{ marginBottom: 8 }}> + {x.tool} is already taking snapshots on this machine, from cron as {x.user}: +
{x.schedule}   {x.command}
+ in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''} + {mine > 0 + ?
A Ham Lab schedule is running as well. Both use the same names and both only remove snapshots that have passed the expiry written into them, so they will not fight — but they will take twice as many. Keep one.
+ :
Ham Lab reads the snapshots it makes and shows when each expires. Add a schedule here only if you mean to replace it; edit this one on the Jobs tab.
} +
+ ) +} +type ExternalJob = ZFSExternal + +function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: string; onChanged: () => void; onEdit: () => void }) { + const [expired, setExpired] = useState(null) + const [busy, setBusy] = useState('') + const preview = async () => { + setBusy('preview') + try { setExpired((await api.zfsExpired(p.id)).expired) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') } + } + const run = async () => { + const n = expired?.length + const warn = n ? ` It will also destroy ${n} expired snapshot${n === 1 ? '' : 's'}.` : '' + if (!await askYes(`Take a snapshot of ${p.dataset} now?${warn}`, 'Run now')) return + setBusy('run') + try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); setExpired(null); onChanged() } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') } + } + const remove = async () => { + if (!await askYes(`Remove the schedule "${p.name}"? The snapshots it already took are left exactly as they are.`, 'Remove')) return + try { await api.deleteZfsSchedule(p.id); toast('schedule removed'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + const toggle = async () => { + try { await api.saveZfsSchedule({ ...p, enabled: !p.enabled }); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + return ( +
+
+ + {p.name || p.dataset} + {!p.enabled && paused} + + {p.enabled ? soon(next) : 'not scheduled'} +
+
+
Dataset{p.dataset}
+
When{describe(p)}
+
Last run{ago(p.last_run)}{p.pruned ? ` · ${p.pruned} expired removed` : ''}
+ {p.last_taken &&
Last snapshot{p.last_taken.split('@')[1]}
} + {p.keep_min > 0 &&
Flooralways keep the newest {p.keep_min}
} +
+ {p.last_error &&
{p.last_error}
} + {expired && ( +
+ {expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : ( + <> + {expired.length} snapshot{expired.length === 1 ? '' : 's'} would be destroyed by a run now: +
+ {expired.map((s) =>
{s.name.split('@')[1]}{new Date(s.created).toLocaleDateString()} · {fmtBytes(s.used)}
)} +
+ + )} +
+ )} +
+ What would it delete? + Run now + + {p.enabled ? 'Pause' : 'Resume'} + Remove +
+
+ ) +} + +const BLANK: Partial = { name: '', dataset: '', recursive: true, every: 'daily', at: '02:30', weekday: 0, day: 1, keep_for: '6w', keep_min: 1, enabled: true } + +function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial; datasets: string[]; onClose: () => void; onSaved: () => void }) { + const [f, setF] = useState>({ ...BLANK, ...p }) + const [err, setErr] = useState('') + useEscape(onClose) + const set = (k: keyof ZFSPolicy, v: unknown) => setF((x) => ({ ...x, [k]: v })) + const save = async (e: FormEvent) => { + e.preventDefault(); setErr('') + try { await api.saveZfsSchedule({ ...f, name: (f.name || '').trim() || f.dataset }); onSaved() } + catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + } + return ( +
+
e.stopPropagation()} onSubmit={save}> +

{f.id ? 'Edit schedule' : 'New snapshot schedule'}

+ + + + + {f.every === 'weekly' && ( + + )} + {f.every === 'monthly' && ( + + )} + + + + + {err &&
{err}
} +
+ + + +
+
+
+ ) +} + +// ---- page ---- + +export default function ZFS() { + const [ov, setOv] = useState(null) + const [err, setErr] = useState('') + const [editing, setEditing] = useState | null>(null) + const rate = useRefreshMs() + + const load = useCallback(async () => { + try { setOv(await api.zfsOverview()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + }, []) + useEffect(() => { load() }, [load]) + useEffect(() => { const t = setInterval(load, Math.max(5000, rate * 10)); return () => clearInterval(t) }, [load, rate]) + + if (err && !ov) return
{err}
+ if (!ov) return
+ const zfs: ZFSInfo = ov.zfs + const datasets = zfs.datasets.filter((d) => d.type !== 'volume').map((d) => d.name) + const mine = ov.policies.filter((x) => x.policy.enabled).length + + if (!zfs.available) return
{zfs.message || 'No ZFS on this host.'}
+ + return ( +
+

Pools

+ {zfs.pools.length === 0 &&
ZFS is present but no pools are imported.
} +
{zfs.pools.map((p) => )}
+ +

Snapshot schedules

+ setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule +
+ {ov.external && } + {ov.policies.length === 0 && !ov.external &&
Nothing is taking snapshots on a schedule. Snapshots are written the way zfSnap writes them, with the retention in the name, so anything already here is understood.
} +
{ov.policies.map(({ policy, next }) => ( + setEditing(policy)} /> + ))}
+ +

Datasets

used · referenced · in snapshots · count · compression · mount
+
+ {zfs.datasets.map((d) => )} +
+ + {editing && p.name)} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />} +
+ ) +} diff --git a/web/src/api.ts b/web/src/api.ts index bb4bb43..9742fc4 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -133,6 +133,10 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent: export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number } export interface SpaceFile { path: string; size: number; apparent: number; mtime: number } +export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly'; at: string; weekday: number; day: number; keep_for: string; keep_min: number; prefix?: string; enabled: boolean; last_run?: string; last_error?: string; last_taken?: string; pruned?: number } +export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number } +export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; next?: string }[]; external?: ZFSExternal | null } + export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean } export interface FileInfo extends FileEntry { path: string; text: boolean } @@ -179,6 +183,12 @@ export const api = { storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'), smart: (dev: string, force = false) => req('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`), smartTest: (dev: string, kind: 'short' | 'long') => req('POST', `/api/storage/smart/${dev}/test/${kind}`), + zfsOverview: () => req('GET', '/api/zfs'), + saveZfsSchedule: (p: Partial) => req('PUT', '/api/zfs/schedules', p), + deleteZfsSchedule: (id: string) => req('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`), + zfsExpired: (id: string) => req<{ expired: ZFSSnapshot[] }>('GET', `/api/zfs/schedules/${encodeURIComponent(id)}/expired`), + runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`), + deleteZfsSnapshot: (name: string, recursive: boolean) => req('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }), zfsSnapshots: (dataset: string) => req('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`), zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`), zfsSnapshot: (dataset: string, name: string) => req('POST', '/api/storage/zfs/snapshot', { dataset, name }), diff --git a/web/src/styles.css b/web/src/styles.css index 0ad4fbb..a881c58 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -729,3 +729,7 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, .t-ifs .t-if { padding: 0 4px; border-radius: 3px; color: var(--muted); } .t-ifs .t-if:hover { color: var(--fg); background: var(--bg3); } .t-ifs .t-if.on { color: var(--fg); box-shadow: inset 0 -2px 0 var(--accent); } + +/* ZFS: the schedule dialog's controls match the rest of the modal forms */ +.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; } +.modal .chips { margin-top: 2px; } diff --git a/web/src/ui.tsx b/web/src/ui.tsx index e2f9af0..38aa9a9 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -14,6 +14,12 @@ export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; ti ) } +// A filled bar. Red past 95%, amber past 85%, unless the caller says otherwise. +export function Meter({ frac, cls }: { frac: number; cls?: string }) { + const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '') + return
+} + export function Loading({ label = 'Loading…' }: { label?: string }) { return
{label}
}