Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
199 lines
9.6 KiB
Markdown
199 lines
9.6 KiB
Markdown
# 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://<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
|
|
|
|
```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)
|