From edd8227938d95c6aa4a5b43866c5889f2a1ab58d Mon Sep 17 00:00:00 2001 From: kp Date: Mon, 13 Jul 2026 13:54:17 -0700 Subject: [PATCH] feat(ui): GCP/Azure/Cloudflare provider packs, palette accordion, cross-provider containment (#37) --- README.md | 9 ++-- e2e/canvas.spec.ts | 10 ++++ packages/core/src/derive.test.ts | 84 ++++++++++++++++++++++++++++- packages/core/src/derive.ts | 17 +++++- packages/ui/src/Palette.tsx | 8 +-- packages/ui/src/icons.tsx | 6 +-- packages/ui/src/resource-palette.ts | 65 ++++++++++++++++++++++ packages/ui/src/styles.css | 9 +++- 8 files changed, 193 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 77e0727..14ed633 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,14 @@ What's provider-specific is only the curation layer, shipped as **provider packs - a palette pack (`packages/ui/src/resource-palette.ts`) — curated drag-and-drop types - containment rules (`DEFAULT_CONTAINMENT_RULES` in `@stackcanvas/core`) — which - resources render as visual containers (AWS VPC/subnet today) + resources render as visual containers (AWS VPC/subnet, GCP network/subnetwork, + Azure subnet, Cloudflare zone today) - icon patterns (`packages/ui/src/icons.tsx`) -AWS ships first. Adding a GCP/Azure/other pack is pure data and makes a great -first PR. +Four packs ship today: **AWS** (complete — the reference pack) and **GCP / +Azure / Cloudflare starter packs** covering the common resource types per +provider. Rounding out a starter pack, or adding a new provider entirely, is +pure data and makes a great first PR. ## OpenTofu diff --git a/e2e/canvas.spec.ts b/e2e/canvas.spec.ts index 65b0955..adf9071 100644 --- a/e2e/canvas.spec.ts +++ b/e2e/canvas.spec.ts @@ -46,6 +46,16 @@ test('nodes can be dragged', async ({ page }) => { expect(moved).toBeGreaterThan(50) }) +test('GCP accordion palette item creates a draft node with provider google', async ({ page }) => { + await page.goto('/') + // GCP is collapsed by default (only AWS opens on load) — expand it first. + await page.locator('.palette-pack summary', { hasText: 'GCP' }).click() + await page.getByRole('button', { name: 'Compute instance' }).click() + const draft = page.locator('.resource-node.draft') + await expect(draft).toBeVisible() + await expect(draft.locator('.type')).toHaveText('google_compute_instance') +}) + test('connecting two existing nodes draws a draft edge and Apply sends a modify', async ({ page }) => { await page.goto('/') const source = page.locator('.react-flow__node', { hasText: 'db' }) diff --git a/packages/core/src/derive.test.ts b/packages/core/src/derive.test.ts index 65e5bc8..5d70515 100644 --- a/packages/core/src/derive.test.ts +++ b/packages/core/src/derive.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from 'node:fs' import { expect, test } from 'vitest' import { parseState } from './parse-state.js' -import { deriveContainment, deriveEdges } from './derive.js' +import { DEFAULT_CONTAINMENT_RULES, deriveContainment, deriveEdges } from './derive.js' +import type { GraphModel } from './types.js' const fixture = JSON.parse( readFileSync(new URL('../test/fixtures/state.json', import.meta.url), 'utf8'), @@ -33,3 +34,84 @@ test('vpc and subnet containment groups', () => { expect(g.nodes.find(n => n.id === 'aws_instance.web')!.group).toBe('subnet:aws_subnet.a') expect(g.nodes.find(n => n.id === 'module.data.aws_db_instance.db')!.group).toBe('vpc:aws_vpc.main') }) + +test('gcp network/subnetwork containment keyed by self_link', () => { + const g: GraphModel = { + edges: [], + groups: [], + nodes: [ + { + id: 'google_compute_network.main', type: 'google_compute_network', name: 'main', + provider: 'google', group: null, status: 'noop', dependsOn: [], + attributes: { id: 'projects/p/global/networks/main', self_link: 'https://www.googleapis.com/compute/v1/projects/p/global/networks/main' }, + }, + { + id: 'google_compute_subnetwork.a', type: 'google_compute_subnetwork', name: 'a', + provider: 'google', group: null, status: 'noop', dependsOn: [], + attributes: { + id: 'projects/p/regions/us/subnetworks/a', + self_link: 'https://www.googleapis.com/compute/v1/projects/p/regions/us/subnetworks/a', + network: 'https://www.googleapis.com/compute/v1/projects/p/global/networks/main', + }, + }, + { + id: 'google_compute_instance.web', type: 'google_compute_instance', name: 'web', + provider: 'google', group: null, status: 'noop', dependsOn: [], + attributes: { id: 'i-1', network: 'https://www.googleapis.com/compute/v1/projects/p/global/networks/main' }, + }, + ], + } + const out = deriveContainment(g, DEFAULT_CONTAINMENT_RULES) + const netGroup = out.groups.find(x => x.id === 'vpc:google_compute_network.main') + expect(netGroup?.kind).toBe('vpc') + // the instance references the network directly (not via the subnetwork), + // so it's contained by the vpc group, not the subnet group. + expect(out.nodes.find(n => n.id === 'google_compute_instance.web')!.group).toBe('vpc:google_compute_network.main') + expect(out.nodes.find(n => n.id === 'google_compute_network.main')!.group).toBe('vpc:google_compute_network.main') +}) + +test('azurerm subnet containment (nic subnet_id references subnet.id)', () => { + const g: GraphModel = { + edges: [], + groups: [], + nodes: [ + { + id: 'azurerm_subnet.internal', type: 'azurerm_subnet', name: 'internal', + provider: 'azurerm', group: null, status: 'noop', dependsOn: [], + attributes: { id: '/subscriptions/x/resourceGroups/rg/subnets/internal' }, + }, + { + id: 'azurerm_network_interface.nic', type: 'azurerm_network_interface', name: 'nic', + provider: 'azurerm', group: null, status: 'noop', dependsOn: [], + attributes: { id: 'nic-1', subnet_id: '/subscriptions/x/resourceGroups/rg/subnets/internal' }, + }, + ], + } + const out = deriveContainment(g, DEFAULT_CONTAINMENT_RULES) + const subnetGroup = out.groups.find(x => x.id === 'subnet:azurerm_subnet.internal') + expect(subnetGroup?.kind).toBe('subnet') + expect(out.nodes.find(n => n.id === 'azurerm_network_interface.nic')!.group).toBe('subnet:azurerm_subnet.internal') +}) + +test('cloudflare zone containment (record zone_id references zone.id)', () => { + const g: GraphModel = { + edges: [], + groups: [], + nodes: [ + { + id: 'cloudflare_zone.example', type: 'cloudflare_zone', name: 'example', + provider: 'cloudflare', group: null, status: 'noop', dependsOn: [], + attributes: { id: 'zone-1' }, + }, + { + id: 'cloudflare_dns_record.www', type: 'cloudflare_dns_record', name: 'www', + provider: 'cloudflare', group: null, status: 'noop', dependsOn: [], + attributes: { id: 'rec-1', zone_id: 'zone-1' }, + }, + ], + } + const out = deriveContainment(g, DEFAULT_CONTAINMENT_RULES) + const zoneGroup = out.groups.find(x => x.id === 'vpc:cloudflare_zone.example') + expect(zoneGroup?.kind).toBe('vpc') + expect(out.nodes.find(n => n.id === 'cloudflare_dns_record.www')!.group).toBe('vpc:cloudflare_zone.example') +}) diff --git a/packages/core/src/derive.ts b/packages/core/src/derive.ts index a3f2dfe..bd38fe3 100644 --- a/packages/core/src/derive.ts +++ b/packages/core/src/derive.ts @@ -31,17 +31,30 @@ export interface ContainmentRule { memberAttr: string /** Group kind; also the group-id prefix, e.g. 'vpc' -> 'vpc:
'. */ kind: string + /** Container attribute whose value members reference (default 'id'). Some + * providers don't expose a stable `id` members can point back to — e.g. + * GCP networks/subnetworks are referenced by `self_link`, not `id`. */ + containerIdAttr?: string } /** * Rule order matters: earlier rules' membership is assigned before later * containers are created, so nested kinds (subnet inside vpc) inherit the * right parent. Provider packs extend this table (PRs welcome) — the only - * requirement is that members reference the container by its physical id. + * requirement is that members reference the container by its physical id + * (or `containerIdAttr`, when the container isn't referenced by `id`). */ export const DEFAULT_CONTAINMENT_RULES: ContainmentRule[] = [ { containerType: 'aws_vpc', memberAttr: 'vpc_id', kind: 'vpc' }, { containerType: 'aws_subnet', memberAttr: 'subnet_id', kind: 'subnet' }, + // GCP networks/subnetworks are referenced by `self_link`, not `id`. + { containerType: 'google_compute_network', memberAttr: 'network', kind: 'vpc', containerIdAttr: 'self_link' }, + { containerType: 'google_compute_subnetwork', memberAttr: 'subnetwork', kind: 'subnet', containerIdAttr: 'self_link' }, + { containerType: 'azurerm_subnet', memberAttr: 'subnet_id', kind: 'subnet' }, + // Cloudflare zones aren't a network container, but they group the + // resources scoped to them the same way a VPC groups its members — reuse + // the 'vpc' kind rather than introducing a one-off styling kind. + { containerType: 'cloudflare_zone', memberAttr: 'zone_id', kind: 'vpc' }, ] export function deriveContainment( @@ -53,7 +66,7 @@ export function deriveContainment( for (const rule of rules) { const groupByPhysicalId = new Map() for (const n of nodes.filter(n => n.type === rule.containerType)) { - const pid = n.attributes['id'] + const pid = n.attributes[rule.containerIdAttr ?? 'id'] if (typeof pid !== 'string') continue const gid = `${rule.kind}:${n.id}` groups.push({ id: gid, label: n.name, kind: rule.kind, parent: n.group }) diff --git a/packages/ui/src/Palette.tsx b/packages/ui/src/Palette.tsx index 623e510..c9a61ce 100644 --- a/packages/ui/src/Palette.tsx +++ b/packages/ui/src/Palette.tsx @@ -9,15 +9,15 @@ export function Palette() { return (