refactor(core): remove store global singleton, thread store explicitly

Remove store.Get()/SetGlobal()/Current. Store is now passed explicitly
to all consumers via constructor parameters and function arguments.

- TUI Model holds store field, set via InitialModel(isAdmin, store)
- monitor.StartEngine(s) and InitHistoryFromStore(s) accept store
- server.Start(cfg, s) closes over store in HTTP handlers
- main.go threads store to SSH server, TUI, monitor, server
- isKeyAllowed receives store as parameter

No more hidden dependency on package-level mutable state in store pkg.
Monitor package still uses package-level state (LiveState, etc.) — will
be encapsulated into Engine struct in Phase 7.
This commit is contained in:
2026-05-15 00:45:07 -04:00
parent d4f4012c8a
commit a6bb9a7aff
9 changed files with 62 additions and 94 deletions
+10 -12
View File
@@ -97,8 +97,6 @@ func main() {
fmt.Printf("Database init error: %v\n", err) fmt.Printf("Database init error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
store.SetGlobal(s)
if *demo { if *demo {
seedDemoData(s) seedDemoData(s)
} }
@@ -117,15 +115,15 @@ func main() {
fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version)
} }
monitor.InitHistoryFromStore() monitor.InitHistoryFromStore(s)
monitor.StartEngine() monitor.StartEngine(s)
server.Start(server.ServerConfig{ server.Start(server.ServerConfig{
Port: httpPort, Port: httpPort,
EnableStatus: enableStatus, EnableStatus: enableStatus,
Title: statusTitle, Title: statusTitle,
ClusterKey: clusterKey, ClusterKey: clusterKey,
}) }, s)
cluster.Start(cluster.Config{ cluster.Start(cluster.Config{
Mode: clusterMode, Mode: clusterMode,
@@ -133,10 +131,10 @@ func main() {
SharedKey: clusterKey, SharedKey: clusterKey,
}) })
startSSHServer(*port) startSSHServer(*port, s)
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
p := tea.NewProgram(tui.InitialModel(true), tea.WithAltScreen(), tea.WithMouseCellMotion()) p := tea.NewProgram(tui.InitialModel(true, s), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Printf("Error: %v\n", err) fmt.Printf("Error: %v\n", err)
} }
@@ -149,16 +147,16 @@ func main() {
} }
} }
func startSSHServer(port int) { func startSSHServer(port int, db store.Store) {
s, err := wish.NewServer( s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf(":%d", port)), wish.WithAddress(fmt.Sprintf(":%d", port)),
wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithHostKeyPath(".ssh/id_ed25519"),
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
return isKeyAllowed(key) return isKeyAllowed(db, key)
}), }),
wish.WithMiddleware( wish.WithMiddleware(
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return tui.InitialModel(false), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} return tui.InitialModel(false, db), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
}), }),
), ),
) )
@@ -206,8 +204,8 @@ func seedDemoData(s store.Store) {
s.AddSite(models.Site{Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7}) s.AddSite(models.Site{Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7})
} }
func isKeyAllowed(incomingKey ssh.PublicKey) bool { func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool {
users, err := store.Get().GetAllUsers() users, err := db.GetAllUsers()
if err != nil { if err != nil {
return false return false
} }
+3 -7
View File
@@ -20,11 +20,7 @@ var (
historyMu sync.RWMutex historyMu sync.RWMutex
) )
func InitHistoryFromStore() { func InitHistoryFromStore(s store.Store) {
s := store.Get()
if s == nil {
return
}
all, err := s.LoadAllHistory(maxHistoryLen) all, err := s.LoadAllHistory(maxHistoryLen)
if err != nil { if err != nil {
AddLog("Failed to load check history: " + err.Error()) AddLog("Failed to load check history: " + err.Error())
@@ -74,8 +70,8 @@ func RecordCheck(siteID int, latency time.Duration, isUp bool) {
h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:] h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:]
} }
if s := store.Get(); s != nil { if db != nil {
go func() { _ = s.SaveCheck(siteID, latency.Nanoseconds(), isUp) }() go func() { _ = db.SaveCheck(siteID, latency.Nanoseconds(), isUp) }()
} }
} }
+7 -11
View File
@@ -55,6 +55,8 @@ var (
insecureSkipVerify bool insecureSkipVerify bool
db store.Store
strictClient = &http.Client{ strictClient = &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
} }
@@ -119,16 +121,11 @@ func RecordHeartbeat(token string) bool {
return true return true
} }
func StartEngine() { func StartEngine(s store.Store) {
db = s
go func() { go func() {
for { for {
s_instance := store.Get() sites, err := db.GetSites()
if s_instance == nil {
time.Sleep(1 * time.Second)
continue
}
sites, err := s_instance.GetSites()
if err != nil { if err != nil {
AddLog(fmt.Sprintf("Failed to load sites: %v", err)) AddLog(fmt.Sprintf("Failed to load sites: %v", err))
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
@@ -407,11 +404,10 @@ func handleStatusChange(site models.Site, rawStatus string, code int, latency ti
} }
func triggerAlert(alertID int, title, message string) { func triggerAlert(alertID int, title, message string) {
s_instance := store.Get() if db == nil {
if s_instance == nil {
return return
} }
cfg, err := s_instance.GetAlert(alertID) cfg, err := db.GetAlert(alertID)
if err != nil { if err != nil {
return return
} }
+4 -4
View File
@@ -148,7 +148,7 @@ type ServerConfig struct {
ClusterKey string // Shared Secret for Security ClusterKey string // Shared Secret for Security
} }
func Start(cfg ServerConfig) { func Start(cfg ServerConfig, s store.Store) {
if cfg.ClusterKey == "" { if cfg.ClusterKey == "" {
fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.") fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
} }
@@ -185,7 +185,7 @@ func Start(cfg ServerConfig) {
http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401) http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401)
return return
} }
data, err := store.Get().ExportData() data, err := s.ExportData()
if err != nil { if err != nil {
log.Printf("Export failed: %v", err) log.Printf("Export failed: %v", err)
http.Error(w, "Export failed", 500) http.Error(w, "Export failed", 500)
@@ -209,7 +209,7 @@ func Start(cfg ServerConfig) {
http.Error(w, "Invalid JSON", 400) http.Error(w, "Invalid JSON", 400)
return return
} }
if err := store.Get().ImportData(data); err != nil { if err := s.ImportData(data); err != nil {
log.Printf("Import failed: %v", err) log.Printf("Import failed: %v", err)
http.Error(w, "Import failed", 500) http.Error(w, "Import failed", 500)
return return
@@ -234,7 +234,7 @@ func Start(cfg ServerConfig) {
return return
} }
backup := importer.ConvertKuma(&kb) backup := importer.ConvertKuma(&kb)
if err := store.Get().ImportData(backup); err != nil { if err := s.ImportData(backup); err != nil {
log.Printf("Kuma import failed: %v", err) log.Printf("Kuma import failed: %v", err)
http.Error(w, "Import failed", 500) http.Error(w, "Import failed", 500)
return return
-10
View File
@@ -35,13 +35,3 @@ type Store interface {
ExportData() (models.Backup, error) ExportData() (models.Backup, error)
ImportData(data models.Backup) error ImportData(data models.Backup) error
} }
var Current Store
func SetGlobal(s Store) {
Current = s
}
func Get() Store {
return Current
}
+2 -3
View File
@@ -3,7 +3,6 @@ package tui
import ( import (
"fmt" "fmt"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
"go-upkeep/internal/store"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
@@ -278,11 +277,11 @@ func (m *Model) submitAlertForm() {
} }
if m.editID > 0 { if m.editID > 0 {
if err := store.Get().UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil { if err := m.store.UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil {
monitor.AddLog("Update alert failed: " + err.Error()) monitor.AddLog("Update alert failed: " + err.Error())
} }
} else { } else {
if err := store.Get().AddAlert(d.Name, d.AlertType, settings); err != nil { if err := m.store.AddAlert(d.Name, d.AlertType, settings); err != nil {
monitor.AddLog("Add alert failed: " + err.Error()) monitor.AddLog("Add alert failed: " + err.Error())
} }
} }
+3 -6
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
"go-upkeep/internal/store"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
@@ -361,8 +360,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
} }
alertOpts := []huh.Option[string]{huh.NewOption("None", "0")} alertOpts := []huh.Option[string]{huh.NewOption("None", "0")}
if s := store.Get(); s != nil { if alerts, err := m.store.GetAllAlerts(); err == nil {
if alerts, err := s.GetAllAlerts(); err == nil {
for _, a := range alerts { for _, a := range alerts {
alertOpts = append(alertOpts, huh.NewOption( alertOpts = append(alertOpts, huh.NewOption(
fmt.Sprintf("%s (%s)", a.Name, a.Type), fmt.Sprintf("%s (%s)", a.Name, a.Type),
@@ -370,7 +368,6 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
)) ))
} }
} }
}
groupOpts := []huh.Option[string]{huh.NewOption("None", "0")} groupOpts := []huh.Option[string]{huh.NewOption("None", "0")}
for _, s := range m.sites { for _, s := range m.sites {
@@ -560,12 +557,12 @@ func (m *Model) submitSiteForm() {
} }
if m.editID > 0 { if m.editID > 0 {
if err := store.Get().UpdateSite(site); err != nil { if err := m.store.UpdateSite(site); err != nil {
monitor.AddLog("Update site failed: " + err.Error()) monitor.AddLog("Update site failed: " + err.Error())
} }
monitor.UpdateSiteConfig(site) monitor.UpdateSiteConfig(site)
} else { } else {
if err := store.Get().AddSite(site); err != nil { if err := m.store.AddSite(site); err != nil {
monitor.AddLog("Add site failed: " + err.Error()) monitor.AddLog("Add site failed: " + err.Error())
} }
} }
+2 -3
View File
@@ -3,7 +3,6 @@ package tui
import ( import (
"fmt" "fmt"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
"go-upkeep/internal/store"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
@@ -146,11 +145,11 @@ func (m *Model) initUserHuhForm() tea.Cmd {
func (m *Model) submitUserForm() { func (m *Model) submitUserForm() {
d := m.userFormData d := m.userFormData
if m.editID > 0 { if m.editID > 0 {
if err := store.Get().UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil { if err := m.store.UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil {
monitor.AddLog("Update user failed: " + err.Error()) monitor.AddLog("Update user failed: " + err.Error())
} }
} else { } else {
if err := store.Get().AddUser(d.Username, d.PublicKey, d.Role); err != nil { if err := m.store.AddUser(d.Username, d.PublicKey, d.Role); err != nil {
monitor.AddLog("Add user failed: " + err.Error()) monitor.AddLog("Add user failed: " + err.Error())
} }
} }
+9 -16
View File
@@ -68,6 +68,7 @@ type Model struct {
deleteTab int deleteTab int
collapsed map[int]bool collapsed map[int]bool
store store.Store
// harmonica animation state // harmonica animation state
pulseSpring harmonica.Spring pulseSpring harmonica.Spring
@@ -80,7 +81,7 @@ type Model struct {
users []models.User users []models.User
} }
func InitialModel(isAdmin bool) Model { func InitialModel(isAdmin bool, s store.Store) Model {
vpLogs := viewport.New(100, 20) vpLogs := viewport.New(100, 20)
vpLogs.SetContent("Waiting for logs...") vpLogs.SetContent("Waiting for logs...")
z := zone.New() z := zone.New()
@@ -90,6 +91,7 @@ func InitialModel(isAdmin bool) Model {
logViewport: vpLogs, logViewport: vpLogs,
maxTableRows: 5, maxTableRows: 5,
isAdmin: isAdmin, isAdmin: isAdmin,
store: s,
zones: z, zones: z,
pulseSpring: spring, pulseSpring: spring,
collapsed: make(map[int]bool), collapsed: make(map[int]bool),
@@ -107,26 +109,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() { switch keyMsg.String() {
case "y", "Y": case "y", "Y":
if s := store.Get(); s != nil {
switch m.deleteTab { switch m.deleteTab {
case 0: case 0:
if err := s.DeleteSite(m.deleteID); err != nil { if err := m.store.DeleteSite(m.deleteID); err != nil {
monitor.AddLog("Delete site failed: " + err.Error()) monitor.AddLog("Delete site failed: " + err.Error())
} }
monitor.RemoveSite(m.deleteID) monitor.RemoveSite(m.deleteID)
m.adjustCursor(len(m.sites) - 1) m.adjustCursor(len(m.sites) - 1)
case 1: case 1:
if err := s.DeleteAlert(m.deleteID); err != nil { if err := m.store.DeleteAlert(m.deleteID); err != nil {
monitor.AddLog("Delete alert failed: " + err.Error()) monitor.AddLog("Delete alert failed: " + err.Error())
} }
m.adjustCursor(len(m.alerts) - 1) m.adjustCursor(len(m.alerts) - 1)
case 3: case 3:
if err := s.DeleteUser(m.deleteID); err != nil { if err := m.store.DeleteUser(m.deleteID); err != nil {
monitor.AddLog("Delete user failed: " + err.Error()) monitor.AddLog("Delete user failed: " + err.Error())
} }
m.adjustCursor(len(m.users) - 1) m.adjustCursor(len(m.users) - 1)
} }
}
m.refreshData() m.refreshData()
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 3 { if m.deleteTab == 3 {
@@ -319,9 +319,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
site := m.sites[m.cursor] site := m.sites[m.cursor]
monitor.ToggleSitePause(site.ID) monitor.ToggleSitePause(site.ID)
site.Paused = !site.Paused site.Paused = !site.Paused
if s := store.Get(); s != nil { _ = m.store.UpdateSitePaused(site.ID, site.Paused)
_ = s.UpdateSitePaused(site.ID, site.Paused)
}
m.refreshData() m.refreshData()
} }
case "d", "backspace": case "d", "backspace":
@@ -470,23 +468,18 @@ func (m *Model) refreshData() {
} }
ordered = append(ordered, ungrouped...) ordered = append(ordered, ungrouped...)
m.sites = ordered m.sites = ordered
if s := store.Get(); s != nil { if alerts, err := m.store.GetAllAlerts(); err == nil {
if alerts, err := s.GetAllAlerts(); err == nil {
m.alerts = alerts m.alerts = alerts
} }
if m.isAdmin { if m.isAdmin {
if users, err := s.GetAllUsers(); err == nil { if users, err := m.store.GetAllUsers(); err == nil {
m.users = users m.users = users
} }
} }
}
m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n")) m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n"))
} }
func (m *Model) submitForm() { func (m *Model) submitForm() {
if store.Get() == nil {
return
}
switch m.state { switch m.state {
case stateFormSite: case stateFormSite:
if m.siteFormData != nil { if m.siteFormData != nil {