78e5c90ed0
also small various features
256 lines
8.4 KiB
Vue
256 lines
8.4 KiB
Vue
<template>
|
|
<div class="space-y-6">
|
|
<!-- Metric cards -->
|
|
<div class="grid grid-cols-4 gap-4">
|
|
<MetricCard
|
|
label="Критических"
|
|
:value="summary.by_severity?.critical ?? 0"
|
|
icon="i-heroicons-fire"
|
|
color="red"
|
|
/>
|
|
<MetricCard
|
|
label="Высоких"
|
|
:value="summary.by_severity?.high ?? 0"
|
|
icon="i-heroicons-exclamation-triangle"
|
|
color="orange"
|
|
/>
|
|
<MetricCard
|
|
label="Средних"
|
|
:value="summary.by_severity?.medium ?? 0"
|
|
icon="i-heroicons-exclamation-circle"
|
|
color="yellow"
|
|
/>
|
|
<MetricCard
|
|
label="Низких"
|
|
:value="summary.by_severity?.low ?? 0"
|
|
icon="i-heroicons-information-circle"
|
|
color="green"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Charts row -->
|
|
<div class="grid grid-cols-3 gap-4">
|
|
<!-- Timeline chart (2/3 width) -->
|
|
<UCard class="col-span-2" :ui="cardUi">
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<p class="text-sm font-semibold text-gray-200">Алерты за последние 24 часа</p>
|
|
<UBadge color="blue" variant="soft" size="xs">{{ summary.total ?? 0 }} всего</UBadge>
|
|
</div>
|
|
</template>
|
|
<div class="h-52">
|
|
<LineChart v-if="timelineReady" :data="timelineData" :options="lineOptions" />
|
|
<div v-else class="h-full flex items-center justify-center text-gray-600 text-sm">
|
|
Нет данных
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<!-- Types donut (1/3 width) -->
|
|
<UCard :ui="cardUi">
|
|
<template #header>
|
|
<p class="text-sm font-semibold text-gray-200">Типы атак</p>
|
|
</template>
|
|
<div class="h-52 flex items-center justify-center">
|
|
<DoughnutChart v-if="typesReady" :data="typesData" :options="doughnutOptions" />
|
|
<div v-else class="text-gray-600 text-sm">Нет данных</div>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<!-- Bottom row -->
|
|
<div class="grid grid-cols-3 gap-4">
|
|
<!-- Top IPs -->
|
|
<UCard :ui="cardUi">
|
|
<template #header>
|
|
<p class="text-sm font-semibold text-gray-200">Топ источников атак</p>
|
|
</template>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="(ip, i) in topIPs"
|
|
:key="ip.src_ip"
|
|
class="flex items-center gap-3"
|
|
>
|
|
<span class="text-xs text-gray-500 w-4 shrink-0">{{ i + 1 }}</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center justify-between mb-0.5">
|
|
<span class="text-xs font-mono text-gray-300 truncate">{{ ip.src_ip }}</span>
|
|
<span class="text-xs text-gray-400 ml-2 shrink-0">{{ ip.count }}</span>
|
|
</div>
|
|
<div class="h-1 bg-gray-800 rounded-full overflow-hidden">
|
|
<div
|
|
class="h-full bg-blue-500 rounded-full"
|
|
:style="{ width: `${(ip.count / maxIPCount) * 100}%` }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="!topIPs.length" class="text-gray-600 text-sm py-4 text-center">
|
|
Нет данных
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<!-- Recent alerts -->
|
|
<UCard class="col-span-2" :ui="cardUi">
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<p class="text-sm font-semibold text-gray-200">Последние инциденты</p>
|
|
<UButton to="/alerts" variant="ghost" size="xs" color="blue" trailing-icon="i-heroicons-arrow-right">
|
|
Все алерты
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="alert in recentAlerts"
|
|
:key="alert.id"
|
|
class="flex items-start gap-3 py-1.5 border-b border-gray-800/60 last:border-0"
|
|
>
|
|
<SeverityBadge :severity="alert.severity" class="mt-0.5 shrink-0" />
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-xs text-gray-300 truncate">{{ alert.description }}</p>
|
|
<p class="text-[10px] text-gray-500 mt-0.5">
|
|
{{ alert.src_ip }} · {{ formatTime(alert.created_at) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="!recentAlerts.length" class="text-gray-600 text-sm py-4 text-center">
|
|
Инцидентов не зафиксировано
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Line as LineChart, Doughnut as DoughnutChart } from 'vue-chartjs'
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale, LinearScale, PointElement, LineElement,
|
|
ArcElement, Tooltip, Legend, Filler,
|
|
} from 'chart.js'
|
|
|
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend, Filler)
|
|
|
|
const { get } = useApi()
|
|
|
|
const summary = ref<any>({})
|
|
const timeline = ref<any[]>([])
|
|
const topIPs = ref<any[]>([])
|
|
const recentAlerts = ref<any[]>([])
|
|
|
|
const cardUi = {
|
|
base: 'bg-gray-900 border border-gray-800',
|
|
header: { base: 'border-b border-gray-800 px-4 py-3' },
|
|
body: { base: 'px-4 py-3' },
|
|
}
|
|
|
|
async function loadAll() {
|
|
const [sum, tl, ips, alerts] = await Promise.all([
|
|
get<any>('/stats/summary'),
|
|
get<any>('/stats/timeline', { hours: 24 }),
|
|
get<any>('/stats/top-ips', { limit: 5 }),
|
|
get<any>('/alerts', { page_size: 8 }),
|
|
])
|
|
summary.value = {
|
|
...sum,
|
|
by_type: (sum.by_type ?? []).map((t: any) => ({ ...t, attack_type: t.type })),
|
|
}
|
|
timeline.value = tl.data ?? []
|
|
topIPs.value = ips.data ?? []
|
|
recentAlerts.value = (alerts.data ?? []).map((a: any) => ({ ...a, attack_type: a.type }))
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadAll()
|
|
// Обновляем каждые 10 секунд только на клиенте
|
|
const id = setInterval(loadAll, 10_000)
|
|
onUnmounted(() => clearInterval(id))
|
|
})
|
|
|
|
// ── Charts ────────────────────────────────────────────────────────────────────
|
|
|
|
const timelineReady = computed(() => timeline.value.length > 0)
|
|
|
|
const timelineData = computed(() => ({
|
|
labels: timeline.value.map(p => p.hour?.slice(11, 16) ?? p.hour),
|
|
datasets: [{
|
|
label: 'Алерты',
|
|
data: timeline.value.map(p => p.count),
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: 'rgba(59,130,246,0.12)',
|
|
tension: 0.4,
|
|
fill: true,
|
|
pointRadius: 3,
|
|
pointBackgroundColor: '#3b82f6',
|
|
}],
|
|
}))
|
|
|
|
const lineOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#6b7280', font: { size: 10 } } },
|
|
y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#6b7280', font: { size: 10 } }, beginAtZero: true },
|
|
},
|
|
}
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
port_scan: '#3b82f6',
|
|
brute_force: '#ef4444',
|
|
dos: '#f97316',
|
|
arp_spoof: '#a855f7',
|
|
dns_anomaly: '#22c55e',
|
|
}
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
port_scan: 'Port Scan',
|
|
brute_force: 'Brute Force',
|
|
dos: 'DoS/DDoS',
|
|
arp_spoof: 'ARP Spoof',
|
|
dns_anomaly: 'DNS Anomaly',
|
|
}
|
|
|
|
const typesReady = computed(() => summary.value.by_type?.length > 0)
|
|
|
|
const typesData = computed(() => {
|
|
const types: any[] = summary.value.by_type ?? []
|
|
return {
|
|
labels: types.map(t => TYPE_LABELS[t.attack_type] ?? t.attack_type ?? '—'),
|
|
datasets: [{
|
|
data: types.map(t => t.count),
|
|
backgroundColor: types.map(t => TYPE_COLORS[t.attack_type] ?? '#6b7280'),
|
|
borderWidth: 0,
|
|
}],
|
|
}
|
|
})
|
|
|
|
const doughnutOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
cutout: '68%',
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom' as const,
|
|
labels: { color: '#9ca3af', font: { size: 10 }, padding: 8, boxWidth: 10 },
|
|
},
|
|
},
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
const maxIPCount = computed(() =>
|
|
topIPs.value.reduce((max, ip) => Math.max(max, ip.count), 1)
|
|
)
|
|
|
|
function formatTime(iso: string) {
|
|
return new Date(iso).toLocaleString('ru-RU', {
|
|
day: '2-digit', month: '2-digit',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})
|
|
}
|
|
</script>
|