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,16 @@
|
|||||||
|
import { PageChrome } from "@/components/global/PageChrome";
|
||||||
|
import { TopologyExplorer } from "@/components/topology/TopologyExplorer";
|
||||||
|
import { fetchTopologyCatalog } from "@/lib/api/topology";
|
||||||
|
|
||||||
|
// Topologies page: browse the catalog and build/visualize a topology.
|
||||||
|
export default async function TopologiesPage() {
|
||||||
|
const catalog = await fetchTopologyCatalog();
|
||||||
|
return (
|
||||||
|
<PageChrome
|
||||||
|
title="Topologies"
|
||||||
|
description={`${catalog.length} organizational patterns to run your agents in`}
|
||||||
|
>
|
||||||
|
<TopologyExplorer catalog={catalog} />
|
||||||
|
</PageChrome>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Blocks, CreditCard, ShieldCheck, Users, Zap } from "lucide-react";
|
import { Blocks, CreditCard, Share2, ShieldCheck, Users, Zap } from "lucide-react";
|
||||||
|
|
||||||
import { GlobalNavItem } from "./GlobalNavItem";
|
import { GlobalNavItem } from "./GlobalNavItem";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/skills", label: "Skills", icon: Zap },
|
{ href: "/skills", label: "Skills", icon: Zap },
|
||||||
{ href: "/apps", label: "Apps", icon: Blocks },
|
{ href: "/apps", label: "Apps", icon: Blocks },
|
||||||
|
{ href: "/topologies", label: "Topologies", icon: Share2 },
|
||||||
{ href: "/approvals", label: "Approvals", icon: ShieldCheck },
|
{ href: "/approvals", label: "Approvals", icon: ShieldCheck },
|
||||||
{ href: "/team", label: "Team", icon: Users },
|
{ href: "/team", label: "Team", icon: Users },
|
||||||
{ href: "/credits", label: "Credits", icon: CreditCard },
|
{ href: "/credits", label: "Credits", icon: CreditCard },
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology";
|
||||||
|
|
||||||
|
import { TopologyGraphView } from "./TopologyGraphView";
|
||||||
|
|
||||||
|
/** Browse the topology catalog, then build + visualize a topology from a kind
|
||||||
|
* and a set of roles (via the /api/topologies/build endpoint). */
|
||||||
|
export function TopologyExplorer({ catalog }: { catalog: CatalogEntry[] }) {
|
||||||
|
const [kind, setKind] = useState(catalog[0]?.kind ?? "hierarchical");
|
||||||
|
const [roles, setRoles] = useState("coordinator, researcher, writer");
|
||||||
|
const [graph, setGraph] = useState<TopologyGraph | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selected = catalog.find((c) => c.kind === kind);
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const roleList = roles
|
||||||
|
.split(",")
|
||||||
|
.map((r) => r.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const res = await fetch("/api/topologies/build", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ kind, roles: roleList }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Build failed (${res.status})`);
|
||||||
|
}
|
||||||
|
setGraph((await res.json()) as TopologyGraph);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Build failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[240px_1fr]">
|
||||||
|
{/* Catalog */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{catalog.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.kind}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKind(c.kind)}
|
||||||
|
className={`rounded-lg px-3 py-2 text-left text-sm capitalize transition-colors ${
|
||||||
|
kind === c.kind
|
||||||
|
? "bg-surface-warm text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-surface-warm/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name.replace(/_/g, " ")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Builder */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{selected && (
|
||||||
|
<p className="text-sm text-muted-foreground">{selected.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||||
|
<label className="flex-1 text-xs text-muted-foreground">
|
||||||
|
Roles (comma-separated)
|
||||||
|
<input
|
||||||
|
value={roles}
|
||||||
|
onChange={(e) => setRoles(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={build}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-lg bg-coral px-4 py-2 text-sm font-medium text-white transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Building…" : "Build"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-coral">{error}</p>}
|
||||||
|
{graph && (
|
||||||
|
<div className="rounded-2xl border border-border bg-card p-4">
|
||||||
|
<TopologyGraphView graph={graph} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { apiFetch } from "./http";
|
||||||
|
|
||||||
|
export const RoleWeightSchema = z.object({
|
||||||
|
role: z.string(),
|
||||||
|
weight: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CatalogEntrySchema = z.object({
|
||||||
|
kind: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
role_distribution: z.array(RoleWeightSchema),
|
||||||
|
});
|
||||||
|
export type CatalogEntry = z.infer<typeof CatalogEntrySchema>;
|
||||||
|
|
||||||
|
export const TopologyNodeSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
level: z.number().optional(),
|
||||||
|
});
|
||||||
|
export const TopologyEdgeSchema = z.object({
|
||||||
|
from: z.string(),
|
||||||
|
to: z.string(),
|
||||||
|
kind: z.string(),
|
||||||
|
});
|
||||||
|
export const TopologyGraphSchema = z.object({
|
||||||
|
kind: z.string(),
|
||||||
|
nodes: z.array(TopologyNodeSchema),
|
||||||
|
edges: z.array(TopologyEdgeSchema),
|
||||||
|
});
|
||||||
|
export type TopologyGraph = z.infer<typeof TopologyGraphSchema>;
|
||||||
|
|
||||||
|
/** The catalog of supported topology kinds (server-side, authed). */
|
||||||
|
export function fetchTopologyCatalog(): Promise<CatalogEntry[]> {
|
||||||
|
return apiFetch(z.array(CatalogEntrySchema), "/api/topologies");
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
// The Topologies page: browse the catalog and build a topology graph against
|
||||||
|
// the real /api/topologies endpoints (frontend → proxy → backend).
|
||||||
|
|
||||||
|
const OWNER_EMAIL = "[email protected]";
|
||||||
|
const OWNER_PASSWORD = "e2e-password";
|
||||||
|
|
||||||
|
async function signIn(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Email").fill(OWNER_EMAIL);
|
||||||
|
await page.getByRole("button", { name: /Continue with work email/ }).click();
|
||||||
|
await page.getByLabel("Password").fill(OWNER_PASSWORD);
|
||||||
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("browse the topology catalog and build a graph", async ({ page }) => {
|
||||||
|
await signIn(page);
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "Topologies" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/topologies$/);
|
||||||
|
await expect(page.getByRole("heading", { name: "Topologies" })).toBeVisible();
|
||||||
|
|
||||||
|
// The catalog lists the kinds.
|
||||||
|
await expect(page.getByRole("button", { name: /hierarchical/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Build a pipeline topology and confirm the graph renders.
|
||||||
|
await page.getByRole("button", { name: /^pipeline$/i }).click();
|
||||||
|
await page.getByLabel(/Roles/).fill("alpha, beta, gamma");
|
||||||
|
await page.getByRole("button", { name: "Build" }).click();
|
||||||
|
await expect(page.locator("svg[aria-label*='topology']")).toBeVisible();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user