feat: dashboard top hosts/types, problems 24h, drill-down (d2-2)
- Extend /dashboards/summary; Events filters from query (hostname, severity) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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<string, number>;
|
||||
top_hosts: TopHostItem[];
|
||||
top_event_types: TopTypeItem[];
|
||||
recent_events: EventSummary[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,16 @@
|
||||
<div class="dash-label">Открытых проблем</div>
|
||||
<RouterLink :to="{ path: '/problems', query: { status: 'open' } }">Проблемы →</RouterLink>
|
||||
</div>
|
||||
<div class="dash-card">
|
||||
<div class="dash-value">{{ data.problems_opened_24h }}</div>
|
||||
<div class="dash-label">Новых проблем за 24 ч</div>
|
||||
<RouterLink to="/problems">Все проблемы →</RouterLink>
|
||||
</div>
|
||||
<div class="dash-card">
|
||||
<div class="dash-value">{{ data.problems_resolved_24h }}</div>
|
||||
<div class="dash-label">Закрыто за 24 ч</div>
|
||||
<RouterLink :to="{ path: '/problems', query: { status: 'resolved' } }">Resolved →</RouterLink>
|
||||
</div>
|
||||
<div class="dash-card">
|
||||
<div class="dash-value">{{ data.hosts_total }}</div>
|
||||
<div class="dash-label">Хостов</div>
|
||||
@@ -27,7 +37,7 @@
|
||||
<div class="dash-card">
|
||||
<div class="dash-value">{{ data.heartbeats_24h }}</div>
|
||||
<div class="dash-label">Heartbeat за 24 ч</div>
|
||||
<RouterLink to="/events?type=agent.heartbeat">События →</RouterLink>
|
||||
<RouterLink :to="{ path: '/events', query: { type: 'agent.heartbeat' } }">События →</RouterLink>
|
||||
</div>
|
||||
<div class="dash-card">
|
||||
<div class="dash-value">{{ data.daily_reports_24h }}</div>
|
||||
@@ -36,10 +46,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-panels">
|
||||
<section class="dash-panel">
|
||||
<h2>Top хостов (24 ч)</h2>
|
||||
<table v-if="data.top_hosts.length" class="dash-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Хост</th>
|
||||
<th>Событий</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="h in data.top_hosts" :key="h.host_id">
|
||||
<td>
|
||||
<RouterLink :to="{ path: '/events', query: { hostname: h.hostname } }">
|
||||
{{ h.hostname }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td>{{ h.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">Нет событий за 24 ч</p>
|
||||
</section>
|
||||
|
||||
<section class="dash-panel">
|
||||
<h2>Top типов (24 ч)</h2>
|
||||
<table v-if="data.top_event_types.length" class="dash-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Событий</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in data.top_event_types" :key="t.type">
|
||||
<td>
|
||||
<RouterLink :to="{ path: '/events', query: { type: t.type } }">
|
||||
<code>{{ t.type }}</code>
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td>{{ t.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">Нет событий за 24 ч</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2>Severity за 24 ч</h2>
|
||||
<ul class="severity-list">
|
||||
<li v-for="(count, sev) in data.severity_24h" :key="sev">
|
||||
<RouterLink :to="{ path: '/events', query: { severity: sev } }">
|
||||
<span :class="'sev-' + sev">{{ sev }}</span>: {{ count }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li v-if="!Object.keys(data.severity_24h).length">Нет событий</li>
|
||||
</ul>
|
||||
@@ -61,7 +121,11 @@
|
||||
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
|
||||
</td>
|
||||
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||
<td>{{ e.hostname }}</td>
|
||||
<td>
|
||||
<RouterLink :to="{ path: '/events', query: { hostname: e.hostname } }">
|
||||
{{ e.hostname }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||
<td>{{ e.title }}</td>
|
||||
</tr>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<option>critical</option>
|
||||
</select>
|
||||
<input v-model="typeFilter" placeholder="type" @keyup.enter="load(1)" />
|
||||
<input v-model="hostnameFilter" placeholder="hostname" @keyup.enter="load(1)" />
|
||||
<button type="button" @click="load(1)">Применить</button>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@@ -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<EventListResponse>(`/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);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user