Files
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

194 lines
6.0 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"
"slices"
"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"`
InternalURL string `json:"internalUrl,omitempty"`
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:"-"`
Image string `json:"-"`
}
// Issue is a label problem on one container, listed on the page so it can be fixed.
type Issue struct {
Host string `json:"host"`
Container string `json:"container"`
Message string `json:"message"`
}
// labelNames are the labels HODA reads, without the prefix.
var labelNames = []string{"enable", "url", "internal_url", "name", "description", "group", "order", "icon"}
// lookalikes maps names people reach for to the label HODA reads.
var lookalikes = map[string]string{
"enabled": "enable", "show": "enable",
"href": "url", "link": "url", "remote": "url", "remote_url": "url", "public": "url", "public_url": "url",
"external": "url", "external_url": "url",
"internal": "internal_url", "internalurl": "internal_url", "local": "internal_url", "local_url": "internal_url",
"lan": "internal_url", "lan_url": "internal_url", "private_url": "internal_url",
"title": "name", "label": "name",
"desc": "description", "subtitle": "description", "info": "description",
"category": "group", "section": "group", "groups": "group",
"sort": "order", "position": "order", "weight": "order", "priority": "order", "rank": "order",
"logo": "icon", "image": "icon", "favicon": "icon",
}
// Parse reads a container's dashboard labels. Label names are matched
// regardless of case and of - or _. A container without any of them is not a
// dashboard app: show is false and there are no problems. Otherwise show says
// whether the app gets a tile, and problems explain anything wrong, shown or not.
func Parse(c docker.Container, prefix, host string) (app App, show bool, problems []string) {
labels := map[string]string{}
var unknown []string
lowerPrefix := strings.ToLower(prefix) + "."
for key, value := range c.Labels {
lower := strings.ToLower(key)
if !strings.HasPrefix(lower, lowerPrefix) {
continue
}
name := strings.ReplaceAll(strings.TrimPrefix(lower, lowerPrefix), "-", "_")
labels[name] = strings.TrimSpace(value)
if !slices.Contains(labelNames, name) {
unknown = append(unknown, key)
}
}
if len(labels) == 0 {
return App{}, false, nil
}
raw, has := labels["enable"]
enabled, err := strconv.ParseBool(raw)
switch {
case !has:
return App{}, false, []string{fmt.Sprintf("has %s labels but no %s.enable: \"true\", so it isn't shown", prefix, prefix)}
case err != nil:
return App{}, false, []string{fmt.Sprintf("%s.enable is %q; use \"true\" to show it", prefix, raw)}
case !enabled:
return App{}, false, nil // hidden on purpose
}
slices.Sort(unknown)
for _, key := range unknown {
name := strings.ReplaceAll(strings.ToLower(key[len(lowerPrefix):]), "-", "_")
if want, ok := lookalikes[name]; ok {
problems = append(problems, fmt.Sprintf("unknown label %s; did you mean %s.%s?", key, prefix, want))
} else {
problems = append(problems, fmt.Sprintf("unknown label %s is ignored", key))
}
}
app = App{
ID: ID(host, c.Name()),
Name: firstNonEmpty(labels["name"], c.Labels["com.docker.compose.service"], c.Name()),
Description: labels["description"],
Group: firstNonEmpty(labels["group"], DefaultGroup),
State: stateOf(c),
Host: host,
IconHint: labels["icon"],
Image: c.Image,
}
switch app.URL = labels["url"]; {
case app.URL == "":
return App{}, false, append(problems, fmt.Sprintf("missing %s.url, so it isn't shown", prefix))
case !IsWebURL(app.URL):
return App{}, false, append(problems, fmt.Sprintf("%s.url %q needs http:// or https://, so it isn't shown", prefix, app.URL))
}
if v := labels["internal_url"]; v != "" {
if IsWebURL(v) {
app.InternalURL = v
} else {
problems = append(problems, fmt.Sprintf("%s.internal_url %q needs http:// or https://, so it is left out", prefix, v))
}
}
if v := labels["order"]; v != "" {
if app.Order, err = strconv.Atoi(v); err != nil {
problems = append(problems, fmt.Sprintf("%s.order %q is not a whole number, so 0 is used", prefix, v))
}
}
return app, true, problems
}
// 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 ""
}