Compare commits

..
6 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 03af5fcfb8 Serverpuls: Zeitzone in Warnungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 57f235dd36 Serverpuls: deutsche Zeitachse und Legende im Diagramm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 16f3150ff1 Serverpuls: JWT-Cache, Lade-Zustand beim Umschalten, Antwort-Reihenfolge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 ec740ca970 Serverpuls: Admin-Seite mit Verlauf und Warnungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 ae905a5d9b Plan: Serverpuls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
ClaudeandClaude Opus 5.5 3eb67bba81 Spec: Serverpuls (Server-Monitoring fuer Admins)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:43:13 +00:00
16 changed files with 1303 additions and 3 deletions

No files matched your search

@@ -0,0 +1,183 @@
# Serverpuls Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Admin-only WOMS page „Serverpuls“ with server/container/app metrics over time and deterministic Low/Medium/Critical mail alerts from a small monitoring service.
**Architecture:** New service `/home/webklar/apps/serverpuls` (own local git repo, Node 22, `node:sqlite`, nodemailer) collects from host `/proc`, a read-only docker-socket-proxy and read-only mounts, stores downsampled series in SQLite, evaluates rules, mails via server@webklar.com and serves a read-only JWT+admin API. WOMS nginx proxies `/api/serverpuls/`; WOMS gets `ServerpulsPage` with uPlot charts.
**Tech Stack:** Node 22 (container) / Node 24 (host tests, `node --test`), `node:sqlite`, nodemailer, `tecnativa/docker-socket-proxy`, React 18 + Vite 5, uPlot, Playwright headless shell (host cache `chromium_headless_shell-1234`).
**Spec:** `docs/superpowers/specs/2026-09-30-serverpuls-design.md` (WOMS worktree `dev-serverpuls`, branch `feature/serverpuls`).
## Global Constraints
- No AI anywhere in collection, rules or mails.
- serverpuls container: `mem_limit: 128m`, `cpus: 0.5`, `init: true`, port 8091, base path `/api/serverpuls`.
- Every API route except `/health` requires `X-Appwrite-JWT`; `/account` must return `labels` containing `admin` → else 401 (no/invalid JWT) / 403 (no admin). Auth cache 60 s.
- API is read-only: only `GET`. No delete/stop/restart anywhere.
- Mail: `mail.your-server.de:587` STARTTLS, user + from + to = `server@webklar.com`; password only in `/home/webklar/apps/.env` as `SERVERPULS_SMTP_PASS`, set by the user; never printed, never read by Claude.
- Retention: raw 5 min for 7 days → hourly (avg + max) for 90 days → daily forever. Sizes daily forever.
- Schedules (Europe/Berlin, via `Intl`): sample every 5 min; sizes 03:00; downsampling 03:30; Medium digest daily 07:00; Low weekly Monday 07:00. Due-state stored in SQLite `meta` so restarts neither skip nor double-send.
- Missing measurement = no row (gap), never 0.
- Critical containers: `webklar-traefik`, `webklar-appwrite`, `webklar-kundenbereich-server`, `webklar-ticket`, `webklar-ticket-integrations`, `webklar-gitea`, `webklar-invoiceninja`, `webklar-n8n`.
- Mail subjects start with `[Serverpuls]`; texts German.
- WOMS: never push; never `git add -A` (node_modules is tracked); branch `feature/serverpuls`. Live = the user pushes to `test`.
- Heavy work (vite build, Playwright, npm install) runs under `systemd-run --scope -p MemoryMax=…M -p MemorySwapMax=0` (build 700M, browser 700M) — swap is full.
- Before editing `/home/webklar/apps/docker-compose.yml`, `.env`-touching scripts or the WOMS nginx conf: backup `*.bak-2026-09-30-serverpuls`.
## Review Focus
- Small apps flapping in growth rules (1 MB → 2 MB = +100 %): percentage growth rules only apply when the app is ≥ 100 MB at the later point (absolute 2 GB rule always applies). Test in Task 3.
- First days without 7-day history: growth rules silent until a size row ≥ 6 days old exists (no division by missing/zero). Test in Task 3.
- Service restart / downtime around 07:00: digest is sent once, on the first tick after 07:00 the same day; never twice. Test in Task 4.
- Container that is deliberately stopped (e.g. Paperless, exit code 0): no alert; only non-zero exit or restart loop counts for non-critical. Test in Task 3.
- API polled by the page every minute while Appwrite is slow: auth cache prevents one `/account` call per request; Appwrite down → 502 with JSON `{error}`, page shows the error instead of blank charts. Tests in Task 5 and Task 8.
---
## File Structure
Service repo `/home/webklar/apps/serverpuls/`:
| File | Responsibility |
|---|---|
| `Dockerfile` | `FROM node:22-alpine`, `apk add --no-cache util-linux-misc` (ionice), `npm ci --omit=dev`, `CMD node src/main.js` |
| `package.json` | `"type":"module"`, dep `nodemailer`, script `test: node --test test/` |
| `config/apps.json` | container → app grouping, app → folders |
| `config/rules.json` | thresholds + critical container list |
| `src/collect/host.js` | parse `/proc` + statfs |
| `src/collect/docker.js` | proxy client: list, inspect, stats, system df |
| `src/collect/sizes.js` | nightly `du` per app + previews + cleanup numbers |
| `src/apps.js` | map containers/volumes/folders to apps |
| `src/store.js` | SQLite schema, inserts, queries, downsampling, meta |
| `src/rules.js` | pure rule evaluation |
| `src/alerts.js` | state diff, mail decisions, digests |
| `src/mail.js` | nodemailer + queue |
| `src/api.js` | HTTP API + auth |
| `src/schedule.js` | Berlin-time due checks |
| `src/main.js` | wiring + timers |
| `test/*.test.js`, `test/fixtures/` | unit/API tests |
WOMS worktree `dev-serverpuls`: `src/lib/serverpulsApi.js`, `src/lib/serverpulsFormat.js`, `src/components/serverpuls/{Chart.jsx,Sparkline.jsx,AppsTable.jsx}`, `src/pages/ServerpulsPage.jsx`, `src/styles/serverpuls.css`; modify `src/App.jsx`, `src/components/Navbar.jsx`, `package.json`, `package-lock.json`; test `tests/unit/serverpulsFormat.test.mjs`.
Ops: `/home/webklar/apps/docker-compose.yml`, `/home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf`, `/home/webklar/apps/scripts/mail-zugang.py`.
---
### Task 1: Repo, host collector, app mapping
**Files:**
- Create: `Dockerfile`, `package.json`, `.gitignore` (`node_modules`, `data`), `config/apps.json`, `src/collect/host.js`, `src/apps.js`
- Test: `test/host.test.js`, `test/apps.test.js`, fixtures `test/fixtures/proc/{stat,stat2,meminfo,loadavg}` and `test/fixtures/proc/{101,102,103}/stat` (103 in state `Z`)
**Interfaces:**
- Produces: `readHost(procRoot: string, diskPath: string, prevCpu?: {idle,total}) → Promise<{cpuPct: number|null, cpuRaw:{idle,total}, load1, load5, load15, memAvail, memTotal, swapUsed, swapTotal, diskUsed, diskTotal, zombies}>` (bytes; `cpuPct` null when no `prevCpu`).
- Produces: `loadAppsConfig(path) → AppsConfig`; `appForContainer(cfg, {name, service, project}) → string`; `foldersForApp(cfg, app) → string[]` (relative to apps root).
- [ ] **Step 1:** `git init` in `/home/webklar/apps/serverpuls`; package.json, Dockerfile, .gitignore. Install nodemailer + lockfile in a throwaway `docker run --rm -v $PWD:/w -w /w node:22-alpine npm install nodemailer` under MemoryMax 700M.
- [ ] **Step 2: Failing tests** — `host.test.js`: with fixtures `stat`→`stat2` delta, `cpuPct` equals the hand-computed value (write it into the fixture comment); `memAvail` = MemAvailable×1024; `swapUsed` = (SwapTotal−SwapFree)×1024; `zombies === 1`; unreadable `/proc/<pid>/stat` (pid dir without stat) is skipped, not thrown. `apps.test.js`: `webklar-appwrite-worker-mails` → `Appwrite`; `openruntimes-executor` → `Appwrite`; `webklar-nextcloud25-db` → `Nextcloud 25`; `webklar-ticket`, `webklar-ticket-build`, `webklar-ticket-integrations` → `Ticketsystem`; `debian-app-1` (project `debian`) → `debian`; unknown `webklar-foo` → `foo`.
- [ ] **Step 3:** Run `node --test test/` → FAIL.
- [ ] **Step 4:** Implement. `config/apps.json` shape: `{"groups":[{"app":"Appwrite","match":["webklar-appwrite*","openruntimes-executor"],"folders":["appwrite"]}, …]}`, glob `*` only; fallback = container name minus `webklar-` prefix, or compose project for non-`apps` projects. Seed groups for Appwrite, Kundenportal (`webklar-kundenbereich-*`, folder `kundenbereich.webklar.com`), Ticketsystem (`ticket.webklar.com`, `ticket-integrations`), Website (`webklar-webclaw*`, `webklar.com`), Nextcloud, Nextcloud 25, InvoiceNinja (`InvoiceNinja`), Gitea, n8n, Bitwarden, E-Mail-Sorter (`emailsorter*`), eship, Previews-System (`webklar-preview-*`, `previews`), Traefik. Disk via `fs.statfsSync(diskPath)`.
- [ ] **Step 5:** `node --test test/` → PASS. Commit `serverpuls: host collector + app mapping`.
### Task 2: Store and downsampling
**Files:** Create `src/store.js`; Test `test/store.test.js`
**Interfaces:**
- Produces: `openStore(file: string) → Store` with
`insertHost(ts, host)`, `insertApps(ts, [{app, cpuPct, memBytes}])`, `insertContainers(ts, [{name, app, status, health, exitCode, restartCount}])`, `insertSizes(day 'YYYY-MM-DD', [{app, bytes}])`, `insertCleanup(day, {imagesBytes, buildCacheBytes, stoppedBytes, stopped:[{name,bytes}]})`,
`hostSeries(metric, sinceTs, res: 'raw'|'hour'|'day') → [{ts, v, max}]`, `appSeries(app, field: 'cpu'|'mem', sinceTs, res)`, `sizeSeries(app|null, sinceDay) → [{day, app, bytes}]`, `containerHistory(name, sinceTs)`, `latestHost()`, `latestContainers()`, `latestCleanup()`,
`downsample(nowTs)`, `getMeta(key)`, `setMeta(key, value)`, `alertState() → Map`, `saveAlertState(Map)`, `queueMail(msg)`, `pendingMails()`, `markMailSent(id)`, `dropMailsOlderThan(ts)`.
- Metrics names: `cpu, load1, load5, load15, memAvail, memTotal, swapUsed, swapTotal, diskUsed, diskTotal, zombies`.
- [ ] **Step 1: Failing tests** (`:memory:` DB): insert 24 raw rows 5 min apart at 10 days ago → `downsample(now)` leaves 0 raw, 2 hour rows with correct avg and max; hour rows older than 90 days become day rows; raw from 1 day ago untouched; `containerHistory` rows older than 7 days deleted; `setMeta/getMeta` round-trip; alert state round-trip survives reopen of a temp file DB.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement (tables per spec; `host_samples(ts, res, metric…, *_max)` one row per ts; indexes on `(res, ts)`; `app_samples(ts,res,app,cpu,cpu_max,mem,mem_max)`). **Step 4:** Run → PASS. **Step 5:** Commit `serverpuls: sqlite store + downsampling`.
### Task 3: Rules
**Files:** Create `config/rules.json`, `src/rules.js`; Test `test/rules.test.js`
**Interfaces:**
- Consumes: Store queries (Task 2) via an input object built in `main.js`.
- Produces: `evaluate(input, rules) → Finding[]` where
`input = {now, hostRaw:[{ts, …metrics}] (last 2 h), containers:[{name, app, status, health, exitCode, restartCount}], restarts1h: {name: n}, sizes:[{day, app, bytes}] (last 8 days), cleanup, zombieSince: ts|null, critical: string[]}` and
`Finding = {id: string, level: 'low'|'medium'|'critical', title: string, value: string}`.
- Ids: `disk`, `mem`, `load`, `zombies`, `zombies-persist`, `container:<name>`, `growth:<app>`, `cleanup`.
- [ ] **Step 1: Failing tests** — one per spec rule and both sides of each threshold: disk 89.9 % → medium, 90 % → critical; mem critical needs every sample in last 10 min to have memAvail < 500 MB **and** swap ≥ 90 % (and ≥ 2 samples); one good sample inside window → not critical; swap ≥ 75 % for 1 h → medium; load5 > 24 for 15 min → critical, > 8 for 1 h → medium; zombies 100 → critical, 20 → medium, ≥ 1 for > 24 h → low `zombies-persist`; critical container `exited` → critical; any container restartCount +4 in 1 h → critical if in list else medium; non-critical exited with exitCode 0 → none, exitCode 1 → medium; growth 7 d +21 % on 500 MB app → medium; +21 % on 50 MB app → none; +2.1 GB on 50 GB app → medium; +11 % week on 500 MB → low; only 3 days of sizes → no growth finding; images 11 GB → low `cleanup`. Each finding has non-empty German `title` and `value`.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement with values from `config/rules.json` (copy the spec numbers verbatim; `minGrowthPctBaseBytes: 104857600`). One finding per id, highest level wins. **Step 4:** Run → PASS. **Step 5:** Commit `serverpuls: warning rules`.
### Task 4: Alerts, schedule, mail
**Files:** Create `src/schedule.js`, `src/alerts.js`, `src/mail.js`; Test `test/alerts.test.js`, `test/schedule.test.js`
**Interfaces:**
- Produces: `berlinParts(ts) → {day:'YYYY-MM-DD', hour, minute, weekday: 1..7}`; `isDue(kind: 'sizes'|'downsample'|'daily'|'weekly', ts, lastRunDay) → boolean` (03:00 / 03:30 / 07:00 / Monday 07:00; due once per day/week after the time).
- Produces: `processFindings(findings, state: Map, now) → {state, immediate: Mail[], resolvedForDigest: {medium:[], low:[]}}` — state entry `{id, level, since, lastMailed}`; critical new or re-mail after 24 h or escalation → immediate; critical resolved → immediate „✅ behoben“; medium/low resolved → collected for next digest.
- Produces: `dailyDigest(state, resolved, now) → Mail|null` (null if nothing active or resolved), `weeklyDigest(state, resolved, tops, now) → Mail` (always, contains the three Top-5 lists); `Mail = {subject, text, html}`.
- Produces: `createMailer(env) → {send(mail) → Promise}`; `flushQueue(store, mailer, now)` retries every 5 min, drops after 24 h; empty `SERVERPULS_SMTP_PASS` → mails stay queued, one log line, no crash.
- [ ] **Step 1: Failing tests** — new critical → 1 immediate, subject `[Serverpuls] 🔴 Critical: <title>`; same finding again 1 h later → 0; 24 h later → 1; medium → critical escalation → 1 immediate; critical disappears → `[Serverpuls] ✅ behoben: <title>`; medium only appears in `dailyDigest` subject `[Serverpuls] 🟠 Tagesbericht: n Warnungen`; weekly subject `[Serverpuls] 🟡 Wochenübersicht KW <n>`; `isDue('daily')` at 06:59 Berlin false, 07:00 true, again same day with lastRunDay=today false, service down 06:00–09:00 then tick 09:05 → true once; DST switch day (2026-10-25) still exactly once. `flushQueue` with a failing mailer keeps the mail; with success marks it sent; mailer without password never calls SMTP and keeps the mail.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement; nodemailer `{host, port:587, secure:false, requireTLS:true, auth}` from `SERVERPULS_SMTP_*`; mails text + simple HTML table, link `https://ticket.webklar.com/serverpuls`. **Step 4:** Run → PASS. **Step 5:** Commit `serverpuls: alerts, digests, mail queue`.
### Task 5: API
**Files:** Create `src/api.js`; Test `test/api.test.js`
**Interfaces:**
- Consumes: Store (Task 2), active alert state.
- Produces: `createApi({store, getAlerts: () => Finding-state[], appwrite: {endpoint, projectId}, fetchImpl}) → http.Server` with routes from the spec table. Range → resolution: `24h`,`7d` raw; `30d`,`90d` hour; `1y` day. `/overview` returns `{lastSample, host: {metric: {value, spark24h:[…], deltaYesterday}}, alerts:[…], level: 'ok'|'medium'|'critical'}`; `/apps` → `{sizesDay, rows}` (sizesDay = date of the latest size measurement, shown on the page), rows `{id, name, cpuPct, memBytes, sizeBytes, delta7d, delta30d, spark30d}` (deltas null without history); `/apps/:id` `{size, mem, cpu}` series; `/cleanup` = latest cleanup row.
- [ ] **Step 1: Failing tests** (seeded `:memory:` store, fake `fetchImpl` for `/account`): no JWT → 401; JWT with `labels:[]` → 403; admin → 200 on every route; `/host?metric=bogus` → 400; `/host?range=30d` returns hour rows; `/apps/unknown` → 404; POST → 405; `/account` throws → 502 `{error}`; two requests with same JWT within 60 s → one `/account` call; `/health` without JWT → 200.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement (plain `node:http`, pattern of `ticket-integrations/server.js`). **Step 4:** Run → PASS. **Step 5:** Commit `serverpuls: read-only admin API`.
### Task 6: Docker + sizes collectors, main wiring
**Files:** Create `src/collect/docker.js`, `src/collect/sizes.js`, `src/main.js`; Test `test/docker.test.js`, `test/sizes.test.js`
**Interfaces:**
- Produces: `dockerClient(baseUrl) → {list(), inspect(id), stats(id), systemDf()}`; `containerCpuPct(stats) → number|null` (standard `cpu_delta/system_delta*online_cpus*100`); `memBytes(stats)` = usage − inactive_file (fallback cache).
- Produces: `measureSizes({appsRoot, volumesRoot, previewsRoot, apps: [{app, folders, volumes}], timeoutMs: 1800000}) → Promise<[{app, bytes}]>` using `ionice -c3 nice -n 19 du -sk` (fallback `nice` only if ionice missing); each preview folder → app `Preview: <name>`.
- `main.js`: env `DB_FILE=/data/serverpuls.db`, `PROC_ROOT=/host/proc`, `DISK_PATH=/host/apps`, `APPS_ROOT=/host/apps`, `VOLUMES_ROOT=/host/volumes`, `PREVIEWS_ROOT=/host/previews`, `DOCKER_URL=http://serverpuls-docker-proxy:2375`; 60 s tick: sample if ≥ 5 min since last; due jobs; rules → alerts → queue → flush. Every collector failure is caught and logged; others still stored.
- [ ] **Step 1: Failing tests** — `containerCpuPct` on a fixture stats JSON equals hand value; missing `precpu_stats` → null; `measureSizes` on a temp tree (folder A 3 files, volume dir, previews/x) returns the three apps with sizes ≥ file bytes; a `du` that exceeds `timeoutMs` (set 1) rejects/returns partial without throwing out of `measureSizes`.
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement. **Step 4:** Run all `node --test test/` → PASS. **Step 5:** Commit `serverpuls: docker/sizes collectors + main loop`.
### Task 7: Deploy service (ops, with user gate for mail)
**Files:** Modify `/home/webklar/apps/docker-compose.yml` (backup first), `/home/webklar/apps/scripts/mail-zugang.py` (backup first), `/home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf` (backup first).
- [ ] **Step 1:** Compose: add `serverpuls-docker-proxy` (`tecnativa/docker-socket-proxy`, `CONTAINERS=1 IMAGES=1 SYSTEM=1 POST=0`, socket `:ro`, `mem_limit: 32m`, `webklar-network`, no Traefik labels) and `serverpuls` (`build: ./serverpuls`, `container_name: webklar-serverpuls`, limits from Global Constraints, mounts `/proc:/host/proc:ro`, `./:/host/apps:ro`, `/var/lib/docker/volumes:/host/volumes:ro`, `/var/www/previews:/host/previews:ro`, `serverpuls-data:/data`, env `SERVERPULS_SMTP_USER=server@webklar.com`, `SERVERPULS_SMTP_PASS=${SERVERPULS_SMTP_PASS:-}`, `TZ=Europe/Berlin`). `docker compose config -q` → exit 0. Note: mount `/host/previews` is an addition to the spec's mount list (needed for preview apps).
- [ ] **Step 2:** `docker compose up -d --build serverpuls-docker-proxy serverpuls` (build under MemoryMax 700M). Check: `docker stats --no-stream webklar-serverpuls` < 128 MiB; after 6 min `wget -qO- http://webklar-serverpuls:8091/api/serverpuls/health` from `webklar-ticket-integrations` shows `lastSample`; proxy rejects `POST /containers/x/stop` with 403.
- [ ] **Step 3:** nginx: add `location /api/serverpuls/` block mirroring `/api/integrations/` (`set $serverpuls_upstream webklar-serverpuls; proxy_pass http://$serverpuls_upstream:8091;`, no `client_max_body_size`). `docker exec webklar-ticket nginx -t && docker exec webklar-ticket nginx -s reload`. `curl -s -o /dev/null -w '%{http_code}' https://ticket.webklar.com/api/serverpuls/overview` → 401.
- [ ] **Step 4:** `mail-zugang.py --serverpuls`: prompt password for server@webklar.com (getpass), test login, backup + write `SERVERPULS_SMTP_PASS` into `.env`, `docker compose up -d serverpuls`, send test mail `[Serverpuls] Testmail` from inside the container. Dry-run with `WEBKLAR_ROOT` copy + fake SMTP. **User gate:** the user runs it; Claude only checks for the `250` result line.
- [ ] **Step 5:** Commit service repo; record compose/nginx/script changes in the memory file.
### Task 8: WOMS page
**Files:** Create `src/lib/serverpulsApi.js`, `src/lib/serverpulsFormat.js`, `src/components/serverpuls/{Chart.jsx,Sparkline.jsx,AppsTable.jsx}`, `src/pages/ServerpulsPage.jsx`, `src/styles/serverpuls.css`; Modify `src/App.jsx` (route `/serverpuls`), `src/components/Navbar.jsx` (entry after „Rechtliches“, before „Admin“, only `isAdmin`, pulse icon, dot from `overview.level`, fetched on app load and every 5 min, admins only), `package.json`/`package-lock.json` (`uplot`); Test `tests/unit/serverpulsFormat.test.mjs`.
**Interfaces:**
- Consumes: API from Task 5.
- Produces: `serverpulsApi.{overview(), host(metric, range), apps(), app(id, range), cleanup()}` (JWT pattern of `integrationsApi.js`, base `/api/serverpuls`); `formatBytes(n) → '1,2 GB'` (de-DE, base 1024), `formatDelta(pct|null) → {text:'+12,3 %'|'–', dir:'up'|'down'|'flat'}`, `ageLabel(ts, now) → {text:'vor 4 Min.', stale: boolean}` (stale > 15 min).
- [ ] **Step 1: Failing test** `serverpulsFormat.test.mjs`: `formatBytes(1288490189)` → `'1,2 GB'`; `formatBytes(0)` → `'0 B'`; `formatDelta(null).text` → `'–'`; `formatDelta(-3.21)` → `{text:'−3,2 %', dir:'down'}`; `ageLabel` at 16 min → stale true, 14 min → false.
- [ ] **Step 2:** `npm test` → FAIL. **Step 3:** Implement format helpers → PASS.
- [ ] **Step 4:** `uplot` into package.json + lockfile via throwaway `docker run node:20-alpine` (never touch tracked node_modules). Implement page per spec „WOMS-Seite“ (ampel, 6 tiles, big chart with range + metric selector, sortable/searchable apps table with row → detail chart, „Aufräumbar“, 60 s refresh while `document.visibilityState==='visible'`, error box on API error, non-admin → `<Navigate to="/tickets">`). Mobile: tiles stack < 700 px.
- [ ] **Step 5:** Deploy-like build: `git archive HEAD` + `docker run --rm node:20-alpine sh -c 'npm install && npm run build'` under MemoryMax 700M → exit 0. Commit `Serverpuls: Admin-Seite mit Verlauf und Warnungen` (only the listed files).
### Task 9: UI test
**Files:** Create (scratchpad, not in repo) `serverpuls-e2e.mjs`.
- [ ] **Step 1:** Vite build with `DIST=…` (MemoryMax 700M); serve via Playwright `page.route` under `https://ticket.test`, mock Appwrite (`/v1/account` with/without `labels:['admin']`) and `/api/serverpuls/*` fixtures (one medium alert, 3 apps, one with `delta7d:null`).
- [ ] **Step 2:** Assertions: admin sees nav entry „Serverpuls“ with orange dot; non-admin sees no entry and `/serverpuls` redirects to `/tickets`; ampel text contains „1 Warnung“; 6 tiles; switching range to „30 T“ requests `range=30d`; clicking column „Δ 7 T“ sorts; null delta shows „–“; row click opens detail chart; `/overview` 502 → error box visible, no crash; `lastSample` 20 min old → stale hint; width 390 px → no horizontal scroll.
- [ ] **Step 3:** Run under MemoryMax 700M → all pass; screenshot desktop + mobile for the user.
### Task 10: Finish
- [ ] **Step 1:** Service: `node --test test/` all pass; live container RAM < 128 MiB after 1 h; first rows in `/overview`.
- [ ] **Step 2:** WOMS: `npm test` all pass; final whole-branch review.
- [ ] **Step 3:** Report to the user: what is live (service + nginx), what waits (WOMS push `git -C dev-serverpuls push origin feature/serverpuls:test`), password step status. Update memory.
@@ -0,0 +1,198 @@
# Serverpuls – Server-Monitoring in WOMS
Stand: 2026-09-30 · Status: Design vom Nutzer im Chat freigegeben, Spec zur Prüfung
## Ziel
Ein neuer Navbar-Eintrag **Serverpuls** in WOMS (ticket.webklar.com) zeigt Admins die
Auslastung des Servers und das Wachstum der einzelnen Projekte über Tage, Wochen und
Monate – als Verlaufskurven „wie bei Aktien“. Feste Warnregeln (keine KI) melden
Probleme per E-Mail in drei Stufen.
Ausdrücklich **nicht KI-gesteuert**: Messung, Auswertung und Mails laufen
deterministisch in einem kleinen Dienst ohne Claude.
## Rahmenbedingungen
- Server: 8 Kerne, 15 GB RAM, 4 GB Swap (am 2026-09-30 voll), Platte 150 GB (76 % belegt),
~61 Container. Der Dienst selbst muss sehr sparsam sein (Limit 128 MB RAM, 0,5 CPU).
- WOMS ist eine statische React/Vite-SPA auf Appwrite; Server-Logik liegt in kleinen
Node-Diensten hinter dem WOMS-nginx (Vorbild: `ticket-integrations`,
`/api/integrations/`, Auth über Appwrite-JWT).
- Admin in WOMS = Appwrite-Konto mit Label `admin` (`AuthContext.jsx`).
- Der Deploy-Webhook von WOMS deployt jeden gepushten Branch; live geht nur per Push
des Nutzers nach `test`.
## Entscheidungen
| Frage | Entscheidung |
|---|---|
| Name | Serverpuls |
| Rolle der KI | keine; feste Warnregeln |
| „Projekt“ | jede App / jeder Container (Previews als eigene Apps) mit CPU, RAM, Speicher |
| Warnkanal | E-Mail in drei Stufen Low / Medium / Critical |
| Mail-Takt | Critical sofort, Medium Sammelmail täglich 07:00, Low Wochenübersicht Mo 07:00 |
| Mail-Adresse | `server@webklar.com` ist Absender **und** einziger Empfänger |
| Umsetzung | eigener schlanker Dienst (kein Beszel, kein Netdata) |
| Sichtbarkeit | nur Admins (Navbar und API) |
## Architektur
```
Host /proc (ro) ──┐
docker-socket-proxy (nur GET containers/stats) ──┼──> serverpuls (Node 22, SQLite) ──> SMTP server@
/home/webklar/apps + Docker-Volumes (ro) ──┘ │
└── HTTP :8091 <── WOMS-nginx /api/serverpuls/ <── ServerpulsPage (uPlot)
```
### Einheiten
1. **Collector** (`collect/`): liest Messwerte, gibt reine Datenobjekte zurück.
- `host.js` – `/proc/stat` (CPU % aus Differenz), `/proc/meminfo` (RAM verfügbar,
Swap), `/proc/loadavg`, Zombies = Prozesse mit State `Z` in `/proc/*/stat`.
Plattenbelegung `/` per `statfs` auf `/host/apps` (gleiches Dateisystem `/dev/sda1`).
- `docker.js` – über den Socket-Proxy: Container-Liste (Name, Compose-Projekt/-Service,
Status, Health, RestartCount) und `stats?stream=false` (CPU %, RAM).
- `sizes.js` – Größe pro App: Ordner unter `/home/webklar/apps/<app>` plus Docker-
Volumes der Container dieser App (`du -sb`, unter `nice -n 19 ionice -c3`).
Außerdem „Aufräumbar“: ungenutzte Images, Build-Cache, gestoppte Container
(`/system/df` über den Proxy).
2. **App-Zuordnung** (`apps.js`): Container → App über das Label
`com.docker.compose.service` bzw. den Containernamen; eine kleine Mapping-Datei
`config/apps.json` erlaubt Umbenennen/Zusammenfassen (z. B. `webklar-ticket-*` →
„Ticketsystem“) und Ordnerzuordnung. Previews erscheinen einzeln.
3. **Speicher** (`store.js`): SQLite (`node:sqlite`) im Volume `serverpuls-data`.
- Tabellen: `host_samples`, `app_samples`, `app_sizes`, `cleanup_samples`,
`alerts` (Zustand), `mail_queue`.
- Aufbewahrung: Rohwerte (5 min) 7 Tage → Stundenmittel/-max 90 Tage →
Tageswerte unbegrenzt. Verdichtung täglich 03:30.
- Erwartete Größe < 100 MB/Jahr.
4. **Regeln** (`rules.js`): reine Funktion `(aktuelle + historische Werte) → Liste aktiver
Befunde {id, level, titel, wert, seit}`. Schwellen in `config/rules.json`.
5. **Alarmierung** (`alerts.js`): vergleicht Befunde mit gespeichertem Zustand, erzeugt
Mails (sofort/täglich/wöchentlich), Wiederholungssperre 24 h, „✅ behoben“-Mail beim
Verschwinden. Zustand in SQLite → Neustart erzeugt keine Doppel-Mails.
6. **Mailer** (`mail.js`): nodemailer, `mail.your-server.de:587` STARTTLS, Login
`server@webklar.com`. Fehlgeschlagene Mails bleiben in `mail_queue` und werden
alle 5 min erneut versucht (max. 24 h).
7. **API** (`api.js`): HTTP-Server Port 8091, Basis `/api/serverpuls`. Jede Anfrage
(außer `/health`) braucht `X-Appwrite-JWT`; der Dienst fragt `/account` bei Appwrite
ab (Cache 60 s) und verlangt Label `admin`, sonst 401/403.
8. **Scheduler** (`main.js`): 5-min-Takt, nächtliche Größenmessung 03:00, Verdichtung
03:30, Medium-Digest täglich 07:00, Low-Wochenübersicht Mo 07:00 (Europe/Berlin).
### Messung
- Alle 5 Minuten: Server gesamt (CPU %, Load 1/5/15, RAM verfügbar, Swap belegt,
Platte `/`, Zombies) und pro Container (CPU %, RAM, Status, Neustarts).
- Täglich 03:00: Größe pro App und „Aufräumbar“-Werte.
- Fällt eine Messung aus, wird **kein** Wert geschrieben (Lücke statt falscher Null).
## Warnregeln
Wichtige Container („kritisch“): Traefik, Appwrite, Kundenportal, Ticketsystem,
ticket-integrations, Gitea, InvoiceNinja, n8n (Liste in `config/rules.json`).
**🔴 Critical – sofortige Mail**
- Platte `/` ≥ 90 %
- RAM verfügbar < 500 MB **und** Swap ≥ 90 %, 10 min lang
- Load (5 min) > 24, 15 min lang
- kritischer Container läuft nicht oder > 3 Neustarts pro Stunde
- Zombies ≥ 100
**🟠 Medium – Sammelmail täglich 07:00**
- Platte `/` ≥ 80 %
- Swap ≥ 75 % oder RAM verfügbar < 1,5 GB, jeweils 1 h lang
- Zombies ≥ 20
- App wächst in 7 Tagen um > 20 % oder > 2 GB
- Load (5 min) > 8, 1 h lang
- nicht-kritischer Container abgestürzt (exited ≠ 0 oder Restart-Schleife)
**🟡 Low – Wochenübersicht Mo 07:00**
- ungenutzte Docker-Images > 10 GB oder Build-Cache > 3 GB
- App wächst in der Woche um > 10 %
- Zombies (≥ 1) halten sich > 24 h
- immer enthalten: Top 5 größte Apps, Top 5 schnellstes Wachstum, Top 5 RAM
**Allgemein**
- Dieselbe Warnung (gleiche `id`) höchstens 1× pro 24 h erneut gemeldet.
- Beim Verschwinden: Mail „✅ behoben“ (in derselben Taktung wie die Stufe; bei
Critical sofort).
- Alle aktiven Warnungen stehen jederzeit auf der Seite.
- Wird eine Warnung höher eingestuft (Medium → Critical), gilt sofort die höhere Stufe.
## API
Alle Antworten JSON, Zeitstempel ISO/UTC.
| Methode + Pfad | Inhalt |
|---|---|
| `GET /health` | ohne Auth; `{ok, lastSample}` |
| `GET /overview` | aktuelle Serverwerte, 24-h-Minikurven, Trend ggü. gestern, aktive Warnungen, Zeitpunkt der letzten Messung |
| `GET /host?metric=cpu\|ram\|swap\|load\|disk\|zombies&range=24h\|7d\|30d\|90d\|1y` | Zeitreihe; Auflösung abhängig vom Zeitraum |
| `GET /apps` | Tabelle: Name, CPU %, RAM, Speicher, Δ 7 T, Δ 30 T, Speicher-Minikurve (30 T) |
| `GET /apps/:id?range=…` | Zeitreihen Speicher, RAM, CPU für eine App |
| `GET /cleanup` | ungenutzte Images, Build-Cache, gestoppte Container mit Größe |
Nur lesend. Es gibt **keine** Endpunkte zum Löschen, Stoppen oder Ändern.
## WOMS-Seite „Serverpuls“
- Navbar-Eintrag mit Pulssymbol, nur für `isAdmin`; roter/oranger Punkt bei aktiver
Critical/Medium-Warnung (aus `/overview`, beim Laden der App und alle 5 min).
- Route `/serverpuls`; Nicht-Admins werden umgeleitet.
- Aufbau:
1. **Kopf mit Ampel**: 🟢 Alles ok / 🟠 n Warnungen / 🔴 Critical, darunter die
aktiven Warnungen (Stufe, seit wann, Wert). Hinweis „letzte Messung vor X Min.“;
gelb, wenn älter als 15 min.
2. **Kacheln**: CPU, Load, RAM, Swap, Platte, Zombies – aktueller Wert, 24-h-Minikurve,
Trend ggü. gestern.
3. **Großes Diagramm** (uPlot): Zeitraum 24 h / 7 T / 30 T / 90 T / 1 J, Wert
wählbar, Tooltip mit exaktem Wert.
4. **Tabelle „Projekte“**: sortierbar, Suchfeld, Δ-Spalten grün/rot mit Pfeil,
Minikurve; Klick öffnet Detaildiagramm der App.
5. **„Aufräumbar“**: reine Anzeige.
- Aktualisiert sich jede Minute, solange sichtbar. Handy: Kacheln untereinander.
- Neue Abhängigkeit: `uplot`. Lockfile wie bisher in einem Wegwerf-`node:20-alpine`
erzeugen (Deploy-Image).
## Betrieb / Deploy
- Code: `/home/webklar/apps/serverpuls/` (eigenes Git-Repo, nur lokal).
- `docker-compose.yml` (Backup vorher): Dienste `serverpuls` (`node:22-alpine`,
`mem_limit: 128m`, `cpus: 0.5`, `init: true`; Host-`/proc` wird als `/host/proc:ro` gemountet,
kein `pid: host`) und `serverpuls-docker-proxy`
(`tecnativa/docker-socket-proxy`, nur lesend: `CONTAINERS=1`, `IMAGES=1`, `SYSTEM=1`
für `/system/df`, `POST=0`), beide im `webklar-network`, ohne Traefik-Route.
- Mounts serverpuls: `/proc:/host/proc:ro`, `/home/webklar/apps:/host/apps:ro`,
`/var/lib/docker/volumes:/host/volumes:ro`, `serverpuls-data:/data`.
- `.env`: `SERVERPULS_SMTP_USER=server@webklar.com`, `SERVERPULS_SMTP_PASS` – vom
Nutzer über eine Erweiterung von `scripts/mail-zugang.py` gesetzt (Claude sieht das
Passwort nicht).
- WOMS-nginx: `location /api/serverpuls/` → `http://serverpuls:8091` (wie
`/api/integrations/`).
- WOMS-Code auf `feature/serverpuls` (Worktree `dev-serverpuls`, Basis `origin/test`).
Live erst nach Push des Nutzers.
## Fehlerbehandlung
- Collector-Teilfehler (z. B. Proxy nicht erreichbar) → betroffene Werte fehlen, Rest
wird gespeichert; Fehler im Log.
- Größenmessung hat Zeitlimit (30 min); bricht sie ab, bleiben die Vortageswerte stehen
und die Seite zeigt deren Datum.
- SMTP-Fehler → `mail_queue`, erneuter Versuch alle 5 min bis 24 h.
- Appwrite nicht erreichbar → API 502, Seite zeigt Fehlerhinweis.
## Tests
- Unit: Regeln (Schwellen, Dauerbedingungen, Hoch-/Herabstufung, Wiederholungssperre,
behoben), Verdichtung, Wachstumsberechnung, `/proc`-Parser mit Beispieldateien.
- API: gemocktes Appwrite (Admin → 200, ohne Label → 403, ohne JWT → 401).
- UI: bewährtes esbuild-/Playwright-Harness unter RAM-Limit mit API-Fixtures.
- Live-Check: Container-RAM < 128 MB, erste Messwerte, Test-Mail an server@.
## Nicht enthalten
- Keine Aktionen (Löschen, Neustarten) aus der Seite heraus.
- Keine KI-Auswertung.
- Keine automatischen Tickets (Warnungen nur per Mail und auf der Seite).
+8 -1
View File
@@ -14,7 +14,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.20.0"
"react-router-dom": "^6.20.0",
"uplot": "^1.6.32"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
@@ -1845,6 +1846,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+2 -1
View File
@@ -9,7 +9,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.20.0"
"react-router-dom": "^6.20.0",
"uplot": "^1.6.32"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
+2
View File
@@ -11,6 +11,7 @@ import CustomersPage from './pages/CustomersPage'
import CustomerDetailPage from './pages/CustomerDetailPage'
import FinancePage from './pages/FinancePage'
import LegalPage from './pages/LegalPage'
import ServerpulsPage from './pages/ServerpulsPage'
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
@@ -44,6 +45,7 @@ function AppRoutes() {
<Route path="/leads" element={<Navigate to="/tickets" replace />} />
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
+26 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import {
@@ -7,6 +7,7 @@ import {
IconTargetArrow,
IconReportMoney,
IconScale,
IconActivityHeartbeat,
IconFolders,
IconCalendar,
IconSettings,
@@ -16,6 +17,7 @@ import {
IconExternalLink,
} from '@tabler/icons-react'
import { openProjectAdmin } from '../lib/employeeAdminApi'
import { serverpulsApi } from '../lib/serverpulsApi'
export default function Sidebar() {
const { user, logout, isAdmin } = useAuth()
@@ -23,6 +25,21 @@ export default function Sidebar() {
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const [switching, setSwitching] = useState(false)
const [pulseLevel, setPulseLevel] = useState('ok')
// Serverpuls-Punkt: nur Admins, beim Laden und alle 5 Minuten; Fehler bleiben still
useEffect(() => {
if (!isAdmin) { setPulseLevel('ok'); return undefined }
let dead = false
const load = () => {
serverpulsApi.overview()
.then((o) => { if (!dead) setPulseLevel(o?.level || 'ok') })
.catch(() => { if (!dead) setPulseLevel('ok') })
}
load()
const iv = setInterval(load, 5 * 60 * 1000)
return () => { dead = true; clearInterval(iv) }
}, [isAdmin])
const handleProjectAdmin = async () => {
setSwitching(true)
@@ -44,6 +61,7 @@ export default function Sidebar() {
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow />, paused: true },
{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> },
{ label: 'Rechtliches', href: '/rechtliches', icon: <IconScale /> },
...(isAdmin ? [{ label: 'Serverpuls', href: '/serverpuls', icon: <IconActivityHeartbeat />, dot: pulseLevel }] : []),
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
]
@@ -81,6 +99,13 @@ export default function Sidebar() {
>
{link.icon}
<span className="side-link-label">{link.label}</span>
{link.dot && link.dot !== 'ok' && (
<span
className={`side-dot side-dot-${link.dot}`}
data-testid="sp-nav-dot"
title={link.dot === 'critical' ? 'Critical-Warnung aktiv' : 'Warnung aktiv'}
/>
)}
</Link>
))}
<button
+90
View File
@@ -0,0 +1,90 @@
import { useMemo, useState } from 'react'
import { formatBytes, formatDelta, formatPct } from '../../lib/serverpulsFormat'
import Sparkline from './Sparkline'
const COLS = [
{ key: 'name', label: 'Projekt', get: (r) => r.name.toLowerCase() },
{ key: 'sizeBytes', label: 'Größe', get: (r) => r.sizeBytes },
{ key: 'delta7d', label: 'Δ 7 T', get: (r) => r.delta7d },
{ key: 'delta30d', label: 'Δ 30 T', get: (r) => r.delta30d },
{ key: 'cpuPct', label: 'CPU', get: (r) => r.cpuPct },
{ key: 'memBytes', label: 'RAM', get: (r) => r.memBytes },
]
function Delta({ pct }) {
const d = formatDelta(pct)
const arrow = d.dir === 'up' ? '▲ ' : d.dir === 'down' ? '▼ ' : ''
return <span className={`sp-delta sp-delta-${d.dir}`}>{arrow}{d.text}</span>
}
export default function AppsTable({ rows, selectedId, onSelect }) {
const [q, setQ] = useState('')
const [sort, setSort] = useState({ key: 'sizeBytes', dir: 'desc' })
const list = useMemo(() => {
const col = COLS.find((c) => c.key === sort.key)
const f = q.trim().toLowerCase()
const out = rows.filter((r) => !f || r.name.toLowerCase().includes(f) || r.id.includes(f))
const mul = sort.dir === 'asc' ? 1 : -1
out.sort((a, b) => {
const va = col.get(a)
const vb = col.get(b)
if (va === null || va === undefined) return vb === null || vb === undefined ? 0 : 1
if (vb === null || vb === undefined) return -1
return va < vb ? -mul : va > vb ? mul : 0
})
return out
}, [rows, q, sort])
const toggle = (key) =>
setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'name' ? 'asc' : 'desc' }))
return (
<div className="sp-apps" data-testid="sp-apps">
<input
className="sp-search"
data-testid="sp-apps-search"
type="search"
placeholder="Projekt suchen …"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="sp-table-wrap">
<table className="sp-table">
<thead>
<tr>
{COLS.map((c) => (
<th key={c.key} onClick={() => toggle(c.key)} className="sp-sortable" data-sort={c.key}>
{c.label}{sort.key === c.key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''}
</th>
))}
<th>30 Tage</th>
</tr>
</thead>
<tbody>
{list.map((r) => (
<tr
key={r.id}
data-testid="sp-app-row"
data-app-id={r.id}
className={`sp-row ${selectedId === r.id ? 'sp-row-active' : ''}`}
onClick={() => onSelect(selectedId === r.id ? null : r.id)}
>
<td className="sp-name">{r.name}</td>
<td>{formatBytes(r.sizeBytes)}</td>
<td><Delta pct={r.delta7d} /></td>
<td><Delta pct={r.delta30d} /></td>
<td>{r.cpuPct === null ? '–' : formatPct(r.cpuPct)}</td>
<td>{formatBytes(r.memBytes)}</td>
<td><Sparkline points={r.spark30d} width={90} height={24} /></td>
</tr>
))}
{list.length === 0 && (
<tr><td colSpan={7} className="sp-empty">Keine Projekte gefunden.</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef } from 'react'
import uPlot from 'uplot'
import { formatAxisTick, formatLegendTime } from '../../lib/serverpulsFormat'
import 'uplot/dist/uPlot.min.css'
/**
* Duenner uPlot-Wrapper.
* data: [xs (Sekunden), ys...]; series: [{label, color, fmt}] fuer die y-Reihen.
*/
export default function Chart({ data, series, height = 280, yFmt, yRange }) {
const host = useRef(null)
const plot = useRef(null)
const cfg = useRef({ series, yFmt, yRange })
cfg.current = { series, yFmt, yRange }
useEffect(() => {
const el = host.current
const { series: s, yFmt: fy, yRange: yr } = cfg.current
const fmtVal = (v) => (v === null || v === undefined ? '–' : (fy ? fy(v) : String(v)))
const opts = {
width: Math.max(el.clientWidth, 200),
height,
cursor: { drag: { x: false, y: false } },
legend: { live: true },
scales: { y: { range: yr || ((u, min, max) => [Math.min(0, min), max === min ? min + 1 : max + (max - min) * 0.08]) } },
axes: [
{
stroke: '#9aa7b4',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.1)' },
values: (u, splits, axisIdx, foundSpace, foundIncr) => splits.map((t) => formatAxisTick(t, foundIncr)),
},
{
stroke: '#9aa7b4',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.1)' },
size: 70,
values: (u, vals) => vals.map(fmtVal),
},
],
series: [
{ label: 'Zeit', value: (u, v) => formatLegendTime(v) },
...s.map((x) => ({
label: x.label,
stroke: x.color || '#10b981',
width: 2,
spanGaps: false,
points: { show: false },
fill: x.fill,
dash: x.dash,
value: (u, v) => fmtVal(v),
})),
],
}
plot.current = new uPlot(opts, data, el)
const ro = new ResizeObserver(() => {
if (plot.current) plot.current.setSize({ width: Math.max(el.clientWidth, 200), height })
})
ro.observe(el)
return () => {
ro.disconnect()
plot.current?.destroy()
plot.current = null
}
// Neu aufbauen nur wenn sich Reihenanzahl/Beschriftung aendert
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [series.map((x) => x.label).join('|'), height])
useEffect(() => {
plot.current?.setData(data)
}, [data])
return <div className="sp-chart" data-testid="sp-chart" ref={host} />
}
+31
View File
@@ -0,0 +1,31 @@
// Winzige Inline-SVG-Minikurve. points: Zahlen oder {v}/{bytes}; null-Werte erzeugen Luecken.
export default function Sparkline({ points = [], width = 120, height = 32, color = 'var(--accent)' }) {
const vals = points.map((p) =>
p === null || p === undefined ? null : typeof p === 'number' ? p : (p.v ?? p.bytes ?? null)
)
const nums = vals.filter((v) => v !== null)
if (nums.length < 2) {
return <svg className="sp-spark" width={width} height={height} aria-hidden="true" />
}
const min = Math.min(...nums)
const max = Math.max(...nums)
const span = max - min || 1
const step = width / (vals.length - 1)
let d = ''
let pen = false
vals.forEach((v, i) => {
if (v === null) {
pen = false
return
}
const x = (i * step).toFixed(1)
const y = (height - 2 - ((v - min) / span) * (height - 4)).toFixed(1)
d += `${pen ? 'L' : 'M'}${x} ${y} `
pen = true
})
return (
<svg className="sp-spark" width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
<path d={d} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { account } from './appwrite'
import { createJwtCache, fetchWithRetry } from './serverpulsAuth'
/**
* Client fuer das Serverpuls-Backend (nur lesend).
* Same-Origin unter /api/serverpuls, authentifiziert per Appwrite-JWT.
*/
const BASE = '/api/serverpuls'
// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer)
const jwtCache = createJwtCache({ createJWT: () => account.createJWT() })
async function get(path) {
const res = await fetchWithRetry(jwtCache, (jwt) =>
fetch(`${BASE}${path}`, { headers: { 'X-Appwrite-JWT': jwt } })
)
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Serverpuls-Fehler ${res.status}`)
return data
}
export const serverpulsApi = {
overview: () => get('/overview'),
host: (metric, range = '24h') =>
get(`/host?metric=${encodeURIComponent(metric)}&range=${encodeURIComponent(range)}`),
apps: () => get('/apps'),
app: (id, range = '30d') =>
get(`/apps/${encodeURIComponent(id)}?range=${encodeURIComponent(range)}`),
cleanup: () => get('/cleanup'),
}
export const { overview, host, apps, app, cleanup } = serverpulsApi
+37
View File
@@ -0,0 +1,37 @@
// JWT-Cache fuer Serverpuls (rein, ohne Appwrite-Import; createJWT und Uhr werden injiziert).
export const JWT_TTL_MS = 10 * 60 * 1000
export function createJwtCache({ createJWT, now = Date.now, ttlMs = JWT_TTL_MS }) {
let entry = null // { jwt, at } oder { promise }
let pending = null
async function get() {
if (entry && now() - entry.at < ttlMs) return entry.jwt
if (pending) return pending
const p = (async () => {
try {
const { jwt } = await createJWT()
entry = { jwt, at: now() }
return jwt
} finally {
if (pending === p) pending = null
}
})()
pending = p
return p
}
function invalidate() {
entry = null
}
return { get, invalidate }
}
// doFetch(jwt) -> Response-artig ({status}); bei 401 einmal mit frischem JWT wiederholen.
export async function fetchWithRetry(cache, doFetch) {
const first = await doFetch(await cache.get())
if (first.status !== 401) return first
cache.invalidate()
return doFetch(await cache.get())
}
+97
View File
@@ -0,0 +1,97 @@
// Reine Format-Helfer fuer die Serverpuls-Seite (kein React, unter Node testbar).
const nf = (digits) =>
new Intl.NumberFormat('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: digits })
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
export function formatBytes(n) {
if (n === null || n === undefined || Number.isNaN(Number(n))) return '–'
let v = Math.abs(Number(n))
if (v < 1) return '0 B'
let i = 0
while (v >= 1024 && i < UNITS.length - 1) {
v /= 1024
i += 1
}
const sign = n < 0 ? '−' : ''
return `${sign}${nf(i === 0 ? 0 : 1).format(v)} ${UNITS[i]}`
}
export function formatDelta(pct) {
if (pct === null || pct === undefined || Number.isNaN(Number(pct))) {
return { text: '–', dir: 'flat' }
}
const rounded = Math.round(Number(pct) * 10) / 10
if (rounded === 0) return { text: '0 %', dir: 'flat' }
const sign = rounded > 0 ? '+' : '−'
return { text: `${sign}${nf(1).format(Math.abs(rounded))} %`, dir: rounded > 0 ? 'up' : 'down' }
}
export const STALE_MS = 15 * 60 * 1000
export function ageLabel(ts, now = Date.now()) {
if (ts === null || ts === undefined) return { text: 'keine Messung', stale: true }
const diff = Math.max(0, now - ts)
const min = Math.floor(diff / 60000)
let text
if (min < 1) text = 'gerade eben'
else if (min < 60) text = `vor ${min} Min.`
else if (min < 1440) text = `vor ${Math.floor(min / 60)} Std.`
else text = `vor ${Math.floor(min / 1440)} T.`
return { text, stale: diff > STALE_MS }
}
// RAM: value ist FREIER Speicher, belegt = 1 - frei/gesamt (in Prozent).
export function usedPct(free, total) {
if (free === null || free === undefined || !total) return null
return Math.round((1 - free / total) * 1000) / 10
}
export function formatPct(v, digits = 1) {
if (v === null || v === undefined) return '–'
return `${nf(digits).format(v)} %`
}
export function formatNumber(v, digits = 2) {
if (v === null || v === undefined) return '–'
return nf(digits).format(v)
}
// --- Zeitachsen (Europe/Berlin, de-DE) ---
const TZ = 'Europe/Berlin'
const dtf = (opts) => new Intl.DateTimeFormat('de-DE', { timeZone: TZ, ...opts })
const F = {
time: dtf({ hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }),
day: dtf({ day: '2-digit', month: '2-digit' }),
month: dtf({ month: 'short', year: 'numeric' }),
year: dtf({ year: 'numeric' }),
legend: dtf({ day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }),
}
// Welches Format passt zu einem Tick-Abstand (Sekunden)?
export function axisTimeKind(incrSec) {
if (incrSec < 86400) return 'time'
if (incrSec < 86400 * 28) return 'day'
if (incrSec < 86400 * 360) return 'month'
return 'year'
}
// Tick (Sekunden) -> Text. Bei Tagesgrenzen innerhalb von Sub-Tages-Achsen das Datum zeigen.
export function formatAxisTick(sec, incrSec) {
const kind = axisTimeKind(incrSec)
const d = new Date(sec * 1000)
if (kind === 'time' && F.time.format(d) === '00:00') return F.day.format(d)
return F[kind].format(d)
}
// Legende: "30.09.2026, 11:20"
export function formatLegendTime(sec) {
if (sec === null || sec === undefined) return '–'
return F.legend.format(new Date(sec * 1000))
}
// "seit"-Zeitpunkt in Warnungen, immer Berliner Zeit.
export function formatSince(ts) {
if (!ts) return ''
return new Date(ts).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short', timeZone: 'Europe/Berlin' })
}
+332
View File
@@ -0,0 +1,332 @@
import { useCallback, useEffect, useRef, useMemo, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { PageHeader } from '../components/ui'
import { serverpulsApi } from '../lib/serverpulsApi'
import {
ageLabel, formatBytes, formatNumber, formatPct, formatSince, usedPct,
} from '../lib/serverpulsFormat'
import Chart from '../components/serverpuls/Chart'
import Sparkline from '../components/serverpuls/Sparkline'
import AppsTable from '../components/serverpuls/AppsTable'
import '../styles/serverpuls.css'
const RANGES = [
{ id: '24h', label: '24 h' },
{ id: '7d', label: '7 T' },
{ id: '30d', label: '30 T' },
{ id: '90d', label: '90 T' },
{ id: '1y', label: '1 J' },
]
const METRICS = [
{ id: 'cpu', label: 'CPU', bytes: false, fmt: (v) => formatPct(v) },
{ id: 'load', label: 'Load', bytes: false, fmt: (v) => formatNumber(v, 2) },
{ id: 'ram', label: 'RAM', bytes: true, fmt: formatBytes },
{ id: 'swap', label: 'Swap', bytes: true, fmt: formatBytes },
{ id: 'disk', label: 'Platte', bytes: true, fmt: formatBytes },
{ id: 'zombies', label: 'Zombies', bytes: false, fmt: (v) => formatNumber(v, 0) },
]
const LEVEL_TEXT = { ok: 'Alles ok', medium: 'Warnungen', critical: 'Critical' }
const LEVEL_LABEL = { critical: 'Critical', medium: 'Medium', low: 'Low' }
// Verlaufswerte in Anzeigewerte umrechnen (RAM: frei -> belegt).
function displayPoints(metric, points, total) {
if (metric !== 'ram') return points
const tmap = new Map((total || []).map((t) => [t.ts, t.v]))
return points.map((p) => {
const t = tmap.get(p.ts)
if (p.v === null || t === undefined || t === null) return { ts: p.ts, v: null, max: null }
return { ts: p.ts, v: t - p.v, max: p.max === null || p.max === undefined ? null : t - p.max }
})
}
function tileModel(key, m) {
if (!m) return null
const total = m.total ?? null
const spark = (m.spark24h || []).map((p) => (key === 'ram' && total && p.v !== null && p.v !== undefined ? total - p.v : p.v))
if (m.value === null || m.value === undefined) return { main: '–', sub: '', spark, delta: null }
switch (key) {
case 'cpu':
return { main: formatPct(m.value), sub: '', spark, delta: m.deltaYesterday, fmtDelta: (d) => `${formatNumber(Math.abs(d), 1)} %` }
case 'load':
return { main: formatNumber(m.value, 2), sub: 'Load (5 Min.)', spark, delta: m.deltaYesterday, fmtDelta: (d) => formatNumber(Math.abs(d), 2) }
case 'ram': {
const used = total ? total - m.value : null
return {
main: used === null ? '–' : formatPct(usedPct(m.value, total)),
sub: used === null ? '' : `${formatBytes(used)} von ${formatBytes(total)}`,
spark,
delta: m.deltaYesterday === null || m.deltaYesterday === undefined ? null : -m.deltaYesterday,
fmtDelta: (d) => formatBytes(Math.abs(d)),
}
}
case 'swap':
case 'disk':
return {
main: total ? formatPct((m.value / total) * 100) : formatBytes(m.value),
sub: total ? `${formatBytes(m.value)} von ${formatBytes(total)}` : '',
spark,
delta: m.deltaYesterday,
fmtDelta: (d) => formatBytes(Math.abs(d)),
}
default:
return { main: formatNumber(m.value, 0), sub: '', spark, delta: m.deltaYesterday, fmtDelta: (d) => formatNumber(Math.abs(d), 0) }
}
}
function Tile({ id, label, model }) {
let deltaEl = null
if (model && model.delta !== null && model.delta !== undefined) {
const d = model.delta
const dir = Math.abs(d) < 1e-9 ? 'flat' : d > 0 ? 'up' : 'down'
const sign = dir === 'up' ? '▲ +' : dir === 'down' ? '▼ −' : ''
deltaEl = (
<div className={`sp-tile-delta sp-delta-${dir}`}>
{sign}{model.fmtDelta(d)} ggü. gestern
</div>
)
}
return (
<div className="sp-tile" data-testid={`sp-tile-${id}`}>
<div className="sp-tile-label">{label}</div>
<div className="sp-tile-value">{model ? model.main : '–'}</div>
<div className="sp-tile-sub">{model?.sub || ' '}</div>
<Sparkline points={model?.spark || []} width={200} height={36} />
{deltaEl}
</div>
)
}
function Pills({ items, value, onChange, testid }) {
return (
<div className="sp-pills" data-testid={testid}>
{items.map((it) => (
<button
key={it.id}
type="button"
className={`sp-pill ${value === it.id ? 'active' : ''}`}
onClick={() => onChange(it.id)}
>
{it.label}
</button>
))}
</div>
)
}
function Cleanup({ data }) {
if (!data || !data.day) return <div className="sp-muted">Noch keine Messung.</div>
const stopped = Array.isArray(data.stopped) ? data.stopped : []
return (
<div className="sp-cleanup-body">
<div className="sp-cleanup-grid">
<div><span className="sp-muted">Ungenutzte Images</span><strong>{formatBytes(data.imagesBytes)}</strong></div>
<div><span className="sp-muted">Build-Cache</span><strong>{formatBytes(data.buildCacheBytes)}</strong></div>
<div><span className="sp-muted">Gestoppte Container</span><strong>{formatBytes(data.stoppedBytes)}</strong></div>
</div>
{stopped.length > 0 && (
<ul className="sp-cleanup-list">
{stopped.map((s) => (
<li key={s.name}><span>{s.name}</span><span>{formatBytes(s.bytes)}</span></li>
))}
</ul>
)}
<div className="sp-muted sp-small">Stand {data.day} · nur Anzeige, hier wird nichts gelöscht.</div>
</div>
)
}
export default function ServerpulsPage() {
const { isAdmin, loading } = useAuth()
const [overview, setOverview] = useState(null)
const [appsData, setAppsData] = useState(null)
const [cleanup, setCleanup] = useState(null)
const [chart, setChart] = useState(null)
const [detail, setDetail] = useState(null)
const [metric, setMetric] = useState('cpu')
const [range, setRange] = useState('24h')
const [selected, setSelected] = useState(null)
const [detailRange, setDetailRange] = useState('30d')
const [error, setError] = useState('')
const [now, setNow] = useState(Date.now())
const [tick, setTick] = useState(0)
// Uebersicht, Projekte, Aufraeumbar: beim Laden und jede Minute (nur sichtbar)
const reqId = useRef(0)
const loadMain = useCallback(async () => {
reqId.current += 1
const mine = reqId.current
try {
const [o, a, c] = await Promise.all([
serverpulsApi.overview(), serverpulsApi.apps(), serverpulsApi.cleanup(),
])
if (mine !== reqId.current) return
setOverview(o)
setAppsData(a)
setCleanup(c)
setError('')
} catch (e) {
if (mine !== reqId.current) return
setError(e.message || 'Serverpuls ist nicht erreichbar.')
}
setNow(Date.now())
setTick((t) => t + 1)
}, [])
useEffect(() => {
if (!isAdmin) return undefined
loadMain()
const iv = setInterval(() => {
if (document.visibilityState === 'visible') loadMain()
}, 60000)
const onVis = () => { if (document.visibilityState === 'visible') loadMain() }
document.addEventListener('visibilitychange', onVis)
return () => { clearInterval(iv); document.removeEventListener('visibilitychange', onVis) }
}, [isAdmin, loadMain])
// grosses Diagramm
useEffect(() => {
if (!isAdmin) return undefined
let dead = false
serverpulsApi.host(metric, range)
.then((d) => { if (!dead) { setChart({ ...d, metric, range }); } })
.catch((e) => { if (!dead) { setChart(null); setError(e.message || 'Diagramm konnte nicht geladen werden.') } })
return () => { dead = true }
}, [isAdmin, metric, range, tick])
// Detaildiagramm der App
useEffect(() => {
if (!isAdmin || !selected) { setDetail(null); return undefined }
let dead = false
serverpulsApi.app(selected, detailRange)
.then((d) => { if (!dead) setDetail(d) })
.catch((e) => { if (!dead) { setDetail(null); setError(e.message || 'Projektdaten konnten nicht geladen werden.') } })
return () => { dead = true }
}, [isAdmin, selected, detailRange, tick])
const changeMetric = (v) => { if (v !== metric) { setChart(null); setMetric(v) } }
const changeRange = (v) => { if (v !== range) { setChart(null); setRange(v) } }
const selectApp = (id) => { setDetail(null); setSelected(id) }
const changeDetailRange = (v) => { if (v !== detailRange) { setDetail(null); setDetailRange(v) } }
const m = METRICS.find((x) => x.id === metric)
const chartData = useMemo(() => {
if (!chart) return null
const pts = displayPoints(chart.metric, chart.points || [], chart.total)
const xs = pts.map((p) => Math.round(p.ts / 1000))
const avg = pts.map((p) => p.v)
const withMax = chart.res !== 'raw' && ['cpu', 'load', 'zombies'].includes(chart.metric)
const data = [xs, avg]
const series = [{ label: m.label, color: '#10b981', fill: 'rgba(16,185,129,0.10)' }]
if (withMax) {
data.push(pts.map((p) => p.max ?? null))
series.push({ label: `${m.label} (Spitze)`, color: '#f59e0b', dash: [4, 4] })
}
return { data, series }
}, [chart, m.label])
const detailData = useMemo(() => {
if (!detail) return null
const days = (detail.size || []).map((s) => Math.round(new Date(`${s.day}T12:00:00Z`).getTime() / 1000))
return {
size: { data: [days, (detail.size || []).map((s) => s.bytes)], series: [{ label: 'Größe', color: '#3b82f6', fill: 'rgba(59,130,246,0.10)' }] },
mem: { data: [(detail.mem || []).map((p) => Math.round(p.ts / 1000)), (detail.mem || []).map((p) => p.v)], series: [{ label: 'RAM', color: '#10b981' }] },
cpu: { data: [(detail.cpu || []).map((p) => Math.round(p.ts / 1000)), (detail.cpu || []).map((p) => p.v)], series: [{ label: 'CPU', color: '#f59e0b' }] },
}
}, [detail])
if (loading) return null
if (!isAdmin) return <Navigate to="/tickets" replace />
const level = overview?.level || 'ok'
const alerts = overview?.alerts || []
const age = ageLabel(overview?.lastSample ?? null, now)
const dot = level === 'critical' ? '🔴' : level === 'medium' ? '🟠' : '🟢'
const headline = level === 'ok'
? 'Alles ok'
: level === 'critical'
? `Critical – ${alerts.length} ${alerts.length === 1 ? 'Warnung' : 'Warnungen'}`
: `${alerts.length} ${alerts.length === 1 ? 'Warnung' : 'Warnungen'}`
return (
<div className="page sp-page" data-testid="sp-page">
<PageHeader title="Serverpuls" subtitle="Zustand und Verlauf des Servers – nur Anzeige" />
{error && <div className="sp-error" data-testid="sp-error" role="alert">{error}</div>}
<section className={`sp-head sp-level-${level}`} data-testid="sp-head">
<div className="sp-head-top">
<span className="sp-ampel" data-testid="sp-ampel" data-level={level}>{dot}</span>
<strong className="sp-headline">{overview ? headline : (error ? 'Keine Daten' : 'Lädt …')}</strong>
{overview && (
<span className={`sp-age ${age.stale ? 'sp-age-stale' : ''}`} data-testid="sp-age" data-stale={age.stale ? '1' : '0'}>
Letzte Messung {age.text}{age.stale ? ' – Daten veraltet' : ''}
</span>
)}
</div>
{alerts.length > 0 && (
<ul className="sp-alerts" data-testid="sp-alerts">
{alerts.map((a) => (
<li key={a.id} className={`sp-alert sp-alert-${a.level}`} data-testid="sp-alert">
<span className="sp-alert-level">{LEVEL_LABEL[a.level] || a.level}</span>
<span className="sp-alert-title">{a.title}</span>
<span className="sp-alert-value">{a.value}</span>
<span className="sp-muted sp-small">seit {formatSince(a.since)}</span>
</li>
))}
</ul>
)}
</section>
<section className="sp-tiles" data-testid="sp-tiles">
{METRICS.map((x) => (
<Tile key={x.id} id={x.id} label={x.label} model={tileModel(x.id, overview?.host?.[x.id])} />
))}
</section>
<section className="sp-panel" data-testid="sp-chart-panel">
<div className="sp-panel-head">
<h3>Verlauf</h3>
<div className="sp-controls">
<Pills items={METRICS} value={metric} onChange={changeMetric} testid="sp-metric-select" />
<Pills items={RANGES} value={range} onChange={changeRange} testid="sp-range-select" />
</div>
</div>
{chartData && chartData.data[0].length > 0
? <Chart data={chartData.data} series={chartData.series} yFmt={m.fmt} />
: <div className="sp-muted sp-nodata">{chart ? 'Für diesen Zeitraum liegen keine Messwerte vor.' : 'Lädt …'}</div>}
</section>
<section className="sp-panel" data-testid="sp-projects">
<div className="sp-panel-head">
<h3>Projekte</h3>
{appsData?.sizesDay && <span className="sp-muted sp-small">Größen von {appsData.sizesDay}</span>}
</div>
<AppsTable rows={appsData?.rows || []} selectedId={selected} onSelect={selectApp} />
{selected && (
<div className="sp-detail" data-testid="sp-detail">
<div className="sp-panel-head">
<h3>{detail?.name || selected}</h3>
<Pills items={RANGES} value={detailRange} onChange={changeDetailRange} testid="sp-detail-range" />
</div>
{detailData ? (
<div className="sp-detail-charts">
<div><div className="sp-chart-title">Größe</div><Chart data={detailData.size.data} series={detailData.size.series} yFmt={formatBytes} height={200} /></div>
<div><div className="sp-chart-title">RAM</div><Chart data={detailData.mem.data} series={detailData.mem.series} yFmt={formatBytes} height={200} /></div>
<div><div className="sp-chart-title">CPU</div><Chart data={detailData.cpu.data} series={detailData.cpu.series} yFmt={(v) => formatPct(v)} height={200} /></div>
</div>
) : <div className="sp-muted sp-nodata">Lädt …</div>}
</div>
)}
</section>
<section className="sp-panel" data-testid="sp-cleanup">
<div className="sp-panel-head"><h3>Aufräumbar</h3></div>
<Cleanup data={cleanup} />
</section>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
/* Serverpuls (Admin) */
.sp-page { display: flex; flex-direction: column; gap: var(--sp-4); }
.sp-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.4); color: #fca5a5; padding: var(--sp-3) var(--sp-4); border-radius: var(--r-md); }
.sp-muted { color: var(--text-muted); }
.sp-small { font-size: 12px; }
.sp-head { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); }
.sp-level-medium { border-color: rgba(245,158,11,.5); }
.sp-level-critical { border-color: rgba(239,68,68,.6); }
.sp-head-top { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
.sp-headline { font-size: 18px; }
.sp-age { margin-left: auto; font-size: 13px; color: var(--text-muted); }
.sp-age-stale { color: #fbbf24; background: rgba(245,158,11,.14); padding: 2px 10px; border-radius: var(--r-pill); }
.sp-alerts { list-style: none; margin: var(--sp-3) 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.sp-alert { display: flex; flex-wrap: wrap; gap: var(--sp-3); align-items: baseline; padding: 8px 12px; border-radius: var(--r-sm); background: var(--surface-2); border-left: 3px solid var(--text-faint); }
.sp-alert-critical { border-left-color: var(--danger); }
.sp-alert-medium { border-left-color: var(--warn); }
.sp-alert-level { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); min-width: 56px; }
.sp-alert-title { font-weight: 600; }
.sp-alert-value { color: var(--text-muted); }
.sp-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--sp-3); }
@media (max-width: 1100px) { .sp-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 700px) { .sp-tiles { grid-template-columns: 1fr; } .sp-age { margin-left: 0; } }
.sp-tile { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4); display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sp-tile-label { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); }
.sp-tile-value { font-size: 26px; font-weight: 800; }
.sp-tile-sub { font-size: 12px; color: var(--text-muted); min-height: 16px; }
.sp-tile-delta { font-size: 12px; margin-top: 2px; }
.sp-spark { display: block; max-width: 100%; margin-top: 6px; }
.sp-delta-up { color: var(--danger); }
.sp-delta-down { color: var(--ok); }
.sp-delta-flat { color: var(--text-muted); }
.sp-panel { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); min-width: 0; }
.sp-panel-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-3); }
.sp-panel-head h3 { margin: 0; font-size: 16px; }
.sp-controls { display: flex; gap: var(--sp-3); flex-wrap: wrap; }
.sp-pills { display: flex; gap: 4px; flex-wrap: wrap; }
.sp-pill { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); border-radius: var(--r-pill); padding: 4px 12px; font: inherit; font-size: 13px; cursor: pointer; }
.sp-pill:hover { color: var(--text); }
.sp-pill.active { background: var(--accent-soft); border-color: var(--border-accent); color: var(--accent); font-weight: 600; }
.sp-chart { width: 100%; min-height: 200px; }
.sp-chart .u-legend { color: var(--text-muted); font-size: 12px; }
.sp-nodata { padding: var(--sp-6, 32px) 0; text-align: center; }
.sp-search { width: 100%; max-width: 320px; margin-bottom: var(--sp-3); background: var(--surface-2); border: 1px solid var(--border); color: var(--text); border-radius: var(--r-sm); padding: 8px 12px; font: inherit; }
.sp-table-wrap { overflow-x: auto; }
.sp-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.sp-table th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.sp-sortable { cursor: pointer; user-select: none; }
.sp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.sp-row { cursor: pointer; }
.sp-row:hover, .sp-row-active { background: var(--surface-2); }
.sp-name { font-weight: 600; }
.sp-empty { text-align: center; color: var(--text-muted); }
.sp-delta { font-variant-numeric: tabular-nums; }
.sp-detail { margin-top: var(--sp-4); padding-top: var(--sp-4); border-top: 1px solid var(--border); }
.sp-detail-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); gap: var(--sp-4); }
.sp-chart-title { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
.sp-cleanup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--sp-3); }
.sp-cleanup-grid > div { display: flex; flex-direction: column; gap: 2px; }
.sp-cleanup-grid strong { font-size: 20px; }
.sp-cleanup-list { list-style: none; margin: var(--sp-3) 0; padding: 0; }
.sp-cleanup-list li { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
.side-dot { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; flex-shrink: 0; }
.side-dot-medium { background: #f59e0b; }
.side-dot-critical { background: #ef4444; }
.side.collapsed .side-dot { position: absolute; margin: 0; transform: translate(12px, -10px); }
.side-link { position: relative; }
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { createJwtCache, fetchWithRetry } = await loadModule('src/lib/serverpulsAuth.js')
function setup() {
let n = 0
let t = 0
const calls = { create: 0 }
const createJWT = async () => { calls.create += 1; n += 1; await Promise.resolve(); return { jwt: `jwt${n}` } }
const cache = createJwtCache({ createJWT, now: () => t })
return { cache, calls, advance: (ms) => { t += ms } }
}
test('3 parallele Aufrufe -> 1 createJWT', async () => {
const { cache, calls } = setup()
const r = await Promise.all([cache.get(), cache.get(), cache.get()])
assert.deepEqual(r, ['jwt1', 'jwt1', 'jwt1'])
assert.equal(calls.create, 1)
})
test('nach 10 Min. neues JWT, davor gecacht', async () => {
const { cache, calls, advance } = setup()
await cache.get()
advance(9 * 60000)
assert.equal(await cache.get(), 'jwt1')
advance(61000)
assert.equal(await cache.get(), 'jwt2')
assert.equal(calls.create, 2)
})
test('createJWT-Fehler wird nicht gecacht', async () => {
let fail = true
const cache = createJwtCache({ createJWT: async () => { if (fail) throw new Error('x'); return { jwt: 'ok' } }, now: () => 0 })
await assert.rejects(cache.get())
fail = false
assert.equal(await cache.get(), 'ok')
})
test('401 -> genau ein Retry mit frischem JWT', async () => {
const { cache, calls } = setup()
const used = []
const res = await fetchWithRetry(cache, async (jwt) => { used.push(jwt); return { status: used.length === 1 ? 401 : 200 } })
assert.equal(res.status, 200)
assert.deepEqual(used, ['jwt1', 'jwt2'])
assert.equal(calls.create, 2)
})
test('zweites 401 wird durchgereicht, kein dritter Versuch', async () => {
const { cache } = setup()
let n = 0
const res = await fetchWithRetry(cache, async () => { n += 1; return { status: 401 } })
assert.equal(res.status, 401)
assert.equal(n, 2)
})
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { formatBytes, formatDelta, ageLabel, usedPct, axisTimeKind, formatAxisTick, formatLegendTime } = await loadModule('src/lib/serverpulsFormat.js')
test('formatBytes: de-DE, Basis 1024', () => {
assert.equal(formatBytes(1288490189), '1,2 GB')
assert.equal(formatBytes(0), '0 B')
assert.equal(formatBytes(512), '512 B')
assert.equal(formatBytes(1536), '1,5 KB')
assert.equal(formatBytes(null), '–')
})
test('formatDelta', () => {
assert.equal(formatDelta(null).text, '–')
assert.equal(formatDelta(null).dir, 'flat')
assert.deepEqual(formatDelta(-3.21), { text: '−3,2 %', dir: 'down' })
assert.deepEqual(formatDelta(12.34), { text: '+12,3 %', dir: 'up' })
assert.equal(formatDelta(0).dir, 'flat')
})
test('ageLabel: stale ab > 15 Min.', () => {
const now = 1_000_000_000_000
const a16 = ageLabel(now - 16 * 60000, now)
assert.equal(a16.stale, true)
assert.equal(a16.text, 'vor 16 Min.')
assert.equal(ageLabel(now - 14 * 60000, now).stale, false)
assert.equal(ageLabel(now - 20000, now).text, 'gerade eben')
assert.equal(ageLabel(null, now).stale, true)
})
test('usedPct: RAM value ist frei', () => {
assert.equal(usedPct(250, 1000), 75)
assert.equal(usedPct(null, 1000), null)
})
test('Zeitachse: Format nach Tick-Abstand', () => {
assert.equal(axisTimeKind(3600), 'time')
assert.equal(axisTimeKind(86400), 'day')
assert.equal(axisTimeKind(86400 * 30), 'month')
assert.equal(axisTimeKind(86400 * 365), 'year')
})
test('Zeitachse: deutsche Ausgabe in Europe/Berlin', () => {
const t = Date.UTC(2026, 8, 30, 9, 20) / 1000 // 11:20 Berlin (MESZ)
assert.equal(formatAxisTick(t, 3600), '11:20')
assert.equal(formatAxisTick(t, 86400 * 2), '30.09.')
assert.equal(formatAxisTick(Date.UTC(2026, 8, 29, 22, 0) / 1000, 3600 * 6), '30.09.') // Mitternacht Berlin
assert.match(formatAxisTick(t, 86400 * 30), /2026/)
assert.equal(formatAxisTick(t, 86400 * 365), '2026')
assert.equal(formatLegendTime(t), '30.09.2026, 11:20')
assert.equal(formatLegendTime(null), '–')
})
test('formatSince: Europe/Berlin, unabhängig von der Prozess-Zeitzone', async () => {
const { formatSince } = await loadModule('src/lib/serverpulsFormat.js')
assert.equal(formatSince(Date.UTC(2026, 6, 1, 10, 0, 0)), '01.07.26, 12:00')
assert.equal(formatSince(Date.UTC(2026, 0, 1, 10, 0, 0)), '01.01.26, 11:00')
assert.equal(formatSince(0), '')
})