Files
safe-os-ui/src/pages/QualityGate.tsx

686 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from "react";
import { API } from "../config";
type PRScan = {
id: number;
pr_number: number;
repo_name: string;
pr_title: string;
pr_url: string;
source_branch: string;
target_branch: string;
author: string;
state: "open" | "merged" | "closed";
scan_status: "pending" | "completed";
issues_count: number;
security_issues: number;
created_at: string;
};
type PRDetail = PRScan & {
scan_result: Record<string, unknown>;
scan_details_with_code: unknown[];
ai_review: unknown;
report_path: string;
};
type ChangedFile = {
filename: string;
status: string;
additions: number;
deletions: number;
};
type FileContentIssue = {
line: number;
severity: string;
message: string;
rule?: string;
scanner?: string;
};
type FileContent = {
content: string;
issues: FileContentIssue[];
};
function normalizeFileContent(data: {
content?: string;
issues?: Array<{ line?: number; severity?: string; message?: string; description?: string; rule?: string; scanner?: string }>;
scan_issues?: Array<{ line?: number; severity?: string; message?: string; description?: string; rule?: string; scanner?: string }>;
}): FileContent {
const raw = data.scan_issues ?? data.issues ?? [];
const issues: FileContentIssue[] = raw.map((iss) => {
const line = typeof iss.line === "number" ? iss.line : typeof iss.line === "string" ? parseInt(iss.line, 10) : 1;
return {
line: Number.isFinite(line) ? line : 1,
severity: String(iss.severity ?? "info").toLowerCase(),
message: String(iss.message ?? iss.description ?? ""),
rule: iss.rule,
scanner: iss.scanner,
};
});
return {
content: data.content ?? "",
issues,
};
}
type View = "dashboard" | "pr-list" | "settings";
type PRHistoryItem = {
pr_number: number;
error_count?: number;
warning_count?: number;
total_issues?: number;
};
type QualityGateProps = {
view: View;
};
const TREND_SLOTS = 15;
const TREND_PX_PER_PR = 80;
const TREND_CHART_HEIGHT = 220;
function ProblemTrendChart({
history,
loading,
}: {
history: PRHistoryItem[];
loading: boolean;
}) {
if (loading) {
return (
<div className="rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)] p-4">
<h3 className="text-base font-extrabold mb-3"></h3>
<div className="text-center py-8 text-txt-muted text-sm">...</div>
</div>
);
}
if (!history || history.length === 0) {
return (
<div className="rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)] p-4">
<h3 className="text-base font-extrabold mb-3"></h3>
<div className="text-center py-8 text-txt-muted text-sm"></div>
</div>
);
}
const n = history.length;
const pad = Math.max(0, TREND_SLOTS - n);
const labels = history.map((p) => `#${p.pr_number}`).concat(Array(pad).fill(""));
const errorData: (number | null)[] = history.map((p) => p.error_count ?? 0).concat(Array(pad).fill(null));
const warningData: (number | null)[] = history.map((p) => p.warning_count ?? 0).concat(Array(pad).fill(null));
const width = TREND_SLOTS * TREND_PX_PER_PR;
const height = TREND_CHART_HEIGHT;
const padding = { top: 28, right: 24, bottom: 32, left: 40 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const maxVal = Math.max(
1,
...errorData.filter((v): v is number => v != null),
...warningData.filter((v): v is number => v != null)
);
const yMax = Math.ceil(maxVal / 2) * 2 || 2;
const yTicks = Array.from({ length: yMax + 1 }, (_, i) => i);
const xScale = (i: number) => padding.left + (i / (labels.length - 1 || 1)) * chartW;
const yScale = (v: number) => padding.top + chartH - (v / yMax) * chartH;
const toPath = (data: (number | null)[]) => {
const parts: string[] = [];
for (let i = 0; i < data.length; i++) {
const v = data[i];
if (v == null) continue;
const x = xScale(i);
const y = yScale(v);
parts.push(parts.length === 0 ? `M ${x} ${y}` : `L ${x} ${y}`);
}
return parts.join(" ");
};
const toAreaPath = (data: (number | null)[]) => {
const points: { i: number; v: number }[] = [];
data.forEach((v, i) => {
if (v != null) points.push({ i, v });
});
if (points.length === 0) return "";
const linePath = points.map(({ i, v }) => `${i === 0 ? "M" : "L"} ${xScale(i)} ${yScale(v)}`).join(" ");
const lastX = xScale(points[points.length - 1].i);
const baseY = padding.top + chartH;
const startX = xScale(points[0].i);
return `${linePath} L ${lastX} ${baseY} L ${startX} ${baseY} Z`;
};
const errorPath = toPath(errorData);
const warningPath = toPath(warningData);
const errorAreaPath = toAreaPath(errorData);
const warningAreaPath = toAreaPath(warningData);
return (
<div className="rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)] p-4">
<h3 className="text-base font-extrabold mb-3"></h3>
<div className="overflow-x-auto overflow-y-hidden" style={{ maxWidth: "100%" }}>
<div style={{ width, minWidth: width, height }}>
<svg width={width} height={height} className="overflow-visible">
{/* 网格线 */}
{yTicks.map((tick, i) => (
<line
key={i}
x1={padding.left}
y1={yScale(tick)}
x2={padding.left + chartW}
y2={yScale(tick)}
stroke="rgba(0,0,0,0.12)"
strokeWidth={1}
strokeDasharray="4 4"
/>
))}
{labels.map((_, i) => (
<line
key={i}
x1={xScale(i)}
y1={padding.top}
x2={xScale(i)}
y2={padding.top + chartH}
stroke="rgba(0,0,0,0.12)"
strokeWidth={1}
strokeDasharray="4 4"
/>
))}
{/* 图例 */}
<g transform={`translate(${padding.left}, 8)`}>
<rect x={0} y={2} width={14} height={10} fill="#dc3545" rx={1} />
<text x={18} y={11} fontSize={11} fill="currentColor" className="text-txt">
</text>
<rect x={70} y={2} width={14} height={10} fill="#ffc107" rx={1} />
<text x={88} y={11} fontSize={11} fill="currentColor" className="text-txt">
</text>
</g>
{/* 面积填充 */}
<path d={errorAreaPath} fill="rgba(220, 53, 69, 0.1)" />
<path d={warningAreaPath} fill="rgba(255, 193, 7, 0.1)" />
{/* 折线 */}
<path d={errorPath} fill="none" stroke="#dc3545" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<path d={warningPath} fill="none" stroke="#ffc107" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
{/* Y 轴刻度 */}
{yTicks.map((tick) => (
<text
key={tick}
x={padding.left - 8}
y={yScale(tick) + 4}
textAnchor="end"
fontSize={10}
fill="var(--color-txt-muted, #666)"
>
{tick}
</text>
))}
{/* X 轴刻度 */}
{labels.map((label, i) => (
<text
key={i}
x={xScale(i)}
y={height - 8}
textAnchor="middle"
fontSize={10}
fill={label ? "var(--color-txt-muted, #666)" : "transparent"}
>
{label || "#"}
</text>
))}
</svg>
</div>
</div>
</div>
);
}
const BASE = API.quality;
async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function apiPost<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { method: "POST" });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
export default function QualityGate({ view }: QualityGateProps) {
const [prs, setPrs] = useState<PRScan[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [filter, setFilter] = useState<"all" | "open" | "merged" | "closed">("all");
const [selectedPR, setSelectedPR] = useState<PRDetail | null>(null);
const [changedFiles, setChangedFiles] = useState<ChangedFile[]>([]);
const [selectedFile, setSelectedFile] = useState<string>("");
const [fileContent, setFileContent] = useState<FileContent | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [trendHistory, setTrendHistory] = useState<PRHistoryItem[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const fetchPRs = useCallback(async () => {
setLoading(true);
setError("");
try {
const query = filter === "all" ? "" : `?state=${filter}`;
const data = await apiGet<{ prs: PRScan[] } | PRScan[]>(`/prs${query}`);
setPrs(Array.isArray(data) ? data : data.prs || []);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
fetchPRs();
}, [fetchPRs]);
const fetchTrendHistory = useCallback(async () => {
setTrendLoading(true);
try {
const data = await apiGet<PRHistoryItem[] | { history?: PRHistoryItem[] }>("/prs/history?limit=15");
const list = Array.isArray(data) ? data : data.history ?? [];
setTrendHistory(list);
} catch {
setTrendHistory([]);
} finally {
setTrendLoading(false);
}
}, []);
useEffect(() => {
if (view === "dashboard") fetchTrendHistory();
}, [view, fetchTrendHistory]);
const openPRDetail = useCallback(async (prId: number) => {
setLoading(true);
setError("");
try {
const [detail, files] = await Promise.all([
apiGet<PRDetail>(`/prs/${prId}`),
apiGet<{ files: ChangedFile[] } | ChangedFile[]>(`/prs/${prId}/files`),
]);
setSelectedPR(detail);
const fileList = Array.isArray(files) ? files : files.files || [];
setChangedFiles(fileList);
if (fileList.length > 0) {
setSelectedFile(fileList[0].filename);
}
setModalOpen(true);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!modalOpen || !selectedPR || !selectedFile) return;
setFileContent(null);
apiGet<Parameters<typeof normalizeFileContent>[0]>(`/prs/${selectedPR.id}/file?path=${encodeURIComponent(selectedFile)}`)
.then((data) => setFileContent(normalizeFileContent(data)))
.catch(() => setFileContent({ content: "// Failed to load file", issues: [] }));
}, [modalOpen, selectedPR, selectedFile]);
const handleMerge = async () => {
if (!selectedPR) return;
setLoading(true);
try {
await apiPost(`/prs/${selectedPR.id}/merge`);
setModalOpen(false);
fetchPRs();
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
};
const handleClose = async () => {
if (!selectedPR) return;
setLoading(true);
try {
await apiPost(`/prs/${selectedPR.id}/close`);
setModalOpen(false);
fetchPRs();
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
};
const stats = {
pending: prs.filter((p) => p.state === "open").length,
passed: prs.filter((p) => p.state === "merged").length,
rejected: prs.filter((p) => p.state === "closed").length,
totalIssues: prs.reduce((s, p) => s + p.issues_count, 0),
};
return (
<div className="h-full overflow-y-auto p-8">
{error && (
<div className="mb-4 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center rounded-xl border border-red-200">
{error}
<button className="font-bold text-red-400 hover:text-red-600" onClick={() => setError("")}></button>
</div>
)}
{view === "dashboard" && (
<>
<h2 className="text-xl font-extrabold mb-6"></h2>
<div className="grid grid-cols-4 gap-5 mb-8">
{[
{ label: "打开 PR", value: stats.pending, color: "text-yellow-600", bg: "bg-yellow-50" },
{ label: "已合并", value: stats.passed, color: "text-green-600", bg: "bg-green-50" },
{ label: "已拒绝", value: stats.rejected, color: "text-red-600", bg: "bg-red-50" },
{ label: "问题总数", value: stats.totalIssues, color: "text-magenta", bg: "bg-magenta-50" },
].map((s) => (
<div key={s.label} className={`${s.bg} p-6 border border-border rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.04)]`}>
<div className={`text-3xl font-extrabold ${s.color}`}>{s.value}</div>
<div className="text-sm font-bold text-txt-muted mt-2">{s.label}</div>
</div>
))}
</div>
<div className="mt-8 mb-8">
<ProblemTrendChart history={trendHistory} loading={trendLoading} />
</div>
<h3 className="text-lg font-extrabold mb-4"> Pull Request</h3>
<PRTable prs={prs.slice(0, 8)} loading={loading} onView={(pr) => openPRDetail(pr.id)} />
</>
)}
{view === "pr-list" && (
<>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-extrabold">Pull Request </h2>
<div className="flex gap-2">
{(["all", "open", "merged", "closed"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 text-xs font-bold border rounded-lg transition-colors ${
filter === f
? "border-magenta text-magenta bg-magenta-50"
: "border-border text-txt-muted hover:border-magenta"
}`}
>
{f === "all" ? "全部" : f === "open" ? "打开" : f === "merged" ? "已合并" : "已关闭"}
</button>
))}
<button className="btn-outline text-xs px-3 py-1.5" onClick={fetchPRs}>
</button>
</div>
</div>
<PRTable prs={prs} loading={loading} onView={(pr) => openPRDetail(pr.id)} />
</>
)}
{view === "settings" && (
<div className="max-w-xl">
<h2 className="text-xl font-extrabold mb-6"></h2>
<div className="card mb-4">
<h3 className="font-bold mb-2">Webhook </h3>
<p className="text-sm text-txt-muted mb-3"> URL Gitea webhook </p>
<code className="block bg-surface-muted p-3 text-sm font-mono break-all rounded-lg">
POST {window.location.origin}/quality-api/webhook/gitea
</code>
</div>
<div className="card">
<h3 className="font-bold mb-2"></h3>
<ul className="text-sm text-txt-muted list-disc list-inside space-y-1">
<li> Gitea Push Pull Request </li>
<li> PylintFlake8ESLint Bandit</li>
<li> AI DeepSeek-V3</li>
<li></li>
</ul>
</div>
</div>
)}
{modalOpen && selectedPR && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white w-[92vw] h-[88vh] flex flex-col rounded-2xl overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.2)]">
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div>
<h2 className="text-lg font-extrabold">
#{selectedPR.pr_number} {selectedPR.pr_title}
</h2>
<p className="text-xs text-txt-muted mt-1">
{selectedPR.author} · {selectedPR.source_branch} {selectedPR.target_branch} · {selectedPR.repo_name}
</p>
</div>
<button
className="w-8 h-8 flex items-center justify-center text-txt-muted hover:text-txt font-bold text-lg"
onClick={() => setModalOpen(false)}
>
</button>
</div>
<div className="flex flex-1 min-h-0">
<div className="w-[260px] shrink-0 border-r border-border overflow-y-auto p-4 bg-surface-muted/35">
<h3 className="text-xs font-bold text-txt-muted uppercase mb-3"></h3>
{changedFiles.map((f) => (
<button
key={f.filename}
className={`block w-full text-left px-3 py-2 text-sm truncate transition-colors rounded-lg ${
selectedFile === f.filename
? "bg-magenta-50 text-magenta font-bold"
: "text-txt hover:bg-surface-muted"
}`}
onClick={() => setSelectedFile(f.filename)}
>
<span className={`inline-block w-4 mr-1 text-xs font-bold ${
f.status === "added" ? "text-green-600" : f.status === "removed" ? "text-red-600" : "text-yellow-600"
}`}>
{f.status === "added" ? "A" : f.status === "removed" ? "D" : "M"}
</span>
{f.filename.split("/").pop()}
<span className="text-xs text-txt-muted ml-1 block truncate">{f.filename}</span>
</button>
))}
</div>
{/* 代码区:完整代码 + 每行右侧缺陷标注,一行一个滚动条(参考 index copy.html */}
<div className="flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 overflow-auto bg-[#1e1e1e] font-mono text-[13px] leading-[1.5]">
{fileContent ? (
<div className="min-w-min">
{(fileContent.content ?? "").split("\n").map((line, i) => {
const lineNum = i + 1;
const lineIssues = (fileContent.issues ?? []).filter((iss) => iss.line === lineNum);
const hasIssue = lineIssues.length > 0;
const reasonText = hasIssue
? lineIssues.map((iss) => (iss.scanner ? `[${iss.scanner}] ` : "") + (iss.message || "")).join("")
: "";
const displayText = line === "" ? "\u00A0" : line;
return (
<div
key={i}
className={`flex min-h-[1.5em] items-stretch ${hasIssue ? "code-line-has-issue" : ""}`}
>
{/* 行号区 */}
<div className="w-12 min-w-[48px] shrink-0 flex items-center justify-end pr-2 text-[#6c757d] bg-[#252526] select-none">
<span className="mr-1">{lineNum}</span>
{hasIssue && (
<span
className={
lineIssues[0]?.severity === "error" || lineIssues[0]?.severity === "high"
? "text-[#f14c4c]"
: "text-[#cca700]"
}
title={reasonText}
>
</span>
)}
</div>
{/* 代码内容 */}
<div
className={`flex-1 min-w-0 px-3 py-0 whitespace-pre-wrap break-all text-[#d4d4d4] ${
hasIssue ? "bg-red-900/20 border-l-2 border-l-red-500 pl-2" : ""
}`}
>
{displayText}
</div>
{/* 虚线连接区 */}
<div className="w-5 min-w-[20px] shrink-0 bg-[#1e1e1e] relative">
{hasIssue && (
<span
className="absolute left-0 right-0 top-1/2 -mt-px block border-b border-dashed border-red-500/80"
aria-hidden
/>
)}
</div>
{/* 缺陷标注(与该行对齐,在右侧) */}
<div
className={`w-[180px] min-w-[180px] shrink-0 py-1.5 px-2 text-[11px] border-l flex items-center ${
hasIssue
? "bg-red-50/90 border-l-2 border-l-red-500 text-red-800"
: "bg-[#252526] border-[#3c3c3c] text-[#9d9d9d]"
}`}
>
{hasIssue && reasonText ? (
<>
<span className="text-red-500 mr-1.5 shrink-0"></span>
<span className="break-words leading-snug">{reasonText}</span>
</>
) : (
<span className="invisible">-</span>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="flex items-center justify-center h-full min-h-[200px] text-[#6c757d] text-sm">
{selectedFile ? "加载文件..." : "请选择要检查的文件"}
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border shrink-0">
<button className="btn-outline" onClick={() => setModalOpen(false)}>
</button>
<button
className="bg-red-600 text-white font-bold text-sm px-6 py-2.5 border-none cursor-pointer hover:opacity-90 disabled:opacity-50 rounded-xl"
onClick={handleClose}
disabled={loading || selectedPR.state !== "open"}
>
</button>
<button
className="btn-magenta"
onClick={handleMerge}
disabled={loading || selectedPR.state !== "open"}
>
</button>
</div>
</div>
</div>
)}
</div>
);
}
function PRTable({
prs,
loading,
onView,
}: {
prs: PRScan[];
loading: boolean;
onView: (pr: PRScan) => void;
}) {
if (loading && prs.length === 0) {
return <div className="text-center py-8 text-txt-muted text-sm">...</div>;
}
if (prs.length === 0) {
return <div className="text-center py-8 text-txt-muted text-sm"> PR </div>;
}
return (
<div className="overflow-x-auto rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)]">
<table className="w-full text-sm">
<thead>
<tr className="bg-surface-muted text-left">
<th className="px-4 py-3 border-b border-border font-bold">PR#</th>
<th className="px-4 py-3 border-b border-border font-bold"></th>
<th className="px-4 py-3 border-b border-border font-bold"></th>
<th className="px-4 py-3 border-b border-border font-bold"></th>
<th className="px-4 py-3 border-b border-border font-bold"></th>
<th className="px-4 py-3 border-b border-border font-bold"></th>
<th className="px-4 py-3 border-b border-border font-bold"></th>
<th className="px-4 py-3 border-b border-border font-bold"></th>
<th className="px-4 py-3 border-b border-border font-bold"></th>
</tr>
</thead>
<tbody>
{prs.map((pr) => (
<tr key={pr.id} className="border-b border-border hover:bg-surface-muted/50 transition-colors">
<td className="px-4 py-3 font-mono text-xs">#{pr.pr_number}</td>
<td className="px-4 py-3 font-bold max-w-[220px] truncate">{pr.pr_title}</td>
<td className="px-4 py-3 text-txt-muted text-xs">{pr.repo_name}</td>
<td className="px-4 py-3 text-txt-muted">{pr.author}</td>
<td className="px-4 py-3 text-xs font-mono text-txt-muted">
{pr.source_branch} {pr.target_branch}
</td>
<td className="px-4 py-3">
<span className={`badge ${
pr.state === "open"
? "bg-yellow-100 text-yellow-700"
: pr.state === "merged"
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}>
{pr.state === "open" ? "打开" : pr.state === "merged" ? "已合并" : "已关闭"}
</span>
</td>
<td className="px-4 py-3">
<span className={`font-bold ${pr.issues_count > 0 ? "text-red-600" : "text-green-600"}`}>
{pr.issues_count}
</span>
</td>
<td className="px-4 py-3 text-xs text-txt-muted">
{new Date(pr.created_at).toLocaleString("zh-CN")}
</td>
<td className="px-4 py-3">
<button
className="text-magenta font-bold text-xs hover:underline"
onClick={() => onView(pr)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}