08a0157c95
调度页执行周期可视化/单次执行/分类选机/推送源对齐;登录 IP 门控与无缝导航; 服务器批量后台任务、执行记录、凭据合并、各页激活刷新与错误提示修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
343 lines
9.1 KiB
TypeScript
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,
|
|
}
|
|
}
|