feat: Cloudformation + RDS
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {ApiGatewayRoutePanel} from './ApiGatewayRoutePanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
@@ -18,6 +18,10 @@ const apiMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const api: CloudResource = {
|
||||
id: 'api-123', name: 'orders', cloud: 'aws', service: 'apigateway', type: 'rest-api',
|
||||
region: null, createdAt: null, metadata: {},
|
||||
@@ -28,9 +32,27 @@ const lambda: CloudResource = {
|
||||
region: null, createdAt: null, metadata: {},
|
||||
}
|
||||
|
||||
const rootResource = {resourceId: 'root-123', parentId: null, path: '/', pathPart: null, methods: []}
|
||||
|
||||
function routesResult(routes: Array<{resourceId: string; path: string; method: string; functionName: string | null; integrationType?: string | null}> = [], stages = ['dev']) {
|
||||
const byResource = new Map<string, typeof routes>()
|
||||
for (const route of routes) byResource.set(route.resourceId, [...(byResource.get(route.resourceId) ?? []), route])
|
||||
return {
|
||||
resources: [rootResource, ...[...byResource.entries()].map(([resourceId, methods]) => ({
|
||||
resourceId,
|
||||
parentId: 'root-123',
|
||||
path: methods[0]!.path,
|
||||
pathPart: methods[0]!.path.slice(1),
|
||||
methods: methods.map((method) => ({...method, integrationType: method.integrationType ?? null})),
|
||||
}))],
|
||||
routes: routes.map((route) => ({...route, integrationType: route.integrationType ?? null})),
|
||||
stages,
|
||||
}
|
||||
}
|
||||
|
||||
test('creates and displays a deployed Lambda proxy route', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
apiMocks.createApiGatewayLambdaRoute.mockResolvedValue({
|
||||
resourceId: 'resource-123', deploymentId: 'deployment-123', invokeUrl: 'http://localhost:4566/execute-api/api-123/dev/orders',
|
||||
})
|
||||
@@ -43,22 +65,88 @@ test('creates and displays a deployed Lambda proxy route', async () => {
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await user.clear(screen.getByPlaceholderText('/orders'))
|
||||
await user.type(screen.getByPlaceholderText('/orders'), '/orders')
|
||||
await user.selectOptions(screen.getAllByRole('combobox')[1]!, 'orders-handler')
|
||||
await user.clear(screen.getByLabelText('Path part'))
|
||||
await user.type(screen.getByLabelText('Path part'), 'orders')
|
||||
await user.selectOptions(screen.getByLabelText('Lambda function'), 'orders-handler')
|
||||
await user.click(screen.getByRole('button', {name: 'Create and deploy route'}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.createApiGatewayLambdaRoute).toHaveBeenCalledWith('aws', 'api-123', {
|
||||
path: '/orders', method: 'GET', stage: 'dev', functionName: 'orders-handler',
|
||||
path: '/orders', parentResourceId: 'root-123', pathPart: 'orders', method: 'GET', stage: 'dev', functionName: 'orders-handler',
|
||||
})
|
||||
})
|
||||
expect(await screen.findByText('Route deployed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders root, empty resources, nested resources, and methods as a tree', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
resources: [
|
||||
rootResource,
|
||||
{resourceId: 'orders-123', parentId: 'root-123', path: '/orders', pathPart: 'orders', methods: []},
|
||||
{resourceId: 'order-123', parentId: 'orders-123', path: '/orders/{id}', pathPart: '{id}', methods: [
|
||||
{resourceId: 'order-123', path: '/orders/{id}', method: 'PATCH', functionName: 'orders-handler', integrationType: 'AWS_PROXY'},
|
||||
]},
|
||||
],
|
||||
routes: [{resourceId: 'order-123', path: '/orders/{id}', method: 'PATCH', functionName: 'orders-handler', integrationType: 'AWS_PROXY'}],
|
||||
stages: ['dev'],
|
||||
})
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
render(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
|
||||
|
||||
const nodes = await screen.findAllByRole('treeitem')
|
||||
expect(nodes).toHaveLength(3)
|
||||
expect(nodes.map((node) => node.getAttribute('aria-level'))).toEqual(['1', '2', '3'])
|
||||
expect(screen.getByRole('button', {name: '/ordersNo methods'})).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', {name: /PATCH.*\/orders\/\{id}.*orders-handler/})).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('creates a path parameter under the selected parent resource', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
resources: [rootResource, {resourceId: 'orders-123', parentId: 'root-123', path: '/orders', pathPart: 'orders', methods: []}],
|
||||
routes: [],
|
||||
stages: ['dev'],
|
||||
})
|
||||
apiMocks.createApiGatewayLambdaRoute.mockResolvedValue({resourceId: 'order-123', deploymentId: 'deployment-123', invokeUrl: '/orders/{id}'})
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
render(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
|
||||
|
||||
await screen.findByRole('option', {name: '/orders'})
|
||||
await user.selectOptions(screen.getByLabelText('Parent resource'), 'orders-123')
|
||||
fireEvent.change(screen.getByLabelText('Path part'), {target: {value: '{id}'}})
|
||||
await user.selectOptions(screen.getByLabelText('Method'), 'PATCH')
|
||||
await user.selectOptions(screen.getByLabelText('Lambda function'), 'orders-handler')
|
||||
await user.click(screen.getByRole('button', {name: 'Create and deploy route'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.createApiGatewayLambdaRoute).toHaveBeenCalledWith('aws', 'api-123', {
|
||||
path: '/orders/{id}', parentResourceId: 'orders-123', pathPart: '{id}', method: 'PATCH', stage: 'dev', functionName: 'orders-handler',
|
||||
}))
|
||||
})
|
||||
|
||||
test('uses a concrete HTTP verb when deployed-testing an ANY method', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'ANY', functionName: 'orders-handler'},
|
||||
]))
|
||||
apiMocks.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '', durationMs: 1})
|
||||
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: /ANY.*\/orders.*orders-handler/}))
|
||||
expect(screen.getByText('ANY is API Gateway configuration, not an HTTP request verb.')).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('HTTP verb for deployed request'), 'HEAD')
|
||||
await user.click(screen.getByRole('button', {name: 'Send deployed HTTP request'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'api-123', expect.objectContaining({method: 'HEAD'})))
|
||||
})
|
||||
|
||||
test('displays and copies the selected API stage base invoke URL', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
@@ -77,7 +165,7 @@ test('displays and copies the selected API stage base invoke URL', async () => {
|
||||
|
||||
test('uses the browser host for a Docker bridge runtime endpoint', () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
render(
|
||||
@@ -91,7 +179,7 @@ test('uses the browser host for a Docker bridge runtime endpoint', () => {
|
||||
|
||||
test('sends a route test request and displays its response', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
apiMocks.testApiGatewayRoute.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
@@ -123,9 +211,9 @@ test('sends a route test request and displays its response', async () => {
|
||||
|
||||
test('tests a selected method through TestInvokeMethod without a deployed stage', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}], stages: [],
|
||||
})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'},
|
||||
], []))
|
||||
apiMocks.testApiGatewayMethod.mockResolvedValue({
|
||||
statusCode: 200, body: '{"message":"Hello"}', headers: {'content-type': 'application/json'},
|
||||
multiValueHeaders: {'set-cookie': ['session=1']}, log: 'Execution completed', latency: 8,
|
||||
@@ -150,7 +238,7 @@ test('tests a selected method through TestInvokeMethod without a deployed stage'
|
||||
|
||||
test('formats the request body as JSON', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult())
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
@@ -169,10 +257,9 @@ test('formats the request body as JSON', async () => {
|
||||
|
||||
test('selects a deployed route for testing', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}],
|
||||
stages: ['dev'],
|
||||
})
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'},
|
||||
]))
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
@@ -184,16 +271,17 @@ test('selects a deployed route for testing', async () => {
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: /POST.*\/orders.*orders-handler/}))
|
||||
|
||||
expect(screen.getByPlaceholderText('/orders')).toHaveValue('/orders')
|
||||
expect(screen.getAllByRole('combobox')[0]).toHaveValue('POST')
|
||||
expect(screen.getAllByRole('combobox')[1]).toHaveValue('orders-handler')
|
||||
expect(screen.getByText(/POST \/dev\/orders/)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Method')).toHaveValue('POST')
|
||||
expect(screen.getByLabelText('Lambda function')).toHaveValue('orders-handler')
|
||||
expect(screen.getByRole('link', {name: 'Open Lambda logs'})).toHaveAttribute('href', '/cloud-explorer/aws/logs?resource=%2Faws%2Flambda%2Forders-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.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'},
|
||||
]))
|
||||
apiMocks.deleteApiGatewayRoute.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
@@ -209,12 +297,10 @@ test('confirms before deleting a deployed route', async () => {
|
||||
|
||||
test('enables CORS for one resource and shows AWS proxy guidance', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
routes: [
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue(routesResult([
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'GET', functionName: null, integrationType: 'HTTP'},
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler', integrationType: 'AWS_PROXY'},
|
||||
], stages: ['dev'],
|
||||
})
|
||||
]))
|
||||
apiMocks.enableApiGatewayCors.mockResolvedValue({configuredMethods: ['GET'], proxyMethods: ['POST']})
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
|
||||
import {useEffect, useState, type CSSProperties} from 'react'
|
||||
import {ArrowRight, Copy, Link2, ListTree, Loader2, Rocket, ScrollText, Send, Trash2, X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayMethod, testApiGatewayRoute} from '@/api/cloudProxyClient'
|
||||
import type {ApiGatewayMethodTestResult} from '@/api/cloudProxyClient'
|
||||
import type {ApiGatewayMethodTestResult, ApiGatewayResourceOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {CopyValueButton} from './CopyValueButton'
|
||||
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
|
||||
interface ApiGatewayRoutePanelProps {
|
||||
cloud: CloudProvider
|
||||
@@ -17,7 +18,10 @@ interface ApiGatewayRoutePanelProps {
|
||||
|
||||
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtimeEndpoint}: ApiGatewayRoutePanelProps) {
|
||||
const [path, setPath] = useState('/hello')
|
||||
const [parentResourceId, setParentResourceId] = useState('')
|
||||
const [pathPart, setPathPart] = useState('hello')
|
||||
const [method, setMethod] = useState('GET')
|
||||
const [anyTestMethod, setAnyTestMethod] = useState('GET')
|
||||
const [stage, setStage] = useState('dev')
|
||||
const [functionName, setFunctionName] = useState('')
|
||||
const [testBody, setTestBody] = useState('{}')
|
||||
@@ -48,7 +52,10 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
|
||||
useEffect(() => {
|
||||
setPath('/hello')
|
||||
setParentResourceId('')
|
||||
setPathPart('hello')
|
||||
setMethod('GET')
|
||||
setAnyTestMethod('GET')
|
||||
setStage('dev')
|
||||
setFunctionName('')
|
||||
setTestBody('{}')
|
||||
@@ -68,13 +75,20 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
}, [resource?.id])
|
||||
|
||||
const routeMutation = useMutation({
|
||||
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {path, method, stage, functionName}),
|
||||
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {
|
||||
path: createPath,
|
||||
parentResourceId: selectedParent?.resourceId,
|
||||
pathPart,
|
||||
method,
|
||||
stage,
|
||||
functionName,
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]}),
|
||||
})
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => testApiGatewayRoute(cloud, resource!.id, {
|
||||
path,
|
||||
method,
|
||||
method: deployedHttpMethod,
|
||||
stage,
|
||||
body: testBody,
|
||||
headers: parseHeaders(testHeaders),
|
||||
@@ -132,9 +146,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
}
|
||||
|
||||
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
|
||||
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
|
||||
const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false
|
||||
const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody)
|
||||
const apiResources = routesQuery.data?.resources ?? []
|
||||
const rootResource = apiResources.find((item) => item.path === '/')
|
||||
const selectedParent = apiResources.find((item) => item.resourceId === parentResourceId) ?? rootResource
|
||||
const createPath = selectedParent ? childResourcePath(selectedParent.path, pathPart) : `/${pathPart}`
|
||||
const deployedHttpMethod = method === 'ANY' ? anyTestMethod : method
|
||||
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName && selectedParent && pathPart) && !routeMutation.isPending
|
||||
const existingPath = apiResources.some((item) => item.path === createPath)
|
||||
const curlCommand = buildCurlCommand(resource.id, stage, path, deployedHttpMethod, testHeaders, testQuery, testBody)
|
||||
const endpoint = invokeEndpoint(runtimeEndpoint)
|
||||
const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null
|
||||
|
||||
@@ -154,6 +173,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
autoRunKey={logsRefreshKey}
|
||||
/>
|
||||
)}
|
||||
{functionName && <a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${functionName}`)}`}><ScrollText size={13}/> Open Lambda logs</a>}
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
{baseInvokeUrl && <div className="dynamic-field">
|
||||
@@ -168,20 +188,27 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
if (canCreate) routeMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<span>Route path</span>
|
||||
<input className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders" required/>
|
||||
<small>{existingPath ? `Adds ${method} to the existing ${path} resource.` : <>One path segment, for example <code>/qrcode</code>.</>}</small>
|
||||
<span>Parent resource</span>
|
||||
<select aria-label="Parent resource" className="input" value={selectedParent?.resourceId ?? ''} onChange={(event) => setParentResourceId(event.target.value)} required>
|
||||
{apiResources.map((item) => <option key={item.resourceId} value={item.resourceId}>{item.path}</option>)}
|
||||
</select>
|
||||
<small>Select where the new resource belongs.</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Path part</span>
|
||||
<input aria-label="Path part" className="input" value={pathPart} onChange={(event) => setPathPart(event.target.value)} placeholder="orders or {id}" required/>
|
||||
<small>{existingPath ? `Adds ${method} to existing ${createPath}.` : <>Creates <code>{createPath}</code>. Path parameters and greedy parameters are supported.</>}</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Method</span>
|
||||
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
|
||||
{['GET', 'POST', 'PUT', 'DELETE'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
<select aria-label="Method" className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
|
||||
{CONFIGURABLE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
<small>Lambda proxy method.</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Lambda function</span>
|
||||
<select className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
|
||||
<select aria-label="Lambda function" className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
|
||||
<option value="">Select a Lambda function</option>
|
||||
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
|
||||
</select>
|
||||
@@ -215,18 +242,28 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
</div>
|
||||
{routesQuery.isLoading && <p className="muted compact-text">Loading configured methods...</p>}
|
||||
{routesQuery.isError && <p className="error-text compact-text">{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API routes.'}</p>}
|
||||
{routesQuery.data?.routes.length === 0 && <p className="muted compact-text">No configured methods yet. Create a route above.</p>}
|
||||
{routesQuery.data && routesQuery.data.routes.length > 0 && (
|
||||
<div className="apigateway-route-list">
|
||||
{routesQuery.data.routes.map((route) => {
|
||||
const selected = route.path === path && route.method === method
|
||||
const key = `${route.resourceId}-${route.method}`
|
||||
const resourceMethods = routesQuery.data.routes
|
||||
.filter((item) => item.resourceId === route.resourceId && CORS_METHODS.includes(item.method))
|
||||
.map((item) => item.method)
|
||||
const isFirstForResource = routesQuery.data.routes.findIndex((item) => item.resourceId === route.resourceId) === routesQuery.data.routes.indexOf(route)
|
||||
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
|
||||
<button className="apigateway-route-select" type="button" onClick={() => {
|
||||
{routesQuery.data && apiResources.length === 0 && <p className="muted compact-text">API Gateway returned no resources.</p>}
|
||||
{routesQuery.data && apiResources.length > 0 && (
|
||||
<div className="apigateway-resource-tree" role="tree" aria-label="API Gateway resources">
|
||||
{resourceTree(apiResources).map(({resource: treeResource, children, depth}) => {
|
||||
const resourceMethods = treeResource.methods.filter((item) => CORS_METHODS.includes(item.method)).map((item) => item.method)
|
||||
return <div className="apigateway-resource-node" role="treeitem" aria-level={depth + 1} key={treeResource.resourceId} style={{'--tree-depth': depth} as CSSProperties}>
|
||||
<div className="apigateway-resource-heading">
|
||||
<button type="button" className="apigateway-resource-select" onClick={() => setParentResourceId(treeResource.resourceId)}>
|
||||
<code>{treeResource.path}</code>
|
||||
<small>{treeResource.methods.length === 0 ? 'No methods' : `${treeResource.methods.length} method${treeResource.methods.length === 1 ? '' : 's'}`}</small>
|
||||
</button>
|
||||
{resourceMethods.length > 0 && <button className="button compact" type="button" onClick={() => {
|
||||
setCorsConfirmation({resourceId: treeResource.resourceId, path: treeResource.path})
|
||||
setCorsMethods(resourceMethods)
|
||||
corsMutation.reset()
|
||||
}}>Enable CORS</button>}
|
||||
</div>
|
||||
{treeResource.methods.map((route) => {
|
||||
const selected = route.path === path && route.method === method
|
||||
const key = `${route.resourceId}-${route.method}`
|
||||
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
|
||||
<button className="apigateway-route-select" type="button" onClick={() => {
|
||||
setPath(route.path)
|
||||
setMethod(route.method)
|
||||
setFunctionName(route.functionName ?? '')
|
||||
@@ -239,28 +276,26 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
<code>{route.path}</code>
|
||||
<small>{route.integrationType ?? 'No integration'}{route.functionName ? ` · ${route.functionName}` : ''}</small>
|
||||
</button>
|
||||
{isFirstForResource && <button className="button compact" type="button" onClick={() => {
|
||||
setCorsConfirmation({resourceId: route.resourceId, path: route.path})
|
||||
setCorsMethods(resourceMethods)
|
||||
corsMutation.reset()
|
||||
}}>Enable CORS</button>}
|
||||
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
|
||||
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
|
||||
<span>Delete {route.method}?</span>
|
||||
<button className="button danger compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => deleteRouteMutation.mutate(route)}>Delete</button>
|
||||
<button className="button compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => setDeleteConfirmation(null)}>Cancel</button>
|
||||
</div> : <button className="icon-btn" type="button" aria-label={`Delete ${route.method} ${route.path}`} onClick={() => setDeleteConfirmation(key)}><Trash2 size={13}/></button>}
|
||||
{corsConfirmation?.resourceId === route.resourceId && isFirstForResource && <div className="apigateway-route-confirm">
|
||||
<strong>Enable CORS for {corsConfirmation.path}</strong>
|
||||
<label className="dynamic-field"><span>Allowed origin</span><input className="input" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label>
|
||||
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={corsHeaders} onChange={(event) => setCorsHeaders(event.target.value)}/></label>
|
||||
<div className="dynamic-field"><span>Allowed methods</span>{resourceMethods.map((value) => <label key={value}><input type="checkbox" checked={corsMethods.includes(value)} onChange={() => setCorsMethods((current) => current.includes(value) ? current.filter((method) => method !== value) : [...current, value])}/>{value}</label>)}</div>
|
||||
<span>Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}.</span>
|
||||
<button className="button primary compact" type="button" disabled={corsMutation.isPending || corsMethods.length === 0} onClick={() => corsMutation.mutate()}>{corsMutation.isPending ? 'Enabling' : 'Confirm enable CORS'}</button>
|
||||
<button className="button compact" type="button" disabled={corsMutation.isPending} onClick={() => setCorsConfirmation(null)}>Cancel</button>
|
||||
{corsMutation.isError && <p className="error-text compact-text">{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}</p>}
|
||||
</div>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
{corsConfirmation?.resourceId === treeResource.resourceId && <div className="apigateway-route-confirm apigateway-cors-confirm">
|
||||
<strong>Enable CORS for {corsConfirmation.path}</strong>
|
||||
<label className="dynamic-field"><span>Allowed origin</span><input className="input" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label>
|
||||
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={corsHeaders} onChange={(event) => setCorsHeaders(event.target.value)}/></label>
|
||||
<div className="dynamic-field"><span>Allowed methods</span>{resourceMethods.map((value) => <label key={value}><input type="checkbox" checked={corsMethods.includes(value)} onChange={() => setCorsMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div>
|
||||
<span>Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}.</span>
|
||||
<button className="button primary compact" type="button" disabled={corsMutation.isPending || corsMethods.length === 0} onClick={() => corsMutation.mutate()}>{corsMutation.isPending ? 'Enabling' : 'Confirm enable CORS'}</button>
|
||||
<button className="button compact" type="button" disabled={corsMutation.isPending} onClick={() => setCorsConfirmation(null)}>Cancel</button>
|
||||
{corsMutation.isError && <p className="error-text compact-text">{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}</p>}
|
||||
</div>}
|
||||
{children.length > 0 && <span className="sr-only">{children.length} child resources</span>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
|
||||
@@ -274,11 +309,16 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
</div>
|
||||
</div>
|
||||
<div className="apigateway-test-workspace">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (selectedResourceId && !methodTestMutation.isPending) methodTestMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (selectedResourceId && !methodTestMutation.isPending) methodTestMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<span>Request path</span>
|
||||
<input aria-label="Request path" className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders/123" required/>
|
||||
<small>Replace path parameters with concrete values for testing.</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Stage variables (JSON)</span>
|
||||
<textarea className="textarea code-textarea small" value={stageVariables} onChange={(event) => setStageVariables(event.target.value)} spellCheck={false}/>
|
||||
<small>Optional variables available during this method test only.</small>
|
||||
@@ -294,22 +334,22 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
<div>
|
||||
<p className="eyebrow">Deployed HTTP Test</p>
|
||||
<h3><Send size={15}/> Send deployed request</h3>
|
||||
<p className="muted compact-text">Invokes <code>{method} /{stage}{path}</code> through the local API Gateway endpoint. Requires a deployed stage.</p>
|
||||
<p className="muted compact-text">Invokes <code>{deployedHttpMethod} /{stage}{path}</code> through the local API Gateway endpoint. Requires a deployed stage.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="apigateway-request-flow" aria-label="API request flow">
|
||||
<div className="apigateway-flow-step">
|
||||
<Send size={16}/>
|
||||
<div><strong>Test request</strong><small>{method} {path}</small></div>
|
||||
<div><strong>Test request</strong><small>{deployedHttpMethod} {path}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
<div className="apigateway-flow-step">
|
||||
<Waypoints size={16}/>
|
||||
<ServiceIcon cloud="aws" iconKey="apigateway" size={22}/>
|
||||
<div><strong>API Gateway</strong><small>{resource.name} · {stage}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
<div className="apigateway-flow-step">
|
||||
<Braces size={16}/>
|
||||
<ServiceIcon cloud="aws" iconKey="serverless" size={22}/>
|
||||
<div><strong>Lambda proxy</strong><small>{functionName || 'Connected Lambda'}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
@@ -319,11 +359,18 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
</div>
|
||||
</div>
|
||||
<div className="apigateway-test-workspace">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!testMutation.isPending) testMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!testMutation.isPending) testMutation.mutate()
|
||||
}}>
|
||||
{method === 'ANY' && <label className="dynamic-field">
|
||||
<span>HTTP verb for deployed request</span>
|
||||
<select aria-label="HTTP verb for deployed request" className="input" value={anyTestMethod} onChange={(event) => setAnyTestMethod(event.target.value)}>
|
||||
{DEPLOYED_HTTP_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
<small>ANY is API Gateway configuration, not an HTTP request verb.</small>
|
||||
</label>}
|
||||
<label className="dynamic-field">
|
||||
<span>Request headers (JSON)</span>
|
||||
<textarea className="textarea code-textarea small" value={testHeaders} onChange={(event) => setTestHeaders(event.target.value)} spellCheck={false}/>
|
||||
</label>
|
||||
@@ -400,7 +447,33 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
)
|
||||
}
|
||||
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
|
||||
const CONFIGURABLE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'ANY', 'HEAD']
|
||||
const DEPLOYED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
|
||||
|
||||
function childResourcePath(parentPath: string, pathPart: string): string {
|
||||
return parentPath === '/' ? `/${pathPart}` : `${parentPath}/${pathPart}`
|
||||
}
|
||||
|
||||
function resourceTree(resources: ApiGatewayResourceOverview[]): Array<{resource: ApiGatewayResourceOverview; children: ApiGatewayResourceOverview[]; depth: number}> {
|
||||
const byParent = new Map<string | null, ApiGatewayResourceOverview[]>()
|
||||
for (const resource of resources) {
|
||||
const siblings = byParent.get(resource.parentId) ?? []
|
||||
siblings.push(resource)
|
||||
byParent.set(resource.parentId, siblings)
|
||||
}
|
||||
for (const siblings of byParent.values()) siblings.sort((left, right) => left.path.localeCompare(right.path))
|
||||
|
||||
const flattened: Array<{resource: ApiGatewayResourceOverview; children: ApiGatewayResourceOverview[]; depth: number}> = []
|
||||
const visit = (resource: ApiGatewayResourceOverview, depth: number) => {
|
||||
const children = byParent.get(resource.resourceId) ?? []
|
||||
flattened.push({resource, children, depth})
|
||||
for (const child of children) visit(child, depth + 1)
|
||||
}
|
||||
const roots = resources.filter((resource) => resource.parentId === null || !resources.some((candidate) => candidate.resourceId === resource.parentId))
|
||||
for (const root of roots.sort((left, right) => left.path.localeCompare(right.path))) visit(root, 0)
|
||||
return flattened
|
||||
}
|
||||
|
||||
function ProxyCorsWarning({methods, origin, headers}: {methods: string[]; origin: string; headers: string}) {
|
||||
return <div className="error-text compact-text">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
|
||||
import {describe, expect, test, vi} from 'vitest'
|
||||
import {CloudFormationCreatePanel} from './CloudFormationCreatePanel'
|
||||
|
||||
describe('CloudFormationCreatePanel', () => {
|
||||
test('loads a YAML template file into the template editor', async () => {
|
||||
render(<CloudFormationCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={vi.fn()}/>)
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, {target: {files: [new File(['Resources:\n Queue:\n Type: AWS::SQS::Queue\n'], 'queue.yaml', {type: 'text/yaml'})]}})
|
||||
await waitFor(() => expect(screen.getByLabelText('Template')).toHaveValue('Resources:\n Queue:\n Type: AWS::SQS::Queue\n'))
|
||||
expect(screen.getByText('Loaded queue.yaml')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('submits a stack with an S3 template URL without inline content', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<CloudFormationCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
|
||||
fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
|
||||
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml'})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {Upload} from 'lucide-react'
|
||||
|
||||
export function CloudFormationCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: {isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record<string, unknown>) => void}) {
|
||||
const [stackName, setStackName] = useState('')
|
||||
const [templateBody, setTemplateBody] = useState('')
|
||||
const [templateUrl, setTemplateUrl] = useState('')
|
||||
const [parameters, setParameters] = useState('{}')
|
||||
const [capabilities, setCapabilities] = useState('')
|
||||
const [fileName, setFileName] = useState<string | null>(null)
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function loadTemplate(file?: File) {
|
||||
if (!file) return
|
||||
if (!/\.(json|ya?ml)$/i.test(file.name)) {
|
||||
setFileError('Choose a JSON, YAML, or YML template file.')
|
||||
return
|
||||
}
|
||||
setTemplateBody(await file.text())
|
||||
setFileName(file.name)
|
||||
setFileError(null)
|
||||
}
|
||||
|
||||
const hasTemplate = Boolean(templateBody.trim() || templateUrl.trim())
|
||||
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (hasTemplate) onSubmit({stackName, templateBody, templateUrl, parameters, capabilities}) }}>
|
||||
<label className="dynamic-field"><span>Stack Name<em className="field-required">*</em></span><input className="input" value={stackName} required pattern="^[a-zA-Z][a-zA-Z0-9\-]{0,127}$" maxLength={128} onChange={(event) => setStackName(event.target.value)} placeholder="orders-stack"/><small>Start with a letter. Use letters, numbers, and hyphens.</small></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Template</span><textarea aria-label="Template" className="textarea code-textarea" value={templateBody} rows={14} spellCheck={false} onChange={(event) => setTemplateBody(event.target.value)} placeholder="Paste CloudFormation JSON or YAML, or upload a template file."/><small>Provide this or a Template S3 URL below.</small></label>
|
||||
<input ref={fileInput} type="file" accept=".json,.yaml,.yml,application/json,application/x-yaml,text/yaml" hidden onChange={(event) => void loadTemplate(event.target.files?.[0])}/>
|
||||
<div style={{gridColumn: '1 / -1', display: 'flex', gap: '8px', alignItems: 'center'}}><button className="button" type="button" disabled={isSubmitting} onClick={() => fileInput.current?.click()}><Upload size={14}/> Upload JSON or YAML</button>{fileName && <span className="muted compact-text">Loaded {fileName}</span>}</div>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Template S3 URL</span><input aria-label="Template S3 URL" className="input" value={templateUrl} onChange={(event) => setTemplateUrl(event.target.value)} placeholder="https://bucket.s3.amazonaws.com/templates/stack.yaml"/><small>Provide this or an inline/uploaded template above.</small></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Parameters (JSON)</span><textarea className="textarea code-textarea small" value={parameters} onChange={(event) => setParameters(event.target.value)} spellCheck={false}/><small>Optional object of parameter keys to string values.</small></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Capabilities</span><input className="input" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} placeholder="CAPABILITY_IAM, CAPABILITY_NAMED_IAM"/><small>Optional comma-separated AWS capabilities.</small></label>
|
||||
{fileError && <div className="form-error" role="alert">{fileError}</div>}
|
||||
{submitError && <div className="form-error" role="alert">{submitError}</div>}
|
||||
<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: '8px'}}><button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting || !hasTemplate}>Create stack</button></div>
|
||||
</form>
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {Copy, Play, Plus, ShieldCheck, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createCloudFormationChangeSet, deleteCloudFormationChangeSet, executeCloudFormationChangeSet, getCloudResource, listCloudFormationChangeSets, updateCloudFormationTerminationProtection} from '@/api/cloudProxyClient'
|
||||
import {useState} from 'react'
|
||||
import {CloudFormationTemplateUpload} from './CloudFormationTemplateUpload'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
export function CloudFormationStackPanel({resource}: {resource?: CloudResource}) {
|
||||
if (!resource || resource.service !== 'iac' || resource.type !== 'stack') return null
|
||||
return <CloudFormationStackPanelContent resource={resource}/>
|
||||
}
|
||||
|
||||
function CloudFormationStackPanelContent({resource}: {resource: CloudResource}) {
|
||||
const detail = useQuery({queryKey: ['cloudformation-stack-detail', resource.id], queryFn: ({signal}) => getCloudResource('aws', 'iac', resource.id, signal), refetchInterval: (query) => /(?:_IN_PROGRESS|_PENDING)$/.test(String(query.state.data?.status ?? resource.status ?? '')) ? 1000 : false})
|
||||
const stack = detail.data ?? resource
|
||||
const [changeSetName, setChangeSetName] = useState('')
|
||||
const [templateBody, setTemplateBody] = useState(String(resource.metadata.templateBody ?? ''))
|
||||
const [templateError, setTemplateError] = useState<string | null>(null)
|
||||
const [changeSetParameters, setChangeSetParameters] = useState('{}')
|
||||
const [changeSetCapabilities, setChangeSetCapabilities] = useState('')
|
||||
const queryClient = useQueryClient()
|
||||
const changeSets = useQuery({queryKey: ['cloudformation-change-sets', resource.id], queryFn: ({signal}) => listCloudFormationChangeSets('aws', resource.id, signal)})
|
||||
const refresh = () => void queryClient.invalidateQueries({queryKey: ['cloudformation-change-sets', resource.id]})
|
||||
const createChangeSet = useMutation({mutationFn: () => createCloudFormationChangeSet('aws', resource.id, {name: changeSetName.trim(), templateBody, parameters: JSON.parse(changeSetParameters) as Record<string, string>, capabilities: changeSetCapabilities.split(',').map((item) => item.trim()).filter(Boolean)}), onSuccess: refresh})
|
||||
const executeChangeSet = useMutation({mutationFn: (name: string) => executeCloudFormationChangeSet('aws', resource.id, name), onSuccess: refresh})
|
||||
const deleteChangeSet = useMutation({mutationFn: (name: string) => deleteCloudFormationChangeSet('aws', resource.id, name), onSuccess: refresh})
|
||||
const protection = useMutation({mutationFn: (enabled: boolean) => updateCloudFormationTerminationProtection('aws', resource.id, enabled), onSuccess: () => void queryClient.invalidateQueries({queryKey: ['cloudformation-stack-detail', resource.id]})})
|
||||
const outputs = array(stack.metadata.outputs)
|
||||
const resources = array(stack.metadata.resources).map((item) => ({...item, consolePath: consolePath(String(item.resourceType ?? ''), String(item.physicalId ?? ''))}))
|
||||
const events = array(stack.metadata.events)
|
||||
const template = typeof stack.metadata.templateBody === 'string' ? stack.metadata.templateBody : null
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Stack workspace</p><h3>{stack.name}</h3><p className="muted compact-text">Stack lifecycle is asynchronous. Refresh the stack list to observe status transitions.</p></div></div>
|
||||
<StackTable title="Outputs" columns={['key', 'value', 'description']} rows={outputs}/>
|
||||
<StackTable title="Resources" columns={['logicalId', 'physicalId', 'resourceType', 'status']} rows={resources}/>
|
||||
<StackTable title="Recent events" columns={['timestamp', 'logicalId', 'status', 'reason']} rows={events}/>
|
||||
<section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Stack actions</p><h3><ShieldCheck size={15}/> Termination protection: {stack.metadata.enableTerminationProtection === true ? 'Enabled' : 'Disabled'}</h3></div><button className="button compact" type="button" disabled={protection.isPending} onClick={() => protection.mutate(stack.metadata.enableTerminationProtection !== true)}>{stack.metadata.enableTerminationProtection === true ? 'Disable protection' : 'Enable protection'}</button></div><p className="muted compact-text">{stack.metadata.enableTerminationProtection === true ? 'Enabled: DeleteStack is blocked until this is disabled.' : 'Disabled: the stack can be deleted.'}</p></section>
|
||||
<section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Change sets</p><h3>Preview and deploy</h3><p className="muted compact-text">Resource import is unavailable because Floci does not yet implement AWS resource adoption.</p></div></div><form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (changeSetName.trim() && templateBody.trim()) createChangeSet.mutate() }}><label className="dynamic-field"><span>Change set name</span><input className="input" value={changeSetName} onChange={(event) => setChangeSetName(event.target.value)} required/></label><label className="dynamic-field dynamic-field--span"><span>Proposed template</span><textarea className="textarea code-textarea" value={templateBody} onChange={(event) => setTemplateBody(event.target.value)} rows={10} required spellCheck={false}/></label><div><CloudFormationTemplateUpload onLoad={(template) => { setTemplateBody(template); setTemplateError(null) }} onError={setTemplateError}/></div><label className="dynamic-field dynamic-field--span"><span>Parameters (JSON)</span><textarea className="textarea code-textarea small" value={changeSetParameters} onChange={(event) => setChangeSetParameters(event.target.value)} spellCheck={false}/></label><label className="dynamic-field dynamic-field--span"><span>Capabilities</span><input className="input" value={changeSetCapabilities} onChange={(event) => setChangeSetCapabilities(event.target.value)} placeholder="CAPABILITY_IAM, CAPABILITY_NAMED_IAM"/></label>{templateError && <div className="form-error" role="alert">{templateError}</div>}<button className="button primary" type="submit" disabled={createChangeSet.isPending}><Plus size={13}/> Create change set</button></form>{changeSets.data?.map((changeSet) => <div className="apigateway-route-item" key={changeSet.id}><div className="apigateway-route-select"><code>{changeSet.name}</code><small>{changeSet.status} / {changeSet.executionStatus}</small>{changeSet.changes.map((change, index) => <small key={index}>{change.action} {change.resourceType} {change.logicalResourceId}</small>)}</div><button className="button compact" type="button" disabled={changeSet.status !== 'CREATE_COMPLETE' && changeSet.status !== 'AVAILABLE' || executeChangeSet.isPending} onClick={() => executeChangeSet.mutate(changeSet.name)}><Play size={13}/> Execute</button><button className="icon-btn" type="button" aria-label={`Delete change set ${changeSet.name}`} disabled={deleteChangeSet.isPending} onClick={() => deleteChangeSet.mutate(changeSet.name)}><Trash2 size={13}/></button></div>)}</section>
|
||||
{template && <section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Submitted template</p><h3>Original template</h3></div><button className="button compact" type="button" onClick={() => void navigator.clipboard.writeText(template)}><Copy size={13}/> Copy</button></div><pre className="metadata-block">{template}</pre></section>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function StackTable({title, columns, rows}: {title: string; columns: string[]; rows: Array<Record<string, unknown>>}) {
|
||||
return <section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">{title}</p><h3>{rows.length}</h3></div></div>{rows.length === 0 ? <p className="muted compact-text">No {title.toLowerCase()} reported.</p> : <div className="cosmos-items-table"><table className="table"><thead><tr>{columns.map((column) => <th key={column}>{label(column)}</th>)}</tr></thead><tbody>{rows.map((row, index) => <tr key={`${title}-${index}`}>{columns.map((column) => <td key={column}>{column === 'physicalId' && typeof row.consolePath === 'string' ? <a href={row.consolePath}><code>{string(row[column])}</code></a> : <code>{string(row[column])}</code>}</td>)}</tr>)}</tbody></table></div>}</section>
|
||||
}
|
||||
|
||||
function array(value: unknown): Array<Record<string, unknown>> { return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item)) : [] }
|
||||
function string(value: unknown): string { return value === null || value === undefined ? '-' : String(value) }
|
||||
function label(value: string): string { return value.replace(/([A-Z])/g, ' $1').replace(/^./, (letter) => letter.toUpperCase()) }
|
||||
function consolePath(resourceType: string, physicalId: string): string | null {
|
||||
const parts = physicalId.split('/').filter(Boolean)
|
||||
const name = parts[parts.length - 1] ?? physicalId
|
||||
const service = resourceType === 'AWS::DynamoDB::Table' ? 'nosql' : resourceType === 'AWS::SQS::Queue' ? 'messaging' : resourceType === 'AWS::Lambda::Function' ? 'serverless' : resourceType === 'AWS::Events::EventBus' ? 'events' : resourceType === 'AWS::StepFunctions::StateMachine' ? 'workflows' : null
|
||||
if (!service || !name) return null
|
||||
const id = service === 'events' ? `event-bus:${name}` : name
|
||||
return `/cloud-explorer/aws/${service}?resource=${encodeURIComponent(id)}`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {useRef} from 'react'
|
||||
import {parse} from 'yaml'
|
||||
import {Upload} from 'lucide-react'
|
||||
|
||||
export function CloudFormationTemplateUpload({onLoad, onError}: {onLoad: (template: string, name: string) => void; onError: (message: string) => void}) {
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
async function load(file?: File) {
|
||||
if (!file) return
|
||||
if (!/\.(json|ya?ml)$/i.test(file.name)) return onError('Choose a JSON, YAML, or YML template file.')
|
||||
const template = await file.text()
|
||||
try {
|
||||
if (/\.json$/i.test(file.name)) JSON.parse(template)
|
||||
else parse(template)
|
||||
onLoad(template, file.name)
|
||||
} catch {
|
||||
onError(`${file.name} is not valid ${/\.json$/i.test(file.name) ? 'JSON' : 'YAML'}.`)
|
||||
}
|
||||
}
|
||||
return <><input ref={input} type="file" accept=".json,.yaml,.yml,application/json,application/x-yaml,text/yaml" hidden onChange={(event) => void load(event.target.files?.[0])}/><button className="button compact" type="button" onClick={() => input.current?.click()}><Upload size={13}/> Upload JSON or YAML</button></>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {useState} from 'react'
|
||||
import {CloudFormationTemplateUpload} from './CloudFormationTemplateUpload'
|
||||
|
||||
export function CloudFormationUpdatePanel({initialTemplate, isSubmitting, submitError, onCancel, onSubmit}: {initialTemplate: string; isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record<string, unknown>) => void}) {
|
||||
const [templateBody, setTemplateBody] = useState(initialTemplate)
|
||||
const [parameters, setParameters] = useState('{}')
|
||||
const [capabilities, setCapabilities] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); onSubmit({templateBody, parameters, capabilities}) }}><label className="dynamic-field dynamic-field--span"><span>Template</span><textarea className="textarea code-textarea" value={templateBody} onChange={(event) => setTemplateBody(event.target.value)} rows={14} required spellCheck={false}/><small>Paste/edit JSON or YAML, or replace it from a file.</small></label><div style={{gridColumn: '1 / -1'}}><CloudFormationTemplateUpload onLoad={(template) => { setTemplateBody(template); setError(null) }} onError={setError}/></div><label className="dynamic-field dynamic-field--span"><span>Parameters (JSON)</span><textarea className="textarea code-textarea small" value={parameters} onChange={(event) => setParameters(event.target.value)} spellCheck={false}/></label><label className="dynamic-field dynamic-field--span"><span>Capabilities</span><input className="input" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} placeholder="CAPABILITY_IAM, CAPABILITY_NAMED_IAM"/></label>{(error || submitError) && <div className="form-error" role="alert">{error ?? submitError}</div>}<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: '8px'}}><button className="button" type="button" onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting}>Save Changes</button></div></form>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, ChevronUp, Info, Loader2, Plus, RefreshCw, Trash2, X } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -49,6 +49,10 @@ import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
|
||||
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
|
||||
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
|
||||
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
|
||||
import { SqsQueuePanel } from "@/components/SqsQueuePanel";
|
||||
import { CloudFormationStackPanel } from "@/components/CloudFormationStackPanel";
|
||||
import { CloudFormationCreatePanel } from "@/components/CloudFormationCreatePanel";
|
||||
import { CloudFormationUpdatePanel } from "@/components/CloudFormationUpdatePanel";
|
||||
import { EventBridgeRulePanel } from "@/components/EventBridgeRulePanel";
|
||||
import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHistoryPanel";
|
||||
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
|
||||
@@ -62,7 +66,7 @@ import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugge
|
||||
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
|
||||
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
|
||||
import {workflowStudioPath} from "@/lib/workflowStudio";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {useNavigate, useSearchParams} from "react-router-dom";
|
||||
|
||||
interface DynamicResourceViewProps {
|
||||
cloud: CloudProvider;
|
||||
@@ -90,6 +94,7 @@ export function DynamicResourceView({
|
||||
onOpenInfo,
|
||||
}: DynamicResourceViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
@@ -105,6 +110,7 @@ export function DynamicResourceView({
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
|
||||
const [deleteWarning, setDeleteWarning] = useState<string | null>(null);
|
||||
const openedResourceRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (cloud === "aws" && service === "compute") {
|
||||
@@ -139,6 +145,16 @@ export function DynamicResourceView({
|
||||
cloudStatus?.runtime === "reachable",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const resourceId = searchParams.get("resource");
|
||||
if (!resourceId || !resourcesQuery.data) return;
|
||||
const key = `${cloud}:${service}:${resourceId}`;
|
||||
if (openedResourceRef.current === key) return;
|
||||
const resource = resourcesQuery.data.find((candidate) => candidate.id === resourceId);
|
||||
if (resource) setSelected(resource);
|
||||
openedResourceRef.current = key;
|
||||
}, [cloud, resourcesQuery.data, searchParams, service]);
|
||||
|
||||
// Automatically reconcile selected items when resources query data changes
|
||||
useEffect(() => {
|
||||
setSelectedItems((prev) => {
|
||||
@@ -284,6 +300,10 @@ export function DynamicResourceView({
|
||||
const warning = bucketDeleteWarning(contents);
|
||||
if (warning) throw new Error(warning);
|
||||
}
|
||||
if (cloud === "aws" && service === "iac") {
|
||||
const current = await getCloudResource(cloud, service, resource.id);
|
||||
if (current?.metadata.enableTerminationProtection === true) throw new Error("Termination protection is enabled. Disable it from Stack actions before deleting this stack.");
|
||||
}
|
||||
await deleteCloudResource(cloud, service, resource.id);
|
||||
},
|
||||
onSuccess: (_, resource) => {
|
||||
@@ -797,13 +817,20 @@ export function DynamicResourceView({
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onSubmit={(values) => createMut.mutate({values})}
|
||||
/>
|
||||
) : service === "workflows" && cloud === "aws" ? (
|
||||
) : service === "workflows" && cloud === "aws" ? (
|
||||
<WorkflowCreatePanel
|
||||
isSubmitting={createMut.isPending}
|
||||
submitError={createMut.error instanceof Error ? createMut.error.message : null}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onSubmit={(submission) => createMut.mutate(submission)}
|
||||
/>
|
||||
) : service === "iac" && cloud === "aws" ? (
|
||||
<CloudFormationCreatePanel
|
||||
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`}
|
||||
@@ -886,6 +913,8 @@ export function DynamicResourceView({
|
||||
{cloud === "aws" && <S3BucketWebsitePanel resource={selected} runtimeReachable={canUseRuntime} />}
|
||||
</>
|
||||
)}
|
||||
{service === "messaging" && cloud === "aws" && <SqsQueuePanel resource={activeSelected} runtimeReachable={canUseRuntime} />}
|
||||
{service === "iac" && cloud === "aws" && <CloudFormationStackPanel resource={activeSelected} />}
|
||||
{service === "ecr" && cloud === "aws" && (
|
||||
<EcrImageBrowser resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
@@ -1015,7 +1044,7 @@ export function DynamicResourceView({
|
||||
onCancel={() => setEditingResource(null)}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
) : <DynamicFormRenderer
|
||||
) : service === "iac" && cloud === "aws" ? <CloudFormationUpdatePanel initialTemplate={String(initialEditValues.templateBody ?? '')} isSubmitting={updateMut.isPending} submitError={updateError} onCancel={() => setEditingResource(null)} onSubmit={handleEditSubmit}/> : <DynamicFormRenderer
|
||||
key={editingResource.id}
|
||||
schema={schema}
|
||||
fields={schema.updateFields}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {DynamoDbItemEditor} from './DynamoDbItemEditor'
|
||||
|
||||
test('preserves typed attributes while switching between form and DynamoDB JSON', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DynamoDbItemEditor
|
||||
keyFields={[{name: 'id', type: 'S'}]}
|
||||
initialDocument={{id: {S: 'order-1'}, total: {N: '9007199254740993'}, tags: {SS: ['new', 'priority']}}}
|
||||
isPending={false}
|
||||
onClose={() => {}}
|
||||
onSubmit={vi.fn()}
|
||||
/>)
|
||||
|
||||
await user.click(screen.getByRole('tab', {name: 'DynamoDB JSON'}))
|
||||
const json = screen.getByLabelText('DynamoDB AttributeValue JSON')
|
||||
expect((json as HTMLTextAreaElement).value).toContain('"N": "9007199254740993"')
|
||||
expect((json as HTMLTextAreaElement).value).toContain('"SS"')
|
||||
|
||||
await user.click(screen.getByRole('tab', {name: 'Form'}))
|
||||
expect(screen.getAllByLabelText('Attribute value').map((input) => input.getAttribute('value'))).toContain('9007199254740993')
|
||||
expect(screen.getByLabelText('Attribute values')).toHaveValue('new\npriority')
|
||||
})
|
||||
|
||||
test('submits DynamoDB AttributeValue JSON from the typed form', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<DynamoDbItemEditor
|
||||
keyFields={[{name: 'id', type: 'S'}]}
|
||||
initialDocument={{id: {S: 'order-1'}}}
|
||||
isPending={false}
|
||||
onClose={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
/>)
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', {name: 'Add new attribute'}), 'S')
|
||||
const names = screen.getAllByLabelText('Attribute name')
|
||||
await user.type(names[names.length - 1], 'active')
|
||||
const values = screen.getAllByLabelText('Attribute value')
|
||||
await user.type(values[values.length - 1], 'yes')
|
||||
await user.click(screen.getByRole('button', {name: 'Create item'}))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({id: {S: 'order-1'}, active: {S: 'yes'}})
|
||||
})
|
||||
|
||||
test('renders a blank numeric key draft and validates it on submit', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<DynamoDbItemEditor
|
||||
keyFields={[{name: 'customerId', type: 'N'}]}
|
||||
isPending={false}
|
||||
onClose={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
/>)
|
||||
|
||||
const keyInput = screen.getByLabelText('Attribute value')
|
||||
expect(keyInput).toHaveValue('')
|
||||
await user.click(screen.getByRole('button', {name: 'Create item'}))
|
||||
expect(screen.getByText('Attribute customerId must be a DynamoDB number.')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
|
||||
await user.type(keyInput, '42')
|
||||
await user.click(screen.getByRole('button', {name: 'Create item'}))
|
||||
expect(onSubmit).toHaveBeenCalledWith({customerId: {N: '42'}})
|
||||
})
|
||||
|
||||
test('renders required keys before other attributes in the Attributes workspace', () => {
|
||||
render(<DynamoDbItemEditor
|
||||
keyFields={[{name: 'pk', type: 'S'}, {name: 'sk', type: 'N'}]}
|
||||
initialDocument={{description: {S: 'order'}, sk: {N: '2'}, pk: {S: 'customer'}}}
|
||||
isPending={false}
|
||||
onClose={() => {}}
|
||||
onSubmit={vi.fn()}
|
||||
/>)
|
||||
|
||||
expect(screen.getByRole('heading', {name: 'Create item'})).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', {name: 'Attributes'})).toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText('Attribute name').map((input) => input.getAttribute('value'))).toEqual(['pk', 'sk', 'description'])
|
||||
expect(screen.getAllByText('Required key')).toHaveLength(2)
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import {FormEvent, useEffect, useState} from 'react'
|
||||
import {Plus, Trash2} from 'lucide-react'
|
||||
import type {DynamoKeyField} from '@/lib/dynamodbItem'
|
||||
|
||||
export type DynamoAttributeType = 'S' | 'N' | 'BOOL' | 'NULL' | 'L' | 'M' | 'SS' | 'NS' | 'B' | 'BS'
|
||||
|
||||
interface AttributeRow {
|
||||
id: number
|
||||
name: string
|
||||
type: DynamoAttributeType
|
||||
value: string
|
||||
children: AttributeRow[]
|
||||
}
|
||||
|
||||
interface DynamoDbItemEditorProps {
|
||||
editing?: boolean
|
||||
keyFields: DynamoKeyField[]
|
||||
initialDocument?: Record<string, unknown>
|
||||
isPending: boolean
|
||||
submitError?: string
|
||||
onClose: () => void
|
||||
onSubmit: (item: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<DynamoAttributeType, string> = {
|
||||
S: 'String', N: 'Number', BOOL: 'Boolean', NULL: 'Null', L: 'List', M: 'Map',
|
||||
SS: 'String Set', NS: 'Number Set', B: 'Binary', BS: 'Binary Set',
|
||||
}
|
||||
|
||||
const NESTED_TYPES: DynamoAttributeType[] = ['S', 'N', 'BOOL', 'NULL', 'SS', 'NS', 'B', 'BS']
|
||||
const NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?$/
|
||||
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||
|
||||
export function DynamoDbItemEditor({
|
||||
editing = false, keyFields, initialDocument, isPending, submitError, onClose, onSubmit,
|
||||
}: DynamoDbItemEditorProps) {
|
||||
const [rows, setRows] = useState<AttributeRow[]>([])
|
||||
const [json, setJson] = useState('{}')
|
||||
const [mode, setMode] = useState<'form' | 'json'>('form')
|
||||
const [validationError, setValidationError] = useState<string>()
|
||||
const [nextId, setNextId] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const document = initialDocument ?? defaultDocument(keyFields)
|
||||
const initialRows = documentRows(document, keyFields)
|
||||
setRows(initialRows)
|
||||
setJson(JSON.stringify(document, null, 2))
|
||||
setMode('form')
|
||||
setValidationError(undefined)
|
||||
setNextId(initialRows.reduce((maximum, row) => Math.max(maximum, row.id, ...row.children.map((child) => child.id)), 0) + 1)
|
||||
}, [initialDocument, keyFields])
|
||||
|
||||
function addRow(type: DynamoAttributeType = 'S', childrenOf?: number) {
|
||||
const row: AttributeRow = {id: nextId, name: '', type, value: '', children: []}
|
||||
setNextId(nextId + 1)
|
||||
if (childrenOf === undefined) {
|
||||
setRows([...rows, row])
|
||||
} else {
|
||||
setRows(updateRow(rows, childrenOf, (parent) => ({...parent, children: [...parent.children, row]})))
|
||||
}
|
||||
}
|
||||
|
||||
function changeRow(id: number, change: Partial<AttributeRow>) {
|
||||
setRows(updateRow(rows, id, (row) => ({...row, ...change, children: change.type && change.type !== row.type ? [] : row.children})))
|
||||
setValidationError(undefined)
|
||||
}
|
||||
|
||||
function removeRow(id: number) {
|
||||
setRows(removeRowById(rows, id))
|
||||
setValidationError(undefined)
|
||||
}
|
||||
|
||||
function changeMode(nextMode: 'form' | 'json') {
|
||||
try {
|
||||
if (nextMode === 'json') {
|
||||
setJson(JSON.stringify(rowsToDocument(rows), null, 2))
|
||||
} else {
|
||||
const parsed = parseAttributeValueDocument(json)
|
||||
const parsedRows = documentRows(parsed, keyFields)
|
||||
setRows(parsedRows)
|
||||
setNextId(parsedRows.reduce((maximum, row) => Math.max(maximum, row.id, ...row.children.map((child) => child.id)), 0) + 1)
|
||||
}
|
||||
setMode(nextMode)
|
||||
setValidationError(undefined)
|
||||
} catch (error) {
|
||||
setValidationError(error instanceof Error ? error.message : 'Invalid DynamoDB AttributeValue JSON.')
|
||||
}
|
||||
}
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
try {
|
||||
const item = mode === 'form' ? rowsToDocument(rows) : parseAttributeValueDocument(json)
|
||||
validateRequiredKeys(item, keyFields)
|
||||
setValidationError(undefined)
|
||||
onSubmit(item)
|
||||
} catch (error) {
|
||||
setValidationError(error instanceof Error ? error.message : 'Invalid DynamoDB item.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="dynamodb-item-editor">
|
||||
<form className="dynamodb-item-editor-form" aria-label={editing ? 'Edit item' : 'Create item'} onSubmit={submit}>
|
||||
<header className="dynamodb-item-editor-header">
|
||||
<div>
|
||||
<h1>{editing ? 'Edit item' : 'Create item'}</h1>
|
||||
<p>Primary key attributes are required. Binary values use base64. A matching key replaces the item.</p>
|
||||
</div>
|
||||
<div className="dynamodb-editor-tabs" role="tablist" aria-label="Item editor mode">
|
||||
<button className={mode === 'form' ? 'active' : ''} type="button" role="tab" aria-selected={mode === 'form'} onClick={() => changeMode('form')}>Form</button>
|
||||
<button className={mode === 'json' ? 'active' : ''} type="button" role="tab" aria-selected={mode === 'json'} onClick={() => changeMode('json')}>DynamoDB JSON</button>
|
||||
</div>
|
||||
</header>
|
||||
{mode === 'form' ? (
|
||||
<div className="dynamodb-attribute-rows">
|
||||
<section className="dynamodb-attributes-section" aria-labelledby="dynamodb-attributes-heading">
|
||||
<div className="dynamodb-attribute-table-heading"><h2 id="dynamodb-attributes-heading">Attributes</h2><select className="select" aria-label="Add new attribute" value="" disabled={isPending} onChange={(event) => { if (event.target.value) addRow(event.target.value as DynamoAttributeType) }}><option value="">Add new attribute</option>{(Object.keys(TYPE_LABELS) as DynamoAttributeType[]).map((type) => <option key={type} value={type}>{TYPE_LABELS[type]}</option>)}</select></div>
|
||||
<div className="dynamodb-attribute-column-headings" aria-hidden="true"><span>Attribute name</span><span>Value</span><span>Type</span><span>Action</span></div>
|
||||
{rows.map((row) => <AttributeEditorRow key={row.id} row={row} keyFields={keyFields} nested={false} disabled={isPending} onChange={changeRow} onRemove={removeRow} onAddChild={(parentId) => addRow('S', parentId)}/>) }
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<textarea className={`textarea code-textarea record-modal-editor ${validationError ? 'invalid' : ''}`} aria-label="DynamoDB AttributeValue JSON" value={json} onChange={(event) => { setJson(event.target.value); setValidationError(undefined) }} spellCheck={false} disabled={isPending}/>
|
||||
)}
|
||||
{(validationError || submitError) && <div className="form-error">{validationError ?? submitError}</div>}
|
||||
<div className="dynamodb-item-editor-actions">
|
||||
<button className="button" type="button" disabled={isPending} onClick={onClose}>Cancel</button>
|
||||
<button className="button primary" type="submit" disabled={isPending}>{isPending ? 'Saving' : editing ? 'Save item' : 'Create item'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function AttributeEditorRow({row, keyFields, nested, disabled, onChange, onRemove, onAddChild}: {row: AttributeRow; keyFields: DynamoKeyField[]; nested: boolean; disabled: boolean; onChange: (id: number, change: Partial<AttributeRow>) => void; onRemove: (id: number) => void; onAddChild: (parentId: number) => void}) {
|
||||
const key = !nested ? keyFields.find((field) => field.name === row.name) : undefined
|
||||
const types = nested ? NESTED_TYPES : Object.keys(TYPE_LABELS) as DynamoAttributeType[]
|
||||
const isContainer = row.type === 'L' || row.type === 'M'
|
||||
return <div className={`dynamodb-attribute-row ${nested ? 'nested' : ''} ${key ? 'key' : ''}`}>
|
||||
<input className="input" aria-label={nested ? 'Nested attribute name' : 'Attribute name'} placeholder={row.type === 'L' ? 'List item' : 'Attribute name'} value={row.name} disabled={disabled || (!nested && Boolean(key)) || (nested && row.type === 'L')} onChange={(event) => onChange(row.id, {name: event.target.value})}/>
|
||||
{isContainer ? <div className="dynamodb-nested-rows">
|
||||
{row.children.map((child) => <AttributeEditorRow key={child.id} row={child} keyFields={[]} nested disabled={disabled} onChange={onChange} onRemove={onRemove} onAddChild={onAddChild}/>) }
|
||||
<button className="button compact" type="button" disabled={disabled} onClick={() => onAddChild(row.id)}><Plus size={12}/>Add {row.type === 'M' ? 'field' : 'item'}</button>
|
||||
</div> : <AttributeValueInput row={row} disabled={disabled} onChange={onChange}/>}
|
||||
<select className="select" aria-label="Attribute type" value={row.type} disabled={disabled || Boolean(key)} onChange={(event) => onChange(row.id, {type: event.target.value as DynamoAttributeType})}>
|
||||
{types.map((type) => <option key={type} value={type}>{TYPE_LABELS[type]}</option>)}
|
||||
</select>
|
||||
{!key && <button className="icon-btn" type="button" aria-label={`Remove ${row.name || 'attribute'}`} disabled={disabled} onClick={() => onRemove(row.id)}><Trash2 size={14}/></button>}
|
||||
{key && <small className="dynamodb-key-badge">Required key</small>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function AttributeValueInput({row, disabled, onChange}: {row: AttributeRow; disabled: boolean; onChange: (id: number, change: Partial<AttributeRow>) => void}) {
|
||||
if (row.type === 'NULL') return <span className="muted">null</span>
|
||||
if (row.type === 'BOOL') return <select className="select" aria-label="Boolean value" value={row.value} disabled={disabled} onChange={(event) => onChange(row.id, {value: event.target.value})}><option value="true">true</option><option value="false">false</option></select>
|
||||
const set = row.type === 'SS' || row.type === 'NS' || row.type === 'BS'
|
||||
return set
|
||||
? <textarea className="textarea" aria-label="Attribute values" placeholder="One value per line" value={row.value} disabled={disabled} onChange={(event) => onChange(row.id, {value: event.target.value})}/>
|
||||
: <input className="input" aria-label="Attribute value" placeholder={row.type === 'B' ? 'Base64 value' : 'Value'} value={row.value} disabled={disabled} onChange={(event) => onChange(row.id, {value: event.target.value})}/>
|
||||
}
|
||||
|
||||
function defaultDocument(keyFields: DynamoKeyField[]): Record<string, unknown> {
|
||||
return Object.fromEntries(keyFields.map(({name, type}) => [name, {[type ?? 'S']: ''}]))
|
||||
}
|
||||
|
||||
function documentRows(document: Record<string, unknown>, keyFields: DynamoKeyField[]): AttributeRow[] {
|
||||
let nextId = 1
|
||||
const required = keyFields.map((key) => [key.name, document[key.name] ?? {[key.type ?? 'S']: ''}] as const)
|
||||
const otherAttributes = Object.entries(document).filter(([name]) => !keyFields.some((key) => key.name === name))
|
||||
return [...required, ...otherAttributes].map(([name, value]) => attributeValueRow(name, value, () => nextId++, false, keyFields.some((key) => key.name === name)))
|
||||
}
|
||||
|
||||
function attributeValueRow(name: string, value: unknown, id: () => number, nested: boolean, allowEmptyKey = false): AttributeRow {
|
||||
const attribute = parseAttributeValue(value, allowEmptyKey)
|
||||
const type = Object.keys(attribute)[0] as DynamoAttributeType
|
||||
const raw = attribute[type]
|
||||
if (type === 'L') {
|
||||
if (nested) throw new Error('The form editor supports List and Map one nesting level deep.')
|
||||
return {id: id(), name, type, value: '', children: (raw as unknown[]).map((item) => attributeValueRow('', item, id, true))}
|
||||
}
|
||||
if (type === 'M') {
|
||||
if (nested) throw new Error('The form editor supports List and Map one nesting level deep.')
|
||||
return {id: id(), name, type, value: '', children: Object.entries(raw as Record<string, unknown>).map(([childName, child]) => attributeValueRow(childName, child, id, true))}
|
||||
}
|
||||
if (type === 'BOOL') return {id: id(), name, type, value: String(raw), children: []}
|
||||
if (type === 'NULL') return {id: id(), name, type, value: '', children: []}
|
||||
if (type === 'B') return {id: id(), name, type, value: String(raw), children: []}
|
||||
return {id: id(), name, type, value: Array.isArray(raw) ? raw.join('\n') : String(raw), children: []}
|
||||
}
|
||||
|
||||
function rowsToDocument(rows: AttributeRow[]): Record<string, unknown> {
|
||||
const document: Record<string, unknown> = {}
|
||||
for (const row of rows) {
|
||||
if (!row.name) throw new Error('Every attribute needs a name.')
|
||||
if (Object.prototype.hasOwnProperty.call(document, row.name)) throw new Error(`Attribute ${row.name} is defined more than once.`)
|
||||
document[row.name] = rowToAttributeValue(row)
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
function rowToAttributeValue(row: AttributeRow): Record<string, unknown> {
|
||||
if (row.type === 'L') return {L: row.children.map(rowToAttributeValue)}
|
||||
if (row.type === 'M') return {M: rowsToDocument(row.children)}
|
||||
if (row.type === 'NULL') return {NULL: true}
|
||||
if (row.type === 'BOOL') return {BOOL: row.value === 'true'}
|
||||
if (row.type === 'N') {
|
||||
if (!NUMBER_PATTERN.test(row.value)) throw new Error(`Attribute ${row.name} must be a DynamoDB number.`)
|
||||
return {N: row.value}
|
||||
}
|
||||
if (row.type === 'B') {
|
||||
if (!isBase64(row.value)) throw new Error(`Binary attribute ${row.name} must be base64.`)
|
||||
return {B: row.value}
|
||||
}
|
||||
if (row.type === 'SS' || row.type === 'NS' || row.type === 'BS') {
|
||||
const values = row.value.split('\n').map((value) => value.trim()).filter(Boolean)
|
||||
if (!values.length) throw new Error(`Attribute ${row.name} must contain at least one value.`)
|
||||
if (row.type === 'NS' && values.some((value) => !NUMBER_PATTERN.test(value))) throw new Error(`Attribute ${row.name} must contain DynamoDB numbers.`)
|
||||
if (row.type === 'BS' && values.some((value) => !isBase64(value))) throw new Error(`Attribute ${row.name} must contain base64 values.`)
|
||||
return {[row.type]: values}
|
||||
}
|
||||
return {S: row.value}
|
||||
}
|
||||
|
||||
function parseAttributeValueDocument(json: string): Record<string, unknown> {
|
||||
let value: unknown
|
||||
try { value = JSON.parse(json) } catch { throw new Error('Invalid JSON.') }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Item must be a DynamoDB AttributeValue JSON object.')
|
||||
for (const attribute of Object.values(value as Record<string, unknown>)) parseAttributeValue(attribute)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseAttributeValue(value: unknown, allowEmptyKey = false): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Each attribute must be a DynamoDB AttributeValue object.')
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
if (entries.length !== 1 || !Object.prototype.hasOwnProperty.call(TYPE_LABELS, entries[0][0])) throw new Error('Each attribute must have exactly one DynamoDB AttributeValue type.')
|
||||
const [type, raw] = entries[0]
|
||||
if (type === 'S' && typeof raw === 'string') return value as Record<string, unknown>
|
||||
if (type === 'N' && typeof raw === 'string' && (NUMBER_PATTERN.test(raw) || allowEmptyKey && raw === '')) return value as Record<string, unknown>
|
||||
if (type === 'B' && typeof raw === 'string' && (isBase64(raw) || allowEmptyKey && raw === '')) return value as Record<string, unknown>
|
||||
if (type === 'BOOL' && typeof raw === 'boolean') return value as Record<string, unknown>
|
||||
if (type === 'NULL' && raw === true) return value as Record<string, unknown>
|
||||
if (type === 'SS' && Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string')) return value as Record<string, unknown>
|
||||
if (type === 'NS' && Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string' && NUMBER_PATTERN.test(item))) return value as Record<string, unknown>
|
||||
if (type === 'BS' && Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string' && isBase64(item))) return value as Record<string, unknown>
|
||||
if (type === 'L' && Array.isArray(raw) && raw.every((item) => {
|
||||
try { parseAttributeValue(item); return true } catch { return false }
|
||||
})) return value as Record<string, unknown>
|
||||
if (type === 'M' && raw && typeof raw === 'object' && !Array.isArray(raw) && Object.values(raw).every((item) => {
|
||||
try { parseAttributeValue(item); return true } catch { return false }
|
||||
})) return value as Record<string, unknown>
|
||||
throw new Error('Each attribute must use a valid DynamoDB AttributeValue.')
|
||||
}
|
||||
|
||||
function validateRequiredKeys(item: Record<string, unknown>, keyFields: DynamoKeyField[]) {
|
||||
for (const key of keyFields) {
|
||||
if (!Object.prototype.hasOwnProperty.call(item, key.name)) throw new Error(`Key attribute ${key.name} is required.`)
|
||||
const value = parseAttributeValue(item[key.name])
|
||||
if (key.type && !Object.prototype.hasOwnProperty.call(value, key.type)) throw new Error(`Key attribute ${key.name} must use type ${key.type}.`)
|
||||
}
|
||||
}
|
||||
|
||||
function updateRow(rows: AttributeRow[], id: number, update: (row: AttributeRow) => AttributeRow): AttributeRow[] {
|
||||
return rows.map((row) => row.id === id ? update(row) : {...row, children: updateRow(row.children, id, update)})
|
||||
}
|
||||
|
||||
function removeRowById(rows: AttributeRow[], id: number): AttributeRow[] {
|
||||
return rows.filter((row) => row.id !== id).map((row) => ({...row, children: removeRowById(row.children, id)}))
|
||||
}
|
||||
|
||||
function isBase64(value: string): boolean {
|
||||
return value.length > 0 && value.length % 4 === 0 && BASE64_PATTERN.test(value)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {MemoryRouter, useLocation} from 'react-router-dom'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {DynamoDbTableExplorer} from './DynamoDbTableExplorer'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
deleteFlatItem: vi.fn(),
|
||||
queryFlatItems: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||
|
||||
const table: CloudResource = {
|
||||
id: 'orders', name: 'orders', cloud: 'aws', service: 'nosql', type: 'dynamodb-table',
|
||||
region: null, createdAt: null, metadata: {
|
||||
keySchema: [{AttributeName: 'pk', KeyType: 'HASH'}, {AttributeName: 'sk', KeyType: 'RANGE'}],
|
||||
attributeDefinitions: [
|
||||
{AttributeName: 'pk', AttributeType: 'S'}, {AttributeName: 'sk', AttributeType: 'N'},
|
||||
{AttributeName: 'status', AttributeType: 'S'}, {AttributeName: 'createdAt', AttributeType: 'S'},
|
||||
],
|
||||
globalSecondaryIndexes: [{IndexName: 'status-created', KeySchema: [
|
||||
{AttributeName: 'status', KeyType: 'HASH'}, {AttributeName: 'createdAt', KeyType: 'RANGE'},
|
||||
]}],
|
||||
},
|
||||
}
|
||||
|
||||
function Location() {
|
||||
return <output data-testid="location">{useLocation().pathname}</output>
|
||||
}
|
||||
|
||||
function renderExplorer() {
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<MemoryRouter initialEntries={['/cloud-explorer/aws/nosql/orders/data']}><QueryClientProvider client={queryClient}><DynamoDbTableExplorer cloud="aws" resource={table} runtimeReachable/><Location/></QueryClientProvider></MemoryRouter>)
|
||||
}
|
||||
|
||||
test('renders returned DynamoDB attributes as columns and bulk deletes selected items', async () => {
|
||||
apiMocks.queryFlatItems.mockResolvedValue({items: [
|
||||
{id: '{"pk":"order-1"}', collectionId: null, timestamp: null, body: {pk: 'order-1', status: 'new', total: 12, tags: ['priority', 'web']}, metadata: {key: {pk: 'order-1'}}},
|
||||
{id: '{"pk":"order-2"}', collectionId: null, timestamp: null, body: {pk: 'order-2', active: true, total: 8}, metadata: {key: {pk: 'order-2'}}},
|
||||
], nextCursor: null})
|
||||
apiMocks.deleteFlatItem.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderExplorer()
|
||||
|
||||
expect(await screen.findByRole('columnheader', {name: 'pk'})).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', {name: 'status'})).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', {name: 'active'})).toBeInTheDocument()
|
||||
expect(screen.queryByRole('columnheader', {name: 'Key'})).not.toBeInTheDocument()
|
||||
expect(screen.getByText('priority, web')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByLabelText('Select all items'))
|
||||
await user.click(screen.getByText('Actions'))
|
||||
await user.click(screen.getByRole('button', {name: 'Delete item'}))
|
||||
expect(screen.getByRole('alertdialog')).toHaveTextContent('Delete 2 items?')
|
||||
expect(screen.getByText('Actions').closest('details')).not.toHaveAttribute('open')
|
||||
await user.click(screen.getByRole('button', {name: 'Delete items'}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.deleteFlatItem).toHaveBeenCalledWith('aws', 'nosql', 'orders', '{"pk":"order-1"}')
|
||||
expect(apiMocks.deleteFlatItem).toHaveBeenCalledWith('aws', 'nosql', 'orders', '{"pk":"order-2"}')
|
||||
})
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/cloud-explorer/aws/nosql/orders/data')
|
||||
})
|
||||
|
||||
test('delete menu action does not navigate to the item editor', async () => {
|
||||
apiMocks.queryFlatItems.mockResolvedValue({items: [
|
||||
{id: 'display-id', collectionId: null, timestamp: null, body: {pk: 'order-1'}, metadata: {key: {pk: 'order-1', sk: '2026-09-29'}}},
|
||||
], nextCursor: null})
|
||||
apiMocks.deleteFlatItem.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderExplorer()
|
||||
|
||||
await user.click(await screen.findByLabelText('Select item display-id'))
|
||||
await user.click(screen.getByText('Actions'))
|
||||
await user.click(screen.getByRole('button', {name: 'Delete item'}))
|
||||
expect(screen.getByRole('alertdialog')).toHaveTextContent('Delete item?')
|
||||
expect(screen.getByText('Actions').closest('details')).not.toHaveAttribute('open')
|
||||
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', {name: 'Delete item'}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.deleteFlatItem).toHaveBeenCalledWith('aws', 'nosql', 'orders', '{"pk":"order-1","sk":"2026-09-29"}')
|
||||
})
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/cloud-explorer/aws/nosql/orders/data')
|
||||
})
|
||||
|
||||
test('runs Scan with a typed server-side filter and loads the next cursor', async () => {
|
||||
apiMocks.queryFlatItems.mockImplementation(async (_cloud, _service, _resource, query, cursor) => {
|
||||
if (!query.filter) return {items: [], nextCursor: null, count: 0}
|
||||
return cursor ? {
|
||||
items: [{id: 'two', collectionId: null, timestamp: null, body: {pk: 'two'}, metadata: {key: {pk: 'two'}}}],
|
||||
nextCursor: null, count: 1, scannedCount: 2, consumedCapacity: 0.25,
|
||||
} : {
|
||||
items: [{id: 'one', collectionId: null, timestamp: null, body: {pk: 'one'}, metadata: {key: {pk: 'one'}}}],
|
||||
nextCursor: 'opaque-page-2', count: 1, scannedCount: 7, consumedCapacity: 0.5,
|
||||
}
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderExplorer()
|
||||
|
||||
await screen.findByText('No records')
|
||||
await user.type(screen.getByLabelText('Filter attribute'), 'total')
|
||||
await user.selectOptions(screen.getByLabelText('Filter operator'), 'GE')
|
||||
await user.selectOptions(screen.getByLabelText('Filter value type'), 'N')
|
||||
await user.type(screen.getByLabelText('Filter value'), '10')
|
||||
await user.click(screen.getByRole('button', {name: 'scan'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.queryFlatItems).toHaveBeenLastCalledWith(
|
||||
'aws', 'nosql', 'orders', {operation: 'scan', filter: {attribute: 'total', operator: 'GE', type: 'N', value: '10'}}, undefined, 100, expect.any(AbortSignal),
|
||||
))
|
||||
await user.click(await screen.findByRole('button', {name: 'Load more'}))
|
||||
await waitFor(() => expect(apiMocks.queryFlatItems).toHaveBeenLastCalledWith(
|
||||
'aws', 'nosql', 'orders', expect.any(Object), 'opaque-page-2', 100, expect.any(AbortSignal),
|
||||
))
|
||||
expect(await screen.findByText('two')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 returned, 9 scanned, 0.75 capacity units')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('adapts Query key fields to a selected index and submits conditions', async () => {
|
||||
apiMocks.queryFlatItems.mockResolvedValue({items: [], nextCursor: null, count: 0})
|
||||
const user = userEvent.setup()
|
||||
renderExplorer()
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Query'}))
|
||||
await user.selectOptions(screen.getByLabelText('Index'), 'status-created')
|
||||
expect(screen.getByText('status (S)')).toBeInTheDocument()
|
||||
await user.type(screen.getByLabelText('Partition key value'), 'OPEN')
|
||||
await user.selectOptions(screen.getByLabelText('Sort key condition'), 'begins_with')
|
||||
await user.type(screen.getByLabelText('Sort key value'), '2026-09')
|
||||
await user.click(screen.getByRole('button', {name: 'query'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.queryFlatItems).toHaveBeenLastCalledWith(
|
||||
'aws', 'nosql', 'orders', {
|
||||
operation: 'query', indexName: 'status-created', partitionKeyValue: 'OPEN',
|
||||
sortKeyCondition: {operator: 'begins_with', value: '2026-09'},
|
||||
}, undefined, 100, expect.any(AbortSignal),
|
||||
))
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {Braces, Plus, RefreshCw, TableProperties} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {listNoSqlItems, putNoSqlItem} from '@/api/cloudProxyClient'
|
||||
import {JsonRecordModal} from '@/components/JsonRecordModal'
|
||||
import {useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {Braces, Download, Plus, RefreshCw, TriangleAlert, Trash2} from 'lucide-react'
|
||||
import {useInfiniteQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import {useNavigate} from 'react-router-dom'
|
||||
import {deleteFlatItem, queryFlatItems} from '@/api/cloudProxyClient'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource, NoSqlItem} from '@/types/resource'
|
||||
import type {ChildItem, CloudResource} from '@/types/resource'
|
||||
|
||||
interface DynamoDbTableExplorerProps {
|
||||
cloud: CloudProvider
|
||||
@@ -13,166 +14,276 @@ interface DynamoDbTableExplorerProps {
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
const EMPTY_ITEMS: ChildItem[] = []
|
||||
const PAGE_LIMIT = 100
|
||||
|
||||
type DynamoOperation = 'scan' | 'query'
|
||||
type DynamoType = 'S' | 'N' | 'B' | 'BOOL'
|
||||
|
||||
interface DynamoRequest extends Record<string, unknown> {
|
||||
operation: DynamoOperation
|
||||
indexName?: string
|
||||
partitionKeyValue?: string
|
||||
sortKeyCondition?: {operator: string; value: string; secondValue?: string}
|
||||
filter?: {attribute: string; operator: string; type: DynamoType; value?: string; secondValue?: string}
|
||||
}
|
||||
|
||||
export function DynamoDbTableExplorer({cloud, resource, runtimeReachable}: DynamoDbTableExplorerProps) {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const accountId = useAccountId()
|
||||
const tableName = resource?.id
|
||||
const [selectedItemId, setSelectedItemId] = useState<string>()
|
||||
const [addRecordOpen, setAddRecordOpen] = useState(false)
|
||||
const itemsKey = useMemo(() => ['nosql-items', accountId, cloud, tableName], [accountId, cloud, tableName])
|
||||
const keyFields = useMemo(() => dynamoKeyFields(resource), [resource])
|
||||
const itemsQuery = useQuery({
|
||||
const actionsRef = useRef<HTMLDetailsElement>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [inspectedItemId, setInspectedItemId] = useState<string>()
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<ChildItem[]>()
|
||||
const [operation, setOperation] = useState<DynamoOperation>('scan')
|
||||
const [indexName, setIndexName] = useState('')
|
||||
const [partitionValue, setPartitionValue] = useState('')
|
||||
const [sortOperator, setSortOperator] = useState('')
|
||||
const [sortValue, setSortValue] = useState('')
|
||||
const [sortSecondValue, setSortSecondValue] = useState('')
|
||||
const [filterAttribute, setFilterAttribute] = useState('')
|
||||
const [filterOperator, setFilterOperator] = useState('EQ')
|
||||
const [filterType, setFilterType] = useState<DynamoType>('S')
|
||||
const [filterValue, setFilterValue] = useState('')
|
||||
const [filterSecondValue, setFilterSecondValue] = useState('')
|
||||
const [request, setRequest] = useState<DynamoRequest>({operation: 'scan'})
|
||||
const indexes = useMemo(() => dynamoIndexes(resource), [resource])
|
||||
const selectedIndex = indexes.find((index) => index.name === indexName) ?? indexes[0]!
|
||||
const itemsKey = useMemo(() => ['flat-items', accountId, cloud, 'nosql', tableName, request], [accountId, cloud, tableName, request])
|
||||
const itemsQuery = useInfiniteQuery({
|
||||
queryKey: itemsKey,
|
||||
queryFn: ({signal}) => listNoSqlItems(cloud, tableName ?? '', signal),
|
||||
queryFn: ({pageParam, signal}) => queryFlatItems(cloud, 'nosql', tableName ?? '', request, pageParam, PAGE_LIMIT, signal),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
enabled: Boolean(tableName) && runtimeReachable,
|
||||
})
|
||||
const items = itemsQuery.data ?? []
|
||||
const selectedItem = items.find((item) => item.id === selectedItemId)
|
||||
const putItemMut = useMutation({
|
||||
mutationFn: (document: Record<string, unknown>) => putNoSqlItem(cloud, tableName ?? '', document),
|
||||
onSuccess: (item) => {
|
||||
setAddRecordOpen(false)
|
||||
setSelectedItemId(item.id)
|
||||
void qc.invalidateQueries({queryKey: itemsKey})
|
||||
const pages = itemsQuery.data?.pages ?? []
|
||||
const items = pages.length ? pages.flatMap((page) => page.items) : EMPTY_ITEMS
|
||||
const count = pages.reduce((total, page) => total + (page.count ?? page.items.length), 0)
|
||||
const scannedCount = pages.some((page) => page.scannedCount !== undefined) ? pages.reduce((total, page) => total + (page.scannedCount ?? 0), 0) : undefined
|
||||
const consumedCapacity = pages.some((page) => page.consumedCapacity !== undefined) ? pages.reduce((total, page) => total + (page.consumedCapacity ?? 0), 0) : undefined
|
||||
const attributes = useMemo(() => itemAttributes(items), [items])
|
||||
const selectedItems = items.filter((item) => selectedIds.has(item.id))
|
||||
const inspectedItem = items.find((item) => item.id === inspectedItemId)
|
||||
const deleteItems = useMutation({
|
||||
mutationFn: async (itemsToDelete: ChildItem[]) => Promise.all(itemsToDelete.map((item) => deleteFlatItem(cloud, 'nosql', tableName ?? '', immutableItemKey(item)))),
|
||||
onSuccess: () => {
|
||||
setDeleteConfirmation(undefined)
|
||||
setSelectedIds(new Set())
|
||||
setInspectedItemId(undefined)
|
||||
void queryClient.invalidateQueries({queryKey: itemsKey})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItemId(undefined)
|
||||
setAddRecordOpen(false)
|
||||
setSelectedIds(new Set())
|
||||
setInspectedItemId(undefined)
|
||||
setOperation('scan')
|
||||
setIndexName('')
|
||||
setRequest({operation: 'scan'})
|
||||
}, [tableName])
|
||||
|
||||
useEffect(() => {
|
||||
const availableIds = new Set(items.map((item) => item.id))
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set([...current].filter((id) => availableIds.has(id)))
|
||||
return next.size === current.size ? current : next
|
||||
})
|
||||
}, [items])
|
||||
|
||||
if (!tableName) {
|
||||
return (
|
||||
<section className="dynamodb-explorer">
|
||||
<div className="empty compact">
|
||||
<h3>Select a DynamoDB table</h3>
|
||||
<p>Records and their contents load after a table is selected.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
return <section className="dynamodb-explorer"><div className="empty compact"><h3>Select a DynamoDB table</h3><p>Records and their contents load after a table is selected.</p></div></section>
|
||||
}
|
||||
|
||||
function toggleItem(itemId: string) {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(itemId)) next.delete(itemId)
|
||||
else next.add(itemId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function requestDeleteSelected() {
|
||||
if (!selectedItems.length || deleteItems.isPending) return
|
||||
closeActions()
|
||||
setDeleteConfirmation(selectedItems)
|
||||
}
|
||||
|
||||
function closeActions() {
|
||||
if (actionsRef.current) actionsRef.current.open = false
|
||||
}
|
||||
|
||||
function run(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const next: DynamoRequest = {operation, ...(indexName ? {indexName} : {})}
|
||||
if (operation === 'query') {
|
||||
next.partitionKeyValue = partitionValue
|
||||
if (sortOperator) next.sortKeyCondition = {
|
||||
operator: sortOperator,
|
||||
value: sortValue,
|
||||
...(sortOperator === 'BETWEEN' ? {secondValue: sortSecondValue} : {}),
|
||||
}
|
||||
}
|
||||
if (filterAttribute) next.filter = {
|
||||
attribute: filterAttribute,
|
||||
operator: filterOperator,
|
||||
type: filterType,
|
||||
...(!filterOperator.startsWith('attribute_') ? {value: filterValue} : {}),
|
||||
...(filterOperator === 'BETWEEN' ? {secondValue: filterSecondValue} : {}),
|
||||
}
|
||||
setSelectedIds(new Set())
|
||||
setInspectedItemId(undefined)
|
||||
setRequest(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="dynamodb-explorer">
|
||||
<div className="cosmos-column cosmos-column--wide">
|
||||
<PanelHeader icon={<TableProperties size={15}/>} eyebrow="Table explorer" title={tableName} detail={`${items.length} records`}/>
|
||||
<PanelHeader icon={<ServiceIcon cloud="aws" iconKey="nosql" size={18}/>} eyebrow="Table explorer" title={tableName} detail={`${count} returned${scannedCount === undefined ? '' : `, ${scannedCount} scanned`}${consumedCapacity === undefined ? '' : `, ${formatCapacity(consumedCapacity)} capacity units`}`}/>
|
||||
<form className="dynamodb-query" onSubmit={run}>
|
||||
<div className="dynamodb-mode" role="group" aria-label="Read mode">
|
||||
<button type="button" className={operation === 'scan' ? 'active' : ''} onClick={() => setOperation('scan')}>Scan</button>
|
||||
<button type="button" className={operation === 'query' ? 'active' : ''} onClick={() => setOperation('query')}>Query</button>
|
||||
</div>
|
||||
<label>Index<select aria-label="Index" value={indexName} onChange={(event) => { setIndexName(event.target.value); setSortOperator('') }}>
|
||||
{indexes.map((index) => <option key={index.name} value={index.name}>{index.label}</option>)}
|
||||
</select></label>
|
||||
{operation === 'query' && <>
|
||||
<label>{selectedIndex.partition.name} ({selectedIndex.partition.type ?? 'S'})<input aria-label="Partition key value" required value={partitionValue} onChange={(event) => setPartitionValue(event.target.value)}/></label>
|
||||
{selectedIndex.sort && <><label>Sort condition<select aria-label="Sort key condition" value={sortOperator} onChange={(event) => setSortOperator(event.target.value)}><option value="">None</option>{['EQ', 'LT', 'LE', 'GT', 'GE', 'BETWEEN', 'begins_with'].map((value) => <option key={value}>{value}</option>)}</select></label>
|
||||
{sortOperator && <label>{selectedIndex.sort.name} ({selectedIndex.sort.type ?? 'S'})<input aria-label="Sort key value" required value={sortValue} onChange={(event) => setSortValue(event.target.value)}/></label>}
|
||||
{sortOperator === 'BETWEEN' && <label>And<input aria-label="Second sort key value" required value={sortSecondValue} onChange={(event) => setSortSecondValue(event.target.value)}/></label>}</>}
|
||||
</>}
|
||||
<label>Filter attribute<input aria-label="Filter attribute" placeholder="Optional" value={filterAttribute} onChange={(event) => setFilterAttribute(event.target.value)}/></label>
|
||||
{filterAttribute && <><label>Operator<select aria-label="Filter operator" value={filterOperator} onChange={(event) => setFilterOperator(event.target.value)}>{['EQ', 'NE', 'LT', 'LE', 'GT', 'GE', 'BETWEEN', 'begins_with', 'contains', 'attribute_exists', 'attribute_not_exists'].map((value) => <option key={value}>{value}</option>)}</select></label>
|
||||
{!filterOperator.startsWith('attribute_') && <><label>Type<select aria-label="Filter value type" value={filterType} onChange={(event) => setFilterType(event.target.value as DynamoType)}><option value="S">String</option><option value="N">Number</option><option value="B">Binary (base64)</option><option value="BOOL">Boolean</option></select></label><label>Value<input aria-label="Filter value" required value={filterValue} onChange={(event) => setFilterValue(event.target.value)}/></label></>}
|
||||
{filterOperator === 'BETWEEN' && <label>And<input aria-label="Second filter value" required value={filterSecondValue} onChange={(event) => setFilterSecondValue(event.target.value)}/></label>}</>}
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || itemsQuery.isFetching}>{itemsQuery.isFetching ? 'Running' : operation}</button>
|
||||
</form>
|
||||
<div className="cosmos-toolbar">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={!runtimeReachable}
|
||||
onClick={() => {
|
||||
putItemMut.reset()
|
||||
setAddRecordOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={14}/>
|
||||
Add record
|
||||
</button>
|
||||
<button className="button" type="button" disabled={itemsQuery.isFetching} onClick={() => itemsQuery.refetch()}>
|
||||
<RefreshCw size={14}/>
|
||||
{itemsQuery.isFetching ? 'Loading' : 'Refresh'}
|
||||
</button>
|
||||
<button className="button primary" type="button" disabled={!runtimeReachable} onClick={() => navigate(`/cloud-explorer/${cloud}/nosql/${encodeURIComponent(tableName)}/data/items/new`)}><Plus size={14}/>Add record</button>
|
||||
<details className="dynamodb-actions" ref={actionsRef}>
|
||||
<summary>Actions</summary>
|
||||
<div className="dynamodb-actions-menu" onClick={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<button className="dynamodb-action-edit" type="button" disabled={selectedItems.length !== 1 || !runtimeReachable} onClick={(event) => { event.preventDefault(); event.stopPropagation(); closeActions(); navigate(`/cloud-explorer/${cloud}/nosql/${encodeURIComponent(tableName)}/data/items/${encodeURIComponent(selectedItems[0].id)}/edit`) }}>Edit item</button>
|
||||
<button className="dynamodb-action-delete" type="button" disabled={!selectedItems.length || !runtimeReachable || deleteItems.isPending} onClick={(event) => { event.preventDefault(); event.stopPropagation(); requestDeleteSelected() }}><Trash2 size={14}/>Delete item</button>
|
||||
<button className="dynamodb-action-download" type="button" disabled={!items.length} onClick={(event) => { event.preventDefault(); event.stopPropagation(); closeActions(); downloadCsv(tableName, attributes, items) }}><Download size={14}/>Download CSV</button>
|
||||
</div>
|
||||
</details>
|
||||
<button className="button" type="button" disabled={itemsQuery.isFetching} onClick={() => itemsQuery.refetch()}><RefreshCw size={14}/>{itemsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
{itemsQuery.error instanceof Error && <div className="form-error">{itemsQuery.error.message}</div>}
|
||||
{(itemsQuery.error instanceof Error || deleteItems.error instanceof Error) && <div className="form-error">{itemsQuery.error instanceof Error ? itemsQuery.error.message : deleteItems.error instanceof Error ? deleteItems.error.message : undefined}</div>}
|
||||
<div className="cosmos-items-table">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Contents</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className={selectedItemId === item.id ? 'selected' : ''}>
|
||||
<td onClick={() => setSelectedItemId(item.id)}><code>{formatJson(item.key)}</code></td>
|
||||
<td onClick={() => setSelectedItemId(item.id)}>{summarize(item)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<thead><tr>
|
||||
<th className="dynamodb-selection"><input aria-label="Select all items" type="checkbox" checked={items.length > 0 && selectedItems.length === items.length} onChange={() => setSelectedIds(selectedItems.length === items.length ? new Set() : new Set(items.map((item) => item.id)))}/></th>
|
||||
{attributes.map((attribute) => <th key={attribute}>{attribute}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{items.map((item) => <tr key={item.id} className={inspectedItemId === item.id ? 'selected' : ''}>
|
||||
<td className="dynamodb-selection"><input aria-label={`Select item ${item.id}`} type="checkbox" checked={selectedIds.has(item.id)} onChange={() => toggleItem(item.id)}/></td>
|
||||
{attributes.map((attribute) => <td key={attribute} onClick={() => setInspectedItemId(item.id)}>{formatAttributeValue(item.body[attribute])}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
{itemsQuery.isLoading && <div className="muted padded">Loading records</div>}
|
||||
{itemsQuery.isSuccess && items.length === 0 && <div className="empty compact"><h3>No records</h3><p>This table does not contain any items.</p></div>}
|
||||
</div>
|
||||
{itemsQuery.hasNextPage && <button className="button dynamodb-load-more" type="button" disabled={itemsQuery.isFetchingNextPage} onClick={() => itemsQuery.fetchNextPage()}>{itemsQuery.isFetchingNextPage ? 'Loading' : 'Load more'}</button>}
|
||||
</div>
|
||||
|
||||
<div className="cosmos-column">
|
||||
<PanelHeader icon={<Braces size={15}/>} eyebrow="Record contents" title={selectedItem ? formatJson(selectedItem.key) : 'Select record'} detail="DynamoDB attributes normalized to JSON"/>
|
||||
{selectedItem ? (
|
||||
<pre className="dynamodb-document">{JSON.stringify(selectedItem.document, null, 2)}</pre>
|
||||
) : (
|
||||
<div className="empty compact"><h3>Select a record</h3><p>Full record contents appear here.</p></div>
|
||||
)}
|
||||
<PanelHeader icon={<Braces size={15}/>} eyebrow="Record contents" title={inspectedItem ? 'Selected record' : 'Select record'} detail="DynamoDB attributes normalized to JSON"/>
|
||||
{inspectedItem ? <pre className="dynamodb-document">{JSON.stringify(inspectedItem.body, null, 2)}</pre> : <div className="empty compact"><h3>Select a record</h3><p>Full record contents appear here.</p></div>}
|
||||
</div>
|
||||
|
||||
<JsonRecordModal
|
||||
open={addRecordOpen}
|
||||
title={`Add record to ${tableName}`}
|
||||
description={dynamoModalDescription(keyFields)}
|
||||
initialValue={JSON.stringify(Object.fromEntries(keyFields.map(({name, type}) => [name, type === 'N' ? '0' : ''])), null, 2)}
|
||||
isPending={putItemMut.isPending}
|
||||
submitError={putItemMut.error instanceof Error ? putItemMut.error.message : undefined}
|
||||
onClose={() => setAddRecordOpen(false)}
|
||||
onSubmit={(document) => putItemMut.mutate(document)}
|
||||
/>
|
||||
{deleteConfirmation && <div className="modal-overlay" onClick={(event) => event.target === event.currentTarget && !deleteItems.isPending && setDeleteConfirmation(undefined)}>
|
||||
<div className="dynamodb-delete-dialog" role="alertdialog" aria-modal="true" aria-labelledby="dynamodb-delete-title">
|
||||
<div className="dynamodb-delete-dialog-heading"><span><TriangleAlert size={20}/></span><div><h3 id="dynamodb-delete-title">Delete {deleteConfirmation.length === 1 ? 'item' : `${deleteConfirmation.length} items`}?</h3><p>This permanently removes the selected {deleteConfirmation.length === 1 ? 'item' : 'items'} from <strong>{tableName}</strong>.</p></div></div>
|
||||
<div className="dynamodb-delete-warning">This action cannot be undone.</div>
|
||||
{deleteItems.error instanceof Error && <div className="form-error">{deleteItems.error.message}</div>}
|
||||
<div className="dynamodb-delete-dialog-actions"><button className="button" type="button" disabled={deleteItems.isPending} onClick={() => setDeleteConfirmation(undefined)}>Cancel</button><button className="button danger" type="button" disabled={deleteItems.isPending} onClick={() => deleteItems.mutate(deleteConfirmation)}><Trash2 size={14}/>{deleteItems.isPending ? 'Deleting' : `Delete ${deleteConfirmation.length === 1 ? 'item' : 'items'}`}</button></div>
|
||||
</div>
|
||||
</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface DynamoKeyField {
|
||||
interface DynamoIndex {
|
||||
name: string
|
||||
type?: 'S' | 'N' | 'B'
|
||||
label: string
|
||||
partition: {name: string; type?: 'S' | 'N' | 'B'}
|
||||
sort?: {name: string; type?: 'S' | 'N' | 'B'}
|
||||
}
|
||||
|
||||
function dynamoKeyFields(resource?: CloudResource): DynamoKeyField[] {
|
||||
const keySchema = Array.isArray(resource?.metadata.keySchema) ? resource.metadata.keySchema : []
|
||||
function dynamoIndexes(resource?: CloudResource): DynamoIndex[] {
|
||||
const definitions = Array.isArray(resource?.metadata.attributeDefinitions) ? resource.metadata.attributeDefinitions : []
|
||||
const types = new Map(definitions.flatMap((definition) => {
|
||||
const types = new Map<string, 'S' | 'N' | 'B'>(definitions.flatMap((definition) => {
|
||||
if (!definition || typeof definition !== 'object') return []
|
||||
const {AttributeName, AttributeType} = definition as {AttributeName?: unknown; AttributeType?: unknown}
|
||||
return typeof AttributeName === 'string' && isDynamoKeyType(AttributeType) ? [[AttributeName, AttributeType] as const] : []
|
||||
const value = definition as {AttributeName?: unknown; AttributeType?: unknown}
|
||||
return typeof value.AttributeName === 'string' && (value.AttributeType === 'S' || value.AttributeType === 'N' || value.AttributeType === 'B') ? [[value.AttributeName, value.AttributeType]] : []
|
||||
}))
|
||||
|
||||
return keySchema.flatMap((key) => {
|
||||
if (!key || typeof key !== 'object') return []
|
||||
const name = (key as {AttributeName?: unknown}).AttributeName
|
||||
return typeof name === 'string' ? [{name, type: types.get(name)}] : []
|
||||
})
|
||||
const result = [indexFromSchema('', 'Base table', resource?.metadata.keySchema, types)]
|
||||
for (const [kind, rawIndexes] of [['GSI', resource?.metadata.globalSecondaryIndexes], ['LSI', resource?.metadata.localSecondaryIndexes]] as const) {
|
||||
if (!Array.isArray(rawIndexes)) continue
|
||||
for (const rawIndex of rawIndexes) {
|
||||
if (!rawIndex || typeof rawIndex !== 'object') continue
|
||||
const index = rawIndex as {IndexName?: unknown; KeySchema?: unknown}
|
||||
if (typeof index.IndexName === 'string') result.push(indexFromSchema(index.IndexName, `${index.IndexName} (${kind})`, index.KeySchema, types))
|
||||
}
|
||||
}
|
||||
return result.filter((index): index is DynamoIndex => Boolean(index.partition.name))
|
||||
}
|
||||
|
||||
function dynamoModalDescription(keyFields: DynamoKeyField[]): string {
|
||||
const keys = keyFields.map(({name, type}) => `${name}${type ? ` (${type})` : ''}`).join(', ')
|
||||
const binaryHint = keyFields.some(({type}) => type === 'B') ? ' Binary keys use base64.' : ''
|
||||
const numberHint = keyFields.some(({type}) => type === 'N') ? ' All number keys must be quoted to preserve precision.' : ''
|
||||
return `Required keys: ${keys || 'the table key attributes'}.${binaryHint}${numberHint} A matching key replaces the existing record.`
|
||||
function indexFromSchema(name: string, label: string, rawSchema: unknown, types: Map<string, 'S' | 'N' | 'B'>): DynamoIndex {
|
||||
const schema = Array.isArray(rawSchema) ? rawSchema : []
|
||||
const field = (keyType: string) => {
|
||||
const raw = schema.find((entry) => entry && typeof entry === 'object' && (entry as {KeyType?: unknown}).KeyType === keyType) as {AttributeName?: unknown} | undefined
|
||||
const keyName = typeof raw?.AttributeName === 'string' ? raw.AttributeName : ''
|
||||
return {name: keyName, type: types.get(keyName)}
|
||||
}
|
||||
const partition = field('HASH')
|
||||
const sort = field('RANGE')
|
||||
return {name, label, partition, ...(sort.name ? {sort} : {})}
|
||||
}
|
||||
|
||||
function isDynamoKeyType(value: unknown): value is 'S' | 'N' | 'B' {
|
||||
return value === 'S' || value === 'N' || value === 'B'
|
||||
function formatCapacity(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
|
||||
}
|
||||
|
||||
function PanelHeader({icon, eyebrow, title, detail}: {icon: React.ReactNode; eyebrow: string; title: string; detail: string}) {
|
||||
return (
|
||||
<div className="cosmos-panel-header">
|
||||
{icon}
|
||||
<span>
|
||||
<small>{eyebrow}</small>
|
||||
<strong>{title}</strong>
|
||||
<em>{detail}</em>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
return <div className="cosmos-panel-header">{icon}<span><small>{eyebrow}</small><strong>{title}</strong><em>{detail}</em></span></div>
|
||||
}
|
||||
|
||||
function summarize(item: NoSqlItem): string {
|
||||
return Object.entries(item.document)
|
||||
.slice(0, 3)
|
||||
.map(([name, value]) => `${name}: ${formatJson(value)}`)
|
||||
.join(' · ')
|
||||
function itemAttributes(items: ChildItem[]): string[] {
|
||||
return [...new Set(items.flatMap((item) => Object.keys(item.body)))]
|
||||
}
|
||||
|
||||
function formatJson(value: unknown): string {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
function immutableItemKey(item: ChildItem): string {
|
||||
const key = item.metadata.key
|
||||
if (!key || typeof key !== 'object' || Array.isArray(key)) {
|
||||
throw new Error('DynamoDB item is missing its primary key.')
|
||||
}
|
||||
return JSON.stringify(key)
|
||||
}
|
||||
|
||||
function formatAttributeValue(value: unknown): string {
|
||||
if (value === undefined) return ''
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (Array.isArray(value)) return value.map(formatAttributeValue).join(', ')
|
||||
return JSON.stringify(value) ?? ''
|
||||
}
|
||||
|
||||
function downloadCsv(tableName: string, attributes: string[], items: ChildItem[]) {
|
||||
const csv = [attributes, ...items.map((item) => attributes.map((attribute) => formatAttributeValue(item.body[attribute])))]
|
||||
.map((row) => row.map(csvCell).join(',')).join('\r\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], {type: 'text/csv;charset=utf-8'}))
|
||||
link.download = `${tableName}-items.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
function csvCell(value: string): string {
|
||||
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {CalendarClock, Loader2, Plus, Radio, Trash2} from 'lucide-react'
|
||||
import {CalendarClock, Loader2, Plus, Send, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createEventBridgeRule, deleteEventBridgeRule, listCloudResources, listEventBridgeRules, type EventBridgeRuleOverview} from '@/api/cloudProxyClient'
|
||||
import {createEventBridgeRule, deleteEventBridgeRule, listCloudResources, listEventBridgeRules, putEventBridgeEvent, type EventBridgeRuleOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider, CloudServiceType} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
|
||||
interface EventBridgeRulePanelProps {
|
||||
cloud: CloudProvider
|
||||
@@ -30,6 +31,9 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
const [targetService, setTargetService] = useState<TargetService>('lambda')
|
||||
const [targetArn, setTargetArn] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<EventBridgeRuleOverview | null>(null)
|
||||
const [eventSource, setEventSource] = useState('orders')
|
||||
const [eventDetailType, setEventDetailType] = useState('order.created')
|
||||
const [eventDetail, setEventDetail] = useState('{\n "orderId": "example-123"\n}')
|
||||
const queryClient = useQueryClient()
|
||||
const targetQueries = {
|
||||
serverless: useQuery({
|
||||
@@ -69,6 +73,9 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
setTargetService('lambda')
|
||||
setTargetArn('')
|
||||
setDeleteTarget(null)
|
||||
setEventSource('orders')
|
||||
setEventDetailType('order.created')
|
||||
setEventDetail('{\n "orderId": "example-123"\n}')
|
||||
}, [resource?.id])
|
||||
|
||||
const resourcesByTargetService = (Object.keys(targetServiceDetails) as TargetService[]).reduce<Record<TargetService, CloudResource[]>>((resources, service) => {
|
||||
@@ -118,6 +125,11 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
void queryClient.invalidateQueries({queryKey: ['eventbridge-rules', cloud, resource?.id]})
|
||||
},
|
||||
})
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: () => putEventBridgeEvent(cloud, resource!.id, {source: eventSource.trim(), detailType: eventDetailType.trim(), detail: eventDetail.trim()}),
|
||||
})
|
||||
const eventDetailValid = isJsonObject(eventDetail)
|
||||
const canPublish = Boolean(eventSource.trim() && eventDetailType.trim() && eventDetail.trim() && eventDetailValid) && !publishMutation.isPending
|
||||
|
||||
if (!resource || resource.service !== 'events' || resource.type !== 'event-bus') return null
|
||||
|
||||
@@ -125,7 +137,7 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">EventBridge Rules</p>
|
||||
<h3><Radio size={15}/> Add rule to {resource.name}</h3>
|
||||
<h3><ServiceIcon cloud="aws" iconKey="events" size={18}/> Add rule to {resource.name}</h3>
|
||||
<p className="muted compact-text">Route a schedule or matching events from this event bus to one existing target.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,6 +158,17 @@ export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventB
|
||||
</form>
|
||||
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to create EventBridge rule.'}</p>}
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Test event delivery</p><h3><Send size={15}/> Publish to {resource.name}</h3><p className="muted compact-text">Sends one real EventBridge PutEvents entry to this bus.</p></div></div>
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => { event.preventDefault(); if (canPublish) publishMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>Source</span><input className="input" value={eventSource} onChange={(event) => setEventSource(event.target.value)} required/></label>
|
||||
<label className="dynamic-field"><span>Detail type</span><input className="input" value={eventDetailType} onChange={(event) => setEventDetailType(event.target.value)} required/></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Detail (JSON)</span><textarea className="textarea code-textarea small" value={eventDetail} onChange={(event) => setEventDetail(event.target.value)} spellCheck={false} required/>{!eventDetailValid && <small className="error-text">Detail must be a JSON object.</small>}</label>
|
||||
<button className="button primary apigateway-route-submit" type="submit" disabled={!canPublish}>{publishMutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{publishMutation.isPending ? 'Publishing event' : 'Publish event'}</button>
|
||||
</form>
|
||||
{publishMutation.isSuccess && <p className={publishMutation.data.errorCode ? 'error-text compact-text' : 'success-text compact-text'}>{publishMutation.data.errorCode ? `${publishMutation.data.errorCode}: ${publishMutation.data.errorMessage}` : `Published event ${publishMutation.data.eventId}`}</p>}
|
||||
{publishMutation.isError && <p className="error-text compact-text">{publishMutation.error instanceof Error ? publishMutation.error.message : 'Unable to publish EventBridge event.'}</p>}
|
||||
</div>
|
||||
<div className="apigateway-route-explorer">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Rules</p><h3><CalendarClock size={15}/> Rules and targets</h3></div></div>
|
||||
{rulesQuery.isLoading && <p className="muted compact-text">Loading EventBridge rules...</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {Copy, KeyRound, Loader2, LockKeyhole, UnlockKeyhole} from "lucide-react";
|
||||
import {Copy, Loader2, LockKeyhole, UnlockKeyhole} from "lucide-react";
|
||||
import {
|
||||
decryptKmsResource,
|
||||
encryptKmsResource,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {useAccountId} from "@/lib/accountStore";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
import {ServiceIcon} from "@/components/serviceIcons";
|
||||
|
||||
interface KmsCryptoPanelProps {
|
||||
cloud: CloudProvider;
|
||||
@@ -136,7 +137,7 @@ export function KmsCryptoPanel({cloud, resource, runtimeReachable}: KmsCryptoPan
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">KMS Actions</p>
|
||||
<h3><KeyRound size={15} /> Encrypt and decrypt</h3>
|
||||
<h3><ServiceIcon cloud="aws" iconKey="kms" size={18} /> Encrypt and decrypt</h3>
|
||||
<p className="muted compact-text">
|
||||
Values stay in this panel and are cleared when you switch keys or operations.
|
||||
</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {useQuery} from '@tanstack/react-query'
|
||||
import {getCloudStatus} from '@/api/cloudProxyClient'
|
||||
import {useCloudServicesQuery} from '@/api/queries/cloudQueries'
|
||||
import {AccountSwitcher} from '@/components/AccountSwitcher'
|
||||
import {serviceIcon} from '@/components/serviceIcons'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import type {CloudProvider, CloudServiceDescriptor, RuntimeReachability} from '@/types/cloud'
|
||||
|
||||
/** The runtime's reachability, plus the state before the status query answers. */
|
||||
@@ -34,11 +34,10 @@ function connectionDotClass(status: ConnectionStatus): string {
|
||||
/** Matches today's service count, so the real nav causes no layout jump. */
|
||||
const SKELETON_ROWS = 7
|
||||
|
||||
function NavItem({to, icon, label, collapsed}: { to: string; icon: React.ElementType; label: string; collapsed: boolean }) {
|
||||
const Icon = icon
|
||||
function NavItem({to, icon, label, collapsed}: { to: string; icon: React.ReactNode; label: string; collapsed: boolean }) {
|
||||
return (
|
||||
<NavLink className="nav-link" to={to} title={collapsed ? label : undefined}>
|
||||
<Icon size={14} aria-hidden="true"/>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
@@ -110,14 +109,14 @@ function CloudServiceNav({collapsed}: {collapsed: boolean}) {
|
||||
}
|
||||
|
||||
function CloudServiceNavItem({cloud, service, collapsed}: {cloud: CloudProvider; service: CloudServiceDescriptor; collapsed: boolean}) {
|
||||
const Icon = serviceIcon(service.iconKey)
|
||||
const reasonTitle = service.reason ? `${service.displayName} — ${service.reason}` : service.displayName
|
||||
const icon = <ServiceIcon cloud={cloud} iconKey={service.iconKey} size={14}/>
|
||||
|
||||
if (service.availability === 'available') {
|
||||
const target = service.route.startsWith('/')
|
||||
? service.route
|
||||
: `/cloud-explorer/${cloud}/${service.route}`
|
||||
return <NavItem to={target} icon={Icon} label={service.displayName} collapsed={collapsed}/>
|
||||
return <NavItem to={target} icon={icon} label={service.displayName} collapsed={collapsed}/>
|
||||
}
|
||||
|
||||
// Title duplicates the visible label when the sidebar is expanded, which WAVE
|
||||
@@ -127,7 +126,7 @@ function CloudServiceNavItem({cloud, service, collapsed}: {cloud: CloudProvider;
|
||||
className="nav-link disabled"
|
||||
title={collapsed || service.reason ? reasonTitle : undefined}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true"/>
|
||||
{icon}
|
||||
<span>{service.displayName}</span>
|
||||
<span className="nav-soon">Soon</span>
|
||||
</div>
|
||||
@@ -188,7 +187,7 @@ export function Layout() {
|
||||
<nav className="nav" aria-label="Console">
|
||||
<div className="nav-section">
|
||||
<span className="nav-label">General</span>
|
||||
<NavItem to={`/console/${activeCloud}`} icon={LayoutDashboard} label="Console Home" collapsed={collapsed}/>
|
||||
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={14} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
||||
</div>
|
||||
<CloudServiceNav collapsed={collapsed}/>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {LogsExplorerPanel} from './LogsExplorerPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
filterLogEvents: vi.fn(),
|
||||
listChildCollections: vi.fn(),
|
||||
listCollectionItems: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||
|
||||
const group: CloudResource = {
|
||||
id: '/aws/lambda/orders', name: '/aws/lambda/orders', cloud: 'aws', service: 'logs',
|
||||
type: 'log-group', region: null, createdAt: null, metadata: {},
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><LogsExplorerPanel cloud="aws" resource={group} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiMocks.listChildCollections.mockResolvedValue({items: [{id: 'stream-a', name: 'stream-a', parentId: group.id, createdAt: null, metadata: {}}], nextCursor: null})
|
||||
apiMocks.listCollectionItems.mockResolvedValue({items: [], nextCursor: null})
|
||||
})
|
||||
|
||||
test('filters events across streams and pages with the AWS cursor', async () => {
|
||||
apiMocks.filterLogEvents
|
||||
.mockResolvedValueOnce({items: [{id: 'one', collectionId: 'stream-a', timestamp: '2026-01-01T00:00:00.000Z', body: {message: 'ERROR one'}, metadata: {}}], nextCursor: 'next'})
|
||||
.mockResolvedValueOnce({items: [{id: 'two', collectionId: 'stream-b', timestamp: '2026-01-01T00:00:01.000Z', body: {message: 'ERROR two'}, metadata: {}}], nextCursor: null})
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByTitle('Expand to full page'))
|
||||
await user.click(screen.getByRole('button', {name: 'Filter all streams'}))
|
||||
await user.type(screen.getByLabelText('Filter pattern'), 'ERROR')
|
||||
await user.click(screen.getByRole('button', {name: 'Filter'}))
|
||||
|
||||
expect(await screen.findByText('ERROR one')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', {name: 'Load more'}))
|
||||
expect(await screen.findByText('ERROR two')).toBeInTheDocument()
|
||||
expect(screen.getByText('stream-b')).toBeInTheDocument()
|
||||
expect(apiMocks.filterLogEvents).toHaveBeenNthCalledWith(2, 'aws', group.id, {filterPattern: 'ERROR'}, 'next', undefined)
|
||||
})
|
||||
|
||||
test('live tail appends new events without duplicating an overlapping event', async () => {
|
||||
apiMocks.filterLogEvents
|
||||
.mockResolvedValueOnce({items: [{id: 'one', collectionId: 'stream-a', timestamp: '2026-01-01T00:00:00.000Z', body: {message: 'first'}, metadata: {}}], nextCursor: null})
|
||||
.mockResolvedValueOnce({items: [
|
||||
{id: 'one', collectionId: 'stream-a', timestamp: '2026-01-01T00:00:00.000Z', body: {message: 'first'}, metadata: {}},
|
||||
{id: 'two', collectionId: 'stream-b', timestamp: '2026-01-01T00:00:01.000Z', body: {message: 'second'}, metadata: {}},
|
||||
], nextCursor: null})
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByTitle('Expand to full page'))
|
||||
await user.click(screen.getByRole('button', {name: 'Filter all streams'}))
|
||||
await user.click(screen.getByRole('button', {name: 'Filter'}))
|
||||
expect(await screen.findByText('first')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', {name: 'Live tail'}))
|
||||
|
||||
expect(await screen.findByText('second')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('first')).toHaveLength(1)
|
||||
await waitFor(() => expect(apiMocks.filterLogEvents).toHaveBeenLastCalledWith('aws', group.id, {filterPattern: undefined, startTime: Date.parse('2026-01-01T00:00:00.000Z')}, undefined, expect.any(AbortSignal)))
|
||||
})
|
||||
@@ -1,14 +1,17 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {createPortal} from 'react-dom'
|
||||
import {ChevronRight, Maximize2, Minimize2, RefreshCw, ScrollText} from 'lucide-react'
|
||||
import {ChevronRight, Maximize2, Minimize2, Radio, RefreshCw, Search, ScrollText, Square} from 'lucide-react'
|
||||
import {useQueries} from '@tanstack/react-query'
|
||||
import {listChildCollections, listCollectionItems} from '@/api/cloudProxyClient'
|
||||
import {filterLogEvents, listChildCollections, listCollectionItems} from '@/api/cloudProxyClient'
|
||||
import {EmptyState} from '@/components/EmptyState'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import {formatBytes, formatDateTime} from '@/lib/format'
|
||||
import {timeAgo} from '@/lib/utils'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const LIVE_TAIL_INTERVAL_MS = 2_000
|
||||
|
||||
interface LogsExplorerPanelProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
@@ -39,11 +42,31 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
const [streamCursors, setStreamCursors] = useState<Array<string | undefined>>([undefined])
|
||||
const [eventCursors, setEventCursors] = useState<Array<string | undefined>>([undefined])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [eventMode, setEventMode] = useState<'stream' | 'filter'>('stream')
|
||||
const [filterPattern, setFilterPattern] = useState('')
|
||||
const [appliedFilterPattern, setAppliedFilterPattern] = useState('')
|
||||
const [filteredEvents, setFilteredEvents] = useState<Awaited<ReturnType<typeof filterLogEvents>>['items']>([])
|
||||
const [filterCursor, setFilterCursor] = useState<string | null>(null)
|
||||
const [filterLoading, setFilterLoading] = useState(false)
|
||||
const [filterError, setFilterError] = useState<string>()
|
||||
const [liveTail, setLiveTail] = useState(false)
|
||||
const filteredEventsRef = useRef(filteredEvents)
|
||||
|
||||
useEffect(() => {
|
||||
filteredEventsRef.current = filteredEvents
|
||||
}, [filteredEvents])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStreamId(undefined)
|
||||
setStreamCursors([undefined])
|
||||
setEventCursors([undefined])
|
||||
setEventMode('stream')
|
||||
setFilterPattern('')
|
||||
setAppliedFilterPattern('')
|
||||
setFilteredEvents([])
|
||||
setFilterCursor(null)
|
||||
setFilterError(undefined)
|
||||
setLiveTail(false)
|
||||
}, [cloud, groupId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,13 +109,62 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
void firstEventPage?.refetch()
|
||||
}
|
||||
|
||||
async function loadFilteredEvents(pattern: string, cursor?: string, append = false, signal?: AbortSignal) {
|
||||
setFilterLoading(true)
|
||||
setFilterError(undefined)
|
||||
try {
|
||||
const page = await filterLogEvents(cloud, groupId ?? '', {filterPattern: pattern || undefined}, cursor, signal)
|
||||
setFilteredEvents((current) => append ? mergeEvents(current, page.items) : page.items)
|
||||
setFilterCursor(page.nextCursor)
|
||||
} catch (error) {
|
||||
if (!signal?.aborted) setFilterError(error instanceof Error ? error.message : 'Unable to filter log events.')
|
||||
} finally {
|
||||
if (!signal?.aborted) setFilterLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveTail || !runtimeReachable || !groupId) return
|
||||
const controller = new AbortController()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const latestTimestamp = filteredEventsRef.current.reduce((latest, event) => {
|
||||
const timestamp = event.timestamp ? Date.parse(event.timestamp) : 0
|
||||
return Math.max(latest, timestamp)
|
||||
}, 0)
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await filterLogEvents(cloud, groupId, {
|
||||
filterPattern: appliedFilterPattern || undefined,
|
||||
startTime: latestTimestamp || undefined,
|
||||
}, cursor, controller.signal)
|
||||
setFilteredEvents((current) => mergeEvents(current, page.items))
|
||||
cursor = page.nextCursor ?? undefined
|
||||
} while (cursor && !controller.signal.aborted)
|
||||
if (!controller.signal.aborted) timer = setTimeout(poll, LIVE_TAIL_INTERVAL_MS)
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setFilterError(error instanceof Error ? error.message : 'Live tail stopped after an error.')
|
||||
setLiveTail(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
void poll()
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [appliedFilterPattern, cloud, groupId, liveTail, runtimeReachable])
|
||||
|
||||
if (!groupId) return null
|
||||
|
||||
const content = (
|
||||
<section className="cosmos-panel">
|
||||
<div className="cosmos-column">
|
||||
<div className="cosmos-panel-header">
|
||||
<ScrollText size={15}/>
|
||||
<ServiceIcon cloud="aws" iconKey="logs" size={18}/>
|
||||
<span>
|
||||
<small>Streams</small>
|
||||
<strong>{groupId}</strong>
|
||||
@@ -150,7 +222,7 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
|
||||
<div className="cosmos-column cosmos-column--wide">
|
||||
<div className="cosmos-panel-header">
|
||||
<ScrollText size={15}/>
|
||||
<ServiceIcon cloud="aws" iconKey="logs" size={18}/>
|
||||
<span>
|
||||
<small>Events</small>
|
||||
<strong>{selectedStreamId ?? 'Select a stream'}</strong>
|
||||
@@ -158,24 +230,43 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
</span>
|
||||
</div>
|
||||
<div className="cosmos-toolbar">
|
||||
<button className="button" type="button" disabled={!runtimeReachable || !selectedStreamId || firstEventPage?.isFetching} onClick={refreshEvents}>
|
||||
<RefreshCw size={14}/>
|
||||
Refresh
|
||||
</button>
|
||||
<div className="logs-mode-toggle" role="group" aria-label="Log event mode">
|
||||
<button className={`button compact${eventMode === 'stream' ? ' active' : ''}`} type="button" onClick={() => { setEventMode('stream'); setLiveTail(false) }}>Selected stream</button>
|
||||
<button className={`button compact${eventMode === 'filter' ? ' active' : ''}`} type="button" onClick={() => setEventMode('filter')}>Filter all streams</button>
|
||||
</div>
|
||||
{eventMode === 'stream' ? <button className="button" type="button" disabled={!runtimeReachable || !selectedStreamId || firstEventPage?.isFetching} onClick={refreshEvents}>
|
||||
<RefreshCw size={14}/> Refresh
|
||||
</button> : <form className="logs-filter-toolbar" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setAppliedFilterPattern(filterPattern.trim())
|
||||
setLiveTail(false)
|
||||
void loadFilteredEvents(filterPattern.trim())
|
||||
}}>
|
||||
<label><span className="sr-only">Filter pattern</span><input className="input logs-filter-pattern" aria-label="Filter pattern" value={filterPattern} onChange={(event) => setFilterPattern(event.target.value)} placeholder='Filter pattern, for example "ERROR"'/></label>
|
||||
<button className="button" type="submit" disabled={!runtimeReachable || filterLoading}><Search size={14}/>{filterLoading ? 'Filtering' : 'Filter'}</button>
|
||||
<button className={`button${liveTail ? ' danger' : ''}`} type="button" disabled={!runtimeReachable || filterLoading} onClick={() => {
|
||||
if (!liveTail && appliedFilterPattern !== filterPattern.trim()) {
|
||||
setAppliedFilterPattern(filterPattern.trim())
|
||||
void loadFilteredEvents(filterPattern.trim())
|
||||
}
|
||||
setLiveTail((current) => !current)
|
||||
}}>{liveTail ? <Square size={13}/> : <Radio size={13}/>} {liveTail ? 'Stop tail' : 'Live tail'}</button>
|
||||
</form>}
|
||||
</div>
|
||||
{firstEventPage?.error instanceof Error && <div className="form-error">{firstEventPage.error.message}</div>}
|
||||
{eventMode === 'stream' && firstEventPage?.error instanceof Error && <div className="form-error">{firstEventPage.error.message}</div>}
|
||||
{eventMode === 'filter' && filterError && <div className="form-error">{filterError}</div>}
|
||||
<div className="cosmos-items-table">
|
||||
{!selectedStreamId && (
|
||||
{eventMode === 'stream' && !selectedStreamId && (
|
||||
<div className="empty compact"><h3>Select a stream</h3><p>Events are scoped to a log stream.</p></div>
|
||||
)}
|
||||
{selectedStreamId && !firstEventPage?.isLoading && events.length === 0 && (
|
||||
{eventMode === 'stream' && selectedStreamId && !firstEventPage?.isLoading && events.length === 0 && (
|
||||
<EmptyState
|
||||
icon={ScrollText}
|
||||
title="No log events"
|
||||
description="Events written to this stream will appear here."
|
||||
/>
|
||||
)}
|
||||
{selectedStreamId && (firstEventPage?.isLoading ? (
|
||||
{eventMode === 'stream' && selectedStreamId && (firstEventPage?.isLoading ? (
|
||||
<div className="muted padded">Loading log events</div>
|
||||
) : events.length > 0 && (
|
||||
<>
|
||||
@@ -211,6 +302,12 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{eventMode === 'filter' && !filterLoading && filteredEvents.length === 0 && <EmptyState icon={Search} title="No matching events" description="Apply a filter pattern or start live tail to search every stream in this group."/>}
|
||||
{eventMode === 'filter' && filteredEvents.length > 0 && <>
|
||||
{liveTail && <div className="logs-live-status"><Radio size={12}/> Live tail active</div>}
|
||||
<table className="table logs-events-table"><thead><tr><th>Timestamp</th><th>Log stream</th><th>Message</th></tr></thead><tbody>{filteredEvents.map((event) => <tr key={event.id}><td className="mono">{formatDateTime(event.timestamp) ?? '-'}</td><td className="mono logs-event-stream">{event.collectionId ?? '-'}</td><td className="mono">{String(event.body.message ?? '')}</td></tr>)}</tbody></table>
|
||||
{filterCursor && !liveTail && <div style={{display: 'flex', justifyContent: 'center', padding: '12px 0'}}><button className="button" type="button" disabled={filterLoading} onClick={() => void loadFilteredEvents(appliedFilterPattern, filterCursor, true)}>{filterLoading ? 'Loading…' : 'Load more'}</button></div>}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -219,7 +316,7 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
const summary = (
|
||||
<section className="logs-query-panel">
|
||||
<div className="logs-query-header">
|
||||
<ScrollText size={15}/>
|
||||
<ServiceIcon cloud="aws" iconKey="logs" size={18}/>
|
||||
<span>
|
||||
<small>Streams & events</small>
|
||||
<strong>{groupId}</strong>
|
||||
@@ -243,3 +340,9 @@ export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplo
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function mergeEvents(current: Awaited<ReturnType<typeof filterLogEvents>>['items'], incoming: Awaited<ReturnType<typeof filterLogEvents>>['items']) {
|
||||
const byId = new Map(current.map((event) => [event.id, event]))
|
||||
for (const event of incoming) byId.set(event.id, event)
|
||||
return [...byId.values()].sort((left, right) => Date.parse(left.timestamp ?? '') - Date.parse(right.timestamp ?? ''))
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export function ResourceInspector({
|
||||
)}
|
||||
</section>
|
||||
{isDatabase && (
|
||||
<DatabaseConnectionsSection metadata={resource.metadata} />
|
||||
<DatabaseConnectionsSection metadata={resource.metadata} engine={resource.engine} />
|
||||
)}
|
||||
{isAwsDatabase && <DatabaseLifecycleSection status={resource.status} />}
|
||||
{isDatabase && !isAwsDatabase && (
|
||||
@@ -282,8 +282,10 @@ function ProviderDatabaseSection({ cloud }: { cloud: string }) {
|
||||
|
||||
function DatabaseConnectionsSection({
|
||||
metadata,
|
||||
engine,
|
||||
}: {
|
||||
metadata: Record<string, unknown>;
|
||||
engine?: string | null;
|
||||
}) {
|
||||
const endpoint = getRecordMetadata(metadata.endpoint);
|
||||
const subnetGroup = getRecordMetadata(metadata.subnetGroup);
|
||||
@@ -311,6 +313,7 @@ function DatabaseConnectionsSection({
|
||||
value={`${securityGroups.length}`}
|
||||
/>
|
||||
</div>
|
||||
{address && port !== null && engine && <DatabaseConnectionCommand engine={engine} address={address} port={port} database={getStringMetadata(metadata.dbName) ?? (engine.includes('postgres') ? 'postgres' : '')} username={getStringMetadata(metadata.masterUsername) ?? (engine.includes('postgres') ? 'postgres' : 'root')} />}
|
||||
{securityGroups.length > 0 && (
|
||||
<div className="metadata-tags">
|
||||
{securityGroups.map((group) => (
|
||||
@@ -325,6 +328,16 @@ function DatabaseConnectionsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseConnectionCommand({ engine, address, port, database, username }: { engine: string; address: string; port: number; database: string; username: string }) {
|
||||
const host = address.startsWith('172.') ? 'localhost' : address
|
||||
const command = engine.includes('postgres')
|
||||
? `psql "host=${host} port=${port} dbname=${database} user=${username} password=<password>"`
|
||||
: engine.includes('mysql') || engine.includes('mariadb')
|
||||
? `mysql --host=${host} --port=${port} --user=${username} --password ${database}`
|
||||
: `sqlcmd -S ${host},${port} -U ${username} -P <password> -d ${database}`
|
||||
return <div className="metadata-block"><p className="metric-label">Terminal connection</p><p className="copy-value mono">{command}<CopyValueButton value={command} label="Connection command" /></p><p className="muted compact-text">Use the endpoint, port, database, and username above in DBeaver or another database client. The password is never retained by the console.</p></div>
|
||||
}
|
||||
|
||||
function DatabaseLifecycleSection({ status }: { status?: string | null }) {
|
||||
return (
|
||||
<section className="inspector-section">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {HardDrive, Loader2, Plus, Trash2, Zap} from 'lucide-react'
|
||||
import {Loader2, Plus, Trash2} 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 {ServiceIcon} from '@/components/serviceIcons'
|
||||
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||
|
||||
interface S3LambdaTriggerPanelProps {
|
||||
@@ -72,7 +73,7 @@ export function S3LambdaTriggerPanel({cloud, resource, runtimeReachable}: S3Lamb
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">Event Source</p>
|
||||
<h3><Zap size={15}/> Add trigger</h3>
|
||||
<h3><ServiceIcon cloud="aws" iconKey="serverless" size={18}/> Add trigger</h3>
|
||||
<p className="muted compact-text">Invoke <code>{resource.name}</code> when an S3 object is created.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +118,7 @@ export function S3LambdaTriggerPanel({cloud, resource, runtimeReachable}: S3Lamb
|
||||
{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>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><ServiceIcon cloud="aws" iconKey="storage" size={18}/> 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>}
|
||||
@@ -128,7 +129,7 @@ export function S3LambdaTriggerPanel({cloud, resource, runtimeReachable}: S3Lamb
|
||||
</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>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Recent Activity</p><h3><ServiceIcon cloud="aws" iconKey="serverless" size={18}/> 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>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {CalendarClock, Loader2, Plus, Trash2, Zap} from 'lucide-react'
|
||||
import {CalendarClock, Loader2, Plus, Trash2} 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'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
|
||||
interface ScheduledLambdaTriggerPanelProps {
|
||||
cloud: CloudProvider
|
||||
@@ -77,7 +78,7 @@ export function ScheduledLambdaTriggerPanel({cloud, resource, runtimeReachable}:
|
||||
{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>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Schedules</p><h3><ServiceIcon cloud="aws" iconKey="events" size={18}/> 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>}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {ChevronDown, ChevronUp, Copy, Loader2, Play, Wand2, Zap} from "lucide-react";
|
||||
import {ChevronDown, ChevronUp, Copy, Loader2, Play, Wand2} from "lucide-react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {invokeCloudResource} from "@/api/cloudProxyClient";
|
||||
import type {ServerlessInvokeResult} from "@/api/cloudProxyClient";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
import {ServiceIcon} from "@/components/serviceIcons";
|
||||
import {LogsQueryPanel} from "./LogsQueryPanel";
|
||||
|
||||
interface ServerlessInvokePanelProps {
|
||||
@@ -107,7 +108,7 @@ const clearPayload = () => {
|
||||
<div>
|
||||
<p className="eyebrow">Serverless Actions</p>
|
||||
<h3>
|
||||
<Zap size={15} />
|
||||
<ServiceIcon cloud={cloud} iconKey="serverless" size={18} />
|
||||
Invoke {providerLabel}
|
||||
</h3>
|
||||
<p className="muted compact-text">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {BellRing, Loader2, Plus, Send, Trash2} from "lucide-react";
|
||||
import {Loader2, Plus, Send, Trash2} from "lucide-react";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {createSnsSubscription, deleteSnsSubscription, listCloudResources, listSnsSubscriptions, publishNotification, type PublishNotificationResult, type SnsSubscriptionOverview} from "@/api/cloudProxyClient";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
import {ServiceIcon} from "@/components/serviceIcons";
|
||||
|
||||
interface SnsTopicPanelProps {
|
||||
cloud: CloudProvider;
|
||||
@@ -130,7 +131,7 @@ export function SnsTopicPanel({cloud, resource, runtimeReachable}: SnsTopicPanel
|
||||
</section>
|
||||
|
||||
{cloud === "aws" && <section className="table-panel sns-subscriptions-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Subscriptions</p><h3><BellRing size={15} /> Subscribe an endpoint</h3></div></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Subscriptions</p><h3><ServiceIcon cloud="aws" iconKey="notifications" size={18} /> Subscribe an endpoint</h3></div></div>
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => { event.preventDefault(); if (canCreateSubscription) createSubscriptionMutation.mutate(); }}>
|
||||
<label className="dynamic-field" htmlFor="sns-subscription-protocol"><span>Protocol</span><select id="sns-subscription-protocol" className="input" value={protocol} onChange={(event) => { setProtocol(event.target.value as SubscriptionProtocol); setEndpoint(""); }}><option value="lambda">Lambda</option><option value="sqs">SQS</option><option value="http">HTTP</option><option value="https">HTTPS</option><option value="email">Email</option><option value="email-json">Email JSON</option><option value="sms">SMS</option></select></label>
|
||||
{needsResourceSelector ? <label className="dynamic-field" htmlFor="sns-subscription-resource-endpoint"><span>{resourceProtocols[protocol].label} endpoint</span><select id="sns-subscription-resource-endpoint" className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} required disabled={!subscriptionsEnabled}><option value="">Select a {resourceProtocols[protocol].label} resource</option>{endpointResources.map((candidate) => <option key={candidate.id} value={String(candidate.metadata.arn)}>{candidate.name}</option>)}</select><small>{resourcesLoading ? `Loading ${resourceProtocols[protocol].label} resources...` : endpointResources.length === 0 ? `No ${resourceProtocols[protocol].label} resources with an ARN are available.` : "Uses the resource ARN returned by the cloud API."}</small></label> : <label className="dynamic-field" htmlFor="sns-subscription-endpoint"><span>Endpoint</span><input id="sns-subscription-endpoint" className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} placeholder={manualEndpointPlaceholder(protocol)} required disabled={!subscriptionsEnabled} /><small>SNS may require endpoint confirmation before delivery can begin.</small></label>}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Loader2, MessageSquare, Plus, Trash2, Zap} from 'lucide-react'
|
||||
import {Loader2, Plus, Trash2} 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'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
|
||||
interface SqsLambdaTriggerPanelProps {
|
||||
cloud: CloudProvider
|
||||
@@ -57,7 +58,7 @@ export function SqsLambdaTriggerPanel({cloud, resource, runtimeReachable}: SqsLa
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">Event Source</p>
|
||||
<h3><Zap size={15}/> Add SQS trigger</h3>
|
||||
<h3><ServiceIcon cloud="aws" iconKey="serverless" size={18}/> 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>
|
||||
@@ -86,7 +87,7 @@ export function SqsLambdaTriggerPanel({cloud, resource, runtimeReachable}: SqsLa
|
||||
{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>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><ServiceIcon cloud="aws" iconKey="messaging" size={18}/> 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>}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {SqsQueuePanel} from './SqsQueuePanel'
|
||||
|
||||
const api = vi.hoisted(() => ({deleteSqsMessage: vi.fn(), listCloudResources: vi.fn(), purgeSqsQueue: vi.fn(), receiveSqsMessages: vi.fn(), sendSqsMessage: vi.fn(), startSqsMoveTask: vi.fn(), updateCloudResource: vi.fn()}))
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
const queue = {id: 'orders', name: 'orders', cloud: 'aws' as const, service: 'messaging' as const, type: 'queue', region: null, createdAt: null, metadata: {arn: 'arn:aws:sqs:us-east-1:000000000000:orders', visibilityTimeout: 5, approximateMessages: 1}}
|
||||
|
||||
function renderPanel() { return render(<QueryClientProvider client={new QueryClient({defaultOptions: {queries: {retry: false}}})}><SqsQueuePanel resource={queue} runtimeReachable/></QueryClientProvider>) }
|
||||
beforeEach(() => { vi.clearAllMocks(); api.listCloudResources.mockResolvedValue([queue]) })
|
||||
|
||||
test('receives without deleting and deletes only on explicit action', async () => {
|
||||
api.receiveSqsMessages.mockResolvedValue([{messageId: 'message-1', receiptHandle: 'receipt-1', body: 'hello', attributes: {ApproximateReceiveCount: '1'}, messageAttributes: {}}])
|
||||
api.deleteSqsMessage.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup(); renderPanel()
|
||||
await user.click(screen.getByRole('button', {name: 'Poll for messages'}))
|
||||
expect(await screen.findByText('hello')).toBeInTheDocument()
|
||||
expect(api.deleteSqsMessage).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', {name: 'Delete message message-1'}))
|
||||
await waitFor(() => expect(api.deleteSqsMessage).toHaveBeenCalledWith('aws', 'orders', 'receipt-1'))
|
||||
})
|
||||
|
||||
test('requires purge confirmation and labels DLQ controls experimental', async () => {
|
||||
api.purgeSqsQueue.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup(); renderPanel()
|
||||
expect(screen.getByText('Experimental: dead-letter queues')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', {name: 'Purge queue'}))
|
||||
expect(api.purgeSqsQueue).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', {name: 'Confirm purge'}))
|
||||
await waitFor(() => expect(api.purgeSqsQueue).toHaveBeenCalledWith('aws', 'orders'))
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Loader2, Send, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {deleteSqsMessage, listCloudResources, purgeSqsQueue, receiveSqsMessages, sendSqsMessage, startSqsMoveTask, updateCloudResource} from '@/api/cloudProxyClient'
|
||||
import type {CloudResource, SqsMessageAttribute} from '@/types/resource'
|
||||
|
||||
export function SqsQueuePanel({resource, runtimeReachable}: {resource?: CloudResource; runtimeReachable: boolean}) {
|
||||
const [body, setBody] = useState('')
|
||||
const [attributes, setAttributes] = useState('{}')
|
||||
const [groupId, setGroupId] = useState('')
|
||||
const [deduplicationId, setDeduplicationId] = useState('')
|
||||
const [messages, setMessages] = useState<Awaited<ReturnType<typeof receiveSqsMessages>>>([])
|
||||
const [purgeConfirm, setPurgeConfirm] = useState(false)
|
||||
const [dlqArn, setDlqArn] = useState('')
|
||||
const [maxReceiveCount, setMaxReceiveCount] = useState('2')
|
||||
const [moveDestination, setMoveDestination] = useState('')
|
||||
const queryClient = useQueryClient()
|
||||
const queuesQuery = useQuery({queryKey: ['cloud-resources', 'aws', 'messaging'], queryFn: ({signal}) => listCloudResources('aws', 'messaging', undefined, signal), enabled: Boolean(resource) && runtimeReachable})
|
||||
const isQueue = resource?.service === 'messaging' && (resource.type === 'queue' || resource.type === 'fifo-queue')
|
||||
const isFifo = resource?.type === 'fifo-queue'
|
||||
|
||||
useEffect(() => { setBody(''); setAttributes('{}'); setGroupId(''); setDeduplicationId(''); setMessages([]); setPurgeConfirm(false); setDlqArn(''); setMoveDestination('') }, [resource?.id])
|
||||
|
||||
const send = useMutation({mutationFn: () => sendSqsMessage('aws', resource!.id, {messageBody: body, messageAttributes: parseAttributes(attributes), ...(isFifo ? {messageGroupId: groupId, ...(deduplicationId ? {messageDeduplicationId: deduplicationId} : {})} : {})}), onSuccess: () => { setBody(''); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
||||
const receive = useMutation({mutationFn: () => receiveSqsMessages('aws', resource!.id, 10), onSuccess: (received) => { setMessages(received); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
||||
const remove = useMutation({mutationFn: (message: Awaited<ReturnType<typeof receiveSqsMessages>>[number]) => deleteSqsMessage('aws', resource!.id, message.receiptHandle), onSuccess: (_, message) => { setMessages((current) => current.filter((item) => item.receiptHandle !== message.receiptHandle)); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
||||
const purge = useMutation({mutationFn: () => purgeSqsQueue('aws', resource!.id), onSuccess: () => { setMessages([]); setPurgeConfirm(false); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
||||
const configureDlq = useMutation({mutationFn: () => updateCloudResource('aws', 'messaging', resource!.id, {redrivePolicy: {deadLetterTargetArn: dlqArn, maxReceiveCount}}), onSuccess: () => queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})})
|
||||
const move = useMutation({mutationFn: () => startSqsMoveTask('aws', resource!.id, moveDestination || undefined)})
|
||||
|
||||
if (!isQueue) return null
|
||||
const queues = (queuesQuery.data ?? []).filter((queue) => queue.id !== resource.id && queue.type === resource.type && typeof queue.metadata.arn === 'string')
|
||||
const error = send.error ?? receive.error ?? remove.error ?? purge.error ?? configureDlq.error ?? move.error
|
||||
return <section className="table-panel sqs-queue-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">SQS Messages</p><h3>Send and receive</h3><p className="muted compact-text">Polling receives messages and starts their configured visibility timeout. Messages are deleted only when you explicitly delete them.</p></div></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (body && (!isFifo || groupId)) send.mutate() }}>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Message body</span><textarea className="textarea code-textarea" value={body} onChange={(event) => setBody(event.target.value)} required/></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Message attributes (JSON)</span><textarea className="textarea code-textarea small" value={attributes} onChange={(event) => setAttributes(event.target.value)} placeholder={'{"kind":{"dataType":"String","stringValue":"order"}}'}/><small>Supports String, Number, and Binary attributes. Binary values are base64.</small></label>
|
||||
{isFifo && <><label className="dynamic-field"><span>Message group ID</span><input className="input" value={groupId} onChange={(event) => setGroupId(event.target.value)} required/></label><label className="dynamic-field"><span>Deduplication ID (optional)</span><input className="input" value={deduplicationId} onChange={(event) => setDeduplicationId(event.target.value)}/></label></>}
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || !body || (isFifo && !groupId) || send.isPending}>{send.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send message'}</button>
|
||||
</form>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Receive</p><h3>Received messages</h3></div><button className="button" type="button" disabled={!runtimeReachable || receive.isPending} onClick={() => receive.mutate()}>{receive.isPending ? 'Polling' : 'Poll for messages'}</button></div>
|
||||
{messages.length === 0 && <p className="muted compact-text">Poll to receive up to 10 messages. Leaving this panel or waiting for the visibility timeout does not delete them.</p>}
|
||||
{messages.map((message) => <article className="apigateway-route-item" key={message.receiptHandle}><div className="apigateway-route-select"><code>{message.messageId}</code><pre>{message.body}</pre><small>Receive count: {message.attributes.ApproximateReceiveCount ?? '-'} · Visibility timeout: {String(resource.metadata.visibilityTimeout ?? 30)}s</small><details><summary>Message details</summary><pre>{JSON.stringify({attributes: message.attributes, messageAttributes: message.messageAttributes, receiptHandle: message.receiptHandle}, null, 2)}</pre></details></div><button className="icon-btn danger" type="button" aria-label={`Delete message ${message.messageId}`} disabled={remove.isPending} onClick={() => remove.mutate(message)}><Trash2 size={14}/></button></article>)}
|
||||
<section className="apigateway-route-explorer"><p className="eyebrow">Queue management</p>{!purgeConfirm ? <button className="button danger" type="button" onClick={() => setPurgeConfirm(true)}>Purge queue</button> : <div className="apigateway-route-confirm"><span>Purge approximately {String(resource.metadata.approximateMessages ?? 0)} visible messages?</span><button className="button danger compact" type="button" disabled={purge.isPending} onClick={() => purge.mutate()}>Confirm purge</button><button className="button compact" type="button" onClick={() => setPurgeConfirm(false)}>Cancel</button></div>}</section>
|
||||
<section className="apigateway-route-explorer"><p className="eyebrow">Experimental: dead-letter queues</p><p className="muted compact-text">DLQ redrive threshold behavior is not fully AWS-compatible in this runtime. Use only for local experiments.</p><div className="dynamic-form"><label className="dynamic-field"><span>Target DLQ</span><select className="input" value={dlqArn} onChange={(event) => setDlqArn(event.target.value)}><option value="">Select a matching queue</option>{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}</select></label><label className="dynamic-field"><span>Max receive count</span><input className="input" type="number" min="1" max="1000" value={maxReceiveCount} onChange={(event) => setMaxReceiveCount(event.target.value)}/></label><button className="button" type="button" disabled={!dlqArn || configureDlq.isPending} onClick={() => configureDlq.mutate()}>Save experimental DLQ</button></div><div className="dynamic-form"><label className="dynamic-field"><span>Move destination</span><select className="input" value={moveDestination} onChange={(event) => setMoveDestination(event.target.value)}><option value="">Original source queue</option>{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}</select></label><button className="button" type="button" disabled={move.isPending} onClick={() => move.mutate()}>Move DLQ messages</button>{move.data && <code>{move.data.taskHandle}</code>}</div></section>
|
||||
{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function parseAttributes(value: string): Record<string, SqsMessageAttribute> | undefined {
|
||||
if (!value.trim()) return undefined
|
||||
const parsed = JSON.parse(value) as Record<string, SqsMessageAttribute>
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Message attributes must be a JSON object.')
|
||||
return parsed
|
||||
}
|
||||
@@ -37,11 +37,11 @@ describe('WorkflowExecutionDebugger', () => {
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toBeInTheDocument()
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionSucceeded') === true)).toBeInTheDocument()
|
||||
expect(await screen.findByText('ExecutionStarted')).toBeInTheDocument()
|
||||
expect(await screen.findByText('ExecutionSucceeded')).toBeInTheDocument()
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(1, machine.id, executionArn, undefined, expect.any(AbortSignal))
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(2, machine.id, executionArn, 'next', expect.any(AbortSignal))
|
||||
expect(screen.getAllByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toHaveLength(1)
|
||||
expect(screen.getAllByText('ExecutionStarted')).toHaveLength(1)
|
||||
expect(screen.queryByRole('button', {name: 'Load more history'})).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('JSON input'), {target: {value: '{"orderId":"1"}'}})
|
||||
await user.click(screen.getByRole('button', {name: 'Start execution'}))
|
||||
@@ -87,7 +87,7 @@ describe('WorkflowExecutionDebugger', () => {
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
|
||||
expect(await screen.findByText('Unable to load the next history page')).toBeInTheDocument()
|
||||
expect(screen.getByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toBeInTheDocument()
|
||||
expect(screen.getByText('ExecutionStarted')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('polls only the trailing history page for a running execution and appends new events', async () => {
|
||||
@@ -101,9 +101,9 @@ describe('WorkflowExecutionDebugger', () => {
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('TaskStateEntered') === true)).toBeInTheDocument()
|
||||
expect(await screen.findByText('TaskStateEntered')).toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('TaskSucceeded') === true, {}, {timeout: 3_000})).toBeInTheDocument()
|
||||
expect(await screen.findByText('TaskSucceeded', {}, {timeout: 3_000})).toBeInTheDocument()
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(3, machine.id, executionArn, 'page-2', expect.any(AbortSignal))
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
@@ -123,6 +123,33 @@ describe('WorkflowExecutionDebugger', () => {
|
||||
|
||||
expect(await screen.findByText('History incomplete: reached the 10-page limit.')).toBeInTheDocument()
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenCalledTimes(10)
|
||||
expect(screen.getAllByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('Event') === true)).toHaveLength(10)
|
||||
expect(screen.getAllByText('Event')).toHaveLength(10)
|
||||
})
|
||||
|
||||
test('renders state status, selected state input, definition, and state events', async () => {
|
||||
const workflow: CloudResource = {...machine, metadata: {machineType: 'STANDARD', definition: JSON.stringify({StartAt: 'ChargePayment', States: {ChargePayment: {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {FunctionName: 'arn:aws:lambda:us-east-1:000000000000:function:charge-payment:$LATEST'}}, PaymentFailed: {Type: 'Task', Resource: 'arn:aws:states:::sns:publish', End: true}}})}}
|
||||
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z'}])
|
||||
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id, input: '{"orderId":"1"}'})
|
||||
vi.mocked(getWorkflowExecutionHistory).mockResolvedValue({events: [
|
||||
{id: 1, type: 'TaskStateEntered', timestamp: '2026-07-28T10:01:00.000Z', stateEnteredEventDetails: {name: 'ChargePayment', input: '{"orderId":"1"}'}},
|
||||
{id: 2, previousEventId: 1, type: 'TaskFailed', taskFailedEventDetails: {error: 'Exception'}},
|
||||
{id: 3, previousEventId: 2, type: 'TaskStateExited', stateExitedEventDetails: {name: 'ChargePayment'}},
|
||||
{id: 4, type: 'TaskStateEntered', timestamp: '2026-07-28T10:01:01.000Z', stateEnteredEventDetails: {name: 'PaymentFailed', input: '{"orderId":"1"}'}},
|
||||
{id: 5, previousEventId: 4, type: 'TaskSucceeded', taskSucceededEventDetails: {output: '{"messageId":"1"}'}},
|
||||
{id: 6, previousEventId: 5, type: 'TaskStateExited', stateExitedEventDetails: {name: 'PaymentFailed', output: '{"messageId":"1"}'}},
|
||||
]})
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
const user = userEvent.setup()
|
||||
render(<QueryClientProvider client={client}><WorkflowExecutionDebugger resource={workflow} runtimeReachable={true}/></QueryClientProvider>)
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
expect(await screen.findByText('Caught error')).toBeInTheDocument()
|
||||
await user.click(screen.getAllByRole('button', {name: /ChargePayment/})[0])
|
||||
expect(screen.getByRole('link', {name: 'Open logs'})).toHaveAttribute('href', '/cloud-explorer/aws/logs?resource=%2Faws%2Flambda%2Fcharge-payment')
|
||||
expect(screen.getByText('{"orderId":"1"}')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('tab', {name: 'definition'}))
|
||||
expect(screen.getByText((_, element) => element?.tagName === 'PRE' && element.textContent?.includes('arn:aws:states:::lambda:invoke') === true)).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('tab', {name: 'events'}))
|
||||
expect(screen.getAllByText('TaskFailed')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {Play, RefreshCw, Square} from 'lucide-react'
|
||||
import {CheckCircle2, CircleAlert, Clock3, Play, RefreshCw, ScrollText, Square, XCircle} from 'lucide-react'
|
||||
import {describeWorkflowExecution, getWorkflowExecutionHistory, listWorkflowExecutions, startWorkflowExecution, stopWorkflowExecution, type WorkflowExecutionDetail} from '@/api/cloudProxyClient'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
@@ -20,6 +20,19 @@ interface ExecutionHistory {
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
type ExecutionStateStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'caught'
|
||||
type ExecutionEvent = Record<string, unknown>
|
||||
|
||||
interface ExecutionState {
|
||||
name: string
|
||||
type: string
|
||||
definition?: Record<string, unknown>
|
||||
status: ExecutionStateStatus
|
||||
input?: string
|
||||
output?: string
|
||||
events: ExecutionEvent[]
|
||||
}
|
||||
|
||||
export function WorkflowExecutionDebugger({resource, runtimeReachable}: WorkflowExecutionDebuggerProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [input, setInput] = useState('{}')
|
||||
@@ -80,24 +93,147 @@ export function WorkflowExecutionDebugger({resource, runtimeReachable}: Workflow
|
||||
<p className="muted compact-text">Execution input, output, and history can contain sensitive data.</p>
|
||||
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start execution.')}</p>}
|
||||
{executions.isError && <p className="error-text compact-text">{message(executions.error, 'Unable to load executions.')}</p>}
|
||||
<table className="table"><thead><tr><th>Name</th><th>Status</th><th>Started</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.executionArn} onClick={() => setSelectedExecutionArn(execution.executionArn)} style={{cursor: 'pointer'}}><td><code>{execution.name}</code></td><td>{execution.status}</td><td>{execution.startDate}</td></tr>)}</tbody></table>
|
||||
{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}
|
||||
{selectedExecutionArn && <ExecutionDetail detail={detail.data} loading={detail.isLoading} error={detail.error} running={running} stopping={stop.isPending} onStop={() => { if (window.confirm('Stop this running execution?')) stop.mutate() }} stopError={stop.error} events={history.events} loadingHistory={history.loading} historyError={history.error} historyIncomplete={history.incomplete}/>}
|
||||
{selectedExecutionArn && <ExecutionDetail resource={resource} detail={detail.data} loading={detail.isLoading} error={detail.error} running={running} stopping={stop.isPending} onStop={() => { if (window.confirm('Stop this running execution?')) stop.mutate() }} stopError={stop.error} events={history.events} loadingHistory={history.loading} historyError={history.error} historyIncomplete={history.incomplete}/>}
|
||||
<section className={`workflow-execution-list${selectedExecutionArn ? ' compact' : ''}`}><div className="workflow-execution-list-heading"><h4>Recent executions</h4><span>{executions.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Name</th><th>Status</th><th>Started</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.executionArn} onClick={() => setSelectedExecutionArn(execution.executionArn)} style={{cursor: 'pointer'}}><td><code>{execution.name}</code></td><td>{execution.status}</td><td>{execution.startDate}</td></tr>)}</tbody></table></div>{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}</section>
|
||||
</section>
|
||||
}
|
||||
|
||||
function ExecutionDetail({detail, loading, error, running, stopping, onStop, stopError, events, loadingHistory, historyError, historyIncomplete}: {detail?: WorkflowExecutionDetail; loading: boolean; error: unknown; running: boolean; stopping: boolean; onStop: () => void; stopError: unknown; events: Array<Record<string, unknown>>; loadingHistory: boolean; historyError: unknown; historyIncomplete?: string}) {
|
||||
function ExecutionDetail({resource, detail, loading, error, running, stopping, onStop, stopError, events, loadingHistory, historyError, historyIncomplete}: {resource: CloudResource; detail?: WorkflowExecutionDetail; loading: boolean; error: unknown; running: boolean; stopping: boolean; onStop: () => void; stopError: unknown; events: ExecutionEvent[]; loadingHistory: boolean; historyError: unknown; historyIncomplete?: string}) {
|
||||
const [selectedStateName, setSelectedStateName] = useState<string>()
|
||||
const [tab, setTab] = useState<'input' | 'output' | 'details' | 'definition' | 'events'>('input')
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStateName(undefined)
|
||||
setTab('input')
|
||||
}, [detail?.executionArn])
|
||||
if (loading) return <p className="muted compact-text">Loading execution...</p>
|
||||
if (error) return <p className="error-text compact-text">{message(error, 'Unable to load execution.')}</p>
|
||||
if (!detail) return null
|
||||
return <div style={{marginTop: 16}}><div className="dynamic-stage-header"><div><h3>{detail.name} <span className="muted">{detail.status}</span></h3></div>{running && <button className="button danger" type="button" disabled={stopping} onClick={onStop}><Square size={13}/>{stopping ? 'Stopping' : 'Stop execution'}</button>}</div>
|
||||
|
||||
const states = executionStates(resource, events)
|
||||
const selectedState = states.find((state) => state.name === selectedStateName) ?? states.find((state) => state.status === 'running') ?? states[states.length - 1]
|
||||
return <div className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Execution</p><h3>{detail.name} <ExecutionStatus status={detail.status}/></h3></div>{running && <button className="button danger" type="button" disabled={stopping} onClick={onStop}><Square size={13}/>{stopping ? 'Stopping' : 'Stop execution'}</button>}</div>
|
||||
{Boolean(stopError) && <p className="error-text compact-text">{message(stopError, 'Unable to stop execution.')}</p>}
|
||||
<ExecutionData label="Input" value={detail.input}/><ExecutionData label="Output" value={detail.output}/>{detail.error && <ExecutionData label="Error" value={detail.error}/>} {detail.cause && <ExecutionData label="Cause" value={detail.cause}/>}
|
||||
<h4>Timeline</h4>{loadingHistory && <p className="muted compact-text">Loading history...</p>}{historyIncomplete && <p className="muted compact-text">History incomplete: {historyIncomplete}</p>}{Boolean(historyError) && <p className="error-text compact-text">{message(historyError, 'Unable to load complete execution history.')}</p>}
|
||||
{events.map((event, index) => <details key={`${String(event.id ?? index)}-${index}`}><summary>{String(event.timestamp ?? '')} {String(event.type ?? 'Event')}</summary><pre>{JSON.stringify(event, null, 2)}</pre></details>)}
|
||||
<div className="workflow-execution-workspace">
|
||||
<ExecutionGraph states={states} selectedStateName={selectedState?.name} onSelect={setSelectedStateName}/>
|
||||
<ExecutionInspector state={selectedState} detail={detail} tab={tab} onTabChange={setTab}/>
|
||||
</div>
|
||||
<section className="workflow-execution-history"><div className="workflow-execution-history-heading"><div><h4>Event history <span>{events.length}</span></h4><p className="muted compact-text">Complete execution history, newest events appear as the execution runs.</p></div>{loadingHistory && <span className="workflow-live-indicator"><Clock3 size={13}/> Updating</span>}</div>{historyIncomplete && <p className="muted compact-text">History incomplete: {historyIncomplete}</p>}{Boolean(historyError) && <p className="error-text compact-text">{message(historyError, 'Unable to load complete execution history.')}</p>}
|
||||
<ol>{events.map((event, index) => { const stateName = eventStateName(event, events); return <li key={`${String(event.id ?? index)}-${index}`}><button type="button" onClick={() => stateName && setSelectedStateName(stateName)} disabled={!stateName}><time>{String(event.timestamp ?? '')}</time><strong>{String(event.type ?? 'Event')}</strong>{stateName && <span>{stateName}</span>}</button></li> })}</ol>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ExecutionGraph({states, selectedStateName, onSelect}: {states: ExecutionState[]; selectedStateName?: string; onSelect: (name: string) => void}) {
|
||||
return <section className="workflow-execution-graph"><div className="workflow-execution-pane-heading"><div><h4>Graph view</h4><p>Live state progression</p></div><span>{states.length} states</span></div><div className="workflow-execution-canvas"><div className="workflow-execution-start">Start</div>{states.map((state) => <div className="workflow-execution-graph-row" key={state.name}><span className="workflow-execution-connector"/><button className={`workflow-execution-node ${state.status}${selectedStateName === state.name ? ' selected' : ''}`} type="button" onClick={() => onSelect(state.name)}><ExecutionStatus status={state.status}/><strong>{state.name}</strong><small>{state.type}</small></button></div>)}<div className="workflow-execution-graph-row"><span className="workflow-execution-connector"/><div className="workflow-execution-end">End</div></div></div><div className="workflow-execution-legend"><span><i className="running"/>In progress</span><span><i className="failed"/>Failed</span><span><i className="caught"/>Caught error</span><span><i className="succeeded"/>Succeeded</span></div></section>
|
||||
}
|
||||
|
||||
function ExecutionInspector({state, detail, tab, onTabChange}: {state?: ExecutionState; detail: WorkflowExecutionDetail; tab: 'input' | 'output' | 'details' | 'definition' | 'events'; onTabChange: (tab: 'input' | 'output' | 'details' | 'definition' | 'events') => void}) {
|
||||
const tabs = ['input', 'output', 'details', 'definition', 'events'] as const
|
||||
const value = tab === 'input' ? state?.input ?? detail.input : tab === 'output' ? state?.output ?? detail.output : undefined
|
||||
const functionName = lambdaFunctionName(state)
|
||||
return <section className="workflow-execution-inspector"><div className="workflow-execution-pane-heading"><div><h4>{state?.name ?? detail.name}</h4><p>{state ? `${state.type} state` : 'Execution details'}</p></div><div className="workflow-execution-heading-actions">{functionName && <a className="button compact" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${functionName}`)}`}><ScrollText size={12}/> Open logs</a>}{state && <ExecutionStatus status={state.status}/>}</div></div><div className="workflow-execution-tabs" role="tablist">{tabs.map((item) => <button key={item} type="button" role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} onClick={() => onTabChange(item)}>{item}</button>)}</div><div className="workflow-execution-tab-content">{(tab === 'input' || tab === 'output') && <ExecutionData label={tab === 'input' ? 'Input' : 'Output'} value={value}/>} {tab === 'details' && <ExecutionData label="Details" value={JSON.stringify({status: state?.status ?? detail.status, startDate: detail.startDate, stopDate: detail.stopDate, error: detail.error, cause: detail.cause}, null, 2)}/>} {tab === 'definition' && <ExecutionData label="State definition" value={state?.definition ? JSON.stringify(state.definition, null, 2) : 'No state definition is available.'}/>} {tab === 'events' && <ol className="workflow-state-events">{(state?.events ?? []).map((event, index) => <li key={`${String(event.id ?? index)}-${index}`}><strong>{String(event.type ?? 'Event')}</strong><time>{String(event.timestamp ?? '')}</time></li>)}</ol>}</div></section>
|
||||
}
|
||||
|
||||
function ExecutionStatus({status}: {status: string}) {
|
||||
const normalized = status.toLowerCase() as ExecutionStateStatus
|
||||
const Icon = normalized === 'succeeded' ? CheckCircle2 : normalized === 'running' || normalized === 'pending' ? Clock3 : normalized === 'caught' ? CircleAlert : XCircle
|
||||
return <span className={`workflow-execution-status ${normalized}`}><Icon size={14}/>{status}</span>
|
||||
}
|
||||
|
||||
function executionStates(resource: CloudResource, events: ExecutionEvent[]): ExecutionState[] {
|
||||
const definitions = stateDefinitions(resource)
|
||||
const byName = new Map<string, ExecutionState>()
|
||||
for (const [name, definition] of definitions) {
|
||||
byName.set(name, {name, type: typeof definition.Type === 'string' ? definition.Type : 'State', definition, status: 'pending', events: []})
|
||||
}
|
||||
for (const event of events) {
|
||||
const name = eventStateName(event, events)
|
||||
if (!name) continue
|
||||
const state = byName.get(name) ?? {name, type: 'State', status: 'pending' as const, events: []}
|
||||
if (!byName.has(name)) byName.set(name, state)
|
||||
state.events.push(event)
|
||||
const type = String(event.type ?? '')
|
||||
const details = eventDetails(event)
|
||||
if (type.endsWith('StateEntered')) {
|
||||
state.status = 'running'
|
||||
state.input = stringField(details, 'input') ?? state.input
|
||||
} else if (type.endsWith('StateExited')) {
|
||||
const taskFailed = state.events.some((stateEvent) => String(stateEvent.type) === 'TaskFailed')
|
||||
const taskSucceeded = state.events.some((stateEvent) => String(stateEvent.type) === 'TaskSucceeded')
|
||||
state.status = taskFailed && !taskSucceeded ? 'failed' : 'succeeded'
|
||||
state.output = stringField(details, 'output') ?? state.output
|
||||
} else if (type === 'TaskSucceeded') {
|
||||
state.status = 'succeeded'
|
||||
state.output = stringField(details, 'output') ?? state.output
|
||||
} else if (type === 'TaskFailed' || type.endsWith('StateFailed')) {
|
||||
state.status = 'failed'
|
||||
}
|
||||
}
|
||||
const ordered = [...byName.values()]
|
||||
for (let index = 0; index < ordered.length - 1; index++) {
|
||||
const state = ordered[index]
|
||||
if (state.status !== 'failed') continue
|
||||
const next = ordered.slice(index + 1).find((candidate) => candidate.events.some((event) => String(event.type).endsWith('StateEntered')))
|
||||
if (next) state.status = 'caught'
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
function stateDefinitions(resource: CloudResource): Array<[string, Record<string, unknown>]> {
|
||||
const raw = resource.metadata.definition
|
||||
if (typeof raw !== 'string') return []
|
||||
try {
|
||||
const definition = JSON.parse(raw) as {States?: unknown}
|
||||
if (!definition.States || typeof definition.States !== 'object' || Array.isArray(definition.States)) return []
|
||||
return Object.entries(definition.States).flatMap(([name, state]) => state && typeof state === 'object' && !Array.isArray(state) ? [[name, state as Record<string, unknown>]] : [])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function eventStateName(event: ExecutionEvent, allEvents: ExecutionEvent[]): string | undefined {
|
||||
const details = eventDetails(event)
|
||||
const direct = stringField(details, 'name')
|
||||
if (direct) return direct
|
||||
const byId = new Map(allEvents.map((item) => [String(item.id), item]))
|
||||
let previousId = typeof event.previousEventId === 'number' ? String(event.previousEventId) : undefined
|
||||
while (previousId) {
|
||||
const previous = byId.get(previousId)
|
||||
if (!previous) return undefined
|
||||
const name = stringField(eventDetails(previous), 'name')
|
||||
if (name) return name
|
||||
previousId = typeof previous.previousEventId === 'number' ? String(previous.previousEventId) : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function eventDetails(event: ExecutionEvent): Record<string, unknown> {
|
||||
const details = Object.entries(event).find(([key, value]) => key.endsWith('EventDetails') && value && typeof value === 'object' && !Array.isArray(value))?.[1]
|
||||
return details as Record<string, unknown> ?? {}
|
||||
}
|
||||
|
||||
function stringField(value: Record<string, unknown>, key: string): string | undefined {
|
||||
return typeof value[key] === 'string' ? value[key] : undefined
|
||||
}
|
||||
|
||||
function lambdaFunctionName(state?: ExecutionState): string | undefined {
|
||||
const definition = state?.definition
|
||||
if (!definition || definition.Type !== 'Task') return undefined
|
||||
const resource = typeof definition.Resource === 'string' ? definition.Resource : ''
|
||||
if (resource.includes(':lambda:') && resource.includes(':function:')) return functionNameFromReference(resource)
|
||||
if (!resource.includes(':::lambda:invoke')) return undefined
|
||||
const parameters = definition.Parameters
|
||||
if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters)) return undefined
|
||||
const functionName = (parameters as Record<string, unknown>).FunctionName
|
||||
return typeof functionName === 'string' ? functionNameFromReference(functionName) : undefined
|
||||
}
|
||||
|
||||
function functionNameFromReference(reference: string): string {
|
||||
const arnName = reference.split(':function:')[1]
|
||||
return arnName ? arnName.split(':')[0] : reference.split(':')[0]
|
||||
}
|
||||
|
||||
function useExecutionHistory(stateMachineArn: string | undefined, executionArn: string | undefined, enabled: boolean, running: boolean): ExecutionHistory {
|
||||
const [history, setHistory] = useState<ExecutionHistory>({events: [], loading: false})
|
||||
|
||||
|
||||
@@ -614,10 +614,12 @@ describe('WorkflowStudio', () => {
|
||||
expect(screen.getByRole('heading', {name: 'SNS Publish Task'})).toBeInTheDocument()
|
||||
expect(listCloudResources).toHaveBeenCalledWith('aws', 'notifications', undefined, expect.any(AbortSignal))
|
||||
await user.selectOptions(screen.getByRole('combobox', {name: 'SNS topic'}), 'arn:aws:sns:us-east-1:000000000000:orders')
|
||||
await user.clear(screen.getByRole('textbox', {name: 'SNS message'}))
|
||||
await user.type(screen.getByRole('textbox', {name: 'SNS message'}), 'Order succeeded')
|
||||
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>}>}
|
||||
expect(saved.States.PublishNotice.Parameters).toEqual({TopicArn: 'arn:aws:sns:us-east-1:000000000000:orders', Message: 'keep'})
|
||||
expect(saved.States.PublishNotice.Parameters).toEqual({TopicArn: 'arn:aws:sns:us-east-1:000000000000:orders', Message: 'Order succeeded'})
|
||||
})
|
||||
|
||||
test('edits SQS parameters from queue inventory and keeps shared task policy controls', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {Background, BaseEdge, EdgeLabelRenderer, getSmoothStepPath, Handle, Mark
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import {getCloudResource, listCloudResources, updateCloudResource} from '@/api/cloudProxyClient'
|
||||
import {cloudQueryKeys, useCloudResourceQuery} from '@/api/queries/cloudQueries'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type AslObject = Record<string, unknown>
|
||||
@@ -38,21 +39,22 @@ interface TaskIntegration {
|
||||
stateName: string
|
||||
resource: string
|
||||
icon: LucideIcon
|
||||
iconKey: string
|
||||
parameters: AslObject
|
||||
}
|
||||
|
||||
const TASK_INTEGRATIONS: TaskIntegration[] = [
|
||||
{action: 'lambda', service: 'Lambda', label: 'Invoke', nodeLabel: 'Lambda: Invoke', stateName: 'LambdaInvoke', resource: 'arn:aws:states:::lambda:invoke', icon: Zap, parameters: {'Payload.$': '$', FunctionName: 'Enter Lambda function name'}},
|
||||
{action: 'sns', service: 'SNS', label: 'Publish', nodeLabel: 'SNS: Publish', stateName: 'SnsPublish', resource: 'arn:aws:states:::sns:publish', icon: BellRing, parameters: {TopicArn: ''}},
|
||||
{action: 'sqs-send-message', service: 'SQS', label: 'SendMessage', nodeLabel: 'SQS: SendMessage', stateName: 'SqsSendMessage', resource: 'arn:aws:states:::sqs:sendMessage', icon: MessageSquare, parameters: {QueueUrl: '', MessageBody: ''}},
|
||||
{action: 'dynamodb-put-item', service: 'DynamoDB', label: 'PutItem', nodeLabel: 'DynamoDB: PutItem', stateName: 'DynamoDbPutItem', resource: 'arn:aws:states:::dynamodb:putItem', icon: Database, parameters: {TableName: '', Item: {}}},
|
||||
{action: 'dynamodb-get-item', service: 'DynamoDB', label: 'GetItem', nodeLabel: 'DynamoDB: GetItem', stateName: 'DynamoDbGetItem', resource: 'arn:aws:states:::dynamodb:getItem', icon: Database, parameters: {TableName: '', Key: {}}},
|
||||
{action: 'dynamodb-update-item', service: 'DynamoDB', label: 'UpdateItem', nodeLabel: 'DynamoDB: UpdateItem', stateName: 'DynamoDbUpdateItem', resource: 'arn:aws:states:::dynamodb:updateItem', icon: Database, parameters: {TableName: '', Key: {}, UpdateExpression: '', ExpressionAttributeValues: {}}},
|
||||
{action: 'dynamodb-delete-item', service: 'DynamoDB', label: 'DeleteItem', nodeLabel: 'DynamoDB: DeleteItem', stateName: 'DynamoDbDeleteItem', resource: 'arn:aws:states:::dynamodb:deleteItem', icon: Database, parameters: {TableName: '', Key: {}}},
|
||||
{action: 'eventbridge-put-events', service: 'EventBridge', label: 'PutEvents', nodeLabel: 'EventBridge: PutEvents', stateName: 'EventBridgePutEvents', resource: 'arn:aws:states:::events:putEvents', icon: Radio, parameters: {Entries: [{EventBusName: '', Source: '', DetailType: '', Detail: {}}]}},
|
||||
{action: 'ecs-run-task', service: 'ECS', label: 'RunTask', nodeLabel: 'ECS: RunTask', stateName: 'EcsRunTask', resource: 'arn:aws:states:::ecs:runTask', icon: Container, parameters: {Cluster: '', TaskDefinition: '', LaunchType: 'FARGATE', Count: 1, NetworkConfiguration: {}}},
|
||||
{action: 'ecs-run-task-sync', service: 'ECS', label: 'RunTask (.sync)', nodeLabel: 'ECS: RunTask (.sync)', stateName: 'EcsRunTaskSync', resource: 'arn:aws:states:::ecs:runTask.sync', icon: Container, parameters: {Cluster: '', TaskDefinition: '', LaunchType: 'FARGATE', Count: 1, NetworkConfiguration: {}}},
|
||||
{action: 'states-start-execution', service: 'Step Functions', label: 'StartExecution', nodeLabel: 'Step Functions: StartExecution', stateName: 'StartExecution', resource: 'arn:aws:states:::states:startExecution', icon: Workflow, parameters: {StateMachineArn: '', Input: {}}},
|
||||
{action: 'lambda', service: 'Lambda', label: 'Invoke', nodeLabel: 'Lambda: Invoke', stateName: 'LambdaInvoke', resource: 'arn:aws:states:::lambda:invoke', icon: Zap, iconKey: 'serverless', parameters: {'Payload.$': '$', FunctionName: 'Enter Lambda function name'}},
|
||||
{action: 'sns', service: 'SNS', label: 'Publish', nodeLabel: 'SNS: Publish', stateName: 'SnsPublish', resource: 'arn:aws:states:::sns:publish', icon: BellRing, iconKey: 'notifications', parameters: {TopicArn: ''}},
|
||||
{action: 'sqs-send-message', service: 'SQS', label: 'SendMessage', nodeLabel: 'SQS: SendMessage', stateName: 'SqsSendMessage', resource: 'arn:aws:states:::sqs:sendMessage', icon: MessageSquare, iconKey: 'messaging', parameters: {QueueUrl: '', MessageBody: ''}},
|
||||
{action: 'dynamodb-put-item', service: 'DynamoDB', label: 'PutItem', nodeLabel: 'DynamoDB: PutItem', stateName: 'DynamoDbPutItem', resource: 'arn:aws:states:::dynamodb:putItem', icon: Database, iconKey: 'nosql', parameters: {TableName: '', Item: {}}},
|
||||
{action: 'dynamodb-get-item', service: 'DynamoDB', label: 'GetItem', nodeLabel: 'DynamoDB: GetItem', stateName: 'DynamoDbGetItem', resource: 'arn:aws:states:::dynamodb:getItem', icon: Database, iconKey: 'nosql', parameters: {TableName: '', Key: {}}},
|
||||
{action: 'dynamodb-update-item', service: 'DynamoDB', label: 'UpdateItem', nodeLabel: 'DynamoDB: UpdateItem', stateName: 'DynamoDbUpdateItem', resource: 'arn:aws:states:::dynamodb:updateItem', icon: Database, iconKey: 'nosql', parameters: {TableName: '', Key: {}, UpdateExpression: '', ExpressionAttributeValues: {}}},
|
||||
{action: 'dynamodb-delete-item', service: 'DynamoDB', label: 'DeleteItem', nodeLabel: 'DynamoDB: DeleteItem', stateName: 'DynamoDbDeleteItem', resource: 'arn:aws:states:::dynamodb:deleteItem', icon: Database, iconKey: 'nosql', parameters: {TableName: '', Key: {}}},
|
||||
{action: 'eventbridge-put-events', service: 'EventBridge', label: 'PutEvents', nodeLabel: 'EventBridge: PutEvents', stateName: 'EventBridgePutEvents', resource: 'arn:aws:states:::events:putEvents', icon: Radio, iconKey: 'events', parameters: {Entries: [{EventBusName: '', Source: '', DetailType: '', Detail: {}}]}},
|
||||
{action: 'ecs-run-task', service: 'ECS', label: 'RunTask', nodeLabel: 'ECS: RunTask', stateName: 'EcsRunTask', resource: 'arn:aws:states:::ecs:runTask', icon: Container, iconKey: 'containers', parameters: {Cluster: '', TaskDefinition: '', LaunchType: 'FARGATE', Count: 1, NetworkConfiguration: {}}},
|
||||
{action: 'ecs-run-task-sync', service: 'ECS', label: 'RunTask (.sync)', nodeLabel: 'ECS: RunTask (.sync)', stateName: 'EcsRunTaskSync', resource: 'arn:aws:states:::ecs:runTask.sync', icon: Container, iconKey: 'containers', parameters: {Cluster: '', TaskDefinition: '', LaunchType: 'FARGATE', Count: 1, NetworkConfiguration: {}}},
|
||||
{action: 'states-start-execution', service: 'Step Functions', label: 'StartExecution', nodeLabel: 'Step Functions: StartExecution', stateName: 'StartExecution', resource: 'arn:aws:states:::states:startExecution', icon: Workflow, iconKey: 'workflows', parameters: {StateMachineArn: '', Input: {}}},
|
||||
]
|
||||
|
||||
const TASK_INTEGRATION_BY_ACTION = new Map(TASK_INTEGRATIONS.map((integration) => [integration.action, integration]))
|
||||
@@ -449,7 +451,7 @@ export function WorkflowStudio({resource}: WorkflowStudioProps) {
|
||||
<p>{paletteTab === 'actions' ? 'AWS service integrations' : 'Flow states'}</p>
|
||||
{paletteTab === 'actions' ? integrationGroups.map((service) => <section className="workflow-palette-service-group" key={service}>
|
||||
<h4>{service}</h4>
|
||||
<div className="workflow-palette-cards">{visibleIntegrations.filter((integration) => integration.service === service).map(({action, label, icon: Icon}) => <button key={action} aria-label={`${service} ${label}`} type="button" draggable onDragStart={(event) => event.dataTransfer.setData('application/x-workflow-action', action)} onClick={() => addAction(action)}><span className={`workflow-palette-icon workflow-palette-${action}`}><Icon size={17}/></span><span><strong>{label}</strong><small>Optimized integration</small></span></button>)}</div>
|
||||
<div className="workflow-palette-cards">{visibleIntegrations.filter((integration) => integration.service === service).map(({action, label, icon, iconKey}) => <button key={action} aria-label={`${service} ${label}`} type="button" draggable onDragStart={(event) => event.dataTransfer.setData('application/x-workflow-action', action)} onClick={() => addAction(action)}><span className={`workflow-palette-icon workflow-palette-${action}`}><ServiceIcon cloud="aws" iconKey={iconKey} fallback={icon} size={28}/></span><span><strong>{label}</strong><small>Optimized integration</small></span></button>)}</div>
|
||||
</section>) : <div className="workflow-palette-cards">{visibleFlowItems.map(({action, label, icon: Icon}) => <button key={action} aria-label={label} type="button" onClick={() => addAction(action)}><span className={`workflow-palette-icon workflow-palette-${action}`}><Icon size={17}/></span><span><strong>{label}</strong><small>Workflow control</small></span></button>)}</div>}
|
||||
{activeBranch && <div className="workflow-branch-hint" role="status">Choose an AWS service integration for the selected branch.</div>}
|
||||
</aside>
|
||||
@@ -542,8 +544,11 @@ function TaskEditorSection({title, addLabel, disabled, onAdd, children}: {title:
|
||||
}
|
||||
|
||||
function SnsTaskPicker({state, topics, loading, failed, onChange}: {state: AslObject; topics: CloudResource[]; loading: boolean; failed: boolean; onChange: (change: (state: AslObject) => void) => void}) {
|
||||
const topicArn = isObject(state.Parameters) ? stringValue(state.Parameters.TopicArn) ?? '' : ''
|
||||
return <div className="workflow-task-editor workflow-sns-task-picker">
|
||||
const parameters = parametersOf(state)
|
||||
const topicArn = stringValue(parameters.TopicArn) ?? ''
|
||||
const pathMode = typeof parameters['Message.$'] === 'string'
|
||||
const message = pathMode ? stringValue(parameters['Message.$']) ?? '' : stringValue(parameters.Message) ?? ''
|
||||
return <div className="workflow-task-editor workflow-service-editor workflow-sns-task-picker">
|
||||
<h4>SNS Publish Task</h4>
|
||||
<label htmlFor="workflow-sns-topic">Topic</label>
|
||||
<select id="workflow-sns-topic" aria-label="SNS topic" value={topicArn} disabled={loading || failed} onChange={(event) => onChange((next) => {
|
||||
@@ -554,6 +559,17 @@ function SnsTaskPicker({state, topics, loading, failed, onChange}: {state: AslOb
|
||||
<option value="" disabled>{loading ? 'Loading SNS topics...' : failed ? 'Unable to load SNS topics' : 'Select an SNS topic'}</option>
|
||||
{topics.map((topic) => <option key={topic.id} value={topic.id}>{topic.name}</option>)}
|
||||
</select>
|
||||
<label>Message mode<select aria-label="SNS message mode" value={pathMode ? 'path' : 'literal'} onChange={(event) => onChange((next) => {
|
||||
const nextParameters = mutableParameters(next)
|
||||
if (event.target.value === 'path') {
|
||||
nextParameters['Message.$'] = stringValue(nextParameters.Message) || '$'
|
||||
delete nextParameters.Message
|
||||
} else {
|
||||
nextParameters.Message = stringValue(nextParameters['Message.$']) || ''
|
||||
delete nextParameters['Message.$']
|
||||
}
|
||||
})}><option value="literal">Literal</option><option value="path">JSONPath</option></select></label>
|
||||
<label>Message<textarea aria-label="SNS message" value={message} onChange={(event) => onChange((next) => setTaskParameter(next, pathMode ? 'Message.$' : 'Message', event.target.value))}/></label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -743,7 +759,7 @@ function StateGraphNode({data}: NodeProps<StateNode>) {
|
||||
return <div className={`workflow-state-node workflow-state-${data.stateType.toLowerCase()}${integration ? ` workflow-node-${integration.action}` : ''}${data.unattached ? ' workflow-state-unattached' : ''}`}>
|
||||
<Handle type="target" position={Position.Top}/>
|
||||
<button className="workflow-node-delete nodrag nopan" type="button" aria-label={`Delete ${data.label}`} onClick={(event) => { event.stopPropagation(); data.onDelete?.() }}><Trash2 size={12}/></button>
|
||||
<span className="workflow-state-type"><Icon size={13}/>{data.stateType}</span>
|
||||
<span className="workflow-state-type">{integration ? <ServiceIcon cloud="aws" iconKey={integration.iconKey} fallback={Icon} size={15}/> : <Icon size={13}/>} {data.stateType}</span>
|
||||
<strong>{integration?.nodeLabel ?? data.label}</strong>
|
||||
{integration && <span className="workflow-state-name">{data.label}</span>}
|
||||
{data.retry && <span className="workflow-retry-marker">Retry</span>}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
Boxes,
|
||||
Circle,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
ScrollText,
|
||||
Scale,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Table2,
|
||||
Workflow,
|
||||
Waypoints,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Maps the server's `iconKey` hint to a component.
|
||||
*
|
||||
* Icon keys are additive and never load-bearing: the server can ship a service
|
||||
* whose key this build has never heard of, and the nav must still render. Passing
|
||||
* `undefined` as a JSX component throws and — before the ErrorBoundary lands —
|
||||
* would blank the whole app, so the fallback is not optional.
|
||||
*/
|
||||
const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
storage: HardDrive,
|
||||
ecr: Container,
|
||||
database: Table2,
|
||||
nosql: Database,
|
||||
k8s: Boxes,
|
||||
compute: Server,
|
||||
containers: Container,
|
||||
networking: Network,
|
||||
apigateway: Waypoints,
|
||||
serverless: Zap,
|
||||
secrets: KeyRound,
|
||||
iac: Layers,
|
||||
messaging: MessageSquare,
|
||||
events: Radio,
|
||||
email: MessageSquare,
|
||||
queue: MessageSquare,
|
||||
logs: ScrollText,
|
||||
iam: ShieldCheck,
|
||||
kms: Lock,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
scheduler: Clock,
|
||||
workflows: Workflow,
|
||||
}
|
||||
|
||||
const FALLBACK_ICON: LucideIcon = Circle
|
||||
|
||||
export function serviceIcon(iconKey?: string): LucideIcon {
|
||||
return (iconKey && SERVICE_ICONS[iconKey]) || FALLBACK_ICON
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Boxes,
|
||||
Circle,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
ScrollText,
|
||||
Scale,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Table2,
|
||||
Workflow,
|
||||
Waypoints,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import apiGatewayIcon from '@/assets/aws-services/api-gateway.svg'
|
||||
import appConfigIcon from '@/assets/aws-services/appconfig.svg'
|
||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||
import ec2Icon from '@/assets/aws-services/ec2.svg'
|
||||
import ecrIcon from '@/assets/aws-services/ecr.svg'
|
||||
import ecsIcon from '@/assets/aws-services/ecs.svg'
|
||||
import eksIcon from '@/assets/aws-services/eks.svg'
|
||||
import elasticLoadBalancingIcon from '@/assets/aws-services/elastic-load-balancing.svg'
|
||||
import eventBridgeIcon from '@/assets/aws-services/eventbridge.svg'
|
||||
import iamIcon from '@/assets/aws-services/iam.svg'
|
||||
import kmsIcon from '@/assets/aws-services/kms.svg'
|
||||
import lambdaIcon from '@/assets/aws-services/lambda.svg'
|
||||
import parameterStoreIcon from '@/assets/aws-services/parameter-store.svg'
|
||||
import rdsIcon from '@/assets/aws-services/rds.svg'
|
||||
import s3Icon from '@/assets/aws-services/s3.svg'
|
||||
import secretsManagerIcon from '@/assets/aws-services/secrets-manager.svg'
|
||||
import sesIcon from '@/assets/aws-services/ses.svg'
|
||||
import snsIcon from '@/assets/aws-services/sns.svg'
|
||||
import sqsIcon from '@/assets/aws-services/sqs.svg'
|
||||
import stepFunctionsIcon from '@/assets/aws-services/step-functions.svg'
|
||||
import vpcIcon from '@/assets/aws-services/vpc.svg'
|
||||
|
||||
/**
|
||||
* Maps the server's `iconKey` hint to a component.
|
||||
*
|
||||
* Icon keys are additive and never load-bearing: the server can ship a service
|
||||
* whose key this build has never heard of, and the nav must still render. Passing
|
||||
* `undefined` as a JSX component throws and — before the ErrorBoundary lands —
|
||||
* would blank the whole app, so the fallback is not optional.
|
||||
*/
|
||||
const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
storage: HardDrive,
|
||||
ecr: Container,
|
||||
database: Table2,
|
||||
nosql: Database,
|
||||
k8s: Boxes,
|
||||
compute: Server,
|
||||
containers: Container,
|
||||
networking: Network,
|
||||
apigateway: Waypoints,
|
||||
serverless: Zap,
|
||||
secrets: KeyRound,
|
||||
iac: Layers,
|
||||
messaging: MessageSquare,
|
||||
events: Radio,
|
||||
email: MessageSquare,
|
||||
queue: MessageSquare,
|
||||
logs: ScrollText,
|
||||
iam: ShieldCheck,
|
||||
kms: Lock,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
scheduler: Clock,
|
||||
workflows: Workflow,
|
||||
}
|
||||
|
||||
const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
apigateway: apiGatewayIcon,
|
||||
compute: ec2Icon,
|
||||
configuration: appConfigIcon,
|
||||
containers: ecsIcon,
|
||||
database: rdsIcon,
|
||||
ecr: ecrIcon,
|
||||
email: sesIcon,
|
||||
events: eventBridgeIcon,
|
||||
iac: cloudFormationIcon,
|
||||
iam: iamIcon,
|
||||
kms: kmsIcon,
|
||||
k8s: eksIcon,
|
||||
loadbalancing: elasticLoadBalancingIcon,
|
||||
logs: cloudWatchLogsIcon,
|
||||
messaging: sqsIcon,
|
||||
nosql: dynamoDbIcon,
|
||||
notifications: snsIcon,
|
||||
networking: vpcIcon,
|
||||
parameters: parameterStoreIcon,
|
||||
secrets: secretsManagerIcon,
|
||||
serverless: lambdaIcon,
|
||||
storage: s3Icon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
const FALLBACK_ICON: LucideIcon = Circle
|
||||
|
||||
function serviceIcon(iconKey?: string): LucideIcon {
|
||||
return (iconKey && SERVICE_ICONS[iconKey]) || FALLBACK_ICON
|
||||
}
|
||||
|
||||
interface ServiceIconProps {
|
||||
cloud?: CloudProvider
|
||||
iconKey?: string
|
||||
size?: number
|
||||
fallback?: LucideIcon
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Renders official local AWS artwork, with the existing Lucide map as a safe fallback. */
|
||||
export function ServiceIcon({cloud, iconKey, size = 16, fallback, className}: ServiceIconProps) {
|
||||
const asset = cloud === 'aws' && iconKey ? AWS_SERVICE_ICON_ASSETS[iconKey] : undefined
|
||||
const classes = ['service-icon-glyph', asset ? 'aws-service-icon' : '', className ?? ''].filter(Boolean).join(' ')
|
||||
|
||||
if (asset) {
|
||||
return <img className={classes} src={asset} width={size} height={size} alt="" aria-hidden="true"/>
|
||||
}
|
||||
|
||||
const FallbackIcon = fallback ?? serviceIcon(iconKey)
|
||||
return <FallbackIcon className={classes} size={size} aria-hidden="true"/>
|
||||
}
|
||||
Reference in New Issue
Block a user