Files
Nexus/frontend/src/composables/useScriptExecutionQueue.ts
T
Nexus Agent 08a0157c95 feat: 调度表单重构、登录门控、批量任务与页面缓存对齐
调度页执行周期可视化/单次执行/分类选机/推送源对齐;登录 IP 门控与无缝导航;
服务器批量后台任务、执行记录、凭据合并、各页激活刷新与错误提示修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:17:21 +08:00

343 lines
9.1 KiB
TypeScript

/**
* Global script execution progress queue — fed by /ws/alerts script_* messages
* with 3s polling fallback when WebSocket is disconnected.
*/
import { ref, computed, onUnmounted } from 'vue'
import { http } from '@/api'
import { isExecTerminalStatus } from '@/utils/scriptExecutionLabels'
export interface ScriptExecQueueItem {
executionId: number
label: string
status: string
total: number
done: number
remaining: number
success: number
failed: number
progress: string
longTask: boolean
operator?: string
taskKind?: 'script' | 'server_batch'
op?: string
}
export interface ScriptExecCompleteAlert {
executionId: number
label: string
status: string
success: number
failed: number
total: number
fading: boolean
taskKind?: 'script' | 'server_batch'
op?: string
}
const POLL_MS = 3000
const activeItems = ref<Map<number, ScriptExecQueueItem>>(new Map())
const completeAlerts = ref<ScriptExecCompleteAlert[]>([])
const wsConnected = ref(true)
let pollTimer: ReturnType<typeof setInterval> | null = null
const completeListeners = new Set<(alert: ScriptExecCompleteAlert) => void>()
function mapToItem(msg: Record<string, unknown>): ScriptExecQueueItem {
const total = Number(msg.total) || 0
const done = Number(msg.done) || 0
const remaining = Number(msg.remaining) ?? Math.max(0, total - done)
const taskKind = msg.task_kind === 'server_batch' ? 'server_batch' : 'script'
return {
executionId: Number(msg.execution_id),
label: String(msg.label || `执行 #${msg.execution_id}`),
status: String(msg.status || 'running'),
total,
done,
remaining,
success: Number(msg.success) || 0,
failed: Number(msg.failed) || 0,
progress: String(msg.progress || `${done}/${total}`),
longTask: Boolean(msg.long_task),
operator: msg.operator ? String(msg.operator) : undefined,
taskKind,
op: msg.op ? String(msg.op) : undefined,
}
}
function upsertItem(item: ScriptExecQueueItem) {
const next = new Map(activeItems.value)
if (isExecTerminalStatus(item.status)) {
next.delete(item.executionId)
} else {
next.set(item.executionId, item)
}
activeItems.value = next
}
function pushCompleteAlert(item: ScriptExecQueueItem) {
const alert: ScriptExecCompleteAlert = {
executionId: item.executionId,
label: item.label,
status: item.status,
success: item.success,
failed: item.failed,
total: item.total,
fading: false,
taskKind: item.taskKind,
op: item.op,
}
completeAlerts.value = [alert, ...completeAlerts.value].slice(0, 5)
for (const fn of completeListeners) {
try {
fn(alert)
} catch {
/* ignore */
}
}
setTimeout(() => {
completeAlerts.value = completeAlerts.value.map(a =>
a.executionId === alert.executionId ? { ...a, fading: true } : a,
)
setTimeout(() => {
completeAlerts.value = completeAlerts.value.filter(
a => a.executionId !== alert.executionId,
)
}, 400)
}, 3000)
}
function ensurePolling() {
if (pollTimer) return
pollTimer = setInterval(() => {
if (wsConnected.value) return
void pollActive()
}, POLL_MS)
}
function stopPollingIfIdle() {
if (activeItems.value.size > 0) return
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function pollActive() {
const ids = [...activeItems.value.keys()]
if (!ids.length) {
stopPollingIfIdle()
return
}
for (const id of ids) {
try {
const prev = activeItems.value.get(id)
const isServerBatch = prev?.taskKind === 'server_batch'
const res = isServerBatch
? await http.get<{
job_id: number
status: string
progress?: string
label?: string
op?: string
total?: number
done?: number
remaining?: number
success?: number
failed?: number
}>(`/servers/batch/jobs/${id}`)
: await http.get<{
id: number
status: string
progress?: string
long_task?: boolean
label?: string
server_ids?: string
results?: Record<string, unknown>
}>(`/scripts/executions/${id}`)
const label = res.label || prev?.label || `执行 #${id}`
let total = prev?.total || 0
if (isServerBatch) {
const batchRes = res as {
total?: number
done?: number
remaining?: number
success?: number
failed?: number
op?: string
}
if (typeof batchRes.total === 'number') total = batchRes.total
const progress = res.progress || prev?.progress || `0/${total}`
const done = Number(batchRes.done) || 0
const totalFromProgress = batchRes.total || total
const item: ScriptExecQueueItem = {
executionId: id,
label,
status: res.status,
total: totalFromProgress || total,
done,
remaining: Number(batchRes.remaining) || Math.max(0, (totalFromProgress || total) - done),
success: Number(batchRes.success) || 0,
failed: Number(batchRes.failed) || 0,
progress,
longTask: false,
operator: prev?.operator,
taskKind: 'server_batch',
op: batchRes.op,
}
if (isExecTerminalStatus(res.status)) {
upsertItem(item)
pushCompleteAlert({ ...item, status: res.status })
} else {
upsertItem(item)
}
continue
}
const scriptRes = res as {
server_ids?: string
long_task?: boolean
}
if (scriptRes.server_ids) {
try {
const parsed = JSON.parse(scriptRes.server_ids) as unknown
if (Array.isArray(parsed)) total = parsed.length
} catch {
/* keep */
}
}
const progress = res.progress || prev?.progress || `0/${total}`
const parts = progress.split('/')
const success = Number(parts[0]) || 0
const totalFromProgress = Number(parts[1]) || total
const item: ScriptExecQueueItem = {
executionId: id,
label,
status: res.status,
total: totalFromProgress || total,
done: success,
remaining: Math.max(0, (totalFromProgress || total) - success),
success,
failed: 0,
progress,
longTask: Boolean(scriptRes.long_task ?? prev?.longTask),
operator: prev?.operator,
taskKind: 'script',
op: prev?.op,
}
if (isExecTerminalStatus(res.status)) {
upsertItem(item)
pushCompleteAlert({ ...item, status: res.status })
} else {
upsertItem(item)
}
} catch {
/* keep last known */
}
}
stopPollingIfIdle()
}
export function handleScriptWsMessage(msg: Record<string, unknown>) {
const item = mapToItem(msg)
if (!item.executionId) return
if (msg.type === 'script_complete' || isExecTerminalStatus(item.status)) {
upsertItem(item)
pushCompleteAlert(item)
stopPollingIfIdle()
return
}
upsertItem(item)
ensurePolling()
}
export function setScriptQueueWsConnected(connected: boolean) {
wsConnected.value = connected
if (!connected && activeItems.value.size > 0) {
ensurePolling()
}
}
export function registerScriptExecution(
executionId: number,
label: string,
totalServers: number,
longTask = false,
progress?: string,
status = 'running',
) {
if (isExecTerminalStatus(status)) return
const item: ScriptExecQueueItem = {
executionId,
label,
status,
total: totalServers,
done: 0,
remaining: totalServers,
success: 0,
failed: 0,
progress: progress || `0/${totalServers}`,
longTask,
taskKind: 'script',
}
upsertItem(item)
ensurePolling()
}
export function registerServerBatchJob(
jobId: number,
label: string,
totalServers: number,
op?: string,
progress?: string,
status = 'running',
) {
if (isExecTerminalStatus(status)) return
const item: ScriptExecQueueItem = {
executionId: jobId,
label,
status,
total: totalServers,
done: 0,
remaining: totalServers,
success: 0,
failed: 0,
progress: progress || `0/${totalServers}`,
longTask: false,
taskKind: 'server_batch',
op,
}
upsertItem(item)
ensurePolling()
}
export function onScriptExecutionComplete(listener: (alert: ScriptExecCompleteAlert) => void) {
completeListeners.add(listener)
return () => completeListeners.delete(listener)
}
export function dismissCompleteAlert(executionId: number) {
completeAlerts.value = completeAlerts.value.map(a =>
a.executionId === executionId ? { ...a, fading: true } : a,
)
setTimeout(() => {
completeAlerts.value = completeAlerts.value.filter(a => a.executionId !== executionId)
}, 400)
}
export function useScriptExecutionQueue() {
const runningItems = computed(() =>
[...activeItems.value.values()].sort((a, b) => b.executionId - a.executionId),
)
onUnmounted(() => {
/* singleton — do not stop global poll on component unmount */
})
return {
runningItems,
completeAlerts: computed(() => completeAlerts.value),
registerScriptExecution,
dismissCompleteAlert,
onScriptExecutionComplete,
}
}