feat: parallel agent upgrades with per-host log panels (0.4.2)

Allow multiple remote update jobs at once; each upgrade gets its own
bottom-right log dock that auto-closes 30s after success.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 10:22:31 +10:00
parent 719d5c5719
commit dd2ae51b43
10 changed files with 267 additions and 174 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.4.1"
APP_VERSION = "0.4.2"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+2 -15
View File
@@ -4,16 +4,7 @@
<main :class="showShell ? 'app-main' : ''">
<RouterView />
</main>
<HostActionLogModal
v-if="showShell"
:open="hostRemoteActionLog.open"
:title="hostRemoteActionLog.title"
:loading="hostRemoteActionLog.loading"
:ok="hostRemoteActionLog.ok"
:message="hostRemoteActionLog.message"
:output="hostRemoteActionLog.output"
@close="closeHostRemoteActionLog"
/>
<HostActionLogStack v-if="showShell" />
</div>
</template>
@@ -22,11 +13,7 @@ import { computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { getToken } from "./api";
import AppSidebar from "./components/AppSidebar.vue";
import HostActionLogModal from "./components/HostActionLogModal.vue";
import {
closeHostRemoteActionLog,
hostRemoteActionLog,
} from "./composables/useHostRemoteAction";
import HostActionLogStack from "./components/HostActionLogStack.vue";
import { refreshSessionRole } from "./router";
const route = useRoute();
+5 -5
View File
@@ -12,11 +12,11 @@
v-if="remoteJobRunning && !remoteJobPanelOpen"
type="button"
class="sac-nav-item sac-remote-job-badge"
title="Обновление агента выполняется на сервере SAC"
@click="showHostRemoteActionLog"
title="Обновления агентов выполняются на сервере SAC"
@click="showHostRemoteActionLog()"
>
<span class="sac-nav-icon sac-remote-job-spinner" aria-hidden="true" />
<span class="sac-nav-label">Обновление</span>
<span class="sac-nav-label">Обновления</span>
</button>
<RouterLink
v-for="item in navItems"
@@ -46,8 +46,8 @@ import { clearToken, isAdmin } from "../api";
import SidebarSystemStats from "./SidebarSystemStats.vue";
import { useSacVersion } from "../composables/useSacVersion";
import {
anyRemoteActionLogOpen,
hasActiveHostRemoteAction,
hostRemoteActionLog,
showHostRemoteActionLog,
} from "../composables/useHostRemoteAction";
@@ -55,7 +55,7 @@ const { appName, appVersion } = useSacVersion();
const route = useRoute();
const router = useRouter();
const remoteJobRunning = computed(() => hasActiveHostRemoteAction());
const remoteJobPanelOpen = computed(() => hostRemoteActionLog.open);
const remoteJobPanelOpen = computed(() => anyRemoteActionLogOpen());
const navItems = computed(() => {
const items = [
@@ -15,7 +15,7 @@
</div>
<p v-if="loading" class="host-action-log-status">
<span class="host-action-log-spinner" aria-hidden="true" />
Выполняется на удалённом хосте Можно перейти в другой раздел SAC.
Выполняется на удалённом хосте Можно запустить обновление других хостов.
</p>
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p>
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p>
@@ -56,12 +56,7 @@ const messageClass = computed(() => {
<style scoped>
.host-action-log-dock {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 1000;
width: min(56rem, calc(100vw - 2rem));
max-width: calc(100vw - 2rem);
width: 100%;
pointer-events: none;
}
@@ -0,0 +1,49 @@
<template>
<div v-if="openLogs.length" class="host-action-log-stack" aria-live="polite">
<HostActionLogModal
v-for="entry in openLogs"
:key="entry.id"
:open="true"
:title="entry.title"
:loading="entry.loading"
:ok="entry.ok"
:message="entry.message"
:output="entry.output"
@close="closeHostRemoteActionLog(entry.id)"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import HostActionLogModal from "./HostActionLogModal.vue";
import {
closeHostRemoteActionLog,
hostRemoteActionLogs,
} from "../composables/useHostRemoteAction";
const openLogs = computed(() => hostRemoteActionLogs.filter((e) => e.open));
</script>
<style scoped>
.host-action-log-stack {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 1000;
display: flex;
flex-direction: column-reverse;
align-items: flex-end;
gap: 0.65rem;
width: min(56rem, calc(100vw - 2rem));
max-height: min(90vh, 48rem);
overflow-y: auto;
pointer-events: none;
}
.host-action-log-stack :deep(.host-action-log-panel) {
pointer-events: auto;
width: 100%;
max-height: min(36rem, 45vh);
}
</style>
@@ -7,11 +7,8 @@
v-if="showUpgrade"
type="button"
class="agent-version-upgrade"
:class="{
'agent-version-upgrade--inactive': upgradeBlocked,
'agent-version-upgrade--updating': updating,
}"
:disabled="updating || upgradeBlocked"
:class="{ 'agent-version-upgrade--updating': updating }"
:disabled="updating"
:title="buttonTitle"
@click.stop="emit('upgrade')"
>
@@ -35,8 +32,6 @@ const props = defineProps<{
host: HostSummary;
git: AgentGitVersions | null | undefined;
updating?: boolean;
/** Другой хост сейчас обновляется — стрелка видна, но неактивна (серая). */
upgradeBlocked?: boolean;
outdatedHighlight?: boolean;
}>();
@@ -65,9 +60,6 @@ const buttonTitle = computed(() => {
if (props.updating) {
return "Обновление выполняется…";
}
if (props.upgradeBlocked) {
return "Дождитесь завершения обновления на другом хосте";
}
return upgradeTitle.value;
});
</script>
@@ -127,13 +119,11 @@ const buttonTitle = computed(() => {
background: rgba(63, 185, 80, 0.28);
}
.agent-version-upgrade--inactive .agent-version-upgrade__arrow,
.agent-version-upgrade--updating .agent-version-upgrade__arrow {
background: rgba(139, 148, 158, 0.2);
color: #8b949e;
}
.agent-version-upgrade--inactive .agent-version-upgrade__target,
.agent-version-upgrade--updating .agent-version-upgrade__target {
color: #8b949e;
text-decoration: none;
+151 -63
View File
@@ -12,60 +12,94 @@ import { emitHostPatch } from "../utils/hostPatchBus";
export type HostRemoteActionKind = "fallback" | "ssh-update";
export const hostRemoteActionLog = reactive({
open: false,
title: "",
loading: false,
ok: null as boolean | null,
message: "",
output: "",
hostId: 0,
});
export type HostRemoteActionLogEntry = {
id: string;
hostId: number;
open: boolean;
loading: boolean;
ok: boolean | null;
title: string;
message: string;
output: string;
};
export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]);
const POLL_MS = 1500;
const SUCCESS_AUTO_CLOSE_MS = 30_000;
let successAutoCloseTimer: ReturnType<typeof setTimeout> | null = null;
let nextLogSeq = 1;
const autoCloseTimers = new Map<string, ReturnType<typeof setTimeout>>();
const hostRunningPromises = new Map<number, Promise<HostRemoteActionJobStatus | null>>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function clearSuccessAutoCloseTimer() {
if (successAutoCloseTimer != null) {
clearTimeout(successAutoCloseTimer);
successAutoCloseTimer = null;
function newLogId(): string {
return `ra-${Date.now()}-${nextLogSeq++}`;
}
function findLogById(entryId: string): HostRemoteActionLogEntry | undefined {
return hostRemoteActionLogs.find((e) => e.id === entryId);
}
function findLoadingLogForHost(hostId: number): HostRemoteActionLogEntry | undefined {
return hostRemoteActionLogs.find((e) => e.hostId === hostId && e.loading);
}
function clearAutoCloseTimer(entryId: string) {
const timer = autoCloseTimers.get(entryId);
if (timer != null) {
clearTimeout(timer);
autoCloseTimers.delete(entryId);
}
}
function scheduleSuccessAutoClose() {
clearSuccessAutoCloseTimer();
successAutoCloseTimer = setTimeout(() => {
hostRemoteActionLog.open = false;
successAutoCloseTimer = null;
}, SUCCESS_AUTO_CLOSE_MS);
}
function applyJobToModal(job: HostRemoteActionJobStatus) {
hostRemoteActionLog.title = job.title || hostRemoteActionLog.title;
hostRemoteActionLog.message = job.message || hostRemoteActionLog.message;
hostRemoteActionLog.output = job.output || job.stdout || "";
}
function finishModal(job: HostRemoteActionJobStatus) {
hostRemoteActionLog.loading = false;
hostRemoteActionLog.ok = job.ok ?? job.status === "success";
applyJobToModal(job);
hostRemoteActionLog.open = true;
if (hostRemoteActionLog.ok) {
scheduleSuccessAutoClose();
function removeClosedFinishedLog(entryId: string) {
const idx = hostRemoteActionLogs.findIndex((e) => e.id === entryId);
if (idx < 0) return;
const entry = hostRemoteActionLogs[idx];
if (!entry.loading && !entry.open) {
hostRemoteActionLogs.splice(idx, 1);
}
}
async function pollRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
function scheduleSuccessAutoClose(entryId: string) {
clearAutoCloseTimer(entryId);
autoCloseTimers.set(
entryId,
setTimeout(() => {
const entry = findLogById(entryId);
if (entry) {
entry.open = false;
removeClosedFinishedLog(entryId);
}
autoCloseTimers.delete(entryId);
}, SUCCESS_AUTO_CLOSE_MS),
);
}
function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
entry.title = job.title || entry.title;
entry.message = job.message || entry.message;
entry.output = job.output || job.stdout || "";
}
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
entry.loading = false;
entry.ok = job.ok ?? job.status === "success";
applyJobToEntry(entry, job);
entry.open = true;
if (entry.ok) {
scheduleSuccessAutoClose(entry.id);
}
}
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
for (;;) {
const job = await fetchHostRemoteJob(hostId);
applyJobToModal(job);
applyJobToEntry(entry, job);
if (!job.active && job.status !== "running") {
return job;
}
@@ -99,20 +133,12 @@ async function startRemoteAction(hostId: number, kind: HostRemoteActionKind) {
await updateHostAgentViaSsh(hostId);
}
export async function runHostRemoteAction(
async function executeRemoteAction(
hostId: number,
title: string,
kind: HostRemoteActionKind,
entry: HostRemoteActionLogEntry,
): Promise<HostRemoteActionJobStatus | null> {
clearSuccessAutoCloseTimer();
hostRemoteActionLog.open = true;
hostRemoteActionLog.loading = true;
hostRemoteActionLog.ok = null;
hostRemoteActionLog.title = title;
hostRemoteActionLog.message = "Запуск на сервере SAC…";
hostRemoteActionLog.output = "";
hostRemoteActionLog.hostId = hostId;
try {
try {
await startRemoteAction(hostId, kind);
@@ -123,31 +149,93 @@ export async function runHostRemoteAction(
throw e;
}
}
hostRemoteActionLog.message =
"Выполняется на удалённом хосте… Можно перейти в другой раздел SAC.";
const job = await pollRemoteJob(hostId);
finishModal(job);
entry.message = "Выполняется на удалённом хосте… Можно запустить обновление других хостов.";
const job = await pollRemoteJob(hostId, entry);
finishEntry(entry, job);
await patchHostFromJob(hostId, job);
return job;
} catch (e) {
hostRemoteActionLog.loading = false;
hostRemoteActionLog.ok = false;
hostRemoteActionLog.message = e instanceof Error ? e.message : "Ошибка выполнения";
entry.loading = false;
entry.ok = false;
entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
entry.open = true;
return null;
}
}
export function closeHostRemoteActionLog() {
clearSuccessAutoCloseTimer();
hostRemoteActionLog.open = false;
}
export async function runHostRemoteAction(
hostId: number,
title: string,
kind: HostRemoteActionKind,
): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) {
const existing = findLoadingLogForHost(hostId);
if (existing) {
existing.open = true;
}
return existingPromise;
}
export function showHostRemoteActionLog() {
if (hostRemoteActionLog.loading || hostRemoteActionLog.ok != null) {
hostRemoteActionLog.open = true;
const entry: HostRemoteActionLogEntry = {
id: newLogId(),
hostId,
open: true,
loading: true,
ok: null,
title,
message: "Запуск на сервере SAC…",
output: "",
};
hostRemoteActionLogs.push(entry);
const promise = executeRemoteAction(hostId, title, kind, entry);
hostRunningPromises.set(hostId, promise);
try {
return await promise;
} finally {
hostRunningPromises.delete(hostId);
}
}
export function hasActiveHostRemoteAction(): boolean {
return hostRemoteActionLog.loading;
export function closeHostRemoteActionLog(entryId: string) {
clearAutoCloseTimer(entryId);
const entry = findLogById(entryId);
if (!entry) return;
entry.open = false;
if (!entry.loading) {
removeClosedFinishedLog(entryId);
}
}
export function showHostRemoteActionLog(hostId?: number) {
if (hostId != null) {
for (const entry of hostRemoteActionLogs) {
if (entry.hostId === hostId && (entry.loading || entry.ok != null)) {
entry.open = true;
}
}
return;
}
for (const entry of hostRemoteActionLogs) {
if (entry.loading || entry.ok === false) {
entry.open = true;
}
}
}
export function isHostRemoteActionActive(hostId: number): boolean {
return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.loading);
}
export function hasActiveHostRemoteAction(): boolean {
return hostRemoteActionLogs.some((e) => e.loading);
}
export function hasOpenRemoteActionLogForHost(hostId: number): boolean {
return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.open);
}
export function anyRemoteActionLogOpen(): boolean {
return hostRemoteActionLogs.some((e) => e.open);
}
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.4.0";
export const APP_VERSION = "0.4.2";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+42 -59
View File
@@ -25,7 +25,7 @@
v-if="host"
:host="host"
:git="agentGitVersions"
:updating="updatingAgent || runningAgentFallback"
:updating="isHostRemoteActionActive(hostId)"
:outdated-highlight="host.version_status === 'outdated'"
@upgrade="runAgentUpgradeFromCell"
/>
@@ -91,13 +91,13 @@
<button
type="button"
class="secondary"
:disabled="runningAgentFallback"
:disabled="isHostRemoteActionActive(hostId)"
@click="runAgentFallback"
>
{{ runningAgentFallback ? "Обновление…" : "Обновить через WinRM" }}
{{ isHostRemoteActionActive(hostId) ? "Обновление…" : "Обновить через WinRM" }}
</button>
</div>
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
<p v-if="agentControlMessage && !hasOpenRemoteActionLogForHost(hostId)" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
</div>
<div v-if="isLinuxHost" class="host-actions">
<div class="host-actions-row">
@@ -119,10 +119,10 @@
<button
type="button"
class="secondary"
:disabled="updatingAgent"
:disabled="isHostRemoteActionActive(hostId)"
@click="runAgentUpdate"
>
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor (SSH)" }}
{{ isHostRemoteActionActive(hostId) ? "Обновление…" : "Обновить ssh-monitor (SSH)" }}
</button>
<button
type="button"
@@ -135,13 +135,13 @@
<button
type="button"
class="secondary"
:disabled="runningAgentFallback"
:disabled="isHostRemoteActionActive(hostId)"
@click="runAgentFallback"
>
{{ runningAgentFallback ? "Fallback…" : "Fallback SSH" }}
{{ isHostRemoteActionActive(hostId) ? "Fallback…" : "Fallback SSH" }}
</button>
</div>
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
<p v-if="agentControlMessage && !hasOpenRemoteActionLogForHost(hostId)" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
<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>
@@ -289,7 +289,8 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import {
hostRemoteActionLog,
hasOpenRemoteActionLogForHost,
isHostRemoteActionActive,
runHostRemoteAction,
} from "../composables/useHostRemoteAction";
import {
@@ -338,13 +339,11 @@ const winRmTestMessage = ref("");
const winRmTestOk = ref(false);
const showWinRmTestFeedback = 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 requestingAgentUpdate = ref(false);
const runningAgentFallback = ref(false);
const agentControlMessage = ref("");
const agentControlOk = ref(false);
const savingAgentConfig = ref(false);
@@ -637,22 +636,17 @@ async function runRequestAgentUpdate() {
}
async function runAgentFallback() {
runningAgentFallback.value = true;
agentControlMessage.value = "";
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
try {
const job = await runHostRemoteAction(hostId.value, title, "fallback");
if (job) {
agentControlOk.value = job.ok ?? false;
if (job.ok) {
agentControlMessage.value = "";
await loadHost();
} else {
agentControlMessage.value = job.message || "";
}
const job = await runHostRemoteAction(hostId.value, title, "fallback");
if (job) {
agentControlOk.value = job.ok ?? false;
if (job.ok) {
agentControlMessage.value = "";
await loadHost();
} else {
agentControlMessage.value = job.message || "";
}
} finally {
runningAgentFallback.value = false;
}
}
@@ -708,23 +702,18 @@ async function loadHost() {
}
async function runAgentUpdate() {
updatingAgent.value = true;
try {
const job = await runHostRemoteAction(
hostId.value,
"Обновление ssh-monitor (SSH)",
"ssh-update",
);
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);
const job = await runHostRemoteAction(
hostId.value,
"Обновление ssh-monitor (SSH)",
"ssh-update",
);
if (job?.ok) {
const detail = await fetchHost(hostId.value);
if (job.product_version) {
detail.product_version = job.product_version;
}
} finally {
updatingAgent.value = false;
applyHostDetail(detail);
recalcVersionStatusFromGit(detail, agentGitVersions.value);
}
}
@@ -732,27 +721,21 @@ async function runAgentUpgradeFromCell() {
if (!host.value) return;
const kind = remoteActionKindForHost(host.value);
if (!kind) return;
if (kind === "ssh-update") {
await runAgentUpdate();
if (isHostRemoteActionActive(hostId.value)) {
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);
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;
}
} finally {
runningAgentFallback.value = false;
applyHostDetail(detail);
recalcVersionStatusFromGit(detail, agentGitVersions.value);
}
}
+12 -11
View File
@@ -71,8 +71,7 @@
<HostAgentVersionUpgrade
:host="h"
:git="data"
:updating="upgradingHostId === h.id"
:upgrade-blocked="upgradingHostId != null && upgradingHostId !== h.id"
:updating="isHostRemoteActionActive(h.id)"
:outdated-highlight="isVersionOutdated(h)"
@upgrade="startAgentUpgrade(h)"
/>
@@ -132,7 +131,11 @@ import {
remoteActionKindForHost,
remoteActionTitleForHost,
} from "../utils/hostAgentUpgrade";
import { runHostRemoteAction } from "../composables/useHostRemoteAction";
import {
isHostRemoteActionActive,
runHostRemoteAction,
showHostRemoteActionLog,
} from "../composables/useHostRemoteAction";
import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue";
const router = useRouter();
@@ -197,7 +200,6 @@ 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;
@@ -234,17 +236,16 @@ function isVersionOutdated(h: HostSummary): boolean {
}
async function startAgentUpgrade(h: HostSummary) {
if (!isGitAgentUpgradeAvailable(h, data.value) || upgradingHostId.value != null) {
if (!isGitAgentUpgradeAvailable(h, data.value)) {
return;
}
if (isHostRemoteActionActive(h.id)) {
showHostRemoteActionLog(h.id);
return;
}
const kind = remoteActionKindForHost(h);
if (!kind) return;
upgradingHostId.value = h.id;
try {
await runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind);
} finally {
upgradingHostId.value = null;
}
void runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind);
}
function openHost(id: number) {