diff --git a/README.md b/README.md index 8fa2a00..fc21cc2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/apps/app.go b/internal/apps/app.go index de11018..25e3088 100644 --- a/internal/apps/app.go +++ b/internal/apps/app.go @@ -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 diff --git a/internal/apps/app_test.go b/internal/apps/app_test.go index f73f1c9..be59c7b 100644 --- a/internal/apps/app_test.go +++ b/internal/apps/app_test.go @@ -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) } diff --git a/internal/apps/store.go b/internal/apps/store.go index 9a32e74..62962bb 100644 --- a/internal/apps/store.go +++ b/internal/apps/store.go @@ -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 diff --git a/internal/apps/store_test.go b/internal/apps/store_test.go index 6394ea6..44fb852 100644 --- a/internal/apps/store_test.go +++ b/internal/apps/store_test.go @@ -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) diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 1116934..fd708db 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -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) } diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go index 27c3647..fe100ad 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -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)) } } diff --git a/internal/docker/client.go b/internal/docker/client.go index 16a5f29..2a9ac90 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -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 { diff --git a/internal/peer/peer.go b/internal/peer/peer.go index 52d6169..69c8ffe 100644 --- a/internal/peer/peer.go +++ b/internal/peer/peer.go @@ -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() } diff --git a/internal/peer/peer_test.go b/internal/peer/peer_test.go index 73fc054..bb78ece 100644 --- a/internal/peer/peer_test.go +++ b/internal/peer/peer_test.go @@ -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) } } diff --git a/internal/server/server.go b/internal/server/server.go index 7e669a5..85f52e7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index db3d67a..7242ce1 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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") diff --git a/internal/server/web/index.html b/internal/server/web/index.html index fea25c1..e03680b 100644 --- a/internal/server/web/index.html +++ b/internal/server/web/index.html @@ -12,12 +12,19 @@

{{.Title}}

-
+
diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index b3fc60d..91db677 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -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) { diff --git a/internal/server/web/static/app.js b/internal/server/web/static/app.js index e8b064b..b4abe8c 100644 --- a/internal/server/web/static/app.js +++ b/internal/server/web/static/app.js @@ -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); } } });