Files
security-alert-center/frontend/src/views/HostDetailView.vue
T
2026-06-05 10:20:32 +10:00

272 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>Версия агента:</strong> {{ 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>