diff --git a/package-lock.json b/package-lock.json index 876b0e7..840d1e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index df1678e..6a36917 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.jsx b/src/App.jsx index c03e852..01ccaf7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 2f2c1eb..3824ea6 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -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: , paused: true }, { label: 'Finanzen', href: '/finance', icon: }, { label: 'Rechtliches', href: '/rechtliches', icon: }, + ...(isAdmin ? [{ label: 'Serverpuls', href: '/serverpuls', icon: , dot: pulseLevel }] : []), ...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: }] : []), ] @@ -81,6 +99,13 @@ export default function Sidebar() { > {link.icon} {link.label} + {link.dot && link.dot !== 'ok' && ( + + )} ))} + ))} + + ) +} + +function Cleanup({ data }) { + if (!data || !data.day) return
Noch keine Messung.
+ const stopped = Array.isArray(data.stopped) ? data.stopped : [] + return ( +
+
+
Ungenutzte Images{formatBytes(data.imagesBytes)}
+
Build-Cache{formatBytes(data.buildCacheBytes)}
+
Gestoppte Container{formatBytes(data.stoppedBytes)}
+
+ {stopped.length > 0 && ( +
    + {stopped.map((s) => ( +
  • {s.name}{formatBytes(s.bytes)}
  • + ))} +
+ )} +
Stand {data.day} · nur Anzeige, hier wird nichts gelöscht.
+
+ ) +} + +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 loadMain = useCallback(async () => { + try { + const [o, a, c] = await Promise.all([ + serverpulsApi.overview(), serverpulsApi.apps(), serverpulsApi.cleanup(), + ]) + setOverview(o) + setAppsData(a) + setCleanup(c) + setError('') + } catch (e) { + 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 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 + + 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 ( +
+ + + {error &&
{error}
} + +
+
+ {dot} + {overview ? headline : (error ? 'Keine Daten' : 'Lädt …')} + {overview && ( + + Letzte Messung {age.text}{age.stale ? ' – Daten veraltet' : ''} + + )} +
+ {alerts.length > 0 && ( +
    + {alerts.map((a) => ( +
  • + {LEVEL_LABEL[a.level] || a.level} + {a.title} + {a.value} + seit {fmtSince(a.since)} +
  • + ))} +
+ )} +
+ +
+ {METRICS.map((x) => ( + + ))} +
+ +
+
+

Verlauf

+
+ + +
+
+ {chartData && chartData.data[0].length > 0 + ? + :
{chart ? 'Für diesen Zeitraum liegen keine Messwerte vor.' : 'Lädt …'}
} +
+ +
+
+

Projekte

+ {appsData?.sizesDay && Größen von {appsData.sizesDay}} +
+ + {selected && ( +
+
+

{detail?.name || selected}

+ +
+ {detailData ? ( +
+
Größe
+
RAM
+
CPU
formatPct(v)} height={200} />
+
+ ) :
Lädt …
} +
+ )} +
+ +
+

Aufräumbar

+ +
+
+ ) +} diff --git a/src/styles/serverpuls.css b/src/styles/serverpuls.css new file mode 100644 index 0000000..b4f8359 --- /dev/null +++ b/src/styles/serverpuls.css @@ -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; } diff --git a/tests/unit/serverpulsFormat.test.mjs b/tests/unit/serverpulsFormat.test.mjs new file mode 100644 index 0000000..68e4e99 --- /dev/null +++ b/tests/unit/serverpulsFormat.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { formatBytes, formatDelta, ageLabel, usedPct } = 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) +})