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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
@@ -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()]
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user