Apps opt in with sema.dashboard.* labels in their own compose file. The primary reads its host's containers through a read-only socket proxy, follows Docker events, and merges in what the agent on the second host reports. Icons come from the icon label, the dashboard-icons set or the app's favicon, with a letter as the fallback. Go with only the standard library, a distroless image, and a Gitea Actions workflow copied from FiMa's: checks in the Dockerfile's check stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9d6a921c34
32 files changed
+3571
No files matched your search
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitea
|
||||
deploy
|
||||
data
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,118 @@
|
||||
# HODA CI/CD on Gitea Actions, following the conventions of the other msemes/* repos (FiMa's ci.yml):
|
||||
# - the `self-hosted` runner label, which drives the host Docker daemon through the mounted socket
|
||||
# - actions mirrored into git.mariosem.es, so the runner never needs public GitHub
|
||||
# - checks run inside Docker, so the runner needs no Go toolchain
|
||||
#
|
||||
# Every push and pull request: gofmt, go vet and tests (the Dockerfile's check stage), then the image
|
||||
# build. Pushes to main and v* tags also scan the image with Trivy and push it to the Gitea registry.
|
||||
#
|
||||
# Needs the REGISTRY_TOKEN secret (personal access token with write:package), set at user level.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize main builds so two quick pushes cannot race on :latest; PR runs cancel superseded runs.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: git.mariosem.es
|
||||
IMAGE: msemes/hoda
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.mariosem.es/actions/checkout@v4
|
||||
|
||||
- name: Metadata
|
||||
id: meta
|
||||
run: |
|
||||
sha="$(git rev-parse --short HEAD)"
|
||||
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
|
||||
case "${GITHUB_REF}" in
|
||||
refs/tags/v*) echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" ;;
|
||||
*) echo "version=${GITHUB_REF_NAME:-local}-${sha}" >> "$GITHUB_OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# gofmt, go vet and go test run in the Dockerfile's check stage; a failure fails the build.
|
||||
- name: Format, vet and test
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: docker build --target check .
|
||||
|
||||
- name: Build image
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: |
|
||||
tags="-t ${{ env.REGISTRY }}/${{ env.IMAGE }}:sha-${{ steps.meta.outputs.sha }}"
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
tags="${tags} -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest"
|
||||
fi
|
||||
case "${GITHUB_REF}" in
|
||||
refs/tags/v*) tags="${tags} -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${GITHUB_REF#refs/tags/v}" ;;
|
||||
esac
|
||||
docker build --platform linux/amd64 \
|
||||
--build-arg HODA_VERSION=${{ steps.meta.outputs.version }} \
|
||||
--label org.opencontainers.image.revision=${{ github.sha }} \
|
||||
${tags} .
|
||||
|
||||
# Same policy as the other repos: print HIGH and CRITICAL, block the push only on CRITICAL.
|
||||
- name: Scan image for known CVEs
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
img=${{ env.REGISTRY }}/${{ env.IMAGE }}:sha-${{ steps.meta.outputs.sha }}
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$HOME/.cache/trivy":/root/.cache/ \
|
||||
aquasec/trivy:latest image \
|
||||
--severity HIGH,CRITICAL --ignore-unfixed --no-progress "$img" || true
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$HOME/.cache/trivy":/root/.cache/ \
|
||||
aquasec/trivy:latest image \
|
||||
--exit-code 1 --severity CRITICAL --ignore-unfixed --no-progress "$img"
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
if: github.event_name == 'push'
|
||||
uses: https://git.mariosem.es/docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
# Personal access token with write:package; the injected GITEA_TOKEN has no package rights.
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Push image
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
set -e
|
||||
retry_push() {
|
||||
for attempt in 1 2 3; do
|
||||
docker push "$1" && return 0
|
||||
[ "$attempt" -lt 3 ] && echo "Push of $1 failed (attempt $attempt/3), retrying in 5s" && sleep 5
|
||||
done
|
||||
echo "Push of $1 failed after 3 attempts"
|
||||
return 1
|
||||
}
|
||||
base=${{ env.REGISTRY }}/${{ env.IMAGE }}
|
||||
retry_push "${base}:sha-${{ steps.meta.outputs.sha }}"
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then retry_push "${base}:latest"; fi
|
||||
case "${GITHUB_REF}" in refs/tags/v*) retry_push "${base}:${GITHUB_REF#refs/tags/v}" ;; esac
|
||||
# Keep the host tidy: drop the per-commit tag; :latest stays as the layer cache anchor.
|
||||
docker rmi "${base}:sha-${{ steps.meta.outputs.sha }}" >/dev/null 2>&1 || true
|
||||
|
||||
- name: Remove pull request image
|
||||
if: github.event_name != 'push'
|
||||
run: docker rmi "${{ env.REGISTRY }}/${{ env.IMAGE }}:sha-${{ steps.meta.outputs.sha }}" >/dev/null 2>&1 || true
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# runtime data (the icon cache when running locally)
|
||||
/data/
|
||||
|
||||
# env and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# local builds
|
||||
/hoda
|
||||
/hoda.exe
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# HODA image, built from the repository root.
|
||||
#
|
||||
# docker build -t hoda . # production image (default target)
|
||||
# docker build --target check . # CI: gofmt, go vet, go test
|
||||
|
||||
ARG GO_VERSION=1
|
||||
|
||||
FROM golang:${GO_VERSION}-alpine AS source
|
||||
WORKDIR /src
|
||||
# HODA uses only the standard library; go.sum appears once a dependency does.
|
||||
COPY go.mod go.sum* ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY . .
|
||||
|
||||
# CI checks: the build fails when formatting, vet or a test fails.
|
||||
FROM source AS check
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
test -z "$(gofmt -l .)" || { echo "Run gofmt on:"; gofmt -l .; exit 1; } \
|
||||
&& go vet ./... \
|
||||
&& go test ./...
|
||||
|
||||
FROM source AS build
|
||||
ARG HODA_VERSION=dev
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${HODA_VERSION}" -o /out/hoda ./cmd/hoda \
|
||||
&& mkdir /out/data
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot AS runner
|
||||
ARG HODA_VERSION=dev
|
||||
COPY --from=build /out/hoda /hoda
|
||||
COPY --from=build --chown=65532:65532 /out/data /data
|
||||
ENV DATA_DIR=/data
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/hoda", "healthcheck"]
|
||||
LABEL org.opencontainers.image.title="HODA" \
|
||||
org.opencontainers.image.description="Homelab dashboard built from Docker labels" \
|
||||
org.opencontainers.image.source="https://git.mariosem.es/msemes/HODA" \
|
||||
org.opencontainers.image.version="${HODA_VERSION}"
|
||||
ENTRYPOINT ["/hoda"]
|
||||
@@ -0,0 +1,94 @@
|
||||
# HODA
|
||||
|
||||
HODA (HOmelab DAshboard) shows every self-hosted app as a tile with its icon, name and link. Apps opt in
|
||||
with Docker labels in their own compose file, so adding an app never means editing the dashboard. It runs
|
||||
at apps.mariosem.es behind Pangolin, which handles login; HODA itself has none.
|
||||
|
||||
One image, two roles: the **primary** serves the page, and an **agent** on each other Docker host reports
|
||||
that host's apps to it. Each instance reads only its own Docker, read-only, through a socket proxy.
|
||||
|
||||
## Add an app
|
||||
|
||||
Put labels on the service in its compose file, then `docker compose up -d`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea
|
||||
labels:
|
||||
sema.dashboard.enable: "true"
|
||||
sema.dashboard.name: Gitea
|
||||
sema.dashboard.url: https://git.mariosem.es
|
||||
sema.dashboard.group: Dev
|
||||
sema.dashboard.description: Git hosting and CI
|
||||
```
|
||||
|
||||
| Label | Required | Effect |
|
||||
| --- | --- | --- |
|
||||
| `sema.dashboard.enable` | Yes | `true` opts the container in |
|
||||
| `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.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.
|
||||
|
||||
**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
|
||||
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)
|
||||
|
||||
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.
|
||||
|
||||
## Configuration
|
||||
|
||||
All environment variables are optional.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `MODE` | `primary` | `agent` serves only `/api/apps` and `/icons` to the primary, with no page |
|
||||
| `PEERS` | | Comma-separated agent URLs the primary pulls from |
|
||||
| `PEER_TOKEN` | | Shared token between primary and agents; required with `PEERS` or `MODE=agent` |
|
||||
| `PEER_INTERVAL` | `5s` | How often the primary pulls each agent |
|
||||
| `NODE_NAME` | container hostname | Host name in tile tooltips and search |
|
||||
| `DOCKER_HOST` | `unix:///var/run/docker.sock` | Docker API; point it at the socket proxy |
|
||||
| `LABEL_PREFIX` | `sema.dashboard` | Label namespace |
|
||||
| `DASHBOARD_TITLE` | `HODA` | Page header and browser tab title |
|
||||
| `PORT` | `8080` | HTTP port |
|
||||
| `LINK_TARGET` | `_blank` | `_self` opens apps in the same tab |
|
||||
| `RESYNC_INTERVAL` | `60s` | Full container re-list, on top of Docker events |
|
||||
| `ICON_REFRESH` | `24h` | How often cached icons are fetched again |
|
||||
| `ICON_CDN` | jsDelivr's dashboard-icons | Icon set base URL; empty turns the icon set off |
|
||||
| `DATA_DIR` | `/data` | Where the icon cache lives |
|
||||
| `LOG_LEVEL` | `info` | `debug` shows every icon lookup |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Path | Primary | Agent |
|
||||
| --- | --- | --- |
|
||||
| `/` | The dashboard | A one-line notice |
|
||||
| `/api/apps` | All hosts' apps as JSON, with an ETag | This host's apps; needs `Authorization: Bearer <PEER_TOKEN>` |
|
||||
| `/icons/<id>` | Cached icon, or fetched from the agent that owns it | This host's icons; needs the token |
|
||||
| `/healthz` | `ok` | `ok`, no token |
|
||||
|
||||
## Development
|
||||
|
||||
HODA is Go with only the standard library; the page is plain HTML, CSS and JavaScript embedded in the
|
||||
binary. Go doesn't need to be installed: everything runs in Docker.
|
||||
|
||||
```sh
|
||||
docker build --target check . # gofmt, go vet, go test
|
||||
docker build -t hoda . # the production image
|
||||
```
|
||||
|
||||
Gitea Actions ([.gitea/workflows/ci.yml](.gitea/workflows/ci.yml)) runs the checks on every push and pull
|
||||
request. Pushes to `main` publish `git.mariosem.es/msemes/hoda:latest` and `:sha-<short>`; a `v1.2.3` tag
|
||||
publishes `:1.2.3`. Images are scanned with Trivy, and a CRITICAL finding blocks the push.
|
||||
@@ -0,0 +1,116 @@
|
||||
// Command hoda runs the HODA homelab dashboard. MODE=primary serves the page
|
||||
// and merges apps from agents; MODE=agent only reports its own host's apps.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
"git.mariosem.es/msemes/hoda/internal/config"
|
||||
"git.mariosem.es/msemes/hoda/internal/discovery"
|
||||
"git.mariosem.es/msemes/hoda/internal/docker"
|
||||
"git.mariosem.es/msemes/hoda/internal/icons"
|
||||
"git.mariosem.es/msemes/hoda/internal/peer"
|
||||
"git.mariosem.es/msemes/hoda/internal/server"
|
||||
)
|
||||
|
||||
// version is set at build time with -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "healthcheck":
|
||||
os.Exit(healthcheck())
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command %q; the commands are healthcheck and version\n", os.Args[1])
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "hoda:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
|
||||
dc, err := docker.New(cfg.DockerHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
store := apps.NewStore()
|
||||
resolver := icons.New(filepath.Join(cfg.DataDir, "icons"), cfg.IconCDN, cfg.IconRefresh, log)
|
||||
go resolver.Run(ctx)
|
||||
go discovery.New(dc, store, resolver, cfg.LabelPrefix, cfg.NodeName, cfg.ResyncInterval, log).Run(ctx)
|
||||
|
||||
deps := server.Deps{Store: store, Icons: resolver}
|
||||
if len(cfg.Peers) > 0 {
|
||||
puller := peer.New(cfg.Peers, cfg.PeerToken, cfg.PeerInterval, store, log)
|
||||
go puller.Run(ctx)
|
||||
deps.Peers = puller
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: server.New(cfg, deps, version, log),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- srv.ListenAndServe() }()
|
||||
log.Info("HODA started", "version", version, "mode", cfg.Mode, "node", cfg.NodeName, "port", cfg.Port, "agents", len(cfg.Peers))
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
}
|
||||
log.Info("shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// healthcheck backs the image's HEALTHCHECK: distroless has no curl or wget.
|
||||
func healthcheck() int {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get("http://127.0.0.1:" + port + "/healthz")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Fprintln(os.Stderr, "healthz:", resp.Status)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copy to .env next to each compose.yml. Both hosts need the same token;
|
||||
# generate one with: openssl rand -hex 32
|
||||
HODA_PEER_TOKEN=
|
||||
# Pin a version instead of latest, e.g. 1.0.0 or sha-abc1234.
|
||||
HODA_TAG=latest
|
||||
@@ -0,0 +1,34 @@
|
||||
# HODA agent, on Docker host 2. Reports this host's labeled apps and their icons
|
||||
# to the primary on host 1; it serves no dashboard of its own.
|
||||
#
|
||||
# Set HODA_PEER_TOKEN in a .env file next to this one (the same value as on host 1),
|
||||
# and replace <host-2-lan-ip> so the port is only reachable on the LAN.
|
||||
services:
|
||||
hoda-agent:
|
||||
image: git.mariosem.es/msemes/hoda:${HODA_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: agent
|
||||
DOCKER_HOST: tcp://socket-proxy:2375
|
||||
NODE_NAME: host-2
|
||||
PEER_TOKEN: ${HODA_PEER_TOKEN:?set HODA_PEER_TOKEN in .env}
|
||||
volumes:
|
||||
- hoda-data:/data
|
||||
ports:
|
||||
- "<host-2-lan-ip>:8081:8080"
|
||||
depends_on:
|
||||
- socket-proxy
|
||||
|
||||
# Read-only Docker API: container lists and events, nothing else.
|
||||
socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONTAINERS: 1
|
||||
EVENTS: 1
|
||||
POST: 0
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
volumes:
|
||||
hoda-data:
|
||||
@@ -0,0 +1,45 @@
|
||||
# HODA primary, on Docker host 1. Serves apps.mariosem.es through Pangolin and
|
||||
# merges in the apps the agent on host 2 reports.
|
||||
#
|
||||
# Set HODA_PEER_TOKEN in a .env file next to this one (the same value as on host 2),
|
||||
# and replace <host-2-lan-ip>. There is no published port: Pangolin reaches hoda:8080
|
||||
# over a Docker network it shares with this stack. Create the apps.mariosem.es
|
||||
# resource in the Pangolin UI with SSO on, or keep the pangolin.* labels below if
|
||||
# Pangolin's site connector reads this host's Docker labels.
|
||||
services:
|
||||
hoda:
|
||||
image: git.mariosem.es/msemes/hoda:${HODA_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: primary
|
||||
DOCKER_HOST: tcp://socket-proxy:2375
|
||||
DASHBOARD_TITLE: apps.mariosem.es
|
||||
NODE_NAME: host-1
|
||||
PEERS: http://<host-2-lan-ip>:8081
|
||||
PEER_TOKEN: ${HODA_PEER_TOKEN:?set HODA_PEER_TOKEN in .env}
|
||||
volumes:
|
||||
- hoda-data:/data
|
||||
depends_on:
|
||||
- socket-proxy
|
||||
labels:
|
||||
pangolin.public-resources.hoda.name: HODA
|
||||
pangolin.public-resources.hoda.mode: http
|
||||
pangolin.public-resources.hoda.full-domain: apps.mariosem.es
|
||||
pangolin.public-resources.hoda.auth.sso-enabled: "true"
|
||||
pangolin.public-resources.hoda.targets[0].method: http
|
||||
pangolin.public-resources.hoda.targets[0].hostname: hoda
|
||||
pangolin.public-resources.hoda.targets[0].port: "8080"
|
||||
|
||||
# Read-only Docker API: container lists and events, nothing else.
|
||||
socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONTAINERS: 1
|
||||
EVENTS: 1
|
||||
POST: 0
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
volumes:
|
||||
hoda-data:
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package apps turns labeled containers into dashboard apps and keeps the
|
||||
// merged list from this host and any agents.
|
||||
package apps
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/docker"
|
||||
)
|
||||
|
||||
// State is what a tile's dot shows.
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateRunning State = "running"
|
||||
StateUnhealthy State = "unhealthy"
|
||||
StateStopped State = "stopped"
|
||||
)
|
||||
|
||||
// DefaultGroup holds apps that have no group label.
|
||||
const DefaultGroup = "Apps"
|
||||
|
||||
// App is one tile on the dashboard. Only the fields with JSON names leave the
|
||||
// process; the rest feed icon resolution.
|
||||
type App struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Group string `json:"group"`
|
||||
Order int `json:"order"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
State State `json:"state"`
|
||||
Host string `json:"host"`
|
||||
|
||||
IconHint string `json:"-"`
|
||||
InternalURL string `json:"-"`
|
||||
Image string `json:"-"`
|
||||
}
|
||||
|
||||
// FromContainer reads an app from a container's labels. ok is false when the
|
||||
// container has not opted in; err says why an opted-in container can't be shown.
|
||||
func FromContainer(c docker.Container, prefix, host string) (app App, ok bool, err error) {
|
||||
label := func(key string) string { return strings.TrimSpace(c.Labels[prefix+"."+key]) }
|
||||
if enabled, _ := strconv.ParseBool(label("enable")); !enabled {
|
||||
return App{}, false, nil
|
||||
}
|
||||
app = App{
|
||||
ID: ID(host, c.Name()),
|
||||
Name: firstNonEmpty(label("name"), c.Labels["com.docker.compose.service"], c.Name()),
|
||||
Description: label("description"),
|
||||
Group: firstNonEmpty(label("group"), DefaultGroup),
|
||||
State: stateOf(c),
|
||||
Host: host,
|
||||
IconHint: label("icon"),
|
||||
Image: c.Image,
|
||||
}
|
||||
if app.URL = label("url"); app.URL == "" {
|
||||
return App{}, true, fmt.Errorf("missing %s.url label", prefix)
|
||||
}
|
||||
if !IsWebURL(app.URL) {
|
||||
return App{}, true, fmt.Errorf("%s.url %q is not an http or https URL", prefix, app.URL)
|
||||
}
|
||||
if app.InternalURL = label("internal_url"); app.InternalURL != "" && !IsWebURL(app.InternalURL) {
|
||||
return App{}, true, fmt.Errorf("%s.internal_url %q is not an http or https URL", prefix, app.InternalURL)
|
||||
}
|
||||
if raw := label("order"); raw != "" {
|
||||
if app.Order, err = strconv.Atoi(raw); err != nil {
|
||||
return App{}, true, fmt.Errorf("%s.order %q is not a whole number", prefix, raw)
|
||||
}
|
||||
}
|
||||
return app, true, nil
|
||||
}
|
||||
|
||||
// ID is a short identifier for a container on a host. It survives the
|
||||
// container being re-created, because compose keeps the name.
|
||||
func ID(host, container string) string {
|
||||
sum := sha256.Sum256([]byte(host + "/" + container))
|
||||
return hex.EncodeToString(sum[:6])
|
||||
}
|
||||
|
||||
// ValidID reports whether id has the shape ID produces.
|
||||
func ValidID(id string) bool {
|
||||
if len(id) != 12 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if !strings.ContainsRune("0123456789abcdef", r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsWebURL reports whether raw is an absolute http or https URL.
|
||||
func IsWebURL(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
|
||||
func stateOf(c docker.Container) State {
|
||||
switch c.State {
|
||||
case "running":
|
||||
if strings.Contains(c.Status, "(unhealthy)") {
|
||||
return StateUnhealthy
|
||||
}
|
||||
return StateRunning
|
||||
case "restarting":
|
||||
return StateUnhealthy
|
||||
default:
|
||||
return StateStopped
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/docker"
|
||||
)
|
||||
|
||||
const prefix = "sema.dashboard"
|
||||
|
||||
func container(state, status string, labels map[string]string) docker.Container {
|
||||
return docker.Container{
|
||||
ID: "4f1c2d3e4f5a6b7c",
|
||||
Names: []string{"/gitea"},
|
||||
Image: "gitea/gitea:1.22",
|
||||
State: state,
|
||||
Status: status,
|
||||
Labels: labels,
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContainer(t *testing.T) {
|
||||
full := map[string]string{
|
||||
"sema.dashboard.enable": "true",
|
||||
"sema.dashboard.name": "Gitea",
|
||||
"sema.dashboard.url": "https://git.mariosem.es",
|
||||
"sema.dashboard.group": "Dev",
|
||||
"sema.dashboard.description": "Git hosting and CI",
|
||||
"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)
|
||||
}
|
||||
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",
|
||||
}
|
||||
if app != want {
|
||||
t.Errorf("FromContainer =\n%+v\nwant\n%+v", app, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContainerDefaults(t *testing.T) {
|
||||
labels := map[string]string{
|
||||
"sema.dashboard.enable": "TRUE",
|
||||
"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)
|
||||
}
|
||||
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" {
|
||||
t.Errorf("name without a compose service = %q, want the container name gitea", app.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContainerRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
wantOK bool
|
||||
wantErr string
|
||||
}{
|
||||
{"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"},
|
||||
{"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)
|
||||
}
|
||||
if tt.wantErr == "" && err != nil {
|
||||
t.Errorf("err = %v, want none", err)
|
||||
}
|
||||
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
|
||||
t.Errorf("err = %v, want one containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestState(t *testing.T) {
|
||||
tests := []struct {
|
||||
state, status string
|
||||
want State
|
||||
}{
|
||||
{"running", "Up 3 hours", StateRunning},
|
||||
{"running", "Up 3 hours (healthy)", StateRunning},
|
||||
{"running", "Up 2 minutes (health: starting)", StateRunning},
|
||||
{"running", "Up 3 hours (unhealthy)", StateUnhealthy},
|
||||
{"restarting", "Restarting (1) 5 seconds ago", StateUnhealthy},
|
||||
{"exited", "Exited (0) 2 days ago", StateStopped},
|
||||
{"paused", "Up 3 hours (Paused)", StateStopped},
|
||||
{"created", "Created", StateStopped},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := stateOf(container(tt.state, tt.status, nil)); got != tt.want {
|
||||
t.Errorf("state of %s / %q = %s, want %s", tt.state, tt.status, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestID(t *testing.T) {
|
||||
a, b := ID("host-1", "gitea"), ID("host-2", "gitea")
|
||||
if a == b {
|
||||
t.Error("the same container name on two hosts got the same ID")
|
||||
}
|
||||
if a != ID("host-1", "gitea") {
|
||||
t.Error("ID is not stable")
|
||||
}
|
||||
if !ValidID(a) || ValidID("../etc/passwd") || ValidID("ABCDEF123456") {
|
||||
t.Error("ValidID accepts or rejects the wrong IDs")
|
||||
}
|
||||
}
|
||||
|
||||
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"}
|
||||
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), leak) {
|
||||
t.Errorf("JSON %s contains %q", raw, leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LocalSource is the store key for the host this instance runs on. Agents are
|
||||
// stored under their URL.
|
||||
const LocalSource = "local"
|
||||
|
||||
// HostStatus says whether a Docker host answered on the last attempt. LastOK
|
||||
// is only sent while the host is unreachable, so the response stays the same
|
||||
// (and its ETag stable) while nothing changes.
|
||||
type HostStatus struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
LastOK *time.Time `json:"lastOk,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type source struct {
|
||||
status HostStatus
|
||||
apps []App
|
||||
}
|
||||
|
||||
// Store holds the latest apps of each source: this host, then one per agent.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
sources map[string]*source
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewStore returns an empty store.
|
||||
func NewStore() *Store {
|
||||
return &Store{sources: map[string]*source{}, now: time.Now}
|
||||
}
|
||||
|
||||
// SetApps records a successful read of a source.
|
||||
func (s *Store) SetApps(key, host string, list []App) {
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sources[key] = &source{status: HostStatus{Name: host, OK: true, LastOK: &now}, apps: list}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if status.OK {
|
||||
now := s.now()
|
||||
status.LastOK = &now
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sources[key] = &source{status: status, apps: list}
|
||||
}
|
||||
|
||||
// SetError marks a source unreachable and keeps its last known apps.
|
||||
func (s *Store) SetError(key, host string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
src, ok := s.sources[key]
|
||||
if !ok {
|
||||
src = &source{status: HostStatus{Name: host}}
|
||||
s.sources[key] = src
|
||||
}
|
||||
src.status.OK = false
|
||||
}
|
||||
|
||||
// Lookup finds an app by ID and the key of the source that reported it.
|
||||
func (s *Store) Lookup(id string) (key string, app App, ok bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for k, src := range s.sources {
|
||||
for _, a := range src.apps {
|
||||
if a.ID == id {
|
||||
return k, a, true
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
hosts := make([]HostStatus, 0, len(s.sources))
|
||||
list := []App{}
|
||||
for key, src := range s.sources {
|
||||
status := src.status
|
||||
if status.OK {
|
||||
status.LastOK = nil
|
||||
}
|
||||
hosts = append(hosts, status)
|
||||
for _, a := range src.apps {
|
||||
if key == LocalSource && localIcon != nil {
|
||||
a.Icon = localIcon(a.ID)
|
||||
}
|
||||
list = append(list, a)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(hosts, func(a, b HostStatus) int { return strings.Compare(a.Name, b.Name) })
|
||||
slices.SortFunc(list, compareApps)
|
||||
return hosts, list
|
||||
}
|
||||
|
||||
// compareApps orders by group, with apps that have none last, then the order
|
||||
// label, then name.
|
||||
func compareApps(a, b App) int {
|
||||
ungrouped := func(app App) int {
|
||||
if app.Group == DefaultGroup {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return cmp.Or(
|
||||
cmp.Compare(ungrouped(a), ungrouped(b)),
|
||||
cmp.Compare(strings.ToLower(a.Group), strings.ToLower(b.Group)),
|
||||
cmp.Compare(a.Order, b.Order),
|
||||
cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)),
|
||||
cmp.Compare(a.Host, b.Host),
|
||||
cmp.Compare(a.ID, b.ID),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSnapshotOrder(t *testing.T) {
|
||||
s := NewStore()
|
||||
s.SetApps(LocalSource, "host-1", []App{
|
||||
{ID: "000000000001", Name: "Vaultwarden", Group: "Dev"},
|
||||
{ID: "000000000002", Name: "gitea", Group: "Dev"},
|
||||
{ID: "000000000003", Name: "Jellyfin", Group: "Media"},
|
||||
{ID: "000000000004", Name: "Zebra", Group: "dev", Order: -1},
|
||||
{ID: "000000000006", Name: "Adminer", Group: DefaultGroup},
|
||||
})
|
||||
s.SetReported("http://host-2:8081", HostStatus{Name: "host-2", OK: true}, []App{
|
||||
{ID: "000000000005", Name: "Immich", Group: "Media", Host: "host-2"},
|
||||
})
|
||||
|
||||
hosts, list := s.Snapshot(nil)
|
||||
var names []string
|
||||
for _, a := range list {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
want := []string{"Zebra", "gitea", "Vaultwarden", "Immich", "Jellyfin", "Adminer"}
|
||||
if len(names) != len(want) {
|
||||
t.Fatalf("apps = %v, want %v", names, want)
|
||||
}
|
||||
for i := range want {
|
||||
if names[i] != want[i] {
|
||||
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)
|
||||
}
|
||||
for _, h := range hosts {
|
||||
if !h.OK || h.LastOK != nil {
|
||||
t.Errorf("host %+v: a reachable host is sent without lastOk", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotLocalIcons(t *testing.T) {
|
||||
s := NewStore()
|
||||
s.SetApps(LocalSource, "host-1", []App{{ID: "000000000001", Name: "Gitea"}})
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if key, _, ok := s.Lookup("000000000002"); !ok || key != "http://host-2:8081" {
|
||||
t.Errorf("Lookup = %q, %v; want the agent's key", key, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package config reads HODA's settings from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mode selects what an instance does: the primary serves the dashboard, an
|
||||
// agent only reports its own host's apps to the primary.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModePrimary Mode = "primary"
|
||||
ModeAgent Mode = "agent"
|
||||
)
|
||||
|
||||
// Config holds every setting; the README lists what each one does.
|
||||
type Config struct {
|
||||
Mode Mode
|
||||
Port string
|
||||
DockerHost string
|
||||
LabelPrefix string
|
||||
Title string
|
||||
NodeName string
|
||||
Peers []string
|
||||
PeerToken string
|
||||
PeerInterval time.Duration
|
||||
LinkTarget string
|
||||
ResyncInterval time.Duration
|
||||
IconRefresh time.Duration
|
||||
IconCDN string
|
||||
DataDir string
|
||||
LogLevel slog.Level
|
||||
}
|
||||
|
||||
// Load reads the configuration from the process environment.
|
||||
func Load() (Config, error) {
|
||||
return parse(os.Getenv)
|
||||
}
|
||||
|
||||
func parse(getenv func(string) string) (Config, error) {
|
||||
get := func(key, fallback string) string {
|
||||
if v := strings.TrimSpace(getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
c := Config{
|
||||
Mode: Mode(strings.ToLower(get("MODE", string(ModePrimary)))),
|
||||
Port: get("PORT", "8080"),
|
||||
DockerHost: get("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
||||
LabelPrefix: strings.TrimSuffix(get("LABEL_PREFIX", "sema.dashboard"), "."),
|
||||
Title: get("DASHBOARD_TITLE", "HODA"),
|
||||
NodeName: get("NODE_NAME", ""),
|
||||
PeerToken: get("PEER_TOKEN", ""),
|
||||
LinkTarget: get("LINK_TARGET", "_blank"),
|
||||
IconCDN: strings.TrimSuffix(get("ICON_CDN", "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons"), "/"),
|
||||
DataDir: get("DATA_DIR", "/data"),
|
||||
}
|
||||
if c.NodeName == "" {
|
||||
c.NodeName, _ = os.Hostname()
|
||||
}
|
||||
for _, p := range strings.Split(getenv("PEERS"), ",") {
|
||||
if p = strings.TrimSuffix(strings.TrimSpace(p), "/"); p != "" {
|
||||
c.Peers = append(c.Peers, p)
|
||||
}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
duration := func(key, fallback string) time.Duration {
|
||||
raw := get(key, fallback)
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil || d <= 0 {
|
||||
errs = append(errs, fmt.Errorf("%s=%q is not a positive duration such as 5s or 24h", key, raw))
|
||||
}
|
||||
return d
|
||||
}
|
||||
c.PeerInterval = duration("PEER_INTERVAL", "5s")
|
||||
c.ResyncInterval = duration("RESYNC_INTERVAL", "60s")
|
||||
c.IconRefresh = duration("ICON_REFRESH", "24h")
|
||||
|
||||
if err := c.LogLevel.UnmarshalText([]byte(get("LOG_LEVEL", "info"))); err != nil {
|
||||
errs = append(errs, errors.New("LOG_LEVEL must be debug, info, warn or error"))
|
||||
}
|
||||
if c.Mode != ModePrimary && c.Mode != ModeAgent {
|
||||
errs = append(errs, fmt.Errorf("MODE=%q must be primary or agent", c.Mode))
|
||||
}
|
||||
if n, err := strconv.Atoi(c.Port); err != nil || n < 1 || n > 65535 {
|
||||
errs = append(errs, fmt.Errorf("PORT=%q is not a valid port", c.Port))
|
||||
}
|
||||
if c.LinkTarget != "_blank" && c.LinkTarget != "_self" {
|
||||
errs = append(errs, fmt.Errorf("LINK_TARGET=%q must be _blank or _self", c.LinkTarget))
|
||||
}
|
||||
if c.Mode == ModeAgent && len(c.Peers) > 0 {
|
||||
errs = append(errs, errors.New("PEERS only applies to MODE=primary"))
|
||||
}
|
||||
if (c.Mode == ModeAgent || len(c.Peers) > 0) && c.PeerToken == "" {
|
||||
errs = append(errs, errors.New("PEER_TOKEN is required when MODE=agent or PEERS is set"))
|
||||
}
|
||||
for _, p := range c.Peers {
|
||||
if !strings.HasPrefix(p, "http://") && !strings.HasPrefix(p, "https://") {
|
||||
errs = append(errs, fmt.Errorf("PEERS entry %q must start with http:// or https://", p))
|
||||
}
|
||||
}
|
||||
return c, errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func env(vars map[string]string) func(string) string {
|
||||
return func(key string) string { return vars[key] }
|
||||
}
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
c, err := parse(env(map[string]string{"NODE_NAME": "host-1"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Mode != ModePrimary || c.Port != "8080" || c.LabelPrefix != "sema.dashboard" || c.Title != "HODA" ||
|
||||
c.LinkTarget != "_blank" || c.DataDir != "/data" || c.DockerHost != "unix:///var/run/docker.sock" {
|
||||
t.Errorf("defaults = %+v", c)
|
||||
}
|
||||
if c.PeerInterval != 5*time.Second || c.ResyncInterval != time.Minute || c.IconRefresh != 24*time.Hour {
|
||||
t.Errorf("default intervals = %s, %s, %s", c.PeerInterval, c.ResyncInterval, c.IconRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeers(t *testing.T) {
|
||||
c, err := parse(env(map[string]string{
|
||||
"PEERS": " http://10.0.0.12:8081/ , ,https://host-3.lan ",
|
||||
"PEER_TOKEN": "secret",
|
||||
"LABEL_PREFIX": "sema.dashboard.",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Peers) != 2 || c.Peers[0] != "http://10.0.0.12:8081" || c.Peers[1] != "https://host-3.lan" {
|
||||
t.Errorf("peers = %q", c.Peers)
|
||||
}
|
||||
if c.LabelPrefix != "sema.dashboard" {
|
||||
t.Errorf("label prefix = %q, want the trailing dot dropped", c.LabelPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
vars map[string]string
|
||||
want string
|
||||
}{
|
||||
{map[string]string{"MODE": "agent"}, "PEER_TOKEN is required"},
|
||||
{map[string]string{"PEERS": "http://10.0.0.12:8081"}, "PEER_TOKEN is required"},
|
||||
{map[string]string{"MODE": "agent", "PEER_TOKEN": "x", "PEERS": "http://a"}, "PEERS only applies"},
|
||||
{map[string]string{"PEERS": "10.0.0.12:8081", "PEER_TOKEN": "x"}, "must start with http"},
|
||||
{map[string]string{"MODE": "server"}, "must be primary or agent"},
|
||||
{map[string]string{"PORT": "80a"}, "not a valid port"},
|
||||
{map[string]string{"PEER_INTERVAL": "5"}, "not a positive duration"},
|
||||
{map[string]string{"LINK_TARGET": "_top"}, "must be _blank or _self"},
|
||||
{map[string]string{"LOG_LEVEL": "loud"}, "LOG_LEVEL"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
_, err := parse(env(tt.vars))
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Errorf("parse(%v) error = %v, want one containing %q", tt.vars, err, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package discovery keeps the store in step with this host's labeled containers.
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
"git.mariosem.es/msemes/hoda/internal/docker"
|
||||
)
|
||||
|
||||
const (
|
||||
settle = 300 * time.Millisecond // lets a burst of events become one re-list
|
||||
minBackoff = time.Second
|
||||
maxBackoff = 30 * time.Second
|
||||
)
|
||||
|
||||
// IconQueue is told about the current apps so their icons get resolved.
|
||||
type IconQueue interface {
|
||||
Ensure([]apps.App)
|
||||
}
|
||||
|
||||
// Discovery lists this host's labeled containers into the store.
|
||||
type Discovery struct {
|
||||
docker *docker.Client
|
||||
store *apps.Store
|
||||
icons IconQueue
|
||||
prefix string
|
||||
host string
|
||||
resync time.Duration
|
||||
log *slog.Logger
|
||||
|
||||
reachable bool
|
||||
count int
|
||||
warned map[string]string // container name -> the label problem already logged
|
||||
}
|
||||
|
||||
// New returns a Discovery for the Docker host behind dc, named host on the dashboard.
|
||||
func New(dc *docker.Client, store *apps.Store, icons IconQueue, prefix, host string, resync time.Duration, log *slog.Logger) *Discovery {
|
||||
return &Discovery{
|
||||
docker: dc,
|
||||
store: store,
|
||||
icons: icons,
|
||||
prefix: prefix,
|
||||
host: host,
|
||||
resync: resync,
|
||||
log: log,
|
||||
reachable: true,
|
||||
count: -1,
|
||||
warned: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Run lists containers now, again after every relevant Docker event and every
|
||||
// resync interval, until ctx ends.
|
||||
func (d *Discovery) Run(ctx context.Context) {
|
||||
changed := make(chan struct{}, 1)
|
||||
go d.watch(ctx, func() {
|
||||
select {
|
||||
case changed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
ticker := time.NewTicker(d.resync)
|
||||
defer ticker.Stop()
|
||||
d.sync(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-changed:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(settle):
|
||||
}
|
||||
select {
|
||||
case <-changed:
|
||||
default:
|
||||
}
|
||||
}
|
||||
d.sync(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
d.store.SetError(apps.LocalSource, d.host)
|
||||
if d.reachable {
|
||||
d.log.Warn("can't reach Docker, keeping the last known apps", "err", err)
|
||||
}
|
||||
d.reachable = false
|
||||
return
|
||||
}
|
||||
if !d.reachable {
|
||||
d.log.Info("Docker is reachable again")
|
||||
}
|
||||
d.reachable = true
|
||||
|
||||
list := make([]apps.App, 0, len(containers))
|
||||
seen := make(map[string]bool, len(containers))
|
||||
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()
|
||||
}
|
||||
continue
|
||||
}
|
||||
delete(d.warned, name)
|
||||
list = append(list, app)
|
||||
}
|
||||
for name := range d.warned {
|
||||
if !seen[name] {
|
||||
delete(d.warned, name)
|
||||
}
|
||||
}
|
||||
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.icons.Ensure(list)
|
||||
}
|
||||
|
||||
// watch follows Docker events and reconnects with backoff when the stream breaks.
|
||||
func (d *Discovery) watch(ctx context.Context, changed func()) {
|
||||
backoff := minBackoff
|
||||
for {
|
||||
started := time.Now()
|
||||
err := d.docker.Events(ctx, d.prefix+".enable", func(ev docker.Event) {
|
||||
if relevant(ev.Action) {
|
||||
changed()
|
||||
}
|
||||
})
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if time.Since(started) > time.Minute {
|
||||
backoff = minBackoff
|
||||
}
|
||||
d.log.Debug("Docker event stream ended, reconnecting", "err", err, "in", backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff = min(backoff*2, maxBackoff)
|
||||
changed() // events may have been missed while disconnected
|
||||
}
|
||||
}
|
||||
|
||||
// relevant drops the event chatter of healthchecks and `docker exec`, which
|
||||
// never changes what the dashboard shows.
|
||||
func relevant(action string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(action, "exec_"), strings.HasPrefix(action, "archive-path"):
|
||||
return false
|
||||
case action == "attach", action == "detach", action == "resize", action == "top",
|
||||
action == "commit", action == "copy", action == "export", action == "extract-to-dir":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
"git.mariosem.es/msemes/hoda/internal/docker"
|
||||
)
|
||||
|
||||
type recorder struct{ got []apps.App }
|
||||
|
||||
func (r *recorder) Ensure(list []apps.App) { r.got = list }
|
||||
|
||||
func TestSync(t *testing.T) {
|
||||
up := true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !up {
|
||||
http.Error(w, "down", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `[
|
||||
{"Id":"a","Names":["/gitea"],"State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"true","sema.dashboard.url":"https://git.mariosem.es"}},
|
||||
{"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"}}
|
||||
]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
dc, err := docker.New(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := apps.NewStore()
|
||||
icons := &recorder{}
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
if !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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelevant(t *testing.T) {
|
||||
for _, action := range []string{"start", "stop", "die", "destroy", "create", "health_status: unhealthy", "pause", "rename"} {
|
||||
if !relevant(action) {
|
||||
t.Errorf("%q is ignored", action)
|
||||
}
|
||||
}
|
||||
for _, action := range []string{"exec_create: sh -c curl", "exec_start: sh", "exec_die", "attach", "resize", "top", "archive-path"} {
|
||||
if relevant(action) {
|
||||
t.Errorf("%q triggers a re-list", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package docker is a small read-only client for the two Docker Engine API
|
||||
// calls HODA makes: listing containers and following container events. It
|
||||
// stands in for the full SDK so the image carries no third-party code.
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Container is the part of a GET /containers/json entry that HODA reads.
|
||||
type Container struct {
|
||||
ID string `json:"Id"`
|
||||
Names []string `json:"Names"`
|
||||
Image string `json:"Image"`
|
||||
State string `json:"State"`
|
||||
Status string `json:"Status"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
|
||||
// Name is the container name without Docker's leading slash.
|
||||
func (c Container) Name() string {
|
||||
if len(c.Names) > 0 {
|
||||
return strings.TrimPrefix(c.Names[0], "/")
|
||||
}
|
||||
if len(c.ID) > 12 {
|
||||
return c.ID[:12]
|
||||
}
|
||||
return c.ID
|
||||
}
|
||||
|
||||
// Event is the part of a GET /events message that HODA reads.
|
||||
type Event struct {
|
||||
Type string `json:"Type"`
|
||||
Action string `json:"Action"`
|
||||
}
|
||||
|
||||
// Client talks to one Docker Engine API endpoint.
|
||||
type Client struct {
|
||||
base string
|
||||
api *http.Client // bounded requests
|
||||
stream *http.Client // the long-lived events stream
|
||||
}
|
||||
|
||||
// New returns a client for host, which takes the forms DOCKER_HOST does:
|
||||
// unix:///var/run/docker.sock, tcp://socket-proxy:2375 or http(s)://host:port.
|
||||
func New(host string) (*Client, error) {
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DOCKER_HOST %q: %w", host, err)
|
||||
}
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 4,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
}
|
||||
var base string
|
||||
switch u.Scheme {
|
||||
case "unix":
|
||||
socket := u.Path
|
||||
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", socket)
|
||||
}
|
||||
base = "http://docker"
|
||||
case "tcp", "http":
|
||||
base = "http://" + u.Host
|
||||
case "https":
|
||||
base = "https://" + u.Host
|
||||
default:
|
||||
return nil, fmt.Errorf("DOCKER_HOST %q: scheme must be unix, tcp, http or https", host)
|
||||
}
|
||||
if u.Scheme != "unix" && u.Host == "" {
|
||||
return nil, fmt.Errorf("DOCKER_HOST %q: missing host", host)
|
||||
}
|
||||
return &Client{
|
||||
base: base,
|
||||
api: &http.Client{Transport: transport, Timeout: 10 * time.Second},
|
||||
stream: &http.Client{Transport: transport},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListContainers returns every container, running or not, that has the label key.
|
||||
func (c *Client) ListContainers(ctx context.Context, labelKey string) ([]Container, error) {
|
||||
q := url.Values{
|
||||
"all": {"1"},
|
||||
"filters": {filters(map[string][]string{"label": {labelKey}})},
|
||||
}
|
||||
resp, err := c.get(ctx, c.api, "/containers/json?"+q.Encode())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out []Container
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Events follows events of containers that have the label key and calls fn
|
||||
// for each one. It returns when ctx ends or the stream breaks.
|
||||
func (c *Client) Events(ctx context.Context, labelKey string, fn func(Event)) error {
|
||||
q := url.Values{
|
||||
"filters": {filters(map[string][]string{"type": {"container"}, "label": {labelKey}})},
|
||||
}
|
||||
resp, err := c.get(ctx, c.stream, "/events?"+q.Encode())
|
||||
if err != nil {
|
||||
return fmt.Errorf("events: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
for {
|
||||
var ev Event
|
||||
if err := dec.Decode(&ev); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("events: %w", err)
|
||||
}
|
||||
fn(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, client *http.Client, path string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func filters(f map[string][]string) string {
|
||||
b, _ := json.Marshal(f)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListContainers(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/containers/json" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var f map[string][]string
|
||||
if err := json.Unmarshal([]byte(r.URL.Query().Get("filters")), &f); err != nil || f["label"][0] != "sema.dashboard.enable" {
|
||||
t.Errorf("filters = %q, want the label key", r.URL.Query().Get("filters"))
|
||||
}
|
||||
if r.URL.Query().Get("all") != "1" {
|
||||
t.Error("stopped containers are not requested")
|
||||
}
|
||||
fmt.Fprint(w, `[{"Id":"abc","Names":["/gitea"],"Image":"gitea/gitea","State":"running","Status":"Up","Labels":{"sema.dashboard.enable":"true"},"Ports":[]}]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New("tcp://" + strings.TrimPrefix(srv.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := c.ListContainers(context.Background(), "sema.dashboard.enable")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Name() != "gitea" || list[0].Labels["sema.dashboard.enable"] != "true" {
|
||||
t.Errorf("containers = %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListContainersError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := New(srv.URL)
|
||||
if _, err := c.ListContainers(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("err = %v, want the 403 reported", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvents(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, `{"Type":"container","Action":"start","Actor":{"ID":"abc"}}`)
|
||||
fmt.Fprintln(w, `{"Type":"container","Action":"exec_start: sh -c true","Actor":{"ID":"abc"}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := New(srv.URL)
|
||||
var actions []string
|
||||
err := c.Events(context.Background(), "sema.dashboard.enable", func(ev Event) { actions = append(actions, ev.Action) })
|
||||
if err == nil {
|
||||
t.Error("Events returned no error when the stream ended")
|
||||
}
|
||||
if len(actions) != 2 || actions[0] != "start" {
|
||||
t.Errorf("actions = %q", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
for _, host := range []string{"unix:///var/run/docker.sock", "tcp://socket-proxy:2375", "https://docker.lan:2376"} {
|
||||
if _, err := New(host); err != nil {
|
||||
t.Errorf("New(%q) = %v", host, err)
|
||||
}
|
||||
}
|
||||
for _, host := range []string{"ssh://user@host", "tcp://", "socket-proxy:2375"} {
|
||||
if _, err := New(host); err == nil {
|
||||
t.Errorf("New(%q) accepted an unusable host", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
)
|
||||
|
||||
type candidate struct {
|
||||
desc string
|
||||
fetch func(context.Context) ([]byte, string, error)
|
||||
}
|
||||
|
||||
// candidates lists where to look for an app's icon, best first: the icon
|
||||
// label, the icon set by app name, then by image name, then the favicon.
|
||||
func (r *Resolver) candidates(app apps.App) []candidate {
|
||||
var out []candidate
|
||||
tried := map[string]bool{}
|
||||
hint := app.IconHint
|
||||
if apps.IsWebURL(hint) {
|
||||
out = append(out, candidate{"icon label " + hint, func(ctx context.Context) ([]byte, string, error) {
|
||||
return r.image(ctx, r.webHTTP, hint)
|
||||
}})
|
||||
} else if name := iconName(hint); name != "" {
|
||||
out = append(out, r.fromSet(name)...)
|
||||
tried[name] = true
|
||||
}
|
||||
// The name was chosen for the dashboard; the image may be a generic base.
|
||||
for _, name := range append([]string{slug(app.Name)}, imageNames(app.Image)...) {
|
||||
if name != "" && !tried[name] {
|
||||
out = append(out, r.fromSet(name)...)
|
||||
tried[name] = true
|
||||
}
|
||||
}
|
||||
page := app.InternalURL
|
||||
if page == "" {
|
||||
page = app.URL
|
||||
}
|
||||
out = append(out, candidate{"favicon of " + page, func(ctx context.Context) ([]byte, string, error) {
|
||||
return r.favicon(ctx, page)
|
||||
}})
|
||||
return out
|
||||
}
|
||||
|
||||
// fromSet looks a name up in the dashboard-icons set, SVG first.
|
||||
func (r *Resolver) fromSet(name string) []candidate {
|
||||
if r.cdn == "" {
|
||||
return nil
|
||||
}
|
||||
var out []candidate
|
||||
for _, format := range []string{"svg", "png"} {
|
||||
link := r.cdn + "/" + format + "/" + name + "." + format
|
||||
out = append(out, candidate{"dashboard-icons " + name + "." + format, func(ctx context.Context) ([]byte, string, error) {
|
||||
return r.image(ctx, r.cdnHTTP, link)
|
||||
}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// favicon reads the icon links of the app's page, largest first, then falls
|
||||
// back to /favicon.ico.
|
||||
func (r *Resolver) favicon(ctx context.Context, page string) ([]byte, string, error) {
|
||||
base, err := url.Parse(page)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var links []string
|
||||
if resp, err := get(ctx, r.webHTTP, page, "text/html,*/*;q=0.8"); err == nil {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxPageBytes))
|
||||
resp.Body.Close()
|
||||
links = iconLinks(body, resp.Request.URL)
|
||||
}
|
||||
if ico := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String(); !slices.Contains(links, ico) {
|
||||
links = append(links, ico)
|
||||
}
|
||||
var lastErr error
|
||||
for _, link := range links {
|
||||
data, ctype, err := r.image(ctx, r.webHTTP, link)
|
||||
if err == nil {
|
||||
return data, ctype, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
// image downloads link and checks that it really is an image: a login page
|
||||
// served with status 200 must not end up as an app's icon.
|
||||
func (r *Resolver) image(ctx context.Context, client *http.Client, link string) ([]byte, string, error) {
|
||||
resp, err := get(ctx, client, link, "image/*")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxIconBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(data) > maxIconBytes {
|
||||
return nil, "", fmt.Errorf("%s: larger than %d KB", link, maxIconBytes>>10)
|
||||
}
|
||||
ctype := sniff(data)
|
||||
if ctype == "" {
|
||||
return nil, "", fmt.Errorf("%s: not an image", link)
|
||||
}
|
||||
return data, ctype, nil
|
||||
}
|
||||
|
||||
func get(ctx context.Context, client *http.Client, link, accept string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", accept)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("%s: %s", link, resp.Status)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// sniff returns the image type of data, or "" if it isn't an image.
|
||||
func sniff(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
if t := http.DetectContentType(data); strings.HasPrefix(t, "image/") {
|
||||
return t
|
||||
}
|
||||
// SVG is XML, which DetectContentType reports as text.
|
||||
head := bytes.ToLower(data[:min(len(data), 1024)])
|
||||
if bytes.Contains(head, []byte("<svg")) && !bytes.Contains(head, []byte("<html")) {
|
||||
return "image/svg+xml"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
|
||||
tagAttr = regexp.MustCompile(`(?is)([a-z][a-z0-9:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))`)
|
||||
)
|
||||
|
||||
// iconLinks returns the icon links of an HTML page, largest first.
|
||||
func iconLinks(page []byte, base *url.URL) []string {
|
||||
type link struct {
|
||||
href string
|
||||
size int
|
||||
}
|
||||
var found []link
|
||||
for _, tag := range linkTag.FindAll(page, -1) {
|
||||
attrs := map[string]string{}
|
||||
for _, m := range tagAttr.FindAllSubmatch(tag, -1) {
|
||||
attrs[strings.ToLower(string(m[1]))] = html.UnescapeString(string(m[2]) + string(m[3]) + string(m[4]))
|
||||
}
|
||||
rel := strings.Fields(strings.ToLower(attrs["rel"]))
|
||||
touch := slices.Contains(rel, "apple-touch-icon") || slices.Contains(rel, "apple-touch-icon-precomposed")
|
||||
if !touch && !slices.Contains(rel, "icon") {
|
||||
continue
|
||||
}
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
if href == "" || strings.HasPrefix(href, "data:") {
|
||||
continue
|
||||
}
|
||||
u, err := base.Parse(href)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
continue
|
||||
}
|
||||
found = append(found, link{u.String(), iconSize(touch, attrs["sizes"], attrs["type"], u.Path)})
|
||||
}
|
||||
slices.SortStableFunc(found, func(a, b link) int { return b.size - a.size })
|
||||
var out []string
|
||||
for _, l := range found {
|
||||
if !slices.Contains(out, l.href) {
|
||||
out = append(out, l.href)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// iconSize ranks a link by its declared size. SVG scales, so it ranks first.
|
||||
func iconSize(touch bool, sizes, typ, path string) int {
|
||||
if strings.Contains(typ, "svg") || strings.HasSuffix(strings.ToLower(path), ".svg") || strings.EqualFold(strings.TrimSpace(sizes), "any") {
|
||||
return 1 << 16
|
||||
}
|
||||
best := 0
|
||||
for _, s := range strings.Fields(strings.ToLower(sizes)) {
|
||||
if w, _, ok := strings.Cut(s, "x"); ok {
|
||||
if n, err := strconv.Atoi(w); err == nil && n > best {
|
||||
best = n
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case best > 0:
|
||||
return best
|
||||
case touch:
|
||||
return 180
|
||||
default:
|
||||
return 32
|
||||
}
|
||||
}
|
||||
|
||||
// genericImages run many different apps, so their logo says nothing about the app.
|
||||
var genericImages = map[string]bool{
|
||||
"alpine": true, "busybox": true, "caddy": true, "debian": true, "eclipse-temurin": true,
|
||||
"golang": true, "httpd": true, "nginx": true, "nginx-unprivileged": true, "node": true,
|
||||
"openjdk": true, "php": true, "python": true, "ruby": true, "ubuntu": true,
|
||||
}
|
||||
|
||||
// imageNames turns an image reference into names to look up in the icon set:
|
||||
// "lscr.io/linuxserver/jellyfin:latest" gives "jellyfin", and
|
||||
// "ghcr.io/immich-app/immich-server:release" gives "immich-server", then "immich".
|
||||
func imageNames(image string) []string {
|
||||
if image == "" || strings.HasPrefix(image, "sha256:") {
|
||||
return nil
|
||||
}
|
||||
ref, _, _ := strings.Cut(image, "@")
|
||||
name := ref[strings.LastIndex(ref, "/")+1:]
|
||||
name, _, _ = strings.Cut(name, ":")
|
||||
if name = slug(name); name == "" || genericImages[name] {
|
||||
return nil
|
||||
}
|
||||
out := []string{name}
|
||||
for _, suffix := range []string{"-server", "-app", "-web", "-frontend", "-backend"} {
|
||||
if base, ok := strings.CutSuffix(name, suffix); ok && base != "" {
|
||||
out = append(out, base)
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// iconName turns an icon label into an icon set name: "Jellyfin.svg" gives "jellyfin".
|
||||
func iconName(hint string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(hint))
|
||||
for _, ext := range []string{".svg", ".png", ".webp"} {
|
||||
lower = strings.TrimSuffix(lower, ext)
|
||||
}
|
||||
return slug(lower)
|
||||
}
|
||||
|
||||
// slug lowercases s and joins its letters and digits with dashes: "Uptime Kuma" gives "uptime-kuma".
|
||||
func slug(s string) string {
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
} else if b.Len() > 0 && !dash {
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
}
|
||||
return strings.TrimSuffix(b.String(), "-")
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Package icons finds an icon for each app and keeps them cached on disk. It
|
||||
// tries, in order: the icon label, the dashboard-icons set by image name and
|
||||
// app name, then the app's own favicon. Apps with no icon get a letter in the UI.
|
||||
package icons
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
)
|
||||
|
||||
const (
|
||||
maxIconBytes = 512 << 10
|
||||
maxPageBytes = 1 << 20
|
||||
fetchTimeout = 3 * time.Second
|
||||
missRetry = time.Hour // an app that had no icon may simply have been down
|
||||
workers = 4
|
||||
indexFile = "index.json"
|
||||
userAgent = "Mozilla/5.0 (compatible; HODA; +https://git.mariosem.es/msemes/HODA)"
|
||||
)
|
||||
|
||||
// Icon is a cached image, ready to serve.
|
||||
type Icon struct {
|
||||
Data []byte
|
||||
ContentType string
|
||||
Version string
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Key string `json:"key"` // hash of the inputs; a change triggers a new lookup
|
||||
Source string `json:"source,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Version string `json:"version,omitempty"` // hash of the image; empty when none was found
|
||||
Checked time.Time `json:"checked"`
|
||||
data []byte
|
||||
}
|
||||
|
||||
// Resolver looks icons up in the background. URL and Lookup never wait on the network.
|
||||
type Resolver struct {
|
||||
dir string
|
||||
cdn string
|
||||
refresh time.Duration
|
||||
log *slog.Logger
|
||||
cdnHTTP *http.Client
|
||||
webHTTP *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
entries map[string]*entry
|
||||
queued map[string]bool
|
||||
queue chan apps.App
|
||||
|
||||
saveMu sync.Mutex
|
||||
}
|
||||
|
||||
// New returns a resolver that caches under dir; an empty dir keeps icons in
|
||||
// memory only. cdn is the dashboard-icons base URL; empty skips the icon set.
|
||||
func New(dir, cdn string, refresh time.Duration, log *slog.Logger) *Resolver {
|
||||
r := &Resolver{
|
||||
dir: dir,
|
||||
cdn: strings.TrimSuffix(cdn, "/"),
|
||||
refresh: refresh,
|
||||
log: log,
|
||||
cdnHTTP: &http.Client{Timeout: fetchTimeout},
|
||||
webHTTP: &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
// Homelab apps often run on self-signed certificates, and only images are read.
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
// A redirect to another host is a login page (Pangolin's SSO, for one), not the app.
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
return fmt.Errorf("redirected to %s, likely a login page", req.URL.Host)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
entries: map[string]*entry{},
|
||||
queued: map[string]bool{},
|
||||
queue: make(chan apps.App, 256),
|
||||
}
|
||||
r.load()
|
||||
return r
|
||||
}
|
||||
|
||||
// Run resolves queued apps until ctx ends.
|
||||
func (r *Resolver) Run(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case app := <-r.queue:
|
||||
r.resolve(ctx, app)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Ensure queues a lookup for every app whose icon is missing, stale or based
|
||||
// on labels that changed, and forgets the icons of apps that are gone.
|
||||
func (r *Resolver) Ensure(list []apps.App) {
|
||||
now := time.Now()
|
||||
present := make(map[string]bool, len(list))
|
||||
var todo []apps.App
|
||||
var gone []string
|
||||
r.mu.Lock()
|
||||
for _, app := range list {
|
||||
present[app.ID] = true
|
||||
e := r.entries[app.ID]
|
||||
if r.queued[app.ID] || (e != nil && e.Key == inputKey(app) && !r.stale(e, now)) {
|
||||
continue
|
||||
}
|
||||
r.queued[app.ID] = true
|
||||
todo = append(todo, app)
|
||||
}
|
||||
for id := range r.entries {
|
||||
if !present[id] {
|
||||
delete(r.entries, id)
|
||||
gone = append(gone, id)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if len(gone) > 0 {
|
||||
r.forget(gone)
|
||||
}
|
||||
for _, app := range todo {
|
||||
select {
|
||||
case r.queue <- app:
|
||||
default: // queue full: the next sync queues it again
|
||||
r.mu.Lock()
|
||||
delete(r.queued, app.ID)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL is the path the browser loads an app's icon from, or "" for a letter.
|
||||
// The version in it lets browsers cache the icon until it changes.
|
||||
func (r *Resolver) URL(id string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if e := r.entries[id]; e != nil && e.Version != "" {
|
||||
return "/icons/" + id + "?v=" + e.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Lookup returns an app's cached icon.
|
||||
func (r *Resolver) Lookup(id string) (Icon, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
e := r.entries[id]
|
||||
if e == nil || e.Version == "" {
|
||||
return Icon{}, false
|
||||
}
|
||||
return Icon{Data: e.data, ContentType: e.ContentType, Version: e.Version}, true
|
||||
}
|
||||
|
||||
func (r *Resolver) stale(e *entry, now time.Time) bool {
|
||||
ttl := r.refresh
|
||||
if e.Version == "" {
|
||||
ttl = min(ttl, missRetry)
|
||||
}
|
||||
return now.Sub(e.Checked) > ttl
|
||||
}
|
||||
|
||||
func (r *Resolver) resolve(ctx context.Context, app apps.App) {
|
||||
e := &entry{Key: inputKey(app), Checked: time.Now()}
|
||||
for _, c := range r.candidates(app) {
|
||||
data, ctype, err := c.fetch(ctx)
|
||||
if err != nil {
|
||||
r.log.Debug("icon candidate failed", "app", app.Name, "candidate", c.desc, "err", err)
|
||||
continue
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
e.Source, e.ContentType, e.Version, e.data = c.desc, ctype, hex.EncodeToString(sum[:6]), data
|
||||
break
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
prev := r.entries[app.ID]
|
||||
delete(r.queued, app.ID)
|
||||
r.entries[app.ID] = e
|
||||
r.mu.Unlock()
|
||||
|
||||
level := slog.LevelDebug
|
||||
if prev == nil || prev.Key != e.Key || prev.Version != e.Version {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
if e.Version != "" {
|
||||
r.log.Log(ctx, level, "icon found", "app", app.Name, "source", e.Source)
|
||||
} else {
|
||||
r.log.Log(ctx, level, "no icon found, showing a letter", "app", app.Name)
|
||||
}
|
||||
r.persist(app.ID, e)
|
||||
}
|
||||
|
||||
// inputKey hashes everything that decides which icon an app gets.
|
||||
func inputKey(app apps.App) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{app.IconHint, app.Image, app.Name, app.URL, app.InternalURL}, "\x00")))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func (r *Resolver) load() {
|
||||
if r.dir == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(r.dir, 0o755); err != nil {
|
||||
r.log.Warn("icon cache disabled, keeping icons in memory", "dir", r.dir, "err", err)
|
||||
r.dir = ""
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(r.dir, indexFile))
|
||||
if err != nil {
|
||||
return // first start
|
||||
}
|
||||
var index map[string]*entry
|
||||
if err := json.Unmarshal(raw, &index); err != nil {
|
||||
r.log.Warn("ignoring an unreadable icon index", "err", err)
|
||||
return
|
||||
}
|
||||
for id, e := range index {
|
||||
if !apps.ValidID(id) || e == nil {
|
||||
continue
|
||||
}
|
||||
if e.Version != "" {
|
||||
if e.data, err = os.ReadFile(filepath.Join(r.dir, id)); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
r.entries[id] = e
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) persist(id string, e *entry) {
|
||||
if r.dir == "" {
|
||||
return
|
||||
}
|
||||
r.saveMu.Lock()
|
||||
defer r.saveMu.Unlock()
|
||||
path := filepath.Join(r.dir, id)
|
||||
var err error
|
||||
if e.Version != "" {
|
||||
err = writeAtomic(path, e.data)
|
||||
} else {
|
||||
err = removeIfExists(path)
|
||||
}
|
||||
if err == nil {
|
||||
err = r.saveIndex()
|
||||
}
|
||||
if err != nil {
|
||||
r.log.Warn("writing the icon cache failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) forget(ids []string) {
|
||||
if r.dir == "" {
|
||||
return
|
||||
}
|
||||
r.saveMu.Lock()
|
||||
defer r.saveMu.Unlock()
|
||||
for _, id := range ids {
|
||||
if err := removeIfExists(filepath.Join(r.dir, id)); err != nil {
|
||||
r.log.Warn("removing a cached icon failed", "err", err)
|
||||
}
|
||||
}
|
||||
if err := r.saveIndex(); err != nil {
|
||||
r.log.Warn("writing the icon cache failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// saveIndex writes the index; the caller holds saveMu.
|
||||
func (r *Resolver) saveIndex() error {
|
||||
r.mu.RLock()
|
||||
raw, err := json.MarshalIndent(r.entries, "", " ")
|
||||
r.mu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomic(filepath.Join(r.dir, indexFile), raw)
|
||||
}
|
||||
|
||||
func writeAtomic(path string, data []byte) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
func removeIfExists(path string) error {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
)
|
||||
|
||||
var (
|
||||
svgIcon = []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>`)
|
||||
pngIcon = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
|
||||
quiet = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
)
|
||||
|
||||
func serve(t *testing.T, routes map[string][]byte) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, ok := routes[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func resolveOne(t *testing.T, r *Resolver, app apps.App) Icon {
|
||||
t.Helper()
|
||||
r.resolve(context.Background(), app)
|
||||
ic, _ := r.Lookup(app.ID)
|
||||
return ic
|
||||
}
|
||||
|
||||
func TestIconSetBeforeFavicon(t *testing.T) {
|
||||
cdn := serve(t, map[string][]byte{"/svg/gitea.svg": svgIcon})
|
||||
site := serve(t, map[string][]byte{"/favicon.ico": pngIcon})
|
||||
r := New("", cdn.URL, time.Hour, quiet)
|
||||
app := apps.App{ID: "000000000001", Name: "Git", Image: "gitea/gitea:1.22", URL: site.URL}
|
||||
ic := resolveOne(t, r, app)
|
||||
if ic.ContentType != "image/svg+xml" {
|
||||
t.Errorf("content type = %q, want the SVG from the icon set", ic.ContentType)
|
||||
}
|
||||
if r.URL(app.ID) != "/icons/000000000001?v="+ic.Version {
|
||||
t.Errorf("URL = %q", r.URL(app.ID))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameBeforeImage(t *testing.T) {
|
||||
cdn := serve(t, map[string][]byte{"/svg/nginx.svg": svgIcon, "/png/nginx.png": pngIcon, "/png/gitea.png": pngIcon, "/svg/immich.svg": svgIcon})
|
||||
r := New("", cdn.URL, time.Hour, quiet)
|
||||
|
||||
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "Gitea", Image: "nginx:alpine", URL: "http://127.0.0.1:1"})
|
||||
if ic.ContentType != "image/png" {
|
||||
t.Errorf("Gitea on nginx: content type = %q, want the gitea PNG, not nginx's logo", ic.ContentType)
|
||||
}
|
||||
ic = resolveOne(t, r, apps.App{ID: "000000000002", Name: "My Blog", Image: "nginx:alpine", URL: "http://127.0.0.1:1"})
|
||||
if ic.Version != "" {
|
||||
t.Error("an app on a generic image got that image's logo")
|
||||
}
|
||||
ic = resolveOne(t, r, apps.App{ID: "000000000003", Name: "Photos", Image: "ghcr.io/immich-app/immich-server:release", URL: "http://127.0.0.1:1"})
|
||||
if ic.ContentType != "image/svg+xml" {
|
||||
t.Errorf("Photos on immich-server: content type = %q, want the immich SVG", ic.ContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconLabelWins(t *testing.T) {
|
||||
cdn := serve(t, map[string][]byte{"/svg/gitea.svg": svgIcon, "/png/forgejo.png": pngIcon})
|
||||
r := New("", cdn.URL, time.Hour, quiet)
|
||||
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "Gitea", Image: "gitea/gitea", IconHint: "Forgejo.png", URL: "http://127.0.0.1:1"})
|
||||
if ic.ContentType != "image/png" {
|
||||
t.Errorf("content type = %q, want the PNG named by the icon label", ic.ContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaviconLargestLink(t *testing.T) {
|
||||
page := []byte(`<html><head>
|
||||
<link rel="icon" href="/small.png" sizes="16x16">
|
||||
<link rel="mask-icon" href="/mask.svg">
|
||||
<link rel=apple-touch-icon href="/touch.png">
|
||||
<link rel="shortcut icon" href="/big.png" sizes="192x192">
|
||||
</head></html>`)
|
||||
site := serve(t, map[string][]byte{"/": page, "/big.png": append(slices.Clone(pngIcon), 'b'), "/small.png": pngIcon})
|
||||
r := New("", "", time.Hour, quiet)
|
||||
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "No Match", URL: site.URL + "/"})
|
||||
if string(ic.Data) != string(pngIcon)+"b" {
|
||||
t.Errorf("got %d bytes, want the 192 px icon", len(ic.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalURLPreferred(t *testing.T) {
|
||||
internal := serve(t, map[string][]byte{"/favicon.ico": pngIcon})
|
||||
r := New("", "", time.Hour, quiet)
|
||||
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "x", URL: "http://127.0.0.1:1", InternalURL: internal.URL})
|
||||
if ic.Version == "" {
|
||||
t.Error("no icon from internal_url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRedirectIsNotAnIcon(t *testing.T) {
|
||||
login := serve(t, map[string][]byte{"/favicon.ico": pngIcon, "/": []byte(`<link rel="icon" href="/favicon.ico">`)})
|
||||
app := httptest.NewServer(http.RedirectHandler(login.URL+"/", http.StatusFound))
|
||||
defer app.Close()
|
||||
r := New("", "", time.Hour, quiet)
|
||||
if ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "x", URL: app.URL}); ic.Version != "" {
|
||||
t.Error("took the login page's icon after a redirect to another host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLIsNotAnIcon(t *testing.T) {
|
||||
site := serve(t, map[string][]byte{"/favicon.ico": []byte("<!doctype html><html><body>Sign in</body></html>")})
|
||||
r := New("", "", time.Hour, quiet)
|
||||
if ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "x", URL: site.URL}); ic.Version != "" {
|
||||
t.Error("took an HTML page as an icon")
|
||||
}
|
||||
if r.URL("000000000001") != "" {
|
||||
t.Error("an app without an icon got an icon URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cdn := serve(t, map[string][]byte{"/svg/jellyfin.svg": svgIcon})
|
||||
app := apps.App{ID: "000000000001", Name: "Jellyfin", URL: "http://127.0.0.1:1"}
|
||||
first := resolveOne(t, New(dir, cdn.URL, time.Hour, quiet), app)
|
||||
|
||||
again := New(dir, cdn.URL, time.Hour, quiet)
|
||||
ic, ok := again.Lookup(app.ID)
|
||||
if !ok || ic.Version != first.Version || string(ic.Data) != string(svgIcon) {
|
||||
t.Fatalf("after restart: ok %v, version %q; want %q", ok, ic.Version, first.Version)
|
||||
}
|
||||
again.Ensure([]apps.App{app})
|
||||
if len(again.queue) != 0 {
|
||||
t.Error("a fresh cached icon was looked up again")
|
||||
}
|
||||
again.Ensure(nil)
|
||||
if _, ok := again.Lookup(app.ID); ok {
|
||||
t.Error("the icon of a removed app was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRequeuesChangedLabels(t *testing.T) {
|
||||
r := New("", "", time.Hour, quiet)
|
||||
app := apps.App{ID: "000000000001", Name: "x", URL: "http://127.0.0.1:1"}
|
||||
r.entries[app.ID] = &entry{Key: inputKey(app), Checked: time.Now()}
|
||||
r.Ensure([]apps.App{app})
|
||||
if len(r.queue) != 0 {
|
||||
t.Fatal("an unchanged app was queued")
|
||||
}
|
||||
app.IconHint = "jellyfin"
|
||||
r.Ensure([]apps.App{app})
|
||||
if len(r.queue) != 1 {
|
||||
t.Error("an app with a new icon label was not queued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconLinks(t *testing.T) {
|
||||
base, _ := url.Parse("https://app.example/sub/page")
|
||||
page := []byte(`<LINK REL="icon" HREF="icon.svg"><link rel='icon' href='data:image/png;base64,AA'><link rel="stylesheet" href="x.css"><link rel="icon" href="/a.png?x=1&y=2" sizes="32x32 64x64">`)
|
||||
got := iconLinks(page, base)
|
||||
want := []string{"https://app.example/sub/icon.svg", "https://app.example/a.png?x=1&y=2"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("iconLinks = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageNames(t *testing.T) {
|
||||
tests := map[string][]string{
|
||||
"lscr.io/linuxserver/jellyfin:latest": {"jellyfin"},
|
||||
"ghcr.io/immich-app/immich-server:release": {"immich-server", "immich"},
|
||||
"gitea/gitea": {"gitea"},
|
||||
"localhost:5000/team/vaultwarden@sha256:0": {"vaultwarden"},
|
||||
"sha256:4f1c2d3e": nil,
|
||||
"": nil,
|
||||
}
|
||||
for image, want := range tests {
|
||||
if got := imageNames(image); !slices.Equal(got, want) {
|
||||
t.Errorf("imageNames(%q) = %q, want %q", image, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlug(t *testing.T) {
|
||||
tests := map[string]string{"Uptime Kuma": "uptime-kuma", " Home--Assistant! ": "home-assistant", "ÄBC": "bc", "": ""}
|
||||
for in, want := range tests {
|
||||
if got := slug(in); got != want {
|
||||
t.Errorf("slug(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Package peer lets the primary pull app lists and icons from agents on other
|
||||
// Docker hosts, authenticating with the shared PEER_TOKEN.
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
"git.mariosem.es/msemes/hoda/internal/icons"
|
||||
)
|
||||
|
||||
const (
|
||||
requestTimeout = 3 * time.Second
|
||||
maxBodyBytes = 4 << 20
|
||||
maxIconBytes = 1 << 20
|
||||
)
|
||||
|
||||
// Puller fetches each agent's apps on an interval and its icons on demand.
|
||||
type Puller struct {
|
||||
peers []string
|
||||
token string
|
||||
interval time.Duration
|
||||
store *apps.Store
|
||||
log *slog.Logger
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
names map[string]string // agent URL -> host name it last reported
|
||||
up map[string]bool // agent URL -> reachable on the last pull
|
||||
icons map[string]icons.Icon // app ID -> icon fetched from its agent
|
||||
}
|
||||
|
||||
// New returns a Puller for the agents at peers.
|
||||
func New(peers []string, token string, interval time.Duration, store *apps.Store, log *slog.Logger) *Puller {
|
||||
return &Puller{
|
||||
peers: peers,
|
||||
token: token,
|
||||
interval: interval,
|
||||
store: store,
|
||||
log: log,
|
||||
client: &http.Client{Timeout: requestTimeout},
|
||||
names: map[string]string{},
|
||||
up: map[string]bool{},
|
||||
icons: map[string]icons.Icon{},
|
||||
}
|
||||
}
|
||||
|
||||
// Run pulls every agent now and then once per interval until ctx ends.
|
||||
func (p *Puller) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
var wg sync.WaitGroup
|
||||
for _, peer := range p.peers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p.pull(ctx, peer)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) pull(ctx context.Context, peer string) {
|
||||
var body apps.Response
|
||||
err := p.get(ctx, peer+"/api/apps", func(resp *http.Response) error {
|
||||
return json.NewDecoder(io.LimitReader(resp.Body, maxBodyBytes)).Decode(&body)
|
||||
})
|
||||
if err == nil && len(body.Hosts) == 0 {
|
||||
err = errors.New("the agent reported no host")
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
p.store.SetError(peer, p.name(peer))
|
||||
p.mark(peer, false, err)
|
||||
return
|
||||
}
|
||||
|
||||
status := body.Hosts[0]
|
||||
list := make([]apps.App, 0, len(body.Apps))
|
||||
for _, a := range body.Apps {
|
||||
// The agent holds the token, but a bad link from it would still end up in an href.
|
||||
if !apps.ValidID(a.ID) || !apps.IsWebURL(a.URL) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(a.Icon, "/icons/"+a.ID+"?") {
|
||||
a.Icon = ""
|
||||
}
|
||||
if a.Host == "" {
|
||||
a.Host = status.Name
|
||||
}
|
||||
list = append(list, a)
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.names[peer] = status.Name
|
||||
p.mu.Unlock()
|
||||
p.store.SetReported(peer, status, list)
|
||||
p.mark(peer, true, nil)
|
||||
p.pruneIcons()
|
||||
}
|
||||
|
||||
// Icon returns an agent's icon for one of its apps, fetched once per icon version.
|
||||
func (p *Puller) Icon(ctx context.Context, id string) (icons.Icon, bool) {
|
||||
key, app, ok := p.store.Lookup(id)
|
||||
if !ok || key == apps.LocalSource || app.Icon == "" {
|
||||
return icons.Icon{}, false
|
||||
}
|
||||
version := ""
|
||||
if u, err := url.Parse(app.Icon); err == nil {
|
||||
version = u.Query().Get("v")
|
||||
}
|
||||
p.mu.Lock()
|
||||
cached, hit := p.icons[id]
|
||||
p.mu.Unlock()
|
||||
if hit && cached.Version == version {
|
||||
return cached, true
|
||||
}
|
||||
|
||||
var ic icons.Icon
|
||||
err := p.get(ctx, key+"/icons/"+id, func(resp *http.Response) error {
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxIconBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctype := resp.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(ctype, "image/") {
|
||||
return fmt.Errorf("agent sent %q, not an image", ctype)
|
||||
}
|
||||
ic = icons.Icon{Data: data, ContentType: ctype, Version: version}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.log.Debug("fetching an icon from an agent failed", "agent", key, "app", app.Name, "err", err)
|
||||
return icons.Icon{}, false
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.icons[id] = ic
|
||||
p.mu.Unlock()
|
||||
return ic, true
|
||||
}
|
||||
|
||||
func (p *Puller) get(ctx context.Context, link string, read func(*http.Response) error) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: %s", link, resp.Status)
|
||||
}
|
||||
return read(resp)
|
||||
}
|
||||
|
||||
// name is the host name an agent last reported, or its URL's host before that.
|
||||
func (p *Puller) name(peer string) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if n := p.names[peer]; n != "" {
|
||||
return n
|
||||
}
|
||||
if u, err := url.Parse(peer); err == nil && u.Hostname() != "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
return peer
|
||||
}
|
||||
|
||||
// mark logs only when an agent goes up or down, not on every pull.
|
||||
func (p *Puller) mark(peer string, up bool, err error) {
|
||||
p.mu.Lock()
|
||||
was, seen := p.up[peer]
|
||||
p.up[peer] = up
|
||||
p.mu.Unlock()
|
||||
switch {
|
||||
case up && (!seen || !was):
|
||||
p.log.Info("agent reachable", "agent", peer)
|
||||
case !up && (!seen || was):
|
||||
p.log.Warn("can't reach agent, keeping its last known apps", "agent", peer, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) pruneIcons() {
|
||||
p.mu.Lock()
|
||||
ids := make([]string, 0, len(p.icons))
|
||||
for id := range p.icons {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
if _, _, ok := p.store.Lookup(id); !ok {
|
||||
p.mu.Lock()
|
||||
delete(p.icons, id)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
)
|
||||
|
||||
var (
|
||||
immichID = apps.ID("host-2", "immich")
|
||||
quiet = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
)
|
||||
|
||||
func agent(t *testing.T, iconHits *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer s3cret" {
|
||||
http.Error(w, "no", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/apps":
|
||||
_ = json.NewEncoder(w).Encode(apps.Response{
|
||||
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: 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"},
|
||||
},
|
||||
})
|
||||
case "/icons/" + immichID:
|
||||
iconHits.Add(1)
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("png"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestPull(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := agent(t, &hits)
|
||||
store := apps.NewStore()
|
||||
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)
|
||||
}
|
||||
if len(hosts) != 1 || !hosts[0].OK || hosts[0].Name != "host-2" {
|
||||
t.Errorf("hosts = %+v", hosts)
|
||||
}
|
||||
|
||||
ic, ok := p.Icon(context.Background(), immichID)
|
||||
if !ok || string(ic.Data) != "png" || ic.Version != "v1" {
|
||||
t.Fatalf("Icon = %+v, %v", ic, ok)
|
||||
}
|
||||
p.Icon(context.Background(), immichID)
|
||||
if hits.Load() != 1 {
|
||||
t.Errorf("icon fetched %d times, want once per version", hits.Load())
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongToken(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := agent(t, &hits)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package server serves the dashboard page, the app list as JSON and the icons.
|
||||
// An agent serves only the JSON and the icons, and only with the peer token.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
"git.mariosem.es/msemes/hoda/internal/config"
|
||||
"git.mariosem.es/msemes/hoda/internal/icons"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
var webFS embed.FS
|
||||
|
||||
// IconSource is this host's icon cache.
|
||||
type IconSource interface {
|
||||
URL(id string) string
|
||||
Lookup(id string) (icons.Icon, bool)
|
||||
}
|
||||
|
||||
// PeerIcons fetches icons of apps that run on an agent's host.
|
||||
type PeerIcons interface {
|
||||
Icon(ctx context.Context, id string) (icons.Icon, bool)
|
||||
}
|
||||
|
||||
// Deps are the parts the server reads from.
|
||||
type Deps struct {
|
||||
Store *apps.Store
|
||||
Icons IconSource
|
||||
Peers PeerIcons // nil when there are no agents
|
||||
}
|
||||
|
||||
type server struct {
|
||||
cfg config.Config
|
||||
deps Deps
|
||||
version string
|
||||
log *slog.Logger
|
||||
page *template.Template
|
||||
}
|
||||
|
||||
// New returns the HTTP handler for cfg.Mode.
|
||||
func New(cfg config.Config, deps Deps, version string, log *slog.Logger) http.Handler {
|
||||
s := &server{
|
||||
cfg: cfg,
|
||||
deps: deps,
|
||||
version: version,
|
||||
log: log,
|
||||
page: template.Must(template.ParseFS(webFS, "web/index.html")),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.healthz)
|
||||
if cfg.Mode == config.ModeAgent {
|
||||
mux.HandleFunc("GET /{$}", s.agentIndex)
|
||||
mux.Handle("GET /api/apps", s.requireToken(s.apiApps))
|
||||
mux.Handle("GET /icons/{id}", s.requireToken(s.icon))
|
||||
} else {
|
||||
static, err := fs.Sub(webFS, "web/static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mux.HandleFunc("GET /{$}", s.index)
|
||||
mux.HandleFunc("GET /api/apps", s.apiApps)
|
||||
mux.HandleFunc("GET /icons/{id}", s.icon)
|
||||
mux.Handle("GET /static/", s.cacheStatic(http.StripPrefix("/static/", http.FileServerFS(static))))
|
||||
}
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
func (s *server) index(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
err := s.page.Execute(w, map[string]string{
|
||||
"Title": s.cfg.Title,
|
||||
"Version": s.version,
|
||||
"LinkTarget": s.cfg.LinkTarget,
|
||||
"LabelPrefix": s.cfg.LabelPrefix,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("rendering the page failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) agentIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("HODA agent on " + s.cfg.NodeName + ". The dashboard is served by the primary.\n"))
|
||||
}
|
||||
|
||||
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})
|
||||
if err != nil {
|
||||
http.Error(w, "encoding the app list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
etag := `"` + hex.EncodeToString(sum[:8]) + `"`
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (s *server) icon(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !apps.ValidID(id) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ic, ok := s.deps.Icons.Lookup(id)
|
||||
if !ok && s.deps.Peers != nil {
|
||||
ic, ok = s.deps.Peers.Icon(r.Context(), id)
|
||||
}
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", ic.ContentType)
|
||||
// Icons come from third parties: the sandbox stops an SVG from running script if opened directly.
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
etag := `"` + ic.Version + `"`
|
||||
h.Set("ETag", etag)
|
||||
if r.URL.Query().Get("v") == ic.Version {
|
||||
h.Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
} else {
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
}
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(ic.Data)
|
||||
}
|
||||
|
||||
func (s *server) healthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
|
||||
func (s *server) requireToken(next http.HandlerFunc) http.Handler {
|
||||
want := []byte("Bearer " + s.cfg.PeerToken)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), want) != 1 {
|
||||
http.Error(w, "missing or wrong peer token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// cacheStatic lets browsers keep assets for a year when the URL carries this
|
||||
// build's version, which changes with every release.
|
||||
func (s *server) cacheStatic(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.version != "dev" && r.URL.Query().Get("v") == s.version {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mariosem.es/msemes/hoda/internal/apps"
|
||||
"git.mariosem.es/msemes/hoda/internal/config"
|
||||
"git.mariosem.es/msemes/hoda/internal/icons"
|
||||
)
|
||||
|
||||
type fakeIcons map[string]icons.Icon
|
||||
|
||||
func (f fakeIcons) URL(id string) string {
|
||||
if ic, ok := f[id]; ok {
|
||||
return "/icons/" + id + "?v=" + ic.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f fakeIcons) Lookup(id string) (icons.Icon, bool) {
|
||||
ic, ok := f[id]
|
||||
return ic, ok
|
||||
}
|
||||
|
||||
var giteaID = apps.ID("host-1", "gitea")
|
||||
|
||||
func newHandler(mode config.Mode) http.Handler {
|
||||
store := apps.NewStore()
|
||||
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",
|
||||
}})
|
||||
cfg := config.Config{
|
||||
Mode: mode, NodeName: "host-1", Title: "HODA", LinkTarget: "_blank",
|
||||
LabelPrefix: "sema.dashboard", PeerToken: "s3cret",
|
||||
}
|
||||
ic := fakeIcons{giteaID: {Data: []byte("<svg></svg>"), ContentType: "image/svg+xml", Version: "abc123"}}
|
||||
return New(cfg, Deps{Store: store, Icons: ic}, "1.0.0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
func do(h http.Handler, path string, header map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAPIApps(t *testing.T) {
|
||||
h := newHandler(config.ModePrimary)
|
||||
rec := do(h, "/api/apps", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var body apps.Response
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
etag := rec.Header().Get("ETag")
|
||||
if again := do(h, "/api/apps", map[string]string{"If-None-Match": etag}); again.Code != http.StatusNotModified {
|
||||
t.Errorf("unchanged list: status = %d, want 304", again.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentNeedsToken(t *testing.T) {
|
||||
h := newHandler(config.ModeAgent)
|
||||
for _, path := range []string{"/api/apps", "/icons/" + giteaID} {
|
||||
if rec := do(h, path, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s without a token: status = %d, want 401", path, rec.Code)
|
||||
}
|
||||
if rec := do(h, path, map[string]string{"Authorization": "Bearer wrong"}); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s with a wrong token: status = %d, want 401", path, rec.Code)
|
||||
}
|
||||
if rec := do(h, path, map[string]string{"Authorization": "Bearer s3cret"}); rec.Code != http.StatusOK {
|
||||
t.Errorf("%s with the token: status = %d, want 200", path, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := do(h, "/healthz", nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("agent healthz: status = %d, want 200 without a token", rec.Code)
|
||||
}
|
||||
if rec := do(h, "/static/app.js", nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("agent static: status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconHeaders(t *testing.T) {
|
||||
h := newHandler(config.ModePrimary)
|
||||
rec := do(h, "/icons/"+giteaID+"?v=abc123", nil)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/svg+xml" {
|
||||
t.Fatalf("status %d, type %q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Security-Policy"), "sandbox") {
|
||||
t.Error("an icon is served without the sandbox policy")
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
|
||||
t.Error("a versioned icon URL is not cached")
|
||||
}
|
||||
for _, path := range []string{"/icons/unknown00000", "/icons/..%2f..%2fetc", "/icons/" + strings.Repeat("a", 12)} {
|
||||
if rec := do(h, path, nil); rec.Code == http.StatusOK {
|
||||
t.Errorf("%s: status = %d, want no icon", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPage(t *testing.T) {
|
||||
h := newHandler(config.ModePrimary)
|
||||
rec := do(h, "/", nil)
|
||||
body := rec.Body.String()
|
||||
if rec.Code != http.StatusOK || !strings.Contains(body, "<title>HODA</title>") || !strings.Contains(body, `/static/app.js?v=1.0.0`) {
|
||||
t.Errorf("page: status %d\n%s", rec.Code, body)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Security-Policy"), "script-src 'self'") {
|
||||
t.Error("the page is served without a content security policy")
|
||||
}
|
||||
for _, path := range []string{"/static/app.js", "/static/app.css", "/static/favicon.svg"} {
|
||||
if rec := do(h, path, nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: status = %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := do(h, "/static/app.js?v=1.0.0", nil); !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
|
||||
t.Error("versioned static assets are not cached")
|
||||
}
|
||||
if rec := do(h, "/nope", nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("/nope: status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="icon" href="/static/favicon.svg?v={{.Version}}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/app.css?v={{.Version}}">
|
||||
<script src="/static/app.js?v={{.Version}}" defer></script>
|
||||
</head>
|
||||
<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>
|
||||
</header>
|
||||
<div id="banners" class="banners" role="status" aria-live="polite"></div>
|
||||
<main id="groups" class="groups" aria-busy="true"></main>
|
||||
<noscript><p class="note">HODA needs JavaScript to list your apps.</p></noscript>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,254 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f6f4;
|
||||
--surface: #ffffff;
|
||||
--border: #dcdbd5;
|
||||
--border-strong: #a9a79e;
|
||||
--text: #1d1d1b;
|
||||
--muted: #64635d;
|
||||
--focus: #2f6fdb;
|
||||
--good: #1a9e3a;
|
||||
--warn: #c98000;
|
||||
--off: #a9a79e;
|
||||
--banner-bg: #fff3d6;
|
||||
--banner-text: #573a00;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #161615;
|
||||
--surface: #1f1f1d;
|
||||
--border: #34332f;
|
||||
--border-strong: #5c5b54;
|
||||
--text: #ededea;
|
||||
--muted: #a5a49d;
|
||||
--focus: #6ea2ff;
|
||||
--good: #3cc35e;
|
||||
--warn: #f0a92a;
|
||||
--off: #6b6a63;
|
||||
--banner-bg: #3a2e12;
|
||||
--banner-text: #f6dca0;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.search { position: relative; flex: 0 1 280px; }
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 8px 36px 8px 12px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.search input:focus { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
|
||||
.search kbd {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
padding: 1px 6px;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input:focus + kbd { display: none; }
|
||||
|
||||
@media (hover: none) {
|
||||
.search kbd { display: none; }
|
||||
}
|
||||
|
||||
.banners:empty { display: none; }
|
||||
|
||||
.banner {
|
||||
max-width: 1440px;
|
||||
margin: 16px auto 0;
|
||||
padding: 10px 14px;
|
||||
color: var(--banner-text);
|
||||
background: var(--banner-bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.banners { padding: 0 24px; }
|
||||
|
||||
.groups { max-width: 1440px; margin: 0 auto; padding: 8px 24px 40px; }
|
||||
|
||||
.group-name {
|
||||
margin: 24px 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 64px;
|
||||
padding: 12px 28px 12px 12px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
transition: border-color 0.12s, transform 0.12s;
|
||||
}
|
||||
|
||||
.tile:hover { border-color: var(--border-strong); transform: translateY(-1px); }
|
||||
.tile:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.tile.state-stopped { opacity: 0.5; }
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.letter {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--hue) 45% 25%);
|
||||
background: hsl(var(--hue) 70% 88%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.letter { color: hsl(var(--hue) 70% 85%); background: hsl(var(--hue) 35% 25%); }
|
||||
}
|
||||
|
||||
.text { display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.name,
|
||||
.desc {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.name { font-weight: 600; }
|
||||
.desc { font-size: 13px; color: var(--muted); }
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--good);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.state-unhealthy .dot { background: var(--warn); }
|
||||
.state-stopped .dot { background: var(--off); }
|
||||
|
||||
.note { margin: 32px 0; color: var(--muted); }
|
||||
|
||||
.empty {
|
||||
max-width: 560px;
|
||||
margin: 48px auto;
|
||||
padding: 24px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.empty h2 { margin: 0 0 8px; font-size: 18px; }
|
||||
.empty p { margin: 0 0 16px; color: var(--muted); }
|
||||
|
||||
.snippet {
|
||||
margin: 0 0 12px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
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;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.top { flex-wrap: wrap; padding: 12px 16px; }
|
||||
.search { flex-basis: 100%; }
|
||||
.banners { 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; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile { transition: none; }
|
||||
.tile:hover { transform: none; }
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// 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.
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const POLL_MS = 10_000;
|
||||
const STATE_LABELS = { running: "Running", unhealthy: "Unhealthy", stopped: "Stopped" };
|
||||
|
||||
const groupsEl = document.getElementById("groups");
|
||||
const bannersEl = document.getElementById("banners");
|
||||
const searchEl = document.getElementById("search");
|
||||
const linkTarget = document.body.dataset.linkTarget === "_self" ? "_self" : "_blank";
|
||||
const labelPrefix = document.body.dataset.labelPrefix || "sema.dashboard";
|
||||
|
||||
let data = null;
|
||||
let etag = "";
|
||||
let inFlight = false;
|
||||
let offline = false;
|
||||
|
||||
async function load() {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const res = await fetch("/api/apps", {
|
||||
cache: "no-store",
|
||||
headers: etag ? { "If-None-Match": etag } : {},
|
||||
});
|
||||
if (res.status === 304) {
|
||||
setOffline(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
data = await res.json();
|
||||
etag = res.headers.get("ETag") || "";
|
||||
offline = false;
|
||||
render();
|
||||
} catch {
|
||||
setOffline(true);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
groupsEl.removeAttribute("aria-busy");
|
||||
}
|
||||
}
|
||||
|
||||
function setOffline(value) {
|
||||
if (offline === value) return;
|
||||
offline = value;
|
||||
renderBanners();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderBanners();
|
||||
if (!data) return;
|
||||
const focusedId = document.activeElement?.dataset?.id;
|
||||
const terms = searchEl.value.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const visible = data.apps.filter((app) => matches(app, terms));
|
||||
|
||||
const sections = [];
|
||||
if (data.apps.length === 0) {
|
||||
sections.push(emptyState());
|
||||
} else if (visible.length === 0) {
|
||||
sections.push(el("p", "note", `No apps match “${searchEl.value.trim()}”.`));
|
||||
} else {
|
||||
for (const [group, list] of groupBy(visible)) {
|
||||
const section = el("section", "group");
|
||||
const grid = el("div", "grid");
|
||||
grid.append(...list.map(tile));
|
||||
section.append(el("h2", "group-name", group), grid);
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
groupsEl.replaceChildren(...sections);
|
||||
if (focusedId) groupsEl.querySelector(`[data-id="${CSS.escape(focusedId)}"]`)?.focus();
|
||||
}
|
||||
|
||||
function renderBanners() {
|
||||
const banners = [];
|
||||
if (offline) banners.push(el("p", "banner", "Can't reach HODA right now. Retrying every 10 seconds."));
|
||||
for (const host of data?.hosts ?? []) {
|
||||
if (host.ok) continue;
|
||||
const since = host.lastOk ? `, showing its apps from ${clock(host.lastOk)}` : "";
|
||||
banners.push(el("p", "banner", `Can't reach ${host.name}${since}.`));
|
||||
}
|
||||
bannersEl.replaceChildren(...banners);
|
||||
}
|
||||
|
||||
function matches(app, terms) {
|
||||
if (terms.length === 0) return true;
|
||||
const text = [app.name, app.description, app.group, app.host].join(" ").toLowerCase();
|
||||
return terms.every((term) => text.includes(term));
|
||||
}
|
||||
|
||||
// The server sends apps in display order, so grouping keeps that order.
|
||||
function groupBy(apps) {
|
||||
const groups = new Map();
|
||||
for (const app of apps) {
|
||||
if (!groups.has(app.group)) groups.set(app.group, []);
|
||||
groups.get(app.group).push(app);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
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}`;
|
||||
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) {
|
||||
if (!app.icon) return letter(app);
|
||||
const img = el("img", "icon");
|
||||
img.src = app.icon;
|
||||
img.alt = "";
|
||||
img.width = 40;
|
||||
img.height = 40;
|
||||
img.decoding = "async";
|
||||
img.addEventListener("error", () => img.replaceWith(letter(app)), { once: true });
|
||||
return img;
|
||||
}
|
||||
|
||||
function letter(app) {
|
||||
const first = (app.name.match(/[\p{L}\p{N}]/u) || ["?"])[0].toUpperCase();
|
||||
const span = el("span", "icon letter", first);
|
||||
span.style.setProperty("--hue", String(hue(app.name)));
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
return span;
|
||||
}
|
||||
|
||||
// A hue derived from the name, so a letter tile keeps its colour across reloads.
|
||||
function hue(text) {
|
||||
let h = 0;
|
||||
for (const ch of text) h = (h * 31 + ch.codePointAt(0)) >>> 0;
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
function emptyState() {
|
||||
const snippet = [
|
||||
"labels:",
|
||||
` ${labelPrefix}.enable: "true"`,
|
||||
` ${labelPrefix}.name: My app`,
|
||||
` ${labelPrefix}.url: https://myapp.example.com`,
|
||||
].join("\n");
|
||||
const copy = el("button", "copy", "Copy");
|
||||
copy.type = "button";
|
||||
copy.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
copy.textContent = "Copied";
|
||||
} catch {
|
||||
copy.textContent = "Copy failed";
|
||||
}
|
||||
});
|
||||
const box = el("div", "empty");
|
||||
box.append(
|
||||
el("h2", "", "No apps yet"),
|
||||
el("p", "", "Add these labels to a service in its docker-compose file, then run docker compose up -d."),
|
||||
el("pre", "snippet", snippet),
|
||||
copy,
|
||||
);
|
||||
return box;
|
||||
}
|
||||
|
||||
function clock(iso) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function openTile(a) {
|
||||
if (a.target === "_blank") window.open(a.href, "_blank", "noopener,noreferrer");
|
||||
else window.location.assign(a.href);
|
||||
}
|
||||
|
||||
function isTyping(target) {
|
||||
return target instanceof HTMLElement && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
|
||||
}
|
||||
|
||||
searchEl.addEventListener("input", render);
|
||||
searchEl.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
searchEl.value = "";
|
||||
render();
|
||||
searchEl.blur();
|
||||
} else if (event.key === "Enter") {
|
||||
const first = groupsEl.querySelector("a.tile");
|
||||
if (first) {
|
||||
event.preventDefault();
|
||||
openTile(first);
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "/" && !event.ctrlKey && !event.metaKey && !event.altKey && !isTyping(event.target)) {
|
||||
event.preventDefault();
|
||||
searchEl.focus();
|
||||
searchEl.select();
|
||||
}
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) load();
|
||||
});
|
||||
setInterval(() => {
|
||||
if (!document.hidden) load();
|
||||
}, POLL_MS);
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#1d1d1b"/><rect x="7" y="7" width="8" height="8" rx="2" fill="#6ea2ff"/><rect x="17" y="7" width="8" height="8" rx="2" fill="#ededea"/><rect x="7" y="17" width="8" height="8" rx="2" fill="#ededea"/><rect x="17" y="17" width="8" height="8" rx="2" fill="#3cc35e"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
Reference in new issue
Block a user