Files
2026-09-08 07:21:34 -04:00

391 lines
17 KiB
TypeScript

"use client";
import React, { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import ReactFlow, {
Background,
Controls,
MiniMap,
MarkerType,
Handle,
Position,
type Node,
type Edge,
type NodeProps,
} from "reactflow";
import "reactflow/dist/style.css";
import {
GitBranch,
ZoomIn,
ZoomOut,
Maximize2,
RefreshCw,
Layers,
ChevronRight,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge, JobStatusBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { traceability, artifacts, projects } from "@/lib/api";
import { cn, shortId } from "@/lib/utils";
import type { TraceGraph, ArtifactType } from "@/lib/types";
const DEMO_GRAPH: TraceGraph = {
nodes: [
{ id: "REQ-441", type: "requirement", label: "Multi-tenant RBAC", project_id: "p1", status: "in_progress", version: 3 },
{ id: "TASK-921", type: "task", label: "Tenant middleware", project_id: "p1", status: "done" },
{ id: "TASK-922", type: "task", label: "Policy evaluator", project_id: "p1", status: "done" },
{ id: "PR-214", type: "pull_request", label: "feat(rbac) #214", project_id: "p1", status: "done" },
{ id: "TEST-88", type: "test", label: "tenant_isolation.py", project_id: "p1", status: "done" },
{ id: "BUILD-4412", type: "build", label: "build #4412", project_id: "p1", status: "done" },
{ id: "DEP-44", type: "deployment", label: "asf-core v0.4.0 → prod", project_id: "p1", status: "done" },
],
edges: [
{ id: "e1", source: "TASK-921", target: "REQ-441", label: "implements" },
{ id: "e2", source: "TASK-922", target: "REQ-441", label: "implements" },
{ id: "e3", source: "PR-214", target: "TASK-921", label: "generated_from" },
{ id: "e4", source: "PR-214", target: "TASK-922", label: "generated_from" },
{ id: "e5", source: "TEST-88", target: "PR-214", label: "verifies" },
{ id: "e6", source: "BUILD-4412", target: "PR-214", label: "generated_from" },
{ id: "e7", source: "DEP-44", target: "BUILD-4412", label: "deployed_as" },
],
mock_fields: ["*"],
};
const TYPE_COLORS: Record<ArtifactType | string, { node: string; ring: string; text: string }> = {
requirement: { node: "#e0efff", ring: "#006fe0", text: "#084a8f" },
epic: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
story: { node: "#e0efff", ring: "#006fe0", text: "#084a8f" },
task: { node: "#e0f2fe", ring: "#0284c7", text: "#075985" },
architecture_decision: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
document: { node: "#f1f5f9", ring: "#64748b", text: "#334155" },
source_file: { node: "#f1f5f9", ring: "#64748b", text: "#334155" },
commit: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
pull_request: { node: "#e0f2fe", ring: "#0284c7", text: "#075985" },
build: { node: "#fef3c7", ring: "#d97706", text: "#92400e" },
test: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
security_finding: { node: "#fee2e2", ring: "#dc2626", text: "#991b1b" },
deployment: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
release: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
runtime_event: { node: "#fef3c7", ring: "#d97706", text: "#92400e" },
incident: { node: "#fee2e2", ring: "#dc2626", text: "#991b1b" },
ai_analysis: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
};
function ArtifactNode({ data, selected }: NodeProps<{ label: string; type: ArtifactType | string; status?: string; version?: number }>) {
const c = TYPE_COLORS[data.type] ?? TYPE_COLORS.document;
return (
<div
className={cn(
"px-3 py-2 rounded-lg shadow-card border backdrop-blur min-w-[160px] transition-shadow",
selected && "ring-2 ring-offset-2 ring-brand-400",
)}
style={{ background: c.node, borderColor: c.ring + "44" }}
>
<Handle type="target" position={Position.Top} className="!bg-slate-300 !border-white !w-2.5 !h-2.5" />
<div className="flex items-center gap-2">
<div className="w-1.5 h-6 rounded-full" style={{ background: c.ring }} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<ArtifactTypeBadge type={data.type} />
{data.version !== undefined && <Badge tone="neutral">v{data.version}</Badge>}
</div>
<div className="mt-1 text-xs font-semibold leading-tight" style={{ color: c.text }}>
{data.label}
</div>
{data.status && (
<div className="mt-1">
<JobStatusBadge status={data.status} />
</div>
)}
</div>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-300 !border-white !w-2.5 !h-2.5" />
</div>
);
}
const nodeTypes = { artifact: ArtifactNode as unknown as React.ComponentType<NodeProps> };
function layoutLayered(graph: TraceGraph): { nodes: Node[]; edges: Edge[] } {
const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
const outgoing = new Map<string, string[]>();
const incoming = new Map<string, string[]>();
graph.nodes.forEach((n) => {
outgoing.set(n.id, []);
incoming.set(n.id, []);
});
graph.edges.forEach((e) => {
outgoing.get(e.source)?.push(e.target);
incoming.get(e.target)?.push(e.source);
});
const depth = new Map<string, number>();
const visit = (id: string, d: number, seen: Set<string>) => {
if (seen.has(id)) return;
seen.add(id);
depth.set(id, Math.max(depth.get(id) ?? 0, d));
outgoing.get(id)?.forEach((next) => visit(next, d + 1, seen));
};
graph.nodes.forEach((n) => {
if ((incoming.get(n.id)?.length ?? 0) === 0) visit(n.id, 0, new Set());
});
graph.nodes.forEach((n) => {
if (!depth.has(n.id)) depth.set(n.id, 0);
});
const byDepth = new Map<number, string[]>();
depth.forEach((d, id) => {
if (!byDepth.has(d)) byDepth.set(d, []);
byDepth.get(d)!.push(id);
});
const nodes: Node[] = [];
const colW = 240;
const rowH = 110;
byDepth.forEach((ids, d) => {
ids.forEach((id, i) => {
const raw = nodeMap.get(id)!;
nodes.push({
id,
type: "artifact",
position: { x: d * colW, y: i * rowH },
data: { label: raw.label, type: raw.type, status: raw.status, version: raw.version },
});
});
});
const edges: Edge[] = graph.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label,
type: "smoothstep",
animated: false,
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
labelStyle: { fontSize: 10, fill: "#64748b" },
labelBgStyle: { fill: "#ffffffcc" },
labelBgPadding: [4, 2],
markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" },
}));
return { nodes, edges };
}
export default function TraceabilityPage() {
const [rootId, setRootId] = useState<string>("");
const graphQ = useQuery({
queryKey: ["traceability", rootId],
queryFn: () => traceability.graph(rootId ? { artifact_id: rootId, max_depth: 4 } : { limit: 200 }),
retry: false,
refetchOnWindowFocus: false,
refetchInterval: 5000,
});
const artQ = useQuery({
queryKey: ["traceability-artifacts"],
queryFn: () => artifacts.list({ limit: 30 }),
retry: false,
refetchOnWindowFocus: false,
});
const projectsQ = useQuery({
queryKey: ["traceability-projects"],
queryFn: () => projects.list({ limit: 50 }),
retry: false,
refetchOnWindowFocus: false,
});
const apiResolved = graphQ.isFetched && graphQ.data !== undefined;
const rawGraph: TraceGraph = apiResolved ? graphQ.data! : DEMO_GRAPH;
const isMock = !apiResolved;
const { nodes, edges } = useMemo(() => layoutLayered(rawGraph), [rawGraph]);
const artifactsList = (artQ.data ?? []).length ? artQ.data! : [];
// Map project_id → pretty project name for the Inspector panel
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectsQ.data ?? []) {
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return m;
}, [projectsQ.data]);
const [selId, setSelId] = useState<string | null>(null);
const selectedNode = rawGraph.nodes.find((n) => n.id === selId);
const onPaneReady = useCallback(() => {}, []);
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<GitBranch className="w-6 h-6 text-brand-600" />
Traceability
</h1>
<p className="mt-1 text-sm text-slate-500">
End-to-end artifact graph: Requirement → Task → Pull Request → Build → Test → Deployment. Click a node to inspect.
</p>
</div>
<div className="flex items-center gap-2">
{isMock && <Badge tone="amber">Demo graph</Badge>}
<select
className="asf-input !py-1.5 !text-sm !w-auto"
value={rootId}
onChange={(e) => setRootId(e.target.value)}
>
<option value="">All projects</option>
{artifactsList.map((a) => (
<option key={a.id} value={a.id}>{a.external_id ?? shortId(a.id)} · {a.title.slice(0, 40)}</option>
))}
</select>
<Button variant="secondary" size="sm" onClick={() => void graphQ.refetch()}>
<RefreshCw className="w-4 h-4" /> Reload
</Button>
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-4 gap-4">
<Card className="xl:col-span-3 flex flex-col min-h-0 overflow-hidden">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="w-4 h-4" />
Graph view
<Badge tone="neutral" className="ml-auto">
{rawGraph.nodes.length} nodes · {rawGraph.edges.length} edges
</Badge>
</CardTitle>
</CardHeader>
<CardBody className="p-0 flex-1 min-h-0 relative">
{nodes.length === 0 ? (
<div className="absolute inset-0 p-8">
<EmptyState title="Graph is empty" description="Create artifacts and relationships to populate the traceability view." />
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={onPaneReady}
onNodeClick={(_, n) => setSelId(n.id)}
onPaneClick={() => setSelId(null)}
fitView
fitViewOptions={{ padding: 0.3 }}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} color="#e2e8f0" />
<Controls
className="!rounded-lg !border !border-slate-200 !shadow-card overflow-hidden"
showInteractive={false}
>
<button className="asf-btn-ghost !p-2" title="Zoom in"><ZoomIn className="w-4 h-4" /></button>
<button className="asf-btn-ghost !p-2" title="Zoom out"><ZoomOut className="w-4 h-4" /></button>
<button className="asf-btn-ghost !p-2" title="Fit"><Maximize2 className="w-4 h-4" /></button>
</Controls>
<MiniMap
pannable
zoomable
className="!rounded-lg !border !border-slate-200 !shadow-card"
nodeColor={(n) => (TYPE_COLORS[(n.data as { type?: string })?.type ?? "document"] ?? TYPE_COLORS.document).ring}
maskColor="rgba(15, 23, 42, 0.06)"
/>
</ReactFlow>
)}
</CardBody>
</Card>
<Card className="flex flex-col min-h-0">
<CardHeader>
<CardTitle>Inspector</CardTitle>
<Badge tone="neutral">{rawGraph.nodes.length} types</Badge>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{selectedNode ? (
<div className="space-y-3">
<div>
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="asf-monospace text-xs text-slate-500">{selectedNode.id}</span>
<ArtifactTypeBadge type={selectedNode.type} />
</div>
<h2 className="text-base font-semibold text-slate-800 leading-tight">
{selectedNode.label}
</h2>
</div>
<dl className="text-sm space-y-1.5">
{selectedNode.status && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Status</dt>
<dd><JobStatusBadge status={selectedNode.status} /></dd>
</div>
)}
{selectedNode.version !== undefined && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Version</dt>
<dd className="asf-monospace">v{selectedNode.version}</dd>
</div>
)}
{selectedNode.project_id && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Project</dt>
<dd className="font-medium text-slate-800">
{projectNameById.get(String(selectedNode.project_id)) ?? (
<span className="asf-monospace text-xs">{shortId(selectedNode.project_id)}</span>
)}
</dd>
</div>
)}
{selectedNode.source && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Source</dt>
<dd>{selectedNode.source}</dd>
</div>
)}
</dl>
<div className="pt-2 flex flex-wrap gap-2">
<a href="/context" className="asf-btn-secondary inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg">
Open in Context <ChevronRight className="w-3 h-3" />
</a>
<a href="/requirements" className="asf-btn-ghost inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg">
Requirements
</a>
</div>
<div className="pt-3 border-t border-slate-100">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Connected edges</div>
<ul className="space-y-1 text-xs">
{rawGraph.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((e) => (
<li key={e.id} className="flex items-center gap-2 p-1.5 rounded hover:bg-slate-50">
<span className="asf-monospace text-slate-500">
{e.source === selectedNode.id ? "→" : "←"}
</span>
<span className="font-mono text-slate-700">{e.source === selectedNode.id ? e.target : e.source}</span>
<Badge tone="brand" className="ml-auto">{e.label}</Badge>
</li>
))}
</ul>
</div>
</div>
) : (
<EmptyState
title="Click a node"
description="Select any node on the graph to inspect its details and connections."
icon={GitBranch}
/>
)}
<div className="mt-4 pt-4 border-t border-slate-100">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Legend</div>
<ul className="grid grid-cols-1 gap-1.5 text-xs">
{Object.entries(TYPE_COLORS).slice(0, 10).map(([k, v]) => (
<li key={k} className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border" style={{ background: v.node, borderColor: v.ring }} />
<span className="text-slate-700 capitalize">{k.replace("_", " ")}</span>
</li>
))}
</ul>
</div>
</CardBody>
</Card>
</div>
</div>
);
}