feat: PF States Chart (Sparkline letzte 8h) — Backend schreibt pf_states in TimescaleDB, Frontend zeigt SVG-Sparkline

This commit is contained in:
cynfo3000 2026-03-06 21:47:47 +01:00
parent e01fd7698c
commit aca013b848
2 changed files with 84 additions and 21 deletions

View File

@ -424,6 +424,14 @@ func (d *Database) writeMetrics(tx *sql.Tx, agentID string, ts time.Time, data *
}
}
// PF States
if data.PFStates != nil && data.PFStates.HardLimit > 0 {
stmt.Exec(ts, agentID, "pf_states_current", float64(data.PFStates.CurrentEntries), nil)
stmt.Exec(ts, agentID, "pf_states_limit", float64(data.PFStates.HardLimit), nil)
usedPct := float64(data.PFStates.CurrentEntries) / float64(data.PFStates.HardLimit) * 100
stmt.Exec(ts, agentID, "pf_states_used_percent", usedPct, nil)
}
// Gateways (Loss und Delay sind Strings wie "0.0%" und "1.2ms")
for _, gw := range data.Gateways {
if gw.Delay != "" {

View File

@ -107,7 +107,7 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
const renderContent = () => {
if (!sys) return <div className="text-gray-500">Keine Systemdaten</div>
const tab = subTab
if (tab === 'overview') return <OverviewTab sys={sys} />
if (tab === 'overview') return <OverviewTab sys={sys} agentId={agentId} />
if (tab === 'interfaces') return <InterfacesTab sys={sys} />
if (tab === 'services') return <ServicesTab sys={sys} />
if (tab === 'tunnels') return <TunnelTab agentId={agentId} agent={agent} />
@ -326,25 +326,87 @@ function GatewaysTab({ sys }) {
)
}
function MetricCard({ icon: Icon, label, value, sub, bar, barColor }) {
function Sparkline({ data, height = 40, color = '#3b82f6' }) {
if (!data || data.length < 2) return null
const vals = data.map(d => d.value)
const min = Math.min(...vals)
const max = Math.max(...vals)
const range = max - min || 1
const w = 200
const h = height
const pts = vals.map((v, i) => {
const x = (i / (vals.length - 1)) * w
const y = h - ((v - min) / range) * (h - 4) - 2
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
function MetricCard({ icon: Icon, label, value, sub, bar, barColor, chart }) {
return (
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
<div className="flex items-center gap-2 text-gray-500 mb-2">
{Icon && <Icon className="w-4 h-4" />}
<span className="text-xs uppercase">{label}</span>
</div>
<div className="text-xl font-bold text-white">{value}</div>
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
{bar !== undefined && bar !== null && (
<div className="mt-2 h-1.5 bg-gray-700 rounded overflow-hidden">
<div className={`h-full rounded ${barColor || 'bg-blue-500'}`} style={{ width: `${Math.min(bar, 100)}%` }} />
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-gray-500 mb-2">
{Icon && <Icon className="w-4 h-4" />}
<span className="text-xs uppercase">{label}</span>
</div>
<div className="text-xl font-bold text-white">{value}</div>
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
{bar !== undefined && bar !== null && (
<div className="mt-2 h-1.5 bg-gray-700 rounded overflow-hidden">
<div className={`h-full rounded ${barColor || 'bg-blue-500'}`} style={{ width: `${Math.min(bar, 100)}%` }} />
</div>
)}
</div>
)}
{chart && (
<div className="ml-3 opacity-70 flex-shrink-0">
{chart}
</div>
)}
</div>
</div>
)
}
function OverviewTab({ sys }) {
function PFStatesCard({ agentId, sys }) {
const [chartData, setChartData] = useState([])
useEffect(() => {
if (!agentId) return
const from = new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString()
api.getMetrics(agentId, 'pf_states_current', `&from=${from}&limit=200`)
.then(data => {
if (Array.isArray(data)) setChartData(data)
})
.catch(() => {})
}, [agentId])
if (!sys?.pf_states) return null
const pf = sys.pf_states
const usedPct = pf.hard_limit > 0 ? (pf.current_entries / pf.hard_limit * 100) : 0
const barColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500'
return (
<MetricCard
icon={Shield}
label="PF States"
value={pf.hard_limit > 0
? `${pf.current_entries.toLocaleString('de-DE')} / ${pf.hard_limit.toLocaleString('de-DE')}`
: `${pf.current_entries.toLocaleString('de-DE')}`}
sub={`Search: ${pf.search_rate?.toFixed(1)}/s · Insert: ${pf.insert_rate?.toFixed(1)}/s`}
bar={pf.hard_limit > 0 ? usedPct : null}
barColor={barColor}
chart={<Sparkline data={chartData} height={44} color="#3b82f6" />}
/>
)
}
function OverviewTab({ sys, agentId }) {
const rootDisk = sys.disks?.find((d) => d.mount_point === '/')
const diskPct = rootDisk && rootDisk.total_bytes > 0
? (rootDisk.used_bytes / rootDisk.total_bytes * 100) : null
@ -375,14 +437,7 @@ function OverviewTab({ sys }) {
sub={rootDisk ? `${formatBytes(rootDisk.used_bytes)} / ${formatBytes(rootDisk.total_bytes)}` : ''}
bar={diskPct} barColor={barColor(diskPct || 0)} />
<MetricCard icon={Clock} label="Uptime" value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'} />
{sys.pf_states && (
<MetricCard icon={Shield} label="PF States"
value={sys.pf_states.hard_limit > 0 ? `${sys.pf_states.current_entries.toLocaleString('de-DE')} / ${sys.pf_states.hard_limit.toLocaleString('de-DE')}` : `${sys.pf_states.current_entries.toLocaleString('de-DE')}`}
sub={`Search: ${sys.pf_states.search_rate?.toFixed(1)}/s · Insert: ${sys.pf_states.insert_rate?.toFixed(1)}/s`}
bar={sys.pf_states.hard_limit > 0 ? (sys.pf_states.current_entries / sys.pf_states.hard_limit * 100) : null}
barColor={barColor(sys.pf_states.hard_limit > 0 ? (sys.pf_states.current_entries / sys.pf_states.hard_limit * 100) : 0)}
/>
)}
<PFStatesCard agentId={agentId} sys={sys} />
</div>
<h3 className="text-sm font-medium text-gray-300 mt-4">System Status</h3>