feat(frontend): Topologies page — catalog browser + builder
New authed workspace page (/topologies, in the global nav) that browses the topology catalog and builds + visualizes a topology from a kind + roles: - lib/api/topology.ts: zod schemas + fetchTopologyCatalog (server, authed). - TopologyGraphView: lightweight SVG renderer (no new deps), laid out per kind (row for pipeline/ring, star for delegation kinds, circle otherwise). - TopologyExplorer (client): catalog list + roles input → POST /api/topologies/build via the /api proxy → render the graph. - ShellNav: add the Topologies nav item. - e2e (p8): sign in → nav → browse catalog → build a pipeline → graph renders. Full suite green (38); npm build + TS clean. Goes live with the next server+ frontend deploy (compare/Pareto UI to follow). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a35c82d860
commit
7648487a59
@@ -0,0 +1,80 @@
|
||||
import type { TopologyGraph } from "@/lib/api/topology";
|
||||
|
||||
const ROW_KINDS = new Set(["pipeline", "ring"]);
|
||||
const STAR_KINDS = new Set(["hierarchical", "hub_spoke", "star_moe", "market"]);
|
||||
|
||||
const W = 640;
|
||||
const H = 340;
|
||||
|
||||
/** Position nodes by topology kind: a row for pipeline/ring, a center+row star
|
||||
* for delegation kinds, a circle otherwise. */
|
||||
function layout(kind: string, n: number): { x: number; y: number }[] {
|
||||
const cx = W / 2;
|
||||
const cy = H / 2;
|
||||
if (n <= 1) return [{ x: cx, y: cy }];
|
||||
|
||||
if (ROW_KINDS.has(kind)) {
|
||||
const gap = (W - 120) / (n - 1);
|
||||
return Array.from({ length: n }, (_, i) => ({ x: 60 + i * gap, y: cy }));
|
||||
}
|
||||
if (STAR_KINDS.has(kind)) {
|
||||
const pts = [{ x: cx, y: 72 }];
|
||||
const spokes = n - 1;
|
||||
const gap = spokes > 1 ? (W - 120) / (spokes - 1) : 0;
|
||||
for (let i = 0; i < spokes; i++) {
|
||||
pts.push({ x: spokes > 1 ? 60 + i * gap : cx, y: H - 72 });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
const r = Math.min(W, H) / 2 - 64;
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
|
||||
return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders a topology graph as a lightweight SVG (no external deps). */
|
||||
export function TopologyGraphView({ graph }: { graph: TopologyGraph }) {
|
||||
const index = new Map(graph.nodes.map((node, i) => [node.id, i]));
|
||||
const pos = layout(graph.kind, graph.nodes.length);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label={`${graph.kind} topology with ${graph.nodes.length} nodes`}
|
||||
>
|
||||
{graph.edges.map((edge, i) => {
|
||||
const a = pos[index.get(edge.from) ?? -1];
|
||||
const b = pos[index.get(edge.to) ?? -1];
|
||||
if (!a || !b) return null;
|
||||
return (
|
||||
<line
|
||||
key={`${edge.from}-${edge.to}-${i}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke="#64748b"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graph.nodes.map((node, i) => {
|
||||
const p = pos[i];
|
||||
return (
|
||||
<g key={node.id}>
|
||||
<circle cx={p.x} cy={p.y} r={24} fill="#e2e8f0" stroke="#94a3b8" />
|
||||
<text x={p.x} y={p.y + 4} textAnchor="middle" fontSize={11} fontWeight={600} fill="#0b1220">
|
||||
{node.id}
|
||||
</text>
|
||||
<text x={p.x} y={p.y + 42} textAnchor="middle" fontSize={11} fill="#94a3b8">
|
||||
{node.role}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user