feat: SAC 0.7.4 sidebar system stats block with UI toggle

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-02 09:15:15 +10:00
parent cf5a6f995c
commit 3053058cdf
20 changed files with 1082 additions and 255 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
"version": "0.7.3",
"version": "0.7.4",
"type": "module",
"scripts": {
"dev": "vite",
+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>
+1 -1
View File
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.7.3";
export const APP_VERSION = "0.7.4";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+58
View File
@@ -11,6 +11,27 @@
<p v-else-if="loading">Загрузка</p>
<template v-else>
<section class="card settings-card settings-policy">
<h2>Интерфейс</h2>
<p class="settings-hint settings-hint-top">
Блок «Сервер» в левой панели: CPU, RAM, диск, задержка БД и счётчики SAC. Обновляется каждые 30 секунд.
</p>
<form class="settings-form" @submit.prevent="saveUiSettings">
<label class="settings-field settings-inline">
<input v-model="uiForm.show_sidebar_system_stats" type="checkbox" />
Показывать блок системной информации в боковой панели
</label>
<p v-if="uiLoaded" class="settings-meta">
Источник: <code>{{ uiLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingUi">
{{ savingUi ? "Сохранение…" : "Сохранить интерфейс" }}
</button>
</div>
</form>
</section>
<section class="card settings-card settings-policy">
<h2>Правило оповещений</h2>
<p class="settings-hint settings-hint-top">
@@ -291,6 +312,11 @@ interface NotificationPolicy {
source: string;
}
interface UiSettings {
show_sidebar_system_stats: boolean;
source: string;
}
interface SeverityOverrideRowApi {
event_type: string;
default_severity: string;
@@ -303,6 +329,7 @@ interface SeverityOverrideRowUi extends SeverityOverrideRowApi {
const loading = ref(true);
const savingPolicy = ref(false);
const savingUi = ref(false);
const savingTelegram = ref(false);
const savingWebhook = ref(false);
const savingEmail = ref(false);
@@ -316,6 +343,7 @@ const telegramLoaded = ref<TelegramSettings | null>(null);
const webhookLoaded = ref<WebhookSettings | null>(null);
const emailLoaded = ref<EmailSettings | null>(null);
const policyLoaded = ref<NotificationPolicy | null>(null);
const uiLoaded = ref<UiSettings | null>(null);
const severityRows = ref<SeverityOverrideRowUi[]>([]);
const severityFilter = ref("");
@@ -326,6 +354,10 @@ const policyForm = reactive({
use_email: false,
});
const uiForm = reactive({
show_sidebar_system_stats: true,
});
const telegramForm = reactive({
enabled: false,
bot_token: "",
@@ -429,6 +461,31 @@ async function saveSeverityOverrides() {
}
}
async function loadUiSettings() {
const data = await apiFetch<UiSettings>("/api/v1/settings/ui");
uiLoaded.value = data;
uiForm.show_sidebar_system_stats = data.show_sidebar_system_stats;
}
async function saveUiSettings() {
savingUi.value = true;
error.value = "";
success.value = "";
try {
uiLoaded.value = await apiFetch<UiSettings>("/api/v1/settings/ui", {
method: "PUT",
body: JSON.stringify({
show_sidebar_system_stats: uiForm.show_sidebar_system_stats,
}),
});
success.value = "Настройки интерфейса сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingUi.value = false;
}
}
async function load() {
loading.value = true;
error.value = "";
@@ -463,6 +520,7 @@ async function load() {
emailForm.mail_to = "";
emailForm.smtp_starttls = data.email.smtp_starttls;
emailForm.smtp_ssl = data.email.smtp_ssl;
await loadUiSettings();
await loadSeverityOverrides();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";