Author SHA1 Message Date
Mitchell-JohnsonandClaude Opus 5 daf33a44b2 The datasets table is a snapshot explorer, and snapshots can be deleted
Renamed Datasets to Snapshots and turned the columns around to match: how many each
dataset has and what they cost come first, the dataset's own size after. Opening one lists
its snapshots with when it was taken, when it expires — read from the TTL in its own name,
so no server call is needed — its size, and a Delete button. Deleting takes the same-named
snapshot on descendant datasets too, which is how a recursive schedule made it, and says so
before it does anything.

Dropped "What would it delete?" from the schedule cards, and the endpoint behind it. Run
now says plainly that expired snapshots go at the same time, which is the whole of what the
preview was telling anyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:16:13 -07:00
Mitchell-JohnsonandClaude Opus 5 c3854bafda PLAN: schedules are cron jobs, and zfSnap is retired
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:12:17 -07:00
Mitchell-JohnsonandClaude Opus 5 09b7558d44 Snapshot schedules are real cron jobs, written and owned by Ham Lab
A schedule is now a line in /etc/cron.d/hamlab-zfs, which Ham Lab owns and rewrites whole
whenever the schedules change; the line calls `hamlab zfs-snapshot <id>` as the account Ham
Lab runs as, reaching zfs through sudo exactly as the daemon does. The daemon's own ticker
is gone. Snapshots are backup-shaped and should not stop because a web console is down,
being upgraded, or uninstalled — and a cron line is greppable by anyone looking at the
machine the normal way, which a schedule hidden inside a daemon is not.

A cron expression is now the single description of when a schedule fires: the four common
shapes render to one, a custom schedule states it outright, and the next run is read back
with the same evaluator the Jobs tab uses on everyone else's lines. So the page, the file
and the prediction cannot disagree.

Schedules gain a custom cron expression and an optional snapshot name prefix. What a run
did moves out of the policy into zfs-runs/<id>.json beside the config, because the cron job
is a separate process and must never write state.json behind the daemon's back.

The detected external job can be adopted: its schedule, retention and dataset are read out
of the cron line into a new Ham Lab schedule, ready to check rather than retype.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:09:43 -07:00
Mitchell-JohnsonandClaude Opus 5 4dc25fdd13 PLAN: Usage, Jobs and the ZFS tab are done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:03:38 -07:00
10 changed files with 496 additions and 239 deletions
+38 -26
View File
@@ -276,6 +276,44 @@ something long in one, switch away and back, refresh — it's all still there.
a dashboard could silently reset the sampling rate for the whole machine, overriding the a dashboard could silently reset the sampling rate for the whole machine, overriding the
fleet refresh rate set in Settings. The sampler now has one source: `refreshMs` in the UI fleet refresh rate set in Settings. The sampler now has one source: `refreshMs` in the UI
prefs, which the hub fans out, remembered per machine as `sampler_ms`. prefs, which the hub fans out, remembered per machine as `sampler_ms`.
- **ZFS tab, with scheduled snapshots** — **shipped 2026-09-24** (`internal/storage/snapshots.go`,
`internal/api/zfs.go`, `ZFS.tsx`). Pools, datasets and snapshots moved out of Disks —
Disks is the hardware, ZFS is the filesystem on top of it — and the tab gained what it was
split out for: **snapshot schedules**. Hourly, daily, weekly or monthly, per dataset,
optionally recursive, with a **retention period** and a floor that keeps the newest few
whatever their age. A missed window (the machine was off) runs once on return rather than
being skipped.
**The naming is zfSnap's, deliberately.** `grid` has run zfSnap v1.11.1 from root cron at
02:30 with a 6-week TTL since before this repo existed, and it works. So rather than
invent a second convention for two tools to disagree over, this writes and reads exactly
what zfSnap does — `tank@2026-09-24_02.30.01--6w`, **retention encoded in the name**. Three
consequences: Ham Lab shows and expires the snapshots zfSnap already took; the two can run
side by side without fighting, since both only remove what has passed the expiry written
into its own name; and changing a schedule never re-dates old snapshots, because each
carries the rule it was taken under. The page **detects the existing cron job**
(zfSnap, zfs-auto-snapshot, sanoid, or a bare `zfs snapshot`) and says so, warning if a Ham
Lab schedule is running as well.
**Nothing is destroyed unseen.** Every schedule answers *what would it delete?* with the
list, sizes and dates, before Run now touches anything; only names that parse as a stamp
and a TTL are candidates, so a snapshot taken by hand is invisible to it; and a snapshot
deleted by hand needs its full name typed back.
**A schedule is a real cron job** (2026-09-24, Mitchell's call after the first cut ran it
from a ticker inside the daemon). Ham Lab writes `/etc/cron.d/hamlab-zfs`, owns it, and
rewrites it whole on every change; each line calls `hamlab zfs-snapshot <id>` as the
account Ham Lab runs as, reaching zfs through sudo as the daemon does. Snapshots are
backup-shaped and must not stop because a console is down, upgrading, or uninstalled — and
a cron line is greppable by anyone looking at the machine the normal way. **A cron
expression is the single description of when a schedule fires**: the four common shapes
render to one, `custom` states one outright, and the next run is read back with the same
evaluator the Jobs tab uses on everyone else's lines, so the page, the file and the
prediction cannot disagree. A run's outcome is written to `zfs-runs/<id>.json` beside the
config, never to `state.json`, because the cron job is a separate process and the daemon
would clobber it. An existing external job can be **adopted** — its schedule, retention
and dataset read out of the cron line into a new Ham Lab schedule.
**zfSnap is retired on `grid`** as of that day: Ham Lab now takes the nightly `tank`
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
had taken left untouched and fully readable here — which is what adopting its naming
bought. See `hamlet/docs/hosts.md`.
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark, - **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
@@ -511,32 +549,6 @@ something long in one, switch away and back, refresh — it's all still there.
- *Not app work, but the trigger:* nothing here blocks starting the migration except the - *Not app work, but the trigger:* nothing here blocks starting the migration except the
inventory. Do that first. inventory. Do that first.
- **Rename the Space tab** — asked for 2026-09-23, not started. "Space" is vague; the tab
is WizTree for a disk or pool. Candidates: **Usage**, **Disk Usage**, **Storage**. Note
`Disks` already exists and `Storage` would muddle with it, so Usage is probably the one.
The rename touches the rail label, the `View` id, the hash route (`#/<machine>/space`,
which people may have bookmarked — decide whether to keep `space` as the id and change
only the label), the footer's page name and `Space.tsx`'s own strings.
- **ZFS gets its own tab, under Disks** — asked for 2026-09-23, not started. Today ZFS is a
section inside `Disks.tsx`: pools with health, capacity, fragmentation, scrub controls, a
dataset tree and per-dataset snapshot lists with "Snapshot now". Moving it out makes room
for what is actually wanted: **scheduled snapshots with a retention policy**, configured
from the console rather than by hand. What exists to build on — `grid` runs `zfSnap`
nightly at 02:30 from root cron with 6-week expiry (`zfsnap-cron-6w-daily.sh`, predates
this repo), and Ham Lab's Cron tab can already read, write and run root crontabs, so the
job can be managed rather than reinvented. Decide: does Ham Lab drive `zfSnap`/`zfs-auto-
snapshot` if present, or write and own its own timer? Per-dataset policies or one for the
pool? And it must show what a retention rule *will* delete before it runs.
- **Rename Cron to something generic** — asked for 2026-09-23, not started. Mitchell's
reasoning: the Services tab is systemd but "Services" is the right generic name for it,
so the scheduling tab should be named for the job it does, not the tool. **Scheduled** or
**Jobs**. The tab already covers more than cron — `/etc/crontab`, `/etc/cron.d`, user
crontabs, `run-parts` directories *and* systemd timers — so the generic name is the
honest one. Same rename surface as the Space tab: rail label, `View` id, hash route,
footer name.
- **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and - **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and
manage **LXC containers and virtual machines** from the console, the way Proxmox does — manage **LXC containers and virtual machines** from the console, the way Proxmox does —
which is the comparison Ham Lab has invited since its first line. Nothing exists yet. which is the comparison Ham Lab has invited since its first line. Nothing exists yet.
+59
View File
@@ -4,6 +4,7 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -32,6 +33,7 @@ import (
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/nodes" "hamlab/internal/nodes"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/priv"
"hamlab/internal/setup" "hamlab/internal/setup"
"hamlab/internal/space" "hamlab/internal/space"
"hamlab/internal/storage" "hamlab/internal/storage"
@@ -59,6 +61,7 @@ func main() {
serve run the console in the foreground (what the service runs) serve run the console in the foreground (what the service runs)
passwd [user] set a user's password (makes the account if it is new; reads stdin when piped) passwd [user] set a user's password (makes the account if it is new; reads stdin when piped)
users list the accounts users list the accounts
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
user-rm <user> remove an account and sign it out everywhere user-rm <user> remove an account and sign it out everywhere
check verify the config and state load, then exit check verify the config and state load, then exit
version version
@@ -129,6 +132,10 @@ func main() {
if err := passwd(cfg, flag.Arg(1)); err != nil { if err := passwd(cfg, flag.Arg(1)); err != nil {
log.Fatal(err) log.Fatal(err)
} }
case "zfs-snapshot":
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
log.Fatal(err)
}
case "users": case "users":
migrateUsers(cfg) migrateUsers(cfg)
if len(cfg.Users) == 0 { if len(cfg.Users) == 0 {
@@ -178,6 +185,58 @@ func main() {
// migrateUsers gives a pre-users config its first account, named after this OS user, so // migrateUsers gives a pre-users config its first account, named after this OS user, so
// the CLI sees the same accounts the daemon will. // the CLI sees the same accounts the daemon will.
// zfsSnapshot runs one snapshot schedule: take it, then expire what has aged out. This is
// what the cron line Ham Lab writes actually calls, so a schedule keeps running whether or
// not the console is up — and keeps running if Ham Lab is never opened again.
//
// It deliberately does not write state.json. The daemon owns that file and would clobber
// anything written from out here; the outcome goes beside it instead, in zfs-runs/.
func zfsSnapshot(cfg *config.Config, id string) error {
if strings.TrimSpace(id) == "" {
return errors.New("usage: hamlab zfs-snapshot <schedule-id>")
}
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
if err != nil {
return err
}
var raw json.RawMessage
st.View(func(s *store.State) { raw = s.ZFS })
var z struct {
Policies []storage.Policy `json:"policies"`
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &z); err != nil {
return fmt.Errorf("reading the schedules: %w", err)
}
}
var p *storage.Policy
for i := range z.Policies {
if z.Policies[i].ID == id {
p = &z.Policies[i]
}
}
if p == nil {
return fmt.Errorf("no snapshot schedule %q — it may have been removed; this cron line is stale", id)
}
res, runErr := storage.New(priv.Available()).Run(context.Background(), p)
rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned}
if runErr != nil {
rec.Error = runErr.Error()
}
if err := storage.WriteRun(cfg.DataDir(), id, rec); err != nil {
fmt.Fprintln(os.Stderr, "recording the run:", err)
}
if runErr != nil {
return runErr
}
fmt.Printf("%s: took %s", p.Name, res.Taken)
if len(res.Pruned) > 0 {
fmt.Printf(", expired %d", len(res.Pruned))
}
fmt.Println()
return nil
}
func migrateUsers(cfg *config.Config) { func migrateUsers(cfg *config.Config) {
name := os.Getenv("USER") name := os.Getenv("USER")
if u, err := user.Current(); err == nil && u.Username != "" { if u, err := user.Current(); err == nil && u.Username != "" {
-2
View File
@@ -137,7 +137,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
go s.keepFleetInStep() go s.keepFleetInStep()
} }
go s.watchForUpdates(context.Background()) go s.watchForUpdates(context.Background())
go s.runSchedules(context.Background())
m := http.NewServeMux() m := http.NewServeMux()
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
@@ -255,7 +254,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview)) m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview))
m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut)) m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut))
m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete)) 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/schedules/{id}/run", s.auth(s.zfsRunNow))
m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete)) 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", s.auth(s.spaceOverview))
+35 -74
View File
@@ -16,8 +16,9 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"sync"
"time" "time"
"hamlab/internal/storage" "hamlab/internal/storage"
@@ -28,11 +29,6 @@ type zfsState struct {
Policies []storage.Policy `json:"policies"` Policies []storage.Policy `json:"policies"`
} }
type zfsEngine struct {
s *Server
mu sync.Mutex
}
func (s *Server) zfsLoad() zfsState { func (s *Server) zfsLoad() zfsState {
var raw json.RawMessage var raw json.RawMessage
s.store.View(func(st *store.State) { raw = st.ZFS }) s.store.View(func(st *store.State) { raw = st.ZFS })
@@ -99,45 +95,22 @@ func randomID() (string, error) {
return hex.EncodeToString(b), nil return hex.EncodeToString(b), nil
} }
// ---- the schedule runs itself ---- // ---- the cron file ----
//
// A schedule is a cron line, written to /etc/cron.d/hamlab-zfs, which Ham Lab owns and
// rewrites whole whenever the schedules change. Nothing in the daemon runs them: snapshots
// are backup-shaped and should not stop because a web console is down, being upgraded, or
// uninstalled. The line calls `hamlab zfs-snapshot <id>` as the account Ham Lab runs as,
// which reaches zfs through sudo exactly as the daemon does.
// runSchedules takes the snapshots that are due. Every 30 s, which is fine for a schedule // syncCron puts the current schedules on the machine.
// whose finest grain is hourly, and cheap when nothing is due. func (s *Server) syncCron(ctx context.Context, z zfsState) error {
func (s *Server) runSchedules(ctx context.Context) { exe, err := os.Executable()
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 { if err != nil {
p.LastError = err.Error() return err
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)
}
}
} }
exe, _ = filepath.EvalSymlinks(exe)
return s.storage.WriteCronFile(ctx, z.Policies, s.user, exe, s.cfg.Path())
} }
// ---- endpoints ---- // ---- endpoints ----
@@ -145,12 +118,16 @@ func (s *Server) runSchedules(ctx context.Context) {
func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) { func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) {
z := s.zfsLoad() z := s.zfsLoad()
now := time.Now() now := time.Now()
dir := s.cfg.DataDir()
out := make([]map[string]any, 0, len(z.Policies)) out := make([]map[string]any, 0, len(z.Policies))
for i := range z.Policies { for i := range z.Policies {
p := z.Policies[i] p := z.Policies[i]
m := map[string]any{"policy": p} expr, _ := p.CronExpr()
m := map[string]any{"policy": p, "cron": expr, "last": storage.ReadRun(dir, p.ID)}
if p.Enabled { if p.Enabled {
m["next"] = p.NextRun(now) if n := p.NextRun(now); !n.IsZero() {
m["next"] = n
}
} }
out = append(out, m) out = append(out, m)
} }
@@ -173,7 +150,6 @@ func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) {
found := false found := false
for i := range z.Policies { for i := range z.Policies {
if z.Policies[i].ID == p.ID && p.ID != "" { 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 z.Policies[i] = p
found = true found = true
} }
@@ -193,7 +169,12 @@ func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
} }
log.Printf("zfs schedule %q saved by %s (%s, %s, keep %s)", p.Name, clientIP(r), p.Dataset, p.Every, p.KeepFor) if err := s.syncCron(r.Context(), z); err != nil {
jsonErr(w, 500, "saved, but writing "+storage.CronFile+" failed: "+err.Error())
return
}
expr, _ := p.CronExpr()
log.Printf("zfs schedule %q saved by %s (%s, %q, keep %s)", p.Name, clientIP(r), p.Dataset, expr, p.KeepFor)
jsonOK(w, 200, p) jsonOK(w, 200, p)
} }
@@ -218,6 +199,11 @@ func (s *Server) zfsPolicyDelete(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
} }
if err := s.syncCron(r.Context(), z); err != nil {
jsonErr(w, 500, "removed, but writing "+storage.CronFile+" failed: "+err.Error())
return
}
storage.ForgetRun(s.cfg.DataDir(), id)
log.Printf("zfs schedule %s removed by %s (its snapshots are left alone)", id, clientIP(r)) log.Printf("zfs schedule %s removed by %s (its snapshots are left alone)", id, clientIP(r))
w.WriteHeader(204) w.WriteHeader(204)
} }
@@ -231,24 +217,6 @@ func (s *Server) zfsPolicy(id string) (storage.Policy, bool) {
return storage.Policy{}, false 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) { func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
p, ok := s.zfsPolicy(id) p, ok := s.zfsPolicy(id)
@@ -257,18 +225,11 @@ func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) {
return return
} }
res, err := s.storage.Run(r.Context(), &p) res, err := s.storage.Run(r.Context(), &p)
now := time.Now() rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned}
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 { if err != nil {
z.Policies[i].LastError = err.Error() rec.Error = err.Error()
} }
} _ = storage.WriteRun(s.cfg.DataDir(), id, rec)
}
_ = s.zfsSave(z)
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
+11
View File
@@ -132,6 +132,17 @@ func (s *schedule) dayMatches(t time.Time) bool {
} }
} }
// NextRun is the first time strictly after `from` that a cron expression fires. Exported so
// anything that writes a cron line — the ZFS snapshot schedules — can say when it runs next
// using the same evaluator that reads everyone else's.
func NextRun(expr string, from time.Time) (time.Time, bool) { return nextRun(expr, from) }
// ValidSchedule reports whether a cron expression parses.
func ValidSchedule(expr string) error {
_, err := parseSchedule(expr)
return err
}
// nextRun finds the first time strictly after `from` that the schedule fires. // nextRun finds the first time strictly after `from` that the schedule fires.
func nextRun(expr string, from time.Time) (time.Time, bool) { func nextRun(expr string, from time.Time) (time.Time, bool) {
s, err := parseSchedule(expr) s, err := parseSchedule(expr)
+182 -57
View File
@@ -18,13 +18,21 @@ package storage
// to it. And every policy can be asked what it would delete, before it deletes anything. // to it. And every policy can be asked what it would delete, before it deletes anything.
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"os/exec"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"hamlab/internal/cron"
"hamlab/internal/priv"
) )
// stampLayout is zfSnap's timestamp: 2026-09-24_02.30.01. // stampLayout is zfSnap's timestamp: 2026-09-24_02.30.01.
@@ -41,12 +49,15 @@ type Policy struct {
Dataset string `json:"dataset"` Dataset string `json:"dataset"`
// Recursive takes one atomic snapshot of the dataset and everything under it (zfs -r). // Recursive takes one atomic snapshot of the dataset and everything under it (zfs -r).
Recursive bool `json:"recursive"` Recursive bool `json:"recursive"`
// Every is hourly | daily | weekly | monthly. At is "HH:MM" (the minute alone is used // Every is hourly | daily | weekly | monthly | custom. At is "HH:MM" (the minute alone
// for hourly). Weekday is 0=Sunday, Day is 1–28. // is used for hourly). Weekday is 0=Sunday, Day is 1–28. With "custom", Cron is a plain
// cron expression and the rest is ignored — the escape hatch for anything the four
// common shapes cannot say.
Every string `json:"every"` Every string `json:"every"`
At string `json:"at"` At string `json:"at"`
Weekday int `json:"weekday"` Weekday int `json:"weekday"`
Day int `json:"day"` Day int `json:"day"`
Cron string `json:"cron,omitempty"`
// KeepFor is how long a snapshot lives: "6w", "30d", "12h". It is written into the // 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. // 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 // KeepMin is a floor — that many newest snapshots survive however old they are, so a
@@ -56,10 +67,6 @@ type Policy struct {
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
Enabled bool `json:"enabled"` 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) } func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) }
@@ -74,8 +81,15 @@ func (p *Policy) Validate() error {
} }
switch p.Every { switch p.Every {
case "hourly", "daily", "weekly", "monthly": case "hourly", "daily", "weekly", "monthly":
case "custom":
if strings.TrimSpace(p.Cron) == "" {
return errors.New("a custom schedule needs a cron expression")
}
default: default:
return errors.New("every: hourly, daily, weekly or monthly") return errors.New("every: hourly, daily, weekly, monthly or custom")
}
if _, err := p.CronExpr(); err != nil {
return err
} }
if _, _, err := p.clock(); err != nil { if _, _, err := p.clock(); err != nil {
return err return err
@@ -144,67 +158,50 @@ func ParseAge(s string) (time.Duration, error) {
return 0, fmt.Errorf("%q: the unit is m, h, d or w", s) 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. // CronExpr renders the schedule as a cron expression — which is what actually runs it. The
// A policy whose LastRun is older than that is due — so a machine that was off through // four common shapes are sugar over this; a custom policy states it outright. One expression
// its window takes one snapshot when it comes back, rather than skipping silently. // is the only description of when a schedule fires, so the page, the cron file and the next
func (p *Policy) lastDue(now time.Time) time.Time { // run can never disagree.
hour, min, _ := p.clock() func (p *Policy) CronExpr() (string, error) {
y, mo, d := now.Date() if p.Every == "custom" {
loc := now.Location() expr := strings.Join(strings.Fields(p.Cron), " ")
if err := cron.ValidSchedule(expr); err != nil {
return "", fmt.Errorf("cron expression: %w", err)
}
if strings.HasPrefix(expr, "@reboot") {
return "", errors.New("@reboot is not a schedule for snapshots")
}
return expr, nil
}
hour, min, err := p.clock()
if err != nil {
return "", err
}
switch p.Every { switch p.Every {
case "hourly": case "hourly":
t := time.Date(y, mo, d, now.Hour(), min, 0, 0, loc) return fmt.Sprintf("%d * * * *", min), nil
if t.After(now) {
t = t.Add(-time.Hour)
}
return t
case "daily": case "daily":
t := time.Date(y, mo, d, hour, min, 0, 0, loc) return fmt.Sprintf("%d %d * * *", min, hour), nil
if t.After(now) {
t = t.AddDate(0, 0, -1)
}
return t
case "weekly": case "weekly":
t := time.Date(y, mo, d, hour, min, 0, 0, loc) return fmt.Sprintf("%d %d * * %d", min, hour, p.Weekday), nil
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": case "monthly":
t := time.Date(y, mo, p.Day, hour, min, 0, 0, loc) return fmt.Sprintf("%d %d %d * *", min, hour, p.Day), nil
if t.After(now) {
t = t.AddDate(0, -1, 0)
} }
return t return "", errors.New("unknown schedule")
}
return time.Time{}
} }
// NextRun is when this policy runs next, for the UI to show. // NextRun is when cron will next fire this schedule, read with the same evaluator the Jobs
// tab uses on everyone else's lines.
func (p *Policy) NextRun(now time.Time) time.Time { func (p *Policy) NextRun(now time.Time) time.Time {
last := p.lastDue(now) expr, err := p.CronExpr()
switch p.Every { if err != nil {
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{} 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) t, ok := cron.NextRun(expr, now)
return !d.IsZero() && p.LastRun.Before(d) if !ok {
return time.Time{}
}
return t
} }
// SnapTime reads a snapshot's name in zfSnap's convention and returns when it was taken, // SnapTime reads a snapshot's name in zfSnap's convention and returns when it was taken,
@@ -349,3 +346,131 @@ func (m *Manager) Run(ctx context.Context, p *Policy) (RunResult, error) {
} }
return res, nil return res, nil
} }
// ---- what happened when it ran ----
//
// The policies are a definition the daemon owns; what a run did is written by whatever ran
// it, which is a cron job in its own process. Keeping the two apart means the CLI never has
// to write state.json behind the daemon's back — the last writer would win and one of them
// would lose its work.
// RunRecord is the outcome of one run of one schedule.
type RunRecord struct {
At time.Time `json:"at"`
Taken string `json:"taken,omitempty"`
Pruned []string `json:"pruned,omitempty"`
Error string `json:"error,omitempty"`
}
func runPath(dir, id string) string { return filepath.Join(dir, "zfs-runs", id+".json") }
// WriteRun records what a run did, beside the config.
func WriteRun(dir, id string, r RunRecord) error {
if err := os.MkdirAll(filepath.Join(dir, "zfs-runs"), 0o700); err != nil {
return err
}
b, err := json.Marshal(r)
if err != nil {
return err
}
p := runPath(dir, id)
tmp := p + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, p)
}
// ReadRun is the last thing a schedule did, or the zero value if it has never run.
func ReadRun(dir, id string) RunRecord {
var r RunRecord
b, err := os.ReadFile(runPath(dir, id))
if err == nil {
_ = json.Unmarshal(b, &r)
}
return r
}
// ForgetRun drops a schedule's record, for when the schedule itself goes.
func ForgetRun(dir, id string) { _ = os.Remove(runPath(dir, id)) }
// ---- the cron file ----
// CronFile is where Ham Lab writes the snapshot schedules. One file it owns entirely and
// rewrites whole, so there is never a half-updated set of lines, and so a schedule removed
// in the UI is removed from the machine.
const CronFile = "/etc/cron.d/hamlab-zfs"
// CronFileText renders every schedule as cron lines. user is who they run as — the account
// Ham Lab itself runs as, which reaches zfs through sudo exactly as the daemon does, so the
// files it writes stay owned by that account rather than by root.
func CronFileText(policies []Policy, user, exe, config string) (string, error) {
var b strings.Builder
b.WriteString("# Ham Lab snapshot schedules — written by the ZFS tab, rewritten whole on every change.\n")
b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n")
b.WriteString("SHELL=/bin/sh\n")
b.WriteString("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
for i := range policies {
p := &policies[i]
if !p.Enabled {
b.WriteString("# " + p.Name + " — paused\n")
continue
}
expr, err := p.CronExpr()
if err != nil {
return "", fmt.Errorf("%s: %w", p.Name, err)
}
name := strings.ReplaceAll(p.Name, "\n", " ")
fmt.Fprintf(&b, "# %s — %s, keep %s\n", name, p.Dataset, p.KeepFor)
fmt.Fprintf(&b, "%s %s %s -config %s zfs-snapshot %s\n\n", expr, user, exe, config, p.ID)
}
return b.String(), nil
}
// WriteCronFile puts the schedules on the machine, or removes the file when there are none.
func (m *Manager) WriteCronFile(ctx context.Context, policies []Policy, user, exe, config string) error {
live := false
for i := range policies {
if policies[i].Enabled {
live = true
}
}
if !live {
out, code, err := m.run(ctx, "rm", "-f", CronFile)
if err != nil {
return err
}
if code != 0 {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
text, err := CronFileText(policies, user, exe, config)
if err != nil {
return err
}
return m.writeRoot(ctx, CronFile, text)
}
// writeRoot puts text at a root-owned path, through the same privilege escalation every
// other write here uses.
func (m *Manager) writeRoot(ctx context.Context, path, text string) error {
args := []string{"tee", path}
if m.sudo {
args = priv.Wrap(args...)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
cmd.Stdin = strings.NewReader(text)
var errb bytes.Buffer
cmd.Stdout, cmd.Stderr = nil, &errb
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
return errors.New(msg)
}
return nil
}
+53 -25
View File
@@ -1,8 +1,11 @@
package storage package storage
import ( import (
"strings"
"testing" "testing"
"time" "time"
"hamlab/internal/cron"
) )
func TestSnapTimeReadsZfSnapNames(t *testing.T) { func TestSnapTimeReadsZfSnapNames(t *testing.T) {
@@ -33,39 +36,62 @@ func TestSnapTimeReadsZfSnapNames(t *testing.T) {
} }
} }
func TestScheduleIsDueAndNext(t *testing.T) { func TestCronExprAndNextRun(t *testing.T) {
now := time.Date(2026, 9, 24, 9, 15, 0, 0, time.Local) now := time.Date(2026, 9, 24, 9, 15, 0, 0, time.Local)
daily := &Policy{Every: "daily", At: "02:30", Enabled: true} for _, c := range []struct {
if d := daily.lastDue(now); d.Hour() != 2 || d.Day() != 24 { p Policy
t.Fatalf("daily lastDue = %v", d) want string
}{
{Policy{Every: "daily", At: "02:30"}, "30 2 * * *"},
{Policy{Every: "hourly", At: "00:30"}, "30 * * * *"},
{Policy{Every: "weekly", At: "04:00", Weekday: 0}, "0 4 * * 0"},
{Policy{Every: "monthly", At: "05:15", Day: 12}, "15 5 12 * *"},
{Policy{Every: "custom", Cron: "*/15 9-17 * * 1-5"}, "*/15 9-17 * * 1-5"},
} {
got, err := c.p.CronExpr()
if err != nil || got != c.want {
t.Errorf("CronExpr(%+v) = %q, %v; want %q", c.p.Every, got, err, c.want)
} }
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 // the next run is read with the same evaluator the Jobs tab uses
if daily.Due(now) { daily := Policy{Every: "daily", At: "02:30"}
t.Fatal("already ran since the window: not due") if n := daily.NextRun(now); n.Hour() != 2 || n.Minute() != 30 || n.Day() != 25 {
t.Fatalf("NextRun = %v, want 25th 02:30", n)
} }
if n := daily.NextRun(now); n.Day() != 25 || n.Hour() != 2 { // a custom expression that does not parse is refused, not silently ignored
t.Fatalf("daily NextRun = %v", n) if _, err := (&Policy{Every: "custom", Cron: "not a schedule"}).CronExpr(); err == nil {
t.Fatal("a bad cron expression must be refused")
} }
// a machine that was off through its window runs once when it comes back, not never if _, err := (&Policy{Every: "custom", Cron: "@reboot"}).CronExpr(); err == nil {
daily.LastRun = now.AddDate(0, 0, -2) t.Fatal("@reboot is not a snapshot schedule")
if !daily.Due(now) {
t.Fatal("a missed window must still be due")
} }
// disabled never runs }
daily.Enabled = false
if daily.Due(now) { func TestCronFileText(t *testing.T) {
t.Fatal("disabled must never be due") ps := []Policy{
{ID: "abc", Name: "tank nightly", Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Enabled: true},
{ID: "def", Name: "paused one", Dataset: "tank/apps", Every: "hourly", At: "00:05", KeepFor: "2d"},
} }
weekly := &Policy{Every: "weekly", At: "04:00", Weekday: int(time.Sunday), Enabled: true} text, err := CronFileText(ps, "core", "/usr/local/bin/hamlab", "/home/core/apps/hamlab/config.json")
if d := weekly.lastDue(now); d.Weekday() != time.Sunday || d.After(now) { if err != nil {
t.Fatalf("weekly lastDue = %v (%v)", d, d.Weekday()) t.Fatal(err)
}
if !strings.Contains(text, "30 2 * * * core /usr/local/bin/hamlab -config /home/core/apps/hamlab/config.json zfs-snapshot abc") {
t.Fatalf("the enabled schedule is missing or malformed:\n%s", text)
}
if strings.Contains(text, "zfs-snapshot def") {
t.Fatalf("a paused schedule must not be scheduled:\n%s", text)
}
// every line cron will read must parse as a schedule
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.Contains(line, "=") {
continue
}
f := strings.Fields(line)
if err := cron.ValidSchedule(strings.Join(f[:5], " ")); err != nil {
t.Errorf("line %q: %v", line, err)
} }
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)
} }
} }
@@ -77,6 +103,8 @@ func TestValidateRefusesNonsense(t *testing.T) {
bad := []Policy{ bad := []Policy{
{Dataset: "", Every: "daily", At: "02:30", KeepFor: "6w"}, {Dataset: "", Every: "daily", At: "02:30", KeepFor: "6w"},
{Dataset: "tank", Every: "yearly", At: "02:30", KeepFor: "6w"}, {Dataset: "tank", Every: "yearly", At: "02:30", KeepFor: "6w"},
{Dataset: "tank", Every: "custom", KeepFor: "6w"},
{Dataset: "tank", Every: "custom", Cron: "not a schedule", KeepFor: "6w"},
{Dataset: "tank", Every: "daily", At: "25:00", 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"}, // no retention
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable {Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable
+101 -45
View File
@@ -9,7 +9,7 @@
// disagree over. An existing cron job is detected and shown, so the page tells the truth // 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. // about the machine whether Ham Lab or something else is doing the work.
import { FormEvent, useCallback, useEffect, useState } from 'react' import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSSnapshot } from './api' import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
import { fmtBytes } from './fmt' import { fmtBytes } from './fmt'
import { useRefreshMs } from './prefs' import { useRefreshMs } from './prefs'
import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui' import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui'
@@ -26,7 +26,8 @@ const soon = (t?: string) => {
} }
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const describe = (p: ZFSPolicy) => { const describe = (p: ZFSPolicy) => {
const when = p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}` const when = p.every === 'custom' ? `on ${p.cron}`
: p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}`
: p.every === 'daily' ? `every day at ${p.at}` : p.every === 'daily' ? `every day at ${p.at}`
: p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}` : p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}`
: `on day ${p.day} of the month at ${p.at}` : `on day ${p.day} of the month at ${p.at}`
@@ -67,18 +68,43 @@ function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: ()
) )
} }
// A snapshot's name carries when it was taken and how long it lives — 2026-09-24_02.30.01--6w
// — so the explorer can say when each one goes without asking the server anything.
function snapMeta(full: string): { taken?: Date; expires?: Date } {
const snap = full.split('@')[1] ?? ''
const [body, ttl] = snap.split('--')
const m = /(\d{4})-(\d{2})-(\d{2})_(\d{2})\.(\d{2})\.(\d{2})$/.exec(body)
if (!m) return {}
const taken = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6])
const a = /^(\d+)([mhdw])$/.exec(ttl ?? '')
if (!a) return { taken }
const ms = { m: 60e3, h: 3600e3, d: 86400e3, w: 7 * 86400e3 }[a[2]]!
return { taken, expires: new Date(taken.getTime() + +a[1] * ms) }
}
// One dataset in the snapshot explorer: how many it has and what they cost, opening into
// the snapshots themselves, each of which can be taken now or deleted.
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) { function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null) const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const reload = useCallback(async () => {
try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) }
}, [d.name])
async function toggle() { async function toggle() {
setOpen(!open) setOpen(!open)
if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } } if (!open && !snaps) await reload()
} }
async function snapshot() { async function snapshot() {
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2') const def = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '.')
const name = await askText(`Snapshot name for ${d.name}@`, def) const name = await askText(`Snapshot name for ${d.name}@`, def)
if (!name) return if (!name) return
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() } try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); await reload(); onChanged() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
async function destroy(s: ZFSSnapshot) {
const short = s.name.split('@')[1]
if (!await askYes(`Delete ${s.name}? It is removed from this dataset and from every dataset beneath it that has a snapshot of the same name. This cannot be undone.`, 'Delete')) return
try { await api.deleteZfsSnapshot(s.name, true); toast(`${short} deleted`); await reload(); onChanged() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
} }
const depth = d.name.split('/').length - 1 const depth = d.name.split('/').length - 1
@@ -86,18 +112,29 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
<> <>
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}> <div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span> <span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
<span className="num">{fmtBytes(d.used)}</span> <span className="num">{d.snapshots}</span>
<span className="num muted">{fmtBytes(d.refer)}</span>
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span> <span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
<span className="num muted">{d.snapshots}</span> <span className="num muted">{fmtBytes(d.used)}</span>
<span className="num muted">{d.compressratio}</span> <span className="num muted">{fmtBytes(d.refer)}</span>
<span className="ds-mount muted">{d.mount}</span> <span className="ds-mount muted">{d.mount}</span>
</div> </div>
{open && ( {open && (
<div className="ds-snaps"> <div className="ds-snaps">
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted">avail {fmtBytes(d.avail)}</span></div> <div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted small">{d.snapshots} snapshot{d.snapshots === 1 ? '' : 's'} · {fmtBytes(d.avail)} free on this dataset</span></div>
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : ( {snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
<div className="snap-list">{snaps.slice().reverse().map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}</span></div>)}</div> <div className="snap-list">{snaps.slice().reverse().map((s) => {
const { taken, expires } = snapMeta(s.name)
const gone = expires && expires.getTime() < Date.now()
return (
<div key={s.name} className="snap">
<span className="mono snap-name" title={s.name}>{s.name.split('@')[1]}</span>
<span className="muted snap-when">{taken ? taken.toLocaleString() : new Date(s.created).toLocaleString()}</span>
<span className={'muted snap-exp' + (gone ? ' warn' : '')}>{expires ? (gone ? 'expired' : `expires ${expires.toLocaleDateString()}`) : 'kept'}</span>
<span className="num muted">{fmtBytes(s.used)}</span>
<Btn className="btn danger snap-del" onClick={() => destroy(s)}>Delete</Btn>
</div>
)
})}</div>
)} )}
</div> </div>
)} )}
@@ -107,7 +144,7 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
// ---- schedules ---- // ---- schedules ----
function ExternalNotice({ x, mine }: { x: ExternalJob; mine: number }) { function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; onAdopt: () => void }) {
return ( return (
<div className={'notice' + (mine > 0 ? ' attn' : '')} style={{ marginBottom: 8 }}> <div className={'notice' + (mine > 0 ? ' attn' : '')} style={{ marginBottom: 8 }}>
<b>{x.tool}</b> is already taking snapshots on this machine, from cron as <b>{x.user}</b>: <b>{x.tool}</b> is already taking snapshots on this machine, from cron as <b>{x.user}</b>:
@@ -115,25 +152,21 @@ function ExternalNotice({ x, mine }: { x: ExternalJob; mine: number }) {
<span className="muted">in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''}</span> <span className="muted">in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''}</span>
{mine > 0 {mine > 0
? <div className="warn" style={{ marginTop: 6 }}>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.</div> ? <div className="warn" style={{ marginTop: 6 }}>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.</div>
: <div className="muted small" style={{ marginTop: 6 }}>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.</div>} : <div className="svc-actions" style={{ marginTop: 6 }}>
<Btn className="btn" onClick={onAdopt}>Adopt as a schedule</Btn>
<span className="muted small">Copies it into a Ham Lab schedule you can edit here. Turn the old cron job off on the Jobs tab once you are happy with it.</span>
</div>}
</div> </div>
) )
} }
type ExternalJob = ZFSExternal type ExternalJob = ZFSExternal
function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: string; onChanged: () => void; onEdit: () => void }) { function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) {
const [expired, setExpired] = useState<ZFSSnapshot[] | null>(null)
const [busy, setBusy] = useState('') 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 run = async () => {
const n = expired?.length if (!await askYes(`Take a snapshot of ${p.dataset} now? Snapshots past their retention are removed at the same time, as they would be on the schedule.`, 'Run now')) return
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') setBusy('run')
try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); setExpired(null); onChanged() } try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); onChanged() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
} }
const remove = async () => { const remove = async () => {
@@ -146,7 +179,7 @@ function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: str
return ( return (
<section className="card"> <section className="card">
<div className="sub-head"> <div className="sub-head">
<span className={'dot ' + (p.last_error ? 'bad' : p.enabled ? 'ok' : '')} /> <span className={'dot ' + (last.error ? 'bad' : p.enabled ? 'ok' : '')} />
<span className="dname">{p.name || p.dataset}</span> <span className="dname">{p.name || p.dataset}</span>
{!p.enabled && <span className="badge">paused</span>} {!p.enabled && <span className="badge">paused</span>}
<span className="spacer" /> <span className="spacer" />
@@ -155,25 +188,13 @@ function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: str
<div className="props" style={{ marginTop: 6 }}> <div className="props" style={{ marginTop: 6 }}>
<div><span className="k">Dataset</span><span className="mono">{p.dataset}</span></div> <div><span className="k">Dataset</span><span className="mono">{p.dataset}</span></div>
<div><span className="k">When</span><span>{describe(p)}</span></div> <div><span className="k">When</span><span>{describe(p)}</span></div>
<div><span className="k">Last run</span><span>{ago(p.last_run)}{p.pruned ? ` · ${p.pruned} expired removed` : ''}</span></div> <div><span className="k">Cron</span><span className="mono small" title={`in ${'/etc/cron.d/hamlab-zfs'}`}>{cron}</span></div>
{p.last_taken && <div><span className="k">Last snapshot</span><span className="mono small">{p.last_taken.split('@')[1]}</span></div>} <div><span className="k">Last run</span><span>{ago(last.at)}{last.pruned?.length ? ` · ${last.pruned.length} expired removed` : ''}</span></div>
{last.taken && <div><span className="k">Last snapshot</span><span className="mono small">{last.taken.split('@')[1]}</span></div>}
{p.keep_min > 0 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>} {p.keep_min > 0 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>}
</div> </div>
{p.last_error && <div className="error" style={{ marginTop: 8 }}>{p.last_error}</div>} {last.error && <div className="error" style={{ marginTop: 8 }}>{last.error}</div>}
{expired && (
<div className="notice" style={{ marginTop: 8 }}>
{expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : (
<>
<b>{expired.length}</b> snapshot{expired.length === 1 ? '' : 's'} would be destroyed by a run now:
<div className="snap-list" style={{ marginTop: 6, maxHeight: 180, overflow: 'auto' }}>
{expired.map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleDateString()} · {fmtBytes(s.used)}</span></div>)}
</div>
</>
)}
</div>
)}
<div className="svc-actions"> <div className="svc-actions">
<Btn className="btn" busy={busy === 'preview'} onClick={preview}>What would it delete?</Btn>
<Btn className="btn on" busy={busy === 'run'} onClick={run}>Run now</Btn> <Btn className="btn on" busy={busy === 'run'} onClick={run}>Run now</Btn>
<button className="btn" onClick={onEdit}>Edit</button> <button className="btn" onClick={onEdit}>Edit</button>
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn> <Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
@@ -210,14 +231,20 @@ function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial<ZFSPolic
</label> </label>
<label className="muted">How often <label className="muted">How often
<span className="chips"> <span className="chips">
{(['hourly', 'daily', 'weekly', 'monthly'] as const).map((k) => ( {(['hourly', 'daily', 'weekly', 'monthly', 'custom'] as const).map((k) => (
<button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button> <button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button>
))} ))}
</span> </span>
</label> </label>
{f.every === 'custom' ? (
<label className="muted">Cron expression — minute hour day month weekday
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 */6 * * *" spellCheck={false} required />
</label>
) : (
<label className="muted">At <label className="muted">At
<input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} /> <input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} />
</label> </label>
)}
{f.every === 'weekly' && ( {f.every === 'weekly' && (
<label className="muted">On <label className="muted">On
<select value={f.weekday} onChange={(e) => set('weekday', Number(e.target.value))}> <select value={f.weekday} onChange={(e) => set('weekday', Number(e.target.value))}>
@@ -230,6 +257,9 @@ function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial<ZFSPolic
<input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} /> <input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} />
</label> </label>
)} )}
<label className="muted">Snapshot name prefix — optional; the timestamp and retention follow it
<input value={f.prefix ?? ''} onChange={(e) => set('prefix', e.target.value)} placeholder="none" spellCheck={false} />
</label>
<label className="muted">Keep for — written into each snapshot's name, as zfSnap does it: 6w, 30d, 12h <label className="muted">Keep for — written into each snapshot's name, as zfSnap does it: 6w, 30d, 12h
<input value={f.keep_for} onChange={(e) => set('keep_for', e.target.value)} placeholder="6w" spellCheck={false} required /> <input value={f.keep_for} onChange={(e) => set('keep_for', e.target.value)} placeholder="6w" spellCheck={false} required />
</label> </label>
@@ -255,6 +285,26 @@ function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial<ZFSPolic
) )
} }
// adopt reads an existing cron job into the fields of a new schedule, so replacing it is a
// matter of checking the numbers rather than retyping them. zfSnap's own flags are read
// where they can be: -a <age> is the retention, -r is recursive.
function adopt(x: ExternalJob, fallbackDataset: string): Partial<ZFSPolicy> {
const cmd = x.command
const keep = /-a\s+(\d+[mhdwMy])/.exec(cmd)?.[1] ?? /--\s*(\d+[mhdw])\b/.exec(cmd)?.[1] ?? '6w'
const f = x.schedule.trim().split(/\s+/)
const min = Number(f[0]), hour = Number(f[1])
const simple = f.length === 5 && Number.isInteger(min) && Number.isInteger(hour) && f[2] === '*' && f[3] === '*' && f[4] === '*'
const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset
return {
...BLANK, name: `${x.tool} (adopted)`, dataset: ds,
recursive: /\s-\w*r/.test(cmd) || true,
every: simple ? 'daily' : 'custom',
at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30',
cron: simple ? '' : x.schedule.trim(),
keep_for: keep, keep_min: 1, enabled: true,
}
}
// ---- page ---- // ---- page ----
export default function ZFS() { export default function ZFS() {
@@ -286,14 +336,20 @@ export default function ZFS() {
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" /> <div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn> <Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
</div> </div>
{ov.external && <ExternalNotice x={ov.external} mine={mine} />} {ov.external && <ExternalNotice x={ov.external} mine={mine} onAdopt={() => setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />}
{ov.policies.length === 0 && !ov.external && <div className="notice">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.</div>} {ov.policies.length === 0 && !ov.external && <div className="notice">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.</div>}
<div className="cards">{ov.policies.map(({ policy, next }) => ( <div className="cards">{ov.policies.map(({ policy, cron, next, last }) => (
<ScheduleCard key={policy.id} p={policy} next={next} onChanged={load} onEdit={() => setEditing(policy)} /> <ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
))}</div> ))}</div>
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Datasets</h2><span className="spacer" /><span className="muted small">used · referenced · in snapshots · count · compression · mount</span></div> <div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshots</h2><span className="spacer" />
<span className="muted small">{zfs.datasets.reduce((a, d) => a + d.snapshots, 0)} across {zfs.datasets.length} datasets · open one to browse, take or delete</span>
</div>
<section className="card ds-table"> <section className="card ds-table">
<div className="ds-row head">
<span>Dataset</span><span className="num">Snapshots</span><span className="num">They use</span>
<span className="num">Dataset uses</span><span className="num">Referenced</span><span>Mount</span>
</div>
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)} {zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
</section> </section>
+3 -3
View File
@@ -133,9 +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 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 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 ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: 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 ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; cron: string; next?: string; last: ZFSRun }[]; 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 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 } export interface FileInfo extends FileEntry { path: string; text: boolean }
@@ -186,7 +187,6 @@ export const api = {
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'), zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p), saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`), deleteZfsSchedule: (id: string) => req<void>('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`), runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
deleteZfsSnapshot: (name: string, recursive: boolean) => req<void>('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }), deleteZfsSnapshot: (name: string, recursive: boolean) => req<void>('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }),
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`), zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
+9 -2
View File
@@ -285,14 +285,21 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
the fallback above stays a fallback. */ the fallback above stays a fallback. */
.disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); } .disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); }
.datasets { padding: 0; overflow-x: auto; } .datasets { padding: 0; overflow-x: auto; }
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } /* the columns and gaps add up to 692: at 640 the row ended before its own cells did */ .ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 90px 90px 90px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; }
.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; } .ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
.ds-row .num { text-align: right; } .ds-row .num { text-align: right; }
.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; } .ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; } .ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; }
.snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; } .snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; }
.snap { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; } .snap { display: grid; grid-template-columns: minmax(150px, 1fr) 150px 120px 70px auto; gap: 10px; align-items: center; font-size: 12px; padding: 2px 0; border-bottom: 1px solid var(--border); }
.snap .snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.snap .snap-when, .snap .snap-exp { white-space: nowrap; }
.snap .snap-exp.warn { color: var(--s2); }
.snap .num { text-align: right; }
.snap .snap-del { padding: 0 6px; font-size: 11px; opacity: .55; }
.snap:hover .snap-del { opacity: 1; }
@media (max-width: 800px) { .snap { grid-template-columns: minmax(120px, 1fr) auto auto; } .snap .snap-when, .snap .snap-exp { display: none; } }
/* files */ /* files */
.files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); } .files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }