CI / build (push) Successful in 30s
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>
128 lines
3.5 KiB
Go
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 ""
|
|
}
|