Traefik's widget was the only card without a BrandIcon or StatusDot in
its header, and had no href either - added icon/href fields to the
schema and matched the exact docker/service widget header markup.
StatusDot reuses the same "running once data loaded" convention the
other widgets already use for a non-container up/down signal.
System's widget was span=5 while every other widget in this app is
span=4 (docker/service/traefik) or 3 (http) - on a 12-column grid that
left a permanent 3-column gap next to it every row. Matched it to
span=4 so system+traefik+one more widget tile a full row exactly.
Also added grid-auto-flow: dense as a general safety net against
future span-mismatch gaps.
Traefik's widget listed every router with rule/TLS/status detail,
which reads as noise now that other widgets in the dashboard show a
single headline stat - collapse it to a route count (with an
"enabled" secondary stat when some routers aren't) plus the existing
middlewares count as a small footer line, same visual language as the
new service widgets.
Coolify's widget only reported project count; /api/v1/resources gives
the deployed-resource count (apps/services/databases across all
projects), which is the more useful "how much is actually running"
number, so it's added as a second stat alongside Projects.
One new `service` widget type (nested discriminated union on `service`)
rather than six, so a single config entry shows both docker container
health and a service-specific stat (repo count, project list, photo
count, workflow count, active users, users/nodes) - avoiding the
overhead of configuring a docker widget and a separate service widget
per container. All six collectors hit the service's container name +
internal port directly on falcon_network, the same container-to-
container pattern just proven out for Traefik's own API, avoiding
vpn-only/hairpin-NAT entirely.
Also adds the public/private config split that was scoped in the
original project plan but never built: lib/config/public.ts strips
apiToken/apiKey/password fields before the config reaches the browser
via SSR or the WS config topic - required before any widget could
carry a real secret. Verified via a throwaway secret field that it's
absent from both the SSR HTML and the WS config:update frame.
Endpoint shapes verified live against the running gitea/coolify/immich/
n8n/umami/headscale containers before committing (unauthenticated
requests correctly 401/200 on every target route; gitea's
X-Total-Count header confirmed present).
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.
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.
- docker-compose.yml used "websecure" as the entrypoint name; the
actual falcon_network convention (confirmed against the real
traefik/compose.yml) is "web-secure". Also switched the networks
block from a hardcoded falcon_network to the same
compose_network-aliased ${NETWORK_NAME} pattern already used in the
Traefik labels, instead of only half-respecting that variable.
- config.yml gave the Traefik widget an href, but this deployment runs
with --api.dashboard=false - there's no dashboard to link to.
Third CI failure: docker build failed on COPY --from=build /app/public
with "not found". public/ has been empty since M1 (all the default
Next.js assets were removed and nothing replaced them), and git simply
doesn't track empty directories - it only ever existed on my local
disk from before I deleted its contents in place, never in a real
checkout. Same root cause class as the tsc fix: local testing looked
fine because of leftover local state a truly fresh clone doesn't have.
The first tag run failed at docker login with "password is empty" -
$PACKAGE_TOKEN wasn't actually present as a bare env var in the job
shell the way I'd assumed. Checked the triggershell repo's own working
release workflow (same instance, same registry) for the real pattern:
it's a Gitea Actions secret (likely instance-wide, which is why it
didn't show up in this repo's own `tea actions secrets list`),
referenced via ${{ secrets.PACKAGE_TOKEN }} and mapped into the step's
env block, not inherited directly from the runner process.
Caught by the first real CI run: a bare `pnpm exec tsc --noEmit` fails
on a clean checkout because Next's own ambient types (LayoutProps and
friends, from .next/types) don't exist until a build has run at least
once. Locally this never surfaced since .next was always already
present from earlier dev/build runs. `next build` performs an
equivalent (and more complete) type check as part of its own process,
so the separate step was both redundant and the thing that broke.
Two jobs:
- checks: runs on every push/PR - lint, tsc --noEmit, and a full
`pnpm build` as the static-check gate.
- publish: only on a tag push, gated on checks passing first. Builds
the Dockerfile and pushes to this instance's container registry
(dev.pivoine.art/<owner>/<repo>) tagged both `latest` and the exact
git tag, using PACKAGE_TOKEN from the runner's own environment to
log in (not a repo secret, per how it's configured on this runner).
create-next-app's stock README (npm/yarn/bun instructions, Geist font
mention, Vercel deploy link) never got replaced. Documents what
PulseNode actually is: quick start, the config.yml/.env split and hot-
reload behavior, a reference table for all seven widget types and
their fields, auto-discovery, the docker-compose deployment (including
the DOCKER_GID gotcha found while testing M5), project structure, and
the known gaps (no auth yet, system widget reflects container cgroup
unless the host proc/sys are mounted in).
Replaces the placeholder bookmark links with real docker/database
widgets for every container in the 10 Projekte/stacks compose files
(traefik, coolify+redis+db, gitea+runner+db, code, immich+ml+redis+db,
n8n+db, passbolt+db, umami+db, headscale, mailpit) - the actual
"widgets for all services in Projekte/stacks" deliverable, not just an
example. Also turns on label-based discovery against falcon_network as
a safety net for anything added later without a config.yml update;
manual widgets here still take precedence for anything already listed.
Verified the config validates and renders with no schema errors, and
that a widget pointed at a container that doesn't exist in this
environment fails gracefully (a clean error result over the socket)
rather than crashing - expected here since none of the real stacks run
in this sandbox; wiring against the live falcon_network happens at
actual deploy time.
Multi-stage Dockerfile (deps -> build -> prod-deps -> runtime) that
ships a full production node_modules rather than Next's standalone
output, since standalone tracing is incompatible with a custom server
(noted back in M1). Runs as a non-root user with a read-only rootfs,
dropped capabilities, and tini as PID 1. tsx moves from dev to a real
runtime dependency since the production start script runs server.ts
directly rather than a precompiled bundle. next.config.ts marks
dockerode/systeminformation as serverExternalPackages so Next's
bundler leaves their OS-conditional requires alone.
docker-compose.yml mirrors the sibling stacks' own conventions
(TRAEFIK_HOST/NETWORK_NAME in .env, falcon_network as an external
network, the same traefik.* label shape) so it fits their existing
tooling, plus a new /api/health route and healthcheck.mjs for the
container HEALTHCHECK.
Verified end-to-end with a real `docker build` + `docker compose up`:
non-root/read-only/cap-dropped container boots cleanly, is reachable
by container name from another container on the shared network (as
Traefik would reach it), and the container's own HEALTHCHECK reports
healthy. That run surfaced a real gap - the non-root user got EACCES
on /var/run/docker.sock, since it's owned by root:docker on the host -
fixed via group_add on a DOCKER_GID env var (documented in .env.example
with the command to find it), then re-verified that both docker.sock
access and label-based auto-discovery work correctly under the fix.
Grounds the visual design in the product's own subject matter (vital-
signs monitoring) rather than generic dark-mode defaults: a signature
"pulse" green tied to healthy status (the accent color and the "up"
status color are the same hue - a steady green pulse reads as
healthy, same convention as a cardiac monitor), a small ECG-trace
brand mark, and a restrained pulsing-glow animation on healthy status
dots (prefers-reduced-motion respected) as the one deliberate motion
touch. Typography moves off the bare system-ui stack: IBM Plex Sans
for UI text, IBM Plex Mono with tabular numerals for metric values
(CPU/mem/uptime/latency), Space Grotesk used once for the wordmark -
chosen partly for IBM Plex's own systems-monitoring heritage.
Functional additions:
- Light/dark toggle, independent of config.yml's theme.mode, persisted
to localStorage with a blocking inline script to avoid a flash of
the wrong theme on load; auto mode now genuinely follows
prefers-color-scheme instead of hardcoding dark.
- theme.customCssPath support via a new route handler that serves a
user-mounted CSS file at runtime (can't be a build-time import,
hence the targeted no-css-tags lint suppression), with a path-
traversal guard since it's still reading from disk on every request.
- Loading-skeleton state for widgets awaiting their first WebSocket
result, distinct from both the error state and genuine no-data.
Verified visually in Chrome: theme toggle switches instantly in both
directions, a live docker widget's status dot and monospace metrics
render correctly in both palettes, and config hot-reload still adds a
new widget without a page refresh.
Adds the remaining monitor types: a systeminformation-backed system
widget (single-flight cached so multiple widgets don't force
concurrent samples), an http widget with per-widget consecutive-
failure tracking, and a traefik widget that reads router/entrypoint/
middleware status from Traefik's own API. Also adds opt-in label-based
docker auto-discovery (discovery.docker in config.yml): containers
carrying traefik.enable=true are turned into docker widgets in a
synthetic "Discovered" group, re-scanned on a timer and whenever
config.yml changes, with manual widgets always taking precedence over
a discovered one for the same container.
Fixes a real bug surfaced while testing discovery: Next's App Router
bundles app/** through its own compiler pass, separate from server.ts
(run directly via tsx), so configStore/effectiveConfigStore were
silently instantiated twice - one instance watched and updated by
server.ts, another frozen instance read by SSR. Live config edits were
never reflected on page load without a full process restart. Both
stores now key their singleton off globalThis, which both module
graphs share within the same process.
Verified against a real Traefik v3 container (API-driven router list,
ping-based http check) and a labeled nginx container (auto-discovery),
including that a config.yml edit now shows up in a fresh page load
without restarting the server, in both dev and the production build.
Adds a custom server.ts (http server + Next request handler + a
noServer:true WebSocket server on /ws) so the app can push live data
without polling. A CollectorScheduler runs one interval-based job per
docker/database widget instance, backed by dockerode against
/var/run/docker.sock, and reconciles jobs when config.yml changes.
Config hot-reload is now fully wired: chokidar watches config.yml/.env,
re-validates on change, and broadcasts config:update (or a
non-destructive config:error, keeping the last-good config) to every
connected browser. The client subscribes to per-widget topics over a
single shared WebSocket with exponential-backoff reconnect and
last-result caching for instant resubscribe.
Verified end-to-end against real throwaway containers (nginx, redis):
live CPU/mem/uptime/health streamed over the socket, interval changes
picked up without a server restart, and a broken config.yml correctly
falls back to the last-valid config instead of crashing the app.
Next.js 16 + TypeScript + Tailwind v4 app with a YAML-driven config
system: schema validation (zod), .env interpolation, and a widget
registry rendering bookmark/search/group widgets. CSS custom
properties (--pn-*) drive theming and are overridable from
config.yml's theme.variables. Config load errors surface as a
readable error page instead of crashing the app.
Hot reload, docker/system/http collectors, and the WebSocket push
layer land in later milestones per the approved plan.