Files
HODA/internal/apps/store.go
T
TMCandClaude Opus 5.5 9d6a921c34
CI / build (push) Successful in 30s
Start HODA: a homelab dashboard built from Docker labels
Apps opt in with sema.dashboard.* labels in their own compose file. The
primary reads its host's containers through a read-only socket proxy,
follows Docker events, and merges in what the agent on the second host
reports. Icons come from the icon label, the dashboard-icons set or the
app's favicon, with a letter as the fallback.

Go with only the standard library, a distroless image, and a Gitea
Actions workflow copied from FiMa's: checks in the Dockerfile's check
stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:18:20 +01:00

137 lines
3.5 KiB
Go

package apps
import (
"cmp"
"slices"
"strings"
"sync"
"time"
)
// LocalSource is the store key for the host this instance runs on. Agents are
// stored under their URL.
const LocalSource = "local"
// HostStatus says whether a Docker host answered on the last attempt. LastOK
// is only sent while the host is unreachable, so the response stays the same
// (and its ETag stable) while nothing changes.
type HostStatus struct {
Name string `json:"name"`
OK bool `json:"ok"`
LastOK *time.Time `json:"lastOk,omitempty"`
}
// Response is the body of GET /api/apps, on the primary and on agents.
type Response struct {
Node string `json:"node"`
Hosts []HostStatus `json:"hosts"`
Apps []App `json:"apps"`
}
type source struct {
status HostStatus
apps []App
}
// Store holds the latest apps of each source: this host, then one per agent.
type Store struct {
mu sync.RWMutex
sources map[string]*source
now func() time.Time
}
// NewStore returns an empty store.
func NewStore() *Store {
return &Store{sources: map[string]*source{}, now: time.Now}
}
// SetApps records a successful read of a source.
func (s *Store) SetApps(key, host string, list []App) {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
s.sources[key] = &source{status: HostStatus{Name: host, OK: true, LastOK: &now}, apps: list}
}
// SetReported records an agent's own view of its host. An agent that can't
// reach its Docker still sends its last known apps and when it last could.
func (s *Store) SetReported(key string, status HostStatus, list []App) {
if status.OK {
now := s.now()
status.LastOK = &now
}
s.mu.Lock()
defer s.mu.Unlock()
s.sources[key] = &source{status: status, apps: list}
}
// SetError marks a source unreachable and keeps its last known apps.
func (s *Store) SetError(key, host string) {
s.mu.Lock()
defer s.mu.Unlock()
src, ok := s.sources[key]
if !ok {
src = &source{status: HostStatus{Name: host}}
s.sources[key] = src
}
src.status.OK = false
}
// Lookup finds an app by ID and the key of the source that reported it.
func (s *Store) Lookup(id string) (key string, app App, ok bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for k, src := range s.sources {
for _, a := range src.apps {
if a.ID == id {
return k, a, true
}
}
}
return "", App{}, false
}
// Snapshot returns every host's status and all apps in display order. localIcon
// gives the icon URL of this host's apps; agents send their own.
func (s *Store) Snapshot(localIcon func(id string) string) ([]HostStatus, []App) {
s.mu.RLock()
defer s.mu.RUnlock()
hosts := make([]HostStatus, 0, len(s.sources))
list := []App{}
for key, src := range s.sources {
status := src.status
if status.OK {
status.LastOK = nil
}
hosts = append(hosts, status)
for _, a := range src.apps {
if key == LocalSource && localIcon != nil {
a.Icon = localIcon(a.ID)
}
list = append(list, a)
}
}
slices.SortFunc(hosts, func(a, b HostStatus) int { return strings.Compare(a.Name, b.Name) })
slices.SortFunc(list, compareApps)
return hosts, list
}
// compareApps orders by group, with apps that have none last, then the order
// label, then name.
func compareApps(a, b App) int {
ungrouped := func(app App) int {
if app.Group == DefaultGroup {
return 1
}
return 0
}
return cmp.Or(
cmp.Compare(ungrouped(a), ungrouped(b)),
cmp.Compare(strings.ToLower(a.Group), strings.ToLower(b.Group)),
cmp.Compare(a.Order, b.Order),
cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)),
cmp.Compare(a.Host, b.Host),
cmp.Compare(a.ID, b.ID),
)
}