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