Serverpuls: Admin-Seite mit Verlauf und Warnungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 12:43:13 +00:00
1 parent ae905a5d9b
commit ec740ca970
12 files changed
+749 -3

No files matched your search

+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>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react'
import uPlot from 'uplot'
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)' } },
{
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: [
{},
...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>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { account } from './appwrite'
/**
* Client fuer das Serverpuls-Backend (nur lesend).
* Same-Origin unter /api/serverpuls, authentifiziert per Appwrite-JWT.
*/
const BASE = '/api/serverpuls'
async function get(path) {
const { jwt } = await account.createJWT()
const res = await 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
+58
View File
@@ -0,0 +1,58 @@
// 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)
}
+327
View File
@@ -0,0 +1,327 @@
import { useCallback, useEffect, 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, 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' }
function fmtSince(ts) {
if (!ts) return ''
return new Date(ts).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
}
// 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 ? 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 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 <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 {fmtSince(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={setMetric} testid="sp-metric-select" />
<Pills items={RANGES} value={range} onChange={setRange} 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={setSelected} />
{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={setDetailRange} 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; }