feat(BackupX): harden agent cluster backup workflow

Squash merge PR #61
This commit is contained in:
Wu Qing
2026-05-13 14:24:45 +08:00
committed by GitHub
parent 7a6ffd4ddd
commit 7084d47c4b
30 changed files with 1360 additions and 155 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { UserInfo } from '../../services/auth'
import { canManageNodes } from './NodesPage'
import { canManageNodes, formatQueueAge, getNodeHealthView } from './NodesPage'
import type { NodeSummary } from '../../types/nodes'
function user(role: string): UserInfo {
return {
@@ -19,3 +20,58 @@ describe('canManageNodes', () => {
expect(canManageNodes(null)).toBe(false)
})
})
describe('node diagnostics helpers', () => {
it('formats queue age and health status from backend summaries', () => {
const node: NodeSummary = {
id: 1,
name: 'edge-a',
hostname: '',
ipAddress: '',
status: 'online',
isLocal: false,
os: 'linux',
arch: 'amd64',
agentVersion: 'v1',
lastSeen: '2026-05-12T00:00:00Z',
createdAt: '2026-05-12T00:00:00Z',
health: 'degraded',
lastError: 'agent timeout',
runningTasks: 1,
queue: {
pending: 2,
dispatched: 1,
depth: 3,
timeouts: 1,
oldestActiveAgeSeconds: 125,
},
}
expect(formatQueueAge(node.queue?.oldestActiveAgeSeconds)).toBe('2m')
expect(getNodeHealthView(node)).toEqual({
text: '异常',
badgeStatus: 'warning',
tagColor: 'orangered',
tooltip: 'agent timeout',
})
})
it('treats offline nodes as offline even without queue errors', () => {
const node = {
id: 2,
name: 'edge-b',
hostname: '',
ipAddress: '',
status: 'offline',
isLocal: false,
os: '',
arch: '',
agentVersion: '',
lastSeen: '',
createdAt: '',
} satisfies NodeSummary
expect(formatQueueAge(0)).toBe('-')
expect(getNodeHealthView(node).text).toBe('离线')
})
})

View File

@@ -20,6 +20,28 @@ export function canManageNodes(user: UserInfo | null | undefined): boolean {
return isAdmin(user)
}
export function formatQueueAge(seconds?: number): string {
if (!seconds || seconds <= 0) return '-'
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
return `${Math.floor(seconds / 3600)}h`
}
export function getNodeHealthView(node: NodeSummary) {
if (node.status !== 'online' || node.health === 'offline') {
return { text: '离线', badgeStatus: 'default' as const, tagColor: 'gray', tooltip: '节点未在线' }
}
if (node.health === 'degraded' || node.queue?.timeouts || node.lastError) {
return {
text: '异常',
badgeStatus: 'warning' as const,
tagColor: 'orangered',
tooltip: node.lastError || '存在超时或失败的 Agent 命令',
}
}
return { text: '健康', badgeStatus: 'success' as const, tagColor: 'green', tooltip: 'Agent 心跳与队列状态正常' }
}
export default function NodesPage() {
const [nodes, setNodes] = useState<NodeSummary[]>([])
const [loading, setLoading] = useState(false)
@@ -122,10 +144,18 @@ export default function NodesPage() {
),
},
{
title: '状态', dataIndex: 'status', width: 100,
render: (status: string) => status === 'online'
? <Badge status="success" text="在线" />
: <Badge status="default" text="离线" />,
title: '健康', dataIndex: 'health', width: 150,
render: (_: string, record: NodeSummary) => {
const health = getNodeHealthView(record)
return (
<Tooltip content={health.tooltip}>
<Space size={6}>
<Badge status={health.badgeStatus} />
<Tag color={health.tagColor}>{health.text}</Tag>
</Space>
</Tooltip>
)
},
},
{ title: '主机名', dataIndex: 'hostname', render: (v: string) => v || '-' },
{ title: 'IP 地址', dataIndex: 'ipAddress', render: (v: string) => v || '-' },
@@ -138,6 +168,27 @@ export default function NodesPage() {
title: 'Agent 版本', dataIndex: 'agentVersion', width: 140,
render: (v: string) => renderAgentVersion(v, masterVersion),
},
{
title: '队列', dataIndex: 'queue', width: 160,
render: (_: unknown, record: NodeSummary) => {
const queue = record.queue
if (!queue || queue.depth === 0) {
return <Text type="secondary"></Text>
}
return (
<Tooltip content={`pending ${queue.pending} / dispatched ${queue.dispatched} / oldest ${formatQueueAge(queue.oldestActiveAgeSeconds)}`}>
<Space size={4}>
<Tag color="arcoblue"> {queue.depth}</Tag>
{queue.timeouts > 0 && <Tag color="orangered"> {queue.timeouts}</Tag>}
</Space>
</Tooltip>
)
},
},
{
title: '运行中', dataIndex: 'runningTasks', width: 90,
render: (v: number | undefined) => v && v > 0 ? <Tag color="green">{v}</Tag> : <Text type="secondary">0</Text>,
},
{
title: '标签 / 节点池', dataIndex: 'labels', width: 180,
render: (v: string) => {

View File

@@ -14,6 +14,19 @@ export interface NodeSummary {
/** CSV 节点标签;任务的 NodePoolTag 命中这里任一即会被调度到本节点 */
labels?: string
createdAt: string
queue?: NodeQueueSummary
runningTasks?: number
lastError?: string
health?: 'healthy' | 'degraded' | 'offline'
}
export interface NodeQueueSummary {
pending: number
dispatched: number
depth: number
timeouts: number
oldestActiveAt?: string
oldestActiveAgeSeconds?: number
}
export interface DirEntry {