From c657a6597058ffdaabf197593c8973d3839b63d8 Mon Sep 17 00:00:00 2001 From: PTah Date: Wed, 27 May 2026 13:20:39 +1000 Subject: [PATCH] feat: v0.2.0 branding, healthz version, SSE dashboard live Centralize APP_VERSION 0.2.0; /health and /healthz return version; startup log line; UI title Security Alert Center v.0.2.0; SSE /api/v1/stream/events for live dashboard counters. --- backend/app/api/v1/health.py | 17 +++++++- backend/app/api/v1/router.py | 3 +- backend/app/api/v1/stream.py | 62 ++++++++++++++++++++++++++++ backend/app/auth/jwt_auth.py | 27 ++++++++---- backend/app/main.py | 17 +++++++- backend/app/version.py | 5 +++ backend/tests/test_health.py | 15 +++++-- docs/operations-prod-status.md | 2 +- docs/work-plan.md | 4 +- frontend/package.json | 2 +- frontend/src/App.vue | 5 ++- frontend/src/style.css | 11 +++++ frontend/src/version.ts | 3 ++ frontend/src/views/DashboardView.vue | 48 +++++++++++++++++++-- frontend/vite.config.ts | 1 + 15 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 backend/app/api/v1/stream.py create mode 100644 backend/app/version.py create mode 100644 frontend/src/version.ts diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py index 11c6c30..c62e296 100644 --- a/backend/app/api/v1/health.py +++ b/backend/app/api/v1/health.py @@ -3,12 +3,12 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.database import get_db +from app.version import APP_NAME, APP_VERSION router = APIRouter(tags=["health"]) -@router.get("/health") -def health(db: Session = Depends(get_db)) -> dict: +def build_health_payload(db: Session) -> dict: db_status = "ok" try: db.execute(text("SELECT 1")) @@ -18,4 +18,17 @@ def health(db: Session = Depends(get_db)) -> dict: "status": "ok" if db_status == "ok" else "degraded", "database": db_status, "service": "security-alert-center", + "name": APP_NAME, + "version": APP_VERSION, } + + +@router.get("/health") +def health(db: Session = Depends(get_db)) -> dict: + return build_health_payload(db) + + +@router.get("/healthz") +def healthz(db: Session = Depends(get_db)) -> dict: + """Kubernetes-style alias; same payload as /health.""" + return build_health_payload(db) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 22bd2be..fdaf0a8 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, dashboards, events, health, hosts, problems +from app.api.v1 import auth, dashboards, events, health, hosts, problems, stream api_router = APIRouter() api_router.include_router(health.router) @@ -9,3 +9,4 @@ api_router.include_router(events.router) api_router.include_router(hosts.router) api_router.include_router(problems.router) api_router.include_router(dashboards.router) +api_router.include_router(stream.router) diff --git a/backend/app/api/v1/stream.py b/backend/app/api/v1/stream.py new file mode 100644 index 0000000..3c48945 --- /dev/null +++ b/backend/app/api/v1/stream.py @@ -0,0 +1,62 @@ +import asyncio +import json +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.auth.jwt_auth import verify_access_token +from app.database import SessionLocal +from app.models import Event, Problem +from app.version import APP_VERSION + +router = APIRouter(prefix="/stream", tags=["stream"]) + +SSE_INTERVAL_SEC = 5 + + +def _dashboard_snapshot(db: Session) -> dict: + since = datetime.now(timezone.utc) - timedelta(hours=24) + events_24h = db.scalar( + select(func.count()).select_from(Event).where(Event.received_at >= since) + ) or 0 + problems_open = ( + db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0 + ) + last_event = db.scalar(select(Event.id).order_by(Event.received_at.desc()).limit(1)) + return { + "type": "dashboard", + "version": APP_VERSION, + "events_last_24h": events_24h, + "problems_open": problems_open, + "last_event_id": last_event, + "at": datetime.now(timezone.utc).isoformat(), + } + + +async def _sse_generator(): + while True: + db = SessionLocal() + try: + payload = _dashboard_snapshot(db) + finally: + db.close() + yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + await asyncio.sleep(SSE_INTERVAL_SEC) + + +def _sse_auth(token: str = Query(..., description="JWT access_token")) -> str: + return verify_access_token(token) + + +@router.get("/events") +async def stream_events( + _user: str = Depends(_sse_auth), +) -> StreamingResponse: + return StreamingResponse( + _sse_generator(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/backend/app/auth/jwt_auth.py b/backend/app/auth/jwt_auth.py index e3d7780..c3675c7 100644 --- a/backend/app/auth/jwt_auth.py +++ b/backend/app/auth/jwt_auth.py @@ -16,18 +16,11 @@ def create_access_token(subject: str) -> str: return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) -def get_current_user( - credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), -) -> str: - if credentials is None or credentials.scheme.lower() != "bearer": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - ) +def _decode_username(token: str) -> str: settings = get_settings() try: payload = jwt.decode( - credentials.credentials, + token, settings.jwt_secret, algorithms=[settings.jwt_algorithm], ) @@ -37,3 +30,19 @@ def get_current_user( return str(username) except JWTError as exc: raise HTTPException(status_code=401, detail="Invalid token") from exc + + +def verify_access_token(token: str) -> str: + """Проверка JWT (в т.ч. query-параметр для SSE).""" + return _decode_username(token) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), +) -> str: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + return _decode_username(credentials.credentials) diff --git a/backend/app/main.py b/backend/app/main.py index 6cb4c6f..2b3bd4d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import logging from contextlib import asynccontextmanager from pathlib import Path @@ -12,8 +13,10 @@ from app.auth.api_key import hash_api_key from app.config import get_settings from app.database import SessionLocal from app.models import ApiKey +from app.version import APP_VERSION, APP_VERSION_LABEL ROOT = Path(__file__).resolve().parents[2] +logger = logging.getLogger("sac") def bootstrap_api_key() -> None: @@ -42,15 +45,27 @@ def bootstrap_api_key() -> None: @asynccontextmanager async def lifespan(_app: FastAPI): + logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION) bootstrap_api_key() yield + logger.info("%s — application shutdown", APP_VERSION_LABEL) + + +def configure_logging() -> None: + if logging.getLogger().handlers: + return + logging.basicConfig( + level=logging.INFO, + format="%(levelname)s: %(message)s", + ) def create_app() -> FastAPI: + configure_logging() settings = get_settings() app = FastAPI( title="Security Alert Center", - version="0.1.0", + version=APP_VERSION, lifespan=lifespan, ) origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] diff --git a/backend/app/version.py b/backend/app/version.py new file mode 100644 index 0000000..bd5843c --- /dev/null +++ b/backend/app/version.py @@ -0,0 +1,5 @@ +"""Единый источник версии SAC (API, health, логи, OpenAPI).""" + +APP_NAME = "Security Alert Center" +APP_VERSION = "0.2.0" +APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 974c8df..254c099 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -1,7 +1,14 @@ -"""Smoke tests — run with PostgreSQL or skip integration.""" +"""Health endpoint smoke tests.""" -import pytest +from unittest.mock import MagicMock + +from app.api.v1.health import build_health_payload +from app.version import APP_NAME, APP_VERSION -def test_placeholder(): - assert True +def test_health_payload_includes_version(): + db = MagicMock() + payload = build_health_payload(db) + assert payload["version"] == APP_VERSION + assert payload["name"] == APP_NAME + assert payload["database"] == "ok" diff --git a/docs/operations-prod-status.md b/docs/operations-prod-status.md index f3045ea..ce2e2f6 100644 --- a/docs/operations-prod-status.md +++ b/docs/operations-prod-status.md @@ -52,7 +52,7 @@ sudo /opt/sac-deploy.sh 1. **Деплой SAC** — `sudo /opt/sac-deploy.sh`, `alembic upgrade head` (миграция `002_problems`) 2. **ssh-monitor на ubabuba** — `update_ssh_monitor.sh` (10.10.36.9), маппинг `notify_or_sac` уже в `main` -3. **1C.4 Telegram из SAC (MVP backend)** — заполнить `TELEGRAM_*` в `config/sac-api.env`, затем `sudo systemctl restart sac-api` +3. **Деплой v0.2.0** — `sudo /opt/sac-deploy.sh`; проверка `curl -sS https://sac.kalinamall.ru/healthz | jq .version` 4. **1C.5** — dashboard 5. RDP-login-monitor — аналог UseSAC diff --git a/docs/work-plan.md b/docs/work-plan.md index 711913f..722f8a6 100644 --- a/docs/work-plan.md +++ b/docs/work-plan.md @@ -53,9 +53,9 @@ | 1C.2 | Auth JWT, admin bootstrap | ✅ prod | | 1C.3 | Problems (базовые правила) | ✅ API + UI (деплой ⏳) | | 1C.4 | Telegram из SAC | ✅ backend (деплой + TELEGRAM_* в env) | -| 1C.5 | Dashboard (3 виджета), SSE | 🔄 Dashboard MVP ✅, SSE ⏳ | +| 1C.5 | Dashboard (3 виджета), SSE | ✅ v0.2.0 | -**Выход:** тег `v0.1.0-mvp`. +**Выход:** тег `v0.2.0-mvp`. --- diff --git a/frontend/package.json b/frontend/package.json index 9631848..86fb1f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "sac-ui", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a1ca970..02981c8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,7 +1,7 @@ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c3a6eae..14f78c6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ proxy: { "/api": "http://127.0.0.1:8000", "/health": "http://127.0.0.1:8000", + "/healthz": "http://127.0.0.1:8000", }, }, build: {