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

+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>
)
}