mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-05 23:47:33 +08:00
feat(reports): 企业合规报表(后端聚合 + CSV 导出 + 前端页面) (#82)
新增合规报表:ReportService 逐任务聚合备份合规证据(成功率/最近成功/SLA 判定/加密/受保护量)+ JSON/CSV API;前端新增 /reports 页面(汇总卡片+明细表+CSV 导出)。后端 go test、前端 tsc+vite、端到端路由验证均通过。
This commit is contained in:
@@ -104,6 +104,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
|
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
|
||||||
restoreLogHub := backup.NewLogHub()
|
restoreLogHub := backup.NewLogHub()
|
||||||
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
||||||
|
reportService := service.NewReportService(backupTaskRepo, backupRecordRepo)
|
||||||
settingsService := service.NewSettingsService(systemConfigRepo)
|
settingsService := service.NewSettingsService(systemConfigRepo)
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
@@ -268,6 +269,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
ApiKeyService: apiKeyService,
|
ApiKeyService: apiKeyService,
|
||||||
NotificationService: notificationService,
|
NotificationService: notificationService,
|
||||||
DashboardService: dashboardService,
|
DashboardService: dashboardService,
|
||||||
|
ReportService: reportService,
|
||||||
SettingsService: settingsService,
|
SettingsService: settingsService,
|
||||||
NodeService: nodeService,
|
NodeService: nodeService,
|
||||||
AgentService: agentService,
|
AgentService: agentService,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"backupx/server/internal/service"
|
||||||
|
"backupx/server/pkg/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReportHandler struct {
|
||||||
|
service *service.ReportService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportHandler(reportService *service.ReportService) *ReportHandler {
|
||||||
|
return &ReportHandler{service: reportService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportDays(c *gin.Context) int {
|
||||||
|
days := 30
|
||||||
|
if v := strings.TrimSpace(c.Query("days")); v != "" {
|
||||||
|
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||||
|
days = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compliance 返回 JSON 合规报表(按任务的备份合规证据 + 汇总)。
|
||||||
|
func (h *ReportHandler) Compliance(c *gin.Context) {
|
||||||
|
payload, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComplianceCSV 把合规报表导出为 CSV(供审计归档)。带 UTF-8 BOM 以便 Excel 正确识别中文。
|
||||||
|
func (h *ReportHandler) ComplianceCSV(c *gin.Context) {
|
||||||
|
report, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("backupx-compliance-%s.csv", report.GeneratedAt.Format("20060102-150405"))
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||||
|
_, _ = c.Writer.WriteString("\ufeff") // UTF-8 BOM
|
||||||
|
w := csv.NewWriter(c.Writer)
|
||||||
|
_ = w.Write([]string{
|
||||||
|
"任务ID", "任务名", "类型", "启用", "节点", "加密", "保留天数", "SLA(RPO小时)",
|
||||||
|
"周期内运行", "成功", "失败", "成功率", "最近状态", "最近运行(UTC)", "最近成功(UTC)", "保护字节数", "合规判定",
|
||||||
|
})
|
||||||
|
for _, row := range report.Tasks {
|
||||||
|
_ = w.Write([]string{
|
||||||
|
strconv.FormatUint(uint64(row.TaskID), 10),
|
||||||
|
row.TaskName,
|
||||||
|
row.Type,
|
||||||
|
boolCN(row.Enabled),
|
||||||
|
row.NodeName,
|
||||||
|
boolCN(row.Encrypted),
|
||||||
|
strconv.Itoa(row.RetentionDays),
|
||||||
|
strconv.Itoa(row.SLAHoursRPO),
|
||||||
|
strconv.Itoa(row.TotalRuns),
|
||||||
|
strconv.Itoa(row.Successes),
|
||||||
|
strconv.Itoa(row.Failures),
|
||||||
|
fmt.Sprintf("%.2f%%", row.SuccessRate*100),
|
||||||
|
row.LastStatus,
|
||||||
|
fmtTimePtr(row.LastRunAt),
|
||||||
|
fmtTimePtr(row.LastSuccessAt),
|
||||||
|
strconv.FormatInt(row.ProtectedBytes, 10),
|
||||||
|
row.Risk,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolCN(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "是"
|
||||||
|
}
|
||||||
|
return "否"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtTimePtr(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.UTC().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ type RouterDependencies struct {
|
|||||||
ApiKeyService *service.ApiKeyService
|
ApiKeyService *service.ApiKeyService
|
||||||
NotificationService *service.NotificationService
|
NotificationService *service.NotificationService
|
||||||
DashboardService *service.DashboardService
|
DashboardService *service.DashboardService
|
||||||
|
ReportService *service.ReportService
|
||||||
SettingsService *service.SettingsService
|
SettingsService *service.SettingsService
|
||||||
NodeService *service.NodeService
|
NodeService *service.NodeService
|
||||||
AgentService *service.AgentService
|
AgentService *service.AgentService
|
||||||
@@ -211,6 +212,15 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
// 基于备份记录的验证入口:与 restore 对称
|
// 基于备份记录的验证入口:与 restore 对称
|
||||||
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
|
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
|
||||||
}
|
}
|
||||||
|
// 企业合规报表:按任务的可导出备份合规证据(区别于 Dashboard 的实时聚合视图)。
|
||||||
|
if deps.ReportService != nil {
|
||||||
|
reportHandler := NewReportHandler(deps.ReportService)
|
||||||
|
reports := api.Group("/reports")
|
||||||
|
reports.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||||
|
reports.GET("/compliance", reportHandler.Compliance)
|
||||||
|
reports.GET("/compliance/export", reportHandler.ComplianceCSV)
|
||||||
|
}
|
||||||
|
|
||||||
dashboard := api.Group("/dashboard")
|
dashboard := api.Group("/dashboard")
|
||||||
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||||
dashboard.GET("/stats", dashboardHandler.Stats)
|
dashboard.GET("/stats", dashboardHandler.Stats)
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"backupx/server/internal/apperror"
|
||||||
|
"backupx/server/internal/model"
|
||||||
|
"backupx/server/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReportService 生成企业合规报表。区别于 Dashboard 的实时聚合视图,
|
||||||
|
// 本服务产出「按任务、可导出、可归档」的时间点合规证据(供 SOC2 / ISO27001 等审计)。
|
||||||
|
type ReportService struct {
|
||||||
|
tasks repository.BackupTaskRepository
|
||||||
|
records repository.BackupRecordRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportService(tasks repository.BackupTaskRepository, records repository.BackupRecordRepository) *ReportService {
|
||||||
|
return &ReportService{tasks: tasks, records: records}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComplianceTaskRow 单个备份任务的合规明细行。
|
||||||
|
type ComplianceTaskRow struct {
|
||||||
|
TaskID uint `json:"taskId"`
|
||||||
|
TaskName string `json:"taskName"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
NodeName string `json:"nodeName"`
|
||||||
|
CronExpr string `json:"cronExpr"`
|
||||||
|
Encrypted bool `json:"encrypted"`
|
||||||
|
RetentionDays int `json:"retentionDays"`
|
||||||
|
SLAHoursRPO int `json:"slaHoursRpo"`
|
||||||
|
TotalRuns int `json:"totalRuns"`
|
||||||
|
Successes int `json:"successes"`
|
||||||
|
Failures int `json:"failures"`
|
||||||
|
SuccessRate float64 `json:"successRate"`
|
||||||
|
LastStatus string `json:"lastStatus"`
|
||||||
|
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
|
||||||
|
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
||||||
|
ProtectedBytes int64 `json:"protectedBytes"`
|
||||||
|
Compliant bool `json:"compliant"`
|
||||||
|
Risk string `json:"risk"` // ok | at_risk | not_applicable
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComplianceSummary 报表汇总。
|
||||||
|
type ComplianceSummary struct {
|
||||||
|
TotalTasks int `json:"totalTasks"`
|
||||||
|
EnabledTasks int `json:"enabledTasks"`
|
||||||
|
CompliantTasks int `json:"compliantTasks"`
|
||||||
|
AtRiskTasks int `json:"atRiskTasks"`
|
||||||
|
EncryptedTasks int `json:"encryptedTasks"`
|
||||||
|
OverallSuccessRate float64 `json:"overallSuccessRate"`
|
||||||
|
TotalProtectedB int64 `json:"totalProtectedBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComplianceReport 完整合规报表。
|
||||||
|
type ComplianceReport struct {
|
||||||
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
|
RangeDays int `json:"rangeDays"`
|
||||||
|
Summary ComplianceSummary `json:"summary"`
|
||||||
|
Tasks []ComplianceTaskRow `json:"tasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
reportMinDays = 1
|
||||||
|
reportMaxDays = 365
|
||||||
|
)
|
||||||
|
|
||||||
|
// ComplianceReport 生成最近 days 天的合规报表。
|
||||||
|
func (s *ReportService) ComplianceReport(ctx context.Context, days int) (*ComplianceReport, error) {
|
||||||
|
if days < reportMinDays || days > reportMaxDays {
|
||||||
|
return nil, apperror.BadRequest("REPORT_RANGE_INVALID",
|
||||||
|
"统计天数需在 1-365 之间", nil)
|
||||||
|
}
|
||||||
|
tasks, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperror.Internal("REPORT_TASKS_FAILED", "无法获取任务列表", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
since := now.AddDate(0, 0, -days)
|
||||||
|
|
||||||
|
report := &ComplianceReport{GeneratedAt: now, RangeDays: days, Tasks: make([]ComplianceTaskRow, 0, len(tasks))}
|
||||||
|
var totalRuns, totalSuccess int
|
||||||
|
for i := range tasks {
|
||||||
|
task := tasks[i]
|
||||||
|
records, err := s.records.ListByTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperror.Internal("REPORT_RECORDS_FAILED", "无法获取任务备份记录", err)
|
||||||
|
}
|
||||||
|
row := s.buildTaskRow(&task, records, now, since)
|
||||||
|
report.Tasks = append(report.Tasks, row)
|
||||||
|
|
||||||
|
report.Summary.TotalTasks++
|
||||||
|
if task.Enabled {
|
||||||
|
report.Summary.EnabledTasks++
|
||||||
|
}
|
||||||
|
if task.Encrypt {
|
||||||
|
report.Summary.EncryptedTasks++
|
||||||
|
}
|
||||||
|
switch row.Risk {
|
||||||
|
case "ok":
|
||||||
|
report.Summary.CompliantTasks++
|
||||||
|
case "at_risk":
|
||||||
|
report.Summary.AtRiskTasks++
|
||||||
|
}
|
||||||
|
report.Summary.TotalProtectedB += row.ProtectedBytes
|
||||||
|
totalRuns += row.TotalRuns
|
||||||
|
totalSuccess += row.Successes
|
||||||
|
}
|
||||||
|
if totalRuns > 0 {
|
||||||
|
report.Summary.OverallSuccessRate = roundRate(float64(totalSuccess) / float64(totalRuns))
|
||||||
|
}
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReportService) buildTaskRow(task *model.BackupTask, records []model.BackupRecord, now, since time.Time) ComplianceTaskRow {
|
||||||
|
row := ComplianceTaskRow{
|
||||||
|
TaskID: task.ID,
|
||||||
|
TaskName: task.Name,
|
||||||
|
Type: task.Type,
|
||||||
|
Enabled: task.Enabled,
|
||||||
|
NodeName: task.Node.Name,
|
||||||
|
CronExpr: task.CronExpr,
|
||||||
|
Encrypted: task.Encrypt,
|
||||||
|
RetentionDays: task.RetentionDays,
|
||||||
|
SLAHoursRPO: task.SLAHoursRPO,
|
||||||
|
LastStatus: "none",
|
||||||
|
}
|
||||||
|
var lastRun, lastSuccess *model.BackupRecord
|
||||||
|
for i := range records {
|
||||||
|
rec := &records[i]
|
||||||
|
// 统计窗口内的运行情况(按 StartedAt 落在 [since, now])。
|
||||||
|
if !rec.StartedAt.Before(since) {
|
||||||
|
row.TotalRuns++
|
||||||
|
switch rec.Status {
|
||||||
|
case model.BackupRecordStatusSuccess:
|
||||||
|
row.Successes++
|
||||||
|
case model.BackupRecordStatusFailed:
|
||||||
|
row.Failures++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 最近一次运行 / 最近一次成功(不限窗口,反映当前保护态)。
|
||||||
|
if lastRun == nil || rec.StartedAt.After(lastRun.StartedAt) {
|
||||||
|
lastRun = rec
|
||||||
|
}
|
||||||
|
if rec.Status == model.BackupRecordStatusSuccess {
|
||||||
|
if lastSuccess == nil || rec.StartedAt.After(lastSuccess.StartedAt) {
|
||||||
|
lastSuccess = rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.TotalRuns > 0 {
|
||||||
|
row.SuccessRate = roundRate(float64(row.Successes) / float64(row.TotalRuns))
|
||||||
|
}
|
||||||
|
if lastRun != nil {
|
||||||
|
row.LastStatus = lastRun.Status
|
||||||
|
started := lastRun.StartedAt
|
||||||
|
row.LastRunAt = &started
|
||||||
|
}
|
||||||
|
if lastSuccess != nil {
|
||||||
|
when := lastSuccess.StartedAt
|
||||||
|
if lastSuccess.CompletedAt != nil {
|
||||||
|
when = *lastSuccess.CompletedAt
|
||||||
|
}
|
||||||
|
row.LastSuccessAt = &when
|
||||||
|
row.ProtectedBytes = lastSuccess.FileSize
|
||||||
|
}
|
||||||
|
row.Compliant, row.Risk = evaluateCompliance(task, lastSuccess, now)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateCompliance 判定任务合规性:
|
||||||
|
// - 禁用任务:not_applicable(不计入合规/风险)。
|
||||||
|
// - 从未成功:at_risk。
|
||||||
|
// - 配置了 SLA(RPO 小时):最近成功在 RPO 内为 ok,否则 at_risk。
|
||||||
|
// - 未配置 SLA:只要存在成功备份即视为 ok。
|
||||||
|
func evaluateCompliance(task *model.BackupTask, lastSuccess *model.BackupRecord, now time.Time) (bool, string) {
|
||||||
|
if !task.Enabled {
|
||||||
|
return false, "not_applicable"
|
||||||
|
}
|
||||||
|
if lastSuccess == nil {
|
||||||
|
return false, "at_risk"
|
||||||
|
}
|
||||||
|
if task.SLAHoursRPO > 0 {
|
||||||
|
when := lastSuccess.StartedAt
|
||||||
|
if lastSuccess.CompletedAt != nil {
|
||||||
|
when = *lastSuccess.CompletedAt
|
||||||
|
}
|
||||||
|
if now.Sub(when).Hours() > float64(task.SLAHoursRPO) {
|
||||||
|
return false, "at_risk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundRate(v float64) float64 {
|
||||||
|
return float64(int(v*10000+0.5)) / 10000
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"backupx/server/internal/backup"
|
||||||
|
"backupx/server/internal/config"
|
||||||
|
"backupx/server/internal/database"
|
||||||
|
"backupx/server/internal/logger"
|
||||||
|
"backupx/server/internal/model"
|
||||||
|
"backupx/server/internal/repository"
|
||||||
|
"backupx/server/internal/storage"
|
||||||
|
"backupx/server/internal/storage/codec"
|
||||||
|
storageRclone "backupx/server/internal/storage/rclone"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newReportTestHarness(t *testing.T) (*ReportService, *BackupExecutionService) {
|
||||||
|
t.Helper()
|
||||||
|
baseDir := t.TempDir()
|
||||||
|
sourceDir := filepath.Join(baseDir, "data")
|
||||||
|
storeDir := filepath.Join(baseDir, "store")
|
||||||
|
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, "f.txt"), []byte("report-data"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
log, err := logger.New(config.LogConfig{Level: "error"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cipher := codec.NewConfigCipher("report-secret")
|
||||||
|
targets := repository.NewStorageTargetRepository(db)
|
||||||
|
tasks := repository.NewBackupTaskRepository(db)
|
||||||
|
records := repository.NewBackupRecordRepository(db)
|
||||||
|
cfg, err := cipher.EncryptJSON(map[string]any{"basePath": storeDir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "s", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
task := &model.BackupTask{Name: "rep-task", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
|
||||||
|
if err := tasks.Create(context.Background(), task); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
|
||||||
|
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
|
||||||
|
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, backup.NewLogHub(), nil, cipher, nil, baseDir, 2, 10, "")
|
||||||
|
return NewReportService(tasks, records), execution
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRow(rows []ComplianceTaskRow, taskID uint) *ComplianceTaskRow {
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].TaskID == taskID {
|
||||||
|
return &rows[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComplianceReport_ReflectsBackupOutcome(t *testing.T) {
|
||||||
|
report, execution := newReportTestHarness(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 备份前:任务启用但从未成功 → at_risk。
|
||||||
|
before, err := report.ComplianceReport(ctx, 30)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ComplianceReport: %v", err)
|
||||||
|
}
|
||||||
|
row := findRow(before.Tasks, 1)
|
||||||
|
if row == nil {
|
||||||
|
t.Fatal("task row missing before backup")
|
||||||
|
}
|
||||||
|
if row.Risk != "at_risk" || row.Compliant {
|
||||||
|
t.Fatalf("expected at_risk before any success, got risk=%s compliant=%v", row.Risk, row.Compliant)
|
||||||
|
}
|
||||||
|
if before.Summary.AtRiskTasks != 1 || before.Summary.CompliantTasks != 0 {
|
||||||
|
t.Fatalf("unexpected summary before: %+v", before.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跑一次成功备份。
|
||||||
|
bd, err := execution.RunTaskByIDSync(ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||||
|
}
|
||||||
|
if bd.Status != "success" {
|
||||||
|
t.Fatalf("backup not success: %s", bd.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份后:合规、成功率 1.0、保护字节数 > 0。
|
||||||
|
after, err := report.ComplianceReport(ctx, 30)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ComplianceReport after: %v", err)
|
||||||
|
}
|
||||||
|
row = findRow(after.Tasks, 1)
|
||||||
|
if row == nil {
|
||||||
|
t.Fatal("task row missing after backup")
|
||||||
|
}
|
||||||
|
if !row.Compliant || row.Risk != "ok" {
|
||||||
|
t.Fatalf("expected ok/compliant after success, got risk=%s compliant=%v", row.Risk, row.Compliant)
|
||||||
|
}
|
||||||
|
if row.TotalRuns != 1 || row.Successes != 1 || row.Failures != 0 {
|
||||||
|
t.Fatalf("unexpected counts: runs=%d ok=%d fail=%d", row.TotalRuns, row.Successes, row.Failures)
|
||||||
|
}
|
||||||
|
if row.SuccessRate != 1 {
|
||||||
|
t.Fatalf("expected success rate 1.0, got %v", row.SuccessRate)
|
||||||
|
}
|
||||||
|
if row.LastStatus != "success" || row.LastSuccessAt == nil || row.ProtectedBytes <= 0 {
|
||||||
|
t.Fatalf("unexpected last/protected: status=%s lastSuccess=%v bytes=%d", row.LastStatus, row.LastSuccessAt, row.ProtectedBytes)
|
||||||
|
}
|
||||||
|
if after.Summary.CompliantTasks != 1 || after.Summary.AtRiskTasks != 0 || after.Summary.OverallSuccessRate != 1 {
|
||||||
|
t.Fatalf("unexpected summary after: %+v", after.Summary)
|
||||||
|
}
|
||||||
|
if after.Summary.TotalProtectedB <= 0 {
|
||||||
|
t.Fatalf("expected protected bytes > 0, got %d", after.Summary.TotalProtectedB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComplianceReport_RejectsInvalidRange(t *testing.T) {
|
||||||
|
report, _ := newReportTestHarness(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := report.ComplianceReport(ctx, 0); err == nil {
|
||||||
|
t.Fatal("expected error for days=0")
|
||||||
|
}
|
||||||
|
if _, err := report.ComplianceReport(ctx, 9999); err == nil {
|
||||||
|
t.Fatal("expected error for days>365")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
IconCloud,
|
IconCloud,
|
||||||
IconDesktop,
|
IconDesktop,
|
||||||
IconList,
|
IconList,
|
||||||
|
IconFilePdf,
|
||||||
} from '@arco-design/web-react/icon'
|
} from '@arco-design/web-react/icon'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
@@ -106,6 +107,7 @@ interface MenuItemConfig {
|
|||||||
|
|
||||||
const menuItems: MenuItemConfig[] = [
|
const menuItems: MenuItemConfig[] = [
|
||||||
{ key: '/dashboard', label: '仪表盘', icon: <IconDashboard /> },
|
{ key: '/dashboard', label: '仪表盘', icon: <IconDashboard /> },
|
||||||
|
{ key: '/reports', label: '合规报表', icon: <IconFilePdf /> },
|
||||||
{ key: '/backup/tasks', label: '备份任务', icon: <IconFile /> },
|
{ key: '/backup/tasks', label: '备份任务', icon: <IconFile /> },
|
||||||
{ key: '/backup/records', label: '备份记录', icon: <IconHistory /> },
|
{ key: '/backup/records', label: '备份记录', icon: <IconHistory /> },
|
||||||
{ key: '/restore/records', label: '恢复记录', icon: <IconRefresh /> },
|
{ key: '/restore/records', label: '恢复记录', icon: <IconRefresh /> },
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { Button, Card, Grid, Message, Select, Space, Statistic, Table, Tag, Typography } from '@arco-design/web-react'
|
||||||
|
import { IconDownload, IconRefresh } from '@arco-design/web-react/icon'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { downloadComplianceCSV, fetchComplianceReport } from '../../services/reports'
|
||||||
|
import type { ComplianceReport, ComplianceRisk, ComplianceTaskRow } from '../../types/reports'
|
||||||
|
import { resolveErrorMessage } from '../../utils/error'
|
||||||
|
import { formatBytes, formatDateTime, formatPercent } from '../../utils/format'
|
||||||
|
|
||||||
|
const { Row, Col } = Grid
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
const rangeOptions = [
|
||||||
|
{ label: '近 7 天', value: 7 },
|
||||||
|
{ label: '近 30 天', value: 30 },
|
||||||
|
{ label: '近 90 天', value: 90 },
|
||||||
|
{ label: '近 180 天', value: 180 },
|
||||||
|
{ label: '近 365 天', value: 365 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function riskTag(risk: ComplianceRisk) {
|
||||||
|
switch (risk) {
|
||||||
|
case 'ok':
|
||||||
|
return <Tag color="green">合规</Tag>
|
||||||
|
case 'at_risk':
|
||||||
|
return <Tag color="red">风险</Tag>
|
||||||
|
default:
|
||||||
|
return <Tag color="gray">未启用</Tag>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportsPage() {
|
||||||
|
const [days, setDays] = useState(30)
|
||||||
|
const [report, setReport] = useState<ComplianceReport | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
|
const loadData = useCallback(async (range: number) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchComplianceReport(range)
|
||||||
|
setReport(data)
|
||||||
|
setError('')
|
||||||
|
} catch (e) {
|
||||||
|
setError(resolveErrorMessage(e, '加载合规报表失败'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData(days)
|
||||||
|
}, [days, loadData])
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
setExporting(true)
|
||||||
|
try {
|
||||||
|
await downloadComplianceCSV(days)
|
||||||
|
Message.success('已导出 CSV')
|
||||||
|
} catch (e) {
|
||||||
|
Message.error(resolveErrorMessage(e, '导出失败'))
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = report?.summary
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '任务',
|
||||||
|
dataIndex: 'taskName',
|
||||||
|
render: (_: unknown, row: ComplianceTaskRow) => (
|
||||||
|
<Space direction="vertical" size={2}>
|
||||||
|
<Text style={{ fontWeight: 600 }}>{row.taskName}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.type} · {row.nodeName || '本机'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '状态', dataIndex: 'risk', render: (_: unknown, row: ComplianceTaskRow) => riskTag(row.risk) },
|
||||||
|
{
|
||||||
|
title: '成功率',
|
||||||
|
dataIndex: 'successRate',
|
||||||
|
render: (value: number, row: ComplianceTaskRow) => (row.totalRuns > 0 ? formatPercent(value) : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '周期内(成功/失败)',
|
||||||
|
dataIndex: 'totalRuns',
|
||||||
|
render: (_: unknown, row: ComplianceTaskRow) => `${row.successes} / ${row.failures}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近成功',
|
||||||
|
dataIndex: 'lastSuccessAt',
|
||||||
|
render: (value?: string) => (value ? formatDateTime(value) : <Text type="secondary">从未</Text>),
|
||||||
|
},
|
||||||
|
{ title: '保护量', dataIndex: 'protectedBytes', render: (value: number) => formatBytes(value) },
|
||||||
|
{
|
||||||
|
title: '加密',
|
||||||
|
dataIndex: 'encrypted',
|
||||||
|
render: (value: boolean) =>
|
||||||
|
value ? <Tag color="arcoblue" size="small">已加密</Tag> : <Text type="secondary">否</Text>,
|
||||||
|
},
|
||||||
|
{ title: 'SLA(RPO)', dataIndex: 'slaHoursRpo', render: (value: number) => (value > 0 ? `${value}h` : '—') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ title: '受保护任务', value: summary?.enabledTasks ?? 0, suffix: `/ ${summary?.totalTasks ?? 0}` },
|
||||||
|
{ title: '合规任务', value: summary?.compliantTasks ?? 0, color: 'rgb(var(--green-6))' },
|
||||||
|
{ title: '风险任务', value: summary?.atRiskTasks ?? 0, color: 'rgb(var(--red-6))' },
|
||||||
|
{ title: '已加密任务', value: summary?.encryptedTasks ?? 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<Title heading={5} style={{ margin: 0 }}>
|
||||||
|
合规报表
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{report
|
||||||
|
? `生成于 ${formatDateTime(report.generatedAt)} · 统计窗口 ${report.rangeDays} 天`
|
||||||
|
: '按任务的备份合规证据,可导出归档以供审计'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Select value={days} onChange={(value) => setDays(value as number)} options={rangeOptions} style={{ width: 130 }} />
|
||||||
|
<Button icon={<IconRefresh />} onClick={() => void loadData(days)} loading={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<IconDownload />} onClick={() => void handleExport()} loading={exporting} disabled={loading}>
|
||||||
|
导出 CSV
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<Col span={6} key={card.title}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={card.title}
|
||||||
|
value={card.value}
|
||||||
|
suffix={card.suffix}
|
||||||
|
groupSeparator
|
||||||
|
styleValue={card.color ? { color: card.color } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="整体成功率" value={summary ? Number((summary.overallSuccessRate * 100).toFixed(1)) : 0} suffix="%" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="受保护数据总量" value={formatBytes(summary?.totalProtectedBytes)} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Card>
|
||||||
|
<Text type="error">{error}</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table
|
||||||
|
rowKey="taskId"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
data={report?.tasks ?? []}
|
||||||
|
pagination={{ pageSize: 20, sizeCanChange: true }}
|
||||||
|
border={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCal
|
|||||||
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
|
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
|
||||||
import { SettingsPage } from '../pages/settings/SettingsPage'
|
import { SettingsPage } from '../pages/settings/SettingsPage'
|
||||||
import { AuditLogsPage } from '../pages/audit/AuditLogsPage'
|
import { AuditLogsPage } from '../pages/audit/AuditLogsPage'
|
||||||
|
import { ReportsPage } from '../pages/reports/ReportsPage'
|
||||||
import NodesPage from '../pages/nodes/NodesPage'
|
import NodesPage from '../pages/nodes/NodesPage'
|
||||||
import { ProtectedRoute } from './ProtectedRoute'
|
import { ProtectedRoute } from './ProtectedRoute'
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export function RouterView() {
|
|||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<DashboardPage />} />
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="backup/tasks" element={<BackupTasksPage />} />
|
<Route path="backup/tasks" element={<BackupTasksPage />} />
|
||||||
<Route path="backup/records" element={<BackupRecordsPage />} />
|
<Route path="backup/records" element={<BackupRecordsPage />} />
|
||||||
<Route path="restore/records" element={<RestoreRecordsPage />} />
|
<Route path="restore/records" element={<RestoreRecordsPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||||
|
import type { ComplianceReport } from '../types/reports'
|
||||||
|
|
||||||
|
export async function fetchComplianceReport(days = 30) {
|
||||||
|
const response = await http.get<ApiEnvelope<ComplianceReport>>('/reports/compliance', { params: { days } })
|
||||||
|
return unwrapApiEnvelope(response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadComplianceCSV 通过带认证的 http 客户端拉取 CSV blob 并触发浏览器下载。
|
||||||
|
export async function downloadComplianceCSV(days = 30) {
|
||||||
|
const response = await http.get('/reports/compliance/export', {
|
||||||
|
params: { days },
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `backupx-compliance-${days}d.csv`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export type ComplianceRisk = 'ok' | 'at_risk' | 'not_applicable'
|
||||||
|
|
||||||
|
export interface ComplianceTaskRow {
|
||||||
|
taskId: number
|
||||||
|
taskName: string
|
||||||
|
type: string
|
||||||
|
enabled: boolean
|
||||||
|
nodeName: string
|
||||||
|
cronExpr: string
|
||||||
|
encrypted: boolean
|
||||||
|
retentionDays: number
|
||||||
|
slaHoursRpo: number
|
||||||
|
totalRuns: number
|
||||||
|
successes: number
|
||||||
|
failures: number
|
||||||
|
successRate: number
|
||||||
|
lastStatus: string
|
||||||
|
lastRunAt?: string
|
||||||
|
lastSuccessAt?: string
|
||||||
|
protectedBytes: number
|
||||||
|
compliant: boolean
|
||||||
|
risk: ComplianceRisk
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplianceSummary {
|
||||||
|
totalTasks: number
|
||||||
|
enabledTasks: number
|
||||||
|
compliantTasks: number
|
||||||
|
atRiskTasks: number
|
||||||
|
encryptedTasks: number
|
||||||
|
overallSuccessRate: number
|
||||||
|
totalProtectedBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplianceReport {
|
||||||
|
generatedAt: string
|
||||||
|
rangeDays: number
|
||||||
|
summary: ComplianceSummary
|
||||||
|
tasks: ComplianceTaskRow[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user