Files
hamlab/internal/store/store.go
T
Mitchell-JohnsonandClaude Opus 5.5 4b238ea93e Remove the KVM tab: proxy, routes, config and state fields, UI
Not needed — the KVMs are reached over WireGuard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:52:38 -08:00

170 lines
5.5 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
}
// RememberedMount is one bind mount of a container that was running when Ham Lab last
// looked. A compose stack records the path its file had *inside* whatever deployed it, so
// when that container is gone the path is the only way back to the file on disk.
type RememberedMount struct {
Container string `json:"container"`
Dest string `json:"dest"` // the path inside the container
Src string `json:"src"` // the path on the machine
Seen time.Time `json:"seen"`
}
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"` // rules, channels and recent events (internal/api/alerts.go)
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go)
DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go)
Network json.RawMessage `json:"network,omitempty"` // speed test results (internal/api/network.go)
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
// orchestrator that has since been removed can still be found on disk. See
// internal/api/stacks.go.
Mounts []RememberedMount `json:"mounts,omitempty"`
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
// Transcode is where videos played in the Files viewer are made (internal/api/play.go):
// a machine and a device on it. nil is the machine the file is on, its best device.
Transcode *Transcode `json:"transcode,omitempty"`
}
// Transcode names a device on a machine: Node "" is the hub itself; Device is a render node
// or "cpu".
type Transcode struct {
Node string `json:"node"`
Device string `json:"device"`
}
// 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)
}