fix(scripts): 执行进度按成功台数计算,失败项快速定位与面板清理

progress 改为 success/total(失败不计入分子);脚本库执行详情中文化、失败筛选与终态任务移出批量状态面板。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-08 05:11:39 +08:00
parent c435413563
commit 5f63fb0a3f
7 changed files with 584 additions and 53 deletions
@@ -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` → 打开失败记录「详情」,确认失败筛选、名称、中文状态、展开输出
+296 -48
View File
@@ -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>
+139
View File
@@ -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:
+58
View File
@@ -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
View File
@@ -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>