feat: Dashboard MVP with summary API and UI

Add GET /api/v1/dashboards/summary (24h events, open problems,
hosts, severity breakdown, recent events) and Vue /dashboard page.
This commit is contained in:
2026-05-27 12:37:06 +10:00
parent eeba3a704d
commit ea221db3c7
8 changed files with 214 additions and 6 deletions
+1
View File
@@ -2,6 +2,7 @@
<div class="layout">
<nav v-if="showNav">
<span class="brand">SAC</span>
<RouterLink to="/dashboard">Dashboard</RouterLink>
<RouterLink to="/events">События</RouterLink>
<RouterLink to="/problems">Проблемы</RouterLink>
<RouterLink to="/hosts">Хосты</RouterLink>
+8
View File
@@ -112,3 +112,11 @@ export async function ackProblem(problemId: number): Promise<{ id: number; statu
export async function resolveProblem(problemId: number): Promise<{ id: number; status: string }> {
return apiFetch(`/api/v1/problems/${problemId}/resolve`, { method: "POST" });
}
export interface DashboardSummary {
events_last_24h: number;
hosts_total: number;
problems_open: number;
severity_24h: Record<string, number>;
recent_events: EventSummary[];
}
+3 -1
View File
@@ -5,11 +5,13 @@ import EventDetailView from "./views/EventDetailView.vue";
import HostsView from "./views/HostsView.vue";
import LoginView from "./views/LoginView.vue";
import ProblemsView from "./views/ProblemsView.vue";
import DashboardView from "./views/DashboardView.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/events" },
{ path: "/", redirect: "/dashboard" },
{ path: "/dashboard", component: DashboardView },
{ path: "/login", component: LoginView },
{ path: "/events", component: EventsView },
{ path: "/events/:id", component: EventDetailView, props: true },
+33
View File
@@ -131,3 +131,36 @@ pre {
padding: 0.75rem;
border-radius: 6px;
}
.dashboard-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.dash-card {
background: #1a2332;
border: 1px solid #2a3441;
border-radius: 8px;
padding: 1rem 1.25rem;
}
.dash-value {
font-size: 2rem;
font-weight: 700;
color: #fff;
}
.dash-label {
color: #9aa4b2;
margin: 0.25rem 0 0.5rem;
}
.severity-list {
list-style: none;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
+86
View File
@@ -0,0 +1,86 @@
<template>
<h1>Dashboard</h1>
<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="/problems">Проблемы </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>
<h2>Severity за 24 ч</h2>
<ul class="severity-list">
<li v-for="(count, sev) in data.severity_24h" :key="sev">
<span :class="'sev-' + sev">{{ sev }}</span>: {{ count }}
</li>
<li v-if="!Object.keys(data.severity_24h).length">Нет событий</li>
</ul>
<h2>Последние события</h2>
<table>
<thead>
<tr>
<th>ID</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.occurred_at) }}</td>
<td>{{ e.hostname }}</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>
</div>
</template>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { apiFetch, type DashboardSummary } from "../api";
const data = ref<DashboardSummary | null>(null);
const loading = ref(false);
const error = ref("");
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
}
async function load() {
loading.value = true;
error.value = "";
try {
data.value = await apiFetch<DashboardSummary>("/api/v1/dashboards/summary");
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
}
onMounted(() => load());
</script>