Spaces:
Running
Running
File size: 6,854 Bytes
2eb87e3 d22337b 2eb87e3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | 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>
);
}
|