diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx index 7e16655..10dbb22 100644 --- a/frontend/src/components/cloudforge/ArchDiagram.tsx +++ b/frontend/src/components/cloudforge/ArchDiagram.tsx @@ -1,378 +1,1446 @@ 'use client'; -import { useMemo } from 'react'; -import { motion } from 'framer-motion'; +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 ──────────────────────────────────────────────────────────────────── */ -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' + | 'internet' + | '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; + /** Optional per-node config override */ + config?: Record; + description?: string; } -interface ArchEdge { +export interface DrawIOEdge { from: string; to: string; + label?: string; + dashed?: boolean; +} + +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; } -interface ArchDiagramProps { - nodes: ArchNode[]; - edges: ArchEdge[]; +/* ── AWS Service Config ──────────────────────────────────────────────────────── */ + +interface ServiceConfig { + bg: string; + shape: 'circle' | 'roundedSquare'; + abbr: string; + /** Icon path fill colour — defaults to 'white'. Use '#333' for transparent-bg icons. */ + iconColor?: string; } -/* ── Constants ──────────────────────────────────────────────────────────────── */ +interface NodeConfigData { + description: string; + category: string; + tier: string; + configProps: Array<{ key: string; value: string }>; + useCases: string[]; + docsUrl: string; +} -const NODE_W = 140; -const NODE_H = 56; -const CANVAS_PADDING = 40; +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', + internet: 'internet', + generic: 'generic', +}; -/* ── Component ──────────────────────────────────────────────────────────────── */ +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' }, + internet: { bg: 'none', shape: 'roundedSquare', abbr: 'NET', iconColor: '#444' }, + generic: { bg: '#545B64', shape: 'roundedSquare', abbr: '?' }, +}; -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 SERVICE_CONFIG_DATA: Partial> = { + lambda: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + 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', + }, + 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', + tier: 'Managed', + configProps: [], + useCases: [], + docsUrl: 'https://aws.amazon.com', + }, +}; - 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]); +/* ── Default Mock Data ───────────────────────────────────────────────────────── */ + +const DEFAULT_NODES: DrawIONode[] = [ + // ── 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[] = [ + // 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' }, - /* 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]); + // 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' }, +]; + +/* ── Backward Compatibility Converters ──────────────────────────────────────── */ + +/** 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: 'generic', + gateway: 'apigateway', + queue: 'sqs', + auth: 'generic', + }; + return typeMap[node.type] ?? 'generic'; +} + +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: mapForgeNodeToService(n), + })); +} + +export function convertForgeEdges(forgeEdges: ForgeArchEdge[]): DrawIOEdge[] { + return forgeEdges.map((e) => ({ + from: e.from, + to: e.to, + })); +} + +/* ── 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})`; +} + +/* ── 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; 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', + 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 ( +
- {/* SVG edge layer */} - - - +
+
+ + {serviceConf.abbr} + +
+
+
+ {node.label} +
+ {node.sublabel && ( +
+ {node.sublabel} +
+ )} +
+
+
+ + {/* Category + Tier badges */} +
+ {configData?.category && ( + - ); - })} - + > + {configData.category} + + )} + {configData?.tier && ( + + {configData.tier} + + )} +
+ - {/* Layer labels */} - - Application Layer - + {/* Scrollable body */} +
- - Infrastructure Layer - - - {/* Node layer */} -
- {nodes.map((node) => ( - - ))} + {/* 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} + +
- {/* Legend */} - + {/* Footer */} + {configData?.docsUrl && ( + + )}
); } -/* ── ArchNodeCard ────────────────────────────────────────────────────────────── */ +/* ── Sub-components ──────────────────────────────────────────────────────────── */ -function ArchNodeCard({ node }: { node: ArchNode }) { - const isApp = node.layer === 'app'; +function GroupContainer({ node }: { node: DrawIONode }) { + const color = node.groupColor ?? '#8C4FFF'; + const labelWidth = node.label.length * 7 + 16; - const defaultBorder = isApp - ? '0.5px solid var(--lp-accent-dim)' - : '0.5px solid var(--cf-purple-glow)'; + return ( + + + + + {node.label} + + + ); +} - const newBorder = node.isNew ? '1px solid var(--lp-accent)' : defaultBorder; - const newShadow = node.isNew ? '0 0 12px var(--lp-accent-glow)' : 'none'; +interface ServiceNodeProps { + node: DrawIONode; + iconSize: number; + isSelected: boolean; + onSelect: (node: DrawIONode) => void; + onHover: (node: DrawIONode, x: number, y: number) => void; + onLeave: () => void; +} - const accentBarColor = isApp ? 'var(--lp-accent)' : 'var(--cf-purple)'; +function ServiceNode({ node, iconSize, isSelected, onSelect, onHover, onLeave }: ServiceNodeProps) { + const cx = node.x + iconSize / 2; - /* 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 }, - } - : {}; + if (node.type === 'user') { + 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 && ( + + )} + + + + + + + {node.label} + + + ); + } + + const service = node.service ?? 'generic'; + const config = AWS_SERVICE_CONFIG[service]; + const iconKey = SERVICE_TO_ICON[service] ?? 'generic'; + const icon = AWS_ICONS[iconKey]; + const [vbW, vbH] = icon.viewBox.split(' ').slice(2).map(Number); + const scale = iconSize / Math.max(vbW, vbH); 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); }} > - {/* Left accent bar */} - - - + )} + {/* Background shape */} + {config.shape === 'circle' ? ( + + ) : ( + + )} + {/* Real AWS icon path scaled to fit iconSize */} + + + + + + {/* Primary label */} + {node.label} - - + + {/* Sublabel */} {node.sublabel && ( - {node.sublabel} - + + )} + + ); +} + +function DiagramEdge({ + edge, + nodeMap, + iconSize, +}: { + edge: DrawIOEdge; + nodeMap: Map; + iconSize: number; +}) { + const fromNode = nodeMap.get(edge.from); + const toNode = nodeMap.get(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]; + } + return [n.x + iconSize / 2, n.y + iconSize / 2]; + }; + + 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} + ); } -/* ── Legend ───────────────────────────────────────────────────────────────────── */ +/* ── Main Component ──────────────────────────────────────────────────────────── */ + +export default function ArchDiagram({ + nodes: propNodes, + edges: propEdges, + onNodeSelect, + selectedNodeId: controlledSelectedNodeId, +}: ArchDiagramProps) { + const nodes = propNodes ?? DEFAULT_NODES; + const edges = propEdges ?? DEFAULT_EDGES; + + // Controlled mode: parent owns selection; uncontrolled: internal state + ConfigPanel + const isControlled = onNodeSelect !== undefined; + + const [internalSelectedId, setInternalSelectedId] = useState(null); + const [tooltip, setTooltip] = useState(null); + const [mounted, setMounted] = useState(false); + const tooltipTimerRef = useRef | null>(null); + + 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; + + 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); + }, []); -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 handleLeave = useCallback(() => { + if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current); + setTooltip(null); + }, []); + + 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); + + 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) => ( -
+ - - {item.label} - -
- ))} + + + + + + + + + + {/* Layer 1: Group containers */} + {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 */} + {nodes + .filter((n) => n.type === 'sectionLabel') + .map((node) => ( + + ))} + +
+ + {/* 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 dde07ac..f0ecc45 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 ────────────────────────────────────────────────────── @@ -565,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(() => { @@ -827,18 +650,12 @@ export default function ArchitecturePanel() { inset: 0, }} > - {/* SVG edge overlay */} - - - {/* Node cards */} - {displayNodes.map((node) => ( - - ))} + )} 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,