diff --git a/backend/app/version.py b/backend/app/version.py index 4cb3115..f3f8ca7 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.3.8" +APP_VERSION = "0.3.9" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/frontend/src/components/HostAgentVersionUpgrade.vue b/frontend/src/components/HostAgentVersionUpgrade.vue new file mode 100644 index 0000000..2826a77 --- /dev/null +++ b/frontend/src/components/HostAgentVersionUpgrade.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/utils/agentVersion.ts b/frontend/src/utils/agentVersion.ts index 8c45f1e..2e6eb7b 100644 --- a/frontend/src/utils/agentVersion.ts +++ b/frontend/src/utils/agentVersion.ts @@ -53,3 +53,14 @@ export function isAgentVersionNewer( if (!prev) return true; return compareVersions(next, prev) > 0; } + +/** Хост строго младше целевой версии (например из git), без порога lag. */ +export function isAgentVersionBehind( + hostVersion: string | null | undefined, + targetVersion: string | null | undefined, +): boolean { + const host = parseAgentVersion(hostVersion); + const target = parseAgentVersion(targetVersion); + if (!host || !target) return false; + return compareVersions(host, target) < 0; +} diff --git a/frontend/src/utils/hostAgentUpgrade.ts b/frontend/src/utils/hostAgentUpgrade.ts new file mode 100644 index 0000000..dbee7f3 --- /dev/null +++ b/frontend/src/utils/hostAgentUpgrade.ts @@ -0,0 +1,60 @@ +import type { HostSummary } from "../api"; +import type { HostRemoteActionKind } from "../composables/useHostRemoteAction"; +import { isAgentVersionBehind } from "./agentVersion"; + +export const PRODUCT_RDP = "rdp-login-monitor"; +export const PRODUCT_SSH = "ssh-monitor"; + +export interface AgentGitVersions { + git_latest_rdp_version?: string | null; + git_latest_ssh_version?: string | null; +} + +export function isWindowsAgentHost(host: Pick): boolean { + if ((host.os_family || "").toLowerCase() === "windows") return true; + return host.product === PRODUCT_RDP; +} + +export function isLinuxAgentHost(host: Pick): boolean { + if ((host.os_family || "").toLowerCase() === "linux") return true; + return host.product === PRODUCT_SSH; +} + +export function gitTargetVersionForHost( + host: Pick, + git: AgentGitVersions | null | undefined, +): string | null { + if (!git) return null; + if (isWindowsAgentHost(host)) { + return git.git_latest_rdp_version?.trim() || null; + } + if (isLinuxAgentHost(host)) { + return git.git_latest_ssh_version?.trim() || null; + } + return null; +} + +export function isGitAgentUpgradeAvailable( + host: Pick, + git: AgentGitVersions | null | undefined, +): boolean { + const target = gitTargetVersionForHost(host, git); + if (!target || !host.product_version?.trim()) return false; + return isAgentVersionBehind(host.product_version, target); +} + +export function remoteActionKindForHost( + host: Pick, +): HostRemoteActionKind | null { + if (isWindowsAgentHost(host)) return "fallback"; + if (isLinuxAgentHost(host)) return "ssh-update"; + return null; +} + +export function remoteActionTitleForHost(host: HostSummary): string { + const label = host.display_name || host.hostname; + if (isWindowsAgentHost(host)) { + return `Обновление ${label} через WinRM`; + } + return `Обновление ssh-monitor (SSH): ${label}`; +} diff --git a/frontend/src/utils/hostDetailLive.ts b/frontend/src/utils/hostDetailLive.ts new file mode 100644 index 0000000..038c0b5 --- /dev/null +++ b/frontend/src/utils/hostDetailLive.ts @@ -0,0 +1,37 @@ +import type { EventListResponse, EventSummary, HostDetail } from "../api"; +import { isAgentVersionBehind } from "./agentVersion"; +import type { AgentGitVersions } from "./hostAgentUpgrade"; +import { gitTargetVersionForHost } from "./hostAgentUpgrade"; + +/** Точечное обновление карточки хоста по событию ingest (без полного GET /hosts/{id}). */ +export function patchHostDetailFromEvent(host: HostDetail, ev: EventSummary): void { + host.last_seen_at = ev.received_at || ev.occurred_at; + host.event_count = (host.event_count ?? 0) + 1; + if (ev.product_version?.trim()) { + host.product_version = ev.product_version.trim(); + } +} + +export function recalcVersionStatusFromGit( + host: HostDetail, + git: AgentGitVersions | null | undefined, +): void { + const target = gitTargetVersionForHost(host, git); + if (!host.product_version?.trim() || !target) { + return; + } + host.version_status = isAgentVersionBehind(host.product_version, target) ? "outdated" : "ok"; +} + +/** Добавляет событие в начало списка на стр. 1, если его ещё нет. */ +export function prependHostEventIfMissing( + events: EventListResponse | null, + ev: EventSummary, + page: number, +): boolean { + if (!events || page !== 1) return false; + if (events.items.some((row) => row.id === ev.id)) return false; + events.items = [ev, ...events.items]; + events.total = (events.total ?? 0) + 1; + return true; +} diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue index f2dc05c..4910f88 100644 --- a/frontend/src/views/HostDetailView.vue +++ b/frontend/src/views/HostDetailView.vue @@ -21,8 +21,14 @@
Версия агента
- {{ host.product_version || "—" }} - (устарела) + обновление запрошено
@@ -299,6 +305,19 @@ import { } from "../api"; import { emitHostPatch } from "../utils/hostPatchBus"; import HostSessionsPanel from "../components/HostSessionsPanel.vue"; +import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue"; +import { + patchHostDetailFromEvent, + prependHostEventIfMissing, + recalcVersionStatusFromGit, +} from "../utils/hostDetailLive"; +import { + isLinuxAgentHost, + remoteActionKindForHost, + remoteActionTitleForHost, + type AgentGitVersions, +} from "../utils/hostAgentUpgrade"; +import { fetchAgentUpdateSettings } from "../api"; const HOST_ACTION_FEEDBACK_MS = 30_000; @@ -339,9 +358,13 @@ let sshTestHideTimer: ReturnType | null = null; let winRmTestHideTimer: ReturnType | null = null; let sshProbeSeq = 0; let refreshingLive = false; +let liveRefreshTimer: ReturnType | null = null; +const LIVE_REFRESH_DEBOUNCE_MS = 400; + +const agentGitVersions = ref(null); useSacLiveEventStream(() => { - void refreshFromLatestEvent(); + scheduleLiveRefreshFromEvent(); }); const hostId = computed(() => parseInt(props.id, 10)); @@ -370,15 +393,12 @@ const sshVerifiedTitle = computed(() => { return "SSH доступен"; }); -const versionStatusClass = computed(() => { - const status = host.value?.version_status; - if (status === "outdated") return "agent-version-outdated"; - if (status === "ok") return "agent-version-ok"; - return ""; -}); - const showAgentConfig = computed(() => isWindowsHost.value || isLinuxHost.value); +function isLinuxHostDetail(detail: HostDetail): boolean { + return isLinuxAgentHost(detail); +} + function syncConfigFormFromHost(detail: HostDetail) { const cfg = detail.agent_config ?? {}; configForm.serverDisplayName = String( @@ -469,11 +489,6 @@ function clearSshTestFeedbackTimer() { } } -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 = { @@ -706,23 +721,67 @@ async function runAgentUpdate() { detail.product_version = job.product_version; } applyHostDetail(detail); + recalcVersionStatusFromGit(detail, agentGitVersions.value); } } finally { updatingAgent.value = false; } } +async function runAgentUpgradeFromCell() { + if (!host.value) return; + const kind = remoteActionKindForHost(host.value); + if (!kind) return; + if (kind === "ssh-update") { + await runAgentUpdate(); + return; + } + runningAgentFallback.value = true; + try { + const job = await runHostRemoteAction( + hostId.value, + remoteActionTitleForHost(host.value), + kind, + ); + if (job?.ok) { + const detail = await fetchHost(hostId.value); + if (job.product_version) { + detail.product_version = job.product_version; + } + applyHostDetail(detail); + recalcVersionStatusFromGit(detail, agentGitVersions.value); + } + } finally { + runningAgentFallback.value = false; + } +} + +function clearLiveRefreshTimer() { + if (liveRefreshTimer != null) { + clearTimeout(liveRefreshTimer); + liveRefreshTimer = null; + } +} + +function scheduleLiveRefreshFromEvent() { + clearLiveRefreshTimer(); + liveRefreshTimer = setTimeout(() => { + liveRefreshTimer = null; + void refreshFromLatestEvent(); + }, LIVE_REFRESH_DEBOUNCE_MS); +} + async function refreshFromLatestEvent() { - if (refreshingLive) return; + if (refreshingLive || !host.value) 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); - } + patchHostDetailFromEvent(host.value, ev); + recalcVersionStatusFromGit(host.value, agentGitVersions.value); + emitHostPatch(host.value); + prependHostEventIfMissing(events.value, ev, eventsPage.value); } catch { /* ignore transient errors */ } finally { @@ -730,6 +789,21 @@ async function refreshFromLatestEvent() { } } +async function loadAgentGitVersions() { + try { + const settings = await fetchAgentUpdateSettings(); + agentGitVersions.value = { + git_latest_rdp_version: settings.git_latest_rdp_version, + git_latest_ssh_version: settings.git_latest_ssh_version, + }; + if (host.value) { + recalcVersionStatusFromGit(host.value, agentGitVersions.value); + } + } catch { + agentGitVersions.value = null; + } +} + async function loadEvents(page = 1) { eventsLoading.value = true; eventsError.value = ""; @@ -749,6 +823,7 @@ async function loadEvents(page = 1) { } onMounted(async () => { + void loadAgentGitVersions(); await loadHost(); await loadEvents(1); }); @@ -756,6 +831,7 @@ onMounted(async () => { onUnmounted(() => { clearSshTestFeedbackTimer(); clearWinRmTestFeedbackTimer(); + clearLiveRefreshTimer(); }); watch( diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue index da6f8b2..77ba59e 100644 --- a/frontend/src/views/HostsView.vue +++ b/frontend/src/views/HostsView.vue @@ -67,13 +67,14 @@ {{ h.id }} {{ h.display_name || h.hostname }} {{ h.hostname }} - - - {{ h.product_version || "—" }} - + + {{ h.os_family }} {{ h.ipv4 || "—" }} @@ -125,6 +126,13 @@ import { import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate"; import { subscribeHostPatch } from "../utils/hostPatchBus"; import { isAgentVersionOutdated } from "../utils/agentVersion"; +import { + isGitAgentUpgradeAvailable, + remoteActionKindForHost, + remoteActionTitleForHost, +} from "../utils/hostAgentUpgrade"; +import { runHostRemoteAction } from "../composables/useHostRemoteAction"; +import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue"; const router = useRouter(); const route = useRoute(); @@ -188,6 +196,7 @@ const initialSort = loadHostsSortPrefs(); const sortBy = ref(initialSort.sortBy); const sortDir = ref<"asc" | "desc">(initialSort.sortDir); const deletingId = ref(null); +const upgradingHostId = ref(null); let patchingHostRow = false; let unsubscribeHostPatch: (() => void) | null = null; @@ -223,17 +232,18 @@ function isVersionOutdated(h: HostSummary): boolean { return isAgentVersionOutdated(h.product_version, reference); } -function versionTitle(h: HostSummary): string { - const reference = - data.value?.reference_agent_versions?.[h.product] ?? - data.value?.latest_agent_versions?.[h.product]; - if (!isVersionOutdated(h)) return ""; - if (!reference) return "Устаревшая версия агента"; - const fromSettings = data.value?.reference_agent_versions?.[h.product]; - if (fromSettings && fromSettings !== data.value?.latest_agent_versions?.[h.product]) { - return `Устарела относительно эталона ${reference} (настройки SAC)`; +async function startAgentUpgrade(h: HostSummary) { + if (!isGitAgentUpgradeAvailable(h, data.value) || upgradingHostId.value != null) { + return; + } + const kind = remoteActionKindForHost(h); + if (!kind) return; + upgradingHostId.value = h.id; + try { + await runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind); + } finally { + upgradingHostId.value = null; } - return `Устарела относительно эталона ${reference}`; } function openHost(id: number) {