From a0d1e6619994543b477ee90d2f24d29b71550a04 Mon Sep 17 00:00:00 2001 From: Wu Qing <3184394176@qq.com> Date: Wed, 27 May 2026 08:14:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(reports):=20=E4=BC=81=E4=B8=9A=E5=90=88?= =?UTF-8?q?=E8=A7=84=E6=8A=A5=E8=A1=A8=EF=BC=88=E5=90=8E=E7=AB=AF=E8=81=9A?= =?UTF-8?q?=E5=90=88=20+=20CSV=20=E5=AF=BC=E5=87=BA=20+=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=A1=B5=E9=9D=A2=EF=BC=89=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增合规报表:ReportService 逐任务聚合备份合规证据(成功率/最近成功/SLA 判定/加密/受保护量)+ JSON/CSV API;前端新增 /reports 页面(汇总卡片+明细表+CSV 导出)。后端 go test、前端 tsc+vite、端到端路由验证均通过。 --- server/internal/app/app.go | 2 + server/internal/http/report_handler.go | 95 +++++++++ server/internal/http/router.go | 10 + server/internal/service/report_service.go | 199 ++++++++++++++++++ .../internal/service/report_service_test.go | 136 ++++++++++++ web/src/layouts/AppLayout.tsx | 2 + web/src/pages/reports/ReportsPage.tsx | 185 ++++++++++++++++ web/src/router/index.tsx | 2 + web/src/services/reports.ts | 24 +++ web/src/types/reports.ts | 40 ++++ 10 files changed, 695 insertions(+) create mode 100644 server/internal/http/report_handler.go create mode 100644 server/internal/service/report_service.go create mode 100644 server/internal/service/report_service_test.go create mode 100644 web/src/pages/reports/ReportsPage.tsx create mode 100644 web/src/services/reports.ts create mode 100644 web/src/types/reports.ts diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 336d0c0..e35e4bc 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -104,6 +104,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, restoreRecordRepo := repository.NewRestoreRecordRepository(db) restoreLogHub := backup.NewLogHub() dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo) + reportService := service.NewReportService(backupTaskRepo, backupRecordRepo) settingsService := service.NewSettingsService(systemConfigRepo) // Audit @@ -268,6 +269,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, ApiKeyService: apiKeyService, NotificationService: notificationService, DashboardService: dashboardService, + ReportService: reportService, SettingsService: settingsService, NodeService: nodeService, AgentService: agentService, diff --git a/server/internal/http/report_handler.go b/server/internal/http/report_handler.go new file mode 100644 index 0000000..9bef5d7 --- /dev/null +++ b/server/internal/http/report_handler.go @@ -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") +} diff --git a/server/internal/http/router.go b/server/internal/http/router.go index 3dedcb5..d35447a 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -41,6 +41,7 @@ type RouterDependencies struct { ApiKeyService *service.ApiKeyService NotificationService *service.NotificationService DashboardService *service.DashboardService + ReportService *service.ReportService SettingsService *service.SettingsService NodeService *service.NodeService AgentService *service.AgentService @@ -211,6 +212,15 @@ func NewRouter(deps RouterDependencies) *gin.Engine { // 基于备份记录的验证入口:与 restore 对称 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.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth)) dashboard.GET("/stats", dashboardHandler.Stats) diff --git a/server/internal/service/report_service.go b/server/internal/service/report_service.go new file mode 100644 index 0000000..b219d78 --- /dev/null +++ b/server/internal/service/report_service.go @@ -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 +} diff --git a/server/internal/service/report_service_test.go b/server/internal/service/report_service_test.go new file mode 100644 index 0000000..768000f --- /dev/null +++ b/server/internal/service/report_service_test.go @@ -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") + } +} diff --git a/web/src/layouts/AppLayout.tsx b/web/src/layouts/AppLayout.tsx index 02b18c0..e5db9a6 100644 --- a/web/src/layouts/AppLayout.tsx +++ b/web/src/layouts/AppLayout.tsx @@ -20,6 +20,7 @@ import { IconCloud, IconDesktop, IconList, + IconFilePdf, } from '@arco-design/web-react/icon' import { useState } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router-dom' @@ -106,6 +107,7 @@ interface MenuItemConfig { const menuItems: MenuItemConfig[] = [ { key: '/dashboard', label: '仪表盘', icon: }, + { key: '/reports', label: '合规报表', icon: }, { key: '/backup/tasks', label: '备份任务', icon: }, { key: '/backup/records', label: '备份记录', icon: }, { key: '/restore/records', label: '恢复记录', icon: }, diff --git a/web/src/pages/reports/ReportsPage.tsx b/web/src/pages/reports/ReportsPage.tsx new file mode 100644 index 0000000..d26d6d7 --- /dev/null +++ b/web/src/pages/reports/ReportsPage.tsx @@ -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 合规 + case 'at_risk': + return 风险 + default: + return 未启用 + } +} + +export function ReportsPage() { + const [days, setDays] = useState(30) + const [report, setReport] = useState(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) => ( + + {row.taskName} + + {row.type} · {row.nodeName || '本机'} + + + ), + }, + { 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) : 从未), + }, + { title: '保护量', dataIndex: 'protectedBytes', render: (value: number) => formatBytes(value) }, + { + title: '加密', + dataIndex: 'encrypted', + render: (value: boolean) => + value ? 已加密 : 否, + }, + { 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 ( + + + + + 合规报表 + + + {report + ? `生成于 ${formatDateTime(report.generatedAt)} · 统计窗口 ${report.rangeDays} 天` + : '按任务的备份合规证据,可导出归档以供审计'} + + + + setDays(value as number)} options={rangeOptions} style={{ width: 130 }} /> + } onClick={() => void loadData(days)} loading={loading}> + 刷新 + + } onClick={() => void handleExport()} loading={exporting} disabled={loading}> + 导出 CSV + + + + + + {statCards.map((card) => ( + + + + + + ))} + + + + + + + + + + + + + + + {error ? ( + + {error} + + ) : ( + + + + )} + + ) +} diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 6136f20..c61a186 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -15,6 +15,7 @@ import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCal import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage' import { SettingsPage } from '../pages/settings/SettingsPage' import { AuditLogsPage } from '../pages/audit/AuditLogsPage' +import { ReportsPage } from '../pages/reports/ReportsPage' import NodesPage from '../pages/nodes/NodesPage' import { ProtectedRoute } from './ProtectedRoute' @@ -32,6 +33,7 @@ export function RouterView() { > } /> } /> + } /> } /> } /> } /> diff --git a/web/src/services/reports.ts b/web/src/services/reports.ts new file mode 100644 index 0000000..683f239 --- /dev/null +++ b/web/src/services/reports.ts @@ -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>('/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) +} diff --git a/web/src/types/reports.ts b/web/src/types/reports.ts new file mode 100644 index 0000000..24ed8fe --- /dev/null +++ b/web/src/types/reports.ts @@ -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[] +}