feat: Dashboard MVP with summary API and UI
Add GET /api/v1/dashboards/summary (24h events, open problems, hosts, severity breakdown, recent events) and Vue /dashboard page. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Event, Host, Problem
|
||||||
|
from app.schemas.list_models import EventSummary
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardSummary(BaseModel):
|
||||||
|
events_last_24h: int
|
||||||
|
hosts_total: int
|
||||||
|
problems_open: int
|
||||||
|
severity_24h: dict[str, int]
|
||||||
|
recent_events: list[EventSummary]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=DashboardSummary)
|
||||||
|
def dashboard_summary(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> DashboardSummary:
|
||||||
|
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
|
||||||
|
hosts_total = db.scalar(select(func.count()).select_from(Host)) or 0
|
||||||
|
problems_open = (
|
||||||
|
db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
severity_rows = db.execute(
|
||||||
|
select(Event.severity, func.count())
|
||||||
|
.where(Event.received_at >= since)
|
||||||
|
.group_by(Event.severity)
|
||||||
|
).all()
|
||||||
|
severity_24h = {row[0]: row[1] for row in severity_rows}
|
||||||
|
|
||||||
|
recent = db.scalars(
|
||||||
|
select(Event)
|
||||||
|
.join(Host)
|
||||||
|
.options(joinedload(Event.host))
|
||||||
|
.order_by(Event.received_at.desc())
|
||||||
|
.limit(8)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
recent_events = [
|
||||||
|
EventSummary(
|
||||||
|
id=e.id,
|
||||||
|
event_id=e.event_id,
|
||||||
|
host_id=e.host_id,
|
||||||
|
hostname=e.host.hostname,
|
||||||
|
occurred_at=e.occurred_at,
|
||||||
|
received_at=e.received_at,
|
||||||
|
category=e.category,
|
||||||
|
type=e.type,
|
||||||
|
severity=e.severity,
|
||||||
|
title=e.title,
|
||||||
|
summary=e.summary,
|
||||||
|
)
|
||||||
|
for e in recent
|
||||||
|
]
|
||||||
|
|
||||||
|
return DashboardSummary(
|
||||||
|
events_last_24h=events_24h,
|
||||||
|
hosts_total=hosts_total,
|
||||||
|
problems_open=problems_open,
|
||||||
|
severity_24h=severity_24h,
|
||||||
|
recent_events=recent_events,
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, events, health, hosts, problems
|
from app.api.v1 import auth, dashboards, events, health, hosts, problems
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
@@ -8,3 +8,4 @@ api_router.include_router(auth.router)
|
|||||||
api_router.include_router(events.router)
|
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)
|
||||||
|
|||||||
+4
-4
@@ -24,8 +24,8 @@
|
|||||||
| 1A.1 | Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, spool | ✅ ssh-monitor |
|
| 1A.1 | Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, spool | ✅ ssh-monitor |
|
||||||
| 1A.2 | `build_sac_event()` + `send_sac_event()` по schema v1 | ✅ `sac-client.sh` |
|
| 1A.2 | `build_sac_event()` + `send_sac_event()` по schema v1 | ✅ `sac-client.sh` |
|
||||||
| 1A.3 | `notify_or_sac()`: off / dual / exclusive / fallback | ✅ + маппинг событий |
|
| 1A.3 | `notify_or_sac()`: off / dual / exclusive / fallback | ✅ + маппинг событий |
|
||||||
| 1A.4 | `--check-sac` / `Test-SacConnection` | 🔄 ssh-monitor `--check-sac` |
|
| 1A.4 | `--check-sac` / `Test-SacConnection` | ✅ ssh-monitor `--check-sac` |
|
||||||
| 1A.5 | README агентов | ⏳ |
|
| 1A.5 | README агентов, first deploy | ✅ `first_deploy.sh`, `--deploy` |
|
||||||
|
|
||||||
**Выход:** на тестовом хосте `dual` шлёт JSON в SAC + Telegram.
|
**Выход:** на тестовом хосте `dual` шлёт JSON в SAC + Telegram.
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
| 1C.1 | Frontend Vue: Events, Hosts | ✅ prod |
|
| 1C.1 | Frontend Vue: Events, Hosts | ✅ prod |
|
||||||
| 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 MVP (деплой ⏳) |
|
| 1C.4 | Telegram из SAC | ✅ backend (деплой + TELEGRAM_* в env) |
|
||||||
| 1C.5 | Dashboard (3 виджета), SSE | ⏳ |
|
| 1C.5 | Dashboard (3 виджета), SSE | 🔄 Dashboard MVP ✅, SSE ⏳ |
|
||||||
|
|
||||||
**Выход:** тег `v0.1.0-mvp`.
|
**Выход:** тег `v0.1.0-mvp`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
<nav v-if="showNav">
|
<nav v-if="showNav">
|
||||||
<span class="brand">SAC</span>
|
<span class="brand">SAC</span>
|
||||||
|
<RouterLink to="/dashboard">Dashboard</RouterLink>
|
||||||
<RouterLink to="/events">События</RouterLink>
|
<RouterLink to="/events">События</RouterLink>
|
||||||
<RouterLink to="/problems">Проблемы</RouterLink>
|
<RouterLink to="/problems">Проблемы</RouterLink>
|
||||||
<RouterLink to="/hosts">Хосты</RouterLink>
|
<RouterLink to="/hosts">Хосты</RouterLink>
|
||||||
|
|||||||
@@ -112,3 +112,11 @@ export async function ackProblem(problemId: number): Promise<{ id: number; statu
|
|||||||
export async function resolveProblem(problemId: number): Promise<{ id: number; status: string }> {
|
export async function resolveProblem(problemId: number): Promise<{ id: number; status: string }> {
|
||||||
return apiFetch(`/api/v1/problems/${problemId}/resolve`, { method: "POST" });
|
return apiFetch(`/api/v1/problems/${problemId}/resolve`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
events_last_24h: number;
|
||||||
|
hosts_total: number;
|
||||||
|
problems_open: number;
|
||||||
|
severity_24h: Record<string, number>;
|
||||||
|
recent_events: EventSummary[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import EventDetailView from "./views/EventDetailView.vue";
|
|||||||
import HostsView from "./views/HostsView.vue";
|
import HostsView from "./views/HostsView.vue";
|
||||||
import LoginView from "./views/LoginView.vue";
|
import LoginView from "./views/LoginView.vue";
|
||||||
import ProblemsView from "./views/ProblemsView.vue";
|
import ProblemsView from "./views/ProblemsView.vue";
|
||||||
|
import DashboardView from "./views/DashboardView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/", redirect: "/events" },
|
{ path: "/", redirect: "/dashboard" },
|
||||||
|
{ path: "/dashboard", component: DashboardView },
|
||||||
{ path: "/login", component: LoginView },
|
{ path: "/login", component: LoginView },
|
||||||
{ path: "/events", component: EventsView },
|
{ path: "/events", component: EventsView },
|
||||||
{ path: "/events/:id", component: EventDetailView, props: true },
|
{ path: "/events/:id", component: EventDetailView, props: true },
|
||||||
|
|||||||
@@ -131,3 +131,36 @@ pre {
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-card {
|
||||||
|
background: #1a2332;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-label {
|
||||||
|
color: #9aa4b2;
|
||||||
|
margin: 0.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
|
<template v-else-if="data">
|
||||||
|
<div class="dashboard-cards">
|
||||||
|
<div class="dash-card">
|
||||||
|
<div class="dash-value">{{ data.events_last_24h }}</div>
|
||||||
|
<div class="dash-label">Событий за 24 ч</div>
|
||||||
|
<RouterLink to="/events">Все события →</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="dash-card">
|
||||||
|
<div class="dash-value">{{ data.problems_open }}</div>
|
||||||
|
<div class="dash-label">Открытых проблем</div>
|
||||||
|
<RouterLink to="/problems">Проблемы →</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="dash-card">
|
||||||
|
<div class="dash-value">{{ data.hosts_total }}</div>
|
||||||
|
<div class="dash-label">Хостов</div>
|
||||||
|
<RouterLink to="/hosts">Хосты →</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Severity за 24 ч</h2>
|
||||||
|
<ul class="severity-list">
|
||||||
|
<li v-for="(count, sev) in data.severity_24h" :key="sev">
|
||||||
|
<span :class="'sev-' + sev">{{ sev }}</span>: {{ count }}
|
||||||
|
</li>
|
||||||
|
<li v-if="!Object.keys(data.severity_24h).length">Нет событий</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Последние события</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Хост</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Title</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="e in data.recent_events" :key="e.id">
|
||||||
|
<td>
|
||||||
|
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||||
|
<td>{{ e.hostname }}</td>
|
||||||
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
|
<td>{{ e.title }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="filters" style="margin-top: 1rem">
|
||||||
|
<button type="button" class="secondary" @click="load">Обновить</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { apiFetch, type DashboardSummary } from "../api";
|
||||||
|
|
||||||
|
const data = ref<DashboardSummary | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
function formatDt(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
data.value = await apiFetch<DashboardSummary>("/api/v1/dashboards/summary");
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => load());
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user