Files
security-alert-center/frontend/src/composables/useHostRemoteAction.ts
T
PTah e7d5b1c60b fix: SSH update modal polls locally, collapsed log by default (v0.5.9)
Modal owns job poll and completion UI with countdown; log hidden until user clicks Показать лог; db.refresh on remote-job; cache-bust poll requests.
2026-07-08 14:48:31 +10:00

320 lines
8.3 KiB
TypeScript

import { reactive } from "vue";
import {
ApiError,
fetchHost,
fetchHostRemoteJob,
runHostAgentUpdateFallback,
updateHostAgentViaSsh,
type HostDetail,
type HostRemoteActionJobStatus,
} from "../api";
import { emitHostPatch } from "../utils/hostPatchBus";
export type HostRemoteActionKind = "fallback" | "ssh-update";
export type HostRemoteActionLogEntry = {
id: string;
hostId: number;
open: boolean;
logVisible: 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 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 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 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);
}
}
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) {
if (job.title) {
entry.title = job.title;
}
if (job.message) {
entry.message = job.message;
}
const nextOutput = (job.output || job.stdout || "").trim();
if (nextOutput) {
entry.output = job.output || job.stdout || "";
}
}
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
if (!entry.loading && entry.ok != null) {
return;
}
entry.loading = false;
entry.ok = job.ok ?? job.status === "success";
applyJobToEntry(entry, job);
entry.open = true;
if (entry.ok) {
scheduleSuccessAutoClose(entry.id);
}
}
export function syncHostRemoteActionJobFinished(
entryId: string,
job: HostRemoteActionJobStatus,
): void {
const entry = findLogById(entryId);
if (!entry) return;
finishEntry(entry, job);
void patchHostFromJob(entry.hostId, job);
}
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
for (;;) {
const job = await fetchHostRemoteJob(hostId);
applyJobToEntry(entry, job);
if (!job.active && job.status !== "running") {
return job;
}
await sleep(POLL_MS);
}
}
async function patchHostFromJob(hostId: number, job: HostRemoteActionJobStatus) {
try {
const detail: HostDetail = await fetchHost(hostId);
if (job.product_version) {
detail.product_version = job.product_version;
}
if (job.agent_update_state) {
detail.agent_update_state = job.agent_update_state;
}
if (job.ok === false && job.message) {
detail.agent_update_last_error = job.message;
}
emitHostPatch(detail);
} catch {
/* list refresh on next open is enough */
}
}
async function startRemoteAction(hostId: number, kind: HostRemoteActionKind) {
if (kind === "fallback") {
await runHostAgentUpdateFallback(hostId);
return;
}
await updateHostAgentViaSsh(hostId);
}
async function executeRemoteAction(
hostId: number,
title: string,
kind: HostRemoteActionKind,
entry: HostRemoteActionLogEntry,
): Promise<HostRemoteActionJobStatus | null> {
try {
try {
await startRemoteAction(hostId, kind);
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
/* already running — poll existing job */
} else {
throw e;
}
}
entry.message = "Выполняется на удалённом хосте… Можно запустить обновление других хостов.";
const job = await pollRemoteJob(hostId, entry);
finishEntry(entry, job);
await patchHostFromJob(hostId, job);
return job;
} catch (e) {
entry.loading = false;
entry.ok = false;
entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
entry.open = true;
return null;
}
}
export async function pollHostRemoteAction(
hostId: number,
title: string,
): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) {
const existing = findLoadingLogForHost(hostId);
if (existing) {
existing.open = true;
}
return existingPromise;
}
const entry: HostRemoteActionLogEntry = {
id: newLogId(),
hostId,
open: true,
logVisible: false,
loading: true,
ok: null,
title,
message: "Выполняется на удалённом хосте…",
output: "",
};
hostRemoteActionLogs.push(entry);
const promise = (async () => {
try {
const job = await pollRemoteJob(hostId, entry);
finishEntry(entry, job);
await patchHostFromJob(hostId, job);
return job;
} catch (e) {
entry.loading = false;
entry.ok = false;
entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
entry.open = true;
return null;
}
})();
hostRunningPromises.set(hostId, promise);
try {
return await promise;
} finally {
hostRunningPromises.delete(hostId);
}
}
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;
}
const entry: HostRemoteActionLogEntry = {
id: newLogId(),
hostId,
open: true,
logVisible: false,
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 toggleHostRemoteActionLog(entryId: string) {
const entry = findLogById(entryId);
if (!entry) return;
entry.logVisible = !entry.logVisible;
entry.open = true;
}
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);
}