Tiles now show the public URL and, when set, the internal URL as a second link. Label names ignore case and - versus _. Containers HODA can't fully use are listed above the tiles with the reason: labels without enable, a missing url, or an unknown label name with a suggestion (category -> group). To find labels without enable, discovery reads all containers. A "Copy labels" button copies every label as a commented template, and falls back to the copy command where the clipboard API is missing (plain http on the LAN). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c58cc329b0
commit
60b4c79b0f
15 files changed
+662
-234
No files matched your search
@@ -29,12 +29,18 @@ services:
|
||||
| `sema.dashboard.url` | Yes | Link the tile opens (http or https) |
|
||||
| `sema.dashboard.name` | No | Tile title; defaults to the compose service name |
|
||||
| `sema.dashboard.icon` | No | A [dashboard-icons](https://github.com/homarr-labs/dashboard-icons) name (`jellyfin`) or an image URL |
|
||||
| `sema.dashboard.internal_url` | No | Where HODA fetches the favicon when the public URL sits behind Pangolin's login |
|
||||
| `sema.dashboard.internal_url` | No | LAN address, shown on the tile as a second link; HODA also fetches the favicon from it |
|
||||
| `sema.dashboard.description` | No | One line under the name |
|
||||
| `sema.dashboard.group` | No | Section heading; apps without one go under "Apps", last |
|
||||
| `sema.dashboard.order` | No | Position inside the group; ties sort by name |
|
||||
|
||||
A container with `enable` but a missing or invalid `url` stays off the dashboard and is logged once.
|
||||
Label names ignore case and `-` versus `_`, so `sema.dashboard.Group` and `sema.dashboard.internal-url`
|
||||
work too. The **Copy labels** button at the top of the dashboard copies this whole set as a commented
|
||||
template.
|
||||
|
||||
Anything HODA can't use is listed in a **label problems** panel above the tiles: a container with
|
||||
`sema.dashboard` labels but no `enable: "true"`, a missing or invalid `url`, or an unknown label name
|
||||
(with a suggestion, such as `category` → `group`). Containers without a `url` aren't shown until it's fixed.
|
||||
|
||||
**Icons**, first match wins: the icon label, the dashboard-icons set by app name, then by image name
|
||||
(generic images such as `nginx` or `node` are skipped), the app's own favicon, and finally the app's
|
||||
@@ -42,11 +48,27 @@ first letter. Icons are cached in `/data/icons` and refreshed daily.
|
||||
|
||||
## Deploy
|
||||
|
||||
- Host 1, the primary: [deploy/primary/compose.yml](deploy/primary/compose.yml)
|
||||
- Host 2, the agent: [deploy/agent/compose.yml](deploy/agent/compose.yml)
|
||||
On the main host ([docker-compose.yml](docker-compose.yml)):
|
||||
|
||||
Both need the same `HODA_PEER_TOKEN` in a `.env` file next to them ([deploy/.env.example](deploy/.env.example)).
|
||||
Publish the primary through Pangolin with SSO on; give it no public port of its own.
|
||||
```sh
|
||||
cp .env.example .env
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
HODA then listens on `10.20.30.10:10040` (`HODA_BIND_IP`, `HODA_PORT`). In Pangolin, point the
|
||||
apps.mariosem.es resource at `http://10.20.30.10:10040` and turn SSO on. The stack brings its own
|
||||
read-only socket proxy.
|
||||
|
||||
**A second Docker host** runs the agent ([docker-compose.agent.yml](docker-compose.agent.yml)), which
|
||||
publishes port 8081 on that host's LAN IP only:
|
||||
|
||||
```sh
|
||||
cp .env.agent.example .env # set HODA_PEER_TOKEN and HODA_AGENT_BIND_IP
|
||||
docker compose -f docker-compose.agent.yml pull && docker compose -f docker-compose.agent.yml up -d
|
||||
```
|
||||
|
||||
Then set `HODA_AGENT_URL` (for example `http://10.20.30.11:8081`) and the same `HODA_PEER_TOKEN`
|
||||
(`openssl rand -hex 32`) in the main host's `.env`, and run `docker compose up -d` there again.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+99
-33
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -31,6 +32,7 @@ type App struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
InternalURL string `json:"internalUrl,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Group string `json:"group"`
|
||||
Order int `json:"order"`
|
||||
@@ -38,43 +40,107 @@ type App struct {
|
||||
State State `json:"state"`
|
||||
Host string `json:"host"`
|
||||
|
||||
IconHint string `json:"-"`
|
||||
InternalURL string `json:"-"`
|
||||
Image string `json:"-"`
|
||||
IconHint string `json:"-"`
|
||||
Image string `json:"-"`
|
||||
}
|
||||
|
||||
// FromContainer reads an app from a container's labels. ok is false when the
|
||||
// container has not opted in; err says why an opted-in container can't be shown.
|
||||
func FromContainer(c docker.Container, prefix, host string) (app App, ok bool, err error) {
|
||||
label := func(key string) string { return strings.TrimSpace(c.Labels[prefix+"."+key]) }
|
||||
if enabled, _ := strconv.ParseBool(label("enable")); !enabled {
|
||||
return App{}, false, nil
|
||||
}
|
||||
app = App{
|
||||
ID: ID(host, c.Name()),
|
||||
Name: firstNonEmpty(label("name"), c.Labels["com.docker.compose.service"], c.Name()),
|
||||
Description: label("description"),
|
||||
Group: firstNonEmpty(label("group"), DefaultGroup),
|
||||
State: stateOf(c),
|
||||
Host: host,
|
||||
IconHint: label("icon"),
|
||||
Image: c.Image,
|
||||
}
|
||||
if app.URL = label("url"); app.URL == "" {
|
||||
return App{}, true, fmt.Errorf("missing %s.url label", prefix)
|
||||
}
|
||||
if !IsWebURL(app.URL) {
|
||||
return App{}, true, fmt.Errorf("%s.url %q is not an http or https URL", prefix, app.URL)
|
||||
}
|
||||
if app.InternalURL = label("internal_url"); app.InternalURL != "" && !IsWebURL(app.InternalURL) {
|
||||
return App{}, true, fmt.Errorf("%s.internal_url %q is not an http or https URL", prefix, app.InternalURL)
|
||||
}
|
||||
if raw := label("order"); raw != "" {
|
||||
if app.Order, err = strconv.Atoi(raw); err != nil {
|
||||
return App{}, true, fmt.Errorf("%s.order %q is not a whole number", prefix, raw)
|
||||
// Issue is a label problem on one container, listed on the page so it can be fixed.
|
||||
type Issue struct {
|
||||
Host string `json:"host"`
|
||||
Container string `json:"container"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// labelNames are the labels HODA reads, without the prefix.
|
||||
var labelNames = []string{"enable", "url", "internal_url", "name", "description", "group", "order", "icon"}
|
||||
|
||||
// lookalikes maps names people reach for to the label HODA reads.
|
||||
var lookalikes = map[string]string{
|
||||
"enabled": "enable", "show": "enable",
|
||||
"href": "url", "link": "url", "remote": "url", "remote_url": "url", "public": "url", "public_url": "url",
|
||||
"external": "url", "external_url": "url",
|
||||
"internal": "internal_url", "internalurl": "internal_url", "local": "internal_url", "local_url": "internal_url",
|
||||
"lan": "internal_url", "lan_url": "internal_url", "private_url": "internal_url",
|
||||
"title": "name", "label": "name",
|
||||
"desc": "description", "subtitle": "description", "info": "description",
|
||||
"category": "group", "section": "group", "groups": "group",
|
||||
"sort": "order", "position": "order", "weight": "order", "priority": "order", "rank": "order",
|
||||
"logo": "icon", "image": "icon", "favicon": "icon",
|
||||
}
|
||||
|
||||
// Parse reads a container's dashboard labels. Label names are matched
|
||||
// regardless of case and of - or _. A container without any of them is not a
|
||||
// dashboard app: show is false and there are no problems. Otherwise show says
|
||||
// whether the app gets a tile, and problems explain anything wrong, shown or not.
|
||||
func Parse(c docker.Container, prefix, host string) (app App, show bool, problems []string) {
|
||||
labels := map[string]string{}
|
||||
var unknown []string
|
||||
lowerPrefix := strings.ToLower(prefix) + "."
|
||||
for key, value := range c.Labels {
|
||||
lower := strings.ToLower(key)
|
||||
if !strings.HasPrefix(lower, lowerPrefix) {
|
||||
continue
|
||||
}
|
||||
name := strings.ReplaceAll(strings.TrimPrefix(lower, lowerPrefix), "-", "_")
|
||||
labels[name] = strings.TrimSpace(value)
|
||||
if !slices.Contains(labelNames, name) {
|
||||
unknown = append(unknown, key)
|
||||
}
|
||||
}
|
||||
return app, true, nil
|
||||
if len(labels) == 0 {
|
||||
return App{}, false, nil
|
||||
}
|
||||
|
||||
raw, has := labels["enable"]
|
||||
enabled, err := strconv.ParseBool(raw)
|
||||
switch {
|
||||
case !has:
|
||||
return App{}, false, []string{fmt.Sprintf("has %s labels but no %s.enable: \"true\", so it isn't shown", prefix, prefix)}
|
||||
case err != nil:
|
||||
return App{}, false, []string{fmt.Sprintf("%s.enable is %q; use \"true\" to show it", prefix, raw)}
|
||||
case !enabled:
|
||||
return App{}, false, nil // hidden on purpose
|
||||
}
|
||||
|
||||
slices.Sort(unknown)
|
||||
for _, key := range unknown {
|
||||
name := strings.ReplaceAll(strings.ToLower(key[len(lowerPrefix):]), "-", "_")
|
||||
if want, ok := lookalikes[name]; ok {
|
||||
problems = append(problems, fmt.Sprintf("unknown label %s; did you mean %s.%s?", key, prefix, want))
|
||||
} else {
|
||||
problems = append(problems, fmt.Sprintf("unknown label %s is ignored", key))
|
||||
}
|
||||
}
|
||||
|
||||
app = App{
|
||||
ID: ID(host, c.Name()),
|
||||
Name: firstNonEmpty(labels["name"], c.Labels["com.docker.compose.service"], c.Name()),
|
||||
Description: labels["description"],
|
||||
Group: firstNonEmpty(labels["group"], DefaultGroup),
|
||||
State: stateOf(c),
|
||||
Host: host,
|
||||
IconHint: labels["icon"],
|
||||
Image: c.Image,
|
||||
}
|
||||
switch app.URL = labels["url"]; {
|
||||
case app.URL == "":
|
||||
return App{}, false, append(problems, fmt.Sprintf("missing %s.url, so it isn't shown", prefix))
|
||||
case !IsWebURL(app.URL):
|
||||
return App{}, false, append(problems, fmt.Sprintf("%s.url %q needs http:// or https://, so it isn't shown", prefix, app.URL))
|
||||
}
|
||||
if v := labels["internal_url"]; v != "" {
|
||||
if IsWebURL(v) {
|
||||
app.InternalURL = v
|
||||
} else {
|
||||
problems = append(problems, fmt.Sprintf("%s.internal_url %q needs http:// or https://, so it is left out", prefix, v))
|
||||
}
|
||||
}
|
||||
if v := labels["order"]; v != "" {
|
||||
if app.Order, err = strconv.Atoi(v); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s.order %q is not a whole number, so 0 is used", prefix, v))
|
||||
}
|
||||
}
|
||||
return app, true, problems
|
||||
}
|
||||
|
||||
// ID is a short identifier for a container on a host. It survives the
|
||||
|
||||
+56
-39
@@ -21,8 +21,12 @@ func container(state, status string, labels map[string]string) docker.Container
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContainer(t *testing.T) {
|
||||
full := map[string]string{
|
||||
func parse(labels map[string]string) (App, bool, []string) {
|
||||
return Parse(container("running", "Up 2 hours (healthy)", labels), prefix, "host-1")
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
app, show, problems := parse(map[string]string{
|
||||
"sema.dashboard.enable": "true",
|
||||
"sema.dashboard.name": "Gitea",
|
||||
"sema.dashboard.url": "https://git.mariosem.es",
|
||||
@@ -31,68 +35,78 @@ func TestFromContainer(t *testing.T) {
|
||||
"sema.dashboard.order": "10",
|
||||
"sema.dashboard.icon": "gitea",
|
||||
"sema.dashboard.internal_url": "http://gitea:3000",
|
||||
}
|
||||
app, ok, err := FromContainer(container("running", "Up 2 hours (healthy)", full), prefix, "host-1")
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("FromContainer = ok %v, err %v; want an app", ok, err)
|
||||
})
|
||||
if !show || len(problems) != 0 {
|
||||
t.Fatalf("show %v, problems %q; want a clean app", show, problems)
|
||||
}
|
||||
want := App{
|
||||
ID: ID("host-1", "gitea"), Name: "Gitea", URL: "https://git.mariosem.es", Group: "Dev",
|
||||
Description: "Git hosting and CI", Order: 10, State: StateRunning, Host: "host-1",
|
||||
IconHint: "gitea", InternalURL: "http://gitea:3000", Image: "gitea/gitea:1.22",
|
||||
ID: ID("host-1", "gitea"), Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000",
|
||||
Group: "Dev", Description: "Git hosting and CI", Order: 10, State: StateRunning, Host: "host-1",
|
||||
IconHint: "gitea", Image: "gitea/gitea:1.22",
|
||||
}
|
||||
if app != want {
|
||||
t.Errorf("FromContainer =\n%+v\nwant\n%+v", app, want)
|
||||
t.Errorf("Parse =\n%+v\nwant\n%+v", app, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContainerDefaults(t *testing.T) {
|
||||
func TestParseIgnoresCaseAndDashes(t *testing.T) {
|
||||
app, show, problems := parse(map[string]string{
|
||||
"Sema.Dashboard.Enable": "True",
|
||||
"sema.dashboard.URL": "https://git.mariosem.es",
|
||||
"sema.dashboard.Group": "Dev",
|
||||
"sema.dashboard.internal-url": "http://10.20.30.10:3000",
|
||||
})
|
||||
if !show || len(problems) != 0 || app.Group != "Dev" || app.InternalURL != "http://10.20.30.10:3000" {
|
||||
t.Errorf("show %v, problems %q, app %+v", show, problems, app)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDefaults(t *testing.T) {
|
||||
labels := map[string]string{
|
||||
"sema.dashboard.enable": "TRUE",
|
||||
"sema.dashboard.enable": "1",
|
||||
"sema.dashboard.url": "http://192.168.1.10:8096",
|
||||
"com.docker.compose.service": "jellyfin",
|
||||
}
|
||||
app, ok, err := FromContainer(container("running", "Up 1 minute", labels), prefix, "host-1")
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("FromContainer = ok %v, err %v; want an app", ok, err)
|
||||
}
|
||||
app, _, _ := parse(labels)
|
||||
if app.Name != "jellyfin" || app.Group != DefaultGroup || app.Order != 0 {
|
||||
t.Errorf("defaults = name %q, group %q, order %d; want jellyfin, %s, 0", app.Name, app.Group, app.Order, DefaultGroup)
|
||||
}
|
||||
|
||||
delete(labels, "com.docker.compose.service")
|
||||
app, _, _ = FromContainer(container("running", "Up 1 minute", labels), prefix, "host-1")
|
||||
if app.Name != "gitea" {
|
||||
if app, _, _ = parse(labels); app.Name != "gitea" {
|
||||
t.Errorf("name without a compose service = %q, want the container name gitea", app.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContainerRejects(t *testing.T) {
|
||||
func TestParseProblems(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
wantOK bool
|
||||
wantErr string
|
||||
name string
|
||||
labels map[string]string
|
||||
wantShow bool
|
||||
want string // a word every problem list must contain; "" = no problems
|
||||
}{
|
||||
{"not opted in", map[string]string{"sema.dashboard.url": "https://x.example"}, false, ""},
|
||||
{"enable false", map[string]string{"sema.dashboard.enable": "false", "sema.dashboard.url": "https://x.example"}, false, ""},
|
||||
{"missing url", map[string]string{"sema.dashboard.enable": "true"}, true, "missing sema.dashboard.url"},
|
||||
{"script url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "javascript:alert(1)"}, true, "not an http or https URL"},
|
||||
{"relative url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "/gitea"}, true, "not an http or https URL"},
|
||||
{"no labels", map[string]string{"com.docker.compose.service": "x"}, false, ""},
|
||||
{"hidden on purpose", map[string]string{"sema.dashboard.enable": "false", "sema.dashboard.category": "x"}, false, ""},
|
||||
{"no enable", map[string]string{"sema.dashboard.url": "https://x.example", "sema.dashboard.group": "Dev"}, false, "no sema.dashboard.enable"},
|
||||
{"enable yes", map[string]string{"sema.dashboard.enable": "yes", "sema.dashboard.url": "https://x.example"}, false, `use "true"`},
|
||||
{"missing url", map[string]string{"sema.dashboard.enable": "true"}, false, "missing sema.dashboard.url"},
|
||||
{"script url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "javascript:alert(1)"}, false, "needs http"},
|
||||
{"lookalike url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.remote_url": "https://x.example"}, false, "did you mean sema.dashboard.url?"},
|
||||
{"lookalike group", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.category": "Media"}, true, "did you mean sema.dashboard.group?"},
|
||||
{"unknown label", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.colour": "red"}, true, "is ignored"},
|
||||
{"bad internal url", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.internal_url": "gitea:3000"}, true, "internal_url"},
|
||||
{"bad order", map[string]string{"sema.dashboard.enable": "true", "sema.dashboard.url": "https://x.example", "sema.dashboard.order": "first"}, true, "not a whole number"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, ok, err := FromContainer(container("running", "Up", tt.labels), prefix, "host-1")
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
||||
_, show, problems := parse(tt.labels)
|
||||
if show != tt.wantShow {
|
||||
t.Errorf("show = %v, want %v", show, tt.wantShow)
|
||||
}
|
||||
if tt.wantErr == "" && err != nil {
|
||||
t.Errorf("err = %v, want none", err)
|
||||
if tt.want == "" && len(problems) != 0 {
|
||||
t.Errorf("problems = %q, want none", problems)
|
||||
}
|
||||
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
|
||||
t.Errorf("err = %v, want one containing %q", err, tt.wantErr)
|
||||
if tt.want != "" && !strings.Contains(strings.Join(problems, "\n"), tt.want) {
|
||||
t.Errorf("problems = %q, want one containing %q", problems, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -132,13 +146,16 @@ func TestID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppJSONHidesInternalFields(t *testing.T) {
|
||||
app := App{ID: "0123456789ab", Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000", Image: "gitea/gitea", IconHint: "gitea"}
|
||||
func TestAppJSON(t *testing.T) {
|
||||
app := App{ID: "0123456789ab", Name: "Gitea", URL: "https://git.mariosem.es", InternalURL: "http://gitea:3000", Image: "gitea/gitea", IconHint: "gitea-hint"}
|
||||
raw, err := json.Marshal(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, leak := range []string{"gitea:3000", "gitea/gitea", "Hint", "Internal", "Image"} {
|
||||
if !strings.Contains(string(raw), `"internalUrl":"http://gitea:3000"`) {
|
||||
t.Errorf("JSON %s lacks the internal URL", raw)
|
||||
}
|
||||
for _, leak := range []string{"gitea/gitea", "gitea-hint", "Image", "Hint"} {
|
||||
if strings.Contains(string(raw), leak) {
|
||||
t.Errorf("JSON %s contains %q", raw, leak)
|
||||
}
|
||||
|
||||
+25
-18
@@ -23,17 +23,20 @@ type HostStatus struct {
|
||||
|
||||
// Response is the body of GET /api/apps, on the primary and on agents.
|
||||
type Response struct {
|
||||
Node string `json:"node"`
|
||||
Hosts []HostStatus `json:"hosts"`
|
||||
Apps []App `json:"apps"`
|
||||
Node string `json:"node"`
|
||||
Hosts []HostStatus `json:"hosts"`
|
||||
Apps []App `json:"apps"`
|
||||
Issues []Issue `json:"issues"`
|
||||
}
|
||||
|
||||
type source struct {
|
||||
status HostStatus
|
||||
apps []App
|
||||
issues []Issue
|
||||
}
|
||||
|
||||
// Store holds the latest apps of each source: this host, then one per agent.
|
||||
// Store holds the latest apps and label problems of each source: this host,
|
||||
// then one per agent.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
sources map[string]*source
|
||||
@@ -46,23 +49,23 @@ func NewStore() *Store {
|
||||
}
|
||||
|
||||
// SetApps records a successful read of a source.
|
||||
func (s *Store) SetApps(key, host string, list []App) {
|
||||
func (s *Store) SetApps(key, host string, list []App, issues []Issue) {
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sources[key] = &source{status: HostStatus{Name: host, OK: true, LastOK: &now}, apps: list}
|
||||
s.sources[key] = &source{status: HostStatus{Name: host, OK: true, LastOK: &now}, apps: list, issues: issues}
|
||||
}
|
||||
|
||||
// SetReported records an agent's own view of its host. An agent that can't
|
||||
// reach its Docker still sends its last known apps and when it last could.
|
||||
func (s *Store) SetReported(key string, status HostStatus, list []App) {
|
||||
func (s *Store) SetReported(key string, status HostStatus, list []App, issues []Issue) {
|
||||
if status.OK {
|
||||
now := s.now()
|
||||
status.LastOK = &now
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sources[key] = &source{status: status, apps: list}
|
||||
s.sources[key] = &source{status: status, apps: list, issues: issues}
|
||||
}
|
||||
|
||||
// SetError marks a source unreachable and keeps its last known apps.
|
||||
@@ -91,29 +94,33 @@ func (s *Store) Lookup(id string) (key string, app App, ok bool) {
|
||||
return "", App{}, false
|
||||
}
|
||||
|
||||
// Snapshot returns every host's status and all apps in display order. localIcon
|
||||
// gives the icon URL of this host's apps; agents send their own.
|
||||
func (s *Store) Snapshot(localIcon func(id string) string) ([]HostStatus, []App) {
|
||||
// Snapshot returns every host's status, all apps in display order and all
|
||||
// label problems. localIcon gives the icon URL of this host's apps; agents send
|
||||
// their own. The caller fills in Node.
|
||||
func (s *Store) Snapshot(localIcon func(id string) string) Response {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
hosts := make([]HostStatus, 0, len(s.sources))
|
||||
list := []App{}
|
||||
resp := Response{Hosts: make([]HostStatus, 0, len(s.sources)), Apps: []App{}, Issues: []Issue{}}
|
||||
for key, src := range s.sources {
|
||||
status := src.status
|
||||
if status.OK {
|
||||
status.LastOK = nil
|
||||
}
|
||||
hosts = append(hosts, status)
|
||||
resp.Hosts = append(resp.Hosts, status)
|
||||
for _, a := range src.apps {
|
||||
if key == LocalSource && localIcon != nil {
|
||||
a.Icon = localIcon(a.ID)
|
||||
}
|
||||
list = append(list, a)
|
||||
resp.Apps = append(resp.Apps, a)
|
||||
}
|
||||
resp.Issues = append(resp.Issues, src.issues...)
|
||||
}
|
||||
slices.SortFunc(hosts, func(a, b HostStatus) int { return strings.Compare(a.Name, b.Name) })
|
||||
slices.SortFunc(list, compareApps)
|
||||
return hosts, list
|
||||
slices.SortFunc(resp.Hosts, func(a, b HostStatus) int { return strings.Compare(a.Name, b.Name) })
|
||||
slices.SortFunc(resp.Apps, compareApps)
|
||||
slices.SortFunc(resp.Issues, func(a, b Issue) int {
|
||||
return cmp.Or(strings.Compare(a.Host, b.Host), strings.Compare(a.Container, b.Container), strings.Compare(a.Message, b.Message))
|
||||
})
|
||||
return resp
|
||||
}
|
||||
|
||||
// compareApps orders by group, with apps that have none last, then the order
|
||||
|
||||
+30
-17
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,11 +87,12 @@ func New(host string) (*Client, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListContainers returns every container, running or not, that has the label key.
|
||||
// ListContainers returns every container, running or not, that has the label
|
||||
// key; an empty key returns them all.
|
||||
func (c *Client) ListContainers(ctx context.Context, labelKey string) ([]Container, error) {
|
||||
q := url.Values{
|
||||
"all": {"1"},
|
||||
"filters": {filters(map[string][]string{"label": {labelKey}})},
|
||||
q := url.Values{"all": {"1"}}
|
||||
if labelKey != "" {
|
||||
q.Set("filters", filters(map[string][]string{"label": {labelKey}}))
|
||||
}
|
||||
resp, err := c.get(ctx, c.api, "/containers/json?"+q.Encode())
|
||||
if err != nil {
|
||||
|
||||
+11
-1
@@ -101,6 +101,9 @@ func (p *Puller) pull(ctx context.Context, peer string) {
|
||||
if !apps.ValidID(a.ID) || !apps.IsWebURL(a.URL) {
|
||||
continue
|
||||
}
|
||||
if a.InternalURL != "" && !apps.IsWebURL(a.InternalURL) {
|
||||
a.InternalURL = ""
|
||||
}
|
||||
if !strings.HasPrefix(a.Icon, "/icons/"+a.ID+"?") {
|
||||
a.Icon = ""
|
||||
}
|
||||
@@ -109,10 +112,17 @@ func (p *Puller) pull(ctx context.Context, peer string) {
|
||||
}
|
||||
list = append(list, a)
|
||||
}
|
||||
issues := make([]apps.Issue, 0, len(body.Issues))
|
||||
for _, issue := range body.Issues {
|
||||
if issue.Host == "" {
|
||||
issue.Host = status.Name
|
||||
}
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.names[peer] = status.Name
|
||||
p.mu.Unlock()
|
||||
p.store.SetReported(peer, status, list)
|
||||
p.store.SetReported(peer, status, list, issues)
|
||||
p.mark(peer, true, nil)
|
||||
p.pruneIcons()
|
||||
}
|
||||
|
||||
+19
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -12,12 +12,19 @@
|
||||
<body data-link-target="{{.LinkTarget}}" data-label-prefix="{{.LabelPrefix}}">
|
||||
<header class="top">
|
||||
<h1 class="title">{{.Title}}</h1>
|
||||
<div class="search">
|
||||
<input id="search" type="search" placeholder="Search apps" autocomplete="off" spellcheck="false" aria-label="Search apps">
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
<div class="actions">
|
||||
<div class="search">
|
||||
<input id="search" type="search" placeholder="Search apps" autocomplete="off" spellcheck="false" aria-label="Search apps">
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</div>
|
||||
<button id="copy-labels" class="button" type="button" title="Copy a template of every HODA label, ready to paste into a service">
|
||||
<svg class="glyph" viewBox="0 0 16 16" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V4A1.5 1.5 0 0 0 9 2.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5"/></svg>
|
||||
<span class="button-text">Copy labels</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="banners" class="banners" role="status" aria-live="polite"></div>
|
||||
<div id="issues" class="issues-wrap"></div>
|
||||
<main id="groups" class="groups" aria-busy="true"></main>
|
||||
<noscript><p class="note">HODA needs JavaScript to list your apps.</p></noscript>
|
||||
</body>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user