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
This commit is contained in:
yyhuni
2026-01-07 23:30:27 +08:00
parent 2d018d33f3
commit 8bb737a9fa
7 changed files with 194 additions and 200 deletions

View File

@@ -103,7 +103,7 @@ export default function ScanHistoryLayout({
}
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 h-full">
{/* Header: Page label + Scan info */}
<div className="flex items-center gap-2 text-sm px-4 lg:px-6">
<span className="text-muted-foreground">{t("breadcrumb.scanHistory")}</span>

View File

@@ -12,7 +12,7 @@ export default function ScanOverviewPage() {
const scanId = Number(id)
return (
<div className="px-4 lg:px-6">
<div className="flex-1 flex flex-col min-h-0 px-4 lg:px-6">
<ScanOverview scanId={scanId} />
</div>
)

View File

@@ -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 (
<div className="space-y-6">
<div className="flex flex-col gap-6 flex-1 min-h-0">
{/* Scan info + Status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-6 text-sm text-muted-foreground">
@@ -299,126 +302,140 @@ export function ScanOverview({ scanId }: ScanOverviewProps) {
</div>
</div>
{/* Vulnerability + Stage Progress - Two columns */}
<div className="grid gap-4 md:grid-cols-2">
{/* Vulnerability Statistics Card */}
<Link href={`/scan/history/${scanId}/vulnerabilities/`} className="block">
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
{/* Stage Progress + Logs - Left-Right Split Layout */}
<div className="grid gap-4 md:grid-cols-[280px_1fr] flex-1 min-h-0">
{/* Left Column: Stage Progress + Vulnerability Stats */}
<div className="flex flex-col gap-4 min-h-0">
{/* Stage Progress */}
<Card className="flex-1 min-h-0">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">{t("vulnerabilitiesTitle")}</CardTitle>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs">
{t("viewAll")}
<ChevronRight className="h-3 w-3 ml-1" />
</Button>
<CardTitle className="text-sm font-medium">{t("stagesTitle")}</CardTitle>
{scan.stageProgress && (
<span className="text-xs text-muted-foreground">
{Object.values(scan.stageProgress).filter((p: any) => p.status === "completed").length}/
{Object.keys(scan.stageProgress).length} {t("stagesCompleted")}
</span>
)}
</CardHeader>
<CardContent className="space-y-4">
{/* Total count */}
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{vulnSummary.total}</span>
<span className="text-sm text-muted-foreground">{t("totalFound")}</span>
</div>
{/* Severity breakdown */}
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-sm text-muted-foreground">{t("severity.critical")}</span>
<span className="text-sm font-medium ml-auto">{vulnSummary.critical}</span>
<CardContent className="pt-0 flex flex-col flex-1 min-h-0">
{scan.stageProgress && Object.keys(scan.stageProgress).length > 0 ? (
<div className="space-y-1 flex-1 min-h-0 overflow-y-auto pr-1">
{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 (
<div
key={stageName}
className={cn(
"flex items-center justify-between py-2 rounded-md transition-colors text-sm",
isRunning && "bg-[#d29922]/10 border border-[#d29922]/30",
stageProgress.status === "completed" && "text-muted-foreground",
stageProgress.status === "failed" && "bg-[#da3633]/10 text-[#da3633]",
stageProgress.status === "cancelled" && "text-muted-foreground",
)}
>
<div className="flex items-center gap-2 min-w-0">
<StageStatusIcon status={stageProgress.status} />
<span className={cn("truncate", isRunning && "font-medium text-foreground")}>
{tProgress(`stages.${stageName}`)}
</span>
{isRunning && (
<span className="text-[10px] text-[#d29922] shrink-0"></span>
)}
</div>
<span className="text-xs text-muted-foreground font-mono shrink-0 ml-2">
{stageProgress.status === "completed" && stageProgress.duration
? formatStageDuration(stageProgress.duration)
: stageProgress.status === "running"
? tProgress("stage_running")
: stageProgress.status === "pending"
? "--"
: ""}
</span>
</div>
)
})}
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500" />
<span className="text-sm text-muted-foreground">{t("severity.high")}</span>
<span className="text-sm font-medium ml-auto">{vulnSummary.high}</span>
) : (
<div className="text-sm text-muted-foreground text-center py-4">
{t("noStages")}
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-sm text-muted-foreground">{t("severity.medium")}</span>
<span className="text-sm font-medium ml-auto">{vulnSummary.medium}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-sm text-muted-foreground">{t("severity.low")}</span>
<span className="text-sm font-medium ml-auto">{vulnSummary.low}</span>
</div>
</div>
)}
</CardContent>
</Card>
</Link>
{/* Stage Progress Card */}
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">{t("stagesTitle")}</CardTitle>
{/* Vulnerability Stats - Compact */}
<Link href={`/scan/history/${scanId}/vulnerabilities/`} className="block">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("vulnerabilitiesTitle")}</CardTitle>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-sm font-medium">{vulnSummary.critical}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-orange-500" />
<span className="text-sm font-medium">{vulnSummary.high}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500" />
<span className="text-sm font-medium">{vulnSummary.medium}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-sm font-medium">{vulnSummary.low}</span>
</div>
<span className="text-xs text-muted-foreground ml-auto">
{t("totalVulns", { count: vulnSummary.total })}
</span>
</div>
</CardContent>
</Card>
</Link>
</div>
{/* Right Column: Logs */}
<div className="flex flex-col min-h-0 rounded-lg overflow-hidden border">
<div className="flex-1 min-h-0">
<ScanLogList logs={logs} loading={logsLoading} />
</div>
{/* Bottom status bar */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-t text-xs text-muted-foreground shrink-0">
<div className="flex items-center gap-3">
<span>{t("logsTitle")}</span>
<Separator orientation="vertical" className="h-3" />
<span>{logs.length} </span>
{isRunning && autoRefresh && (
<>
<Separator orientation="vertical" className="h-3" />
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
3
</span>
</>
)}
</div>
</CardHeader>
<CardContent>
{scan.stageProgress && Object.keys(scan.stageProgress).length > 0 ? (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{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 (
<div
key={stageName}
className={cn(
"flex items-center justify-between py-2 px-3 rounded-lg transition-colors",
stageProgress.status === "running" && "bg-[#d29922]/10 border border-[#d29922]/20",
stageProgress.status === "completed" && "bg-muted/50",
stageProgress.status === "failed" && "bg-[#da3633]/10",
stageProgress.status === "cancelled" && "bg-[#848d97]/10",
)}
>
<div className="flex items-center gap-2">
<StageStatusIcon status={stageProgress.status} />
<span className="text-sm font-medium">{tProgress(`stages.${stageName}`)}</span>
</div>
<div className="flex items-center gap-2 text-right">
{stageProgress.status === "running" && (
<Badge variant="outline" className="bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20 text-xs">
{tProgress("stage_running")}
</Badge>
)}
{stageProgress.status === "completed" && stageProgress.duration && (
<span className="text-xs text-muted-foreground font-mono">
{formatStageDuration(stageProgress.duration)}
</span>
)}
{stageProgress.status === "pending" && (
<span className="text-xs text-muted-foreground">{tProgress("stage_pending")}</span>
)}
{stageProgress.status === "failed" && (
<Badge variant="outline" className="bg-[#da3633]/10 text-[#da3633] border-[#da3633]/20 text-xs">
{tProgress("stage_failed")}
</Badge>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="text-sm text-muted-foreground text-center py-4">
{t("noStages")}
{isRunning && (
<div className="flex items-center gap-2">
<Switch
id="log-auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
className="scale-75"
/>
<Label htmlFor="log-auto-refresh" className="text-xs cursor-pointer">
</Label>
</div>
)}
</CardContent>
</Card>
</div>
{/* Scan Logs */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("logsTitle")}</h3>
<Card>
<CardContent className="p-0">
<ScanLogList logs={logs} loading={logsLoading} />
</CardContent>
</Card>
</div>
</div>
</div>
</div>
)

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 扫描日志列表组件
*
* 特性:
* - 预渲染 HTML 字符串,减少 DOM 节点提升性能
* - 颜色区分info=默认, warning=黄色, error=红色
* - 自动滚动到底部
* 复用 AnsiLogViewer 组件
*/
export function ScanLogList({ logs, loading }: ScanLogListProps) {
const containerRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true) // 跟踪用户是否在底部
// 稳定的 content 引用,只有内容真正变化时才更新
const contentRef = useRef('')
const lastLogCountRef = useRef(0)
const lastLogIdRef = useRef<number | null>(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 `<div style="line-height:1.625;word-break:break-all;${levelStyle}"><span style="color:#6b7280">${time}</span> ${content}</div>`
}).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 (
<div className="h-full flex items-center justify-center bg-[#1e1e1e] text-[#808080]">
...
</div>
)
}
// 只有用户在底部时才自动滚动
useEffect(() => {
if (containerRef.current && isAtBottomRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [htmlContent])
if (logs.length === 0) {
return (
<div className="h-full flex items-center justify-center bg-[#1e1e1e] text-[#808080]">
</div>
)
}
return (
<div
ref={containerRef}
className="h-[400px] overflow-y-auto font-mono text-[11px] p-3 bg-muted/30 rounded-lg"
>
{logs.length === 0 && !loading && (
<div className="text-muted-foreground text-center py-8">
</div>
)}
{htmlContent && (
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
)}
{loading && logs.length === 0 && (
<div className="text-muted-foreground text-center py-8">
...
</div>
)}
</div>
)
return <AnsiLogViewer content={content} />
}

View File

@@ -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])

View File

@@ -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",

View File

@@ -747,9 +747,11 @@
"assetsTitle": "发现的资产",
"vulnerabilitiesTitle": "漏洞统计",
"stagesTitle": "扫描进度",
"stagesCompleted": "完成",
"logsTitle": "扫描日志",
"noStages": "暂无阶段进度",
"totalFound": "个漏洞",
"totalVulns": "共 {count} 个",
"viewAll": "查看全部",
"cards": {
"websites": "网站",