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 sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.version import APP_NAME, APP_VERSION
router = APIRouter(tags=["health"]) router = APIRouter(tags=["health"])
@router.get("/health") def build_health_payload(db: Session) -> dict:
def health(db: Session = Depends(get_db)) -> dict:
db_status = "ok" db_status = "ok"
try: try:
db.execute(text("SELECT 1")) 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", "status": "ok" if db_status == "ok" else "degraded",
"database": db_status, "database": db_status,
"service": "security-alert-center", "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 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 = APIRouter()
api_router.include_router(health.router) 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(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(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) return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def get_current_user( def _decode_username(token: str) -> str:
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",
)
settings = get_settings() settings = get_settings()
try: try:
payload = jwt.decode( payload = jwt.decode(
credentials.credentials, token,
settings.jwt_secret, settings.jwt_secret,
algorithms=[settings.jwt_algorithm], algorithms=[settings.jwt_algorithm],
) )
@@ -37,3 +30,19 @@ def get_current_user(
return str(username) return str(username)
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) -> 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 contextlib import asynccontextmanager
from pathlib import Path 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.config import get_settings
from app.database import SessionLocal from app.database import SessionLocal
from app.models import ApiKey from app.models import ApiKey
from app.version import APP_VERSION, APP_VERSION_LABEL
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
logger = logging.getLogger("sac")
def bootstrap_api_key() -> None: def bootstrap_api_key() -> None:
@@ -42,15 +45,27 @@ def bootstrap_api_key() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
bootstrap_api_key() bootstrap_api_key()
yield 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: def create_app() -> FastAPI:
configure_logging()
settings = get_settings() settings = get_settings()
app = FastAPI( app = FastAPI(
title="Security Alert Center", title="Security Alert Center",
version="0.1.0", version=APP_VERSION,
lifespan=lifespan, lifespan=lifespan,
) )
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] 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(): def test_health_payload_includes_version():
assert True 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`) 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` 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 4. **1C.5** — dashboard
5. RDP-login-monitor — аналог UseSAC 5. RDP-login-monitor — аналог UseSAC
+2 -2
View File
@@ -53,9 +53,9 @@
| 1C.2 | Auth JWT, admin bootstrap | ✅ prod | | 1C.2 | Auth JWT, admin bootstrap | ✅ prod |
| 1C.3 | Problems (базовые правила) | ✅ API + UI (деплой ⏳) | | 1C.3 | Problems (базовые правила) | ✅ API + UI (деплой ⏳) |
| 1C.4 | Telegram из SAC | ✅ backend (деплой + TELEGRAM_* в env) | | 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", "name": "sac-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+4 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="layout"> <div class="layout">
<nav v-if="showNav"> <nav v-if="showNav">
<span class="brand">SAC</span> <span class="brand">{{ appTitle }}</span>
<RouterLink to="/dashboard">Dashboard</RouterLink> <RouterLink to="/dashboard">Dashboard</RouterLink>
<RouterLink to="/events">События</RouterLink> <RouterLink to="/events">События</RouterLink>
<RouterLink to="/problems">Проблемы</RouterLink> <RouterLink to="/problems">Проблемы</RouterLink>
@@ -16,6 +16,9 @@
import { computed } from "vue"; import { computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { clearToken, getToken } from "./api"; import { clearToken, getToken } from "./api";
import { APP_VERSION_LABEL } from "./version";
const appTitle = APP_VERSION_LABEL;
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
+11
View File
@@ -36,6 +36,9 @@ nav .brand {
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
margin-right: auto; margin-right: auto;
font-size: 0.95rem;
line-height: 1.3;
max-width: min(420px, 45vw);
} }
table { table {
@@ -164,3 +167,11 @@ pre {
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; 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> </table>
<div class="filters" style="margin-top: 1rem"> <div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" @click="load">Обновить</button> <button type="button" class="secondary" @click="load">Обновить</button>
<span v-if="live" class="live-badge">live</span>
</div> </div>
</template> </template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { apiFetch, type DashboardSummary } from "../api"; import { apiFetch, getToken, type DashboardSummary } from "../api";
const data = ref<DashboardSummary | null>(null); const data = ref<DashboardSummary | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
const live = ref(false);
let eventSource: EventSource | null = null;
function formatDt(iso: string) { function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU"); 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> </script>
+1
View File
@@ -8,6 +8,7 @@ export default defineConfig({
proxy: { proxy: {
"/api": "http://127.0.0.1:8000", "/api": "http://127.0.0.1:8000",
"/health": "http://127.0.0.1:8000", "/health": "http://127.0.0.1:8000",
"/healthz": "http://127.0.0.1:8000",
}, },
}, },
build: { build: {