Files
security-alert-center/frontend/src/views/HostsView.vue
T

382 lines
12 KiB
Vue

<template>
<h1>Хосты</h1>
<div class="filters">
<label>
Поиск (hostname / display name)
<input v-model="search" type="search" placeholder="UNMS Kalina" @keyup.enter="applyFilters" />
</label>
<label>
Статус агента
<select v-model="agentStatusFilter" @change="applyFilters">
<option value="">Все</option>
<option value="online">online</option>
<option value="stale">stale</option>
<option value="unknown">unknown</option>
</select>
</label>
<button type="button" class="secondary" @click="applyFilters">Найти</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
<template v-else>
<p>
Всего: {{ data?.total ?? 0 }}
<span v-if="live" class="live-badge">live</span>
</p>
<table class="hosts-table">
<thead>
<tr>
<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_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><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>
<th><button type="button" class="th-sort" @click="setSort('event_count')">Events {{ sortMark('event_count') }}</button></th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr
v-for="h in sortedItems"
:key="h.id"
class="sac-host-row"
tabindex="0"
:title="`Карточка хоста ${h.hostname}`"
@click="openHost(h.id)"
@keydown.enter="openHost(h.id)"
>
<td>{{ h.id }}</td>
<td>{{ h.display_name || h.hostname }}</td>
<td>{{ h.hostname }}</td>
<td>
<span
:class="{ 'agent-version-outdated': isVersionOutdated(h) }"
:title="versionTitle(h)"
>
{{ h.product_version || "—" }}
</span>
</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 @click.stop>
<RouterLink
v-if="h.last_daily_report_at"
:to="{ path: '/reports', query: { hostname: h.hostname } }"
>
{{ formatDt(h.last_daily_report_at) }}
</RouterLink>
<span v-else></span>
</td>
<td>{{ formatDt(h.last_seen_at) }}</td>
<td>{{ h.event_count ?? 0 }}</td>
<td class="actions" @click.stop>
<div class="actions-inner">
<button
type="button"
class="danger"
:disabled="deletingId === h.id"
title="Удалить хост и все его события"
@click="confirmDelete(h)"
>
Удалить
</button>
</div>
</td>
</tr>
</tbody>
</table>
</template>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import {
apiFetch,
fetchHost,
fetchRecentEvents,
type HostListResponse,
type HostSummary,
} from "../api";
import { patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
import { isAgentVersionOutdated } from "../utils/agentVersion";
const router = useRouter();
const route = useRoute();
const data = ref<HostListResponse | null>(null);
const loading = ref(false);
const error = ref("");
const search = ref("");
const agentStatusFilter = ref("");
type SortKey =
| "id"
| "display_name"
| "hostname"
| "product_version"
| "os_family"
| "ipv4"
| "agent_status"
| "last_seen_at"
| "event_count"
| "last_daily_report_at";
const HOSTS_SORT_KEYS: SortKey[] = [
"id",
"display_name",
"hostname",
"product_version",
"os_family",
"ipv4",
"agent_status",
"last_seen_at",
"event_count",
"last_daily_report_at",
];
const HOSTS_SORT_STORAGE_KEY = "sac_hosts_sort";
function loadHostsSortPrefs(): { sortBy: SortKey; sortDir: "asc" | "desc" } {
const fallback = { sortBy: "last_seen_at" as SortKey, sortDir: "desc" as const };
try {
const raw = localStorage.getItem(HOSTS_SORT_STORAGE_KEY);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as { sortBy?: string; sortDir?: string };
if (
parsed.sortBy &&
HOSTS_SORT_KEYS.includes(parsed.sortBy as SortKey) &&
(parsed.sortDir === "asc" || parsed.sortDir === "desc")
) {
return { sortBy: parsed.sortBy as SortKey, sortDir: parsed.sortDir };
}
} catch {
/* ignore corrupt storage */
}
return fallback;
}
function saveHostsSortPrefs(sortBy: SortKey, sortDir: "asc" | "desc") {
localStorage.setItem(HOSTS_SORT_STORAGE_KEY, JSON.stringify({ sortBy, sortDir }));
}
const initialSort = loadHostsSortPrefs();
const sortBy = ref<SortKey>(initialSort.sortBy);
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
const deletingId = ref<number | null>(null);
let patchingHostRow = false;
const { live } = useSacLiveEventStream(() => {
void patchHostRowFromLatestEvent();
});
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";
}
function isVersionOutdated(h: HostSummary): boolean {
const latest = data.value?.latest_agent_versions?.[h.product];
return isAgentVersionOutdated(h.product_version, latest);
}
function versionTitle(h: HostSummary): string {
const latest = data.value?.latest_agent_versions?.[h.product];
if (!isVersionOutdated(h)) return "";
return latest
? `Устарела относительно последней для ${h.product}: ${latest}`
: "Устаревшая версия агента";
}
function openHost(id: number) {
router.push(`/hosts/${id}`);
}
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";
} else {
sortBy.value = key;
sortDir.value = key === "id" || key === "last_seen_at" || key === "event_count" ? "desc" : "asc";
}
saveHostsSortPrefs(sortBy.value, sortDir.value);
}
function sortMark(key: SortKey) {
if (sortBy.value !== key) return "";
return sortDir.value === "asc" ? "↑" : "↓";
}
function compareNullableStrings(a: string | null, b: string | null) {
const av = (a ?? "").trim().toLowerCase();
const bv = (b ?? "").trim().toLowerCase();
return av.localeCompare(bv, "ru");
}
function compareNullableDates(a: string | null, b: string | null) {
const av = a ? new Date(a).getTime() : 0;
const bv = b ? new Date(b).getTime() : 0;
return av - bv;
}
const sortedItems = computed(() => {
const items = [...(data.value?.items ?? [])];
const key = sortBy.value;
const dir = sortDir.value === "asc" ? 1 : -1;
items.sort((a: HostSummary, b: HostSummary) => {
let cmp = 0;
switch (key) {
case "id":
cmp = a.id - b.id;
break;
case "display_name":
cmp = compareNullableStrings(a.display_name, b.display_name);
break;
case "hostname":
cmp = compareNullableStrings(a.hostname, b.hostname);
break;
case "product_version":
cmp = compareNullableStrings(a.product_version, b.product_version);
break;
case "os_family":
cmp = compareNullableStrings(a.os_family, b.os_family);
break;
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;
case "last_daily_report_at":
cmp = compareNullableDates(a.last_daily_report_at, b.last_daily_report_at);
break;
case "last_seen_at":
cmp = compareNullableDates(a.last_seen_at, b.last_seen_at);
break;
}
if (cmp === 0) {
cmp = a.hostname.localeCompare(b.hostname, "ru");
}
return cmp * dir;
});
return items;
});
async function loadHosts() {
loading.value = true;
error.value = "";
try {
const q = search.value.trim();
const params = new URLSearchParams({ page: "1", page_size: "100" });
if (q) params.set("hostname", q);
if (agentStatusFilter.value) params.set("agent_status", agentStatusFilter.value);
data.value = await apiFetch<HostListResponse>(`/api/v1/hosts?${params}`);
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
} finally {
loading.value = false;
}
}
function buildHostsQuery(): Record<string, string> {
const q: Record<string, string> = {};
if (search.value.trim()) q.hostname = search.value.trim();
if (agentStatusFilter.value) q.agent_status = agentStatusFilter.value;
return q;
}
function applyFilters() {
void router.replace({ path: "/hosts", query: buildHostsQuery() });
void loadHosts();
}
function syncFromRoute() {
const hostname = route.query.hostname;
if (typeof hostname === "string") search.value = hostname;
const status = route.query.agent_status;
agentStatusFilter.value = typeof status === "string" ? status : "";
}
async function patchHostRowFromLatestEvent() {
if (!data.value?.items.length || patchingHostRow) return;
patchingHostRow = true;
try {
const recent = await fetchRecentEvents(1);
const ev = recent[0];
if (!ev) return;
const row = data.value.items.find((h) => h.id === ev.host_id);
if (!row) return;
const detail = await fetchHost(ev.host_id);
patchHostSummaryFromDetail(row, detail);
} catch {
/* keep table on transient errors */
} finally {
patchingHostRow = false;
}
}
interface HostDeleteResponse {
status: string;
host_id: number;
hostname: string;
deleted_events: number;
deleted_problems: number;
}
async function confirmDelete(h: HostSummary) {
const label = h.display_name || h.hostname;
const n = h.event_count ?? 0;
const msg =
`Удалить хост «${label}» (${h.hostname})?\n\n` +
`Будут удалены все события (${n}) и проблемы этого хоста. ` +
`При новом ingest агент снова появится в списке.`;
if (!window.confirm(msg)) {
return;
}
deletingId.value = h.id;
error.value = "";
try {
await apiFetch<HostDeleteResponse>(`/api/v1/hosts/${h.id}`, { method: "DELETE" });
await loadHosts();
} catch (e) {
error.value = e instanceof Error ? e.message : "Не удалось удалить хост";
} finally {
deletingId.value = null;
}
}
onMounted(() => {
syncFromRoute();
loadHosts();
});
watch(
() => [route.query.hostname, route.query.agent_status] as const,
() => {
syncFromRoute();
loadHosts();
},
);
</script>