Files
hamlab/internal/store/store.go
T
Mitchell-JohnsonandClaude Fable 5.1 668e716601 Top wears the skin; the refresh rate reaches every machine; bug bash 4
The process table's columns fit the box they are in, the rate control saves, curves are
timed on the viewer's clock and keep to the stride, the power icon is drawn, and a young
history draws in from the right instead of being stretched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 10:12:45 -07:00

100 lines
2.7 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"`
}
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)
}