feat: move navigation to dashboard sidebar and bump SAC to 0.5.0

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 13:45:25 +10:00
parent 81cf9c46b3
commit dce5b88b2f
6 changed files with 646 additions and 313 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.2.0" APP_VERSION = "0.5.0"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants(): def test_version_constants():
assert APP_VERSION == "0.2.0" assert APP_VERSION == "0.5.0"
assert APP_NAME == "Security Alert Center" assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.2.0" assert APP_VERSION_LABEL == "Security Alert Center v.0.5.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sac-ui", "name": "sac-ui",
"private": true, "private": true,
"version": "0.2.0", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
-28
View File
@@ -1,33 +1,5 @@
<template> <template>
<div class="layout"> <div class="layout">
<nav v-if="showNav">
<span class="brand">{{ appTitle }}</span>
<RouterLink to="/dashboard">Dashboard</RouterLink>
<RouterLink to="/events">События</RouterLink>
<RouterLink to="/reports">Отчёты</RouterLink>
<RouterLink to="/problems">Проблемы</RouterLink>
<RouterLink to="/hosts">Хосты</RouterLink>
<button type="button" class="secondary" @click="logout">Выход</button>
</nav>
<RouterView /> <RouterView />
</div> </div>
</template> </template>
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { clearToken, getToken } from "./api";
import { APP_VERSION_LABEL } from "./version";
const appTitle = APP_VERSION_LABEL;
const route = useRoute();
const router = useRouter();
const showNav = computed(() => route.path !== "/login" && !!getToken());
function logout() {
clearToken();
router.push("/login");
}
</script>
+1 -1
View File
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center"; export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.2.0"; export const APP_VERSION = "0.5.0";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+397 -36
View File
@@ -1,280 +1,641 @@
<template> <template>
<div class="dashboard-layout">
<aside class="dashboard-sidebar">
<div class="dashboard-sidebar-title">{{ appTitle }}</div>
<RouterLink to="/dashboard">Dashboard</RouterLink>
<RouterLink to="/events">События</RouterLink>
<RouterLink to="/reports">Отчёты</RouterLink>
<RouterLink to="/problems">Проблемы</RouterLink>
<RouterLink to="/hosts">Хосты</RouterLink>
<button type="button" class="secondary" @click="logout">Выход</button>
</aside>
<section class="dashboard-content">
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p> <p v-else-if="loading">Загрузка</p>
<template v-else-if="data"> <template v-else-if="data">
<div class="dashboard-cards"> <div class="dashboard-cards">
<div class="dash-card"> <div class="dash-card">
<div class="dash-value">{{ data.events_last_24h }}</div> <div class="dash-value">{{ data.events_last_24h }}</div>
<div class="dash-label">Событий за 24 ч</div> <div class="dash-label">Событий за 24 ч</div>
<RouterLink to="/events">Все события </RouterLink> <RouterLink to="/events">Все события </RouterLink>
</div> </div>
<div class="dash-card"> <div class="dash-card">
<div class="dash-value">{{ data.problems_open }}</div> <div class="dash-value">{{ data.problems_open }}</div>
<div class="dash-label">Открытых проблем</div> <div class="dash-label">Открытых проблем</div>
<RouterLink :to="{ path: '/problems', query: { status: 'open' } }">Проблемы </RouterLink> <RouterLink :to="{ path: '/problems', query: { status: 'open' } }">Проблемы </RouterLink>
</div> </div>
<div class="dash-card"> <div class="dash-card">
<div class="dash-value">{{ data.problems_opened_24h }}</div> <div class="dash-value">{{ data.problems_opened_24h }}</div>
<div class="dash-label">Новых проблем за 24 ч</div> <div class="dash-label">Новых проблем за 24 ч</div>
<RouterLink to="/problems">Все проблемы </RouterLink> <RouterLink to="/problems">Все проблемы </RouterLink>
</div> </div>
<div class="dash-card"> <div class="dash-card">
<div class="dash-value">{{ data.problems_resolved_24h }}</div> <div class="dash-value">{{ data.problems_resolved_24h }}</div>
<div class="dash-label">Закрыто за 24 ч</div> <div class="dash-label">Закрыто за 24 ч</div>
<RouterLink :to="{ path: '/problems', query: { status: 'resolved' } }">Resolved </RouterLink> <RouterLink :to="{ path: '/problems', query: { status: 'resolved' } }">Resolved </RouterLink>
</div> </div>
<div class="dash-card"> <div class="dash-card">
<div class="dash-value">{{ data.hosts_total }}</div> <div class="dash-value">{{ data.hosts_total }}</div>
<div class="dash-label">Хостов</div> <div class="dash-label">Хостов</div>
<RouterLink to="/hosts">Хосты </RouterLink> <RouterLink to="/hosts">Хосты </RouterLink>
</div> </div>
<div class="dash-card" :class="{ 'dash-warn': data.hosts_stale > 0 }"> <div class="dash-card" :class="{ 'dash-warn': data.hosts_stale > 0 }">
<div class="dash-value">{{ data.hosts_stale }}</div> <div class="dash-value">{{ data.hosts_stale }}</div>
<div class="dash-label">Устаревший heartbeat</div> <div class="dash-label">Устаревший heartbeat</div>
<RouterLink to="/hosts">Хосты </RouterLink> <RouterLink to="/hosts">Хосты </RouterLink>
</div> </div>
<div class="dash-card"> <div class="dash-card">
<div class="dash-value">{{ data.heartbeats_24h }}</div> <div class="dash-value">{{ data.heartbeats_24h }}</div>
<div class="dash-label">Heartbeat за 24 ч</div> <div class="dash-label">Heartbeat за 24 ч</div>
<RouterLink :to="{ path: '/events', query: { type: 'agent.heartbeat' } }">События </RouterLink> <RouterLink :to="{ path: '/events', query: { type: 'agent.heartbeat' } }">События </RouterLink>
</div> </div>
<div class="dash-card"> <div class="dash-card">
<div class="dash-value">{{ data.daily_reports_24h }}</div> <div class="dash-value">{{ data.daily_reports_24h }}</div>
<div class="dash-label">Отчёты за 24 ч</div> <div class="dash-label">Отчёты за 24 ч</div>
<RouterLink to="/reports">Отчёты </RouterLink> <RouterLink to="/reports">Отчёты </RouterLink>
</div> </div>
</div> </div>
<div class="dashboard-panels"> <div class="dashboard-panels">
</div>
<div class="dash-card">
<div class="dash-value">{{ data.hosts_total }}</div>
<div class="dash-label">Хостов</div>
<RouterLink to="/hosts">Хосты </RouterLink>
</div>
<div class="dash-card" :class="{ 'dash-warn': data.hosts_stale > 0 }">
<div class="dash-value">{{ data.hosts_stale }}</div>
<div class="dash-label">Устаревший heartbeat</div>
<RouterLink to="/hosts">Хосты </RouterLink>
</div>
<section class="dash-panel"> <section class="dash-panel">
<div class="dash-value">{{ data.heartbeats_24h }}</div> <h2>Топ хостов (24 ч)</h2>
<div v-if="data.top_hosts.length" class="dash-table-scroll">
<table class="dash-table">
<thead> <thead>
<tr> <tr>
<th>Хост</th>
<th>Событий</th> <th>Событий</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<div class="dash-card">
<tr v-for="h in data.top_hosts" :key="h.host_id">
<td> <td>
<div class="dash-value">{{ data.daily_reports_24h }}</div>
<RouterLink :to="{ path: '/events', query: { hostname: h.hostname } }">
{{ h.hostname }}
</RouterLink> </RouterLink>
</td> </td>
<RouterLink to="/reports">Отчёты </RouterLink>
<td>{{ h.count }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
<p v-else class="muted">Нет событий за 24 ч</p>
</section>
<section class="dash-panel">
<h2>Топ типов (24 ч)</h2>
<div v-if="data.top_event_types.length" class="dash-table-scroll">
<table class="dash-table">
<thead>
<tr>
<th>Type</th>
<th>Событий</th>
</tr>
</thead>
<tbody>
<tr v-for="t in data.top_event_types" :key="t.type">
<td>
<RouterLink :to="{ path: '/events', query: { type: t.type } }">
<code>{{ t.type }}</code>
</RouterLink>
</td>
<td>{{ t.count }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="muted">Нет событий за 24 ч</p>
</section>
</div>
<h2>Severity за 24 ч</h2> <h2>Severity за 24 ч</h2>
<ul class="severity-list"> <ul class="severity-list">
<li v-for="(count, sev) in data.severity_24h" :key="sev"> <li v-for="(count, sev) in data.severity_24h" :key="sev">
<RouterLink :to="{ path: '/events', query: { severity: sev } }"> <RouterLink :to="{ path: '/events', query: { severity: sev } }">
<span :class="'sev-' + sev">{{ sev }}</span>: {{ count }} <span :class="'sev-' + sev">{{ sev }}</span>: {{ count }}
</RouterLink> </RouterLink>
</li> </li>
<li v-if="!Object.keys(data.severity_24h).length">Нет событий</li> <li v-if="!Object.keys(data.severity_24h).length">Нет событий</li>
</ul> </ul>
<h2>Последние события</h2> <h2>Последние события</h2>
<p class="muted dash-recent-hint"> <p class="muted dash-recent-hint">
Обновляется при поступлении событий (live, ~5 с). Время момент приёма SAC. Обновляется при поступлении событий (live, ~5 с). Время момент приёма SAC.
</p> </p>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Получено</th> <th>Получено</th>
<th>Хост</th> <th>Хост</th>
<th>Severity</th> <th>Severity</th>
<th>Title</th> <th>Title</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="e in data.recent_events" :key="e.id"> <tr v-for="e in data.recent_events" :key="e.id">
<td> <td>
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink> <RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
</td> </td>
<td>{{ formatDt(e.received_at) }}</td> <td>{{ formatDt(e.received_at) }}</td>
<td> <td>
<RouterLink :to="{ path: '/events', query: { hostname: e.hostname } }"> <RouterLink :to="{ path: '/events', query: { hostname: e.hostname } }">
{{ e.hostname }} {{ e.hostname }}
</RouterLink> </RouterLink>
</td> </td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td> <td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td>{{ e.title }}</td> <td>{{ e.title }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="filters" style="margin-top: 1rem"> <div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" @click="load">Обновить всё</button> <button type="button" class="secondary" @click="load">Обновить всё</button>
<span v-if="live" class="live-badge">live</span> <span v-if="live" class="live-badge">live</span>
</div>
</template>
</section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch, clearToken, getToken, type DashboardSummary, type EventSummary } from "../api";
import { APP_VERSION_LABEL } from "../version";
const data = ref<DashboardSummary | null>(null); const data = ref<DashboardSummary | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
const live = ref(false); const live = ref(false);
const lastKnownEventDbId = ref<number | null>(null); const lastKnownEventDbId = ref<number | null>(null);
let eventSource: EventSource | null = null; let eventSource: EventSource | null = null;
let refreshingRecent = false; let refreshingRecent = false;
const router = useRouter();
const appTitle = APP_VERSION_LABEL;
function logout() {
clearToken();
router.push("/login");
}
function formatDt(iso: string) { function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU"); return new Date(iso).toLocaleString("ru-RU");
} }
function syncLastKnownFromRecent() { function syncLastKnownFromRecent() {
const top = data.value?.recent_events?.[0]; const top = data.value?.recent_events?.[0];
lastKnownEventDbId.value = top?.id ?? null; lastKnownEventDbId.value = top?.id ?? null;
} }
async function refreshRecentEvents() { async function refreshRecentEvents() {
if (!data.value || refreshingRecent) return; if (!data.value || refreshingRecent) return;
refreshingRecent = true; refreshingRecent = true;
try { try {
const events = await apiFetch<EventSummary[]>( const events = await apiFetch<EventSummary[]>(
"/api/v1/dashboards/recent-events?limit=8", "/api/v1/dashboards/recent-events?limit=8",
); );
data.value.recent_events = events; data.value.recent_events = events;
syncLastKnownFromRecent(); syncLastKnownFromRecent();
} catch { } catch {
/* keep table on transient errors */ /* keep table on transient errors */
} finally { } finally {
refreshingRecent = false; refreshingRecent = false;
} }
} }
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
try { try {
data.value = await apiFetch<DashboardSummary>("/api/v1/dashboards/summary"); data.value = await apiFetch<DashboardSummary>("/api/v1/dashboards/summary");
syncLastKnownFromRecent(); syncLastKnownFromRecent();
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : String(e); error.value = e instanceof Error ? e.message : String(e);
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
function applyDashboardCounters(msg: { function applyDashboardCounters(msg: {
events_last_24h?: number; events_last_24h?: number;
problems_open?: number; problems_open?: number;
hosts_stale?: number; hosts_stale?: number;
heartbeats_24h?: number; heartbeats_24h?: number;
daily_reports_24h?: number; daily_reports_24h?: number;
last_event_id?: number | null; last_event_id?: number | null;
}) { }) {
if (!data.value) return; if (!data.value) return;
if (typeof msg.events_last_24h === "number") { if (typeof msg.events_last_24h === "number") {
data.value.events_last_24h = msg.events_last_24h; data.value.events_last_24h = msg.events_last_24h;
} }
if (typeof msg.problems_open === "number") { if (typeof msg.problems_open === "number") {
data.value.problems_open = msg.problems_open; data.value.problems_open = msg.problems_open;
} }
if (typeof msg.hosts_stale === "number") { if (typeof msg.hosts_stale === "number") {
data.value.hosts_stale = msg.hosts_stale; data.value.hosts_stale = msg.hosts_stale;
} }
if (typeof msg.heartbeats_24h === "number") { if (typeof msg.heartbeats_24h === "number") {
data.value.heartbeats_24h = msg.heartbeats_24h; data.value.heartbeats_24h = msg.heartbeats_24h;
} }
if (typeof msg.daily_reports_24h === "number") { if (typeof msg.daily_reports_24h === "number") {
data.value.daily_reports_24h = msg.daily_reports_24h; data.value.daily_reports_24h = msg.daily_reports_24h;
} }
const incoming = msg.last_event_id; const incoming = msg.last_event_id;
if (incoming === undefined) return; if (incoming === undefined) return;
const prev = lastKnownEventDbId.value; const prev = lastKnownEventDbId.value;
if (incoming === null) { if (incoming === null) {
if (prev !== null) void refreshRecentEvents(); if (prev !== null) void refreshRecentEvents();
lastKnownEventDbId.value = null; lastKnownEventDbId.value = null;
return; return;
} }
if (prev === null || incoming !== prev) { if (prev === null || incoming !== prev) {
void refreshRecentEvents(); void refreshRecentEvents();
} }
lastKnownEventDbId.value = incoming; lastKnownEventDbId.value = incoming;
} }
function connectLive() { function connectLive() {
const token = getToken(); const token = getToken();
if (!token) return; if (!token) return;
eventSource?.close(); eventSource?.close();
const url = `/api/v1/stream/events?token=${encodeURIComponent(token)}`; const url = `/api/v1/stream/events?token=${encodeURIComponent(token)}`;
eventSource = new EventSource(url); eventSource = new EventSource(url);
eventSource.onopen = () => { eventSource.onopen = () => {
live.value = true; live.value = true;
}; };
eventSource.onmessage = (ev) => { eventSource.onmessage = (ev) => {
try { try {
const msg = JSON.parse(ev.data) as { const msg = JSON.parse(ev.data) as {
type?: string; type?: string;
events_last_24h?: number; events_last_24h?: number;
problems_open?: number; problems_open?: number;
hosts_stale?: number; hosts_stale?: number;
heartbeats_24h?: number; heartbeats_24h?: number;
daily_reports_24h?: number; daily_reports_24h?: number;
last_event_id?: number | null; last_event_id?: number | null;
}; };
if (msg.type !== "dashboard" || !data.value) return; if (msg.type !== "dashboard" || !data.value) return;
applyDashboardCounters(msg); applyDashboardCounters(msg);
} catch { } catch {
/* ignore malformed SSE */ /* ignore malformed SSE */
} }
}; };
eventSource.onerror = () => { eventSource.onerror = () => {
live.value = false; live.value = false;
}; };
} }
onMounted(() => { onMounted(() => {
load().then(() => connectLive()); load().then(() => connectLive());
}); });
onUnmounted(() => { onUnmounted(() => {
eventSource?.close(); eventSource?.close();
eventSource = null; eventSource = null;
}); });
</script> </script>
<style scoped> <style scoped>
<button type="button" class="secondary" @click="load">Обновить всё</button>
.dashboard-layout {
<span v-if="live" class="live-badge">live</span>
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 1rem;
} }
.dashboard-sidebar {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: #1a2332;
border: 1px solid #2a3441;
border-radius: 8px;
padding: 0.9rem;
align-self: start;
width: max-content;
}
.dashboard-sidebar-title {
font-weight: 700;
color: #fff;
margin-bottom: 0.5rem;
white-space: nowrap;
}
.dashboard-content {
min-width: 0;
}
.dash-table-scroll {
max-height: 21rem;
overflow-y: auto;
}
.dash-recent-hint {
margin: -0.25rem 0 0.75rem;
font-size: 0.9rem;
}
</style> </style>