Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions server/internal/http/backup_record_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ func (h *BackupRecordHandler) Get(c *gin.Context) {
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) {
id, ok := parseUintParam(c, "id")
if !ok {
Expand Down
1 change: 1 addition & 0 deletions server/internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
backupRecords.GET("/:id", backupRecordHandler.Get)
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.GET("/:id/contents", backupRecordHandler.Contents)
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
Expand Down
59 changes: 59 additions & 0 deletions server/internal/service/backup_record_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -80,6 +81,64 @@ func (s *BackupRecordService) Get(ctx context.Context, id uint) (*BackupRecordDe
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) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
Expand Down
109 changes: 109 additions & 0 deletions web/src/components/backup-records/BackupRecordContentsModal.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
4 changes: 4 additions & 0 deletions web/src/components/backup-records/BackupRecordLogDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { BackupTaskDetail } from '../../types/backup-tasks'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
import { RestoreConfirmModal } from '../restore-records/RestoreConfirmModal'
import { BackupRecordContentsModal } from './BackupRecordContentsModal'

interface BackupRecordLogDrawerProps {
visible: boolean
Expand Down Expand Up @@ -53,6 +54,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
const [restoreLoading, setRestoreLoading] = useState(false)
const [restorePreparing, setRestorePreparing] = useState(false)
const [verifyLoading, setVerifyLoading] = useState(false)
const [contentsVisible, setContentsVisible] = useState(false)

useEffect(() => {
if (!visible || !recordId) {
Expand Down Expand Up @@ -268,6 +270,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
<Button loading={acting} onClick={handleDownload}>
下载
</Button>
<Button onClick={() => setContentsVisible(true)}>查看内容</Button>
{writable && (
<Button
type="primary"
Expand Down Expand Up @@ -324,6 +327,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
}}
onConfirm={() => void handleConfirmRestore()}
/>
<BackupRecordContentsModal visible={contentsVisible} recordId={recordId} onClose={() => setContentsVisible(false)} />
</Drawer>
)
}
8 changes: 7 additions & 1 deletion web/src/services/backup-records.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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'

interface RecordLogStreamHandlers {
Expand Down Expand Up @@ -69,6 +69,12 @@ export async function getBackupRecord(id: number) {
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) {
const response = await http.get<Blob>(`/backup/records/${id}/download`, { responseType: 'blob' })
return {
Expand Down
14 changes: 14 additions & 0 deletions web/src/types/backup-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ export interface BackupRecordSummary {
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 {
storageTargetId: number
storageTargetName: string
Expand Down
Loading