feat: host heartbeat status and dashboard metrics (2.2)

Track agent.heartbeat and report.daily.ssh per host; show online/stale
on Hosts UI; dashboard counters and SAC_HEARTBEAT_STALE_MINUTES config.
This commit is contained in:
PTah
2026-05-27 13:36:17 +10:00
parent 9ba3c4b63b
commit 04301ab359
14 changed files with 246 additions and 4 deletions
+6
View File
@@ -76,6 +76,9 @@ export interface HostSummary {
ipv4: string | null;
last_seen_at: string;
event_count: number | null;
last_heartbeat_at: string | null;
last_daily_report_at: string | null;
agent_status: "online" | "stale" | "unknown";
}
export interface HostListResponse {
@@ -116,6 +119,9 @@ export async function resolveProblem(problemId: number): Promise<{ id: number; s
export interface DashboardSummary {
events_last_24h: number;
hosts_total: number;
hosts_stale: number;
heartbeats_24h: number;
daily_reports_24h: number;
problems_open: number;
severity_24h: Record<string, number>;
recent_events: EventSummary[];
+14
View File
@@ -160,6 +160,10 @@ pre {
margin: 0.25rem 0 0.5rem;
}
.dash-card.dash-warn {
border-color: #ff6b6b;
}
.severity-list {
list-style: none;
padding: 0;
@@ -168,6 +172,16 @@ pre {
gap: 1rem;
}
.agent-online {
color: #6bcb77;
}
.agent-stale {
color: #ff6b6b;
}
.agent-unknown {
color: #9aa4b2;
}
.live-badge {
color: #6bcb77;
font-size: 0.85rem;
+27
View File
@@ -19,6 +19,21 @@
<div class="dash-label">Хостов</div>
<RouterLink to="/hosts">Хосты </RouterLink>
</div>
<div class="dash-card" :class="{ 'dash-warn': data.hosts_stale > 0 }">
<div class="dash-value">{{ data.hosts_stale }}</div>
<div class="dash-label">Устаревший heartbeat</div>
<RouterLink to="/hosts">Хосты </RouterLink>
</div>
<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>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.daily_reports_24h }}</div>
<div class="dash-label">Отчёты за 24 ч</div>
<RouterLink to="/events?type=report.daily.ssh">События </RouterLink>
</div>
</div>
<h2>Severity за 24 ч</h2>
@@ -100,6 +115,9 @@ function connectLive() {
type?: string;
events_last_24h?: number;
problems_open?: number;
hosts_stale?: number;
heartbeats_24h?: number;
daily_reports_24h?: number;
};
if (msg.type !== "dashboard" || !data.value) return;
if (typeof msg.events_last_24h === "number") {
@@ -108,6 +126,15 @@ function connectLive() {
if (typeof msg.problems_open === "number") {
data.value.problems_open = msg.problems_open;
}
if (typeof msg.hosts_stale === "number") {
data.value.hosts_stale = msg.hosts_stale;
}
if (typeof msg.heartbeats_24h === "number") {
data.value.heartbeats_24h = msg.heartbeats_24h;
}
if (typeof msg.daily_reports_24h === "number") {
data.value.daily_reports_24h = msg.daily_reports_24h;
}
} catch {
/* ignore malformed SSE */
}
+8 -1
View File
@@ -57,8 +57,11 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { apiFetch, type EventListResponse } from "../api";
const route = useRoute();
const data = ref<EventListResponse | null>(null);
const loading = ref(false);
const error = ref("");
@@ -92,5 +95,9 @@ async function load(p: number) {
}
}
onMounted(() => load(1));
onMounted(() => {
const t = route.query.type;
if (typeof t === "string" && t) typeFilter.value = t;
load(1);
});
</script>
+12
View File
@@ -12,6 +12,9 @@
<th>Product</th>
<th>OS</th>
<th>IPv4</th>
<th>Агент</th>
<th>Heartbeat</th>
<th>Отчёт</th>
<th>Last seen</th>
<th>Events</th>
</tr>
@@ -23,6 +26,9 @@
<td>{{ h.product }} {{ h.product_version || "" }}</td>
<td>{{ h.os_family }}</td>
<td>{{ h.ipv4 || "—" }}</td>
<td :class="'agent-' + h.agent_status">{{ agentLabel(h.agent_status) }}</td>
<td>{{ h.last_heartbeat_at ? formatDt(h.last_heartbeat_at) : "—" }}</td>
<td>{{ h.last_daily_report_at ? formatDt(h.last_daily_report_at) : "—" }}</td>
<td>{{ formatDt(h.last_seen_at) }}</td>
<td>{{ h.event_count ?? 0 }}</td>
</tr>
@@ -43,6 +49,12 @@ function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
}
function agentLabel(status: string) {
if (status === "online") return "online";
if (status === "stale") return "stale";
return "unknown";
}
onMounted(async () => {
loading.value = true;
try {