fix(scripts): 执行进度按成功台数计算,失败项快速定位与面板清理
progress 改为 success/total(失败不计入分子);脚本库执行详情中文化、失败筛选与终态任务移出批量状态面板。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
| `server/domain/models/__init__.py` | ☑ 已审 |
|
||||
| `server/application/services/credential_poller.py` | ☑ 已审 |
|
||||
| `server/application/services/script_service.py` | ☑ 已审 |
|
||||
| `server/infrastructure/redis/script_execution_store.py` | ☑ 已审(progress 成功/总数) |
|
||||
| `server/application/services/sync_engine_v2.py` | ☑ 已审 |
|
||||
| `frontend/public/servers_import_template.csv` | ☑ 已审 |
|
||||
| `frontend/src/pages/ServersPage.vue` | ☑ 已审 |
|
||||
@@ -44,12 +45,14 @@
|
||||
| `frontend/src/constants/dataTable.ts` | ☑ 已审 |
|
||||
| `frontend/src/utils/paginatedFetch.ts` | ☑ 已审 |
|
||||
| `frontend/src/utils/serverTableSort.ts` | ☑ 已审 |
|
||||
| `frontend/src/utils/scriptExecutionLabels.ts` | ☑ 已审 |
|
||||
| `frontend/src/plugins/vuetify.ts` | ☑ 已审 |
|
||||
| `frontend/src/types/api.ts` | ☑ 已审 |
|
||||
| `scripts/import_servers_from_xls.py` | ☑ 已审 |
|
||||
| `tests/test_credential_poller.py` | ☑ 已审 |
|
||||
| `tests/test_rsync_push_permissions.py` | ☑ 已审 |
|
||||
| `tests/test_script_dispatch.py` | ☑ 已审 |
|
||||
| `tests/test_script_execution_progress.py` | ☑ 已审 |
|
||||
| `web/app/index.html` | ☑ 构建产物入口 |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 2026-06-08 — 脚本库执行详情中文化与服务器名称
|
||||
|
||||
## 日期
|
||||
2026-06-08
|
||||
|
||||
## 变更摘要
|
||||
脚本库「执行详情」弹窗:每台服务器状态翻译为中文;服务器列显示名称与地址;失败项快速定位(摘要、筛选、展开输出、复制、终端跳转);历史列表显示失败台数。
|
||||
|
||||
## 动机
|
||||
执行详情表格直接展示 `pending`/`success` 等英文状态及数字 ID,运维难以快速对应目标机。
|
||||
|
||||
## 涉及文件
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend/src/utils/scriptExecutionLabels.ts` | 整体/单台状态中文映射 |
|
||||
| `frontend/src/pages/ScriptsPage.vue` | 详情表绑定名称与中文状态 |
|
||||
|
||||
## 状态映射示例
|
||||
| 原始 | 中文 |
|
||||
|------|------|
|
||||
| pending | 后台执行中 |
|
||||
| success / completed | 成功 / 已完成 |
|
||||
| failed / error | 失败 / 错误 |
|
||||
| skipped | 已跳过 |
|
||||
| timeout | 超时 |
|
||||
| connection_error | 连接失败 |
|
||||
|
||||
## 是否需迁移
|
||||
否
|
||||
|
||||
## 是否需重启
|
||||
是(后端 progress 计算逻辑)
|
||||
|
||||
## 进度语义(2026-06-08 补充)
|
||||
- API `progress` 由 `success/total` 表示(成功台数/总台数)
|
||||
- 失败、跳过、超时、连接失败等**不计入分子**;1 台失败时显示 `365/366` 而非 `366/366`
|
||||
- 涉及:`server/infrastructure/redis/script_execution_store.py` — `execution_progress_summary`
|
||||
|
||||
## 批量执行状态面板
|
||||
- 仅展示 `running` 任务;失败/完成/部分成功/已取消后自动移出面板
|
||||
- 终态任务请查看下方「全部执行历史」
|
||||
|
||||
## 失败快速定位
|
||||
- 打开失败/部分成功记录时默认「仅失败」筛选,并展开首台失败输出
|
||||
- 顶部红色告警列出全部失败服务器名称
|
||||
- 行点击展开完整 stderr/stdout;支持复制、跳转终端
|
||||
- 执行历史进度列显示「N 台失败」Chip
|
||||
|
||||
## 验证方式
|
||||
1. `cd frontend && npm run build`
|
||||
2. `#/scripts` → 打开失败记录「详情」,确认失败筛选、名称、中文状态、展开输出
|
||||
@@ -240,7 +240,18 @@
|
||||
{{ parseServerCount(item.server_ids) }}
|
||||
</template>
|
||||
<template #item.progress="{ item }">
|
||||
{{ formatProgress(item.progress) }}
|
||||
<div class="d-flex align-center flex-wrap ga-1">
|
||||
<span>{{ formatProgress(item.progress) }}</span>
|
||||
<v-chip
|
||||
v-if="execResultSummary(item).failed > 0"
|
||||
color="error"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
{{ execResultSummary(item).failed }} 台失败
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.started_at="{ item }">
|
||||
{{ formatExecTime(item.started_at) }}
|
||||
@@ -274,7 +285,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Execution Status Panel -->
|
||||
<v-card v-if="trackedExecs.length > 0" elevation="0" border rounded="lg" class="mt-4">
|
||||
<v-card v-if="activeTrackedExecs.length > 0" elevation="0" border rounded="lg" class="mt-4">
|
||||
<v-card-title class="d-flex align-center text-subtitle-1">
|
||||
<v-icon class="mr-2">mdi-progress-clock</v-icon>
|
||||
批量执行状态
|
||||
@@ -284,7 +295,7 @@
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="exec in trackedExecs" :key="exec.id" :title="exec.name">
|
||||
<v-list-item v-for="exec in activeTrackedExecs" :key="exec.id" :title="exec.name">
|
||||
<template #subtitle>
|
||||
<span class="text-caption">
|
||||
#{{ exec.id }} · {{ exec.progress || '—' }} · {{ exec.totalServers }} 台
|
||||
@@ -429,10 +440,19 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- Execution Detail Dialog -->
|
||||
<v-dialog v-model="showExecDetail" max-width="900" scrollable>
|
||||
<v-dialog v-model="showExecDetail" max-width="960" scrollable>
|
||||
<v-card border>
|
||||
<v-card-title class="d-flex align-center">
|
||||
执行详情 #{{ execDetail?.id }}
|
||||
<v-card-title class="d-flex align-center flex-wrap ga-2">
|
||||
<span>执行详情 #{{ execDetail?.id }}</span>
|
||||
<v-chip
|
||||
v-if="execDetail"
|
||||
:color="execStatusColor(execDetail.status)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
{{ execStatusLabel(execDetail.status) }}
|
||||
</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="execDetail?.long_task"
|
||||
@@ -446,31 +466,133 @@
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text v-if="execDetail">
|
||||
<div class="text-caption text-medium-emphasis mb-3">
|
||||
{{ execStatusLabel(execDetail.status) }} · {{ formatProgress(execDetail.progress) }}
|
||||
· 操作人 {{ execDetail.operator || '—' }}
|
||||
<div class="text-caption text-medium-emphasis mb-2">
|
||||
{{ formatProgress(execDetail.progress) }} · 操作人 {{ execDetail.operator || '—' }}
|
||||
</div>
|
||||
<pre class="text-body-2 mb-4 pa-3 rounded bg-surface-variant overflow-auto" style="max-height: 120px">{{ execDetail.command }}</pre>
|
||||
<v-table density="compact">
|
||||
<pre class="text-body-2 mb-3 pa-3 rounded bg-surface-variant overflow-auto" style="max-height: 100px">{{ execDetail.command }}</pre>
|
||||
|
||||
<div v-if="execDetailStats.total" class="d-flex flex-wrap ga-2 mb-3">
|
||||
<v-chip size="small" variant="tonal" label>共 {{ execDetailStats.total }} 台</v-chip>
|
||||
<v-chip v-if="execDetailStats.success" color="success" size="small" variant="tonal" label>
|
||||
成功 {{ execDetailStats.success }}
|
||||
</v-chip>
|
||||
<v-chip v-if="execDetailStats.failed" color="error" size="small" variant="tonal" label>
|
||||
失败 {{ execDetailStats.failed }}
|
||||
</v-chip>
|
||||
<v-chip v-if="execDetailStats.pending" color="info" size="small" variant="tonal" label>
|
||||
进行中 {{ execDetailStats.pending }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="execDetailFailedNames.length"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
:title="`失败 ${execDetailFailedNames.length} 台`"
|
||||
>
|
||||
<div class="text-body-2">{{ execDetailFailedNames.join('、') }}</div>
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex align-center flex-wrap ga-2 mb-3">
|
||||
<span class="text-caption text-medium-emphasis">筛选</span>
|
||||
<v-btn-toggle v-model="detailRowFilter" mandatory density="compact" color="primary" variant="outlined" divided>
|
||||
<v-btn value="all" size="small">全部</v-btn>
|
||||
<v-btn value="failed" size="small">仅失败</v-btn>
|
||||
<v-btn value="success" size="small">仅成功</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<v-table density="compact" class="exec-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>服务器</th>
|
||||
<th>状态</th>
|
||||
<th>退出码</th>
|
||||
<th>输出摘要</th>
|
||||
<th style="width: 72px">退出码</th>
|
||||
<th>错误摘要</th>
|
||||
<th style="width: 120px" class="text-end">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in execDetailRows" :key="row.serverId">
|
||||
<td>#{{ row.serverId }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td>{{ row.exitCode ?? '—' }}</td>
|
||||
<td class="text-truncate" style="max-width: 360px">{{ row.summary }}</td>
|
||||
<tr
|
||||
v-for="row in filteredExecDetailRows"
|
||||
:key="row.serverId"
|
||||
:class="{ 'exec-detail-row--failed': row.failed, 'exec-detail-row--active': expandedRowId === row.serverId }"
|
||||
class="exec-detail-row cursor-pointer"
|
||||
@click="toggleDetailRow(row.serverId)"
|
||||
>
|
||||
<td>
|
||||
<div class="font-weight-medium">{{ row.serverName }}</div>
|
||||
<div v-if="row.serverDomain" class="text-caption text-medium-emphasis">{{ row.serverDomain }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<v-chip
|
||||
:color="execServerStatusColor(row.status, row.phase, row.exitCode)"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
{{ execServerStatusLabel(row.status, row.phase) }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td :class="{ 'text-error font-weight-medium': row.failed }">{{ row.exitCode ?? '—' }}</td>
|
||||
<td class="text-truncate" style="max-width: 280px" :title="row.summary">{{ row.summary }}</td>
|
||||
<td class="text-end" @click.stop>
|
||||
<v-btn
|
||||
v-if="row.fullOutput && row.fullOutput !== '—'"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-content-copy"
|
||||
title="复制输出"
|
||||
@click="copyExecOutput(row)"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-console"
|
||||
title="打开终端"
|
||||
@click="openServerTerminal(row.serverIdNum)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filteredExecDetailRows.length === 0">
|
||||
<td colspan="5" class="text-center text-medium-emphasis py-4">当前筛选无记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<v-card
|
||||
v-if="expandedDetailRow"
|
||||
variant="tonal"
|
||||
:color="expandedDetailRow.failed ? 'error' : undefined"
|
||||
class="mt-3 pa-3"
|
||||
>
|
||||
<div class="d-flex align-center flex-wrap ga-2 mb-2">
|
||||
<span class="text-subtitle-2">
|
||||
{{ expandedDetailRow.serverName }}
|
||||
<span v-if="expandedDetailRow.serverDomain" class="text-medium-emphasis">({{ expandedDetailRow.serverDomain }})</span>
|
||||
</span>
|
||||
<v-spacer />
|
||||
<v-btn size="x-small" variant="text" prepend-icon="mdi-content-copy" @click="copyExecOutput(expandedDetailRow)">
|
||||
复制
|
||||
</v-btn>
|
||||
<v-btn size="x-small" variant="text" prepend-icon="mdi-console" @click="openServerTerminal(expandedDetailRow.serverIdNum)">
|
||||
终端
|
||||
</v-btn>
|
||||
</div>
|
||||
<pre class="text-body-2 overflow-auto exec-detail-output">{{ expandedDetailRow.fullOutput }}</pre>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
<v-card-actions v-if="execDetail">
|
||||
<v-btn
|
||||
v-if="execDetail.status === 'failed' || execDetail.status === 'partial'"
|
||||
color="primary"
|
||||
variant="text"
|
||||
@click="retryFromDetail"
|
||||
>
|
||||
重试失败项
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showExecDetail = false">关闭</v-btn>
|
||||
</v-card-actions>
|
||||
@@ -481,6 +603,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ScriptContentEditor from '@/components/ScriptContentEditor.vue'
|
||||
import ServerCategoryPicker from '@/components/servers/ServerCategoryPicker.vue'
|
||||
import { http } from '@/api'
|
||||
@@ -491,9 +614,20 @@ import { required } from '@/utils/validation'
|
||||
import type { DbCredentialItem, ScriptExecItem, ScriptItem } from '@/types/api'
|
||||
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
|
||||
import { fetchLimitOffset } from '@/utils/paginatedFetch'
|
||||
import {
|
||||
execStatusLabel,
|
||||
execStatusColor,
|
||||
execServerStatusLabel,
|
||||
execServerStatusColor,
|
||||
isExecServerFailed,
|
||||
isExecServerPending,
|
||||
isExecTerminalStatus,
|
||||
summarizeExecServerResults,
|
||||
} from '@/utils/scriptExecutionLabels'
|
||||
|
||||
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
|
||||
const snackbar = useSnackbar()
|
||||
const router = useRouter()
|
||||
const { servers: serverList, loading: serversLoading, loadServers } = useServerList()
|
||||
|
||||
const categoryOptions = [
|
||||
@@ -521,9 +655,16 @@ interface TrackedExec {
|
||||
|
||||
interface ExecDetailRow {
|
||||
serverId: string
|
||||
serverIdNum: number
|
||||
serverName: string
|
||||
serverDomain: string
|
||||
status: string
|
||||
phase?: string
|
||||
exitCode: number | null
|
||||
summary: string
|
||||
fullOutput: string
|
||||
failed: boolean
|
||||
pending: boolean
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -572,10 +713,17 @@ const quickExecuting = ref(false)
|
||||
const trackedExecs = ref<TrackedExec[]>([])
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 批量执行状态面板仅展示进行中任务(running) */
|
||||
const activeTrackedExecs = computed(() =>
|
||||
trackedExecs.value.filter(e => e.status === 'running'),
|
||||
)
|
||||
|
||||
const showExecDetail = ref(false)
|
||||
const execDetail = ref<ScriptExecItem | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailExecId = ref<number | null>(null)
|
||||
const detailRowFilter = ref<'all' | 'failed' | 'success'>('all')
|
||||
const expandedRowId = ref<string | null>(null)
|
||||
|
||||
const credentialOptions = computed(() =>
|
||||
credentials.value.map(c => ({ title: c.name, value: c.id })),
|
||||
@@ -628,18 +776,77 @@ const execEventActionLabels: Record<string, string> = {
|
||||
job_completed: '回调完成',
|
||||
}
|
||||
|
||||
const serverBriefById = computed(() => new Map(serverList.value.map(s => [s.id, s])))
|
||||
|
||||
function buildExecOutput(entry: ScriptExecItem['results'][string]): { summary: string; full: string } {
|
||||
const stderr = (entry.stderr || '').trim()
|
||||
const stdout = (entry.stdout || '').trim()
|
||||
const message = (entry.message || '').trim()
|
||||
const full = stderr || stdout || message || '—'
|
||||
const summary = (stderr || message || stdout || '').slice(0, 200) || '—'
|
||||
return { summary, full }
|
||||
}
|
||||
|
||||
const execDetailRows = computed<ExecDetailRow[]>(() => {
|
||||
if (!execDetail.value?.results) return []
|
||||
return Object.entries(execDetail.value.results)
|
||||
const briefs = serverBriefById.value
|
||||
const rows = Object.entries(execDetail.value.results)
|
||||
.filter(([k]) => k !== '_meta' && /^\d+$/.test(k))
|
||||
.map(([serverId, entry]) => ({
|
||||
serverId,
|
||||
status: String(entry.status || entry.phase || '—'),
|
||||
exitCode: entry.exit_code ?? null,
|
||||
summary: (entry.stdout || entry.stderr || entry.message || '').slice(0, 200) || '—',
|
||||
}))
|
||||
.map(([serverId, entry]) => {
|
||||
const idNum = Number(serverId)
|
||||
const brief = briefs.get(idNum)
|
||||
const status = String(entry.status || '')
|
||||
const phase = entry.phase ? String(entry.phase) : undefined
|
||||
const exitCode = entry.exit_code ?? null
|
||||
const output = buildExecOutput(entry)
|
||||
const failed = isExecServerFailed(status, phase, exitCode)
|
||||
const pending = isExecServerPending(phase)
|
||||
return {
|
||||
serverId,
|
||||
serverIdNum: idNum,
|
||||
serverName: brief?.name || `服务器 #${serverId}`,
|
||||
serverDomain: brief?.domain || '',
|
||||
status,
|
||||
phase,
|
||||
exitCode,
|
||||
summary: output.summary,
|
||||
fullOutput: output.full,
|
||||
failed,
|
||||
pending,
|
||||
}
|
||||
})
|
||||
|
||||
return rows.sort((a, b) => {
|
||||
if (a.failed !== b.failed) return a.failed ? -1 : 1
|
||||
if (a.pending !== b.pending) return a.pending ? 1 : -1
|
||||
return a.serverName.localeCompare(b.serverName, 'zh')
|
||||
})
|
||||
})
|
||||
|
||||
const execDetailStats = computed(() =>
|
||||
summarizeExecServerResults(execDetail.value?.results, execDetail.value?.server_ids),
|
||||
)
|
||||
|
||||
const execDetailFailedNames = computed(() =>
|
||||
execDetailRows.value.filter(r => r.failed).map(r => r.serverName),
|
||||
)
|
||||
|
||||
const filteredExecDetailRows = computed(() => {
|
||||
if (detailRowFilter.value === 'failed') return execDetailRows.value.filter(r => r.failed)
|
||||
if (detailRowFilter.value === 'success') {
|
||||
return execDetailRows.value.filter(r => !r.failed && !r.pending)
|
||||
}
|
||||
return execDetailRows.value
|
||||
})
|
||||
|
||||
const expandedDetailRow = computed(() =>
|
||||
execDetailRows.value.find(r => r.serverId === expandedRowId.value) || null,
|
||||
)
|
||||
|
||||
function execResultSummary(item: ScriptExecItem) {
|
||||
return summarizeExecServerResults(item.results, item.server_ids)
|
||||
}
|
||||
|
||||
function categoryLabel(v: string) {
|
||||
return categoryOptions.find(o => o.value === v)?.label || v
|
||||
}
|
||||
@@ -921,6 +1128,10 @@ async function doQuickExec() {
|
||||
}
|
||||
}
|
||||
|
||||
function pruneTerminalTrackedExecs() {
|
||||
trackedExecs.value = trackedExecs.value.filter(e => !isExecTerminalStatus(e.status))
|
||||
}
|
||||
|
||||
function addTrackedExecution(
|
||||
name: string,
|
||||
execId: number,
|
||||
@@ -929,11 +1140,13 @@ function addTrackedExecution(
|
||||
progress?: string,
|
||||
status?: string,
|
||||
) {
|
||||
const initialStatus = status || 'running'
|
||||
if (isExecTerminalStatus(initialStatus)) return
|
||||
if (trackedExecs.value.some(e => e.id === execId)) return
|
||||
trackedExecs.value.unshift({
|
||||
id: execId,
|
||||
name,
|
||||
status: status || 'running',
|
||||
status: initialStatus,
|
||||
totalServers,
|
||||
progress: progress || `0/${totalServers}`,
|
||||
longTask,
|
||||
@@ -945,8 +1158,9 @@ function addTrackedExecution(
|
||||
function startPolling() {
|
||||
if (pollTimer) return
|
||||
pollTimer = setInterval(async () => {
|
||||
const active = trackedExecs.value.filter(e => e.status === 'running' || e.status === 'partial')
|
||||
const active = trackedExecs.value.filter(e => e.status === 'running')
|
||||
if (active.length === 0) {
|
||||
pruneTerminalTrackedExecs()
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
@@ -968,8 +1182,14 @@ async function refreshExecStatus(exec: TrackedExec) {
|
||||
exec.status = res.status
|
||||
exec.progress = formatProgress(res.progress)
|
||||
exec.longTask = Boolean(res.long_task)
|
||||
if (res.status !== 'running' && res.status !== 'partial') {
|
||||
if (isExecTerminalStatus(res.status)) {
|
||||
const name = exec.name
|
||||
const failed = res.status === 'failed' || res.status === 'partial'
|
||||
trackedExecs.value = trackedExecs.value.filter(e => e.id !== exec.id)
|
||||
refreshExecutionHistory()
|
||||
if (failed) {
|
||||
snackbar(`「${name}」已结束,请查看下方执行历史`, 'warning')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* keep last known */
|
||||
@@ -1034,8 +1254,42 @@ async function retryHistoryExec(item: ScriptExecItem) {
|
||||
|
||||
async function openExecDetail(id: number) {
|
||||
detailExecId.value = id
|
||||
detailRowFilter.value = 'all'
|
||||
expandedRowId.value = null
|
||||
showExecDetail.value = true
|
||||
await loadExecDetail(false)
|
||||
const summary = summarizeExecServerResults(execDetail.value?.results, execDetail.value?.server_ids)
|
||||
if (
|
||||
summary.failed > 0 &&
|
||||
(execDetail.value?.status === 'failed' || execDetail.value?.status === 'partial')
|
||||
) {
|
||||
detailRowFilter.value = 'failed'
|
||||
const firstFailed = execDetailRows.value.find(r => r.failed)
|
||||
if (firstFailed) expandedRowId.value = firstFailed.serverId
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetailRow(serverId: string) {
|
||||
expandedRowId.value = expandedRowId.value === serverId ? null : serverId
|
||||
}
|
||||
|
||||
async function copyExecOutput(row: ExecDetailRow) {
|
||||
if (!row.fullOutput || row.fullOutput === '—') return
|
||||
try {
|
||||
await navigator.clipboard.writeText(row.fullOutput)
|
||||
snackbar('已复制到剪贴板')
|
||||
} catch {
|
||||
snackbar('复制失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function openServerTerminal(serverId: number) {
|
||||
router.push({ path: '/terminal', query: { server_id: String(serverId) } })
|
||||
}
|
||||
|
||||
async function retryFromDetail() {
|
||||
if (!execDetail.value) return
|
||||
await retryHistoryExec(execDetail.value)
|
||||
}
|
||||
|
||||
async function loadExecDetail(fetchLogs: boolean) {
|
||||
@@ -1054,26 +1308,6 @@ async function loadExecDetail(fetchLogs: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function execStatusColor(s: string) {
|
||||
if (s === 'running') return 'blue'
|
||||
if (s === 'completed') return 'green'
|
||||
if (s === 'failed') return 'red'
|
||||
if (s === 'partial') return 'orange'
|
||||
if (s === 'cancelled') return 'grey'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function execStatusLabel(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
running: '执行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
partial: '部分成功',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadScripts()
|
||||
loadServers({ all: true })
|
||||
@@ -1086,3 +1320,17 @@ onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exec-detail-row--failed {
|
||||
background: rgba(var(--v-theme-error), 0.08);
|
||||
}
|
||||
.exec-detail-row--active {
|
||||
background: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
.exec-detail-output {
|
||||
max-height: 240px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
export interface ExecServerResultLike {
|
||||
status?: string
|
||||
phase?: string
|
||||
exit_code?: number | null
|
||||
}
|
||||
|
||||
/** 脚本执行整体状态(execution.status) */
|
||||
const EXEC_STATUS_LABELS: Record<string, string> = {
|
||||
running: '执行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
partial: '部分成功',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
|
||||
/** 单台服务器执行状态(results[serverId].status / phase) */
|
||||
const EXEC_SERVER_STATUS_LABELS: Record<string, string> = {
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
completed: '已完成',
|
||||
error: '错误',
|
||||
skipped: '已跳过',
|
||||
started: '已启动',
|
||||
timeout: '超时',
|
||||
connection_error: '连接失败',
|
||||
unknown: '未知',
|
||||
cancelled: '已取消',
|
||||
pending: '后台执行中',
|
||||
done: '已完成',
|
||||
running: '执行中',
|
||||
}
|
||||
|
||||
const FAILED_STATUSES = new Set([
|
||||
'failed',
|
||||
'error',
|
||||
'skipped',
|
||||
'timeout',
|
||||
'connection_error',
|
||||
'unknown',
|
||||
'cancelled',
|
||||
])
|
||||
|
||||
export function execStatusLabel(status: string): string {
|
||||
return EXEC_STATUS_LABELS[status] || status
|
||||
}
|
||||
|
||||
/** 与后端 script_execution_store.TERMINAL_STATUSES 一致 */
|
||||
const EXEC_TERMINAL_STATUSES = new Set(['completed', 'failed', 'partial', 'cancelled'])
|
||||
|
||||
export function isExecTerminalStatus(status: string): boolean {
|
||||
return EXEC_TERMINAL_STATUSES.has(status)
|
||||
}
|
||||
|
||||
export function execStatusColor(status: string): string {
|
||||
if (status === 'running') return 'blue'
|
||||
if (status === 'completed') return 'green'
|
||||
if (status === 'failed') return 'red'
|
||||
if (status === 'partial') return 'orange'
|
||||
if (status === 'cancelled') return 'grey'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
/** 优先 phase(长任务 pending),其次 status */
|
||||
export function execServerStatusLabel(status: string, phase?: string): string {
|
||||
const p = (phase || '').trim()
|
||||
const s = (status || '').trim()
|
||||
if (p === 'pending') return EXEC_SERVER_STATUS_LABELS.pending
|
||||
if (p === 'cancelled' || s === 'cancelled') return EXEC_SERVER_STATUS_LABELS.cancelled
|
||||
if (s && EXEC_SERVER_STATUS_LABELS[s]) return EXEC_SERVER_STATUS_LABELS[s]
|
||||
if (p && EXEC_SERVER_STATUS_LABELS[p]) return EXEC_SERVER_STATUS_LABELS[p]
|
||||
return s || p || '—'
|
||||
}
|
||||
|
||||
export function isExecServerPending(phase?: string): boolean {
|
||||
return (phase || '').trim() === 'pending'
|
||||
}
|
||||
|
||||
/** 与后端 _aggregate_from_results 对齐:pending 不算失败 */
|
||||
export function isExecServerFailed(
|
||||
status: string,
|
||||
phase: string | undefined,
|
||||
exitCode: number | null | undefined,
|
||||
): boolean {
|
||||
if (isExecServerPending(phase)) return false
|
||||
if (exitCode != null && exitCode !== 0) return true
|
||||
const s = (status || '').trim().toLowerCase()
|
||||
if (FAILED_STATUSES.has(s)) return true
|
||||
if ((phase || '').trim() === 'cancelled') return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function execServerStatusColor(
|
||||
status: string,
|
||||
phase: string | undefined,
|
||||
exitCode: number | null | undefined,
|
||||
): string {
|
||||
if (isExecServerPending(phase)) return 'blue'
|
||||
if (isExecServerFailed(status, phase, exitCode)) return 'error'
|
||||
if (exitCode === 0 || ['success', 'completed', 'done'].includes((status || '').toLowerCase())) {
|
||||
return 'success'
|
||||
}
|
||||
return 'grey'
|
||||
}
|
||||
|
||||
export function summarizeExecServerResults(
|
||||
results: Record<string, ExecServerResultLike> | undefined,
|
||||
serverIdsJson?: string,
|
||||
): { total: number; success: number; failed: number; pending: number } {
|
||||
let ids: string[] = []
|
||||
if (serverIdsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(serverIdsJson) as unknown
|
||||
if (Array.isArray(parsed)) ids = parsed.map(String)
|
||||
} catch {
|
||||
ids = []
|
||||
}
|
||||
}
|
||||
if (!ids.length && results) {
|
||||
ids = Object.keys(results).filter(k => k !== '_meta' && /^\d+$/.test(k))
|
||||
}
|
||||
|
||||
let success = 0
|
||||
let failed = 0
|
||||
let pending = 0
|
||||
for (const id of ids) {
|
||||
const entry = results?.[id] || {}
|
||||
const status = String(entry.status || '')
|
||||
const phase = entry.phase ? String(entry.phase) : undefined
|
||||
const exitCode = entry.exit_code ?? null
|
||||
if (isExecServerPending(phase)) {
|
||||
pending += 1
|
||||
} else if (isExecServerFailed(status, phase, exitCode)) {
|
||||
failed += 1
|
||||
} else {
|
||||
success += 1
|
||||
}
|
||||
}
|
||||
return { total: ids.length, success, failed, pending }
|
||||
}
|
||||
@@ -22,24 +22,56 @@ STUCK_NO_PROGRESS_SECONDS = 3600 # auto mark stuck if running with no update fo
|
||||
|
||||
TERMINAL_STATUSES = frozenset({"completed", "failed", "partial", "cancelled"})
|
||||
|
||||
_FAILED_SERVER_STATUSES = frozenset({
|
||||
"failed",
|
||||
"error",
|
||||
"skipped",
|
||||
"timeout",
|
||||
"connection_error",
|
||||
"unknown",
|
||||
"cancelled",
|
||||
})
|
||||
|
||||
|
||||
def _is_server_exec_success(entry: Any) -> bool:
|
||||
"""True when a server finished successfully (counts toward progress numerator)."""
|
||||
if not isinstance(entry, dict) or not entry:
|
||||
return False
|
||||
phase = str(entry.get("phase") or "").strip()
|
||||
if phase in ("pending", "cancelled"):
|
||||
return False
|
||||
status = str(entry.get("status") or "").strip().lower()
|
||||
if status in _FAILED_SERVER_STATUSES:
|
||||
return False
|
||||
exit_code = entry.get("exit_code")
|
||||
if exit_code is not None and exit_code != 0:
|
||||
return False
|
||||
if exit_code == 0:
|
||||
return True
|
||||
if status in ("success", "completed", "done"):
|
||||
return True
|
||||
if phase == "done":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def execution_progress_summary(
|
||||
results: dict[str, Any],
|
||||
server_ids: Optional[list] = None,
|
||||
) -> str:
|
||||
"""Return 'done/total' for UI (pending servers count as not done)."""
|
||||
"""Return 'success_count/total' for UI (failures and pending excluded from numerator)."""
|
||||
ids = server_ids
|
||||
if ids is None:
|
||||
ids = [int(k) for k in results if k != "_meta" and str(k).isdigit()]
|
||||
total = len(ids)
|
||||
if not total:
|
||||
return "—"
|
||||
done = sum(
|
||||
success = sum(
|
||||
1
|
||||
for sid in ids
|
||||
if (results.get(str(sid)) or {}).get("phase") != "pending"
|
||||
if _is_server_exec_success(results.get(str(sid)))
|
||||
)
|
||||
return f"{done}/{total}"
|
||||
return f"{success}/{total}"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for script execution progress summary (success/total, failures excluded)."""
|
||||
|
||||
from server.infrastructure.redis.script_execution_store import execution_progress_summary
|
||||
|
||||
|
||||
def test_all_success():
|
||||
ids = [1, 2, 3]
|
||||
results = {
|
||||
"1": {"status": "success", "exit_code": 0},
|
||||
"2": {"status": "success", "exit_code": 0},
|
||||
"3": {"phase": "done", "status": "completed", "exit_code": 0},
|
||||
}
|
||||
assert execution_progress_summary(results, ids) == "3/3"
|
||||
|
||||
|
||||
def test_partial_failure():
|
||||
ids = list(range(1, 367))
|
||||
results = {str(i): {"status": "success", "exit_code": 0} for i in ids[:-1]}
|
||||
results[str(ids[-1])] = {"status": "failed", "exit_code": 1, "stderr": "error"}
|
||||
assert execution_progress_summary(results, ids) == "365/366"
|
||||
|
||||
|
||||
def test_all_failed():
|
||||
ids = [10, 20]
|
||||
results = {
|
||||
"10": {"status": "failed", "exit_code": 1},
|
||||
"20": {"status": "error", "exit_code": -1},
|
||||
}
|
||||
assert execution_progress_summary(results, ids) == "0/2"
|
||||
|
||||
|
||||
def test_pending_not_counted():
|
||||
ids = [1, 2, 3]
|
||||
results = {
|
||||
"1": {"status": "success", "exit_code": 0},
|
||||
"2": {"phase": "pending", "status": "started"},
|
||||
"3": {"status": "failed", "exit_code": 1},
|
||||
}
|
||||
assert execution_progress_summary(results, ids) == "1/3"
|
||||
|
||||
|
||||
def test_skipped_not_counted():
|
||||
ids = [1, 2]
|
||||
results = {
|
||||
"1": {"status": "success", "exit_code": 0},
|
||||
"2": {"status": "skipped", "exit_code": -1, "stderr": "no ssh"},
|
||||
}
|
||||
assert execution_progress_summary(results, ids) == "1/2"
|
||||
|
||||
|
||||
def test_missing_result_not_success():
|
||||
ids = [1, 2, 3]
|
||||
results = {"1": {"status": "success", "exit_code": 0}}
|
||||
assert execution_progress_summary(results, ids) == "1/3"
|
||||
|
||||
|
||||
def test_empty_total():
|
||||
assert execution_progress_summary({}, []) == "—"
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" href="/app/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus</title>
|
||||
<script type="module" crossorigin src="/app/assets/index-HeC-ymfd.js"></script>
|
||||
<script type="module" crossorigin src="/app/assets/index-Hwt84p4T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-BGzIPEZO.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user