9 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
valknar 8c87680078 feat: fold traefik into the service widget type, remove the dedicated one
CI / Static checks (push) Successful in 37s
CI / Build and push image (push) Successful in 1m9s
Traefik gets the same treatment as the other six services now instead
of a bespoke widget type: docker health merged with a single "Routes"
stat, via the same internal container-to-container API call
(http://traefik:8080/api/http/routers) it already used. No auth
needed, same as before.

Deleted lib/collectors/traefik.ts, lib/types/traefik-result.ts, and
components/widgets/traefik/ entirely - the generic service Widget.tsx
and ServiceWidgetResult shape cover it with zero new component code.
2026-08-17 21:33:33 +02:00
valknar e14fbc9205 feat: streamline traefik widget with icon/status dot, fix grid gap
CI / Static checks (push) Successful in 36s
CI / Build and push image (push) Successful in 1m9s
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.
2026-08-17 21:23:01 +02:00
valknar 813cad613e feat: simplify traefik widget to a route count, add coolify resource count
CI / Static checks (push) Successful in 36s
CI / Build and push image (push) Successful in 1m6s
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.
2026-08-17 21:11:56 +02:00
valknar ffb70ecddf feat: add combined docker+API service widgets for gitea/coolify/immich/n8n/umami/headscale
CI / Static checks (push) Successful in 35s
CI / Build and push image (push) Successful in 1m12s
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).
2026-08-17 20:45:29 +02:00
29 changed files with 651 additions and 156 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
A self-hosted infrastructure dashboard: system stats, Docker containers,
databases, and HTTP/Traefik health, 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.
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}`), 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;
the server only serializes results for widgets someone is actually viewing
- **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
`traefik.enable=true` can be turned into dashboard widgets automatically;
manual `config.yml` entries always take precedence
@@ -64,13 +69,20 @@ discovery:
network: falcon_network # only include containers on this docker network
groups:
- name: Core Infra
- name: Infra
widgets:
- type: docker
- type: system
interval: 5s
- name: Services
widgets:
- type: service
service: traefik
name: Traefik
containerName: traefik
icon: traefik
href: https://traefik.example.com
interval: 10s
interval: 15s
```
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`. |
| `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`). |
| `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`.
#### `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
@@ -147,8 +175,10 @@ server.ts custom server: http + WebSocket (/ws) + collector schedul
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)
lib/collectors/ docker, system, http, traefik collectors + the scheduler
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
```
@@ -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,
mount `/proc` and `/sys` from the host and run with `pid: host` (not
configured by default).
## License
[MIT](LICENSE)
+15
View File
@@ -133,8 +133,22 @@ body {
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) {
.status-pulse,
.status-pulse-down,
.pn-toast,
.pn-spin-in {
animation: none;
@@ -144,6 +158,7 @@ body {
.pn-bento {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-flow: dense;
}
@media (max-width: 860px) {
+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>
);
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { ConfigError } from "@/lib/config/loader";
import { effectiveConfigStore } from "@/lib/config/effective";
import { toPublicConfig } from "@/lib/config/public";
import { Dashboard } from "@/components/layout/Dashboard";
export const dynamic = "force-dynamic";
@@ -20,5 +21,5 @@ export default function Home() {
);
}
return <Dashboard config={config} />;
return <Dashboard config={toPublicConfig(config)} />;
}
+31
View File
@@ -6,6 +6,37 @@ import { BrandIcon } from "@/components/widgets/BrandIcon";
type BookmarkWidget = Extract<Widget, { type: "bookmark" }>;
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 (
<a
href={widget.href}
+2 -2
View File
@@ -6,7 +6,7 @@ import { DockerWidget } from "./docker/Widget";
import { DatabaseWidget } from "./database/Widget";
import { SystemWidget } from "./system/Widget";
import { HttpWidget } from "./http/Widget";
import { TraefikWidget } from "./traefik/Widget";
import { ServiceWidget } from "./service/Widget";
type WidgetComponent<T extends Widget["type"]> = ComponentType<{
widget: Extract<Widget, { type: T }>;
@@ -20,5 +20,5 @@ export const widgetRegistry: { [K in Widget["type"]]: WidgetComponent<K> } = {
database: DatabaseWidget,
system: SystemWidget,
http: HttpWidget,
traefik: TraefikWidget,
service: ServiceWidget,
};
+90
View File
@@ -0,0 +1,90 @@
"use client";
import type { Widget } from "@/lib/config/schema";
import { useWidgetSubscription } from "@/lib/ws/client";
import { StatusDot } from "@/components/widgets/StatusDot";
import { StatusTag } from "@/components/widgets/StatusTag";
import { SkeletonLines } from "@/components/widgets/Skeleton";
import { WidgetCard } from "@/components/widgets/WidgetCard";
import { MetricBar } from "@/components/widgets/MetricBar";
import { BrandIcon } from "@/components/widgets/BrandIcon";
import { formatBytes, formatUptime } from "@/lib/format";
type ServiceWidget = Extract<Widget, { type: "service" }>;
export function ServiceWidget({ widget, widgetId }: { widget: ServiceWidget; widgetId: string }) {
const result = useWidgetSubscription(widgetId);
const data = result?.type === "service" ? result.data : null;
const errorMessage = result?.type === "error" ? result.message : null;
const memPercent =
data?.docker.memUsageBytes != null && data.docker.memLimitBytes
? (data.docker.memUsageBytes / data.docker.memLimitBytes) * 100
: null;
return (
<WidgetCard span={4}>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<BrandIcon slug={widget.icon} />
{widget.href ? (
<a
href={widget.href}
target="_blank"
rel="noreferrer"
className="truncate text-[15px] font-medium text-fg hover:text-accent"
>
{widget.name}
</a>
) : (
<span className="truncate text-[15px] font-medium text-fg">{widget.name}</span>
)}
</div>
<StatusDot status={data?.docker.status} health={data?.docker.health} />
</div>
{errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>}
{!result && !errorMessage && <SkeletonLines count={3} />}
{data && (
<>
{data.stats.length > 0 && (
<div className="flex items-start gap-4">
{data.stats.map((stat, index) => (
<div
key={stat.label}
className={`flex flex-col gap-0.5 ${index > 0 ? "border-l border-border pl-4" : ""}`}
>
<span className="font-mono text-2xl leading-none font-semibold tabular-nums text-accent">
{stat.value}
</span>
<span className="text-[10px] tracking-wide text-fg-muted uppercase">{stat.label}</span>
</div>
))}
</div>
)}
{data.detail && data.detail.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{data.detail.map((entry) => (
<StatusTag key={entry} tone="neutral">
{entry}
</StatusTag>
))}
</div>
)}
{data.statError && <StatusTag tone="degraded">{data.statError}</StatusTag>}
{widget.showStats && data.docker.cpuPercent !== null && memPercent !== null && (
<div className="mt-0.5 flex gap-4">
<MetricBar label="CPU" percent={data.docker.cpuPercent} detail={`${data.docker.cpuPercent.toFixed(1)}%`} />
<MetricBar label="MEM" percent={memPercent} detail={`${memPercent.toFixed(0)}%`} />
</div>
)}
<div className="flex items-center gap-3 text-[11px] text-fg-muted">
{data.docker.uptimeSeconds !== null && <span>up {formatUptime(data.docker.uptimeSeconds)}</span>}
{widget.showStats && data.docker.memUsageBytes !== null && memPercent === null && (
<span className="font-mono tabular-nums">{formatBytes(data.docker.memUsageBytes)}</span>
)}
{data.docker.restartCount > 0 && <span>{data.docker.restartCount} restarts</span>}
</div>
</>
)}
</WidgetCard>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function SystemWidget({ widget, widgetId }: { widget: SystemWidget; widge
const errorMessage = result?.type === "error" ? result.message : null;
return (
<WidgetCard span={5} rowSpan={2}>
<WidgetCard span={4} rowSpan={2}>
<div className="text-[10px] tracking-[0.1em] text-accent uppercase">System</div>
<span className="text-[15px] font-medium text-fg">{widget.name}</span>
{errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>}
-53
View File
@@ -1,53 +0,0 @@
"use client";
import { Lock } from "@phosphor-icons/react/ssr";
import type { Widget } from "@/lib/config/schema";
import { useWidgetSubscription } from "@/lib/ws/client";
import { SkeletonLines } from "@/components/widgets/Skeleton";
import { WidgetCard } from "@/components/widgets/WidgetCard";
type TraefikWidget = Extract<Widget, { type: "traefik" }>;
export function TraefikWidget({ widget, widgetId }: { widget: TraefikWidget; widgetId: string }) {
const result = useWidgetSubscription(widgetId);
const data = result?.type === "traefik" ? result.data : null;
const errorMessage = result?.type === "error" ? result.message : null;
return (
<WidgetCard span={4}>
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-fg">{widget.name}</span>
{data && (
<span className="font-mono text-[11px] tabular-nums text-fg-muted">
{data.entrypoints.map((ep) => ep.address).join(" · ") || `${data.entrypoints.length} entrypoints`}
</span>
)}
</div>
{errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>}
{!result && !errorMessage && <SkeletonLines count={3} />}
{data && (
<div className="flex flex-col">
{data.routers.map((router) => (
<div key={router.name} className="flex items-center justify-between gap-2 border-t border-border py-1.5 text-[13px] first:border-t-0">
<span className="truncate text-fg" title={router.rule}>
{router.name.replace(/@.*$/, "")}
</span>
<span className="flex items-center gap-1.5 text-[11px] text-fg-muted">
{router.tls && <Lock size={11} aria-label="TLS enabled" />}
<span
className={`flex items-center gap-1.5 ${router.status === "enabled" ? "text-status-up" : "text-status-down"}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" />
{router.status}
</span>
</span>
</div>
))}
<span className="pt-2 text-[11px] text-fg-muted">
<span className="font-mono tabular-nums">{data.middlewaresCount}</span> middlewares
</span>
</div>
)}
</WidgetCard>
);
}
+3
View File
@@ -1,11 +1,14 @@
import Docker from "dockerode";
import type { ContainerStats } from "dockerode";
import pLimit from "p-limit";
import type { DockerContainerResult } from "@/lib/types/widget-result";
const docker = new Docker({
socketPath: process.env.DOCKER_SOCKET_PATH ?? "/var/run/docker.sock",
});
export const dockerLimit = pLimit(4);
function calcCpuPercent(stats: ContainerStats): number | null {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
+9 -10
View File
@@ -2,13 +2,12 @@ import pLimit from "p-limit";
import type { Config, Widget } from "@/lib/config/schema";
import { flattenWidgets } from "@/lib/config/widgets";
import { parseDuration } from "@/lib/config/duration";
import { collectDockerContainer } from "./docker";
import { collectDockerContainer, dockerLimit } from "./docker";
import { collectSystem } from "./system";
import { collectHttp } from "./http";
import { collectTraefik } from "./traefik";
import { collectService } from "./service";
import type { WidgetResult } from "@/lib/types/widget-result";
const dockerLimit = pLimit(4);
const httpLimit = pLimit(8);
type ResultListener = (widgetId: string, result: WidgetResult) => void;
@@ -20,7 +19,7 @@ interface Job {
consecutiveFailures: number;
}
const COLLECTOR_TYPES = new Set<Widget["type"]>(["docker", "database", "system", "http", "traefik"]);
const COLLECTOR_TYPES = new Set<Widget["type"]>(["docker", "database", "system", "http", "service"]);
function isCollectorWidget(widget: Widget): boolean {
return COLLECTOR_TYPES.has(widget.type);
@@ -32,7 +31,7 @@ function getIntervalMs(widget: Widget): number {
widget.type === "database" ||
widget.type === "system" ||
widget.type === "http" ||
widget.type === "traefik"
widget.type === "service"
) {
return parseDuration(widget.interval);
}
@@ -52,8 +51,8 @@ function sameTarget(a: Widget, b: Widget): boolean {
if (a.type === "http" && b.type === "http") {
return a.url === b.url && a.method === b.method && a.expect.status === b.expect.status;
}
if (a.type === "traefik" && b.type === "traefik") {
return a.apiUrl === b.apiUrl;
if (a.type === "service" && b.type === "service") {
return a.containerName === b.containerName && a.service === b.service && a.showStats === b.showStats;
}
return false;
}
@@ -77,9 +76,9 @@ async function collect(widget: Widget): Promise<WidgetResult> {
);
return { type: "http", data: { ...data, consecutiveFailures: 0 } };
}
if (widget.type === "traefik") {
const data = await collectTraefik(widget.apiUrl);
return { type: "traefik", data };
if (widget.type === "service") {
const data = await collectService(widget);
return { type: "service", data };
}
throw new Error(`No collector for widget type "${widget.type}"`);
}
+44
View File
@@ -0,0 +1,44 @@
import type { ServiceWidget } from "@/lib/config/schema";
import type { ServiceStat, ServiceWidgetResult } from "@/lib/types/service-result";
import { collectDockerContainer, dockerLimit } from "./docker";
import { collectGitea } from "./services/gitea";
import { collectCoolify } from "./services/coolify";
import { collectImmich } from "./services/immich";
import { collectN8n } from "./services/n8n";
import { collectUmami } from "./services/umami";
import { collectHeadscale } from "./services/headscale";
import { collectTraefikStat } from "./services/traefik";
function collectServiceStat(widget: ServiceWidget): Promise<{ stats: ServiceStat[]; detail?: string[] }> {
switch (widget.service) {
case "gitea":
return collectGitea(widget);
case "coolify":
return collectCoolify(widget);
case "immich":
return collectImmich(widget);
case "n8n":
return collectN8n(widget);
case "umami":
return collectUmami(widget);
case "headscale":
return collectHeadscale(widget);
case "traefik":
return collectTraefikStat(widget);
}
}
export async function collectService(widget: ServiceWidget): Promise<ServiceWidgetResult> {
const dockerPromise = dockerLimit(() => collectDockerContainer(widget.containerName, widget.showStats));
// A failed service-API call (bad token, service down) must not take out the
// docker health readout too - the two are independent failure modes and
// merging them into one card should not let one mask the other.
const statPromise = collectServiceStat(widget).catch((err) => ({
stats: [] as ServiceStat[],
statError: (err as Error).message,
}));
const [docker, statResult] = await Promise.all([dockerPromise, statPromise]);
return { docker, ...statResult };
}
+15
View File
@@ -0,0 +1,15 @@
import type { ServiceWidget } from "@/lib/config/schema";
const SERVICE_PORTS: Record<ServiceWidget["service"], number> = {
gitea: 3000,
coolify: 8080,
immich: 2283,
n8n: 5678,
umami: 3000,
headscale: 8080,
traefik: 8080,
};
export function serviceBaseUrl(widget: ServiceWidget): string {
return `http://${widget.containerName}:${SERVICE_PORTS[widget.service]}`;
}
+40
View File
@@ -0,0 +1,40 @@
import type { CoolifyServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url";
const DETAIL_LIMIT = 5;
interface CoolifyProject {
name: string;
}
async function fetchCoolify<T>(base: string, path: string, apiToken: string): Promise<T> {
const response = await fetch(`${base}${path}`, {
headers: { Authorization: `Bearer ${apiToken}` },
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
throw new Error(`Coolify API returned ${response.status}`);
}
return (await response.json()) as T;
}
export async function collectCoolify(
widget: CoolifyServiceWidget
): Promise<{ stats: ServiceStat[]; detail?: string[] }> {
const base = serviceBaseUrl(widget);
const [projects, resources] = await Promise.all([
fetchCoolify<CoolifyProject[]>(base, "/api/v1/projects", widget.apiToken),
fetchCoolify<unknown[]>(base, "/api/v1/resources", widget.apiToken),
]);
const names = projects.slice(0, DETAIL_LIMIT).map((p) => p.name);
if (projects.length > DETAIL_LIMIT) names.push(`+${projects.length - DETAIL_LIMIT} more`);
return {
stats: [
{ label: "Projects", value: String(projects.length) },
{ label: "Resources", value: String(resources.length) },
],
detail: names.length > 0 ? names : undefined,
};
}
+16
View File
@@ -0,0 +1,16 @@
import type { GiteaServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url";
export async function collectGitea(widget: GiteaServiceWidget): Promise<{ stats: ServiceStat[] }> {
const base = serviceBaseUrl(widget);
const response = await fetch(`${base}/api/v1/users/${widget.username}/repos?limit=1`, {
headers: { Authorization: `token ${widget.apiToken}` },
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
throw new Error(`Gitea API returned ${response.status}`);
}
const count = response.headers.get("X-Total-Count") ?? "0";
return { stats: [{ label: "Repos", value: count }] };
}
+30
View File
@@ -0,0 +1,30 @@
import type { HeadscaleServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url";
async function fetchList(url: string, apiToken: string, label: string, key: "users" | "nodes"): Promise<unknown[]> {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiToken}` },
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
throw new Error(`Headscale ${label} API returned ${response.status}`);
}
const body = (await response.json()) as Record<string, unknown[]>;
return body[key] ?? [];
}
export async function collectHeadscale(widget: HeadscaleServiceWidget): Promise<{ stats: ServiceStat[] }> {
const base = serviceBaseUrl(widget);
const [users, nodes] = await Promise.all([
fetchList(`${base}/api/v1/user`, widget.apiToken, "user", "users"),
fetchList(`${base}/api/v1/node`, widget.apiToken, "node", "nodes"),
]);
return {
stats: [
{ label: "Users", value: String(users.length) },
{ label: "Nodes", value: String(nodes.length) },
],
};
}
+26
View File
@@ -0,0 +1,26 @@
import type { ImmichServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url";
interface ImmichStatistics {
photos: number;
videos: number;
}
export async function collectImmich(widget: ImmichServiceWidget): Promise<{ stats: ServiceStat[] }> {
const base = serviceBaseUrl(widget);
const response = await fetch(`${base}/api/server/statistics`, {
headers: { "x-api-key": widget.apiKey },
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
throw new Error(`Immich API returned ${response.status}`);
}
const stats = (await response.json()) as ImmichStatistics;
return {
stats: [
{ label: "Photos", value: String(stats.photos) },
{ label: "Videos", value: String(stats.videos) },
],
};
}
+20
View File
@@ -0,0 +1,20 @@
import type { N8nServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url";
interface N8nWorkflowsPage {
data: unknown[];
}
export async function collectN8n(widget: N8nServiceWidget): Promise<{ stats: ServiceStat[] }> {
const base = serviceBaseUrl(widget);
const response = await fetch(`${base}/api/v1/workflows?limit=250`, {
headers: { "X-N8N-API-KEY": widget.apiKey },
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
throw new Error(`n8n API returned ${response.status}`);
}
const page = (await response.json()) as N8nWorkflowsPage;
return { stats: [{ label: "Workflows", value: String(page.data.length) }] };
}
+17
View File
@@ -0,0 +1,17 @@
import type { TraefikServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url";
interface RawRouter {
status: string;
}
export async function collectTraefikStat(widget: TraefikServiceWidget): Promise<{ stats: ServiceStat[] }> {
const base = serviceBaseUrl(widget);
const response = await fetch(`${base}/api/http/routers`, { signal: AbortSignal.timeout(5_000) });
if (!response.ok) {
throw new Error(`Traefik API returned ${response.status}`);
}
const routers = (await response.json()) as RawRouter[];
return { stats: [{ label: "Routes", value: String(routers.length) }] };
}
+48
View File
@@ -0,0 +1,48 @@
import type { UmamiServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url";
// Self-hosted Umami has no static API-key header (that's Cloud-only) - it needs
// a username/password login exchanged for a Bearer token. Cache the token per
// container and only re-login when a request comes back 401, since the docs
// don't document a fixed token lifetime to pre-emptively refresh against.
const tokenCache = new Map<string, string>();
async function login(widget: UmamiServiceWidget, base: string): Promise<string> {
const response = await fetch(`${base}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: widget.username, password: widget.password }),
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
throw new Error(`Umami login returned ${response.status}`);
}
const body = (await response.json()) as { token: string };
tokenCache.set(widget.containerName, body.token);
return body.token;
}
export async function collectUmami(widget: UmamiServiceWidget): Promise<{ stats: ServiceStat[] }> {
const base = serviceBaseUrl(widget);
let token = tokenCache.get(widget.containerName) ?? (await login(widget, base));
let response = await fetch(`${base}/api/websites/${widget.websiteId}/active`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5_000),
});
if (response.status === 401) {
token = await login(widget, base);
response = await fetch(`${base}/api/websites/${widget.websiteId}/active`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5_000),
});
}
if (!response.ok) {
throw new Error(`Umami API returned ${response.status}`);
}
const body = (await response.json()) as { visitors: number };
return { stats: [{ label: "Active", value: String(body.visitors) }] };
}
-48
View File
@@ -1,48 +0,0 @@
import type { TraefikResult } from "@/lib/types/traefik-result";
interface RawRouter {
name: string;
rule: string;
service: string;
status: string;
tls?: unknown;
entryPoints?: string[];
}
interface RawEntrypoint {
name: string;
address: string;
}
async function fetchJson<T>(url: string): Promise<T | null> {
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
if (!response.ok) return null;
return (await response.json()) as T;
}
export async function collectTraefik(apiUrl: string): Promise<TraefikResult> {
const base = apiUrl.replace(/\/$/, "");
const [routers, entrypoints, middlewares] = await Promise.all([
fetchJson<RawRouter[]>(`${base}/http/routers`),
fetchJson<RawEntrypoint[]>(`${base}/entrypoints`),
fetchJson<unknown[]>(`${base}/http/middlewares`),
]);
if (routers === null) {
throw new Error(`Traefik API at ${base}/http/routers is unreachable or returned an error`);
}
return {
routers: routers.map((router) => ({
name: router.name,
rule: router.rule,
service: router.service,
status: router.status,
tls: Boolean(router.tls),
entryPoints: router.entryPoints ?? [],
})),
entrypoints: (entrypoints ?? []).map((ep) => ({ name: ep.name, address: ep.address })),
middlewaresCount: middlewares?.length ?? 0,
};
}
+19
View File
@@ -0,0 +1,19 @@
import type { Config, Widget } from "./schema";
const SECRET_WIDGET_FIELDS = ["apiToken", "apiKey", "password"] as const;
export function toPublicConfig(config: Config): Config {
return {
...config,
groups: config.groups.map((group) => ({
...group,
widgets: group.widgets.map(redactWidget),
})),
};
}
function redactWidget(widget: Widget): Widget {
const clone: Record<string, unknown> = { ...widget };
for (const key of SECRET_WIDGET_FIELDS) delete clone[key];
return clone as Widget;
}
+78 -6
View File
@@ -6,12 +6,19 @@ const durationSchema = z
.string()
.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({
type: z.literal("bookmark"),
name: z.string(),
href: z.string().url(),
href: z.string().url().optional(),
description: z.string().optional(),
icon: z.string().optional(),
links: z.array(bookmarkLinkSchema).optional(),
});
export const searchWidgetSchema = z.object({
@@ -58,13 +65,70 @@ export const httpWidgetSchema = z.object({
.default({ status: 200 }),
});
export const traefikWidgetSchema = z.object({
type: z.literal("traefik"),
name: z.string().default("Traefik"),
apiUrl: z.string().url(),
const serviceCommonFields = {
type: z.literal("service") as z.ZodLiteral<"service">,
name: z.string(),
containerName: z.string(),
href: z.string().url().optional(),
showStats: z.boolean().default(true),
interval: durationSchema.default("15s"),
icon: z.string().optional(),
};
export const giteaServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("gitea"),
username: z.string().default("Valknar"),
apiToken: z.string(),
});
export const coolifyServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("coolify"),
apiToken: z.string(),
});
export const immichServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("immich"),
apiKey: z.string(),
});
export const n8nServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("n8n"),
apiKey: z.string(),
});
export const umamiServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("umami"),
username: z.string(),
password: z.string(),
websiteId: z.string(),
});
export const headscaleServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("headscale"),
apiToken: z.string(),
});
export const traefikServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("traefik"),
});
export const serviceWidgetSchema = z.discriminatedUnion("service", [
giteaServiceWidgetSchema,
coolifyServiceWidgetSchema,
immichServiceWidgetSchema,
n8nServiceWidgetSchema,
umamiServiceWidgetSchema,
headscaleServiceWidgetSchema,
traefikServiceWidgetSchema,
]);
export const widgetSchema = z.discriminatedUnion("type", [
bookmarkWidgetSchema,
searchWidgetSchema,
@@ -72,7 +136,7 @@ export const widgetSchema = z.discriminatedUnion("type", [
databaseWidgetSchema,
systemWidgetSchema,
httpWidgetSchema,
traefikWidgetSchema,
serviceWidgetSchema,
]);
export const groupSchema = z.object({
@@ -126,3 +190,11 @@ export type Config = z.infer<typeof configSchema>;
export type Widget = z.infer<typeof widgetSchema>;
export type Group = z.infer<typeof groupSchema>;
export type SearchEngine = z.infer<typeof searchEngineSchema>;
export type ServiceWidget = z.infer<typeof serviceWidgetSchema>;
export type GiteaServiceWidget = z.infer<typeof giteaServiceWidgetSchema>;
export type CoolifyServiceWidget = z.infer<typeof coolifyServiceWidgetSchema>;
export type ImmichServiceWidget = z.infer<typeof immichServiceWidgetSchema>;
export type N8nServiceWidget = z.infer<typeof n8nServiceWidgetSchema>;
export type UmamiServiceWidget = z.infer<typeof umamiServiceWidgetSchema>;
export type HeadscaleServiceWidget = z.infer<typeof headscaleServiceWidgetSchema>;
export type TraefikServiceWidget = z.infer<typeof traefikServiceWidgetSchema>;
+13
View File
@@ -0,0 +1,13 @@
import type { DockerContainerResult } from "./widget-result";
export interface ServiceStat {
label: string;
value: string;
}
export interface ServiceWidgetResult {
docker: DockerContainerResult;
stats: ServiceStat[];
detail?: string[];
statError?: string;
}
-19
View File
@@ -1,19 +0,0 @@
export interface TraefikRouterInfo {
name: string;
rule: string;
service: string;
status: string;
tls: boolean;
entryPoints: string[];
}
export interface TraefikEntrypointInfo {
name: string;
address: string;
}
export interface TraefikResult {
routers: TraefikRouterInfo[];
entrypoints: TraefikEntrypointInfo[];
middlewaresCount: number;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { SystemResult } from "./system-result";
import type { HttpCheckResult } from "./http-result";
import type { TraefikResult } from "./traefik-result";
import type { ServiceWidgetResult } from "./service-result";
export interface DockerContainerResult {
status: "running" | "exited" | "restarting" | "paused" | "dead" | "created" | "unknown";
@@ -19,5 +19,5 @@ export type WidgetResult =
| { type: "database"; data: DockerContainerResult }
| { type: "system"; data: SystemResult }
| { type: "http"; data: HttpCheckResult }
| { type: "traefik"; data: TraefikResult }
| { type: "service"; data: ServiceWidgetResult }
| { type: "error"; message: string };
+8 -2
View File
@@ -3,6 +3,7 @@ import { WebSocket, WebSocketServer } from "ws";
import { collectorScheduler } from "@/lib/collectors/scheduler";
import { configStore, type ConfigError } from "@/lib/config/loader";
import { effectiveConfigStore } from "@/lib/config/effective";
import { toPublicConfig } from "@/lib/config/public";
import type { Config } from "@/lib/config/schema";
import type { WidgetResult } from "@/lib/types/widget-result";
@@ -49,7 +50,7 @@ export function attachWebSocketServer(httpServer: HttpServer): WebSocketServer {
try {
const config = effectiveConfigStore.get();
send(socket, { topic: "config", type: "config:update", ts: Date.now(), data: config });
send(socket, { topic: "config", type: "config:update", ts: Date.now(), data: toPublicConfig(config) });
} catch {
// no valid config loaded yet; client keeps its SSR-seeded config until one arrives
}
@@ -80,7 +81,12 @@ export function attachWebSocketServer(httpServer: HttpServer): WebSocketServer {
});
effectiveConfigStore.onUpdate((config) => {
const envelope: Envelope = { topic: "config", type: "config:update", ts: Date.now(), data: config };
const envelope: Envelope = {
topic: "config",
type: "config:update",
ts: Date.now(),
data: toPublicConfig(config),
};
for (const socket of allSockets) send(socket, envelope);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulsenode",
"version": "0.3.0",
"version": "0.4.4",
"private": true,
"scripts": {
"dev": "tsx watch server.ts",