mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-31 17:49:55 +08:00
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:
154
web/src/pages/audit/AuditLogsPage.tsx
Normal file
154
web/src/pages/audit/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { 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 { listAuditLogs } from '../../services/audit'
|
||||
import type { AuditLog } from '../../types/audit'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '认证', value: 'auth' },
|
||||
{ label: '存储目标', value: 'storage_target' },
|
||||
{ label: '备份任务', value: 'backup_task' },
|
||||
{ label: '备份记录', value: 'backup_record' },
|
||||
{ label: '系统设置', value: 'settings' },
|
||||
]
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
auth: '认证',
|
||||
storage_target: '存储目标',
|
||||
backup_task: '备份任务',
|
||||
backup_record: '备份记录',
|
||||
settings: '系统设置',
|
||||
}
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
login_success: '登录成功',
|
||||
login_failed: '登录失败',
|
||||
setup: '系统初始化',
|
||||
change_password: '修改密码',
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
enable: '启用',
|
||||
disable: '停用',
|
||||
run: '执行',
|
||||
restore: '恢复',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const columns: ColumnProps<AuditLog>[] = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 180,
|
||||
render: (_, record) => formatDateTime(record.createdAt),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Tag bordered>{categoryLabels[record.category] ?? record.category}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => actionLabels[record.action] ?? record.action,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '目标',
|
||||
dataIndex: 'targetName',
|
||||
width: 160,
|
||||
render: (_, record) => record.targetName || record.targetId || '-',
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
dataIndex: 'detail',
|
||||
render: (_, record) => record.detail || '-',
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
dataIndex: 'clientIp',
|
||||
width: 130,
|
||||
render: (_, record) => record.clientIp || '-',
|
||||
},
|
||||
]
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const fetchData = useCallback(async (cat: string, currentPage: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await listAuditLogs({
|
||||
category: cat || 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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData(category, page)
|
||||
}, [category, page, fetchData])
|
||||
|
||||
function handleCategoryChange(value: string) {
|
||||
setCategory(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<PageHeader
|
||||
style={{ paddingBottom: 0 }}
|
||||
title="审计日志"
|
||||
subTitle="记录系统中所有关键操作,保障数据操作链可溯源"
|
||||
/>
|
||||
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={category}
|
||||
options={categoryOptions}
|
||||
onChange={handleCategoryChange}
|
||||
placeholder="筛选分类"
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={logs}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
total,
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
onChange: setPage,
|
||||
showTotal: true,
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import { BackupTaskDetailDrawer } from '../../components/backup-tasks/BackupTask
|
||||
import { BackupTaskFormDrawer } from '../../components/backup-tasks/BackupTaskFormDrawer'
|
||||
import { getBackupTaskStatusColor, getBackupTaskStatusLabel, getBackupTaskTypeLabel } from '../../components/backup-tasks/field-config'
|
||||
import { createBackupTask, deleteBackupTask, getBackupTask, listBackupTasks, runBackupTask, toggleBackupTask, updateBackupTask } from '../../services/backup-tasks'
|
||||
import { listStorageTargets } from '../../services/storage-targets'
|
||||
import { listNodes } from '../../services/nodes'
|
||||
import { createStorageTarget, listStorageTargets, startGoogleDriveAuth, testStorageTarget } from '../../services/storage-targets'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskSummary } from '../../types/backup-tasks'
|
||||
import type { StorageTargetSummary } from '../../types/storage-targets'
|
||||
import type { StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
@@ -22,15 +23,20 @@ export function BackupTasksPage() {
|
||||
const [editingTask, setEditingTask] = useState<BackupTaskDetail | null>(null)
|
||||
const [detailTask, setDetailTask] = useState<BackupTaskDetail | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [localNodeId, setLocalNodeId] = useState<number | undefined>(undefined)
|
||||
|
||||
const enabledStorageTargets = useMemo(() => storageTargets.filter((item) => item.enabled), [storageTargets])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [taskList, targetList] = await Promise.all([listBackupTasks(), listStorageTargets()])
|
||||
const [taskList, targetList, nodeList] = await Promise.all([listBackupTasks(), listStorageTargets(), listNodes()])
|
||||
setTasks(taskList)
|
||||
setStorageTargets(targetList)
|
||||
const localNode = nodeList.find((n) => n.isLocal)
|
||||
if (localNode) {
|
||||
setLocalNodeId(localNode.id)
|
||||
}
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载备份任务失败'))
|
||||
@@ -123,6 +129,28 @@ export function BackupTasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateStorageTarget(value: StorageTargetPayload) {
|
||||
const result = await createStorageTarget(value)
|
||||
Message.success('存储目标已创建')
|
||||
return result
|
||||
}
|
||||
|
||||
async function handleTestStorageTarget(value: StorageTargetPayload) {
|
||||
const result = await testStorageTarget(value)
|
||||
Message.success(result.message)
|
||||
return result
|
||||
}
|
||||
|
||||
async function handleGoogleDriveAuth(value: StorageTargetPayload, targetId?: number) {
|
||||
const result = await startGoogleDriveAuth(value, targetId)
|
||||
window.open(result.authUrl, '_blank')
|
||||
}
|
||||
|
||||
async function reloadStorageTargets() {
|
||||
const targetList = await listStorageTargets()
|
||||
setStorageTargets(targetList)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '任务名称',
|
||||
@@ -146,8 +174,18 @@ export function BackupTasksPage() {
|
||||
},
|
||||
{
|
||||
title: '存储目标',
|
||||
dataIndex: 'storageTargetName',
|
||||
render: (value: string) => value || '-',
|
||||
dataIndex: 'storageTargetNames',
|
||||
render: (_: unknown, record: BackupTaskSummary) => {
|
||||
const names = record.storageTargetNames?.length > 0 ? record.storageTargetNames : record.storageTargetName ? [record.storageTargetName] : []
|
||||
if (names.length === 0) return '-'
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{names.map((name, i) => (
|
||||
<Tag key={i} color="arcoblue" bordered>{name}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '策略',
|
||||
@@ -228,11 +266,16 @@ export function BackupTasksPage() {
|
||||
loading={submitting}
|
||||
initialValue={editingTask}
|
||||
storageTargets={enabledStorageTargets}
|
||||
localNodeId={localNodeId}
|
||||
onCancel={() => {
|
||||
setDrawerVisible(false)
|
||||
setEditingTask(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onCreateStorageTarget={handleCreateStorageTarget}
|
||||
onTestStorageTarget={handleTestStorageTarget}
|
||||
onGoogleDriveAuth={handleGoogleDriveAuth}
|
||||
onStorageTargetCreated={reloadStorageTargets}
|
||||
/>
|
||||
|
||||
<BackupTaskDetailDrawer
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
startGoogleDriveAuth,
|
||||
testSavedStorageTarget,
|
||||
testStorageTarget,
|
||||
toggleStorageTargetStar,
|
||||
updateStorageTarget,
|
||||
} from '../../services/storage-targets'
|
||||
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
@@ -148,6 +149,15 @@ export function StorageTargetsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStar(id: number) {
|
||||
try {
|
||||
await toggleStorageTargetStar(id)
|
||||
await loadTargets()
|
||||
} catch (starError) {
|
||||
Message.error(resolveErrorMessage(starError))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleDriveAuth(value: StorageTargetPayload, targetId?: number) {
|
||||
try {
|
||||
const result = await startGoogleDriveAuth(value, targetId)
|
||||
@@ -194,7 +204,7 @@ export function StorageTargetsPage() {
|
||||
<Space size="large" align="start" style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginBottom: 4 }}>
|
||||
{target.name}
|
||||
{target.starred ? '★ ' : ''}{target.name}
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
{getStorageTargetTypeLabel(target.type) && <Tag color="arcoblue" bordered>{getStorageTargetTypeLabel(target.type)}</Tag>}
|
||||
@@ -211,6 +221,9 @@ export function StorageTargetsPage() {
|
||||
<Typography.Text type="secondary">更新时间:{target.updatedAt}</Typography.Text>
|
||||
|
||||
<Space wrap size="mini">
|
||||
<Button size="small" type="text" onClick={() => void handleToggleStar(target.id)}>
|
||||
{target.starred ? '取消收藏' : '收藏'}
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={() => void openEdit(target.id)} loading={submitting && editingTarget?.id === target.id}>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user