391 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|