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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user