Serverpuls: Admin-Seite mit Verlauf und Warnungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ae905a5d9b
commit
ec740ca970
12 files changed
+749
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user