23 Commits
Author SHA1 Message Date
valknar f7e148d105 chore: bump version to 0.4.8
CI / Static checks (push) Successful in 36s
CI / Build and push image (push) Successful in 1m30s
2026-08-19 09:39:56 +02:00
valknarandClaude Sonnet 5 7727ca8e97 feat: add structured backend logging
CI / Static checks (push) Successful in 37s
CI / Build and push image (push) Skipped
Collector failures, service-API errors, WS connection lifecycle, and
config reload/discovery events were previously invisible outside the
browser (or, for a few config/discovery cases, logged with an
inconsistent ad-hoc console.error). Add a small scoped logger
(lib/logger.ts, level via LOG_LEVEL) and wire it through server.ts,
the scheduler, service collector, config loader/effective store, and
the WS server so operators can see failures via `docker logs`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 09:39:30 +02:00
valknar 94ffa9e930 chore: bump version to 0.4.7
CI / Static checks (push) Successful in 35s
CI / Build and push image (push) Successful in 1m32s
2026-08-19 08:46:02 +02:00
valknarandClaude Sonnet 5 491c0a9c7c fix: show live connection indicator on mobile
CI / Static checks (push) Successful in 1m8s
CI / Build and push image (push) Skipped
The header's live/connecting status was hidden below the 860px
breakpoint; keep it visible on mobile too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 08:45:38 +02:00
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
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
valknar aa657419ca chore: bump version to 0.3.0
CI / Static checks (push) Successful in 40s
CI / Build and push image (push) Successful in 1m31s
2026-08-17 17:58:12 +02:00
valknar 4308169608 feat: brand icons for widgets, fix stale accent color
CI / Static checks (push) Successful in 34s
CI / Build and push image (push) Skipped
Two separate issues, both real bugs:

- Dark mode was showing light blue instead of green: config.yml still
  had a leftover --pn-accent: #38bdf8 override from before the design
  redesign, which won via the runtime theme injector regardless of
  what app/globals.css defaulted to. Removed the override and pushed
  the dark-mode default from a soft mint (#5ee08c) to an actual neon
  green (#39ff88) - closer to what "neon" means and closer to the real
  phosphor-green a cardiac monitor trace uses, which fits the pulse
  metaphor better than the pastel version did.

- No per-service brand icons anywhere. Added an optional `icon` field
  to docker/bookmark widgets (a key into lib/brand-icons.ts) rather
  than guessing from containerName, since a heuristic would silently
  misfire for anyone's own container naming. database widgets derive
  their icon automatically from `engine` (postgres/redis) since that's
  already required and unambiguous. Icon path data comes from the CC0
  simple-icons package via scripts/extract-brand-icons.py, extracted
  and committed as plain TS data (lib/brand-icons.ts) rather than
  imported live - keeps it self-hosted (no unpkg.com/CDN calls at
  runtime, matching the fonts) and pinned regardless of dependency
  updates. Rendered monochrome (currentColor, not each brand's own
  hex) to stay consistent with the single-accent design rather than a
  dozen competing colors - only the shape carries the identity.

Wired real icons into config.yml for every service that has one:
traefik, coolify, gitea (+ gitea_runner), immich (+ immich_ml), n8n,
passbolt, umami, headscale, plus postgresql/redis on every database
widget. code and mailpit have no brand match in the curated set, so
they render without one rather than a wrong/generic substitute.

Verified in the actual rendered page: compiled CSS confirms
--pn-accent is #39ff88 (dark) / #339a5a (light) with no trace of the
old #38bdf8, and all 18 expected icon instances (10 docker + 8
database widgets) are present.
2026-08-17 17:55:27 +02:00
valknar db1b933c33 chore: bump version to 0.2.0
CI / Static checks (push) Successful in 35s
CI / Build and push image (push) Successful in 1m26s
2026-08-17 17:22:33 +02:00
valknar e1e571754a feat: apply imported design (bento grid, real elevation tokens, live indicator)
CI / Static checks (push) Successful in 33s
CI / Build and push image (push) Skipped
Imports the visual direction from a claude.ai/design session ("PulseNode
Dashboard.dc.html") and reconciles it with the app's existing choices
rather than a wholesale swap: kept the IBM Plex Sans/Mono + Space
Grotesk typography and the pulse-green accent concept from the earlier
pass, adopted the imported design's structural ideas - a real
tonal/elevation token system (derived divider/muted colors via
color-mix instead of hardcoded per-theme duplicates, actual box-shadow
elevation), a 12-column bento grid with per-widget-type column spans
instead of uniform equal-width cards, fading-edge section dividers,
and a shared WidgetCard/MetricBar/StatusTag vocabulary so every widget
type stops repeating its own card markup.

Icons come from @phosphor-icons/react's /ssr entrypoint (bundled at
build time) rather than the imported design's unpkg.com CDN script -
that script is fine for the standalone design-tool preview, but a
runtime third-party call would break the self-hosted-only principle
already established (fonts self-hosted via next/font, no external
requests). New app/icon.svg reuses the same nav badge mark as the
favicon, replacing the never-touched create-next-app default.

Two new pieces of real functionality prompted by the design's mockup
toast/live-indicator, not just decoration: a "live" WebSocket
connection-status indicator (lib/ws/client.ts now tracks and exposes
real connection state), and a toast that fires on actual config:update
and config:error events - the latter finally surfaces config validation
failures in the browser, previously visible only in server logs despite
being designed for exactly this back in M1.
2026-08-17 17:21:18 +02:00
valknar fa73750a28 chore: gitignore local .claude session state
CI / Static checks (push) Successful in 34s
CI / Build and push image (push) Skipped
2026-08-17 16:05:34 +02:00
valknar f05ad662d0 fix: correct real-world deployment details found while writing the stacks entry
CI / Static checks (push) Successful in 37s
CI / Build and push image (push) Skipped
- docker-compose.yml used "websecure" as the entrypoint name; the
  actual falcon_network convention (confirmed against the real
  traefik/compose.yml) is "web-secure". Also switched the networks
  block from a hardcoded falcon_network to the same
  compose_network-aliased ${NETWORK_NAME} pattern already used in the
  Traefik labels, instead of only half-respecting that variable.
- config.yml gave the Traefik widget an href, but this deployment runs
  with --api.dashboard=false - there's no dashboard to link to.
2026-08-17 15:38:52 +02:00
56 changed files with 1421 additions and 373 deletions
+1
View File
@@ -23,6 +23,7 @@
# misc
.DS_Store
*.pem
.claude
# debug
npm-debug.log*
+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.
+57 -15
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
@@ -54,8 +59,8 @@ settings:
theme:
mode: dark # dark | light | auto
variables:
--pn-accent: "#38bdf8"
# variables: # override any --pn-* token from app/globals.css
# --pn-accent: "#ff6b6b"
# customCssPath: custom.css # relative to config/, served at /api/theme/custom-css
discovery:
@@ -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
@@ -87,16 +99,40 @@ reload; never exposed to the client. See `config/.env.example`.
| `type` | Required fields | Notes |
|------------|-------------------------------------|-------|
| `bookmark` | `name`, `href` | Static link card. Optional `description`. |
| `bookmark` | `name`, `href` | Static link card. Optional `description`, `icon` (see below). |
| `search` | - | Client-side search box. Optional `engines` (`duckduckgo`/`google`/`bing`, default `[duckduckgo]`), `defaultEngine`. |
| `docker` | `name`, `containerName` | Status, uptime, CPU/mem, restart count via `dockerode`. Optional `href`, `showStats` (default `true`), `interval` (default `5s`). |
| `docker` | `name`, `containerName` | Status, uptime, CPU/mem, restart count via `dockerode`. Optional `href`, `icon` (see below), `showStats` (default `true`), `interval` (default `5s`). |
| `database` | `name`, `containerName`, `engine` | Thin skin over the same Docker collector - `engine` is `postgres` or `redis`, used for icon/label only. `interval` default `10s`. |
| `system` | - | Host CPU/mem/disk/network via `systeminformation`. Optional `name` (default `System`), `interval` (default `5s`). |
| `http` | `name`, `url` | HTTP health check with latency and consecutive-failure tracking. Optional `method` (default `GET`), `timeout` (default `5s`), `interval` (default `30s`), `expect.status` (default `200`). |
| `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
with the accent color. `database` widgets pick their icon automatically from
`engine`. Regenerate/extend the icon set with `python3 scripts/extract-brand-icons.py`
after adding an entry to that script's `ICONS` map (path data comes from the
CC0-licensed `simple-icons` package, bundled at build time - no runtime CDN calls).
### Auto-discovery
With `discovery.docker.enabled: true`, PulseNode scans running containers
@@ -139,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
```
@@ -154,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)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+115 -28
View File
@@ -1,44 +1,43 @@
@import "tailwindcss";
:root {
--pn-bg: #0a0e13;
--pn-surface: #10151c;
--pn-surface-raised: #161d27;
--pn-border: #232b38;
--pn-fg: #e8ecf1;
--pn-fg-muted: #7c8798;
--pn-accent: #3ee8a8;
--pn-radius: 0.75rem;
--pn-status-up: #3ee8a8;
--pn-bg: #1a1d1f;
--pn-surface: #24282b;
--pn-surface-raised: #2b3033;
--pn-fg: #f2f4f5;
--pn-fg-muted: color-mix(in srgb, var(--pn-fg) 55%, transparent);
--pn-border: color-mix(in srgb, var(--pn-fg) 14%, transparent);
--pn-accent: #39ff88;
--pn-status-up: #39ff88;
--pn-status-down: #ff5f6d;
--pn-status-degraded: #ffb84c;
--pn-shadow-sm: 0 0 0 1px var(--pn-border);
--pn-shadow-md: 0 0 0 1px var(--pn-border), 0 6px 18px rgba(0, 0, 0, 0.45);
}
:root[data-theme="light"] {
--pn-bg: #f6f7f5;
--pn-surface: #ffffff;
--pn-surface-raised: #eef1ef;
--pn-border: #dde1de;
--pn-fg: #12181a;
--pn-fg-muted: #5c6b66;
--pn-accent: #0e9f6e;
--pn-status-up: #0e9f6e;
--pn-bg: #e9ebec;
--pn-surface: #f6f7f7;
--pn-surface-raised: #eceded;
--pn-fg: #1e2224;
--pn-accent: #339a5a;
--pn-status-up: #339a5a;
--pn-status-down: #dc2626;
--pn-status-degraded: #d97706;
--pn-shadow-md: 0 0 0 1px var(--pn-border), 0 6px 18px rgba(30, 32, 45, 0.1);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
--pn-bg: #f6f7f5;
--pn-surface: #ffffff;
--pn-surface-raised: #eef1ef;
--pn-border: #dde1de;
--pn-fg: #12181a;
--pn-fg-muted: #5c6b66;
--pn-accent: #0e9f6e;
--pn-status-up: #0e9f6e;
--pn-bg: #e9ebec;
--pn-surface: #f6f7f7;
--pn-surface-raised: #eceded;
--pn-fg: #1e2224;
--pn-accent: #339a5a;
--pn-status-up: #339a5a;
--pn-status-down: #dc2626;
--pn-status-degraded: #d97706;
--pn-shadow-md: 0 0 0 1px var(--pn-border), 0 6px 18px rgba(30, 32, 45, 0.1);
}
}
@@ -53,7 +52,8 @@
--color-status-up: var(--pn-status-up);
--color-status-down: var(--pn-status-down);
--color-status-degraded: var(--pn-status-degraded);
--radius-widget: var(--pn-radius);
--radius-md: 0.625rem;
--radius-lg: 1rem;
--font-sans: var(--font-plex-sans);
--font-mono: var(--font-plex-mono);
--font-display: var(--font-space-grotesk);
@@ -64,6 +64,62 @@ body {
color: var(--pn-fg);
}
/* Rules fade to transparent at both ends instead of stopping cleanly. */
.pn-hr {
height: 1px;
flex: 1;
background: linear-gradient(
to right,
transparent,
var(--pn-border) 32px,
var(--pn-border) calc(100% - 32px),
transparent
);
}
.pn-card {
transition:
box-shadow 0.18s ease,
transform 0.18s ease,
background 0.3s ease,
border-color 0.3s ease;
box-shadow: var(--pn-shadow-sm);
}
.pn-card:hover {
transform: translateY(-2px);
box-shadow:
var(--pn-shadow-md),
0 0 0 1px color-mix(in srgb, var(--pn-accent) 35%, transparent);
}
@keyframes pn-toast-in {
from {
transform: translateY(12px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.pn-toast {
animation: pn-toast-in 0.28s ease;
}
@keyframes pn-spin-in {
from {
transform: rotate(-60deg) scale(0.6);
opacity: 0;
}
to {
transform: rotate(0) scale(1);
opacity: 1;
}
}
.pn-spin-in {
animation: pn-spin-in 0.35s ease;
}
@keyframes pulse-glow {
0%,
100% {
@@ -73,13 +129,44 @@ body {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--pn-status-up) 0%, transparent);
}
}
.status-pulse {
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,
.status-pulse-down,
.pn-toast,
.pn-spin-in {
animation: none;
}
}
.pn-bento {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-flow: dense;
}
@media (max-width: 860px) {
.pn-bento {
grid-template-columns: repeat(6, 1fr) !important;
}
.pn-bento > * {
grid-column: 1 / -1 !important;
grid-row: auto !important;
}
}
+12
View File
@@ -0,0 +1,12 @@
<svg width="32" height="32" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="28" height="28" rx="8" fill="#2b3033" stroke="rgba(94,224,140,0.45)" />
<circle cx="15" cy="15" r="8" stroke="#5ee08c" stroke-width="1.5" />
<polyline
points="8,15 12,15 14,9 17,21 19,15 22,15"
fill="none"
stroke="#5ee08c"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 452 B

+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>
);
}
+4 -3
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";
@@ -12,13 +13,13 @@ export default function Home() {
const message = err instanceof ConfigError ? err.message : "Unknown configuration error";
return (
<main className="mx-auto flex max-w-2xl flex-col gap-4 px-6 py-16">
<h1 className="text-lg font-semibold text-status-down">Configuration Error</h1>
<pre className="rounded-[var(--radius-widget)] border border-status-down bg-surface p-4 text-sm whitespace-pre-wrap text-fg">
<h1 className="font-display text-lg font-semibold text-status-down">Configuration Error</h1>
<pre className="rounded-lg border border-status-down bg-surface p-4 text-sm whitespace-pre-wrap text-fg">
{message}
</pre>
</main>
);
}
return <Dashboard config={config} />;
return <Dashboard config={toPublicConfig(config)} />;
}
+34 -14
View File
@@ -1,28 +1,48 @@
"use client";
import { Broadcast } from "@phosphor-icons/react/ssr";
import type { Config } from "@/lib/config/schema";
import { useConfigSubscription } from "@/lib/ws/client";
import { useConfigSubscription, useConnectionStatus } from "@/lib/ws/client";
import { GroupSection } from "./GroupSection";
import { ThemeToggle } from "./ThemeToggle";
import { PulseMark } from "./PulseMark";
import { ToastStack } from "./Toast";
function LiveIndicator() {
const status = useConnectionStatus();
const label = status === "connected" ? "live" : status === "reconnecting" ? "reconnecting…" : "connecting…";
return (
<div className="pn-live-label flex items-center gap-1.5 text-xs text-fg-muted">
<Broadcast size={15} className={status === "connected" ? "text-accent" : "text-status-degraded"} aria-hidden />
<span>{label}</span>
</div>
);
}
export function Dashboard({ config: initialConfig }: { config: Config }) {
const config = useConfigSubscription(initialConfig);
return (
<main className="mx-auto flex max-w-6xl flex-col gap-8 px-6 py-10">
<header className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<PulseMark />
<h1 className="font-display text-lg font-semibold tracking-tight text-fg">
{config.settings.title}
</h1>
<div className="relative min-h-screen">
<div className="sticky top-0 z-20 border-b border-border bg-bg/70 backdrop-blur-lg">
<div className="mx-auto flex max-w-6xl items-center gap-4 px-6 py-3">
<div className="mr-auto flex items-center gap-2.5">
<PulseMark size={30} />
<span className="font-display text-lg font-semibold tracking-tight text-fg">
{config.settings.title}
</span>
</div>
<LiveIndicator />
<ThemeToggle />
</div>
<ThemeToggle />
</header>
{config.groups.map((group, groupIndex) => (
<GroupSection key={group.name} group={group} groupIndex={groupIndex} />
))}
</main>
</div>
<main className="mx-auto max-w-6xl px-6 pt-2 pb-20">
{config.groups.map((group, groupIndex) => (
<GroupSection key={group.name} group={group} groupIndex={groupIndex} />
))}
</main>
<ToastStack />
</div>
);
}
+8 -5
View File
@@ -4,11 +4,14 @@ import { widgetRegistry } from "@/components/widgets/registry";
export function GroupSection({ group, groupIndex }: { group: Group; groupIndex: number }) {
return (
<section className="flex flex-col gap-3">
<h2 className="text-xs font-semibold tracking-wide text-fg-muted uppercase">
{group.name}
</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
<section className="mt-8 flex flex-col gap-4">
<div className="flex items-baseline gap-2.5">
<h6 className="m-0 text-xs font-semibold tracking-wide text-fg-muted uppercase">
{group.name}
</h6>
<div className="pn-hr" />
</div>
<div className="pn-bento gap-4">
{group.widgets.map((widget, index) => {
const Component = widgetRegistry[widget.type];
const id = widgetId(groupIndex, index);
+17 -14
View File
@@ -1,18 +1,21 @@
export function PulseMark() {
export function PulseMark({ size = 30 }: { size?: number }) {
return (
<svg
width="30"
height="16"
viewBox="0 0 60 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
className="text-accent"
>
<path
d="M0 12H11L15 5L20 19L24 12H33L37 8L41 12H60"
stroke="currentColor"
strokeWidth="2.5"
<svg width={size} height={size} viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect
x="1"
y="1"
width="28"
height="28"
rx="8"
fill="var(--pn-surface-raised)"
stroke="color-mix(in srgb, var(--pn-accent) 45%, transparent)"
/>
<circle cx="15" cy="15" r="8" stroke="var(--pn-accent)" strokeWidth="1.5" />
<polyline
points="8,15 12,15 14,9 17,21 19,15 22,15"
fill="none"
stroke="var(--pn-accent)"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
+6 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { MoonStars, Sun } from "@phosphor-icons/react/ssr";
const STORAGE_KEY = "pulsenode-theme";
@@ -34,17 +35,19 @@ export function ThemeToggle() {
}
if (theme === null) {
return <div className="h-7 w-16 rounded-[var(--radius-widget)] border border-border bg-surface" />;
return <div className="h-9 w-9 rounded-md border border-border bg-surface" />;
}
return (
<button
type="button"
onClick={toggle}
className="rounded-[var(--radius-widget)] border border-border bg-surface px-3 py-1.5 text-xs text-fg-muted transition-colors hover:text-fg"
className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-surface text-fg-muted transition-colors hover:text-fg"
aria-label="Toggle color theme"
>
{theme === "light" ? "Light" : "Dark"}
<span key={theme} className="pn-spin-in flex">
{theme === "dark" ? <MoonStars size={17} /> : <Sun size={17} />}
</span>
</button>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { CheckCircle, WarningCircle } from "@phosphor-icons/react/ssr";
import { useConfigToasts } from "@/lib/ws/client";
export function ToastStack() {
const toasts = useConfigToasts();
if (toasts.length === 0) return null;
return (
<div className="fixed right-6 bottom-6 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className="pn-toast flex max-w-[340px] items-center gap-2.5 rounded-lg border border-border bg-surface px-3.5 py-3 shadow-[var(--pn-shadow-md)]"
>
{toast.tone === "ok" ? (
<CheckCircle size={20} className="shrink-0 text-accent" aria-hidden />
) : (
<WarningCircle size={20} className="shrink-0 text-status-down" aria-hidden />
)}
<span className="text-[13px] text-fg">{toast.message}</span>
</div>
))}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { GlobeSimple } from "@phosphor-icons/react/ssr";
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 }) {
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];
if (!path) return null;
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className="shrink-0 text-fg-muted" aria-hidden>
<path d={path} />
</svg>
);
}
+16
View File
@@ -0,0 +1,16 @@
export function MetricBar({ label, percent, detail }: { label: string; percent: number; detail: string }) {
return (
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center justify-between text-[11px] text-fg-muted">
<span>{label}</span>
<span className="font-mono tabular-nums text-fg">{detail}</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-surface-raised">
<div
className="h-full rounded-full bg-accent transition-[width] duration-500 ease-out"
style={{ width: `${Math.min(100, Math.max(0, percent))}%` }}
/>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
export function StatusTag({ children, tone = "accent" }: { children: React.ReactNode; tone?: "accent" | "down" | "degraded" | "neutral" }) {
const toneClass = {
accent: "bg-accent/15 text-accent border-accent/40",
down: "bg-status-down/15 text-status-down border-status-down/40",
degraded: "bg-status-degraded/15 text-status-degraded border-status-degraded/40",
neutral: "bg-surface-raised text-fg-muted border-border",
}[tone];
return (
<span className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] tracking-wide ${toneClass}`}>
{children}
</span>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { CSSProperties, ReactNode } from "react";
export const PN_CARD_CLASS = "pn-card flex flex-col gap-2 rounded-md border border-border bg-surface p-4";
export function WidgetCard({
span = 3,
rowSpan,
children,
}: {
span?: number;
rowSpan?: number;
children: ReactNode;
}) {
const style: CSSProperties = {
gridColumn: `span ${span}`,
...(rowSpan ? { gridRow: `span ${rowSpan}` } : {}),
};
return (
<div className={PN_CARD_CLASS} style={style}>
{children}
</div>
);
}
+42 -5
View File
@@ -1,19 +1,56 @@
import { ArrowUpRight } from "@phosphor-icons/react/ssr";
import type { Widget } from "@/lib/config/schema";
import { PN_CARD_CLASS } from "@/components/widgets/WidgetCard";
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 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 (
<a
href={widget.href}
target="_blank"
rel="noreferrer"
className="flex flex-col gap-1 rounded-[var(--radius-widget)] border border-border bg-surface p-4 transition-colors hover:border-accent"
className={`${PN_CARD_CLASS} text-inherit no-underline`}
style={{ gridColumn: "span 3" }}
>
<span className="text-sm font-medium text-fg">{widget.name}</span>
{widget.description && (
<span className="text-xs text-fg-muted">{widget.description}</span>
)}
<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>
<ArrowUpRight size={16} className="shrink-0 text-accent" aria-hidden />
</div>
{widget.description && <span className="text-xs text-fg-muted">{widget.description}</span>}
</a>
);
}
+16 -6
View File
@@ -3,6 +3,8 @@
import type { Widget } from "@/lib/config/schema";
import { useWidgetSubscription } from "@/lib/ws/client";
import { StatusDot } from "@/components/widgets/StatusDot";
import { WidgetCard } from "@/components/widgets/WidgetCard";
import { BrandIcon } from "@/components/widgets/BrandIcon";
import { formatUptime } from "@/lib/format";
type DatabaseWidget = Extract<Widget, { type: "database" }>;
@@ -12,24 +14,32 @@ const ENGINE_LABEL: Record<DatabaseWidget["engine"], string> = {
redis: "Redis",
};
const ENGINE_ICON: Record<DatabaseWidget["engine"], string> = {
postgres: "postgresql",
redis: "redis",
};
export function DatabaseWidget({ widget, widgetId }: { widget: DatabaseWidget; widgetId: string }) {
const result = useWidgetSubscription(widgetId);
const data = result?.type === "database" ? result.data : null;
const errorMessage = result?.type === "error" ? result.message : null;
return (
<div className="flex flex-col gap-2 rounded-[var(--radius-widget)] border border-border bg-surface p-4">
<WidgetCard span={4}>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-fg">{widget.name}</span>
<div className="flex min-w-0 items-center gap-2">
<BrandIcon slug={ENGINE_ICON[widget.engine]} />
<span className="truncate text-[15px] font-medium text-fg">{widget.name}</span>
</div>
<StatusDot status={data?.status} health={data?.health} />
</div>
<span className="text-xs text-fg-muted">{ENGINE_LABEL[widget.engine]}</span>
<span className="text-[11px] text-fg-muted">{ENGINE_LABEL[widget.engine]}</span>
{errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>}
{data?.uptimeSeconds != null && (
<span className="text-xs text-fg-muted">
Uptime: <span className="font-mono tabular-nums text-fg">{formatUptime(data.uptimeSeconds)}</span>
<span className="text-[11px] text-fg-muted">
up <span className="font-mono tabular-nums text-fg">{formatUptime(data.uptimeSeconds)}</span>
</span>
)}
</div>
</WidgetCard>
);
}
+34 -38
View File
@@ -4,6 +4,9 @@ import type { Widget } from "@/lib/config/schema";
import { useWidgetSubscription } from "@/lib/ws/client";
import { StatusDot } from "@/components/widgets/StatusDot";
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 DockerWidget = Extract<Widget, { type: "docker" }>;
@@ -12,54 +15,47 @@ export function DockerWidget({ widget, widgetId }: { widget: DockerWidget; widge
const result = useWidgetSubscription(widgetId);
const data = result?.type === "docker" ? result.data : null;
const errorMessage = result?.type === "error" ? result.message : null;
const memPercent = data?.memUsageBytes != null && data.memLimitBytes ? (data.memUsageBytes / data.memLimitBytes) * 100 : null;
return (
<div className="flex flex-col gap-2 rounded-[var(--radius-widget)] border border-border bg-surface p-4">
<WidgetCard span={4}>
<div className="flex items-center justify-between gap-2">
{widget.href ? (
<a
href={widget.href}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-fg hover:text-accent"
>
{widget.name}
</a>
) : (
<span className="text-sm font-medium text-fg">{widget.name}</span>
)}
<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?.status} health={data?.health} />
</div>
{errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>}
{!result && !errorMessage && <SkeletonLines />}
{data && (
<dl className="flex flex-col gap-1 text-xs text-fg-muted">
{data.uptimeSeconds !== null && (
<div>
Uptime: <span className="font-mono tabular-nums text-fg">{formatUptime(data.uptimeSeconds)}</span>
<>
{widget.showStats && data.cpuPercent !== null && memPercent !== null && (
<div className="mt-0.5 flex gap-4">
<MetricBar label="CPU" percent={data.cpuPercent} detail={`${data.cpuPercent.toFixed(1)}%`} />
<MetricBar label="MEM" percent={memPercent} detail={`${memPercent.toFixed(0)}%`} />
</div>
)}
{widget.showStats && data.cpuPercent !== null && (
<div>
CPU: <span className="font-mono tabular-nums text-fg">{data.cpuPercent.toFixed(1)}%</span>
</div>
)}
{widget.showStats && data.memUsageBytes !== null && (
<div>
Mem:{" "}
<span className="font-mono tabular-nums text-fg">
{formatBytes(data.memUsageBytes)}
{data.memLimitBytes ? ` / ${formatBytes(data.memLimitBytes)}` : ""}
</span>
</div>
)}
{data.restartCount > 0 && (
<div>
Restarts: <span className="font-mono tabular-nums text-fg">{data.restartCount}</span>
</div>
)}
</dl>
<div className="flex items-center gap-3 text-[11px] text-fg-muted">
{data.uptimeSeconds !== null && <span>up {formatUptime(data.uptimeSeconds)}</span>}
{widget.showStats && data.memUsageBytes !== null && memPercent === null && (
<span className="font-mono tabular-nums">{formatBytes(data.memUsageBytes)}</span>
)}
{data.restartCount > 0 && <span>{data.restartCount} restarts</span>}
</div>
</>
)}
</div>
</WidgetCard>
);
}
+22 -22
View File
@@ -1,45 +1,45 @@
"use client";
import { Globe, Timer } 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";
import { StatusTag } from "@/components/widgets/StatusTag";
type HttpWidget = Extract<Widget, { type: "http" }>;
function statusColor(up: boolean | undefined, consecutiveFailures: number): string {
if (up === undefined) return "bg-fg-muted";
if (up) return "bg-status-up";
return consecutiveFailures > 2 ? "bg-status-down" : "bg-status-degraded";
function statusTone(up: boolean | undefined, consecutiveFailures: number): "accent" | "down" | "degraded" | "neutral" {
if (up === undefined) return "neutral";
if (up) return "accent";
return consecutiveFailures > 2 ? "down" : "degraded";
}
export function HttpWidget({ widget, widgetId }: { widget: HttpWidget; widgetId: string }) {
const result = useWidgetSubscription(widgetId);
const data = result?.type === "http" ? result.data : null;
const errorMessage = result?.type === "error" ? result.message : null;
const pulse = data?.up ? "status-pulse" : "";
return (
<div className="flex flex-col gap-2 rounded-[var(--radius-widget)] border border-border bg-surface p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-fg">{widget.name}</span>
<span
className={`h-2.5 w-2.5 shrink-0 rounded-full ${statusColor(data?.up, data?.consecutiveFailures ?? 0)} ${pulse}`}
title={data ? (data.up ? "up" : `down (${data.consecutiveFailures} checks failed)`) : "unknown"}
/>
<WidgetCard span={3}>
<div className="flex items-center gap-2.5">
<Globe size={18} className="text-accent" aria-hidden />
<span className="flex-1 text-[15px] font-medium text-fg">{widget.name}</span>
</div>
<div>
<StatusTag tone={statusTone(data?.up, data?.consecutiveFailures ?? 0)}>
{data ? (data.up ? `${data.statusCode ?? "up"}` : "down") : "…"}
</StatusTag>
</div>
{errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>}
{!result && !errorMessage && <SkeletonLines />}
{data && (
<dl className="flex flex-col gap-1 text-xs text-fg-muted">
<div>
Status: <span className="font-mono tabular-nums text-fg">{data.statusCode ?? "—"}</span>
</div>
<div>
Latency: <span className="font-mono tabular-nums text-fg">{data.latencyMs}ms</span>
</div>
{!data.up && data.error && <div className="text-status-down">{data.error}</div>}
</dl>
<div className="flex items-center gap-1.5 text-[11px] text-fg-muted">
<Timer size={12} aria-hidden />
<span className="font-mono tabular-nums text-fg">{data.latencyMs}ms</span> latency
{!data.up && data.error && <span className="text-status-down">{data.error}</span>}
</div>
)}
</div>
</WidgetCard>
);
}
+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,
};
+41 -24
View File
@@ -2,6 +2,7 @@
import { useState, type FormEvent } from "react";
import type { SearchEngine, Widget } from "@/lib/config/schema";
import { WidgetCard } from "@/components/widgets/WidgetCard";
type SearchWidget = Extract<Widget, { type: "search" }>;
@@ -11,6 +12,12 @@ const ENGINE_URLS: Record<string, string> = {
bing: "https://www.bing.com/search?q=",
};
const ENGINE_LABEL: Record<string, string> = {
duckduckgo: "DuckDuckGo",
google: "Google",
bing: "Bing",
};
export function SearchWidget({ widget }: { widget: SearchWidget }) {
const [engine, setEngine] = useState(widget.defaultEngine ?? widget.engines[0]);
const [query, setQuery] = useState("");
@@ -23,29 +30,39 @@ export function SearchWidget({ widget }: { widget: SearchWidget }) {
}
return (
<form
onSubmit={handleSubmit}
className="flex items-center gap-2 rounded-[var(--radius-widget)] border border-border bg-surface p-2 sm:col-span-2"
>
{widget.engines.length > 1 && (
<select
value={engine}
onChange={(event) => setEngine(event.target.value as SearchEngine)}
className="rounded bg-transparent text-xs text-fg-muted outline-none"
>
{widget.engines.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)}
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-muted"
/>
</form>
<WidgetCard span={4}>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col justify-center gap-2">
<div className="text-[10px] tracking-[0.1em] text-accent uppercase">Search</div>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search the web…"
autoComplete="off"
className="min-h-9 w-full rounded-md border border-border bg-bg px-2.5 py-1.5 text-sm text-fg outline-none placeholder:text-fg-muted focus-visible:border-accent"
/>
{widget.engines.length > 1 && (
<div className="inline-flex self-start overflow-hidden rounded-md border border-border">
{widget.engines.map((option) => (
<label
key={option}
className={`cursor-pointer px-2.5 py-1 text-[12px] first:border-l-0 [&+label]:border-l [&+label]:border-border ${
option === engine ? "text-accent shadow-[inset_0_0_0_1px_var(--pn-accent)]" : "text-fg-muted"
}`}
>
<input
type="radio"
name="engine"
value={option}
checked={option === engine}
onChange={() => setEngine(option as SearchEngine)}
className="sr-only"
/>
{ENGINE_LABEL[option] ?? option}
</label>
))}
</div>
)}
</form>
</WidgetCard>
);
}
+81
View File
@@ -0,0 +1,81 @@
"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.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>
);
}
+21 -31
View File
@@ -3,6 +3,8 @@
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";
import { MetricBar } from "@/components/widgets/MetricBar";
import { formatBytes } from "@/lib/format";
type SystemWidget = Extract<Widget, { type: "system" }>;
@@ -11,51 +13,39 @@ function usagePercent(used: number, total: number): number {
return total > 0 ? Math.min(100, (used / total) * 100) : 0;
}
function UsageBar({ label, percent, detail }: { label: string; percent: number; detail: string }) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs text-fg-muted">
<span>{label}</span>
<span className="font-mono tabular-nums text-fg">{detail}</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-raised">
<div className="h-full rounded-full bg-accent" style={{ width: `${percent}%` }} />
</div>
</div>
);
}
export function SystemWidget({ widget, widgetId }: { widget: SystemWidget; widgetId: string }) {
const result = useWidgetSubscription(widgetId);
const data = result?.type === "system" ? result.data : null;
const errorMessage = result?.type === "error" ? result.message : null;
return (
<div className="flex flex-col gap-3 rounded-[var(--radius-widget)] border border-border bg-surface p-4 sm:col-span-2">
<span className="text-sm font-medium text-fg">{widget.name}</span>
<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>}
{!result && !errorMessage && <SkeletonLines count={3} />}
{data && (
<div className="flex flex-col gap-2">
<UsageBar label="CPU" percent={data.cpuPercent} detail={`${data.cpuPercent.toFixed(1)}%`} />
<UsageBar
label="Memory"
percent={usagePercent(data.memUsedBytes, data.memTotalBytes)}
detail={`${formatBytes(data.memUsedBytes)} / ${formatBytes(data.memTotalBytes)}`}
/>
<UsageBar
label="Disk"
percent={usagePercent(data.diskUsedBytes, data.diskTotalBytes)}
detail={`${formatBytes(data.diskUsedBytes)} / ${formatBytes(data.diskTotalBytes)}`}
/>
<div className="flex items-center justify-between text-xs text-fg-muted">
<span>Network</span>
<div className="mt-1 flex flex-1 flex-col justify-between gap-3">
<div className="flex flex-col gap-3">
<MetricBar label="CPU" percent={data.cpuPercent} detail={`${data.cpuPercent.toFixed(1)}%`} />
<MetricBar
label="Memory"
percent={usagePercent(data.memUsedBytes, data.memTotalBytes)}
detail={`${formatBytes(data.memUsedBytes)} / ${formatBytes(data.memTotalBytes)}`}
/>
<MetricBar
label="Disk"
percent={usagePercent(data.diskUsedBytes, data.diskTotalBytes)}
detail={`${formatBytes(data.diskUsedBytes)} / ${formatBytes(data.diskTotalBytes)}`}
/>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-fg-muted">
<span className="font-mono tabular-nums text-fg">
{formatBytes(data.netRxBytesPerSec)}/s · {formatBytes(data.netTxBytesPerSec)}/s
</span>
</div>
</div>
)}
</div>
</WidgetCard>
);
}
-50
View File
@@ -1,50 +0,0 @@
"use client";
import type { Widget } from "@/lib/config/schema";
import { useWidgetSubscription } from "@/lib/ws/client";
import { SkeletonLines } from "@/components/widgets/Skeleton";
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 (
<div className="flex flex-col gap-3 rounded-[var(--radius-widget)] border border-border bg-surface p-4 sm:col-span-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-fg">{widget.name}</span>
{data && (
<span className="font-mono text-xs 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 gap-1">
{data.routers.map((router) => (
<div key={router.name} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate text-fg" title={router.rule}>
{router.name.replace(/@.*$/, "")}
</span>
<span className="flex items-center gap-1 text-fg-muted">
{router.tls && <span title="TLS enabled">🔒</span>}
<span
className={router.status === "enabled" ? "text-status-up" : "text-status-down"}
>
{router.status}
</span>
</span>
</div>
))}
<span className="pt-1 text-xs text-fg-muted">
<span className="font-mono tabular-nums">{data.middlewaresCount}</span> middlewares
</span>
</div>
)}
</div>
);
}
-1
View File
@@ -1,7 +1,6 @@
# Public hostnames of the services PulseNode links to / monitors.
# These are not secrets, but keeping them here (rather than hardcoded in
# config.yml) means the same config.yml works across environments.
TRAEFIK_HOST_TRAEFIK=traefik.example.com
TRAEFIK_HOST_CODE=code.example.com
TRAEFIK_HOST_COOLIFY=coolify.example.com
TRAEFIK_HOST_GITEA=git.example.com
+12 -4
View File
@@ -3,8 +3,6 @@ settings:
theme:
mode: dark
variables:
--pn-accent: "#38bdf8"
# Manual widgets below always take precedence over an auto-discovered one for
# the same container - this is on as a safety net for anything added to
@@ -21,14 +19,16 @@ groups:
- type: docker
name: Traefik
containerName: traefik
href: https://${TRAEFIK_HOST_TRAEFIK}
icon: traefik
interval: 10s
# No href: the dashboard is disabled (--api.dashboard=false).
# Swap for a `traefik` widget (router/entrypoint/middleware view)
# once the API is enabled - see Traefik's --api.dashboard docs.
# if the API gets enabled - see Traefik's --api.dashboard docs.
- type: docker
name: Coolify
containerName: coolify
icon: coolify
href: https://${TRAEFIK_HOST_COOLIFY}
interval: 10s
@@ -37,12 +37,14 @@ groups:
- type: docker
name: Gitea
containerName: gitea
icon: gitea
href: https://${TRAEFIK_HOST_GITEA}
interval: 10s
- type: docker
name: Gitea Runner
containerName: gitea_runner
icon: gitea
showStats: true
interval: 15s
@@ -57,35 +59,41 @@ groups:
- type: docker
name: Immich
containerName: immich
icon: immich
href: https://${TRAEFIK_HOST_IMMICH}
interval: 10s
- type: docker
name: Immich ML
containerName: immich_ml
icon: immich
interval: 15s
- type: docker
name: n8n
containerName: n8n
icon: n8n
href: https://${TRAEFIK_HOST_N8N}
interval: 10s
- type: docker
name: Passbolt
containerName: passbolt
icon: passbolt
href: https://${TRAEFIK_HOST_PASSBOLT}
interval: 10s
- type: docker
name: Umami
containerName: umami
icon: umami
href: https://${TRAEFIK_HOST_UMAMI}
interval: 10s
- type: docker
name: Headscale
containerName: headscale
icon: headscale
href: https://${TRAEFIK_HOST_HEADSCALE}
interval: 10s
+4 -6
View File
@@ -24,7 +24,7 @@ services:
- ./config:/app/config:ro
env_file: .env
networks:
- falcon_network
- compose_network
labels:
- traefik.enable=true
- traefik.docker.network=${NETWORK_NAME}
@@ -33,11 +33,8 @@ services:
- traefik.http.routers.pulsenode-web.entrypoints=web
- traefik.http.routers.pulsenode-web.middlewares=pulsenode-redirect-web-secure
- traefik.http.routers.pulsenode-web-secure.rule=Host(`${TRAEFIK_HOST}`)
- traefik.http.routers.pulsenode-web-secure.entrypoints=websecure
- traefik.http.routers.pulsenode-web-secure.entrypoints=web-secure
- traefik.http.routers.pulsenode-web-secure.tls.certresolver=resolver
# Verify these entrypoint/middleware/certresolver names against
# traefik/dynamic/*.yaml in the stacks repo before deploying - written
# to match that repo's own convention, not independently confirmed.
- traefik.http.routers.pulsenode-web-secure.middlewares=security-headers@file,no-index@file
- traefik.http.services.pulsenode-web-secure.loadbalancer.server.port=3000
healthcheck:
@@ -48,5 +45,6 @@ services:
retries: 3
networks:
falcon_network:
compose_network:
name: ${NETWORK_NAME}
external: true
File diff suppressed because one or more lines are too long
+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;
+49 -10
View File
@@ -2,13 +2,15 @@ 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";
import { createLogger } from "@/lib/logger";
const log = createLogger("scheduler");
const dockerLimit = pLimit(4);
const httpLimit = pLimit(8);
type ResultListener = (widgetId: string, result: WidgetResult) => void;
@@ -20,7 +22,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 +34,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 +54,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 +79,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}"`);
}
@@ -123,6 +125,12 @@ class CollectorScheduler {
if (existing) clearInterval(existing.timer);
log.debug(existing ? "job replaced" : "job added", {
widgetId: instance.id,
type: instance.widget.type,
intervalMs,
});
const run = () => this.runJob(instance.id);
const timer = setInterval(run, intervalMs);
this.jobs.set(instance.id, { widget: instance.widget, intervalMs, timer, consecutiveFailures: 0 });
@@ -131,16 +139,20 @@ class CollectorScheduler {
for (const [id, job] of this.jobs) {
if (!seen.has(id)) {
log.debug("job removed", { widgetId: id, type: job.widget.type });
clearInterval(job.timer);
this.jobs.delete(id);
this.lastResults.delete(id);
}
}
log.info("reconciled", { jobs: this.jobs.size });
}
stop(): void {
for (const job of this.jobs.values()) clearInterval(job.timer);
this.jobs.clear();
log.info("scheduler stopped");
}
private async runJob(id: string): Promise<void> {
@@ -151,10 +163,37 @@ class CollectorScheduler {
try {
result = await collect(job.widget);
if (result.type === "http") {
const wasFailing = job.consecutiveFailures > 0;
job.consecutiveFailures = result.data.up ? 0 : job.consecutiveFailures + 1;
result = { type: "http", data: { ...result.data, consecutiveFailures: job.consecutiveFailures } };
if (!result.data.up) {
log.warn("http check failed", {
widgetId: id,
url: (job.widget as { url?: string }).url,
statusCode: result.data.statusCode,
attempt: job.consecutiveFailures,
});
} else if (wasFailing) {
log.info("http check recovered", { widgetId: id, url: (job.widget as { url?: string }).url });
}
} else {
if (job.consecutiveFailures > 0) {
log.info("collector recovered", {
widgetId: id,
type: job.widget.type,
afterFailures: job.consecutiveFailures,
});
}
job.consecutiveFailures = 0;
}
} catch (err) {
job.consecutiveFailures += 1;
log.error("collector failed", {
widgetId: id,
type: job.widget.type,
attempt: job.consecutiveFailures,
error: (err as Error).message,
});
result = { type: "error", message: (err as Error).message };
}
+48
View File
@@ -0,0 +1,48 @@
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";
import { createLogger } from "@/lib/logger";
const log = createLogger("service");
function collectServiceStat(widget: ServiceWidget): Promise<{ stats: ServiceStat[] }> {
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) => {
const message = (err as Error).message;
log.warn("service API call failed", { service: widget.service, containerName: widget.containerName, error: message });
return { stats: [] as ServiceStat[], statError: 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]}`;
}
+29
View File
@@ -0,0 +1,29 @@
import type { CoolifyServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url";
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[] }> {
const base = serviceBaseUrl(widget);
const [projects, resources] = await Promise.all([
fetchCoolify<unknown[]>(base, "/api/v1/projects", widget.apiToken),
fetchCoolify<unknown[]>(base, "/api/v1/resources", widget.apiToken),
]);
return {
stats: [
{ label: "Projects", value: String(projects.length) },
{ label: "Resources", value: String(resources.length) },
],
};
}
+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,
};
}
+8 -1
View File
@@ -1,6 +1,9 @@
import { configStore } from "./loader";
import { discoverDockerWidgets } from "@/lib/discovery/traefik-labels";
import type { Config, Widget } from "./schema";
import { createLogger } from "@/lib/logger";
const log = createLogger("discovery");
const DISCOVERY_INTERVAL_MS = 60_000;
@@ -53,11 +56,15 @@ class EffectiveConfigStore {
try {
const next = await discoverDockerWidgets(configStore.get());
if (!sameDiscoveredSet(this.discovered, next)) {
log.info("discovered widget set changed", {
previous: this.discovered.length,
current: next.length,
});
this.discovered = next;
this.emit();
}
} catch (err) {
console.error(`[discovery] ${(err as Error).message}`);
log.error("discovery scan failed", { error: (err as Error).message });
}
}
+15 -2
View File
@@ -5,11 +5,18 @@ import chokidar, { type FSWatcher } from "chokidar";
import { parse as parseYaml } from "yaml";
import { configSchema, type Config } from "./schema";
import { interpolateEnv } from "./interpolate";
import { createLogger } from "@/lib/logger";
const log = createLogger("config");
const CONFIG_DIR = path.join(process.cwd(), "config");
const CONFIG_PATH = path.join(CONFIG_DIR, "config.yml");
const ENV_PATH = path.join(CONFIG_DIR, ".env");
function widgetCount(config: Config): number {
return config.groups.reduce((sum, group) => sum + group.widgets.length, 0);
}
export class ConfigError extends Error {}
function loadEnv(): Record<string, string | undefined> {
@@ -62,6 +69,7 @@ class ConfigStore {
load(): Config {
this.current = readConfig();
log.info("config loaded", { groups: this.current.groups.length, widgets: widgetCount(this.current) });
return this.current;
}
@@ -85,7 +93,11 @@ class ConfigStore {
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
ignoreInitial: true,
});
this.watcher.on("all", () => this.reload());
this.watcher.on("all", (event) => {
log.debug("watched file changed", { event });
this.reload();
});
log.info("watching config files", { paths: [CONFIG_PATH, ENV_PATH] });
}
stop(): void {
@@ -97,10 +109,11 @@ class ConfigStore {
try {
const next = readConfig();
this.current = next;
log.info("config reloaded", { groups: next.groups.length, widgets: widgetCount(next) });
for (const listener of this.listeners) listener(next);
} catch (err) {
const configError = err instanceof ConfigError ? err : new ConfigError((err as Error).message);
console.error(`[config] ${configError.message}`);
log.error("config reload failed", { error: configError.message });
for (const listener of this.errorListeners) listener(configError);
}
}
+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;
}
+80 -6
View File
@@ -6,11 +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({
@@ -26,6 +34,7 @@ export const dockerWidgetSchema = z.object({
href: z.string().url().optional(),
showStats: z.boolean().default(true),
interval: durationSchema.default("5s"),
icon: z.string().optional(),
});
export const databaseWidgetSchema = z.object({
@@ -56,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,
@@ -70,7 +136,7 @@ export const widgetSchema = z.discriminatedUnion("type", [
databaseWidgetSchema,
systemWidgetSchema,
httpWidgetSchema,
traefikWidgetSchema,
serviceWidgetSchema,
]);
export const groupSchema = z.object({
@@ -124,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>;
+47
View File
@@ -0,0 +1,47 @@
type LogLevel = "debug" | "info" | "warn" | "error";
type LogFields = Record<string, unknown>;
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
function resolveMinLevel(): LogLevel {
const configured = process.env.LOG_LEVEL?.toLowerCase();
if (configured === "debug" || configured === "info" || configured === "warn" || configured === "error") {
return configured;
}
return process.env.NODE_ENV === "production" ? "info" : "debug";
}
const MIN_LEVEL = resolveMinLevel();
function formatFields(fields?: LogFields): string {
if (!fields) return "";
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
}
function write(level: LogLevel, scope: string, message: string, fields?: LogFields): void {
if (LEVEL_ORDER[level] < LEVEL_ORDER[MIN_LEVEL]) return;
const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} [${scope}] ${message}${formatFields(fields)}`;
if (level === "error") console.error(line);
else if (level === "warn") console.warn(line);
else console.log(line);
}
export interface Logger {
debug(message: string, fields?: LogFields): void;
info(message: string, fields?: LogFields): void;
warn(message: string, fields?: LogFields): void;
error(message: string, fields?: LogFields): void;
}
/** Scoped logger. `scope` is the bracketed tag, e.g. createLogger("scheduler") -> "[scheduler]". */
export function createLogger(scope: string): Logger {
return {
debug: (message, fields) => write("debug", scope, message, fields),
info: (message, fields) => write("info", scope, message, fields),
warn: (message, fields) => write("warn", scope, message, fields),
error: (message, fields) => write("error", scope, message, fields),
};
}
+12
View File
@@ -0,0 +1,12 @@
import type { DockerContainerResult } from "./widget-result";
export interface ServiceStat {
label: string;
value: string;
}
export interface ServiceWidgetResult {
docker: DockerContainerResult;
stats: ServiceStat[];
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 };
+73
View File
@@ -12,14 +12,23 @@ interface Envelope {
}
type TopicListener = (envelope: Envelope) => void;
export type ConnectionStatus = "connecting" | "connected" | "reconnecting";
type ConnectionListener = (status: ConnectionStatus) => void;
let sharedSocket: WebSocket | null = null;
let refCount = 0;
let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let connectionStatus: ConnectionStatus = "connecting";
const topicListeners = new Map<string, Set<TopicListener>>();
const connectionListeners = new Set<ConnectionListener>();
const pendingWidgetSubscriptions = new Set<string>();
function setConnectionStatus(status: ConnectionStatus): void {
connectionStatus = status;
for (const listener of connectionListeners) listener(status);
}
function getSocketUrl(): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/ws`;
@@ -27,6 +36,7 @@ function getSocketUrl(): string {
function scheduleReconnect(): void {
if (reconnectTimer || refCount === 0) return;
setConnectionStatus("reconnecting");
const delay = Math.min(30_000, 1_000 * 2 ** reconnectAttempts);
reconnectAttempts += 1;
reconnectTimer = setTimeout(() => {
@@ -45,6 +55,7 @@ function ensureSocket(): WebSocket {
socket.addEventListener("open", () => {
reconnectAttempts = 0;
setConnectionStatus("connected");
for (const widgetId of pendingWidgetSubscriptions) {
socket.send(JSON.stringify({ action: "subscribe", widgetId }));
}
@@ -134,3 +145,65 @@ export function useConfigSubscription(initial: Config): Config {
return config;
}
export function useConnectionStatus(): ConnectionStatus {
const [status, setStatus] = useState<ConnectionStatus>(connectionStatus);
useEffect(() => {
refCount += 1;
// ensureSocket() can synchronously open a connection another component
// already established, so the module-level status may have moved past
// what this hook's initial useState captured - reconcile once before
// subscribing to future changes.
ensureSocket();
connectionListeners.add(setStatus);
// eslint-disable-next-line react-hooks/set-state-in-effect
setStatus(connectionStatus);
return () => {
refCount -= 1;
connectionListeners.delete(setStatus);
};
}, []);
return status;
}
export interface ConfigToast {
id: number;
tone: "ok" | "error";
message: string;
}
let toastCounter = 0;
export function useConfigToasts(): ConfigToast[] {
const [toasts, setToasts] = useState<ConfigToast[]>([]);
useEffect(() => {
refCount += 1;
ensureSocket();
const removeListener = addTopicListener("config", (envelope) => {
let toast: Omit<ConfigToast, "id"> | null = null;
if (envelope.type === "config:update") {
toast = { tone: "ok", message: "config.yml reloaded" };
} else if (envelope.type === "config:error") {
toast = { tone: "error", message: "config.yml has an error — showing last valid config" };
}
if (!toast) return;
toastCounter += 1;
const id = toastCounter;
setToasts((prev) => [...prev, { ...toast!, id }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
});
return () => {
refCount -= 1;
removeListener();
};
}, []);
return toasts;
}
+30 -6
View File
@@ -3,8 +3,12 @@ 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";
import { createLogger } from "@/lib/logger";
const log = createLogger("ws");
type Envelope =
| { topic: string; type: "result"; ts: number; data: WidgetResult }
@@ -21,6 +25,8 @@ export function attachWebSocketServer(httpServer: HttpServer): WebSocketServer {
const wss = new WebSocketServer({ noServer: true });
const subscriptions = new Map<string, Set<WebSocket>>();
const allSockets = new Set<WebSocket>();
const connectionIds = new WeakMap<WebSocket, number>();
let nextConnectionId = 1;
function subscribe(socket: WebSocket, widgetId: string): void {
let set = subscriptions.get(widgetId);
@@ -45,12 +51,16 @@ export function attachWebSocketServer(httpServer: HttpServer): WebSocketServer {
}
wss.on("connection", (socket) => {
const connectionId = nextConnectionId++;
connectionIds.set(socket, connectionId);
allSockets.add(socket);
log.info("client connected", { connectionId, clients: allSockets.size });
try {
const config = effectiveConfigStore.get();
send(socket, { topic: "config", type: "config:update", ts: Date.now(), data: config });
} catch {
send(socket, { topic: "config", type: "config:update", ts: Date.now(), data: toPublicConfig(config) });
} catch (err) {
log.debug("no valid config to send on connect", { connectionId, error: (err as Error).message });
// no valid config loaded yet; client keeps its SSR-seeded config until one arrives
}
@@ -58,18 +68,27 @@ export function attachWebSocketServer(httpServer: HttpServer): WebSocketServer {
let message: { action?: string; widgetId?: string };
try {
message = JSON.parse(raw.toString());
} catch {
} catch (err) {
log.warn("received malformed message", { connectionId, error: (err as Error).message });
return;
}
if (message.action === "subscribe" && typeof message.widgetId === "string") {
log.debug("client subscribed", { connectionId, widgetId: message.widgetId });
subscribe(socket, message.widgetId);
} else if (message.action === "unsubscribe" && typeof message.widgetId === "string") {
log.debug("client unsubscribed", { connectionId, widgetId: message.widgetId });
unsubscribe(socket, message.widgetId);
}
});
socket.on("close", () => cleanupSocket(socket));
socket.on("error", () => cleanupSocket(socket));
socket.on("close", (code) => {
log.info("client disconnected", { connectionId, code, clients: allSockets.size - 1 });
cleanupSocket(socket);
});
socket.on("error", (err) => {
log.error("socket error", { connectionId, error: err.message });
cleanupSocket(socket);
});
});
collectorScheduler.onResult((widgetId, result) => {
@@ -80,7 +99,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);
});
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulsenode",
"version": "0.1.0",
"version": "0.4.8",
"private": true,
"scripts": {
"dev": "tsx watch server.ts",
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"chokidar": "^5.0.0",
"dockerode": "^5.0.1",
"dotenv": "^17.4.2",
@@ -16,6 +17,7 @@
"p-limit": "^7.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"simple-icons": "^16.28.0",
"systeminformation": "^5.33.1",
"tsx": "^4.23.12",
"ws": "^8.21.3",
+24
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
chokidar:
specifier: ^5.0.0
version: 5.0.0
@@ -29,6 +32,9 @@ importers:
react-dom:
specifier: 19.2.8
version: 19.2.8(react@19.2.8)
simple-icons:
specifier: ^16.28.0
version: 16.28.0
systeminformation:
specifier: ^5.33.1
version: 5.33.1
@@ -660,6 +666,13 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@phosphor-icons/react@2.1.10':
resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==}
engines: {node: '>=10'}
peerDependencies:
react: '>= 16.8'
react-dom: '>= 16.8'
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -2203,6 +2216,10 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
simple-icons@16.28.0:
resolution: {integrity: sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==}
engines: {node: '>=0.12.18'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2943,6 +2960,11 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
@@ -4701,6 +4723,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
simple-icons@16.28.0: {}
source-map-js@1.2.1: {}
split-ca@1.0.1: {}
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Regenerates lib/brand-icons.ts from the `simple-icons` package.
Run from the repo root after `pnpm add simple-icons` has installed it:
python3 scripts/extract-brand-icons.py
Add a new service by adding a "our key" -> "simple-icons slug" entry below.
Slugs are the filenames under node_modules/simple-icons/icons/ (without the
.svg extension).
"""
import json
import re
from pathlib import Path
ICONS = {
"traefik": "traefikproxy",
"coolify": "coolify",
"gitea": "gitea",
"docker": "docker",
"immich": "immich",
"n8n": "n8n",
"passbolt": "passbolt",
"umami": "umami",
"postgresql": "postgresql",
"redis": "redis",
"headscale": "tailscale",
"coder": "coder",
}
ROOT = Path(__file__).resolve().parent.parent
ICONS_DIR = ROOT / "node_modules" / "simple-icons" / "icons"
OUT_PATH = ROOT / "lib" / "brand-icons.ts"
HEADER = """// Path data extracted from the `simple-icons` package (CC0) at build time -
// see scripts/extract-brand-icons.py. Rendered monochrome (currentColor) to
// stay consistent with the single-accent design rather than a dozen
// competing brand colors; the shape alone is enough to be recognizable.
export const BRAND_ICON_PATHS: Record<string, string> = {
"""
def main() -> None:
lines = [HEADER]
for key, slug in ICONS.items():
svg = (ICONS_DIR / f"{slug}.svg").read_text()
d_matches = re.findall(r'<path d="([^"]+)"', svg)
combined = " ".join(d_matches)
lines.append(f" {json.dumps(key)}: {json.dumps(combined)},\n")
lines.append("};\n")
OUT_PATH.write_text("".join(lines))
print(f"Wrote {OUT_PATH} ({len(ICONS)} icons)")
if __name__ == "__main__":
main()
+19 -7
View File
@@ -4,6 +4,9 @@ import { attachWebSocketServer } from "./lib/ws/server";
import { configStore } from "./lib/config/loader";
import { effectiveConfigStore } from "./lib/config/effective";
import { collectorScheduler } from "./lib/collectors/scheduler";
import { createLogger } from "./lib/logger";
const log = createLogger("server");
const port = Number(process.env.PORT ?? 3000);
const hostname = process.env.HOSTNAME ?? "0.0.0.0";
@@ -14,6 +17,7 @@ const handle = app.getRequestHandler();
async function main(): Promise<void> {
await app.prepare();
log.info("next.js prepared", { dev });
const upgradeHandler = app.getUpgradeHandler();
configStore.load();
@@ -21,6 +25,7 @@ async function main(): Promise<void> {
await effectiveConfigStore.start();
collectorScheduler.start(effectiveConfigStore.get());
effectiveConfigStore.onUpdate((config) => collectorScheduler.reconcile(config));
log.info("collector scheduler started");
const httpServer = createServer((req, res) => {
handle(req, res);
@@ -39,23 +44,30 @@ async function main(): Promise<void> {
void upgradeHandler(req, socket, head);
});
function shutdown(): void {
function shutdown(signal: NodeJS.Signals): void {
log.info("shutting down", { signal });
collectorScheduler.stop();
effectiveConfigStore.stop();
configStore.stop();
httpServer.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5_000).unref();
httpServer.close(() => {
log.info("http server closed");
process.exit(0);
});
setTimeout(() => {
log.warn("shutdown timed out, forcing exit");
process.exit(0);
}, 5_000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
httpServer.listen(port, () => {
console.log(`> PulseNode listening on http://${hostname}:${port}`);
log.info("listening", { url: `http://${hostname}:${port}` });
});
}
main().catch((err) => {
console.error(err);
log.error("fatal startup error", { error: (err as Error).message, stack: (err as Error).stack });
process.exit(1);
});