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:
@@ -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."""
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user