feat: add SQS + EventBridge

This commit is contained in:
Zakaria
2026-09-24 22:34:04 -04:00
parent 9bbe80b5f2
commit 1b129e9dcf
37 changed files with 2902 additions and 66 deletions
+91 -1
View File
@@ -26,7 +26,17 @@ export const apiEndpointKeys = {
decrypt: "clouds.services.resources.decrypt",
createApiGatewayRoute: "clouds.services.resources.apigateway.route.create",
listApiGatewayRoutes: "clouds.services.resources.apigateway.routes.list",
testApiGatewayRoute: "clouds.services.resources.apigateway.route.test",
deleteApiGatewayRoute: "clouds.services.resources.apigateway.route.delete",
testApiGatewayRoute: "clouds.services.resources.apigateway.route.test",
createS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.create",
listS3LambdaTriggers: "clouds.services.resources.storage.lambda-triggers.list",
deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete",
createEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.create",
listEventBridgeLambdaTriggers: "clouds.services.resources.serverless.eventbridge-triggers.list",
deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete",
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list",
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
},
storage: {
objects: {
@@ -310,6 +320,14 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "apigateway" },
},
],
[
apiEndpointKeys.clouds.resources.deleteApiGatewayRoute,
{
path: "/clouds/:cloud/services/apigateway/resources/:id/routes/:resourceId/:method",
method: "DELETE",
telemetry: { service: "apigateway" },
},
],
[
apiEndpointKeys.clouds.resources.testApiGatewayRoute,
{
@@ -318,6 +336,78 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "apigateway" },
},
],
[
apiEndpointKeys.clouds.resources.createS3LambdaTrigger,
{
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers",
method: "POST",
telemetry: { service: "storage" },
},
],
[
apiEndpointKeys.clouds.resources.listS3LambdaTriggers,
{
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers",
method: "GET",
telemetry: { service: "storage" },
},
],
[
apiEndpointKeys.clouds.resources.deleteS3LambdaTrigger,
{
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers/:triggerId",
method: "DELETE",
telemetry: { service: "storage" },
},
],
[
apiEndpointKeys.clouds.resources.createEventBridgeLambdaTrigger,
{
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers",
method: "POST",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.listEventBridgeLambdaTriggers,
{
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers",
method: "GET",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.deleteEventBridgeLambdaTrigger,
{
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers/:triggerId",
method: "DELETE",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping,
{
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings",
method: "POST",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.listSqsLambdaEventSourceMappings,
{
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings",
method: "GET",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.deleteSqsLambdaEventSourceMapping,
{
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings/:mappingId",
method: "DELETE",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.get,
{
@@ -245,6 +245,60 @@ export interface ApiGatewayRoutesResult {
stages: string[];
}
export interface DeleteApiGatewayRouteInput {
resourceId: string;
path: string;
method: string;
stage: string;
}
export interface CreateS3LambdaTriggerInput {
functionName: string;
events: string[];
prefix?: string;
suffix?: string;
}
export interface S3LambdaTriggerOverview {
id: string;
functionName: string;
functionArn: string;
events: string[];
prefix: string | null;
suffix: string | null;
}
export interface CreateEventBridgeLambdaTriggerInput {
ruleName: string;
scheduleExpression: string;
}
export interface EventBridgeLambdaTriggerOverview {
id: string;
ruleName: string;
ruleArn: string;
functionName: string;
functionArn: string;
scheduleExpression: string;
state: string | null;
}
export interface CreateSqsLambdaEventSourceMappingInput {
queueArn: string;
batchSize?: number;
}
export interface SqsLambdaEventSourceMappingOverview {
id: string;
uuid: string;
functionName: string;
functionArn: string | null;
queueArn: string;
batchSize: number | null;
state: string | null;
stateTransitionReason: string | null;
}
export async function createApiGatewayLambdaRoute(
cloud: CloudProvider,
apiId: string,
@@ -272,6 +326,19 @@ export async function listApiGatewayRoutes(
return res.data;
}
export async function deleteApiGatewayRoute(
cloud: CloudProvider,
apiId: string,
input: DeleteApiGatewayRouteInput,
signal?: AbortSignal,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.deleteApiGatewayRoute,
requestOptions(cloud, "apigateway", {signal, params: {stage: input.stage, path: input.path}}),
{cloud, id: apiId, resourceId: input.resourceId, method: input.method},
);
}
export async function testApiGatewayRoute(
cloud: CloudProvider,
apiId: string,
@@ -286,6 +353,126 @@ export async function testApiGatewayRoute(
return res.data;
}
export async function createS3LambdaTrigger(
cloud: CloudProvider,
bucketName: string,
input: CreateS3LambdaTriggerInput,
signal?: AbortSignal,
): Promise<S3LambdaTriggerOverview> {
const res = await apiClient.call<S3LambdaTriggerOverview, CreateS3LambdaTriggerInput>(
apiEndpointKeys.clouds.resources.createS3LambdaTrigger,
requestOptions(cloud, "storage", {signal, body: input}),
{cloud, id: bucketName},
);
return res.data;
}
export async function listS3LambdaTriggers(
cloud: CloudProvider,
bucketName: string,
signal?: AbortSignal,
): Promise<S3LambdaTriggerOverview[]> {
const res = await apiClient.call<S3LambdaTriggerOverview[]>(
apiEndpointKeys.clouds.resources.listS3LambdaTriggers,
requestOptions(cloud, "storage", {signal}),
{cloud, id: bucketName},
);
return res.data;
}
export async function deleteS3LambdaTrigger(
cloud: CloudProvider,
bucketName: string,
triggerId: string,
signal?: AbortSignal,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.deleteS3LambdaTrigger,
requestOptions(cloud, "storage", {signal}),
{cloud, id: bucketName, triggerId},
);
}
export async function createEventBridgeLambdaTrigger(
cloud: CloudProvider,
functionName: string,
input: CreateEventBridgeLambdaTriggerInput,
signal?: AbortSignal,
): Promise<EventBridgeLambdaTriggerOverview> {
const res = await apiClient.call<EventBridgeLambdaTriggerOverview, CreateEventBridgeLambdaTriggerInput>(
apiEndpointKeys.clouds.resources.createEventBridgeLambdaTrigger,
requestOptions(cloud, "serverless", {signal, body: input}),
{cloud, id: functionName},
);
return res.data;
}
export async function listEventBridgeLambdaTriggers(
cloud: CloudProvider,
functionName: string,
signal?: AbortSignal,
): Promise<EventBridgeLambdaTriggerOverview[]> {
const res = await apiClient.call<EventBridgeLambdaTriggerOverview[]>(
apiEndpointKeys.clouds.resources.listEventBridgeLambdaTriggers,
requestOptions(cloud, "serverless", {signal}),
{cloud, id: functionName},
);
return res.data;
}
export async function deleteEventBridgeLambdaTrigger(
cloud: CloudProvider,
functionName: string,
triggerId: string,
signal?: AbortSignal,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.deleteEventBridgeLambdaTrigger,
requestOptions(cloud, "serverless", {signal}),
{cloud, id: functionName, triggerId},
);
}
export async function createSqsLambdaEventSourceMapping(
cloud: CloudProvider,
functionName: string,
input: CreateSqsLambdaEventSourceMappingInput,
signal?: AbortSignal,
): Promise<SqsLambdaEventSourceMappingOverview> {
const res = await apiClient.call<SqsLambdaEventSourceMappingOverview, CreateSqsLambdaEventSourceMappingInput>(
apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping,
requestOptions(cloud, "serverless", {signal, body: input}),
{cloud, id: functionName},
);
return res.data;
}
export async function listSqsLambdaEventSourceMappings(
cloud: CloudProvider,
functionName: string,
signal?: AbortSignal,
): Promise<SqsLambdaEventSourceMappingOverview[]> {
const res = await apiClient.call<SqsLambdaEventSourceMappingOverview[]>(
apiEndpointKeys.clouds.resources.listSqsLambdaEventSourceMappings,
requestOptions(cloud, "serverless", {signal}),
{cloud, id: functionName},
);
return res.data;
}
export async function deleteSqsLambdaEventSourceMapping(
cloud: CloudProvider,
functionName: string,
mappingId: string,
signal?: AbortSignal,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.deleteSqsLambdaEventSourceMapping,
requestOptions(cloud, "serverless", {signal}),
{cloud, id: functionName, mappingId},
);
}
export type KmsEncryptionAlgorithm =
| "SYMMETRIC_DEFAULT"
| "RSAES_OAEP_SHA_1"
@@ -7,8 +7,10 @@ import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
createApiGatewayLambdaRoute: vi.fn(),
deleteApiGatewayRoute: vi.fn(),
listApiGatewayRoutes: vi.fn(),
listCloudResources: vi.fn(),
queryLogs: vi.fn(),
testApiGatewayRoute: vi.fn(),
}))
@@ -124,3 +126,21 @@ test('selects a deployed route for testing', async () => {
expect(screen.getAllByRole('combobox')[0]).toHaveValue('POST')
expect(screen.getAllByRole('combobox')[1]).toHaveValue('orders-handler')
})
test('confirms before deleting a deployed route', async () => {
apiMocks.listCloudResources.mockResolvedValue([lambda])
apiMocks.listApiGatewayRoutes.mockResolvedValue({
routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}], stages: ['dev'],
})
apiMocks.deleteApiGatewayRoute.mockResolvedValue(undefined)
const user = userEvent.setup()
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
await user.click(await screen.findByRole('button', {name: 'Delete POST /orders'}))
expect(screen.getByText('Delete POST?')).toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'Delete'}))
await waitFor(() => expect(apiMocks.deleteApiGatewayRoute).toHaveBeenCalledWith('aws', 'api-123', {
resourceId: 'resource-123', path: '/orders', method: 'POST', stage: 'dev',
}))
})
@@ -1,9 +1,10 @@
import {useEffect, useState} from 'react'
import {ArrowRight, Braces, Link2, ListTree, Loader2, Rocket, Send, Waypoints, X} from 'lucide-react'
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {createApiGatewayLambdaRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
import {LogsQueryPanel} from './LogsQueryPanel'
interface ApiGatewayRoutePanelProps {
cloud: CloudProvider
@@ -18,7 +19,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
const [functionName, setFunctionName] = useState('')
const [testBody, setTestBody] = useState('{}')
const [testHeaders, setTestHeaders] = useState('{\n "content-type": "application/json"\n}')
const [testQuery, setTestQuery] = useState('')
const [curlCopied, setCurlCopied] = useState(false)
const [bodyFormatError, setBodyFormatError] = useState<string | null>(null)
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
const [deleteConfirmation, setDeleteConfirmation] = useState<string | null>(null)
const queryClient = useQueryClient()
const functionsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'serverless'],
@@ -38,7 +43,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
setFunctionName('')
setTestBody('{}')
setTestHeaders('{\n "content-type": "application/json"\n}')
setTestQuery('')
setCurlCopied(false)
setBodyFormatError(null)
setLogsRefreshKey(0)
setDeleteConfirmation(null)
}, [resource?.id])
const routeMutation = useMutation({
@@ -52,8 +61,17 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
stage,
body: testBody,
headers: parseHeaders(testHeaders),
query: '',
query: testQuery,
}),
onSuccess: () => setLogsRefreshKey((value) => value + 1),
})
const deleteRouteMutation = useMutation({
mutationFn: ({resourceId, path, method}: {resourceId: string; path: string; method: string}) => deleteApiGatewayRoute(cloud, resource!.id, {resourceId, path, method, stage}),
onSuccess: () => {
setDeleteConfirmation(null)
testMutation.reset()
queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]})
},
})
if (!resource || resource.service !== 'apigateway') {
@@ -69,6 +87,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
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)
return (
<section className="table-panel">
@@ -78,6 +98,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
<h3><Link2 size={15}/> Connect Lambda</h3>
<p className="muted compact-text">Creates and deploys one Lambda proxy route to the selected REST API.</p>
</div>
{testMutation.data && functionName && (
<LogsQueryPanel
cloud={cloud}
logGroupName={`/aws/lambda/${functionName}`}
runtimeReachable={runtimeReachable}
autoRunKey={logsRefreshKey}
/>
)}
</div>
<div className="resource-create-inline">
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
@@ -87,7 +115,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
<label className="dynamic-field">
<span>Route path</span>
<input className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders" required/>
<small>One path segment, for example <code>/qrcode</code>.</small>
<small>{existingPath ? `Adds ${method} to the existing ${path} resource.` : <>One path segment, for example <code>/qrcode</code>.</>}</small>
</label>
<label className="dynamic-field">
<span>Method</span>
@@ -137,7 +165,9 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
<div className="apigateway-route-list">
{routesQuery.data.routes.map((route) => {
const selected = route.path === path && route.method === method
return <button className={`apigateway-route-item${selected ? ' selected' : ''}`} type="button" key={`${route.resourceId}-${route.method}`} onClick={() => {
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 ?? '')
@@ -147,7 +177,13 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
<span className="apigateway-route-method">{route.method}</span>
<code>{route.path}</code>
<small>{route.functionName ?? 'No Lambda integration'}</small>
</button>
</button>
{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>}
</div>
})}
</div>
)}
@@ -190,6 +226,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
<span>Request headers (JSON)</span>
<textarea className="textarea code-textarea small" value={testHeaders} onChange={(event) => setTestHeaders(event.target.value)} spellCheck={false}/>
</label>
<label className="dynamic-field">
<span>Query parameters</span>
<input className="input" value={testQuery} onChange={(event) => setTestQuery(event.target.value)} placeholder="page=1&limit=20"/>
<small>URL-encoded query string.</small>
</label>
<div className="dynamic-field apigateway-test-body">
<div className="apigateway-test-body-label">
<span>Request body</span>
@@ -225,6 +266,13 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
{testMutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}
{testMutation.isPending ? 'Sending' : 'Send test request'}
</button>
<button className="button" type="button" onClick={() => {
void navigator.clipboard.writeText(curlCommand)
setCurlCopied(true)
window.setTimeout(() => setCurlCopied(false), 1200)
}}>
<Copy size={13}/>{curlCopied ? 'Copied' : 'Copy cURL'}
</button>
{testMutation.isError && <p className="error-text compact-text">{testMutation.error instanceof Error ? testMutation.error.message : 'Unable to test API route.'}</p>}
</form>
<div className="apigateway-test-response">
@@ -271,3 +319,16 @@ function formatResponseBody(body: string): string {
return body || '(empty response body)'
}
}
function buildCurlCommand(apiId: string, stage: string, path: string, method: string, headersText: string, query: string, body: string): string {
let headers: Record<string, string> = {}
try {
headers = parseHeaders(headersText)
} catch {
// Keep cURL available while the user is editing invalid headers.
}
const url = `http://localhost:4566/execute-api/${apiId}/${stage}${path}${query ? `?${query}` : ''}`
const args = [`curl -X ${method}`, `'${url}'`, ...Object.entries(headers).map(([name, value]) => `-H '${name}: ${value.replace(/'/g, "'\\''")}'`)]
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && body) args.push(`--data '${body.replace(/'/g, "'\\''")}'`)
return args.join(' ')
}
@@ -48,3 +48,16 @@ test('replaces Hello World source when the Lambda runtime changes', async () =>
expect(screen.getByLabelText('Hello World Source')).toHaveValue('def handler(event, context):\n return {}')
})
test('edits key-value fields as variable rows', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<DynamicFormRenderer schema={{...schema, fields: [{name: 'environment', label: 'Environment Variables', type: 'key-value', required: false}]}} isSubmitting={false} onSubmit={onSubmit}/>)
await user.click(screen.getByRole('button', {name: 'Add variable'}))
await user.type(screen.getByLabelText('Environment Variables key 1'), 'APP_GREETING')
await user.type(screen.getByLabelText('Environment Variables value 1'), 'Hello')
await user.click(screen.getByRole('button', {name: 'Create'}))
expect(onSubmit).toHaveBeenCalledWith({environment: '[{"key":"APP_GREETING","value":"Hello"}]'})
})
@@ -1,5 +1,5 @@
import {FormEvent, useState} from 'react'
import {Plus} from 'lucide-react'
import {FormEvent, useRef, useState} from 'react'
import {Plus, Trash2} from 'lucide-react'
import type {FieldSchema, ServiceSchema} from '@/types/schema'
interface DynamicFormRendererProps {
@@ -146,6 +146,10 @@ function FieldInput({field, required, maxLength, value, invalid, messageId, onCh
)
}
if (field.type === 'key-value') {
return <KeyValueInput field={field} value={value} onChange={onChange}/>
}
return (
<input
type={field.type === 'password' ? 'password' : 'text'}
@@ -163,6 +167,35 @@ function FieldInput({field, required, maxLength, value, invalid, messageId, onCh
)
}
function KeyValueInput({field, value, onChange}: {field: FieldSchema; value: string; onChange: (value: string) => void}) {
const [entries, setEntries] = useState(() => keyValueEntries(value))
const entriesRef = useRef(entries)
function commit(next: Array<{key: string; value: string}>) {
entriesRef.current = next
setEntries(next)
onChange(JSON.stringify(next))
}
function updateEntry(index: number, property: 'key' | 'value', nextValue: string) {
const next = entriesRef.current.map((entry, entryIndex) => entryIndex === index ? {...entry, [property]: nextValue} : entry)
commit(next)
}
return (
<div className="key-value-editor">
{entries.map((entry, index) => (
<div className="key-value-row" key={index}>
<input className="input" value={entry.key} placeholder="Variable name" aria-label={`${field.label} key ${index + 1}`} onChange={(event) => updateEntry(index, 'key', event.target.value)}/>
<input className="input" value={entry.value} placeholder="Value" aria-label={`${field.label} value ${index + 1}`} onChange={(event) => updateEntry(index, 'value', event.target.value)}/>
<button className="icon-btn" type="button" aria-label={`Remove ${entry.key || 'environment variable'}`} onClick={() => commit(entriesRef.current.filter((_, entryIndex) => entryIndex !== index))}><Trash2 size={13}/></button>
</div>
))}
<button className="button compact" type="button" aria-label="Add variable" onClick={() => commit([...entriesRef.current, {key: '', value: ''}])}><Plus size={13}/> Add variable</button>
</div>
)
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
@@ -187,13 +220,27 @@ function getInitialFormValues(
if (initialValues) {
for (const [key, val] of Object.entries(initialValues)) {
if (val !== undefined && val !== null) {
defaults[key] = String(val)
const field = fields.find((entry) => entry.name === key)
defaults[key] = field?.type === 'key-value' && typeof val === 'object'
? JSON.stringify(Object.entries(val as Record<string, unknown>).map(([entryKey, entryValue]) => ({key: entryKey, value: String(entryValue)})))
: String(val)
}
}
}
return defaults
}
function keyValueEntries(value: string): Array<{key: string; value: string}> {
if (!value) return []
try {
const parsed: unknown = JSON.parse(value)
if (!Array.isArray(parsed)) return []
return parsed.filter((entry): entry is {key: string; value: string} => Boolean(entry) && typeof entry === 'object' && typeof entry.key === 'string' && typeof entry.value === 'string')
} catch {
return []
}
}
function defaultValues(fields: FieldSchema[]): Record<string, string> {
const values = Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? '']))
for (const field of fields) {
@@ -0,0 +1,13 @@
import {expect, test} from 'vitest'
import {bucketDeleteWarning} from '@/lib/bucketDeletion'
test('warns before deleting a bucket that contains folder prefixes', () => {
expect(bucketDeleteWarning({
prefix: '',
objects: [{key: 'orders/', name: 'orders', type: 'folder', size: null, lastModified: null, metadata: {}}],
})).toBe('This bucket contains objects or folders. Delete them before deleting the bucket.')
})
test('allows deleting an empty bucket', () => {
expect(bucketDeleteWarning({prefix: '', objects: []})).toBeNull()
})
@@ -4,10 +4,16 @@ import { ChevronDown, ChevronUp, Info, Loader2, Plus, RefreshCw, Trash2, X } fro
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createCloudResource,
createApiGatewayLambdaRoute,
createEventBridgeLambdaTrigger,
createS3LambdaTrigger,
createSqsLambdaEventSourceMapping,
clearEmailInbox,
deleteCloudResource,
getCloudResource,
getServiceSchema,
listCloudResources,
listStorageObjects,
updateCloudResource,
} from "@/api/cloudProxyClient";
import { DynamicFormRenderer } from "@/components/DynamicFormRenderer";
@@ -39,6 +45,12 @@ import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm";
import { AppConfigPanel } from "@/components/AppConfigPanel";
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHistoryPanel";
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
import { bucketDeleteWarning } from "@/lib/bucketDeletion";
interface DynamicResourceViewProps {
cloud: CloudProvider;
@@ -73,6 +85,7 @@ export function DynamicResourceView({
const [selectedItems, setSelectedItems] = useState<CloudResource[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
const [deleteWarning, setDeleteWarning] = useState<string | null>(null);
const handleDatabaseTabChange = (tab: "instances" | "snapshots") => {
@@ -138,8 +151,94 @@ export function DynamicResourceView({
};
const createMut = useMutation({
mutationFn: (values: Record<string, unknown>) =>
createCloudResource(cloud, service, values),
mutationFn: async (values: Record<string, unknown>) => {
const blueprintBucketName = typeof values.blueprintBucketName === "string" ? values.blueprintBucketName.trim() : "";
const blueprintApiName = typeof values.blueprintApiName === "string" ? values.blueprintApiName.trim() : "";
const blueprintRuleName = typeof values.blueprintRuleName === "string" ? values.blueprintRuleName.trim() : "";
const blueprintScheduleExpression = typeof values.blueprintScheduleExpression === "string" ? values.blueprintScheduleExpression.trim() : "";
const blueprintQueueName = typeof values.blueprintQueueName === "string" ? values.blueprintQueueName.trim() : "";
const blueprintSqsBatchSize = typeof values.blueprintSqsBatchSize === "number" ? values.blueprintSqsBatchSize : undefined;
const lambdaValues = {...values};
delete lambdaValues.blueprintBucketName;
delete lambdaValues.blueprintApiName;
delete lambdaValues.blueprintApiPath;
delete lambdaValues.blueprintApiMethod;
delete lambdaValues.blueprintApiStage;
delete lambdaValues.blueprintRuleName;
delete lambdaValues.blueprintScheduleExpression;
delete lambdaValues.blueprintQueueName;
delete lambdaValues.blueprintSqsBatchSize;
const resource = await createCloudResource(cloud, service, lambdaValues);
if (blueprintBucketName) {
let bucketCreated = false;
try {
await createCloudResource(cloud, "storage", {bucketName: blueprintBucketName});
bucketCreated = true;
await createS3LambdaTrigger(cloud, blueprintBucketName, {
functionName: resource.name,
events: ["s3:ObjectCreated:*"],
});
return resource;
} catch (error) {
if (bucketCreated) await deleteCloudResource(cloud, "storage", blueprintBucketName);
await deleteCloudResource(cloud, service, resource.id);
throw error;
}
}
if (blueprintRuleName) {
try {
await createEventBridgeLambdaTrigger(cloud, resource.name, {
ruleName: blueprintRuleName,
scheduleExpression: blueprintScheduleExpression,
});
return resource;
} catch (error) {
await deleteCloudResource(cloud, service, resource.id);
throw error;
}
}
if (blueprintQueueName) {
let queueId: string | undefined;
try {
const queue = await createCloudResource(cloud, "messaging", {queueName: blueprintQueueName});
queueId = queue.id;
const queueArn = queue.metadata.arn;
if (typeof queueArn !== "string" || !queueArn) {
throw new Error("Created SQS queue did not return an ARN.");
}
await createSqsLambdaEventSourceMapping(cloud, resource.name, {
queueArn,
batchSize: blueprintSqsBatchSize,
});
return resource;
} catch (error) {
const rollbacks = [deleteCloudResource(cloud, service, resource.id)];
if (queueId) rollbacks.push(deleteCloudResource(cloud, "messaging", queueId));
await Promise.allSettled(rollbacks);
throw error;
}
}
if (!blueprintApiName) return resource;
let apiId: string | undefined;
try {
const api = await createCloudResource(cloud, "apigateway", {name: blueprintApiName});
apiId = api.id;
const route = await createApiGatewayLambdaRoute(cloud, api.id, {
functionName: resource.name,
path: String(values.blueprintApiPath ?? ""),
method: String(values.blueprintApiMethod ?? "GET"),
stage: String(values.blueprintApiStage ?? "dev"),
});
return {...resource, metadata: {...resource.metadata, apiGatewayId: api.id, apiGatewayInvokeUrl: route.invokeUrl}};
} catch (error) {
if (apiId) await deleteCloudResource(cloud, "apigateway", apiId);
await deleteCloudResource(cloud, service, resource.id);
throw error;
}
},
onSuccess: (resource) => {
setSelected(resource);
setCreateOpen(false);
@@ -150,15 +249,25 @@ export function DynamicResourceView({
});
const deleteMut = useMutation({
mutationFn: (resource: CloudResource) =>
deleteCloudResource(cloud, service, resource.id),
mutationFn: async (resource: CloudResource) => {
if (cloud === "aws" && service === "storage") {
const contents = await listStorageObjects(cloud, resource.id);
const warning = bucketDeleteWarning(contents);
if (warning) throw new Error(warning);
}
await deleteCloudResource(cloud, service, resource.id);
},
onSuccess: (_, resource) => {
setDeleteWarning(null);
if (selected?.id === resource.id) setSelected(undefined);
setSelectedItems((prev) => prev.filter((item) => item.id !== resource.id));
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, service],
});
},
onError: (error) => {
setDeleteWarning(error instanceof Error ? error.message : "Unable to delete resource.");
},
});
const deleteMultipleMut = useMutation({
@@ -234,6 +343,13 @@ export function DynamicResourceView({
}
}, [successToast]);
useEffect(() => {
if (deleteWarning) {
const timer = setTimeout(() => setDeleteWarning(null), 5000);
return () => clearTimeout(timer);
}
}, [deleteWarning]);
const updateMut = useMutation({
mutationFn: ({ id, values }: { id: string; values: Record<string, unknown> }) =>
updateCloudResource(cloud, service, id, values),
@@ -280,8 +396,9 @@ export function DynamicResourceView({
if (!trimmed && !field.required) {
continue;
}
const initialStr =
initialVal !== undefined && initialVal !== null ? String(initialVal).trim() : "";
const initialStr = field.type === 'key-value' && initialVal && typeof initialVal === 'object'
? JSON.stringify(Object.entries(initialVal).map(([key, value]) => ({key, value: String(value)})))
: initialVal !== undefined && initialVal !== null ? String(initialVal).trim() : "";
if (trimmed !== initialStr) {
diff[field.name] = trimmed;
}
@@ -623,6 +740,14 @@ export function DynamicResourceView({
}}
onCancel={() => setCreateOpen(false)}
/>
) : service === "serverless" && cloud === "aws" ? (
<LambdaCreatePanel
schema={schema}
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
/>
) : (
<DynamicFormRenderer
key={`${cloud}:${service}:create`}
@@ -658,9 +783,14 @@ export function DynamicResourceView({
schema.updateFields &&
schema.updateFields.length > 0 &&
schema.actions.includes("update")
? (resource) => {
setEditingResource(resource);
setUpdateError(null);
? async (resource) => {
try {
const detailedResource = await getCloudResource(cloud, service, resource.id);
setEditingResource(detailedResource);
setUpdateError(null);
} catch (error) {
setUpdateError(error instanceof Error ? error.message : "Unable to load resource configuration");
}
}
: undefined,
onDelete: (resource) => deleteMut.mutate(resource),
@@ -709,11 +839,31 @@ export function DynamicResourceView({
/>
)}
{service === "serverless" && (
<ServerlessInvokePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<>
<ServerlessInvokePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<S3LambdaTriggerPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<ScheduledLambdaTriggerPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<SqsLambdaTriggerPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
{cloud === "aws" && activeSelected?.type === "lambda" && (
<LambdaInvocationHistoryPanel cloud={cloud} functionName={activeSelected.name} runtimeReachable={canUseRuntime}/>
)}
</>
)}
{service === "apigateway" && (
<ApiGatewayRoutePanel
@@ -803,6 +953,35 @@ export function DynamicResourceView({
{successToast}
</div>
)}
{deleteWarning && (
<div
role="alert"
style={{
position: "fixed",
bottom: 24,
right: 24,
background: "var(--status-error, #dc2626)",
color: "#fff",
padding: "10px 18px",
borderRadius: 6,
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
zIndex: 1000,
fontSize: 13,
fontWeight: 500,
}}
>
<span>{deleteWarning}</span>
<button
className="icon-btn"
type="button"
aria-label="Dismiss warning"
onClick={() => setDeleteWarning(null)}
style={{ marginLeft: 10, color: "inherit" }}
>
<X size={14} />
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,102 @@
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {expect, test, vi} from 'vitest'
import {LambdaCreatePanel} from './LambdaCreatePanel'
import type {ServiceSchema} from '@/types/schema'
const schema: ServiceSchema = {
cloud: 'aws', service: 'serverless', displayName: 'AWS Lambda', actions: ['create'], columns: [], filters: [], capabilities: {resourceActions: []},
fields: [
{name: 'functionName', label: 'Function Name', type: 'text', required: true},
{name: 'runtime', label: 'Runtime', type: 'select', required: true, options: [{label: 'Node.js 22.x', value: 'nodejs22.x'}]},
{name: 'handler', label: 'Handler', type: 'text', required: true},
{name: 'role', label: 'Execution Role ARN', type: 'text', required: true},
{name: 'description', label: 'Description', type: 'text', required: false},
{name: 'code', label: 'Hello World Source', type: 'textarea', required: false},
],
}
function renderPanel(onSubmit = vi.fn()) {
render(<LambdaCreatePanel schema={schema} isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
return onSubmit
}
test('offers scratch and blueprint Lambda creation paths', () => {
renderPanel()
expect(screen.getByRole('button', {name: /start from scratch/i})).toBeVisible()
expect(screen.getByRole('button', {name: /^blueprints/i})).toBeVisible()
})
test('prefills the reviewed S3 upload processor blueprint', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', {name: /^blueprints/i}))
expect(screen.getByText('Lambda Blueprints')).toBeVisible()
await user.click(screen.getByRole('button', {name: /s3 upload processor/i}))
expect(screen.getByText('Blueprint: S3 upload processor')).toBeVisible()
expect(screen.getByDisplayValue('Processes S3 object-created events.')).toBeVisible()
expect(screen.getByDisplayValue(/record\.s3\.bucket\.name/)).toBeVisible()
expect(screen.getByText(/create lambda, create bucket/i)).toBeVisible()
expect(screen.getByLabelText(/s3 bucket name/i)).toBeRequired()
})
test('keeps the blank creation form available', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', {name: /start from scratch/i}))
expect(screen.getByText('Start from scratch')).toBeVisible()
expect(screen.getByLabelText(/function name/i)).toHaveValue('')
})
test('prefills the REST Lambda API blueprint and its deployment plan', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', {name: /^blueprints/i}))
await user.click(screen.getByRole('button', {name: /rest lambda api/i}))
expect(screen.getByText('Blueprint: REST Lambda API')).toBeVisible()
expect(screen.getByDisplayValue(/event\.httpMethod/)).toBeVisible()
expect(screen.getByLabelText(/api name/i)).toBeVisible()
expect(screen.getByText(/grant api gateway permission/i)).toBeVisible()
})
test('prefills the scheduled Lambda blueprint and its EventBridge schedule', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', {name: /^blueprints/i}))
await user.click(screen.getByRole('button', {name: /scheduled lambda/i}))
expect(screen.getByText('Blueprint: Scheduled Lambda')).toBeVisible()
expect(screen.getByDisplayValue(/scheduled event received/i)).toBeVisible()
expect(screen.getByLabelText(/rule name/i)).toBeVisible()
expect(screen.getByLabelText(/schedule expression/i)).toHaveValue('rate(5 minutes)')
expect(screen.getByText(/create eventbridge rule/i)).toBeVisible()
})
test('prefills the SQS worker blueprint and submits queue mapping settings', async () => {
const user = userEvent.setup()
const onSubmit = renderPanel()
await user.click(screen.getByRole('button', {name: /^blueprints/i}))
await user.click(screen.getByRole('button', {name: /sqs worker/i}))
expect(screen.getByText('Blueprint: SQS Worker')).toBeVisible()
expect(screen.getByDisplayValue(/event\.Records/)).toBeVisible()
await user.type(screen.getByLabelText(/sqs queue name/i), 'orders-queue')
await user.clear(screen.getByLabelText(/batch size/i))
await user.type(screen.getByLabelText(/batch size/i), '5')
await user.type(screen.getByLabelText(/function name/i), 'orders-worker')
await user.type(screen.getByLabelText(/execution role arn/i), 'arn:aws:iam::000000000000:role/lambda')
await user.click(screen.getByRole('button', {name: 'Create Lambda'}))
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
blueprintQueueName: 'orders-queue', blueprintSqsBatchSize: 5,
code: expect.stringContaining('event.Records'),
}))
})
@@ -0,0 +1,146 @@
import {useState} from 'react'
import {Braces, Boxes} from 'lucide-react'
import {DynamicFormRenderer} from './DynamicFormRenderer'
import type {ServiceSchema} from '@/types/schema'
interface LambdaCreatePanelProps {
schema: ServiceSchema
isSubmitting: boolean
submitError: string | null
onCancel: () => void
onSubmit: (values: Record<string, unknown>) => void
}
export function LambdaCreatePanel({schema, isSubmitting, submitError, onCancel, onSubmit}: LambdaCreatePanelProps) {
const [mode, setMode] = useState<'choose' | 'catalog' | 'scratch' | 's3' | 'api' | 'scheduled' | 'sqs'>('choose')
const [bucketName, setBucketName] = useState('')
const [apiName, setApiName] = useState('')
const [apiPath, setApiPath] = useState('/hello')
const [apiMethod, setApiMethod] = useState('GET')
const [apiStage, setApiStage] = useState('dev')
const [ruleName, setRuleName] = useState('')
const [scheduleExpression, setScheduleExpression] = useState('rate(5 minutes)')
const [queueName, setQueueName] = useState('')
const [batchSize, setBatchSize] = useState('10')
const [blueprintError, setBlueprintError] = useState<string | null>(null)
if (mode === 'choose') return <div className="resource-create-inline">
<p className="eyebrow">Create Lambda</p><h3>How do you want to start?</h3>
<div className="apigateway-request-flow">
<button className="apigateway-flow-step" type="button" onClick={() => setMode('scratch')}><Braces size={18}/><div><strong>Start from scratch</strong><small>Choose runtime, handler, source, and settings.</small></div></button>
<button className="apigateway-flow-step" type="button" onClick={() => setMode('catalog')}><Boxes size={18}/><div><strong>Blueprints</strong><small>Choose a reviewed multi-resource starter.</small></div></button>
</div>
</div>
if (mode === 'catalog') return <div className="resource-create-inline">
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda Blueprints</p><h3>Choose a starting point</h3><p className="muted compact-text">Each blueprint displays its source, resources, permissions, and deployment plan before it creates anything.</p></div><button className="button" type="button" onClick={() => setMode('choose')}>Back</button></div>
<div className="apigateway-request-flow">
<button className="apigateway-flow-step" type="button" onClick={() => setMode('s3')}><Boxes size={18}/><div><strong>S3 upload processor</strong><small>Create a bucket and invoke Lambda for new objects.</small></div></button>
<button className="apigateway-flow-step" type="button" onClick={() => setMode('api')}><Braces size={18}/><div><strong>REST Lambda API</strong><small>Create and deploy an API Gateway Lambda proxy route.</small></div></button>
<button className="apigateway-flow-step" type="button" onClick={() => setMode('scheduled')}><Boxes size={18}/><div><strong>Scheduled Lambda</strong><small>Invoke Lambda on an EventBridge schedule.</small></div></button>
<button className="apigateway-flow-step" type="button" onClick={() => setMode('sqs')}><Boxes size={18}/><div><strong>SQS Worker</strong><small>Create a queue and process messages in Lambda batches.</small></div></button>
</div>
</div>
const blueprint = mode === 's3' ? {
runtime: 'nodejs22.x', handler: 'index.handler', description: 'Processes S3 object-created events.',
code: S3_UPLOAD_PROCESSOR_SOURCE,
} : mode === 'api' ? {
runtime: 'nodejs22.x', handler: 'index.handler', description: 'Handles API Gateway Lambda proxy requests.',
code: REST_LAMBDA_API_SOURCE,
} : mode === 'scheduled' ? {
runtime: 'nodejs22.x', handler: 'index.handler', description: 'Runs on an EventBridge schedule.',
code: SCHEDULED_LAMBDA_SOURCE,
} : mode === 'sqs' ? {
runtime: 'nodejs22.x', handler: 'index.handler', description: 'Processes messages from an SQS queue.',
code: SQS_WORKER_SOURCE,
} : {}
return <div className="resource-create-inline">
<div className="dynamic-stage-header"><div><p className="eyebrow">{mode === 's3' ? 'Blueprint: S3 upload processor' : mode === 'api' ? 'Blueprint: REST Lambda API' : mode === 'scheduled' ? 'Blueprint: Scheduled Lambda' : mode === 'sqs' ? 'Blueprint: SQS Worker' : 'Start from scratch'}</p><p className="muted compact-text">{mode === 's3' ? 'Review the Lambda, bucket, trigger, and permission before creating them.' : mode === 'api' ? 'Review the Lambda, REST API, route, deployment, and permission before creating them.' : mode === 'scheduled' ? 'Review the Lambda and EventBridge schedule before creating them.' : mode === 'sqs' ? 'Review the Lambda, SQS queue, and event source mapping before creating them.' : 'Create a Lambda with your own source and configuration.'}</p></div><button className="button" type="button" disabled={isSubmitting} onClick={() => setMode('choose')}>Back</button></div>
{mode === 's3' && <label className="dynamic-field"><span>S3 Bucket Name<em className="field-required">*</em></span><input className="input" value={bucketName} required disabled={isSubmitting} onChange={(event) => { setBucketName(event.target.value); setBlueprintError(null) }} placeholder="my-upload-bucket"/><small>Creates this bucket, then invokes the Lambda for every object-created event.</small></label>}
{mode === 's3' && <div className="apigateway-route-explorer"><p className="eyebrow">Creation plan</p><p className="muted compact-text">Create Lambda, create bucket <code>{bucketName || 'your-bucket'}</code>, grant S3 permission to invoke the Lambda, then add an <code>s3:ObjectCreated:*</code> trigger.</p></div>}
{mode === 'api' && <div className="dynamic-form">
<label className="dynamic-field"><span>API Name<em className="field-required">*</em></span><input className="input" value={apiName} disabled={isSubmitting} onChange={(event) => setApiName(event.target.value)} placeholder="orders-api"/><small>Creates an API Gateway REST API.</small></label>
<label className="dynamic-field"><span>Route Path<em className="field-required">*</em></span><input className="input" value={apiPath} disabled={isSubmitting} onChange={(event) => setApiPath(event.target.value)} placeholder="/orders"/><small>One top-level Lambda proxy path.</small></label>
<label className="dynamic-field"><span>Method</span><select className="input" value={apiMethod} disabled={isSubmitting} onChange={(event) => setApiMethod(event.target.value)}>{['GET', 'POST', 'PUT', 'DELETE'].map((value) => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="dynamic-field"><span>Stage<em className="field-required">*</em></span><input className="input" value={apiStage} disabled={isSubmitting} onChange={(event) => setApiStage(event.target.value)} placeholder="dev"/></label>
</div>}
{mode === 'api' && <div className="apigateway-route-explorer"><p className="eyebrow">Creation plan</p><p className="muted compact-text">Create Lambda, create REST API <code>{apiName || 'your-api'}</code>, add <code>{apiMethod} {apiPath || '/route'}</code>, grant API Gateway permission, and deploy stage <code>{apiStage || 'dev'}</code>.</p></div>}
{mode === 'scheduled' && <div className="dynamic-form">
<label className="dynamic-field"><span>Rule Name<em className="field-required">*</em></span><input className="input" value={ruleName} disabled={isSubmitting} onChange={(event) => setRuleName(event.target.value)} placeholder="hourly-report"/><small>Creates this EventBridge rule.</small></label>
<label className="dynamic-field"><span>Schedule Expression<em className="field-required">*</em></span><input className="input" value={scheduleExpression} disabled={isSubmitting} onChange={(event) => setScheduleExpression(event.target.value)} placeholder="rate(5 minutes)"/><small>Use a rate or cron expression.</small></label>
</div>}
{mode === 'scheduled' && <div className="apigateway-route-explorer"><p className="eyebrow">Creation plan</p><p className="muted compact-text">Create Lambda, create EventBridge rule <code>{ruleName || 'your-rule'}</code>, then invoke it on <code>{scheduleExpression || 'your schedule'}</code>.</p></div>}
{mode === 'sqs' && <div className="dynamic-form">
<label className="dynamic-field"><span>SQS Queue Name<em className="field-required">*</em></span><input className="input" value={queueName} required disabled={isSubmitting} onChange={(event) => { setQueueName(event.target.value); setBlueprintError(null) }} placeholder="orders-queue"/><small>Creates this SQS queue.</small></label>
<label className="dynamic-field"><span>Batch Size<em className="field-required">*</em></span><input className="input" type="number" min="1" max="10" value={batchSize} required disabled={isSubmitting} onChange={(event) => { setBatchSize(event.target.value); setBlueprintError(null) }}/><small>Messages sent to Lambda per invocation, from 1 to 10.</small></label>
</div>}
{mode === 'sqs' && <div className="apigateway-route-explorer"><p className="eyebrow">Creation plan</p><p className="muted compact-text">Create Lambda, create SQS queue <code>{queueName || 'your-queue'}</code>, then create an event source mapping with a batch size of <code>{batchSize || '10'}</code>.</p></div>}
{blueprintError && <p className="form-error" role="alert">{blueprintError}</p>}
<DynamicFormRenderer schema={schema} initialValues={blueprint} isSubmitting={isSubmitting} submitLabel={mode === 's3' ? 'Create blueprint' : 'Create Lambda'} pendingLabel="Creating Lambda" submitError={submitError} onCancel={onCancel} onSubmit={(values) => {
if (mode === 's3' && !bucketName.trim()) {
setBlueprintError('S3 bucket name is required.')
return
}
if (mode === 'api' && (!apiName.trim() || !apiPath.trim() || !apiStage.trim())) {
setBlueprintError('API name, route path, and stage are required.')
return
}
if (mode === 'scheduled' && (!ruleName.trim() || !scheduleExpression.trim())) {
setBlueprintError('Rule name and schedule expression are required.')
return
}
const parsedBatchSize = Number(batchSize)
if (mode === 'sqs' && (!queueName.trim() || !Number.isInteger(parsedBatchSize) || parsedBatchSize < 1 || parsedBatchSize > 10)) {
setBlueprintError('SQS queue name and a batch size from 1 to 10 are required.')
return
}
onSubmit(mode === 's3' ? {...values, blueprintBucketName: bucketName} : mode === 'api' ? {...values, blueprintApiName: apiName, blueprintApiPath: apiPath, blueprintApiMethod: apiMethod, blueprintApiStage: apiStage} : mode === 'scheduled' ? {...values, blueprintRuleName: ruleName, blueprintScheduleExpression: scheduleExpression} : mode === 'sqs' ? {...values, blueprintQueueName: queueName, blueprintSqsBatchSize: parsedBatchSize} : values)
}}/>
</div>
}
const S3_UPLOAD_PROCESSOR_SOURCE = `exports.handler = async (event) => {
for (const record of event.Records ?? []) {
console.log("S3 object created:", {
bucket: record.s3.bucket.name,
key: record.s3.object.key,
event: record.eventName,
});
}
return { statusCode: 200, body: JSON.stringify({message: "Processed S3 event"}) };
};`
const REST_LAMBDA_API_SOURCE = `exports.handler = async (event) => {
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
message: "Hello from Lambda",
method: event.httpMethod,
path: event.path,
}),
};
};`
const SCHEDULED_LAMBDA_SOURCE = `exports.handler = async (event) => {
console.log("Scheduled event received:", {
id: event.id,
time: event.time,
source: event.source,
});
return { statusCode: 200, body: JSON.stringify({message: "Scheduled task completed"}) };
};`
const SQS_WORKER_SOURCE = `exports.handler = async (event) => {
for (const record of event.Records ?? []) {
console.log("SQS message received:", {
messageId: record.messageId,
body: record.body,
attributes: record.attributes,
});
}
return { statusCode: 200, body: JSON.stringify({message: "Processed SQS batch"}) };
};`
@@ -0,0 +1,69 @@
import {Activity, RefreshCw, Timer, TriangleAlert} from 'lucide-react'
import {useQuery} from '@tanstack/react-query'
import {queryLogs} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
interface LambdaInvocationHistoryPanelProps {
cloud: CloudProvider
functionName: string
runtimeReachable: boolean
}
interface InvocationRecord {
requestId: string
timestamp: string
durationMs: number
errorType: string | null
}
const HISTORY_QUERY = 'fields @timestamp, @message\n| filter @message like /REPORT RequestId:/\n| sort @timestamp desc\n| limit 20'
export function LambdaInvocationHistoryPanel({cloud, functionName, runtimeReachable}: LambdaInvocationHistoryPanelProps) {
const history = useQuery({
queryKey: ['lambda-invocation-history', cloud, functionName],
queryFn: async () => {
const endTime = Math.floor(Date.now() / 1000)
try {
const result = await queryLogs(cloud, `/aws/lambda/${functionName}`, {
queryString: HISTORY_QUERY,
startTime: endTime - 24 * 60 * 60,
endTime,
})
return result.rows.map(toInvocationRecord).filter((record): record is InvocationRecord => record !== null)
} catch (error) {
if (isMissingLogGroup(error)) return []
throw error
}
},
enabled: cloud === 'aws' && runtimeReachable,
})
const records = history.data ?? []
const errors = records.filter((record) => record.errorType !== null).length
const averageDuration = records.length === 0 ? null : records.reduce((sum, record) => sum + record.durationMs, 0) / records.length
return <section className="table-panel">
<div className="dynamic-stage-header">
<div><p className="eyebrow">Observability</p><h3><Activity size={15}/> Invocation history</h3><p className="muted compact-text">Real Lambda platform records from the last 24 hours.</p></div>
<button className="button" type="button" disabled={history.isFetching} onClick={() => history.refetch()}><RefreshCw size={13}/>{history.isFetching ? 'Refreshing' : 'Refresh'}</button>
</div>
<div className="apigateway-request-flow">
<div className="apigateway-flow-step"><Timer size={16}/><div><strong>{averageDuration === null ? 'No duration' : `${averageDuration.toFixed(1)} ms`}</strong><small>Average duration</small></div></div>
<div className="apigateway-flow-step"><TriangleAlert size={16}/><div><strong>{errors}</strong><small>Function errors</small></div></div>
<div className="apigateway-flow-step"><Activity size={16}/><div><strong>{records.length}</strong><small>Recorded invocations</small></div></div>
</div>
{history.isError && <p className="error-text compact-text">{history.error instanceof Error ? history.error.message : 'Unable to load invocation history.'}</p>}
{!history.isLoading && records.length === 0 && <p className="muted compact-text">No Lambda platform records yet. Invoke the function or trigger it from S3, then refresh.</p>}
{records.length > 0 && <table className="table"><thead><tr><th>Time</th><th>Request ID</th><th>Duration</th><th>Outcome</th></tr></thead><tbody>{records.map((record) => <tr key={record.requestId}><td>{record.timestamp}</td><td><code>{record.requestId}</code></td><td>{record.durationMs.toFixed(2)} ms</td><td>{record.errorType ? <span className="error-text">{record.errorType}</span> : 'Success'}</td></tr>)}</tbody></table>}
</section>
}
function toInvocationRecord(row: Record<string, string>): InvocationRecord | null {
const message = row['@message'] ?? ''
const match = /REPORT RequestId: (\S+) Duration: ([\d.]+) ms.*?(?: Error Type: (.+))?$/.exec(message)
if (!match) return null
return {requestId: match[1], timestamp: row['@timestamp'] ?? '', durationMs: Number(match[2]), errorType: match[3] ?? null}
}
function isMissingLogGroup(error: unknown): boolean {
return error instanceof Error && /log group does not exist|resource_not_found/i.test(error.message)
}
@@ -19,6 +19,7 @@ interface LogsQueryPanelProps {
cloud: CloudProvider
logGroupName?: string
runtimeReachable: boolean
autoRunKey?: number
}
/**
@@ -35,19 +36,24 @@ interface LogsQueryPanelProps {
* implement (stats/visualize, pattern analysis, live tail) — this repo's own
* rule against decorative, unwired controls.
*/
export function LogsQueryPanel({cloud, logGroupName, runtimeReachable}: LogsQueryPanelProps) {
export function LogsQueryPanel({cloud, logGroupName, runtimeReachable, autoRunKey = 0}: LogsQueryPanelProps) {
const [queryText, setQueryText] = useState(DEFAULT_QUERY)
const [rangeMinutes, setRangeMinutes] = useState(RANGE_OPTIONS[1].minutes)
const [expanded, setExpanded] = useState(false)
const queryMut = useMutation({
mutationFn: () => {
mutationFn: async () => {
const endTime = Math.floor(Date.now() / 1000)
return queryLogs(cloud, logGroupName ?? '', {
queryString: queryText,
startTime: endTime - rangeMinutes * 60,
endTime,
})
try {
return await queryLogs(cloud, logGroupName ?? '', {
queryString: queryText,
startTime: endTime - rangeMinutes * 60,
endTime,
})
} catch (error) {
if (isMissingLogGroup(error)) return {queryId: '', status: 'Complete', rows: []}
throw error
}
},
})
const {mutate: runQuery, reset: resetQuery} = queryMut
@@ -62,6 +68,10 @@ export function LogsQueryPanel({cloud, logGroupName, runtimeReachable}: LogsQuer
setExpanded(false)
}, [logGroupName, resetQuery])
useEffect(() => {
if (autoRunKey > 0 && logGroupName && runtimeReachable) runQuery()
}, [autoRunKey, logGroupName, runQuery, runtimeReachable])
if (!logGroupName) return null
function submit() {
@@ -150,6 +160,10 @@ function macShortcut(): string {
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'
}
function isMissingLogGroup(error: unknown): boolean {
return error instanceof Error && /log group does not exist|resource_not_found/i.test(error.message)
}
const STILL_RUNNING_STATUSES = new Set(['Running', 'Scheduled'])
function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult; expanded: boolean}) {
@@ -120,7 +120,7 @@ export function ResourceTable({
)}
{canEdit && (
<button
className="icon-btn"
className={schema.cloud === 'aws' && schema.service === 'serverless' ? 'button compact lambda-edit-button' : 'icon-btn'}
type="button"
title={`Edit ${resource.name}`}
aria-label={`Edit ${resource.name}`}
@@ -130,6 +130,7 @@ export function ResourceTable({
}}
>
<Pencil size={13} />
{schema.cloud === 'aws' && schema.service === 'serverless' && 'Edit Lambda'}
</button>
)}
{canDelete && (
@@ -187,4 +188,3 @@ export function ResourceTable({
</table>
);
}
@@ -0,0 +1,136 @@
import {useEffect, useState} from 'react'
import {HardDrive, Loader2, Plus, Trash2, Zap} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {createS3LambdaTrigger, deleteS3LambdaTrigger, listCloudResources, listS3LambdaTriggers, type S3LambdaTriggerOverview} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
import {LogsQueryPanel} from './LogsQueryPanel'
interface S3LambdaTriggerPanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
}
interface TriggerWithBucket extends S3LambdaTriggerOverview {
bucketName: string
}
export function S3LambdaTriggerPanel({cloud, resource, runtimeReachable}: S3LambdaTriggerPanelProps) {
const [bucketName, setBucketName] = useState('')
const [eventName, setEventName] = useState('s3:ObjectCreated:*')
const [prefix, setPrefix] = useState('')
const [suffix, setSuffix] = useState('')
const [deleteTarget, setDeleteTarget] = useState<TriggerWithBucket | null>(null)
const queryClient = useQueryClient()
const bucketsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'storage'],
queryFn: ({signal}) => listCloudResources(cloud, 'storage', undefined, signal),
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
})
const triggersQuery = useQuery({
queryKey: ['s3-lambda-triggers', cloud, resource?.id],
queryFn: async ({signal}): Promise<TriggerWithBucket[]> => {
const buckets = await listCloudResources(cloud, 'storage', undefined, signal)
const results = await Promise.all(buckets.map(async (bucket) => {
const triggers = await listS3LambdaTriggers(cloud, bucket.id, signal)
return triggers.filter((trigger) => trigger.functionName === resource!.name).map((trigger) => ({...trigger, bucketName: bucket.id}))
}))
return results.flat()
},
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setBucketName('')
setPrefix('')
setSuffix('')
setDeleteTarget(null)
}, [resource?.id])
const createMutation = useMutation({
mutationFn: () => createS3LambdaTrigger(cloud, bucketName, {functionName: resource!.name, events: [eventName], prefix, suffix}),
onSuccess: () => {
setPrefix('')
setSuffix('')
queryClient.invalidateQueries({queryKey: ['s3-lambda-triggers', cloud, resource?.id]})
},
})
const deleteMutation = useMutation({
mutationFn: (trigger: TriggerWithBucket) => deleteS3LambdaTrigger(cloud, trigger.bucketName, trigger.id),
onSuccess: () => {
setDeleteTarget(null)
queryClient.invalidateQueries({queryKey: ['s3-lambda-triggers', cloud, resource?.id]})
},
})
if (!resource || resource.service !== 'serverless' || resource.type !== 'lambda') return null
const buckets = bucketsQuery.data?.filter((bucket) => bucket.type === 'bucket') ?? []
const canCreate = Boolean(bucketName) && !createMutation.isPending
return <section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">Event Source</p>
<h3><Zap size={15}/> Add trigger</h3>
<p className="muted compact-text">Invoke <code>{resource.name}</code> when an S3 object is created.</p>
</div>
</div>
<div className="resource-create-inline">
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
event.preventDefault()
if (canCreate) createMutation.mutate()
}}>
<label className="dynamic-field">
<span>S3 bucket</span>
<select className="input" value={bucketName} onChange={(event) => setBucketName(event.target.value)} required>
<option value="">Select a bucket</option>
{buckets.map((bucket) => <option key={bucket.id} value={bucket.id}>{bucket.name}</option>)}
</select>
<small>{bucketsQuery.isLoading ? 'Loading buckets...' : buckets.length === 0 ? 'Create an S3 bucket before adding a trigger.' : 'The bucket that sends object-created events.'}</small>
</label>
<label className="dynamic-field">
<span>Event type</span>
<select className="input" value={eventName} onChange={(event) => setEventName(event.target.value)}>
<option value="s3:ObjectCreated:*">All object created events</option>
<option value="s3:ObjectCreated:Put">Object uploaded (PUT)</option>
<option value="s3:ObjectCreated:Post">Object uploaded (POST)</option>
<option value="s3:ObjectCreated:Copy">Object copied</option>
<option value="s3:ObjectCreated:CompleteMultipartUpload">Multipart upload completed</option>
</select>
</label>
<label className="dynamic-field">
<span>Prefix filter</span>
<input className="input" value={prefix} onChange={(event) => setPrefix(event.target.value)} placeholder="uploads/"/>
<small>Optional object-key prefix.</small>
</label>
<label className="dynamic-field">
<span>Suffix filter</span>
<input className="input" value={suffix} onChange={(event) => setSuffix(event.target.value)} placeholder=".json"/>
<small>Optional object-key suffix.</small>
</label>
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}
{createMutation.isPending ? 'Adding trigger' : 'Add trigger'}
</button>
</form>
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add S3 trigger.'}</p>}
</div>
<div className="apigateway-route-explorer">
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><HardDrive size={15}/> S3 object created</h3></div></div>
{triggersQuery.isLoading && <p className="muted compact-text">Loading S3 triggers...</p>}
{triggersQuery.isError && <p className="error-text compact-text">{triggersQuery.error instanceof Error ? triggersQuery.error.message : 'Unable to load S3 triggers.'}</p>}
{triggersQuery.data?.length === 0 && <p className="muted compact-text">No S3 triggers configured for this Lambda.</p>}
{triggersQuery.data?.map((trigger) => <div className="apigateway-route-item" key={`${trigger.bucketName}-${trigger.id}`}>
<div className="apigateway-route-select"><span className="apigateway-route-method">S3</span><code>{trigger.bucketName}</code><small>{trigger.events.join(', ')}{trigger.prefix ? ` · prefix: ${trigger.prefix}` : ''}{trigger.suffix ? ` · suffix: ${trigger.suffix}` : ''}</small></div>
{deleteTarget?.id === trigger.id && deleteTarget.bucketName === trigger.bucketName ? <div className="apigateway-route-confirm"><span>Remove trigger?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(trigger)}>Remove</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Remove S3 trigger from ${trigger.bucketName}`} onClick={() => setDeleteTarget(trigger)}><Trash2 size={13}/></button>}
</div>)}
</div>
{triggersQuery.data && triggersQuery.data.length > 0 && (
<div className="apigateway-route-explorer">
<div className="dynamic-stage-header"><div><p className="eyebrow">Recent Activity</p><h3><Zap size={15}/> Lambda logs</h3><p className="muted compact-text">Inspect the function logs generated by S3-triggered invocations.</p></div></div>
<LogsQueryPanel cloud={cloud} logGroupName={`/aws/lambda/${resource.name}`} runtimeReachable={runtimeReachable} autoRunKey={triggersQuery.data.length}/>
</div>
)}
</section>
}
@@ -0,0 +1,53 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {expect, test, vi} from 'vitest'
import {ScheduledLambdaTriggerPanel} from './ScheduledLambdaTriggerPanel'
import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
createEventBridgeLambdaTrigger: vi.fn(),
deleteEventBridgeLambdaTrigger: vi.fn(),
listEventBridgeLambdaTriggers: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => apiMocks)
const lambda: CloudResource = {
id: 'reporter', name: 'reporter', cloud: 'aws', service: 'serverless', type: 'lambda',
region: null, createdAt: null, metadata: {},
}
function renderPanel() {
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(<QueryClientProvider client={queryClient}><ScheduledLambdaTriggerPanel cloud="aws" resource={lambda} runtimeReachable/></QueryClientProvider>)
}
test('creates a scheduled EventBridge Lambda trigger', async () => {
apiMocks.listEventBridgeLambdaTriggers.mockResolvedValue([])
apiMocks.createEventBridgeLambdaTrigger.mockResolvedValue({id: 'hourly-report', ruleName: 'hourly-report', scheduleExpression: 'rate(1 hour)'})
const user = userEvent.setup()
renderPanel()
await user.type(screen.getByLabelText(/rule name/i), 'hourly-report')
await user.clear(screen.getByLabelText(/schedule expression/i))
await user.type(screen.getByLabelText(/schedule expression/i), 'rate(1 hour)')
await user.click(screen.getByRole('button', {name: 'Add schedule'}))
await waitFor(() => expect(apiMocks.createEventBridgeLambdaTrigger).toHaveBeenCalledWith('aws', 'reporter', {
ruleName: 'hourly-report', scheduleExpression: 'rate(1 hour)',
}))
})
test('confirms before deleting a scheduled trigger', async () => {
apiMocks.listEventBridgeLambdaTriggers.mockResolvedValue([{id: 'hourly-report', ruleName: 'hourly-report', scheduleExpression: 'rate(1 hour)', state: 'ENABLED'}])
apiMocks.deleteEventBridgeLambdaTrigger.mockResolvedValue(undefined)
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Remove schedule hourly-report'}))
expect(screen.getByText('Remove schedule?')).toBeVisible()
await user.click(screen.getByRole('button', {name: 'Remove'}))
await waitFor(() => expect(apiMocks.deleteEventBridgeLambdaTrigger).toHaveBeenCalledWith('aws', 'reporter', 'hourly-report'))
})
@@ -0,0 +1,90 @@
import {useEffect, useState} from 'react'
import {CalendarClock, Loader2, Plus, Trash2, Zap} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {createEventBridgeLambdaTrigger, deleteEventBridgeLambdaTrigger, listEventBridgeLambdaTriggers, type EventBridgeLambdaTriggerOverview} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
interface ScheduledLambdaTriggerPanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
}
export function ScheduledLambdaTriggerPanel({cloud, resource, runtimeReachable}: ScheduledLambdaTriggerPanelProps) {
const [ruleName, setRuleName] = useState('')
const [scheduleExpression, setScheduleExpression] = useState('rate(5 minutes)')
const [deleteTarget, setDeleteTarget] = useState<EventBridgeLambdaTriggerOverview | null>(null)
const queryClient = useQueryClient()
const triggersQuery = useQuery({
queryKey: ['eventbridge-lambda-triggers', cloud, resource?.id],
queryFn: ({signal}) => listEventBridgeLambdaTriggers(cloud, resource!.name, signal),
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setRuleName('')
setScheduleExpression('rate(5 minutes)')
setDeleteTarget(null)
}, [resource?.id])
const createMutation = useMutation({
mutationFn: () => createEventBridgeLambdaTrigger(cloud, resource!.name, {ruleName, scheduleExpression}),
onSuccess: () => {
setRuleName('')
queryClient.invalidateQueries({queryKey: ['eventbridge-lambda-triggers', cloud, resource?.id]})
},
})
const deleteMutation = useMutation({
mutationFn: (trigger: EventBridgeLambdaTriggerOverview) => deleteEventBridgeLambdaTrigger(cloud, resource!.name, trigger.id),
onSuccess: () => {
setDeleteTarget(null)
queryClient.invalidateQueries({queryKey: ['eventbridge-lambda-triggers', cloud, resource?.id]})
},
})
if (!resource || resource.service !== 'serverless' || resource.type !== 'lambda') return null
const canCreate = Boolean(ruleName.trim() && scheduleExpression.trim()) && !createMutation.isPending
return <section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">Schedule</p>
<h3><CalendarClock size={15}/> Add scheduled trigger</h3>
<p className="muted compact-text">Invoke <code>{resource.name}</code> on an EventBridge schedule.</p>
</div>
</div>
<div className="resource-create-inline">
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
event.preventDefault()
if (canCreate) createMutation.mutate()
}}>
<label className="dynamic-field">
<span>Rule name</span>
<input className="input" value={ruleName} onChange={(event) => setRuleName(event.target.value)} placeholder="hourly-report" required/>
<small>A unique EventBridge rule name.</small>
</label>
<label className="dynamic-field">
<span>Schedule expression</span>
<input className="input" value={scheduleExpression} onChange={(event) => setScheduleExpression(event.target.value)} placeholder="rate(5 minutes)" required/>
<small>Use a rate or cron expression, for example <code>rate(1 hour)</code>.</small>
</label>
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}
{createMutation.isPending ? 'Adding schedule' : 'Add schedule'}
</button>
</form>
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add scheduled trigger.'}</p>}
</div>
<div className="apigateway-route-explorer">
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Schedules</p><h3><Zap size={15}/> EventBridge rules</h3></div></div>
{triggersQuery.isLoading && <p className="muted compact-text">Loading scheduled triggers...</p>}
{triggersQuery.isError && <p className="error-text compact-text">{triggersQuery.error instanceof Error ? triggersQuery.error.message : 'Unable to load scheduled triggers.'}</p>}
{triggersQuery.data?.length === 0 && <p className="muted compact-text">No scheduled triggers configured for this Lambda.</p>}
{triggersQuery.data?.map((trigger) => <div className="apigateway-route-item" key={trigger.id}>
<div className="apigateway-route-select"><span className="apigateway-route-method">RULE</span><code>{trigger.ruleName}</code><small>{trigger.scheduleExpression}{trigger.state ? ` · ${trigger.state}` : ''}</small></div>
{deleteTarget?.id === trigger.id ? <div className="apigateway-route-confirm"><span>Remove schedule?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(trigger)}>Remove</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Remove schedule ${trigger.ruleName}`} onClick={() => setDeleteTarget(trigger)}><Trash2 size={13}/></button>}
</div>)}
</div>
</section>
}
@@ -5,6 +5,7 @@ import {invokeCloudResource} from "@/api/cloudProxyClient";
import type {ServerlessInvokeResult} from "@/api/cloudProxyClient";
import type {CloudProvider} from "@/types/cloud";
import type {CloudResource} from "@/types/resource";
import {LogsQueryPanel} from "./LogsQueryPanel";
interface ServerlessInvokePanelProps {
cloud: CloudProvider;
@@ -22,6 +23,7 @@ export function ServerlessInvokePanel({
const [showLog, setShowLog] = useState(false);
const [copied, setCopied] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [logsRefreshKey, setLogsRefreshKey] = useState(0);
useEffect(() => {
setPayload("{\n \n}");
@@ -29,6 +31,7 @@ export function ServerlessInvokePanel({
setShowLog(false);
setCopied(false);
setValidationError(null);
setLogsRefreshKey(0);
}, [resource?.id]);
const isSupportedResource =
@@ -54,6 +57,7 @@ export function ServerlessInvokePanel({
setInvokeResult(result);
setShowLog(false);
setCopied(false);
setLogsRefreshKey((value) => value + 1);
},
});
@@ -221,6 +225,14 @@ const clearPayload = () => {
</div>
)}
</div>
{cloud === "aws" && invokeResult && (
<LogsQueryPanel
cloud={cloud}
logGroupName={`/aws/lambda/${resource.name}`}
runtimeReachable={runtimeReachable}
autoRunKey={logsRefreshKey}
/>
)}
</section>
);
}
@@ -0,0 +1,64 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {expect, test, vi} from 'vitest'
import {SqsLambdaTriggerPanel} from './SqsLambdaTriggerPanel'
import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
createSqsLambdaEventSourceMapping: vi.fn(),
deleteSqsLambdaEventSourceMapping: vi.fn(),
listCloudResources: vi.fn(),
listSqsLambdaEventSourceMappings: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => apiMocks)
const lambda: CloudResource = {
id: 'worker', name: 'worker', cloud: 'aws', service: 'serverless', type: 'lambda',
region: null, createdAt: null, metadata: {},
}
const queue: CloudResource = {
id: 'orders', name: 'orders', cloud: 'aws', service: 'messaging', type: 'queue',
region: null, createdAt: null, metadata: {arn: 'arn:aws:sqs:us-east-1:000000000000:orders'},
}
function renderPanel() {
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(<QueryClientProvider client={queryClient}><SqsLambdaTriggerPanel cloud="aws" resource={lambda} runtimeReachable/></QueryClientProvider>)
}
test('creates an SQS Lambda event source mapping from the selected queue ARN', async () => {
apiMocks.listCloudResources.mockResolvedValue([queue])
apiMocks.listSqsLambdaEventSourceMappings.mockResolvedValue([])
apiMocks.createSqsLambdaEventSourceMapping.mockResolvedValue({id: 'mapping-1'})
const user = userEvent.setup()
renderPanel()
await screen.findByRole('option', {name: 'orders'})
await user.selectOptions(screen.getByLabelText(/sqs queue/i), 'arn:aws:sqs:us-east-1:000000000000:orders')
await user.clear(screen.getByLabelText(/batch size/i))
await user.type(screen.getByLabelText(/batch size/i), '5')
await user.click(screen.getByRole('button', {name: 'Add trigger'}))
await waitFor(() => expect(apiMocks.createSqsLambdaEventSourceMapping).toHaveBeenCalledWith('aws', 'worker', {
queueArn: 'arn:aws:sqs:us-east-1:000000000000:orders', batchSize: 5,
}))
})
test('confirms before deleting an SQS Lambda event source mapping', async () => {
apiMocks.listCloudResources.mockResolvedValue([queue])
apiMocks.listSqsLambdaEventSourceMappings.mockResolvedValue([{
id: 'mapping-1', queueArn: queue.metadata.arn, batchSize: 10, state: 'Enabled',
}])
apiMocks.deleteSqsLambdaEventSourceMapping.mockResolvedValue(undefined)
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByRole('button', {name: /remove sqs trigger from/i}))
expect(screen.getByText('Remove trigger?')).toBeVisible()
await user.click(screen.getByRole('button', {name: 'Remove'}))
await waitFor(() => expect(apiMocks.deleteSqsLambdaEventSourceMapping).toHaveBeenCalledWith('aws', 'worker', 'mapping-1'))
})
@@ -0,0 +1,99 @@
import {useEffect, useState} from 'react'
import {Loader2, MessageSquare, Plus, Trash2, Zap} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {createSqsLambdaEventSourceMapping, deleteSqsLambdaEventSourceMapping, listCloudResources, listSqsLambdaEventSourceMappings, type SqsLambdaEventSourceMappingOverview} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
interface SqsLambdaTriggerPanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
}
export function SqsLambdaTriggerPanel({cloud, resource, runtimeReachable}: SqsLambdaTriggerPanelProps) {
const [queueArn, setQueueArn] = useState('')
const [batchSize, setBatchSize] = useState('10')
const [deleteTarget, setDeleteTarget] = useState<SqsLambdaEventSourceMappingOverview | null>(null)
const queryClient = useQueryClient()
const queuesQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'messaging'],
queryFn: ({signal}) => listCloudResources(cloud, 'messaging', undefined, signal),
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
})
const mappingsQuery = useQuery({
queryKey: ['sqs-lambda-event-source-mappings', cloud, resource?.id],
queryFn: ({signal}) => listSqsLambdaEventSourceMappings(cloud, resource!.name, signal),
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setQueueArn('')
setBatchSize('10')
setDeleteTarget(null)
}, [resource?.id])
const createMutation = useMutation({
mutationFn: () => createSqsLambdaEventSourceMapping(cloud, resource!.name, {queueArn, batchSize: Number(batchSize)}),
onSuccess: () => {
setQueueArn('')
queryClient.invalidateQueries({queryKey: ['sqs-lambda-event-source-mappings', cloud, resource?.id]})
},
})
const deleteMutation = useMutation({
mutationFn: (mapping: SqsLambdaEventSourceMappingOverview) => deleteSqsLambdaEventSourceMapping(cloud, resource!.name, mapping.id),
onSuccess: () => {
setDeleteTarget(null)
queryClient.invalidateQueries({queryKey: ['sqs-lambda-event-source-mappings', cloud, resource?.id]})
},
})
if (!resource || resource.service !== 'serverless' || resource.type !== 'lambda') return null
const queues = (queuesQuery.data ?? []).filter((queue) => (queue.type === 'queue' || queue.type === 'fifo-queue') && typeof queue.metadata.arn === 'string')
const parsedBatchSize = Number(batchSize)
const canCreate = Boolean(queueArn) && Number.isInteger(parsedBatchSize) && parsedBatchSize >= 1 && parsedBatchSize <= 10 && !createMutation.isPending
return <section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">Event Source</p>
<h3><Zap size={15}/> Add SQS trigger</h3>
<p className="muted compact-text">Poll an SQS queue and invoke <code>{resource.name}</code> for each batch.</p>
</div>
</div>
<div className="resource-create-inline">
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
event.preventDefault()
if (canCreate) createMutation.mutate()
}}>
<label className="dynamic-field">
<span>SQS queue</span>
<select className="input" value={queueArn} onChange={(event) => setQueueArn(event.target.value)} required>
<option value="">Select a queue</option>
{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}
</select>
<small>{queuesQuery.isLoading ? 'Loading queues...' : queues.length === 0 ? 'Create an SQS queue before adding a trigger.' : 'The queue Lambda polls for messages.'}</small>
</label>
<label className="dynamic-field">
<span>Batch size</span>
<input className="input" type="number" min="1" max="10" value={batchSize} onChange={(event) => setBatchSize(event.target.value)} required/>
<small>Messages sent to Lambda per invocation, from 1 to 10.</small>
</label>
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>} {createMutation.isPending ? 'Adding trigger' : 'Add trigger'}
</button>
</form>
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add SQS trigger.'}</p>}
</div>
<div className="apigateway-route-explorer">
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><MessageSquare size={15}/> SQS event source mappings</h3></div></div>
{mappingsQuery.isLoading && <p className="muted compact-text">Loading SQS triggers...</p>}
{mappingsQuery.isError && <p className="error-text compact-text">{mappingsQuery.error instanceof Error ? mappingsQuery.error.message : 'Unable to load SQS triggers.'}</p>}
{mappingsQuery.data?.length === 0 && <p className="muted compact-text">No SQS triggers configured for this Lambda.</p>}
{mappingsQuery.data?.map((mapping) => <div className="apigateway-route-item" key={mapping.id}>
<div className="apigateway-route-select"><span className="apigateway-route-method">SQS</span><code>{mapping.queueArn}</code><small>Batch size: {mapping.batchSize ?? 'default'}{mapping.state ? ` · ${mapping.state}` : ''}</small></div>
{deleteTarget?.id === mapping.id ? <div className="apigateway-route-confirm"><span>Remove trigger?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(mapping)}>Remove</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Remove SQS trigger from ${mapping.queueArn}`} onClick={() => setDeleteTarget(mapping)}><Trash2 size={13}/></button>}
</div>)}
</div>
</section>
}
@@ -0,0 +1,100 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {expect, test, vi} from 'vitest'
import {StorageObjectBrowser} from './StorageObjectBrowser'
import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
copyStorageObject: vi.fn(),
createCloudResource: vi.fn(),
deleteStorageObject: vi.fn(),
listCloudResources: vi.fn(),
listStorageObjects: vi.fn(),
storageObjectDownloadUrl: vi.fn(),
uploadStorageObject: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => apiMocks)
const bucket: CloudResource = {
id: 'incoming-orders', name: 'incoming-orders', cloud: 'aws', service: 'storage', type: 'bucket',
region: null, createdAt: null, metadata: {},
}
test('deletes a folder and all nested objects', async () => {
apiMocks.listStorageObjects.mockImplementation((_cloud: string, _bucket: string, prefix: string) => {
if (prefix === 'orders/') {
return Promise.resolve({prefix, objects: [{
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
}]})
}
return Promise.resolve({prefix, objects: [{
key: 'orders/', name: 'orders', type: 'folder', size: null, lastModified: null, metadata: {},
}]})
})
apiMocks.deleteStorageObject.mockResolvedValue(undefined)
const user = userEvent.setup()
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(
<QueryClientProvider client={queryClient}>
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={['delete']} onSelectObject={() => {}}/>
</QueryClientProvider>,
)
await user.click(await screen.findByRole('button', {name: 'Delete orders folder'}))
await user.click(screen.getByRole('button', {name: 'Delete folder'}))
await waitFor(() => {
expect(apiMocks.deleteStorageObject).toHaveBeenCalledWith('aws', 'incoming-orders', 'orders/order.json')
expect(apiMocks.deleteStorageObject).toHaveBeenCalledWith('aws', 'incoming-orders', 'orders/')
})
})
test('requires the bucket name before emptying all folder contents', async () => {
apiMocks.listStorageObjects.mockImplementation((_cloud: string, _bucket: string, prefix: string) => {
if (prefix === 'orders/') {
return Promise.resolve({prefix, objects: [{
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
}]})
}
return Promise.resolve({prefix, objects: [{
key: 'orders/', name: 'orders', type: 'folder', size: null, lastModified: null, metadata: {},
}]})
})
apiMocks.deleteStorageObject.mockResolvedValue(undefined)
const user = userEvent.setup()
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(
<QueryClientProvider client={queryClient}>
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={['delete']} onSelectObject={() => {}}/>
</QueryClientProvider>,
)
await user.click(await screen.findByRole('button', {name: 'Empty bucket'}))
expect(screen.getAllByRole('button', {name: /^Empty bucket$/})[1]).toBeDisabled()
await user.type(screen.getByPlaceholderText('incoming-orders'), 'incoming-orders')
await user.click(screen.getAllByRole('button', {name: /^Empty bucket$/})[1])
await waitFor(() => {
expect(apiMocks.deleteStorageObject).toHaveBeenCalledWith('aws', 'incoming-orders', 'orders/order.json')
expect(apiMocks.deleteStorageObject).toHaveBeenCalledWith('aws', 'incoming-orders', 'orders/')
})
})
test('shows an object extension instead of a generic object type', async () => {
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
}]})
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(
<QueryClientProvider client={queryClient}>
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={['delete']} onSelectObject={() => {}}/>
</QueryClientProvider>,
)
expect(await screen.findByText('JSON file')).toBeInTheDocument()
})
@@ -35,11 +35,16 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
const [folderName, setFolderName] = useState('')
const [createFolderOpen, setCreateFolderOpen] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [deleteFolderConfirm, setDeleteFolderConfirm] = useState<string | null>(null)
const [emptyBucketConfirm, setEmptyBucketConfirm] = useState(false)
const [emptyBucketText, setEmptyBucketText] = useState('')
const [copyObject, setCopyObject] = useState<StorageObject | null>(null)
const [search, setSearch] = useState('')
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
const [bulkDeleting, setBulkDeleting] = useState(false)
const [previewObject, setPreviewObject] = useState<StorageObject | null>(null)
const [folderUpload, setFolderUpload] = useState<{completed: number; total: number; failed: number} | null>(null)
const [folderUploadNotice, setFolderUploadNotice] = useState<string | null>(null)
const resolvedCapabilities = withRuntimeState(normalizeCapabilities(capabilities), runtimeReachable)
const uploadCapability = capabilityFor(resolvedCapabilities, 'upload')
@@ -69,10 +74,15 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
setFolderName('')
setCreateFolderOpen(false)
setDeleteConfirm(null)
setDeleteFolderConfirm(null)
setEmptyBucketConfirm(false)
setEmptyBucketText('')
setCopyObject(null)
setSearch('')
setSelectedKeys(new Set())
setPreviewObject(null)
setFolderUpload(null)
setFolderUploadNotice(null)
onSelectObject(undefined)
}, [resource?.id, onSelectObject])
@@ -91,6 +101,27 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
})
const folderUploadMut = useMutation({
mutationFn: async (files: File[]) => {
if (!resource) return
const basePrefix = uploadPrefix || prefix
setFolderUpload({completed: 0, total: files.length, failed: 0})
let completed = 0
let failed = 0
for (const file of files) {
const relativePath = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name
try {
await uploadStorageObject(cloud, resource.id, `${basePrefix}${relativePath}`, file)
completed++
} catch {
failed++
}
setFolderUpload({completed, total: files.length, failed})
}
},
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
})
const createFolderMut = useMutation({
mutationFn: async (name: string) => {
if (!resource) return
@@ -115,6 +146,31 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
},
})
const deleteFolderMut = useMutation({
mutationFn: async (folder: StorageObject) => {
if (!resource) return
await deleteFolderContents(cloud, resource.id, folder.key)
},
onSuccess: (_, folder) => {
if (selectedObjectKey?.startsWith(folder.key)) onSelectObject(undefined)
setDeleteFolderConfirm(null)
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
},
})
const emptyBucketMut = useMutation({
mutationFn: async () => {
if (!resource) return
await emptyBucket(cloud, resource.id)
},
onSuccess: () => {
setEmptyBucketConfirm(false)
setEmptyBucketText('')
onSelectObject(undefined)
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
},
})
const moveCopyMut = useMutation({
mutationFn: async ({srcKey, destKey, destResourceId, mode}: {
srcKey: string; destKey: string; destResourceId?: string; mode: 'move' | 'copy'
@@ -137,6 +193,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
setSearch('')
setSelectedKeys(new Set())
setDeleteConfirm(null)
setDeleteFolderConfirm(null)
setPreviewObject(null)
onSelectObject(undefined)
}
@@ -181,7 +238,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
const allSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selectedKeys.has(f.key))
const someSelected = !allSelected && filteredFiles.some((f) => selectedKeys.has(f.key))
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? moveCopyMut.error
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? deleteFolderMut.error ?? emptyBucketMut.error ?? moveCopyMut.error
return (
<section className="object-browser">
@@ -224,6 +281,12 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
Back
</button>
)}
{cloud === 'aws' && (
<button className="button danger" type="button" disabled={emptyBucketMut.isPending} onClick={() => setEmptyBucketConfirm(true)}>
<Trash2 size={14}/>
Empty bucket
</button>
)}
{createFolderCapability && (
<button className="button" type="button" disabled={!canCreateFolder} title={createFolderCapability.reason} onClick={() => setCreateFolderOpen((open) => !open)}>
<Folder size={14}/>
@@ -267,6 +330,42 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
</button>
</form>
)}
{emptyBucketConfirm && (
<form className="object-create-folder" onSubmit={(event) => {
event.preventDefault()
if (emptyBucketText === resource.name) emptyBucketMut.mutate()
}}>
<label>
<span>Type <code>{resource.name}</code> to permanently delete every object and folder in this bucket.</span>
<input className="input" value={emptyBucketText} onChange={(event) => setEmptyBucketText(event.target.value)} placeholder={resource.name}/>
</label>
<button className="button danger" type="submit" disabled={emptyBucketMut.isPending || emptyBucketText !== resource.name}>
<Trash2 size={14}/>
{emptyBucketMut.isPending ? 'Emptying bucket' : 'Empty bucket'}
</button>
<button className="button" type="button" disabled={emptyBucketMut.isPending} onClick={() => {
setEmptyBucketConfirm(false)
setEmptyBucketText('')
}}>Cancel</button>
</form>
)}
{folderUpload && <p className={folderUpload.failed > 0 ? 'error-text compact-text' : 'muted compact-text'}>
Folder upload: {folderUpload.completed} of {folderUpload.total} files uploaded{folderUpload.failed > 0 ? `, ${folderUpload.failed} failed` : ''}.
</p>}
{folderUploadNotice && <p className="error-text compact-text">{folderUploadNotice}</p>}
{canUpload && <div className="object-upload-dropzone" onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
event.preventDefault()
void droppedFiles(event.dataTransfer).then((files) => {
if (files.length > 0) {
setFolderUploadNotice(null)
folderUploadMut.mutate(files)
} else {
setFolderUploadNotice('Drop files or a folder from your desktop to upload them.')
}
})
}}>
<Folder size={18}/><strong>Drop files or folders here</strong><span>Folder paths are preserved under the current upload prefix.</span>
</div>}
{/* ── Error ── */}
{error && (
@@ -348,10 +447,25 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
<ChevronRight size={12}/>
</span>
</td>
<td>{object.type}</td>
<td>Folder</td>
<td>—</td>
<td>{object.lastModified ?? '—'}</td>
<td/>
<td className="table-actions">
{canDelete && deleteFolderConfirm === object.key ? (
<span className="object-folder-confirm">
<button className="button danger compact" disabled={deleteFolderMut.isPending} onClick={() => deleteFolderMut.mutate(object)}>
Delete folder
</button>
<button className="button compact" disabled={deleteFolderMut.isPending} onClick={() => setDeleteFolderConfirm(null)}>
Cancel
</button>
</span>
) : deleteCapability && (
<button className="icon-btn danger" disabled={!canDelete} title={deleteCapability.reason ?? `Delete ${object.name} and all contents`} aria-label={`Delete ${object.name} folder`} onClick={() => setDeleteFolderConfirm(object.key)}>
<Trash2 size={13}/>
</button>
)}
</td>
</tr>
))}
{filteredFiles.map((object) => {
@@ -389,7 +503,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
{object.name}
</span>
</td>
<td>{object.type}</td>
<td>{objectTypeLabel(object.name)}</td>
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
<td>{object.lastModified ?? '—'}</td>
<td className="table-actions">
@@ -639,3 +753,59 @@ function parentPrefix(prefix: string): string {
return segments.length ? `${segments.join('/')}/` : ''
}
function objectTypeLabel(name: string): string {
const extension = name.split('.').pop()?.trim().toUpperCase()
return extension && extension !== name.toUpperCase() ? `${extension} file` : 'File'
}
interface DroppedEntry {
isFile: boolean
isDirectory: boolean
name: string
file(callback: (file: File) => void, errorCallback?: (error: DOMException) => void): void
createReader(): {readEntries(callback: (entries: DroppedEntry[]) => void, errorCallback?: (error: DOMException) => void): void}
}
async function droppedFiles(dataTransfer: DataTransfer): Promise<File[]> {
const items = Array.from(dataTransfer.items)
const entries = items.map((item) => (item as DataTransferItem & {webkitGetAsEntry?: () => unknown}).webkitGetAsEntry?.() as DroppedEntry | null | undefined).filter((entry): entry is DroppedEntry => entry !== null && entry !== undefined)
if (entries.length === 0) return Array.from(dataTransfer.files)
const files: File[] = []
for (const entry of entries) files.push(...await filesInEntry(entry))
return files
}
async function filesInEntry(entry: DroppedEntry, prefix = ''): Promise<File[]> {
const files: File[] = []
const path = `${prefix}${entry.name}`
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => entry.file(resolve, reject))
Object.defineProperty(file, 'webkitRelativePath', {value: path})
return [file]
}
if (!entry.isDirectory) return files
const reader = entry.createReader()
let entries: DroppedEntry[]
do {
entries = await new Promise<DroppedEntry[]>((resolve, reject) => reader.readEntries(resolve, reject))
for (const child of entries) files.push(...await filesInEntry(child, `${path}/`))
} while (entries.length > 0)
return files
}
async function deleteFolderContents(cloud: CloudProvider, bucketName: string, prefix: string): Promise<void> {
const listing = await listStorageObjects(cloud, bucketName, prefix)
const folders = listing.objects.filter((object) => object.type === 'folder')
const objects = listing.objects.filter((object) => object.type === 'object')
await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key)))
await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key)))
await deleteStorageObject(cloud, bucketName, prefix)
}
async function emptyBucket(cloud: CloudProvider, bucketName: string): Promise<void> {
const listing = await listStorageObjects(cloud, bucketName, '')
const folders = listing.objects.filter((object) => object.type === 'folder')
const objects = listing.objects.filter((object) => object.type === 'object')
await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key)))
await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key)))
}
+60 -1
View File
@@ -1436,6 +1436,18 @@ pre,
color: var(--status-error);
}
.key-value-editor {
display: grid;
gap: 6px;
}
.key-value-row {
align-items: center;
display: grid;
gap: 6px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}
.dynamic-field--span {
grid-column: 1 / -1;
}
@@ -2460,6 +2472,36 @@ pre,
white-space: nowrap;
}
.object-upload-dropzone {
display: flex;
align-items: center;
gap: 9px;
margin: 12px 0;
padding: 14px 16px;
border: 1px dashed var(--border, #4b5563);
border-radius: 7px;
color: var(--text-2);
}
.object-upload-dropzone strong {
color: var(--text-1);
}
.object-upload-dropzone span {
font-size: 12px;
}
.lambda-edit-button {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 65%, var(--border));
color: var(--accent);
}
.lambda-edit-button:hover {
background: color-mix(in srgb, var(--accent) 28%, transparent);
border-color: var(--accent);
}
.table-confirm-actions {
display: inline-flex;
align-items: center;
@@ -2582,13 +2624,30 @@ pre,
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
display: flex;
gap: 8px;
padding: 7px 9px;
text-align: left;
}
.apigateway-route-select {
align-items: center;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: flex;
gap: 8px;
padding: 0;
text-align: left;
}
.apigateway-route-confirm {
align-items: center;
display: flex;
gap: 5px;
}
.apigateway-route-item:hover,
.apigateway-route-item.selected {
border-color: var(--accent);
@@ -0,0 +1,7 @@
import type {StorageObjectList} from '@/types/resource'
export function bucketDeleteWarning(contents: StorageObjectList): string | null {
return contents.objects.length > 0
? 'This bucket contains objects or folders. Delete them before deleting the bucket.'
: null
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type {CloudProvider, CloudServiceType} from './cloud'
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file'
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
export type ActionSchema = 'list' | 'create' | 'update' | 'delete' | 'inspect'
// Mirrors packages/api/src/cloud-spi/types.ts. Lifecycle verbs can be advertised
// in a capability block even though they are not table-level controls.