OpenVuln / src /features /project /ProjectPage.tsx
zRzRzRzRzRzRzR
feat: sync latest OpenVuln frontend
d22337b
Raw
History Blame Contribute Delete
26.5 kB
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowUpRight,
Check,
ChevronDown,
Download,
ExternalLink,
Github,
LoaderCircle,
Shield,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useParams, useSearchParams } from "react-router-dom";
import { EmptyState } from "../../components/EmptyState";
import { ReportBody } from "../../components/ReportBody";
import { ScanDurationNotice } from "../../components/ScanDurationNotice";
import { SeverityBar } from "../../components/SeverityBar";
import { SeverityChip } from "../../components/SeverityChip";
import { ScanningSpinner, StatusBadge } from "../../components/StatusBadge";
import { api, apiUrl } from "../../shared/api/client";
import { useMe } from "../auth/useAuth";
import { OwnerFindings } from "./OwnerFindings";
import { VersionBar, type ViewJob } from "./VersionBar";
import { formatDate, formatStars, shortSha, totalFindings } from "../../shared/lib/format";
export function ProjectPage() {
const [openKey, setOpenKey] = useState<string | null>(null);
const { owner = "", repo = "" } = useParams();
const [params, setParams] = useSearchParams();
const tabParam = params.get("tab");
const location = useLocation();
const justSubmitted = Boolean(
(location.state as { justSubmitted?: boolean } | null)?.justSubmitted,
);
const projectQ = useQuery({
queryKey: ["public", "project", owner, repo],
queryFn: () => api.getProject(owner, repo),
enabled: Boolean(owner && repo),
refetchInterval: (q) => {
const s = q.state.data?.latest_scan?.state;
if (s === "queued" || s === "dispatching" || s === "scanning") return 5000;
return false;
},
});
const project = projectQ.data;
const meQ = useMe();
// owner 探测:登录后尝试拉取全量 findings;200=有权限(显示 Manage findings tab),401/403=公众视图
const ownerQ = useQuery({
queryKey: ["owner-findings", project?.id],
queryFn: () => api.ownerFindings(project!.id),
enabled: Boolean(project?.id) && meQ.data?.authenticated === true,
retry: false,
staleTime: 30_000,
});
const isOwner = ownerQ.isSuccess;
// 版本查看状态(fish No.1253:切换操作上移到头部)
const [viewJob, setViewJob] = useState<ViewJob | null>(null);
// Details 与 Manage findings 合并为 Findings(fish No.1252);tab=details 兼容旧链接
const tab = tabParam === "findings" || tabParam === "details" ? "findings" : "overview";
const cweMax = useMemo(() => {
const list = project?.cwe_distribution ?? [];
return Math.max(1, ...list.map((c) => c.count));
}, [project]);
if (projectQ.isLoading) {
return <ScanProgressPage owner={owner} repo={repo} state="loading" />;
}
if (projectQ.isError || !project) {
return (
<div className="mx-auto max-w-6xl px-6 py-16">
<EmptyState
icon={Shield}
title="Project not found"
description="This project is not on OpenVuln yet."
action={
<Link to="/#projects" className="text-sm text-accent-600 hover:underline">
Back to projects
</Link>
}
/>
</div>
);
}
const state = project.latest_scan?.state;
const scanning = state === "scanning" || state === "dispatching";
const soFar = project.latest_scan?.findings_so_far ?? 0;
const findingsTotal = totalFindings(project.severity_counts);
if (state === "queued" || scanning) {
return (
<ScanProgressPage
owner={project.owner_login}
repo={project.name}
htmlUrl={project.html_url}
branch={project.default_branch}
state={state}
findingsSoFar={soFar}
justSubmitted={justSubmitted}
/>
);
}
return (
<div className="mx-auto max-w-6xl px-6 py-8">
<Link
to="/#projects"
className="inline-flex items-center gap-1.5 text-[13px] text-ink-secondary hover:text-ink"
>
<ArrowLeft size={14} /> Projects
</Link>
{justSubmitted && (
<div
className="mt-4 rounded-md border border-accent-100 bg-accent-50 px-4 py-3 text-sm text-accent-700"
role="status"
>
Added to the scan queue.
</div>
)}
{/* Header */}
<div className="mt-4 border-b border-line pb-6">
<div className="flex flex-wrap items-center gap-2">
<h1 className="font-display text-xl font-semibold tracking-tight">
<span className="text-ink-secondary">{project.owner_login}</span>
<span className="text-ink-tertiary"> / </span>
<span className="text-ink">{project.name}</span>
</h1>
<a
href={project.html_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-ink-secondary hover:text-accent-600"
title="View on GitHub"
>
<ExternalLink size={14} />
</a>
</div>
{project.description && (
<p className="mt-2 max-w-prose text-sm text-ink-secondary">{project.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] text-ink-tertiary">
{project.language && (
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-accent-600/70" />
{project.language}
</span>
)}
<span>·</span>
<span>★ {formatStars(project.stars)}</span>
<span>·</span>
<span>Added {formatDate(project.created_at)}</span>
</div>
{isOwner && (
<div className="mt-3">
<VersionBar projectId={project.id} viewJob={viewJob} onViewJob={setViewJob} />
</div>
)}
</div>
{/* Tabs */}
<div className="mt-4 flex gap-1 border-b border-line">
<TabButton active={tab === "overview"} onClick={() => setParams({})}>
Overview
</TabButton>
<TabButton active={tab === "findings"} onClick={() => setParams({ tab: "findings" })}>
Findings
</TabButton>
</div>
{tab === "overview" && (
<div className="py-6">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm">
<StatusBadge state={state} finishedAt={project.latest_scan?.finished_at} />
{project.latest_scan?.finished_at && (
<span className="text-ink-secondary">
{formatDate(project.latest_scan.finished_at)}
</span>
)}
{project.latest_scan?.commit_sha && (
<a
href={`${project.html_url}/commit/${project.latest_scan.commit_sha}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-0.5 font-mono text-[13px] text-ink-secondary hover:text-accent-600"
>
{shortSha(project.latest_scan.commit_sha)}
<ArrowUpRight size={12} />
</a>
)}
</div>
<p className="mt-2 text-[13px] text-ink-secondary">
{state === "completed" ? "Scanned" : scanning ? "Scanning" : "To be scanned"} by{" "}
<span className="font-medium text-ink">VulnHunter AI engine</span>
{project.default_branch ? ` · default branch (${project.default_branch})` : null}
</p>
{scanning && (
<div className="mt-8 space-y-3">
<ScanningSpinner
label={
soFar > 0
? `Scan in progress — ${soFar} finding${soFar === 1 ? "" : "s"} so far`
: "Scan in progress — stats will appear when complete."
}
/>
<div className="h-2 w-full max-w-xl animate-pulse rounded-full bg-surface-sunken" />
</div>
)}
{state !== "completed" && !scanning && (
<p className="mt-8 text-sm text-ink-secondary">This project is in the scan queue.</p>
)}
{state === "completed" && (
<>
<section className="mt-8">
<p className="font-mono text-[11px] uppercase tracking-wider text-ink-tertiary">
Scan results
</p>
<h2 className="mt-1 font-display text-base font-semibold text-ink">
Findings overview
</h2>
{findingsTotal === 0 ? (
<p className="mt-3 max-w-prose text-sm text-ink-secondary">
No findings were confirmed in this scan. A clean scan does not prove a project is
free of vulnerabilities.
</p>
) : (
<>
<p className="mt-3 text-sm font-medium text-ink">
{findingsTotal} finding{findingsTotal === 1 ? "" : "s"} found
</p>
<div className="mt-4">
<SeverityBar
counts={project.severity_counts}
widthClass="w-full max-w-[80%]"
heightClass="h-2"
/>
</div>
</>
)}
</section>
{project.cwe_distribution.length > 0 && (
<section className="mt-10">
<h2 className="font-display text-base font-semibold text-ink">
Top CWE categories
</h2>
<ul className="mt-4 max-w-[80%] space-y-2.5">
{project.cwe_distribution.slice(0, 5).map((c) => (
<li key={c.cwe} className="flex items-center gap-3 text-sm">
<a
href={`https://cwe.mitre.org/data/definitions/${c.cwe.replace(/^CWE-/i, "")}.html`}
target="_blank"
rel="noreferrer"
className="w-24 shrink-0 font-mono text-[13px] text-accent-700 hover:underline"
>
{c.cwe}
</a>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-sunken">
<div
className="h-full rounded-full bg-ink/40"
style={{ width: `${(c.count / cweMax) * 100}%` }}
/>
</div>
<span className="w-8 text-right font-mono text-[13px] text-ink-secondary">
{c.count}
</span>
</li>
))}
</ul>
</section>
)}
<div className="mt-10 rounded-md border border-line bg-surface-raised p-5">
<p className="text-sm leading-relaxed text-ink-secondary">
Detailed findings are visible to signed-in repository maintainers. Maintainers
review the full reports and choose what to disclose publicly.
</p>
</div>
{project.disclosed_findings.length > 0 && (
<section className="mt-10">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="font-display text-base font-semibold text-ink">
Disclosed findings
</h2>
<div className="flex items-center gap-3 text-[13px]">
<a
href={apiUrl(`/api/projects/${project.id}/report?format=zip`)}
className="inline-flex items-center gap-1 text-ink-secondary hover:text-accent-600"
>
<Download size={14} /> Download all (zip)
</a>
</div>
</div>
<div className="mt-3 overflow-hidden rounded-md border border-line">
<table className="w-full table-fixed text-left text-sm">
<thead className="border-b border-line bg-surface-sunken/50 text-xs uppercase tracking-wide text-ink-secondary">
<tr>
<th className="w-[92px] px-3 py-2.5 font-medium">Sev</th>
<th className="px-3 py-2.5 font-medium">Title</th>
<th className="w-[112px] px-3 py-2.5 font-medium">CWE</th>
<th className="w-[120px] px-3 py-2.5 font-medium">Status</th>
<th className="w-[52px] px-3 py-2.5 font-medium" />
</tr>
</thead>
<tbody>
{project.disclosed_findings.map((f) => (
<tr key={f.id} className="border-b border-line last:border-0">
<td className="px-3 py-3">
<SeverityChip severity={f.severity} />
</td>
<td className="max-w-0 truncate px-3 py-3 font-medium text-ink" title={f.title}>
{f.title}
</td>
<td className="px-3 py-3 font-mono text-[13px] text-ink-secondary">
{f.cwe ?? "—"}
</td>
<td className="px-3 py-3 text-[13px] text-success">Disclosed</td>
<td className="px-3 py-3 text-right">
<a
href={apiUrl(`/api/projects/${project.id}/report/${encodeURIComponent(f.finding_key)}`)}
className="text-ink-tertiary hover:text-accent-600"
title="Download full report (markdown)"
download
>
<Download size={14} className="inline" />
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</>
)}
</div>
)}
{tab === "findings" && !isOwner && (
<div className="space-y-4 py-6">
{project.disclosed_findings.length === 0 ? (
<div className="rounded-md border border-line bg-surface-raised p-6">
<h2 className="font-display text-base font-semibold text-ink">
No disclosures yet
</h2>
<p className="mt-2 w-full text-sm leading-relaxed text-ink-secondary">
Full finding details are visible to repository maintainers after sign-in. Disclosed
findings appear here with the full report content, and on the Overview tab.
</p>
</div>
) : (
project.disclosed_findings.map((f) => {
const open = openKey === f.id;
return (
<article
key={f.id}
className="overflow-hidden rounded-md border border-line bg-surface-raised"
>
<div
role="button"
tabIndex={0}
onClick={() => setOpenKey(open ? null : f.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpenKey(open ? null : f.id);
}
}}
aria-expanded={open}
className="flex w-full cursor-pointer items-start gap-3 px-5 py-4 text-left transition-colors hover:bg-surface-sunken/50 focus-ring sm:px-6"
>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<SeverityChip severity={f.severity} />
{f.cwe && (
<span className="font-mono text-[12px] text-ink-secondary">{f.cwe}</span>
)}
<span className="text-[12px] text-success">Disclosed</span>
</div>
<h3
className="line-clamp-2 font-display text-[15px] font-semibold leading-snug text-ink"
title={f.title}
>
{f.title}
</h3>
<p className="font-mono text-[11px] text-ink-tertiary">{f.finding_key}</p>
</div>
<span className="flex shrink-0 items-center gap-2 self-center">
<a
href={apiUrl(`/api/projects/${project.id}/report/${encodeURIComponent(f.finding_key)}`)}
className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1.5 text-xs font-medium text-ink-secondary hover:border-accent-300 hover:text-accent-700"
download
onClick={(e) => e.stopPropagation()}
title="Download full report (markdown)"
>
<Download size={14} />
.md
</a>
<ChevronDown
size={16}
className={`text-ink-tertiary transition-transform ${open ? "rotate-180" : ""}`}
/>
</span>
</div>
{open && (
<div className="border-t border-line px-5 pb-5 sm:px-6 sm:pb-6">
{f.report ? (
<ReportBody report={f.report} />
) : (
<p className="mt-4 text-sm text-ink-tertiary">
Structured report is not available for this disclosure (older
summary-only disclose). Use the download button if a fidelity pack was
attached later.
</p>
)}
</div>
)}
</article>
);
})
)}
</div>
)}
{tab === "findings" && isOwner && (
<div className="py-6">
<OwnerFindings
projectId={project.id}
currentFindings={ownerQ.data?.findings ?? []}
viewJob={viewJob}
onViewJob={setViewJob}
/>
</div>
)}
</div>
);
}
function ScanProgressPage({
owner,
repo,
htmlUrl,
branch,
state,
findingsSoFar = 0,
justSubmitted = false,
}: {
owner: string;
repo: string;
htmlUrl?: string;
branch?: string | null;
state: "loading" | "queued" | "dispatching" | "scanning";
findingsSoFar?: number;
justSubmitted?: boolean;
}) {
useEffect(() => {
document.body.classList.add("openvuln-running");
return () => document.body.classList.remove("openvuln-running");
}, []);
const queued = state === "queued";
const loading = state === "loading";
const dispatching = state === "dispatching";
const currentStage = queued ? 0 : dispatching ? 1 : 2;
const stages = ["Queued", "Preparing", "Scanning", "Results"];
const statusLabel = loading
? "Loading scan status"
: queued
? "Waiting in scan queue"
: dispatching
? "Preparing the scan"
: "AI security analysis in progress";
const detail = loading
? "Retrieving the latest status…"
: queued
? "The repository is queued and will start automatically when a scanner is available."
: dispatching
? "OpenVuln is packaging the repository and handing it to an available scanner."
: findingsSoFar > 0
? `${findingsSoFar} confirmed finding${findingsSoFar === 1 ? "" : "s"} so far. Analysis is still running.`
: "VulnHunter is analyzing the repository. Results will appear after the scan completes.";
return (
<div className="fixed inset-0 z-50 overflow-y-auto bg-surface text-ink">
<div className="openvuln-glow pointer-events-none absolute inset-0 -z-10" />
<header className="flex items-center justify-between px-5 py-5 sm:px-8 sm:py-7">
<Link
to="/"
className="inline-flex items-center gap-2 rounded-full border border-line bg-surface-raised px-3.5 py-2 text-xs font-medium text-ink-secondary transition hover:border-line-strong hover:bg-surface-sunken hover:text-ink focus-ring"
>
<ArrowLeft size={14} />
OpenVuln
</Link>
{htmlUrl && (
<a
href={htmlUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-line bg-surface-raised px-3.5 py-2 text-xs font-medium text-ink-secondary transition hover:border-line-strong hover:bg-surface-sunken hover:text-ink focus-ring"
>
<Github size={14} />
GitHub
</a>
)}
</header>
<main className="mx-auto flex min-h-[calc(100vh-88px)] w-full max-w-4xl flex-col items-center justify-center px-5 pb-16 pt-8 text-center sm:px-8">
<div className="openvuln-brand flex h-16 w-16 items-center justify-center rounded-2xl border border-line bg-surface-raised shadow-[0_20px_56px_-20px_rgba(0,0,0,0.7)]">
<LoaderCircle
size={28}
className="animate-spin text-accent-600 motion-reduce:animate-none"
strokeWidth={1.7}
/>
</div>
<p className="mt-7 font-mono text-[11px] uppercase tracking-[0.22em] text-ink-tertiary">
{justSubmitted ? "Repository added" : "Live scan status"}
</p>
<h1 className="openvuln-title mt-3 text-balance font-display text-3xl font-medium leading-tight sm:text-5xl">
{statusLabel}
</h1>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2 text-sm text-ink-secondary">
<span className="text-ink">{owner}</span>
<span className="text-ink-tertiary">/</span>
<span className="text-ink">{repo}</span>
{branch && (
<>
<span className="text-ink-tertiary">·</span>
<span className="font-mono text-xs text-ink-tertiary">{branch}</span>
</>
)}
</div>
<div className="openvuln-composer mt-9 w-full max-w-2xl rounded-2xl border border-line bg-surface-raised px-5 py-5 text-left shadow-[0_24px_64px_-24px_rgba(0,0,0,0.7)] sm:px-6">
<div className="flex items-center gap-3">
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent-600 opacity-40 motion-reduce:animate-none" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-accent-600" />
</span>
<p className="text-sm font-medium text-ink">{statusLabel}</p>
</div>
<p className="mt-3 text-sm leading-6 text-ink-secondary">{detail}</p>
{!loading && (
<div className="mt-5 border-t border-line pt-4">
<div className="flex items-center justify-between text-xs text-ink-tertiary">
<span>Current stage</span>
<span>
{currentStage + 1} of {stages.length}
</span>
</div>
<ol className="mt-3 grid grid-cols-4 gap-2" aria-label="Scan stages">
{stages.map((stageLabel, index) => {
const complete = index < currentStage;
const current = index === currentStage;
return (
<li
key={stageLabel}
className={`flex min-w-0 flex-col items-center gap-2 rounded-xl border px-2 py-3 text-center ${
current
? "border-accent-200 bg-accent-50 text-ink"
: complete
? "border-line bg-surface-sunken text-ink-secondary"
: "border-line bg-surface text-ink-tertiary"
}`}
aria-current={current ? "step" : undefined}
>
<span
className={`flex h-6 w-6 items-center justify-center rounded-full border text-[11px] ${
current
? "border-transparent bg-ink text-surface"
: complete
? "border-line bg-surface text-ink-secondary"
: "border-line text-ink-tertiary"
}`}
>
{complete ? <Check size={13} strokeWidth={2.2} /> : index + 1}
</span>
<span className="truncate text-[11px] font-medium sm:text-xs">
{stageLabel}
</span>
</li>
);
})}
</ol>
<p className="mt-3 text-xs text-ink-tertiary">
This shows status stages, not elapsed-time progress. Refreshes every 5 seconds.
</p>
</div>
)}
</div>
<ScanDurationNotice className="mt-4 w-full max-w-2xl" />
</main>
</div>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`-mb-px inline-flex items-center border-b-2 px-3 py-2.5 text-sm font-medium transition-colors focus-ring ${
active
? "border-accent-600 text-accent-700"
: "border-transparent text-ink-secondary hover:text-ink"
}`}
>
{children}
</button>
);
}