# 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) ```bash 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`: ```yaml 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://:`), 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 ```bash 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](LICENSE)