Files
pulsenode/README.md
T
valknar 4308169608
CI / Static checks (push) Successful in 34s
CI / Build and push image (push) Skipped
feat: brand icons for widgets, fix stale accent color
Two separate issues, both real bugs:

- Dark mode was showing light blue instead of green: config.yml still
  had a leftover --pn-accent: #38bdf8 override from before the design
  redesign, which won via the runtime theme injector regardless of
  what app/globals.css defaulted to. Removed the override and pushed
  the dark-mode default from a soft mint (#5ee08c) to an actual neon
  green (#39ff88) - closer to what "neon" means and closer to the real
  phosphor-green a cardiac monitor trace uses, which fits the pulse
  metaphor better than the pastel version did.

- No per-service brand icons anywhere. Added an optional `icon` field
  to docker/bookmark widgets (a key into lib/brand-icons.ts) rather
  than guessing from containerName, since a heuristic would silently
  misfire for anyone's own container naming. database widgets derive
  their icon automatically from `engine` (postgres/redis) since that's
  already required and unambiguous. Icon path data comes from the CC0
  simple-icons package via scripts/extract-brand-icons.py, extracted
  and committed as plain TS data (lib/brand-icons.ts) rather than
  imported live - keeps it self-hosted (no unpkg.com/CDN calls at
  runtime, matching the fonts) and pinned regardless of dependency
  updates. Rendered monochrome (currentColor, not each brand's own
  hex) to stay consistent with the single-accent design rather than a
  dozen competing colors - only the shape carries the identity.

Wired real icons into config.yml for every service that has one:
traefik, coolify, gitea (+ gitea_runner), immich (+ immich_ml), n8n,
passbolt, umami, headscale, plus postgresql/redis on every database
widget. code and mailpit have no brand match in the curated set, so
they render without one rather than a wrong/generic substitute.

Verified in the actual rendered page: compiled CSS confirms
--pn-accent is #39ff88 (dark) / #339a5a (light) with no trace of the
old #38bdf8, and all 18 expected icon instances (10 docker + 8
database widgets) are present.
2026-08-17 17:55:27 +02:00

7.7 KiB

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).