diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index 28ea711..bf1c6b4 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -3,7 +3,7 @@ * Cyberpunk Terminal Aesthetic */ -import { useState, useEffect } from "react"; +import { useMemo, useState, useEffect } from "react"; import { useParams, Link } from "react-router-dom"; import { Button } from "@/components/ui/button"; @@ -24,6 +24,7 @@ import { AlertTriangle, CheckCircle, Clock, + XCircle, Play, FileText, Upload, @@ -32,7 +33,9 @@ import { } from "lucide-react"; import { api } from "@/shared/config/database"; import { runRepositoryAudit, scanStoredZipFile } from "@/features/projects/services"; -import type { Project, AuditTask, CreateProjectForm } from "@/shared/types"; +import type { Project, AuditTask, CreateProjectForm, AuditIssue } from "@/shared/types"; +import type { AgentFinding, AgentTask } from "@/shared/api/agentTasks"; +import { getAgentFindings, getAgentTasks } from "@/shared/api/agentTasks"; import { hasZipFile } from "@/shared/utils/zipStorage"; import { isRepositoryProject, getSourceTypeLabel, getRepositoryPlatformLabel } from "@/shared/utils/projectUtils"; import { toast } from "sonner"; @@ -45,7 +48,8 @@ import { SUPPORTED_LANGUAGES, REPOSITORY_PLATFORMS } from "@/shared/constants"; export default function ProjectDetail() { const { id } = useParams<{ id: string }>(); const [project, setProject] = useState(null); - const [tasks, setTasks] = useState([]); + const [auditTasks, setAuditTasks] = useState([]); + const [agentTasks, setAgentTasks] = useState([]); const [loading, setLoading] = useState(true); const [scanning, setScanning] = useState(false); const [showCreateTaskDialog, setShowCreateTaskDialog] = useState(false); @@ -61,34 +65,243 @@ export default function ProjectDetail() { programming_languages: [] }); const [activeTab, setActiveTab] = useState("overview"); - const [latestIssues, setLatestIssues] = useState([]); + type AggregatedAuditIssue = AuditIssue & { + task_created_at?: string; + task_completed_at?: string; + }; + + const [latestIssues, setLatestIssues] = useState([]); + type AggregatedAgentFinding = AgentFinding & { + task_created_at?: string; + task_completed_at?: string | null; + }; + + const [latestFindings, setLatestFindings] = useState([]); const [loadingIssues, setLoadingIssues] = useState(false); + const [issuesSummary, setIssuesSummary] = useState<{ + completedAuditTasksCount: number; + completedAgentTasksCount: number; + fetchedAuditTasksCount: number; + fetchedAgentTasksCount: number; + isLimited: boolean; + maxTasks: number; + }>({ + completedAuditTasksCount: 0, + completedAgentTasksCount: 0, + fetchedAuditTasksCount: 0, + fetchedAgentTasksCount: 0, + isLimited: false, + maxTasks: 20 + }); const [showFileSelectionDialog, setShowFileSelectionDialog] = useState(false); const [showAuditOptionsDialog, setShowAuditOptionsDialog] = useState(false); useEffect(() => { - if (activeTab === 'issues' && tasks.length > 0) { + if (activeTab === 'issues' && (auditTasks.length > 0 || agentTasks.length > 0)) { loadLatestIssues(); } - }, [activeTab, tasks]); + }, [activeTab, auditTasks, agentTasks]); const loadLatestIssues = async () => { - const completedTasks = tasks.filter(t => t.status === 'completed').sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - if (completedTasks.length > 0) { - setLoadingIssues(true); - try { - const issues = await api.getAuditIssues(completedTasks[0].id); - setLatestIssues(issues); - } catch (error) { - console.error('Failed to load issues:', error); - toast.error("加载问题列表失败"); - } finally { - setLoadingIssues(false); - } + const completedAuditTasks = auditTasks + .filter((t: AuditTask) => t.status === 'completed') + .sort((a: AuditTask, b: AuditTask) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + const completedAgentTasks = agentTasks + .filter((t: AgentTask) => t.status === 'completed') + .sort((a: AgentTask, b: AgentTask) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + const MAX_TASKS = 20; + const limitedAuditTasks = completedAuditTasks.slice(0, MAX_TASKS); + const limitedAgentTasks = completedAgentTasks.slice(0, MAX_TASKS); + + setIssuesSummary({ + completedAuditTasksCount: completedAuditTasks.length, + completedAgentTasksCount: completedAgentTasks.length, + fetchedAuditTasksCount: limitedAuditTasks.length, + fetchedAgentTasksCount: limitedAgentTasks.length, + isLimited: completedAuditTasks.length > MAX_TASKS || completedAgentTasks.length > MAX_TASKS, + maxTasks: MAX_TASKS + }); + + if (limitedAuditTasks.length === 0 && limitedAgentTasks.length === 0) { + setLatestIssues([]); + setLatestFindings([]); + return; + } + + setLoadingIssues(true); + try { + const [issuesResults, findingsResults] = await Promise.all([ + Promise.allSettled( + limitedAuditTasks.map(async (t: AuditTask) => { + const issues = await api.getAuditIssues(t.id); + const enriched: AggregatedAuditIssue[] = (issues || []).map((i) => ({ + ...(i as AuditIssue), + task_created_at: t.created_at, + task_completed_at: t.completed_at + })); + return enriched; + }) + ), + Promise.allSettled( + limitedAgentTasks.map(async (t: AgentTask) => { + const findings = await getAgentFindings(t.id); + const enriched: AggregatedAgentFinding[] = (findings || []).map((f) => ({ + ...(f as AgentFinding), + task_created_at: t.created_at, + task_completed_at: t.completed_at + })); + return enriched; + }) + ) + ]); + + const flatIssues = issuesResults + .filter((r: PromiseSettledResult): r is PromiseFulfilledResult => r.status === 'fulfilled') + .flatMap((r: PromiseFulfilledResult) => r.value); + const flatFindings = findingsResults + .filter((r: PromiseSettledResult): r is PromiseFulfilledResult => r.status === 'fulfilled') + .flatMap((r: PromiseFulfilledResult) => r.value); + + const severityRank: Record = { critical: 4, high: 3, medium: 2, low: 1 }; + flatIssues.sort((a: AggregatedAuditIssue, b: AggregatedAuditIssue) => { + const sa = severityRank[a.severity] ?? 0; + const sb = severityRank[b.severity] ?? 0; + if (sa !== sb) return sb - sa; + + const ta = new Date(a.created_at).getTime(); + const tb = new Date(b.created_at).getTime(); + if (ta !== tb) return tb - ta; + + const tta = a.task_created_at ? new Date(a.task_created_at).getTime() : 0; + const ttb = b.task_created_at ? new Date(b.task_created_at).getTime() : 0; + return ttb - tta; + }); + + setLatestIssues(flatIssues); + flatFindings.sort((a: AggregatedAgentFinding, b: AggregatedAgentFinding) => { + const sa = severityRank[String(a.severity || '').toLowerCase()] ?? 0; + const sb = severityRank[String(b.severity || '').toLowerCase()] ?? 0; + if (sa !== sb) return sb - sa; + const ta = new Date(a.created_at).getTime(); + const tb = new Date(b.created_at).getTime(); + if (ta !== tb) return tb - ta; + const tta = a.task_created_at ? new Date(a.task_created_at).getTime() : 0; + const ttb = b.task_created_at ? new Date(b.task_created_at).getTime() : 0; + return ttb - tta; + }); + setLatestFindings(flatFindings); + } catch (error) { + console.error('Failed to load issues:', error); + toast.error("加载问题列表失败"); + } finally { + setLoadingIssues(false); } }; + type LatestProblem = { + kind: 'audit' | 'agent'; + id: string; + task_id: string; + task_created_at?: string; + created_at: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + title: string; + description?: string | null; + file_path?: string | null; + line_number?: number | null; + line_end?: number | null; + category?: string | null; + }; + + const latestProblems: LatestProblem[] = useMemo(() => { + const parsePathLineFromTitle = (title: string) => { + // Pattern examples: + // "path/to/File.java:66 - Something" + // "path/to/File.java:137-138 - Something" + const m = title.match(/^(.*?):(\d+)(?:-(\d+))?\s*-\s*(.+)$/); + if (!m) return null; + const [, path, lineStartStr, lineEndStr, rest] = m; + const lineStart = Number(lineStartStr); + const lineEnd = lineEndStr ? Number(lineEndStr) : null; + return { + file_path: path, + line_start: Number.isFinite(lineStart) ? lineStart : null, + line_end: lineEnd != null && Number.isFinite(lineEnd) ? lineEnd : null, + rest_title: rest, + }; + }; + + const normalizeSeverity = (s: unknown): LatestProblem['severity'] => { + const v = String(s || '').toLowerCase(); + if (v === 'critical') return 'critical'; + if (v === 'high') return 'high'; + if (v === 'medium') return 'medium'; + return 'low'; + }; + + const audit: LatestProblem[] = latestIssues.map((i) => ({ + // AuditIssue 在后端 schema 里可能叫 message(frontend type 没显式定义),这里做兼容兜底 + // 同时优先展示更“可读”的说明字段,避免 UI 出现大量 '-' + kind: 'audit', + id: i.id, + task_id: i.task_id, + task_created_at: i.task_created_at, + created_at: i.created_at, + severity: normalizeSeverity(i.severity), + title: i.title || '(未命名问题)', + description: + i.description ?? + (i as any).message ?? + (i as any).ai_explanation ?? + (i as any).suggestion ?? + (i as any).code_snippet ?? + null, + file_path: i.file_path, + line_number: i.line_number ?? null, + category: (i as any).issue_type ?? null, + })); + + const agent: LatestProblem[] = latestFindings.map((f) => { + const rawTitle = f.title || '(未命名漏洞)'; + const parsed = (!f.file_path || f.file_path === '-') ? parsePathLineFromTitle(rawTitle) : null; + + return { + kind: 'agent', + id: f.id, + task_id: f.task_id, + task_created_at: f.task_created_at, + created_at: f.created_at, + severity: normalizeSeverity(f.severity), + // 如果 title 里带了 "path:line - xxx",则剥离掉路径前缀,仅保留 xxx,避免标题重复且过长 + title: parsed?.rest_title || rawTitle, + description: f.description, + // 如果后端没给 file_path,尽量从 title 解析出来填到“文件”列 + file_path: f.file_path ?? parsed?.file_path ?? null, + line_number: ((f.line_start ?? parsed?.line_start ?? null) as any), + line_end: ((f.line_end ?? parsed?.line_end ?? null) as any), + category: (f as any).vulnerability_type ?? null, + }; + }); + + const merged = [...audit, ...agent]; + // 按时间倒序(最新在前),时间相同再按严重程度 + const severityRank: Record = { critical: 4, high: 3, medium: 2, low: 1 }; + merged.sort((a, b) => { + const ta = new Date(a.created_at).getTime(); + const tb = new Date(b.created_at).getTime(); + if (ta !== tb) return tb - ta; + const sa = severityRank[a.severity] ?? 0; + const sb = severityRank[b.severity] ?? 0; + if (sa !== sb) return sb - sa; + const tta = a.task_created_at ? new Date(a.task_created_at).getTime() : 0; + const ttb = b.task_created_at ? new Date(b.task_created_at).getTime() : 0; + return ttb - tta; + }); + return merged; + }, [latestIssues, latestFindings]); + const handleOpenSettings = () => { if (!project) return; @@ -136,13 +349,15 @@ export default function ProjectDetail() { try { setLoading(true); - const [projectData, tasksData] = await Promise.all([ + const [projectData, tasksData, agentTasksData] = await Promise.all([ api.getProjectById(id), - api.getAuditTasks(id) + api.getAuditTasks(id), + getAgentTasks({ project_id: id }).catch(() => []) ]); setProject(projectData); - setTasks(tasksData); + setAuditTasks(Array.isArray(tasksData) ? tasksData : []); + setAgentTasks(Array.isArray(agentTasksData) ? agentTasksData : []); } catch (error) { console.error('Failed to load project data:', error); toast.error("加载项目数据失败"); @@ -151,6 +366,36 @@ export default function ProjectDetail() { } }; + type UnifiedTask = + | { kind: 'audit'; task: AuditTask } + | { kind: 'agent'; task: AgentTask }; + + const unifiedTasks: UnifiedTask[] = useMemo(() => { + const merged: UnifiedTask[] = [ + ...auditTasks.map((t) => ({ kind: 'audit' as const, task: t })), + ...agentTasks.map((t) => ({ kind: 'agent' as const, task: t })), + ]; + merged.sort((a, b) => new Date((b.task as any).created_at).getTime() - new Date((a.task as any).created_at).getTime()); + return merged; + }, [auditTasks, agentTasks]); + + const combinedStats = useMemo(() => { + const totalTasks = auditTasks.length + agentTasks.length; + const completedTasks = + auditTasks.filter((t) => t.status === 'completed').length + + agentTasks.filter((t) => t.status === 'completed').length; + const totalIssues = + auditTasks.reduce((sum, t) => sum + (t.issues_count || 0), 0) + + agentTasks.reduce((sum, t) => sum + (t.findings_count || 0), 0); + const avgQualityScore = totalTasks > 0 + ? ( + (auditTasks.reduce((sum, t) => sum + (t.quality_score || 0), 0) + + agentTasks.reduce((sum, t) => sum + (t.quality_score || 0), 0)) / totalTasks + ) + : 0; + return { totalTasks, completedTasks, totalIssues, avgQualityScore }; + }, [auditTasks, agentTasks]); + const handleRunAudit = () => { setShowAuditOptionsDialog(true); }; @@ -270,6 +515,8 @@ export default function ProjectDetail() { return 运行中; case 'failed': return 失败; + case 'cancelled': + return 已取消; default: return 等待中; } @@ -280,6 +527,7 @@ export default function ProjectDetail() { case 'completed': return ; case 'running': return ; case 'failed': return ; + case 'cancelled': return ; default: return ; } }; @@ -379,7 +627,7 @@ export default function ProjectDetail() {

审计任务

-

{tasks.length}

+

{combinedStats.totalTasks}

@@ -391,7 +639,7 @@ export default function ProjectDetail() {

已完成

-

{tasks.filter(t => t.status === 'completed').length}

+

{combinedStats.completedTasks}

@@ -403,7 +651,7 @@ export default function ProjectDetail() {

发现问题

-

{tasks.reduce((sum, task) => sum + task.issues_count, 0)}

+

{combinedStats.totalIssues}

@@ -415,12 +663,7 @@ export default function ProjectDetail() {

平均质量分

-

- {tasks.length > 0 - ? (tasks.reduce((sum, task) => sum + task.quality_score, 0) / tasks.length).toFixed(1) - : '0.0' - } -

+

{combinedStats.avgQualityScore.toFixed(1)}

@@ -519,32 +762,39 @@ export default function ProjectDetail() {

最近活动

- {tasks.length > 0 ? ( + {unifiedTasks.length > 0 ? (
- {tasks.slice(0, 5).map((task) => ( + {unifiedTasks.slice(0, 5).map((t) => (
-
- {getStatusIcon(task.status)} + {getStatusIcon(t.task.status)}

- {task.task_type === 'repository' ? '仓库审计' : '即时分析'} + {t.kind === 'audit' + ? ((t.task as AuditTask).task_type === 'repository' ? '审计任务' : '即时分析') + : 'Agent 审计'}

- {formatDate(task.created_at)} + {formatDate(t.task.created_at)}

- {getStatusBadge(task.status)} +
+ + {t.kind === 'agent' ? 'AGENT' : 'AUDIT'} + + {getStatusBadge(t.task.status)} +
))}
@@ -571,10 +821,18 @@ export default function ProjectDetail() {
- {tasks.length > 0 ? ( + {unifiedTasks.length > 0 ? (
- {tasks.map((task) => ( -
+ {unifiedTasks.map((t) => { + const isAudit = t.kind === 'audit'; + const task = t.task as any; + const issuesOrFindings = isAudit ? (task.issues_count ?? 0) : (task.findings_count ?? 0); + const totalFiles = task.total_files ?? 0; + const totalLines = task.total_lines ?? '-'; + const qualityScore = typeof task.quality_score === 'number' ? task.quality_score : 0; + + return ( +

- {task.task_type === 'repository' ? '仓库审计任务' : '即时分析任务'} + {isAudit + ? (task.task_type === 'repository' ? '审计任务' : '即时分析任务') + : 'Agent 审计任务'}

创建于 {formatDate(task.created_at)}

- {getStatusBadge(task.status)} +
+ + {t.kind === 'agent' ? 'AGENT' : 'AUDIT'} + + {getStatusBadge(task.status)} +
-

{task.total_files}

+

{totalFiles}

总文件数

-

{task.total_lines}

+

{totalLines}

代码行数

-

{task.issues_count}

-

发现问题

+

{issuesOrFindings}

+

{isAudit ? '发现问题' : '发现漏洞'}

-

{task.quality_score.toFixed(1)}

+

{qualityScore.toFixed(1)}

质量评分

- {task.status === 'completed' && ( + {task.status === 'completed' && typeof qualityScore === 'number' && (
质量评分 - {task.quality_score.toFixed(1)}/100 + {qualityScore.toFixed(1)}/100
- +
)}
- +
- ))} + )})}
) : (
@@ -655,9 +920,11 @@ export default function ProjectDetail() {

最新发现的问题

- {tasks.length > 0 && ( + {(auditTasks.length > 0 || agentTasks.length > 0) && (

- 来自最近一次审计 ({formatDate(tasks[0].created_at)}) + 已完成审计任务:{issuesSummary.completedAuditTasksCount} 次 / Agent审计:{issuesSummary.completedAgentTasksCount} 次 + {issuesSummary.isLimited ? `(各仅展示最近 ${issuesSummary.maxTasks} 次)` : ''} + ,共 {latestProblems.length} 条问题/漏洞

)}
@@ -667,9 +934,9 @@ export default function ProjectDetail() {

正在加载问题列表...

- ) : latestIssues.length > 0 ? ( + ) : latestProblems.length > 0 ? (
- {latestIssues.map((issue, index) => ( + {latestProblems.map((issue, index) => (
@@ -683,25 +950,46 @@ export default function ProjectDetail() {

{issue.title}

- {issue.file_path}:{issue.line_number} - {issue.category} + + {issue.file_path || '未知文件'} + {issue.line_number != null + ? (issue.line_end != null && issue.line_end !== issue.line_number + ? `:${issue.line_number}-${issue.line_end}` + : `:${issue.line_number}`) + : '' + } + + {issue.category || '-'} + {issue.task_created_at && ( + + {issue.kind === 'agent' ? 'Agent' : 'Audit'} {issue.task_id?.slice(0, 8)} · {formatDate(issue.task_created_at)} + + )}
- - {issue.severity === 'critical' ? '严重' : - issue.severity === 'high' ? '高' : - issue.severity === 'medium' ? '中等' : '低'} - +
+ + + + + {issue.severity === 'critical' ? '严重' : + issue.severity === 'high' ? '高' : + issue.severity === 'medium' ? '中等' : '低'} + +

- {issue.description} + {issue.description || '-'}

))} @@ -710,7 +998,7 @@ export default function ProjectDetail() {

未发现问题

-

最近一次审计未发现明显问题,或尚未进行审计。

+

最近一次审计/Agent审计未发现明显问题,或尚未进行审计。

)} @@ -939,4 +1227,4 @@ export default function ProjectDetail() { />
); -} +} \ No newline at end of file