11 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 1d51c5c887 chore: bump version to 0.4.6
CI / Static checks (push) Successful in 38s
CI / Build and push image (push) Successful in 1m30s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 16:58:56 +02:00
valknarandClaude Sonnet 5 2c84a4c9c2 fix: remove project name badges from the Coolify widget
Coolify was the only service reporting the generic detail field
(project name chips); drop it end to end - collector, type, and the
widget's chip row - rather than leave the plumbing for zero producers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 16:58:47 +02:00
valknarandClaude Sonnet 5 6ab3054d9a chore: bump version to 0.4.5
CI / Static checks (push) Successful in 36s
CI / Build and push image (push) Successful in 1m34s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:26:58 +02:00
valknarandClaude Sonnet 5 7549658fbb fix: bookmark widget sizing, generic globe icon, code-server brand icon
- multi-link bookmark cards now span 4/2 (matching the system widget)
  instead of 3/1, so they're not cramped and line up into an even
  3-up row alongside it
- the link list scrolls internally instead of stretching the card,
  so widgets in the same row stay equal height regardless of link count
- BrandIcon gains a small "generic icon" escape hatch (currently just
  `globe`, via phosphor) for widgets that don't have a real brand mark
- added a `coder` brand icon (Coder/code-server) for the Code Server link,
  since simple-icons has no official VS Code mark to use

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:26:45 +02:00
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
23 changed files with 224 additions and 187 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)
+15
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;
@@ -144,6 +158,7 @@ body {
.pn-bento { .pn-bento {
display: grid; display: grid;
grid-template-columns: repeat(12, 1fr); grid-template-columns: repeat(12, 1fr);
grid-auto-flow: dense;
} }
@media (max-width: 860px) { @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>
);
}
+13
View File
@@ -1,7 +1,20 @@
import { GlobeSimple } from "@phosphor-icons/react/ssr";
import { BRAND_ICON_PATHS } from "@/lib/brand-icons"; import { BRAND_ICON_PATHS } from "@/lib/brand-icons";
// Generic (non-brand) icons that don't belong in BRAND_ICON_PATHS - for
// widgets that link to something with no single logo of its own.
const GENERIC_ICONS = {
globe: GlobeSimple,
};
export function BrandIcon({ slug, size = 18 }: { slug?: string; size?: number }) { export function BrandIcon({ slug, size = 18 }: { slug?: string; size?: number }) {
if (!slug) return null; if (!slug) return null;
const GenericIcon = GENERIC_ICONS[slug as keyof typeof GENERIC_ICONS];
if (GenericIcon) {
return <GenericIcon size={size} className="shrink-0 text-fg-muted" aria-hidden />;
}
const path = BRAND_ICON_PATHS[slug]; const path = BRAND_ICON_PATHS[slug];
if (!path) return null; if (!path) return null;
+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 4", gridRow: "span 2" }}>
<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 min-h-0 flex-1 flex-col divide-y divide-border overflow-y-auto">
{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}
-2
View File
@@ -6,7 +6,6 @@ import { DockerWidget } from "./docker/Widget";
import { DatabaseWidget } from "./database/Widget"; import { DatabaseWidget } from "./database/Widget";
import { SystemWidget } from "./system/Widget"; import { SystemWidget } from "./system/Widget";
import { HttpWidget } from "./http/Widget"; import { HttpWidget } from "./http/Widget";
import { TraefikWidget } from "./traefik/Widget";
import { ServiceWidget } from "./service/Widget"; import { ServiceWidget } from "./service/Widget";
type WidgetComponent<T extends Widget["type"]> = ComponentType<{ type WidgetComponent<T extends Widget["type"]> = ComponentType<{
@@ -21,6 +20,5 @@ export const widgetRegistry: { [K in Widget["type"]]: WidgetComponent<K> } = {
database: DatabaseWidget, database: DatabaseWidget,
system: SystemWidget, system: SystemWidget,
http: HttpWidget, http: HttpWidget,
traefik: TraefikWidget,
service: ServiceWidget, service: ServiceWidget,
}; };
-9
View File
@@ -60,15 +60,6 @@ export function ServiceWidget({ widget, widgetId }: { widget: ServiceWidget; wid
))} ))}
</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>} {data.statError && <StatusTag tone="degraded">{data.statError}</StatusTag>}
{widget.showStats && data.docker.cpuPercent !== null && memPercent !== null && ( {widget.showStats && data.docker.cpuPercent !== null && memPercent !== null && (
<div className="mt-0.5 flex gap-4"> <div className="mt-0.5 flex gap-4">
+1 -1
View File
@@ -19,7 +19,7 @@ export function SystemWidget({ widget, widgetId }: { widget: SystemWidget; widge
const errorMessage = result?.type === "error" ? result.message : null; const errorMessage = result?.type === "error" ? result.message : null;
return ( return (
<WidgetCard span={5} rowSpan={2}> <WidgetCard span={4} rowSpan={2}>
<div className="text-[10px] tracking-[0.1em] text-accent uppercase">System</div> <div className="text-[10px] tracking-[0.1em] text-accent uppercase">System</div>
<span className="text-[15px] font-medium text-fg">{widget.name}</span> <span className="text-[15px] font-medium text-fg">{widget.name}</span>
{errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>} {errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>}
-53
View File
@@ -1,53 +0,0 @@
"use client";
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;
const enabledCount = data?.routers.filter((router) => router.status === "enabled").length ?? 0;
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.length} entrypoints
</span>
)}
</div>
{errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>}
{!result && !errorMessage && <SkeletonLines count={3} />}
{data && (
<>
<div className="flex items-start gap-4">
<div className="flex flex-col gap-0.5">
<span className="font-mono text-2xl leading-none font-semibold tabular-nums text-accent">
{data.routers.length}
</span>
<span className="text-[10px] tracking-wide text-fg-muted uppercase">Routes</span>
</div>
{enabledCount !== data.routers.length && (
<div className="flex flex-col gap-0.5 border-l border-border pl-4">
<span className="font-mono text-2xl leading-none font-semibold tabular-nums text-status-degraded">
{enabledCount}
</span>
<span className="text-[10px] tracking-wide text-fg-muted uppercase">Enabled</span>
</div>
)}
</div>
<span className="text-[11px] text-fg-muted">
<span className="font-mono tabular-nums">{data.middlewaresCount}</span> middlewares
</span>
</>
)}
</WidgetCard>
);
}
File diff suppressed because one or more lines are too long
+1 -17
View File
@@ -5,7 +5,6 @@ import { parseDuration } from "@/lib/config/duration";
import { collectDockerContainer, dockerLimit } from "./docker"; import { collectDockerContainer, dockerLimit } from "./docker";
import { collectSystem } from "./system"; import { collectSystem } from "./system";
import { collectHttp } from "./http"; import { collectHttp } from "./http";
import { collectTraefik } from "./traefik";
import { collectService } from "./service"; import { collectService } from "./service";
import type { WidgetResult } from "@/lib/types/widget-result"; import type { WidgetResult } from "@/lib/types/widget-result";
@@ -20,14 +19,7 @@ interface Job {
consecutiveFailures: number; consecutiveFailures: number;
} }
const COLLECTOR_TYPES = new Set<Widget["type"]>([ const COLLECTOR_TYPES = new Set<Widget["type"]>(["docker", "database", "system", "http", "service"]);
"docker",
"database",
"system",
"http",
"traefik",
"service",
]);
function isCollectorWidget(widget: Widget): boolean { function isCollectorWidget(widget: Widget): boolean {
return COLLECTOR_TYPES.has(widget.type); return COLLECTOR_TYPES.has(widget.type);
@@ -39,7 +31,6 @@ function getIntervalMs(widget: Widget): number {
widget.type === "database" || widget.type === "database" ||
widget.type === "system" || widget.type === "system" ||
widget.type === "http" || widget.type === "http" ||
widget.type === "traefik" ||
widget.type === "service" widget.type === "service"
) { ) {
return parseDuration(widget.interval); return parseDuration(widget.interval);
@@ -60,9 +51,6 @@ function sameTarget(a: Widget, b: Widget): boolean {
if (a.type === "http" && b.type === "http") { if (a.type === "http" && b.type === "http") {
return a.url === b.url && a.method === b.method && a.expect.status === b.expect.status; 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") { if (a.type === "service" && b.type === "service") {
return a.containerName === b.containerName && a.service === b.service && a.showStats === b.showStats; return a.containerName === b.containerName && a.service === b.service && a.showStats === b.showStats;
} }
@@ -88,10 +76,6 @@ async function collect(widget: Widget): Promise<WidgetResult> {
); );
return { type: "http", data: { ...data, consecutiveFailures: 0 } }; 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") { if (widget.type === "service") {
const data = await collectService(widget); const data = await collectService(widget);
return { type: "service", data }; return { type: "service", data };
+4 -1
View File
@@ -7,8 +7,9 @@ import { collectImmich } from "./services/immich";
import { collectN8n } from "./services/n8n"; import { collectN8n } from "./services/n8n";
import { collectUmami } from "./services/umami"; import { collectUmami } from "./services/umami";
import { collectHeadscale } from "./services/headscale"; import { collectHeadscale } from "./services/headscale";
import { collectTraefikStat } from "./services/traefik";
function collectServiceStat(widget: ServiceWidget): Promise<{ stats: ServiceStat[]; detail?: string[] }> { function collectServiceStat(widget: ServiceWidget): Promise<{ stats: ServiceStat[] }> {
switch (widget.service) { switch (widget.service) {
case "gitea": case "gitea":
return collectGitea(widget); return collectGitea(widget);
@@ -22,6 +23,8 @@ function collectServiceStat(widget: ServiceWidget): Promise<{ stats: ServiceStat
return collectUmami(widget); return collectUmami(widget);
case "headscale": case "headscale":
return collectHeadscale(widget); return collectHeadscale(widget);
case "traefik":
return collectTraefikStat(widget);
} }
} }
+1
View File
@@ -7,6 +7,7 @@ const SERVICE_PORTS: Record<ServiceWidget["service"], number> = {
n8n: 5678, n8n: 5678,
umami: 3000, umami: 3000,
headscale: 8080, headscale: 8080,
traefik: 8080,
}; };
export function serviceBaseUrl(widget: ServiceWidget): string { export function serviceBaseUrl(widget: ServiceWidget): string {
+2 -13
View File
@@ -2,12 +2,6 @@ import type { CoolifyServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result"; import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url"; 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> { async function fetchCoolify<T>(base: string, path: string, apiToken: string): Promise<T> {
const response = await fetch(`${base}${path}`, { const response = await fetch(`${base}${path}`, {
headers: { Authorization: `Bearer ${apiToken}` }, headers: { Authorization: `Bearer ${apiToken}` },
@@ -19,22 +13,17 @@ async function fetchCoolify<T>(base: string, path: string, apiToken: string): Pr
return (await response.json()) as T; return (await response.json()) as T;
} }
export async function collectCoolify( export async function collectCoolify(widget: CoolifyServiceWidget): Promise<{ stats: ServiceStat[] }> {
widget: CoolifyServiceWidget
): Promise<{ stats: ServiceStat[]; detail?: string[] }> {
const base = serviceBaseUrl(widget); const base = serviceBaseUrl(widget);
const [projects, resources] = await Promise.all([ const [projects, resources] = await Promise.all([
fetchCoolify<CoolifyProject[]>(base, "/api/v1/projects", widget.apiToken), fetchCoolify<unknown[]>(base, "/api/v1/projects", widget.apiToken),
fetchCoolify<unknown[]>(base, "/api/v1/resources", 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 { return {
stats: [ stats: [
{ label: "Projects", value: String(projects.length) }, { label: "Projects", value: String(projects.length) },
{ label: "Resources", value: String(resources.length) }, { label: "Resources", value: String(resources.length) },
], ],
detail: names.length > 0 ? names : undefined,
}; };
} }
+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
@@ -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,
};
}
+15 -9
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({
@@ -58,13 +65,6 @@ export const httpWidgetSchema = z.object({
.default({ status: 200 }), .default({ status: 200 }),
}); });
export const traefikWidgetSchema = z.object({
type: z.literal("traefik"),
name: z.string().default("Traefik"),
apiUrl: z.string().url(),
interval: durationSchema.default("15s"),
});
const serviceCommonFields = { const serviceCommonFields = {
type: z.literal("service") as z.ZodLiteral<"service">, type: z.literal("service") as z.ZodLiteral<"service">,
name: z.string(), name: z.string(),
@@ -114,6 +114,11 @@ export const headscaleServiceWidgetSchema = z.object({
apiToken: z.string(), apiToken: z.string(),
}); });
export const traefikServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("traefik"),
});
export const serviceWidgetSchema = z.discriminatedUnion("service", [ export const serviceWidgetSchema = z.discriminatedUnion("service", [
giteaServiceWidgetSchema, giteaServiceWidgetSchema,
coolifyServiceWidgetSchema, coolifyServiceWidgetSchema,
@@ -121,6 +126,7 @@ export const serviceWidgetSchema = z.discriminatedUnion("service", [
n8nServiceWidgetSchema, n8nServiceWidgetSchema,
umamiServiceWidgetSchema, umamiServiceWidgetSchema,
headscaleServiceWidgetSchema, headscaleServiceWidgetSchema,
traefikServiceWidgetSchema,
]); ]);
export const widgetSchema = z.discriminatedUnion("type", [ export const widgetSchema = z.discriminatedUnion("type", [
@@ -130,7 +136,6 @@ export const widgetSchema = z.discriminatedUnion("type", [
databaseWidgetSchema, databaseWidgetSchema,
systemWidgetSchema, systemWidgetSchema,
httpWidgetSchema, httpWidgetSchema,
traefikWidgetSchema,
serviceWidgetSchema, serviceWidgetSchema,
]); ]);
@@ -192,3 +197,4 @@ export type ImmichServiceWidget = z.infer<typeof immichServiceWidgetSchema>;
export type N8nServiceWidget = z.infer<typeof n8nServiceWidgetSchema>; export type N8nServiceWidget = z.infer<typeof n8nServiceWidgetSchema>;
export type UmamiServiceWidget = z.infer<typeof umamiServiceWidgetSchema>; export type UmamiServiceWidget = z.infer<typeof umamiServiceWidgetSchema>;
export type HeadscaleServiceWidget = z.infer<typeof headscaleServiceWidgetSchema>; export type HeadscaleServiceWidget = z.infer<typeof headscaleServiceWidgetSchema>;
export type TraefikServiceWidget = z.infer<typeof traefikServiceWidgetSchema>;
-1
View File
@@ -8,6 +8,5 @@ export interface ServiceStat {
export interface ServiceWidgetResult { export interface ServiceWidgetResult {
docker: DockerContainerResult; docker: DockerContainerResult;
stats: ServiceStat[]; stats: ServiceStat[];
detail?: string[];
statError?: 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
View File
@@ -1,6 +1,5 @@
import type { SystemResult } from "./system-result"; import type { SystemResult } from "./system-result";
import type { HttpCheckResult } from "./http-result"; import type { HttpCheckResult } from "./http-result";
import type { TraefikResult } from "./traefik-result";
import type { ServiceWidgetResult } from "./service-result"; import type { ServiceWidgetResult } from "./service-result";
export interface DockerContainerResult { export interface DockerContainerResult {
@@ -20,6 +19,5 @@ export type WidgetResult =
| { type: "database"; data: DockerContainerResult } | { type: "database"; data: DockerContainerResult }
| { type: "system"; data: SystemResult } | { type: "system"; data: SystemResult }
| { type: "http"; data: HttpCheckResult } | { type: "http"; data: HttpCheckResult }
| { type: "traefik"; data: TraefikResult }
| { type: "service"; data: ServiceWidgetResult } | { type: "service"; data: ServiceWidgetResult }
| { type: "error"; message: string }; | { type: "error"; message: string };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulsenode", "name": "pulsenode",
"version": "0.3.0", "version": "0.4.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch server.ts", "dev": "tsx watch server.ts",
+1
View File
@@ -26,6 +26,7 @@ ICONS = {
"postgresql": "postgresql", "postgresql": "postgresql",
"redis": "redis", "redis": "redis",
"headscale": "tailscale", "headscale": "tailscale",
"coder": "coder",
} }
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent