valknar ffb70ecddf
CI / Static checks (push) Successful in 35s
CI / Build and push image (push) Successful in 1m12s
feat: add combined docker+API service widgets for gitea/coolify/immich/n8n/umami/headscale
One new `service` widget type (nested discriminated union on `service`)
rather than six, so a single config entry shows both docker container
health and a service-specific stat (repo count, project list, photo
count, workflow count, active users, users/nodes) - avoiding the
overhead of configuring a docker widget and a separate service widget
per container. All six collectors hit the service's container name +
internal port directly on falcon_network, the same container-to-
container pattern just proven out for Traefik's own API, avoiding
vpn-only/hairpin-NAT entirely.

Also adds the public/private config split that was scoped in the
original project plan but never built: lib/config/public.ts strips
apiToken/apiKey/password fields before the config reaches the browser
via SSR or the WS config topic - required before any widget could
carry a real secret. Verified via a throwaway secret field that it's
absent from both the SSR HTML and the WS config:update frame.

Endpoint shapes verified live against the running gitea/coolify/immich/
n8n/umami/headscale containers before committing (unauthenticated
requests correctly 401/200 on every target route; gitea's
X-Total-Count header confirmed present).
2026-08-17 20:45:29 +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-17 14:39:44 +02:00
2026-08-17 17:58:12 +02:00

PulseNode

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. 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}), never sent to the browser
  • 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, Traefik router status, 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: Core Infra
    widgets:
      - type: docker
        name: Traefik
        containerName: traefik
        href: https://traefik.example.com
        interval: 10s

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).
traefik apiUrl Router/entrypoint/middleware status from Traefik's own API (--api.dashboard=true must be enabled). Optional name (default Traefik), interval (default 15s).

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

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)
lib/collectors/        docker, system, http, traefik collectors + 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).
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%