feat: one-click agent upgrade from hosts list (0.3.9)

Add Omada-style git version arrow in the agent version column to trigger WinRM/SSH background updates, and soften host detail live refresh with debounced incremental event patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 15:32:09 +10:00
parent 0601aafd2d
commit 9053ef0588
7 changed files with 352 additions and 38 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.3.8" APP_VERSION = "0.3.9"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
@@ -0,0 +1,120 @@
<template>
<span class="agent-version-cell">
<span :class="{ 'agent-version-outdated': outdatedHighlight }" :title="currentTitle">
{{ host.product_version || "—" }}
</span>
<button
v-if="showUpgrade"
type="button"
class="agent-version-upgrade"
:disabled="updating"
:title="upgradeTitle"
@click.stop="emit('upgrade')"
>
<span class="agent-version-upgrade__arrow" aria-hidden="true"></span>
<span class="agent-version-upgrade__target">{{ gitTargetVersion }}</span>
</button>
<span v-if="updating" class="agent-version-upgrade__busy muted"></span>
</span>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { HostSummary } from "../api";
import {
gitTargetVersionForHost,
isGitAgentUpgradeAvailable,
type AgentGitVersions,
} from "../utils/hostAgentUpgrade";
const props = defineProps<{
host: HostSummary;
git: AgentGitVersions | null | undefined;
updating?: boolean;
outdatedHighlight?: boolean;
}>();
const emit = defineEmits<{
upgrade: [];
}>();
const gitTargetVersion = computed(() => gitTargetVersionForHost(props.host, props.git));
const showUpgrade = computed(
() => !props.updating && isGitAgentUpgradeAvailable(props.host, props.git),
);
const currentTitle = computed(() => {
if (!props.host.product_version) return "";
if (showUpgrade.value && gitTargetVersion.value) {
return `Текущая версия ${props.host.product_version}. Доступна ${gitTargetVersion.value}`;
}
return props.host.product_version;
});
const upgradeTitle = computed(() => {
if (!gitTargetVersion.value) return "";
return `Обновить агент до ${gitTargetVersion.value}`;
});
</script>
<style scoped>
.agent-version-cell {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem 0.35rem;
}
.agent-version-upgrade {
display: inline-flex;
align-items: center;
gap: 0.15rem;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
font: inherit;
line-height: 1.2;
}
.agent-version-upgrade:disabled {
cursor: default;
opacity: 0.65;
}
.agent-version-upgrade__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: rgba(63, 185, 80, 0.18);
color: #3fb950;
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
}
.agent-version-upgrade__target {
color: #3fb950;
font-size: 0.92em;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 2px;
}
.agent-version-upgrade:hover:not(:disabled) .agent-version-upgrade__target {
color: #56d364;
}
.agent-version-upgrade:hover:not(:disabled) .agent-version-upgrade__arrow {
background: rgba(63, 185, 80, 0.28);
}
.agent-version-upgrade__busy {
font-size: 0.85em;
}
</style>
+11
View File
@@ -53,3 +53,14 @@ export function isAgentVersionNewer(
if (!prev) return true; if (!prev) return true;
return compareVersions(next, prev) > 0; 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;
}
+60
View File
@@ -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<HostSummary, "os_family" | "product">): boolean {
if ((host.os_family || "").toLowerCase() === "windows") return true;
return host.product === PRODUCT_RDP;
}
export function isLinuxAgentHost(host: Pick<HostSummary, "os_family" | "product">): boolean {
if ((host.os_family || "").toLowerCase() === "linux") return true;
return host.product === PRODUCT_SSH;
}
export function gitTargetVersionForHost(
host: Pick<HostSummary, "os_family" | "product">,
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<HostSummary, "os_family" | "product" | "product_version">,
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<HostSummary, "os_family" | "product">,
): 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}`;
}
+37
View File
@@ -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;
}
+96 -20
View File
@@ -21,8 +21,14 @@
<div class="detail-field"> <div class="detail-field">
<dt>Версия агента</dt> <dt>Версия агента</dt>
<dd> <dd>
<span :class="versionStatusClass">{{ host.product_version || "—" }}</span> <HostAgentVersionUpgrade
<span v-if="host.version_status === 'outdated'" class="muted version-hint"> (устарела)</span> v-if="host"
:host="host"
:git="agentGitVersions"
:updating="updatingAgent || runningAgentFallback"
:outdated-highlight="host.version_status === 'outdated'"
@upgrade="runAgentUpgradeFromCell"
/>
<span v-if="host.pending_agent_update" class="pending-update-badge">обновление запрошено</span> <span v-if="host.pending_agent_update" class="pending-update-badge">обновление запрошено</span>
</dd> </dd>
</div> </div>
@@ -299,6 +305,19 @@ import {
} from "../api"; } from "../api";
import { emitHostPatch } from "../utils/hostPatchBus"; import { emitHostPatch } from "../utils/hostPatchBus";
import HostSessionsPanel from "../components/HostSessionsPanel.vue"; 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; const HOST_ACTION_FEEDBACK_MS = 30_000;
@@ -339,9 +358,13 @@ let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null; let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let sshProbeSeq = 0; let sshProbeSeq = 0;
let refreshingLive = false; let refreshingLive = false;
let liveRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const LIVE_REFRESH_DEBOUNCE_MS = 400;
const agentGitVersions = ref<AgentGitVersions | null>(null);
useSacLiveEventStream(() => { useSacLiveEventStream(() => {
void refreshFromLatestEvent(); scheduleLiveRefreshFromEvent();
}); });
const hostId = computed(() => parseInt(props.id, 10)); const hostId = computed(() => parseInt(props.id, 10));
@@ -370,15 +393,12 @@ const sshVerifiedTitle = computed(() => {
return "SSH доступен"; 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); const showAgentConfig = computed(() => isWindowsHost.value || isLinuxHost.value);
function isLinuxHostDetail(detail: HostDetail): boolean {
return isLinuxAgentHost(detail);
}
function syncConfigFormFromHost(detail: HostDetail) { function syncConfigFormFromHost(detail: HostDetail) {
const cfg = detail.agent_config ?? {}; const cfg = detail.agent_config ?? {};
configForm.serverDisplayName = String( 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 }) { function applySshAdminStatus(result: { ok: boolean; ssh_admin_ok?: boolean | null }) {
if (!host.value) return; if (!host.value) return;
host.value = { host.value = {
@@ -706,23 +721,67 @@ async function runAgentUpdate() {
detail.product_version = job.product_version; detail.product_version = job.product_version;
} }
applyHostDetail(detail); applyHostDetail(detail);
recalcVersionStatusFromGit(detail, agentGitVersions.value);
} }
} finally { } finally {
updatingAgent.value = false; 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() { async function refreshFromLatestEvent() {
if (refreshingLive) return; if (refreshingLive || !host.value) return;
refreshingLive = true; refreshingLive = true;
try { try {
const recent = await fetchRecentEvents(1); const recent = await fetchRecentEvents(1);
const ev = recent[0]; const ev = recent[0];
if (!ev || ev.host_id !== hostId.value) return; if (!ev || ev.host_id !== hostId.value) return;
applyHostDetail(await fetchHost(hostId.value)); patchHostDetailFromEvent(host.value, ev);
if (eventsPage.value === 1) { recalcVersionStatusFromGit(host.value, agentGitVersions.value);
await loadEvents(1); emitHostPatch(host.value);
} prependHostEventIfMissing(events.value, ev, eventsPage.value);
} catch { } catch {
/* ignore transient errors */ /* ignore transient errors */
} finally { } 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) { async function loadEvents(page = 1) {
eventsLoading.value = true; eventsLoading.value = true;
eventsError.value = ""; eventsError.value = "";
@@ -749,6 +823,7 @@ async function loadEvents(page = 1) {
} }
onMounted(async () => { onMounted(async () => {
void loadAgentGitVersions();
await loadHost(); await loadHost();
await loadEvents(1); await loadEvents(1);
}); });
@@ -756,6 +831,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
clearSshTestFeedbackTimer(); clearSshTestFeedbackTimer();
clearWinRmTestFeedbackTimer(); clearWinRmTestFeedbackTimer();
clearLiveRefreshTimer();
}); });
watch( watch(
+27 -17
View File
@@ -67,13 +67,14 @@
<td>{{ h.id }}</td> <td>{{ h.id }}</td>
<td>{{ h.display_name || h.hostname }}</td> <td>{{ h.display_name || h.hostname }}</td>
<td>{{ h.hostname }}</td> <td>{{ h.hostname }}</td>
<td> <td @click.stop>
<span <HostAgentVersionUpgrade
:class="{ 'agent-version-outdated': isVersionOutdated(h) }" :host="h"
:title="versionTitle(h)" :git="data"
> :updating="upgradingHostId === h.id"
{{ h.product_version || "—" }} :outdated-highlight="isVersionOutdated(h)"
</span> @upgrade="startAgentUpgrade(h)"
/>
</td> </td>
<td>{{ h.os_family }}</td> <td>{{ h.os_family }}</td>
<td>{{ h.ipv4 || "—" }}</td> <td>{{ h.ipv4 || "—" }}</td>
@@ -125,6 +126,13 @@ import {
import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate"; import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
import { subscribeHostPatch } from "../utils/hostPatchBus"; import { subscribeHostPatch } from "../utils/hostPatchBus";
import { isAgentVersionOutdated } from "../utils/agentVersion"; 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 router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -188,6 +196,7 @@ const initialSort = loadHostsSortPrefs();
const sortBy = ref<SortKey>(initialSort.sortBy); const sortBy = ref<SortKey>(initialSort.sortBy);
const sortDir = ref<"asc" | "desc">(initialSort.sortDir); const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
const deletingId = ref<number | null>(null); const deletingId = ref<number | null>(null);
const upgradingHostId = ref<number | null>(null);
let patchingHostRow = false; let patchingHostRow = false;
let unsubscribeHostPatch: (() => void) | null = null; let unsubscribeHostPatch: (() => void) | null = null;
@@ -223,17 +232,18 @@ function isVersionOutdated(h: HostSummary): boolean {
return isAgentVersionOutdated(h.product_version, reference); return isAgentVersionOutdated(h.product_version, reference);
} }
function versionTitle(h: HostSummary): string { async function startAgentUpgrade(h: HostSummary) {
const reference = if (!isGitAgentUpgradeAvailable(h, data.value) || upgradingHostId.value != null) {
data.value?.reference_agent_versions?.[h.product] ?? return;
data.value?.latest_agent_versions?.[h.product]; }
if (!isVersionOutdated(h)) return ""; const kind = remoteActionKindForHost(h);
if (!reference) return "Устаревшая версия агента"; if (!kind) return;
const fromSettings = data.value?.reference_agent_versions?.[h.product]; upgradingHostId.value = h.id;
if (fromSettings && fromSettings !== data.value?.latest_agent_versions?.[h.product]) { try {
return `Устарела относительно эталона ${reference} (настройки SAC)`; await runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind);
} finally {
upgradingHostId.value = null;
} }
return `Устарела относительно эталона ${reference}`;
} }
function openHost(id: number) { function openHost(id: number) {