Initial commit

This commit is contained in:
kilyabin
2026-04-12 22:26:26 +04:00
commit 68f9a01ed5
44 changed files with 7134 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
export default defineAppConfig({
ui: {
primary: 'blue',
gray: 'neutral',
},
})
+5
View File
@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
+1822
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -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>
+26
View File
@@ -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>
+38
View File
@@ -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 }
}
+59
View File
@@ -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 }
}
+116
View File
@@ -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>
+31
View File
@@ -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',
})
+17
View File
@@ -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"
}
}
+266
View File
@@ -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>
+251
View File
@@ -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>
+252
View File
@@ -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>
+233
View File
@@ -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>