feat: SAC 0.7.4 sidebar system stats block with UI toggle
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user