mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-07 08:26:42 +08:00
feat(backup): 新增备份内容浏览 (#90)
查看每次备份捕获的文件清单(路径/大小/目录),核对完整性、排查遗漏。清单取自全量备份记录,无需下载解压;差异记录回退基线清单。只读端点 + 前端可筛选弹窗。
This commit is contained in:
@@ -52,6 +52,20 @@ func (h *BackupRecordHandler) Get(c *gin.Context) {
|
|||||||
response.Success(c, item)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contents 返回备份记录的文件清单(内容浏览,只读)。
|
||||||
|
func (h *BackupRecordHandler) Contents(c *gin.Context) {
|
||||||
|
id, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contents, err := h.service.ListContents(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, contents)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *BackupRecordHandler) StreamLogs(c *gin.Context) {
|
func (h *BackupRecordHandler) StreamLogs(c *gin.Context) {
|
||||||
id, ok := parseUintParam(c, "id")
|
id, ok := parseUintParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
backupRecords.GET("/:id", backupRecordHandler.Get)
|
backupRecords.GET("/:id", backupRecordHandler.Get)
|
||||||
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
||||||
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
||||||
|
backupRecords.GET("/:id/contents", backupRecordHandler.Contents)
|
||||||
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
|
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
|
||||||
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
|
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
|
||||||
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
|
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -80,6 +81,64 @@ func (s *BackupRecordService) Get(ctx context.Context, id uint) (*BackupRecordDe
|
|||||||
return toBackupRecordDetail(item, s.logHub), nil
|
return toBackupRecordDetail(item, s.logHub), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BackupContentEntry 描述备份内单个条目(文件或目录),用于内容浏览。
|
||||||
|
type BackupContentEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupRecordContents 是一次备份的内容清单视图。
|
||||||
|
type BackupRecordContents struct {
|
||||||
|
RecordID uint `json:"recordId"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
BasedOnFull uint `json:"basedOnFull,omitempty"` // 差异记录时,清单取自该基线全量
|
||||||
|
Entries []BackupContentEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupContentsMaxEntries = 10000
|
||||||
|
|
||||||
|
// ListContents 返回某备份记录的文件清单(仅文件类型的新全量备份会记录清单)。
|
||||||
|
// 差异记录回退到其基线全量的清单,近似展示恢复后的目录结构。无清单时返回明确错误。
|
||||||
|
func (s *BackupRecordService) ListContents(ctx context.Context, id uint) (*BackupRecordContents, error) {
|
||||||
|
item, err := s.records.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err)
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", nil)
|
||||||
|
}
|
||||||
|
manifestJSON := item.Manifest
|
||||||
|
basedOnFull := uint(0)
|
||||||
|
if strings.TrimSpace(manifestJSON) == "" && item.BaseRecordID != 0 {
|
||||||
|
if base, baseErr := s.records.FindByID(ctx, item.BaseRecordID); baseErr == nil && base != nil {
|
||||||
|
manifestJSON = base.Manifest
|
||||||
|
basedOnFull = base.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(manifestJSON) == "" {
|
||||||
|
return nil, apperror.New(422, "BACKUP_CONTENTS_UNAVAILABLE", "该备份未记录文件清单(仅文件类型的新全量备份支持内容浏览),请重新执行一次全量备份后再试。", nil)
|
||||||
|
}
|
||||||
|
manifest, decErr := backup.DecodeManifest([]byte(manifestJSON))
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, apperror.Internal("BACKUP_CONTENTS_DECODE_FAILED", "解析备份清单失败", decErr)
|
||||||
|
}
|
||||||
|
entries := manifest.Entries
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path })
|
||||||
|
total := len(entries)
|
||||||
|
truncated := false
|
||||||
|
if total > backupContentsMaxEntries {
|
||||||
|
entries = entries[:backupContentsMaxEntries]
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
result := &BackupRecordContents{RecordID: item.ID, Total: total, Truncated: truncated, BasedOnFull: basedOnFull, Entries: make([]BackupContentEntry, 0, len(entries))}
|
||||||
|
for _, e := range entries {
|
||||||
|
result.Entries = append(result.Entries, BackupContentEntry{Path: e.Path, Size: e.Size, IsDir: e.IsDir})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *BackupRecordService) SubscribeLogs(ctx context.Context, id uint, buffer int) (<-chan backup.LogEvent, func(), error) {
|
func (s *BackupRecordService) SubscribeLogs(ctx context.Context, id uint, buffer int) (<-chan backup.LogEvent, func(), error) {
|
||||||
item, err := s.records.FindByID(ctx, id)
|
item, err := s.records.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Alert, Input, Modal, Spin, Table, Tag, Typography } from '@arco-design/web-react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { getBackupRecordContents } from '../../services/backup-records'
|
||||||
|
import type { BackupRecordContentEntry, BackupRecordContents } from '../../types/backup-records'
|
||||||
|
import { resolveErrorMessage } from '../../utils/error'
|
||||||
|
import { formatBytes } from '../../utils/format'
|
||||||
|
|
||||||
|
interface BackupRecordContentsModalProps {
|
||||||
|
visible: boolean
|
||||||
|
recordId?: number
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。
|
||||||
|
// 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。
|
||||||
|
export function BackupRecordContentsModal({ visible, recordId, onClose }: BackupRecordContentsModalProps) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [contents, setContents] = useState<BackupRecordContents | null>(null)
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !recordId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let active = true
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
setKeyword('')
|
||||||
|
setContents(null)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const data = await getBackupRecordContents(recordId)
|
||||||
|
if (active) {
|
||||||
|
setContents(data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (active) {
|
||||||
|
setError(resolveErrorMessage(e, '加载备份内容失败'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [visible, recordId])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const entries = contents?.entries ?? []
|
||||||
|
const kw = keyword.trim().toLowerCase()
|
||||||
|
if (!kw) {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
return entries.filter((e) => e.path.toLowerCase().includes(kw))
|
||||||
|
}, [contents, keyword])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} title="备份内容" footer={null} onCancel={onClose} unmountOnExit style={{ width: 760 }}>
|
||||||
|
{loading ? (
|
||||||
|
<Spin style={{ display: 'block', textAlign: 'center', padding: 40 }} />
|
||||||
|
) : error ? (
|
||||||
|
<Alert type="warning" content={error} />
|
||||||
|
) : contents ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
共 {contents.total} 个条目
|
||||||
|
{contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''}
|
||||||
|
{contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
<Input.Search allowClear placeholder="按路径筛选" value={keyword} onChange={setKeyword} style={{ margin: '8px 0' }} />
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="path"
|
||||||
|
data={filtered}
|
||||||
|
pagination={{ pageSize: 50, sizeCanChange: false }}
|
||||||
|
scroll={{ y: 420 }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '路径',
|
||||||
|
dataIndex: 'path',
|
||||||
|
render: (_: unknown, row: BackupRecordContentEntry) => (
|
||||||
|
<span>
|
||||||
|
{row.isDir ? (
|
||||||
|
<Tag size="small" color="arcoblue">
|
||||||
|
目录
|
||||||
|
</Tag>
|
||||||
|
) : null}{' '}
|
||||||
|
{row.path}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '大小',
|
||||||
|
dataIndex: 'size',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
render: (_: unknown, row: BackupRecordContentEntry) => (row.isDir ? '-' : formatBytes(row.size)),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type { BackupTaskDetail } from '../../types/backup-tasks'
|
|||||||
import { resolveErrorMessage } from '../../utils/error'
|
import { resolveErrorMessage } from '../../utils/error'
|
||||||
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
|
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
|
||||||
import { RestoreConfirmModal } from '../restore-records/RestoreConfirmModal'
|
import { RestoreConfirmModal } from '../restore-records/RestoreConfirmModal'
|
||||||
|
import { BackupRecordContentsModal } from './BackupRecordContentsModal'
|
||||||
|
|
||||||
interface BackupRecordLogDrawerProps {
|
interface BackupRecordLogDrawerProps {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -53,6 +54,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
|||||||
const [restoreLoading, setRestoreLoading] = useState(false)
|
const [restoreLoading, setRestoreLoading] = useState(false)
|
||||||
const [restorePreparing, setRestorePreparing] = useState(false)
|
const [restorePreparing, setRestorePreparing] = useState(false)
|
||||||
const [verifyLoading, setVerifyLoading] = useState(false)
|
const [verifyLoading, setVerifyLoading] = useState(false)
|
||||||
|
const [contentsVisible, setContentsVisible] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !recordId) {
|
if (!visible || !recordId) {
|
||||||
@@ -268,6 +270,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
|||||||
<Button loading={acting} onClick={handleDownload}>
|
<Button loading={acting} onClick={handleDownload}>
|
||||||
下载
|
下载
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => setContentsVisible(true)}>查看内容</Button>
|
||||||
{writable && (
|
{writable && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -324,6 +327,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
|||||||
}}
|
}}
|
||||||
onConfirm={() => void handleConfirmRestore()}
|
onConfirm={() => void handleConfirmRestore()}
|
||||||
/>
|
/>
|
||||||
|
<BackupRecordContentsModal visible={contentsVisible} recordId={recordId} onClose={() => setContentsVisible(false)} />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||||
import type { BackupLogEvent, BackupRecordDetail, BackupRecordListFilter, BackupRecordSummary } from '../types/backup-records'
|
import type { BackupLogEvent, BackupRecordContents, BackupRecordDetail, BackupRecordListFilter, BackupRecordSummary } from '../types/backup-records'
|
||||||
import { resolveErrorMessage } from '../utils/error'
|
import { resolveErrorMessage } from '../utils/error'
|
||||||
|
|
||||||
interface RecordLogStreamHandlers {
|
interface RecordLogStreamHandlers {
|
||||||
@@ -69,6 +69,12 @@ export async function getBackupRecord(id: number) {
|
|||||||
return unwrapApiEnvelope(response.data)
|
return unwrapApiEnvelope(response.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getBackupRecordContents 获取备份记录的文件清单(内容浏览,只读)。
|
||||||
|
export async function getBackupRecordContents(id: number) {
|
||||||
|
const response = await http.get<ApiEnvelope<BackupRecordContents>>(`/backup/records/${id}/contents`)
|
||||||
|
return unwrapApiEnvelope(response.data)
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadBackupRecord(id: number) {
|
export async function downloadBackupRecord(id: number) {
|
||||||
const response = await http.get<Blob>(`/backup/records/${id}/download`, { responseType: 'blob' })
|
const response = await http.get<Blob>(`/backup/records/${id}/download`, { responseType: 'blob' })
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ export interface BackupRecordSummary {
|
|||||||
backupKind: 'full' | 'differential'
|
backupKind: 'full' | 'differential'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupRecordContentEntry {
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
isDir: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupRecordContents {
|
||||||
|
recordId: number
|
||||||
|
total: number
|
||||||
|
truncated: boolean
|
||||||
|
basedOnFull?: number
|
||||||
|
entries: BackupRecordContentEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorageUploadResultItem {
|
export interface StorageUploadResultItem {
|
||||||
storageTargetId: number
|
storageTargetId: number
|
||||||
storageTargetName: string
|
storageTargetName: string
|
||||||
|
|||||||
Reference in New Issue
Block a user