5 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 d980e68577 chore: bump version to 0.4.4
CI / Static checks (push) Successful in 36s
CI / Build and push image (push) Successful in 1m30s
package.json had drifted out of sync with git tags since v0.4.0
(tags were cut without a matching package.json bump); this catches
it up and covers the bookmark links-list feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:15:51 +02:00
valknarandClaude Sonnet 5 5a33218235 feat: support a multi-link list in the bookmark widget
A bookmark widget can now carry an optional links array instead of
a single href, rendering as a compact card with one row per link -
for grouping several quick links (e.g. "all services", "all Coolify
apps") into one widget instead of one card per link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:14:47 +02:00
valknarandClaude Sonnet 5 39e0eaf256 docs: add MIT license
CI / Static checks (push) Successful in 37s
CI / Build and push image (push) Skipped
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:06:55 +02:00
valknarandClaude Sonnet 5 9c3afb31af docs: update README for combined docker+API service widgets
The dedicated traefik widget type was folded into the generic
service type; document the resulting service variants (gitea,
coolify, immich, n8n, umami, headscale, traefik) and the secret
redaction that ships alongside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:05:45 +02:00
valknarandClaude Sonnet 5 cbfdb92be4 feat: add themed 404 page
CI / Static checks (push) Successful in 39s
CI / Build and push image (push) Skipped
Flatlined pulse trace mirrors the PulseNode brand mark - a route
that isn't monitored here reads as "no pulse" rather than a generic
error page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 08:32:18 +02:00
7 changed files with 175 additions and 13 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sebastian Krüger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+45 -11
View File
@@ -1,20 +1,25 @@
# PulseNode # PulseNode
A self-hosted infrastructure dashboard: system stats, Docker containers, A self-hosted infrastructure dashboard: system stats, Docker containers,
databases, and HTTP/Traefik health, configured entirely through a YAML file databases, HTTP checks, and combined docker+API service widgets, configured
and pushed to the browser live over WebSockets. No database of its own - entirely through a YAML file and pushed to the browser live over WebSockets.
`config.yml` is the source of truth, metrics are ephemeral. No database of its own - `config.yml` is the source of truth, metrics are
ephemeral.
## Features ## Features
- **YAML-configured** widgets and layout, hot-reloaded - edit `config.yml` - **YAML-configured** widgets and layout, hot-reloaded - edit `config.yml`
and connected browsers update without a page refresh and connected browsers update without a page refresh
- **Secrets via `.env`**, interpolated into `config.yml` at load time - **Secrets via `.env`**, interpolated into `config.yml` at load time
(`${VAR}` / `${VAR:-default}`), never sent to the browser (`${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; - **Live updates over WebSocket** - each widget subscribes to its own topic;
the server only serializes results for widgets someone is actually viewing the server only serializes results for widgets someone is actually viewing
- **Widget types**: Docker containers, Postgres/Redis instances, system - **Widget types**: Docker containers, Postgres/Redis instances, system
resources, HTTP health checks, Traefik router status, bookmarks, search 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 - **Optional label-based auto-discovery** - containers carrying
`traefik.enable=true` can be turned into dashboard widgets automatically; `traefik.enable=true` can be turned into dashboard widgets automatically;
manual `config.yml` entries always take precedence manual `config.yml` entries always take precedence
@@ -64,13 +69,20 @@ discovery:
network: falcon_network # only include containers on this docker network network: falcon_network # only include containers on this docker network
groups: groups:
- name: Core Infra - name: Infra
widgets: widgets:
- type: docker - type: system
interval: 5s
- name: Services
widgets:
- type: service
service: traefik
name: Traefik name: Traefik
containerName: traefik containerName: traefik
icon: traefik
href: https://traefik.example.com href: https://traefik.example.com
interval: 10s interval: 15s
``` ```
Config errors are **non-destructive**: if `config.yml` fails validation Config errors are **non-destructive**: if `config.yml` fails validation
@@ -93,10 +105,26 @@ reload; never exposed to the client. See `config/.env.example`.
| `database` | `name`, `containerName`, `engine` | Thin skin over the same Docker collector - `engine` is `postgres` or `redis`, used for icon/label only. `interval` default `10s`. | | `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`). | | `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`). | | `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`). | | `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`. `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 `icon` on `docker`/`bookmark` widgets is a key into `lib/brand-icons.ts` (currently
`traefik`, `coolify`, `gitea`, `docker`, `immich`, `n8n`, `passbolt`, `umami`, `traefik`, `coolify`, `gitea`, `docker`, `immich`, `n8n`, `passbolt`, `umami`,
`postgresql`, `redis`, `headscale`), rendered monochrome so it doesn't compete `postgresql`, `redis`, `headscale`), rendered monochrome so it doesn't compete
@@ -147,8 +175,10 @@ server.ts custom server: http + WebSocket (/ws) + collector schedul
app/ Next.js App Router pages and API routes app/ Next.js App Router pages and API routes
components/widgets/ one folder per widget type (Widget.tsx + shared Skeleton/StatusDot) components/widgets/ one folder per widget type (Widget.tsx + shared Skeleton/StatusDot)
components/layout/ Dashboard shell, theme toggle, brand mark components/layout/ Dashboard shell, theme toggle, brand mark
lib/config/ schema (zod), loader (parse/interpolate/watch), effective (+ discovery merge) lib/config/ schema (zod), loader (parse/interpolate/watch), effective (+ discovery merge),
lib/collectors/ docker, system, http, traefik collectors + the scheduler 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/discovery/ traefik-label auto-discovery
lib/ws/ WebSocket server (topics) and the browser-side subscription hooks lib/ws/ WebSocket server (topics) and the browser-side subscription hooks
``` ```
@@ -162,3 +192,7 @@ lib/ws/ WebSocket server (topics) and the browser-side subscripti
runs in - for true host-level stats rather than the container's own view, 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 mount `/proc` and `/sys` from the host and run with `pid: host` (not
configured by default). configured by default).
## License
[MIT](LICENSE)
+14
View File
@@ -133,8 +133,22 @@ body {
animation: pulse-glow 2s ease-in-out infinite; animation: pulse-glow 2s ease-in-out infinite;
} }
@keyframes pulse-glow-down {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--pn-status-down) 55%, transparent);
}
50% {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--pn-status-down) 0%, transparent);
}
}
.status-pulse-down {
animation: pulse-glow-down 2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.status-pulse, .status-pulse,
.status-pulse-down,
.pn-toast, .pn-toast,
.pn-spin-in { .pn-spin-in {
animation: none; animation: none;
+55
View File
@@ -0,0 +1,55 @@
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Not Found",
description: "No pulse on this route.",
};
export default function NotFound() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 text-center">
<svg
width="320"
height="64"
viewBox="0 0 320 64"
fill="none"
aria-hidden="true"
className="w-full max-w-[320px]"
>
<polyline
points="0,32 30,32 40,10 50,54 60,24 72,32 140,32"
fill="none"
stroke="var(--pn-accent)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line
x1="140"
y1="32"
x2="300"
y2="32"
stroke="var(--pn-status-down)"
strokeWidth="2"
strokeLinecap="round"
opacity="0.55"
/>
<circle cx="300" cy="32" r="4" fill="var(--pn-status-down)" className="status-pulse-down" />
</svg>
<div className="flex flex-col gap-3">
<p className="font-display text-7xl font-semibold tracking-tight text-fg">404</p>
<p className="text-base font-medium text-fg">No pulse on this route.</p>
<p className="text-sm text-fg-muted">Nothing here is being monitored.</p>
</div>
<Link
href="/"
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-fg transition-colors hover:border-accent hover:text-accent"
>
Back to dashboard
</Link>
</main>
);
}
+31
View File
@@ -6,6 +6,37 @@ import { BrandIcon } from "@/components/widgets/BrandIcon";
type BookmarkWidget = Extract<Widget, { type: "bookmark" }>; type BookmarkWidget = Extract<Widget, { type: "bookmark" }>;
export function BookmarkWidget({ widget }: { widget: BookmarkWidget }) { export function BookmarkWidget({ widget }: { widget: BookmarkWidget }) {
if (widget.links && widget.links.length > 0) {
return (
<div className={PN_CARD_CLASS} style={{ gridColumn: "span 3" }}>
<div className="flex items-center gap-2">
<BrandIcon slug={widget.icon} />
<span className="min-w-0 flex-1 truncate text-[15px] font-medium text-fg">{widget.name}</span>
</div>
{widget.description && <span className="text-xs text-fg-muted">{widget.description}</span>}
<div className="-mx-1 flex flex-col divide-y divide-border">
{widget.links.map((link) => (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noreferrer"
className="group flex items-center gap-2 px-1 py-1.5 text-sm text-fg-muted no-underline hover:text-accent"
>
<BrandIcon slug={link.icon} size={14} />
<span className="min-w-0 flex-1 truncate">{link.name}</span>
<ArrowUpRight
size={13}
className="shrink-0 text-accent opacity-0 group-hover:opacity-100"
aria-hidden
/>
</a>
))}
</div>
</div>
);
}
return ( return (
<a <a
href={widget.href} href={widget.href}
+8 -1
View File
@@ -6,12 +6,19 @@ const durationSchema = z
.string() .string()
.regex(/^\d+(ms|s|m|h)$/, 'Expected a duration like "500ms", "5s", "1m", or "1h"'); .regex(/^\d+(ms|s|m|h)$/, 'Expected a duration like "500ms", "5s", "1m", or "1h"');
export const bookmarkLinkSchema = z.object({
name: z.string(),
href: z.string().url(),
icon: z.string().optional(),
});
export const bookmarkWidgetSchema = z.object({ export const bookmarkWidgetSchema = z.object({
type: z.literal("bookmark"), type: z.literal("bookmark"),
name: z.string(), name: z.string(),
href: z.string().url(), href: z.string().url().optional(),
description: z.string().optional(), description: z.string().optional(),
icon: z.string().optional(), icon: z.string().optional(),
links: z.array(bookmarkLinkSchema).optional(),
}); });
export const searchWidgetSchema = z.object({ export const searchWidgetSchema = z.object({
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulsenode", "name": "pulsenode",
"version": "0.3.0", "version": "0.4.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch server.ts", "dev": "tsx watch server.ts",