3475c1811b
Windows WinRM updates no longer show the Linux /var/log/update_script.log hint while output is still empty.
558 lines
17 KiB
Vue
558 lines
17 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>
|
|
<button type="button" @click="manualAddOpen = true">Добавить вручную</button>
|
|
</div>
|
|
<p v-if="error" class="error">{{ error }}</p>
|
|
<p v-else-if="loading">Загрузка…</p>
|
|
<template v-else>
|
|
<div class="hosts-list-meta">
|
|
<p class="hosts-list-total">
|
|
Всего: {{ data?.total ?? 0 }}
|
|
<span v-if="live" class="live-badge">live</span>
|
|
</p>
|
|
<aside
|
|
class="agent-releases-plate"
|
|
title="Версии из git-репозиториев агентов (version.txt, ветка main)"
|
|
>
|
|
<span class="agent-releases-plate__label">Последние версии агентов:</span>
|
|
<span class="agent-releases-plate__item">
|
|
Win: <code>{{ gitWinVersion }}</code>
|
|
</span>
|
|
<span class="agent-releases-plate__item">
|
|
SSH: <code>{{ gitSshVersion }}</code>
|
|
</span>
|
|
</aside>
|
|
</div>
|
|
<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 @click.stop>
|
|
<HostAgentVersionUpgrade
|
|
:host="h"
|
|
:git="data"
|
|
:updating="isHostRemoteActionActive(h.id)"
|
|
:outdated-highlight="isVersionOutdated(h)"
|
|
@upgrade="startAgentUpgrade(h)"
|
|
/>
|
|
</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>
|
|
<HostManualAddModal
|
|
:open="manualAddOpen"
|
|
@close="manualAddOpen = false"
|
|
@started="onManualAddStarted"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
|
import {
|
|
apiFetch,
|
|
ApiError,
|
|
fetchHost,
|
|
fetchRecentEvents,
|
|
type HostDetail,
|
|
type HostListResponse,
|
|
type HostSummary,
|
|
} from "../api";
|
|
import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
|
|
import { subscribeHostPatch } from "../utils/hostPatchBus";
|
|
import { isAgentVersionOutdated } from "../utils/agentVersion";
|
|
import {
|
|
isGitAgentUpgradeAvailable,
|
|
remoteActionKindForHost,
|
|
remoteActionLogPlaceholder,
|
|
remoteActionLogPlaceholderFromTitle,
|
|
remoteActionTitleForHost,
|
|
} from "../utils/hostAgentUpgrade";
|
|
import {
|
|
hostRemoteActionLogs,
|
|
isHostRemoteActionActive,
|
|
pollHostRemoteAction,
|
|
runHostRemoteAction,
|
|
showHostRemoteActionLog,
|
|
} from "../composables/useHostRemoteAction";
|
|
import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue";
|
|
import HostManualAddModal from "../components/HostManualAddModal.vue";
|
|
|
|
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);
|
|
const manualAddOpen = ref(false);
|
|
let patchingHostRow = false;
|
|
let unsubscribeHostPatch: (() => void) | null = null;
|
|
|
|
function applyHostPatchFromDetail(detail: HostDetail) {
|
|
if (!data.value) return;
|
|
const row = data.value.items.find((h) => h.id === detail.id);
|
|
if (row) {
|
|
patchHostSummaryFromDetail(row, detail);
|
|
bumpLatestAgentVersion(data.value.latest_agent_versions, row.product, detail.product_version);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if (h.version_status === "outdated") return true;
|
|
if (h.version_status === "ok") return false;
|
|
const reference =
|
|
data.value?.reference_agent_versions?.[h.product] ??
|
|
data.value?.latest_agent_versions?.[h.product];
|
|
return isAgentVersionOutdated(h.product_version, reference);
|
|
}
|
|
|
|
function countOutdatedUpgradeableHosts(): number {
|
|
return (data.value?.items ?? []).filter(
|
|
(h) => isGitAgentUpgradeAvailable(h, data.value) && isVersionOutdated(h),
|
|
).length;
|
|
}
|
|
|
|
function countActiveRemoteUpgrades(): number {
|
|
return hostRemoteActionLogs.filter((e) => e.loading).length;
|
|
}
|
|
|
|
function confirmMassUpgradeIfNeeded(hostname: string): boolean {
|
|
const active = countActiveRemoteUpgrades();
|
|
const outdated = countOutdatedUpgradeableHosts();
|
|
if (active >= 2) {
|
|
return window.confirm(
|
|
`Сейчас обновляется ${active} хост(ов). Рекомендуется пачками по 2–3. Продолжить обновление «${hostname}»?`,
|
|
);
|
|
}
|
|
if (outdated > 3 && active >= 1) {
|
|
return window.confirm(
|
|
`Устарело агентов на странице: ${outdated}, уже идёт обновление. Продолжить «${hostname}»?`,
|
|
);
|
|
}
|
|
if (outdated > 3) {
|
|
return window.confirm(
|
|
`Устарело агентов на странице: ${outdated}. Рекомендуется обновлять пачками по 2–3, не все сразу. Продолжить «${hostname}»?`,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function startAgentUpgrade(h: HostSummary) {
|
|
if (!isGitAgentUpgradeAvailable(h, data.value)) {
|
|
return;
|
|
}
|
|
if (isHostRemoteActionActive(h.id)) {
|
|
showHostRemoteActionLog(h.id);
|
|
return;
|
|
}
|
|
if (!confirmMassUpgradeIfNeeded(h.hostname)) {
|
|
return;
|
|
}
|
|
const kind = remoteActionKindForHost(h);
|
|
if (!kind) return;
|
|
void runHostRemoteAction(
|
|
h.id,
|
|
remoteActionTitleForHost(h),
|
|
kind,
|
|
remoteActionLogPlaceholder(h, kind),
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
const gitWinVersion = computed(
|
|
() => data.value?.git_latest_rdp_version?.trim() || "—",
|
|
);
|
|
const gitSshVersion = computed(
|
|
() => data.value?.git_latest_ssh_version?.trim() || "—",
|
|
);
|
|
|
|
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(retry = 0) {
|
|
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) {
|
|
const status = e instanceof ApiError ? e.status : 0;
|
|
if (retry < 2 && (status === 502 || status === 503)) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 2500));
|
|
return loadHosts(retry + 1);
|
|
}
|
|
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 onManualAddStarted(payload: { hostId: number; title: string }) {
|
|
await loadHosts();
|
|
void pollHostRemoteAction(
|
|
payload.hostId,
|
|
payload.title,
|
|
remoteActionLogPlaceholderFromTitle(payload.title),
|
|
);
|
|
}
|
|
|
|
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();
|
|
unsubscribeHostPatch = subscribeHostPatch(applyHostPatchFromDetail);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
unsubscribeHostPatch?.();
|
|
unsubscribeHostPatch = null;
|
|
});
|
|
|
|
watch(
|
|
() => [route.query.hostname, route.query.agent_status] as const,
|
|
() => {
|
|
syncFromRoute();
|
|
loadHosts();
|
|
},
|
|
);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.hosts-list-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: stretch;
|
|
gap: 0.5rem 0.75rem;
|
|
margin: 0.35rem 0 1rem;
|
|
}
|
|
|
|
.hosts-list-total {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin: 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.agent-releases-plate {
|
|
display: inline-flex;
|
|
flex-wrap: wrap;
|
|
align-items: baseline;
|
|
gap: 0.35rem 0.85rem;
|
|
margin: -0.15rem 0 0;
|
|
padding: 0.45rem 1rem 0.5rem 1.1rem;
|
|
border-radius: 0 0.75rem 0.75rem 0.35rem;
|
|
border: 1px solid #2d3a4d;
|
|
border-left: 3px solid #58a6ff;
|
|
background: linear-gradient(135deg, #1a2332 0%, #151d28 100%);
|
|
box-shadow:
|
|
0 2px 8px rgba(0, 0, 0, 0.35),
|
|
0 1px 0 rgba(88, 166, 255, 0.12) inset;
|
|
font-size: 0.9rem;
|
|
color: #9aa4b2;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.agent-releases-plate__label {
|
|
color: #c9d1d9;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.agent-releases-plate__item code {
|
|
font-size: 0.88em;
|
|
color: #79c0ff;
|
|
background: rgba(88, 166, 255, 0.08);
|
|
padding: 0.1rem 0.35rem;
|
|
border-radius: 0.25rem;
|
|
}
|
|
</style>
|