// 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"` } type State struct { Terminals []Terminal `json:"terminals"` Sessions map[string]time.Time `json:"sessions"` // token -> expiry Dashboard json.RawMessage `json:"dashboard,omitempty"` // UI layout prefs, opaque to the server UI json.RawMessage `json:"ui,omitempty"` // theme etc., opaque to the server Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go) 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 } 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]time.Time{} } 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) }