mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 11:46:16 +08:00
- 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
443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import React, { useState } from "react"
|
|
import Link from "next/link"
|
|
import { useTranslations, useLocale } from "next-intl"
|
|
import {
|
|
Globe,
|
|
Network,
|
|
Server,
|
|
Link2,
|
|
FolderOpen,
|
|
AlertTriangle,
|
|
Clock,
|
|
Calendar,
|
|
ChevronRight,
|
|
Target,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
Cpu,
|
|
HardDrive,
|
|
} from "lucide-react"
|
|
import {
|
|
IconCircleCheck,
|
|
IconCircleX,
|
|
IconClock,
|
|
} from "@tabler/icons-react"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { Badge } from "@/components/ui/badge"
|
|
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"
|
|
import { getDateLocale } from "@/lib/date-utils"
|
|
import { cn } from "@/lib/utils"
|
|
import type { StageStatus } from "@/types/scan.types"
|
|
|
|
interface ScanOverviewProps {
|
|
scanId: number
|
|
}
|
|
|
|
/**
|
|
* Scan overview component
|
|
* Displays statistics cards for the scan results
|
|
*/
|
|
// Pulsing dot animation
|
|
function PulsingDot({ className }: { className?: string }) {
|
|
return (
|
|
<span className={cn("relative flex h-3 w-3", className)}>
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
|
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-current" />
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// Stage status icon
|
|
function StageStatusIcon({ status }: { status: StageStatus }) {
|
|
switch (status) {
|
|
case "completed":
|
|
return <IconCircleCheck className="h-5 w-5 text-[#238636] dark:text-[#3fb950]" />
|
|
case "running":
|
|
return <PulsingDot className="text-[#d29922]" />
|
|
case "failed":
|
|
return <IconCircleX className="h-5 w-5 text-[#da3633] dark:text-[#f85149]" />
|
|
case "cancelled":
|
|
return <IconCircleX className="h-5 w-5 text-[#848d97]" />
|
|
default:
|
|
return <IconClock className="h-5 w-5 text-muted-foreground" />
|
|
}
|
|
}
|
|
|
|
// Format duration (seconds -> readable string)
|
|
function formatStageDuration(seconds?: number): string | undefined {
|
|
if (seconds === undefined || seconds === null) return undefined
|
|
if (seconds < 1) return "<1s"
|
|
if (seconds < 60) return `${Math.round(seconds)}s`
|
|
const minutes = Math.floor(seconds / 60)
|
|
const secs = Math.round(seconds % 60)
|
|
return secs > 0 ? `${minutes}m ${secs}s` : `${minutes}m`
|
|
}
|
|
|
|
export function ScanOverview({ scanId }: ScanOverviewProps) {
|
|
const t = useTranslations("scan.history.overview")
|
|
const tStatus = useTranslations("scan.history.status")
|
|
const tProgress = useTranslations("scan.progress")
|
|
const locale = useLocale()
|
|
|
|
const { data: scan, isLoading, error } = useScan(scanId)
|
|
|
|
// 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 && autoRefresh ? 3000 : 0,
|
|
})
|
|
|
|
// Format date helper
|
|
const formatDate = (dateString: string | undefined): string => {
|
|
if (!dateString) return "-"
|
|
return new Date(dateString).toLocaleString(getDateLocale(locale), {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
}
|
|
|
|
// Calculate duration
|
|
const formatDuration = (startedAt: string | undefined, completedAt: string | undefined): string => {
|
|
if (!startedAt) return "-"
|
|
const start = new Date(startedAt)
|
|
const end = completedAt ? new Date(completedAt) : new Date()
|
|
const diffMs = end.getTime() - start.getTime()
|
|
const diffMins = Math.floor(diffMs / 60000)
|
|
const diffHours = Math.floor(diffMins / 60)
|
|
const remainingMins = diffMins % 60
|
|
|
|
if (diffHours > 0) {
|
|
return `${diffHours}h ${remainingMins}m`
|
|
}
|
|
return `${diffMins}m`
|
|
}
|
|
|
|
// Status style configuration (consistent with scan-history-columns)
|
|
const SCAN_STATUS_STYLES: Record<string, string> = {
|
|
running: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
|
|
cancelled: "bg-[#848d97]/10 text-[#848d97] border-[#848d97]/20",
|
|
completed: "bg-[#238636]/10 text-[#238636] border-[#238636]/20 dark:text-[#3fb950]",
|
|
failed: "bg-[#da3633]/10 text-[#da3633] border-[#da3633]/20 dark:text-[#f85149]",
|
|
initiated: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
|
|
pending: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
|
|
}
|
|
|
|
// Get status icon
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case "completed":
|
|
return { icon: CheckCircle2, animate: false }
|
|
case "running":
|
|
return { icon: Loader2, animate: true }
|
|
case "failed":
|
|
return { icon: XCircle, animate: false }
|
|
case "cancelled":
|
|
return { icon: XCircle, animate: false }
|
|
case "pending":
|
|
case "initiated":
|
|
return { icon: Loader2, animate: true }
|
|
default:
|
|
return { icon: Clock, animate: false }
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Stats cards skeleton */}
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{[...Array(6)].map((_, i) => (
|
|
<Card key={i}>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-4 w-4" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-8 w-16" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !scan) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-12">
|
|
<AlertTriangle className="h-10 w-10 text-destructive mb-4" />
|
|
<p className="text-muted-foreground">{t("loadError")}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Use type assertion for extended properties
|
|
const scanAny = scan as any
|
|
const summary = scanAny.summary || {}
|
|
const vulnSummary = summary.vulnerabilities || { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
const statusIconConfig = getStatusIcon(scan.status)
|
|
const StatusIcon = statusIconConfig.icon
|
|
const statusStyle = SCAN_STATUS_STYLES[scan.status] || "bg-muted text-muted-foreground"
|
|
const targetId = scanAny.target // Target ID
|
|
const targetName = scan.targetName // Target name
|
|
const startedAt = scanAny.startedAt || scan.createdAt
|
|
const completedAt = scanAny.completedAt
|
|
|
|
const assetCards = [
|
|
{
|
|
title: t("cards.websites"),
|
|
value: summary.websites || 0,
|
|
icon: Globe,
|
|
href: `/scan/history/${scanId}/websites/`,
|
|
},
|
|
{
|
|
title: t("cards.subdomains"),
|
|
value: summary.subdomains || 0,
|
|
icon: Network,
|
|
href: `/scan/history/${scanId}/subdomain/`,
|
|
},
|
|
{
|
|
title: t("cards.ips"),
|
|
value: summary.ips || 0,
|
|
icon: Server,
|
|
href: `/scan/history/${scanId}/ip-addresses/`,
|
|
},
|
|
{
|
|
title: t("cards.urls"),
|
|
value: summary.endpoints || 0,
|
|
icon: Link2,
|
|
href: `/scan/history/${scanId}/endpoints/`,
|
|
},
|
|
{
|
|
title: t("cards.directories"),
|
|
value: summary.directories || 0,
|
|
icon: FolderOpen,
|
|
href: `/scan/history/${scanId}/directories/`,
|
|
},
|
|
]
|
|
|
|
return (
|
|
<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">
|
|
{/* Target */}
|
|
{targetId && targetName && (
|
|
<Link
|
|
href={`/target/${targetId}/overview/`}
|
|
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
|
>
|
|
<Target className="h-4 w-4" />
|
|
<span>{targetName}</span>
|
|
</Link>
|
|
)}
|
|
{/* Started at */}
|
|
<div className="flex items-center gap-1.5">
|
|
<Calendar className="h-4 w-4" />
|
|
<span>{t("startedAt")}: {formatDate(startedAt)}</span>
|
|
</div>
|
|
{/* Duration */}
|
|
<div className="flex items-center gap-1.5">
|
|
<Clock className="h-4 w-4" />
|
|
<span>{t("duration")}: {formatDuration(startedAt, completedAt)}</span>
|
|
</div>
|
|
{/* Engine */}
|
|
{scan.engineNames && scan.engineNames.length > 0 && (
|
|
<div className="flex items-center gap-1.5">
|
|
<Cpu className="h-4 w-4" />
|
|
<span>{scan.engineNames.join(", ")}</span>
|
|
</div>
|
|
)}
|
|
{/* Worker */}
|
|
{scan.workerName && (
|
|
<div className="flex items-center gap-1.5">
|
|
<HardDrive className="h-4 w-4" />
|
|
<span>{scan.workerName}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Status badge */}
|
|
<Badge variant="outline" className={statusStyle}>
|
|
<StatusIcon className={`h-3.5 w-3.5 mr-1.5 ${statusIconConfig.animate ? 'animate-spin' : ''}`} />
|
|
{tStatus(scan.status)}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Asset statistics cards */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold mb-4">{t("assetsTitle")}</h3>
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
|
{assetCards.map((card) => (
|
|
<Link key={card.title} href={card.href}>
|
|
<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">{card.title}</CardTitle>
|
|
<card.icon className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{card.value.toLocaleString()}</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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">
|
|
<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="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="text-sm text-muted-foreground text-center py-4">
|
|
{t("noStages")}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 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>
|
|
{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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|