Files
security-alert-center/frontend/src/views/DashboardView.vue
T

593 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="overview-page">
<h1>Обзор</h1>
<p class="dashboard-sac-version">{{ appName }} v.{{ appVersion }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
<template v-else-if="data">
<div class="dashboard-cards">
<div class="dash-card">
<div class="dash-value">{{ data.events_last_24h }}</div>
<div class="dash-label">Событий за 24 ч</div>
<RouterLink to="/events">Все события </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.problems_open }}</div>
<div class="dash-label">Открытых проблем</div>
<RouterLink :to="{ path: '/problems', query: { status: 'open' } }">Проблемы </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.problems_opened_24h }}</div>
<div class="dash-label">Новых проблем за 24 ч</div>
<RouterLink :to="{ path: '/problems', query: { created_within_hours: '24' } }">Новые за 24 ч </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.problems_resolved_24h }}</div>
<div class="dash-label">Закрыто за 24 ч</div>
<RouterLink :to="{ path: '/problems', query: { resolved_within_hours: '24' } }">Закрытые за 24 ч </RouterLink>
</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="{ path: '/hosts', query: { agent_status: 'stale' } }">Stale-хосты </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.heartbeats_24h }}</div>
<div class="dash-label">Heartbeat за 24 ч</div>
<RouterLink :to="{ path: '/events', query: { type: 'agent.heartbeat' } }">События </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.daily_reports_24h }}</div>
<div class="dash-label">Отчёты за 24 ч</div>
<RouterLink to="/reports">Отчёты </RouterLink>
</div>
</div>
<div class="dashboard-panels">
<section class="dash-panel">
<h2>Топ хостов (24 ч)</h2>
<div v-if="data.top_hosts.length" class="dash-table-scroll">
<table class="dash-table">
<thead>
<tr>
<th>Хост</th>
<th>Событий</th>
</tr>
</thead>
<tbody>
<tr v-for="h in data.top_hosts" :key="h.host_id">
<td>
<RouterLink :to="{ path: '/events', query: { hostname: h.hostname } }">
{{ h.hostname }}
</RouterLink>
</td>
<td>{{ h.count }}</td>
</tr>
</tbody>
</table>
</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>
<ul class="severity-list">
<li v-for="(count, sev) in data.severity_24h" :key="sev">
<RouterLink :to="{ path: '/events', query: { severity: sev } }">
<span :class="'sev-' + sev">{{ sev }}</span>: {{ count }}
</RouterLink>
</li>
<li v-if="!Object.keys(data.severity_24h).length">Нет событий</li>
</ul>
<h2>Последние события</h2>
<p class="muted dash-recent-hint">
Обновляется при поступлении событий (live, ~5 с). Время момент приёма SAC.
</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Получено</th>
<th>Хост</th>
<th>Имя сервера</th>
<th>Пользователь</th>
<th>Версия агента</th>
<th>Severity</th>
<th>Title</th>
</tr>
</thead>
<tbody>
<tr v-for="e in data.recent_events" :key="e.id">
<td>
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
</td>
<td>{{ formatDt(e.received_at) }}</td>
<td>
<RouterLink :to="{ path: '/events', query: { hostname: e.hostname } }">
{{ e.hostname }}
</RouterLink>
</td>
<td>{{ formatServerName(e.display_name) }}</td>
<td>{{ e.actor_user || "—" }}</td>
<td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td>{{ e.title }}</td>
</tr>
</tbody>
</table>
<div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" @click="load">Обновить всё</button>
<span v-if="live" class="live-badge">live</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api";
import { useSacVersion } from "../composables/useSacVersion";
import { formatServerName } from "../utils/hostDisplay";
const { appName, appVersion } = useSacVersion();
const data = ref<DashboardSummary | null>(null);
const loading = ref(false);
const error = ref("");
const live = ref(false);
const lastKnownEventDbId = ref<number | null>(null);
let eventSource: EventSource | null = null;
let refreshingRecent = false;
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
}
function syncLastKnownFromRecent() {
const top = data.value?.recent_events?.[0];
lastKnownEventDbId.value = top?.id ?? null;
}
async function refreshRecentEvents() {
if (!data.value || refreshingRecent) return;
refreshingRecent = true;
try {
const events = await apiFetch<EventSummary[]>(
"/api/v1/dashboards/recent-events?limit=8",
);
data.value.recent_events = events;
syncLastKnownFromRecent();
} catch {
/* keep table on transient errors */
} finally {
refreshingRecent = false;
}
}
async function load() {
loading.value = true;
error.value = "";
try {
data.value = await apiFetch<DashboardSummary>("/api/v1/dashboards/summary");
syncLastKnownFromRecent();
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
}
function applyDashboardCounters(msg: {
events_last_24h?: number;
problems_open?: number;
hosts_stale?: number;
heartbeats_24h?: number;
daily_reports_24h?: number;
last_event_id?: number | null;
}) {
if (!data.value) return;
if (typeof msg.events_last_24h === "number") {
data.value.events_last_24h = msg.events_last_24h;
}
if (typeof msg.problems_open === "number") {
data.value.problems_open = msg.problems_open;
}
if (typeof msg.hosts_stale === "number") {
data.value.hosts_stale = msg.hosts_stale;
}
if (typeof msg.heartbeats_24h === "number") {
data.value.heartbeats_24h = msg.heartbeats_24h;
}
if (typeof msg.daily_reports_24h === "number") {
data.value.daily_reports_24h = msg.daily_reports_24h;
}
const incoming = msg.last_event_id;
if (incoming === undefined) return;
const prev = lastKnownEventDbId.value;
if (incoming === null) {
if (prev !== null) void refreshRecentEvents();
lastKnownEventDbId.value = null;
return;
}
if (prev === null || incoming !== prev) {
void refreshRecentEvents();
}
lastKnownEventDbId.value = incoming;
}
function connectLive() {
const token = getToken();
if (!token) return;
eventSource?.close();
const url = `/api/v1/stream/events?token=${encodeURIComponent(token)}`;
eventSource = new EventSource(url);
eventSource.onopen = () => {
live.value = true;
};
eventSource.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data) as {
type?: string;
events_last_24h?: number;
problems_open?: number;
hosts_stale?: number;
heartbeats_24h?: number;
daily_reports_24h?: number;
last_event_id?: number | null;
};
if (msg.type !== "dashboard" || !data.value) return;
applyDashboardCounters(msg);
} catch {
/* ignore malformed SSE */
}
};
eventSource.onerror = () => {
live.value = false;
};
}
onMounted(() => {
load().then(() => connectLive());
});
onUnmounted(() => {
eventSource?.close();
eventSource = null;
});
</script>
<style scoped>
.dash-table-scroll {
max-height: 21rem;
overflow-y: auto;
}
.dashboard-sac-version {
margin: -0.35rem 0 1rem;
color: #9aa4b2;
font-size: 0.9rem;
}
.dash-recent-hint {
margin: -0.25rem 0 0.75rem;
font-size: 0.9rem;
}
</style>