Pools, datasets and snapshots move out of Disks — Disks is the hardware, this is the filesystem on top of it. New: snapshot schedules, hourly to monthly, with a retention period and a floor that keeps the newest few whatever their age. The naming is zfSnap's, deliberately. grid has run zfSnap from root cron since before this repo existed and it works, so rather than invent a second convention this writes and reads exactly what zfSnap does: 2026-09-24_02.30.01--6w, with the retention in the name. Ham Lab can therefore show and expire the snapshots zfSnap already took, the two cannot disagree about what a snapshot is, and changing a schedule never re-dates old ones — each carries the rule it was taken under. An existing cron job is detected and shown on the page, with a warning if a Ham Lab schedule is running as well. Nothing is destroyed unseen. Every schedule answers what it would delete, with the list, before Run now touches anything, and only names that parse as a stamp and a TTL are ever candidates. A snapshot taken by hand is invisible to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
4.2 KiB
Go
144 lines
4.2 KiB
Go
// Package store persists small runtime state (terminal names, login sessions)
|
|
// to a JSON file with atomic writes.
|
|
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Terminal struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// Session is one signed-in browser. The map key is the cookie's token; ID is what the
|
|
// UI sees and revokes by, so a session list never shows a usable token.
|
|
type Session struct {
|
|
ID string `json:"id"`
|
|
User string `json:"user"`
|
|
Expires time.Time `json:"expires"`
|
|
Created time.Time `json:"created,omitempty"`
|
|
LastSeen time.Time `json:"last_seen,omitempty"`
|
|
IP string `json:"ip,omitempty"`
|
|
Agent string `json:"agent,omitempty"`
|
|
}
|
|
|
|
// UnmarshalJSON also reads the pre-users form, a bare expiry time: such a session has no
|
|
// user yet; the server gives it to the migrated first account.
|
|
func (s *Session) UnmarshalJSON(b []byte) error {
|
|
if len(b) > 0 && b[0] == '"' {
|
|
return json.Unmarshal(b, &s.Expires)
|
|
}
|
|
type plain Session
|
|
return json.Unmarshal(b, (*plain)(s))
|
|
}
|
|
|
|
// UserState is what follows an account rather than the machine: how the console looks
|
|
// to that person and how their dashboard is laid out.
|
|
type UserState struct {
|
|
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
|
}
|
|
|
|
type State struct {
|
|
Terminals []Terminal `json:"terminals"`
|
|
Sessions map[string]Session `json:"sessions"` // token -> session
|
|
Users map[string]*UserState `json:"users,omitempty"`
|
|
// 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"`
|
|
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
|
|
}
|
|
|
|
// UserState returns the prefs of an account, making an empty one if needed.
|
|
func (st *State) UserState(name string) *UserState {
|
|
if st.Users == nil {
|
|
st.Users = map[string]*UserState{}
|
|
}
|
|
u := st.Users[name]
|
|
if u == nil {
|
|
u = &UserState{}
|
|
st.Users[name] = u
|
|
}
|
|
return u
|
|
}
|
|
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
path string
|
|
state State
|
|
}
|
|
|
|
func Open(path string) (*Store, error) {
|
|
s := &Store{path: path}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
if err == nil {
|
|
// State is conveniences (sessions, terminal names, layout). A file mangled by a power
|
|
// cut must not keep the console down: set it aside and start clean.
|
|
if err := json.Unmarshal(b, &s.state); err != nil {
|
|
s.state = State{}
|
|
_ = os.Rename(path, path+".corrupt")
|
|
log.Printf("state: %s unreadable (%v); kept as %s.corrupt, starting fresh", path, err, path)
|
|
}
|
|
}
|
|
if s.state.Sessions == nil {
|
|
s.state.Sessions = map[string]Session{}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Update applies fn under the lock and writes the result to disk.
|
|
func (s *Store) Update(fn func(*State)) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
fn(&s.state)
|
|
return s.save()
|
|
}
|
|
|
|
// View reads state under the lock.
|
|
func (s *Store) View(fn func(*State)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
fn(&s.state)
|
|
}
|
|
|
|
func (s *Store) save() error {
|
|
b, err := json.MarshalIndent(&s.state, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return WriteFileAtomic(s.path, b, 0o600)
|
|
}
|
|
|
|
// WriteFileAtomic writes beside path, syncs, then renames: after a crash the file is the
|
|
// old version or the new one, never empty or half-written.
|
|
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
|
tmp := path + ".tmp"
|
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = f.Write(data)
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
if cerr := f.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
if err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|