feat: host inventory (agent.inventory), host detail page, UI columns

Store hardware/software snapshots on hosts; warn on hardware changes. Host card from Hosts list. Add agent version column to Events/Overview; rename Hosts table columns and sort by status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-05 09:53:00 +10:00
parent 5d3cda2ab6
commit 54337a5917
23 changed files with 826 additions and 17 deletions
+4
View File
@@ -236,6 +236,8 @@
<th>Имя сервера</th>
<th>Версия агента</th>
<th>Severity</th>
<th>Title</th>
@@ -268,6 +270,8 @@
<td>{{ formatServerName(e.display_name) }}</td>
<td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td>{{ e.title }}</td>
+2
View File
@@ -33,6 +33,7 @@
<th>Время</th>
<th>Хост</th>
<th>Имя сервера</th>
<th>Версия агента</th>
<th>Severity</th>
<th>Type</th>
<th>Title</th>
@@ -46,6 +47,7 @@
<td>{{ formatDt(e.occurred_at) }}</td>
<td>{{ e.hostname }}</td>
<td>{{ formatServerName(e.display_name) }}</td>
<td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td>
<td>{{ e.title }}</td>
+272
View File
@@ -0,0 +1,272 @@
<template>
<p><RouterLink to="/hosts"> Хосты</RouterLink></p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
<template v-else-if="host">
<h1>{{ host.display_name || host.hostname }}</h1>
<div class="card report-meta">
<p>
<strong>Hostname:</strong> {{ host.hostname }}
· <strong>ID:</strong> {{ host.id }}
· <strong>Агент:</strong>
<span :class="'agent-' + host.agent_status">{{ agentLabel(host.agent_status) }}</span>
</p>
<p>
<strong>Product:</strong> {{ host.product }} {{ host.product_version || "" }}
· <strong>OS:</strong> {{ host.os_family }} {{ host.os_version || "" }}
· <strong>IPv4:</strong> {{ host.ipv4 || "—" }}
</p>
<p>
<strong>Heartbeat:</strong> {{ host.last_heartbeat_at ? formatDt(host.last_heartbeat_at) : "—" }}
· <strong>Last seen:</strong> {{ formatDt(host.last_seen_at) }}
· <strong>Инвентаризация:</strong>
{{ host.last_inventory_at ? formatDt(host.last_inventory_at) : "—" }}
· <strong>Событий:</strong> {{ host.event_count ?? 0 }}
</p>
</div>
<section v-if="inventory" class="host-inventory card">
<h2>Железо и ПО</h2>
<div v-if="windowsInfo" class="inv-block">
<h3>Windows</h3>
<p>{{ windowsInfo }}</p>
</div>
<div v-if="computerName" class="inv-block">
<p><strong>Имя компьютера:</strong> {{ computerName }}</p>
</div>
<div v-if="processors.length" class="inv-block">
<h3>Процессор</h3>
<ul>
<li v-for="(p, i) in processors" :key="'cpu-' + i">
{{ p.name }}
<span v-if="p.cores != null"> {{ p.cores }} ядер / {{ p.logical_processors }} потоков</span>
</li>
</ul>
</div>
<div v-if="motherboard" class="inv-block">
<h3>Материнская плата</h3>
<p>{{ motherboard }}</p>
</div>
<div v-if="memoryGb != null" class="inv-block">
<h3>Память</h3>
<p>{{ memoryGb }} GB</p>
</div>
<div v-if="disks.length" class="inv-block">
<h3>Диски</h3>
<table class="dash-table">
<thead>
<tr>
<th>Имя</th>
<th>Тип</th>
<th>Размер, GB</th>
</tr>
</thead>
<tbody>
<tr v-for="(d, i) in disks" :key="'disk-' + i">
<td>{{ d.friendly_name || "—" }}</td>
<td>{{ d.media_type || "—" }}</td>
<td>{{ d.size_gb ?? "—" }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="video.length" class="inv-block">
<h3>Видео</h3>
<ul>
<li v-for="(v, i) in video" :key="'gpu-' + i">{{ v.name }}</li>
</ul>
</div>
<div v-if="ipv4List.length" class="inv-block">
<h3>IPv4</h3>
<p>{{ ipv4List.join(", ") }}</p>
</div>
</section>
<p v-else class="muted">Инвентаризация ещё не поступала с агента (agent.inventory).</p>
<h2>События хоста</h2>
<p v-if="eventsLoading">Загрузка событий</p>
<p v-else-if="eventsError" class="error">{{ eventsError }}</p>
<template v-else>
<p>Всего: {{ events?.total ?? 0 }}</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Время</th>
<th>Severity</th>
<th>Type</th>
<th>Title</th>
</tr>
</thead>
<tbody>
<tr v-for="e in events?.items ?? []" :key="e.id">
<td>
<RouterLink :to="{ path: `/events/${e.id}`, query: eventsBackQuery }">{{ e.id }}</RouterLink>
</td>
<td>{{ formatDt(e.occurred_at) }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td>
<td>{{ e.title }}</td>
</tr>
</tbody>
</table>
<div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" :disabled="eventsPage <= 1" @click="loadEvents(eventsPage - 1)">
Назад
</button>
<span>Стр. {{ eventsPage }}</span>
<button
type="button"
class="secondary"
:disabled="!events || eventsPage * eventsPageSize >= events.total"
@click="loadEvents(eventsPage + 1)"
>
Вперёд
</button>
</div>
</template>
</template>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { apiFetch, type EventListResponse, type HostDetail } from "../api";
const props = defineProps<{ id: string }>();
const route = useRoute();
const host = ref<HostDetail | null>(null);
const events = ref<EventListResponse | null>(null);
const loading = ref(false);
const eventsLoading = ref(false);
const error = ref("");
const eventsError = ref("");
const eventsPage = ref(1);
const eventsPageSize = 50;
const hostId = computed(() => parseInt(props.id, 10));
const inventory = computed(() => host.value?.inventory ?? null);
const windowsInfo = computed(() => {
const w = inventory.value?.windows as Record<string, string> | undefined;
if (!w) return "";
const parts = [w.product_name, w.version, w.os_hardware_abstraction_layer].filter(Boolean);
return parts.join(" · ");
});
const computerName = computed(() => {
const v = inventory.value?.computer_name;
return typeof v === "string" && v.trim() ? v : "";
});
const processors = computed(() => {
const raw = inventory.value?.processor;
if (!Array.isArray(raw)) return [];
return raw.filter((p): p is Record<string, unknown> => p != null && typeof p === "object");
});
const motherboard = computed(() => {
const m = inventory.value?.motherboard as Record<string, string> | undefined;
if (!m) return "";
return [m.manufacturer, m.product].filter(Boolean).join(" ");
});
const memoryGb = computed(() => {
const v = inventory.value?.memory_gb;
return typeof v === "number" ? v : null;
});
const disks = computed(() => {
const raw = inventory.value?.disks;
if (!Array.isArray(raw)) return [];
return raw.filter((d): d is Record<string, unknown> => d != null && typeof d === "object");
});
const video = computed(() => {
const raw = inventory.value?.video;
if (!Array.isArray(raw)) return [];
return raw.filter((v): v is Record<string, string> => v != null && typeof v === "object");
});
const ipv4List = computed(() => {
const raw = inventory.value?.ipv4;
if (!Array.isArray(raw)) return [];
return raw.map(String).filter(Boolean);
});
const eventsBackQuery = computed(() => ({ host_id: String(hostId.value) }));
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";
}
async function loadHost() {
loading.value = true;
error.value = "";
try {
host.value = await apiFetch<HostDetail>(`/api/v1/hosts/${hostId.value}`);
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста";
} finally {
loading.value = false;
}
}
async function loadEvents(page = 1) {
eventsLoading.value = true;
eventsError.value = "";
eventsPage.value = page;
try {
const params = new URLSearchParams({
page: String(page),
page_size: String(eventsPageSize),
host_id: String(hostId.value),
});
events.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
} catch (e) {
eventsError.value = e instanceof Error ? e.message : "Ошибка загрузки событий";
} finally {
eventsLoading.value = false;
}
}
onMounted(async () => {
await loadHost();
await loadEvents(1);
});
watch(
() => route.params.id,
async () => {
await loadHost();
await loadEvents(1);
},
);
</script>
<style scoped>
.host-inventory {
margin: 1rem 0 1.5rem;
}
.inv-block {
margin-bottom: 1rem;
}
.inv-block h3 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.muted {
color: #9aa4b2;
}
</style>
+39 -9
View File
@@ -17,10 +17,10 @@
<th><button type="button" class="th-sort" @click="setSort('id')">ID {{ sortMark('id') }}</button></th>
<th><button type="button" class="th-sort" @click="setSort('display_name')">Имя {{ sortMark('display_name') }}</button></th>
<th><button type="button" class="th-sort" @click="setSort('hostname')">Hostname {{ sortMark('hostname') }}</button></th>
<th><button type="button" class="th-sort" @click="setSort('product')">Product {{ sortMark('product') }}</button></th>
<th><button type="button" class="th-sort" @click="setSort('product_version')">Версия агента {{ sortMark('product_version') }}</button></th>
<th><button type="button" class="th-sort" @click="setSort('os_family')">OS {{ sortMark('os_family') }}</button></th>
<th><button type="button" class="th-sort" @click="setSort('ipv4')">IPv4 {{ sortMark('ipv4') }}</button></th>
<th>Агент</th>
<th><button type="button" class="th-sort" @click="setSort('agent_status')">Статус {{ sortMark('agent_status') }}</button></th>
<th>Heartbeat</th>
<th><button type="button" class="th-sort" @click="setSort('last_daily_report_at')">Отчёт {{ sortMark('last_daily_report_at') }}</button></th>
<th><button type="button" class="th-sort" @click="setSort('last_seen_at')">Last seen {{ sortMark('last_seen_at') }}</button></th>
@@ -31,9 +31,18 @@
<tbody>
<tr v-for="h in sortedItems" :key="h.id">
<td>{{ h.id }}</td>
<td>{{ h.display_name || "—" }}</td>
<td>{{ h.hostname }}</td>
<td>{{ h.product }} {{ h.product_version || "" }}</td>
<td>
<RouterLink :to="`/hosts/${h.id}`" class="host-link">
{{ h.display_name || h.hostname }}
</RouterLink>
</td>
<td>
<RouterLink v-if="h.hostname !== (h.display_name || h.hostname)" :to="`/hosts/${h.id}`" class="host-link">
{{ h.hostname }}
</RouterLink>
<span v-else>{{ h.hostname }}</span>
</td>
<td>{{ h.product_version || "—" }}</td>
<td>{{ h.os_family }}</td>
<td>{{ h.ipv4 || "—" }}</td>
<td :class="'agent-' + h.agent_status">{{ agentLabel(h.agent_status) }}</td>
@@ -78,9 +87,10 @@ type SortKey =
| "id"
| "display_name"
| "hostname"
| "product"
| "product_version"
| "os_family"
| "ipv4"
| "agent_status"
| "last_seen_at"
| "event_count"
| "last_daily_report_at";
@@ -89,9 +99,10 @@ const HOSTS_SORT_KEYS: SortKey[] = [
"id",
"display_name",
"hostname",
"product",
"product_version",
"os_family",
"ipv4",
"agent_status",
"last_seen_at",
"event_count",
"last_daily_report_at",
@@ -137,6 +148,12 @@ function agentLabel(status: string) {
return "unknown";
}
function agentStatusOrder(status: string) {
if (status === "online") return 0;
if (status === "stale") return 1;
return 2;
}
function setSort(key: SortKey) {
if (sortBy.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
@@ -180,8 +197,8 @@ const sortedItems = computed(() => {
case "hostname":
cmp = compareNullableStrings(a.hostname, b.hostname);
break;
case "product":
cmp = compareNullableStrings(a.product, b.product);
case "product_version":
cmp = compareNullableStrings(a.product_version, b.product_version);
break;
case "os_family":
cmp = compareNullableStrings(a.os_family, b.os_family);
@@ -189,6 +206,9 @@ const sortedItems = computed(() => {
case "ipv4":
cmp = compareNullableStrings(a.ipv4, b.ipv4);
break;
case "agent_status":
cmp = agentStatusOrder(a.agent_status) - agentStatusOrder(b.agent_status);
break;
case "event_count":
cmp = (a.event_count ?? 0) - (b.event_count ?? 0);
break;
@@ -256,3 +276,13 @@ onMounted(() => {
loadHosts();
});
</script>
<style scoped>
.host-link {
cursor: pointer;
text-decoration: none;
}
.host-link:hover {
text-decoration: underline;
}
</style>