Files

199 lines
9.6 KiB
Markdown
Raw Permalink Normal View History

# PulseNode
2026-08-17 13:49:55 +02:00
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.
2026-08-17 13:49:55 +02:00
## 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)
2026-08-17 13:49:55 +02:00
```bash
pnpm install
cp config/.env.example config/.env # fill in real values
cp .env.example .env # only needed for docker compose
2026-08-17 13:49:55 +02:00
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.
2026-08-17 13:49:55 +02:00
Other scripts: `pnpm build` (production build), `pnpm start` (run the
production build via the same custom server), `pnpm lint`, `pnpm exec tsc
--noEmit`.
2026-08-17 13:49:55 +02:00
## Configuration
2026-08-17 13:49:55 +02:00
### `config/config.yml`
2026-08-17 13:49:55 +02:00
Everything is organized into `groups`, each a list of `widgets`:
2026-08-17 13:49:55 +02:00
```yaml
settings:
title: PulseNode
2026-08-17 13:49:55 +02:00
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
2026-08-17 13:49:55 +02:00
discovery:
docker:
enabled: false # set true to auto-discover traefik-labeled containers
network: falcon_network # only include containers on this docker network
2026-08-17 13:49:55 +02:00
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
```
2026-08-17 13:49:55 +02:00
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).
2026-08-18 10:06:55 +02:00
## License
[MIT](LICENSE)