Show internal URLs, list label problems, copy a label template
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>
This commit is contained in:
TMCandClaude Opus 5.5 committed 2026-11-02 22:55:06 +01:00
1 parent c58cc329b0
commit 60b4c79b0f
15 files changed
+662 -234

No files matched your search

+28 -6
View File
@@ -29,12 +29,18 @@ services:
| `sema.dashboard.url` | Yes | Link the tile opens (http or https) |
| `sema.dashboard.name` | No | Tile title; defaults to the compose service name |
| `sema.dashboard.icon` | No | A [dashboard-icons](https://github.com/homarr-labs/dashboard-icons) name (`jellyfin`) or an image URL |
| `sema.dashboard.internal_url` | No | Where HODA fetches the favicon when the public URL sits behind Pangolin's login |
| `sema.dashboard.internal_url` | No | LAN address, shown on the tile as a second link; HODA also fetches the favicon from it |
| `sema.dashboard.description` | No | One line under the name |
| `sema.dashboard.group` | No | Section heading; apps without one go under "Apps", last |
| `sema.dashboard.order` | No | Position inside the group; ties sort by name |
A container with `enable` but a missing or invalid `url` stays off the dashboard and is logged once.
Label names ignore case and `-` versus `_`, so `sema.dashboard.Group` and `sema.dashboard.internal-url`
work too. The **Copy labels** button at the top of the dashboard copies this whole set as a commented
template.
Anything HODA can't use is listed in a **label problems** panel above the tiles: a container with
`sema.dashboard` labels but no `enable: "true"`, a missing or invalid `url`, or an unknown label name
(with a suggestion, such as `category` → `group`). Containers without a `url` aren't shown until it's fixed.
**Icons**, first match wins: the icon label, the dashboard-icons set by app name, then by image name
(generic images such as `nginx` or `node` are skipped), the app's own favicon, and finally the app's
@@ -42,11 +48,27 @@ first letter. Icons are cached in `/data/icons` and refreshed daily.
## Deploy
- Host 1, the primary: [deploy/primary/compose.yml](deploy/primary/compose.yml)
- Host 2, the agent: [deploy/agent/compose.yml](deploy/agent/compose.yml)
On the main host ([docker-compose.yml](docker-compose.yml)):
Both need the same `HODA_PEER_TOKEN` in a `.env` file next to them ([deploy/.env.example](deploy/.env.example)).
Publish the primary through Pangolin with SSO on; give it no public port of its own.
```sh
cp .env.example .env
docker compose pull && docker compose up -d
```
HODA then listens on `10.20.30.10:10040` (`HODA_BIND_IP`, `HODA_PORT`). In Pangolin, point the
apps.mariosem.es resource at `http://10.20.30.10:10040` and turn SSO on. The stack brings its own
read-only socket proxy.
**A second Docker host** runs the agent ([docker-compose.agent.yml](docker-compose.agent.yml)), which
publishes port 8081 on that host's LAN IP only:
```sh
cp .env.agent.example .env # set HODA_PEER_TOKEN and HODA_AGENT_BIND_IP
docker compose -f docker-compose.agent.yml pull && docker compose -f docker-compose.agent.yml up -d
```
Then set `HODA_AGENT_URL` (for example `http://10.20.30.11:8081`) and the same `HODA_PEER_TOKEN`
(`openssl rand -hex 32`) in the main host's `.env`, and run `docker compose up -d` there again.
## Configuration
+99 -33
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"fmt"
"net/url"
"slices"
"strconv"
"strings"
@@ -31,6 +32,7 @@ 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"`
@@ -38,43 +40,107 @@ type App struct {
State State `json:"state"`
Host string `json:"host"`
IconHint string `json:"-"`
InternalURL string `json:"-"`
Image string `json:"-"`
IconHint 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)
// 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)
}
}
return app, true, nil
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
+56 -39
View File
@@ -21,8 +21,12 @@ func container(state, status string, labels map[string]string) docker.Container
}
}
func TestFromContainer(t *testing.T) {
full := map[string]string{
func parse(labels map[string]string) (App, bool, []string) {
return Parse(container("running", "Up 2 hours (healthy)", labels), prefix, "host-1")
}
func TestParse(t *testing.T) {
app, show, problems := parse(map[string]string{
"sema.dashboard.enable": "true",
"sema.dashboard.name": "Gitea",
"sema.dashboard.url": "https://git.mariosem.es",
@@ -31,68 +35,78 @@ func TestFromContainer(t *testing.T) {
"sema.dashboard.order": "10",
"sema.dashboard.icon": "gitea",
"sema.dashboard.internal_url": "http://gitea:3000",
}
app, ok, err := FromContainer(container("running", "Up 2 hours (healthy)", full), prefix, "host-1")
if !ok || err != nil {
t.Fatalf("FromContainer = ok %v, err %v; want an app", ok, err)
})
if !show || len(problems) != 0 {
t.Fatalf("show %v, problems %q; want a clean app", show, problems)
}
want := App{
ID: ID("host-1", "gitea"), Name: "Gitea", URL: "https://git.mariosem.es", Group: "Dev",
Description: "Git hosting and CI", Order: 10, State: StateRunning, Host: "host-1",
IconHint: "gitea", InternalURL: "http://gitea:3000", Image: "gitea/gitea:1.22",
ID: ID("host-1", "gitea"), Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000",
Group: "Dev", Description: "Git hosting and CI", Order: 10, State: StateRunning, Host: "host-1",
IconHint: "gitea", Image: "gitea/gitea:1.22",
}
if app != want {
t.Errorf("FromContainer =\n%+v\nwant\n%+v", app, want)
t.Errorf("Parse =\n%+v\nwant\n%+v", app, want)
}
}
func TestFromContainerDefaults(t *testing.T) {
func TestParseIgnoresCaseAndDashes(t *testing.T) {
app, show, problems := parse(map[string]string{
"Sema.Dashboard.Enable": "True",
"sema.dashboard.URL": "https://git.mariosem.es",
"sema.dashboard.Group": "Dev",
"sema.dashboard.internal-url": "http://10.20.30.10:3000",
})
if !show || len(problems) != 0 || app.Group != "Dev" || app.InternalURL != "http://10.20.30.10:3000" {
t.Errorf("show %v, problems %q, app %+v", show, problems, app)
}
}
func TestParseDefaults(t *testing.T) {
labels := map[string]string{
"sema.dashboard.enable": "TRUE",
"sema.dashboard.enable": "1",
"sema.dashboard.url": "http://192.168.1.10:8096",
"com.docker.compose.service": "jellyfin",
}
app, ok, err := FromContainer(container("running", "Up 1 minute", labels), prefix, "host-1")
if !ok || err != nil {
t.Fatalf("FromContainer = ok %v, err %v; want an app", ok, err)
}
app, _, _ := parse(labels)
if app.Name != "jellyfin" || app.Group != DefaultGroup || app.Order != 0 {
t.Errorf("defaults = name %q, group %q, order %d; want jellyfin, %s, 0", app.Name, app.Group, app.Order, DefaultGroup)
}
delete(labels, "com.docker.compose.service")
app, _, _ = FromContainer(container("running", "Up 1 minute", labels), prefix, "host-1")
if app.Name != "gitea" {
if app, _, _ = parse(labels); app.Name != "gitea" {
t.Errorf("name without a compose service = %q, want the container name gitea", app.Name)
}
}
func TestFromContainerRejects(t *testing.T) {
func TestParseProblems(t *testing.T) {
tests := []struct {
name string
labels map[string]string
wantOK bool
wantErr string
name string
labels map[string]string
wantShow bool
want string // a word every problem list must contain; "" = no problems
}{
{"not opted in", map[string]string{"sema.dashboard.url": "https://x.example"}, false, ""},
{"enable false", map[string]string{"sema.dashboard.enable": "false", "sema.dashboard.url": "https://x.example"}, false, ""},
{"missing url", map[string]string{"sema.dashboard.enable": "true"}, true, "missing sema.dashboard.url"},
{"script url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "javascript:alert(1)"}, true, "not an http or https URL"},
{"relative url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "/gitea"}, true, "not an http or https URL"},
{"no labels", map[string]string{"com.docker.compose.service": "x"}, false, ""},
{"hidden on purpose", map[string]string{"sema.dashboard.enable": "false", "sema.dashboard.category": "x"}, false, ""},
{"no enable", map[string]string{"sema.dashboard.url": "https://x.example", "sema.dashboard.group": "Dev"}, false, "no sema.dashboard.enable"},
{"enable yes", map[string]string{"sema.dashboard.enable": "yes", "sema.dashboard.url": "https://x.example"}, false, `use "true"`},
{"missing url", map[string]string{"sema.dashboard.enable": "true"}, false, "missing sema.dashboard.url"},
{"script url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "javascript:alert(1)"}, false, "needs http"},
{"lookalike url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.remote_url": "https://x.example"}, false, "did you mean sema.dashboard.url?"},
{"lookalike group", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.category": "Media"}, true, "did you mean sema.dashboard.group?"},
{"unknown label", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.colour": "red"}, true, "is ignored"},
{"bad internal url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.internal_url": "gitea:3000"}, true, "internal_url"},
{"bad order", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.order": "first"}, true, "not a whole number"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, ok, err := FromContainer(container("running", "Up", tt.labels), prefix, "host-1")
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
_, show, problems := parse(tt.labels)
if show != tt.wantShow {
t.Errorf("show = %v, want %v", show, tt.wantShow)
}
if tt.wantErr == "" && err != nil {
t.Errorf("err = %v, want none", err)
if tt.want == "" && len(problems) != 0 {
t.Errorf("problems = %q, want none", problems)
}
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
t.Errorf("err = %v, want one containing %q", err, tt.wantErr)
if tt.want != "" && !strings.Contains(strings.Join(problems, "\n"), tt.want) {
t.Errorf("problems = %q, want one containing %q", problems, tt.want)
}
})
}
@@ -132,13 +146,16 @@ func TestID(t *testing.T) {
}
}
func TestAppJSONHidesInternalFields(t *testing.T) {
app := App{ID: "0123456789ab", Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000", Image: "gitea/gitea", IconHint: "gitea"}
func TestAppJSON(t *testing.T) {
app := App{ID: "0123456789ab", Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000", Image: "gitea/gitea", IconHint: "gitea-hint"}
raw, err := json.Marshal(app)
if err != nil {
t.Fatal(err)
}
for _, leak := range []string{"gitea:3000", "gitea/gitea", "Hint", "Internal", "Image"} {
if !strings.Contains(string(raw), `"internalUrl":"http://gitea:3000"`) {
t.Errorf("JSON %s lacks the internal URL", raw)
}
for _, leak := range []string{"gitea/gitea", "gitea-hint", "Image", "Hint"} {
if strings.Contains(string(raw), leak) {
t.Errorf("JSON %s contains %q", raw, leak)
}
+25 -18
View File
@@ -23,17 +23,20 @@ type HostStatus struct {
// Response is the body of GET /api/apps, on the primary and on agents.
type Response struct {
Node string `json:"node"`
Hosts []HostStatus `json:"hosts"`
Apps []App `json:"apps"`
Node string `json:"node"`
Hosts []HostStatus `json:"hosts"`
Apps []App `json:"apps"`
Issues []Issue `json:"issues"`
}
type source struct {
status HostStatus
apps []App
issues []Issue
}
// Store holds the latest apps of each source: this host, then one per agent.
// Store holds the latest apps and label problems of each source: this host,
// then one per agent.
type Store struct {
mu sync.RWMutex
sources map[string]*source
@@ -46,23 +49,23 @@ func NewStore() *Store {
}
// SetApps records a successful read of a source.
func (s *Store) SetApps(key, host string, list []App) {
func (s *Store) SetApps(key, host string, list []App, issues []Issue) {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
s.sources[key] = &source{status: HostStatus{Name: host, OK: true, LastOK: &now}, apps: list}
s.sources[key] = &source{status: HostStatus{Name: host, OK: true, LastOK: &now}, apps: list, issues: issues}
}
// SetReported records an agent's own view of its host. An agent that can't
// reach its Docker still sends its last known apps and when it last could.
func (s *Store) SetReported(key string, status HostStatus, list []App) {
func (s *Store) SetReported(key string, status HostStatus, list []App, issues []Issue) {
if status.OK {
now := s.now()
status.LastOK = &now
}
s.mu.Lock()
defer s.mu.Unlock()
s.sources[key] = &source{status: status, apps: list}
s.sources[key] = &source{status: status, apps: list, issues: issues}
}
// SetError marks a source unreachable and keeps its last known apps.
@@ -91,29 +94,33 @@ func (s *Store) Lookup(id string) (key string, app App, ok bool) {
return "", App{}, false
}
// Snapshot returns every host's status and all apps in display order. localIcon
// gives the icon URL of this host's apps; agents send their own.
func (s *Store) Snapshot(localIcon func(id string) string) ([]HostStatus, []App) {
// Snapshot returns every host's status, all apps in display order and all
// label problems. localIcon gives the icon URL of this host's apps; agents send
// their own. The caller fills in Node.
func (s *Store) Snapshot(localIcon func(id string) string) Response {
s.mu.RLock()
defer s.mu.RUnlock()
hosts := make([]HostStatus, 0, len(s.sources))
list := []App{}
resp := Response{Hosts: make([]HostStatus, 0, len(s.sources)), Apps: []App{}, Issues: []Issue{}}
for key, src := range s.sources {
status := src.status
if status.OK {
status.LastOK = nil
}
hosts = append(hosts, status)
resp.Hosts = append(resp.Hosts, status)
for _, a := range src.apps {
if key == LocalSource && localIcon != nil {
a.Icon = localIcon(a.ID)
}
list = append(list, a)
resp.Apps = append(resp.Apps, a)
}
resp.Issues = append(resp.Issues, src.issues...)
}
slices.SortFunc(hosts, func(a, b HostStatus) int { return strings.Compare(a.Name, b.Name) })
slices.SortFunc(list, compareApps)
return hosts, list
slices.SortFunc(resp.Hosts, func(a, b HostStatus) int { return strings.Compare(a.Name, b.Name) })
slices.SortFunc(resp.Apps, compareApps)
slices.SortFunc(resp.Issues, func(a, b Issue) int {
return cmp.Or(strings.Compare(a.Host, b.Host), strings.Compare(a.Container, b.Container), strings.Compare(a.Message, b.Message))
})
return resp
}
// compareApps orders by group, with apps that have none last, then the order
+30 -17
View File
@@ -13,14 +13,14 @@ func TestSnapshotOrder(t *testing.T) {
{ID: "000000000003", Name: "Jellyfin", Group: "Media"},
{ID: "000000000004", Name: "Zebra", Group: "dev", Order: -1},
{ID: "000000000006", Name: "Adminer", Group: DefaultGroup},
})
}, nil)
s.SetReported("http://host-2:8081", HostStatus{Name: "host-2", OK: true}, []App{
{ID: "000000000005", Name: "Immich", Group: "Media", Host: "host-2"},
})
}, nil)
hosts, list := s.Snapshot(nil)
resp := s.Snapshot(nil)
var names []string
for _, a := range list {
for _, a := range resp.Apps {
names = append(names, a.Name)
}
want := []string{"Zebra", "gitea", "Vaultwarden", "Immich", "Jellyfin", "Adminer"}
@@ -32,40 +32,53 @@ func TestSnapshotOrder(t *testing.T) {
t.Fatalf("apps = %v, want %v", names, want)
}
}
if len(hosts) != 2 || hosts[0].Name != "host-1" || hosts[1].Name != "host-2" {
t.Errorf("hosts = %+v, want host-1 then host-2", hosts)
if len(resp.Hosts) != 2 || resp.Hosts[0].Name != "host-1" || resp.Hosts[1].Name != "host-2" {
t.Errorf("hosts = %+v, want host-1 then host-2", resp.Hosts)
}
for _, h := range hosts {
for _, h := range resp.Hosts {
if !h.OK || h.LastOK != nil {
t.Errorf("host %+v: a reachable host is sent without lastOk", h)
}
}
if resp.Issues == nil || len(resp.Issues) != 0 {
t.Errorf("issues = %#v, want an empty list", resp.Issues)
}
}
func TestSnapshotIssues(t *testing.T) {
s := NewStore()
s.SetApps(LocalSource, "host-1", nil, []Issue{{Host: "host-1", Container: "keep", Message: "b"}, {Host: "host-1", Container: "fima", Message: "a"}})
s.SetReported("http://host-2:8081", HostStatus{Name: "host-2", OK: true}, nil, []Issue{{Host: "host-2", Container: "immich", Message: "c"}})
resp := s.Snapshot(nil)
if len(resp.Issues) != 3 || resp.Issues[0].Container != "fima" || resp.Issues[2].Host != "host-2" {
t.Errorf("issues = %+v, want all three sorted by host, then container", resp.Issues)
}
}
func TestSetErrorKeepsApps(t *testing.T) {
s := NewStore()
at := time.Date(2026, 11, 2, 14, 2, 0, 0, time.UTC)
s.now = func() time.Time { return at }
s.SetApps(LocalSource, "host-1", []App{{ID: "000000000001", Name: "Gitea"}})
s.SetApps(LocalSource, "host-1", []App{{ID: "000000000001", Name: "Gitea"}}, nil)
s.SetError(LocalSource, "host-1")
hosts, list := s.Snapshot(nil)
if len(list) != 1 {
t.Fatalf("apps = %d, want the last known app kept", len(list))
resp := s.Snapshot(nil)
if len(resp.Apps) != 1 {
t.Fatalf("apps = %d, want the last known app kept", len(resp.Apps))
}
if hosts[0].OK || hosts[0].LastOK == nil || !hosts[0].LastOK.Equal(at) {
t.Errorf("host = %+v, want unreachable, last OK at %s", hosts[0], at)
if resp.Hosts[0].OK || resp.Hosts[0].LastOK == nil || !resp.Hosts[0].LastOK.Equal(at) {
t.Errorf("host = %+v, want unreachable, last OK at %s", resp.Hosts[0], at)
}
}
func TestSnapshotLocalIcons(t *testing.T) {
s := NewStore()
s.SetApps(LocalSource, "host-1", []App{{ID: "000000000001", Name: "Gitea"}})
s.SetApps(LocalSource, "host-1", []App{{ID: "000000000001", Name: "Gitea"}}, nil)
s.SetReported("http://host-2:8081", HostStatus{Name: "host-2", OK: true}, []App{
{ID: "000000000002", Name: "Immich", Icon: "/icons/000000000002?v=abc"},
})
_, list := s.Snapshot(func(id string) string { return "/icons/" + id + "?v=local" })
for _, a := range list {
}, nil)
resp := s.Snapshot(func(id string) string { return "/icons/" + id + "?v=local" })
for _, a := range resp.Apps {
want := map[string]string{"Gitea": "/icons/000000000001?v=local", "Immich": "/icons/000000000002?v=abc"}[a.Name]
if a.Icon != want {
t.Errorf("%s icon = %q, want %q", a.Name, a.Icon, want)
+22 -22
View File
@@ -34,7 +34,7 @@ type Discovery struct {
reachable bool
count int
warned map[string]string // container name -> the label problem already logged
logged map[string]bool // container + problem already logged
}
// New returns a Discovery for the Docker host behind dc, named host on the dashboard.
@@ -49,7 +49,7 @@ func New(dc *docker.Client, store *apps.Store, icons IconQueue, prefix, host str
log: log,
reachable: true,
count: -1,
warned: map[string]string{},
logged: map[string]bool{},
}
}
@@ -89,7 +89,8 @@ func (d *Discovery) Run(ctx context.Context) {
func (d *Discovery) sync(ctx context.Context) {
listCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
containers, err := d.docker.ListContainers(listCtx, d.prefix+".enable")
// 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
@@ -106,35 +107,34 @@ func (d *Discovery) sync(ctx context.Context) {
}
d.reachable = true
list := make([]apps.App, 0, len(containers))
seen := make(map[string]bool, len(containers))
list := []apps.App{}
issues := []apps.Issue{}
current := map[string]bool{}
for _, c := range containers {
app, ok, err := apps.FromContainer(c, d.prefix, d.host)
if !ok {
continue
}
name := c.Name()
seen[name] = true
if err != nil {
if d.warned[name] != err.Error() {
d.log.Warn("container left off the dashboard", "container", name, "reason", err)
d.warned[name] = err.Error()
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
}
continue
}
delete(d.warned, name)
list = append(list, app)
if show {
list = append(list, app)
}
}
for name := range d.warned {
if !seen[name] {
delete(d.warned, name)
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)
d.store.SetApps(apps.LocalSource, d.host, list, issues)
d.icons.Ensure(list)
}
+27 -11
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -25,10 +26,15 @@ func TestSync(t *testing.T) {
http.Error(w, "down", http.StatusBadGateway)
return
}
if r.URL.Query().Get("filters") != "" {
t.Error("containers are filtered, so labels without enable go unreported")
}
fmt.Fprint(w, `[
{"Id":"a","Names":["/gitea"],"State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"true","sema.dashboard.url":"https://git.mariosem.es"}},
{"Id":"a","Names":["/gitea"],"State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"true","sema.dashboard.url":"https://git.mariosem.es","sema.dashboard.category":"Dev"}},
{"Id":"b","Names":["/broken"],"State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"true"}},
{"Id":"c","Names":["/off"],"State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"false"}}
{"Id":"c","Names":["/forgot"],"State":"running","Status":"Up","Labels":{"sema.dashboard.url":"https://x.example"}},
{"Id":"d","Names":["/off"],"State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"false"}},
{"Id":"e","Names":["/postgres"],"State":"running","Status":"Up","Labels":{"com.docker.compose.service":"db"}}
]`)
}))
defer srv.Close()
@@ -41,25 +47,35 @@ func TestSync(t *testing.T) {
d := New(dc, store, icons, "sema.dashboard", "host-1", time.Minute, slog.New(slog.NewTextHandler(io.Discard, nil)))
d.sync(context.Background())
hosts, list := store.Snapshot(nil)
if len(list) != 1 || list[0].Name != "gitea" || list[0].Host != "host-1" {
t.Fatalf("apps = %+v, want only gitea", list)
resp := store.Snapshot(nil)
if len(resp.Apps) != 1 || resp.Apps[0].Name != "gitea" || resp.Apps[0].Host != "host-1" {
t.Fatalf("apps = %+v, want only gitea", resp.Apps)
}
if len(icons.got) != 1 {
t.Error("icons were not queued for the apps")
}
if _, warned := d.warned["broken"]; !warned {
t.Error("the container without a url label was not reported")
var problems []string
for _, issue := range resp.Issues {
problems = append(problems, issue.Container+": "+issue.Message)
}
if !hosts[0].OK {
got := strings.Join(problems, "\n")
for _, want := range []string{"broken: missing sema.dashboard.url", "forgot: has sema.dashboard labels but no", "gitea: unknown label sema.dashboard.category"} {
if !strings.Contains(got, want) {
t.Errorf("issues:\n%s\nwant one starting %q", got, want)
}
}
if len(resp.Issues) != 3 {
t.Errorf("%d issues, want 3 (nothing for the hidden or unlabeled containers)", len(resp.Issues))
}
if !resp.Hosts[0].OK {
t.Error("host not marked reachable")
}
up = false
d.sync(context.Background())
hosts, list = store.Snapshot(nil)
if hosts[0].OK || len(list) != 1 {
t.Errorf("Docker down: hosts %+v, %d apps; want unreachable with apps kept", hosts, len(list))
resp = store.Snapshot(nil)
if resp.Hosts[0].OK || len(resp.Apps) != 1 || len(resp.Issues) != 3 {
t.Errorf("Docker down: hosts %+v, %d apps, %d issues; want unreachable with both kept", resp.Hosts, len(resp.Apps), len(resp.Issues))
}
}
+5 -4
View File
@@ -87,11 +87,12 @@ func New(host string) (*Client, error) {
}, nil
}
// ListContainers returns every container, running or not, that has the label key.
// ListContainers returns every container, running or not, that has the label
// key; an empty key returns them all.
func (c *Client) ListContainers(ctx context.Context, labelKey string) ([]Container, error) {
q := url.Values{
"all": {"1"},
"filters": {filters(map[string][]string{"label": {labelKey}})},
q := url.Values{"all": {"1"}}
if labelKey != "" {
q.Set("filters", filters(map[string][]string{"label": {labelKey}}))
}
resp, err := c.get(ctx, c.api, "/containers/json?"+q.Encode())
if err != nil {
+11 -1
View File
@@ -101,6 +101,9 @@ func (p *Puller) pull(ctx context.Context, peer string) {
if !apps.ValidID(a.ID) || !apps.IsWebURL(a.URL) {
continue
}
if a.InternalURL != "" && !apps.IsWebURL(a.InternalURL) {
a.InternalURL = ""
}
if !strings.HasPrefix(a.Icon, "/icons/"+a.ID+"?") {
a.Icon = ""
}
@@ -109,10 +112,17 @@ func (p *Puller) pull(ctx context.Context, peer string) {
}
list = append(list, a)
}
issues := make([]apps.Issue, 0, len(body.Issues))
for _, issue := range body.Issues {
if issue.Host == "" {
issue.Host = status.Name
}
issues = append(issues, issue)
}
p.mu.Lock()
p.names[peer] = status.Name
p.mu.Unlock()
p.store.SetReported(peer, status, list)
p.store.SetReported(peer, status, list, issues)
p.mark(peer, true, nil)
p.pruneIcons()
}
+19 -12
View File
@@ -32,10 +32,11 @@ func agent(t *testing.T, iconHits *atomic.Int32) *httptest.Server {
Node: "host-2",
Hosts: []apps.HostStatus{{Name: "host-2", OK: true}},
Apps: []apps.App{
{ID: immichID, Name: "Immich", URL: "https://photos.mariosem.es", Group: "Media", Host: "host-2", Icon: "/icons/" + immichID + "?v=v1"},
{ID: immichID, Name: "Immich", URL: "https://photos.mariosem.es", InternalURL: "javascript:alert(2)", Group: "Media", Host: "host-2", Icon: "/icons/" + immichID + "?v=v1"},
{ID: apps.ID("host-2", "evil"), Name: "Evil", URL: "javascript:alert(1)", Host: "host-2"},
{ID: "../../etc", Name: "Bad ID", URL: "https://x.example", Host: "host-2"},
},
Issues: []apps.Issue{{Container: "jellyfin", Message: "missing sema.dashboard.url, so it isn't shown"}},
})
case "/icons/" + immichID:
iconHits.Add(1)
@@ -56,12 +57,18 @@ func TestPull(t *testing.T) {
p := New([]string{srv.URL}, "s3cret", time.Second, store, quiet)
p.pull(context.Background(), srv.URL)
hosts, list := store.Snapshot(nil)
if len(list) != 1 || list[0].Name != "Immich" {
t.Fatalf("apps = %+v, want only the valid app", list)
resp := store.Snapshot(nil)
if len(resp.Apps) != 1 || resp.Apps[0].Name != "Immich" {
t.Fatalf("apps = %+v, want only the valid app", resp.Apps)
}
if len(hosts) != 1 || !hosts[0].OK || hosts[0].Name != "host-2" {
t.Errorf("hosts = %+v", hosts)
if resp.Apps[0].InternalURL != "" {
t.Errorf("internal URL %q from the agent was kept, want a non-web URL dropped", resp.Apps[0].InternalURL)
}
if len(resp.Hosts) != 1 || !resp.Hosts[0].OK || resp.Hosts[0].Name != "host-2" {
t.Errorf("hosts = %+v", resp.Hosts)
}
if len(resp.Issues) != 1 || resp.Issues[0].Host != "host-2" || resp.Issues[0].Container != "jellyfin" {
t.Errorf("issues = %+v, want the agent's issue under its host", resp.Issues)
}
ic, ok := p.Icon(context.Background(), immichID)
@@ -75,9 +82,9 @@ func TestPull(t *testing.T) {
srv.Close()
p.pull(context.Background(), srv.URL)
hosts, list = store.Snapshot(nil)
if len(list) != 1 || hosts[0].OK || hosts[0].Name != "host-2" || hosts[0].LastOK == nil {
t.Errorf("after the agent went away: hosts %+v, %d apps; want it unreachable with its apps kept", hosts, len(list))
resp = store.Snapshot(nil)
if len(resp.Apps) != 1 || resp.Hosts[0].OK || resp.Hosts[0].Name != "host-2" || resp.Hosts[0].LastOK == nil {
t.Errorf("after the agent went away: hosts %+v, %d apps; want it unreachable with its apps kept", resp.Hosts, len(resp.Apps))
}
}
@@ -87,8 +94,8 @@ func TestWrongToken(t *testing.T) {
store := apps.NewStore()
p := New([]string{srv.URL}, "wrong", time.Second, store, quiet)
p.pull(context.Background(), srv.URL)
hosts, list := store.Snapshot(nil)
if len(list) != 0 || len(hosts) != 1 || hosts[0].OK || hosts[0].Name != "127.0.0.1" {
t.Errorf("hosts %+v, apps %+v; want the agent unreachable under its address", hosts, list)
resp := store.Snapshot(nil)
if len(resp.Apps) != 0 || len(resp.Hosts) != 1 || resp.Hosts[0].OK || resp.Hosts[0].Name != "127.0.0.1" {
t.Errorf("hosts %+v, apps %+v; want the agent unreachable under its address", resp.Hosts, resp.Apps)
}
}
+3 -2
View File
@@ -96,8 +96,9 @@ func (s *server) agentIndex(w http.ResponseWriter, r *http.Request) {
}
func (s *server) apiApps(w http.ResponseWriter, r *http.Request) {
hosts, list := s.deps.Store.Snapshot(s.deps.Icons.URL)
body, err := json.Marshal(apps.Response{Node: s.cfg.NodeName, Hosts: hosts, Apps: list})
resp := s.deps.Store.Snapshot(s.deps.Icons.URL)
resp.Node = s.cfg.NodeName
body, err := json.Marshal(resp)
if err != nil {
http.Error(w, "encoding the app list failed", http.StatusInternalServerError)
return
+9 -3
View File
@@ -35,7 +35,7 @@ func newHandler(mode config.Mode) http.Handler {
store.SetApps(apps.LocalSource, "host-1", []apps.App{{
ID: giteaID, Name: "Gitea", URL: "https://git.mariosem.es", Group: "Dev", State: apps.StateRunning,
Host: "host-1", InternalURL: "http://gitea:3000", Image: "gitea/gitea",
}})
}}, []apps.Issue{{Host: "host-1", Container: "keep", Message: "missing sema.dashboard.url, so it isn't shown"}})
cfg := config.Config{
Mode: mode, NodeName: "host-1", Title: "HODA", LinkTarget: "_blank",
LabelPrefix: "sema.dashboard", PeerToken: "s3cret",
@@ -67,8 +67,14 @@ func TestAPIApps(t *testing.T) {
if len(body.Apps) != 1 || body.Apps[0].Icon != "/icons/"+giteaID+"?v=abc123" || body.Node != "host-1" {
t.Errorf("body = %+v", body)
}
if strings.Contains(rec.Body.String(), "gitea:3000") || strings.Contains(rec.Body.String(), "gitea/gitea") {
t.Errorf("the response leaks internal fields: %s", rec.Body)
if body.Apps[0].InternalURL != "http://gitea:3000" {
t.Errorf("internal URL = %q, want it sent for the tile's second link", body.Apps[0].InternalURL)
}
if len(body.Issues) != 1 || body.Issues[0].Container != "keep" {
t.Errorf("issues = %+v, want the label problem", body.Issues)
}
if strings.Contains(rec.Body.String(), "gitea/gitea") {
t.Errorf("the response leaks the image name: %s", rec.Body)
}
etag := rec.Header().Get("ETag")
+10 -3
View File
@@ -12,12 +12,19 @@
<body data-link-target="{{.LinkTarget}}" data-label-prefix="{{.LabelPrefix}}">
<header class="top">
<h1 class="title">{{.Title}}</h1>
<div class="search">
<input id="search" type="search" placeholder="Search apps" autocomplete="off" spellcheck="false" aria-label="Search apps">
<kbd aria-hidden="true">/</kbd>
<div class="actions">
<div class="search">
<input id="search" type="search" placeholder="Search apps" autocomplete="off" spellcheck="false" aria-label="Search apps">
<kbd aria-hidden="true">/</kbd>
</div>
<button id="copy-labels" class="button" type="button" title="Copy a template of every HODA label, ready to paste into a service">
<svg class="glyph" viewBox="0 0 16 16" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V4A1.5 1.5 0 0 0 9 2.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5"/></svg>
<span class="button-text">Copy labels</span>
</button>
</div>
</header>
<div id="banners" class="banners" role="status" aria-live="polite"></div>
<div id="issues" class="issues-wrap"></div>
<main id="groups" class="groups" aria-busy="true"></main>
<noscript><p class="note">HODA needs JavaScript to list your apps.</p></noscript>
</body>
+143 -30
View File
@@ -2,11 +2,13 @@
color-scheme: light dark;
--bg: #f6f6f4;
--surface: #ffffff;
--chip: #f0efeb;
--border: #dcdbd5;
--border-strong: #a9a79e;
--text: #1d1d1b;
--muted: #64635d;
--focus: #2f6fdb;
--link: #2459b8;
--good: #1a9e3a;
--warn: #c98000;
--off: #a9a79e;
@@ -19,11 +21,13 @@
:root {
--bg: #161615;
--surface: #1f1f1d;
--chip: #2a2a27;
--border: #34332f;
--border-strong: #5c5b54;
--text: #ededea;
--muted: #a5a49d;
--focus: #6ea2ff;
--link: #8db6ff;
--good: #3cc35e;
--warn: #f0a92a;
--off: #6b6a63;
@@ -43,10 +47,23 @@ body {
line-height: 1.4;
}
.glyph {
flex: none;
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Header */
.top {
position: sticky;
top: 0;
z-index: 1;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
@@ -65,7 +82,9 @@ body {
text-overflow: ellipsis;
}
.search { position: relative; flex: 0 1 280px; }
.actions { display: flex; align-items: center; gap: 8px; flex: 0 1 420px; }
.search { position: relative; flex: 1 1 auto; min-width: 0; }
.search input {
width: 100%;
@@ -98,7 +117,29 @@ body {
.search kbd { display: none; }
}
.banners:empty { display: none; }
.button {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
padding: 8px 12px;
font: inherit;
font-size: 14px;
white-space: nowrap;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 8px;
cursor: pointer;
}
.button:hover { background: var(--chip); }
.button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* Banners and label problems */
.banners, .issues-wrap { padding: 0 24px; }
.banners:empty, .issues-wrap:empty { display: none; }
.banner {
max-width: 1440px;
@@ -109,7 +150,35 @@ body {
border-radius: 8px;
}
.banners { padding: 0 24px; }
.issues {
max-width: 1440px;
margin: 16px auto 0;
background: var(--surface);
border: 1px solid var(--border);
border-left: 4px solid var(--warn);
border-radius: 8px;
}
.issues summary {
padding: 10px 14px;
font-weight: 600;
cursor: pointer;
}
.issues summary:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; border-radius: 8px; }
.issue-list {
margin: 0;
padding: 0 14px 12px 32px;
font-size: 14px;
}
.issue-list li { margin: 4px 0; }
.issue-container { font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
.issue-host { color: var(--muted); }
.issue-message::before { content: ": "; }
/* Groups and tiles */
.groups { max-width: 1440px; margin: 0 auto; padding: 8px 24px 40px; }
@@ -124,19 +193,17 @@ body {
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.tile {
position: relative;
display: flex;
align-items: center;
align-items: flex-start;
gap: 12px;
min-height: 64px;
min-height: 72px;
padding: 12px 28px 12px 12px;
color: inherit;
text-decoration: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
@@ -144,7 +211,7 @@ body {
}
.tile:hover { border-color: var(--border-strong); transform: translateY(-1px); }
.tile:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.tile:has(.name:focus-visible) { outline: 2px solid var(--focus); outline-offset: 2px; }
.tile.state-stopped { opacity: 0.5; }
.icon {
@@ -168,18 +235,62 @@ body {
.letter { color: hsl(var(--hue) 70% 85%); background: hsl(var(--hue) 35% 25%); }
}
.text { display: flex; flex-direction: column; min-width: 0; }
.text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.name,
.desc {
.desc,
.chip-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name { font-weight: 600; }
/* The name is the tile's link; its ::after stretches over the whole tile. */
.name {
display: block;
font-weight: 600;
color: inherit;
text-decoration: none;
}
.name::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
}
.name:focus-visible { outline: none; }
.desc { font-size: 13px; color: var(--muted); }
.urls { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; min-width: 0; }
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 7px 2px 5px;
font-size: 12px;
color: var(--muted);
background: var(--chip);
border-radius: 6px;
}
.chip .glyph { width: 12px; height: 12px; }
/* The internal link sits above the tile's stretched link, so it opens on its own. */
.chip-internal {
position: relative;
z-index: 1;
color: var(--link);
text-decoration: none;
}
.chip-internal:hover .chip-text { text-decoration: underline; }
.chip-internal:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.dot {
position: absolute;
top: 10px;
@@ -193,10 +304,12 @@ body {
.state-unhealthy .dot { background: var(--warn); }
.state-stopped .dot { background: var(--off); }
/* Empty and no-match states */
.note { margin: 32px 0; color: var(--muted); }
.empty {
max-width: 560px;
max-width: 640px;
margin: 48px auto;
padding: 24px;
background: var(--surface);
@@ -217,18 +330,6 @@ body {
border-radius: 8px;
}
.copy {
padding: 6px 14px;
font: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 8px;
cursor: pointer;
}
.copy:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.visually-hidden {
position: absolute;
width: 1px;
@@ -240,12 +341,24 @@ body {
@media (max-width: 480px) {
.top { flex-wrap: wrap; padding: 12px 16px; }
.search { flex-basis: 100%; }
.banners { padding: 0 16px; }
.actions { flex-basis: 100%; }
.banners, .issues-wrap { padding: 0 16px; }
.groups { padding: 4px 16px 32px; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.tile { gap: 10px; padding: 10px 22px 10px 10px; }
.desc { display: none; }
/* Icon and name on the first row, links across the whole tile below. */
.tile {
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
gap: 6px 10px;
min-height: 0;
padding: 10px 22px 10px 10px;
}
.text { display: contents; }
.icon { width: 32px; height: 32px; }
.urls { grid-column: 1 / -1; margin-top: 0; }
.urls.remote-only { display: none; }
.desc, .chip-remote { display: none; }
}
@media (prefers-reduced-motion: reduce) {
+175 -33
View File
@@ -1,15 +1,23 @@
// HODA dashboard: loads /api/apps, shows the apps as tiles grouped by their
// group label, and keeps them current by polling with ETags. Search filters as
// you type; "/" focuses it and Enter opens the first match.
// you type; "/" focuses it and Enter opens the first match. Label problems on
// containers are listed above the tiles so they can be fixed.
(() => {
"use strict";
const POLL_MS = 10_000;
const STATE_LABELS = { running: "Running", unhealthy: "Unhealthy", stopped: "Stopped" };
const SVG = "http://www.w3.org/2000/svg";
const GLYPHS = {
remote: "M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm0 0C6.2 3.3 5.3 5.5 5.3 8s.9 4.7 2.7 6.5m0-13c1.8 1.8 2.7 4 2.7 6.5s-.9 4.7-2.7 6.5M1.8 6h12.4M1.8 10h12.4",
internal: "M2 7.5 8 2.5l6 5M3.5 6.5v7h9v-7M6.5 13.5V10h3v3.5",
};
const groupsEl = document.getElementById("groups");
const bannersEl = document.getElementById("banners");
const issuesEl = document.getElementById("issues");
const searchEl = document.getElementById("search");
const copyLabelsEl = document.getElementById("copy-labels");
const linkTarget = document.body.dataset.linkTarget === "_self" ? "_self" : "_blank";
const labelPrefix = document.body.dataset.labelPrefix || "sema.dashboard";
@@ -17,6 +25,7 @@
let etag = "";
let inFlight = false;
let offline = false;
let issuesOpen = false;
async function load() {
if (inFlight) return;
@@ -51,8 +60,9 @@
function render() {
renderBanners();
renderIssues();
if (!data) return;
const focusedId = document.activeElement?.dataset?.id;
const focusedId = document.activeElement?.closest?.("[data-id]")?.dataset.id;
const terms = searchEl.value.toLowerCase().split(/\s+/).filter(Boolean);
const visible = data.apps.filter((app) => matches(app, terms));
@@ -71,7 +81,7 @@
}
}
groupsEl.replaceChildren(...sections);
if (focusedId) groupsEl.querySelector(`[data-id="${CSS.escape(focusedId)}"]`)?.focus();
if (focusedId) groupsEl.querySelector(`[data-id="${CSS.escape(focusedId)}"] a.name`)?.focus();
}
function renderBanners() {
@@ -85,9 +95,37 @@
bannersEl.replaceChildren(...banners);
}
// Containers whose labels HODA couldn't use, and why: hidden apps, unknown
// label names, values in the wrong shape.
function renderIssues() {
const issues = data?.issues ?? [];
if (issues.length === 0) {
issuesEl.replaceChildren();
return;
}
const manyHosts = (data.hosts ?? []).length > 1;
const details = el("details", "issues");
details.open = issuesOpen;
details.addEventListener("toggle", () => {
issuesOpen = details.open;
});
const containers = new Set(issues.map((i) => `${i.host}/${i.container}`)).size;
const summary = el("summary", "", `${issues.length} label ${plural(issues.length, "problem")} on ${containers} ${plural(containers, "container")}`);
const list = el("ul", "issue-list");
for (const issue of issues) {
const item = el("li");
item.append(el("code", "issue-container", issue.container));
if (manyHosts) item.append(el("span", "issue-host", ` on ${issue.host}`));
item.append(el("span", "issue-message", issue.message));
list.append(item);
}
details.append(summary, list);
issuesEl.replaceChildren(details);
}
function matches(app, terms) {
if (terms.length === 0) return true;
const text = [app.name, app.description, app.group, app.host].join(" ").toLowerCase();
const text = [app.name, app.description, app.group, app.host, app.url, app.internalUrl].join(" ").toLowerCase();
return terms.every((term) => text.includes(term));
}
@@ -101,23 +139,72 @@
return groups;
}
// A tile is one big link to the app's URL. The internal URL, when there is
// one, is a second link on top of it.
function tile(app) {
const state = STATE_LABELS[app.state] ? app.state : "stopped";
const a = el("a", `tile state-${state}`);
a.href = app.url;
a.dataset.id = app.id;
a.title = `${app.name} on ${app.host}`;
const card = el("article", `tile state-${state}`);
card.dataset.id = app.id;
card.title = `${app.name} on ${app.host}`;
const name = el("a", "name", app.name);
name.href = app.url;
setTarget(name);
const text = el("div", "text");
text.append(name);
if (app.description) text.append(el("span", "desc", app.description));
const urls = el("div", "urls");
urls.append(chip("span", "remote", app.url));
if (!app.internalUrl || app.internalUrl === app.url) urls.classList.add("remote-only");
if (app.internalUrl && app.internalUrl !== app.url) {
const internal = chip("a", "internal", app.internalUrl);
internal.href = app.internalUrl;
internal.title = `Open ${app.name} on the local network`;
setTarget(internal);
urls.append(internal);
}
text.append(urls);
const dot = el("span", "dot");
dot.title = STATE_LABELS[state];
card.append(icon(app), text, dot, el("span", "visually-hidden", `, ${STATE_LABELS[state]}`));
return card;
}
function chip(tag, kind, href) {
const node = el(tag, `chip chip-${kind}`);
node.append(glyph(kind), el("span", "chip-text", shortURL(href)));
return node;
}
// "https://git.mariosem.es/" reads as "git.mariosem.es"; a path is kept.
function shortURL(href) {
try {
const u = new URL(href);
const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
return u.host + path;
} catch {
return href;
}
}
function glyph(name) {
const svg = document.createElementNS(SVG, "svg");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("aria-hidden", "true");
svg.classList.add("glyph");
const path = document.createElementNS(SVG, "path");
path.setAttribute("d", GLYPHS[name]);
svg.append(path);
return svg;
}
function setTarget(a) {
if (linkTarget === "_blank") {
a.target = "_blank";
a.rel = "noopener noreferrer";
}
const text = el("span", "text");
text.append(el("span", "name", app.name));
if (app.description) text.append(el("span", "desc", app.description));
const dot = el("span", "dot");
dot.title = STATE_LABELS[state];
a.append(icon(app), text, dot, el("span", "visually-hidden", `, ${STATE_LABELS[state]}`));
return a;
}
function icon(app) {
@@ -147,23 +234,73 @@
return h % 360;
}
function emptyState() {
const snippet = [
"labels:",
` ${labelPrefix}.enable: "true"`,
` ${labelPrefix}.name: My app`,
` ${labelPrefix}.url: https://myapp.example.com`,
// Every label with an example value, indented to paste under a service.
function labelTemplate() {
const p = labelPrefix;
const host = location.hostname;
const isIP = /^[\d.]+$/.test(host) || host === "localhost";
const domain = !isIP && host.includes(".") ? host.split(".").slice(-2).join(".") : "example.com";
const lan = isIP && host !== "localhost" && !host.startsWith("127.") ? host : "192.168.1.10";
const rows = [
[`${p}.enable: "true"`, "required: puts the service on the dashboard"],
[`${p}.url: https://myapp.${domain}`, "required: where the tile links"],
[`${p}.internal_url: http://${lan}:8080`, "LAN address, shown as a second link"],
[`${p}.name: My App`, "defaults to the service name"],
[`${p}.description: What it does`, "one line under the name"],
[`${p}.group: Media`, "section heading; apps without one go last"],
[`${p}.order: "10"`, "lower comes first within the group"],
[`${p}.icon: jellyfin`, "dashboard-icons name or an image URL"],
];
const width = Math.max(...rows.map(([label]) => label.length)) + 2;
return [
" labels:",
...rows.map(([label, note]) => ` ${label.padEnd(width)}# ${note}`),
].join("\n");
const copy = el("button", "copy", "Copy");
}
// The clipboard API only exists on https; HODA is also opened over plain
// http on the LAN, where the older copy command still works.
async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const area = el("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.append(area);
area.select();
const ok = document.execCommand("copy");
area.remove();
if (!ok) throw new Error("copy failed");
}
function flash(button, text) {
const label = button.querySelector(".button-text") || button;
label.dataset.idle ??= label.textContent;
label.textContent = text;
clearTimeout(Number(button.dataset.timer));
button.dataset.timer = String(setTimeout(() => {
label.textContent = label.dataset.idle;
}, 1600));
}
async function copyWithFeedback(button, text) {
try {
await copyText(text);
flash(button, "Copied");
} catch {
flash(button, "Copy failed");
}
}
function emptyState() {
const snippet = labelTemplate();
const copy = el("button", "button", "Copy");
copy.type = "button";
copy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(snippet);
copy.textContent = "Copied";
} catch {
copy.textContent = "Copy failed";
}
});
copy.addEventListener("click", () => copyWithFeedback(copy, snippet));
const box = el("div", "empty");
box.append(
el("h2", "", "No apps yet"),
@@ -174,6 +311,10 @@
return box;
}
function plural(n, word) {
return n === 1 ? word : `${word}s`;
}
function clock(iso) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
@@ -185,7 +326,7 @@
return node;
}
function openTile(a) {
function openLink(a) {
if (a.target === "_blank") window.open(a.href, "_blank", "noopener,noreferrer");
else window.location.assign(a.href);
}
@@ -194,6 +335,7 @@
return target instanceof HTMLElement && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
}
copyLabelsEl.addEventListener("click", () => copyWithFeedback(copyLabelsEl, labelTemplate()));
searchEl.addEventListener("input", render);
searchEl.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
@@ -201,10 +343,10 @@
render();
searchEl.blur();
} else if (event.key === "Enter") {
const first = groupsEl.querySelector("a.tile");
const first = groupsEl.querySelector("a.name");
if (first) {
event.preventDefault();
openTile(first);
openLink(first);
}
}
});