mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-11 18:10:23 +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:
@@ -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>
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
120
web/src/components/common/DatabasePicker.tsx
Normal file
120
web/src/components/common/DatabasePicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
web/src/components/common/DirectoryPicker.tsx
Normal file
141
web/src/components/common/DirectoryPicker.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconDown,
|
||||
IconCloud,
|
||||
IconDesktop,
|
||||
IconList,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
@@ -37,6 +38,9 @@ function resolveSelectedKey(pathname: string) {
|
||||
if (pathname.startsWith('/settings/notifications')) {
|
||||
return '/settings/notifications'
|
||||
}
|
||||
if (pathname.startsWith('/audit')) {
|
||||
return '/audit'
|
||||
}
|
||||
if (pathname.startsWith('/nodes')) {
|
||||
return '/nodes'
|
||||
}
|
||||
@@ -53,6 +57,7 @@ const menuItems = [
|
||||
{ key: '/storage-targets', label: '存储目标', icon: <IconStorage /> },
|
||||
{ key: '/nodes', label: '节点管理', icon: <IconDesktop /> },
|
||||
{ key: '/settings/notifications', label: '通知配置', icon: <IconNotification /> },
|
||||
{ key: '/audit', label: '审计日志', icon: <IconList /> },
|
||||
{ key: '/settings', label: '系统设置', icon: <IconSettings /> },
|
||||
]
|
||||
|
||||
|
||||
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>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BackupTasksPage } from '../pages/backup-tasks/BackupTasksPage'
|
||||
import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCallbackPage'
|
||||
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
|
||||
import { SettingsPage } from '../pages/settings/SettingsPage'
|
||||
import { AuditLogsPage } from '../pages/audit/AuditLogsPage'
|
||||
import NodesPage from '../pages/nodes/NodesPage'
|
||||
import { ProtectedRoute } from './ProtectedRoute'
|
||||
|
||||
@@ -32,6 +33,7 @@ export function RouterView() {
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="settings/notifications" element={<NotificationsPage />} />
|
||||
<Route path="nodes" element={<NodesPage />} />
|
||||
<Route path="audit" element={<AuditLogsPage />} />
|
||||
<Route path="system-info" element={<Navigate to="/settings" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
7
web/src/services/audit.ts
Normal file
7
web/src/services/audit.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { http } from './http'
|
||||
import type { AuditLogListResult } from '../types/audit'
|
||||
|
||||
export async function listAuditLogs(params: { category?: string; limit?: number; offset?: number }) {
|
||||
const response = await http.get<{ code: string; message: string; data: AuditLogListResult }>('/audit-logs', { params })
|
||||
return response.data.data
|
||||
}
|
||||
18
web/src/services/database.ts
Normal file
18
web/src/services/database.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
|
||||
export interface DatabaseDiscoverPayload {
|
||||
type: 'mysql' | 'postgresql'
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface DatabaseDiscoverResult {
|
||||
databases: string[]
|
||||
}
|
||||
|
||||
export async function discoverDatabases(payload: DatabaseDiscoverPayload): Promise<string[]> {
|
||||
const response = await http.post<ApiEnvelope<DatabaseDiscoverResult>>('/database/discover', payload, { timeout: 10000 })
|
||||
return unwrapApiEnvelope(response.data).databases ?? []
|
||||
}
|
||||
@@ -74,6 +74,11 @@ export interface StorageTargetUsage {
|
||||
totalSize: number
|
||||
}
|
||||
|
||||
export async function toggleStorageTargetStar(id: number) {
|
||||
const response = await http.put<ApiEnvelope<StorageTargetSummary>>(`/storage-targets/${id}/star`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function getStorageTargetUsage(id: number) {
|
||||
const response = await http.get<ApiEnvelope<StorageTargetUsage>>(`/storage-targets/${id}/usage`)
|
||||
return unwrap(response.data)
|
||||
|
||||
18
web/src/types/audit.ts
Normal file
18
web/src/types/audit.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface AuditLog {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
category: string
|
||||
action: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
targetName: string
|
||||
detail: string
|
||||
clientIp: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AuditLogListResult {
|
||||
items: AuditLog[]
|
||||
total: number
|
||||
}
|
||||
@@ -26,9 +26,19 @@ export interface BackupRecordSummary {
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export interface StorageUploadResultItem {
|
||||
storageTargetId: number
|
||||
storageTargetName: string
|
||||
status: 'success' | 'failed'
|
||||
storagePath?: string
|
||||
fileSize?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BackupRecordDetail extends BackupRecordSummary {
|
||||
logContent: string
|
||||
logEvents?: BackupLogEvent[]
|
||||
storageUploadResults?: StorageUploadResultItem[]
|
||||
}
|
||||
|
||||
export interface BackupRecordListFilter {
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface BackupTaskSummary {
|
||||
cronExpr: string
|
||||
storageTargetId: number
|
||||
storageTargetName: string
|
||||
storageTargetIds: number[]
|
||||
storageTargetNames: string[]
|
||||
nodeId: number
|
||||
nodeName?: string
|
||||
tags: string
|
||||
@@ -24,6 +26,7 @@ export interface BackupTaskSummary {
|
||||
|
||||
export interface BackupTaskDetail extends BackupTaskSummary {
|
||||
sourcePath: string
|
||||
sourcePaths: string[]
|
||||
excludePatterns: string[]
|
||||
dbHost: string
|
||||
dbPort: number
|
||||
@@ -40,6 +43,7 @@ export interface BackupTaskPayload {
|
||||
enabled: boolean
|
||||
cronExpr: string
|
||||
sourcePath: string
|
||||
sourcePaths: string[]
|
||||
excludePatterns: string[]
|
||||
dbHost: string
|
||||
dbPort: number
|
||||
@@ -48,6 +52,7 @@ export interface BackupTaskPayload {
|
||||
dbName: string
|
||||
dbPath: string
|
||||
storageTargetId: number
|
||||
storageTargetIds: number[]
|
||||
nodeId: number
|
||||
tags: string
|
||||
retentionDays: number
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface StorageTargetSummary {
|
||||
type: StorageTargetType
|
||||
description: string
|
||||
enabled: boolean
|
||||
starred: boolean
|
||||
updatedAt: string
|
||||
lastTestedAt?: string
|
||||
lastTestStatus: StorageTestStatus
|
||||
|
||||
Reference in New Issue
Block a user