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:
@@ -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}"
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -21,8 +21,14 @@
|
||||
<div class="detail-field">
|
||||
<dt>Версия агента</dt>
|
||||
<dd>
|
||||
<span :class="versionStatusClass">{{ host.product_version || "—" }}</span>
|
||||
<span v-if="host.version_status === 'outdated'" class="muted version-hint"> (устарела)</span>
|
||||
<HostAgentVersionUpgrade
|
||||
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>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sshProbeSeq = 0;
|
||||
let refreshingLive = false;
|
||||
let liveRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const LIVE_REFRESH_DEBOUNCE_MS = 400;
|
||||
|
||||
const agentGitVersions = ref<AgentGitVersions | null>(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(
|
||||
|
||||
@@ -67,13 +67,14 @@
|
||||
<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 @click.stop>
|
||||
<HostAgentVersionUpgrade
|
||||
:host="h"
|
||||
:git="data"
|
||||
:updating="upgradingHostId === h.id"
|
||||
:outdated-highlight="isVersionOutdated(h)"
|
||||
@upgrade="startAgentUpgrade(h)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ h.os_family }}</td>
|
||||
<td>{{ h.ipv4 || "—" }}</td>
|
||||
@@ -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<SortKey>(initialSort.sortBy);
|
||||
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
|
||||
const deletingId = ref<number | null>(null);
|
||||
const upgradingHostId = ref<number | null>(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) {
|
||||
|
||||
Reference in New Issue
Block a user