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 @@