From 32cd7d7511dfff720db750355c41492e06fc2d95 Mon Sep 17 00:00:00 2001 From: Francisco Meneses Date: Thu, 11 Jun 2026 19:01:10 -0400 Subject: [PATCH] feat(visualize): add --canvas flag for Cursor topology view Generate an interactive topology canvas from any export with a single CLI flag, embed policy chains from proxy.json when policies.json is empty, and ship a fixture-based demo plus docs for client use. Co-authored-by: Cursor --- README.md | 2 +- docs/TEST_CASES.md | 31 + docs/VISUALIZE.md | 47 +- docs/examples/topology-demo.canvas.tsx | 558 ++++++++++++++++++ internal/visualize/auth_test.go | 4 +- internal/visualize/canvas.go | 49 ++ .../visualize/canvas/topology.canvas.tsx.tmpl | 558 ++++++++++++++++++ internal/visualize/canvas_data.go | 167 ++++++ internal/visualize/canvas_data_test.go | 127 ---- internal/visualize/canvas_test.go | 104 ++++ internal/visualize/cli/cli.go | 14 +- internal/visualize/cli/cli_test.go | 17 + internal/visualize/loader.go | 45 ++ 13 files changed, 1586 insertions(+), 137 deletions(-) create mode 100644 docs/examples/topology-demo.canvas.tsx create mode 100644 internal/visualize/canvas.go create mode 100644 internal/visualize/canvas/topology.canvas.tsx.tmpl create mode 100644 internal/visualize/canvas_data.go delete mode 100644 internal/visualize/canvas_data_test.go create mode 100644 internal/visualize/canvas_test.go diff --git a/README.md b/README.md index b826cab..d615394 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ chmod +x threescale-visualize ./threescale-visualize ./export -o ./report ``` -See **[docs/VISUALIZE.md](docs/VISUALIZE.md)** for report layout. +See **[docs/VISUALIZE.md](docs/VISUALIZE.md)** for report layout and optional Cursor topology canvas. --- diff --git a/docs/TEST_CASES.md b/docs/TEST_CASES.md index ff2a899..06c1870 100644 --- a/docs/TEST_CASES.md +++ b/docs/TEST_CASES.md @@ -426,6 +426,37 @@ Automation references are verified against the repository at the time of writing --- +### TC-VIZ-004 — Generate Cursor topology canvas from export + +| Field | Value | +|-------|-------| +| Priority | P2 | +| CLI | `threescale-visualize --canvas` | +| Automation | **covered** (`TestVisualizeCanvasFlag`, `TestWriteCanvasTSX`) | + +**Preconditions** + +- Built `threescale-visualize` binary +- Valid export directory (fixture or lab export) + +**Steps** + +1. Run `threescale-visualize ./export --canvas ./topology.canvas.tsx` +2. Open the generated file in Cursor (`~/.cursor/projects//canvases/`) + +**Expected results** + +- Exit code 0 +- `.canvas.tsx` contains `TopologyCanvas`, embedded product data, and `cursor/canvas` import +- No customer-specific paths or tenant names in committed demo (`docs/examples/topology-demo.canvas.tsx` uses `seed_alpha` fixture) + +**Notes** + +- Canvas is optional; Markdown report is still generated by default +- Policy chains fall back to `proxy.json` when `policies.json` is empty + +--- + ## Lab pipeline ### TC-PIPE-001 — Seed → export → visualize diff --git a/docs/VISUALIZE.md b/docs/VISUALIZE.md index 66f6b6b..57a8b05 100644 --- a/docs/VISUALIZE.md +++ b/docs/VISUALIZE.md @@ -2,6 +2,8 @@ **Optional** tool that generates a Markdown report from a directory exported by `threescale-export`. Useful for migration reviews without opening JSON/YAML manually. +It can also generate a **Cursor IDE topology canvas** (`.canvas.tsx`) for interactive exploration of products, backends, applications, and policies. + To export a tenant, use the [main README](../README.md). ## Requirements @@ -9,6 +11,7 @@ To export a tenant, use the [main README](../README.md). - Go 1.22+ (to compile; release binary does not require Go) - An existing export with `schema_version` **1.0** (`manifest.json` at the root) - **No** Admin API, Docker, or `THREESCALE_*` variables required +- Cursor IDE (only if you open the optional topology canvas) ## Install @@ -21,13 +24,17 @@ Or download `threescale-visualize-v*.*.*-linux-amd64.tar.gz` from [Releases](htt ## Usage ```bash -# After an export (default ./export) +# Markdown report (default ./report) bin/threescale-visualize ./export -o ./report + +# Report + Cursor topology canvas +bin/threescale-visualize ./export -o ./report --canvas ./topology.canvas.tsx ``` | Flag | Description | |------|-------------| | `-o`, `--output` | Report directory (default `./report`) | +| `--canvas` | Write a Cursor IDE topology canvas (`.canvas.tsx`) | | `--version` | Binary version | ## Report layout @@ -43,6 +50,35 @@ report/ Open `index.md` in GitHub, VS Code, or Cursor to navigate via relative links. Mermaid diagrams render on GitHub and in compatible editors. +## Cursor topology canvas + +The canvas is an optional interactive view (charts, product→backend graph, sortable product table with policy names). Generate it from the same export directory: + +```bash +bin/threescale-visualize ./export --canvas ./topology.canvas.tsx +``` + +### Open the canvas in Cursor + +1. Copy or move the generated file into your Cursor project canvases folder: + `~/.cursor/projects//canvases/topology.canvas.tsx` +2. Open the file from the Cursor canvases panel (beside the chat). + +The canvas embeds data from **your local export only**. Do not commit generated `.canvas.tsx` files from production tenants to public repositories. + +### Demo canvas (lab fixture) + +The repository includes a demo generated from the offline fixture (`seed_alpha`, `seed_multi_backend`): + +- [`docs/examples/topology-demo.canvas.tsx`](examples/topology-demo.canvas.tsx) + +Regenerate it after template changes: + +```bash +bin/threescale-visualize internal/visualize/testdata/export-minimal \ + --canvas docs/examples/topology-demo.canvas.tsx +``` + ## Demo with seed data ```bash @@ -52,13 +88,16 @@ bin/threescale-seed # 2. Export lab tenant bin/threescale-export --output ./export --include-applications --redact-secrets -# 3. Generate report -bin/threescale-visualize ./export -o ./report +# 3. Generate report and optional canvas +bin/threescale-visualize ./export -o ./report --canvas ./topology.canvas.tsx ``` +Use `--include-applications` on export when you want subscribed applications in the canvas graph. + ## Limitations (v1) - Read-only from on-disk export; does not call Admin API - Does not include `policies/catalog.json` content (global reference, not tenant config) +- Policy chains are read from `policies.json` when present, otherwise from `proxy.json` (`policies_config`) - Redacted secrets (`***REDACTED***`) are shown as-is — not de-redacted -- No HTTP server or HTML (planned for future versions) +- Canvas requires Cursor IDE; the Markdown report works everywhere diff --git a/docs/examples/topology-demo.canvas.tsx b/docs/examples/topology-demo.canvas.tsx new file mode 100644 index 0000000..ebd2249 --- /dev/null +++ b/docs/examples/topology-demo.canvas.tsx @@ -0,0 +1,558 @@ +import { + BarChart, + Button, + Card, + CardBody, + CardHeader, + Checkbox, + CollapsibleSection, + computeDAGLayout, + Grid, + H1, + PieChart, + Row, + Select, + Spacer, + Stack, + Stat, + Text, + TextInput, + useCanvasState, + useHostTheme, +} from "cursor/canvas"; + +type Edge = [number, string]; +type AppEntry = [string, string, string]; +type Product = { n: string; c: string; a: string; e: Edge[]; p?: AppEntry[]; pol?: string[] }; +type Shared = { b: string; n: number; p: string[] }; +type TopologyData = { + m: { + admin_url: string; + exported_at: string; + product_count: number; + backend_count: number; + application_count: number; + }; + cat: Record; + catCounts: Record; + backends: string[]; + products: Product[]; + shared: Shared[]; +}; + +const DATA = {"m":{"admin_url":"https://tenant-admin.example.com","application_count":2,"backend_count":2,"exported_at":"2026-06-05T12:00:00Z","include_applications":true,"incomplete":false,"product_count":2,"schema_version":"1.0"},"cat":{"B":"Business API","I":"Integration (-IS)","P":"Platform / misc","S":"SAP"},"catCounts":{"B":2,"I":0,"P":0,"S":0},"backends":["billing_api","shared_payments"],"products":[{"n":"seed_alpha","c":"B","a":"API Key","e":[[1,"/payments"]],"p":[["Alpha App","Basic","live"]],"pol":["cors"]},{"n":"seed_multi_backend","c":"B","a":"OIDC","e":[[1,"/payments"],[0,"/billing"],[0,"/invoices"]],"p":[["Multi App","Default","live"]],"pol":["edge_limit","url_rewriting"]}],"shared":[{"b":"shared_payments","n":2,"p":["seed_alpha","seed_multi_backend"]}]} as TopologyData; + +const PAGE_SIZE_OPTIONS = [ + { value: "10", label: "10 / page" }, + { value: "20", label: "20 / page" }, + { value: "50", label: "50 / page" }, + { value: "100", label: "100 / page" }, +]; + +type TableSortKey = + | "product" + | "category" + | "auth" + | "backends" + | "apps" + | "policies" + | "policyNames"; +type TableSortDir = "asc" | "desc"; + +const TABLE_COLUMNS: { key: TableSortKey; label: string; numeric?: boolean }[] = [ + { key: "product", label: "Product" }, + { key: "category", label: "Category" }, + { key: "auth", label: "Auth" }, + { key: "backends", label: "Backends", numeric: true }, + { key: "apps", label: "Apps", numeric: true }, + { key: "policies", label: "Policies", numeric: true }, + { key: "policyNames", label: "Policy names" }, +]; + +function formatPolicyChain(names?: string[]): string { + if (!names || names.length === 0) { + return "—"; + } + return names.join(" → "); +} + +function columnFlex(key: TableSortKey): number { + switch (key) { + case "product": + return 2; + case "category": + case "auth": + return 1; + case "backends": + case "apps": + case "policies": + return 0.65; + case "policyNames": + return 3.5; + } +} + +function ProductDataTable({ + products, + sortKey, + sortDir, + onSort, +}: { + products: Product[]; + sortKey: TableSortKey; + sortDir: TableSortDir; + onSort: (key: TableSortKey) => void; +}) { + const theme = useHostTheme(); + const shellStyle = { + overflowX: "auto" as const, + border: `1px solid ${theme.stroke.primary}`, + borderRadius: 6, + }; + const headerStyle = { + padding: "8px 12px", + borderBottom: `1px solid ${theme.stroke.primary}`, + background: theme.fill.secondary, + }; + const cell = (flex: number, align: "left" | "right" = "left") => ({ + flex, + minWidth: 0, + textAlign: align, + }); + + return ( +
+ + + {TABLE_COLUMNS.map((col) => { + const active = sortKey === col.key; + const indicator = active ? (sortDir === "asc" ? " ↑" : " ↓") : ""; + return ( +
+ +
+ ); + })} +
+ {products.map((product, index) => { + const policyNames = product.pol ?? []; + const rowStyle = { + padding: "8px 12px", + borderBottom: `1px solid ${theme.stroke.secondary}`, + background: index % 2 === 1 ? theme.fill.tertiary : undefined, + }; + return ( +
+ + {product.n} + + {DATA.cat[product.c] ?? product.c} + + {product.a} + + {String(product.e.length)} + + + {String(product.p?.length ?? 0)} + + + {String(policyNames.length)} + + + {formatPolicyChain(policyNames)} + + +
+ ); + })} +
+
+ ); +} + +function compareProducts(a: Product, b: Product, key: TableSortKey): number { + switch (key) { + case "product": + return a.n.localeCompare(b.n); + case "category": + return (DATA.cat[a.c] ?? a.c).localeCompare(DATA.cat[b.c] ?? b.c); + case "auth": + return a.a.localeCompare(b.a); + case "backends": + return a.e.length - b.e.length; + case "apps": + return (a.p?.length ?? 0) - (b.p?.length ?? 0); + case "policies": + return (a.pol?.length ?? 0) - (b.pol?.length ?? 0); + case "policyNames": + return formatPolicyChain(a.pol).localeCompare(formatPolicyChain(b.pol)); + } +} + +function ProductGraph({ + product, + backends, + showApps, +}: { + product: Product; + backends: string[]; + showApps: boolean; +}) { + const theme = useHostTheme(); + const backendIds = [...new Set(product.e.map(([idx]) => idx))]; + const apps = product.p ?? []; + + const nodes = [{ id: "product" }]; + const edges: { from: string; to: string }[] = []; + + if (showApps) { + for (let i = 0; i < apps.length; i++) { + nodes.push({ id: `a${i}` }); + edges.push({ from: "product", to: `a${i}` }); + } + } + for (const idx of backendIds) { + nodes.push({ id: `b${idx}` }); + edges.push({ from: "product", to: `b${idx}` }); + } + + const layout = computeDAGLayout({ + nodes, + edges, + direction: "horizontal", + nodeWidth: 168, + nodeHeight: 36, + rankGap: showApps && apps.length > 0 ? 120 : 96, + nodeGap: 24, + padding: 20, + }); + + const pathByBackend = new Map(); + for (const [idx, path] of product.e) { + const list = pathByBackend.get(idx) ?? []; + list.push(path); + pathByBackend.set(idx, list); + } + + return ( + +
+ + {layout.edges.map((edge, i) => ( + + ))} + + + + + + {layout.nodes.map((node) => { + const isProduct = node.id === "product"; + const isApp = node.id.startsWith("a"); + let label = node.id; + let fill = theme.fill.secondary; + let color = theme.text.primary; + + if (isProduct) { + label = product.n; + fill = theme.accent.control; + color = theme.text.onAccent; + } else if (isApp) { + const appIdx = Number(node.id.slice(1)); + const app = apps[appIdx]; + label = app?.[0] ?? node.id; + fill = theme.fill.tertiary; + } else { + label = backends[Number(node.id.slice(1))] ?? node.id; + } + + return ( + + + + {label.length > 22 ? `${label.slice(0, 20)}…` : label} + + + ); + })} + +
+ + {showApps && apps.length > 0 ? ( + + Subscribed applications ({apps.length}) + {apps.map((app, i) => ( +
+ + {app[0]} · plan {app[1] || "—"} · {app[2] || "unknown"} + +
+ ))} +
+ ) : null} + + {backendIds.length > 0 ? ( + + Routing paths + {backendIds.map((idx) => ( +
+ + {backends[idx]}: {(pathByBackend.get(idx) ?? []).join(", ")} + +
+ ))} +
+ ) : null} +
+ ); +} + +export default function TopologyCanvas() { + const [query, setQuery] = useCanvasState("query", ""); + const [selected, setSelected] = useCanvasState("selected", DATA.products[0]?.n ?? ""); + const [showApps, setShowApps] = useCanvasState("showApps", false); + const [tablePage, setTablePage] = useCanvasState("tablePage", 0); + const [pageSize, setPageSize] = useCanvasState("pageSize", 20); + const [tableSortKey, setTableSortKey] = useCanvasState("tableSortKey", "backends"); + const [tableSortDir, setTableSortDir] = useCanvasState("tableSortDir", "desc"); + + const handleSort = (key: TableSortKey) => { + if (tableSortKey === key) { + setTableSortDir(tableSortDir === "asc" ? "desc" : "asc"); + } else { + setTableSortKey(key); + const col = TABLE_COLUMNS.find((c) => c.key === key); + setTableSortDir(col?.numeric ? "desc" : "asc"); + } + setTablePage(0); + }; + + const filtered = DATA.products.filter((p) => + p.n.toLowerCase().includes(query.toLowerCase()), + ); + const selectedProduct = + DATA.products.find((p) => p.n === selected) ?? + filtered[0] ?? + DATA.products[0]; + + const sortMultiplier = tableSortDir === "asc" ? 1 : -1; + const sortedProducts = [...DATA.products].sort( + (a, b) => compareProducts(a, b, tableSortKey) * sortMultiplier, + ); + const size = Math.max(1, Number(pageSize) || 20); + const totalPages = Math.max(1, Math.ceil(sortedProducts.length / size)); + const currentPage = Math.min(Math.max(0, tablePage), totalPages - 1); + const pageStart = currentPage * size; + const pageProducts = sortedProducts.slice(pageStart, pageStart + size); + + const pieData = Object.entries(DATA.catCounts).map(([key, value]) => ({ + label: DATA.cat[key] ?? key, + value, + })); + + const sharedCategories = DATA.shared.map((s) => + s.b.length > 18 ? `${s.b.slice(0, 16)}…` : s.b, + ); + const sharedValues = DATA.shared.map((s) => s.n); + + return ( + + +

3scale tenant — component topology

+ + Exported {DATA.m.exported_at} ·{" "} + {DATA.m.admin_url} + +
+ + + + + + n + p.e.length, 0))} + tone="warning" + /> + + + + + Products by domain + + + + {DATA.m.product_count} API products grouped by naming domain + + + + + Most shared backends + + + + Backends used by more than one API product + + + + + + + Product ↔ backend relationships + + + + + Filter products + + + + Selected product + { + setPageSize(Number(value)); + setTablePage(0); + }} + options={PAGE_SIZE_OPTIONS} + /> + + + + Page {currentPage + 1} of {totalPages} + + + + + + + + + + + {DATA.shared.map((s) => ( +
+ + + {s.b} + + Referenced by {s.n} products: {s.p.join(", ")} + {s.n > s.p.length ? " …" : ""} + + + +
+ ))} +
+
+
+ ); +} diff --git a/internal/visualize/auth_test.go b/internal/visualize/auth_test.go index e0e2f36..8f14da6 100644 --- a/internal/visualize/auth_test.go +++ b/internal/visualize/auth_test.go @@ -18,7 +18,7 @@ func TestInferAuthTypeFromProxyLegacyFields(t *testing.T) { }{ {name: "api key booleans", userKey: "true", appID: "false", want: "api_key"}, {name: "app id booleans", userKey: "false", appID: "true", appKey: "app_key", want: "app_id_and_app_key"}, - {name: "copec param names", userKey: "user_key", appID: "app_id", appKey: "app_key", want: "app_id_and_app_key"}, + {name: "legacy param names", userKey: "user_key", appID: "app_id", appKey: "app_key", want: "app_id_and_app_key"}, {name: "oidc issuer", oidcURL: "https://sso.example.com/realm/demo", want: "oidc"}, {name: "explicit auth type", userKey: "true", appID: "false", want: "api_key"}, } @@ -97,7 +97,7 @@ items: } } -func TestLoadExportCopecStyleProxy(t *testing.T) { +func TestLoadExportLegacyProxyAuthFields(t *testing.T) { dir := t.TempDir() writeMinimalManifest(t, dir, false) writeJSON(t, filepath.Join(dir, "backends", "billing.json"), map[string]any{ diff --git a/internal/visualize/canvas.go b/internal/visualize/canvas.go new file mode 100644 index 0000000..0225aa9 --- /dev/null +++ b/internal/visualize/canvas.go @@ -0,0 +1,49 @@ +package visualize + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +//go:embed canvas/topology.canvas.tsx.tmpl +var topologyCanvasTemplate string + +const canvasDataPlaceholder = "__CANVAS_DATA_JSON__" + +// WriteCanvasTSX renders a Cursor IDE topology canvas from tenant export data. +func WriteCanvasTSX(tenant *Tenant, outPath string) error { + if tenant == nil { + return fmt.Errorf("tenant is nil") + } + if strings.TrimSpace(outPath) == "" { + return fmt.Errorf("canvas output path is empty") + } + + data := BuildCanvasData(tenant) + payload, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("marshal canvas data: %w", err) + } + + content := strings.Replace( + topologyCanvasTemplate, + canvasDataPlaceholder, + string(payload), + 1, + ) + if !strings.Contains(content, `"products"`) { + return fmt.Errorf("canvas template placeholder was not replaced") + } + + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return fmt.Errorf("create canvas directory: %w", err) + } + if err := os.WriteFile(outPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("write canvas: %w", err) + } + return nil +} diff --git a/internal/visualize/canvas/topology.canvas.tsx.tmpl b/internal/visualize/canvas/topology.canvas.tsx.tmpl new file mode 100644 index 0000000..416bab1 --- /dev/null +++ b/internal/visualize/canvas/topology.canvas.tsx.tmpl @@ -0,0 +1,558 @@ +import { + BarChart, + Button, + Card, + CardBody, + CardHeader, + Checkbox, + CollapsibleSection, + computeDAGLayout, + Grid, + H1, + PieChart, + Row, + Select, + Spacer, + Stack, + Stat, + Text, + TextInput, + useCanvasState, + useHostTheme, +} from "cursor/canvas"; + +type Edge = [number, string]; +type AppEntry = [string, string, string]; +type Product = { n: string; c: string; a: string; e: Edge[]; p?: AppEntry[]; pol?: string[] }; +type Shared = { b: string; n: number; p: string[] }; +type TopologyData = { + m: { + admin_url: string; + exported_at: string; + product_count: number; + backend_count: number; + application_count: number; + }; + cat: Record; + catCounts: Record; + backends: string[]; + products: Product[]; + shared: Shared[]; +}; + +const DATA = __CANVAS_DATA_JSON__ as TopologyData; + +const PAGE_SIZE_OPTIONS = [ + { value: "10", label: "10 / page" }, + { value: "20", label: "20 / page" }, + { value: "50", label: "50 / page" }, + { value: "100", label: "100 / page" }, +]; + +type TableSortKey = + | "product" + | "category" + | "auth" + | "backends" + | "apps" + | "policies" + | "policyNames"; +type TableSortDir = "asc" | "desc"; + +const TABLE_COLUMNS: { key: TableSortKey; label: string; numeric?: boolean }[] = [ + { key: "product", label: "Product" }, + { key: "category", label: "Category" }, + { key: "auth", label: "Auth" }, + { key: "backends", label: "Backends", numeric: true }, + { key: "apps", label: "Apps", numeric: true }, + { key: "policies", label: "Policies", numeric: true }, + { key: "policyNames", label: "Policy names" }, +]; + +function formatPolicyChain(names?: string[]): string { + if (!names || names.length === 0) { + return "—"; + } + return names.join(" → "); +} + +function columnFlex(key: TableSortKey): number { + switch (key) { + case "product": + return 2; + case "category": + case "auth": + return 1; + case "backends": + case "apps": + case "policies": + return 0.65; + case "policyNames": + return 3.5; + } +} + +function ProductDataTable({ + products, + sortKey, + sortDir, + onSort, +}: { + products: Product[]; + sortKey: TableSortKey; + sortDir: TableSortDir; + onSort: (key: TableSortKey) => void; +}) { + const theme = useHostTheme(); + const shellStyle = { + overflowX: "auto" as const, + border: `1px solid ${theme.stroke.primary}`, + borderRadius: 6, + }; + const headerStyle = { + padding: "8px 12px", + borderBottom: `1px solid ${theme.stroke.primary}`, + background: theme.fill.secondary, + }; + const cell = (flex: number, align: "left" | "right" = "left") => ({ + flex, + minWidth: 0, + textAlign: align, + }); + + return ( +
+ + + {TABLE_COLUMNS.map((col) => { + const active = sortKey === col.key; + const indicator = active ? (sortDir === "asc" ? " ↑" : " ↓") : ""; + return ( +
+ +
+ ); + })} +
+ {products.map((product, index) => { + const policyNames = product.pol ?? []; + const rowStyle = { + padding: "8px 12px", + borderBottom: `1px solid ${theme.stroke.secondary}`, + background: index % 2 === 1 ? theme.fill.tertiary : undefined, + }; + return ( +
+ + {product.n} + + {DATA.cat[product.c] ?? product.c} + + {product.a} + + {String(product.e.length)} + + + {String(product.p?.length ?? 0)} + + + {String(policyNames.length)} + + + {formatPolicyChain(policyNames)} + + +
+ ); + })} +
+
+ ); +} + +function compareProducts(a: Product, b: Product, key: TableSortKey): number { + switch (key) { + case "product": + return a.n.localeCompare(b.n); + case "category": + return (DATA.cat[a.c] ?? a.c).localeCompare(DATA.cat[b.c] ?? b.c); + case "auth": + return a.a.localeCompare(b.a); + case "backends": + return a.e.length - b.e.length; + case "apps": + return (a.p?.length ?? 0) - (b.p?.length ?? 0); + case "policies": + return (a.pol?.length ?? 0) - (b.pol?.length ?? 0); + case "policyNames": + return formatPolicyChain(a.pol).localeCompare(formatPolicyChain(b.pol)); + } +} + +function ProductGraph({ + product, + backends, + showApps, +}: { + product: Product; + backends: string[]; + showApps: boolean; +}) { + const theme = useHostTheme(); + const backendIds = [...new Set(product.e.map(([idx]) => idx))]; + const apps = product.p ?? []; + + const nodes = [{ id: "product" }]; + const edges: { from: string; to: string }[] = []; + + if (showApps) { + for (let i = 0; i < apps.length; i++) { + nodes.push({ id: `a${i}` }); + edges.push({ from: "product", to: `a${i}` }); + } + } + for (const idx of backendIds) { + nodes.push({ id: `b${idx}` }); + edges.push({ from: "product", to: `b${idx}` }); + } + + const layout = computeDAGLayout({ + nodes, + edges, + direction: "horizontal", + nodeWidth: 168, + nodeHeight: 36, + rankGap: showApps && apps.length > 0 ? 120 : 96, + nodeGap: 24, + padding: 20, + }); + + const pathByBackend = new Map(); + for (const [idx, path] of product.e) { + const list = pathByBackend.get(idx) ?? []; + list.push(path); + pathByBackend.set(idx, list); + } + + return ( + +
+ + {layout.edges.map((edge, i) => ( + + ))} + + + + + + {layout.nodes.map((node) => { + const isProduct = node.id === "product"; + const isApp = node.id.startsWith("a"); + let label = node.id; + let fill = theme.fill.secondary; + let color = theme.text.primary; + + if (isProduct) { + label = product.n; + fill = theme.accent.control; + color = theme.text.onAccent; + } else if (isApp) { + const appIdx = Number(node.id.slice(1)); + const app = apps[appIdx]; + label = app?.[0] ?? node.id; + fill = theme.fill.tertiary; + } else { + label = backends[Number(node.id.slice(1))] ?? node.id; + } + + return ( + + + + {label.length > 22 ? `${label.slice(0, 20)}…` : label} + + + ); + })} + +
+ + {showApps && apps.length > 0 ? ( + + Subscribed applications ({apps.length}) + {apps.map((app, i) => ( +
+ + {app[0]} · plan {app[1] || "—"} · {app[2] || "unknown"} + +
+ ))} +
+ ) : null} + + {backendIds.length > 0 ? ( + + Routing paths + {backendIds.map((idx) => ( +
+ + {backends[idx]}: {(pathByBackend.get(idx) ?? []).join(", ")} + +
+ ))} +
+ ) : null} +
+ ); +} + +export default function TopologyCanvas() { + const [query, setQuery] = useCanvasState("query", ""); + const [selected, setSelected] = useCanvasState("selected", DATA.products[0]?.n ?? ""); + const [showApps, setShowApps] = useCanvasState("showApps", false); + const [tablePage, setTablePage] = useCanvasState("tablePage", 0); + const [pageSize, setPageSize] = useCanvasState("pageSize", 20); + const [tableSortKey, setTableSortKey] = useCanvasState("tableSortKey", "backends"); + const [tableSortDir, setTableSortDir] = useCanvasState("tableSortDir", "desc"); + + const handleSort = (key: TableSortKey) => { + if (tableSortKey === key) { + setTableSortDir(tableSortDir === "asc" ? "desc" : "asc"); + } else { + setTableSortKey(key); + const col = TABLE_COLUMNS.find((c) => c.key === key); + setTableSortDir(col?.numeric ? "desc" : "asc"); + } + setTablePage(0); + }; + + const filtered = DATA.products.filter((p) => + p.n.toLowerCase().includes(query.toLowerCase()), + ); + const selectedProduct = + DATA.products.find((p) => p.n === selected) ?? + filtered[0] ?? + DATA.products[0]; + + const sortMultiplier = tableSortDir === "asc" ? 1 : -1; + const sortedProducts = [...DATA.products].sort( + (a, b) => compareProducts(a, b, tableSortKey) * sortMultiplier, + ); + const size = Math.max(1, Number(pageSize) || 20); + const totalPages = Math.max(1, Math.ceil(sortedProducts.length / size)); + const currentPage = Math.min(Math.max(0, tablePage), totalPages - 1); + const pageStart = currentPage * size; + const pageProducts = sortedProducts.slice(pageStart, pageStart + size); + + const pieData = Object.entries(DATA.catCounts).map(([key, value]) => ({ + label: DATA.cat[key] ?? key, + value, + })); + + const sharedCategories = DATA.shared.map((s) => + s.b.length > 18 ? `${s.b.slice(0, 16)}…` : s.b, + ); + const sharedValues = DATA.shared.map((s) => s.n); + + return ( + + +

3scale tenant — component topology

+ + Exported {DATA.m.exported_at} ·{" "} + {DATA.m.admin_url} + +
+ + + + + + n + p.e.length, 0))} + tone="warning" + /> + + + + + Products by domain + + + + {DATA.m.product_count} API products grouped by naming domain + + + + + Most shared backends + + + + Backends used by more than one API product + + + + + + + Product ↔ backend relationships + + + + + Filter products + + + + Selected product + { + setPageSize(Number(value)); + setTablePage(0); + }} + options={PAGE_SIZE_OPTIONS} + /> + + + + Page {currentPage + 1} of {totalPages} + + + + + + + + + + + {DATA.shared.map((s) => ( +
+ + + {s.b} + + Referenced by {s.n} products: {s.p.join(", ")} + {s.n > s.p.length ? " …" : ""} + + + +
+ ))} +
+
+
+ ); +} diff --git a/internal/visualize/canvas_data.go b/internal/visualize/canvas_data.go new file mode 100644 index 0000000..728f0ca --- /dev/null +++ b/internal/visualize/canvas_data.go @@ -0,0 +1,167 @@ +package visualize + +import ( + "sort" + "strings" +) + +// CanvasData is the compact payload embedded in a Cursor topology canvas. +type CanvasData struct { + Manifest map[string]any `json:"m"` + Cat map[string]string `json:"cat"` + CatCounts map[string]int `json:"catCounts"` + Backends []string `json:"backends"` + Products []CanvasProduct `json:"products"` + Shared []CanvasShared `json:"shared"` +} + +type CanvasProduct struct { + Name string `json:"n"` + Category string `json:"c"` + Auth string `json:"a"` + Edges [][2]any `json:"e"` + Apps [][3]string `json:"p,omitempty"` + PolicyNames []string `json:"pol,omitempty"` +} + +type CanvasShared struct { + Backend string `json:"b"` + Count int `json:"n"` + Products []string `json:"p"` +} + +var canvasCategoryLabels = map[string]string{ + "I": "Integration (-IS)", + "B": "Business API", + "S": "SAP", + "P": "Platform / misc", +} + +// BuildCanvasData converts a loaded tenant export into canvas-ready JSON. +func BuildCanvasData(tenant *Tenant) CanvasData { + backendNames := make([]string, 0, len(tenant.Backends)) + backendIndex := map[string]int{} + for _, b := range tenant.Backends { + backendIndex[b.SystemName] = len(backendNames) + backendNames = append(backendNames, b.SystemName) + } + + refs := map[string]map[string]struct{}{} + appsByProduct := map[string][][3]string{} + for _, a := range tenant.Applications { + if strings.TrimSpace(a.ProductName) == "" { + continue + } + appsByProduct[a.ProductName] = append(appsByProduct[a.ProductName], [3]string{ + a.Name, + a.PlanName, + a.State, + }) + } + for name := range appsByProduct { + sort.Slice(appsByProduct[name], func(i, j int) bool { + return appsByProduct[name][i][0] < appsByProduct[name][j][0] + }) + } + + products := make([]CanvasProduct, 0, len(tenant.Products)) + for _, p := range tenant.Products { + edges := make([][2]any, 0, len(p.BackendUsages)) + for _, u := range p.BackendUsages { + if u.Backend == nil { + continue + } + idx := backendIndex[u.Backend.SystemName] + edges = append(edges, [2]any{idx, u.Path}) + if refs[u.Backend.SystemName] == nil { + refs[u.Backend.SystemName] = map[string]struct{}{} + } + refs[u.Backend.SystemName][p.SystemName] = struct{}{} + } + + policyNames := make([]string, 0, len(p.Policies)) + for _, policy := range p.Policies { + policyNames = append(policyNames, policy.Name) + } + + product := CanvasProduct{ + Name: p.SystemName, + Category: canvasCategoryKey(p.SystemName), + Auth: authLabel(p.AuthType), + Edges: edges, + PolicyNames: policyNames, + } + if apps := appsByProduct[p.SystemName]; len(apps) > 0 { + product.Apps = apps + } + products = append(products, product) + } + + var shared []CanvasShared + for backend, ps := range refs { + if len(ps) < 2 { + continue + } + names := make([]string, 0, len(ps)) + for name := range ps { + names = append(names, name) + } + sort.Strings(names) + limit := len(names) + if limit > 6 { + limit = 6 + } + shared = append(shared, CanvasShared{ + Backend: backend, + Count: len(names), + Products: names[:limit], + }) + } + sort.Slice(shared, func(i, j int) bool { + if shared[i].Count != shared[j].Count { + return shared[i].Count > shared[j].Count + } + return shared[i].Backend < shared[j].Backend + }) + if len(shared) > 12 { + shared = shared[:12] + } + + catCounts := map[string]int{"I": 0, "B": 0, "S": 0, "P": 0} + for _, p := range products { + catCounts[p.Category]++ + } + + manifest := map[string]any{ + "schema_version": tenant.Manifest.SchemaVersion, + "exported_at": tenant.Manifest.ExportedAt, + "admin_url": tenant.Manifest.AdminURL, + "product_count": tenant.Manifest.ProductCount, + "backend_count": tenant.Manifest.BackendCount, + "application_count": tenant.Manifest.ApplicationCount, + "include_applications": tenant.Manifest.IncludeApplications, + "incomplete": tenant.Manifest.Incomplete, + } + + return CanvasData{ + Manifest: manifest, + Cat: canvasCategoryLabels, + CatCounts: catCounts, + Backends: backendNames, + Products: products, + Shared: shared, + } +} + +func canvasCategoryKey(name string) string { + switch { + case strings.HasSuffix(name, "-IS") || name == "Industrial-IS-int": + return "I" + case strings.Contains(name, "SAP") || strings.HasPrefix(name, "SalidaSap"): + return "S" + case name == "dummy-for-alerts" || name == "llm" || name == "ddrr": + return "P" + default: + return "B" + } +} diff --git a/internal/visualize/canvas_data_test.go b/internal/visualize/canvas_data_test.go deleted file mode 100644 index 7488850..0000000 --- a/internal/visualize/canvas_data_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package visualize - -import ( - "encoding/json" - "os" - "sort" - "strings" - "testing" -) - -// TestWriteCopecCanvasData regenerates embedded canvas DATA when WRITE_COPEC_CANVAS=1. -func TestWriteCopecCanvasData(t *testing.T) { - if os.Getenv("WRITE_COPEC_CANVAS") == "" { - t.Skip("set WRITE_COPEC_CANVAS=1 to regenerate Copec canvas data") - } - - root := os.Getenv("COPEC_EXPORT_ROOT") - if root == "" { - root = "/home/fmeneses/Documents/copec/export" - } - - tenant, err := LoadExport(root) - if err != nil { - t.Fatal(err) - } - - backendNames := make([]string, 0, len(tenant.Backends)) - backendIndex := map[string]int{} - for _, b := range tenant.Backends { - backendIndex[b.SystemName] = len(backendNames) - backendNames = append(backendNames, b.SystemName) - } - - refs := map[string]map[string]struct{}{} - products := make([]map[string]any, 0, len(tenant.Products)) - for _, p := range tenant.Products { - es := make([][2]any, 0, len(p.BackendUsages)) - for _, u := range p.BackendUsages { - if u.Backend == nil { - continue - } - idx := backendIndex[u.Backend.SystemName] - es = append(es, [2]any{idx, u.Path}) - if refs[u.Backend.SystemName] == nil { - refs[u.Backend.SystemName] = map[string]struct{}{} - } - refs[u.Backend.SystemName][p.SystemName] = struct{}{} - } - products = append(products, map[string]any{ - "n": p.SystemName, - "c": canvasCategoryKey(p.SystemName), - "a": authLabel(p.AuthType), - "e": es, - }) - } - - type sharedEntry struct { - B string `json:"b"` - N int `json:"n"` - P []string `json:"p"` - } - var shared []sharedEntry - for b, ps := range refs { - if len(ps) < 2 { - continue - } - names := make([]string, 0, len(ps)) - for name := range ps { - names = append(names, name) - } - sort.Strings(names) - limit := len(names) - if limit > 6 { - limit = 6 - } - shared = append(shared, sharedEntry{B: b, N: len(names), P: names[:limit]}) - } - sort.Slice(shared, func(i, j int) bool { - if shared[i].N != shared[j].N { - return shared[i].N > shared[j].N - } - return shared[i].B < shared[j].B - }) - if len(shared) > 12 { - shared = shared[:12] - } - - catCounts := map[string]int{"I": 0, "B": 0, "S": 0, "P": 0} - for _, p := range products { - catCounts[p["c"].(string)]++ - } - - out := map[string]any{ - "m": tenant.Manifest, - "cat": map[string]string{"I": "Integration (-IS)", "B": "Business API", "S": "SAP", "P": "Platform / misc"}, - "catCounts": catCounts, - "backends": backendNames, - "products": products, - "shared": shared, - } - - outPath := os.Getenv("COPEC_CANVAS_DATA_OUT") - if outPath == "" { - outPath = "/tmp/copec-canvas-data.json" - } - data, err := json.Marshal(out) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(outPath, data, 0o644); err != nil { - t.Fatal(err) - } - t.Logf("wrote %s (%d bytes)", outPath, len(data)) -} - -func canvasCategoryKey(name string) string { - switch { - case strings.HasSuffix(name, "-IS") || name == "Industrial-IS-int": - return "I" - case strings.Contains(name, "SAP") || strings.HasPrefix(name, "SalidaSap"): - return "S" - case name == "dummy-for-alerts" || name == "llm" || name == "ddrr": - return "P" - default: - return "B" - } -} diff --git a/internal/visualize/canvas_test.go b/internal/visualize/canvas_test.go new file mode 100644 index 0000000..9c174c2 --- /dev/null +++ b/internal/visualize/canvas_test.go @@ -0,0 +1,104 @@ +package visualize + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildCanvasDataFromMinimalFixture(t *testing.T) { + tenant, err := LoadExport(filepath.Join("testdata", "export-minimal")) + if err != nil { + t.Fatal(err) + } + + data := BuildCanvasData(tenant) + if data.Manifest["product_count"] != 2 { + t.Fatalf("product_count = %v, want 2", data.Manifest["product_count"]) + } + if len(data.Products) != 2 { + t.Fatalf("products = %d, want 2", len(data.Products)) + } + if len(data.Backends) != 2 { + t.Fatalf("backends = %d, want 2", len(data.Backends)) + } + + var canvasAlpha *CanvasProduct + for i := range data.Products { + if data.Products[i].Name == "seed_alpha" { + canvasAlpha = &data.Products[i] + break + } + } + if canvasAlpha == nil { + t.Fatal("seed_alpha missing from canvas products") + } + if canvasAlpha.Auth != "API Key" { + t.Fatalf("auth = %q", canvasAlpha.Auth) + } + if len(canvasAlpha.Edges) != 1 { + t.Fatalf("edges = %d, want 1", len(canvasAlpha.Edges)) + } + if len(canvasAlpha.PolicyNames) != 1 || canvasAlpha.PolicyNames[0] != "cors" { + t.Fatalf("policies = %v", canvasAlpha.PolicyNames) + } + if len(canvasAlpha.Apps) != 1 { + t.Fatalf("apps = %d, want 1", len(canvasAlpha.Apps)) + } +} + +func TestWriteCanvasTSXFromMinimalFixture(t *testing.T) { + tenant, err := LoadExport(filepath.Join("testdata", "export-minimal")) + if err != nil { + t.Fatal(err) + } + + out := filepath.Join(t.TempDir(), "topology.canvas.tsx") + if err := WriteCanvasTSX(tenant, out); err != nil { + t.Fatal(err) + } + + content, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + text := string(content) + if strings.Contains(text, "__CANVAS_DATA_JSON__") { + t.Fatal("canvas placeholder was not replaced") + } + payload := extractCanvasJSON(t, text) + if !json.Valid([]byte(payload)) { + t.Fatal("embedded canvas DATA is not valid JSON") + } + for _, want := range []string{"TopologyCanvas", "seed_alpha", "cursor/canvas", "Policy names"} { + if !strings.Contains(text, want) { + t.Fatalf("missing %q in canvas output", want) + } + } +} + +func TestPolicyNamesFromProxyFile(t *testing.T) { + raw := []byte(`{"proxy":{"policies_config":[{"name":"apicast"},{"name":"headers"}]}}`) + got := policyNamesFromProxyFile(raw) + if len(got) != 2 || got[0].Name != "apicast" || got[1].Name != "headers" { + t.Fatalf("policies = %+v", got) + } +} + +func extractCanvasJSON(t *testing.T, content string) string { + t.Helper() + const prefix = "const DATA = " + start := strings.Index(content, prefix) + if start < 0 { + t.Fatal("DATA assignment not found") + } + start += len(prefix) + rest := content[start:] + end := strings.Index(rest, " as TopologyData;") + if end < 0 { + t.Fatal("TopologyData suffix not found") + } + return rest[:end] +} diff --git a/internal/visualize/cli/cli.go b/internal/visualize/cli/cli.go index 9d2d205..c8ac2c8 100644 --- a/internal/visualize/cli/cli.go +++ b/internal/visualize/cli/cli.go @@ -11,6 +11,7 @@ import ( func NewRoot() *cobra.Command { outputDir := "./report" + canvasPath := "" cmd := &cobra.Command{ Use: "threescale-visualize [export-dir]", @@ -18,22 +19,29 @@ func NewRoot() *cobra.Command { Long: `Reads an export directory produced by threescale-export (schema v1.0) and writes a multi-file Markdown bundle for migration review. -See docs/VISUALIZE.md for usage and report layout.`, +Optionally writes a Cursor IDE topology canvas (.canvas.tsx) for interactive +exploration. See docs/VISUALIZE.md for usage and report layout.`, Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return RunVisualize(args[0], outputDir) + return RunVisualize(args[0], outputDir, canvasPath) }, } cmd.Flags().StringVarP(&outputDir, "output", "o", "./report", "report output directory") + cmd.Flags().StringVar(&canvasPath, "canvas", "", "write Cursor IDE topology canvas (.canvas.tsx)") cmd.Version = version.Version return cmd } -func RunVisualize(exportDir, outputDir string) error { +func RunVisualize(exportDir, outputDir, canvasPath string) error { tenant, err := visualize.LoadExport(exportDir) if err != nil { return err } + if canvasPath != "" { + if err := visualize.WriteCanvasTSX(tenant, canvasPath); err != nil { + return fmt.Errorf("write canvas: %w", err) + } + } return visualize.WriteReport(tenant, outputDir) } diff --git a/internal/visualize/cli/cli_test.go b/internal/visualize/cli/cli_test.go index d04ece8..f804bb3 100644 --- a/internal/visualize/cli/cli_test.go +++ b/internal/visualize/cli/cli_test.go @@ -3,6 +3,7 @@ package cli import ( "os" "path/filepath" + "strings" "testing" ) @@ -25,3 +26,19 @@ func TestVisualizeExportMinimalFixture(t *testing.T) { t.Fatalf("missing backends.md: %v", err) } } + +func TestVisualizeCanvasFlag(t *testing.T) { + fixture := filepath.Join("..", "testdata", "export-minimal") + canvas := filepath.Join(t.TempDir(), "topology.canvas.tsx") + if got := execute([]string{fixture, "--canvas", canvas}); got != 0 { + t.Fatalf("execute(canvas) = %d, want 0", got) + } + content, err := os.ReadFile(canvas) + if err != nil { + t.Fatalf("read canvas: %v", err) + } + text := string(content) + if !strings.Contains(text, "TopologyCanvas") || !strings.Contains(text, "seed_alpha") || !strings.Contains(text, "cursor/canvas") { + t.Fatalf("unexpected canvas content") + } +} diff --git a/internal/visualize/loader.go b/internal/visualize/loader.go index a6fd5e1..7cc5efd 100644 --- a/internal/visualize/loader.go +++ b/internal/visualize/loader.go @@ -167,6 +167,14 @@ func loadProduct(productsDir, systemName string) (Product, error) { }); err != nil { return Product{}, err } + if len(product.Policies) == 0 { + if err := readOptionalJSON(filepath.Join(productDir, "proxy.json"), func(data []byte) error { + product.Policies = policyNamesFromProxyFile(data) + return nil + }); err != nil { + return Product{}, err + } + } if err := readOptionalJSON(filepath.Join(productDir, "backend_usages.json"), func(data []byte) error { product.BackendUsages = parseBackendUsages(data) return nil @@ -266,6 +274,43 @@ func parseProxy(data []byte, product *Product) error { return nil } +func policyNamesFromProxyFile(data []byte) []Policy { + var envelope struct { + Proxy struct { + PoliciesConfig json.RawMessage `json:"policies_config"` + } `json:"proxy"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return nil + } + return policyNamesFromConfig(envelope.Proxy.PoliciesConfig) +} + +func policyNamesFromConfig(raw json.RawMessage) []Policy { + if len(raw) == 0 { + return nil + } + var policies []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(raw, &policies); err != nil { + var encoded string + if json.Unmarshal(raw, &encoded) != nil { + return nil + } + if err := json.Unmarshal([]byte(encoded), &policies); err != nil { + return nil + } + } + out := make([]Policy, 0, len(policies)) + for _, policy := range policies { + if policy.Name != "" { + out = append(out, Policy{Name: policy.Name}) + } + } + return out +} + func parsePolicies(data []byte) []Policy { var envelope struct { Policies []struct {