252 lines
8.6 KiB
Vue
252 lines
8.6 KiB
Vue
<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>
|