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:
PTah
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}"
+11 -4
View File
@@ -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"
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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`.
---
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
+4 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="layout">
<nav v-if="showNav">
<span class="brand">SAC</span>
<span class="brand">{{ appTitle }}</span>
<RouterLink to="/dashboard">Dashboard</RouterLink>
<RouterLink to="/events">События</RouterLink>
<RouterLink to="/problems">Проблемы</RouterLink>
@@ -16,6 +16,9 @@
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { clearToken, getToken } from "./api";
import { APP_VERSION_LABEL } from "./version";
const appTitle = APP_VERSION_LABEL;
const route = useRoute();
const router = useRouter();
+11
View File
@@ -36,6 +36,9 @@ nav .brand {
font-weight: 700;
color: #fff;
margin-right: auto;
font-size: 0.95rem;
line-height: 1.3;
max-width: min(420px, 45vw);
}
table {
@@ -164,3 +167,11 @@ pre {
flex-wrap: wrap;
gap: 1rem;
}
.live-badge {
color: #6bcb77;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
+3
View File
@@ -0,0 +1,3 @@
export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.2.0";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+45 -3
View File
@@ -54,17 +54,20 @@
</table>
<div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" @click="load">Обновить</button>
<span v-if="live" class="live-badge">live</span>
</div>
</template>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { apiFetch, type DashboardSummary } from "../api";
import { onMounted, onUnmounted, ref } from "vue";
import { apiFetch, getToken, type DashboardSummary } from "../api";
const data = ref<DashboardSummary | null>(null);
const loading = ref(false);
const error = ref("");
const live = ref(false);
let eventSource: EventSource | null = null;
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
@@ -82,5 +85,44 @@ async function load() {
}
}
onMounted(() => load());
function connectLive() {
const token = getToken();
if (!token) return;
eventSource?.close();
const url = `/api/v1/stream/events?token=${encodeURIComponent(token)}`;
eventSource = new EventSource(url);
eventSource.onopen = () => {
live.value = true;
};
eventSource.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data) as {
type?: string;
events_last_24h?: number;
problems_open?: number;
};
if (msg.type !== "dashboard" || !data.value) return;
if (typeof msg.events_last_24h === "number") {
data.value.events_last_24h = msg.events_last_24h;
}
if (typeof msg.problems_open === "number") {
data.value.problems_open = msg.problems_open;
}
} catch {
/* ignore malformed SSE */
}
};
eventSource.onerror = () => {
live.value = false;
};
}
onMounted(() => {
load().then(() => connectLive());
});
onUnmounted(() => {
eventSource?.close();
eventSource = null;
});
</script>
+1
View File
@@ -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: {