feat: healthcheck backend and fix data types

also small various features
This commit is contained in:
kilyabin
2026-04-28 00:10:06 +04:00
parent 1cb93a1f31
commit 78e5c90ed0
9 changed files with 305 additions and 132 deletions
+46 -4
View File
@@ -1,13 +1,55 @@
const RETRY_DELAYS = [0, 500, 1500]
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
function cacheKey(url: string) {
return `nids_cache:${url}`
}
function saveToCache(url: string, data: unknown) {
try {
localStorage.setItem(cacheKey(url), JSON.stringify({ data, ts: Date.now() }))
} catch {}
}
function loadFromCache<T>(url: string): T | null {
try {
const raw = localStorage.getItem(cacheKey(url))
if (!raw) return null
const { data, ts } = JSON.parse(raw)
if (Date.now() - ts > CACHE_TTL_MS) return null
return data as T
} catch {
return null
}
}
// Базовый fetch к Go API // Базовый fetch к Go API
export function useApi() { export function useApi() {
async function get<T>(path: string, params?: Record<string, string | number>) { async function get<T>(path: string, params?: Record<string, string | number>): Promise<T> {
const url = new URL('/api' + path, window.location.origin) const url = new URL('/api' + path, window.location.origin)
if (params) { if (params) {
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, String(v))) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, String(v)))
} }
const res = await fetch(url.toString()) const urlStr = url.toString()
if (!res.ok) throw new Error(await res.text())
return res.json() as Promise<T> let lastErr: unknown
for (let i = 0; i < RETRY_DELAYS.length; i++) {
if (i > 0) await new Promise(r => setTimeout(r, RETRY_DELAYS[i]))
try {
const res = await fetch(urlStr, { signal: AbortSignal.timeout(5000) })
if (!res.ok) throw new Error(await res.text())
const data = await res.json() as T
saveToCache(urlStr, data)
return data
} catch (e) {
lastErr = e
}
}
const cached = loadFromCache<T>(urlStr)
if (cached !== null) return cached
throw lastErr
} }
async function post<T>(path: string, body?: unknown) { async function post<T>(path: string, body?: unknown) {
+24
View File
@@ -0,0 +1,24 @@
// Singleton: один interval на всё приложение
let checkInterval: number | null = null
const isOnline = ref<boolean>(true)
export function useBackendStatus() {
async function check() {
try {
const res = await fetch('/api/health', { signal: AbortSignal.timeout(3000) })
isOnline.value = res.ok
} catch {
isOnline.value = false
}
}
function startPolling() {
if (checkInterval || !import.meta.client) return
check()
checkInterval = window.setInterval(check, 5000)
}
onMounted(startPolling)
return { isOnline, check }
}
+21 -2
View File
@@ -31,8 +31,8 @@
</NuxtLink> </NuxtLink>
</nav> </nav>
<!-- Status indicator --> <!-- Status indicators -->
<div class="px-4 py-4 border-t border-gray-800"> <div class="px-4 py-4 border-t border-gray-800 space-y-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class="w-2 h-2 rounded-full shrink-0" class="w-2 h-2 rounded-full shrink-0"
@@ -42,6 +42,15 @@
{{ captureRunning ? 'Захват активен' : 'Захват остановлен' }} {{ captureRunning ? 'Захват активен' : 'Захват остановлен' }}
</span> </span>
</div> </div>
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="isOnline ? 'bg-green-400' : 'bg-red-500 animate-pulse'"
/>
<span class="text-xs" :class="isOnline ? 'text-gray-400' : 'text-red-400'">
{{ isOnline ? 'Бэкенд онлайн' : 'Бэкенд недоступен' }}
</span>
</div>
</div> </div>
</aside> </aside>
@@ -71,6 +80,15 @@
</UButton> </UButton>
</header> </header>
<!-- Offline banner -->
<div
v-if="!isOnline"
class="mx-6 mt-4 flex items-center gap-2 text-xs text-yellow-300 bg-yellow-950/50 border border-yellow-800/60 rounded-lg px-3 py-2 shrink-0"
>
<UIcon name="i-heroicons-signal-slash" class="w-4 h-4 shrink-0" />
Бэкенд недоступен отображаются данные из БД
</div>
<!-- Page content --> <!-- Page content -->
<main class="flex-1 overflow-auto p-6"> <main class="flex-1 overflow-auto p-6">
<slot /> <slot />
@@ -82,6 +100,7 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute() const route = useRoute()
const { status, stopCapture } = useCaptureStatus() const { status, stopCapture } = useCaptureStatus()
const { isOnline } = useBackendStatus()
const captureRunning = computed(() => status.value?.running ?? false) const captureRunning = computed(() => status.value?.running ?? false)
+4 -3
View File
@@ -11,9 +11,10 @@ export default defineNuxtConfig({
// Проксируем только маршруты Go бэкенда (не трогаем /api/_nuxt_icon и др. внутренние) // Проксируем только маршруты Go бэкенда (не трогаем /api/_nuxt_icon и др. внутренние)
routeRules: { routeRules: {
'/api/alerts/**': { proxy: 'http://localhost:8080/api/alerts/**' }, '/api/health': { proxy: 'http://localhost:8080/api/health' },
'/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/alerts': { proxy: 'http://localhost:8080/api/alerts' },
'/api/stats/**': { proxy: 'http://localhost:8080/api/stats/**' },
'/api/capture/**': { proxy: 'http://localhost:8080/api/capture/**' }, '/api/capture/**': { proxy: 'http://localhost:8080/api/capture/**' },
'/api/reports/**': { proxy: 'http://localhost:8080/api/reports/**' }, '/api/reports/**': { proxy: 'http://localhost:8080/api/reports/**' },
}, },
+7 -4
View File
@@ -154,10 +154,13 @@ async function loadAll() {
get<any>('/stats/top-ips', { limit: 5 }), get<any>('/stats/top-ips', { limit: 5 }),
get<any>('/alerts', { page_size: 8 }), get<any>('/alerts', { page_size: 8 }),
]) ])
summary.value = sum summary.value = {
...sum,
by_type: (sum.by_type ?? []).map((t: any) => ({ ...t, attack_type: t.type })),
}
timeline.value = tl.data ?? [] timeline.value = tl.data ?? []
topIPs.value = ips.data ?? [] topIPs.value = ips.data ?? []
recentAlerts.value = alerts.data ?? [] recentAlerts.value = (alerts.data ?? []).map((a: any) => ({ ...a, attack_type: a.type }))
} }
onMounted(() => { onMounted(() => {
@@ -216,10 +219,10 @@ const typesReady = computed(() => summary.value.by_type?.length > 0)
const typesData = computed(() => { const typesData = computed(() => {
const types: any[] = summary.value.by_type ?? [] const types: any[] = summary.value.by_type ?? []
return { return {
labels: types.map(t => TYPE_LABELS[t.type] ?? t.type ?? '—'), labels: types.map(t => TYPE_LABELS[t.attack_type] ?? t.attack_type ?? '—'),
datasets: [{ datasets: [{
data: types.map(t => t.count), data: types.map(t => t.count),
backgroundColor: types.map(t => TYPE_COLORS[t.type] ?? '#6b7280'), backgroundColor: types.map(t => TYPE_COLORS[t.attack_type] ?? '#6b7280'),
borderWidth: 0, borderWidth: 0,
}], }],
} }
+6 -1
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"fmt" "fmt"
"net/http"
"dip-ids/internal/analyzer" "dip-ids/internal/analyzer"
"dip-ids/internal/api/handlers" "dip-ids/internal/api/handlers"
@@ -27,7 +28,7 @@ func New(
r.Use(gin.Recovery()) r.Use(gin.Recovery())
r.Use(corsMiddleware()) r.Use(corsMiddleware())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{ r.Use(gin.LoggerWithConfig(gin.LoggerConfig{
SkipPaths: []string{"/api/capture/status"}, SkipPaths: []string{"/api/capture/status", "/api/health"},
})) }))
alertsH := handlers.NewAlertsHandler(db) alertsH := handlers.NewAlertsHandler(db)
@@ -37,6 +38,10 @@ func New(
api := r.Group("/api") api := r.Group("/api")
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
api.GET("/alerts", alertsH.GetAlerts) api.GET("/alerts", alertsH.GetAlerts)
api.DELETE("/alerts", alertsH.ClearAlerts) api.DELETE("/alerts", alertsH.ClearAlerts)
api.DELETE("/alerts/:id", alertsH.DeleteAlert) api.DELETE("/alerts/:id", alertsH.DeleteAlert)
+183 -104
View File
@@ -15,6 +15,7 @@ import (
"github.com/johnfercher/maroto/v2/pkg/components/text" "github.com/johnfercher/maroto/v2/pkg/components/text"
"github.com/johnfercher/maroto/v2/pkg/config" "github.com/johnfercher/maroto/v2/pkg/config"
"github.com/johnfercher/maroto/v2/pkg/consts/align" "github.com/johnfercher/maroto/v2/pkg/consts/align"
"github.com/johnfercher/maroto/v2/pkg/consts/border"
"github.com/johnfercher/maroto/v2/pkg/consts/fontstyle" "github.com/johnfercher/maroto/v2/pkg/consts/fontstyle"
"github.com/johnfercher/maroto/v2/pkg/core" "github.com/johnfercher/maroto/v2/pkg/core"
"github.com/johnfercher/maroto/v2/pkg/props" "github.com/johnfercher/maroto/v2/pkg/props"
@@ -33,7 +34,6 @@ type Data struct {
RecentStats []storage.PacketStat RecentStats []storage.PacketStat
} }
// Пути к шрифту DejaVu Sans с поддержкой кириллицы
var cyrillicFontCandidates = []string{ var cyrillicFontCandidates = []string{
"/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
@@ -59,6 +59,17 @@ func findFont(candidates []string) string {
const fontFamily = "DejaVu" const fontFamily = "DejaVu"
// цветовая палитра
var (
colorBlue = &props.Color{Red: 37, Green: 99, Blue: 235}
colorBlueDark = &props.Color{Red: 30, Green: 64, Blue: 175}
colorWhite = &props.Color{Red: 255, Green: 255, Blue: 255}
colorGray100 = &props.Color{Red: 248, Green: 250, Blue: 252}
colorGray200 = &props.Color{Red: 226, Green: 232, Blue: 240}
colorGray500 = &props.Color{Red: 100, Green: 116, Blue: 139}
colorGray700 = &props.Color{Red: 55, Green: 65, Blue: 81}
)
// Generate создаёт PDF-отчёт и возвращает байты // Generate создаёт PDF-отчёт и возвращает байты
func Generate(d Data) ([]byte, error) { func Generate(d Data) ([]byte, error) {
normalFont := findFont(cyrillicFontCandidates) normalFont := findFont(cyrillicFontCandidates)
@@ -71,14 +82,19 @@ func Generate(d Data) ([]byte, error) {
WithBottomMargin(15) WithBottomMargin(15)
if normalFont != "" && boldFont != "" { if normalFont != "" && boldFont != "" {
builder = builder. fonts, err := repository.New().
WithCustomFonts([]*entity.CustomFont{ AddUTF8Font(fontFamily, fontstyle.Normal, normalFont).
{Family: fontFamily, Style: fontstyle.Normal, File: normalFont}, AddUTF8Font(fontFamily, fontstyle.Bold, boldFont).
{Family: fontFamily, Style: fontstyle.Bold, File: boldFont}, AddUTF8Font(fontFamily, fontstyle.Italic, normalFont).
{Family: fontFamily, Style: fontstyle.Italic, File: normalFont}, AddUTF8Font(fontFamily, fontstyle.BoldItalic, boldFont).
{Family: fontFamily, Style: fontstyle.BoldItalic, File: boldFont}, Load()
}). if err != nil {
WithDefaultFont(&props.Font{Family: fontFamily, Size: 9}) log.Printf("[report] failed to load DejaVu font: %v", err)
} else {
builder = builder.
WithCustomFonts(fonts).
WithDefaultFont(&props.Font{Family: fontFamily, Size: 9})
}
} else { } else {
log.Println("[report] DejaVu font not found, Cyrillic may not render. Install ttf-dejavu.") log.Println("[report] DejaVu font not found, Cyrillic may not render. Install ttf-dejavu.")
} }
@@ -87,45 +103,54 @@ func Generate(d Data) ([]byte, error) {
// ── Заголовок ───────────────────────────────────────────────────────────── // ── Заголовок ─────────────────────────────────────────────────────────────
m.AddRows(buildHeader(d)...) m.AddRows(buildHeader(d)...)
m.AddRow(3, line.NewCol(12, props.Line{Color: &props.Color{Red: 37, Green: 99, Blue: 235}})) m.AddRow(2, line.NewCol(12, props.Line{Color: colorBlue, Thickness: 0.5}))
m.AddRows(buildMeta(d)...) m.AddRows(buildMeta(d)...)
m.AddRow(5) m.AddRow(6)
// ── Сводка по критичности ───────────────────────────────────────────────── // ── Сводка по критичности ─────────────────────────────────────────────────
m.AddRows(buildSectionTitle("1. Сводка по уровням критичности")...) m.AddRows(buildSectionTitle("1. Сводка по уровням критичности")...)
m.AddRow(2)
m.AddRows(buildSeverityTable(d.BySeverity)...) m.AddRows(buildSeverityTable(d.BySeverity)...)
m.AddRow(4) m.AddRow(6)
// ── Типы угроз ──────────────────────────────────────────────────────────── // ── Типы угроз ────────────────────────────────────────────────────────────
m.AddRows(buildSectionTitle("2. Типы обнаруженных угроз")...) m.AddRows(buildSectionTitle("2. Типы обнаруженных угроз")...)
m.AddRow(2)
m.AddRows(buildTypeTable(d.ByType)...) m.AddRows(buildTypeTable(d.ByType)...)
m.AddRow(4) m.AddRow(6)
// ── Топ источников ──────────────────────────────────────────────────────── // ── Топ источников ────────────────────────────────────────────────────────
if len(d.TopIPs) > 0 { if len(d.TopIPs) > 0 {
m.AddRows(buildSectionTitle("3. Топ источников атак")...) m.AddRows(buildSectionTitle("3. Топ источников атак")...)
m.AddRow(2)
m.AddRows(buildTopIPsTable(d.TopIPs)...) m.AddRows(buildTopIPsTable(d.TopIPs)...)
m.AddRow(4) m.AddRow(6)
} }
// ── Статистика трафика по протоколам ────────────────────────────────────── // ── Статистика трафика по протоколам ──────────────────────────────────────
if len(d.TrafficByProto) > 0 { if len(d.TrafficByProto) > 0 {
m.AddRows(buildSectionTitle("4. Статистика трафика по протоколам")...) m.AddRows(buildSectionTitle("4. Статистика трафика по протоколам")...)
m.AddRow(2)
m.AddRows(buildTrafficProtoTable(d.TrafficByProto)...) m.AddRows(buildTrafficProtoTable(d.TrafficByProto)...)
m.AddRow(4) m.AddRow(6)
} }
// ── Лог трафика (последние записи) ──────────────────────────────────────── // ── Лог трафика (последние записи) ────────────────────────────────────────
if len(d.RecentStats) > 0 { if len(d.RecentStats) > 0 {
m.AddRows(buildSectionTitle(fmt.Sprintf("5. Журнал трафика (последние %d записей)", len(d.RecentStats)))...) limit := len(d.RecentStats)
if limit > 100 {
limit = 100
}
m.AddRows(buildSectionTitle(fmt.Sprintf("5. Журнал трафика (последние %d записей)", limit))...)
m.AddRow(2)
m.AddRows(buildTrafficLogHeader()...) m.AddRows(buildTrafficLogHeader()...)
for i, s := range d.RecentStats { for i, s := range d.RecentStats {
if i >= 100 { if i >= 100 {
break break
} }
m.AddRows(buildTrafficLogRow(s)...) m.AddRows(buildTrafficLogRow(s, i)...)
} }
m.AddRow(4) m.AddRow(6)
} }
// ── Журнал инцидентов ───────────────────────────────────────────────────── // ── Журнал инцидентов ─────────────────────────────────────────────────────
@@ -134,12 +159,13 @@ func Generate(d Data) ([]byte, error) {
sectionNum = 4 sectionNum = 4
} }
m.AddRows(buildSectionTitle(fmt.Sprintf("%d. Журнал инцидентов", sectionNum))...) m.AddRows(buildSectionTitle(fmt.Sprintf("%d. Журнал инцидентов", sectionNum))...)
m.AddRow(2)
m.AddRows(buildAlertsTableHeader()...) m.AddRows(buildAlertsTableHeader()...)
for i, a := range d.Alerts { for i, a := range d.Alerts {
if i >= 200 { if i >= 200 {
break break
} }
m.AddRows(buildAlertRow(a)...) m.AddRows(buildAlertRow(a, i)...)
} }
doc, err := m.Generate() doc, err := m.Generate()
@@ -149,21 +175,22 @@ func Generate(d Data) ([]byte, error) {
return doc.GetBytes(), nil return doc.GetBytes(), nil
} }
// ── Секции отчёта ───────────────────────────────────────────────────────────── // ── Заголовок отчёта ──────────────────────────────────────────────────────────
func buildHeader(d Data) []core.Row { func buildHeader(d Data) []core.Row {
return []core.Row{ return []core.Row{
row.New(14).Add( row.New(10).Add(
col.New(8).Add( col.New(8).Add(
text.New("СИСТЕМА ОБНАРУЖЕНИЯ ВТОРЖЕНИЙ", props.Text{ text.New("СИСТЕМА ОБНАРУЖЕНИЯ ВТОРЖЕНИЙ", props.Text{
Size: 16, Size: 14,
Style: fontstyle.Bold, Style: fontstyle.Bold,
Color: &props.Color{Red: 37, Green: 99, Blue: 235}, Color: colorBlue,
Top: 1,
}), }),
text.New("Отчёт об инцидентах сетевой безопасности", props.Text{ text.New("Отчёт об инцидентах сетевой безопасности", props.Text{
Size: 9, Size: 9,
Top: 8, Top: 7,
Color: &props.Color{Red: 100, Green: 100, Blue: 100}, Color: colorGray500,
}), }),
), ),
col.New(4).Add( col.New(4).Add(
@@ -171,13 +198,14 @@ func buildHeader(d Data) []core.Row {
Size: 9, Size: 9,
Align: align.Right, Align: align.Right,
Style: fontstyle.Bold, Style: fontstyle.Bold,
Color: &props.Color{Red: 37, Green: 99, Blue: 235}, Color: colorBlue,
Top: 1,
}), }),
text.New(fmt.Sprintf("Дата: %s", d.GeneratedAt.Format("02.01.2006 15:04:05")), props.Text{ text.New(fmt.Sprintf("Дата: %s", d.GeneratedAt.Format("02.01.2006 15:04:05")), props.Text{
Size: 8, Size: 8,
Top: 5, Top: 6,
Align: align.Right, Align: align.Right,
Color: &props.Color{Red: 120, Green: 120, Blue: 120}, Color: colorGray500,
}), }),
), ),
), ),
@@ -197,38 +225,52 @@ func buildMeta(d Data) []core.Row {
duration = "активна" duration = "активна"
} }
} }
return []core.Row{ r := row.New(8).Add(
row.New(5).Add( col.New(3).Add(metaCell("Источник", source)),
col.New(3).Add(metaCell("Источник", source)), col.New(3).Add(metaCell("Режим", mode)),
col.New(3).Add(metaCell("Режим", mode)), col.New(3).Add(metaCell("Длительность", duration)),
col.New(3).Add(metaCell("Длительность", duration)), col.New(3).Add(metaCell("Пакетов / Объём", pkts+" / "+bytes)),
col.New(3).Add(metaCell("Пакетов / Объём", pkts+" / "+bytes)), )
), r.WithStyle(&props.Cell{
} BackgroundColor: colorGray100,
BorderColor: colorGray200,
BorderType: border.Full,
BorderThickness: 0.3,
})
return []core.Row{r}
} }
func metaCell(label, value string) core.Component { func metaCell(label, value string) core.Component {
return text.New(label+": "+value, props.Text{ return text.New(label+": "+value, props.Text{
Size: 8, Size: 8,
Color: &props.Color{Red: 80, Green: 80, Blue: 80}, Left: 2,
Top: 2,
Color: colorGray700,
}) })
} }
func buildSectionTitle(title string) []core.Row { func buildSectionTitle(title string) []core.Row {
return []core.Row{ r := row.New(8).Add(
row.New(8).Add( col.New(12).Add(
col.New(12).Add( text.New(title, props.Text{
text.New(title, props.Text{ Size: 10,
Size: 11, Style: fontstyle.Bold,
Style: fontstyle.Bold, Color: colorBlueDark,
Color: &props.Color{Red: 37, Green: 99, Blue: 235}, Left: 1,
}), Top: 2,
), }),
), ),
} )
r.WithStyle(&props.Cell{
BackgroundColor: &props.Color{Red: 239, Green: 246, Blue: 255},
BorderColor: colorBlue,
BorderType: border.Left,
BorderThickness: 2,
})
return []core.Row{r}
} }
// ── Таблицы сводки ──────────────────────────────────────────────────────────── // ── Таблицы ───────────────────────────────────────────────────────────────────
func buildSeverityTable(data []storage.SeverityCount) []core.Row { func buildSeverityTable(data []storage.SeverityCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{ rows := []core.Row{tableHeaderRow([]colDef{
@@ -239,13 +281,23 @@ func buildSeverityTable(data []storage.SeverityCount) []core.Row {
for _, s := range data { for _, s := range data {
counts[s.Severity] = s.Count counts[s.Severity] = s.Count
} }
for _, sev := range []string{"critical", "high", "medium", "low"} { for i, sev := range []string{"critical", "high", "medium", "low"} {
rows = append(rows, row.New(6).Add( r := row.New(7).Add(
col.New(6).Add(text.New(severityLabel(sev), props.Text{ col.New(6).Add(text.New(severityLabel(sev), props.Text{
Size: 9, Style: fontstyle.Bold, Color: severityColor(sev), Size: 9,
Style: fontstyle.Bold,
Color: severityColor(sev),
Left: 2,
Top: 2,
})), })),
col.New(6).Add(text.New(fmt.Sprintf("%d", counts[sev]), props.Text{Size: 9})), col.New(6).Add(text.New(fmt.Sprintf("%d", counts[sev]), props.Text{
)) Size: 9,
Left: 2,
Top: 2,
})),
)
r.WithStyle(dataRowStyle(i))
rows = append(rows, r)
} }
return rows return rows
} }
@@ -255,11 +307,13 @@ func buildTypeTable(data []storage.TypeCount) []core.Row {
{w: 6, label: "Тип атаки"}, {w: 6, label: "Тип атаки"},
{w: 6, label: "Количество"}, {w: 6, label: "Количество"},
})} })}
for _, t := range data { for i, t := range data {
rows = append(rows, row.New(6).Add( r := row.New(7).Add(
col.New(6).Add(text.New(typeLabel(t.Type), props.Text{Size: 9})), col.New(6).Add(text.New(typeLabel(t.Type), props.Text{Size: 9, Left: 2, Top: 2})),
col.New(6).Add(text.New(fmt.Sprintf("%d", t.Count), props.Text{Size: 9})), col.New(6).Add(text.New(fmt.Sprintf("%d", t.Count), props.Text{Size: 9, Left: 2, Top: 2})),
)) )
r.WithStyle(dataRowStyle(i))
rows = append(rows, r)
} }
return rows return rows
} }
@@ -267,39 +321,39 @@ func buildTypeTable(data []storage.TypeCount) []core.Row {
func buildTopIPsTable(data []storage.IPCount) []core.Row { func buildTopIPsTable(data []storage.IPCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{ rows := []core.Row{tableHeaderRow([]colDef{
{w: 1, label: "#"}, {w: 1, label: "#"},
{w: 7, label: "IP-адрес источника"}, {w: 8, label: "IP-адрес источника"},
{w: 4, label: "Алертов"}, {w: 3, label: "Алертов"},
})} })}
for i, ip := range data { for i, ip := range data {
rows = append(rows, row.New(6).Add( r := row.New(7).Add(
col.New(1).Add(text.New(fmt.Sprintf("%d", i+1), props.Text{Size: 8})), col.New(1).Add(text.New(fmt.Sprintf("%d", i+1), props.Text{Size: 8, Left: 2, Top: 2})),
col.New(7).Add(text.New(ip.SrcIP, props.Text{Size: 8})), col.New(8).Add(text.New(ip.SrcIP, props.Text{Size: 8, Left: 2, Top: 2})),
col.New(4).Add(text.New(fmt.Sprintf("%d", ip.Count), props.Text{Size: 8})), col.New(3).Add(text.New(fmt.Sprintf("%d", ip.Count), props.Text{Size: 8, Left: 2, Top: 2})),
)) )
r.WithStyle(dataRowStyle(i))
rows = append(rows, r)
} }
return rows return rows
} }
// ── Таблица трафика по протоколам ─────────────────────────────────────────────
func buildTrafficProtoTable(data []storage.ProtocolCount) []core.Row { func buildTrafficProtoTable(data []storage.ProtocolCount) []core.Row {
rows := []core.Row{tableHeaderRow([]colDef{ rows := []core.Row{tableHeaderRow([]colDef{
{w: 4, label: "Протокол"}, {w: 4, label: "Протокол"},
{w: 4, label: "Пакетов"}, {w: 4, label: "Пакетов"},
{w: 4, label: "Объём трафика"}, {w: 4, label: "Объём трафика"},
})} })}
for _, p := range data { for i, p := range data {
rows = append(rows, row.New(6).Add( r := row.New(7).Add(
col.New(4).Add(text.New(p.Protocol, props.Text{Size: 9, Style: fontstyle.Bold})), col.New(4).Add(text.New(p.Protocol, props.Text{Size: 9, Style: fontstyle.Bold, Left: 2, Top: 2})),
col.New(4).Add(text.New(fmt.Sprintf("%d", p.Count), props.Text{Size: 9})), col.New(4).Add(text.New(fmt.Sprintf("%d", p.Count), props.Text{Size: 9, Left: 2, Top: 2})),
col.New(4).Add(text.New(humanBytes(p.Bytes), props.Text{Size: 9})), col.New(4).Add(text.New(humanBytes(p.Bytes), props.Text{Size: 9, Left: 2, Top: 2})),
)) )
r.WithStyle(dataRowStyle(i))
rows = append(rows, r)
} }
return rows return rows
} }
// ── Журнал трафика ────────────────────────────────────────────────────────────
func buildTrafficLogHeader() []core.Row { func buildTrafficLogHeader() []core.Row {
return []core.Row{tableHeaderRow([]colDef{ return []core.Row{tableHeaderRow([]colDef{
{w: 3, label: "Время"}, {w: 3, label: "Время"},
@@ -309,41 +363,43 @@ func buildTrafficLogHeader() []core.Row {
})} })}
} }
func buildTrafficLogRow(s storage.PacketStat) []core.Row { func buildTrafficLogRow(s storage.PacketStat, idx int) []core.Row {
return []core.Row{ r := row.New(6).Add(
row.New(5).Add( col.New(3).Add(text.New(s.CreatedAt.Format("02.01 15:04:05"), props.Text{Size: 7, Left: 2, Top: 1})),
col.New(3).Add(text.New(s.CreatedAt.Format("02.01 15:04:05"), props.Text{Size: 7})), col.New(2).Add(text.New(s.Protocol, props.Text{Size: 7, Style: fontstyle.Bold, Left: 2, Top: 1})),
col.New(2).Add(text.New(s.Protocol, props.Text{Size: 7, Style: fontstyle.Bold})), col.New(4).Add(text.New(s.SrcIP, props.Text{Size: 7, Left: 2, Top: 1})),
col.New(4).Add(text.New(s.SrcIP, props.Text{Size: 7})), col.New(3).Add(text.New(s.DstIP+" "+humanBytes(s.Bytes), props.Text{Size: 7, Left: 2, Top: 1})),
col.New(3).Add(text.New(s.DstIP+" "+humanBytes(s.Bytes), props.Text{Size: 7})), )
), r.WithStyle(dataRowStyle(idx))
} return []core.Row{r}
} }
// ── Журнал инцидентов ─────────────────────────────────────────────────────────
func buildAlertsTableHeader() []core.Row { func buildAlertsTableHeader() []core.Row {
return []core.Row{tableHeaderRow([]colDef{ return []core.Row{tableHeaderRow([]colDef{
{w: 3, label: "Дата/Время"}, {w: 3, label: "Дата/Время"},
{w: 2, label: "Тип"}, {w: 2, label: "Тип"},
{w: 2, label: "Критичность"}, {w: 2, label: "Критичность"},
{w: 2, label: "Источник"}, {w: 3, label: "Источник"},
{w: 3, label: "Описание"}, {w: 2, label: "Описание"},
})} })}
} }
func buildAlertRow(a storage.Alert) []core.Row { func buildAlertRow(a storage.Alert, idx int) []core.Row {
return []core.Row{ r := row.New(7).Add(
row.New(6).Add( col.New(3).Add(text.New(a.CreatedAt.Format("02.01.06 15:04"), props.Text{Size: 7, Left: 2, Top: 2})),
col.New(3).Add(text.New(a.CreatedAt.Format("02.01.06 15:04:05"), props.Text{Size: 7})), col.New(2).Add(text.New(typeLabel(a.Type), props.Text{Size: 7, Left: 2, Top: 2})),
col.New(2).Add(text.New(typeLabel(a.Type), props.Text{Size: 7})), col.New(2).Add(text.New(severityLabel(a.Severity), props.Text{
col.New(2).Add(text.New(severityLabel(a.Severity), props.Text{ Size: 7,
Size: 7, Style: fontstyle.Bold, Color: severityColor(a.Severity), Style: fontstyle.Bold,
})), Color: severityColor(a.Severity),
col.New(2).Add(text.New(a.SrcIP, props.Text{Size: 7})), Left: 2,
col.New(3).Add(text.New(truncate(a.Description, 60), props.Text{Size: 7})), Top: 2,
), })),
} col.New(3).Add(text.New(a.SrcIP, props.Text{Size: 7, Left: 2, Top: 2})),
col.New(2).Add(text.New(truncate(a.Description, 40), props.Text{Size: 7, Left: 2, Top: 2})),
)
r.WithStyle(dataRowStyle(idx))
return []core.Row{r}
} }
// ── Вспомогательные типы и функции ─────────────────────────────────────────── // ── Вспомогательные типы и функции ───────────────────────────────────────────
@@ -360,11 +416,33 @@ func tableHeaderRow(cols []colDef) core.Row {
text.New(c.label, props.Text{ text.New(c.label, props.Text{
Size: 9, Size: 9,
Style: fontstyle.Bold, Style: fontstyle.Bold,
Color: &props.Color{Red: 255, Green: 255, Blue: 255}, Color: colorWhite,
Left: 2,
Top: 2,
}), }),
)) ))
} }
return row.New(7).Add(cells...) r := row.New(8).Add(cells...)
r.WithStyle(&props.Cell{
BackgroundColor: colorBlue,
BorderColor: colorBlueDark,
BorderType: border.Full,
BorderThickness: 0.3,
})
return r
}
func dataRowStyle(idx int) *props.Cell {
bg := colorWhite
if idx%2 == 0 {
bg = colorGray100
}
return &props.Cell{
BackgroundColor: bg,
BorderColor: colorGray200,
BorderType: border.Bottom,
BorderThickness: 0.3,
}
} }
func severityLabel(s string) string { func severityLabel(s string) string {
@@ -433,8 +511,9 @@ func humanBytes(b int64) string {
} }
func truncate(s string, n int) string { func truncate(s string, n int) string {
if len([]rune(s)) <= n { runes := []rune(s)
if len(runes) <= n {
return s return s
} }
return string([]rune(s)[:n-3]) + "..." return string(runes[:n-3]) + "..."
} }
+14 -14
View File
@@ -87,8 +87,8 @@ func (d *DB) CreatePacketStat(s *PacketStat) error {
} }
type SeverityCount struct { type SeverityCount struct {
Severity string Severity string `json:"severity"`
Count int64 Count int64 `json:"count"`
} }
func (d *DB) AlertsBySeverity() ([]SeverityCount, error) { func (d *DB) AlertsBySeverity() ([]SeverityCount, error) {
@@ -101,8 +101,8 @@ func (d *DB) AlertsBySeverity() ([]SeverityCount, error) {
} }
type TypeCount struct { type TypeCount struct {
Type string Type string `json:"type"`
Count int64 Count int64 `json:"count"`
} }
func (d *DB) AlertsByType() ([]TypeCount, error) { func (d *DB) AlertsByType() ([]TypeCount, error) {
@@ -115,8 +115,8 @@ func (d *DB) AlertsByType() ([]TypeCount, error) {
} }
type IPCount struct { type IPCount struct {
SrcIP string SrcIP string `json:"src_ip"`
Count int64 Count int64 `json:"count"`
} }
func (d *DB) TopSourceIPs(limit int) ([]IPCount, error) { func (d *DB) TopSourceIPs(limit int) ([]IPCount, error) {
@@ -131,16 +131,16 @@ func (d *DB) TopSourceIPs(limit int) ([]IPCount, error) {
} }
type TimelinePoint struct { type TimelinePoint struct {
Hour string Hour string `json:"hour"`
Count int64 Count int64 `json:"count"`
} }
func (d *DB) AlertsTimeline(hours int) ([]TimelinePoint, error) { func (d *DB) AlertsTimeline(hours int) ([]TimelinePoint, error) {
since := time.Now().Add(-time.Duration(hours) * time.Hour) since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
var result []TimelinePoint var result []TimelinePoint
err := d.db.Model(&Alert{}). err := d.db.Model(&Alert{}).
Select("strftime('%Y-%m-%dT%H:00:00', created_at) as hour, count(*) as count"). Select("strftime('%Y-%m-%dT%H:00:00Z', created_at) as hour, count(*) as count").
Where("created_at >= ?", since). Where("created_at >= datetime(?)", since.Format("2006-01-02 15:04:05")).
Group("hour"). Group("hour").
Order("hour asc"). Order("hour asc").
Find(&result).Error Find(&result).Error
@@ -154,9 +154,9 @@ func (d *DB) TotalAlerts() (int64, error) {
} }
type ProtocolCount struct { type ProtocolCount struct {
Protocol string Protocol string `json:"protocol"`
Count int64 Count int64 `json:"count"`
Bytes int64 Bytes int64 `json:"bytes"`
} }
func (d *DB) TrafficByProtocol() ([]ProtocolCount, error) { func (d *DB) TrafficByProtocol() ([]ProtocolCount, error) {
BIN
View File
Binary file not shown.