diff --git a/backend/app/api/v1/dashboards.py b/backend/app/api/v1/dashboards.py index 4959dca..e804ff8 100644 --- a/backend/app/api/v1/dashboards.py +++ b/backend/app/api/v1/dashboards.py @@ -15,6 +15,17 @@ from app.services.host_health import DAILY_REPORT_SSH, HEARTBEAT_TYPE, count_sta router = APIRouter(prefix="/dashboards", tags=["dashboards"]) +class TopHostItem(BaseModel): + host_id: int + hostname: str + count: int + + +class TopTypeItem(BaseModel): + type: str + count: int + + class DashboardSummary(BaseModel): events_last_24h: int hosts_total: int @@ -22,7 +33,11 @@ class DashboardSummary(BaseModel): heartbeats_24h: int daily_reports_24h: int problems_open: int + problems_opened_24h: int + problems_resolved_24h: int severity_24h: dict[str, int] + top_hosts: list[TopHostItem] + top_event_types: list[TopTypeItem] recent_events: list[EventSummary] @@ -52,6 +67,40 @@ def dashboard_summary( problems_open = ( db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0 ) + problems_opened_24h = ( + db.scalar(select(func.count()).select_from(Problem).where(Problem.created_at >= since)) or 0 + ) + problems_resolved_24h = ( + db.scalar( + select(func.count()).select_from(Problem).where( + Problem.status == "resolved", + Problem.updated_at >= since, + ) + ) + or 0 + ) + + top_host_rows = db.execute( + select(Host.id, Host.hostname, func.count()) + .select_from(Event) + .join(Host, Event.host_id == Host.id) + .where(Event.received_at >= since) + .group_by(Host.id, Host.hostname) + .order_by(func.count().desc()) + .limit(10) + ).all() + top_hosts = [ + TopHostItem(host_id=row[0], hostname=row[1], count=row[2]) for row in top_host_rows + ] + + top_type_rows = db.execute( + select(Event.type, func.count()) + .where(Event.received_at >= since) + .group_by(Event.type) + .order_by(func.count().desc()) + .limit(10) + ).all() + top_event_types = [TopTypeItem(type=row[0], count=row[1]) for row in top_type_rows] severity_rows = db.execute( select(Event.severity, func.count()) @@ -92,6 +141,10 @@ def dashboard_summary( heartbeats_24h=heartbeats_24h, daily_reports_24h=daily_reports_24h, problems_open=problems_open, + problems_opened_24h=problems_opened_24h, + problems_resolved_24h=problems_resolved_24h, severity_24h=severity_24h, + top_hosts=top_hosts, + top_event_types=top_event_types, recent_events=recent_events, ) diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..da58ec2 --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,92 @@ +"""Dashboard summary API.""" + +import uuid +from datetime import datetime, timedelta, timezone + +from app.models import Event, Host, Problem + + +def _host(db, name: str) -> Host: + h = Host( + hostname=name, + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db.add(h) + db.flush() + return h + + +def _event(db, host: Host, etype: str, *, received_at: datetime | None = None) -> Event: + now = received_at or datetime.now(timezone.utc) + e = Event( + event_id=str(uuid.uuid4()), + host_id=host.id, + occurred_at=now, + received_at=now, + category="security", + type=etype, + severity="info", + title=f"t {etype}", + summary="s", + payload={}, + ) + db.add(e) + db.flush() + return e + + +def test_dashboard_summary_top_and_problems_24h(client, db_session, jwt_headers): + h1 = _host(db_session, "web-01") + h2 = _host(db_session, "db-01") + now = datetime.now(timezone.utc) + for _ in range(3): + _event(db_session, h1, "ssh.login.failed") + _event(db_session, h2, "agent.heartbeat") + _event(db_session, h2, "agent.heartbeat") + + db_session.add( + Problem( + host_id=h1.id, + title="open now", + summary="s", + severity="high", + status="open", + rule_id="rule:test", + fingerprint="fp1", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(hours=2), + updated_at=now - timedelta(hours=2), + ) + ) + db_session.add( + Problem( + host_id=h2.id, + title="resolved today", + summary="s", + severity="warning", + status="resolved", + rule_id="rule:test", + fingerprint="fp2", + event_count=1, + last_seen_at=now, + created_at=now - timedelta(hours=5), + updated_at=now - timedelta(hours=1), + ) + ) + db_session.commit() + + r = client.get("/api/v1/dashboards/summary", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["events_last_24h"] == 5 + assert body["problems_open"] == 1 + assert body["problems_opened_24h"] == 2 + assert body["problems_resolved_24h"] == 1 + assert body["top_hosts"][0]["hostname"] == "web-01" + assert body["top_hosts"][0]["count"] == 3 + types = {row["type"]: row["count"] for row in body["top_event_types"]} + assert types["ssh.login.failed"] == 3 + assert types["agent.heartbeat"] == 2 diff --git a/docs/work-plan.md b/docs/work-plan.md index d175182..bb2504d 100644 --- a/docs/work-plan.md +++ b/docs/work-plan.md @@ -88,7 +88,7 @@ ### День 2 - [x] `d2-1` UI Problems: список, фильтры, `Ack/Resolve`, карточка с таймлайном -- [ ] `d2-2` Dashboard: top hosts/types, `open vs resolved 24h`, drill-down +- [x] `d2-2` Dashboard: top hosts/types, `open vs resolved 24h`, drill-down - [ ] `d2-3` Ops: retention, health checks, runbook backup/restore/deploy - [ ] `dod` DoD: нет дублей `event_id`, Problems e2e, 3 правила, UI MVP, docs, push в kalinamall @@ -118,8 +118,8 @@ - [x] `09:00–11:00` UI Problems: таблица + фильтры (`status/severity/host`) - [x] `11:00–12:00` карточка проблемы + таймлайн событий -- [ ] `13:00–14:30` Dashboard: виджеты top hosts/types -- [ ] `14:30–16:00` Dashboard: `open/resolved 24h` + drill-down +- [x] `13:00–14:30` Dashboard: виджеты top hosts/types +- [x] `14:30–16:00` Dashboard: `open/resolved 24h` + drill-down - [ ] `16:00–17:00` retention job (`events 30–90d`, `problems 180d+`) - [ ] `17:00–18:00` health checks (DB/worker/heartbeat stale) - [ ] `18:00–19:00` runbook + финальный push в kalinamall + freeze dev diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8ee422d..54c0c91 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -137,6 +137,17 @@ export async function resolveProblem(problemId: number): Promise<{ id: number; s return apiFetch(`/api/v1/problems/${problemId}/resolve`, { method: "POST" }); } +export interface TopHostItem { + host_id: number; + hostname: string; + count: number; +} + +export interface TopTypeItem { + type: string; + count: number; +} + export interface DashboardSummary { events_last_24h: number; hosts_total: number; @@ -144,6 +155,10 @@ export interface DashboardSummary { heartbeats_24h: number; daily_reports_24h: number; problems_open: number; + problems_opened_24h: number; + problems_resolved_24h: number; severity_24h: Record; + top_hosts: TopHostItem[]; + top_event_types: TopTypeItem[]; recent_events: EventSummary[]; } diff --git a/frontend/src/style.css b/frontend/src/style.css index f397666..c9d4ce6 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -147,6 +147,40 @@ pre { margin-bottom: 1.5rem; } +.dashboard-panels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.dash-panel { + background: #1a2332; + border: 1px solid #2a3441; + border-radius: 8px; + padding: 1rem; +} + +.dash-panel h2 { + margin-top: 0; + font-size: 1rem; +} + +.dash-table { + width: 100%; +} + +.dash-table td:last-child, +.dash-table th:last-child { + text-align: right; + width: 5rem; +} + +.muted { + color: #9aa4b2; + margin: 0; +} + .dash-card { background: #1a2332; border: 1px solid #2a3441; diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 3587e65..955465d 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -14,6 +14,16 @@
Открытых проблем
Проблемы → +
+
{{ data.problems_opened_24h }}
+
Новых проблем за 24 ч
+ Все проблемы → +
+
+
{{ data.problems_resolved_24h }}
+
Закрыто за 24 ч
+ Resolved → +
{{ data.hosts_total }}
Хостов
@@ -27,7 +37,7 @@
{{ data.heartbeats_24h }}
Heartbeat за 24 ч
- События → + События →
{{ data.daily_reports_24h }}
@@ -36,10 +46,60 @@
+
+
+

Top хостов (24 ч)

+ + + + + + + + + + + + + +
ХостСобытий
+ + {{ h.hostname }} + + {{ h.count }}
+

Нет событий за 24 ч

+
+ +
+

Top типов (24 ч)

+ + + + + + + + + + + + + +
TypeСобытий
+ + {{ t.type }} + + {{ t.count }}
+

Нет событий за 24 ч

+
+
+

Severity за 24 ч

@@ -61,7 +121,11 @@ {{ e.id }} {{ formatDt(e.occurred_at) }} - {{ e.hostname }} + + + {{ e.hostname }} + + {{ e.severity }} {{ e.title }} diff --git a/frontend/src/views/EventsView.vue b/frontend/src/views/EventsView.vue index 07695f5..7b69177 100644 --- a/frontend/src/views/EventsView.vue +++ b/frontend/src/views/EventsView.vue @@ -10,6 +10,7 @@ +

{{ error }}

@@ -70,6 +71,7 @@ const pageSize = 50; const q = ref(""); const severity = ref(""); const typeFilter = ref(""); +const hostnameFilter = ref(""); function formatDt(iso: string) { return new Date(iso).toLocaleString("ru-RU"); @@ -86,6 +88,7 @@ async function load(p: number) { }); if (severity.value) params.set("severity", severity.value); if (typeFilter.value) params.set("type", typeFilter.value); + if (hostnameFilter.value.trim()) params.set("hostname", hostnameFilter.value.trim()); if (q.value.trim()) params.set("q", q.value.trim()); data.value = await apiFetch(`/api/v1/events?${params}`); } catch (e) { @@ -98,6 +101,10 @@ async function load(p: number) { onMounted(() => { const t = route.query.type; if (typeof t === "string" && t) typeFilter.value = t; + const sev = route.query.severity; + if (typeof sev === "string" && sev) severity.value = sev; + const h = route.query.hostname; + if (typeof h === "string" && h) hostnameFilter.value = h; load(1); });