Files
HODA/internal/discovery/discovery.go
T
TMCandClaude Opus 5.5 60b4c79b0f
CI / build (push) Successful in 40s
Show internal URLs, list label problems, copy a label template
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>
2026-11-02 22:55:06 +01:00

180 lines
4.3 KiB
Go

// Package discovery keeps the store in step with this host's labeled containers.
package discovery
import (
"context"
"log/slog"
"strings"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
"git.mariosem.es/msemes/hoda/internal/docker"
)
const (
settle = 300 * time.Millisecond // lets a burst of events become one re-list
minBackoff = time.Second
maxBackoff = 30 * time.Second
)
// IconQueue is told about the current apps so their icons get resolved.
type IconQueue interface {
Ensure([]apps.App)
}
// Discovery lists this host's labeled containers into the store.
type Discovery struct {
docker *docker.Client
store *apps.Store
icons IconQueue
prefix string
host string
resync time.Duration
log *slog.Logger
reachable bool
count int
logged map[string]bool // container + problem already logged
}
// New returns a Discovery for the Docker host behind dc, named host on the dashboard.
func New(dc *docker.Client, store *apps.Store, icons IconQueue, prefix, host string, resync time.Duration, log *slog.Logger) *Discovery {
return &Discovery{
docker: dc,
store: store,
icons: icons,
prefix: prefix,
host: host,
resync: resync,
log: log,
reachable: true,
count: -1,
logged: map[string]bool{},
}
}
// Run lists containers now, again after every relevant Docker event and every
// resync interval, until ctx ends.
func (d *Discovery) Run(ctx context.Context) {
changed := make(chan struct{}, 1)
go d.watch(ctx, func() {
select {
case changed <- struct{}{}:
default:
}
})
ticker := time.NewTicker(d.resync)
defer ticker.Stop()
d.sync(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
case <-changed:
select {
case <-ctx.Done():
return
case <-time.After(settle):
}
select {
case <-changed:
default:
}
}
d.sync(ctx)
}
}
func (d *Discovery) sync(ctx context.Context) {
listCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Every container, not only opted-in ones: labels without enable are a problem to report.
containers, err := d.docker.ListContainers(listCtx, "")
if err != nil {
if ctx.Err() != nil {
return
}
d.store.SetError(apps.LocalSource, d.host)
if d.reachable {
d.log.Warn("can't reach Docker, keeping the last known apps", "err", err)
}
d.reachable = false
return
}
if !d.reachable {
d.log.Info("Docker is reachable again")
}
d.reachable = true
list := []apps.App{}
issues := []apps.Issue{}
current := map[string]bool{}
for _, c := range containers {
app, show, problems := apps.Parse(c, d.prefix, d.host)
for _, p := range problems {
issues = append(issues, apps.Issue{Host: d.host, Container: c.Name(), Message: p})
key := c.Name() + "\x00" + p
current[key] = true
if !d.logged[key] {
d.log.Warn("label problem", "container", c.Name(), "problem", p)
d.logged[key] = true
}
}
if show {
list = append(list, app)
}
}
for key := range d.logged {
if !current[key] {
delete(d.logged, key)
}
}
if len(list) != d.count {
d.log.Info("apps on this host", "count", len(list))
d.count = len(list)
}
d.store.SetApps(apps.LocalSource, d.host, list, issues)
d.icons.Ensure(list)
}
// watch follows Docker events and reconnects with backoff when the stream breaks.
func (d *Discovery) watch(ctx context.Context, changed func()) {
backoff := minBackoff
for {
started := time.Now()
err := d.docker.Events(ctx, d.prefix+".enable", func(ev docker.Event) {
if relevant(ev.Action) {
changed()
}
})
if ctx.Err() != nil {
return
}
if time.Since(started) > time.Minute {
backoff = minBackoff
}
d.log.Debug("Docker event stream ended, reconnecting", "err", err, "in", backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff = min(backoff*2, maxBackoff)
changed() // events may have been missed while disconnected
}
}
// relevant drops the event chatter of healthchecks and `docker exec`, which
// never changes what the dashboard shows.
func relevant(action string) bool {
switch {
case strings.HasPrefix(action, "exec_"), strings.HasPrefix(action, "archive-path"):
return false
case action == "attach", action == "detach", action == "resize", action == "top",
action == "commit", action == "copy", action == "export", action == "extract-to-dir":
return false
}
return true
}