Files
dip-ids/frontend/pages/index.vue
T
2026-04-12 22:26:26 +04:00

253 lines
8.3 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
timeline.value = tl.data ?? []
topIPs.value = ips.data ?? []
recentAlerts.value = alerts.data ?? []
}
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.type] ?? t.type ?? '—'),
datasets: [{
data: types.map(t => t.count),
backgroundColor: types.map(t => TYPE_COLORS[t.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>