first
This commit is contained in:
@@ -0,0 +1,697 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Code2,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
FileCode2,
|
||||
CheckCircle2,
|
||||
AlertOctagon,
|
||||
AlertCircle,
|
||||
Info,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
ArrowLeft,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { artifacts as apiArtifacts, projects } from "@/lib/api";
|
||||
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
|
||||
import type { Artifact, ArtifactType, Project } from "@/lib/types";
|
||||
|
||||
type Scope = "all" | "source_file" | "smell";
|
||||
type Severity = "all" | "low" | "medium" | "high" | "critical";
|
||||
|
||||
const SEVERITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
const SEVERITY_META: Record<Exclude<Severity, "all">, { icon: typeof AlertTriangle; tone: "red" | "amber" | "brand" | "neutral"; label: string }> = {
|
||||
critical: { icon: AlertOctagon, tone: "red", label: "CRITICAL" },
|
||||
high: { icon: AlertTriangle, tone: "amber", label: "HIGH" },
|
||||
medium: { icon: AlertCircle, tone: "brand", label: "MEDIUM" },
|
||||
low: { icon: Info, tone: "neutral", label: "LOW" },
|
||||
};
|
||||
|
||||
const DEMO_ROWS: Artifact[] = [
|
||||
{
|
||||
id: "src-1", tenant_id: "t1", project_id: "p1",
|
||||
type: "source_file", external_id: "SRC-DEMO-1",
|
||||
title: "app/middleware/auth.py",
|
||||
status: "active", source: "git",
|
||||
content: "def authorize(request):\n return check_jwt(request.headers.get('authorization'))\n",
|
||||
metadata: { language: "python", path: "app/middleware/auth.py", loc: 134, repo: "asf", branch: "main" },
|
||||
created_at: "2025-11-01T09:00:00Z", updated_at: "2025-11-05T11:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "sm-1", tenant_id: "t1", project_id: "p1",
|
||||
type: "ai_analysis", external_id: "CS-DEMO-1",
|
||||
title: "SQL injection in user lookup (no ORM)",
|
||||
status: "open", source: "bandit",
|
||||
content: "Line 22 uses f-string on cursor.execute().",
|
||||
metadata: { kind: "code_smell", language: "python", severity: "high", rule_id: "B608", engine: "bandit", lines: [20, 21, 22] },
|
||||
created_at: "2025-11-08T09:00:00Z", updated_at: "2025-11-08T09:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
function isSmell(a: Artifact): boolean {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
return typeof md === "object" && (md as Record<string, unknown>)["kind"] === "code_smell";
|
||||
}
|
||||
function isSourceFile(a: Artifact): boolean {
|
||||
return a.type === "source_file";
|
||||
}
|
||||
function getLanguage(a: Artifact): string {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["language"];
|
||||
return typeof v === "string" ? v : "—";
|
||||
}
|
||||
function getSeverity(a: Artifact): "low" | "medium" | "high" | "critical" | null {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["severity"];
|
||||
if (typeof v === "string" && (v === "low" || v === "medium" || v === "high" || v === "critical")) return v;
|
||||
return null;
|
||||
}
|
||||
function getRule(a: Artifact): string | null {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["rule_id"];
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
function getEngine(a: Artifact): string | null {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["engine"];
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
function getPath(a: Artifact): string | null {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["path"];
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
function getLines(a: Artifact): number[] | null {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["lines"];
|
||||
return Array.isArray(v) && v.every((x) => typeof x === "number") ? (v as number[]) : null;
|
||||
}
|
||||
function getLOC(a: Artifact): number | null {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["loc"];
|
||||
return typeof v === "number" ? v : null;
|
||||
}
|
||||
function getRepo(a: Artifact): string | null {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["repo"];
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
function getBranch(a: Artifact): string | null {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["branch"];
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
function getConfidence(a: Artifact): number | null {
|
||||
const md = a.metadata ?? ({} as Record<string, unknown>);
|
||||
const v = (md as Record<string, unknown>)["confidence_pct"];
|
||||
return typeof v === "number" ? v : null;
|
||||
}
|
||||
|
||||
export default function CodePage() {
|
||||
const [scope, setScope] = useState<Scope>("all");
|
||||
const [projectFilter, setProjectFilter] = useState<string>("all");
|
||||
const [languageFilter, setLanguageFilter] = useState<string>("all");
|
||||
const [severityFilter, setSeverityFilter] = useState<Severity>("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [q, setQ] = useState<string>("");
|
||||
const [detailId, setDetailId] = useState<string>("");
|
||||
|
||||
const projectsQ = useQuery({
|
||||
queryKey: ["code-projects"],
|
||||
queryFn: () => projects.list({ limit: 50 }),
|
||||
retry: false, refetchOnWindowFocus: false,
|
||||
});
|
||||
const apiResolved = projectsQ.isFetched && projectsQ.data !== undefined;
|
||||
const projectRows: Project[] = apiResolved ? projectsQ.data! : [];
|
||||
const projectNameById = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const p of projectRows) m.set(String(p.id), p.name);
|
||||
return m;
|
||||
}, [projectRows]);
|
||||
|
||||
const sourceQ = useQuery({
|
||||
queryKey: ["code-source", projectFilter, q.trim()],
|
||||
queryFn: () =>
|
||||
apiArtifacts.list({
|
||||
type: "source_file" as ArtifactType,
|
||||
project_id: projectFilter !== "all" ? projectFilter : undefined,
|
||||
search: q.trim() || undefined,
|
||||
limit: 200,
|
||||
}),
|
||||
retry: false, refetchOnWindowFocus: false,
|
||||
});
|
||||
const smellsQ = useQuery({
|
||||
queryKey: ["code-smells", projectFilter, q.trim()],
|
||||
queryFn: () =>
|
||||
apiArtifacts.list({
|
||||
type: "ai_analysis" as ArtifactType,
|
||||
project_id: projectFilter !== "all" ? projectFilter : undefined,
|
||||
search: q.trim() || undefined,
|
||||
limit: 200,
|
||||
}),
|
||||
retry: false, refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const sourceResolved = sourceQ.isFetched && sourceQ.data !== undefined;
|
||||
const smellsResolved = smellsQ.isFetched && smellsQ.data !== undefined;
|
||||
const isMock = !sourceResolved || !smellsResolved;
|
||||
|
||||
const rawRows: Artifact[] = useMemo(() => {
|
||||
const allSources = sourceResolved ? sourceQ.data! : DEMO_ROWS.filter(isSourceFile);
|
||||
const allAnalyses = smellsResolved ? smellsQ.data! : DEMO_ROWS.filter(isSmell);
|
||||
const smellsOnly = allAnalyses.filter(isSmell);
|
||||
const combined = [...allSources, ...smellsOnly];
|
||||
// apply language + severity + status filters (server can't filter metadata)
|
||||
return combined.filter((a) => {
|
||||
if (scope === "source_file" && !isSourceFile(a)) return false;
|
||||
if (scope === "smell" && !isSmell(a)) return false;
|
||||
if (languageFilter !== "all" && getLanguage(a) !== languageFilter) return false;
|
||||
if (severityFilter !== "all") {
|
||||
if (!isSmell(a)) return false;
|
||||
if (getSeverity(a) !== severityFilter) return false;
|
||||
}
|
||||
if (statusFilter !== "all" && (a.status ?? "") !== statusFilter) return false;
|
||||
return true;
|
||||
}).sort((b, a) => {
|
||||
// severity first (only if smell), then updated_at desc
|
||||
const sa = isSmell(a) ? (SEVERITY_ORDER[getSeverity(a) ?? "low"] ?? 9) : 99;
|
||||
const sb = isSmell(b) ? (SEVERITY_ORDER[getSeverity(b) ?? "low"] ?? 9) : 99;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return (a.updated_at ?? "").localeCompare(b.updated_at ?? "");
|
||||
});
|
||||
}, [sourceResolved, smellsResolved, sourceQ.data, smellsQ.data, scope, languageFilter, severityFilter, statusFilter]);
|
||||
|
||||
const languages = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const r of rawRows) {
|
||||
const l = getLanguage(r);
|
||||
if (l && l !== "—") s.add(l);
|
||||
}
|
||||
return Array.from(s).sort();
|
||||
}, [rawRows]);
|
||||
|
||||
const qc = useQueryClient();
|
||||
const markFixedM = useMutation({
|
||||
mutationFn: async ({ id, status }: { id: string; status: string }) =>
|
||||
apiArtifacts.update(id, { status } as Partial<Artifact>),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["code-source"] });
|
||||
qc.invalidateQueries({ queryKey: ["code-smells"] });
|
||||
},
|
||||
});
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const src = rawRows.filter(isSourceFile).length;
|
||||
const sm = rawRows.filter(isSmell);
|
||||
const smBySev = { low: 0, medium: 0, high: 0, critical: 0 } as Record<"low" | "medium" | "high" | "critical", number>;
|
||||
for (const s of sm) {
|
||||
const sv = getSeverity(s);
|
||||
if (sv) smBySev[sv] += 1;
|
||||
}
|
||||
return { src, smells: sm.length, smBySev };
|
||||
}, [rawRows]);
|
||||
|
||||
const effectiveDetail = useMemo(() => {
|
||||
if (!detailId) return null;
|
||||
return rawRows.find((r) => String(r.id) === String(detailId)) ?? null;
|
||||
}, [detailId, rawRows]);
|
||||
|
||||
if (effectiveDetail) {
|
||||
return (
|
||||
<CodeDetailView
|
||||
artifact={effectiveDetail}
|
||||
projectName={projectNameById.get(String(effectiveDetail.project_id)) ?? String(effectiveDetail.project_id)}
|
||||
onBack={() => setDetailId("")}
|
||||
onMarkFixed={(status) =>
|
||||
markFixedM.mutate({ id: String(effectiveDetail.id), status })
|
||||
}
|
||||
markFixedPending={markFixedM.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<header className="px-6 lg:px-8 pt-6 pb-4">
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 shadow-soft grid place-items-center text-white shrink-0">
|
||||
<Code2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2 flex-wrap">
|
||||
Code Analysis
|
||||
{isMock && <Badge tone="amber">Demo data</Badge>}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 max-w-3xl">
|
||||
Source files indexed from your repositories + automated code smells detected by ASF AI Analyzer
|
||||
(Ruff / ESLint / Bandit …). Click any row to see the detail with highlighted lines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 flex flex-col gap-4 min-w-0">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<StatCard label="Source files" value={stats.src} tone="brand" icon={FileCode2} />
|
||||
<StatCard label="Code smells" value={stats.smells} tone="amber" icon={AlertTriangle} />
|
||||
<StatCard label="Critical" value={stats.smBySev.critical} tone="red" icon={AlertOctagon} />
|
||||
<StatCard label="High" value={stats.smBySev.high} tone="amber" icon={AlertTriangle} />
|
||||
<StatCard label="Med/Low" value={stats.smBySev.medium + stats.smBySev.low} tone="neutral" icon={Info} />
|
||||
</div>
|
||||
|
||||
<Card className="flex-1 min-h-0 flex flex-col">
|
||||
<CardHeader className="flex-wrap items-end gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="!text-sm flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-slate-400" />
|
||||
Inventory
|
||||
<span className="asf-chip">{rawRows.length}</span>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<div className="relative w-full md:w-64">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
className="asf-input pl-9"
|
||||
placeholder="Search paths, titles, rules…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select className="asf-input !w-auto" value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
|
||||
<option value="all">All code</option>
|
||||
<option value="source_file">Source files only</option>
|
||||
<option value="smell">Code smells only</option>
|
||||
</select>
|
||||
<select className="asf-input !w-auto" value={languageFilter} onChange={(e) => setLanguageFilter(e.target.value)}>
|
||||
<option value="all">Any language</option>
|
||||
{languages.map((l) => (
|
||||
<option key={l} value={l}>{l.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="asf-input !w-auto" value={severityFilter} onChange={(e) => setSeverityFilter(e.target.value as Severity)}>
|
||||
<option value="all">Any severity</option>
|
||||
<option value="critical">CRITICAL</option>
|
||||
<option value="high">HIGH</option>
|
||||
<option value="medium">MEDIUM</option>
|
||||
<option value="low">LOW</option>
|
||||
</select>
|
||||
<select className="asf-input !w-auto" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="all">Any status</option>
|
||||
{Array.from(new Set(rawRows.map((r) => r.status ?? "unknown").filter(Boolean))).sort().map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="asf-input !w-auto" value={projectFilter} onChange={(e) => setProjectFilter(e.target.value)}>
|
||||
<option value="all">All projects</option>
|
||||
{projectRows.map((p) => (
|
||||
<option key={p.id} value={String(p.id)}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="p-0 flex flex-col min-h-0 overflow-hidden">
|
||||
{rawRows.length === 0 ? (
|
||||
<div className="flex-1 grid place-items-center p-10">
|
||||
<EmptyState title="No matches" description="Try a different combination of filters, or index a repository to seed source files." icon={FileCode2} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-auto flex-1 min-h-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-slate-50/95 backdrop-blur-sm border-b border-slate-200 text-[11px] uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left w-16">Kind</th>
|
||||
<th className="px-4 py-2.5 text-left">Artifact</th>
|
||||
<th className="px-4 py-2.5 text-left w-28">Lang</th>
|
||||
<th className="px-4 py-2.5 text-left w-28">Severity</th>
|
||||
<th className="px-4 py-2.5 text-left w-28">Status</th>
|
||||
<th className="px-4 py-2.5 text-left w-48">Project</th>
|
||||
<th className="px-4 py-2.5 text-right w-28">Updated</th>
|
||||
<th className="px-4 py-2.5 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rawRows.map((a) => (
|
||||
<tr
|
||||
key={a.id}
|
||||
onClick={() => setDetailId(String(a.id))}
|
||||
className="group cursor-pointer hover:bg-slate-50/80 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 align-top">
|
||||
{isSourceFile(a) ? (
|
||||
<Badge tone="blue" className="flex items-center gap-1 w-fit">
|
||||
<FileCode2 className="w-3 h-3" />
|
||||
<span>file</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="amber" className="flex items-center gap-1 w-fit">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span>smell</span>
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="asf-monospace text-[11px] text-slate-500">
|
||||
{a.external_id ?? shortId(a.id)}
|
||||
</span>
|
||||
{isSmell(a) && getRule(a) && (
|
||||
<Badge tone="neutral">{getRule(a)}</Badge>
|
||||
)}
|
||||
{isSourceFile(a) && getLOC(a) != null && (
|
||||
<span className="asf-chip">{getLOC(a)} LOC</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 font-medium text-slate-800 truncate max-w-[520px]">
|
||||
{a.title}
|
||||
</div>
|
||||
{getPath(a) && (
|
||||
<div className="mt-0.5 text-xs text-slate-500 truncate font-mono max-w-[520px]">
|
||||
{getPath(a)}
|
||||
</div>
|
||||
)}
|
||||
{isSmell(a) && getEngine(a) && (
|
||||
<div className="mt-1 text-[11px] text-slate-400">
|
||||
scanner: <span className="text-slate-600 font-medium">{getEngine(a)}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<Badge tone="brand" className="uppercase">{getLanguage(a)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
{isSmell(a) && getSeverity(a) ? (
|
||||
<SeverityBadge s={getSeverity(a)!} />
|
||||
) : (
|
||||
<span className="text-slate-300 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
{a.status ? <Badge tone={a.status === "open" ? "amber" : a.status === "fixed" ? "green" : a.status === "active" ? "blue" : "brand"}>{a.status}</Badge> : <span className="text-slate-300 text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-slate-600 text-xs truncate max-w-[200px]">
|
||||
{projectNameById.get(String(a.project_id)) ?? shortId(a.project_id)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-right text-xs text-slate-500 whitespace-nowrap">
|
||||
{fmtDate(a.updated_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-right">
|
||||
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:text-brand-500 inline-block" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeverityBadge({ s }: { s: "low" | "medium" | "high" | "critical" }) {
|
||||
const m = SEVERITY_META[s];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Badge tone={m.tone} className="inline-flex items-center gap-1">
|
||||
<Icon className="w-3 h-3" />
|
||||
{m.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label, value, tone, icon: Icon,
|
||||
}: {
|
||||
label: string; value: number; tone: "brand" | "amber" | "red" | "neutral" | "green" | "blue";
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
const toneBg: Record<string, string> = {
|
||||
brand: "from-brand-500 to-brand-600",
|
||||
amber: "from-amber-400 to-amber-500",
|
||||
red: "from-rose-500 to-red-600",
|
||||
neutral: "from-slate-400 to-slate-500",
|
||||
green: "from-emerald-400 to-emerald-600",
|
||||
blue: "from-sky-400 to-blue-600",
|
||||
};
|
||||
return (
|
||||
<Card className="!shadow-none overflow-hidden">
|
||||
<CardBody className="p-4 flex items-center gap-3">
|
||||
<div className={cn("w-11 h-11 rounded-xl bg-gradient-to-br shadow-sm grid place-items-center text-white shrink-0", toneBg[tone])}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</div>
|
||||
<div className="text-2xl font-semibold text-slate-900 leading-tight mt-0.5">{value}</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeDetailView({
|
||||
artifact, projectName, onBack, onMarkFixed, markFixedPending,
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
projectName: string;
|
||||
onBack: () => void;
|
||||
onMarkFixed?: (status: string) => void;
|
||||
markFixedPending?: boolean;
|
||||
}) {
|
||||
const lang = getLanguage(artifact);
|
||||
const sev = getSeverity(artifact);
|
||||
const path = getPath(artifact);
|
||||
const rule = getRule(artifact);
|
||||
const engine = getEngine(artifact);
|
||||
const repo = getRepo(artifact);
|
||||
const branch = getBranch(artifact);
|
||||
const lines = getLines(artifact);
|
||||
const confidence = getConfidence(artifact);
|
||||
const smell = isSmell(artifact);
|
||||
const source = isSourceFile(artifact);
|
||||
|
||||
const linesToHighlight = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
if (lines) {
|
||||
for (const n of lines) set.add(n);
|
||||
}
|
||||
return set;
|
||||
}, [lines]);
|
||||
|
||||
const contentLines = useMemo(() => {
|
||||
const raw = artifact.content ?? "";
|
||||
return raw.split("\n");
|
||||
}, [artifact.content]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<header className="px-6 lg:px-8 pt-6 pb-4">
|
||||
<Button variant="ghost" size="sm" className="mb-3 -ml-2" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Back to inventory
|
||||
</Button>
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className={cn(
|
||||
"w-11 h-11 rounded-xl bg-gradient-to-br shadow-soft grid place-items-center text-white shrink-0",
|
||||
smell
|
||||
? "from-amber-400 to-rose-500"
|
||||
: "from-brand-500 to-violet-600",
|
||||
)}>
|
||||
{smell ? <AlertTriangle className="w-5 h-5" /> : <FileCode2 className="w-5 h-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ArtifactTypeBadge type={artifact.type as ArtifactType} />
|
||||
{smell ? (
|
||||
<Badge tone="amber">code_smell</Badge>
|
||||
) : (
|
||||
<Badge tone="blue">source_file</Badge>
|
||||
)}
|
||||
{sev && <SeverityBadge s={sev} />}
|
||||
{rule && <Badge tone="neutral">rule {rule}</Badge>}
|
||||
{lang && <Badge tone="brand" className="uppercase">{lang}</Badge>}
|
||||
{artifact.status && (
|
||||
<Badge tone={artifact.status === "open" ? "amber" : artifact.status === "fixed" ? "green" : artifact.status === "active" ? "blue" : "brand"}>
|
||||
{artifact.status}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="asf-monospace text-xs text-slate-500">
|
||||
{artifact.external_id ?? shortId(artifact.id)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mt-2 text-xl font-semibold text-slate-900 break-words">{artifact.title}</h1>
|
||||
{path && (
|
||||
<div className="mt-1 text-xs text-slate-500 font-mono break-all">{path}</div>
|
||||
)}
|
||||
<div className="mt-1 text-xs text-slate-500 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span>Project: <span className="text-slate-700 font-medium">{projectName}</span></span>
|
||||
{engine && <span>Scanner: <span className="text-slate-700 font-medium">{engine}</span></span>}
|
||||
{repo && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3 text-slate-400" />
|
||||
<span className="text-slate-700 font-medium">{truncate(repo, 40)}{branch ? ` @ ${branch}` : ""}</span>
|
||||
</span>
|
||||
)}
|
||||
{confidence != null && <span>Confidence: <span className="text-slate-700 font-medium">{confidence}%</span></span>}
|
||||
<span>Updated: <span className="text-slate-700 font-medium">{fmtDate(artifact.updated_at)}</span></span>
|
||||
{lines && <span>Affected lines: <span className="text-slate-700 font-medium">{lines.join(", ")}</span></span>}
|
||||
{source && getLOC(artifact) != null && <span>Lines of code: <span className="text-slate-700 font-medium">{getLOC(artifact)!}</span></span>}
|
||||
</div>
|
||||
</div>
|
||||
{smell && onMarkFixed && (
|
||||
<div className="flex items-center gap-2 shrink-0 ml-auto">
|
||||
{artifact.status === "fixed" ? (
|
||||
<Badge tone="green" className="inline-flex items-center gap-1 !px-3 !py-1.5">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Resolved
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={markFixedPending}
|
||||
onClick={() => onMarkFixed("fixed")}
|
||||
className="!bg-emerald-500 hover:!bg-emerald-600"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" />
|
||||
{markFixedPending ? "Saving..." : "Mark as fixed"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
<Card className="lg:col-span-7 flex flex-col min-h-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="!text-sm flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-400" />
|
||||
{smell ? "Finding details" : "Source preview"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody className="p-0 flex flex-col min-h-0 overflow-hidden">
|
||||
{contentLines.length === 1 && contentLines[0] === "" ? (
|
||||
<div className="p-10">
|
||||
<EmptyState title="No content body available" description="This artifact is metadata-only. The blob content may still be fetchable from the Git integration." icon={FileCode2} />
|
||||
</div>
|
||||
) : (
|
||||
<pre className="flex-1 min-h-0 overflow-auto text-[12.5px] leading-6 bg-slate-900 text-slate-100 p-4 font-mono rounded-b-xl">
|
||||
{contentLines.map((ln, i) => {
|
||||
const n = i + 1;
|
||||
const hi = linesToHighlight.has(n);
|
||||
return (
|
||||
<div
|
||||
key={n}
|
||||
className={cn(
|
||||
"flex -mx-4 px-4",
|
||||
hi && "bg-rose-500/15 border-l-2 border-rose-400",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-10 shrink-0 text-right pr-3 select-none",
|
||||
hi ? "text-rose-200" : "text-slate-600",
|
||||
)}>{n}</span>
|
||||
<span className="flex-1 min-w-0 break-all whitespace-pre-wrap">{ln || " "}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-5 flex flex-col min-h-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="!text-sm flex items-center gap-2">
|
||||
{smell ? <AlertTriangle className="w-4 h-4 text-amber-500" /> : <CheckCircle2 className="w-4 h-4 text-emerald-500" />}
|
||||
{smell ? "Remediation guidance" : "File index info"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-4 text-sm">
|
||||
{smell ? (
|
||||
<>
|
||||
<SuggestionBlock
|
||||
title="Root cause"
|
||||
body={
|
||||
`Rule ${rule ?? "unknown"} reported by ${engine ?? "scanner"}. ` +
|
||||
(lines?.length ? `Focus attention on ${lines.length} affected line${lines.length > 1 ? "s" : ""}.` : "Inspect the highlighted rows in the source pane.")
|
||||
}
|
||||
tone="amber"
|
||||
/>
|
||||
<SuggestionBlock
|
||||
title="Suggested fix"
|
||||
body={
|
||||
sev === "critical" || sev === "high"
|
||||
? "Open a TASK linked to this finding, assign an owner, and block the PR/deployment pipeline until merged. Prefer writing a regression test that proves the issue."
|
||||
: "Fix in the next planned sprint; add a suppression comment only if you've filed a follow-up TASK with justification."
|
||||
}
|
||||
tone={sev === "critical" || sev === "high" ? "red" : "brand"}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<InfoCell label="Version" value={artifact.version != null ? `v${artifact.version}` : "—"} />
|
||||
<InfoCell label="Source" value={artifact.source ?? "—"} />
|
||||
<InfoCell label="Created" value={fmtDate(artifact.created_at)} />
|
||||
<InfoCell label="Updated" value={fmtDate(artifact.updated_at)} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SuggestionBlock
|
||||
title="Traceability"
|
||||
body="This file is linked to the ASF artifact graph. Open Context Explorer to inspect which PRs, commits, tasks, and deployments reference it."
|
||||
tone="brand"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<InfoCell label="LOC" value={getLOC(artifact) != null ? String(getLOC(artifact)!) : "—"} />
|
||||
<InfoCell label="Language" value={lang ? lang.toUpperCase() : "—"} />
|
||||
<InfoCell label="Created" value={fmtDate(artifact.created_at)} />
|
||||
<InfoCell label="Updated" value={fmtDate(artifact.updated_at)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionBlock({
|
||||
title, body, tone,
|
||||
}: { title: string; body: string; tone: "brand" | "amber" | "red" | "green" | "blue" }) {
|
||||
const toneCls: Record<string, string> = {
|
||||
brand: "bg-brand-50 border-brand-100 text-brand-900",
|
||||
amber: "bg-amber-50 border-amber-100 text-amber-900",
|
||||
red: "bg-rose-50 border-rose-100 text-rose-900",
|
||||
green: "bg-emerald-50 border-emerald-100 text-emerald-900",
|
||||
blue: "bg-sky-50 border-sky-100 text-sky-900",
|
||||
};
|
||||
return (
|
||||
<div className={cn("p-3 rounded-lg border", toneCls[tone])}>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide mb-1 opacity-80">{title}</div>
|
||||
<p className="text-sm leading-snug">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-100 bg-slate-50/60 px-2.5 py-1.5">
|
||||
<div className="uppercase tracking-wide text-[9px] text-slate-400">{label}</div>
|
||||
<div className="asf-monospace font-semibold text-slate-700 truncate">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user