valknarandClaude Sonnet 5 5a33218235 feat: support a multi-link list in the bookmark widget
A bookmark widget can now carry an optional links array instead of
a single href, rendering as a compact card with one row per link -
for grouping several quick links (e.g. "all services", "all Coolify
apps") into one widget instead of one card per link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:14:47 +02:00
2026-08-18 08:32:18 +02:00
2026-08-17 14:39:44 +02:00
2026-08-17 14:39:44 +02:00
2026-08-17 14:39:44 +02:00
2026-08-17 14:39:44 +02:00
2026-08-18 10:06:55 +02:00
2026-08-17 14:39:44 +02:00
2026-08-17 17:58:12 +02:00
2026-08-18 10:06:55 +02:00

PulseNode

A self-hosted infrastructure dashboard: system stats, Docker containers, databases, HTTP checks, and combined docker+API service widgets, configured entirely through a YAML file and pushed to the browser live over WebSockets. No database of its own - config.yml is the source of truth, metrics are ephemeral.

Features

  • YAML-configured widgets and layout, hot-reloaded - edit config.yml and connected browsers update without a page refresh
  • Secrets via .env, interpolated into config.yml at load time (${VAR} / ${VAR:-default}). Widget fields named apiToken/apiKey/ password are stripped before the config ever reaches the browser (lib/config/public.ts) - collectors run server-side and use the real values, the client never sees them
  • Live updates over WebSocket - each widget subscribes to its own topic; the server only serializes results for widgets someone is actually viewing
  • Widget types: Docker containers, Postgres/Redis instances, system resources, HTTP health checks, combined docker+API service widgets (Gitea, Coolify, Immich, n8n, Umami, Headscale, Traefik), bookmarks, search
  • Optional label-based auto-discovery - containers carrying traefik.enable=true can be turned into dashboard widgets automatically; manual config.yml entries always take precedence
  • CSS-variable theming, light/dark toggle, theme.customCssPath for a fully custom stylesheet
  • Ships as a single Docker image, designed to run non-root with a read-only root filesystem

Quick start (local development)

pnpm install
cp config/.env.example config/.env   # fill in real values
cp .env.example .env                 # only needed for docker compose
pnpm dev

Open http://localhost:3000. pnpm dev runs the custom server (server.ts) via tsx watch, which boots Next.js, the WebSocket server, and the collector scheduler together, and restarts on server-side code changes. Editing config.yml itself does not restart the process - it's picked up live via the config watcher.

Other scripts: pnpm build (production build), pnpm start (run the production build via the same custom server), pnpm lint, pnpm exec tsc --noEmit.

Configuration

config/config.yml

Everything is organized into groups, each a list of widgets:

settings:
  title: PulseNode

theme:
  mode: dark              # dark | light | auto
  # variables:             # override any --pn-* token from app/globals.css
  #   --pn-accent: "#ff6b6b"
  # customCssPath: custom.css   # relative to config/, served at /api/theme/custom-css

discovery:
  docker:
    enabled: false          # set true to auto-discover traefik-labeled containers
    network: falcon_network # only include containers on this docker network

groups:
  - name: Infra
    widgets:
      - type: system
        interval: 5s

  - name: Services
    widgets:
      - type: service
        service: traefik
        name: Traefik
        containerName: traefik
        icon: traefik
        href: https://traefik.example.com
        interval: 15s

Config errors are non-destructive: if config.yml fails validation after a live edit, PulseNode logs the error and keeps serving the last valid config instead of crashing.

config/.env

Secrets and per-deployment values referenced from config.yml via ${VAR} or ${VAR:-default}. Loaded once at startup and on every config reload; never exposed to the client. See config/.env.example.

Widget types

type Required fields Notes
bookmark name, href Static link card. Optional description, icon (see below).
search - Client-side search box. Optional engines (duckduckgo/google/bing, default [duckduckgo]), defaultEngine.
docker name, containerName Status, uptime, CPU/mem, restart count via dockerode. Optional href, icon (see below), showStats (default true), interval (default 5s).
database name, containerName, engine Thin skin over the same Docker collector - engine is postgres or redis, used for icon/label only. interval default 10s.
system - Host CPU/mem/disk/network via systeminformation. Optional name (default System), interval (default 5s).
http name, url HTTP health check with latency and consecutive-failure tracking. Optional method (default GET), timeout (default 5s), interval (default 30s), expect.status (default 200).
service name, containerName, service Docker health/CPU/mem (same collector as docker) merged with a stat pulled from the service's own API. service picks the variant (see below) and its extra fields. A failed API call surfaces as a non-fatal statError on the card without hiding docker health. Optional href, icon, showStats (default true), interval (default 15s).

interval/timeout values are duration strings: 500ms, 5s, 1m, 1h.

service variants

Each service widget calls its target container directly by name on the docker network (http://<containerName>:<port>), not through Traefik/TLS - no apiUrl field needed.

service Extra fields Stat shown
gitea username (default Valknar), apiToken Repository count
coolify apiToken Project count, resource count
immich apiKey Photo count, video count
n8n apiKey Workflow count
umami username, password, websiteId Active visitors (login-and-cache, self-hosted has no static API key)
headscale apiToken User count, node count
traefik - HTTP router count (needs --api.insecure=true or a dashboard entrypoint reachable on the docker network)

icon on docker/bookmark widgets is a key into lib/brand-icons.ts (currently traefik, coolify, gitea, docker, immich, n8n, passbolt, umami, postgresql, redis, headscale), rendered monochrome so it doesn't compete with the accent color. database widgets pick their icon automatically from engine. Regenerate/extend the icon set with python3 scripts/extract-brand-icons.py after adding an entry to that script's ICONS map (path data comes from the CC0-licensed simple-icons package, bundled at build time - no runtime CDN calls).

Auto-discovery

With discovery.docker.enabled: true, PulseNode scans running containers every 60s (and immediately on any config.yml change) for traefik.enable=true labels, parses the router's Host(...) rule for a link, and adds anything not already listed manually to a synthetic "Discovered" group. discovery.docker.excludeLabels and .network narrow what qualifies; .labelPrefix (default traefik) controls which label namespace is read.

Deployment

cp .env.example .env
cp config/.env.example config/.env
# edit both, then:
docker compose up -d --build
  • .env (repo root) holds TRAEFIK_HOST, NETWORK_NAME, and DOCKER_GID - compose-level values, interpolated into docker-compose.yml's Traefik labels and used to join the container to the docker group. Find your host's docker group id with getent group docker | cut -d: -f3; without it the non-root container gets EACCES on /var/run/docker.sock.
  • config/.env holds values referenced from config.yml (see above).
  • The compose file joins the container to an external falcon_network so collectors can reach services directly by container name (http://umami:3000/...) instead of only through Traefik.
  • /var/run/docker.sock is mounted read-only; /app/config is mounted read-only from ./config.
  • The container runs as a non-root user with a read-only root filesystem, dropped capabilities, and tini as PID 1.
  • /api/health backs the container HEALTHCHECK.

Project structure

config/                config.yml + .env (gitignored) live here
server.ts              custom server: http + WebSocket (/ws) + collector scheduler
app/                   Next.js App Router pages and API routes
components/widgets/    one folder per widget type (Widget.tsx + shared Skeleton/StatusDot)
components/layout/     Dashboard shell, theme toggle, brand mark
lib/config/            schema (zod), loader (parse/interpolate/watch), effective (+ discovery merge),
                       public.ts (strips secrets before the config reaches the browser)
lib/collectors/        docker, system, http collectors, service.ts (docker+API merge) with
                       one collector per service under services/, + the scheduler
lib/discovery/         traefik-label auto-discovery
lib/ws/                WebSocket server (topics) and the browser-side subscription hooks

Known limitations

  • No authentication yet. PulseNode assumes it's reachable only on a private network (e.g. behind Traefik with no public route, or on a VPN). A minimal session-based auth gate is a planned follow-up, not yet built.
  • system widget metrics reflect whatever cgroup/namespace the container runs in - for true host-level stats rather than the container's own view, mount /proc and /sys from the host and run with pid: host (not configured by default).

License

MIT

S
Description
A self-hosted infrastructure dashboard: system stats, Docker containers, databases, and HTTP/Traefik health, configured entirely through a YAML file and pushed to the browser live over WebSockets.
Readme MIT
415 KiB
Languages
TypeScript 93%
CSS 3.5%
Python 1.5%
Dockerfile 1.2%
JavaScript 0.8%