CI / build (push) Successful in 30s
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>
137 lines
3.5 KiB
Go
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),
|
|
)
|
|
}
|