2eb06acb5b
Silent SSH probe on host card open; manual test feedback auto-hides. Persist ssh_admin_ok in DB (migration 019). After agent update read version from host and refresh UI.
640 lines
18 KiB
Vue
640 lines
18 KiB
Vue
<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">
|
||
<dl class="detail-fields">
|
||
<div class="detail-field">
|
||
<dt>Hostname</dt>
|
||
<dd>{{ host.hostname }}</dd>
|
||
</div>
|
||
<div class="detail-field">
|
||
<dt>ID</dt>
|
||
<dd>{{ host.id }}</dd>
|
||
</div>
|
||
<div class="detail-field">
|
||
<dt>Агент</dt>
|
||
<dd><span :class="'agent-' + host.agent_status">{{ agentLabel(host.agent_status) }}</span></dd>
|
||
</div>
|
||
<div class="detail-field">
|
||
<dt>Версия агента</dt>
|
||
<dd>{{ host.product_version || "—" }}</dd>
|
||
</div>
|
||
<div class="detail-field">
|
||
<dt>OS</dt>
|
||
<dd>{{ host.os_family }} {{ host.os_version || "" }}</dd>
|
||
</div>
|
||
<div class="detail-field">
|
||
<dt>IPv4</dt>
|
||
<dd>{{ host.ipv4 || "—" }}</dd>
|
||
</div>
|
||
<div class="detail-field">
|
||
<dt>Heartbeat</dt>
|
||
<dd>{{ host.last_heartbeat_at ? formatDt(host.last_heartbeat_at) : "—" }}</dd>
|
||
</div>
|
||
<div class="detail-field">
|
||
<dt>Last seen</dt>
|
||
<dd>{{ formatDt(host.last_seen_at) }}</dd>
|
||
</div>
|
||
<div class="detail-field">
|
||
<dt>Инвентаризация</dt>
|
||
<dd>{{ host.last_inventory_at ? formatDt(host.last_inventory_at) : "—" }}</dd>
|
||
</div>
|
||
<div class="detail-field">
|
||
<dt>Событий</dt>
|
||
<dd>{{ host.event_count ?? 0 }}</dd>
|
||
</div>
|
||
</dl>
|
||
<div v-if="isWindowsHost" class="host-actions">
|
||
<button
|
||
type="button"
|
||
class="secondary"
|
||
:disabled="testingWinRm"
|
||
@click="runWinRmTest"
|
||
>
|
||
{{ testingWinRm ? "Проверка…" : "Проверить WinRM (admin)" }}
|
||
</button>
|
||
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
|
||
</div>
|
||
<div v-if="isLinuxHost" class="host-actions">
|
||
<div class="host-actions-row">
|
||
<div class="host-action-item">
|
||
<button
|
||
type="button"
|
||
class="secondary"
|
||
:disabled="testingSsh"
|
||
@click="runSshTestManual"
|
||
>
|
||
{{ testingSsh ? "Проверка…" : "Проверить SSH" }}
|
||
</button>
|
||
<span
|
||
v-if="sshVerified"
|
||
class="host-action-ok"
|
||
:title="sshVerifiedTitle"
|
||
>✓</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="secondary"
|
||
:disabled="updatingAgent"
|
||
@click="runAgentUpdate"
|
||
>
|
||
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }}
|
||
</button>
|
||
</div>
|
||
<div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback">
|
||
<p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p>
|
||
<pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre>
|
||
</div>
|
||
<div v-if="agentUpdateMessage || agentUpdateOutput" class="host-action-feedback">
|
||
<p :class="agentUpdateOk ? 'success' : 'error'">{{ agentUpdateMessage }}</p>
|
||
<pre v-if="agentUpdateOutput" class="host-ssh-output">{{ agentUpdateOutput }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<section v-if="inventory" class="host-inventory card">
|
||
<h2>Железо и ПО</h2>
|
||
<div v-if="windowsInfo" class="inv-block">
|
||
<h3>Windows</h3>
|
||
<dl class="detail-fields detail-fields-compact">
|
||
<div v-for="item in windowsFields" :key="item.label" class="detail-field">
|
||
<dt>{{ item.label }}</dt>
|
||
<dd>{{ item.value }}</dd>
|
||
</div>
|
||
</dl>
|
||
</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 }} потоков</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, onUnmounted, ref, watch } from "vue";
|
||
import { useRoute } from "vue-router";
|
||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||
import {
|
||
apiFetch,
|
||
fetchHost,
|
||
fetchRecentEvents,
|
||
testHostWinRm,
|
||
testHostSsh,
|
||
updateHostAgentViaSsh,
|
||
type EventListResponse,
|
||
type HostDetail,
|
||
} from "../api";
|
||
import { emitHostPatch } from "../utils/hostPatchBus";
|
||
|
||
const SSH_TEST_FEEDBACK_MS = 60_000;
|
||
|
||
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 testingWinRm = ref(false);
|
||
const winRmTestMessage = ref("");
|
||
const winRmTestOk = ref(false);
|
||
const testingSsh = ref(false);
|
||
const updatingAgent = ref(false);
|
||
const showSshTestFeedback = ref(false);
|
||
const sshTestMessage = ref("");
|
||
const sshTestOk = ref(false);
|
||
const sshTestOutput = ref("");
|
||
const agentUpdateMessage = ref("");
|
||
const agentUpdateOk = ref(false);
|
||
const agentUpdateOutput = ref("");
|
||
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let sshProbeSeq = 0;
|
||
let refreshingLive = false;
|
||
|
||
useSacLiveEventStream(() => {
|
||
void refreshFromLatestEvent();
|
||
});
|
||
|
||
const hostId = computed(() => parseInt(props.id, 10));
|
||
|
||
const isWindowsHost = computed(() => {
|
||
const h = host.value;
|
||
if (!h) return false;
|
||
if ((h.os_family || "").toLowerCase() === "windows") return true;
|
||
return h.product === "rdp-login-monitor";
|
||
});
|
||
|
||
const isLinuxHost = computed(() => {
|
||
const h = host.value;
|
||
if (!h) return false;
|
||
if ((h.os_family || "").toLowerCase() === "linux") return true;
|
||
return h.product === "ssh-monitor";
|
||
});
|
||
|
||
const sshVerified = computed(() => host.value?.ssh_admin_ok === true);
|
||
|
||
const sshVerifiedTitle = computed(() => {
|
||
const checkedAt = host.value?.ssh_admin_checked_at;
|
||
if (checkedAt) {
|
||
return `SSH доступен (проверено ${formatDt(checkedAt)})`;
|
||
}
|
||
return "SSH доступен";
|
||
});
|
||
|
||
const inventory = computed(() => host.value?.inventory ?? null);
|
||
|
||
const windowsFields = computed(() => {
|
||
const w = inventory.value?.windows as Record<string, string> | undefined;
|
||
if (!w) return [];
|
||
const labels: Record<string, string> = {
|
||
product_name: "Продукт",
|
||
version: "Версия",
|
||
os_hardware_abstraction_layer: "HAL",
|
||
};
|
||
return Object.entries(labels)
|
||
.map(([key, label]) => ({ label, value: w[key]?.trim() || "" }))
|
||
.filter((item) => item.value);
|
||
});
|
||
|
||
const windowsInfo = computed(() => windowsFields.value.length > 0);
|
||
|
||
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")
|
||
.map((p) => ({
|
||
name: String(p.name ?? "—"),
|
||
cores: typeof p.cores === "number" ? p.cores : null,
|
||
logical: typeof p.logical_processors === "number" ? p.logical_processors : null,
|
||
}));
|
||
});
|
||
|
||
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";
|
||
}
|
||
|
||
function clearSshTestFeedbackTimer() {
|
||
if (sshTestHideTimer != null) {
|
||
clearTimeout(sshTestHideTimer);
|
||
sshTestHideTimer = null;
|
||
}
|
||
}
|
||
|
||
function isLinuxHostDetail(detail: HostDetail): boolean {
|
||
if ((detail.os_family || "").toLowerCase() === "linux") return true;
|
||
return detail.product === "ssh-monitor";
|
||
}
|
||
|
||
function applySshAdminStatus(result: { ok: boolean; ssh_admin_ok?: boolean | null }) {
|
||
if (!host.value) return;
|
||
host.value = {
|
||
...host.value,
|
||
ssh_admin_ok: result.ssh_admin_ok ?? result.ok,
|
||
ssh_admin_checked_at: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function scheduleSshTestFeedbackHide() {
|
||
clearSshTestFeedbackTimer();
|
||
sshTestHideTimer = setTimeout(() => {
|
||
sshTestMessage.value = "";
|
||
sshTestOutput.value = "";
|
||
showSshTestFeedback.value = false;
|
||
sshTestHideTimer = null;
|
||
}, SSH_TEST_FEEDBACK_MS);
|
||
}
|
||
|
||
async function runSshTest(options: { silent?: boolean } = {}) {
|
||
const silent = options.silent === true;
|
||
const probeId = ++sshProbeSeq;
|
||
|
||
if (!silent) {
|
||
testingSsh.value = true;
|
||
clearSshTestFeedbackTimer();
|
||
sshTestMessage.value = "";
|
||
sshTestOutput.value = "";
|
||
showSshTestFeedback.value = false;
|
||
}
|
||
|
||
try {
|
||
const result = await testHostSsh(hostId.value);
|
||
if (probeId !== sshProbeSeq) return;
|
||
|
||
applySshAdminStatus(result);
|
||
|
||
if (!silent) {
|
||
showSshTestFeedback.value = true;
|
||
sshTestOk.value = result.ok;
|
||
sshTestMessage.value = result.message;
|
||
sshTestOutput.value = formatSshOutput(result);
|
||
if (result.ok) {
|
||
scheduleSshTestFeedbackHide();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (probeId !== sshProbeSeq) return;
|
||
if (host.value) {
|
||
host.value = { ...host.value, ssh_admin_ok: false, ssh_admin_checked_at: new Date().toISOString() };
|
||
}
|
||
if (!silent) {
|
||
showSshTestFeedback.value = true;
|
||
sshTestOk.value = false;
|
||
sshTestMessage.value = e instanceof Error ? e.message : "Ошибка проверки SSH";
|
||
}
|
||
} finally {
|
||
if (!silent && probeId === sshProbeSeq) {
|
||
testingSsh.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
function runSshTestManual() {
|
||
void runSshTest({ silent: false });
|
||
}
|
||
|
||
async function probeSshOnOpen() {
|
||
await runSshTest({ silent: true });
|
||
}
|
||
|
||
async function runWinRmTest() {
|
||
testingWinRm.value = true;
|
||
winRmTestMessage.value = "";
|
||
try {
|
||
const result = await testHostWinRm(hostId.value);
|
||
winRmTestOk.value = result.ok;
|
||
winRmTestMessage.value = result.message;
|
||
} catch (e) {
|
||
winRmTestOk.value = false;
|
||
winRmTestMessage.value = e instanceof Error ? e.message : "Ошибка проверки WinRM";
|
||
} finally {
|
||
testingWinRm.value = false;
|
||
}
|
||
}
|
||
|
||
function formatSshOutput(result: { stdout: string | null; stderr: string | null; exit_code: number | null }) {
|
||
const parts: string[] = [];
|
||
if (result.stdout?.trim()) {
|
||
parts.push(result.stdout.trim());
|
||
}
|
||
if (result.stderr?.trim()) {
|
||
parts.push(result.stderr.trim());
|
||
}
|
||
if (result.exit_code != null) {
|
||
parts.push(`exit code: ${result.exit_code}`);
|
||
}
|
||
return parts.join("\n\n");
|
||
}
|
||
|
||
function applyHostDetail(detail: HostDetail) {
|
||
host.value = detail;
|
||
emitHostPatch(detail);
|
||
}
|
||
|
||
async function loadHost() {
|
||
loading.value = true;
|
||
error.value = "";
|
||
winRmTestMessage.value = "";
|
||
sshProbeSeq += 1;
|
||
clearSshTestFeedbackTimer();
|
||
showSshTestFeedback.value = false;
|
||
sshTestMessage.value = "";
|
||
sshTestOutput.value = "";
|
||
agentUpdateMessage.value = "";
|
||
agentUpdateOutput.value = "";
|
||
try {
|
||
const detail = await fetchHost(hostId.value);
|
||
applyHostDetail(detail);
|
||
if (isLinuxHostDetail(detail)) {
|
||
void probeSshOnOpen();
|
||
}
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function runAgentUpdate() {
|
||
updatingAgent.value = true;
|
||
agentUpdateMessage.value = "";
|
||
agentUpdateOutput.value = "";
|
||
try {
|
||
const result = await updateHostAgentViaSsh(hostId.value);
|
||
agentUpdateOk.value = result.ok;
|
||
agentUpdateMessage.value = result.message;
|
||
agentUpdateOutput.value = formatSshOutput(result);
|
||
if (result.ok) {
|
||
const detail = await fetchHost(hostId.value);
|
||
if (result.product_version) {
|
||
detail.product_version = result.product_version;
|
||
}
|
||
if (result.ssh_admin_ok != null) {
|
||
detail.ssh_admin_ok = result.ssh_admin_ok;
|
||
}
|
||
applyHostDetail(detail);
|
||
} else if (host.value && result.ssh_admin_ok != null) {
|
||
host.value = { ...host.value, ssh_admin_ok: result.ssh_admin_ok };
|
||
}
|
||
} catch (e) {
|
||
agentUpdateOk.value = false;
|
||
agentUpdateMessage.value = e instanceof Error ? e.message : "Ошибка обновления агента";
|
||
} finally {
|
||
updatingAgent.value = false;
|
||
}
|
||
}
|
||
|
||
async function refreshFromLatestEvent() {
|
||
if (refreshingLive) return;
|
||
refreshingLive = true;
|
||
try {
|
||
const recent = await fetchRecentEvents(1);
|
||
const ev = recent[0];
|
||
if (!ev || ev.host_id !== hostId.value) return;
|
||
applyHostDetail(await fetchHost(hostId.value));
|
||
if (eventsPage.value === 1) {
|
||
await loadEvents(1);
|
||
}
|
||
} catch {
|
||
/* ignore transient errors */
|
||
} finally {
|
||
refreshingLive = 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);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
clearSshTestFeedbackTimer();
|
||
});
|
||
|
||
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;
|
||
}
|
||
|
||
.host-actions {
|
||
margin-top: 1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.host-actions-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.host-action-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.host-action-ok {
|
||
color: #3fb950;
|
||
font-size: 1.15rem;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
|
||
.host-action-feedback {
|
||
width: 100%;
|
||
}
|
||
|
||
.host-ssh-output {
|
||
margin-top: 0.75rem;
|
||
max-height: 16rem;
|
||
overflow: auto;
|
||
padding: 0.75rem;
|
||
background: #0d1117;
|
||
border-radius: 6px;
|
||
font-size: 0.85rem;
|
||
white-space: pre-wrap;
|
||
}
|
||
</style>
|