CI / build (push) Successful in 40s
Tiles now show the public URL and, when set, the internal URL as a second link. Label names ignore case and - versus _. Containers HODA can't fully use are listed above the tiles with the reason: labels without enable, a missing url, or an unknown label name with a suggestion (category -> group). To find labels without enable, discovery reads all containers. A "Copy labels" button copies every label as a commented template, and falls back to the copy command where the clipboard API is missing (plain http on the LAN). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
4.0 KiB
Go
144 lines
4.0 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"`
|
|
Issues []Issue `json:"issues"`
|
|
}
|
|
|
|
type source struct {
|
|
status HostStatus
|
|
apps []App
|
|
issues []Issue
|
|
}
|
|
|
|
// Store holds the latest apps and label problems 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, issues []Issue) {
|
|
now := s.now()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.sources[key] = &source{status: HostStatus{Name: host, OK: true, LastOK: &now}, apps: list, issues: issues}
|
|
}
|
|
|
|
// 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, issues []Issue) {
|
|
if status.OK {
|
|
now := s.now()
|
|
status.LastOK = &now
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.sources[key] = &source{status: status, apps: list, issues: issues}
|
|
}
|
|
|
|
// 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, all apps in display order and all
|
|
// label problems. localIcon gives the icon URL of this host's apps; agents send
|
|
// their own. The caller fills in Node.
|
|
func (s *Store) Snapshot(localIcon func(id string) string) Response {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
resp := Response{Hosts: make([]HostStatus, 0, len(s.sources)), Apps: []App{}, Issues: []Issue{}}
|
|
for key, src := range s.sources {
|
|
status := src.status
|
|
if status.OK {
|
|
status.LastOK = nil
|
|
}
|
|
resp.Hosts = append(resp.Hosts, status)
|
|
for _, a := range src.apps {
|
|
if key == LocalSource && localIcon != nil {
|
|
a.Icon = localIcon(a.ID)
|
|
}
|
|
resp.Apps = append(resp.Apps, a)
|
|
}
|
|
resp.Issues = append(resp.Issues, src.issues...)
|
|
}
|
|
slices.SortFunc(resp.Hosts, func(a, b HostStatus) int { return strings.Compare(a.Name, b.Name) })
|
|
slices.SortFunc(resp.Apps, compareApps)
|
|
slices.SortFunc(resp.Issues, func(a, b Issue) int {
|
|
return cmp.Or(strings.Compare(a.Host, b.Host), strings.Compare(a.Container, b.Container), strings.Compare(a.Message, b.Message))
|
|
})
|
|
return resp
|
|
}
|
|
|
|
// 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),
|
|
)
|
|
}
|