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
@@ -0,0 +1,31 @@
"""ui_settings singleton for SAC UI preferences
Revision ID: 012
Revises: 011
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "012"
down_revision: Union[str, None] = "011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"ui_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("show_sidebar_system_stats", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.PrimaryKeyConstraint("id"),
)
op.execute(
"INSERT INTO ui_settings (id, show_sidebar_system_stats) VALUES (1, true)"
)
def downgrade() -> None:
op.drop_table("ui_settings")
+156 -78
View File
@@ -1,78 +1,156 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
from app.config import get_settings from app.config import get_settings
from app.database import get_db from app.database import get_db
from app.services.login_rate_limit import ( from app.services.login_rate_limit import (
ensure_login_allowed, ensure_login_allowed,
record_login_failure, record_login_failure,
record_login_success, record_login_success,
) )
from app.services.user_auth import authenticate_user from app.services.user_auth import authenticate_user
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
username: str username: str
role: str role: str
class MeResponse(BaseModel): class MeResponse(BaseModel):
username: str username: str
role: str role: str
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> TokenResponse: def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
settings = get_settings() settings = get_settings()
if not settings.sac_admin_password and not _has_any_user(db): if not settings.sac_admin_password and not _has_any_user(db):
raise HTTPException( raise HTTPException(
status_code=503,
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
)
ip_address = ensure_login_allowed(db, request)
user = authenticate_user(db, body.username, body.password)
if user is None:
record_login_failure(db, ip_address=ip_address, username=body.username)
db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
record_login_success(db, ip_address=ip_address, username=user.username)
db.commit()
token = create_access_token(user.username, user.role)
return TokenResponse(
access_token=token,
username=user.username,
role=user.role,
)
@router.get("/me", response_model=MeResponse)
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
return MeResponse(username=current_user.username, role=current_user.role)
def _has_any_user(db: Session) -> bool:
from sqlalchemy import func, select
from app.models.user import User
try:
count = db.scalar(select(func.count()).select_from(User)) or 0
return count > 0
except Exception:
db.rollback()
return False
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, dashboards, events, health, hosts, problems, settings, stream, users from app.api.v1 import auth, dashboards, events, health, hosts, problems, settings, stream, system, users
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(health.router) api_router.include_router(health.router)
@@ -10,5 +10,6 @@ api_router.include_router(hosts.router)
api_router.include_router(problems.router) api_router.include_router(problems.router)
api_router.include_router(dashboards.router) api_router.include_router(dashboards.router)
api_router.include_router(settings.router) api_router.include_router(settings.router)
api_router.include_router(system.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(stream.router) api_router.include_router(stream.router)
+38
View File
@@ -28,6 +28,10 @@ from app.services.event_severity_overrides import (
list_severity_override_items, list_severity_override_items,
replace_severity_overrides, replace_severity_overrides,
) )
from app.services.ui_settings import (
get_effective_ui_settings,
upsert_ui_settings,
)
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
@@ -392,3 +396,37 @@ def update_severity_overrides(
], ],
source=SOURCE_DB, source=SOURCE_DB,
) )
class UiSettingsResponse(BaseModel):
show_sidebar_system_stats: bool
source: str = Field(description="db или default")
class UiSettingsUpdate(BaseModel):
show_sidebar_system_stats: bool
@router.get("/ui", response_model=UiSettingsResponse)
def get_ui_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> UiSettingsResponse:
cfg = get_effective_ui_settings(db)
return UiSettingsResponse(
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
source=cfg.source,
)
@router.put("/ui", response_model=UiSettingsResponse)
def update_ui_settings(
body: UiSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> UiSettingsResponse:
cfg = upsert_ui_settings(db, show_sidebar_system_stats=body.show_sidebar_system_stats)
return UiSettingsResponse(
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
source=cfg.source,
)
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.jwt_auth import get_current_user
from app.database import get_db
from app.services.system_stats import collect_system_stats
from app.services.ui_settings import get_effective_ui_settings
router = APIRouter(prefix="/system", tags=["system"])
class SystemStatsResponse(BaseModel):
visible: bool = Field(description="Показывать блок в боковой панели (глобальная настройка)")
collected_at: str
cpu_percent: float | None = None
memory: dict | None = None
disk: dict | None = None
database: dict
app: dict
@router.get("/stats", response_model=SystemStatsResponse)
def get_system_stats(
db: Session = Depends(get_db),
_user=Depends(get_current_user),
) -> SystemStatsResponse:
ui = get_effective_ui_settings(db)
payload = collect_system_stats(db)
return SystemStatsResponse(visible=ui.show_sidebar_system_stats, **payload)
+341 -171
View File
@@ -1,171 +1,341 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.database import get_db from app.database import get_db
from app.models.user import USER_ROLE_ADMIN, User from app.models.user import USER_ROLE_ADMIN, User
bearer_scheme = HTTPBearer(auto_error=False) bearer_scheme = HTTPBearer(auto_error=False)
@dataclass(frozen=True) @dataclass(frozen=True)
class CurrentUser: class CurrentUser:
username: str username: str
role: str role: str
@property @property
def is_admin(self) -> bool: def is_admin(self) -> bool:
return self.role == USER_ROLE_ADMIN return self.role == USER_ROLE_ADMIN
def create_access_token(subject: str, role: str) -> str: def create_access_token(subject: str, role: str) -> str:
settings = get_settings() settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "role": role, "exp": expire} payload = {"sub": subject, "role": role, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def _user_from_db(db: Session, username: str) -> User: def _user_from_db(db: Session, username: str) -> User:
normalized = username.strip() normalized = username.strip()
user = db.scalar(select(User).where(func.lower(User.username) == normalized.lower())) user = db.scalar(select(User).where(func.lower(User.username) == normalized.lower()))
if user is None or not user.is_active: if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
return user return user
def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser: def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser:
settings = get_settings() settings = get_settings()
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
username = payload.get("sub")
if not username:
raise HTTPException(status_code=401, detail="Invalid token")
if db is not None:
user = _user_from_db(db, str(username))
return CurrentUser(username=user.username, role=user.role)
role = str(payload.get("role") or USER_ROLE_ADMIN)
return CurrentUser(username=str(username), role=role)
except JWTError as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
def verify_access_token(token: str, db: Session | None = None) -> str:
"""Проверка JWT (в т.ч. query-параметр для SSE). Возвращает username."""
return _decode_current_user(token, db=db).username
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> CurrentUser:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
return _decode_current_user(credentials.credentials, db=db)
def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
+2
View File
@@ -6,6 +6,7 @@ from app.models.notification_cooldown import NotificationCooldown
from app.models.notification_policy import NotificationPolicy from app.models.notification_policy import NotificationPolicy
from app.models.problem import Problem, ProblemEvent from app.models.problem import Problem, ProblemEvent
from app.models.login_attempt import LoginAttempt from app.models.login_attempt import LoginAttempt
from app.models.ui_settings import UiSettings
from app.models.event_severity_override import EventSeverityOverride from app.models.event_severity_override import EventSeverityOverride
from app.models.user import User from app.models.user import User
@@ -21,4 +22,5 @@ __all__ = [
"User", "User",
"EventSeverityOverride", "EventSeverityOverride",
"LoginAttempt", "LoginAttempt",
"UiSettings",
] ]
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy import Boolean
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
UI_SETTINGS_ROW_ID = 1
class UiSettings(Base):
"""Singleton: глобальные настройки интерфейса SAC."""
__tablename__ = "ui_settings"
id: Mapped[int] = mapped_column(primary_key=True)
show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+115
View File
@@ -0,0 +1,115 @@
"""Host and DB metrics for SAC sidebar (best-effort, no heavy queries)."""
from __future__ import annotations
import os
import shutil
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.models import Event, Problem
def _disk_path() -> str:
explicit = os.environ.get("SAC_STATS_DISK_PATH", "").strip()
if explicit:
return explicit
return str(Path("/").resolve())
def _read_cpu_percent() -> float | None:
try:
import psutil # type: ignore[import-untyped]
return round(float(psutil.cpu_percent(interval=None)), 1)
except Exception:
return None
def _read_memory() -> dict[str, float | int] | None:
try:
import psutil # type: ignore[import-untyped]
vm = psutil.virtual_memory()
return {
"used_mb": int(vm.used / (1024 * 1024)),
"total_mb": int(vm.total / (1024 * 1024)),
"percent": round(float(vm.percent), 1),
}
except Exception:
return None
def _read_disk(path: str) -> dict[str, float | int | str] | None:
try:
usage = shutil.disk_usage(path)
total = int(usage.total)
used = int(usage.used)
if total <= 0:
return None
return {
"path": path,
"used_gb": round(used / (1024**3), 1),
"total_gb": round(total / (1024**3), 1),
"percent": round(100.0 * used / total, 1),
}
except Exception:
return None
def collect_system_stats(db: Session) -> dict:
collected_at = datetime.now(timezone.utc).isoformat()
disk_path = _disk_path()
db_status = "ok"
db_latency_ms: float | None = None
db_connections: int | None = None
t0 = time.perf_counter()
try:
db.execute(text("SELECT 1"))
db_latency_ms = round((time.perf_counter() - t0) * 1000, 1)
try:
db_connections = int(
db.scalar(
text(
"SELECT count(*) FROM pg_stat_activity "
"WHERE datname = current_database() AND pid <> pg_backend_pid()"
)
)
or 0
)
except Exception:
db_connections = None
except Exception:
db_status = "error"
since = datetime.now(timezone.utc) - timedelta(hours=1)
events_last_hour = 0
problems_open = 0
if db_status == "ok":
events_last_hour = int(
db.scalar(select(func.count()).select_from(Event).where(Event.received_at >= since)) or 0
)
problems_open = int(
db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0
)
return {
"collected_at": collected_at,
"cpu_percent": _read_cpu_percent(),
"memory": _read_memory(),
"disk": _read_disk(disk_path),
"database": {
"status": db_status,
"latency_ms": db_latency_ms,
"active_connections": db_connections,
},
"app": {
"events_last_hour": events_last_hour,
"problems_open": problems_open,
},
}
+30
View File
@@ -0,0 +1,30 @@
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
@dataclass(frozen=True)
class UiSettingsConfig:
show_sidebar_system_stats: bool
source: str = "db"
def get_effective_ui_settings(db: Session) -> UiSettingsConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
return UiSettingsConfig(show_sidebar_system_stats=True, source="default")
return UiSettingsConfig(show_sidebar_system_stats=bool(row.show_sidebar_system_stats), source="db")
def upsert_ui_settings(db: Session, *, show_sidebar_system_stats: bool) -> UiSettingsConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=show_sidebar_system_stats)
db.add(row)
else:
row.show_sidebar_system_stats = show_sidebar_system_stats
db.commit()
db.refresh(row)
return get_effective_ui_settings(db)
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.7.3" APP_VERSION = "0.7.4"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+1
View File
@@ -10,6 +10,7 @@ jsonschema>=4.23.0,<5
passlib[bcrypt]>=1.7.4,<2 passlib[bcrypt]>=1.7.4,<2
python-jose[cryptography]>=3.3.0,<4 python-jose[cryptography]>=3.3.0,<4
httpx>=0.28.0,<1 httpx>=0.28.0,<1
psutil>=6.1.0,<8
# dev # dev
pytest>=8.3.0,<9 pytest>=8.3.0,<9
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants(): def test_version_constants():
assert APP_VERSION == "0.7.3" assert APP_VERSION == "0.7.4"
assert APP_NAME == "Security Alert Center" assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.7.3" assert APP_VERSION_LABEL == "Security Alert Center v.0.7.4"
+30
View File
@@ -0,0 +1,30 @@
"""GET /api/v1/system/stats"""
from app.services.ui_settings import upsert_ui_settings
def test_system_stats_requires_auth(client):
response = client.get("/api/v1/system/stats")
assert response.status_code == 401
def test_system_stats_visible_default(jwt_headers, client):
response = client.get("/api/v1/system/stats", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["visible"] is True
assert "collected_at" in body
assert "database" in body
assert body["database"]["status"] in ("ok", "error")
assert "app" in body
assert "events_last_hour" in body["app"]
assert "problems_open" in body["app"]
def test_system_stats_respects_ui_setting(jwt_headers, client, db_session):
upsert_ui_settings(db_session, show_sidebar_system_stats=False)
db_session.commit()
response = client.get("/api/v1/system/stats", headers=jwt_headers)
assert response.status_code == 200
assert response.json()["visible"] is False
+32
View File
@@ -0,0 +1,32 @@
"""GET/PUT /api/v1/settings/ui"""
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
def test_get_ui_settings_default(jwt_headers, client):
response = client.get("/api/v1/settings/ui", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["show_sidebar_system_stats"] is True
assert body["source"] == "default"
def test_put_ui_settings_persists(jwt_headers, client, db_session):
response = client.put(
"/api/v1/settings/ui",
headers=jwt_headers,
json={"show_sidebar_system_stats": False},
)
assert response.status_code == 200
body = response.json()
assert body["show_sidebar_system_stats"] is False
assert body["source"] == "db"
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
assert row is not None
assert row.show_sidebar_system_stats is False
def test_ui_settings_requires_admin(jwt_monitor_headers, client):
response = client.get("/api/v1/settings/ui", headers=jwt_monitor_headers)
assert response.status_code == 403
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sac-ui", "name": "sac-ui",
"private": true, "private": true,
"version": "0.7.3", "version": "0.7.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+5
View File
@@ -20,6 +20,7 @@
</RouterLink> </RouterLink>
</nav> </nav>
<div class="sac-sidebar-footer"> <div class="sac-sidebar-footer">
<SidebarSystemStats />
<button type="button" class="sac-nav-item sac-nav-logout" @click="logout"> <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-icon" aria-hidden="true"></span>
<span class="sac-nav-label">Выход</span> <span class="sac-nav-label">Выход</span>
@@ -32,6 +33,7 @@
import { computed } from "vue"; import { computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { clearToken, isAdmin } from "../api"; import { clearToken, isAdmin } from "../api";
import SidebarSystemStats from "./SidebarSystemStats.vue";
import { APP_NAME, APP_VERSION } from "../version"; import { APP_NAME, APP_VERSION } from "../version";
const appName = APP_NAME; const appName = APP_NAME;
@@ -137,6 +139,9 @@ function logout() {
padding-top: 0.75rem; padding-top: 0.75rem;
margin-top: auto; margin-top: auto;
border-top: 1px solid #2a3441; border-top: 1px solid #2a3441;
display: flex;
flex-direction: column;
gap: 0.35rem;
} }
.sac-nav-item { .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_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}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+58
View File
@@ -11,6 +11,27 @@
<p v-else-if="loading">Загрузка</p> <p v-else-if="loading">Загрузка</p>
<template v-else> <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"> <section class="card settings-card settings-policy">
<h2>Правило оповещений</h2> <h2>Правило оповещений</h2>
<p class="settings-hint settings-hint-top"> <p class="settings-hint settings-hint-top">
@@ -291,6 +312,11 @@ interface NotificationPolicy {
source: string; source: string;
} }
interface UiSettings {
show_sidebar_system_stats: boolean;
source: string;
}
interface SeverityOverrideRowApi { interface SeverityOverrideRowApi {
event_type: string; event_type: string;
default_severity: string; default_severity: string;
@@ -303,6 +329,7 @@ interface SeverityOverrideRowUi extends SeverityOverrideRowApi {
const loading = ref(true); const loading = ref(true);
const savingPolicy = ref(false); const savingPolicy = ref(false);
const savingUi = ref(false);
const savingTelegram = ref(false); const savingTelegram = ref(false);
const savingWebhook = ref(false); const savingWebhook = ref(false);
const savingEmail = ref(false); const savingEmail = ref(false);
@@ -316,6 +343,7 @@ const telegramLoaded = ref<TelegramSettings | null>(null);
const webhookLoaded = ref<WebhookSettings | null>(null); const webhookLoaded = ref<WebhookSettings | null>(null);
const emailLoaded = ref<EmailSettings | null>(null); const emailLoaded = ref<EmailSettings | null>(null);
const policyLoaded = ref<NotificationPolicy | null>(null); const policyLoaded = ref<NotificationPolicy | null>(null);
const uiLoaded = ref<UiSettings | null>(null);
const severityRows = ref<SeverityOverrideRowUi[]>([]); const severityRows = ref<SeverityOverrideRowUi[]>([]);
const severityFilter = ref(""); const severityFilter = ref("");
@@ -326,6 +354,10 @@ const policyForm = reactive({
use_email: false, use_email: false,
}); });
const uiForm = reactive({
show_sidebar_system_stats: true,
});
const telegramForm = reactive({ const telegramForm = reactive({
enabled: false, enabled: false,
bot_token: "", 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() { async function load() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
@@ -463,6 +520,7 @@ async function load() {
emailForm.mail_to = ""; emailForm.mail_to = "";
emailForm.smtp_starttls = data.email.smtp_starttls; emailForm.smtp_starttls = data.email.smtp_starttls;
emailForm.smtp_ssl = data.email.smtp_ssl; emailForm.smtp_ssl = data.email.smtp_ssl;
await loadUiSettings();
await loadSeverityOverrides(); await loadSeverityOverrides();
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки"; error.value = e instanceof Error ? e.message : "Ошибка загрузки";