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.
This commit is contained in:
2026-05-27 13:20:39 +10:00
parent ea221db3c7
commit c657a65970
15 changed files with 197 additions and 25 deletions
+15 -2
View File
@@ -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)
+2 -1
View File
@@ -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)
+62
View File
@@ -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"},
)
+18 -9
View File
@@ -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)
+16 -1
View File
@@ -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()]
+5
View File
@@ -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}"