Files
HODA/internal/apps/app.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

128 lines
3.5 KiB
Go

// Package apps turns labeled containers into dashboard apps and keeps the
// merged list from this host and any agents.
package apps
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"strconv"
"strings"
"git.mariosem.es/msemes/hoda/internal/docker"
)
// State is what a tile's dot shows.
type State string
const (
StateRunning State = "running"
StateUnhealthy State = "unhealthy"
StateStopped State = "stopped"
)
// DefaultGroup holds apps that have no group label.
const DefaultGroup = "Apps"
// App is one tile on the dashboard. Only the fields with JSON names leave the
// process; the rest feed icon resolution.
type App struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Description string `json:"description,omitempty"`
Group string `json:"group"`
Order int `json:"order"`
Icon string `json:"icon,omitempty"`
State State `json:"state"`
Host string `json:"host"`
IconHint string `json:"-"`
InternalURL string `json:"-"`
Image string `json:"-"`
}
// FromContainer reads an app from a container's labels. ok is false when the
// container has not opted in; err says why an opted-in container can't be shown.
func FromContainer(c docker.Container, prefix, host string) (app App, ok bool, err error) {
label := func(key string) string { return strings.TrimSpace(c.Labels[prefix+"."+key]) }
if enabled, _ := strconv.ParseBool(label("enable")); !enabled {
return App{}, false, nil
}
app = App{
ID: ID(host, c.Name()),
Name: firstNonEmpty(label("name"), c.Labels["com.docker.compose.service"], c.Name()),
Description: label("description"),
Group: firstNonEmpty(label("group"), DefaultGroup),
State: stateOf(c),
Host: host,
IconHint: label("icon"),
Image: c.Image,
}
if app.URL = label("url"); app.URL == "" {
return App{}, true, fmt.Errorf("missing %s.url label", prefix)
}
if !IsWebURL(app.URL) {
return App{}, true, fmt.Errorf("%s.url %q is not an http or https URL", prefix, app.URL)
}
if app.InternalURL = label("internal_url"); app.InternalURL != "" && !IsWebURL(app.InternalURL) {
return App{}, true, fmt.Errorf("%s.internal_url %q is not an http or https URL", prefix, app.InternalURL)
}
if raw := label("order"); raw != "" {
if app.Order, err = strconv.Atoi(raw); err != nil {
return App{}, true, fmt.Errorf("%s.order %q is not a whole number", prefix, raw)
}
}
return app, true, nil
}
// ID is a short identifier for a container on a host. It survives the
// container being re-created, because compose keeps the name.
func ID(host, container string) string {
sum := sha256.Sum256([]byte(host + "/" + container))
return hex.EncodeToString(sum[:6])
}
// ValidID reports whether id has the shape ID produces.
func ValidID(id string) bool {
if len(id) != 12 {
return false
}
for _, r := range id {
if !strings.ContainsRune("0123456789abcdef", r) {
return false
}
}
return true
}
// IsWebURL reports whether raw is an absolute http or https URL.
func IsWebURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func stateOf(c docker.Container) State {
switch c.State {
case "running":
if strings.Contains(c.Status, "(unhealthy)") {
return StateUnhealthy
}
return StateRunning
case "restarting":
return StateUnhealthy
default:
return StateStopped
}
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}