feat: Cloudformation + RDS

This commit is contained in:
Zakaria
2026-09-30 17:43:42 -04:00
parent 548a15d7ec
commit 7764ea0dc1
411 changed files with 10182 additions and 1063 deletions
@@ -1,12 +1,13 @@
import {useEffect, useState} from 'react'
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
import {useEffect, useState, type CSSProperties} from 'react'
import {ArrowRight, Copy, Link2, ListTree, Loader2, Rocket, ScrollText, Send, Trash2, X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayMethod, testApiGatewayRoute} from '@/api/cloudProxyClient'
import type {ApiGatewayMethodTestResult} from '@/api/cloudProxyClient'
import type {ApiGatewayMethodTestResult, ApiGatewayResourceOverview} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
import {CopyValueButton} from './CopyValueButton'
import {LogsQueryPanel} from './LogsQueryPanel'
import {ServiceIcon} from '@/components/serviceIcons'
interface ApiGatewayRoutePanelProps {
cloud: CloudProvider
@@ -17,7 +18,10 @@ interface ApiGatewayRoutePanelProps {
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtimeEndpoint}: ApiGatewayRoutePanelProps) {
const [path, setPath] = useState('/hello')
const [parentResourceId, setParentResourceId] = useState('')
const [pathPart, setPathPart] = useState('hello')
const [method, setMethod] = useState('GET')
const [anyTestMethod, setAnyTestMethod] = useState('GET')
const [stage, setStage] = useState('dev')
const [functionName, setFunctionName] = useState('')
const [testBody, setTestBody] = useState('{}')
@@ -48,7 +52,10 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
useEffect(() => {
setPath('/hello')
setParentResourceId('')
setPathPart('hello')
setMethod('GET')
setAnyTestMethod('GET')
setStage('dev')
setFunctionName('')
setTestBody('{}')
@@ -68,13 +75,20 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
}, [resource?.id])
const routeMutation = useMutation({
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {path, method, stage, functionName}),
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {
path: createPath,
parentResourceId: selectedParent?.resourceId,
pathPart,
method,
stage,
functionName,
}),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]}),
})
const testMutation = useMutation({
mutationFn: () => testApiGatewayRoute(cloud, resource!.id, {
path,
method,
method: deployedHttpMethod,
stage,
body: testBody,
headers: parseHeaders(testHeaders),
@@ -132,9 +146,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
}
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false
const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody)
const apiResources = routesQuery.data?.resources ?? []
const rootResource = apiResources.find((item) => item.path === '/')
const selectedParent = apiResources.find((item) => item.resourceId === parentResourceId) ?? rootResource
const createPath = selectedParent ? childResourcePath(selectedParent.path, pathPart) : `/${pathPart}`
const deployedHttpMethod = method === 'ANY' ? anyTestMethod : method
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName && selectedParent && pathPart) && !routeMutation.isPending
const existingPath = apiResources.some((item) => item.path === createPath)
const curlCommand = buildCurlCommand(resource.id, stage, path, deployedHttpMethod, testHeaders, testQuery, testBody)
const endpoint = invokeEndpoint(runtimeEndpoint)
const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null
@@ -154,6 +173,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
autoRunKey={logsRefreshKey}
/>
)}
{functionName && <a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${functionName}`)}`}><ScrollText size={13}/> Open Lambda logs</a>}
</div>
<div className="resource-create-inline">
{baseInvokeUrl && <div className="dynamic-field">
@@ -168,20 +188,27 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
if (canCreate) routeMutation.mutate()
}}>
<label className="dynamic-field">
<span>Route path</span>
<input className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders" required/>
<small>{existingPath ? `Adds ${method} to the existing ${path} resource.` : <>One path segment, for example <code>/qrcode</code>.</>}</small>
<span>Parent resource</span>
<select aria-label="Parent resource" className="input" value={selectedParent?.resourceId ?? ''} onChange={(event) => setParentResourceId(event.target.value)} required>
{apiResources.map((item) => <option key={item.resourceId} value={item.resourceId}>{item.path}</option>)}
</select>
<small>Select where the new resource belongs.</small>
</label>
<label className="dynamic-field">
<span>Path part</span>
<input aria-label="Path part" className="input" value={pathPart} onChange={(event) => setPathPart(event.target.value)} placeholder="orders or {id}" required/>
<small>{existingPath ? `Adds ${method} to existing ${createPath}.` : <>Creates <code>{createPath}</code>. Path parameters and greedy parameters are supported.</>}</small>
</label>
<label className="dynamic-field">
<span>Method</span>
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
{['GET', 'POST', 'PUT', 'DELETE'].map((value) => <option key={value} value={value}>{value}</option>)}
<select aria-label="Method" className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
{CONFIGURABLE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
<small>Lambda proxy method.</small>
</label>
<label className="dynamic-field">
<span>Lambda function</span>
<select className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
<select aria-label="Lambda function" className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
<option value="">Select a Lambda function</option>
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
</select>
@@ -215,18 +242,28 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
</div>
{routesQuery.isLoading && <p className="muted compact-text">Loading configured methods...</p>}
{routesQuery.isError && <p className="error-text compact-text">{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API routes.'}</p>}
{routesQuery.data?.routes.length === 0 && <p className="muted compact-text">No configured methods yet. Create a route above.</p>}
{routesQuery.data && routesQuery.data.routes.length > 0 && (
<div className="apigateway-route-list">
{routesQuery.data.routes.map((route) => {
const selected = route.path === path && route.method === method
const key = `${route.resourceId}-${route.method}`
const resourceMethods = routesQuery.data.routes
.filter((item) => item.resourceId === route.resourceId && CORS_METHODS.includes(item.method))
.map((item) => item.method)
const isFirstForResource = routesQuery.data.routes.findIndex((item) => item.resourceId === route.resourceId) === routesQuery.data.routes.indexOf(route)
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
<button className="apigateway-route-select" type="button" onClick={() => {
{routesQuery.data && apiResources.length === 0 && <p className="muted compact-text">API Gateway returned no resources.</p>}
{routesQuery.data && apiResources.length > 0 && (
<div className="apigateway-resource-tree" role="tree" aria-label="API Gateway resources">
{resourceTree(apiResources).map(({resource: treeResource, children, depth}) => {
const resourceMethods = treeResource.methods.filter((item) => CORS_METHODS.includes(item.method)).map((item) => item.method)
return <div className="apigateway-resource-node" role="treeitem" aria-level={depth + 1} key={treeResource.resourceId} style={{'--tree-depth': depth} as CSSProperties}>
<div className="apigateway-resource-heading">
<button type="button" className="apigateway-resource-select" onClick={() => setParentResourceId(treeResource.resourceId)}>
<code>{treeResource.path}</code>
<small>{treeResource.methods.length === 0 ? 'No methods' : `${treeResource.methods.length} method${treeResource.methods.length === 1 ? '' : 's'}`}</small>
</button>
{resourceMethods.length > 0 && <button className="button compact" type="button" onClick={() => {
setCorsConfirmation({resourceId: treeResource.resourceId, path: treeResource.path})
setCorsMethods(resourceMethods)
corsMutation.reset()
}}>Enable CORS</button>}
</div>
{treeResource.methods.map((route) => {
const selected = route.path === path && route.method === method
const key = `${route.resourceId}-${route.method}`
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
<button className="apigateway-route-select" type="button" onClick={() => {
setPath(route.path)
setMethod(route.method)
setFunctionName(route.functionName ?? '')
@@ -239,28 +276,26 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
<code>{route.path}</code>
<small>{route.integrationType ?? 'No integration'}{route.functionName ? ` · ${route.functionName}` : ''}</small>
</button>
{isFirstForResource && <button className="button compact" type="button" onClick={() => {
setCorsConfirmation({resourceId: route.resourceId, path: route.path})
setCorsMethods(resourceMethods)
corsMutation.reset()
}}>Enable CORS</button>}
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
<span>Delete {route.method}?</span>
<button className="button danger compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => deleteRouteMutation.mutate(route)}>Delete</button>
<button className="button compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => setDeleteConfirmation(null)}>Cancel</button>
</div> : <button className="icon-btn" type="button" aria-label={`Delete ${route.method} ${route.path}`} onClick={() => setDeleteConfirmation(key)}><Trash2 size={13}/></button>}
{corsConfirmation?.resourceId === route.resourceId && isFirstForResource && <div className="apigateway-route-confirm">
<strong>Enable CORS for {corsConfirmation.path}</strong>
<label className="dynamic-field"><span>Allowed origin</span><input className="input" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label>
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={corsHeaders} onChange={(event) => setCorsHeaders(event.target.value)}/></label>
<div className="dynamic-field"><span>Allowed methods</span>{resourceMethods.map((value) => <label key={value}><input type="checkbox" checked={corsMethods.includes(value)} onChange={() => setCorsMethods((current) => current.includes(value) ? current.filter((method) => method !== value) : [...current, value])}/>{value}</label>)}</div>
<span>Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}.</span>
<button className="button primary compact" type="button" disabled={corsMutation.isPending || corsMethods.length === 0} onClick={() => corsMutation.mutate()}>{corsMutation.isPending ? 'Enabling' : 'Confirm enable CORS'}</button>
<button className="button compact" type="button" disabled={corsMutation.isPending} onClick={() => setCorsConfirmation(null)}>Cancel</button>
{corsMutation.isError && <p className="error-text compact-text">{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}</p>}
</div>}
</div>
})}
</div>
})}
{corsConfirmation?.resourceId === treeResource.resourceId && <div className="apigateway-route-confirm apigateway-cors-confirm">
<strong>Enable CORS for {corsConfirmation.path}</strong>
<label className="dynamic-field"><span>Allowed origin</span><input className="input" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label>
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={corsHeaders} onChange={(event) => setCorsHeaders(event.target.value)}/></label>
<div className="dynamic-field"><span>Allowed methods</span>{resourceMethods.map((value) => <label key={value}><input type="checkbox" checked={corsMethods.includes(value)} onChange={() => setCorsMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div>
<span>Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}.</span>
<button className="button primary compact" type="button" disabled={corsMutation.isPending || corsMethods.length === 0} onClick={() => corsMutation.mutate()}>{corsMutation.isPending ? 'Enabling' : 'Confirm enable CORS'}</button>
<button className="button compact" type="button" disabled={corsMutation.isPending} onClick={() => setCorsConfirmation(null)}>Cancel</button>
{corsMutation.isError && <p className="error-text compact-text">{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}</p>}
</div>}
{children.length > 0 && <span className="sr-only">{children.length} child resources</span>}
</div>
})}
</div>
)}
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
@@ -274,11 +309,16 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
</div>
</div>
<div className="apigateway-test-workspace">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (selectedResourceId && !methodTestMutation.isPending) methodTestMutation.mutate()
}}>
<label className="dynamic-field">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (selectedResourceId && !methodTestMutation.isPending) methodTestMutation.mutate()
}}>
<label className="dynamic-field">
<span>Request path</span>
<input aria-label="Request path" className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders/123" required/>
<small>Replace path parameters with concrete values for testing.</small>
</label>
<label className="dynamic-field">
<span>Stage variables (JSON)</span>
<textarea className="textarea code-textarea small" value={stageVariables} onChange={(event) => setStageVariables(event.target.value)} spellCheck={false}/>
<small>Optional variables available during this method test only.</small>
@@ -294,22 +334,22 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
<div>
<p className="eyebrow">Deployed HTTP Test</p>
<h3><Send size={15}/> Send deployed request</h3>
<p className="muted compact-text">Invokes <code>{method} /{stage}{path}</code> through the local API Gateway endpoint. Requires a deployed stage.</p>
<p className="muted compact-text">Invokes <code>{deployedHttpMethod} /{stage}{path}</code> through the local API Gateway endpoint. Requires a deployed stage.</p>
</div>
</div>
<div className="apigateway-request-flow" aria-label="API request flow">
<div className="apigateway-flow-step">
<Send size={16}/>
<div><strong>Test request</strong><small>{method} {path}</small></div>
<div><strong>Test request</strong><small>{deployedHttpMethod} {path}</small></div>
</div>
<ArrowRight className="apigateway-flow-arrow" size={16}/>
<div className="apigateway-flow-step">
<Waypoints size={16}/>
<ServiceIcon cloud="aws" iconKey="apigateway" size={22}/>
<div><strong>API Gateway</strong><small>{resource.name} · {stage}</small></div>
</div>
<ArrowRight className="apigateway-flow-arrow" size={16}/>
<div className="apigateway-flow-step">
<Braces size={16}/>
<ServiceIcon cloud="aws" iconKey="serverless" size={22}/>
<div><strong>Lambda proxy</strong><small>{functionName || 'Connected Lambda'}</small></div>
</div>
<ArrowRight className="apigateway-flow-arrow" size={16}/>
@@ -319,11 +359,18 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
</div>
</div>
<div className="apigateway-test-workspace">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (!testMutation.isPending) testMutation.mutate()
}}>
<label className="dynamic-field">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (!testMutation.isPending) testMutation.mutate()
}}>
{method === 'ANY' && <label className="dynamic-field">
<span>HTTP verb for deployed request</span>
<select aria-label="HTTP verb for deployed request" className="input" value={anyTestMethod} onChange={(event) => setAnyTestMethod(event.target.value)}>
{DEPLOYED_HTTP_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
<small>ANY is API Gateway configuration, not an HTTP request verb.</small>
</label>}
<label className="dynamic-field">
<span>Request headers (JSON)</span>
<textarea className="textarea code-textarea small" value={testHeaders} onChange={(event) => setTestHeaders(event.target.value)} spellCheck={false}/>
</label>
@@ -400,7 +447,33 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
)
}
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
const CONFIGURABLE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'ANY', 'HEAD']
const DEPLOYED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
function childResourcePath(parentPath: string, pathPart: string): string {
return parentPath === '/' ? `/${pathPart}` : `${parentPath}/${pathPart}`
}
function resourceTree(resources: ApiGatewayResourceOverview[]): Array<{resource: ApiGatewayResourceOverview; children: ApiGatewayResourceOverview[]; depth: number}> {
const byParent = new Map<string | null, ApiGatewayResourceOverview[]>()
for (const resource of resources) {
const siblings = byParent.get(resource.parentId) ?? []
siblings.push(resource)
byParent.set(resource.parentId, siblings)
}
for (const siblings of byParent.values()) siblings.sort((left, right) => left.path.localeCompare(right.path))
const flattened: Array<{resource: ApiGatewayResourceOverview; children: ApiGatewayResourceOverview[]; depth: number}> = []
const visit = (resource: ApiGatewayResourceOverview, depth: number) => {
const children = byParent.get(resource.resourceId) ?? []
flattened.push({resource, children, depth})
for (const child of children) visit(child, depth + 1)
}
const roots = resources.filter((resource) => resource.parentId === null || !resources.some((candidate) => candidate.resourceId === resource.parentId))
for (const root of roots.sort((left, right) => left.path.localeCompare(right.path))) visit(root, 0)
return flattened
}
function ProxyCorsWarning({methods, origin, headers}: {methods: string[]; origin: string; headers: string}) {
return <div className="error-text compact-text">