feat: add community enhancements — password reset, audit logs, multi-source backup

Three community-requested features:

1. CLI password reset: `backupx reset-password --username admin --password xxx`
   Docker users can run via `docker exec`. No full app init needed.

2. Audit logging: async fire-and-forget audit trail for all key operations
   (login, CRUD on tasks/targets/records, settings changes).
   New UI page at /audit with category filter and pagination.

3. Multi-source path backup: file backup tasks now support multiple source
   directories packed into a single tar archive. Backward compatible with
   existing single sourcePath field.
This commit is contained in:
Awuqing
2026-03-30 23:04:37 +08:00
parent 8cf97e439e
commit 5a25690f3f
47 changed files with 1902 additions and 263 deletions

View File

@@ -1,7 +1,7 @@
import { Alert, Button, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, restoreBackupRecord, streamBackupRecordLogs } from '../../services/backup-records'
import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus } from '../../types/backup-records'
import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus, StorageUploadResultItem } from '../../types/backup-records'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
@@ -221,6 +221,19 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
</Button>
</Space>
{record.storageUploadResults && record.storageUploadResults.length > 1 && (
<div>
<Typography.Title heading={6}></Typography.Title>
<Descriptions
column={1}
data={record.storageUploadResults.map((r: StorageUploadResultItem) => ({
label: r.storageTargetName,
value: r.status === 'success' ? '上传成功' : `上传失败: ${r.error || '未知错误'}`,
}))}
/>
</div>
)}
<div>
<Typography.Title heading={6}></Typography.Title>
<div className="log-viewer">{logText || '暂无日志输出'}</div>

View File

@@ -29,7 +29,7 @@ export function BackupTaskDetailDrawer({ visible, task, onCancel }: BackupTaskDe
border
data={[
{ label: 'Cron', value: task.cronExpr || '仅手动执行' },
{ label: '存储目标', value: task.storageTargetName || task.storageTargetId },
{ label: '存储目标', value: task.storageTargetNames?.length > 0 ? task.storageTargetNames.join('、') : (task.storageTargetName || task.storageTargetId) },
{ label: '保留天数', value: task.retentionDays },
{ label: '最大保留份数', value: task.maxBackups },
{ label: '压缩', value: task.compression },
@@ -40,7 +40,15 @@ export function BackupTaskDetailDrawer({ visible, task, onCancel }: BackupTaskDe
]}
/>
{task.type === 'file' ? (
<Descriptions border column={1} data={[{ label: '源路径', value: task.sourcePath || '-' }, { label: '排除规则', value: task.excludePatterns.join(', ') || '-' }]} />
<Descriptions border column={1} data={[
{
label: '源路径',
value: task.sourcePaths?.length > 0
? task.sourcePaths.join('\n')
: (task.sourcePath || '-'),
},
{ label: '排除规则', value: task.excludePatterns.join(', ') || '-' },
]} />
) : null}
{task.type === 'sqlite' ? <Descriptions border column={1} data={[{ label: 'SQLite 路径', value: task.dbPath || '-' }]} /> : null}
{task.type === 'mysql' || task.type === 'postgresql' ? (

View File

@@ -1,8 +1,13 @@
import { Alert, Button, Divider, Drawer, Input, InputNumber, Select, Space, Steps, Switch, Typography } from '@arco-design/web-react'
import { Alert, Button, Divider, Drawer, Input, InputNumber, Select, Space, Steps, Switch, Typography, Grid } from '@arco-design/web-react'
import { IconDelete, IconPlus } from '@arco-design/web-react/icon'
import { useEffect, useMemo, useState } from 'react'
import { CronInput } from '../CronInput'
import type { StorageTargetSummary } from '../../types/storage-targets'
import type { StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
import type { StorageConnectionTestResult } from '../../types/storage-targets'
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
import { DatabasePicker } from '../common/DatabasePicker'
import { DirectoryPicker } from '../common/DirectoryPicker'
import { StorageTargetFormDrawer } from '../storage-targets/StorageTargetFormDrawer'
import {
backupCompressionOptions,
backupTaskTypeOptions,
@@ -17,17 +22,24 @@ interface BackupTaskFormDrawerProps {
loading: boolean
initialValue: BackupTaskDetail | null
storageTargets: StorageTargetSummary[]
localNodeId?: number
onCancel: () => void
onSubmit: (value: BackupTaskPayload, taskId?: number) => Promise<void>
onCreateStorageTarget?: (value: StorageTargetPayload) => Promise<StorageTargetDetail>
onTestStorageTarget?: (value: StorageTargetPayload, targetId?: number) => Promise<StorageConnectionTestResult>
onGoogleDriveAuth?: (value: StorageTargetPayload, targetId?: number) => Promise<void>
onStorageTargetCreated?: () => Promise<void>
}
function createEmptyDraft(storageTargetId?: number): BackupTaskPayload {
function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPayload {
const defaultIds = storageTargets && storageTargets.length > 0 ? [storageTargets[0].id] : []
return {
name: '',
type: 'file',
enabled: true,
cronExpr: '',
sourcePath: '',
sourcePaths: [''],
excludePatterns: [],
dbHost: '',
dbPort: 0,
@@ -35,7 +47,8 @@ function createEmptyDraft(storageTargetId?: number): BackupTaskPayload {
dbPassword: '',
dbName: '',
dbPath: '',
storageTargetId: storageTargetId ?? 0,
storageTargetId: defaultIds[0] ?? 0,
storageTargetIds: defaultIds,
nodeId: 0,
tags: '',
retentionDays: 30,
@@ -45,11 +58,14 @@ function createEmptyDraft(storageTargetId?: number): BackupTaskPayload {
}
}
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, onCancel, onSubmit }: BackupTaskFormDrawerProps) {
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, localNodeId, onCancel, onSubmit, onCreateStorageTarget, onTestStorageTarget, onGoogleDriveAuth, onStorageTargetCreated }: BackupTaskFormDrawerProps) {
const [draft, setDraft] = useState<BackupTaskPayload>(createEmptyDraft())
const [excludePatternsText, setExcludePatternsText] = useState('')
const [currentStep, setCurrentStep] = useState(0)
const [error, setError] = useState('')
const [quickCreateVisible, setQuickCreateVisible] = useState(false)
const [quickCreateLoading, setQuickCreateLoading] = useState(false)
const [quickCreateTesting, setQuickCreateTesting] = useState(false)
useEffect(() => {
if (!visible) {
@@ -57,7 +73,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
}
if (!initialValue) {
const nextDraft = createEmptyDraft(storageTargets[0]?.id)
const nextDraft = createEmptyDraft(storageTargets)
setDraft(nextDraft)
setExcludePatternsText('')
setCurrentStep(0)
@@ -65,12 +81,24 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
return
}
const editTargetIds = initialValue.storageTargetIds?.length > 0
? initialValue.storageTargetIds
: initialValue.storageTargetId > 0
? [initialValue.storageTargetId]
: []
// 编辑时sourcePaths 优先,为空回退 sourcePath
const editSourcePaths = initialValue.sourcePaths?.length > 0
? initialValue.sourcePaths
: initialValue.sourcePath
? [initialValue.sourcePath]
: ['']
setDraft({
name: initialValue.name,
type: initialValue.type,
enabled: initialValue.enabled,
cronExpr: initialValue.cronExpr,
sourcePath: initialValue.sourcePath,
sourcePaths: editSourcePaths,
excludePatterns: initialValue.excludePatterns,
dbHost: initialValue.dbHost,
dbPort: initialValue.dbPort,
@@ -78,7 +106,8 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
dbPassword: '',
dbName: initialValue.dbName,
dbPath: initialValue.dbPath,
storageTargetId: initialValue.storageTargetId,
storageTargetId: editTargetIds[0] ?? 0,
storageTargetIds: editTargetIds,
nodeId: (initialValue as any).nodeId ?? 0,
tags: (initialValue as any).tags ?? '',
retentionDays: initialValue.retentionDays,
@@ -92,7 +121,17 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
}, [initialValue, storageTargets, visible])
const storageTargetOptions = useMemo(
() => storageTargets.map((item) => ({ label: item.name, value: item.id, disabled: !item.enabled })),
() => {
const sorted = [...storageTargets].sort((a, b) => {
if (a.starred !== b.starred) return a.starred ? -1 : 1
return 0
})
return sorted.map((item) => ({
label: item.starred ? `${item.name}` : item.name,
value: item.id,
disabled: !item.enabled,
}))
},
[storageTargets],
)
@@ -105,6 +144,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
...current,
type: value,
sourcePath: value === 'file' ? current.sourcePath : '',
sourcePaths: value === 'file' ? current.sourcePaths : [''],
excludePatterns: value === 'file' ? current.excludePatterns : [],
dbHost: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbHost : '',
dbPort: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbPort || getDefaultPort(value) : 0,
@@ -122,8 +162,8 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
if (!value.name.trim()) {
return '请输入任务名称'
}
if (!value.storageTargetId) {
return '请选择存储目标'
if (!value.storageTargetIds || value.storageTargetIds.length === 0) {
return '请选择至少一个存储目标'
}
if (value.cronExpr.trim() && value.cronExpr.trim().split(/\s+/).length < 5) {
return 'Cron 表达式至少需要 5 段'
@@ -134,8 +174,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
if (value.maxBackups < 0) {
return '最大保留份数不能小于 0'
}
if (isFileBackupTask(value.type) && !value.sourcePath.trim()) {
return '请输入源路径'
if (isFileBackupTask(value.type)) {
const validPaths = (value.sourcePaths ?? []).filter((p) => p.trim())
if (validPaths.length === 0 && !value.sourcePath.trim()) {
return '请输入至少一个源路径'
}
}
if (isSQLiteBackupTask(value.type) && !value.dbPath.trim()) {
return '请输入 SQLite 数据库路径'
@@ -161,8 +204,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
}
async function handleSubmit() {
const validSourcePaths = (draft.sourcePaths ?? []).filter((p) => p.trim())
const nextValue: BackupTaskPayload = {
...draft,
sourcePaths: validSourcePaths,
sourcePath: validSourcePaths[0] ?? draft.sourcePath,
excludePatterns: excludePatternsText
.split('\n')
.map((item) => item.trim())
@@ -203,14 +249,65 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
)
}
function updateSourcePath(index: number, value: string) {
setDraft((current) => {
const next = [...(current.sourcePaths ?? [''])]
next[index] = value
return { ...current, sourcePaths: next, sourcePath: next[0] ?? '' }
})
}
function addSourcePath() {
setDraft((current) => ({
...current,
sourcePaths: [...(current.sourcePaths ?? ['']), ''],
}))
}
function removeSourcePath(index: number) {
setDraft((current) => {
const next = [...(current.sourcePaths ?? [''])]
next.splice(index, 1)
if (next.length === 0) next.push('')
return { ...current, sourcePaths: next, sourcePath: next[0] ?? '' }
})
}
function renderSourceStep() {
const paths = draft.sourcePaths?.length > 0 ? draft.sourcePaths : ['']
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{isFileBackupTask(draft.type) ? (
<>
<div>
<Typography.Text></Typography.Text>
<Input value={draft.sourcePath} placeholder="例如:/var/www/html" onChange={(value) => updateDraft({ sourcePath: value })} />
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
{paths.map((p, index) => (
<Grid.Row key={index} gutter={8} align="center">
<Grid.Col flex="auto">
<DirectoryPicker
value={p}
placeholder={`源路径 ${index + 1},例如:/var/www/html`}
mode="directory"
nodeId={localNodeId}
onChange={(value) => updateSourcePath(index, value)}
/>
</Grid.Col>
<Grid.Col flex="none">
<Button
type="text"
icon={<IconDelete />}
status="danger"
disabled={paths.length <= 1}
onClick={() => removeSourcePath(index)}
/>
</Grid.Col>
</Grid.Row>
))}
<Button type="dashed" long icon={<IconPlus />} onClick={addSourcePath}>
</Button>
</Space>
</div>
<div>
<Typography.Text></Typography.Text>
@@ -227,7 +324,13 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
{isSQLiteBackupTask(draft.type) ? (
<div>
<Typography.Text>SQLite </Typography.Text>
<Input value={draft.dbPath} placeholder="例如:/data/app.db" onChange={(value) => updateDraft({ dbPath: value })} />
<DirectoryPicker
value={draft.dbPath}
placeholder="例如:/data/app.db"
mode="file"
nodeId={localNodeId}
onChange={(value) => updateDraft({ dbPath: value })}
/>
</div>
) : null}
@@ -251,7 +354,19 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
</div>
<div>
<Typography.Text></Typography.Text>
<Input value={draft.dbName} placeholder="例如app_prod" onChange={(value) => updateDraft({ dbName: value })} />
{(draft.type === 'mysql' || draft.type === 'postgresql') ? (
<DatabasePicker
dbType={draft.type}
dbHost={draft.dbHost}
dbPort={draft.dbPort}
dbUser={draft.dbUser}
dbPassword={draft.dbPassword}
value={draft.dbName}
onChange={(value) => updateDraft({ dbName: value })}
/>
) : (
<Input value={draft.dbName} placeholder="例如app_prod" onChange={(value) => updateDraft({ dbName: value })} />
)}
</div>
</>
) : null}
@@ -259,12 +374,53 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
)
}
async function handleQuickCreateSubmit(value: StorageTargetPayload) {
if (!onCreateStorageTarget) return
setQuickCreateLoading(true)
try {
const created = await onCreateStorageTarget(value)
setQuickCreateVisible(false)
if (onStorageTargetCreated) {
await onStorageTargetCreated()
}
const currentIds = draft.storageTargetIds ?? []
const nextIds = [...currentIds, created.id]
updateDraft({ storageTargetIds: nextIds, storageTargetId: nextIds[0] ?? 0 })
} finally {
setQuickCreateLoading(false)
}
}
async function handleQuickCreateTest(value: StorageTargetPayload, targetId?: number): Promise<StorageConnectionTestResult> {
if (!onTestStorageTarget) return { success: false, message: '测试不可用' }
setQuickCreateTesting(true)
try {
return await onTestStorageTarget(value, targetId)
} finally {
setQuickCreateTesting(false)
}
}
function renderPolicyStep() {
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Text></Typography.Text>
<Select value={draft.storageTargetId || undefined} placeholder="请选择存储目标" options={storageTargetOptions} onChange={(value) => updateDraft({ storageTargetId: Number(value) })} />
<Space style={{ width: '100%' }} align="start">
<Select
style={{ flex: 1 }}
mode="multiple"
value={draft.storageTargetIds?.length > 0 ? draft.storageTargetIds : undefined}
placeholder="请选择存储目标(可多选)"
options={storageTargetOptions}
onChange={(values: number[]) => updateDraft({ storageTargetIds: values, storageTargetId: values[0] ?? 0 })}
/>
{onCreateStorageTarget && (
<Button type="outline" size="small" onClick={() => setQuickCreateVisible(true)}>
+
</Button>
)}
</Space>
</div>
<div>
<Typography.Text></Typography.Text>
@@ -320,6 +476,19 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
)}
</Space>
</Space>
{onCreateStorageTarget && (
<StorageTargetFormDrawer
visible={quickCreateVisible}
loading={quickCreateLoading}
testing={quickCreateTesting}
initialValue={null}
onCancel={() => setQuickCreateVisible(false)}
onSubmit={handleQuickCreateSubmit}
onTest={handleQuickCreateTest}
onGoogleDriveAuth={onGoogleDriveAuth ?? (async () => {})}
/>
)}
</Drawer>
)
}

View File

@@ -0,0 +1,120 @@
import { Button, Checkbox, Input, Message, Space, Spin, Typography } from '@arco-design/web-react'
import { useState } from 'react'
import { discoverDatabases } from '../../services/database'
interface DatabasePickerProps {
dbType: 'mysql' | 'postgresql'
dbHost: string
dbPort: number
dbUser: string
dbPassword: string
value: string
onChange: (value: string) => void
}
export function DatabasePicker({ dbType, dbHost, dbPort, dbUser, dbPassword, value, onChange }: DatabasePickerProps) {
const [databases, setDatabases] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [discovered, setDiscovered] = useState(false)
const [error, setError] = useState('')
const selectedDbs = value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const canDiscover = dbHost.trim() && dbPort > 0 && dbUser.trim() && dbPassword.trim()
async function handleDiscover() {
setLoading(true)
setError('')
try {
const result = await discoverDatabases({
type: dbType,
host: dbHost.trim(),
port: dbPort,
user: dbUser.trim(),
password: dbPassword.trim(),
})
setDatabases(result)
setDiscovered(true)
if (result.length === 0) {
setError('未发现用户数据库')
}
} catch (discoverError: any) {
const msg = discoverError?.response?.data?.message ?? discoverError?.message ?? '发现数据库失败'
setError(msg)
Message.error(msg)
} finally {
setLoading(false)
}
}
function handleToggle(db: string, checked: boolean) {
let next: string[]
if (checked) {
next = [...selectedDbs, db]
} else {
next = selectedDbs.filter((d) => d !== db)
}
onChange(next.join(','))
}
function handleSelectAll() {
onChange(databases.join(','))
}
function handleDeselectAll() {
onChange('')
}
return (
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Space style={{ width: '100%' }}>
<Input
style={{ flex: 1 }}
value={value}
placeholder="数据库名称(多个以逗号分隔)"
onChange={onChange}
/>
<Button
type="outline"
size="small"
loading={loading}
disabled={!canDiscover}
onClick={handleDiscover}
>
</Button>
</Space>
{error && <Typography.Text type="error">{error}</Typography.Text>}
{loading && <Spin size={16} />}
{discovered && databases.length > 0 && (
<div style={{ border: '1px solid var(--color-border-2)', borderRadius: 4, padding: '8px 12px', maxHeight: 200, overflow: 'auto' }}>
<Space size="mini" style={{ marginBottom: 8 }}>
<Button type="text" size="mini" onClick={handleSelectAll}>
</Button>
<Button type="text" size="mini" onClick={handleDeselectAll}>
</Button>
</Space>
<Space direction="vertical" size={4}>
{databases.map((db) => (
<Checkbox
key={db}
checked={selectedDbs.includes(db)}
onChange={(checked) => handleToggle(db, checked)}
>
{db}
</Checkbox>
))}
</Space>
</div>
)}
</Space>
)
}

View File

@@ -0,0 +1,141 @@
import { Button, Input, Modal, Space, Spin, Tree, Typography } from '@arco-design/web-react'
import { useCallback, useState } from 'react'
import { listNodeDirectory } from '../../services/nodes'
import type { DirEntry } from '../../types/nodes'
interface DirectoryPickerProps {
value: string
onChange: (path: string) => void
placeholder?: string
mode?: 'directory' | 'file'
nodeId?: number
}
interface TreeNodeData {
key: string
title: string
isLeaf: boolean
children?: TreeNodeData[]
}
function entriesToTreeNodes(entries: DirEntry[], mode: 'directory' | 'file'): TreeNodeData[] {
return entries
.filter((entry) => mode === 'file' || entry.isDir)
.map((entry) => ({
key: entry.path,
title: entry.name,
isLeaf: !entry.isDir,
children: entry.isDir ? [] : undefined,
}))
}
export function DirectoryPicker({ value, onChange, placeholder, mode = 'directory', nodeId }: DirectoryPickerProps) {
const [modalVisible, setModalVisible] = useState(false)
const [treeData, setTreeData] = useState<TreeNodeData[]>([])
const [loading, setLoading] = useState(false)
const [selectedPath, setSelectedPath] = useState('')
const [error, setError] = useState('')
const loadDirectory = useCallback(
async (path: string) => {
if (nodeId === undefined) return []
try {
const entries = await listNodeDirectory(nodeId, path)
return entriesToTreeNodes(entries, mode)
} catch {
setError('加载目录失败')
return []
}
},
[nodeId, mode],
)
async function handleOpen() {
setModalVisible(true)
setSelectedPath(value || '')
setError('')
setLoading(true)
try {
const rootNodes = await loadDirectory('/')
setTreeData(rootNodes)
} finally {
setLoading(false)
}
}
async function handleLoadMore(node: TreeNodeData) {
const children = await loadDirectory(node.key)
setTreeData((prev) => {
function updateChildren(nodes: TreeNodeData[]): TreeNodeData[] {
return nodes.map((n) => {
if (n.key === node.key) {
return { ...n, children }
}
if (n.children) {
return { ...n, children: updateChildren(n.children) }
}
return n
})
}
return updateChildren(prev)
})
}
function handleConfirm() {
if (selectedPath) {
onChange(selectedPath)
}
setModalVisible(false)
}
if (nodeId === undefined) {
return <Input value={value} placeholder={placeholder} onChange={onChange} />
}
return (
<>
<Space style={{ width: '100%' }}>
<Input style={{ flex: 1 }} value={value} placeholder={placeholder} onChange={onChange} />
<Button type="outline" size="small" onClick={handleOpen}>
</Button>
</Space>
<Modal
title={mode === 'directory' ? '选择目录' : '选择文件'}
visible={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleConfirm}
okText="选择"
cancelText="取消"
style={{ width: 520 }}
okButtonProps={{ disabled: !selectedPath }}
>
{error && <Typography.Text type="error">{error}</Typography.Text>}
{selectedPath && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
: {selectedPath}
</Typography.Text>
)}
{loading ? (
<Spin style={{ display: 'block', textAlign: 'center', padding: 24 }} />
) : treeData.length === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<div style={{ maxHeight: 400, overflow: 'auto' }}>
<Tree
treeData={treeData as any}
onSelect={(keys) => {
if (keys.length > 0) {
setSelectedPath(keys[0] as string)
}
}}
selectedKeys={selectedPath ? [selectedPath] : []}
loadMore={(node: any) => handleLoadMore(node.props)}
/>
</div>
)}
</Modal>
</>
)
}