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>
194 lines
6.0 KiB
Go
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 ""
|
|
}
|