From 8bb737a9fa384e74e3114e16cd4b0836df5365dd Mon Sep 17 00:00:00 2001 From: yyhuni Date: Wed, 7 Jan 2026 23:30:27 +0800 Subject: [PATCH] feat(scan-history): add auto-refresh toggle and improve layout - Add auto-refresh toggle switch to scan logs section for manual control - Implement flexible polling based on auto-refresh state and scan status - Restructure scan overview layout to use left-right split (stages + logs) - Move stage progress to left column with vulnerability statistics - Implement scrollable logs panel on right side with proper height constraints - Update component imports to use Switch and Label instead of Button - Add full-height flex layout to parent containers for proper scrolling - Refactor grid layout from 2-column to fixed-width left + flexible right - Update translations for new UI elements and labels - Improve responsive design with better flex constraints and min-height handling --- .../app/[locale]/scan/history/[id]/layout.tsx | 2 +- .../scan/history/[id]/overview/page.tsx | 2 +- .../components/scan/history/scan-overview.tsx | 249 ++++++++++-------- frontend/components/scan/scan-log-list.tsx | 127 ++++----- frontend/hooks/use-scan-logs.ts | 10 +- frontend/messages/en.json | 2 + frontend/messages/zh.json | 2 + 7 files changed, 194 insertions(+), 200 deletions(-) diff --git a/frontend/app/[locale]/scan/history/[id]/layout.tsx b/frontend/app/[locale]/scan/history/[id]/layout.tsx index 49eccdba..8dac83df 100644 --- a/frontend/app/[locale]/scan/history/[id]/layout.tsx +++ b/frontend/app/[locale]/scan/history/[id]/layout.tsx @@ -103,7 +103,7 @@ export default function ScanHistoryLayout({ } return ( -
+
{/* Header: Page label + Scan info */}
{t("breadcrumb.scanHistory")} diff --git a/frontend/app/[locale]/scan/history/[id]/overview/page.tsx b/frontend/app/[locale]/scan/history/[id]/overview/page.tsx index 66568cca..28778064 100644 --- a/frontend/app/[locale]/scan/history/[id]/overview/page.tsx +++ b/frontend/app/[locale]/scan/history/[id]/overview/page.tsx @@ -12,7 +12,7 @@ export default function ScanOverviewPage() { const scanId = Number(id) return ( -
+
) diff --git a/frontend/components/scan/history/scan-overview.tsx b/frontend/components/scan/history/scan-overview.tsx index 4ca24629..6eb07b19 100644 --- a/frontend/components/scan/history/scan-overview.tsx +++ b/frontend/components/scan/history/scan-overview.tsx @@ -1,6 +1,6 @@ "use client" -import React from "react" +import React, { useState } from "react" import Link from "next/link" import { useTranslations, useLocale } from "next-intl" import { @@ -9,7 +9,6 @@ import { Server, Link2, FolderOpen, - ShieldAlert, AlertTriangle, Clock, Calendar, @@ -18,7 +17,6 @@ import { CheckCircle2, XCircle, Loader2, - PlayCircle, Cpu, HardDrive, } from "lucide-react" @@ -30,7 +28,9 @@ import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" import { useScan } from "@/hooks/use-scans" import { useScanLogs } from "@/hooks/use-scan-logs" import { ScanLogList } from "@/components/scan/scan-log-list" @@ -93,11 +93,14 @@ export function ScanOverview({ scanId }: ScanOverviewProps) { // Check if scan is running (for log polling) const isRunning = scan?.status === 'running' || scan?.status === 'initiated' + // Auto-refresh state (default: on when running) + const [autoRefresh, setAutoRefresh] = useState(true) + // Logs hook const { logs, loading: logsLoading } = useScanLogs({ scanId, enabled: !!scan, - pollingInterval: isRunning ? 3000 : 0, + pollingInterval: isRunning && autoRefresh ? 3000 : 0, }) // Format date helper @@ -233,7 +236,7 @@ export function ScanOverview({ scanId }: ScanOverviewProps) { ] return ( -
+
{/* Scan info + Status */}
@@ -299,126 +302,140 @@ export function ScanOverview({ scanId }: ScanOverviewProps) {
- {/* Vulnerability + Stage Progress - Two columns */} -
- {/* Vulnerability Statistics Card */} - - + {/* Stage Progress + Logs - Left-Right Split Layout */} +
+ {/* Left Column: Stage Progress + Vulnerability Stats */} +
+ {/* Stage Progress */} + -
- - {t("vulnerabilitiesTitle")} -
- + {t("stagesTitle")} + {scan.stageProgress && ( + + {Object.values(scan.stageProgress).filter((p: any) => p.status === "completed").length}/ + {Object.keys(scan.stageProgress).length} {t("stagesCompleted")} + + )}
- - {/* Total count */} -
- {vulnSummary.total} - {t("totalFound")} -
- - {/* Severity breakdown */} -
-
-
- {t("severity.critical")} - {vulnSummary.critical} + + {scan.stageProgress && Object.keys(scan.stageProgress).length > 0 ? ( +
+ {Object.entries(scan.stageProgress) + .sort(([, a], [, b]) => ((a as any).order ?? 0) - ((b as any).order ?? 0)) + .map(([stageName, progress]) => { + const stageProgress = progress as any + const isRunning = stageProgress.status === "running" + return ( +
+
+ + + {tProgress(`stages.${stageName}`)} + + {isRunning && ( + + )} +
+ + {stageProgress.status === "completed" && stageProgress.duration + ? formatStageDuration(stageProgress.duration) + : stageProgress.status === "running" + ? tProgress("stage_running") + : stageProgress.status === "pending" + ? "--" + : ""} + +
+ ) + })}
-
-
- {t("severity.high")} - {vulnSummary.high} + ) : ( +
+ {t("noStages")}
-
-
- {t("severity.medium")} - {vulnSummary.medium} -
-
-
- {t("severity.low")} - {vulnSummary.low} -
-
+ )} - - {/* Stage Progress Card */} - - -
- - {t("stagesTitle")} + {/* Vulnerability Stats - Compact */} + + + + {t("vulnerabilitiesTitle")} + + + +
+
+
+ {vulnSummary.critical} +
+
+
+ {vulnSummary.high} +
+
+
+ {vulnSummary.medium} +
+
+
+ {vulnSummary.low} +
+ + {t("totalVulns", { count: vulnSummary.total })} + +
+ + + +
+ + {/* Right Column: Logs */} +
+
+ +
+ {/* Bottom status bar */} +
+
+ {t("logsTitle")} + + {logs.length} 条记录 + {isRunning && autoRefresh && ( + <> + + + + 每 3 秒刷新 + + + )}
- - - {scan.stageProgress && Object.keys(scan.stageProgress).length > 0 ? ( -
- {Object.entries(scan.stageProgress) - .sort(([, a], [, b]) => ((a as any).order ?? 0) - ((b as any).order ?? 0)) - .map(([stageName, progress]) => { - const stageProgress = progress as any - return ( -
-
- - {tProgress(`stages.${stageName}`)} -
-
- {stageProgress.status === "running" && ( - - {tProgress("stage_running")} - - )} - {stageProgress.status === "completed" && stageProgress.duration && ( - - {formatStageDuration(stageProgress.duration)} - - )} - {stageProgress.status === "pending" && ( - {tProgress("stage_pending")} - )} - {stageProgress.status === "failed" && ( - - {tProgress("stage_failed")} - - )} -
-
- ) - })} -
- ) : ( -
- {t("noStages")} + {isRunning && ( +
+ +
)} - - -
- - {/* Scan Logs */} -
-

{t("logsTitle")}

- - - - - +
+
) diff --git a/frontend/components/scan/scan-log-list.tsx b/frontend/components/scan/scan-log-list.tsx index f4207a79..ef7ef964 100644 --- a/frontend/components/scan/scan-log-list.tsx +++ b/frontend/components/scan/scan-log-list.tsx @@ -1,6 +1,7 @@ "use client" -import { useEffect, useRef, useMemo } from "react" +import { useMemo, useRef } from "react" +import { AnsiLogViewer } from "@/components/settings/system-logs" import type { ScanLog } from "@/services/scan.service" interface ScanLogListProps { @@ -14,98 +15,68 @@ interface ScanLogListProps { function formatTime(isoString: string): string { try { const date = new Date(isoString) - return date.toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - }) + const h = String(date.getHours()).padStart(2, '0') + const m = String(date.getMinutes()).padStart(2, '0') + const s = String(date.getSeconds()).padStart(2, '0') + return `${h}:${m}:${s}` } catch { return isoString } } -/** - * HTML 转义,防止 XSS - */ -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - /** * 扫描日志列表组件 - * - * 特性: - * - 预渲染 HTML 字符串,减少 DOM 节点提升性能 - * - 颜色区分:info=默认, warning=黄色, error=红色 - * - 自动滚动到底部 + * 复用 AnsiLogViewer 组件 */ export function ScanLogList({ logs, loading }: ScanLogListProps) { - const containerRef = useRef(null) - const isAtBottomRef = useRef(true) // 跟踪用户是否在底部 + // 稳定的 content 引用,只有内容真正变化时才更新 + const contentRef = useRef('') + const lastLogCountRef = useRef(0) + const lastLogIdRef = useRef(null) - // 预渲染 HTML 字符串 - const htmlContent = useMemo(() => { + // 将日志转换为纯文本格式 + const content = useMemo(() => { if (logs.length === 0) return '' - return logs.map(log => { + // 检查是否真正需要更新 + const lastLog = logs[logs.length - 1] + if ( + logs.length === lastLogCountRef.current && + lastLog?.id === lastLogIdRef.current + ) { + // 日志没有变化,返回缓存的 content + return contentRef.current + } + + // 更新缓存 + lastLogCountRef.current = logs.length + lastLogIdRef.current = lastLog?.id ?? null + + const newContent = logs.map(log => { const time = formatTime(log.createdAt) - const content = escapeHtml(log.content) - const levelStyle = log.level === 'error' - ? 'color:#ef4444' - : log.level === 'warning' - ? 'color:#eab308' - : '' - - return `
${time} ${content}
` - }).join('') + const levelTag = log.level.toUpperCase() + return `[${time}] [${levelTag}] ${log.content}` + }).join('\n') + + contentRef.current = newContent + return newContent }, [logs]) - // 监听滚动事件,检测用户是否在底部 - useEffect(() => { - const container = containerRef.current - if (!container) return - - const handleScroll = () => { - const { scrollTop, scrollHeight, clientHeight } = container - // 允许 30px 的容差,认为在底部附近 - isAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 30 - } - - container.addEventListener('scroll', handleScroll) - return () => container.removeEventListener('scroll', handleScroll) - }, []) + if (loading && logs.length === 0) { + return ( +
+ 加载中... +
+ ) + } - // 只有用户在底部时才自动滚动 - useEffect(() => { - if (containerRef.current && isAtBottomRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight - } - }, [htmlContent]) + if (logs.length === 0) { + return ( +
+ 暂无日志 +
+ ) + } - return ( -
- {logs.length === 0 && !loading && ( -
- 暂无日志 -
- )} - {htmlContent && ( -
- )} - {loading && logs.length === 0 && ( -
- 加载中... -
- )} -
- ) + return } diff --git a/frontend/hooks/use-scan-logs.ts b/frontend/hooks/use-scan-logs.ts index 89ab2283..82b8a4f8 100644 --- a/frontend/hooks/use-scan-logs.ts +++ b/frontend/hooks/use-scan-logs.ts @@ -83,16 +83,18 @@ export function useScanLogs({ return () => { isMounted.current = false } - }, [scanId, enabled]) + }, [scanId, enabled, fetchLogs]) // 轮询 useEffect(() => { if (!enabled) return - + // pollingInterval <= 0 表示禁用轮询(避免 setInterval(0) 导致高频请求/卡顿) + if (!pollingInterval || pollingInterval <= 0) return + const interval = setInterval(() => { - fetchLogs(true) // 增量查询 + fetchLogs(true) // 增量查询 }, pollingInterval) - + return () => clearInterval(interval) }, [enabled, pollingInterval, fetchLogs]) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 49f46a0b..ded38297 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -747,9 +747,11 @@ "assetsTitle": "Discovered Assets", "vulnerabilitiesTitle": "Vulnerabilities", "stagesTitle": "Scan Progress", + "stagesCompleted": "completed", "logsTitle": "Scan Logs", "noStages": "No stage progress available", "totalFound": "total found", + "totalVulns": "{count} total", "viewAll": "View All", "cards": { "websites": "Websites", diff --git a/frontend/messages/zh.json b/frontend/messages/zh.json index 39becd00..ff5b3e3b 100644 --- a/frontend/messages/zh.json +++ b/frontend/messages/zh.json @@ -747,9 +747,11 @@ "assetsTitle": "发现的资产", "vulnerabilitiesTitle": "漏洞统计", "stagesTitle": "扫描进度", + "stagesCompleted": "完成", "logsTitle": "扫描日志", "noStages": "暂无阶段进度", "totalFound": "个漏洞", + "totalVulns": "共 {count} 个", "viewAll": "查看全部", "cards": { "websites": "网站",