@@ -233,7 +260,9 @@ export function ApiKeysPage() {
data={filteredItems}
stripe
pagination={filteredItems.length > 10 ? { pageSize: 10 } : false}
- noDataElement={
}
+ noDataElement={
+
+ }
columns={[
{
title: '名称',
@@ -242,7 +271,10 @@ export function ApiKeysPage() {
render: (value: string, row: ApiKeySummary) => (
{value}
-
+
由 {row.createdBy || '-'} 创建 · {formatDateTime(row.createdAt)}
@@ -252,7 +284,11 @@ export function ApiKeysPage() {
title: '角色',
dataIndex: 'role',
width: 90,
- render: (value: string) =>
{roleLabel(value)},
+ render: (value: string) => (
+
+ {roleLabel(value)}
+
+ ),
},
{
title: 'Key 前缀',
@@ -264,13 +300,15 @@ export function ApiKeysPage() {
title: '最近使用',
dataIndex: 'lastUsedAt',
width: 160,
- render: (value?: string) => value ?
{formatDateTime(value)} : '从未使用',
+ render: (value?: string) =>
+ value ?
{formatDateTime(value)} : '从未使用',
},
{
title: '有效期',
dataIndex: 'expiresAt',
width: 160,
- render: (value?: string) => value ?
{formatDateTime(value)} : '永不过期',
+ render: (value?: string) =>
+ value ?
{formatDateTime(value)} : '永不过期',
},
{
title: '状态',
@@ -278,9 +316,23 @@ export function ApiKeysPage() {
width: 90,
render: (_: boolean, row: ApiKeySummary) => {
const status = resolveApiKeyStatus(row, now)
- if (status === 'expired') return
已过期
- if (status === 'disabled') return
已停用
- return
当前可用
+ if (status === 'expired')
+ return (
+
+ 已过期
+
+ )
+ if (status === 'disabled')
+ return (
+
+ 已停用
+
+ )
+ return (
+
+ 当前可用
+
+ )
},
},
{
@@ -292,7 +344,11 @@ export function ApiKeysPage() {
{expired ? (
-
+
+
+
) : (
- } onClick={openCreate}>新建用户
+ } onClick={() => navigate('/audit?category=user')}>
+ 用户审计
+
+ } onClick={openCreate}>
+ 新建用户
+
- )}
- toolbar={(
+ }
+ toolbar={
- )}
+ }
>
{error ? (
@@ -260,7 +304,9 @@ export function UsersPage() {
data={filteredItems}
stripe
pagination={filteredItems.length > 10 ? { pageSize: 10 } : false}
- noDataElement={
}
+ noDataElement={
+
+ }
columns={[
{
title: '用户',
@@ -283,7 +329,11 @@ export function UsersPage() {
title: '角色',
dataIndex: 'role',
width: 80,
- render: (value: string) =>
{roleLabel(value)},
+ render: (value: string) => (
+
+ {roleLabel(value)}
+
+ ),
},
{
title: '联系方式',
@@ -300,34 +350,75 @@ export function UsersPage() {
title: '状态',
dataIndex: 'disabled',
width: 80,
- render: (disabled: boolean) => disabled
- ?
已停用
- :
已启用,
+ render: (disabled: boolean) =>
+ disabled ? (
+
+ 已停用
+
+ ) : (
+
+ 已启用
+
+ ),
},
{
title: '多因素认证',
dataIndex: 'mfaEnabled',
width: 210,
- render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
-
- {row.twoFactorEnabled ? TOTP : null}
- {row.webAuthnEnabled ? Passkey {row.webAuthnCredentialCount} : null}
- {row.emailOtpEnabled ? 邮件 : null}
- {row.smsOtpEnabled ? 短信 : null}
- {row.trustedDeviceCount > 0 ? 可信设备 {row.trustedDeviceCount} : null}
- {row.twoFactorEnabled ? 恢复码 {row.twoFactorRecoveryCodesRemaining} : null}
-
- ) :
未启用,
+ render: (_: boolean, row: UserSummary) =>
+ row.mfaEnabled ? (
+
+ {row.twoFactorEnabled ? (
+
+ TOTP
+
+ ) : null}
+ {row.webAuthnEnabled ? (
+
+ Passkey {row.webAuthnCredentialCount}
+
+ ) : null}
+ {row.emailOtpEnabled ? (
+
+ 邮件
+
+ ) : null}
+ {row.smsOtpEnabled ? (
+
+ 短信
+
+ ) : null}
+ {row.trustedDeviceCount > 0 ? (
+ 可信设备 {row.trustedDeviceCount}
+ ) : null}
+ {row.twoFactorEnabled ? (
+
+ 恢复码 {row.twoFactorRecoveryCodesRemaining}
+
+ ) : null}
+
+ ) : (
+
未启用
+ ),
},
{
title: '操作',
width: 270,
render: (_: unknown, row: UserSummary) => {
- const deleteDisabled = row.id === user?.id || (row.role === 'admin' && adminCount <= 1)
- const deleteReason = row.id === user?.id ? '不能删除当前登录账号' : '不能删除系统最后一个管理员'
+ const deleteDisabled =
+ row.id === user?.id || (row.role === 'admin' && adminCount <= 1)
+ const deleteReason =
+ row.id === user?.id ? '不能删除当前登录账号' : '不能删除系统最后一个管理员'
return (
- } onClick={() => openEdit(row)}>编辑
+ }
+ onClick={() => openEdit(row)}
+ >
+ 编辑
+
{row.mfaEnabled ? (
- } disabled>删除
+ }
+ disabled
+ >
+ 删除
+
) : (
@@ -397,19 +496,28 @@ export function UsersPage() {
- setDraft({ ...draft, displayName: value })} />
+ setDraft({ ...draft, displayName: value })}
+ />
- setDraft({ ...draft, email: value })} />
+ setDraft({ ...draft, email: value })}
+ />
- setDraft({ ...draft, phone: value })} />
+ setDraft({ ...draft, phone: value })}
+ />
@@ -429,7 +537,9 @@ export function UsersPage() {
onChange={(role) => setDraft({ ...draft, role })}
/>
- {editingSelf ? '当前登录账号不能在此修改自身角色。' : adminRoleDescriptions[draft.role]}
+ {editingSelf
+ ? '当前登录账号不能在此修改自身角色。'
+ : adminRoleDescriptions[draft.role]}
@@ -443,7 +553,9 @@ export function UsersPage() {
/>
{draft.disabled ? '已停用' : '已启用'}
- {editingSelf ?
当前登录账号不能停用自身。 : null}
+ {editingSelf ? (
+
当前登录账号不能停用自身。
+ ) : null}
diff --git a/web/src/pages/audit/AuditLogsPage.tsx b/web/src/pages/audit/AuditLogsPage.tsx
index 942b18d..31e02fb 100644
--- a/web/src/pages/audit/AuditLogsPage.tsx
+++ b/web/src/pages/audit/AuditLogsPage.tsx
@@ -1,4 +1,16 @@
-import { Button, DatePicker, Input, InputNumber, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
+import {
+ Button,
+ DatePicker,
+ Input,
+ InputNumber,
+ Message,
+ PageHeader,
+ Select,
+ Space,
+ Table,
+ Tag,
+ Typography,
+} from '@arco-design/web-react'
import type { ColumnProps } from '@arco-design/web-react/es/Table'
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
@@ -76,9 +88,7 @@ const columns: ColumnProps
[] = [
title: '分类',
dataIndex: 'category',
width: 100,
- render: (_, record) => (
- {categoryLabels[record.category] ?? record.category}
- ),
+ render: (_, record) => {categoryLabels[record.category] ?? record.category},
},
{
title: '操作',
@@ -129,27 +139,30 @@ export function AuditLogsPage() {
const [retentionDays, setRetentionDays] = useState(0)
const [savingRetention, setSavingRetention] = useState(false)
- const fetchData = useCallback(async (currentPage: number) => {
- setLoading(true)
- try {
- const result = await listAuditLogs({
- category: category || undefined,
- username: username.trim() || undefined,
- keyword: keyword.trim() || undefined,
- dateFrom: dateRange?.[0] ? new Date(dateRange[0]).toISOString() : undefined,
- dateTo: dateRange?.[1] ? new Date(dateRange[1]).toISOString() : undefined,
- limit: PAGE_SIZE,
- offset: (currentPage - 1) * PAGE_SIZE,
- })
- setLogs(result.items ?? [])
- setTotal(result.total ?? 0)
- setError('')
- } catch (loadError) {
- setError(resolveErrorMessage(loadError, '加载审计日志失败'))
- } finally {
- setLoading(false)
- }
- }, [category, username, keyword, dateRange])
+ const fetchData = useCallback(
+ async (currentPage: number) => {
+ setLoading(true)
+ try {
+ const result = await listAuditLogs({
+ category: category || undefined,
+ username: username.trim() || undefined,
+ keyword: keyword.trim() || undefined,
+ dateFrom: dateRange?.[0] ? new Date(dateRange[0]).toISOString() : undefined,
+ dateTo: dateRange?.[1] ? new Date(dateRange[1]).toISOString() : undefined,
+ limit: PAGE_SIZE,
+ offset: (currentPage - 1) * PAGE_SIZE,
+ })
+ setLogs(result.items ?? [])
+ setTotal(result.total ?? 0)
+ setError('')
+ } catch (loadError) {
+ setError(resolveErrorMessage(loadError, '加载审计日志失败'))
+ } finally {
+ setLoading(false)
+ }
+ },
+ [category, username, keyword, dateRange],
+ )
useEffect(() => {
void fetchData(page)
@@ -172,7 +185,9 @@ export function AuditLogsPage() {
setSavingRetention(true)
try {
await updateSettings({ audit_retention_days: String(retentionDays) })
- Message.success(retentionDays > 0 ? `已设置:保留最近 ${retentionDays} 天` : '已设置:永久保留')
+ Message.success(
+ retentionDays > 0 ? `已设置:保留最近 ${retentionDays} 天` : '已设置:永久保留',
+ )
} catch (e) {
Message.error(resolveErrorMessage(e, '保存保留期失败'))
} finally {
@@ -228,7 +243,12 @@ export function AuditLogsPage() {
suffix="天"
placeholder="0=永久"
/>
- void handleSaveRetention()}>
+ void handleSaveRetention()}
+ >
保存
@@ -253,23 +273,42 @@ export function AuditLogsPage() {
value={username}
placeholder="用户名"
onChange={setUsername}
- onPressEnter={() => { setPage(1); void fetchData(1) }}
+ onPressEnter={() => {
+ setPage(1)
+ void fetchData(1)
+ }}
/>
{ setPage(1); void fetchData(1) }}
+ onPressEnter={() => {
+ setPage(1)
+ void fetchData(1)
+ }}
/>
{ setDateRange(v as string[] | null); setPage(1) }}
+ onChange={(v) => {
+ setDateRange(v as string[] | null)
+ setPage(1)
+ }}
/>
- { setPage(1); void fetchData(1) }}>查询
+ {
+ setPage(1)
+ void fetchData(1)
+ }}
+ >
+ 查询
+
重置
- void handleExport()}>导出 CSV
+ void handleExport()}>
+ 导出 CSV
+
[{ label: '全部任务', value: 0 }, ...tasks.map((item) => ({ label: item.name, value: item.id }))],
+ () => [
+ { label: '全部任务', value: 0 },
+ ...tasks.map((item) => ({ label: item.name, value: item.id })),
+ ],
[tasks],
)
@@ -93,13 +106,32 @@ export function BackupRecordsPage() {
title: '任务 / 状态',
dataIndex: 'taskName',
render: (_: unknown, record: BackupRecordSummary) => {
- const statusLabel = record.status === 'success' ? '成功' : record.status === 'failed' ? '失败' : record.status === 'running' ? '执行中' : record.status
+ const statusLabel =
+ record.status === 'success'
+ ? '成功'
+ : record.status === 'failed'
+ ? '失败'
+ : record.status === 'running'
+ ? '执行中'
+ : record.status
return (
{record.taskName}
- {statusLabel ? {statusLabel} : -}
- {record.storageTargetName ? {record.storageTargetName} : -}
+ {statusLabel ? (
+
+ {statusLabel}
+
+ ) : (
+ -
+ )}
+ {record.storageTargetName ? (
+
+ {record.storageTargetName}
+
+ ) : (
+ -
+ )}
)
@@ -112,9 +144,21 @@ export function BackupRecordsPage() {
{record.fileName || '-'}
- {record.locked && 已锁定}
- {record.backupKind === 'differential' && 差异}
- {record.backupKind === 'repository' && CDC}
+ {record.locked && (
+
+ 已锁定
+
+ )}
+ {record.backupKind === 'differential' && (
+
+ 差异
+
+ )}
+ {record.backupKind === 'repository' && (
+
+ CDC
+
+ )}
{formatBytes(record.fileSize)}
{record.checksum && (
@@ -151,7 +195,11 @@ export function BackupRecordsPage() {
width: 180,
render: (_: unknown, record: BackupRecordSummary) => (
- updateSearchParam('recordId', String(record.id))}>
+ updateSearchParam('recordId', String(record.id))}
+ >
查看日志
{record.status === 'success' && (
@@ -183,30 +231,57 @@ export function BackupRecordsPage() {
任务筛选
-
状态筛选
- updateSearchParam('status', value ? String(value) : undefined)} />
+ updateSearchParam('status', value ? String(value) : undefined)}
+ />
- {
- const next = new URLSearchParams(searchParams)
- next.delete('taskId')
- next.delete('status')
- setSearchParams(next, { replace: true })
- }}>
+ {
+ const next = new URLSearchParams(searchParams)
+ next.delete('taskId')
+ next.delete('status')
+ setSearchParams(next, { replace: true })
+ }}
+ >
重置筛选
- {error ? {error} : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
{records.length === 0 && !loading ? (
) : (
- } />
+ }
+ />
)}
diff --git a/web/src/pages/backup-tasks/BackupTasksPage.tsx b/web/src/pages/backup-tasks/BackupTasksPage.tsx
index 419425b..19e972d 100644
--- a/web/src/pages/backup-tasks/BackupTasksPage.tsx
+++ b/web/src/pages/backup-tasks/BackupTasksPage.tsx
@@ -1,14 +1,54 @@
-import { Button, Card, Empty, Message, Modal, PageHeader, Select, Space, Table, Tag, Typography, Upload } from '@arco-design/web-react'
+import {
+ Button,
+ Card,
+ Empty,
+ Message,
+ Modal,
+ PageHeader,
+ Select,
+ Space,
+ Table,
+ Tag,
+ Typography,
+ Upload,
+} from '@arco-design/web-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { BackupTaskDetailDrawer } from '../../components/backup-tasks/BackupTaskDetailDrawer'
import { BackupTaskFormDrawer } from '../../components/backup-tasks/BackupTaskFormDrawer'
import { TaskDependencyGraph } from '../../components/backup-tasks/TaskDependencyGraph'
-import { getBackupTaskStatusColor, getBackupTaskStatusLabel, getBackupTaskTypeLabel } from '../../components/backup-tasks/field-config'
-import { batchDeleteTasks, batchRunTasks, batchToggleTasks, createBackupTask, deleteBackupTask, exportBackupTasks, getBackupTask, importBackupTasks, listBackupTasks, runBackupTask, toggleBackupTask, updateBackupTask, type TaskImportResult } from '../../services/backup-tasks'
+import {
+ getBackupTaskStatusColor,
+ getBackupTaskStatusLabel,
+ getBackupTaskTypeLabel,
+} from '../../components/backup-tasks/field-config'
+import {
+ batchDeleteTasks,
+ batchRunTasks,
+ batchToggleTasks,
+ createBackupTask,
+ deleteBackupTask,
+ exportBackupTasks,
+ getBackupTask,
+ importBackupTasks,
+ listBackupTasks,
+ runBackupTask,
+ toggleBackupTask,
+ updateBackupTask,
+ type TaskImportResult,
+} from '../../services/backup-tasks'
import { listNodes } from '../../services/nodes'
-import { createStorageTarget, listStorageTargets, startGoogleDriveAuth, testStorageTarget } from '../../services/storage-targets'
-import type { BackupTaskDetail, BackupTaskPayload, BackupTaskSummary } from '../../types/backup-tasks'
+import {
+ createStorageTarget,
+ listStorageTargets,
+ startGoogleDriveAuth,
+ testStorageTarget,
+} from '../../services/storage-targets'
+import type {
+ BackupTaskDetail,
+ BackupTaskPayload,
+ BackupTaskSummary,
+} from '../../types/backup-tasks'
import type { NodeSummary } from '../../types/nodes'
import type { StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
import { useAuthStore } from '../../stores/auth'
@@ -36,14 +76,20 @@ export function BackupTasksPage() {
const [batchLoading, setBatchLoading] = useState(false)
const [importResults, setImportResults] = useState(null)
- const enabledStorageTargets = useMemo(() => storageTargets.filter((item) => item.enabled), [storageTargets])
+ const enabledStorageTargets = useMemo(
+ () => storageTargets.filter((item) => item.enabled),
+ [storageTargets],
+ )
// 从全量任务中提取所有用过的标签,作为筛选器选项
const availableTags = useMemo(() => {
const set = new Set()
for (const task of tasks) {
if (!task.tags) continue
- for (const tag of task.tags.split(',').map((t) => t.trim()).filter(Boolean)) {
+ for (const tag of task.tags
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean)) {
set.add(tag)
}
}
@@ -54,7 +100,10 @@ export function BackupTasksPage() {
const filteredTasks = useMemo(() => {
if (tagFilter.length === 0) return tasks
return tasks.filter((task) => {
- const taskTags = (task.tags ?? '').split(',').map((t) => t.trim()).filter(Boolean)
+ const taskTags = (task.tags ?? '')
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean)
return tagFilter.every((filter) => taskTags.includes(filter))
})
}, [tasks, tagFilter])
@@ -62,7 +111,11 @@ export function BackupTasksPage() {
const loadData = useCallback(async () => {
setLoading(true)
try {
- const [taskList, targetList, nodeList] = await Promise.all([listBackupTasks(), listStorageTargets(), listNodes()])
+ const [taskList, targetList, nodeList] = await Promise.all([
+ listBackupTasks(),
+ listStorageTargets(),
+ listNodes(),
+ ])
setTasks(taskList)
setStorageTargets(targetList)
setNodes(nodeList)
@@ -166,7 +219,9 @@ export function BackupTasksPage() {
async function handleExport() {
try {
await exportBackupTasks(selectedIds.length > 0 ? selectedIds : undefined)
- Message.success(selectedIds.length > 0 ? `已导出 ${selectedIds.length} 个任务` : '已导出全部任务')
+ Message.success(
+ selectedIds.length > 0 ? `已导出 ${selectedIds.length} 个任务` : '已导出全部任务',
+ )
} catch (e) {
Message.error(resolveErrorMessage(e, '导出失败'))
}
@@ -181,7 +236,9 @@ export function BackupTasksPage() {
setImportResults(results)
const succ = results.filter((r) => r.success && !r.skipped).length
const skipped = results.filter((r) => r.skipped).length
- Message.success(`导入完成:创建 ${succ} / 跳过 ${skipped} / 失败 ${results.length - succ - skipped}`)
+ Message.success(
+ `导入完成:创建 ${succ} / 跳过 ${skipped} / 失败 ${results.length - succ - skipped}`,
+ )
await loadData()
} catch (e) {
Message.error(resolveErrorMessage(e, '导入失败'))
@@ -190,14 +247,15 @@ export function BackupTasksPage() {
}
// 批量操作辅助
- async function runBatch(
- action: 'run' | 'enable' | 'disable' | 'delete',
- ) {
+ async function runBatch(action: 'run' | 'enable' | 'disable' | 'delete') {
if (selectedIds.length === 0) {
Message.info('请先选择要操作的任务')
return
}
- if (action === 'delete' && !window.confirm(`确定删除 ${selectedIds.length} 个任务?操作不可撤销。`)) {
+ if (
+ action === 'delete' &&
+ !window.confirm(`确定删除 ${selectedIds.length} 个任务?操作不可撤销。`)
+ ) {
return
}
setBatchLoading(true)
@@ -263,9 +321,15 @@ export function BackupTasksPage() {
{record.name}
- {getBackupTaskTypeLabel(record.type) && {getBackupTaskTypeLabel(record.type)}}
+ {getBackupTaskTypeLabel(record.type) && (
+
+ {getBackupTaskTypeLabel(record.type)}
+
+ )}
{record.enabled !== undefined && (
- {record.enabled ? '已启用' : '已停用'}
+
+ {record.enabled ? '已启用' : '已停用'}
+
)}
@@ -280,12 +344,19 @@ export function BackupTasksPage() {
title: '存储目标',
dataIndex: 'storageTargetNames',
render: (_: unknown, record: BackupTaskSummary) => {
- const names = record.storageTargetNames?.length > 0 ? record.storageTargetNames : record.storageTargetName ? [record.storageTargetName] : []
+ const names =
+ record.storageTargetNames?.length > 0
+ ? record.storageTargetNames
+ : record.storageTargetName
+ ? [record.storageTargetName]
+ : []
if (names.length === 0) return '-'
return (
{names.map((name, i) => (
- {name}
+
+ {name}
+
))}
)
@@ -294,17 +365,25 @@ export function BackupTasksPage() {
{
title: '策略',
dataIndex: 'retentionDays',
- render: (_: unknown, record: BackupTaskSummary) => `${record.retentionDays} 天 / ${record.maxBackups} 份`,
+ render: (_: unknown, record: BackupTaskSummary) =>
+ `${record.retentionDays} 天 / ${record.maxBackups} 份`,
},
{
title: '标签',
dataIndex: 'tags',
render: (value: string) => {
- const items = (value ?? '').split(',').map((t) => t.trim()).filter(Boolean)
+ const items = (value ?? '')
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean)
if (items.length === 0) return -
return (
- {items.map((tag) => {tag})}
+ {items.map((tag) => (
+
+ {tag}
+
+ ))}
)
},
@@ -315,16 +394,35 @@ export function BackupTasksPage() {
render: (value: number, record: BackupTaskSummary) => {
if (value <= 0) return 未配置
// 简单着色:仅根据是否启用验证/SLA 显示徽章(实时 SLA 违约见 Dashboard)
- const bits = [RPO {value}h]
- if (record.verifyEnabled) bits.push(定时验证)
- return {bits}
+ const bits = [
+
+ RPO {value}h
+ ,
+ ]
+ if (record.verifyEnabled)
+ bits.push(
+
+ 定时验证
+ ,
+ )
+ return (
+
+ {bits}
+
+ )
},
},
{
title: '最近状态',
render: (value: BackupTaskSummary['lastStatus']) => {
const label = getBackupTaskStatusLabel(value)
- return label ? {label} : -
+ return label ? (
+
+ {label}
+
+ ) : (
+ -
+ )
},
},
{
@@ -342,12 +440,22 @@ export function BackupTasksPage() {
详情
{writable && (
- void openEdit(record.id)} loading={submitting && editingTask?.id === record.id}>
+ void openEdit(record.id)}
+ loading={submitting && editingTask?.id === record.id}
+ >
编辑
)}
{writable && (
- void handleRun(record)}>
+ void handleRun(record)}
+ >
立即执行
)}
@@ -357,7 +465,12 @@ export function BackupTasksPage() {
)}
{writable && (
- void handleDelete(record)}>
+ void handleDelete(record)}
+ >
删除
)}
@@ -402,7 +515,11 @@ export function BackupTasksPage() {
}
/>
- {error ? {error} : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
{enabledStorageTargets.length === 0 ? (
@@ -414,7 +531,9 @@ export function BackupTasksPage() {
{availableTags.length > 0 && (
- 按标签筛选:
+
+ 按标签筛选:
+
已选 {selectedIds.length} 个任务:
- void runBatch('run')}>批量执行
- void runBatch('enable')}>批量启用
- void runBatch('disable')}>批量停用
- void runBatch('delete')}>批量删除
- setSelectedIds([])}>取消
+ void runBatch('run')}
+ >
+ 批量执行
+
+ void runBatch('enable')}>
+ 批量启用
+
+ void runBatch('disable')}>
+ 批量停用
+
+ void runBatch('delete')}
+ >
+ 批量删除
+
+ setSelectedIds([])}>
+ 取消
+
)}
@@ -457,12 +596,22 @@ export function BackupTasksPage() {
data={filteredTasks}
pagination={{ pageSize: 10 }}
stripe
- noDataElement={ 0 ? "当前筛选下无任务" : "暂无备份任务,请先点击右上角创建任务"} />}
- rowSelection={writable ? {
- type: 'checkbox',
- selectedRowKeys: selectedIds,
- onChange: (keys) => setSelectedIds(keys.map((k) => Number(k))),
- } : undefined}
+ noDataElement={
+ 0 ? '当前筛选下无任务' : '暂无备份任务,请先点击右上角创建任务'
+ }
+ />
+ }
+ rowSelection={
+ writable
+ ? {
+ type: 'checkbox',
+ selectedRowKeys: selectedIds,
+ onChange: (keys) => setSelectedIds(keys.map((k) => Number(k))),
+ }
+ : undefined
+ }
/>
@@ -509,12 +658,24 @@ export function BackupTasksPage() {
size="small"
columns={[
{ title: '任务名', dataIndex: 'name' },
- { title: '状态', render: (_: unknown, r: TaskImportResult) => (
- r.skipped ? 跳过
- : r.success ? 创建
- : 失败
- )},
- { title: 'ID', dataIndex: 'taskId', render: (v?: number) => v ? `#${v}` : '-' },
+ {
+ title: '状态',
+ render: (_: unknown, r: TaskImportResult) =>
+ r.skipped ? (
+
+ 跳过
+
+ ) : r.success ? (
+
+ 创建
+
+ ) : (
+
+ 失败
+
+ ),
+ },
+ { title: 'ID', dataIndex: 'taskId', render: (v?: number) => (v ? `#${v}` : '-') },
{ title: '说明', dataIndex: 'error', render: (v?: string) => v || '-' },
]}
/>
diff --git a/web/src/pages/dashboard/DashboardPage.tsx b/web/src/pages/dashboard/DashboardPage.tsx
index 1c578af..621bfc3 100644
--- a/web/src/pages/dashboard/DashboardPage.tsx
+++ b/web/src/pages/dashboard/DashboardPage.tsx
@@ -1,19 +1,59 @@
-import { Alert, Avatar, Card, Empty, Grid, PageHeader, Space, Table, Tag, Typography } from '@arco-design/web-react'
-import { IconCheckCircle, IconDesktop, IconHistory, IconSafe, IconSave, IconStorage } from '../../components/icons'
+import {
+ Alert,
+ Avatar,
+ Card,
+ Empty,
+ Grid,
+ PageHeader,
+ Space,
+ Table,
+ Tag,
+ Typography,
+} from '@arco-design/web-react'
+import {
+ IconCheckCircle,
+ IconDesktop,
+ IconHistory,
+ IconSafe,
+ IconSave,
+ IconStorage,
+} from '../../components/icons'
import ReactEChartsCore from 'echarts-for-react/lib/core'
import * as echarts from 'echarts/core'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { fetchDashboardBreakdown, fetchDashboardCluster, fetchDashboardNodePerformance, fetchDashboardSLA, fetchDashboardStats, fetchDashboardTimeline } from '../../services/dashboard'
+import {
+ fetchDashboardBreakdown,
+ fetchDashboardCluster,
+ fetchDashboardNodePerformance,
+ fetchDashboardSLA,
+ fetchDashboardStats,
+ fetchDashboardTimeline,
+} from '../../services/dashboard'
import { useEventStream } from '../../hooks/useEventStream'
import { useAuthStore } from '../../stores/auth'
-import type { BackupTimelinePoint, BreakdownStats, ClusterOverview, DashboardStats, NodePerformance, SLAComplianceReport } from '../../types/dashboard'
+import type {
+ BackupTimelinePoint,
+ BreakdownStats,
+ ClusterOverview,
+ DashboardStats,
+ NodePerformance,
+ SLAComplianceReport,
+} from '../../types/dashboard'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatPercent } from '../../utils/format'
-echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
+echarts.use([
+ BarChart,
+ LineChart,
+ PieChart,
+ GridComponent,
+ TooltipComponent,
+ LegendComponent,
+ CanvasRenderer,
+])
const { Row, Col } = Grid
@@ -32,7 +72,14 @@ export function DashboardPage() {
const reload = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
- const [statsResult, timelineResult, slaResult, clusterResult, breakdownResult, nodePerfResult] = await Promise.all([
+ const [
+ statsResult,
+ timelineResult,
+ slaResult,
+ clusterResult,
+ breakdownResult,
+ nodePerfResult,
+ ] = await Promise.all([
fetchDashboardStats(),
fetchDashboardTimeline(30),
fetchDashboardSLA(),
@@ -60,85 +107,131 @@ export function DashboardPage() {
// 订阅实时事件:备份完成 / 恢复完成 / SLA 违约 / 存储健康变化时自动刷新 Dashboard。
// 只关心会影响 Dashboard 显示的事件,避免无关事件造成频繁重渲染。
- useEventStream(
- () => {
- // debounce 500ms:短时间多条事件合并一次刷新
- void reload(false)
- },
- ['backup_success', 'backup_failed', 'restore_success', 'restore_failed', 'verify_failed', 'sla_violation', 'storage_unhealthy', 'storage_capacity_warning'],
- )
+ useEventStream(() => {
+ // debounce 500ms:短时间多条事件合并一次刷新
+ void reload(false)
+ }, [
+ 'backup_success',
+ 'backup_failed',
+ 'restore_success',
+ 'restore_failed',
+ 'verify_failed',
+ 'sla_violation',
+ 'storage_unhealthy',
+ 'storage_capacity_warning',
+ ])
const cards = useMemo(
() => [
- { label: '备份任务', value: stats?.totalTasks ?? 0, helper: `${stats?.enabledTasks ?? 0} 个已启用`, icon: , color: 'var(--color-primary-6)', bg: 'var(--color-primary-1)' },
- { label: '成功率', value: formatPercent(stats?.successRate), helper: '最近 30 天', icon: , color: 'var(--color-success-6)', bg: 'var(--color-success-1)' },
- { label: '总备份量', value: formatBytes(stats?.totalBackupBytes), helper: '历史累计', icon: , color: 'var(--color-purple-6)', bg: 'var(--color-purple-1)' },
- { label: '最近备份', value: stats?.totalRecords ?? 0, helper: formatDateTime(stats?.lastBackupAt), icon: , color: 'var(--color-warning-6)', bg: 'var(--color-warning-1)' },
+ {
+ label: '备份任务',
+ value: stats?.totalTasks ?? 0,
+ helper: `${stats?.enabledTasks ?? 0} 个已启用`,
+ icon: ,
+ color: 'var(--color-primary-6)',
+ bg: 'var(--color-primary-1)',
+ },
+ {
+ label: '成功率',
+ value: formatPercent(stats?.successRate),
+ helper: '最近 30 天',
+ icon: ,
+ color: 'var(--color-success-6)',
+ bg: 'var(--color-success-1)',
+ },
+ {
+ label: '总备份量',
+ value: formatBytes(stats?.totalBackupBytes),
+ helper: '历史累计',
+ icon: ,
+ color: 'var(--color-purple-6)',
+ bg: 'var(--color-purple-1)',
+ },
+ {
+ label: '最近备份',
+ value: stats?.totalRecords ?? 0,
+ helper: formatDateTime(stats?.lastBackupAt),
+ icon: ,
+ color: 'var(--color-warning-6)',
+ bg: 'var(--color-warning-1)',
+ },
],
[stats],
)
- const timelineChartOption = useMemo(() => ({
- tooltip: { trigger: 'axis' as const },
- legend: { data: ['成功', '失败'], bottom: 0 },
- grid: { left: 40, right: 20, top: 40, bottom: 40 },
- xAxis: {
- type: 'category' as const,
- data: timeline.map((p) => p.date),
- axisLabel: { rotate: 45, fontSize: 11, color: 'var(--color-text-3)' },
- axisLine: { lineStyle: { color: 'var(--color-border-2)' } },
- axisTick: { show: false },
- },
- yAxis: {
- type: 'value' as const,
- minInterval: 1,
- axisLabel: { color: 'var(--color-text-3)' },
- splitLine: { lineStyle: { type: 'dashed', color: 'var(--color-border-2)' } },
- },
- series: [
- {
- name: '成功',
- type: 'line' as const,
- smooth: true,
- data: timeline.map((p) => p.success),
- itemStyle: { color: 'var(--color-primary-6)' },
- areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: 'rgba(52,145,250,0.25)' },
- { offset: 1, color: 'rgba(52,145,250,0.02)' },
- ]) },
- symbolSize: 6,
+ const timelineChartOption = useMemo(
+ () => ({
+ tooltip: { trigger: 'axis' as const },
+ legend: { data: ['成功', '失败'], bottom: 0 },
+ grid: { left: 40, right: 20, top: 40, bottom: 40 },
+ xAxis: {
+ type: 'category' as const,
+ data: timeline.map((p) => p.date),
+ axisLabel: { rotate: 45, fontSize: 11, color: 'var(--color-text-3)' },
+ axisLine: { lineStyle: { color: 'var(--color-border-2)' } },
+ axisTick: { show: false },
},
- {
- name: '失败',
- type: 'line' as const,
- smooth: true,
- data: timeline.map((p) => p.failed),
- itemStyle: { color: 'var(--color-danger-light-4)' },
- areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: 'rgba(245,63,63,0.15)' },
- { offset: 1, color: 'rgba(245,63,63,0.01)' },
- ]) },
- symbolSize: 6,
+ yAxis: {
+ type: 'value' as const,
+ minInterval: 1,
+ axisLabel: { color: 'var(--color-text-3)' },
+ splitLine: { lineStyle: { type: 'dashed', color: 'var(--color-border-2)' } },
},
- ],
- }), [timeline])
+ series: [
+ {
+ name: '成功',
+ type: 'line' as const,
+ smooth: true,
+ data: timeline.map((p) => p.success),
+ itemStyle: { color: 'var(--color-primary-6)' },
+ areaStyle: {
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+ { offset: 0, color: 'rgba(52,145,250,0.25)' },
+ { offset: 1, color: 'rgba(52,145,250,0.02)' },
+ ]),
+ },
+ symbolSize: 6,
+ },
+ {
+ name: '失败',
+ type: 'line' as const,
+ smooth: true,
+ data: timeline.map((p) => p.failed),
+ itemStyle: { color: 'var(--color-danger-light-4)' },
+ areaStyle: {
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+ { offset: 0, color: 'rgba(245,63,63,0.15)' },
+ { offset: 1, color: 'rgba(245,63,63,0.01)' },
+ ]),
+ },
+ symbolSize: 6,
+ },
+ ],
+ }),
+ [timeline],
+ )
// 任务类型分布(饼图)
const typeChartOption = useMemo(() => {
- const data = (breakdown?.byType ?? []).map((item) => ({ name: item.label, value: item.count ?? 0 }))
+ const data = (breakdown?.byType ?? []).map((item) => ({
+ name: item.label,
+ value: item.count ?? 0,
+ }))
return {
tooltip: { trigger: 'item' as const },
legend: { bottom: 0, type: 'scroll' as const },
- series: [{
- type: 'pie' as const,
- radius: ['45%', '68%'],
- avoidLabelOverlap: false,
- itemStyle: { borderRadius: 6, borderColor: 'var(--color-bg-2)', borderWidth: 2 },
- label: { show: false },
- emphasis: { label: { show: true, fontSize: 13, fontWeight: 'bold' } },
- data,
- color: ['#165DFF', '#14C9C9', '#FADC19', '#FF7D00', '#722ED1', '#F53F3F'],
- }],
+ series: [
+ {
+ type: 'pie' as const,
+ radius: ['45%', '68%'],
+ avoidLabelOverlap: false,
+ itemStyle: { borderRadius: 6, borderColor: 'var(--color-bg-2)', borderWidth: 2 },
+ label: { show: false },
+ emphasis: { label: { show: true, fontSize: 13, fontWeight: 'bold' } },
+ data,
+ color: ['#165DFF', '#14C9C9', '#FADC19', '#FF7D00', '#722ED1', '#F53F3F'],
+ },
+ ],
}
}, [breakdown])
@@ -161,12 +254,14 @@ export function DashboardPage() {
axisLabel: { color: 'var(--color-text-3)' },
splitLine: { lineStyle: { type: 'dashed', color: 'var(--color-border-2)' } },
},
- series: [{
- type: 'bar' as const,
- data: items.map((i) => i.count ?? 0),
- itemStyle: { color: 'var(--color-primary-6)', borderRadius: [4, 4, 0, 0] },
- barMaxWidth: 40,
- }],
+ series: [
+ {
+ type: 'bar' as const,
+ data: items.map((i) => i.count ?? 0),
+ itemStyle: { color: 'var(--color-primary-6)', borderRadius: [4, 4, 0, 0] },
+ barMaxWidth: 40,
+ },
+ ],
}
}, [breakdown])
@@ -212,15 +307,23 @@ export function DashboardPage() {
-
+
{card.icon}
- {card.label}
+
+ {card.label}
+
{card.value}
- {card.helper}
+
+ {card.helper}
+
@@ -232,9 +335,20 @@ export function DashboardPage() {
{timeline.length > 0 ? (
-
+
) : (
-
+
暂无数据
)}
@@ -243,9 +357,20 @@ export function DashboardPage() {
{(stats?.storageUsage ?? []).length > 0 ? (
-
+
) : (
-
+
暂无存储数据
)}
@@ -258,9 +383,20 @@ export function DashboardPage() {
{(breakdown.byType ?? []).length > 0 ? (
-
+
) : (
-
+
暂无任务
)}
@@ -269,9 +405,20 @@ export function DashboardPage() {
{(breakdown.byNode ?? []).length > 0 ? (
-
+
) : (
-
+
暂无数据
)}
@@ -281,36 +428,70 @@ export function DashboardPage() {
) : null}
{cluster && cluster.totalNodes > 0 ? (
-
-
- 集群概览
- Master {cluster.masterVersion || '-'}
-
- }>
+
+
+ 集群概览
+ Master {cluster.masterVersion || '-'}
+
+ }
+ >
- 节点总数
- {cluster.totalNodes}
+
+ 节点总数
+
+
+ {cluster.totalNodes}
+
- 在线
- {cluster.onlineNodes}
+
+ 在线
+
+
+ {cluster.onlineNodes}
+
- 离线
- 0 ? 'var(--color-danger-6)' : undefined }}>{cluster.offlineNodes}
+
+ 离线
+
+ 0 ? 'var(--color-danger-6)' : undefined,
+ }}
+ >
+ {cluster.offlineNodes}
+
- Agent 过期
- 0 ? 'var(--color-warning-6)' : undefined }}>{cluster.outdatedAgents}
+
+ Agent 过期
+
+ 0 ? 'var(--color-warning-6)' : undefined,
+ }}
+ >
+ {cluster.outdatedAgents}
+
@@ -321,20 +502,59 @@ export function DashboardPage() {
pagination={false}
data={cluster.nodes}
columns={[
- { title: '节点', dataIndex: 'name', render: (v: string, row) => (
-
- {v}
- {row.hostname || '-'}
-
- )},
- { title: '状态', dataIndex: 'status', render: (s: string) => {s === 'online' ? '在线' : '离线'} },
- { title: '版本', dataIndex: 'agentVersion', render: (v: string, row) => {
- const color = row.versionStatus === 'outdated' ? 'orange' : row.versionStatus === 'unknown' ? 'gray' : 'arcoblue'
- const label = row.versionStatus === 'outdated' ? '过期' : row.versionStatus === 'unknown' ? '未知' : '当前'
- return {v || '-'}{label}
- }},
+ {
+ title: '节点',
+ dataIndex: 'name',
+ render: (v: string, row) => (
+
+ {v}
+
+ {row.hostname || '-'}
+
+
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ render: (s: string) => (
+
+ {s === 'online' ? '在线' : '离线'}
+
+ ),
+ },
+ {
+ title: '版本',
+ dataIndex: 'agentVersion',
+ render: (v: string, row) => {
+ const color =
+ row.versionStatus === 'outdated'
+ ? 'orange'
+ : row.versionStatus === 'unknown'
+ ? 'gray'
+ : 'arcoblue'
+ const label =
+ row.versionStatus === 'outdated'
+ ? '过期'
+ : row.versionStatus === 'unknown'
+ ? '未知'
+ : '当前'
+ return (
+
+ {v || '-'}
+
+ {label}
+
+
+ )
+ },
+ },
{ title: '任务', dataIndex: 'taskCount', render: (v: number) => `${v} 个` },
- { title: '最近心跳', dataIndex: 'lastSeen', render: (v: string) => formatDateTime(v) },
+ {
+ title: '最近心跳',
+ dataIndex: 'lastSeen',
+ render: (v: string) => formatDateTime(v),
+ },
]}
/>
@@ -348,85 +568,161 @@ export function DashboardPage() {
pagination={false}
data={nodePerf.filter((n) => n.totalRuns > 0)}
columns={[
- { title: '节点', render: (_: unknown, r: NodePerformance) => (
-
- {r.nodeName}
- {r.isLocal && Master}
-
- )},
+ {
+ title: '节点',
+ render: (_: unknown, r: NodePerformance) => (
+
+ {r.nodeName}
+ {r.isLocal && (
+
+ Master
+
+ )}
+
+ ),
+ },
{ title: '执行次数', dataIndex: 'totalRuns', render: (v: number) => `${v}` },
- { title: '成功 / 失败', render: (_: unknown, r: NodePerformance) => (
-
- {r.successRuns}
- /
- 0 ? 'var(--color-danger-6)' : undefined }}>{r.failedRuns}
-
- )},
- { title: '成功率', dataIndex: 'successRate', render: (v: number) => {
- const rate = v * 100
- const color = rate >= 95 ? 'var(--color-success-6)' : rate >= 80 ? 'var(--color-warning-6)' : 'var(--color-danger-6)'
- return {rate.toFixed(1)}%
- }},
+ {
+ title: '成功 / 失败',
+ render: (_: unknown, r: NodePerformance) => (
+
+
+ {r.successRuns}
+
+ /
+ 0 ? 'var(--color-danger-6)' : undefined }}
+ >
+ {r.failedRuns}
+
+
+ ),
+ },
+ {
+ title: '成功率',
+ dataIndex: 'successRate',
+ render: (v: number) => {
+ const rate = v * 100
+ const color =
+ rate >= 95
+ ? 'var(--color-success-6)'
+ : rate >= 80
+ ? 'var(--color-warning-6)'
+ : 'var(--color-danger-6)'
+ return {rate.toFixed(1)}%
+ },
+ },
{ title: '备份总量', dataIndex: 'totalBytes', render: (v: number) => formatBytes(v) },
- { title: '平均耗时', dataIndex: 'avgDurationSecs', render: (v: number) => {
- if (v <= 0) return '-'
- if (v < 60) return `${v.toFixed(0)} 秒`
- return `${(v / 60).toFixed(1)} 分`
- }},
+ {
+ title: '平均耗时',
+ dataIndex: 'avgDurationSecs',
+ render: (v: number) => {
+ if (v <= 0) return '-'
+ if (v < 60) return `${v.toFixed(0)} 秒`
+ return `${(v / 60).toFixed(1)} 分`
+ },
+ },
]}
/>
) : null}
{sla && sla.totalTasksWithSla > 0 ? (
-
-
- SLA 合规
-
- {sla.violated === 0 ? '全部达标' : `${sla.violated} 个违约`}
-
-
- }>
+
+
+ SLA 合规
+
+ {sla.violated === 0 ? '全部达标' : `${sla.violated} 个违约`}
+
+
+ }
+ >
- 参与 SLA 任务数
- {sla.totalTasksWithSla}
+
+ 参与 SLA 任务数
+
+
+ {sla.totalTasksWithSla}
+
- 达标
- {sla.compliant}
+
+ 达标
+
+
+ {sla.compliant}
+
- 合规率
- {formatPercent(sla.coverageRate)}
+
+ 合规率
+
+
+ {formatPercent(sla.coverageRate)}
+
{sla.violations.length > 0 && (
<>
-
+
}
rowKey="taskId"
columns={[
- { title: '任务', dataIndex: 'taskName', render: (value: string, record: SLAComplianceReport['violations'][number]) => (
-
- {value}
- {record.nodeName ? 节点: {record.nodeName} : null}
-
- ) },
- { title: 'RPO 目标', dataIndex: 'slaHoursRpo', render: (value: number) => `${value} 小时` },
- { title: '距上次成功', dataIndex: 'hoursSinceLastSuccess', render: (value: number, record: SLAComplianceReport['violations'][number]) =>
- record.neverSucceeded ? 从未成功 : `${value.toFixed(1)} 小时`,
+ {
+ title: '任务',
+ dataIndex: 'taskName',
+ render: (value: string, record: SLAComplianceReport['violations'][number]) => (
+
+ {value}
+ {record.nodeName ? (
+
+ 节点: {record.nodeName}
+
+ ) : null}
+
+ ),
+ },
+ {
+ title: 'RPO 目标',
+ dataIndex: 'slaHoursRpo',
+ render: (value: number) => `${value} 小时`,
+ },
+ {
+ title: '距上次成功',
+ dataIndex: 'hoursSinceLastSuccess',
+ render: (value: number, record: SLAComplianceReport['violations'][number]) =>
+ record.neverSucceeded ? (
+
+ 从未成功
+
+ ) : (
+ `${value.toFixed(1)} 小时`
+ ),
+ },
+ {
+ title: '最近成功',
+ dataIndex: 'lastSuccessAt',
+ render: (value?: string) => formatDateTime(value),
},
- { title: '最近成功', dataIndex: 'lastSuccessAt', render: (value?: string) => formatDateTime(value) },
]}
data={sla.violations}
pagination={false}
@@ -447,16 +743,36 @@ export function DashboardPage() {
title: '状态',
dataIndex: 'status',
render: (value: string) => {
- const label = value === 'success' ? '成功' : value === 'failed' ? '失败' : value === 'running' ? '执行中' : value
+ const label =
+ value === 'success'
+ ? '成功'
+ : value === 'failed'
+ ? '失败'
+ : value === 'running'
+ ? '执行中'
+ : value
return label ? (
-
+
{label}
- ) : -
+ ) : (
+ -
+ )
},
},
- { title: '文件大小', dataIndex: 'fileSize', render: (value: number) => formatBytes(value) },
- { title: '开始时间', dataIndex: 'startedAt', render: (value: string) => formatDateTime(value) },
+ {
+ title: '文件大小',
+ dataIndex: 'fileSize',
+ render: (value: number) => formatBytes(value),
+ },
+ {
+ title: '开始时间',
+ dataIndex: 'startedAt',
+ render: (value: string) => formatDateTime(value),
+ },
]}
data={stats?.recentRecords ?? []}
pagination={false}
diff --git a/web/src/pages/dashboard/page.tsx b/web/src/pages/dashboard/page.tsx
deleted file mode 100644
index c6ea738..0000000
--- a/web/src/pages/dashboard/page.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Grid, Statistic, Typography } from '@arco-design/web-react';
-
-import { PageCard } from '../../components/page-card';
-
-const cards = [
- { label: '存储目标', value: 0 },
- { label: '备份任务', value: 0 },
- { label: '最近执行', value: 0 },
-];
-
-export function DashboardPage() {
- return (
-
-
-
- `platform-foundation` 阶段提供基础登录、导航与系统状态展示,后续模块将在此页面扩展统计与运行数据。
-
-
-
- {cards.map((card) => (
-
-
-
-
-
- ))}
-
-
- );
-}
diff --git a/web/src/pages/login/LoginPage.test.tsx b/web/src/pages/login/LoginPage.test.tsx
index 8b62e98..2d00b54 100644
--- a/web/src/pages/login/LoginPage.test.tsx
+++ b/web/src/pages/login/LoginPage.test.tsx
@@ -16,11 +16,12 @@ vi.mock('../../services/auth', () => ({
}))
vi.mock('../../stores/auth', () => ({
- useAuthStore: (selector: (state: unknown) => unknown) => selector({
- status: 'anonymous',
- login: mocks.login,
- setup: mocks.setup,
- }),
+ useAuthStore: (selector: (state: unknown) => unknown) =>
+ selector({
+ status: 'anonymous',
+ login: mocks.login,
+ setup: mocks.setup,
+ }),
}))
vi.mock('../../utils/webauthn', () => ({
@@ -51,7 +52,9 @@ describe('LoginPage initialization', () => {
expect(await screen.findByText('System setup')).toBeInTheDocument()
expect(screen.getByText('Create the first administrator account.')).toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Create administrator and sign in' })).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: 'Create administrator and sign in' }),
+ ).toBeInTheDocument()
})
it('does not mistake an unreachable fresh install for an initialized system', async () => {
diff --git a/web/src/pages/login/LoginPage.tsx b/web/src/pages/login/LoginPage.tsx
index 1c01c17..f9eeacc 100644
--- a/web/src/pages/login/LoginPage.tsx
+++ b/web/src/pages/login/LoginPage.tsx
@@ -1,5 +1,11 @@
import { Button, Checkbox, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
-import { BackupServerIllustration, IconCloud, IconLock, IconSafe, IconUser } from '../../components/icons'
+import {
+ BackupServerIllustration,
+ IconCloud,
+ IconLock,
+ IconSafe,
+ IconUser,
+} from '../../components/icons'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
@@ -86,12 +92,14 @@ export function LoginPage() {
}
}, [])
+ const invalidateSetupStatusRequest = useCallback(() => {
+ setupStatusRequest.current++
+ }, [])
+
useEffect(() => {
void loadSetupStatus()
- return () => {
- setupStatusRequest.current++
- }
- }, [loadSetupStatus])
+ return invalidateSetupStatusRequest
+ }, [invalidateSetupStatusRequest, loadSetupStatus])
const handleSetup = async (values: SetupFormValues) => {
setLoading(true)
@@ -131,7 +139,8 @@ export function LoginPage() {
}
}
- function readLoginCredentials(): (LoginFormValues & { username: string; password: string }) | null {
+ function readLoginCredentials():
+ (LoginFormValues & { username: string; password: string }) | null {
const values = loginForm.getFieldsValue()
if (!values.username?.trim() || !values.password?.trim()) {
Message.error(t('auth.credentialsRequired'))
@@ -163,7 +172,10 @@ export function LoginPage() {
if (!values) return
setMfaActionLoading('webauthn')
try {
- const options = await beginWebAuthnLogin({ username: values.username, password: values.password })
+ const options = await beginWebAuthnLogin({
+ username: values.username,
+ password: values.password,
+ })
const assertion = await getWebAuthnAssertion(options)
await doLogin({
username: values.username,
@@ -183,16 +195,20 @@ export function LoginPage() {
}
}
- const pageTitle = initialized === null
- ? t('auth.setupStatusTitle')
- : initialized
- ? t('auth.welcomeTitle')
- : t('auth.setupTitle')
- const pageSubtitle = initialized === null
- ? setupStatusFailed ? t('auth.statusErrorDescription') : t('auth.checkingStatus')
- : initialized
- ? t('auth.welcomeSubtitle')
- : t('auth.setupSubtitle')
+ const pageTitle =
+ initialized === null
+ ? t('auth.setupStatusTitle')
+ : initialized
+ ? t('auth.welcomeTitle')
+ : t('auth.setupTitle')
+ const pageSubtitle =
+ initialized === null
+ ? setupStatusFailed
+ ? t('auth.statusErrorDescription')
+ : t('auth.checkingStatus')
+ : initialized
+ ? t('auth.welcomeSubtitle')
+ : t('auth.setupSubtitle')
return (
@@ -202,7 +218,10 @@ export function LoginPage() {
-
+
{t('auth.bannerTitle')}
@@ -210,7 +229,7 @@ export function LoginPage() {
-
+
@@ -218,7 +237,18 @@ export function LoginPage() {
-
+
@@ -248,58 +278,150 @@ export function LoginPage() {
)
) : initialized === false ? (
- } size="large" />
+
+ }
+ size="large"
+ />
-
- } size="large" />
+
+ }
+ size="large"
+ />
-
- } size="large" />
+
+ }
+ size="large"
+ />
-
+
{t('auth.setupSubmit')}
) : (
- } size="large" onChange={resetTwoFactorPrompt} />
+
+ }
+ size="large"
+ onChange={resetTwoFactorPrompt}
+ />
-
- } size="large" onChange={resetTwoFactorPrompt} />
+
+ }
+ size="large"
+ onChange={resetTwoFactorPrompt}
+ />
{twoFactorRequired && (
<>
-
- } size="large" maxLength={32} />
+
+ }
+ size="large"
+ maxLength={32}
+ />
- void handleSendOTP('email')}>{t('auth.sendEmailCode')}
- void handleSendOTP('sms')}>{t('auth.sendSmsCode')}
- void handleWebAuthnLogin()}>{t('auth.usePasskey')}
+ void handleSendOTP('email')}
+ >
+ {t('auth.sendEmailCode')}
+
+ void handleSendOTP('sms')}
+ >
+ {t('auth.sendSmsCode')}
+
+ void handleWebAuthnLogin()}
+ >
+ {t('auth.usePasskey')}
+
{t('auth.trustDevice')}
>
)}
-
+
{twoFactorRequired ? t('auth.verifyAndLogin') : t('auth.login')}
diff --git a/web/src/pages/login/page.tsx b/web/src/pages/login/page.tsx
deleted file mode 100644
index 1ded942..0000000
--- a/web/src/pages/login/page.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import {
- Alert,
- Button,
- Card,
- Form,
- Grid,
- Input,
- Space,
- Typography,
-} from '@arco-design/web-react';
-import { useMemo, useState } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
-
-import { useAuthStore } from '../../stores/auth';
-
-interface LoginFormValue {
- username: string;
- password: string;
-}
-
-export function LoginPage() {
- const navigate = useNavigate();
- const location = useLocation();
- const login = useAuthStore((state) => state.login);
- const status = useAuthStore((state) => state.status);
- const [errorMessage, setErrorMessage] = useState(null);
-
- const redirectPath = useMemo(() => {
- const from = location.state as { from?: { pathname?: string } } | null;
- return from?.from?.pathname ?? '/';
- }, [location.state]);
-
- async function handleSubmit(values: LoginFormValue) {
- setErrorMessage(null);
-
- try {
- await login(values);
- navigate(redirectPath, { replace: true });
- } catch (error) {
- setErrorMessage(error instanceof Error ? error.message : '登录失败');
- }
- }
-
- return (
-
-
-
-
-
-
- 欢迎使用 BackupX
-
- 登录后可管理备份任务、存储目标与系统状态。
-
-
- {errorMessage ? : null}
-
-
-
-
-
-
-
- 登录
-
-
-
-
-
-
-
- );
-}
diff --git a/web/src/pages/nodes/AgentInstallWizard.tsx b/web/src/pages/nodes/AgentInstallWizard.tsx
index a14abfd..1e931b8 100644
--- a/web/src/pages/nodes/AgentInstallWizard.tsx
+++ b/web/src/pages/nodes/AgentInstallWizard.tsx
@@ -1,7 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'
import { Modal, Steps, Button, Space, Message, Spin } from '@arco-design/web-react'
import { Step1NodeName, type Mode } from './wizard/Step1NodeName'
-import { Step2DeployOptions, isReleaseVersion, type DeployOptions } from './wizard/Step2DeployOptions'
+import {
+ Step2DeployOptions,
+ isReleaseVersion,
+ type DeployOptions,
+} from './wizard/Step2DeployOptions'
import { Step3CommandPreview } from './wizard/Step3CommandPreview'
import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable'
import type { InstallTokenInput, InstallTokenResult } from '../../types/nodes'
@@ -20,7 +24,13 @@ interface Props {
fixedNode?: { id: number; name: string }
}
-export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, fixedNode }: Props) {
+export function AgentInstallWizard({
+ visible,
+ onClose,
+ onSuccess,
+ masterVersion,
+ fixedNode,
+}: Props) {
const [step, setStep] = useState(fixedNode ? 1 : 0)
const [mode, setMode] = useState('single')
const [singleName, setSingleName] = useState('')
@@ -76,7 +86,10 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
}
const parseBatchNames = (): string[] =>
- batchText.split('\n').map((s) => s.trim()).filter(Boolean)
+ batchText
+ .split('\n')
+ .map((s) => s.trim())
+ .filter(Boolean)
const handleNextFromStep1 = () => {
if (mode === 'single') {
@@ -154,10 +167,13 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
const retryBatchNode = async (row: BatchCommandRow) => {
setSubmitting(true)
try {
- const next = await deployFlow.regenerateNode({ id: row.nodeId, name: row.nodeName }, toInstallTokenInput(deploy))
- setBatchRows((rows) => rows.map((item) => (
- item.nodeId === row.nodeId ? toBatchRows([next])[0] : item
- )))
+ const next = await deployFlow.regenerateNode(
+ { id: row.nodeId, name: row.nodeName },
+ toInstallTokenInput(deploy),
+ )
+ setBatchRows((rows) =>
+ rows.map((item) => (item.nodeId === row.nodeId ? toBatchRows([next])[0] : item)),
+ )
if (next.status === 'ready') {
Message.success(`节点「${row.nodeName}」安装命令已重新生成`)
} else {
@@ -231,9 +247,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
- {!fixedNode && (
- setStep(0)}>上一步
- )}
+ {!fixedNode && setStep(0)}>上一步}
取消
生成安装命令
@@ -254,7 +268,9 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
onRegenerate={regenerateSingle}
/>
)}
- {batchRows.length > 0 && }
+ {batchRows.length > 0 && (
+
+ )}
完成
@@ -265,11 +281,17 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
)
- function applySingleOrTableResult(rows: AgentDeployRow[], fallbackNode?: { id: number; name: string }) {
+ function applySingleOrTableResult(
+ rows: AgentDeployRow[],
+ fallbackNode?: { id: number; name: string },
+ ) {
const row = rows[0]
if (!row) return
if (row.status === 'ready' && row.installToken) {
- setSingleNodeInfo({ id: row.nodeId || fallbackNode?.id || 0, name: row.nodeName || fallbackNode?.name || '' })
+ setSingleNodeInfo({
+ id: row.nodeId || fallbackNode?.id || 0,
+ name: row.nodeName || fallbackNode?.name || '',
+ })
setSingleToken(row.installToken)
setBatchRows([])
return
@@ -288,7 +310,8 @@ function toInstallTokenInput(deploy: DeployOptions): InstallTokenInput {
agentVersion: deploy.agentVersion.trim(),
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
- agentMasterUrl: deploy.connectionMode === 'restricted' ? deploy.agentMasterUrl.trim() : undefined,
+ agentMasterUrl:
+ deploy.connectionMode === 'restricted' ? deploy.agentMasterUrl.trim() : undefined,
proxyUrl: deploy.connectionMode === 'restricted' ? deploy.proxyUrl.trim() : undefined,
caCertFile: deploy.connectionMode === 'restricted' ? deploy.caCertFile.trim() : undefined,
}
diff --git a/web/src/pages/nodes/BatchCommandTable.tsx b/web/src/pages/nodes/BatchCommandTable.tsx
index badd4fc..f82125b 100644
--- a/web/src/pages/nodes/BatchCommandTable.tsx
+++ b/web/src/pages/nodes/BatchCommandTable.tsx
@@ -66,10 +66,11 @@ export function BatchCommandTable({ rows, onRetryNode }: Props) {
columns={[
{ title: '节点', dataIndex: 'nodeName', width: 140 },
{
- title: '状态', dataIndex: 'status', width: 90,
- render: (status: BatchCommandRow['status']) => (
- status === 'ready' ? 可执行 : 失败
- ),
+ title: '状态',
+ dataIndex: 'status',
+ width: 90,
+ render: (status: BatchCommandRow['status']) =>
+ status === 'ready' ? 可执行 : 失败,
},
{
title: '安装命令',
@@ -77,42 +78,66 @@ export function BatchCommandTable({ rows, onRetryNode }: Props) {
render: (cmd: unknown, row: BatchCommandRow) => {
const left = remaining[row.nodeId] ?? 0
if (row.status === 'failed') {
- return {row.errorMessage || '生成安装命令失败'}
+ return (
+
+ {row.errorMessage || '生成安装命令失败'}
+
+ )
}
return (
-
+
{cmd as string}
)
},
},
{
- title: '剩余', dataIndex: 'expiresAt', width: 90,
+ title: '剩余',
+ dataIndex: 'expiresAt',
+ width: 90,
render: (_v: unknown, row: BatchCommandRow) => {
const left = remaining[row.nodeId] ?? 0
if (row.status === 'failed') {
- return -
+ return (
+
+ -
+
+ )
}
return (
- {left === 0 ? '已过期' : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`}
+ {left === 0
+ ? '已过期'
+ : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`}
)
},
},
{
- title: '操作', width: 110,
+ title: '操作',
+ width: 110,
render: (_v: unknown, row: BatchCommandRow) => (
{row.status === 'ready' && (
- } onClick={() => copy(row.command)}
- disabled={(remaining[row.nodeId] ?? 0) === 0}>复制
+
}
+ onClick={() => copy(row.command)}
+ disabled={(remaining[row.nodeId] ?? 0) === 0}
+ >
+ 复制
+
)}
{row.status === 'failed' && onRetryNode && (
-
} onClick={() => onRetryNode(row)}>重试
+
} onClick={() => onRetryNode(row)}>
+ 重试
+
)}
),
@@ -123,8 +148,13 @@ export function BatchCommandTable({ rows, onRetryNode }: Props) {
/>
- } onClick={exportAll}
- disabled={getExportableBatchRows(rows).length === 0}>导出 .sh
+ }
+ onClick={exportAll}
+ disabled={getExportableBatchRows(rows).length === 0}
+ >
+ 导出 .sh
+
diff --git a/web/src/pages/nodes/NodesPage.tsx b/web/src/pages/nodes/NodesPage.tsx
index 79c4dd5..3ff2070 100644
--- a/web/src/pages/nodes/NodesPage.tsx
+++ b/web/src/pages/nodes/NodesPage.tsx
@@ -1,10 +1,30 @@
import React, { useEffect, useState, useCallback } from 'react'
import {
- Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card,
- Empty, Dropdown, Menu, Tooltip, InputNumber,
+ Table,
+ Button,
+ Space,
+ Tag,
+ Typography,
+ PageHeader,
+ Modal,
+ Input,
+ Message,
+ Badge,
+ Popconfirm,
+ Card,
+ Empty,
+ Dropdown,
+ Menu,
+ Tooltip,
+ InputNumber,
} from '@arco-design/web-react'
import {
- IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit, IconMore,
+ IconPlus,
+ IconDelete,
+ IconDesktop,
+ IconCloudDownload,
+ IconEdit,
+ IconMore,
} from '../../components/icons'
import type { NodeSummary } from '../../types/nodes'
import { listNodes, deleteNode, updateNode, rotateNodeToken } from '../../services/nodes'
@@ -29,7 +49,12 @@ export function formatQueueAge(seconds?: number): string {
export function getNodeHealthView(node: NodeSummary) {
if (node.status !== 'online' || node.health === 'offline') {
- return { text: '离线', badgeStatus: 'default' as const, tagColor: 'gray', tooltip: '节点未在线' }
+ return {
+ text: '离线',
+ badgeStatus: 'default' as const,
+ tagColor: 'gray',
+ tooltip: '节点未在线',
+ }
}
if (node.health === 'degraded' || node.queue?.timeouts || node.lastError) {
return {
@@ -39,7 +64,12 @@ export function getNodeHealthView(node: NodeSummary) {
tooltip: node.lastError || '存在超时或失败的 Agent 命令',
}
}
- return { text: '健康', badgeStatus: 'success' as const, tagColor: 'green', tooltip: 'Agent 心跳与队列状态正常' }
+ return {
+ text: '健康',
+ badgeStatus: 'success' as const,
+ tagColor: 'green',
+ tooltip: 'Agent 心跳与队列状态正常',
+ }
}
export default function NodesPage() {
@@ -76,9 +106,11 @@ export default function NodesPage() {
fetchNodes()
// 取 Master 版本号作为 Wizard agentVersion 默认值。
// 拉取失败或字段缺失时置为空串,Wizard 会提示用户手动输入。
- fetchSystemInfo().then((info) => {
- setMasterVersion(info?.version || '')
- }).catch(() => setMasterVersion(''))
+ fetchSystemInfo()
+ .then((info) => {
+ setMasterVersion(info?.version || '')
+ })
+ .catch(() => setMasterVersion(''))
}, [fetchNodes])
const handleDelete = async (id: number) => {
@@ -134,17 +166,28 @@ export default function NodesPage() {
const columns = [
{
- title: '节点名称', dataIndex: 'name',
+ title: '节点名称',
+ dataIndex: 'name',
render: (name: string, record: NodeSummary) => (
- {record.isLocal ? : }
+ {record.isLocal ? (
+
+ ) : (
+
+ )}
{name}
- {record.isLocal && 本机}
+ {record.isLocal && (
+
+ 本机
+
+ )}
),
},
{
- title: '健康', dataIndex: 'health', width: 150,
+ title: '健康',
+ dataIndex: 'health',
+ width: 150,
render: (_: string, record: NodeSummary) => {
const health = getNodeHealthView(record)
return (
@@ -160,23 +203,37 @@ export default function NodesPage() {
{ title: '主机名', dataIndex: 'hostname', render: (v: string) => v || '-' },
{ title: 'IP 地址', dataIndex: 'ipAddress', render: (v: string) => v || '-' },
{
- title: '系统', dataIndex: 'os', width: 120,
- render: (_: string, record: NodeSummary) => record.os
- ? {record.os}/{record.arch} : '-',
+ title: '系统',
+ dataIndex: 'os',
+ width: 120,
+ render: (_: string, record: NodeSummary) =>
+ record.os ? (
+
+ {record.os}/{record.arch}
+
+ ) : (
+ '-'
+ ),
},
{
- title: 'Agent 版本', dataIndex: 'agentVersion', width: 140,
+ title: 'Agent 版本',
+ dataIndex: 'agentVersion',
+ width: 140,
render: (v: string) => renderAgentVersion(v, masterVersion),
},
{
- title: '队列', dataIndex: 'queue', width: 160,
+ title: '队列',
+ dataIndex: 'queue',
+ width: 160,
render: (_: unknown, record: NodeSummary) => {
const queue = record.queue
if (!queue || queue.depth === 0) {
return 空闲
}
return (
-
+
深度 {queue.depth}
{queue.timeouts > 0 && 超时 {queue.timeouts}}
@@ -186,50 +243,82 @@ export default function NodesPage() {
},
},
{
- title: '运行中', dataIndex: 'runningTasks', width: 90,
- render: (v: number | undefined) => v && v > 0 ? {v} : 0,
+ title: '运行中',
+ dataIndex: 'runningTasks',
+ width: 90,
+ render: (v: number | undefined) =>
+ v && v > 0 ? {v} : 0,
},
{
- title: '标签 / 节点池', dataIndex: 'labels', width: 180,
+ title: '标签 / 节点池',
+ dataIndex: 'labels',
+ width: 180,
render: (v: string) => {
- const tags = (v || '').split(',').map(s => s.trim()).filter(Boolean)
+ const tags = (v || '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
if (tags.length === 0) return -
- return {tags.map(tag => {tag})}
+ return (
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )
},
},
{
- title: '最后活跃', dataIndex: 'lastSeen', width: 170,
- render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
+ title: '最后活跃',
+ dataIndex: 'lastSeen',
+ width: 170,
+ render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
},
{
- title: '操作', width: 180,
+ title: '操作',
+ width: 180,
render: (_: unknown, record: NodeSummary) => {
if (!manageable) {
return -
}
return (
- } size="small"
+ }
+ size="small"
onClick={() => {
- setEditNode(record); setEditName(record.name)
+ setEditNode(record)
+ setEditName(record.name)
setEditLabels(record.labels || '')
setEditMaxConcurrent(record.maxConcurrent || 0)
setEditBandwidthLimit(record.bandwidthLimit || '')
setEditVisible(true)
- }} />
+ }}
+ />
{!record.isLocal && (
<>
-
- { setWizardFixedNode({ id: record.id, name: record.name }); setWizardVisible(true) }}>
- 生成安装命令
-
- handleRotate(record)}>
- 重新生成 Token
-
-
- )}>
+
+ {
+ setWizardFixedNode({ id: record.id, name: record.name })
+ setWizardVisible(true)
+ }}
+ >
+ 生成安装命令
+
+ handleRotate(record)}>
+ 重新生成 Token
+
+
+ }
+ >
} size="small" />
handleDelete(record.id)}>
@@ -248,17 +337,31 @@ export default function NodesPage() {
}
- onClick={() => { setWizardFixedNode(undefined); setWizardVisible(true) }}>
- 添加节点
-
- ) : undefined}
+ extra={
+ manageable ? (
+ }
+ onClick={() => {
+ setWizardFixedNode(undefined)
+ setWizardVisible(true)
+ }}
+ >
+ 添加节点
+
+ ) : undefined
+ }
/>
- } />
+ }
+ />
- setEditVisible(false)} onOk={handleEdit}
- okText="保存" cancelText="取消" style={{ width: 520 }}>
- 节点名称
+ setEditVisible(false)}
+ onOk={handleEdit}
+ okText="保存"
+ cancelText="取消"
+ style={{ width: 520 }}
+ >
+
+ 节点名称
+
标签 / 节点池
- ⓘ
+
+ ⓘ
+
- 最大并发任务数(0 = 不限)
- setEditMaxConcurrent(v ?? 0)} style={{ width: '100%' }} />
+
+ 最大并发任务数(0 = 不限)
+
+ setEditMaxConcurrent(v ?? 0)}
+ style={{ width: '100%' }}
+ />
带宽限速
- ⓘ
+
+ ⓘ
+
-
+
)
@@ -310,7 +437,9 @@ function renderAgentVersion(agentVer: string, masterVer: string | null): React.R
if (agentVer === masterVer) return agentVer
return (
- {agentVer} ≠ {masterVer}
+
+ {agentVer} ≠ {masterVer}
+
)
}
diff --git a/web/src/pages/nodes/installCommands.test.ts b/web/src/pages/nodes/installCommands.test.ts
index 4b569d1..7820a8c 100644
--- a/web/src/pages/nodes/installCommands.test.ts
+++ b/web/src/pages/nodes/installCommands.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from './installCommands'
+import {
+ buildAgentDownloadCommand,
+ buildAgentInstallCommand,
+ buildEmbeddedAgentInstallCommand,
+} from './installCommands'
describe('install command builders', () => {
it('adds script marker validation and fallback install path', () => {
@@ -35,11 +39,10 @@ describe('install command builders', () => {
})
it('binds proxy and private CA settings to installer downloads', () => {
- const cmd = buildAgentInstallCommand(
- 'https://master.internal/api/install/abc',
- undefined,
- { proxyUrl: 'socks5h://127.0.0.1:1080', caCertFile: '/etc/backupx-agent/ca.pem' },
- )
+ const cmd = buildAgentInstallCommand('https://master.internal/api/install/abc', undefined, {
+ proxyUrl: 'socks5h://127.0.0.1:1080',
+ caCertFile: '/etc/backupx-agent/ca.pem',
+ })
expect(cmd).toContain("--proxy 'socks5h://127.0.0.1:1080'")
expect(cmd).toContain("--cacert '/etc/backupx-agent/ca.pem'")
diff --git a/web/src/pages/nodes/installCommands.ts b/web/src/pages/nodes/installCommands.ts
index 1646dcb..a576518 100644
--- a/web/src/pages/nodes/installCommands.ts
+++ b/web/src/pages/nodes/installCommands.ts
@@ -29,50 +29,66 @@ function curlFetch(url: string, destination: string, options: InstallFetchOption
return args.join(' ')
}
-export function buildAgentInstallCommand(url: string, fallbackUrl?: string, options: InstallFetchOptions = {}) {
+export function buildAgentInstallCommand(
+ url: string,
+ fallbackUrl?: string,
+ options: InstallFetchOptions = {},
+) {
const primary = url.trim()
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
const urls = fallback && fallback !== primary ? [primary, fallback] : [primary]
const marker = shellQuote(INSTALL_MAGIC_MARKER)
- const fetchScript = urls.length > 1
- ? `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(urls[1], '"$tmp"', options)})`
- : `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp")`
+ const fetchScript =
+ urls.length > 1
+ ? `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(urls[1], '"$tmp"', options)})`
+ : `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp")`
- return [
- 'umask 077',
- 'tmp=$(mktemp)',
- fetchScript,
- `{ grep -q ${marker} "$tmp" || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 "$tmp" >&2; false; }; }`,
- runScriptCommand('"$tmp"'),
- ].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
+ return (
+ [
+ 'umask 077',
+ 'tmp=$(mktemp)',
+ fetchScript,
+ `{ grep -q ${marker} "$tmp" || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 "$tmp" >&2; false; }; }`,
+ runScriptCommand('"$tmp"'),
+ ].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
+ )
}
-export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, options: InstallFetchOptions = {}) {
+export function buildAgentDownloadCommand(
+ url: string,
+ fallbackUrl?: string,
+ options: InstallFetchOptions = {},
+) {
const primary = url.trim()
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
const marker = shellQuote(INSTALL_MAGIC_MARKER)
- const fetchScript = fallback && fallback !== primary
- ? `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(fallback, '"$tmp"', options)})`
- : `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp")`
+ const fetchScript =
+ fallback && fallback !== primary
+ ? `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(fallback, '"$tmp"', options)})`
+ : `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp")`
- return [
- 'umask 077',
- 'tmp=$(mktemp /tmp/bx-agent-install.XXXXXX)',
- fetchScript,
- `{ grep -q ${marker} "$tmp" || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 "$tmp" >&2; false; }; }`,
- runScriptCommand('"$tmp"'),
- ].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
+ return (
+ [
+ 'umask 077',
+ 'tmp=$(mktemp /tmp/bx-agent-install.XXXXXX)',
+ fetchScript,
+ `{ grep -q ${marker} "$tmp" || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 "$tmp" >&2; false; }; }`,
+ runScriptCommand('"$tmp"'),
+ ].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
+ )
}
export function buildEmbeddedAgentInstallCommand(scriptBase64: string) {
const marker = shellQuote(INSTALL_MAGIC_MARKER)
- return [
- 'umask 077',
- 'enc=$(mktemp)',
- 'tmp=$(mktemp)',
- `printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`,
- '(base64 -d < "$enc" > "$tmp" 2>/dev/null || base64 -D < "$enc" > "$tmp")',
- `{ grep -q ${marker} "$tmp" || { echo 'BackupX embedded installer is invalid.' >&2; head -5 "$tmp" >&2; false; }; }`,
- runScriptCommand('"$tmp"'),
- ].join(' && ') + '; rc=$?; rm -f "$enc" "$tmp"; test $rc -eq 0'
+ return (
+ [
+ 'umask 077',
+ 'enc=$(mktemp)',
+ 'tmp=$(mktemp)',
+ `printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`,
+ '(base64 -d < "$enc" > "$tmp" 2>/dev/null || base64 -D < "$enc" > "$tmp")',
+ `{ grep -q ${marker} "$tmp" || { echo 'BackupX embedded installer is invalid.' >&2; head -5 "$tmp" >&2; false; }; }`,
+ runScriptCommand('"$tmp"'),
+ ].join(' && ') + '; rc=$?; rm -f "$enc" "$tmp"; test $rc -eq 0'
+ )
}
diff --git a/web/src/pages/nodes/useAgentDeployFlow.test.ts b/web/src/pages/nodes/useAgentDeployFlow.test.ts
index 5938e63..473c179 100644
--- a/web/src/pages/nodes/useAgentDeployFlow.test.ts
+++ b/web/src/pages/nodes/useAgentDeployFlow.test.ts
@@ -60,7 +60,10 @@ describe('createAgentDeployFlow', () => {
if (nodeId === 2) {
throw new Error('token service unavailable')
}
- return tokenResult({ installToken: `tok-${nodeId}`, url: `https://master.example.com/api/install/tok-${nodeId}` })
+ return tokenResult({
+ installToken: `tok-${nodeId}`,
+ url: `https://master.example.com/api/install/tok-${nodeId}`,
+ })
},
})
@@ -79,10 +82,11 @@ describe('createAgentDeployFlow', () => {
it('uses restricted-network options in batch install commands', async () => {
const flow = createAgentDeployFlow({
batchCreateNodes: async () => [{ id: 1, name: 'restricted' }],
- createInstallToken: async () => tokenResult({
- url: 'https://master.internal/api/install/install-token',
- fallbackUrl: 'https://master.internal/install/install-token',
- }),
+ createInstallToken: async () =>
+ tokenResult({
+ url: 'https://master.internal/api/install/install-token',
+ fallbackUrl: 'https://master.internal/install/install-token',
+ }),
})
const result = await flow.submitNewNodes(['restricted'], {
...deployOptions(),
@@ -102,7 +106,8 @@ describe('createAgentDeployFlow', () => {
createInstallToken: async () => tokenResult(),
})
- await expect(flow.submitNewNodes(['prod-a', ' prod-a '], deployOptions()))
- .rejects.toThrow('批次内重复节点名')
+ await expect(flow.submitNewNodes(['prod-a', ' prod-a '], deployOptions())).rejects.toThrow(
+ '批次内重复节点名',
+ )
})
})
diff --git a/web/src/pages/nodes/useAgentDeployFlow.ts b/web/src/pages/nodes/useAgentDeployFlow.ts
index bfcc70a..9432f5a 100644
--- a/web/src/pages/nodes/useAgentDeployFlow.ts
+++ b/web/src/pages/nodes/useAgentDeployFlow.ts
@@ -1,10 +1,7 @@
import { useMemo } from 'react'
import type { BatchCreateResult, InstallTokenInput, InstallTokenResult } from '../../types/nodes'
import { batchCreateNodes, createInstallToken } from '../../services/nodes'
-import {
- buildAgentInstallCommand,
- buildEmbeddedAgentInstallCommand,
-} from './installCommands'
+import { buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from './installCommands'
export type DeployRowStatus = 'ready' | 'failed'
export type DeployResultStatus = 'ready' | 'partialFailed'
@@ -38,7 +35,10 @@ interface AgentDeployFlowDeps {
const TOKEN_CONCURRENCY = 4
export function createAgentDeployFlow(deps: AgentDeployFlowDeps) {
- const issueTokenForNode = async (node: AgentDeployNode, input: InstallTokenInput): Promise => {
+ const issueTokenForNode = async (
+ node: AgentDeployNode,
+ input: InstallTokenInput,
+ ): Promise => {
try {
const token = await deps.createInstallToken(node.id, input)
return readyRow(node, token, input)
@@ -58,11 +58,16 @@ export function createAgentDeployFlow(deps: AgentDeployFlowDeps) {
async submitNewNodes(names: string[], input: InstallTokenInput): Promise {
const cleanedNames = normalizeNodeNames(names)
const nodes = await deps.batchCreateNodes(cleanedNames)
- const rows = await mapWithConcurrency(nodes, TOKEN_CONCURRENCY, (node) => issueTokenForNode(node, input))
+ const rows = await mapWithConcurrency(nodes, TOKEN_CONCURRENCY, (node) =>
+ issueTokenForNode(node, input),
+ )
return resultFromRows(rows)
},
- async submitExistingNode(node: AgentDeployNode, input: InstallTokenInput): Promise {
+ async submitExistingNode(
+ node: AgentDeployNode,
+ input: InstallTokenInput,
+ ): Promise {
const row = await issueTokenForNode(node, input)
return resultFromRows([row])
},
@@ -77,7 +82,11 @@ export function useAgentDeployFlow() {
return useMemo(() => createAgentDeployFlow({ batchCreateNodes, createInstallToken }), [])
}
-function readyRow(node: AgentDeployNode, token: InstallTokenResult, input: InstallTokenInput): AgentDeployRow {
+function readyRow(
+ node: AgentDeployNode,
+ token: InstallTokenResult,
+ input: InstallTokenInput,
+): AgentDeployRow {
return {
nodeId: node.id,
nodeName: node.name,
diff --git a/web/src/pages/nodes/wizard/AgentConnectionOptions.test.ts b/web/src/pages/nodes/wizard/AgentConnectionOptions.test.ts
index 780571c..5d38c58 100644
--- a/web/src/pages/nodes/wizard/AgentConnectionOptions.test.ts
+++ b/web/src/pages/nodes/wizard/AgentConnectionOptions.test.ts
@@ -17,7 +17,9 @@ describe('validateAgentConnection', () => {
})
it('accepts an SSH local-forward URL and SOCKS5 proxy', () => {
- expect(validateAgentConnection(connection({ agentMasterUrl: 'http://127.0.0.1:18340' }))).toBe('')
+ expect(validateAgentConnection(connection({ agentMasterUrl: 'http://127.0.0.1:18340' }))).toBe(
+ '',
+ )
expect(validateAgentConnection(connection({ proxyUrl: 'socks5h://127.0.0.1:1080' }))).toBe('')
})
@@ -27,8 +29,16 @@ describe('validateAgentConnection', () => {
})
it('rejects credentials and shell-unsafe values before submission', () => {
- expect(validateAgentConnection(connection({ agentMasterUrl: 'https://user:pass@master.example.com' }))).not.toBe('')
- expect(validateAgentConnection(connection({ proxyUrl: 'http://user:pass@proxy.example.com' }))).not.toBe('')
- expect(validateAgentConnection(connection({ caCertFile: '/etc/pki/internal ca.pem' }))).not.toBe('')
+ expect(
+ validateAgentConnection(
+ connection({ agentMasterUrl: 'https://user:pass@master.example.com' }),
+ ),
+ ).not.toBe('')
+ expect(
+ validateAgentConnection(connection({ proxyUrl: 'http://user:pass@proxy.example.com' })),
+ ).not.toBe('')
+ expect(
+ validateAgentConnection(connection({ caCertFile: '/etc/pki/internal ca.pem' })),
+ ).not.toBe('')
})
})
diff --git a/web/src/pages/nodes/wizard/AgentConnectionOptions.tsx b/web/src/pages/nodes/wizard/AgentConnectionOptions.tsx
index d549487..66dcf4b 100644
--- a/web/src/pages/nodes/wizard/AgentConnectionOptions.tsx
+++ b/web/src/pages/nodes/wizard/AgentConnectionOptions.tsx
@@ -24,7 +24,11 @@ export function AgentConnectionOptions({ value, onChange }: Props) {
<>
Agent 只需主动访问 Master,不需要从 Master 反向开放节点端口。}
+ extra={
+
+ Agent 只需主动访问 Master,不需要从 Master 反向开放节点端口。
+
+ }
>
可填写经 SSH 本地转发后的地址;留空则继续使用 Master 对外地址。}
+ extra={
+
+ 可填写经 SSH 本地转发后的地址;留空则继续使用 Master 对外地址。
+
+ }
>
支持 http、https、socks5、socks5h;SSH 动态转发可使用 socks5h://127.0.0.1:1080。}
+ extra={
+
+ 支持 http、https、socks5、socks5h;SSH 动态转发可使用 socks5h://127.0.0.1:1080。
+
+ }
>
目标节点上已存在的 PEM 文件绝对路径;安装器会复制到受保护的 Agent 配置目录。}
+ extra={
+
+ 目标节点上已存在的 PEM 文件绝对路径;安装器会复制到受保护的 Agent 配置目录。
+
+ }
>
part === '..'))) {
+ if (
+ caCertFile &&
+ (!/^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/.test(caCertFile) ||
+ caCertFile.split('/').some((part) => part === '..'))
+ ) {
return '私有 CA 证书必须使用不含空格或特殊字符的绝对路径'
}
return ''
diff --git a/web/src/pages/nodes/wizard/InstallCommandBlock.tsx b/web/src/pages/nodes/wizard/InstallCommandBlock.tsx
index 0dbc0f7..0dcba30 100644
--- a/web/src/pages/nodes/wizard/InstallCommandBlock.tsx
+++ b/web/src/pages/nodes/wizard/InstallCommandBlock.tsx
@@ -14,14 +14,39 @@ interface Props {
export function InstallCommandBlock({ label, command, disabled, action, onCopy }: Props) {
return (
-
- {label &&
{label}}
-
+
+ {label && (
+
+ {label}
+
+ )}
+
{command}
- } disabled={disabled} onClick={() => onCopy(command)}>复制
+ }
+ disabled={disabled}
+ onClick={() => onCopy(command)}
+ >
+ 复制
+
{action}
diff --git a/web/src/pages/nodes/wizard/Step1NodeName.tsx b/web/src/pages/nodes/wizard/Step1NodeName.tsx
index 13bb788..b417561 100644
--- a/web/src/pages/nodes/wizard/Step1NodeName.tsx
+++ b/web/src/pages/nodes/wizard/Step1NodeName.tsx
@@ -15,7 +15,12 @@ interface Props {
}
export function Step1NodeName({
- mode, onModeChange, singleName, onSingleNameChange, batchText, onBatchTextChange,
+ mode,
+ onModeChange,
+ singleName,
+ onSingleNameChange,
+ batchText,
+ onBatchTextChange,
}: Props) {
return (
@@ -42,7 +47,9 @@ export function Step1NodeName({
) : (
-
节点名称(每行一个,最多 50 个)
+
+ 节点名称(每行一个,最多 50 个)
+