e7d5b1c60b
Modal owns job poll and completion UI with countdown; log hidden until user clicks Показать лог; db.refresh on remote-job; cache-bust poll requests.
320 lines
8.3 KiB
TypeScript
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);
|
|
}
|