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}
}