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