From 833f3e3a651d3ab21e35a0c71beb5a22a5abd3b1 Mon Sep 17 00:00:00 2001 From: Gunbir Singh Date: Sat, 21 Mar 2026 19:29:33 -0700 Subject: [PATCH 1/9] feat(arch): rewrite architecture diagram to draw.io-style AWS layout - Complete rewrite of ArchDiagram.tsx: white background, pure SVG canvas, colored AWS service icon nodes (Lambda orange circle, S3 dark square, API Gateway purple, EventBridge pink, Bedrock teal, etc.) - Dashed group/cluster boxes with label badges (Agentcore, Runtime, Tools) - Section labels (Live Search, Job Search Batch Process, Communication Batch Process) - Dark arrows (#333) with arrowhead markers and optional edge labels - Default mock data mirrors the multi-agent career platform reference diagram - Add convertForgeNodes/convertForgeEdges for backward compat with forge store types - Wire ArchDiagram into ArchitecturePanel replacing inline EdgeOverlay + NodeCard --- .../src/components/cloudforge/ArchDiagram.tsx | 920 +++++++++++------- .../components/forge/ArchitecturePanel.tsx | 195 +--- 2 files changed, 596 insertions(+), 519 deletions(-) diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx index 7e16655..51528c8 100644 --- a/frontend/src/components/cloudforge/ArchDiagram.tsx +++ b/frontend/src/components/cloudforge/ArchDiagram.tsx @@ -1,378 +1,640 @@ 'use client'; -import { useMemo } from 'react'; -import { motion } from 'framer-motion'; +import type { ForgeArchNode, ForgeArchEdge } from '@/store/forgeStore'; /* ── Types ──────────────────────────────────────────────────────────────────── */ -interface ArchNode { +export type DrawIONodeType = 'service' | 'group' | 'sectionLabel' | 'user'; + +export type AWSServiceId = + | 'lambda' + | 's3' + | 'apigateway' + | 'sqs' + | 'eventbridge' + | 'bedrock' + | 'neptune' + | 'amplify' + | 'dynamodb' + | 'rds' + | 'cloudfront' + | 'cognito' + | 'ecs' + | 'sns' + | 'stepfunctions' + | 'route53' + | 'elb' + | 'ec2' + | 'eks' + | 'kinesis' + | 'generic'; + +export interface DrawIONode { id: string; - label: string; - sublabel?: string; - layer: 'app' | 'infra'; + type: DrawIONodeType; x: number; y: number; - isNew?: boolean; - isActive?: boolean; + label: string; + sublabel?: string; + service?: AWSServiceId; + width?: number; + height?: number; + groupColor?: string; + layer?: string; } -interface ArchEdge { +export interface DrawIOEdge { from: string; to: string; + label?: string; + dashed?: boolean; } -interface ArchDiagramProps { - nodes: ArchNode[]; - edges: ArchEdge[]; +export interface ArchDiagramProps { + nodes?: DrawIONode[]; + edges?: DrawIOEdge[]; } -/* ── Constants ──────────────────────────────────────────────────────────────── */ +/* ── AWS Service Config ──────────────────────────────────────────────────────── */ -const NODE_W = 140; -const NODE_H = 56; -const CANVAS_PADDING = 40; - -/* ── Component ──────────────────────────────────────────────────────────────── */ +interface ServiceConfig { + bg: string; + shape: 'circle' | 'roundedSquare'; + abbr: string; + iconContent: string; +} -export default function ArchDiagram({ nodes, edges }: ArchDiagramProps) { - const nodeMap = useMemo(() => { - const map = new Map(); - for (const node of nodes) { - map.set(node.id, node); - } - return map; - }, [nodes]); +const AWS_SERVICE_CONFIG: Record = { + lambda: { + bg: '#FF9900', + shape: 'circle', + abbr: 'λ', + iconContent: `λ`, + }, + s3: { + bg: '#3F8624', + shape: 'roundedSquare', + abbr: 'S3', + iconContent: `S3`, + }, + apigateway: { + bg: '#8C4FFF', + shape: 'roundedSquare', + abbr: 'API', + iconContent: `</>`, + }, + sqs: { + bg: '#232F3E', + shape: 'roundedSquare', + abbr: 'SQS', + iconContent: `SQS`, + }, + eventbridge: { + bg: '#FF4F8B', + shape: 'roundedSquare', + abbr: 'EB', + iconContent: `Event`, + }, + bedrock: { + bg: '#01A88D', + shape: 'roundedSquare', + abbr: 'BR', + iconContent: `Bedrock`, + }, + neptune: { + bg: '#7B16FF', + shape: 'roundedSquare', + abbr: 'NP', + iconContent: `Neptune`, + }, + amplify: { + bg: '#FF4F8B', + shape: 'roundedSquare', + abbr: 'AMP', + iconContent: `Amplify`, + }, + dynamodb: { + bg: '#4053D6', + shape: 'roundedSquare', + abbr: 'DDB', + iconContent: `DDB`, + }, + rds: { + bg: '#3F8624', + shape: 'roundedSquare', + abbr: 'RDS', + iconContent: `RDS`, + }, + cloudfront: { + bg: '#FF9900', + shape: 'roundedSquare', + abbr: 'CF', + iconContent: `CF`, + }, + cognito: { + bg: '#DD3522', + shape: 'roundedSquare', + abbr: 'CGN', + iconContent: `Cognito`, + }, + ecs: { + bg: '#FF9900', + shape: 'roundedSquare', + abbr: 'ECS', + iconContent: `ECS`, + }, + sns: { + bg: '#FF4F8B', + shape: 'roundedSquare', + abbr: 'SNS', + iconContent: `SNS`, + }, + stepfunctions: { + bg: '#FF4F8B', + shape: 'roundedSquare', + abbr: 'SF', + iconContent: `Step Fn`, + }, + route53: { + bg: '#8C4FFF', + shape: 'roundedSquare', + abbr: 'R53', + iconContent: `R53`, + }, + elb: { + bg: '#8C4FFF', + shape: 'roundedSquare', + abbr: 'ELB', + iconContent: `ELB`, + }, + ec2: { + bg: '#FF9900', + shape: 'roundedSquare', + abbr: 'EC2', + iconContent: `EC2`, + }, + eks: { + bg: '#FF9900', + shape: 'roundedSquare', + abbr: 'EKS', + iconContent: `EKS`, + }, + kinesis: { + bg: '#8C4FFF', + shape: 'roundedSquare', + abbr: 'KNS', + iconContent: `Kinesis`, + }, + generic: { + bg: '#545B64', + shape: 'roundedSquare', + abbr: '?', + iconContent: `?`, + }, +}; + +/* ── Default Mock Data ───────────────────────────────────────────────────────── */ + +const DEFAULT_NODES: DrawIONode[] = [ + { id: 'sec1', type: 'sectionLabel', x: 20, y: 10, label: 'Live Search' }, + { id: 'users', type: 'user', x: 60, y: 60, label: 'Students' }, + { id: 'amplify', type: 'service', x: 200, y: 130, label: 'AWS Amplify', service: 'amplify' }, + { id: 'lambda1', type: 'service', x: 340, y: 60, label: 'Lambda', sublabel: 'Save Resume', service: 'lambda' }, + { id: 's3', type: 'service', x: 480, y: 20, label: 'Amazon S3', service: 's3' }, + { id: 'lambda2', type: 'service', x: 340, y: 140, label: 'Lambda', sublabel: 'Resume Parser', service: 'lambda' }, + { id: 'bedrock1', type: 'service', x: 490, y: 130, label: 'Nova Pro', service: 'bedrock' }, + { id: 'lambda3', type: 'service', x: 340, y: 220, label: 'Lambda', sublabel: 'Save Profile', service: 'lambda' }, + { id: 'apigw', type: 'service', x: 340, y: 300, label: 'API Gateway', service: 'apigateway' }, + { id: 'lambda4', type: 'service', x: 200, y: 360, label: 'Lambda', service: 'lambda' }, + { id: 'g_agentcore', type: 'group', x: 30, y: 440, width: 560, height: 280, label: 'Agentcore', groupColor: '#8C4FFF' }, + { id: 'g_runtime', type: 'group', x: 140, y: 470, width: 360, height: 240, label: 'Runtime', groupColor: '#999' }, + { id: 'routing', type: 'service', x: 170, y: 530, label: 'Routing Agent', service: 'bedrock' }, + { id: 'career', type: 'service', x: 330, y: 490, label: 'Career Exploration Agent', service: 'bedrock' }, + { id: 'jobsearch', type: 'service', x: 330, y: 600, label: 'Job Search Agent', service: 'bedrock' }, + { id: 'memory', type: 'service', x: 60, y: 510, label: 'Memory', service: 'bedrock' }, + { id: 'g_tools', type: 'group', x: 620, y: 340, width: 300, height: 360, label: 'Tools', groupColor: '#8C4FFF' }, + { id: 'bedrock_kb', type: 'service', x: 640, y: 390, label: 'Bedrock Knowledge Base', service: 'bedrock' }, + { id: 's3_vector', type: 'service', x: 780, y: 390, label: 'S3 Vector store', service: 's3' }, + { id: 'graphrag', type: 'service', x: 640, y: 520, label: 'Graph RAG', service: 'bedrock' }, + { id: 'neptune', type: 'service', x: 780, y: 520, label: 'Neptune Graph', service: 'neptune' }, + { id: 'sec2', type: 'sectionLabel', x: 20, y: 760, label: 'Job Search Batch Process' }, + { id: 'eb1', type: 'service', x: 80, y: 860, label: 'EventBridge', sublabel: 'trigger 1am', service: 'eventbridge' }, + { id: 'lambda_q', type: 'service', x: 80, y: 750, label: 'Lambda', sublabel: 'Add to Queue', service: 'lambda' }, + { id: 'sqs', type: 'service', x: 200, y: 800, label: 'Amazon SQS', service: 'sqs' }, + { id: 'lambda_pq', type: 'service', x: 200, y: 700, label: 'Lambda', sublabel: 'Process Queue', service: 'lambda' }, + { id: 'sec3', type: 'sectionLabel', x: 440, y: 760, label: 'Communication Batch Process' }, + { id: 'eb2', type: 'service', x: 450, y: 860, label: 'EventBridge', sublabel: 'trigger 9am', service: 'eventbridge' }, + { id: 'lambda_dn', type: 'service', x: 450, y: 750, label: 'Lambda', sublabel: 'Send Daily Notifications', service: 'lambda' }, + { id: 'ses', type: 'service', x: 620, y: 720, label: 'Simple Email Service', service: 'sns' }, + { id: 'pinpoint', type: 'service', x: 620, y: 820, label: 'End User Messaging', service: 'sns' }, +]; + +const DEFAULT_EDGES: DrawIOEdge[] = [ + { from: 'users', to: 'amplify' }, + { from: 'amplify', to: 'lambda1', label: 'Save Resume' }, + { from: 'amplify', to: 'lambda2', label: 'S3 Path' }, + { from: 'amplify', to: 'lambda3', label: 'Save Profile' }, + { from: 'amplify', to: 'apigw', label: 'Job Notification' }, + { from: 'lambda1', to: 's3' }, + { from: 'lambda2', to: 'bedrock1', label: 'Resume Parser' }, + { from: 'amplify', to: 'lambda4' }, + { from: 'lambda4', to: 'routing' }, + { from: 'routing', to: 'career' }, + { from: 'routing', to: 'jobsearch' }, + { from: 'routing', to: 'memory' }, + { from: 'career', to: 'bedrock_kb' }, + { from: 'jobsearch', to: 'graphrag' }, + { from: 'bedrock_kb', to: 's3_vector' }, + { from: 'graphrag', to: 'neptune' }, + { from: 'eb1', to: 'lambda_q' }, + { from: 'lambda_q', to: 'sqs' }, + { from: 'sqs', to: 'lambda_pq' }, + { from: 'eb2', to: 'lambda_dn' }, + { from: 'lambda_dn', to: 'ses' }, + { from: 'lambda_dn', to: 'pinpoint' }, +]; + +/* ── Backward Compatibility Converters ──────────────────────────────────────── */ + +function mapForgeTypeToService(type: ForgeArchNode['type']): AWSServiceId { + const map: Record = { + compute: 'lambda', + storage: 's3', + cache: 'dynamodb', + gateway: 'apigateway', + queue: 'sqs', + auth: 'cognito', + }; + return map[type] ?? 'generic'; +} - const ariaLabel = useMemo(() => { - const nodeLabels = nodes.map((n) => n.label).join(', '); - const edgePairs = edges - .map((e) => { - const fromLabel = nodeMap.get(e.from)?.label ?? e.from; - const toLabel = nodeMap.get(e.to)?.label ?? e.to; - return `${fromLabel} → ${toLabel}`; - }) - .join(', '); - const nodesPart = nodeLabels ? `Architecture diagram: ${nodeLabels}.` : 'Architecture diagram.'; - const edgesPart = edgePairs ? ` Connections: ${edgePairs}.` : ''; - return `${nodesPart}${edgesPart}`; - }, [nodes, edges, nodeMap]); - - const layerLabels = useMemo(() => { - const appNodes = nodes.filter((n) => n.layer === 'app'); - const infraNodes = nodes.filter((n) => n.layer === 'infra'); - - const appMinY = appNodes.length > 0 ? Math.min(...appNodes.map((n) => n.y)) : 0; - const infraMinY = infraNodes.length > 0 ? Math.min(...infraNodes.map((n) => n.y)) : 0; - - return { - app: { y: appMinY + CANVAS_PADDING - 20 }, - infra: { y: infraMinY + CANVAS_PADDING - 20 }, - }; - }, [nodes]); - - /* Calculate canvas height from node positions */ - const canvasHeight = useMemo(() => { - if (nodes.length === 0) return 380; - const maxY = Math.max(...nodes.map((n) => n.y)); - return maxY + CANVAS_PADDING * 2 + NODE_H + 60; /* 60px for legend */ - }, [nodes]); +export function convertForgeNodes(forgeNodes: ForgeArchNode[]): DrawIONode[] { + return forgeNodes.map((n, i) => ({ + id: n.id, + type: 'service' as const, + x: n.x ?? (i % 4) * 180 + 40, + y: n.y ?? Math.floor(i / 4) * 160 + 40, + label: n.label, + sublabel: n.sublabel, + service: mapForgeTypeToService(n.type), + })); +} - return ( -
- {/* SVG edge layer */} - - - - - - +export function convertForgeEdges(forgeEdges: ForgeArchEdge[]): DrawIOEdge[] { + return forgeEdges.map((e) => ({ + from: e.from, + to: e.to, + })); +} - {edges.map((edge, index) => { - const fromNode = nodeMap.get(edge.from); - const toNode = nodeMap.get(edge.to); - if (!fromNode || !toNode) return null; - - const x1 = fromNode.x + CANVAS_PADDING + NODE_W; - const y1 = fromNode.y + CANVAS_PADDING + NODE_H / 2; - const x2 = toNode.x + CANVAS_PADDING; - const y2 = toNode.y + CANVAS_PADDING + NODE_H / 2; - const mx = (x1 + x2) / 2; - - const d = `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`; - - return ( - - ); - })} - +/* ── Helper ──────────────────────────────────────────────────────────────────── */ + +function hexToRgba(hex: string, alpha: number): string { + const clean = hex.replace('#', ''); + const full = clean.length === 3 + ? clean.split('').map((c) => c + c).join('') + : clean; + const r = parseInt(full.slice(0, 2), 16); + const g = parseInt(full.slice(2, 4), 16); + const b = parseInt(full.slice(4, 6), 16); + return `rgba(${r},${g},${b},${alpha})`; +} - {/* Layer labels */} - - Application Layer - - - - Infrastructure Layer - +/* ── Sub-components ──────────────────────────────────────────────────────────── */ - {/* Node layer */} -
- {nodes.map((node) => ( - - ))} -
+function GroupContainer({ node }: { node: DrawIONode }) { + const color = node.groupColor ?? '#8C4FFF'; + const labelWidth = node.label.length * 7 + 16; - {/* Legend */} - -
+ return ( + + + + + {node.label} + + ); } -/* ── ArchNodeCard ────────────────────────────────────────────────────────────── */ - -function ArchNodeCard({ node }: { node: ArchNode }) { - const isApp = node.layer === 'app'; - - const defaultBorder = isApp - ? '0.5px solid var(--lp-accent-dim)' - : '0.5px solid var(--cf-purple-glow)'; - - const newBorder = node.isNew ? '1px solid var(--lp-accent)' : defaultBorder; - const newShadow = node.isNew ? '0 0 12px var(--lp-accent-glow)' : 'none'; - - const accentBarColor = isApp ? 'var(--lp-accent)' : 'var(--cf-purple)'; - - /* Animation props for new / active nodes */ - const animateProps = node.isNew - ? { - animate: { - boxShadow: [ - '0 0 0px var(--lp-accent-glow)', - '0 0 20px var(--lp-accent-glow)', - '0 0 0px var(--lp-accent-glow)', - ], - }, - transition: { duration: 1.5, repeat: 2, ease: 'easeInOut' as const }, - } - : node.isActive - ? { - animate: { - borderColor: [ - 'rgba(45,212,191,0.2)', - 'rgba(45,212,191,0.6)', - 'rgba(45,212,191,0.2)', - ], - }, - transition: { duration: 1.2, repeat: Infinity }, - } - : {}; +function ServiceNode({ node, iconSize }: { node: DrawIONode; iconSize: number }) { + if (node.type === 'user') { + const cx = node.x + iconSize / 2; + const cy = node.y + 16; + return ( + + + + + {node.label} + + + ); + } - return ( - - {/* Left accent bar */} - + const service = node.service ?? 'generic'; + const config = AWS_SERVICE_CONFIG[service]; + const cx = node.x + iconSize / 2; - + {config.shape === 'circle' ? ( + + ) : ( + + )} + {/* Icon inner content rendered via a nested SVG to scope the coordinate system */} + + + + {/* Primary label */} + {node.label} - - + + {/* Sublabel */} {node.sublabel && ( - {node.sublabel} - + )} - + ); } -/* ── Legend ───────────────────────────────────────────────────────────────────── */ +function DiagramEdge({ + edge, + nodes, + iconSize, +}: { + edge: DrawIOEdge; + nodes: DrawIONode[]; + iconSize: number; +}) { + const fromNode = nodes.find((n) => n.id === edge.from); + const toNode = nodes.find((n) => n.id === edge.to); + if (!fromNode || !toNode) return null; + + const getCenter = (n: DrawIONode): [number, number] => { + if (n.type === 'group') { + return [n.x + (n.width ?? 200) / 2, n.y + (n.height ?? 150) / 2]; + } + if (n.type === 'user') { + return [n.x + iconSize / 2, n.y + iconSize / 2]; + } + return [n.x + iconSize / 2, n.y + iconSize / 2]; + }; -function Legend() { - const items: { color: string; label: string; glow?: boolean }[] = [ - { color: 'var(--lp-accent)', label: 'Application services' }, - { color: 'var(--cf-purple)', label: 'Infrastructure' }, - { color: 'var(--lp-accent)', label: 'Newly added', glow: true }, - ]; + const [x1, y1] = getCenter(fromNode); + const [x2, y2] = getCenter(toNode); + const my = (y1 + y2) / 2; + const mx = (x1 + x2) / 2; + const d = `M ${x1},${y1} C ${x1},${my} ${x2},${my} ${x2},${y2}`; + + return ( + + + {edge.label && ( + + {edge.label} + + )} + + ); +} + +function SectionLabel({ node }: { node: DrawIONode }) { + return ( + + {node.label} + + ); +} + +/* ── Main Component ──────────────────────────────────────────────────────────── */ + +export default function ArchDiagram({ nodes: propNodes, edges: propEdges }: ArchDiagramProps) { + const nodes = propNodes ?? DEFAULT_NODES; + const edges = propEdges ?? DEFAULT_EDGES; + + const ICON_SIZE = 56; + const PAD = 60; + + const serviceNodes = nodes.filter( + (n) => n.type !== 'group' && n.type !== 'sectionLabel', + ); + + const maxX = + serviceNodes.length > 0 + ? Math.max(...serviceNodes.map((n) => n.x + ICON_SIZE)) + : 800; + + const allNodes = nodes.filter((n) => n.type !== 'sectionLabel'); + const maxY = + allNodes.length > 0 + ? Math.max( + ...allNodes.map((n) => n.y + (n.height ?? ICON_SIZE) + 30), + ) + : 600; + + const svgWidth = Math.max(maxX + PAD, 800); + const svgHeight = Math.max(maxY + PAD, 600); + + /* Accessible aria label */ + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + const ariaLabel = [ + 'AWS architecture diagram.', + nodes + .filter((n) => n.type === 'service' || n.type === 'user') + .map((n) => n.label) + .join(', '), + 'Connections:', + edges + .map((e) => { + const from = nodeMap.get(e.from)?.label ?? e.from; + const to = nodeMap.get(e.to)?.label ?? e.to; + return `${from} to ${to}`; + }) + .join(', '), + ] + .filter(Boolean) + .join(' '); return (
- {items.map((item) => ( -
- - -
- ))} + + + + + {/* Layer 1: Group containers — rendered behind everything */} + {nodes + .filter((n) => n.type === 'group') + .map((node) => ( + + ))} + + {/* Layer 2: Edges */} + {edges.map((edge, i) => ( + + ))} + + {/* Layer 3: Service and user nodes */} + {nodes + .filter((n) => n.type === 'service' || n.type === 'user') + .map((node) => ( + + ))} + + {/* Layer 4: Section labels — on top */} + {nodes + .filter((n) => n.type === 'sectionLabel') + .map((node) => ( + + ))} +
); } diff --git a/frontend/src/components/forge/ArchitecturePanel.tsx b/frontend/src/components/forge/ArchitecturePanel.tsx index dde07ac..10e34d6 100644 --- a/frontend/src/components/forge/ArchitecturePanel.tsx +++ b/frontend/src/components/forge/ArchitecturePanel.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { motion, AnimatePresence } from 'framer-motion'; import { useForgeStore } from '@/store/forgeStore'; +import ArchDiagram, { convertForgeNodes, convertForgeEdges } from '@/components/cloudforge/ArchDiagram'; import { runAgent2, AGENT2_STEPS, @@ -14,9 +15,6 @@ import type { ForgeArchNode } from '@/store/forgeStore'; // ── Constants ───────────────────────────────────────────────────────────────── -const NODE_W = 140; -const NODE_H = 70; - const ALTERNATIVES = [ { name: 'DynamoDB', @@ -30,44 +28,6 @@ const ALTERNATIVES = [ }, ]; -// ── Node type color maps ─────────────────────────────────────────────────────── - -const NODE_COLORS: Record< - ForgeArchNode['type'], - { background: string; border: string; borderSelected: string } -> = { - gateway: { - background: 'rgba(45,212,191,0.06)', - border: 'rgba(45,212,191,0.25)', - borderSelected: 'rgba(45,212,191,0.7)', - }, - compute: { - background: 'rgba(45,212,191,0.06)', - border: 'rgba(45,212,191,0.2)', - borderSelected: 'rgba(45,212,191,0.6)', - }, - cache: { - background: 'rgba(245,158,11,0.06)', - border: 'rgba(245,158,11,0.2)', - borderSelected: 'rgba(245,158,11,0.65)', - }, - storage: { - background: 'rgba(52,211,153,0.06)', - border: 'rgba(52,211,153,0.2)', - borderSelected: 'rgba(52,211,153,0.65)', - }, - auth: { - background: 'rgba(167,139,250,0.06)', - border: 'rgba(167,139,250,0.2)', - borderSelected: 'rgba(167,139,250,0.65)', - }, - queue: { - background: 'rgba(45,212,191,0.06)', - border: 'rgba(45,212,191,0.15)', - borderSelected: 'rgba(45,212,191,0.55)', - }, -}; - const VALIDATES_CHIP_COLORS: Record = { gateway: 'rgba(45,212,191,0.15)', compute: 'rgba(45,212,191,0.15)', @@ -129,143 +89,6 @@ function StepDot({ state }: { state: 'done' | 'active' | 'pending' }) { ); } -// ── Architecture diagram: SVG edge overlay ──────────────────────────────────── - -interface EdgeOverlayProps { - nodes: ForgeArchNode[]; - edges: Array<{ from: string; to: string }>; -} - -function EdgeOverlay({ nodes, edges }: EdgeOverlayProps) { - const nodeMap = new Map(nodes.map((n) => [n.id, n])); - - return ( - - ); -} - -// ── Architecture diagram: single node card ──────────────────────────────────── - -interface NodeCardProps { - node: ForgeArchNode; - isSelected: boolean; - onClick: (id: string) => void; -} - -function NodeCard({ node, isSelected, onClick }: NodeCardProps) { - const colors = NODE_COLORS[node.type]; - - return ( - onClick(node.id)} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - transition={{ type: 'spring', stiffness: 400, damping: 25 }} - aria-pressed={isSelected} - aria-label={`${node.label} — ${node.sublabel}`} - style={{ - position: 'absolute', - left: node.x, - top: node.y, - width: `${NODE_W}px`, - height: `${NODE_H}px`, - background: colors.background, - border: `${isSelected ? '1.5px' : '0.5px'} solid ${ - isSelected ? colors.borderSelected : colors.border - }`, - borderRadius: '10px', - cursor: 'pointer', - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - padding: '10px 12px', - textAlign: 'left', - gap: '3px', - outline: 'none', - transition: 'border-color 150ms ease', - }} - > - {/* Validated badge */} - - ✓ - - - {/* Service name */} - - {node.label} - - - {/* Sublabel */} - - {node.sublabel} - - - ); -} // ── Node inspector panel ────────────────────────────────────────────────────── @@ -827,18 +650,10 @@ export default function ArchitecturePanel() { inset: 0, }} > - {/* SVG edge overlay */} - - - {/* Node cards */} - {displayNodes.map((node) => ( - - ))} + )} From 843e4334f4603ca00831d7e8dfdd81dcb60f5cc9 Mon Sep 17 00:00:00 2001 From: Gunbir Singh Date: Sat, 21 Mar 2026 20:33:09 -0700 Subject: [PATCH 2/9] feat(arch): add tooltips, config panel, controlled selection, and NodeInspector wiring - Hover tooltips via createPortal (escapes Framer Motion transform containment) - Click-to-open ConfigPanel with AWS service description, config props, use cases, docs link - Controlled selection mode: when onNodeSelect prop is passed, ArchDiagram defers to parent - ArchitecturePanel now passes onNodeSelect + selectedNodeId to ArchDiagram, restoring NodeInspector wiring (terraformResource, estimatedCost, whyChosen, validates, config) that was broken when the inline NodeCard was replaced with the new ArchDiagram component --- .../src/components/cloudforge/ArchDiagram.tsx | 1058 ++++++++++++++--- .../components/forge/ArchitecturePanel.tsx | 6 +- 2 files changed, 882 insertions(+), 182 deletions(-) diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx index 51528c8..41e279f 100644 --- a/frontend/src/components/cloudforge/ArchDiagram.tsx +++ b/frontend/src/components/cloudforge/ArchDiagram.tsx @@ -1,6 +1,9 @@ 'use client'; +import { useState, useRef, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import type { ForgeArchNode, ForgeArchEdge } from '@/store/forgeStore'; +import { AWS_ICONS, type AwsIconKey } from '@/lib/aws-icons'; /* ── Types ──────────────────────────────────────────────────────────────────── */ @@ -41,6 +44,9 @@ export interface DrawIONode { height?: number; groupColor?: string; layer?: string; + /** Optional per-node config override */ + config?: Record; + description?: string; } export interface DrawIOEdge { @@ -53,6 +59,14 @@ export interface DrawIOEdge { export interface ArchDiagramProps { nodes?: DrawIONode[]; edges?: DrawIOEdge[]; + /** + * When provided, ArchDiagram runs in controlled mode: + * - node clicks call onNodeSelect instead of opening the internal ConfigPanel + * - the internal side panel is suppressed + */ + onNodeSelect?: (nodeId: string | null) => void; + /** Controlled selected node ID — used for the selection ring when in controlled mode */ + selectedNodeId?: string | null; } /* ── AWS Service Config ──────────────────────────────────────────────────────── */ @@ -61,135 +75,353 @@ interface ServiceConfig { bg: string; shape: 'circle' | 'roundedSquare'; abbr: string; - iconContent: string; } +interface NodeConfigData { + description: string; + category: string; + tier: string; + configProps: Array<{ key: string; value: string }>; + useCases: string[]; + docsUrl: string; +} + +const SERVICE_TO_ICON: Record = { + lambda: 'lambda', + s3: 's3', + apigateway: 'apigateway', + sqs: 'sqs', + eventbridge: 'eventbridge', + bedrock: 'bedrock', + neptune: 'neptune', + amplify: 'amplify', + dynamodb: 'dynamodb', + rds: 'rds', + cloudfront: 'cloudfront', + sns: 'sns', + cognito: 'generic', + ecs: 'generic', + stepfunctions: 'generic', + route53: 'generic', + elb: 'generic', + ec2: 'generic', + eks: 'generic', + kinesis: 'generic', + generic: 'generic', +}; + const AWS_SERVICE_CONFIG: Record = { + lambda: { bg: '#FF9900', shape: 'circle', abbr: 'λ' }, + s3: { bg: '#3F8624', shape: 'roundedSquare', abbr: 'S3' }, + apigateway: { bg: '#8C4FFF', shape: 'roundedSquare', abbr: 'API' }, + sqs: { bg: '#FF4F8B', shape: 'roundedSquare', abbr: 'SQS' }, + eventbridge: { bg: '#FF4F8B', shape: 'roundedSquare', abbr: 'EB' }, + bedrock: { bg: '#01A88D', shape: 'roundedSquare', abbr: 'BR' }, + neptune: { bg: '#7B16FF', shape: 'roundedSquare', abbr: 'NP' }, + amplify: { bg: '#FF4F8B', shape: 'roundedSquare', abbr: 'AMP' }, + dynamodb: { bg: '#4053D6', shape: 'roundedSquare', abbr: 'DDB' }, + rds: { bg: '#3F8624', shape: 'roundedSquare', abbr: 'RDS' }, + cloudfront: { bg: '#FF9900', shape: 'roundedSquare', abbr: 'CF' }, + cognito: { bg: '#DD3522', shape: 'roundedSquare', abbr: 'CGN' }, + ecs: { bg: '#FF9900', shape: 'roundedSquare', abbr: 'ECS' }, + sns: { bg: '#FF4F8B', shape: 'roundedSquare', abbr: 'SNS' }, + stepfunctions: { bg: '#FF4F8B', shape: 'roundedSquare', abbr: 'SF' }, + route53: { bg: '#8C4FFF', shape: 'roundedSquare', abbr: 'R53' }, + elb: { bg: '#8C4FFF', shape: 'roundedSquare', abbr: 'ELB' }, + ec2: { bg: '#FF9900', shape: 'roundedSquare', abbr: 'EC2' }, + eks: { bg: '#FF9900', shape: 'roundedSquare', abbr: 'EKS' }, + kinesis: { bg: '#8C4FFF', shape: 'roundedSquare', abbr: 'KNS' }, + generic: { bg: '#545B64', shape: 'roundedSquare', abbr: '?' }, +}; + +const SERVICE_CONFIG_DATA: Partial> = { lambda: { - bg: '#FF9900', - shape: 'circle', - abbr: 'λ', - iconContent: `λ`, + description: 'Run code without provisioning or managing servers. Pay only for compute time.', + category: 'Compute', + tier: 'Serverless', + configProps: [ + { key: 'Runtime', value: 'Node.js 20.x' }, + { key: 'Memory', value: '256 MB' }, + { key: 'Timeout', value: '30s' }, + { key: 'Concurrency', value: 'Unreserved' }, + { key: 'Invocation', value: 'Event-driven' }, + ], + useCases: ['API backends', 'Event processing', 'Data transformation'], + docsUrl: 'https://docs.aws.amazon.com/lambda', }, s3: { - bg: '#3F8624', - shape: 'roundedSquare', - abbr: 'S3', - iconContent: `S3`, + description: 'Object storage built to store and retrieve any amount of data from anywhere.', + category: 'Storage', + tier: 'Managed', + configProps: [ + { key: 'Storage class', value: 'Standard' }, + { key: 'Versioning', value: 'Enabled' }, + { key: 'Encryption', value: 'SSE-S3' }, + { key: 'Access', value: 'Private' }, + { key: 'Lifecycle', value: 'Configured' }, + ], + useCases: ['Static hosting', 'Data lake', 'Backup & restore', 'Media storage'], + docsUrl: 'https://docs.aws.amazon.com/s3', }, apigateway: { - bg: '#8C4FFF', - shape: 'roundedSquare', - abbr: 'API', - iconContent: `</>`, + description: 'Create, publish, and secure APIs at any scale. Supports REST, HTTP, and WebSocket.', + category: 'Networking', + tier: 'Managed', + configProps: [ + { key: 'Type', value: 'REST API' }, + { key: 'Auth', value: 'Cognito User Pool' }, + { key: 'Stage', value: 'prod' }, + { key: 'Throttle', value: '1000 req/s' }, + { key: 'Cache', value: 'Disabled' }, + ], + useCases: ['Microservices gateway', 'Lambda proxy', 'WebSocket APIs'], + docsUrl: 'https://docs.aws.amazon.com/apigateway', }, sqs: { - bg: '#232F3E', - shape: 'roundedSquare', - abbr: 'SQS', - iconContent: `SQS`, + description: 'Fully managed message queuing for microservices and distributed systems.', + category: 'Messaging', + tier: 'Serverless', + configProps: [ + { key: 'Type', value: 'Standard' }, + { key: 'Visibility timeout', value: '30s' }, + { key: 'Message retention', value: '4 days' }, + { key: 'Max message size', value: '256 KB' }, + { key: 'DLQ', value: 'Configured' }, + ], + useCases: ['Task queues', 'Load leveling', 'Decoupling services'], + docsUrl: 'https://docs.aws.amazon.com/sqs', }, eventbridge: { - bg: '#FF4F8B', - shape: 'roundedSquare', - abbr: 'EB', - iconContent: `Event`, + description: 'Serverless event bus that connects app data from your own apps and AWS services.', + category: 'Messaging', + tier: 'Serverless', + configProps: [ + { key: 'Bus', value: 'Default' }, + { key: 'Schedule', value: 'cron(0 1 * * ? *)' }, + { key: 'Target', value: 'Lambda' }, + { key: 'Retry', value: '2 attempts' }, + { key: 'DLQ', value: 'Enabled' }, + ], + useCases: ['Event-driven workflows', 'Scheduled tasks', 'Cross-account events'], + docsUrl: 'https://docs.aws.amazon.com/eventbridge', }, bedrock: { - bg: '#01A88D', - shape: 'roundedSquare', - abbr: 'BR', - iconContent: `Bedrock`, + description: 'Fully managed service for accessing foundation models via a single API.', + category: 'AI / ML', + tier: 'Managed', + configProps: [ + { key: 'Model', value: 'amazon.nova-pro-v1' }, + { key: 'Max tokens', value: '4096' }, + { key: 'Temperature', value: '0.7' }, + { key: 'Top-p', value: '0.9' }, + { key: 'Guardrails', value: 'Enabled' }, + ], + useCases: ['Agents', 'RAG', 'Summarization', 'Code generation'], + docsUrl: 'https://docs.aws.amazon.com/bedrock', }, neptune: { - bg: '#7B16FF', - shape: 'roundedSquare', - abbr: 'NP', - iconContent: `Neptune`, + description: 'Fast, reliable, fully managed graph database for highly connected datasets.', + category: 'Database', + tier: 'Managed', + configProps: [ + { key: 'Engine', value: 'Neptune 1.3' }, + { key: 'Instance', value: 'db.r6g.large' }, + { key: 'Query language', value: 'Gremlin / SPARQL' }, + { key: 'Multi-AZ', value: 'Yes' }, + { key: 'Backup', value: '7 days' }, + ], + useCases: ['Knowledge graphs', 'Fraud detection', 'Recommendation engines'], + docsUrl: 'https://docs.aws.amazon.com/neptune', }, amplify: { - bg: '#FF4F8B', - shape: 'roundedSquare', - abbr: 'AMP', - iconContent: `Amplify`, + description: 'Build full-stack web and mobile apps with AWS. Includes hosting, auth, and data.', + category: 'Frontend / Hosting', + tier: 'Managed', + configProps: [ + { key: 'Framework', value: 'Next.js' }, + { key: 'Branch', value: 'main' }, + { key: 'Build', value: 'Auto-deploy' }, + { key: 'CDN', value: 'CloudFront' }, + { key: 'Custom domain', value: 'Configured' }, + ], + useCases: ['Static hosting', 'SSR apps', 'CI/CD pipelines'], + docsUrl: 'https://docs.aws.amazon.com/amplify', }, dynamodb: { - bg: '#4053D6', - shape: 'roundedSquare', - abbr: 'DDB', - iconContent: `DDB`, + description: 'Serverless, NoSQL, fully managed database with single-digit millisecond performance.', + category: 'Database', + tier: 'Serverless', + configProps: [ + { key: 'Billing mode', value: 'On-demand' }, + { key: 'Replication', value: 'Single-region' }, + { key: 'TTL', value: 'Enabled' }, + { key: 'Streams', value: 'Enabled' }, + { key: 'Encryption', value: 'AWS-owned key' }, + ], + useCases: ['Session stores', 'Leaderboards', 'Real-time apps'], + docsUrl: 'https://docs.aws.amazon.com/dynamodb', }, rds: { - bg: '#3F8624', - shape: 'roundedSquare', - abbr: 'RDS', - iconContent: `RDS`, + description: 'Managed relational database service for PostgreSQL, MySQL, and more.', + category: 'Database', + tier: 'Managed', + configProps: [ + { key: 'Engine', value: 'PostgreSQL 16' }, + { key: 'Instance', value: 'db.t3.medium' }, + { key: 'Storage', value: '100 GB gp3' }, + { key: 'Multi-AZ', value: 'Yes' }, + { key: 'Backup', value: '7 days' }, + ], + useCases: ['OLTP workloads', 'Application databases', 'Microservice backends'], + docsUrl: 'https://docs.aws.amazon.com/rds', }, cloudfront: { - bg: '#FF9900', - shape: 'roundedSquare', - abbr: 'CF', - iconContent: `CF`, + description: 'Fast, highly secure global content delivery network (CDN).', + category: 'Networking', + tier: 'Managed', + configProps: [ + { key: 'Price class', value: 'All Edge Locations' }, + { key: 'Cache policy', value: 'CachingOptimized' }, + { key: 'HTTPS', value: 'Required' }, + { key: 'WAF', value: 'Attached' }, + { key: 'Geo restriction', value: 'None' }, + ], + useCases: ['Static asset delivery', 'API acceleration', 'DDoS protection'], + docsUrl: 'https://docs.aws.amazon.com/cloudfront', }, cognito: { - bg: '#DD3522', - shape: 'roundedSquare', - abbr: 'CGN', - iconContent: `Cognito`, + description: 'Add user sign-up, sign-in, and access control to your apps.', + category: 'Security / Identity', + tier: 'Managed', + configProps: [ + { key: 'Pool type', value: 'User Pool' }, + { key: 'MFA', value: 'Optional TOTP' }, + { key: 'OAuth', value: 'Google, GitHub' }, + { key: 'Password policy', value: 'Strong' }, + { key: 'Token expiry', value: '1 hour' }, + ], + useCases: ['User authentication', 'Social sign-in', 'Token vending'], + docsUrl: 'https://docs.aws.amazon.com/cognito', }, ecs: { - bg: '#FF9900', - shape: 'roundedSquare', - abbr: 'ECS', - iconContent: `ECS`, + description: 'Fully managed container orchestration service. Run Docker containers at scale.', + category: 'Compute', + tier: 'Container', + configProps: [ + { key: 'Launch type', value: 'Fargate' }, + { key: 'CPU', value: '1 vCPU' }, + { key: 'Memory', value: '2 GB' }, + { key: 'Auto-scaling', value: 'Target tracking' }, + { key: 'Service mesh', value: 'App Mesh' }, + ], + useCases: ['Microservices', 'Batch processing', 'Long-running tasks'], + docsUrl: 'https://docs.aws.amazon.com/ecs', }, sns: { - bg: '#FF4F8B', - shape: 'roundedSquare', - abbr: 'SNS', - iconContent: `SNS`, + description: 'Fully managed pub/sub messaging for application-to-person and app-to-app notifications.', + category: 'Messaging', + tier: 'Serverless', + configProps: [ + { key: 'Type', value: 'Standard Topic' }, + { key: 'Protocol', value: 'Email / SQS / Lambda' }, + { key: 'Subscriptions', value: '3' }, + { key: 'Encryption', value: 'SSE-KMS' }, + { key: 'Delivery retry', value: 'Configured' }, + ], + useCases: ['Fan-out messaging', 'Alerts', 'Mobile push notifications'], + docsUrl: 'https://docs.aws.amazon.com/sns', }, stepfunctions: { - bg: '#FF4F8B', - shape: 'roundedSquare', - abbr: 'SF', - iconContent: `Step Fn`, + description: 'Visual workflow service to coordinate distributed applications using state machines.', + category: 'Orchestration', + tier: 'Serverless', + configProps: [ + { key: 'Type', value: 'Standard Workflow' }, + { key: 'Max duration', value: '1 year' }, + { key: 'Logging', value: 'CloudWatch' }, + { key: 'X-Ray', value: 'Enabled' }, + { key: 'Error handling', value: 'Retry + Catch' }, + ], + useCases: ['Workflow orchestration', 'ETL pipelines', 'Saga pattern'], + docsUrl: 'https://docs.aws.amazon.com/step-functions', }, route53: { - bg: '#8C4FFF', - shape: 'roundedSquare', - abbr: 'R53', - iconContent: `R53`, + description: 'Highly available and scalable cloud Domain Name System (DNS) web service.', + category: 'Networking', + tier: 'Managed', + configProps: [ + { key: 'Routing policy', value: 'Latency-based' }, + { key: 'Health checks', value: 'Enabled' }, + { key: 'Failover', value: 'Active-Active' }, + { key: 'DNSSEC', value: 'Enabled' }, + { key: 'TTL', value: '60s' }, + ], + useCases: ['DNS routing', 'Failover', 'Traffic management'], + docsUrl: 'https://docs.aws.amazon.com/route53', }, elb: { - bg: '#8C4FFF', - shape: 'roundedSquare', - abbr: 'ELB', - iconContent: `ELB`, + description: 'Distribute incoming traffic across multiple targets for high availability.', + category: 'Networking', + tier: 'Managed', + configProps: [ + { key: 'Type', value: 'Application Load Balancer' }, + { key: 'Scheme', value: 'Internet-facing' }, + { key: 'Target', value: 'ECS Tasks' }, + { key: 'SSL policy', value: 'ELBSecurityPolicy-TLS13' }, + { key: 'Access logs', value: 'S3 Enabled' }, + ], + useCases: ['Blue/green deploys', 'Multi-zone HA', 'Path-based routing'], + docsUrl: 'https://docs.aws.amazon.com/elasticloadbalancing', }, ec2: { - bg: '#FF9900', - shape: 'roundedSquare', - abbr: 'EC2', - iconContent: `EC2`, + description: 'Resizable compute capacity in the cloud. Launch virtual machines in minutes.', + category: 'Compute', + tier: 'IaaS', + configProps: [ + { key: 'Instance type', value: 't3.medium' }, + { key: 'AMI', value: 'Amazon Linux 2023' }, + { key: 'Storage', value: '30 GB gp3' }, + { key: 'Auto Scaling', value: 'Min 1 / Max 5' }, + { key: 'Placement', value: 'Multi-AZ' }, + ], + useCases: ['Custom runtimes', 'Lift-and-shift', 'Stateful workloads'], + docsUrl: 'https://docs.aws.amazon.com/ec2', }, eks: { - bg: '#FF9900', - shape: 'roundedSquare', - abbr: 'EKS', - iconContent: `EKS`, + description: 'Managed Kubernetes service to run Kubernetes without installing your own cluster.', + category: 'Compute', + tier: 'Container', + configProps: [ + { key: 'Version', value: 'K8s 1.30' }, + { key: 'Node group', value: 'Managed' }, + { key: 'Instance type', value: 'm5.large' }, + { key: 'Nodes', value: '2–10 (auto-scale)' }, + { key: 'Add-ons', value: 'CoreDNS, kube-proxy' }, + ], + useCases: ['Container orchestration', 'Microservices platform', 'ML workloads'], + docsUrl: 'https://docs.aws.amazon.com/eks', }, kinesis: { - bg: '#8C4FFF', - shape: 'roundedSquare', - abbr: 'KNS', - iconContent: `Kinesis`, + description: 'Collect, process, and analyze real-time streaming data at any scale.', + category: 'Streaming', + tier: 'Managed', + configProps: [ + { key: 'Shards', value: '2' }, + { key: 'Retention', value: '24 hours' }, + { key: 'Enhanced fan-out', value: 'Enabled' }, + { key: 'Encryption', value: 'SSE-KMS' }, + { key: 'Consumer', value: 'Lambda' }, + ], + useCases: ['Real-time analytics', 'Log ingestion', 'IoT data streams'], + docsUrl: 'https://docs.aws.amazon.com/kinesis', }, generic: { - bg: '#545B64', - shape: 'roundedSquare', - abbr: '?', - iconContent: `?`, + description: 'AWS service node.', + category: 'AWS', + tier: 'Managed', + configProps: [], + useCases: [], + docsUrl: 'https://aws.amazon.com', }, }; @@ -300,6 +532,354 @@ function hexToRgba(hex: string, alpha: number): string { return `rgba(${r},${g},${b},${alpha})`; } +/* ── Tooltip DOM Component ───────────────────────────────────────────────────── */ + +interface TooltipState { + node: DrawIONode; + x: number; + y: number; +} + +function NodeTooltip({ state }: { state: TooltipState }) { + const service = state.node.service ?? 'generic'; + const config = SERVICE_CONFIG_DATA[service]; + const serviceConf = AWS_SERVICE_CONFIG[service]; + + return ( +
+
+ + {serviceConf.abbr.slice(0, 2)} + + + {state.node.label} + +
+ {state.node.sublabel && ( +
+ {state.node.sublabel} +
+ )} + {config && ( +
+ {config.description} +
+ )} +
+ Click to view config → +
+
+ ); +} + +/* ── Config Side Panel ───────────────────────────────────────────────────────── */ + +interface ConfigPanelProps { + node: DrawIONode; + onClose: () => void; +} + +function ConfigPanel({ node, onClose }: ConfigPanelProps) { + const service = node.service ?? 'generic'; + const serviceConf = AWS_SERVICE_CONFIG[service]; + const configData = SERVICE_CONFIG_DATA[service]; + + // Merge node-level config overrides with defaults + const configProps = [ + ...(configData?.configProps ?? []), + ...Object.entries(node.config ?? {}).map(([key, value]) => ({ key, value })), + ]; + + const tierColors: Record = { + Serverless: '#01A88D', + Managed: '#4053D6', + Container: '#FF9900', + IaaS: '#545B64', + Streaming: '#8C4FFF', + Orchestration: '#FF4F8B', + 'AI / ML': '#01A88D', + 'Security / Identity': '#DD3522', + 'Frontend / Hosting': '#FF4F8B', + Database: '#4053D6', + }; + + const tierColor = tierColors[configData?.tier ?? ''] ?? '#545B64'; + + return ( +
+ {/* Header */} +
+
+
+
+ + {serviceConf.abbr} + +
+
+
+ {node.label} +
+ {node.sublabel && ( +
+ {node.sublabel} +
+ )} +
+
+ +
+ + {/* Category + Tier badges */} +
+ {configData?.category && ( + + {configData.category} + + )} + {configData?.tier && ( + + {configData.tier} + + )} +
+
+ + {/* Scrollable body */} +
+ + {/* Description */} + {configData?.description && ( +

+ {node.description ?? configData.description} +

+ )} + + {/* Config Properties */} + {configProps.length > 0 && ( +
+

+ Configuration +

+
+ {configProps.map(({ key, value }, i) => ( +
+ {key} + + {value} + +
+ ))} +
+
+ )} + + {/* Use Cases */} + {configData?.useCases && configData.useCases.length > 0 && ( +
+

+ Use Cases +

+
    + {configData.useCases.map((uc) => ( +
  • + + {uc} +
  • + ))} +
+
+ )} + + {/* Node ID (useful for debugging / referencing) */} +
+

+ Node ID +

+ + {node.id} + +
+
+ + {/* Footer */} + {configData?.docsUrl && ( + + )} +
+ ); +} + /* ── Sub-components ──────────────────────────────────────────────────────────── */ function GroupContainer({ node }: { node: DrawIONode }) { @@ -344,29 +924,52 @@ function GroupContainer({ node }: { node: DrawIONode }) { ); } -function ServiceNode({ node, iconSize }: { node: DrawIONode; iconSize: number }) { +interface ServiceNodeProps { + node: DrawIONode; + iconSize: number; + isSelected: boolean; + onSelect: (node: DrawIONode) => void; + onHover: (node: DrawIONode, x: number, y: number) => void; + onLeave: () => void; +} + +function ServiceNode({ node, iconSize, isSelected, onSelect, onHover, onLeave }: ServiceNodeProps) { + const cx = node.x + iconSize / 2; + if (node.type === 'user') { - const cx = node.x + iconSize / 2; - const cy = node.y + 16; + const icon = AWS_ICONS.users; + const scale = iconSize / 48; return ( - - - + onSelect(node)} + onMouseEnter={(e) => onHover(node, e.clientX, e.clientY)} + onMouseMove={(e) => onHover(node, e.clientX, e.clientY)} + onMouseLeave={onLeave} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(node); }} + > + {isSelected && ( + + )} + + + + + + onSelect(node)} + onMouseEnter={(e) => onHover(node, e.clientX, e.clientY)} + onMouseMove={(e) => onHover(node, e.clientX, e.clientY)} + onMouseLeave={onLeave} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(node); }} + > + {/* Selection ring */} + {isSelected && ( + + )} + {/* Background shape */} {config.shape === 'circle' ? ( )} - {/* Icon inner content rendered via a nested SVG to scope the coordinate system */} - - - + {/* Real AWS icon path scaled to fit iconSize */} + + + + + {/* Primary label */} (null); + const [tooltip, setTooltip] = useState(null); + const [mounted, setMounted] = useState(false); + const tooltipTimerRef = useRef | null>(null); + + useEffect(() => { setMounted(true); }, []); + const ICON_SIZE = 56; const PAD = 60; + const selectedNodeId = isControlled ? (controlledSelectedNodeId ?? null) : internalSelectedId; + // ConfigPanel only shown in uncontrolled mode (parent provides its own inspector) + const selectedNode = !isControlled && selectedNodeId + ? nodes.find((n) => n.id === selectedNodeId) ?? null + : null; + + const handleSelect = useCallback((node: DrawIONode) => { + if (isControlled) { + onNodeSelect(node.id === (controlledSelectedNodeId ?? null) ? null : node.id); + } else { + setInternalSelectedId((prev) => (prev === node.id ? null : node.id)); + } + setTooltip(null); + }, [isControlled, onNodeSelect, controlledSelectedNodeId]); + + const handleHover = useCallback((node: DrawIONode, x: number, y: number) => { + if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current); + setTooltip((prev) => (prev?.node.id === node.id ? { node, x, y } : prev)); + tooltipTimerRef.current = setTimeout(() => { + setTooltip({ node, x, y }); + }, 250); + }, []); + + const handleLeave = useCallback(() => { + if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current); + setTooltip(null); + }, []); + const serviceNodes = nodes.filter( (n) => n.type !== 'group' && n.type !== 'sectionLabel', ); @@ -542,7 +1209,6 @@ export default function ArchDiagram({ nodes: propNodes, edges: propEdges }: Arch const svgWidth = Math.max(maxX + PAD, 800); const svgHeight = Math.max(maxY + PAD, 600); - /* Accessible aria label */ const nodeMap = new Map(nodes.map((n) => [n.id, n])); const ariaLabel = [ 'AWS architecture diagram.', @@ -567,74 +1233,106 @@ export default function ArchDiagram({ nodes: propNodes, edges: propEdges }: Arch style={{ width: '100%', height: '100%', - overflow: 'auto', + display: 'flex', + overflow: 'hidden', background: '#FAFAFA', borderRadius: '12px', + position: 'relative', }} - role="img" aria-label={ariaLabel} > - + + + {/* Config side panel */} + {selectedNode && selectedNode.type !== 'sectionLabel' && ( + setInternalSelectedId(null)} + /> + )} + + {/* Tooltip — portalled to document.body to escape any parent transform/overflow */} + {mounted && tooltip && !selectedNodeId && + createPortal(, document.body) + } ); } diff --git a/frontend/src/components/forge/ArchitecturePanel.tsx b/frontend/src/components/forge/ArchitecturePanel.tsx index 10e34d6..f0ecc45 100644 --- a/frontend/src/components/forge/ArchitecturePanel.tsx +++ b/frontend/src/components/forge/ArchitecturePanel.tsx @@ -388,8 +388,8 @@ export default function ArchitecturePanel() { ) ?? null : null; - const handleNodeClick = useCallback((id: string) => { - setSelectedNodeId((prev) => (prev === id ? null : id)); + const handleNodeClick = useCallback((id: string | null) => { + setSelectedNodeId(id); }, []); const handleCloseInspector = useCallback(() => { @@ -653,6 +653,8 @@ export default function ArchitecturePanel() { )} From 2c2b86792a96a556ebbac1a31ff8d7a637bf4209 Mon Sep 17 00:00:00 2001 From: Gunbir Singh Date: Sat, 21 Mar 2026 20:38:11 -0700 Subject: [PATCH 3/9] feat(arch): update diagram nodes to match reference image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Observability node inside Agentcore group - Add Internet/Globe node (transparent bg, dark icon) with dashed edge to Bedrock KB - Add career resources S3 bucket (top-right, from Career Exploration Agent) - Add Job Postings S3 bucket (right, from Neptune Graph) - Add Student Information DynamoDB (inside Tools group, shared by agents + batch lambdas) - Rename s3 → s3_resume to clarify resume storage bucket - Add internet AWSServiceId with iconColor support for transparent-bg icons - Update EventBridge sublabels to full description from reference - Add edge: Job Notification Result label on Amplify → API Gateway - Add edges: routing → observability, lambda_pq/lambda_dn → student_info --- .../src/components/cloudforge/ArchDiagram.tsx | 168 ++++++++++++------ 1 file changed, 114 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx index 41e279f..761ccb0 100644 --- a/frontend/src/components/cloudforge/ArchDiagram.tsx +++ b/frontend/src/components/cloudforge/ArchDiagram.tsx @@ -30,6 +30,7 @@ export type AWSServiceId = | 'ec2' | 'eks' | 'kinesis' + | 'internet' | 'generic'; export interface DrawIONode { @@ -75,6 +76,8 @@ interface ServiceConfig { bg: string; shape: 'circle' | 'roundedSquare'; abbr: string; + /** Icon path fill colour — defaults to 'white'. Use '#333' for transparent-bg icons. */ + iconColor?: string; } interface NodeConfigData { @@ -107,6 +110,7 @@ const SERVICE_TO_ICON: Record = { ec2: 'generic', eks: 'generic', kinesis: 'generic', + internet: 'internet', generic: 'generic', }; @@ -130,8 +134,9 @@ const AWS_SERVICE_CONFIG: Record = { elb: { bg: '#8C4FFF', shape: 'roundedSquare', abbr: 'ELB' }, ec2: { bg: '#FF9900', shape: 'roundedSquare', abbr: 'EC2' }, eks: { bg: '#FF9900', shape: 'roundedSquare', abbr: 'EKS' }, - kinesis: { bg: '#8C4FFF', shape: 'roundedSquare', abbr: 'KNS' }, - generic: { bg: '#545B64', shape: 'roundedSquare', abbr: '?' }, + kinesis: { bg: '#8C4FFF', shape: 'roundedSquare', abbr: 'KNS' }, + internet: { bg: 'none', shape: 'roundedSquare', abbr: 'NET', iconColor: '#444' }, + generic: { bg: '#545B64', shape: 'roundedSquare', abbr: '?' }, }; const SERVICE_CONFIG_DATA: Partial> = { @@ -415,6 +420,17 @@ const SERVICE_CONFIG_DATA: Partial> = { useCases: ['Real-time analytics', 'Log ingestion', 'IoT data streams'], docsUrl: 'https://docs.aws.amazon.com/kinesis', }, + internet: { + description: 'External internet traffic entering the system.', + category: 'External', + tier: 'External', + configProps: [ + { key: 'Protocol', value: 'HTTPS' }, + { key: 'Auth', value: 'API Key / OAuth' }, + ], + useCases: ['External API consumers', 'Public web traffic', 'Third-party integrations'], + docsUrl: 'https://aws.amazon.com/architecture', + }, generic: { description: 'AWS service node.', category: 'AWS', @@ -428,60 +444,104 @@ const SERVICE_CONFIG_DATA: Partial> = { /* ── Default Mock Data ───────────────────────────────────────────────────────── */ const DEFAULT_NODES: DrawIONode[] = [ - { id: 'sec1', type: 'sectionLabel', x: 20, y: 10, label: 'Live Search' }, - { id: 'users', type: 'user', x: 60, y: 60, label: 'Students' }, - { id: 'amplify', type: 'service', x: 200, y: 130, label: 'AWS Amplify', service: 'amplify' }, - { id: 'lambda1', type: 'service', x: 340, y: 60, label: 'Lambda', sublabel: 'Save Resume', service: 'lambda' }, - { id: 's3', type: 'service', x: 480, y: 20, label: 'Amazon S3', service: 's3' }, - { id: 'lambda2', type: 'service', x: 340, y: 140, label: 'Lambda', sublabel: 'Resume Parser', service: 'lambda' }, - { id: 'bedrock1', type: 'service', x: 490, y: 130, label: 'Nova Pro', service: 'bedrock' }, - { id: 'lambda3', type: 'service', x: 340, y: 220, label: 'Lambda', sublabel: 'Save Profile', service: 'lambda' }, - { id: 'apigw', type: 'service', x: 340, y: 300, label: 'API Gateway', service: 'apigateway' }, - { id: 'lambda4', type: 'service', x: 200, y: 360, label: 'Lambda', service: 'lambda' }, - { id: 'g_agentcore', type: 'group', x: 30, y: 440, width: 560, height: 280, label: 'Agentcore', groupColor: '#8C4FFF' }, - { id: 'g_runtime', type: 'group', x: 140, y: 470, width: 360, height: 240, label: 'Runtime', groupColor: '#999' }, - { id: 'routing', type: 'service', x: 170, y: 530, label: 'Routing Agent', service: 'bedrock' }, - { id: 'career', type: 'service', x: 330, y: 490, label: 'Career Exploration Agent', service: 'bedrock' }, - { id: 'jobsearch', type: 'service', x: 330, y: 600, label: 'Job Search Agent', service: 'bedrock' }, - { id: 'memory', type: 'service', x: 60, y: 510, label: 'Memory', service: 'bedrock' }, - { id: 'g_tools', type: 'group', x: 620, y: 340, width: 300, height: 360, label: 'Tools', groupColor: '#8C4FFF' }, - { id: 'bedrock_kb', type: 'service', x: 640, y: 390, label: 'Bedrock Knowledge Base', service: 'bedrock' }, - { id: 's3_vector', type: 'service', x: 780, y: 390, label: 'S3 Vector store', service: 's3' }, - { id: 'graphrag', type: 'service', x: 640, y: 520, label: 'Graph RAG', service: 'bedrock' }, - { id: 'neptune', type: 'service', x: 780, y: 520, label: 'Neptune Graph', service: 'neptune' }, - { id: 'sec2', type: 'sectionLabel', x: 20, y: 760, label: 'Job Search Batch Process' }, - { id: 'eb1', type: 'service', x: 80, y: 860, label: 'EventBridge', sublabel: 'trigger 1am', service: 'eventbridge' }, - { id: 'lambda_q', type: 'service', x: 80, y: 750, label: 'Lambda', sublabel: 'Add to Queue', service: 'lambda' }, - { id: 'sqs', type: 'service', x: 200, y: 800, label: 'Amazon SQS', service: 'sqs' }, - { id: 'lambda_pq', type: 'service', x: 200, y: 700, label: 'Lambda', sublabel: 'Process Queue', service: 'lambda' }, - { id: 'sec3', type: 'sectionLabel', x: 440, y: 760, label: 'Communication Batch Process' }, - { id: 'eb2', type: 'service', x: 450, y: 860, label: 'EventBridge', sublabel: 'trigger 9am', service: 'eventbridge' }, - { id: 'lambda_dn', type: 'service', x: 450, y: 750, label: 'Lambda', sublabel: 'Send Daily Notifications', service: 'lambda' }, - { id: 'ses', type: 'service', x: 620, y: 720, label: 'Simple Email Service', service: 'sns' }, - { id: 'pinpoint', type: 'service', x: 620, y: 820, label: 'End User Messaging', service: 'sns' }, + // ── Section labels ───────────────────────────────────────────────────────── + { id: 'sec1', type: 'sectionLabel', x: 20, y: 10, label: 'Live Search' }, + { id: 'sec2', type: 'sectionLabel', x: 20, y: 840, label: 'Job Search Batch Process' }, + { id: 'sec3', type: 'sectionLabel', x: 460, y: 840, label: 'Communication Batch Process' }, + + // ── Live Search ──────────────────────────────────────────────────────────── + { id: 'users', type: 'user', x: 60, y: 70, label: 'Students' }, + { id: 'amplify', type: 'service', x: 200, y: 140, label: 'AWS Amplify', service: 'amplify' }, + { id: 'lambda1', type: 'service', x: 340, y: 60, label: 'Lambda', sublabel: 'Save Resume', service: 'lambda' }, + { id: 's3_resume',type: 'service', x: 480, y: 20, label: 'Amazon S3', service: 's3' }, + { id: 'lambda2', type: 'service', x: 340, y: 150, label: 'Lambda', sublabel: 'Resume Parser', service: 'lambda' }, + { id: 'bedrock1', type: 'service', x: 490, y: 140, label: 'Nova Pro', service: 'bedrock' }, + { id: 'lambda3', type: 'service', x: 340, y: 240, label: 'Lambda', sublabel: 'Save Profile', service: 'lambda' }, + { id: 'apigw', type: 'service', x: 340, y: 325, label: 'API Gateway', service: 'apigateway' }, + { id: 'lambda4', type: 'service', x: 200, y: 390, label: 'Lambda', service: 'lambda' }, + + // ── Agentcore group ──────────────────────────────────────────────────────── + { id: 'g_agentcore', type: 'group', x: 28, y: 462, width: 600, height: 320, label: 'Agentcore', groupColor: '#8C4FFF' }, + { id: 'g_runtime', type: 'group', x: 150, y: 492, width: 400, height: 270, label: 'Runtime', groupColor: '#999' }, + + { id: 'memory', type: 'service', x: 55, y: 520, label: 'Memory', service: 'bedrock' }, + { id: 'observability',type: 'service', x: 55, y: 665, label: 'Observability', service: 'generic' }, + { id: 'routing', type: 'service', x: 185, y: 575, label: 'Routing Agent', service: 'bedrock' }, + { id: 'career', type: 'service', x: 380, y: 510, label: 'Career Exploration Agent', service: 'bedrock' }, + { id: 'jobsearch', type: 'service', x: 380, y: 650, label: 'Job Search Agent', service: 'bedrock' }, + + // ── Internet (globe) — above Tools ──────────────────────────────────────── + { id: 'internet', type: 'service', x: 710, y: 250, label: 'Internet', service: 'internet' }, + + // ── Tools group ──────────────────────────────────────────────────────────── + { id: 'g_tools', type: 'group', x: 660, y: 370, width: 320, height: 400, label: 'Tools', groupColor: '#8C4FFF' }, + + { id: 'bedrock_kb', type: 'service', x: 680, y: 420, label: 'Bedrock Knowledge Base', service: 'bedrock' }, + { id: 's3_vector', type: 'service', x: 840, y: 420, label: 'S3 Vector store', service: 's3' }, + { id: 'graphrag', type: 'service', x: 680, y: 560, label: 'Graph RAG', service: 'bedrock' }, + { id: 'neptune', type: 'service', x: 840, y: 560, label: 'Neptune Graph', service: 'neptune' }, + { id: 'student_info', type: 'service', x: 760, y: 680, label: 'Student Information', service: 'dynamodb' }, + + // ── Outside Tools (right side) ───────────────────────────────────────────── + { id: 'career_res', type: 'service', x: 1020, y: 300, label: 'career resources', service: 's3' }, + { id: 'job_postings', type: 'service', x: 1020, y: 550, label: 'Job Postings', service: 's3' }, + + // ── Job Search Batch Process ─────────────────────────────────────────────── + { id: 'lambda_pq', type: 'service', x: 170, y: 870, label: 'Lambda', sublabel: 'Process Queue', service: 'lambda' }, + { id: 'sqs', type: 'service', x: 170, y: 970, label: 'Amazon SQS', service: 'sqs' }, + { id: 'lambda_q', type: 'service', x: 55, y: 970, label: 'Lambda', sublabel: 'Add to Queue', service: 'lambda' }, + { id: 'eb1', type: 'service', x: 55, y: 1070, label: 'EventBridge', sublabel: 'trigger 1am everyday (Time Configurable)', service: 'eventbridge' }, + + // ── Communication Batch Process ──────────────────────────────────────────── + { id: 'lambda_dn', type: 'service', x: 490, y: 870, label: 'Lambda', sublabel: 'Send Daily Notifications', service: 'lambda' }, + { id: 'ses', type: 'service', x: 680, y: 850, label: 'Simple Email Service', service: 'sns' }, + { id: 'pinpoint', type: 'service', x: 680, y: 950, label: 'End User Messaging', service: 'sns' }, + { id: 'eb2', type: 'service', x: 490, y: 1070, label: 'EventBridge', sublabel: 'trigger 9am everyday (Time Configurable)', service: 'eventbridge' }, ]; const DEFAULT_EDGES: DrawIOEdge[] = [ - { from: 'users', to: 'amplify' }, - { from: 'amplify', to: 'lambda1', label: 'Save Resume' }, - { from: 'amplify', to: 'lambda2', label: 'S3 Path' }, - { from: 'amplify', to: 'lambda3', label: 'Save Profile' }, - { from: 'amplify', to: 'apigw', label: 'Job Notification' }, - { from: 'lambda1', to: 's3' }, - { from: 'lambda2', to: 'bedrock1', label: 'Resume Parser' }, - { from: 'amplify', to: 'lambda4' }, - { from: 'lambda4', to: 'routing' }, - { from: 'routing', to: 'career' }, - { from: 'routing', to: 'jobsearch' }, - { from: 'routing', to: 'memory' }, - { from: 'career', to: 'bedrock_kb' }, - { from: 'jobsearch', to: 'graphrag' }, - { from: 'bedrock_kb', to: 's3_vector' }, - { from: 'graphrag', to: 'neptune' }, - { from: 'eb1', to: 'lambda_q' }, - { from: 'lambda_q', to: 'sqs' }, - { from: 'sqs', to: 'lambda_pq' }, - { from: 'eb2', to: 'lambda_dn' }, + // Live Search + { from: 'users', to: 'amplify' }, + { from: 'amplify', to: 'lambda1', label: 'Save Resume' }, + { from: 'amplify', to: 'lambda2', label: 'S3 Path' }, + { from: 'amplify', to: 'lambda3', label: 'Save Profile' }, + { from: 'amplify', to: 'apigw', label: 'Job Notification Result' }, + { from: 'lambda1', to: 's3_resume' }, + { from: 'lambda2', to: 'bedrock1', label: 'Resume Parser' }, + { from: 'amplify', to: 'lambda4' }, + { from: 'lambda4', to: 'routing' }, + + // Agentcore routing + { from: 'routing', to: 'career' }, + { from: 'routing', to: 'jobsearch' }, + { from: 'routing', to: 'memory' }, + { from: 'routing', to: 'observability' }, + + // Tools connections + { from: 'career', to: 'bedrock_kb' }, + { from: 'jobsearch', to: 'graphrag' }, + { from: 'bedrock_kb', to: 's3_vector' }, + { from: 'graphrag', to: 'neptune' }, + + // Right-side external nodes + { from: 'career', to: 'career_res' }, + { from: 'neptune', to: 'job_postings' }, + + // Internet (dashed — external traffic into Tools) + { from: 'internet', to: 'bedrock_kb', dashed: true }, + + // Student Information (shared store) + { from: 'jobsearch', to: 'student_info' }, + { from: 'lambda_pq', to: 'student_info' }, + { from: 'lambda_dn', to: 'student_info' }, + + // Job Search Batch + { from: 'eb1', to: 'lambda_q' }, + { from: 'lambda_q', to: 'sqs' }, + { from: 'sqs', to: 'lambda_pq' }, + + // Communication Batch + { from: 'eb2', to: 'lambda_dn' }, { from: 'lambda_dn', to: 'ses' }, { from: 'lambda_dn', to: 'pinpoint' }, ]; @@ -1036,7 +1096,7 @@ function ServiceNode({ node, iconSize, isSelected, onSelect, onHover, onLeave }: {/* Real AWS icon path scaled to fit iconSize */} - + {/* Primary label */} From cbb6bc53bfaf8f7ead9cdf7488e74a645dd3e096 Mon Sep 17 00:00:00 2001 From: Gunbir Singh Date: Sat, 21 Mar 2026 22:51:25 -0700 Subject: [PATCH 4/9] feat(build): add Architecture/Files tab switcher with node-to-file navigation + audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BuildPanel: global "Files | Architecture" toggle; Architecture view embeds ArchDiagram in controlled mode with BuildNodeInspector - Node-to-file navigation: click a node → "View [file] →" opens its code file; falls back to main.tf for resources without a dedicated file - GeneratedFile: add optional nodeId field; seed mock files with node associations (lambda.tf, index.ts → lambda; jwt.ts → secrets; rds.tf → rds) - Fix updateFileStatus/updateNodeDeployStatus returning {} on miss — now return state - Remove void remaining dead code; drop unused _node param from handleViewCode - Remove line highlight feature entirely --- frontend/src/components/forge/BuildPanel.tsx | 880 ++++++++++++------- frontend/src/lib/forge-agents.ts | 172 ++-- frontend/src/store/forgeStore.ts | 7 +- 3 files changed, 636 insertions(+), 423 deletions(-) diff --git a/frontend/src/components/forge/BuildPanel.tsx b/frontend/src/components/forge/BuildPanel.tsx index c1770db..74e8a9a 100644 --- a/frontend/src/components/forge/BuildPanel.tsx +++ b/frontend/src/components/forge/BuildPanel.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { FileCode2 } from 'lucide-react'; import { useForgeStore } from '@/store/forgeStore'; import { runAgent3, MOCK_ARCH_NODES, MOCK_ARCH_EDGES } from '@/lib/forge-agents'; -import type { GeneratedFile } from '@/store/forgeStore'; +import ArchDiagram, { convertForgeNodes, convertForgeEdges } from '@/components/cloudforge/ArchDiagram'; +import type { GeneratedFile, ForgeArchNode } from '@/store/forgeStore'; // ── Syntax tokenizer ────────────────────────────────────────────────────────── @@ -15,31 +16,20 @@ function tokenizeLine(content: string, lang: string): React.ReactNode { } if (lang === 'hcl' || lang === 'terraform') { - // Comments if (/^\s*(#|\/\/)/.test(content)) { return {content}; } - - // Tokenize HCL inline const segments: React.ReactNode[] = []; let remaining = content; let key = 0; - const hclKeywordRe = /\b(resource|provider|terraform|output|variable|module|data|locals|backend|required_providers|required_version)\b/; - const stringRe = /"([^"]*)"/g; const accentRe = /(?:resource|module)\s+"[^"]*"\s+"([^"]+)"/; - - // Check for accent identifier (resource/module second label) const accentMatch = accentRe.exec(content); const accentName = accentMatch ? accentMatch[1] : null; - - // Build token list by scanning let i = 0; const chars = content; let buf = ''; - while (i < chars.length) { - // String literal if (chars[i] === '"') { if (buf) { const kwMatch = hclKeywordRe.exec(buf); @@ -55,28 +45,19 @@ function tokenizeLine(content: string, lang: string): React.ReactNode { } buf = ''; } - // Collect string including closing quote let strContent = '"'; i++; - while (i < chars.length && chars[i] !== '"') { - strContent += chars[i]; - i++; - } + while (i < chars.length && chars[i] !== '"') { strContent += chars[i]; i++; } strContent += '"'; i++; - // Check if this string value matches the accent name const innerVal = strContent.slice(1, -1); if (accentName && innerVal === accentName) { segments.push({strContent}); } else { segments.push({strContent}); } - } else { - buf += chars[i]; - i++; - } + } else { buf += chars[i]; i++; } } - if (buf) { const kwMatch = hclKeywordRe.exec(buf); if (kwMatch) { @@ -90,25 +71,19 @@ function tokenizeLine(content: string, lang: string): React.ReactNode { segments.push({buf}); } } - return <>{segments}; } - // TypeScript / JavaScript if (lang === 'typescript' || lang === 'javascript' || lang === 'ts' || lang === 'js') { - // Comments if (/^\s*(\/\/|\/\*)/.test(content)) { return {content}; } - const segments: React.ReactNode[] = []; let key = 0; let i = 0; const chars = content; let buf = ''; - const tsKeywordRe = /\b(import|export|async|await|const|let|var|function|return|if|else|from|type|interface|class|extends|implements|new|typeof|keyof|readonly|default|as|of|for|while|try|catch|finally|throw|in)\b/; - const flushBuf = () => { if (!buf) return; const kwMatch = tsKeywordRe.exec(buf); @@ -120,28 +95,21 @@ function tokenizeLine(content: string, lang: string): React.ReactNode { segments.push({kw}); if (after) segments.push({after}); } else { - // Check for type annotation pattern (: TypeName) const typeRe = /:\s*[A-Z][A-Za-z<>\[\]|&]+/g; let last = 0; let tm: RegExpExecArray | null; let typed = false; while ((tm = typeRe.exec(buf)) !== null) { typed = true; - if (tm.index > last) { - segments.push({buf.slice(last, tm.index)}); - } + if (tm.index > last) segments.push({buf.slice(last, tm.index)}); segments.push({tm[0]}); last = tm.index + tm[0].length; } - if (typed && last < buf.length) { - segments.push({buf.slice(last)}); - } else if (!typed) { - segments.push({buf}); - } + if (typed && last < buf.length) segments.push({buf.slice(last)}); + else if (!typed) segments.push({buf}); } buf = ''; }; - while (i < chars.length) { if (chars[i] === '"' || chars[i] === "'" || chars[i] === '`') { flushBuf(); @@ -149,29 +117,18 @@ function tokenizeLine(content: string, lang: string): React.ReactNode { let strContent = quote; i++; while (i < chars.length && chars[i] !== quote) { - if (chars[i] === '\\') { - strContent += chars[i]; - i++; - } - if (i < chars.length) { - strContent += chars[i]; - i++; - } + if (chars[i] === '\\') { strContent += chars[i]; i++; } + if (i < chars.length) { strContent += chars[i]; i++; } } strContent += quote; i++; segments.push({strContent}); - } else { - buf += chars[i]; - i++; - } + } else { buf += chars[i]; i++; } } flushBuf(); - return <>{segments}; } - // Fallback return {content}; } @@ -181,6 +138,341 @@ function getFileContent(file: GeneratedFile): string { return file.lines.map((l) => l.content).join('\n'); } +// ── View tab bar ────────────────────────────────────────────────────────────── + +type BuildView = 'files' | 'architecture'; + +function ViewTabBar({ + active, + onChange, +}: { + active: BuildView; + onChange: (v: BuildView) => void; +}) { + const tabs: { id: BuildView; label: string }[] = [ + { id: 'files', label: 'Files' }, + { id: 'architecture', label: 'Architecture' }, + ]; + + return ( +
+ {tabs.map((tab) => { + const isActive = tab.id === active; + return ( + + ); + })} +
+ ); +} + +// ── Node inspector for Build — includes "View code" button ──────────────────── + +const VALIDATES_CHIP_COLORS: Record = { + gateway: 'rgba(45,212,191,0.15)', + compute: 'rgba(45,212,191,0.15)', + cache: 'rgba(245,158,11,0.15)', + storage: 'rgba(52,211,153,0.15)', + auth: 'rgba(167,139,250,0.15)', + queue: 'rgba(45,212,191,0.12)', +}; + +interface BuildNodeInspectorProps { + node: ForgeArchNode | null; + associatedFile: { id: string; name: string } | null; + onClose: () => void; + onViewCode: (fileId: string) => void; +} + +function InspectorRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

+ {label} +

+ {children} +
+ ); +} + +function BuildNodeInspector({ node, associatedFile, onClose, onViewCode }: BuildNodeInspectorProps) { + return ( + + {node && ( +
+ {/* Header */} +
+

+ {node.label} +

+ +
+ + {/* View associated code file — primary CTA */} + associatedFile && onViewCode(associatedFile.id)} + disabled={!associatedFile} + whileHover={associatedFile ? { scale: 1.02 } : {}} + whileTap={associatedFile ? { scale: 0.98 } : {}} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + width: '100%', + padding: '9px 12px', + marginBottom: 18, + background: associatedFile ? 'var(--lp-accent-dim)' : 'var(--lp-elevated)', + border: `0.5px solid ${associatedFile ? 'rgba(45,212,191,0.35)' : 'var(--lp-border)'}`, + borderRadius: 8, + cursor: associatedFile ? 'pointer' : 'default', + fontFamily: 'var(--font-jetbrains-mono), monospace', + fontSize: 11, + color: associatedFile ? 'var(--lp-accent)' : 'var(--lp-text-hint)', + textAlign: 'left', + }} + aria-label={associatedFile ? `View ${associatedFile.name}` : 'No associated code file'} + > + + + {associatedFile ? `View ${associatedFile.name}` : 'No dedicated code file'} + + {associatedFile && } + + + {/* Terraform resource */} + + + {node.terraformResource} + + + + {/* Estimated cost */} + + + {node.estimatedCost} + + + + {/* Config */} + +
+ {Object.entries(node.config).map(([key, value]) => ( +
+ {key}: + + {value} + +
+ ))} +
+
+ + {/* Why chosen */} + +

+ {node.whyChosen} +

+
+ + {/* Validates */} +
+

+ Validates +

+
+ {node.validates.map((constraint) => ( + + {constraint} + + ))} +
+
+
+ )} +
+ ); +} + +// ── Architecture view ───────────────────────────────────────────────────────── + +interface ArchViewProps { + archNodes: ForgeArchNode[]; + archEdges: Array<{ from: string; to: string }>; + generatedFiles: Record; + selectedNodeId: string | null; + onNodeSelect: (id: string | null) => void; + onViewCode: (fileId: string) => void; +} + +function ArchView({ archNodes, archEdges, generatedFiles, selectedNodeId, onNodeSelect, onViewCode }: ArchViewProps) { + const selectedNode = selectedNodeId + ? archNodes.find((n) => n.id === selectedNodeId) ?? null + : null; + + // Find the most relevant associated file for the selected node + const findAssociatedFile = (nodeId: string): { id: string; name: string } | null => { + const allFiles = Object.values(generatedFiles); + // Prefer a dedicated code file (non-main.tf) for the node + const dedicated = allFiles.find((f) => f.nodeId === nodeId && f.name !== 'main.tf'); + if (dedicated) return { id: dedicated.id, name: dedicated.name }; + // Fall back to any file with matching nodeId + const any = allFiles.find((f) => f.nodeId === nodeId); + if (any) return { id: any.id, name: any.name }; + // Last resort: stack definition (main.tf) + const mainFile = allFiles.find((f) => f.name === 'main.tf'); + return mainFile ? { id: mainFile.id, name: mainFile.name } : null; + }; + + const associatedFile = selectedNode ? findAssociatedFile(selectedNode.id) : null; + + return ( +
+
+ {/* Header hint */} +

+ Click any resource to inspect its config and view the associated code file. +

+ + {/* Diagram */} +
+ +
+
+ + {/* Node inspector */} + onNodeSelect(null)} + onViewCode={onViewCode} + /> +
+ ); +} + // ── BuildPanel ──────────────────────────────────────────────────────────────── export default function BuildPanel() { @@ -200,11 +492,16 @@ export default function BuildPanel() { closeFile, } = useForgeStore(); + const [activeView, setActiveView] = useState('files'); + const [selectedNodeId, setSelectedNodeId] = useState(null); const agentRan = useRef(false); const activityFilesRef = useRef>([]); const buildStatus = stageStatus.build; + const archNodes = architectureData?.nodes ?? MOCK_ARCH_NODES; + const archEdges = architectureData?.edges ?? MOCK_ARCH_EDGES; + const handleCopy = useCallback(() => { if (!activeFile) return; const file = generatedFiles[activeFile]; @@ -225,20 +522,24 @@ export default function BuildPanel() { URL.revokeObjectURL(url); }, [activeFile, generatedFiles]); + const handleViewCode = useCallback((fileId: string) => { + openFile(fileId); + setActiveView('files'); + setSelectedNodeId(null); + }, [openFile]); + useEffect(() => { if (agentRan.current) return; if (buildStatus !== 'processing' && buildStatus !== 'locked') return; agentRan.current = true; - // Initial chat message addChatMessage('build', { id: `agent3-start-${Date.now()}`, role: 'agent', content: 'Agent 3 is generating Terraform and application code from your architecture…', }); - // Initial activity card message addChatMessage('build', { id: `agent3-activity-${Date.now()}`, role: 'agent', @@ -255,13 +556,10 @@ export default function BuildPanel() { runAgent3(archData, { onFileReady: (file: GeneratedFile) => { addGeneratedFile(file); - activityFilesRef.current = [ ...activityFilesRef.current, { id: file.id, name: file.name, status: file.status }, ]; - - // Chat message per completed file addChatMessage('build', { id: `agent3-file-${file.id}-${Date.now()}`, role: 'agent', @@ -304,7 +602,6 @@ export default function BuildPanel() { const isDone = buildProgress >= buildTotal && buildTotal > 0 && buildStatus === 'done'; const pct = buildTotal > 0 ? (buildProgress / buildTotal) * 100 : 0; - const activeFileData = activeFile ? generatedFiles[activeFile] : null; return ( @@ -317,9 +614,9 @@ export default function BuildPanel() { background: 'var(--lp-bg)', overflow: 'hidden', }} - aria-label="Build panel — code editor" + aria-label="Build panel" > - {/* ── Section 1: Agent progress bar ─────────────────────────────────── */} + {/* ── Progress bar (during generation) ──────────────────────────────── */} {!isDone && ( - {/* Spinning amber dot */} )} - {/* ── Section 2: Editor tab bar ──────────────────────────────────────── */} -
- - {openFiles.map((id) => { - const file = generatedFiles[id]; - if (!file) return null; - const isActive = id === activeFile; - return ( - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openFile(id); - } - }} - onClick={() => openFile(id)} - style={{ - display: 'flex', - alignItems: 'center', - gap: '6px', - padding: '0 12px', - height: '100%', - cursor: 'pointer', - flexShrink: 0, - background: isActive ? 'var(--lp-elevated)' : 'transparent', - borderBottom: isActive - ? '1.5px solid var(--lp-accent)' - : '1.5px solid transparent', - borderRight: '0.5px solid var(--lp-border)', - userSelect: 'none', - }} - > - - {file.name} - - - - ); - })} - -
+ {/* ── Global view tab bar ────────────────────────────────────────────── */} + + + {/* ── Architecture view ──────────────────────────────────────────────── */} + {activeView === 'architecture' && ( + + )} - {/* ── Section 3: Editor or empty state ──────────────────────────────── */} - {openFiles.length === 0 || !activeFileData ? ( -
-
- ) : ( -
- {/* Action buttons */} + {/* ── Files view ─────────────────────────────────────────────────────── */} + {activeView === 'files' && ( +
+ {/* File editor tab bar */}
- - + + {openFiles.map((id) => { + const file = generatedFiles[id]; + if (!file) return null; + const isActive = id === activeFile; + return ( + { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(id); } + }} + onClick={() => { openFile(id); }} + style={{ + display: 'flex', alignItems: 'center', gap: 6, + padding: '0 12px', height: '100%', + cursor: 'pointer', flexShrink: 0, + background: isActive ? 'var(--lp-elevated)' : 'transparent', + borderBottom: isActive ? '1.5px solid var(--lp-accent)' : '1.5px solid transparent', + borderRight: '0.5px solid var(--lp-border)', + userSelect: 'none', + }} + > + + {file.name} + + + + ); + })} +
- {/* Code view */} -
- {/* Line numbers */} + {/* Code editor or empty state */} + {openFiles.length === 0 || !activeFileData ? ( + ) : ( +
+ {/* Action buttons */} +
+ + +
- {/* Code column */} -
- {activeFileData.lines.map((line, idx) => ( + {/* Code view */} +
+ {/* Line numbers */} - ))} + {/* Code column */} +
+ {activeFileData.lines.map((line, idx) => ( +
+ {tokenizeLine(line.content, activeFileData.lang)} +
+ ))} +
+
-
+ )}
)} diff --git a/frontend/src/lib/forge-agents.ts b/frontend/src/lib/forge-agents.ts index 22b8b01..501131c 100644 --- a/frontend/src/lib/forge-agents.ts +++ b/frontend/src/lib/forge-agents.ts @@ -209,24 +209,24 @@ const MOCK_FILES: GeneratedFile[] = [ lang: 'hcl', status: 'new', lines: [ - { content: 'terraform {', highlight: false }, - { content: ' required_version = ">= 1.6"', highlight: false }, - { content: ' required_providers {', highlight: false }, - { content: ' aws = {', highlight: false }, - { content: ' source = "hashicorp/aws"', highlight: false }, - { content: ' version = "~> 5.0"', highlight: false }, - { content: ' }', highlight: false }, - { content: ' }', highlight: false }, - { content: ' backend "s3" {', highlight: true }, - { content: ' bucket = var.tf_state_bucket', highlight: true }, - { content: ' key = "auth-service/terraform.tfstate"', highlight: true }, - { content: ' region = var.aws_region', highlight: true }, - { content: ' }', highlight: true }, - { content: '}', highlight: false }, - { content: '', highlight: false }, - { content: 'provider "aws" {', highlight: false }, - { content: ' region = var.aws_region', highlight: false }, - { content: '}', highlight: false }, + { content: 'terraform {' }, + { content: ' required_version = ">= 1.6"' }, + { content: ' required_providers {' }, + { content: ' aws = {' }, + { content: ' source = "hashicorp/aws"' }, + { content: ' version = "~> 5.0"' }, + { content: ' }' }, + { content: ' }' }, + { content: ' backend "s3" {' }, + { content: ' bucket = var.tf_state_bucket' }, + { content: ' key = "auth-service/terraform.tfstate"' }, + { content: ' region = var.aws_region' }, + { content: ' }' }, + { content: '}' }, + { content: '' }, + { content: 'provider "aws" {' }, + { content: ' region = var.aws_region' }, + { content: '}' }, ], }, { @@ -235,23 +235,24 @@ const MOCK_FILES: GeneratedFile[] = [ path: 'infra/lambda.tf', lang: 'hcl', status: 'new', + nodeId: 'lambda', lines: [ - { content: 'resource "aws_lambda_function" "auth" {', highlight: false }, - { content: ' function_name = "${var.project_name}-auth"', highlight: false }, - { content: ' runtime = "nodejs20.x"', highlight: false }, - { content: ' handler = "index.handler"', highlight: false }, - { content: ' memory_size = 512', highlight: false }, - { content: ' timeout = 10', highlight: false }, - { content: ' architectures = ["arm64"]', highlight: false }, - { content: '', highlight: false }, - { content: ' environment {', highlight: true }, - { content: ' variables = {', highlight: true }, - { content: ' REDIS_URL = aws_elasticache_cluster.cache.cache_nodes[0].address', highlight: true }, - { content: ' DATABASE_URL = aws_db_instance.postgres.endpoint', highlight: true }, - { content: ' SECRET_ARN = aws_secretsmanager_secret.jwt_key.arn', highlight: true }, - { content: ' }', highlight: true }, - { content: ' }', highlight: true }, - { content: '}', highlight: false }, + { content: 'resource "aws_lambda_function" "auth" {' }, + { content: ' function_name = "${var.project_name}-auth"' }, + { content: ' runtime = "nodejs20.x"' }, + { content: ' handler = "index.handler"' }, + { content: ' memory_size = 512' }, + { content: ' timeout = 10' }, + { content: ' architectures = ["arm64"]' }, + { content: '' }, + { content: ' environment {' }, + { content: ' variables = {' }, + { content: ' REDIS_URL = aws_elasticache_cluster.cache.cache_nodes[0].address' }, + { content: ' DATABASE_URL = aws_db_instance.postgres.endpoint' }, + { content: ' SECRET_ARN = aws_secretsmanager_secret.jwt_key.arn' }, + { content: ' }' }, + { content: ' }' }, + { content: '}' }, ], }, { @@ -260,28 +261,29 @@ const MOCK_FILES: GeneratedFile[] = [ path: 'src/index.ts', lang: 'typescript', status: 'new', + nodeId: 'lambda', lines: [ - { content: 'import { APIGatewayEvent, APIGatewayProxyResult } from "aws-lambda";', highlight: false }, - { content: 'import { verifyJWT } from "./auth/jwt";', highlight: false }, - { content: 'import { rateLimiter } from "./middleware/rateLimit";', highlight: false }, - { content: 'import { auditLog } from "./services/audit";', highlight: false }, - { content: '', highlight: false }, - { content: 'export async function handler(', highlight: false }, - { content: ' event: APIGatewayEvent', highlight: false }, - { content: '): Promise {', highlight: false }, - { content: ' const allowed = await rateLimiter.check(', highlight: true }, - { content: ' event.requestContext.identity.sourceIp', highlight: true }, - { content: ' );', highlight: true }, - { content: ' if (!allowed) return { statusCode: 429, body: "Too Many Requests" };', highlight: true }, - { content: '', highlight: false }, - { content: ' const token = event.headers.Authorization?.split(" ")[1];', highlight: false }, - { content: ' if (!token) return { statusCode: 401, body: "Unauthorized" };', highlight: false }, - { content: '', highlight: false }, - { content: ' const payload = await verifyJWT(token);', highlight: false }, - { content: ' await auditLog({ userId: payload.sub, action: event.path });', highlight: false }, - { content: '', highlight: false }, - { content: ' return { statusCode: 200, body: JSON.stringify(payload) };', highlight: false }, - { content: '}', highlight: false }, + { content: 'import { APIGatewayEvent, APIGatewayProxyResult } from "aws-lambda";' }, + { content: 'import { verifyJWT } from "./auth/jwt";' }, + { content: 'import { rateLimiter } from "./middleware/rateLimit";' }, + { content: 'import { auditLog } from "./services/audit";' }, + { content: '' }, + { content: 'export async function handler(' }, + { content: ' event: APIGatewayEvent' }, + { content: '): Promise {' }, + { content: ' const allowed = await rateLimiter.check(' }, + { content: ' event.requestContext.identity.sourceIp' }, + { content: ' );' }, + { content: ' if (!allowed) return { statusCode: 429, body: "Too Many Requests" };' }, + { content: '' }, + { content: ' const token = event.headers.Authorization?.split(" ")[1];' }, + { content: ' if (!token) return { statusCode: 401, body: "Unauthorized" };' }, + { content: '' }, + { content: ' const payload = await verifyJWT(token);' }, + { content: ' await auditLog({ userId: payload.sub, action: event.path });' }, + { content: '' }, + { content: ' return { statusCode: 200, body: JSON.stringify(payload) };' }, + { content: '}' }, ], }, { @@ -290,24 +292,25 @@ const MOCK_FILES: GeneratedFile[] = [ path: 'src/auth/jwt.ts', lang: 'typescript', status: 'new', + nodeId: 'secrets', lines: [ - { content: 'import * as jwt from "jsonwebtoken";', highlight: false }, - { content: 'import { getSecret } from "../services/secrets";', highlight: false }, - { content: '', highlight: false }, - { content: 'export async function verifyJWT(token: string) {', highlight: false }, - { content: ' const publicKey = await getSecret("jwt-public-key");', highlight: true }, - { content: ' return jwt.verify(token, publicKey, {', highlight: true }, - { content: ' algorithms: ["RS256"],', highlight: true }, - { content: ' });', highlight: true }, - { content: '}', highlight: false }, - { content: '', highlight: false }, - { content: 'export async function signJWT(payload: Record) {', highlight: false }, - { content: ' const privateKey = await getSecret("jwt-private-key");', highlight: false }, - { content: ' return jwt.sign(payload, privateKey, {', highlight: false }, - { content: ' algorithm: "RS256",', highlight: false }, - { content: ' expiresIn: "15m",', highlight: false }, - { content: ' });', highlight: false }, - { content: '}', highlight: false }, + { content: 'import * as jwt from "jsonwebtoken";' }, + { content: 'import { getSecret } from "../services/secrets";' }, + { content: '' }, + { content: 'export async function verifyJWT(token: string) {' }, + { content: ' const publicKey = await getSecret("jwt-public-key");' }, + { content: ' return jwt.verify(token, publicKey, {' }, + { content: ' algorithms: ["RS256"],' }, + { content: ' });' }, + { content: '}' }, + { content: '' }, + { content: 'export async function signJWT(payload: Record) {' }, + { content: ' const privateKey = await getSecret("jwt-private-key");' }, + { content: ' return jwt.sign(payload, privateKey, {' }, + { content: ' algorithm: "RS256",' }, + { content: ' expiresIn: "15m",' }, + { content: ' });' }, + { content: '}' }, ], }, { @@ -316,19 +319,20 @@ const MOCK_FILES: GeneratedFile[] = [ path: 'infra/rds.tf', lang: 'hcl', status: 'new', + nodeId: 'rds', lines: [ - { content: 'resource "aws_db_instance" "postgres" {', highlight: false }, - { content: ' identifier = "${var.project_name}-db"', highlight: false }, - { content: ' engine = "postgres"', highlight: false }, - { content: ' engine_version = "15"', highlight: false }, - { content: ' instance_class = "db.t3.micro"', highlight: false }, - { content: ' allocated_storage = 20', highlight: false }, - { content: ' username = var.db_username', highlight: false }, - { content: ' password = var.db_password', highlight: false }, - { content: ' skip_final_snapshot = true', highlight: false }, - { content: ' backup_retention_period = 7', highlight: true }, - { content: ' deletion_protection = false', highlight: false }, - { content: '}', highlight: false }, + { content: 'resource "aws_db_instance" "postgres" {' }, + { content: ' identifier = "${var.project_name}-db"' }, + { content: ' engine = "postgres"' }, + { content: ' engine_version = "15"' }, + { content: ' instance_class = "db.t3.micro"' }, + { content: ' allocated_storage = 20' }, + { content: ' username = var.db_username' }, + { content: ' password = var.db_password' }, + { content: ' skip_final_snapshot = true' }, + { content: ' backup_retention_period = 7' }, + { content: ' deletion_protection = false' }, + { content: '}' }, ], }, ]; diff --git a/frontend/src/store/forgeStore.ts b/frontend/src/store/forgeStore.ts index 38c7873..5f470b6 100644 --- a/frontend/src/store/forgeStore.ts +++ b/frontend/src/store/forgeStore.ts @@ -52,7 +52,8 @@ export interface GeneratedFile { path: string; lang: string; status: 'new' | 'modified' | 'pending'; - lines: Array<{ content: string; highlight: boolean }>; + nodeId?: string; + lines: Array<{ content: string }>; } // ── Store interface ─────────────────────────────────────────────────────────── @@ -190,7 +191,7 @@ export const useForgeStore = create((set, get) => ({ updateFileStatus: (id, status) => set((state) => { const existing = state.generatedFiles[id]; - if (!existing) return {}; + if (!existing) return state; return { generatedFiles: { ...state.generatedFiles, @@ -238,7 +239,7 @@ export const useForgeStore = create((set, get) => ({ updateNodeDeployStatus: (nodeId, status) => set((state) => { - if (!state.architectureData) return {}; + if (!state.architectureData) return state; return { architectureData: { ...state.architectureData, From 417a3b68a6beb5891c3ede30140d067ff535eeff Mon Sep 17 00:00:00 2001 From: Gunbir Singh Date: Sun, 22 Mar 2026 00:19:35 -0700 Subject: [PATCH 5/9] fix: deduplicate configProps by key to prevent duplicate React keys ConfigPanel concatenated service defaults and node.config overrides without deduplication. If an override used the same key as a default (e.g. "Runtime"), the same key appeared twice in the list, causing duplicate React keys and potential incorrect row updates. Now using a Map so node-level overrides win and each key appears exactly once. --- frontend/src/components/cloudforge/ArchDiagram.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx index 761ccb0..3d27a5c 100644 --- a/frontend/src/components/cloudforge/ArchDiagram.tsx +++ b/frontend/src/components/cloudforge/ArchDiagram.tsx @@ -674,11 +674,14 @@ function ConfigPanel({ node, onClose }: ConfigPanelProps) { const serviceConf = AWS_SERVICE_CONFIG[service]; const configData = SERVICE_CONFIG_DATA[service]; - // Merge node-level config overrides with defaults - const configProps = [ - ...(configData?.configProps ?? []), - ...Object.entries(node.config ?? {}).map(([key, value]) => ({ key, value })), - ]; + // Merge node-level config overrides with defaults; overrides win on duplicate keys + const configPropsMap = new Map( + (configData?.configProps ?? []).map(({ key, value }) => [key, value]), + ); + for (const [key, value] of Object.entries(node.config ?? {})) { + configPropsMap.set(key, value); + } + const configProps = Array.from(configPropsMap, ([key, value]) => ({ key, value })); const tierColors: Record = { Serverless: '#01A88D', From 9c0ee7eb89ab6ab05723b6e0eb8ce3da739bd811 Mon Sep 17 00:00:00 2001 From: Gunbir Singh Date: Sun, 22 Mar 2026 00:19:56 -0700 Subject: [PATCH 6/9] fix: replace O(N*E) nodes.find in DiagramEdge with O(1) nodeMap.get DiagramEdge called nodes.find() twice per edge on every render, making edge resolution O(N*E). The main component already builds a nodeMap; pass it into DiagramEdge so each endpoint lookup is O(1). --- frontend/src/components/cloudforge/ArchDiagram.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx index 3d27a5c..bffb4d3 100644 --- a/frontend/src/components/cloudforge/ArchDiagram.tsx +++ b/frontend/src/components/cloudforge/ArchDiagram.tsx @@ -1133,15 +1133,15 @@ function ServiceNode({ node, iconSize, isSelected, onSelect, onHover, onLeave }: function DiagramEdge({ edge, - nodes, + nodeMap, iconSize, }: { edge: DrawIOEdge; - nodes: DrawIONode[]; + nodeMap: Map; iconSize: number; }) { - const fromNode = nodes.find((n) => n.id === edge.from); - const toNode = nodes.find((n) => n.id === edge.to); + const fromNode = nodeMap.get(edge.from); + const toNode = nodeMap.get(edge.to); if (!fromNode || !toNode) return null; const getCenter = (n: DrawIONode): [number, number] => { @@ -1355,7 +1355,7 @@ export default function ArchDiagram({ ))} From 4d9162500394955e96755da400dd2dc6c057649e Mon Sep 17 00:00:00 2001 From: Gunbir Singh Date: Sun, 22 Mar 2026 00:20:22 -0700 Subject: [PATCH 7/9] fix: use terraformResource for accurate AWS service icon mapping The coarse ForgeArchNode.type caused mis-renders: cache mapped to DynamoDB instead of ElastiCache, storage mapped to S3 instead of RDS, auth mapped to Cognito instead of Secrets Manager. mapForgeTypeToService is replaced with mapForgeNodeToService which checks terraformResource first and falls back to the type field only when no tf resource match exists. --- .../src/components/cloudforge/ArchDiagram.tsx | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx index bffb4d3..ea2c554 100644 --- a/frontend/src/components/cloudforge/ArchDiagram.tsx +++ b/frontend/src/components/cloudforge/ArchDiagram.tsx @@ -548,16 +548,51 @@ const DEFAULT_EDGES: DrawIOEdge[] = [ /* ── Backward Compatibility Converters ──────────────────────────────────────── */ -function mapForgeTypeToService(type: ForgeArchNode['type']): AWSServiceId { - const map: Record = { +/** Maps a ForgeArchNode to an AWSServiceId, using terraformResource for precision. */ +function mapForgeNodeToService(node: ForgeArchNode): AWSServiceId { + // Exact terraform resource type takes priority over the coarse `type` field + const tfMap: Partial> = { + aws_lambda_function: 'lambda', + aws_apigatewayv2_api: 'apigateway', + aws_api_gateway_rest_api: 'apigateway', + aws_elasticache_cluster: 'generic', // ElastiCache not in AWSServiceId — use generic + aws_elasticache_replication_group: 'generic', + aws_db_instance: 'rds', + aws_rds_cluster: 'rds', + aws_s3_bucket: 's3', + aws_sqs_queue: 'sqs', + aws_sns_topic: 'sns', + aws_cloudfront_distribution: 'cloudfront', + aws_cognito_user_pool: 'cognito', + aws_secretsmanager_secret: 'generic', + aws_dynamodb_table: 'dynamodb', + aws_ecs_service: 'ecs', + aws_ecs_cluster: 'ecs', + aws_eks_cluster: 'eks', + aws_kinesis_stream: 'kinesis', + aws_cloudwatch_event_rule: 'eventbridge', + aws_sfn_state_machine: 'stepfunctions', + aws_route53_record: 'route53', + aws_lb: 'elb', + aws_alb: 'elb', + aws_instance: 'ec2', + aws_bedrock_model_invocation_logging_configuration: 'bedrock', + }; + + if (node.terraformResource && tfMap[node.terraformResource] !== undefined) { + return tfMap[node.terraformResource]!; + } + + // Fall back to coarse type mapping + const typeMap: Record = { compute: 'lambda', storage: 's3', - cache: 'dynamodb', + cache: 'generic', gateway: 'apigateway', queue: 'sqs', - auth: 'cognito', + auth: 'generic', }; - return map[type] ?? 'generic'; + return typeMap[node.type] ?? 'generic'; } export function convertForgeNodes(forgeNodes: ForgeArchNode[]): DrawIONode[] { @@ -568,7 +603,7 @@ export function convertForgeNodes(forgeNodes: ForgeArchNode[]): DrawIONode[] { y: n.y ?? Math.floor(i / 4) * 160 + 40, label: n.label, sublabel: n.sublabel, - service: mapForgeTypeToService(n.type), + service: mapForgeNodeToService(n), })); } From e5f76e4e576a8257d64abbcb0bed5eccff7a36a7 Mon Sep 17 00:00:00 2001 From: Gunbir Singh Date: Sun, 22 Mar 2026 00:20:41 -0700 Subject: [PATCH 8/9] fix: clear tooltip timer on ArchDiagram unmount tooltipTimerRef was cleared on hover/leave but not on component unmount. If the component unmounted while a 250ms tooltip delay was pending, the scheduled setTooltip would fire on an unmounted component causing a React warning. Added a useEffect cleanup to cancel the timer. --- frontend/src/components/cloudforge/ArchDiagram.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx index ea2c554..f254cee 100644 --- a/frontend/src/components/cloudforge/ArchDiagram.tsx +++ b/frontend/src/components/cloudforge/ArchDiagram.tsx @@ -1256,6 +1256,15 @@ export default function ArchDiagram({ useEffect(() => { setMounted(true); }, []); + // Clear pending tooltip timer on unmount to prevent state updates on unmounted component + useEffect(() => { + return () => { + if (tooltipTimerRef.current) { + clearTimeout(tooltipTimerRef.current); + } + }; + }, []); + const ICON_SIZE = 56; const PAD = 60; From 46e3d263befd9e73dbb63a1603595e8daa668744 Mon Sep 17 00:00:00 2001 From: Gunbir Singh Date: Sun, 22 Mar 2026 00:20:57 -0700 Subject: [PATCH 9/9] fix: add role="img" to diagram wrapper so aria-label is announced The wrapper div had an aria-label describing the diagram but no ARIA role, so assistive technology would not treat it as a landmark or announce the label. Adding role="img" makes the relationship explicit and ensures screen readers announce the diagram's textual description. --- frontend/src/components/cloudforge/ArchDiagram.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx index f254cee..10dbb22 100644 --- a/frontend/src/components/cloudforge/ArchDiagram.tsx +++ b/frontend/src/components/cloudforge/ArchDiagram.tsx @@ -1346,6 +1346,7 @@ export default function ArchDiagram({ borderRadius: '12px', position: 'relative', }} + role="img" aria-label={ariaLabel} > {/* SVG diagram — shrinks when panel is open */}