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

437 lines
17 KiB
TypeScript
Raw Normal View History

2026-03-12 16:33:33 +08:00
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 FileContent = {
content: string;
issues: Array<{
line: number;
severity: string;
message: string;
rule?: string;
}>;
};
type View = "dashboard" | "pr-list" | "settings";
type QualityGateProps = {
view: View;
};
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 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 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<FileContent>(`/prs/${selectedPR.id}/file?path=${encodeURIComponent(selectedFile)}`)
.then(setFileContent)
.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">Overview</h2>
<div className="grid grid-cols-4 gap-5 mb-8">
{[
{ label: "Open PRs", value: stats.pending, color: "text-yellow-600", bg: "bg-yellow-50" },
{ label: "Merged", value: stats.passed, color: "text-green-600", bg: "bg-green-50" },
{ label: "Rejected", value: stats.rejected, color: "text-red-600", bg: "bg-red-50" },
{ label: "Total Issues", 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>
<h3 className="text-lg font-extrabold mb-4">Recent Pull Requests</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 List</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" ? "All" : f === "open" ? "Open" : f === "merged" ? "Merged" : "Closed"}
</button>
))}
<button className="btn-outline text-xs px-3 py-1.5" onClick={fetchPRs}>
Refresh
</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">Settings</h2>
<div className="card mb-4">
<h3 className="font-bold mb-2">Webhook Configuration</h3>
<p className="text-sm text-txt-muted mb-3">Add this URL to your Gitea repository webhook settings:</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">Quick Notes</h3>
<ul className="text-sm text-txt-muted list-disc list-inside space-y-1">
<li>Supports Gitea Push and Pull Request events</li>
<li>Runs Pylint, Flake8, ESLint, and Bandit automatically</li>
<li>Optional AI review using DeepSeek-V3</li>
<li>Scan summary can be pushed to Feishu channels</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">Changed Files</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>
<div className="flex-1 overflow-auto bg-white">
{fileContent ? (
<pre className="text-sm font-mono leading-6 p-4">
{fileContent.content.split("\n").map((line, i) => {
const lineNum = i + 1;
const issues = fileContent.issues.filter((iss) => iss.line === lineNum);
return (
<div key={i} className={`flex ${issues.length > 0 ? "bg-red-50/80" : ""}`}>
<span className="w-12 shrink-0 text-right pr-4 text-txt-muted select-none text-xs leading-6">
{lineNum}
</span>
<span className="flex-1 whitespace-pre-wrap">{line}</span>
{issues.length > 0 && (
<span className="shrink-0 px-2 text-xs text-red-600 max-w-xs truncate" title={issues[0].message}>
{issues[0].message}
</span>
)}
</div>
);
})}
</pre>
) : (
<div className="flex items-center justify-center h-full text-txt-muted text-sm">
{selectedFile ? "Loading file..." : "Select a file to inspect"}
</div>
)}
</div>
<div className="w-[220px] shrink-0 border-l border-border overflow-y-auto p-4 bg-surface-muted/60">
<h3 className="text-xs font-bold text-txt-muted uppercase mb-3">Issue Panel</h3>
{fileContent?.issues.length === 0 && (
<p className="text-xs text-txt-muted">No issues</p>
)}
{fileContent?.issues.map((iss, i) => (
<div key={i} className="mb-3 p-2 bg-white border border-border text-xs rounded-lg">
<div className="flex items-center gap-1 mb-1">
<span className={`font-bold ${
iss.severity === "error" ? "text-red-600" : iss.severity === "warning" ? "text-yellow-600" : "text-blue-600"
}`}>
{iss.severity === "error" ? "❌" : iss.severity === "warning" ? "⚠️" : ""}
</span>
<span className="text-txt-muted">L{iss.line}</span>
</div>
<p className="text-txt">{iss.message}</p>
{iss.rule && <p className="text-txt-muted mt-0.5">{iss.rule}</p>}
</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)}>
Close
</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"}
>
Reject
</button>
<button
className="btn-magenta"
onClick={handleMerge}
disabled={loading || selectedPR.state !== "open"}
>
Approve & Merge
</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">Loading...</div>;
}
if (prs.length === 0) {
return <div className="text-center py-8 text-txt-muted text-sm">No PR records found</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">Title</th>
<th className="px-4 py-3 border-b border-border font-bold">Repository</th>
<th className="px-4 py-3 border-b border-border font-bold">Author</th>
<th className="px-4 py-3 border-b border-border font-bold">Branch</th>
<th className="px-4 py-3 border-b border-border font-bold">State</th>
<th className="px-4 py-3 border-b border-border font-bold">Issues</th>
<th className="px-4 py-3 border-b border-border font-bold">Created</th>
<th className="px-4 py-3 border-b border-border font-bold">Action</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" ? "Open" : pr.state === "merged" ? "Merged" : "Closed"}
</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("en-US")}
</td>
<td className="px-4 py-3">
<button
className="text-magenta font-bold text-xs hover:underline"
onClick={() => onView(pr)}
>
Inspect
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}