Initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
primary: 'blue',
|
||||
gray: 'neutral',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
+1822
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<UCard :ui="cardUi">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
||||
:class="bgClass"
|
||||
>
|
||||
<UIcon :name="icon" class="w-5 h-5" :class="iconClass" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-white leading-none">{{ value.toLocaleString() }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: number
|
||||
icon: string
|
||||
color: 'red' | 'orange' | 'yellow' | 'green' | 'blue'
|
||||
}>()
|
||||
|
||||
const cardUi = {
|
||||
base: 'bg-gray-900 border border-gray-800',
|
||||
body: { base: 'px-4 py-4' },
|
||||
}
|
||||
|
||||
const bgClass = computed(() => ({
|
||||
red: 'bg-red-500/15',
|
||||
orange: 'bg-orange-500/15',
|
||||
yellow: 'bg-yellow-500/15',
|
||||
green: 'bg-green-500/15',
|
||||
blue: 'bg-blue-500/15',
|
||||
}[props.color]))
|
||||
|
||||
const iconClass = computed(() => ({
|
||||
red: 'text-red-400',
|
||||
orange: 'text-orange-400',
|
||||
yellow: 'text-yellow-400',
|
||||
green: 'text-green-400',
|
||||
blue: 'text-blue-400',
|
||||
}[props.color]))
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<UBadge :color="badgeColor" variant="soft" size="xs" class="uppercase tracking-wide font-semibold">
|
||||
{{ label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ severity: string }>()
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
critical: 'Крит',
|
||||
high: 'Высок',
|
||||
medium: 'Средн',
|
||||
low: 'Низк',
|
||||
}
|
||||
|
||||
const COLORS: Record<string, 'red' | 'orange' | 'yellow' | 'green' | 'gray'> = {
|
||||
critical: 'red',
|
||||
high: 'orange',
|
||||
medium: 'yellow',
|
||||
low: 'green',
|
||||
}
|
||||
|
||||
const label = computed(() => LABELS[props.severity] ?? props.severity)
|
||||
const badgeColor = computed(() => COLORS[props.severity] ?? 'gray')
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
// Базовый fetch к Go API
|
||||
export function useApi() {
|
||||
async function get<T>(path: string, params?: Record<string, string | number>) {
|
||||
const url = new URL('/api' + path, window.location.origin)
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, String(v)))
|
||||
}
|
||||
const res = await fetch(url.toString())
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body?: unknown) {
|
||||
const res = await fetch('/api' + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function del(path: string) {
|
||||
const res = await fetch('/api' + path, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function upload(path: string, file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await fetch('/api' + path, { method: 'POST', body: form })
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json()
|
||||
}
|
||||
|
||||
return { get, post, del, upload }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
interface CaptureStatus {
|
||||
running: boolean
|
||||
session_id?: number
|
||||
source?: string
|
||||
mode?: string
|
||||
pkts?: number
|
||||
bytes?: number
|
||||
last_session?: {
|
||||
id: number
|
||||
source: string
|
||||
mode: string
|
||||
status: string
|
||||
total_pkts: number
|
||||
total_bytes: number
|
||||
created_at: string
|
||||
ended_at?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Синглтон — один интервал на всё приложение
|
||||
let interval: number | null = null
|
||||
const status = ref<CaptureStatus>({ running: false })
|
||||
|
||||
export function useCaptureStatus() {
|
||||
const { get, post } = useApi()
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
status.value = await get<CaptureStatus>('/capture/status')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (interval || !import.meta.client) return
|
||||
fetchStatus()
|
||||
interval = window.setInterval(fetchStatus, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCapture() {
|
||||
try {
|
||||
await post('/capture/stop')
|
||||
await fetchStatus()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(startPolling)
|
||||
onUnmounted(() => {
|
||||
// Не останавливаем polling при размонтировании layout'а
|
||||
})
|
||||
|
||||
return { status, fetchStatus, stopCapture, startPolling, stopPolling }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-950 text-gray-100 flex">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-60 shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
<!-- Logo -->
|
||||
<div class="px-5 py-5 border-b border-gray-800">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center shrink-0">
|
||||
<UIcon name="i-heroicons-shield-exclamation" class="text-white w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-white leading-tight">DIP-IDS</p>
|
||||
<p class="text-[10px] text-gray-400 leading-tight">Network IDS</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-3 py-4 space-y-0.5">
|
||||
<NuxtLink
|
||||
v-for="item in navigation"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors"
|
||||
:class="isActive(item.to)
|
||||
? 'bg-blue-600/20 text-blue-400 font-medium'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'"
|
||||
>
|
||||
<UIcon :name="item.icon" class="w-4.5 h-4.5 shrink-0" />
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div class="px-4 py-4 border-t border-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="captureRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-600'"
|
||||
/>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ captureRunning ? 'Захват активен' : 'Захват остановлен' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Top bar -->
|
||||
<header class="h-14 border-b border-gray-800 bg-gray-900/50 flex items-center px-6 gap-4 shrink-0">
|
||||
<h1 class="text-sm font-semibold text-gray-200 flex-1">{{ pageTitle }}</h1>
|
||||
<UButton
|
||||
v-if="captureRunning"
|
||||
color="red"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
icon="i-heroicons-stop-circle"
|
||||
@click="stopCapture"
|
||||
>
|
||||
Остановить захват
|
||||
</UButton>
|
||||
<UButton
|
||||
color="blue"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
icon="i-heroicons-document-arrow-down"
|
||||
@click="downloadReport"
|
||||
>
|
||||
PDF отчёт
|
||||
</UButton>
|
||||
</header>
|
||||
|
||||
<!-- Page content -->
|
||||
<main class="flex-1 overflow-auto p-6">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { status, stopCapture } = useCaptureStatus()
|
||||
|
||||
const captureRunning = computed(() => status.value?.running ?? false)
|
||||
|
||||
const navigation = [
|
||||
{ to: '/', label: 'Дашборд', icon: 'i-heroicons-chart-bar-square' },
|
||||
{ to: '/alerts', label: 'Алерты', icon: 'i-heroicons-bell-alert' },
|
||||
{ to: '/capture', label: 'Захват', icon: 'i-heroicons-radio' },
|
||||
{ to: '/stats', label: 'Статистика', icon: 'i-heroicons-chart-pie' },
|
||||
]
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/': 'Обзор',
|
||||
'/alerts': 'Журнал алертов',
|
||||
'/capture': 'Управление захватом',
|
||||
'/stats': 'Статистика трафика',
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => pageTitles[route.path] ?? 'DIP-IDS')
|
||||
|
||||
function isActive(to: string) {
|
||||
return to === '/' ? route.path === '/' : route.path.startsWith(to)
|
||||
}
|
||||
|
||||
function downloadReport() {
|
||||
const a = document.createElement('a')
|
||||
a.href = '/api/reports/generate'
|
||||
a.download = `nids-report-${new Date().toISOString().slice(0, 10)}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@nuxt/ui'],
|
||||
|
||||
ui: {
|
||||
global: true,
|
||||
},
|
||||
|
||||
colorMode: {
|
||||
preference: 'dark',
|
||||
},
|
||||
|
||||
// Проксируем только маршруты Go бэкенда (не трогаем /api/_nuxt_icon и др. внутренние)
|
||||
routeRules: {
|
||||
'/api/alerts/**': { proxy: 'http://localhost:8080/api/alerts/**' },
|
||||
'/api/alerts': { proxy: 'http://localhost:8080/api/alerts' },
|
||||
'/api/stats/**': { proxy: 'http://localhost:8080/api/stats/**' },
|
||||
'/api/capture/**': { proxy: 'http://localhost:8080/api/capture/**' },
|
||||
'/api/reports/**': { proxy: 'http://localhost:8080/api/reports/**' },
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
title: 'DIP-IDS — Network Intrusion Detection System',
|
||||
meta: [
|
||||
{ name: 'description', content: 'Система обнаружения вторжений на основе анализа сетевого трафика' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
compatibilityDate: '2024-10-01',
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "dip-ids-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^2.18.4",
|
||||
"chart.js": "^4.4.4",
|
||||
"vue-chartjs": "^5.3.1",
|
||||
"nuxt": "^3.13.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Filters -->
|
||||
<UCard :ui="cardUi">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex-1 min-w-36">
|
||||
<label class="text-xs text-gray-400 mb-1 block">Тип атаки</label>
|
||||
<USelect
|
||||
v-model="filters.type"
|
||||
:options="typeOptions"
|
||||
size="sm"
|
||||
placeholder="Все типы"
|
||||
:ui="selectUi"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-36">
|
||||
<label class="text-xs text-gray-400 mb-1 block">Критичность</label>
|
||||
<USelect
|
||||
v-model="filters.severity"
|
||||
:options="severityOptions"
|
||||
size="sm"
|
||||
placeholder="Все уровни"
|
||||
:ui="selectUi"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-36">
|
||||
<label class="text-xs text-gray-400 mb-1 block">IP источника</label>
|
||||
<UInput
|
||||
v-model="filters.src_ip"
|
||||
size="sm"
|
||||
placeholder="192.168.0.1"
|
||||
:ui="inputUi"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton size="sm" color="blue" icon="i-heroicons-magnifying-glass" @click="loadAlerts">
|
||||
Найти
|
||||
</UButton>
|
||||
<UButton size="sm" color="gray" variant="soft" icon="i-heroicons-x-mark" @click="resetFilters">
|
||||
Сбросить
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="red"
|
||||
variant="soft"
|
||||
icon="i-heroicons-trash"
|
||||
:loading="clearing"
|
||||
@click="clearAll"
|
||||
>
|
||||
Очистить всё
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Table -->
|
||||
<UCard :ui="cardUi">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-semibold text-gray-200">
|
||||
Инциденты
|
||||
<span class="text-gray-500 font-normal ml-1">({{ total }})</span>
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<UBadge
|
||||
v-for="s in activeSeverities"
|
||||
:key="s.severity"
|
||||
:color="severityColor(s.severity)"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
>
|
||||
{{ s.label }}: {{ s.count }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UTable
|
||||
:rows="alerts"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:ui="tableUi"
|
||||
>
|
||||
<template #severity-data="{ row }">
|
||||
<SeverityBadge :severity="row.severity" />
|
||||
</template>
|
||||
|
||||
<template #attack_type-data="{ row }">
|
||||
<span class="text-xs font-mono text-blue-300">{{ typeLabel(row.attack_type) }}</span>
|
||||
</template>
|
||||
|
||||
<template #src_ip-data="{ row }">
|
||||
<span class="font-mono text-xs text-gray-300">{{ row.src_ip }}</span>
|
||||
</template>
|
||||
|
||||
<template #dst_ip-data="{ row }">
|
||||
<span class="font-mono text-xs text-gray-400">
|
||||
{{ row.dst_ip }}{{ row.dst_port ? ':' + row.dst_port : '' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #created_at-data="{ row }">
|
||||
<span class="text-xs text-gray-400">{{ formatTime(row.created_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template #description-data="{ row }">
|
||||
<span class="text-xs text-gray-300">{{ row.description }}</span>
|
||||
</template>
|
||||
|
||||
<template #actions-data="{ row }">
|
||||
<UButton
|
||||
icon="i-heroicons-trash"
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="ghost"
|
||||
@click="deleteAlert(row.id)"
|
||||
/>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex justify-between items-center mt-4 pt-4 border-t border-gray-800">
|
||||
<p class="text-xs text-gray-500">
|
||||
Показано {{ alerts.length }} из {{ total }}
|
||||
</p>
|
||||
<UPagination
|
||||
v-model="page"
|
||||
:page-count="pageSize"
|
||||
:total="total"
|
||||
size="xs"
|
||||
:ui="{ wrapper: 'gap-1' }"
|
||||
@update:model-value="loadAlerts"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { get, del } = useApi()
|
||||
|
||||
const alerts = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const clearing = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 25
|
||||
|
||||
const filters = reactive({
|
||||
type: '',
|
||||
severity: '',
|
||||
src_ip: '',
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'created_at', label: 'Время', sortable: false },
|
||||
{ key: 'severity', label: 'Критичность', sortable: false },
|
||||
{ key: 'attack_type', label: 'Тип', sortable: false },
|
||||
{ key: 'src_ip', label: 'Источник', sortable: false },
|
||||
{ key: 'dst_ip', label: 'Назначение', sortable: false },
|
||||
{ key: 'description', label: 'Описание', sortable: false },
|
||||
{ key: 'actions', label: '', sortable: false },
|
||||
]
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Все типы', value: '' },
|
||||
{ label: 'Port Scan', value: 'port_scan' },
|
||||
{ label: 'Brute Force', value: 'brute_force' },
|
||||
{ label: 'DoS/DDoS', value: 'dos' },
|
||||
{ label: 'ARP Spoof', value: 'arp_spoof' },
|
||||
{ label: 'DNS Anomaly', value: 'dns_anomaly' },
|
||||
]
|
||||
|
||||
const severityOptions = [
|
||||
{ label: 'Все уровни', value: '' },
|
||||
{ label: 'Критический', value: 'critical' },
|
||||
{ label: 'Высокий', value: 'high' },
|
||||
{ label: 'Средний', value: 'medium' },
|
||||
{ label: 'Низкий', value: 'low' },
|
||||
]
|
||||
|
||||
async function loadAlerts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (filters.type) params.type = filters.type
|
||||
if (filters.severity) params.severity = filters.severity
|
||||
if (filters.src_ip) params.src_ip = filters.src_ip
|
||||
|
||||
const res = await get<any>('/alerts', params)
|
||||
// Копируем type → attack_type, т.к. Vue 3 перехватывает .type на реактивных объектах
|
||||
alerts.value = (res.data ?? []).map((a: any) => ({ ...a, attack_type: a.type }))
|
||||
total.value = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAlert(id: number) {
|
||||
await del(`/alerts/${id}`)
|
||||
loadAlerts()
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
clearing.value = true
|
||||
try {
|
||||
await del('/alerts')
|
||||
await loadAlerts()
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.type = ''
|
||||
filters.severity = ''
|
||||
filters.src_ip = ''
|
||||
page.value = 1
|
||||
loadAlerts()
|
||||
}
|
||||
|
||||
const activeSeverities = computed(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
alerts.value.forEach(a => { counts[a.severity] = (counts[a.severity] ?? 0) + 1 })
|
||||
return Object.entries(counts).map(([severity, count]) => ({
|
||||
severity,
|
||||
count,
|
||||
label: { critical: 'Крит', high: 'Выс', medium: 'Ср', low: 'Низ' }[severity] ?? severity,
|
||||
}))
|
||||
})
|
||||
|
||||
function severityColor(s: string): 'red' | 'orange' | 'yellow' | 'green' | 'gray' {
|
||||
return ({ critical: 'red', high: 'orange', medium: 'yellow', low: 'green' } as any)[s] ?? 'gray'
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
function typeLabel(t: string | undefined): string {
|
||||
if (!t) return '—'
|
||||
return TYPE_LABELS[t] ?? t
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadAlerts)
|
||||
|
||||
// UI tokens
|
||||
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' } }
|
||||
const tableUi = { wrapper: 'overflow-x-auto', td: { base: 'py-2' }, th: { base: 'text-gray-400 text-xs font-medium' } }
|
||||
const selectUi = { base: 'bg-gray-800 border-gray-700 text-gray-200 text-sm' }
|
||||
const inputUi = { base: 'bg-gray-800 border-gray-700 text-gray-200 text-sm' }
|
||||
</script>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="space-y-4 max-w-4xl">
|
||||
<!-- Status banner -->
|
||||
<UAlert
|
||||
v-if="status.running"
|
||||
icon="i-heroicons-radio"
|
||||
color="green"
|
||||
variant="soft"
|
||||
:title="`Захват активен — ${status.source} (${modeLabel(status.mode)})`"
|
||||
:description="`Пакетов: ${status.pkts?.toLocaleString() ?? 0} · Трафик: ${humanBytes(status.bytes ?? 0)}`"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton color="red" variant="soft" size="xs" @click="doStop">Остановить</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<!-- Tabs: Live / PCAP -->
|
||||
<UCard :ui="cardUi">
|
||||
<template #header>
|
||||
<p class="text-sm font-semibold text-gray-200">Источник трафика</p>
|
||||
</template>
|
||||
|
||||
<UTabs :items="tabs" :ui="tabsUi">
|
||||
<!-- Live capture tab -->
|
||||
<template #live>
|
||||
<div class="space-y-4 pt-4">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1.5 block">Сетевой интерфейс</label>
|
||||
<div class="flex gap-2">
|
||||
<USelect
|
||||
v-model="liveIface"
|
||||
:options="ifaceOptions"
|
||||
:disabled="status.running"
|
||||
class="flex-1"
|
||||
:ui="selectUi"
|
||||
/>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
icon="i-heroicons-arrow-path"
|
||||
:loading="loadingIfaces"
|
||||
@click="loadInterfaces"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
icon="i-heroicons-information-circle"
|
||||
color="blue"
|
||||
variant="soft"
|
||||
title="Требуются права суперпользователя"
|
||||
description="Live-захват использует raw sockets. Запустите бэкенд через sudo."
|
||||
/>
|
||||
|
||||
<UButton
|
||||
:color="status.running ? 'red' : 'green'"
|
||||
:icon="status.running ? 'i-heroicons-stop-circle' : 'i-heroicons-play-circle'"
|
||||
:loading="actionLoading"
|
||||
size="sm"
|
||||
@click="status.running ? doStop() : doStartLive()"
|
||||
>
|
||||
{{ status.running ? 'Остановить захват' : 'Начать захват' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- PCAP upload tab -->
|
||||
<template #pcap>
|
||||
<div class="space-y-4 pt-4">
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-700 rounded-xl p-8 text-center transition-colors"
|
||||
:class="dragging ? 'border-blue-500 bg-blue-500/5' : 'hover:border-gray-600'"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<UIcon name="i-heroicons-document-arrow-up" class="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p class="text-sm text-gray-400 mb-1">
|
||||
{{ pcapFile ? pcapFile.name : 'Перетащите .pcap файл или выберите' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 mb-3">Поддерживается формат libpcap / pcapng</p>
|
||||
<label class="cursor-pointer">
|
||||
<UButton color="gray" variant="soft" size="sm" as="span">
|
||||
Выбрать файл
|
||||
</UButton>
|
||||
<input type="file" accept=".pcap,.pcapng" class="hidden" @change="onFileSelect" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="blue"
|
||||
icon="i-heroicons-arrow-up-tray"
|
||||
:disabled="!pcapFile || status.running"
|
||||
:loading="actionLoading"
|
||||
size="sm"
|
||||
@click="doUploadPCAP"
|
||||
>
|
||||
{{ pcapFile ? `Анализировать ${pcapFile.name}` : 'Выберите файл' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</UCard>
|
||||
|
||||
<!-- Sessions history -->
|
||||
<UCard :ui="cardUi">
|
||||
<template #header>
|
||||
<p class="text-sm font-semibold text-gray-200">История сессий</p>
|
||||
</template>
|
||||
|
||||
<UTable :rows="sessions" :columns="sessionColumns" :ui="tableUi">
|
||||
<template #mode-data="{ row }">
|
||||
<UBadge :color="row.mode === 'live' ? 'blue' : 'purple'" variant="soft" size="xs">
|
||||
{{ modeLabel(row.mode) }}
|
||||
</UBadge>
|
||||
</template>
|
||||
<template #status-data="{ row }">
|
||||
<UBadge :color="row.status === 'running' ? 'green' : 'gray'" variant="soft" size="xs">
|
||||
{{ statusLabel(row.status) }}
|
||||
</UBadge>
|
||||
</template>
|
||||
<template #total_pkts-data="{ row }">
|
||||
<span class="text-xs font-mono">{{ row.total_pkts.toLocaleString() }}</span>
|
||||
</template>
|
||||
<template #total_bytes-data="{ row }">
|
||||
<span class="text-xs font-mono">{{ humanBytes(row.total_bytes) }}</span>
|
||||
</template>
|
||||
<template #created_at-data="{ row }">
|
||||
<span class="text-xs text-gray-400">{{ formatTime(row.created_at) }}</span>
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { post, upload, get } = useApi()
|
||||
const { status, fetchStatus, stopCapture } = useCaptureStatus()
|
||||
|
||||
const liveIface = ref('')
|
||||
const pcapFile = ref<File | null>(null)
|
||||
const dragging = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const loadingIfaces = ref(false)
|
||||
const sessions = ref<any[]>([])
|
||||
const ifaceOptions = ref<{ label: string, value: string }[]>([])
|
||||
|
||||
const tabs = [
|
||||
{ slot: 'live', label: 'Live захват', icon: 'i-heroicons-radio' },
|
||||
{ slot: 'pcap', label: 'PCAP файл', icon: 'i-heroicons-document' },
|
||||
]
|
||||
|
||||
const sessionColumns = [
|
||||
{ key: 'created_at', label: 'Время' },
|
||||
{ key: 'source', label: 'Источник' },
|
||||
{ key: 'mode', label: 'Режим' },
|
||||
{ key: 'status', label: 'Статус' },
|
||||
{ key: 'total_pkts', label: 'Пакетов' },
|
||||
{ key: 'total_bytes', label: 'Трафик' },
|
||||
]
|
||||
|
||||
async function loadInterfaces() {
|
||||
loadingIfaces.value = true
|
||||
try {
|
||||
const res = await get<any>('/capture/interfaces')
|
||||
ifaceOptions.value = (res.interfaces ?? []).map((i: string) => ({ label: i, value: i }))
|
||||
if (!liveIface.value && ifaceOptions.value.length) {
|
||||
liveIface.value = ifaceOptions.value[0].value
|
||||
}
|
||||
} finally {
|
||||
loadingIfaces.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
const res = await get<any>('/capture/sessions')
|
||||
sessions.value = res.data ?? []
|
||||
}
|
||||
|
||||
async function doStartLive() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await post('/capture/start', { interface: liveIface.value })
|
||||
await fetchStatus()
|
||||
await loadSessions()
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doStop() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await stopCapture()
|
||||
await loadSessions()
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doUploadPCAP() {
|
||||
if (!pcapFile.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await upload('/capture/upload', pcapFile.value)
|
||||
await fetchStatus()
|
||||
await loadSessions()
|
||||
pcapFile.value = null
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files?.[0]) pcapFile.value = input.files[0]
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
dragging.value = false
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file) pcapFile.value = file
|
||||
}
|
||||
|
||||
function modeLabel(m?: string) {
|
||||
return m === 'live' ? 'Live' : m === 'pcap' ? 'PCAP' : m ?? ''
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
return ({ running: 'Активна', stopped: 'Остановлена', completed: 'Завершена' } as any)[s] ?? s
|
||||
}
|
||||
|
||||
function humanBytes(b: number) {
|
||||
if (b >= 1 << 30) return `${(b / (1 << 30)).toFixed(2)} GB`
|
||||
if (b >= 1 << 20) return `${(b / (1 << 20)).toFixed(2)} MB`
|
||||
if (b >= 1 << 10) return `${(b / (1 << 10)).toFixed(2)} KB`
|
||||
return `${b} B`
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => { loadInterfaces(); loadSessions() })
|
||||
|
||||
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' } }
|
||||
const tableUi = { wrapper: 'overflow-x-auto', td: { base: 'py-2' }, th: { base: 'text-gray-400 text-xs font-medium' } }
|
||||
const selectUi = { base: 'bg-gray-800 border-gray-700 text-gray-200' }
|
||||
const tabsUi = { list: { background: 'bg-gray-800', tab: { active: 'bg-gray-700' } } }
|
||||
</script>
|
||||
@@ -0,0 +1,252 @@
|
||||
<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>
|
||||
@@ -0,0 +1,233 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user