feat: SAC 0.7.4 sidebar system stats block with UI toggle
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")
|
||||||
@@ -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,
|
status_code=503,
|
||||||
|
|
||||||
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
|
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ip_address = ensure_login_allowed(db, request)
|
ip_address = ensure_login_allowed(db, request)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
user = authenticate_user(db, body.username, body.password)
|
user = authenticate_user(db, body.username, body.password)
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
||||||
record_login_failure(db, ip_address=ip_address, username=body.username)
|
record_login_failure(db, ip_address=ip_address, username=body.username)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
record_login_success(db, ip_address=ip_address, username=user.username)
|
record_login_success(db, ip_address=ip_address, username=user.username)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
token = create_access_token(user.username, user.role)
|
token = create_access_token(user.username, user.role)
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
|
|
||||||
access_token=token,
|
access_token=token,
|
||||||
|
|
||||||
username=user.username,
|
username=user.username,
|
||||||
|
|
||||||
role=user.role,
|
role=user.role,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=MeResponse)
|
@router.get("/me", response_model=MeResponse)
|
||||||
|
|
||||||
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
|
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
|
||||||
|
|
||||||
return MeResponse(username=current_user.username, role=current_user.role)
|
return MeResponse(username=current_user.username, role=current_user.role)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _has_any_user(db: Session) -> bool:
|
def _has_any_user(db: Session) -> bool:
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
count = db.scalar(select(func.count()).select_from(User)) or 0
|
count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
|
||||||
return count > 0
|
return count > 0
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
try:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
token,
|
token,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
settings.jwt_secret,
|
settings.jwt_secret,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
algorithms=[settings.jwt_algorithm],
|
algorithms=[settings.jwt_algorithm],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if not username:
|
if not username:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid token")
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if db is not None:
|
if db is not None:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
user = _user_from_db(db, str(username))
|
user = _user_from_db(db, str(username))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return CurrentUser(username=user.username, role=user.role)
|
return CurrentUser(username=user.username, role=user.role)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
role = str(payload.get("role") or USER_ROLE_ADMIN)
|
role = str(payload.get("role") or USER_ROLE_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return CurrentUser(username=str(username), role=role)
|
return CurrentUser(username=str(username), role=role)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
except JWTError as exc:
|
except JWTError as exc:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid token") from exc
|
raise HTTPException(status_code=401, detail="Invalid token") from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def verify_access_token(token: str, db: Session | None = None) -> str:
|
def verify_access_token(token: str, db: Session | None = None) -> str:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""Проверка JWT (в т.ч. query-параметр для SSE). Возвращает username."""
|
"""Проверка JWT (в т.ч. query-параметр для SSE). Возвращает username."""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return _decode_current_user(token, db=db).username
|
return _decode_current_user(token, db=db).username
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
) -> CurrentUser:
|
) -> CurrentUser:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
detail="Not authenticated",
|
detail="Not authenticated",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return _decode_current_user(credentials.credentials, db=db)
|
return _decode_current_user(credentials.credentials, db=db)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if not user.is_admin:
|
if not user.is_admin:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
detail="Admin access required",
|
detail="Admin access required",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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,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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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}`;
|
||||||
|
|||||||
@@ -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 : "Ошибка загрузки";
|
||||||
|
|||||||
Reference in New Issue
Block a user