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>
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 : "Ошибка загрузки";
|
||||
|
||||
Reference in New Issue
Block a user