Imports the visual direction from a claude.ai/design session ("PulseNode
Dashboard.dc.html") and reconciles it with the app's existing choices
rather than a wholesale swap: kept the IBM Plex Sans/Mono + Space
Grotesk typography and the pulse-green accent concept from the earlier
pass, adopted the imported design's structural ideas - a real
tonal/elevation token system (derived divider/muted colors via
color-mix instead of hardcoded per-theme duplicates, actual box-shadow
elevation), a 12-column bento grid with per-widget-type column spans
instead of uniform equal-width cards, fading-edge section dividers,
and a shared WidgetCard/MetricBar/StatusTag vocabulary so every widget
type stops repeating its own card markup.
Icons come from @phosphor-icons/react's /ssr entrypoint (bundled at
build time) rather than the imported design's unpkg.com CDN script -
that script is fine for the standalone design-tool preview, but a
runtime third-party call would break the self-hosted-only principle
already established (fonts self-hosted via next/font, no external
requests). New app/icon.svg reuses the same nav badge mark as the
favicon, replacing the never-touched create-next-app default.
Two new pieces of real functionality prompted by the design's mockup
toast/live-indicator, not just decoration: a "live" WebSocket
connection-status indicator (lib/ws/client.ts now tracks and exposes
real connection state), and a toast that fires on actual config:update
and config:error events - the latter finally surfaces config validation
failures in the browser, previously visible only in server logs despite
being designed for exactly this back in M1.
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.ymland connected browsers update without a page refresh - Secrets via
.env, interpolated intoconfig.ymlat 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=truecan be turned into dashboard widgets automatically; manualconfig.ymlentries always take precedence - CSS-variable theming, light/dark toggle,
theme.customCssPathfor 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:
--pn-accent: "#38bdf8"
# 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. |
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, 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.
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) holdsTRAEFIK_HOST,NETWORK_NAME, andDOCKER_GID- compose-level values, interpolated intodocker-compose.yml's Traefik labels and used to join the container to the docker group. Find your host's docker group id withgetent group docker | cut -d: -f3; without it the non-root container getsEACCESon/var/run/docker.sock.config/.envholds values referenced fromconfig.yml(see above).- The compose file joins the container to an external
falcon_networkso collectors can reach services directly by container name (http://umami:3000/...) instead of only through Traefik. /var/run/docker.sockis mounted read-only;/app/configis mounted read-only from./config.- The container runs as a non-root user with a read-only root filesystem,
dropped capabilities, and
tinias PID 1. /api/healthbacks the containerHEALTHCHECK.
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.
systemwidget metrics reflect whatever cgroup/namespace the container runs in - for true host-level stats rather than the container's own view, mount/procand/sysfrom the host and run withpid: host(not configured by default).