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:
PTah
2026-06-05 09:53:00 +10:00
parent 5d3cda2ab6
commit 54337a5917
23 changed files with 826 additions and 17 deletions
+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>