diff --git a/ui/index.css b/ui/index.css index f0114c7e..a992e231 100644 --- a/ui/index.css +++ b/ui/index.css @@ -296,3 +296,17 @@ opacity: 0; } } + +/* Compose canvas (prototype): smooth live-traffic transitions. Edge width + and color track the simulated per-boundary rps/error figures, so they + ease between ticks instead of snapping. */ +.compose-canvas .react-flow__edge-path { + transition: + stroke 700ms ease, + stroke-width 700ms ease, + opacity 700ms ease; +} + +.compose-canvas .react-flow__node { + transition: transform 500ms cubic-bezier(0.33, 1, 0.68, 1); +} diff --git a/ui/studio/Navigation.tsx b/ui/studio/Navigation.tsx index ac175c73..b01c376f 100644 --- a/ui/studio/Navigation.tsx +++ b/ui/studio/Navigation.tsx @@ -595,6 +595,22 @@ export function Navigation({ className }: NavigationProps) { )} + + + Compose + + JSX.Element | null> = { console: ConsoleView, sql: SqlView, migrations: MigrationsView, + compose: ComposeView, default: BasicView, }; diff --git a/ui/studio/views/compose/ComposeView.tsx b/ui/studio/views/compose/ComposeView.tsx new file mode 100644 index 00000000..fb8f6cbf --- /dev/null +++ b/ui/studio/views/compose/ComposeView.tsx @@ -0,0 +1,472 @@ +import { + Activity, + Box, + Database, + Globe, + Layers, + Mail, + Package, + Pause, + Play, + Radio, + X, + Zap, +} from "lucide-react"; +import { type FC, memo, useEffect, useMemo, useRef, useState } from "react"; +import ReactFlow, { + Background, + Controls, + type Edge, + Handle, + type NodeProps, + type NodeTypes, + Position, +} from "reactflow"; + +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { cn } from "../../../lib/utils"; +import { StudioHeader } from "../../StudioHeader"; +import type { ViewProps } from "../View"; +import { + buildTraceRows, + type ComposeGraph, + type ComposeHealth, + type ComposeServiceType, + type ComposeTraceRow, + createComposeFixture, + createRng, + summarizeGraph, + tickTraffic, +} from "./compose-data"; +import { + buildComposeFlow, + type ComposeFlowNode, + type ComposeNodeData, + layoutComposeNodes, +} from "./compose-layout"; + +const TICK_MS = 1400; + +const HEALTH_STYLES: Record = { + healthy: { dot: "bg-emerald-500", label: "healthy" }, + degraded: { dot: "bg-amber-500", label: "degraded" }, + down: { dot: "bg-rose-500", label: "down" }, +}; + +const SERVICE_ICONS: Record> = { + postgres: Database, + redis: Zap, + "object-storage": Package, + "external-api": Globe, + email: Mail, +}; + +const HealthDot: FC<{ health: ComposeHealth }> = ({ health }) => ( + + + {HEALTH_STYLES[health].label} + +); + +const MetricsRow: FC<{ data: ComposeNodeData }> = ({ data }) => ( +
+ {(data.outRps || data.inRps).toFixed(1)} rps + · + p95 {data.p95Ms}ms +
+); + +const ComposeModuleNode: FC> = memo( + function ComposeModuleNode({ data }) { + const { node, health } = data; + + if (node.kind === "ingress") { + return ( +
+ +
+ +
+
+ {node.name} +
+
+ {node.detail} +
+
+ + {data.outRps.toFixed(1)} rps + +
+
+ ); + } + + const bundled = node.deployment?.mode === "bundled"; + + return ( +
+ +
+
+ {bundled ? ( + + ) : ( + + )} + + {node.name} + +
+ + {bundled && node.deployment?.mode === "bundled" + ? `bundled · ${node.deployment.app}` + : "unit"} + +
+
+
+ {node.detail} +
+
+ + +
+
+ +
+ ); + }, +); + +const ComposeServiceNode: FC> = memo( + function ComposeServiceNode({ data }) { + const { node, health } = data; + const Icon = node.serviceType ? SERVICE_ICONS[node.serviceType] : Globe; + + return ( +
+ +
+ + + +
+
+ {node.name} +
+
+ {node.detail} +
+
+
+ + + {data.inRps.toFixed(1)} rps + +
+
+ +
+ ); + }, +); + +const nodeTypes: NodeTypes = { + composeModule: ComposeModuleNode, + composeService: ComposeServiceNode, +}; + +function TracePanel(props: { + graph: ComposeGraph; + selectedId: string; + tick: number; + onClose: () => void; +}) { + const { graph, selectedId, tick, onClose } = props; + const node = graph.nodes.find((candidate) => candidate.id === selectedId); + const rows: ComposeTraceRow[] = useMemo( + () => + node + ? buildTraceRows(node, graph.edges, createRng(tick * 7919 + 17)) + : [], + [node, graph.edges, tick], + ); + + if (!node) { + return null; + } + + return ( +
+
+ + + Recent boundary traces — {node.name} + + + auto-instrumented at the Compose boundary + + +
+
+ + + {rows.map((row, index) => ( + + + + + + ))} + +
+ + {row.span} + + {row.target} + + {row.durationMs}ms +
+
+
+ ); +} + +export function ComposeView(_props: ViewProps) { + const [graph, setGraph] = useState(() => + createComposeFixture(), + ); + const [tick, setTick] = useState(0); + const [isLive, setIsLive] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [layoutedNodes, setLayoutedNodes] = useState( + null, + ); + const rngRef = useRef(createRng(20260715)); + + const flow = useMemo(() => buildComposeFlow(graph), [graph]); + + // Layout once — the topology is fixed; ticks only mutate traffic data, + // so positions are computed a single time and node payloads are merged + // into the layouted set every render (persistent-canvas pattern from + // the Migrations view). + useEffect(() => { + let cancelled = false; + const { nodes, edges } = buildComposeFlow(graph); + + void layoutComposeNodes(nodes, edges).then((positioned) => { + if (!cancelled) { + setLayoutedNodes(positioned); + } + }); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- layout depends only on the fixed topology + }, []); + + useEffect(() => { + if (!isLive) { + return; + } + + const interval = window.setInterval(() => { + setTick((current) => current + 1); + setGraph((current) => ({ + ...current, + edges: tickTraffic(current.edges, Date.now() / TICK_MS, rngRef.current), + })); + }, TICK_MS); + + return () => window.clearInterval(interval); + }, [isLive]); + + const positionById = useMemo( + () => + new Map( + (layoutedNodes ?? []).map((node) => [node.id, node.position] as const), + ), + [layoutedNodes], + ); + + const liveNodes = useMemo( + () => + layoutedNodes === null + ? [] + : flow.nodes.map((node) => ({ + ...node, + position: positionById.get(node.id) ?? node.position, + })), + [flow.nodes, layoutedNodes, positionById], + ); + + const liveEdges: Edge[] = layoutedNodes === null ? [] : flow.edges; + const summary = useMemo(() => summarizeGraph(graph), [graph]); + const summaryHealth = HEALTH_STYLES[summary.health]; + + return ( +
+ +
+ + Compose + prototype +
+
+ +
+
+

+ {graph.appName} +

+ + + {summaryHealth.label} + +
+ + {summary.modules} modules + + + {summary.services} services + + + {summary.inboundRps.toFixed(1)} rps in + + = 0.02 + ? "bg-rose-500/15 text-rose-700 dark:text-rose-300" + : "bg-muted", + )} + > + {(summary.errorRate * 100).toFixed(1)}% err + +
+
+ + simulated data + + +
+
+ +
+
+ { + setSelectedId(node.id.replace(/^compose:/, "")); + }} + onPaneClick={() => setSelectedId(null)} + proOptions={{ hideAttribution: true }} + > + + + +
+
+ + {selectedId !== null && ( + setSelectedId(null)} + selectedId={selectedId} + tick={tick} + /> + )} +
+
+ ); +} diff --git a/ui/studio/views/compose/compose-data.test.ts b/ui/studio/views/compose/compose-data.test.ts new file mode 100644 index 00000000..911a0500 --- /dev/null +++ b/ui/studio/views/compose/compose-data.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + buildTraceRows, + createComposeFixture, + createRng, + deriveHealth, + summarizeGraph, + tickTraffic, +} from "./compose-data"; + +describe("createComposeFixture", () => { + it("wires every edge to existing nodes", () => { + const graph = createComposeFixture(); + const ids = new Set(graph.nodes.map((node) => node.id)); + + for (const edge of graph.edges) { + expect(ids.has(edge.source)).toBe(true); + expect(ids.has(edge.target)).toBe(true); + } + }); + + it("has one ingress and both deployment modes represented", () => { + const graph = createComposeFixture(); + + expect(graph.nodes.filter((node) => node.kind === "ingress")).toHaveLength( + 1, + ); + + const modes = graph.nodes + .filter((node) => node.kind === "module") + .map((node) => node.deployment?.mode); + expect(modes).toContain("standalone"); + expect(modes).toContain("bundled"); + }); +}); + +describe("tickTraffic", () => { + it("is pure and keeps traffic within sane bounds", () => { + const graph = createComposeFixture(); + const before = JSON.stringify(graph.edges); + const random = createRng(42); + + let edges = graph.edges; + for (let tick = 0; tick < 50; tick += 1) { + edges = tickTraffic(edges, tick, random); + } + + expect(JSON.stringify(graph.edges)).toBe(before); + + for (const edge of edges) { + expect(edge.traffic.rps).toBeGreaterThan(0); + expect(edge.traffic.rps).toBeLessThanOrEqual(edge.traffic.baseRps * 2); + expect(edge.traffic.errorRate).toBeGreaterThanOrEqual(0); + expect(edge.traffic.errorRate).toBeLessThanOrEqual(0.25); + expect(edge.traffic.p95Ms).toBeGreaterThan(0); + } + }); + + it("is deterministic for a given seed", () => { + const graph = createComposeFixture(); + const a = tickTraffic(graph.edges, 3, createRng(7)); + const b = tickTraffic(graph.edges, 3, createRng(7)); + + expect(a).toEqual(b); + }); +}); + +describe("deriveHealth", () => { + it("marks nodes touching a high-error boundary as degraded", () => { + const graph = createComposeFixture(); + const email = graph.nodes.find((node) => node.id === "email")!; + const notifications = graph.nodes.find( + (node) => node.id === "notifications", + )!; + const web = graph.nodes.find((node) => node.id === "web")!; + + // The fixture ships the email boundary at 6% errors. + expect(deriveHealth(email, graph.edges)).toBe("degraded"); + expect(deriveHealth(notifications, graph.edges)).toBe("degraded"); + expect(deriveHealth(web, graph.edges)).toBe("healthy"); + }); + + it("escalates to down at hard error rates", () => { + const graph = createComposeFixture(); + const edges = graph.edges.map((edge) => + edge.id === "billing->stripe" + ? { ...edge, traffic: { ...edge.traffic, errorRate: 0.2 } } + : edge, + ); + const stripe = graph.nodes.find((node) => node.id === "stripe")!; + + expect(deriveHealth(stripe, edges)).toBe("down"); + }); +}); + +describe("summarizeGraph", () => { + it("aggregates counts, inbound rps, and overall health", () => { + const graph = createComposeFixture(); + const summary = summarizeGraph(graph); + + expect(summary.modules).toBe(7); + expect(summary.services).toBe(6); + expect(summary.inboundRps).toBeGreaterThan(0); + // email at 6% drags overall health to degraded. + expect(summary.health).toBe("degraded"); + }); +}); + +describe("buildTraceRows", () => { + it("fabricates span rows only from boundaries touching the node", () => { + const graph = createComposeFixture(); + const billing = graph.nodes.find((node) => node.id === "billing")!; + const rows = buildTraceRows(billing, graph.edges, createRng(1), 20); + + expect(rows).toHaveLength(20); + const neighbors = new Set(["orders", "stripe", "app-db"]); + for (const row of rows) { + expect(neighbors.has(row.target)).toBe(true); + expect(row.durationMs).toBeGreaterThan(0); + } + }); +}); diff --git a/ui/studio/views/compose/compose-data.ts b/ui/studio/views/compose/compose-data.ts new file mode 100644 index 00000000..cfc49e4e --- /dev/null +++ b/ui/studio/views/compose/compose-data.ts @@ -0,0 +1,405 @@ +/** + * Simulated data model for the Compose application canvas prototype. + * + * Compose composes an application from modules (independently deployable + * or bundled into a single Bun app) and tracks their dependencies on each + * other and on external services. Module boundaries are auto-instrumented, + * so every cross-boundary request is an OTel span — which is what the + * live traffic figures on the canvas represent. + * + * Everything here is a hard-coded fixture plus a deterministic traffic + * simulator: there is no Compose backend yet. The simulator is pure + * (state in, state out; randomness injected) so ticks are testable. + */ + +export type ComposeNodeKind = "ingress" | "module" | "service"; + +export type ComposeServiceType = + | "postgres" + | "redis" + | "object-storage" + | "external-api" + | "email"; + +export type ComposeDeployment = + | { mode: "standalone" } + | { mode: "bundled"; app: string }; + +export type ComposeHealth = "healthy" | "degraded" | "down"; + +export interface ComposeNode { + id: string; + kind: ComposeNodeKind; + name: string; + /** Modules: runtime; services: provider label (e.g. "Prisma Postgres"). */ + detail: string; + serviceType?: ComposeServiceType; + deployment?: ComposeDeployment; +} + +export interface ComposeEdgeTraffic { + /** Steady-state requests per second the simulator oscillates around. */ + baseRps: number; + rps: number; + p95Ms: number; + /** 0..1 fraction of failed requests on this boundary. */ + errorRate: number; +} + +export interface ComposeEdge { + id: string; + source: string; + target: string; + /** Boundary protocol shown on hover/labels. */ + protocol: "http" | "queue" | "sql" | "s3" | "smtp"; + traffic: ComposeEdgeTraffic; +} + +export interface ComposeGraph { + appName: string; + nodes: ComposeNode[]; + edges: ComposeEdge[]; +} + +export interface ComposeTraceRow { + span: string; + target: string; + durationMs: number; + ok: boolean; +} + +/** Deterministic PRNG (mulberry32) so simulator behavior is testable. */ +export function createRng(seed: number): () => number { + let state = seed >>> 0; + + return () => { + state = (state + 0x6d2b79f5) | 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const edge = ( + source: string, + target: string, + protocol: ComposeEdge["protocol"], + baseRps: number, + p95Ms: number, + errorRate = 0.002, +): ComposeEdge => ({ + id: `${source}->${target}`, + source, + target, + protocol, + traffic: { baseRps, rps: baseRps, p95Ms, errorRate }, +}); + +/** + * The fixture application: an e-commerce app composed of seven modules + * (three standalone units, four bundled into the single Bun app "core") + * and six external services. + */ +export function createComposeFixture(): ComposeGraph { + return { + appName: "acme-shop", + nodes: [ + { + id: "ingress", + kind: "ingress", + name: "Edge traffic", + detail: "https · public", + }, + { + id: "web", + kind: "module", + name: "web", + detail: "storefront BFF", + deployment: { mode: "standalone" }, + }, + { + id: "auth", + kind: "module", + name: "auth", + detail: "sessions & identity", + deployment: { mode: "standalone" }, + }, + { + id: "catalog", + kind: "module", + name: "catalog", + detail: "products & search", + deployment: { mode: "bundled", app: "core" }, + }, + { + id: "orders", + kind: "module", + name: "orders", + detail: "cart & checkout", + deployment: { mode: "bundled", app: "core" }, + }, + { + id: "billing", + kind: "module", + name: "billing", + detail: "payments", + deployment: { mode: "bundled", app: "core" }, + }, + { + id: "notifications", + kind: "module", + name: "notifications", + detail: "async worker", + deployment: { mode: "bundled", app: "core" }, + }, + { + id: "media", + kind: "module", + name: "media", + detail: "image pipeline", + deployment: { mode: "standalone" }, + }, + { + id: "app-db", + kind: "service", + name: "app-db", + detail: "Prisma Postgres", + serviceType: "postgres", + }, + { + id: "catalog-db", + kind: "service", + name: "catalog-db", + detail: "Prisma Postgres", + serviceType: "postgres", + }, + { + id: "cache", + kind: "service", + name: "cache", + detail: "Redis", + serviceType: "redis", + }, + { + id: "object-storage", + kind: "service", + name: "media-bucket", + detail: "S3 object storage", + serviceType: "object-storage", + }, + { + id: "stripe", + kind: "service", + name: "stripe", + detail: "Stripe API", + serviceType: "external-api", + }, + { + id: "email", + kind: "service", + name: "email", + detail: "Resend", + serviceType: "email", + }, + ], + edges: [ + edge("ingress", "web", "http", 42, 88), + edge("web", "auth", "http", 18, 24), + edge("web", "catalog", "http", 31, 41), + edge("web", "orders", "http", 9, 63), + edge("web", "media", "http", 6, 130), + edge("auth", "cache", "http", 25, 3), + edge("auth", "app-db", "sql", 7, 9), + edge("catalog", "catalog-db", "sql", 38, 12), + edge("catalog", "cache", "http", 22, 2), + edge("orders", "app-db", "sql", 14, 15), + edge("orders", "billing", "http", 4, 210), + edge("orders", "notifications", "queue", 4, 6), + edge("billing", "stripe", "http", 4, 320, 0.02), + edge("billing", "app-db", "sql", 5, 11), + edge("notifications", "email", "smtp", 3, 540, 0.06), + edge("media", "object-storage", "s3", 6, 95), + ], + }; +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +/** + * Advances live traffic one tick: every edge's rps drifts around its + * baseline with a slow wave plus jitter, latency wobbles, and error + * rates decay toward their baseline with occasional spikes (the Stripe + * boundary is the fixture's designated troublemaker). Pure — returns new + * edge objects and leaves the input untouched. + */ +export function tickTraffic( + edges: readonly ComposeEdge[], + tick: number, + random: () => number, +): ComposeEdge[] { + return edges.map((current, index) => { + const { baseRps, p95Ms, errorRate } = current.traffic; + const wave = 1 + 0.25 * Math.sin(tick / 5 + index * 1.7); + const jitter = 0.85 + random() * 0.3; + const rps = clamp(baseRps * wave * jitter, 0.2, baseRps * 2); + + const baseP95 = initialP95(current); + const nextP95 = clamp( + p95Ms * (0.9 + random() * 0.2), + baseP95 * 0.6, + baseP95 * 2.5, + ); + + const baseError = initialErrorRate(current); + const spiked = + random() < 0.02 ? baseError + 0.05 + random() * 0.05 : undefined; + const nextError = + spiked ?? clamp(errorRate * 0.8 + baseError * 0.2, 0, 0.25); + + return { + ...current, + traffic: { + baseRps, + rps: Math.round(rps * 10) / 10, + p95Ms: Math.round(nextP95), + errorRate: Math.round(nextError * 1000) / 1000, + }, + }; + }); +} + +const FIXTURE_BASELINES = new Map( + createComposeFixture().edges.map((e) => [e.id, e.traffic] as const), +); + +function initialP95(edgeValue: ComposeEdge): number { + return FIXTURE_BASELINES.get(edgeValue.id)?.p95Ms ?? edgeValue.traffic.p95Ms; +} + +function initialErrorRate(edgeValue: ComposeEdge): number { + return ( + FIXTURE_BASELINES.get(edgeValue.id)?.errorRate ?? + edgeValue.traffic.errorRate + ); +} + +/** + * Health is derived from the traffic crossing a node's boundaries: + * any touching edge with a hard error rate marks it degraded (down is + * reserved for a dead boundary — no simulated case yet, but the state + * exists so the visual language is complete). + */ +export function deriveHealth( + node: ComposeNode, + edges: readonly ComposeEdge[], +): ComposeHealth { + const touching = edges.filter( + (candidate) => candidate.source === node.id || candidate.target === node.id, + ); + + if (touching.length === 0) { + return "healthy"; + } + + if (touching.some((candidate) => candidate.traffic.errorRate >= 0.15)) { + return "down"; + } + + if (touching.some((candidate) => candidate.traffic.errorRate >= 0.04)) { + return "degraded"; + } + + return "healthy"; +} + +/** Aggregate figures for the floating header. */ +export function summarizeGraph(graph: ComposeGraph): { + modules: number; + services: number; + inboundRps: number; + errorRate: number; + health: ComposeHealth; +} { + const modules = graph.nodes.filter((node) => node.kind === "module").length; + const services = graph.nodes.filter((node) => node.kind === "service").length; + const inboundRps = graph.edges + .filter((candidate) => candidate.source === "ingress") + .reduce((sum, candidate) => sum + candidate.traffic.rps, 0); + const totalRps = graph.edges.reduce( + (sum, candidate) => sum + candidate.traffic.rps, + 0, + ); + const weightedErrors = graph.edges.reduce( + (sum, candidate) => + sum + candidate.traffic.rps * candidate.traffic.errorRate, + 0, + ); + const errorRate = totalRps > 0 ? weightedErrors / totalRps : 0; + const healths = graph.nodes.map((node) => deriveHealth(node, graph.edges)); + const health: ComposeHealth = healths.includes("down") + ? "down" + : healths.includes("degraded") + ? "degraded" + : "healthy"; + + return { + modules, + services, + inboundRps: Math.round(inboundRps * 10) / 10, + errorRate, + health, + }; +} + +/** + * Fabricates the "recent boundary traces" list for a selected node from + * the edges touching it — one OTel-span-shaped row per sample, weighted + * by each boundary's traffic share. + */ +export function buildTraceRows( + node: ComposeNode, + edges: readonly ComposeEdge[], + random: () => number, + count = 8, +): ComposeTraceRow[] { + const touching = edges.filter( + (candidate) => candidate.source === node.id || candidate.target === node.id, + ); + + if (touching.length === 0) { + return []; + } + + const totalRps = touching.reduce( + (sum, candidate) => sum + candidate.traffic.rps, + 0, + ); + const rows: ComposeTraceRow[] = []; + + for (let index = 0; index < count; index += 1) { + let pick = random() * totalRps; + let chosen = touching[0]!; + + for (const candidate of touching) { + pick -= candidate.traffic.rps; + if (pick <= 0) { + chosen = candidate; + break; + } + } + + const outbound = chosen.source === node.id; + const other = outbound ? chosen.target : chosen.source; + const spread = 0.4 + random() * 1.4; + + rows.push({ + span: `${chosen.protocol.toUpperCase()} ${outbound ? "→" : "←"} ${other}`, + target: other, + durationMs: Math.max(1, Math.round(chosen.traffic.p95Ms * spread)), + ok: random() >= chosen.traffic.errorRate, + }); + } + + return rows; +} diff --git a/ui/studio/views/compose/compose-layout.test.ts b/ui/studio/views/compose/compose-layout.test.ts new file mode 100644 index 00000000..61f1f8ed --- /dev/null +++ b/ui/studio/views/compose/compose-layout.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { createComposeFixture } from "./compose-data"; +import { buildComposeFlow, edgeStroke, edgeWidth } from "./compose-layout"; + +describe("buildComposeFlow", () => { + it("emits one flow node per graph node with stable prefixed ids", () => { + const graph = createComposeFixture(); + const { nodes, edges } = buildComposeFlow(graph); + + expect(nodes).toHaveLength(graph.nodes.length); + expect(edges).toHaveLength(graph.edges.length); + expect(nodes.every((node) => node.id.startsWith("compose:"))).toBe(true); + }); + + it("routes services to the service card type and aggregates per-node rps", () => { + const graph = createComposeFixture(); + const { nodes } = buildComposeFlow(graph); + + const cache = nodes.find((node) => node.id === "compose:cache")!; + expect(cache.type).toBe("composeService"); + // auth (25) + catalog (22) flow into the cache. + expect(cache.data.inRps).toBeCloseTo(47, 0); + + const web = nodes.find((node) => node.id === "compose:web")!; + expect(web.type).toBe("composeModule"); + expect(web.data.outRps).toBeGreaterThan(0); + }); + + it("animates only boundaries with meaningful traffic", () => { + const graph = createComposeFixture(); + graph.edges[0]!.traffic.rps = 0; + const { edges } = buildComposeFlow(graph); + + expect(edges[0]!.animated).toBe(false); + expect(edges.slice(1).every((edge) => edge.animated)).toBe(true); + }); +}); + +describe("edgeWidth", () => { + it("scales monotonically and stays bounded", () => { + expect(edgeWidth(0)).toBeCloseTo(1, 5); + expect(edgeWidth(5)).toBeGreaterThan(edgeWidth(1)); + expect(edgeWidth(40)).toBeGreaterThan(edgeWidth(5)); + expect(edgeWidth(10_000)).toBeLessThanOrEqual(4.5); + }); +}); + +describe("edgeStroke", () => { + it("shifts from calm to amber to destructive as errors rise", () => { + const graph = createComposeFixture(); + const base = graph.edges[0]!; + const at = (errorRate: number) => ({ + ...base, + traffic: { ...base.traffic, errorRate }, + }); + + expect(edgeStroke(at(0.001))).toContain("160"); + expect(edgeStroke(at(0.02))).toContain("75"); + expect(edgeStroke(at(0.05))).toBe("var(--destructive)"); + }); +}); diff --git a/ui/studio/views/compose/compose-layout.ts b/ui/studio/views/compose/compose-layout.ts new file mode 100644 index 00000000..d7681cd1 --- /dev/null +++ b/ui/studio/views/compose/compose-layout.ts @@ -0,0 +1,169 @@ +import ELK from "elkjs/lib/elk.bundled.js"; +import type { Edge, Node } from "reactflow"; +import { Position } from "reactflow"; + +import { + type ComposeEdge, + type ComposeGraph, + type ComposeHealth, + type ComposeNode, + deriveHealth, +} from "./compose-data"; + +const elk = new ELK(); + +const ELK_LAYOUT_OPTIONS = { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.edgeRouting": "SPLINES", + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + "elk.layered.spacing.nodeNodeBetweenLayers": "110", + "elk.spacing.nodeNode": "56", +} as const; + +const NODE_WIDTH = 224; + +export interface ComposeNodeData { + node: ComposeNode; + health: ComposeHealth; + /** Requests per second flowing out of / into this node this tick. */ + outRps: number; + inRps: number; + p95Ms: number; +} + +export type ComposeFlowNode = Node; + +/** + * Builds the ReactFlow node/edge sets for one traffic tick. Node ids are + * stable across ticks so the single canvas instance updates in place — + * only the data payloads and edge styling change (same persistent-canvas + * approach as the Migrations diff view). + */ +export function buildComposeFlow(graph: ComposeGraph): { + nodes: ComposeFlowNode[]; + edges: Edge[]; +} { + const nodes: ComposeFlowNode[] = graph.nodes.map((node) => { + const touching = graph.edges.filter( + (candidate) => + candidate.source === node.id || candidate.target === node.id, + ); + const outRps = touching + .filter((candidate) => candidate.source === node.id) + .reduce((sum, candidate) => sum + candidate.traffic.rps, 0); + const inRps = touching + .filter((candidate) => candidate.target === node.id) + .reduce((sum, candidate) => sum + candidate.traffic.rps, 0); + const p95Ms = touching.reduce( + (max, candidate) => Math.max(max, candidate.traffic.p95Ms), + 0, + ); + + return { + id: `compose:${node.id}`, + type: node.kind === "service" ? "composeService" : "composeModule", + position: { x: 0, y: 0 }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + data: { + node, + health: deriveHealth(node, graph.edges), + outRps: Math.round(outRps * 10) / 10, + inRps: Math.round(inRps * 10) / 10, + p95Ms, + }, + }; + }); + + const edges: Edge[] = graph.edges.map((composeEdge) => ({ + id: `compose-edge:${composeEdge.id}`, + source: `compose:${composeEdge.source}`, + target: `compose:${composeEdge.target}`, + label: `${composeEdge.traffic.rps} rps`, + labelStyle: { fontSize: 10, fill: "var(--muted-foreground)" }, + labelBgStyle: { fill: "var(--background)", fillOpacity: 0.75 }, + animated: composeEdge.traffic.rps > 0.5, + style: { + stroke: edgeStroke(composeEdge), + strokeWidth: edgeWidth(composeEdge.traffic.rps), + opacity: 0.9, + }, + type: "default", + })); + + return { nodes, edges }; +} + +/** Log-ish scaling so a 40 rps boundary doesn't dwarf a 2 rps one. */ +export function edgeWidth(rps: number): number { + return Math.min(4.5, 1 + Math.log10(1 + rps) * 1.6); +} + +export function edgeStroke(composeEdge: ComposeEdge): string { + if (composeEdge.traffic.errorRate >= 0.04) { + return "var(--destructive)"; + } + + if (composeEdge.traffic.errorRate >= 0.015) { + return "oklch(0.75 0.15 75)"; + } + + return "oklch(0.7 0.12 160)"; +} + +function estimateNodeHeight(node: ComposeFlowNode): number { + return node.data.node.kind === "ingress" ? 72 : 118; +} + +export async function layoutComposeNodes( + nodes: ComposeFlowNode[], + edges: Pick[], +): Promise { + if (nodes.length <= 1) { + return nodes; + } + + try { + const layouted = await elk.layout({ + id: "root", + layoutOptions: { ...ELK_LAYOUT_OPTIONS }, + children: nodes.map((node) => ({ + id: node.id, + width: NODE_WIDTH, + height: estimateNodeHeight(node), + })), + edges: edges.map((candidate) => ({ + id: candidate.id, + sources: [candidate.source], + targets: [candidate.target], + })), + }); + + const positions = new Map( + (layouted.children ?? []).map((child) => [ + child.id, + { x: child.x ?? 0, y: child.y ?? 0 }, + ]), + ); + + return nodes.map((node) => ({ + ...node, + position: positions.get(node.id) ?? node.position, + })); + } catch (error) { + console.warn( + "[compose] ELK layout failed; falling back to grid placement", + error, + ); + const columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length))); + + return nodes.map((node, index) => ({ + ...node, + position: { + x: (index % columns) * (NODE_WIDTH + 80), + y: Math.floor(index / columns) * 220, + }, + })); + } +}