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:
@@ -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}"
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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}`;
|
||||||
|
|||||||
@@ -1,280 +1,641 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="dashboard-layout">
|
||||||
<h1>Dashboard</h1>
|
<aside class="dashboard-sidebar">
|
||||||
|
<div class="dashboard-sidebar-title">{{ appTitle }}</div>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<RouterLink to="/dashboard">Dashboard</RouterLink>
|
||||||
|
<RouterLink to="/events">События</RouterLink>
|
||||||
<p v-else-if="loading">Загрузка…</p>
|
<RouterLink to="/reports">Отчёты</RouterLink>
|
||||||
|
<RouterLink to="/problems">Проблемы</RouterLink>
|
||||||
<template v-else-if="data">
|
<RouterLink to="/hosts">Хосты</RouterLink>
|
||||||
|
<button type="button" class="secondary" @click="logout">Выход</button>
|
||||||
<div class="dashboard-cards">
|
</aside>
|
||||||
|
<section class="dashboard-content">
|
||||||
<div class="dash-card">
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
<div class="dash-value">{{ data.events_last_24h }}</div>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
<div class="dash-label">Событий за 24 ч</div>
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
|
|
||||||
<RouterLink to="/events">Все события →</RouterLink>
|
<template v-else-if="data">
|
||||||
|
|
||||||
</div>
|
<div class="dashboard-cards">
|
||||||
|
|
||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
|
|
||||||
<div class="dash-value">{{ data.problems_open }}</div>
|
<div class="dash-value">{{ data.events_last_24h }}</div>
|
||||||
|
|
||||||
<div class="dash-label">Открытых проблем</div>
|
<div class="dash-label">Событий за 24 ч</div>
|
||||||
|
|
||||||
<RouterLink :to="{ path: '/problems', query: { status: 'open' } }">Проблемы →</RouterLink>
|
<RouterLink to="/events">Все события →</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_open }}</div>
|
||||||
|
|
||||||
<div class="dash-label">Новых проблем за 24 ч</div>
|
<div class="dash-label">Открытых проблем</div>
|
||||||
|
|
||||||
<RouterLink to="/problems">Все проблемы →</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_resolved_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="{ path: '/problems', query: { status: 'resolved' } }">Resolved →</RouterLink>
|
<RouterLink to="/problems">Все проблемы →</RouterLink>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
|
|
||||||
<div class="dash-value">{{ data.hosts_total }}</div>
|
<div class="dash-value">{{ data.problems_resolved_24h }}</div>
|
||||||
|
|
||||||
<div class="dash-label">Хостов</div>
|
<div class="dash-label">Закрыто за 24 ч</div>
|
||||||
|
|
||||||
<RouterLink to="/hosts">Хосты →</RouterLink>
|
<RouterLink :to="{ path: '/problems', query: { status: 'resolved' } }">Resolved →</RouterLink>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dash-card" :class="{ 'dash-warn': data.hosts_stale > 0 }">
|
<div class="dash-card">
|
||||||
|
|
||||||
<div class="dash-value">{{ data.hosts_stale }}</div>
|
<div class="dash-value">{{ data.hosts_total }}</div>
|
||||||
|
|
||||||
<div class="dash-label">Устаревший heartbeat</div>
|
<div class="dash-label">Хостов</div>
|
||||||
|
|
||||||
<RouterLink to="/hosts">Хосты →</RouterLink>
|
<RouterLink to="/hosts">Хосты →</RouterLink>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dash-card">
|
<div class="dash-card" :class="{ 'dash-warn': data.hosts_stale > 0 }">
|
||||||
|
|
||||||
<div class="dash-value">{{ data.heartbeats_24h }}</div>
|
<div class="dash-value">{{ data.hosts_stale }}</div>
|
||||||
|
|
||||||
<div class="dash-label">Heartbeat за 24 ч</div>
|
<div class="dash-label">Устаревший heartbeat</div>
|
||||||
|
|
||||||
<RouterLink :to="{ path: '/events', query: { type: 'agent.heartbeat' } }">События →</RouterLink>
|
<RouterLink to="/hosts">Хосты →</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.heartbeats_24h }}</div>
|
||||||
|
|
||||||
<div class="dash-label">Отчёты за 24 ч</div>
|
<div class="dash-label">Heartbeat за 24 ч</div>
|
||||||
|
|
||||||
<RouterLink to="/reports">Отчёты →</RouterLink>
|
<RouterLink :to="{ path: '/events', query: { type: 'agent.heartbeat' } }">События →</RouterLink>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
<div class="dash-card">
|
||||||
|
|
||||||
|
<div class="dash-value">{{ data.daily_reports_24h }}</div>
|
||||||
|
|
||||||
<div class="dashboard-panels">
|
<div class="dash-label">Отчёты за 24 ч</div>
|
||||||
|
|
||||||
<section class="dash-panel">
|
<RouterLink to="/reports">Отчёты →</RouterLink>
|
||||||
|
|
||||||
<h2>Top хостов (24 ч)</h2>
|
</div>
|
||||||
|
|
||||||
<table v-if="data.top_hosts.length" class="dash-table">
|
</div>
|
||||||
|
|
||||||
<thead>
|
|
||||||
|
|
||||||
<tr>
|
<div class="dashboard-panels">
|
||||||
|
|
||||||
<th>Хост</th>
|
<section class="dash-panel">
|
||||||
|
|
||||||
<th>Событий</th>
|
<h2>Топ хостов (24 ч)</h2>
|
||||||
|
|
||||||
</tr>
|
<div v-if="data.top_hosts.length" class="dash-table-scroll">
|
||||||
|
<table class="dash-table">
|
||||||
</thead>
|
|
||||||
|
<thead>
|
||||||
<tbody>
|
|
||||||
|
<tr>
|
||||||
<tr v-for="h in data.top_hosts" :key="h.host_id">
|
|
||||||
|
<th>Хост</th>
|
||||||
<td>
|
|
||||||
|
<th>Событий</th>
|
||||||
<RouterLink :to="{ path: '/events', query: { hostname: h.hostname } }">
|
|
||||||
|
</tr>
|
||||||
{{ h.hostname }}
|
|
||||||
|
</thead>
|
||||||
</RouterLink>
|
|
||||||
|
<tbody>
|
||||||
</td>
|
|
||||||
|
<tr v-for="h in data.top_hosts" :key="h.host_id">
|
||||||
<td>{{ h.count }}</td>
|
|
||||||
|
<td>
|
||||||
</tr>
|
|
||||||
|
<RouterLink :to="{ path: '/events', query: { hostname: h.hostname } }">
|
||||||
</tbody>
|
|
||||||
|
{{ h.hostname }}
|
||||||
</table>
|
|
||||||
|
</RouterLink>
|
||||||
<p v-else class="muted">Нет событий за 24 ч</p>
|
|
||||||
|
</td>
|
||||||
</section>
|
|
||||||
|
<td>{{ h.count }}</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
<section class="dash-panel">
|
|
||||||
|
</tbody>
|
||||||
<h2>Top типов (24 ч)</h2>
|
|
||||||
|
</table>
|
||||||
<table v-if="data.top_event_types.length" class="dash-table">
|
</div>
|
||||||
|
|
||||||
<thead>
|
<p v-else class="muted">Нет событий за 24 ч</p>
|
||||||
|
|
||||||
<tr>
|
</section>
|
||||||
|
|
||||||
<th>Type</th>
|
|
||||||
|
|
||||||
<th>Событий</th>
|
<section class="dash-panel">
|
||||||
|
|
||||||
</tr>
|
<h2>Топ типов (24 ч)</h2>
|
||||||
|
|
||||||
</thead>
|
<div v-if="data.top_event_types.length" class="dash-table-scroll">
|
||||||
|
<table class="dash-table">
|
||||||
<tbody>
|
|
||||||
|
<thead>
|
||||||
<tr v-for="t in data.top_event_types" :key="t.type">
|
|
||||||
|
<tr>
|
||||||
<td>
|
|
||||||
|
<th>Type</th>
|
||||||
<RouterLink :to="{ path: '/events', query: { type: t.type } }">
|
|
||||||
|
<th>Событий</th>
|
||||||
<code>{{ t.type }}</code>
|
|
||||||
|
</tr>
|
||||||
</RouterLink>
|
|
||||||
|
</thead>
|
||||||
</td>
|
|
||||||
|
<tbody>
|
||||||
<td>{{ t.count }}</td>
|
|
||||||
|
<tr v-for="t in data.top_event_types" :key="t.type">
|
||||||
</tr>
|
|
||||||
|
<td>
|
||||||
</tbody>
|
|
||||||
|
<RouterLink :to="{ path: '/events', query: { type: t.type } }">
|
||||||
</table>
|
|
||||||
|
<code>{{ t.type }}</code>
|
||||||
<p v-else class="muted">Нет событий за 24 ч</p>
|
|
||||||
|
</RouterLink>
|
||||||
</section>
|
|
||||||
|
</td>
|
||||||
</div>
|
|
||||||
|
<td>{{ t.count }}</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
<h2>Severity за 24 ч</h2>
|
|
||||||
|
</tbody>
|
||||||
<ul class="severity-list">
|
|
||||||
|
</table>
|
||||||
<li v-for="(count, sev) in data.severity_24h" :key="sev">
|
</div>
|
||||||
|
|
||||||
<RouterLink :to="{ path: '/events', query: { severity: sev } }">
|
<p v-else class="muted">Нет событий за 24 ч</p>
|
||||||
|
|
||||||
<span :class="'sev-' + sev">{{ sev }}</span>: {{ count }}
|
</section>
|
||||||
|
|
||||||
</RouterLink>
|
</div>
|
||||||
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li v-if="!Object.keys(data.severity_24h).length">Нет событий</li>
|
<h2>Severity за 24 ч</h2>
|
||||||
|
|
||||||
</ul>
|
<ul class="severity-list">
|
||||||
|
|
||||||
|
<li v-for="(count, sev) in data.severity_24h" :key="sev">
|
||||||
|
|
||||||
<h2>Последние события</h2>
|
<RouterLink :to="{ path: '/events', query: { severity: sev } }">
|
||||||
|
|
||||||
<p class="muted dash-recent-hint">
|
<span :class="'sev-' + sev">{{ sev }}</span>: {{ count }}
|
||||||
|
|
||||||
Обновляется при поступлении событий (live, ~5 с). Время — момент приёма SAC.
|
</RouterLink>
|
||||||
|
|
||||||
</p>
|
</li>
|
||||||
|
|
||||||
<table>
|
<li v-if="!Object.keys(data.severity_24h).length">Нет событий</li>
|
||||||
|
|
||||||
<thead>
|
</ul>
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<th>ID</th>
|
<h2>Последние события</h2>
|
||||||
|
|
||||||
<th>Получено</th>
|
<p class="muted dash-recent-hint">
|
||||||
|
|
||||||
<th>Хост</th>
|
Обновляется при поступлении событий (live, ~5 с). Время — момент приёма SAC.
|
||||||
|
|
||||||
<th>Severity</th>
|
</p>
|
||||||
|
|
||||||
<th>Title</th>
|
<table>
|
||||||
|
|
||||||
</tr>
|
<thead>
|
||||||
|
|
||||||
</thead>
|
<tr>
|
||||||
|
|
||||||
<tbody>
|
<th>ID</th>
|
||||||
|
|
||||||
<tr v-for="e in data.recent_events" :key="e.id">
|
<th>Получено</th>
|
||||||
|
|
||||||
<td>
|
<th>Хост</th>
|
||||||
|
|
||||||
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
|
<th>Severity</th>
|
||||||
|
|
||||||
</td>
|
<th>Title</th>
|
||||||
|
|
||||||
<td>{{ formatDt(e.received_at) }}</td>
|
</tr>
|
||||||
|
|
||||||
<td>
|
</thead>
|
||||||
|
|
||||||
<RouterLink :to="{ path: '/events', query: { hostname: e.hostname } }">
|
<tbody>
|
||||||
|
|
||||||
{{ e.hostname }}
|
<tr v-for="e in data.recent_events" :key="e.id">
|
||||||
|
|
||||||
</RouterLink>
|
<td>
|
||||||
|
|
||||||
</td>
|
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
|
||||||
|
|
||||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
</td>
|
||||||
|
|
||||||
<td>{{ e.title }}</td>
|
<td>{{ formatDt(e.received_at) }}</td>
|
||||||
|
|
||||||
</tr>
|
<td>
|
||||||
|
|
||||||
</tbody>
|
<RouterLink :to="{ path: '/events', query: { hostname: e.hostname } }">
|
||||||
|
|
||||||
</table>
|
{{ e.hostname }}
|
||||||
|
|
||||||
<div class="filters" style="margin-top: 1rem">
|
</RouterLink>
|
||||||
|
|
||||||
<button type="button" class="secondary" @click="load">Обновить всё</button>
|
</td>
|
||||||
|
|
||||||
<span v-if="live" class="live-badge">live</span>
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
|
|
||||||
</div>
|
<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>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
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 loading = ref(false);
|
||||||
|
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
const live = ref(false);
|
||||||
|
|
||||||
|
const lastKnownEventDbId = ref<number | null>(null);
|
||||||
|
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
|
||||||
|
let refreshingRecent = false;
|
||||||
|
const router = useRouter();
|
||||||
|
const appTitle = APP_VERSION_LABEL;
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearToken();
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
.dashboard-layout {
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user