234 lines
9.1 KiB
Vue
234 lines
9.1 KiB
Vue
<template>
|
|
<div class="space-y-4">
|
|
<!-- Period selector -->
|
|
<div class="flex gap-2">
|
|
<UButton
|
|
v-for="h in periodOptions"
|
|
:key="h"
|
|
:color="hours === h ? 'blue' : 'gray'"
|
|
:variant="hours === h ? 'solid' : 'soft'"
|
|
size="xs"
|
|
@click="hours = h; loadAll()"
|
|
>
|
|
{{ periodLabel(h) }}
|
|
</UButton>
|
|
</div>
|
|
|
|
<!-- Timeline chart -->
|
|
<UCard :ui="cardUi">
|
|
<template #header>
|
|
<p class="text-sm font-semibold text-gray-200">Динамика алертов</p>
|
|
</template>
|
|
<div class="h-64">
|
|
<BarChart v-if="timelineReady" :data="timelineData" :options="barOptions" />
|
|
<div v-else class="h-full flex items-center justify-center text-gray-600 text-sm">Нет данных</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<!-- Second row: Types + Severity -->
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<UCard :ui="cardUi">
|
|
<template #header>
|
|
<p class="text-sm font-semibold text-gray-200">По типам атак</p>
|
|
</template>
|
|
<div class="h-56 flex items-center justify-center">
|
|
<DoughnutChart v-if="typesReady" :data="typesChartData" :options="doughnutOptions" />
|
|
<div v-else class="text-gray-600 text-sm">Нет данных</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard :ui="cardUi">
|
|
<template #header>
|
|
<p class="text-sm font-semibold text-gray-200">По уровню критичности</p>
|
|
</template>
|
|
<div class="h-56 flex items-center justify-center">
|
|
<DoughnutChart v-if="severityReady" :data="severityChartData" :options="doughnutOptions" />
|
|
<div v-else class="text-gray-600 text-sm">Нет данных</div>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<!-- Top IPs bar -->
|
|
<UCard :ui="cardUi">
|
|
<template #header>
|
|
<p class="text-sm font-semibold text-gray-200">Топ-10 источников атак</p>
|
|
</template>
|
|
<div class="h-56">
|
|
<BarChart v-if="ipsReady" :data="ipsChartData" :options="ipsBarOptions" />
|
|
<div v-else class="h-full flex items-center justify-center text-gray-600 text-sm">Нет данных</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<!-- Summary table -->
|
|
<UCard :ui="cardUi">
|
|
<template #header>
|
|
<p class="text-sm font-semibold text-gray-200">Сводная статистика</p>
|
|
</template>
|
|
<div class="grid grid-cols-3 gap-6">
|
|
<div>
|
|
<p class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">По критичности</p>
|
|
<div class="space-y-1.5">
|
|
<div v-for="s in severityRows" :key="s.severity" class="flex items-center justify-between">
|
|
<SeverityBadge :severity="s.severity" />
|
|
<span class="text-sm font-mono text-gray-300">{{ s.count }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">По типу</p>
|
|
<div class="space-y-1.5">
|
|
<div v-for="t in typeRows" :key="t.type" class="flex items-center justify-between">
|
|
<span class="text-xs text-blue-300 font-mono">{{ typeLabel(t.type) }}</span>
|
|
<span class="text-sm font-mono text-gray-300">{{ t.count }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">Итого</p>
|
|
<p class="text-3xl font-bold text-white">{{ summary.total ?? 0 }}</p>
|
|
<p class="text-xs text-gray-500 mt-1">алертов зафиксировано</p>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {
|
|
Chart as ChartJS, CategoryScale, LinearScale,
|
|
BarElement, ArcElement, Tooltip, Legend,
|
|
} from 'chart.js'
|
|
import { Bar as BarChart, Doughnut as DoughnutChart } from 'vue-chartjs'
|
|
|
|
ChartJS.register(CategoryScale, LinearScale, BarElement, ArcElement, Tooltip, Legend)
|
|
|
|
const { get } = useApi()
|
|
|
|
const hours = ref(24)
|
|
const periodOptions = [6, 24, 48, 168]
|
|
const summary = ref<any>({})
|
|
const timeline = ref<any[]>([])
|
|
const topIPs = ref<any[]>([])
|
|
|
|
async function loadAll() {
|
|
const [sum, tl, ips] = await Promise.all([
|
|
get<any>('/stats/summary'),
|
|
get<any>('/stats/timeline', { hours: hours.value }),
|
|
get<any>('/stats/top-ips', { limit: 10 }),
|
|
])
|
|
summary.value = sum
|
|
timeline.value = tl.data ?? []
|
|
topIPs.value = ips.data ?? []
|
|
}
|
|
|
|
onMounted(loadAll)
|
|
|
|
function periodLabel(h: number) {
|
|
return h < 24 ? `${h}ч` : h === 24 ? '24ч' : h === 48 ? '2д' : '7д'
|
|
}
|
|
|
|
// ── Timeline chart ────────────────────────────────────────────────────────────
|
|
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),
|
|
backgroundColor: 'rgba(59,130,246,0.7)',
|
|
borderRadius: 4,
|
|
}],
|
|
}))
|
|
|
|
const barOptions = {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#6b7280', font: { size: 10 } } },
|
|
y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#6b7280', font: { size: 10 } }, beginAtZero: true },
|
|
},
|
|
}
|
|
|
|
// ── Types chart ───────────────────────────────────────────────────────────────
|
|
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 typesChartData = computed(() => {
|
|
const types = summary.value.by_type ?? []
|
|
return {
|
|
labels: types.map((t: any) => TYPE_LABELS[t.type] ?? t.type),
|
|
datasets: [{ data: types.map((t: any) => t.count), backgroundColor: types.map((t: any) => TYPE_COLORS[t.type] ?? '#6b7280'), borderWidth: 0 }],
|
|
}
|
|
})
|
|
|
|
// ── Severity chart ────────────────────────────────────────────────────────────
|
|
const SEV_COLORS: Record<string, string> = {
|
|
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#22c55e',
|
|
}
|
|
const SEV_LABELS: Record<string, string> = {
|
|
critical: 'Критический', high: 'Высокий', medium: 'Средний', low: 'Низкий',
|
|
}
|
|
|
|
const severityReady = computed(() => Object.values(summary.value.by_severity ?? {}).some(v => v))
|
|
const severityChartData = computed(() => {
|
|
const sev = summary.value.by_severity ?? {}
|
|
const order = ['critical', 'high', 'medium', 'low']
|
|
return {
|
|
labels: order.map(s => SEV_LABELS[s]),
|
|
datasets: [{ data: order.map(s => sev[s] ?? 0), backgroundColor: order.map(s => SEV_COLORS[s]), borderWidth: 0 }],
|
|
}
|
|
})
|
|
|
|
const doughnutOptions = {
|
|
responsive: true, maintainAspectRatio: false, cutout: '65%',
|
|
plugins: {
|
|
legend: { position: 'right' as const, labels: { color: '#9ca3af', font: { size: 10 }, padding: 10, boxWidth: 10 } },
|
|
},
|
|
}
|
|
|
|
// ── IPs bar chart ─────────────────────────────────────────────────────────────
|
|
const ipsReady = computed(() => topIPs.value.length > 0)
|
|
const ipsChartData = computed(() => ({
|
|
labels: topIPs.value.map(ip => ip.src_ip),
|
|
datasets: [{
|
|
label: 'Алертов',
|
|
data: topIPs.value.map(ip => ip.count),
|
|
backgroundColor: 'rgba(168,85,247,0.7)',
|
|
borderRadius: 4,
|
|
}],
|
|
}))
|
|
|
|
const ipsBarOptions = {
|
|
indexAxis: 'y' as const,
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#6b7280', font: { size: 10 } }, beginAtZero: true },
|
|
y: { grid: { display: false }, ticks: { color: '#d1d5db', font: { size: 10, family: 'monospace' } } },
|
|
},
|
|
}
|
|
|
|
// ── Tables ────────────────────────────────────────────────────────────────────
|
|
const SEV_ORDER = ['critical', 'high', 'medium', 'low']
|
|
const severityRows = computed(() => {
|
|
const sev = summary.value.by_severity ?? {}
|
|
return SEV_ORDER.map(s => ({ severity: s, count: sev[s] ?? 0 }))
|
|
})
|
|
|
|
const typeRows = computed(() => summary.value.by_type ?? [])
|
|
|
|
function typeLabel(t: string | undefined): string {
|
|
if (!t) return '—'
|
|
return TYPE_LABELS[t] ?? t
|
|
}
|
|
|
|
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-4' } }
|
|
</script>
|