Spaces:
Running
Running
| import { useEffect, useRef, useState } from "react"; | |
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| import { Ban, ChevronDown, CircleCheck, LoaderCircle } from "lucide-react"; | |
| import { api, type ScanJobSummary } from "../../shared/api/client"; | |
| import { ConfirmDialog } from "../../components/ConfirmDialog"; | |
| import { formatRelativeTime, shortSha } from "../../shared/lib/format"; | |
| export interface ViewJob { | |
| id: string; | |
| label: string; | |
| } | |
| /** | |
| * 项目头部版本条(owner 专属,fish No.1253):版本切换从 tab 内容区上移到标题区。 | |
| * 紧凑单行:当前版本下拉(切换查看历史版本)+ 进行中状态(可取消)+ Rescan。 | |
| */ | |
| export function VersionBar({ | |
| projectId, | |
| viewJob, | |
| onViewJob, | |
| }: { | |
| projectId: string; | |
| viewJob: ViewJob | null; | |
| onViewJob: (v: ViewJob | null) => void; | |
| }) { | |
| const qc = useQueryClient(); | |
| const [open, setOpen] = useState(false); | |
| const [cancelTarget, setCancelTarget] = useState<ScanJobSummary | null>(null); | |
| const rootRef = useRef<HTMLDivElement>(null); | |
| const scansQ = useQuery({ | |
| queryKey: ["project-scans", projectId], | |
| queryFn: () => api.projectScans(projectId), | |
| refetchInterval: (q) => | |
| (q.state.data?.scans ?? []).some((s) => ["queued", "dispatching", "scanning"].includes(s.state)) | |
| ? 5000 | |
| : false, | |
| retry: false, | |
| }); | |
| useEffect(() => { | |
| if (!open) return; | |
| const onDoc = (e: MouseEvent) => { | |
| if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); | |
| }; | |
| const onKey = (e: KeyboardEvent) => { | |
| if (e.key === "Escape") setOpen(false); | |
| }; | |
| document.addEventListener("mousedown", onDoc); | |
| document.addEventListener("keydown", onKey); | |
| return () => { | |
| document.removeEventListener("mousedown", onDoc); | |
| document.removeEventListener("keydown", onKey); | |
| }; | |
| }, [open]); | |
| const invalidateAll = () => { | |
| void qc.invalidateQueries({ queryKey: ["project-scans", projectId] }); | |
| void qc.invalidateQueries({ queryKey: ["public", "project"] }); | |
| void qc.invalidateQueries({ queryKey: ["owner-findings", projectId] }); | |
| }; | |
| const cancelM = useMutation({ | |
| mutationFn: (jobId: string) => api.cancelScanJob(projectId, jobId), | |
| onSettled: () => { | |
| setCancelTarget(null); | |
| invalidateAll(); | |
| }, | |
| }); | |
| const scans = scansQ.data?.scans ?? []; | |
| const inflight = scans.find((s) => ["queued", "dispatching", "scanning"].includes(s.state)); | |
| const completed = scans.filter((s) => s.state === "completed"); | |
| const current = completed[0] ?? null; // 列表按创建时间倒序 | |
| const viewing = viewJob ?? (current ? { id: current.id, label: current.commit_sha?.slice(0, 7) ?? "current" } : null); | |
| return ( | |
| <div ref={rootRef} className="flex flex-wrap items-center gap-2 text-[13px]"> | |
| {/* 版本切换下拉 */} | |
| <div className="relative"> | |
| <button | |
| type="button" | |
| onClick={() => setOpen((v) => !v)} | |
| aria-expanded={open} | |
| aria-haspopup="menu" | |
| className="inline-flex h-8 items-center gap-1.5 rounded-full border border-line bg-surface-raised px-3 text-ink transition-colors hover:border-line-strong hover:bg-surface-sunken focus-ring" | |
| > | |
| <CircleCheck size={13} className="text-success" /> | |
| <span className="font-mono text-[12px]">{viewing?.label ?? "—"}</span> | |
| <ChevronDown size={13} className={`text-ink-tertiary transition-transform ${open ? "rotate-180" : ""}`} /> | |
| </button> | |
| {open && ( | |
| <div | |
| role="menu" | |
| className="absolute left-0 top-full z-40 mt-1.5 w-72 overflow-hidden rounded-lg border border-line bg-surface shadow-lg" | |
| > | |
| <ul className="max-h-72 overflow-y-auto py-1"> | |
| {completed.map((s) => { | |
| const isCurrent = current?.id === s.id; | |
| const isSelected = viewing?.id === s.id; | |
| return ( | |
| <li key={s.id}> | |
| <button | |
| type="button" | |
| role="menuitem" | |
| onClick={() => { | |
| setOpen(false); | |
| onViewJob(isCurrent ? null : { id: s.id, label: s.commit_sha?.slice(0, 7) ?? s.id.slice(0, 8) }); | |
| }} | |
| className={`flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-surface-sunken ${ | |
| isSelected ? "bg-surface-sunken" : "" | |
| }`} | |
| > | |
| <span className="font-mono text-[12px] text-ink"> | |
| {s.commit_sha ? shortSha(s.commit_sha) : "—"} | |
| </span> | |
| <span className="text-[12px] text-ink-tertiary">{s.findings_so_far} findings</span> | |
| <span className="ml-auto font-mono text-[11px] text-ink-tertiary"> | |
| {formatRelativeTime(s.created_at)} | |
| </span> | |
| </button> | |
| </li> | |
| ); | |
| })} | |
| {completed.length === 0 && ( | |
| <li className="px-3 py-3 text-center text-[12px] text-ink-tertiary">No completed scans yet</li> | |
| )} | |
| </ul> | |
| </div> | |
| )} | |
| </div> | |
| {/* 进行中状态 + Cancel */} | |
| {inflight && ( | |
| <span className="inline-flex h-8 items-center gap-1.5 rounded-full border border-running/30 bg-running-bg px-3 text-running-ink"> | |
| <LoaderCircle size={13} className="animate-spin" /> | |
| <span className="text-[12px] font-medium capitalize">{inflight.state}</span> | |
| {inflight.state === "scanning" && ( | |
| <span className="text-[12px]">{inflight.findings_so_far} so far</span> | |
| )} | |
| {(inflight.state === "queued" || inflight.state === "scanning") && ( | |
| <button | |
| type="button" | |
| onClick={() => setCancelTarget(inflight)} | |
| className="ml-0.5 inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[11px] text-ink-secondary transition-colors hover:text-danger" | |
| title="Cancel this scan" | |
| > | |
| <Ban size={11} /> Cancel | |
| </button> | |
| )} | |
| </span> | |
| )} | |
| <ConfirmDialog | |
| open={cancelTarget !== null} | |
| title="Cancel this scan?" | |
| body={`The ${cancelTarget?.state === "scanning" ? "running" : "queued"} scan will be stopped and its VulnHunter slot released. This version can be resubmitted later.`} | |
| confirmLabel="Cancel scan" | |
| danger | |
| busy={cancelM.isPending} | |
| onConfirm={() => cancelTarget && cancelM.mutate(cancelTarget.id)} | |
| onCancel={() => setCancelTarget(null)} | |
| /> | |
| </div> | |
| ); | |
| } | |