feat: redesign Overview with active threats, severity donut, top processes bar

- Add /api/active-anomalies endpoint — latest snapshot per PID where is_anomaly=1
- Active threats card with pulsing indicator, CPU/RAM/TCP per process
- Alerts by severity donut chart (high/medium/low)
- Top processes horizontal bar chart weighted by alert count
This commit is contained in:
kilyabin
2026-05-28 14:43:03 +04:00
parent 9b7a840ba1
commit 42535528eb
3 changed files with 264 additions and 16 deletions
+18
View File
@@ -227,6 +227,24 @@ async def get_system_metrics(limit: int = 60) -> list[dict]:
return list(reversed([dict(r) for r in rows])) return list(reversed([dict(r) for r in rows]))
@app.get("/api/active-anomalies", summary="Currently anomalous processes")
async def get_active_anomalies() -> list[dict]:
"""Latest snapshot per PID — only rows where is_anomaly=1."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute("""
SELECT m.*
FROM metrics m
INNER JOIN (
SELECT pid, MAX(id) AS max_id FROM metrics GROUP BY pid
) latest ON m.id = latest.max_id
WHERE m.is_anomaly = 1
ORDER BY m.cpu_percent DESC
LIMIT 20
""") as cur:
return [dict(r) for r in await cur.fetchall()]
@app.get("/api/file-events", summary="Recent filesystem events") @app.get("/api/file-events", summary="Recent filesystem events")
async def get_file_events(limit: int = 100) -> list[dict]: async def get_file_events(limit: int = 100) -> list[dict]:
"""Return the most recent *limit* filesystem events.""" """Return the most recent *limit* filesystem events."""
+244 -15
View File
@@ -1,9 +1,36 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bell, Cpu, Database } from 'lucide-react' import { Activity, AlertTriangle, Bell, Cpu, Database, ShieldAlert } from 'lucide-react'
import { api } from '@/lib/api' import {
PieChart,
Pie,
Cell,
Tooltip as ChartTooltip,
BarChart,
Bar,
XAxis,
YAxis,
ResponsiveContainer,
} from 'recharts'
import { api, type Alert, type ProcessMetric } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { SystemChart } from '@/components/SystemChart' import { SystemChart } from '@/components/SystemChart'
// ── shared tooltip style ──────────────────────────────────────────────────────
const TOOLTIP_STYLE = {
contentStyle: {
background: '#111118',
border: '1px solid #00ff9d33',
borderRadius: 6,
fontSize: 12,
fontFamily: 'JetBrains Mono, monospace',
},
labelStyle: { color: '#6b7280' },
}
// ── stat card ─────────────────────────────────────────────────────────────────
function StatCard({ function StatCard({
title, title,
value, value,
@@ -36,6 +63,186 @@ function StatCard({
) )
} }
// ── active anomalies list ─────────────────────────────────────────────────────
function ActiveAnomaliesList({ procs }: { procs: ProcessMetric[] }) {
return (
<Card className="flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-red-500" />
Активные угрозы
</CardTitle>
{procs.length > 0 && (
<Badge variant="destructive" className="text-xs">
{procs.length}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="flex-1 overflow-auto p-0">
{procs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 gap-2 text-muted-foreground text-sm">
<span className="text-[#00ff9d] text-2xl"></span>
Аномалий не обнаружено
</div>
) : (
<ul className="divide-y divide-white/5">
{procs.map((p) => (
<li key={p.pid} className="flex items-start gap-3 px-5 py-3">
<span className="mt-1.5 w-2 h-2 rounded-full bg-red-500 shadow-[0_0_6px_#ff3366] shrink-0 animate-pulse" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm truncate">{p.name}</span>
<span className="font-mono text-xs text-muted-foreground shrink-0">
PID {p.pid}
</span>
</div>
<div className="flex gap-3 mt-1 text-xs font-mono text-muted-foreground">
<span style={{ color: '#00ff9d' }}>CPU {p.cpu_percent}%</span>
<span style={{ color: '#00d4ff' }}>RAM {Math.round(p.memory_mb)} МБ</span>
{p.connections > 0 && (
<span style={{ color: '#a78bfa' }}>{p.connections} TCP</span>
)}
</div>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
// ── severity donut ────────────────────────────────────────────────────────────
const SEV_META = [
{ key: 'high', label: 'Высокий', color: '#ff3366' },
{ key: 'medium', label: 'Средний', color: '#ff6b35' },
{ key: 'low', label: 'Низкий', color: '#00d4ff' },
] as const
function SeverityDonut({ alerts }: { alerts: Alert[] }) {
const counts = { high: 0, medium: 0, low: 0 }
for (const a of alerts) counts[a.severity] = (counts[a.severity] || 0) + 1
const data = SEV_META.map((m) => ({ name: m.label, value: counts[m.key], color: m.color }))
.filter((d) => d.value > 0)
const renderLegend = () => (
<ul className="flex flex-col gap-1.5 pl-2">
{data.map((d) => (
<li key={d.name} className="flex items-center gap-2 text-xs font-mono">
<span className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ background: d.color }} />
<span className="text-muted-foreground">{d.name}</span>
<span className="ml-auto" style={{ color: d.color }}>{d.value}</span>
</li>
))}
</ul>
)
return (
<Card>
<CardHeader className="pb-2">
<CardTitle>Алерты по серьёзности</CardTitle>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="flex items-center justify-center h-44 text-muted-foreground text-sm">
Алертов нет
</div>
) : (
<div className="flex items-center gap-4">
<ResponsiveContainer width="60%" height={180}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={48}
outerRadius={72}
paddingAngle={3}
dataKey="value"
stroke="none"
>
{data.map((d) => (
<Cell key={d.name} fill={d.color} />
))}
</Pie>
<ChartTooltip
{...TOOLTIP_STYLE}
formatter={(v: number, n: string) => [v, n]}
/>
</PieChart>
</ResponsiveContainer>
<div className="flex-1">{renderLegend()}</div>
</div>
)}
</CardContent>
</Card>
)
}
// ── top processes bar ─────────────────────────────────────────────────────────
function TopProcessesBar({ alerts }: { alerts: Alert[] }) {
const counts: Record<string, number> = {}
for (const a of alerts) {
counts[a.process_name] = (counts[a.process_name] || 0) + (a.count ?? 1)
}
const data = Object.entries(counts)
.sort(([, a], [, b]) => b - a)
.slice(0, 6)
.map(([name, value]) => ({ name, value }))
return (
<Card>
<CardHeader className="pb-2">
<CardTitle>Топ процессов по алертам</CardTitle>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="flex items-center justify-center h-44 text-muted-foreground text-sm">
Нет данных
</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 36)}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 4, right: 32, left: 8, bottom: 4 }}
>
<XAxis
type="number"
tick={{ fontSize: 10, fill: '#6b7280', fontFamily: 'JetBrains Mono, monospace' }}
tickLine={false}
axisLine={{ stroke: '#ffffff18' }}
/>
<YAxis
type="category"
dataKey="name"
width={100}
tick={{ fontSize: 11, fill: '#e5e7eb', fontFamily: 'JetBrains Mono, monospace' }}
tickLine={false}
axisLine={false}
/>
<ChartTooltip
{...TOOLTIP_STYLE}
formatter={(v: number) => [v, 'алертов']}
/>
<Bar dataKey="value" fill="#ff6b35" radius={[0, 3, 3, 0]} maxBarSize={18} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)
}
// ── main component ────────────────────────────────────────────────────────────
export function Overview() { export function Overview() {
const { data: stats } = useQuery({ const { data: stats } = useQuery({
queryKey: ['stats'], queryKey: ['stats'],
@@ -43,22 +250,33 @@ export function Overview() {
refetchInterval: 5_000, refetchInterval: 5_000,
}) })
// Системные метрики — одна точка на батч (каждые 5 с), хранятся в отдельной таблице
const { data: sysRows } = useQuery({ const { data: sysRows } = useQuery({
queryKey: ['system-metrics'], queryKey: ['system-metrics'],
queryFn: () => api.systemMetrics(60), queryFn: () => api.systemMetrics(60),
refetchInterval: 5_000, refetchInterval: 5_000,
}) })
const { data: activeProcs } = useQuery({
queryKey: ['active-anomalies'],
queryFn: api.activeAnomalies,
refetchInterval: 5_000,
})
const { data: alerts } = useQuery({
queryKey: ['alerts'],
queryFn: () => api.alerts(200),
refetchInterval: 10_000,
})
const chartData = (sysRows ?? []).map((r) => ({ const chartData = (sysRows ?? []).map((r) => ({
t: r.timestamp.slice(11, 19), // HH:MM:SS t: r.timestamp.slice(11, 19),
cpu: Math.round(r.cpu_percent * 10) / 10, cpu: Math.round(r.cpu_percent * 10) / 10,
ram: Math.round(r.ram_percent * 10) / 10, ram: Math.round(r.ram_percent * 10) / 10,
})) }))
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* Карточки */} {/* Stat cards */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
<StatCard <StatCard
title="Всего процессов" title="Всего процессов"
@@ -92,15 +310,26 @@ export function Overview() {
/> />
</div> </div>
{/* График */} {/* Active anomalies + system chart */}
<Card> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<CardHeader> <ActiveAnomaliesList procs={activeProcs ?? []} />
<CardTitle>Нагрузка системы реальное время (60 точек)</CardTitle> <Card className="lg:col-span-2">
</CardHeader> <CardHeader className="pb-2">
<CardContent> <CardTitle>Нагрузка системы реальное время (60 точек)</CardTitle>
<SystemChart data={chartData} /> </CardHeader>
</CardContent> <CardContent>
</Card> <SystemChart data={chartData} />
</CardContent>
</Card>
</div>
{/* Severity donut + top processes */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<SeverityDonut alerts={alerts ?? []} />
<div className="lg:col-span-2">
<TopProcessesBar alerts={alerts ?? []} />
</div>
</div>
</div> </div>
) )
} }
+2 -1
View File
@@ -70,5 +70,6 @@ export const api = {
alerts: (limit = 50) => get<Alert[]>(`${BASE}/alerts?limit=${limit}`), alerts: (limit = 50) => get<Alert[]>(`${BASE}/alerts?limit=${limit}`),
fileEvents: (limit = 100) => get<FileEvent[]>(`${BASE}/file-events?limit=${limit}`), fileEvents: (limit = 100) => get<FileEvent[]>(`${BASE}/file-events?limit=${limit}`),
stats: () => get<Stats>(`${BASE}/stats`), stats: () => get<Stats>(`${BASE}/stats`),
systemMetrics: (limit = 60) => get<SystemMetricRow[]>(`${BASE}/system-metrics?limit=${limit}`), systemMetrics: (limit = 60) => get<SystemMetricRow[]>(`${BASE}/system-metrics?limit=${limit}`),
activeAnomalies: () => get<ProcessMetric[]>(`${BASE}/active-anomalies`),
} }