feat: SAC 0.7.4 sidebar system stats block with UI toggle

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 09:15:15 +10:00
parent cf5a6f995c
commit 3053058cdf
20 changed files with 1082 additions and 255 deletions
+5
View File
@@ -20,6 +20,7 @@
</RouterLink>
</nav>
<div class="sac-sidebar-footer">
<SidebarSystemStats />
<button type="button" class="sac-nav-item sac-nav-logout" @click="logout">
<span class="sac-nav-icon" aria-hidden="true"></span>
<span class="sac-nav-label">Выход</span>
@@ -32,6 +33,7 @@
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { clearToken, isAdmin } from "../api";
import SidebarSystemStats from "./SidebarSystemStats.vue";
import { APP_NAME, APP_VERSION } from "../version";
const appName = APP_NAME;
@@ -137,6 +139,9 @@ function logout() {
padding-top: 0.75rem;
margin-top: auto;
border-top: 1px solid #2a3441;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.sac-nav-item {
@@ -0,0 +1,191 @@
<template>
<div v-if="visible" class="sidebar-stats" aria-label="Состояние сервера SAC">
<p class="sidebar-stats-title">Сервер</p>
<div v-if="loading && !stats" class="sidebar-stats-muted"></div>
<template v-else-if="stats">
<div v-if="stats.cpu_percent != null" class="sidebar-stat-row">
<span class="sidebar-stat-label">CPU</span>
<div class="sidebar-stat-bar-wrap">
<div
class="sidebar-stat-bar"
:class="barClass(stats.cpu_percent)"
:style="{ width: `${Math.min(100, stats.cpu_percent)}%` }"
/>
</div>
<span class="sidebar-stat-value">{{ stats.cpu_percent }}%</span>
</div>
<div v-if="stats.memory" class="sidebar-stat-row">
<span class="sidebar-stat-label">RAM</span>
<div class="sidebar-stat-bar-wrap">
<div
class="sidebar-stat-bar"
:class="barClass(stats.memory.percent)"
:style="{ width: `${Math.min(100, stats.memory.percent)}%` }"
/>
</div>
<span class="sidebar-stat-value">{{ stats.memory.percent }}%</span>
</div>
<div v-if="stats.disk" class="sidebar-stat-row">
<span class="sidebar-stat-label">Диск</span>
<div class="sidebar-stat-bar-wrap">
<div
class="sidebar-stat-bar"
:class="barClass(stats.disk.percent)"
:style="{ width: `${Math.min(100, stats.disk.percent)}%` }"
/>
</div>
<span class="sidebar-stat-value">{{ stats.disk.percent }}%</span>
</div>
<ul class="sidebar-stats-meta">
<li v-if="stats.database.latency_ms != null">
БД: {{ stats.database.latency_ms }} ms
<span v-if="stats.database.active_connections != null">
· {{ stats.database.active_connections }} conn
</span>
</li>
<li>События/ч: {{ stats.app.events_last_hour }}</li>
<li>Problems open: {{ stats.app.problems_open }}</li>
</ul>
</template>
<p v-else-if="error" class="sidebar-stats-error" :title="error">нет данных</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { apiFetch } from "../api";
export interface SystemStats {
visible: boolean;
collected_at: string;
cpu_percent: number | null;
memory: { used_mb: number; total_mb: number; percent: number } | null;
disk: { path: string; used_gb: number; total_gb: number; percent: number } | null;
database: { status: string; latency_ms: number | null; active_connections: number | null };
app: { events_last_hour: number; problems_open: number };
}
const POLL_MS = 30_000;
const visible = ref(false);
const stats = ref<SystemStats | null>(null);
const loading = ref(false);
const error = ref("");
let timer: ReturnType<typeof setInterval> | null = null;
function barClass(percent: number): string {
if (percent >= 90) return "is-critical";
if (percent >= 70) return "is-warn";
return "is-ok";
}
async function refresh() {
loading.value = true;
try {
const data = await apiFetch<SystemStats>("/api/v1/system/stats");
visible.value = data.visible;
if (data.visible) {
stats.value = data;
} else {
stats.value = null;
}
error.value = "";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
visible.value = false;
} finally {
loading.value = false;
}
}
onMounted(() => {
void refresh();
timer = setInterval(() => void refresh(), POLL_MS);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
defineExpose({ refresh });
</script>
<style scoped>
.sidebar-stats {
flex-shrink: 0;
margin-bottom: 0.65rem;
padding: 0.55rem 0.65rem;
background: #0f1419;
border: 1px solid #2a3441;
border-radius: 6px;
font-size: 0.72rem;
color: #9aa4b2;
}
.sidebar-stats-title {
margin: 0 0 0.45rem;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #7eb8ff;
}
.sidebar-stat-row {
display: grid;
grid-template-columns: 2rem 1fr 2.1rem;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
}
.sidebar-stat-label {
color: #c5d0dc;
}
.sidebar-stat-bar-wrap {
height: 0.35rem;
background: #1a2332;
border-radius: 2px;
overflow: hidden;
}
.sidebar-stat-bar {
height: 100%;
border-radius: 2px;
transition: width 0.25s ease;
}
.sidebar-stat-bar.is-ok {
background: #6bcb77;
}
.sidebar-stat-bar.is-warn {
background: #ffc857;
}
.sidebar-stat-bar.is-critical {
background: #ff6b6b;
}
.sidebar-stat-value {
text-align: right;
color: #e8eaed;
font-variant-numeric: tabular-nums;
}
.sidebar-stats-meta {
list-style: none;
margin: 0.45rem 0 0;
padding: 0;
line-height: 1.45;
font-size: 0.68rem;
}
.sidebar-stats-muted,
.sidebar-stats-error {
margin: 0;
font-size: 0.72rem;
}
</style>