feat: EC2 UI polished + storage persisting

This commit is contained in:
Zakaria
2026-09-25 18:18:33 -04:00
parent 1b129e9dcf
commit 093afb9b2b
40 changed files with 2586 additions and 102 deletions
+1
View File
@@ -0,0 +1 @@
bun = "1.4.2"
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+1
View File
@@ -63,6 +63,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Networking | ELB / Load Balancing | Yes (list, create, delete, inspect) | No | No |
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+1
View File
@@ -4,6 +4,7 @@ WORKDIR /app
COPY packages/frontend/package.json ./
RUN npm install
COPY packages/frontend/ .
COPY OS_img/ /OS_img/
RUN npm run build
# Stage 2 — compile API to a self-contained binary
+2 -1
View File
@@ -14,8 +14,8 @@
"@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
"@aws-sdk/client-ec2": "^3.1076.0",
"@aws-sdk/client-dynamodb": "^3.1088.0",
"@aws-sdk/client-ec2": "^3.1076.0",
"@aws-sdk/client-eks": "^3.1076.0",
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
"@aws-sdk/client-eventbridge": "^3.1076.0",
@@ -26,6 +26,7 @@
"@aws-sdk/client-s3": "^3.1076.0",
"@aws-sdk/client-secrets-manager": "^3.1076.0",
"@aws-sdk/client-sfn": "^3.1096.0",
"@aws-sdk/client-sns": "^3.1140.0",
"@aws-sdk/client-sqs": "^3.1076.0",
"@aws-sdk/client-ssm": "^3.1096.0",
"@aws-sdk/util-dynamodb": "^3.996.7",
@@ -112,12 +112,16 @@ describe('AwsComputeAdapter', () => {
keyName: 'my-key',
subnetId: 'subnet-abc',
securityGroupIds: 'sg-111, sg-222',
rootVolumeSize: '30',
rootVolumeType: 'gp3',
}})
expect(launched).toHaveLength(1)
expect(launched[0].name).toBe('test-box')
expect(launched[0].imageId).toBe('ami-0abcdef123')
expect(launched[0].instanceType).toBe('t3.micro')
expect(launched[0].securityGroupIds).toEqual(['sg-111', 'sg-222'])
expect(launched[0].rootVolumeSize).toBe(30)
expect(launched[0].rootVolumeType).toBe('gp3')
expect(result.type).toBe('instance')
expect(result.cloud).toBe('aws')
})
@@ -1,4 +1,5 @@
import {awsComputeSchema} from '../cloud-spi/computeSchema'
import {ValidationError} from '../cloud-spi/errors'
import type {
CloudResource,
CloudServiceAdapter,
@@ -51,6 +52,7 @@ export class AwsComputeAdapter implements CloudServiceAdapter {
const v = input.values
const sgRaw = typeof v.securityGroupIds === 'string' ? v.securityGroupIds : ''
const securityGroupIds = sgRaw ? sgRaw.split(',').map((s: string) => s.trim()).filter(Boolean) : undefined
const rootVolumeSize = positiveInteger(v.rootVolumeSize)
const runInput: RunInstanceInput = {
name: String(v.name ?? ''),
imageId: String(v.imageId ?? ''),
@@ -58,6 +60,8 @@ export class AwsComputeAdapter implements CloudServiceAdapter {
keyName: v.keyName ? String(v.keyName) : undefined,
subnetId: v.subnetId ? String(v.subnetId) : undefined,
securityGroupIds,
rootVolumeSize,
rootVolumeType: v.rootVolumeType ? String(v.rootVolumeType) : undefined,
}
const instance = await this.service_.runInstance(runInput)
return instanceToResource(instance)
@@ -68,6 +72,13 @@ export class AwsComputeAdapter implements CloudServiceAdapter {
}
}
function positiveInteger(value: unknown): number | undefined {
if (value === undefined || value === null || String(value).trim() === '') return undefined
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) throw new ValidationError('rootVolumeSize must be a positive whole number')
return parsed
}
function instanceToResource(instance: Ec2Instance): CloudResource {
return {
id: instance.instanceId,
@@ -2,10 +2,17 @@ import {describe, expect, test} from 'bun:test'
import {
CreateEventBusCommand,
DeleteEventBusCommand,
DeleteRuleCommand,
DescribeEventBusCommand,
ListEventBusesCommand,
ListRulesCommand,
ListTargetsByRuleCommand,
PutRuleCommand,
PutTargetsCommand,
RemoveTargetsCommand,
type EventBridgeClient,
} from '@aws-sdk/client-eventbridge'
import {AddPermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {AwsEventBridgeAdapter} from './AwsEventBridgeAdapter'
type SendHandler = (command: unknown) => Promise<unknown>
@@ -74,8 +81,64 @@ describe('AwsEventBridgeAdapter', () => {
expect(commands[1]).toBeInstanceOf(DeleteEventBusCommand)
expect((commands[1] as DeleteEventBusCommand).input.Name).toBe('orders')
})
test('lists every rule and its targets for an event bus', async () => {
const adapter = createAdapter(async (command) => {
if (command instanceof ListRulesCommand) {
expect(command.input.EventBusName).toBe('orders')
return command.input.NextToken
? {Rules: [{Name: 'nightly', Arn: 'arn:aws:events:us-east-1:000000000000:rule/orders/nightly', EventPattern: '{"source":["billing"]}', State: 'DISABLED'}]}
: {Rules: [{Name: 'hourly', Arn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly', ScheduleExpression: 'rate(1 hour)'}], NextToken: 'next'}
}
if (command instanceof ListTargetsByRuleCommand) {
return {Targets: [{Id: 'worker', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]}
}
return {}
})
await expect(adapter.listEventBridgeRules('event-bus:orders')).resolves.toEqual([
{name: 'hourly', busName: 'orders', busId: 'event-bus:orders', arn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly', scheduleExpression: 'rate(1 hour)', state: 'ENABLED', targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]},
{name: 'nightly', busName: 'orders', busId: 'event-bus:orders', arn: 'arn:aws:events:us-east-1:000000000000:rule/orders/nightly', eventPattern: '{"source":["billing"]}', state: 'DISABLED', targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]},
])
})
test('creates a rule, attaches targets, and permits Lambda invocation', async () => {
const commands: unknown[] = []
const adapter = createAdapter(async (command) => {
commands.push(command)
if (command instanceof PutRuleCommand) return {RuleArn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly'}
return {}
})
await expect(adapter.createEventBridgeRule('event-bus:orders', {
name: 'hourly', scheduleExpression: 'rate(1 hour)', state: 'DISABLED', description: 'Run the worker',
targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}],
})).resolves.toMatchObject({name: 'hourly', busName: 'orders', state: 'DISABLED'})
expect((commands[0] as PutRuleCommand).input).toEqual({Name: 'hourly', EventBusName: 'orders', ScheduleExpression: 'rate(1 hour)', State: 'DISABLED', Description: 'Run the worker'})
expect((commands[1] as PutTargetsCommand).input).toEqual({Rule: 'hourly', EventBusName: 'orders', Targets: [{Id: 'worker', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]})
expect((commands[2] as AddPermissionCommand).input).toMatchObject({FunctionName: 'arn:aws:lambda:us-east-1:000000000000:function:worker', Principal: 'events.amazonaws.com', SourceArn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly'})
})
test('removes targets before deleting a rule', async () => {
const commands: unknown[] = []
const adapter = createAdapter(async (command) => {
commands.push(command)
if (command instanceof ListTargetsByRuleCommand) return {Targets: [{Id: 'worker', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]}
return {}
})
await adapter.deleteEventBridgeRule('event-bus:orders', 'hourly')
expect(commands[0]).toBeInstanceOf(ListTargetsByRuleCommand)
expect((commands[1] as RemoveTargetsCommand).input).toEqual({Rule: 'hourly', EventBusName: 'orders', Ids: ['worker']})
expect((commands[2] as DeleteRuleCommand).input).toEqual({Name: 'hourly', EventBusName: 'orders'})
})
})
function createAdapter(handler: SendHandler = async () => ({EventBuses: []})): AwsEventBridgeAdapter {
return new AwsEventBridgeAdapter({send: handler} as unknown as EventBridgeClient)
return new AwsEventBridgeAdapter(
{send: handler} as unknown as EventBridgeClient,
{send: handler} as unknown as LambdaClient,
)
}
@@ -2,10 +2,19 @@ import {
CreateEventBusCommand,
DeleteEventBusCommand,
DescribeEventBusCommand,
DeleteRuleCommand,
ListEventBusesCommand,
ListRulesCommand,
ListTargetsByRuleCommand,
PutRuleCommand,
PutTargetsCommand,
RemoveTargetsCommand,
type EventBridgeClient,
type EventBus,
type Rule,
type Target,
} from '@aws-sdk/client-eventbridge'
import {AddPermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {ValidationError} from '../cloud-spi/errors'
import type {
@@ -13,6 +22,9 @@ import type {
CloudServiceAdapter,
CloudServiceDescriptorOverride,
CreateResourceInput,
EventBridgeRuleInput,
EventBridgeRuleOverview,
EventBridgeRuleTarget,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
@@ -24,7 +36,10 @@ export class AwsEventBridgeAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'events' as const
constructor(private readonly eventbridge: EventBridgeClient) {}
constructor(
private readonly eventbridge: EventBridgeClient,
private readonly lambda: LambdaClient,
) {}
schema(): ServiceSchema {
return awsEventBridgeSchema()
@@ -67,6 +82,90 @@ export class AwsEventBridgeAdapter implements CloudServiceAdapter {
async delete(id: string): Promise<void> {
await this.eventbridge.send(new DeleteEventBusCommand({Name: eventBusName(id)}))
}
async listEventBridgeRules(eventBusId: string): Promise<EventBridgeRuleOverview[]> {
const busName = eventBusName(eventBusId)
const rules: Rule[] = []
let nextToken: string | undefined
do {
const response = await this.eventbridge.send(new ListRulesCommand({
...eventBusInput(busName),
NextToken: nextToken,
}))
rules.push(...(response.Rules ?? []))
nextToken = response.NextToken
} while (nextToken)
return Promise.all(rules.filter((rule) => Boolean(rule.Name)).map(async (rule) => {
const name = rule.Name as string
return ruleOverview(rule, busName, await this.listTargets(name, busName))
}))
}
async createEventBridgeRule(eventBusId: string, input: EventBridgeRuleInput): Promise<EventBridgeRuleOverview> {
const busName = eventBusName(eventBusId)
const name = requiredValue(input.name, 'name')
const targetInput = ruleInput(input)
const response = await this.eventbridge.send(new PutRuleCommand({
Name: name,
...eventBusInput(busName),
...(targetInput.scheduleExpression ? {ScheduleExpression: targetInput.scheduleExpression} : {}),
...(targetInput.eventPattern ? {EventPattern: targetInput.eventPattern} : {}),
State: targetInput.state,
...(targetInput.description ? {Description: targetInput.description} : {}),
}))
const arn = response.RuleArn
if (!arn) throw new ValidationError(`EventBridge rule ${name} did not return an ARN`)
await this.eventbridge.send(new PutTargetsCommand({
Rule: name,
...eventBusInput(busName),
Targets: input.targets.map((target) => ({Id: target.id, Arn: target.arn})),
}))
await Promise.all(input.targets.filter((target) => isLambdaArn(target.arn)).map((target) =>
this.lambda.send(new AddPermissionCommand({
FunctionName: target.arn,
StatementId: permissionStatementId(busName, name, target.id),
Action: 'lambda:InvokeFunction',
Principal: 'events.amazonaws.com',
SourceArn: arn,
})),
))
return {
name,
busName,
busId: `${EVENT_BUS_PREFIX}${busName}`,
arn,
...targetInput,
targets: input.targets,
}
}
async deleteEventBridgeRule(eventBusId: string, ruleName: string): Promise<void> {
const busName = eventBusName(eventBusId)
const name = requiredValue(ruleName, 'ruleName')
const targets = await this.listTargets(name, busName)
const targetIds = targets.map((target) => target.Id).filter((id): id is string => Boolean(id))
if (targetIds.length > 0) {
await this.eventbridge.send(new RemoveTargetsCommand({Rule: name, ...eventBusInput(busName), Ids: targetIds}))
}
await this.eventbridge.send(new DeleteRuleCommand({Name: name, ...eventBusInput(busName)}))
}
private async listTargets(ruleName: string, busName: string): Promise<Target[]> {
const targets: Target[] = []
let nextToken: string | undefined
do {
const response = await this.eventbridge.send(new ListTargetsByRuleCommand({
Rule: ruleName,
...eventBusInput(busName),
NextToken: nextToken,
}))
targets.push(...(response.Targets ?? []))
nextToken = response.NextToken
} while (nextToken)
return targets
}
}
function eventBusResource(eventBus: EventBus): CloudResource {
@@ -122,3 +221,55 @@ function isNotFound(error: unknown): boolean {
const value = error as {name?: string; $metadata?: {httpStatusCode?: number}}
return value.name === 'ResourceNotFoundException' || value.$metadata?.httpStatusCode === 404
}
function eventBusInput(busName: string): {EventBusName?: string} {
return busName === 'default' ? {} : {EventBusName: busName}
}
function ruleInput(input: EventBridgeRuleInput): Pick<EventBridgeRuleOverview, 'scheduleExpression' | 'eventPattern' | 'state' | 'description'> {
const scheduleExpression = stringValue(input.scheduleExpression)
const eventPattern = stringValue(input.eventPattern)
if (Boolean(scheduleExpression) === Boolean(eventPattern)) {
throw new ValidationError('exactly one of scheduleExpression or eventPattern is required')
}
if (eventPattern) {
try {
JSON.parse(eventPattern)
} catch {
throw new ValidationError('eventPattern must be a JSON string')
}
}
if (!Array.isArray(input.targets) || input.targets.length === 0) throw new ValidationError('at least one target is required')
const targetsValid = input.targets.every((target) => requiredValue(target.id, 'target.id') && requiredValue(target.arn, 'target.arn'))
if (!targetsValid) throw new ValidationError('target id and arn are required')
const state = input.state ?? 'ENABLED'
if (state !== 'ENABLED' && state !== 'DISABLED') throw new ValidationError('state must be ENABLED or DISABLED')
return {
...(scheduleExpression ? {scheduleExpression} : {}),
...(eventPattern ? {eventPattern} : {}),
state,
...(stringValue(input.description) ? {description: stringValue(input.description)} : {}),
}
}
function ruleOverview(rule: Rule, busName: string, targets: Target[]): EventBridgeRuleOverview {
return {
name: rule.Name ?? '',
busName,
busId: `${EVENT_BUS_PREFIX}${busName}`,
arn: rule.Arn ?? null,
...(rule.ScheduleExpression ? {scheduleExpression: rule.ScheduleExpression} : {}),
...(rule.EventPattern ? {eventPattern: rule.EventPattern} : {}),
state: rule.State === 'DISABLED' ? 'DISABLED' : 'ENABLED',
...(rule.Description ? {description: rule.Description} : {}),
targets: targets.flatMap((target) => target.Id && target.Arn ? [{id: target.Id, arn: target.Arn}] : []),
}
}
function isLambdaArn(arn: string): boolean {
return arn.split(':')[2] === 'lambda'
}
function permissionStatementId(busName: string, ruleName: string, targetId: string): string {
return `floci-events-${busName}-${ruleName}-${targetId}`.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 100)
}
@@ -0,0 +1,138 @@
import {describe, expect, test} from 'bun:test'
import {
CreateTopicCommand,
DeleteTopicCommand,
GetSubscriptionAttributesCommand,
GetTopicAttributesCommand,
ListSubscriptionsByTopicCommand,
ListTopicsCommand,
PublishCommand,
SubscribeCommand,
type SNSClient,
UnsubscribeCommand,
} from '@aws-sdk/client-sns'
import {AwsSnsAdapter} from './AwsSnsAdapter'
import {ValidationError} from '../cloud-spi/errors'
const ORDERS_ARN = 'arn:aws:sns:us-east-1:000000000000:orders'
const ALERTS_ARN = 'arn:aws:sns:us-east-1:000000000000:alerts'
function stubSns() {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
if (command instanceof ListTopicsCommand) return {Topics: [{TopicArn: ORDERS_ARN}, {TopicArn: ALERTS_ARN}]}
if (command instanceof GetTopicAttributesCommand) {
return {Attributes: {DisplayName: 'Order notifications', FifoTopic: 'false'}}
}
if (command instanceof CreateTopicCommand) {
return {TopicArn: `arn:aws:sns:us-east-1:000000000000:${command.input.Name}`}
}
if (command instanceof PublishCommand) return {MessageId: 'e5a98c5b-cd94-4c38-a9ac-7260ec3e575f'}
if (command instanceof ListSubscriptionsByTopicCommand) return {Subscriptions: [{
SubscriptionArn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-1',
Protocol: 'sqs',
Endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders',
}]}
if (command instanceof GetSubscriptionAttributesCommand) return {Attributes: {FilterPolicy: '{"eventType":["created"]}'}}
if (command instanceof SubscribeCommand) return {SubscriptionArn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-2'}
return {}
},
} as unknown as SNSClient
return {client, sent}
}
describe('AwsSnsAdapter', () => {
test('lists SNS topic ARNs as topic resources', async () => {
const {client} = stubSns()
const [topic] = await new AwsSnsAdapter(client).list()
expect(topic).toMatchObject({id: ORDERS_ARN, name: 'orders', service: 'notifications', type: 'topic'})
expect(topic?.metadata.arn).toBe(ORDERS_ARN)
})
test('gets a topic with its SNS attributes', async () => {
const {client, sent} = stubSns()
const topic = await new AwsSnsAdapter(client).get(ORDERS_ARN)
expect(sent[0]).toBeInstanceOf(GetTopicAttributesCommand)
expect(topic?.metadata.displayName).toBe('Order notifications')
})
test('creates and reads back an SNS topic', async () => {
const {client, sent} = stubSns()
const topic = await new AwsSnsAdapter(client).create({values: {name: 'orders'}})
expect(sent[0]).toBeInstanceOf(CreateTopicCommand)
expect((sent[0] as CreateTopicCommand).input.Name).toBe('orders')
expect(sent[1]).toBeInstanceOf(GetTopicAttributesCommand)
expect(topic.id).toBe(ORDERS_ARN)
})
test('deletes a topic by ARN', async () => {
const {client, sent} = stubSns()
await new AwsSnsAdapter(client).delete(ORDERS_ARN)
expect((sent[0] as DeleteTopicCommand).input.TopicArn).toBe(ORDERS_ARN)
})
test('publishes a message with optional subject and string message attributes', async () => {
const {client, sent} = stubSns()
const result = await new AwsSnsAdapter(client).publish(ORDERS_ARN, {
message: 'Order placed',
subject: 'Order update',
messageAttributes: {orderId: '1234'},
})
expect(result.messageId).toBe('e5a98c5b-cd94-4c38-a9ac-7260ec3e575f')
expect((sent[0] as PublishCommand).input).toEqual({
TopicArn: ORDERS_ARN,
Message: 'Order placed',
Subject: 'Order update',
MessageAttributes: {orderId: {DataType: 'String', StringValue: '1234'}},
})
})
test('manages SNS subscriptions with direct SDK commands and exposes their attributes', async () => {
const {client, sent} = stubSns()
const adapter = new AwsSnsAdapter(client)
const created = await adapter.createSnsSubscription(ORDERS_ARN, {
protocol: 'sqs',
endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders',
})
const subscriptions = await adapter.listSnsSubscriptions(ORDERS_ARN)
await adapter.deleteSnsSubscription('arn:aws:sns:us-east-1:000000000000:orders:sub-1')
expect((sent[0] as SubscribeCommand).input).toEqual({
TopicArn: ORDERS_ARN,
Protocol: 'sqs',
Endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders',
})
expect((sent[1] as ListSubscriptionsByTopicCommand).input).toEqual({TopicArn: ORDERS_ARN})
expect((sent[2] as GetSubscriptionAttributesCommand).input).toEqual({
SubscriptionArn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-1',
})
expect((sent[3] as UnsubscribeCommand).input).toEqual({
SubscriptionArn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-1',
})
expect(created).toMatchObject({arn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-2', protocol: 'sqs', status: 'Confirmed'})
expect(subscriptions).toEqual([{
arn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-1',
protocol: 'sqs',
endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders',
status: 'Confirmed',
filterPolicy: '{"eventType":["created"]}',
}])
})
test('rejects invalid topic names and blank messages before calling SNS', async () => {
const {client, sent} = stubSns()
const adapter = new AwsSnsAdapter(client)
await expect(adapter.create({values: {name: 'has space'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.publish(ORDERS_ARN, {message: ''})).rejects.toBeInstanceOf(ValidationError)
expect(sent).toEqual([])
})
})
@@ -0,0 +1,200 @@
import {
CreateTopicCommand,
DeleteTopicCommand,
GetSubscriptionAttributesCommand,
GetTopicAttributesCommand,
ListSubscriptionsByTopicCommand,
ListTopicsCommand,
PublishCommand,
SubscribeCommand,
type SNSClient,
type Subscription,
type Topic,
UnsubscribeCommand,
} from '@aws-sdk/client-sns'
import {ValidationError} from '../cloud-spi/errors'
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
PublishNotificationInput,
PublishNotificationResult,
ResourceQuery,
ServiceSchema,
SnsSubscriptionInput,
SnsSubscriptionOverview,
} from '../cloud-spi/types'
/** Amazon SNS topics through the provider-neutral Notifications category. */
export class AwsSnsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'notifications' as const
constructor(private readonly sns: SNSClient) {}
schema(): ServiceSchema {
return awsNotificationsSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const topics: Topic[] = []
let nextToken: string | undefined
do {
const response = await this.sns.send(new ListTopicsCommand(nextToken ? {NextToken: nextToken} : {}))
topics.push(...(response.Topics ?? []))
nextToken = response.NextToken
} while (nextToken)
return filterBySearch(topics.map((topic) => topicResource(topic)), query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
const response = await this.sns.send(new GetTopicAttributesCommand({TopicArn: id}))
return topicResource({TopicArn: id}, response.Attributes)
} catch (error) {
if (isMissing(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = requiredName(input.values.name)
const response = await this.sns.send(new CreateTopicCommand({Name: name}))
const arn = response.TopicArn
if (!arn) throw new ValidationError(`SNS did not return an ARN for ${name}`)
return (await this.get(arn)) ?? topicResource({TopicArn: arn})
}
async delete(id: string): Promise<void> {
await this.sns.send(new DeleteTopicCommand({TopicArn: id}))
}
async publish(id: string, input: PublishNotificationInput): Promise<PublishNotificationResult> {
const message = requiredMessage(input.message)
const subject = optionalSubject(input.subject)
const MessageAttributes = toMessageAttributes(input.messageAttributes)
const response = await this.sns.send(new PublishCommand({
TopicArn: id,
Message: message,
...(subject ? {Subject: subject} : {}),
...(MessageAttributes ? {MessageAttributes} : {}),
}))
return {messageId: response.MessageId ?? null}
}
async createSnsSubscription(topicArn: string, input: SnsSubscriptionInput): Promise<SnsSubscriptionOverview> {
const protocol = requiredSubscriptionValue(input.protocol, 'protocol')
const endpoint = requiredSubscriptionValue(input.endpoint, 'endpoint')
const response = await this.sns.send(new SubscribeCommand({
TopicArn: topicArn,
Protocol: protocol,
Endpoint: endpoint,
}))
return {
arn: response.SubscriptionArn ?? null,
protocol,
endpoint,
status: response.SubscriptionArn === 'PendingConfirmation' ? 'PendingConfirmation' : 'Confirmed',
filterPolicy: null,
}
}
async listSnsSubscriptions(topicArn: string): Promise<SnsSubscriptionOverview[]> {
const subscriptions: Subscription[] = []
let nextToken: string | undefined
do {
const response = await this.sns.send(new ListSubscriptionsByTopicCommand({
TopicArn: topicArn,
...(nextToken ? {NextToken: nextToken} : {}),
}))
subscriptions.push(...(response.Subscriptions ?? []))
nextToken = response.NextToken
} while (nextToken)
return Promise.all(subscriptions.map((subscription) => this.subscriptionOverview(subscription)))
}
async deleteSnsSubscription(subscriptionArn: string): Promise<void> {
await this.sns.send(new UnsubscribeCommand({SubscriptionArn: subscriptionArn}))
}
async health(): Promise<void> {
await this.sns.send(new ListTopicsCommand({}))
}
private async subscriptionOverview(subscription: Subscription): Promise<SnsSubscriptionOverview> {
const arn = subscription.SubscriptionArn ?? null
const pending = arn === 'PendingConfirmation'
const attributes = !arn || pending
? undefined
: await this.sns.send(new GetSubscriptionAttributesCommand({SubscriptionArn: arn}))
return {
arn,
protocol: subscription.Protocol ?? '',
endpoint: subscription.Endpoint ?? '',
status: pending ? 'PendingConfirmation' : 'Confirmed',
filterPolicy: attributes?.Attributes?.FilterPolicy ?? null,
}
}
}
function topicResource(topic: Topic, attributes: Record<string, string> = {}): CloudResource {
const arn = topic.TopicArn ?? ''
return {
id: arn,
name: topicName(arn),
cloud: 'aws',
service: 'notifications',
type: attributes.FifoTopic === 'true' ? 'fifo-topic' : 'topic',
region: arn.split(':')[3] || null,
createdAt: null,
metadata: {arn, fifoTopic: attributes.FifoTopic === 'true', displayName: attributes.DisplayName},
}
}
function topicName(arn: string): string {
return arn.split(':').pop() ?? arn
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
function requiredName(value: unknown): string {
if (typeof value !== 'string' || !value.trim()) throw new ValidationError('name is required')
const name = value.trim()
if (!/^[A-Za-z0-9_-]{1,256}$/.test(name) && !/^[A-Za-z0-9_-]{1,251}\.fifo$/.test(name)) {
throw new ValidationError('name must be up to 256 letters, numbers, hyphens, underscores, or end in .fifo')
}
return name
}
function requiredMessage(value: unknown): string {
if (typeof value !== 'string' || !value) throw new ValidationError('message is required')
return value
}
function requiredSubscriptionValue(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) throw new ValidationError(`${field} is required`)
return value.trim()
}
function optionalSubject(value: unknown): string | undefined {
if (value === undefined || value === '') return undefined
if (typeof value !== 'string') throw new ValidationError('subject must be a string')
if (value.length > 100) throw new ValidationError('subject must be 100 characters or fewer')
return value
}
function toMessageAttributes(values: Record<string, string> | undefined) {
if (!values || Object.keys(values).length === 0) return undefined
return Object.fromEntries(Object.entries(values).map(([Name, Value]) => [Name, {DataType: 'String', StringValue: Value}]))
}
function isMissing(error: unknown): boolean {
return typeof error === 'object' && error !== null && (error as {name?: string}).name === 'NotFound'
}
+3
View File
@@ -16,6 +16,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
import { AppConfigClient } from "@aws-sdk/client-appconfig";
import { SNSClient } from "@aws-sdk/client-sns";
const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
@@ -63,6 +64,7 @@ export type AwsClients = {
apiGateway: APIGatewayClient;
cloudformation: CloudFormationClient;
appConfig: AppConfigClient;
sns: SNSClient;
};
export type AwsClientName = keyof AwsClients;
@@ -94,6 +96,7 @@ function buildClients(accountId: string): AwsClients {
apiGateway: new APIGatewayClient(base),
cloudformation: new CloudFormationClient(base),
appConfig: new AppConfigClient(base),
sns: new SNSClient(base),
};
}
@@ -0,0 +1,33 @@
import type {ServiceSchema} from './types'
export function awsNotificationsSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'notifications',
displayName: 'Amazon SNS',
fields: [
{
name: 'name',
label: 'Topic Name',
type: 'text',
required: true,
description: 'Up to 256 letters, numbers, hyphens, and underscores.',
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'Name'},
{name: 'arn', label: 'Topic ARN', path: 'metadata.arn', format: 'code'},
{name: 'type', label: 'Type'},
],
capabilities: {
resourceActions: [
{name: 'list', label: 'List topics', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create topic', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete topic', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect topic', enabled: true, status: 'available', runtimeRequired: false},
],
},
}
}
@@ -111,6 +111,13 @@ export const SERVICE_CATALOG = {
group: 'Integration',
order: 10,
},
notifications: {
displayName: 'Notifications',
displayNameByCloud: {aws: 'SNS'},
iconKey: 'notifications',
group: 'Integration',
order: 11,
},
events: {
displayName: 'Events',
displayNameByCloud: {aws: 'EventBridge'},
+51
View File
@@ -450,6 +450,29 @@ export interface ServerlessInvokeResult {
executionDuration?: number
}
export interface PublishNotificationInput {
message: string
subject?: string
messageAttributes?: Record<string, string>
}
export interface PublishNotificationResult {
messageId: string | null
}
export interface SnsSubscriptionInput {
protocol: string
endpoint: string
}
export interface SnsSubscriptionOverview {
arn: string | null
protocol: string
endpoint: string
status: 'Confirmed' | 'PendingConfirmation'
filterPolicy: string | null
}
export interface ApiGatewayLambdaRouteInput {
path: string
method: string
@@ -530,6 +553,27 @@ export interface EventBridgeLambdaTriggerOverview {
state: string | null
}
export interface EventBridgeRuleTarget {
id: string
arn: string
}
export interface EventBridgeRuleInput {
name: string
scheduleExpression?: string
eventPattern?: string
state?: 'ENABLED' | 'DISABLED'
description?: string
targets: EventBridgeRuleTarget[]
}
export interface EventBridgeRuleOverview extends EventBridgeRuleInput {
busName: string
busId: string
arn: string | null
state: 'ENABLED' | 'DISABLED'
}
export interface SqsLambdaEventSourceMappingInput {
functionName: string
queueArn: string
@@ -608,6 +652,10 @@ export interface CloudServiceAdapter {
createDatabaseSnapshot?(input: CreateDatabaseSnapshotInput): Promise<DatabaseSnapshot>
listDatabaseOrderableInstanceClasses?(engine?: string): Promise<string[]>
invoke?(id: string, payload: string): Promise<ServerlessInvokeResult>
publish?(id: string, input: PublishNotificationInput): Promise<PublishNotificationResult>
createSnsSubscription?(topicArn: string, input: SnsSubscriptionInput): Promise<SnsSubscriptionOverview>
listSnsSubscriptions?(topicArn: string): Promise<SnsSubscriptionOverview[]>
deleteSnsSubscription?(subscriptionArn: string): Promise<void>
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
@@ -618,6 +666,9 @@ export interface CloudServiceAdapter {
createEventBridgeLambdaTrigger?(input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview>
listEventBridgeLambdaTriggers?(functionName: string): Promise<EventBridgeLambdaTriggerOverview[]>
deleteEventBridgeLambdaTrigger?(functionName: string, triggerId: string): Promise<void>
listEventBridgeRules?(eventBusId: string): Promise<EventBridgeRuleOverview[]>
createEventBridgeRule?(eventBusId: string, input: EventBridgeRuleInput): Promise<EventBridgeRuleOverview>
deleteEventBridgeRule?(eventBusId: string, ruleName: string): Promise<void>
createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview>
listSqsLambdaEventSourceMappings?(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]>
deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise<void>
+3 -1
View File
@@ -36,6 +36,7 @@ import {AwsCloudFormationAdapter} from './adapter-aws/AwsCloudFormationAdapter'
import {AwsSecretsAdapter} from './adapter-aws/AwsSecretsAdapter'
import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter'
import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter'
import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks'
@@ -67,13 +68,14 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsKmsAdapter(clients.kms),
new AwsStepFunctionsAdapter(clients.sfn),
new AwsLoadBalancingAdapter(clients.elbv2),
new AwsEventBridgeAdapter(clients.eventbridge),
new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
new AwsIamAdapter(clients.iam),
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)),
new AwsCloudFormationAdapter(clients.cloudformation),
new AwsSecretsAdapter(clients.secretsManager),
new AwsSesAdapter(),
new AwsAppConfigAdapter(clients.appConfig),
new AwsSnsAdapter(clients.sns),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(),
+84
View File
@@ -17,6 +17,8 @@ import {awsKmsSchema} from '../cloud-spi/kmsSchema'
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
import type {
AppConfigConfigurationProfile,
AppConfigDeployment,
@@ -99,6 +101,88 @@ function appWithRoutes(
return app
}
describe('EventBridge rule routes', () => {
test('routes rule lifecycle requests through the Events adapter using the event bus resource id', async () => {
const calls: Array<{operation: string; args: unknown[]}> = []
const adapter = mockAdapter('aws', {
service: 'events',
schema: awsEventBridgeSchema,
listEventBridgeRules: async (...args) => {
calls.push({operation: 'list', args})
return []
},
createEventBridgeRule: async (...args) => {
calls.push({operation: 'create', args})
return {
name: 'hourly', busName: 'orders', busId: 'event-bus:orders', arn: null,
scheduleExpression: 'rate(1 hour)', state: 'ENABLED', targets: [],
}
},
deleteEventBridgeRule: async (...args) => {
calls.push({operation: 'delete', args})
},
})
const app = appWithRoutes([adapter])
const create = await app.request('/api/clouds/aws/services/events/resources/event-bus:orders/rules', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({
name: 'hourly', scheduleExpression: 'rate(1 hour)',
targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}],
}),
})
expect(create.status).toBe(201)
await app.request('/api/clouds/aws/services/events/resources/event-bus:orders/rules')
const remove = await app.request('/api/clouds/aws/services/events/resources/event-bus:orders/rules/hourly', {method: 'DELETE'})
expect(remove.status).toBe(200)
expect(calls).toEqual([
{operation: 'create', args: ['event-bus:orders', {name: 'hourly', scheduleExpression: 'rate(1 hour)', eventPattern: undefined, state: undefined, description: undefined, targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]}]},
{operation: 'list', args: ['event-bus:orders']},
{operation: 'delete', args: ['event-bus:orders', 'hourly']},
])
})
})
describe('SNS subscription routes', () => {
test('routes subscription lifecycle requests through the Notifications adapter', async () => {
const calls: Array<{operation: string; args: unknown[]}> = []
const adapter = mockAdapter('aws', {
service: 'notifications',
schema: awsNotificationsSchema,
createSnsSubscription: async (...args) => {
calls.push({operation: 'create', args})
return {arn: 'subscription-1', protocol: 'sqs', endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders', status: 'Confirmed', filterPolicy: null}
},
listSnsSubscriptions: async (...args) => {
calls.push({operation: 'list', args})
return []
},
deleteSnsSubscription: async (...args) => {
calls.push({operation: 'delete', args})
},
})
const app = appWithRoutes([adapter])
const create = await app.request('/api/clouds/aws/services/notifications/resources/orders/subscriptions', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({protocol: 'sqs', endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders'}),
})
expect(create.status).toBe(201)
await app.request('/api/clouds/aws/services/notifications/resources/orders/subscriptions')
const remove = await app.request('/api/clouds/aws/services/notifications/resources/orders/subscriptions/subscription-1', {method: 'DELETE'})
expect(remove.status).toBe(200)
expect(calls).toEqual([
{operation: 'create', args: ['orders', {protocol: 'sqs', endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders'}]},
{operation: 'list', args: ['orders']},
{operation: 'delete', args: ['subscription-1']},
])
})
})
const databaseSnapshot: DatabaseSnapshot = {
id: 'orders-db-snapshot-1',
name: 'orders-db-snapshot-1',
+97
View File
@@ -620,6 +620,42 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.get('/:cloud/services/events/resources/:id/rules', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listEventBridgeRules(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/events/resources/:id/rules', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const rule = await svc(c).createEventBridgeRule(cloud, c.req.param('id'), {
name: typeof input.name === 'string' ? input.name : '',
scheduleExpression: typeof input.scheduleExpression === 'string' ? input.scheduleExpression : undefined,
eventPattern: typeof input.eventPattern === 'string' ? input.eventPattern : undefined,
state: input.state === 'DISABLED' ? 'DISABLED' : input.state === 'ENABLED' ? 'ENABLED' : undefined,
description: typeof input.description === 'string' ? input.description : undefined,
targets: Array.isArray(input.targets) ? input.targets.flatMap((target) => {
if (!target || typeof target !== 'object' || Array.isArray(target)) return []
const value = target as {id?: unknown; arn?: unknown}
return typeof value.id === 'string' && typeof value.arn === 'string' ? [{id: value.id, arn: value.arn}] : []
}) : [],
})
return c.json(rule, 201)
})
})
app.delete('/:cloud/services/events/resources/:id/rules/:ruleName', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteEventBridgeRule(cloud, c.req.param('id'), c.req.param('ruleName'))
return c.json({ok: true})
})
})
app.get('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
@@ -867,6 +903,60 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.post('/:cloud/services/notifications/resources/:id/publish', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await jsonBody<{message?: unknown; subject?: unknown; messageAttributes?: unknown}>(c)
if (typeof body.message !== 'string' || !body.message) {
throw new ValidationError('message is required')
}
if (body.subject !== undefined && typeof body.subject !== 'string') {
throw new ValidationError('subject must be a string')
}
if (body.messageAttributes !== undefined && !isStringRecord(body.messageAttributes)) {
throw new ValidationError('messageAttributes must be an object of string values')
}
const result = await svc(c).publishNotification(cloud, c.req.param('id'), {
message: body.message,
...(body.subject ? {subject: body.subject} : {}),
...(body.messageAttributes ? {messageAttributes: body.messageAttributes} : {}),
})
return c.json(result)
})
})
app.get('/:cloud/services/notifications/resources/:id/subscriptions', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listSnsSubscriptions(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/notifications/resources/:id/subscriptions', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await jsonBody<{protocol?: unknown; endpoint?: unknown}>(c)
if (typeof body.protocol !== 'string' || !body.protocol.trim()) throw new ValidationError('protocol is required')
if (typeof body.endpoint !== 'string' || !body.endpoint.trim()) throw new ValidationError('endpoint is required')
const subscription = await svc(c).createSnsSubscription(cloud, c.req.param('id'), {
protocol: body.protocol,
endpoint: body.endpoint,
})
return c.json(subscription, 201)
})
})
app.delete('/:cloud/services/notifications/resources/:id/subscriptions/:subscriptionArn', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteSnsSubscription(cloud, c.req.param('subscriptionArn'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/kms/resources/:id/encrypt', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -984,6 +1074,13 @@ function encryptionContext(value: unknown): Record<string, string> | undefined {
return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record<string, string>
}
function isStringRecord(value: unknown): value is Record<string, string> {
return typeof value === 'object'
&& value !== null
&& !Array.isArray(value)
&& Object.values(value).every((item) => typeof item === 'string')
}
function stringHeaders(value: unknown): Record<string, string> {
if (value === undefined) return {}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
+42 -1
View File
@@ -12,6 +12,7 @@ import type {
Ec2IpPermission,
Ec2KeyPair,
Ec2KeyPairMaterial,
Ec2KeyPairType,
Ec2NatGateway,
Ec2RouteTable,
Ec2SecurityGroup,
@@ -75,7 +76,7 @@ const fakeService = {
getConsoleOutput: async (instanceId: string): Promise<Ec2ConsoleOutput> => ({instanceId, output: 'boot log'}),
listAmis: async (): Promise<Ec2Image[]> => [],
listKeyPairs: async (): Promise<Ec2KeyPair[]> => [],
createKeyPair: async (name: string): Promise<Ec2KeyPairMaterial> => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}),
createKeyPair: async (name: string, _keyType: Ec2KeyPairType = 'rsa'): Promise<Ec2KeyPairMaterial> => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}),
deleteKeyPair: async () => {},
listSecurityGroups: async (): Promise<Ec2SecurityGroup[]> => [],
createSecurityGroup: async () => ({groupId: 'sg-0a1b2c3d4e5f6a7b'}),
@@ -257,6 +258,46 @@ describe('POST /key-pairs', () => {
expect(body.keyName).toBe('my-key')
expect(body.keyMaterial).toBe('---PEM---')
})
test('accepts ed25519 key type', async () => {
let receivedKeyType: Ec2KeyPairType | undefined
const router = createEc2Router({...fakeService, createKeyPair: async (name: string, keyType: Ec2KeyPairType) => {
receivedKeyType = keyType
return {keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}
}})
const res = await router.request('/key-pairs', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({name: 'my-ed25519-key', keyType: 'ed25519'}),
})
expect(res.status).toBe(200)
expect(receivedKeyType).toBe('ed25519')
})
test('defaults key type to rsa', async () => {
let receivedKeyType: Ec2KeyPairType | undefined
const router = createEc2Router({...fakeService, createKeyPair: async (name: string, keyType: Ec2KeyPairType) => {
receivedKeyType = keyType
return {keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}
}})
const res = await router.request('/key-pairs', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({name: 'my-rsa-key'}),
})
expect(res.status).toBe(200)
expect(receivedKeyType).toBe('rsa')
})
test('rejects unsupported key types', async () => {
const res = await app.request('/key-pairs', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({name: 'my-key', keyType: 'dsa'}),
})
expect(res.status).toBe(400)
expect((await res.json() as {error: string}).error).toBe('keyType must be rsa or ed25519')
})
})
describe('GET /internet-gateways', () => {
+11 -2
View File
@@ -4,6 +4,7 @@ import type {
CreateAmiInput,
CreateNatGatewayInput,
CreateRouteInput,
Ec2KeyPairType,
Ec2Tag,
IpPermissionInput,
RunInstanceInput,
@@ -12,6 +13,11 @@ import type {
import {ec2Service} from '../services/ec2'
type Ec2Service = typeof ec2Service
type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType}
function isEc2KeyPairType(value: unknown): value is Ec2KeyPairType {
return value === 'rsa' || value === 'ed25519'
}
function handleError(c: Context, error: unknown) {
const msg = error instanceof Error ? error.message : 'Unknown error'
@@ -124,8 +130,11 @@ export function createEc2Router(svc: Ec2Service = ec2Service) {
app.post('/key-pairs', async (c) => {
try {
const {name} = await c.req.json<{name: string}>()
return c.json(await svc.createKeyPair(name))
const {name, keyType = 'rsa'} = await c.req.json<CreateKeyPairInput>()
if (!isEc2KeyPairType(keyType)) {
return c.json({error: 'keyType must be rsa or ed25519'}, 400)
}
return c.json(await svc.createKeyPair(name, keyType))
} catch (error) {
return handleError(c, error)
}
@@ -30,6 +30,8 @@ import type {
S3LambdaTriggerOverview,
EventBridgeLambdaTriggerInput,
EventBridgeLambdaTriggerOverview,
EventBridgeRuleInput,
EventBridgeRuleOverview,
SqsLambdaEventSourceMappingInput,
SqsLambdaEventSourceMappingOverview,
DatabaseSnapshot,
@@ -42,6 +44,10 @@ import type {
KmsEncryptInput,
KmsEncryptResult,
NoSqlItem,
PublishNotificationInput,
PublishNotificationResult,
SnsSubscriptionInput,
SnsSubscriptionOverview,
ResourceQuery,
ServerlessInvokeResult,
SqlConnectionInput,
@@ -310,6 +316,24 @@ export class CloudProxyService {
await adapter.deleteEventBridgeLambdaTrigger(functionName, triggerId)
}
async listEventBridgeRules(cloud: CloudProvider, eventBusId: string): Promise<EventBridgeRuleOverview[]> {
const adapter = this.requireAdapter(cloud, 'events')
if (!adapter.listEventBridgeRules) throw new NotSupportedError(`EventBridge rules are not supported for ${cloud}`)
return adapter.listEventBridgeRules(eventBusId)
}
async createEventBridgeRule(cloud: CloudProvider, eventBusId: string, input: EventBridgeRuleInput): Promise<EventBridgeRuleOverview> {
const adapter = this.requireAdapter(cloud, 'events')
if (!adapter.createEventBridgeRule) throw new NotSupportedError(`EventBridge rules are not supported for ${cloud}`)
return adapter.createEventBridgeRule(eventBusId, input)
}
async deleteEventBridgeRule(cloud: CloudProvider, eventBusId: string, ruleName: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'events')
if (!adapter.deleteEventBridgeRule) throw new NotSupportedError(`EventBridge rules are not supported for ${cloud}`)
await adapter.deleteEventBridgeRule(eventBusId, ruleName)
}
async createSqsLambdaEventSourceMapping(cloud: CloudProvider, input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.createSqsLambdaEventSourceMapping) throw new NotSupportedError(`SQS Lambda event source mappings are not supported for ${cloud}`)
@@ -360,6 +384,29 @@ export class CloudProxyService {
if (!adapter.invoke) throw new NotSupportedError(`${cloud}/${service} invoke is not supported`)
return adapter.invoke(id, payload)
}
async publishNotification(cloud: CloudProvider, topicArn: string, input: PublishNotificationInput): Promise<PublishNotificationResult> {
const adapter = this.requireAdapter(cloud, 'notifications')
if (!adapter.publish) throw new NotSupportedError(`Publishing notifications is not supported for ${cloud}`)
return adapter.publish(topicArn, input)
}
async createSnsSubscription(cloud: CloudProvider, topicArn: string, input: SnsSubscriptionInput): Promise<SnsSubscriptionOverview> {
const adapter = this.requireAdapter(cloud, 'notifications')
if (!adapter.createSnsSubscription) throw new NotSupportedError(`SNS subscriptions are not supported for ${cloud}`)
return adapter.createSnsSubscription(topicArn, input)
}
async listSnsSubscriptions(cloud: CloudProvider, topicArn: string): Promise<SnsSubscriptionOverview[]> {
const adapter = this.requireAdapter(cloud, 'notifications')
if (!adapter.listSnsSubscriptions) throw new NotSupportedError(`SNS subscriptions are not supported for ${cloud}`)
return adapter.listSnsSubscriptions(topicArn)
}
async deleteSnsSubscription(cloud: CloudProvider, subscriptionArn: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'notifications')
if (!adapter.deleteSnsSubscription) throw new NotSupportedError(`SNS subscriptions are not supported for ${cloud}`)
await adapter.deleteSnsSubscription(subscriptionArn)
}
async listObjects(cloud: CloudProvider, service: CloudServiceType, resourceId: string, prefix?: string): Promise<StorageObjectList> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.listObjects) throw new NotSupportedError(`Object listing is not supported for ${cloud}/${service}`)
+15 -2
View File
@@ -214,7 +214,7 @@ describe('ec2Service key pairs', () => {
expect(result).toEqual([])
})
test('createKeyPair sends CreateKeyPairCommand and returns material', async () => {
test('createKeyPair sends CreateKeyPairCommand with the requested key type and returns material', async () => {
const sent: unknown[] = []
const client: EC2Client = {
send: async (cmd: unknown) => {
@@ -222,12 +222,25 @@ describe('ec2Service key pairs', () => {
return {KeyPairId: 'kp-1', KeyName: 'my-key', KeyMaterial: '-----BEGIN RSA-----'}
},
} as unknown as EC2Client
const result = await createEc2Service(client).createKeyPair('my-key')
const result = await createEc2Service(client).createKeyPair('my-key', 'ed25519')
expect(sent[0]).toBeInstanceOf(CreateKeyPairCommand)
expect((sent[0] as CreateKeyPairCommand).input).toMatchObject({KeyName: 'my-key', KeyType: 'ed25519'})
expect(result.keyName).toBe('my-key')
expect(result.keyMaterial).toBe('-----BEGIN RSA-----')
})
test('createKeyPair defaults to rsa', async () => {
const sent: CreateKeyPairCommand[] = []
const client: EC2Client = {
send: async (cmd: CreateKeyPairCommand) => {
sent.push(cmd)
return {}
},
} as unknown as EC2Client
await createEc2Service(client).createKeyPair('my-key')
expect(sent[0].input.KeyType).toBe('rsa')
})
test('deleteKeyPair sends DeleteKeyPairCommand', async () => {
const sent: unknown[] = []
const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client
+4 -2
View File
@@ -142,6 +142,8 @@ export type Ec2KeyPairMaterial = {
keyMaterial: string
}
export type Ec2KeyPairType = 'rsa' | 'ed25519'
export type Ec2Vpc = {
vpcId: string
cidrBlock: string
@@ -588,8 +590,8 @@ export function createEc2Service(client: EC2Client = awsClients.ec2) {
}
},
async createKeyPair(name: string): Promise<Ec2KeyPairMaterial> {
const res = await client.send(new CreateKeyPairCommand({KeyName: name}))
async createKeyPair(name: string, keyType: Ec2KeyPairType = 'rsa'): Promise<Ec2KeyPairMaterial> {
const res = await client.send(new CreateKeyPairCommand({KeyName: name, KeyType: keyType}))
return {
keyPairId: res.KeyPairId ?? '',
keyName: res.KeyName ?? name,
+65 -2
View File
@@ -22,6 +22,10 @@ export const apiEndpointKeys = {
update: "clouds.services.resources.update",
delete: "clouds.services.resources.delete",
invoke: "clouds.services.resources.invoke",
publish: "clouds.services.resources.notifications.publish",
createSnsSubscription: "clouds.services.resources.notifications.subscription.create",
listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list",
deleteSnsSubscription: "clouds.services.resources.notifications.subscription.delete",
encrypt: "clouds.services.resources.encrypt",
decrypt: "clouds.services.resources.decrypt",
createApiGatewayRoute: "clouds.services.resources.apigateway.route.create",
@@ -33,7 +37,10 @@ export const apiEndpointKeys = {
deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete",
createEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.create",
listEventBridgeLambdaTriggers: "clouds.services.resources.serverless.eventbridge-triggers.list",
deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete",
deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete",
createEventBridgeRule: "clouds.services.resources.events.rule.create",
listEventBridgeRules: "clouds.services.resources.events.rules.list",
deleteEventBridgeRule: "clouds.services.resources.events.rule.delete",
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list",
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
@@ -297,7 +304,7 @@ export const endpointRegistry: EndpointRegistry = new Map([
},
],
[
apiEndpointKeys.clouds.resources.invoke,
apiEndpointKeys.clouds.resources.invoke,
{
path: "/clouds/:cloud/services/:service/resources/:id/invoke",
method: "POST",
@@ -312,6 +319,38 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "apigateway" },
},
],
[
apiEndpointKeys.clouds.resources.publish,
{
path: "/clouds/:cloud/services/notifications/resources/:id/publish",
method: "POST",
telemetry: { service: "notifications" },
},
],
[
apiEndpointKeys.clouds.resources.createSnsSubscription,
{
path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions",
method: "POST",
telemetry: { service: "notifications" },
},
],
[
apiEndpointKeys.clouds.resources.listSnsSubscriptions,
{
path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions",
method: "GET",
telemetry: { service: "notifications" },
},
],
[
apiEndpointKeys.clouds.resources.deleteSnsSubscription,
{
path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions/:subscriptionArn",
method: "DELETE",
telemetry: { service: "notifications" },
},
],
[
apiEndpointKeys.clouds.resources.listApiGatewayRoutes,
{
@@ -384,6 +423,30 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.createEventBridgeRule,
{
path: "/clouds/:cloud/services/events/resources/:id/rules",
method: "POST",
telemetry: { service: "events" },
},
],
[
apiEndpointKeys.clouds.resources.listEventBridgeRules,
{
path: "/clouds/:cloud/services/events/resources/:id/rules",
method: "GET",
telemetry: { service: "events" },
},
],
[
apiEndpointKeys.clouds.resources.deleteEventBridgeRule,
{
path: "/clouds/:cloud/services/events/resources/:id/rules/:ruleName",
method: "DELETE",
telemetry: { service: "events" },
},
],
[
apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping,
{
+5 -3
View File
@@ -77,6 +77,8 @@ export type Ec2KeyPairMaterial = {
keyMaterial: string;
};
export type Ec2KeyPairType = "rsa" | "ed25519";
export type Ec2Vpc = {
vpcId: string;
cidrBlock: string;
@@ -344,10 +346,10 @@ export async function listEc2KeyPairs(signal?: AbortSignal): Promise<Ec2KeyPair[
return res.data;
}
export async function createEc2KeyPair(name: string): Promise<Ec2KeyPairMaterial> {
const res = await apiClient.call<Ec2KeyPairMaterial>(
export async function createEc2KeyPair(name: string, keyType: Ec2KeyPairType = "rsa"): Promise<Ec2KeyPairMaterial> {
const res = await apiClient.call<Ec2KeyPairMaterial, {name: string; keyType: Ec2KeyPairType}>(
apiEndpointKeys.aws.ec2.keyPairs.create,
{ body: JSON.stringify({ name }), headers: { "content-type": "application/json" } },
{ body: { name, keyType } },
);
return res.data;
}
@@ -204,6 +204,16 @@ export interface ServerlessInvokeResult {
executionDuration?: number;
}
export interface PublishNotificationInput {
message: string;
subject?: string;
messageAttributes?: Record<string, string>;
}
export interface PublishNotificationResult {
messageId: string | null;
}
export interface CreateApiGatewayLambdaRouteInput {
path: string;
method: string;
@@ -268,6 +278,19 @@ export interface S3LambdaTriggerOverview {
suffix: string | null;
}
export interface CreateSnsSubscriptionInput {
protocol: string;
endpoint: string;
}
export interface SnsSubscriptionOverview {
arn: string | null;
protocol: string;
endpoint: string;
status: "Confirmed" | "PendingConfirmation";
filterPolicy: string | null;
}
export interface CreateEventBridgeLambdaTriggerInput {
ruleName: string;
scheduleExpression: string;
@@ -283,6 +306,27 @@ export interface EventBridgeLambdaTriggerOverview {
state: string | null;
}
export interface EventBridgeRuleTarget {
id: string;
arn: string;
}
export interface CreateEventBridgeRuleInput {
name: string;
scheduleExpression?: string;
eventPattern?: string;
state?: "ENABLED" | "DISABLED";
description?: string;
targets: EventBridgeRuleTarget[];
}
export interface EventBridgeRuleOverview extends CreateEventBridgeRuleInput {
busName: string;
busId: string;
arn: string | null;
state: "ENABLED" | "DISABLED";
}
export interface CreateSqsLambdaEventSourceMappingInput {
queueArn: string;
batchSize?: number;
@@ -393,6 +437,46 @@ export async function deleteS3LambdaTrigger(
);
}
export async function createSnsSubscription(
cloud: CloudProvider,
topicArn: string,
input: CreateSnsSubscriptionInput,
signal?: AbortSignal,
): Promise<SnsSubscriptionOverview> {
const res = await apiClient.call<SnsSubscriptionOverview, CreateSnsSubscriptionInput>(
apiEndpointKeys.clouds.resources.createSnsSubscription,
requestOptions(cloud, "notifications", {signal, body: input}),
{cloud, id: topicArn},
);
return res.data;
}
export async function listSnsSubscriptions(
cloud: CloudProvider,
topicArn: string,
signal?: AbortSignal,
): Promise<SnsSubscriptionOverview[]> {
const res = await apiClient.call<SnsSubscriptionOverview[]>(
apiEndpointKeys.clouds.resources.listSnsSubscriptions,
requestOptions(cloud, "notifications", {signal}),
{cloud, id: topicArn},
);
return res.data;
}
export async function deleteSnsSubscription(
cloud: CloudProvider,
topicArn: string,
subscriptionArn: string,
signal?: AbortSignal,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.deleteSnsSubscription,
requestOptions(cloud, "notifications", {signal}),
{cloud, id: topicArn, subscriptionArn},
);
}
export async function createEventBridgeLambdaTrigger(
cloud: CloudProvider,
functionName: string,
@@ -433,6 +517,46 @@ export async function deleteEventBridgeLambdaTrigger(
);
}
export async function createEventBridgeRule(
cloud: CloudProvider,
eventBusId: string,
input: CreateEventBridgeRuleInput,
signal?: AbortSignal,
): Promise<EventBridgeRuleOverview> {
const res = await apiClient.call<EventBridgeRuleOverview, CreateEventBridgeRuleInput>(
apiEndpointKeys.clouds.resources.createEventBridgeRule,
requestOptions(cloud, "events", {signal, body: input}),
{cloud, id: eventBusId},
);
return res.data;
}
export async function listEventBridgeRules(
cloud: CloudProvider,
eventBusId: string,
signal?: AbortSignal,
): Promise<EventBridgeRuleOverview[]> {
const res = await apiClient.call<EventBridgeRuleOverview[]>(
apiEndpointKeys.clouds.resources.listEventBridgeRules,
requestOptions(cloud, "events", {signal}),
{cloud, id: eventBusId},
);
return res.data;
}
export async function deleteEventBridgeRule(
cloud: CloudProvider,
eventBusId: string,
ruleName: string,
signal?: AbortSignal,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.deleteEventBridgeRule,
requestOptions(cloud, "events", {signal}),
{cloud, id: eventBusId, ruleName},
);
}
export async function createSqsLambdaEventSourceMapping(
cloud: CloudProvider,
functionName: string,
@@ -525,6 +649,20 @@ export async function invokeCloudResource(
return res.data;
}
export async function publishNotification(
cloud: CloudProvider,
topicArn: string,
input: PublishNotificationInput,
signal?: AbortSignal,
): Promise<PublishNotificationResult> {
const res = await apiClient.call<PublishNotificationResult, PublishNotificationInput>(
apiEndpointKeys.clouds.resources.publish,
requestOptions(cloud, "notifications", { signal, body: input }),
{ cloud, id: topicArn },
);
return res.data;
}
export async function encryptKmsResource(
cloud: CloudProvider,
id: string,
+259 -85
View File
@@ -1,4 +1,4 @@
import { type FormEvent, useState } from 'react'
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
AlertTriangle,
@@ -10,6 +10,7 @@ import {
RefreshCw,
RotateCcw,
Save,
Search,
Terminal,
Trash2,
X,
@@ -17,6 +18,7 @@ import {
import {
authorizeEc2SecurityGroupIngress,
createEc2Ami,
createEc2KeyPair,
createEc2SecurityGroup,
deregisterEc2Ami,
describeEc2Instance,
@@ -42,6 +44,10 @@ import {
import { createCloudResource } from '@/api/cloudProxyClient'
import type { CloudProvider } from '@/types/cloud'
import type { CloudResource } from '@/types/resource'
import amazonLinuxImage from '../../../../OS_img/amazon_Linux.png'
import debianImage from '../../../../OS_img/Debian_linux.png'
import redHatImage from '../../../../OS_img/red-hat-logo.png'
import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png'
// ─── Props ────────────────────────────────────────────────────────────────────
@@ -343,11 +349,16 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
const query = useQuery({
queryKey: ['ec2', 'instance', instanceId],
queryFn: ({ signal }) => describeEc2Instance(instanceId, signal),
refetchInterval: (query) => {
const state = query.state.data?.state
return state === 'pending' || state === 'stopping' || state === 'shutting-down' ? 1_500 : false
},
})
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['ec2', 'instance', instanceId] })
void qc.invalidateQueries({ queryKey: ['ec2', 'instances'] })
void qc.invalidateQueries({ queryKey: ['cloud-resources', 'aws', 'compute'] })
}
const startMut = useMutation({
@@ -678,6 +689,32 @@ export function ComputePanel({ cloud, resource, runtimeReachable }: ComputePanel
type InboundRule = { id: string; protocol: string; portInput: string; cidr: string }
type LaunchStep = 1 | 2 | 3 | 4
type AmiFamily = 'amazon-linux' | 'fedora' | 'ubuntu' | 'red-hat' | 'debian' | 'suse' | 'other'
type AmiClassification = { family: AmiFamily; label: string; mark: string; color: string }
function classifyAmi(ami: Ec2Image): AmiClassification {
const metadata = `${ami.name} ${ami.description ?? ''} ${ami.platform ?? ''}`.toLowerCase()
if (metadata.includes('ubuntu')) return { family: 'ubuntu', label: 'Ubuntu', mark: 'U', color: '#e95420' }
if (metadata.includes('amazon linux') || metadata.includes('amzn')) return { family: 'amazon-linux', label: 'Amazon Linux', mark: 'A', color: '#ff9900' }
if (metadata.includes('fedora')) return { family: 'fedora', label: 'Fedora', mark: 'F', color: '#51a2da' }
if (metadata.includes('red hat') || metadata.includes('rhel')) return { family: 'red-hat', label: 'Red Hat', mark: 'R', color: '#ee0000' }
if (metadata.includes('debian')) return { family: 'debian', label: 'Debian', mark: 'D', color: '#a80030' }
if (metadata.includes('suse')) return { family: 'suse', label: 'SUSE', mark: 'S', color: '#30ba78' }
return { family: 'other', label: ami.platform || 'Other', mark: '?', color: 'var(--text-2)' }
}
const QUICK_START_AMIS: Array<{ family: Exclude<AmiFamily, 'other'>; label: string; image: string; preferredImageId?: string }> = [
{ family: 'amazon-linux', label: 'Amazon Linux 2023', image: amazonLinuxImage, preferredImageId: 'ami-0abcdef1234567891' },
{ family: 'fedora', label: 'Fedora', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2351a2da"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="34" font-weight="bold" fill="white"%3EF%3C/text%3E%3C/svg%3E' },
{ family: 'ubuntu', label: 'Ubuntu', image: ubuntuImage },
{ family: 'suse', label: 'openSUSE', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2330ba78"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="30" font-weight="bold" fill="white"%3ES%3C/text%3E%3C/svg%3E' },
{ family: 'red-hat', label: 'Red Hat', image: redHatImage },
{ family: 'debian', label: 'Debian', image: debianImage },
]
const SG_PRESETS: Array<{ label: string; protocol: string; portInput: string; cidr: string }> = [
{ label: 'SSH', protocol: 'tcp', portInput: '22', cidr: '0.0.0.0/0' },
{ label: 'HTTP', protocol: 'tcp', portInput: '80', cidr: '0.0.0.0/0' },
@@ -714,18 +751,23 @@ export function LaunchInstanceForm({
onCancel: () => void
}) {
const qc = useQueryClient()
// Instance fields
const [step, setStep] = useState<LaunchStep>(1)
const [stepError, setStepError] = useState('')
const [name, setName] = useState('')
const [imageId, setImageId] = useState(selectedResource?.type === 'image' ? selectedResource.id : '')
const [architectureFilter, setArchitectureFilter] = useState('')
const [amiSearch, setAmiSearch] = useState('')
const [showAmiBrowser, setShowAmiBrowser] = useState(false)
const [instanceType, setInstanceType] = useState('t3.micro')
// Networking fields
const [keyName, setKeyName] = useState('')
const [keyPairMode, setKeyPairMode] = useState<'existing' | 'new'>('existing')
const [newKeyName, setNewKeyName] = useState('')
const [newKeyType, setNewKeyType] = useState<'rsa' | 'ed25519'>('rsa')
const [createdKeyMaterial, setCreatedKeyMaterial] = useState<{ name: string; material: string }>()
const [rootVolumeSize, setRootVolumeSize] = useState('20')
const [rootVolumeType, setRootVolumeType] = useState('gp3')
const [subnetId, setSubnetId] = useState('')
const [selectedSgs, setSelectedSgs] = useState<string[]>([])
// Inline SG creation
const [showCreateSg, setShowCreateSg] = useState(false)
const [newSgName, setNewSgName] = useState('')
const [newSgDesc, setNewSgDesc] = useState('')
@@ -756,6 +798,15 @@ export function LaunchInstanceForm({
},
})
const createKeyMut = useMutation({
mutationFn: () => createEc2KeyPair(newKeyName.trim(), newKeyType),
onSuccess: (keyPair) => {
setKeyName(keyPair.keyName)
setCreatedKeyMaterial({ name: keyPair.keyName, material: keyPair.keyMaterial })
void qc.invalidateQueries({ queryKey: ['ec2', 'key-pairs'] })
},
})
const createSgMut = useMutation({
mutationFn: async (vars: {name: string; desc: string; vpc: string; rules: InboundRule[]}) => {
const { groupId } = await createEc2SecurityGroup(vars.name, vars.desc || vars.name, vars.vpc || undefined)
@@ -793,8 +844,7 @@ export function LaunchInstanceForm({
setSelectedSgs((prev) => checked ? [...prev, groupId] : prev.filter((id) => id !== groupId))
}
function submit(e: FormEvent) {
e.preventDefault()
function launch() {
createMut.mutate({
name,
imageId,
@@ -802,9 +852,34 @@ export function LaunchInstanceForm({
keyName: keyName || undefined,
subnetId: subnetId || undefined,
securityGroupIds: selectedSgs.length ? selectedSgs.join(',') : undefined,
rootVolumeSize,
rootVolumeType,
})
}
function nextStep() {
if (step === 1 && (!name.trim() || !imageId)) {
setStepError('Enter an instance name and select an AMI to continue.')
return
}
if (step === 2 && keyPairMode === 'new' && !keyName) {
setStepError('Create the new key pair before continuing, or choose an existing key pair.')
return
}
setStepError('')
setStep((current) => Math.min(4, current + 1) as LaunchStep)
}
function downloadPrivateKey() {
if (!createdKeyMaterial) return
const href = URL.createObjectURL(new Blob([createdKeyMaterial.material], { type: 'application/x-pem-file' }))
const link = document.createElement('a')
link.href = href
link.download = `${createdKeyMaterial.name}.pem`
link.click()
URL.revokeObjectURL(href)
}
const vpcs = vpcsQ.data ?? []
const subnets = subsQ.data ?? []
const groupByVpc = vpcs.length > 2
@@ -814,75 +889,148 @@ export function LaunchInstanceForm({
return tag ? `${tag} (${vpc.vpcId})` : vpc.vpcId
}
const amis = amisQ.data ?? []
const architectures = [...new Set(amis.map((ami) => ami.architecture).filter((architecture): architecture is string => Boolean(architecture)))].sort()
const filteredAmis = amis.filter((ami) => !architectureFilter || ami.architecture === architectureFilter)
const quickStartAmis = QUICK_START_AMIS.map((catalogAmi) => ({
...catalogAmi,
ami: filteredAmis.find((candidate) => candidate.imageId === catalogAmi.preferredImageId)
?? filteredAmis.find((candidate) => classifyAmi(candidate).family === catalogAmi.family),
}))
const quickStartImageIds = new Set(quickStartAmis.flatMap((catalogAmi) => catalogAmi.ami ? [catalogAmi.ami.imageId] : []))
const normalizedAmiSearch = amiSearch.trim().toLowerCase()
const visibleQuickStartAmis = quickStartAmis.filter((catalogAmi) => {
const searchable = `${catalogAmi.label} ${catalogAmi.ami?.imageId ?? ''} ${catalogAmi.ami?.name ?? ''} ${catalogAmi.ami?.description ?? ''}`.toLowerCase()
return !normalizedAmiSearch || searchable.includes(normalizedAmiSearch)
})
const browsableAmis = filteredAmis.filter((ami) => {
const searchable = `${ami.imageId} ${ami.name} ${ami.description ?? ''} ${classifyAmi(ami).label}`.toLowerCase()
return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch))
})
const selectedAmi = amis.find((ami) => ami.imageId === imageId)
const selectedSubnet = subnets.find((subnet) => subnet.subnetId === subnetId)
const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId))
const steps: Array<{ number: LaunchStep; label: string }> = [
{ number: 1, label: 'Name and AMI' },
{ number: 2, label: 'Instance and key pair' },
{ number: 3, label: 'Network and security' },
{ number: 4, label: 'Storage and review' },
]
return (
<form className="launch-form" onSubmit={submit}>
{/* ── Required ──────────────────────────────────────────── */}
<div className="launch-form-group">Required</div>
<form className="launch-form" aria-label="Launch instance wizard" onSubmit={(event) => { event.preventDefault(); if (step === 4) launch() }}>
<div className="sns-tabs" aria-label="Launch steps" style={{ gridColumn: '1 / -1', overflowX: 'auto' }}>
{steps.map((item) => (
<button key={item.number} type="button" className={`sns-tab${step === item.number ? ' active' : ''}`} onClick={() => item.number < step && setStep(item.number)} disabled={item.number > step}>
{item.number}. {item.label}
</button>
))}
</div>
<label className="launch-field launch-field--span">
<span>Instance Name <em className="field-required">*</em></span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g. my-web-server"/>
</label>
{step === 1 && <>
<div className="launch-form-group">Name and AMI</div>
<label className="launch-field launch-field--span">
<span>Instance name <em className="field-required">*</em></span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. my-web-server" />
</label>
<label className="launch-field">
<span>Architecture</span>
<select className="input" value={architectureFilter} onChange={(e) => { setArchitectureFilter(e.target.value); setImageId('') }}>
<option value="">All architectures</option>
{architectures.map((architecture) => <option key={architecture} value={architecture}>{architecture}</option>)}
</select>
</label>
<div className="launch-field launch-field--span">
<span>Amazon Machine Image <em className="field-required">*</em></span>
<div className="ami-catalog">
<div className="ami-search">
<Search size={15} aria-hidden="true" />
<input className="input" value={amiSearch} onChange={(e) => { setAmiSearch(e.target.value); setShowAmiBrowser(Boolean(e.target.value)) }} placeholder="Search AMIs" aria-label="Search available AMIs" />
</div>
{amisQ.isLoading && <span className="muted">Loading AMIs…</span>}
{!amisQ.isLoading && <>
<div className="ami-catalog-heading">Quick Start</div>
<div className="ami-quick-start-grid">
{visibleQuickStartAmis.map((catalogAmi) => (
<button
key={catalogAmi.family}
type="button"
className={`ami-quick-start-tile${imageId === catalogAmi.ami?.imageId ? ' selected' : ''}${catalogAmi.ami ? '' : ' unavailable'}`}
disabled={!catalogAmi.ami}
onClick={() => { if (catalogAmi.ami) { setImageId(catalogAmi.ami.imageId); setArchitectureFilter(catalogAmi.ami.architecture ?? '') } }}
title={catalogAmi.ami ? catalogAmi.ami.name || catalogAmi.ami.imageId : `No local ${catalogAmi.label} AMI is available`}
>
<img src={catalogAmi.image} alt="" />
<span>{catalogAmi.label}</span>
<small>{catalogAmi.ami?.architecture || 'No local AMI'}</small>
</button>
))}
</div>
</>}
{!amisQ.isLoading && amis.length === 0 && <span className="muted">No AMIs are available.</span>}
{!amisQ.isLoading && amis.length > 0 && <>
<button type="button" className="ami-browser-toggle" onClick={() => setShowAmiBrowser((visible) => !visible)}>
{showAmiBrowser ? 'Hide available AMIs' : 'Browse available AMIs'}
</button>
{showAmiBrowser && <div className="ami-browser-list">
{browsableAmis.map((ami) => {
const distro = classifyAmi(ami)
return <label key={ami.imageId} className="ami-browser-item">
<input type="radio" name="ami" checked={imageId === ami.imageId} onChange={() => { setImageId(ami.imageId); setArchitectureFilter(ami.architecture ?? '') }} />
<span><b style={{ background: distro.color }}>{distro.mark}</b>{ami.name || ami.imageId}</span>
<code>{ami.imageId} · {distro.label}</code>
</label>
})}
{browsableAmis.length === 0 && <span className="muted">No additional AMIs match this search and architecture.</span>}
</div>}
</>}
</div>
</div>
</>}
<label className="launch-field">
<span>AMI <em className="field-required">*</em></span>
<select className="input" value={imageId} onChange={(e) => setImageId(e.target.value)} required>
<option value="">Select an AMI…</option>
{(amisQ.data ?? []).map((ami: Ec2Image) => (
<option key={ami.imageId} value={ami.imageId}>{ami.name || ami.imageId}</option>
))}
</select>
</label>
{step === 2 && <>
<div className="launch-form-group">Instance type and key pair</div>
<label className="launch-field">
<span>Instance type <em className="field-required">*</em></span>
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)}>
{INSTANCE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</label>
<div className="launch-field launch-field--span">
<span>Key pair</span>
<div className="field-row" style={{ marginTop: 6 }}>
<label><input type="radio" checked={keyPairMode === 'existing'} onChange={() => setKeyPairMode('existing')} /> Choose existing</label>
<label><input type="radio" checked={keyPairMode === 'new'} onChange={() => { setKeyPairMode('new'); setKeyName('') }} /> Create new</label>
</div>
{keyPairMode === 'existing' ? (
<select className="input" style={{ marginTop: 8 }} value={keyName} onChange={(e) => setKeyName(e.target.value)}>
<option value="">No key pair</option>
{(keysQ.data ?? []).map((keyPair: Ec2KeyPair) => <option key={keyPair.keyPairId} value={keyPair.keyName}>{keyPair.keyName}</option>)}
</select>
) : <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<input className="input" value={newKeyName} onChange={(e) => { setNewKeyName(e.target.value); setKeyName('') }} placeholder="Key pair name" />
<select className="input" value={newKeyType} onChange={(e) => setNewKeyType(e.target.value as 'rsa' | 'ed25519')}><option value="rsa">RSA</option><option value="ed25519">ED25519</option></select>
<button type="button" className="button" disabled={!newKeyName.trim() || createKeyMut.isPending} onClick={() => createKeyMut.mutate()}>{createKeyMut.isPending ? 'Creating…' : 'Create key pair'}</button>
{createdKeyMaterial && <button type="button" className="button primary" onClick={downloadPrivateKey}>Download {createdKeyMaterial.name}.pem</button>}
</div>}
{createKeyMut.error instanceof Error && <p className="form-error" style={{ marginTop: 8 }}>{createKeyMut.error.message}</p>}
{createdKeyMaterial && <p className="muted" style={{ margin: '8px 0 0' }}>The private key is available only from this creation response. Download it before leaving this wizard.</p>}
</div>
</>}
<label className="launch-field">
<span>Instance Type <em className="field-required">*</em></span>
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)} required>
{INSTANCE_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
{/* ── Networking — optional ─────────────────────────────── */}
<div className="launch-form-group">Networking — optional</div>
<label className="launch-field">
<span>Key Pair</span>
<select className="input" value={keyName} onChange={(e) => setKeyName(e.target.value)}>
<option value="">None</option>
{(keysQ.data ?? []).map((kp: Ec2KeyPair) => (
<option key={kp.keyPairId} value={kp.keyName}>{kp.keyName}</option>
))}
</select>
</label>
<label className="launch-field">
<span>Subnet</span>
<select className="input" value={subnetId} onChange={(e) => setSubnetId(e.target.value)}>
<option value="">Default</option>
{groupByVpc
? vpcs.map((vpc: Ec2Vpc) => {
const vpcSubnets = subnets.filter((s: Ec2Subnet) => s.vpcId === vpc.vpcId)
if (!vpcSubnets.length) return null
return (
<optgroup key={vpc.vpcId} label={vpcLabel(vpc)}>
{vpcSubnets.map((s: Ec2Subnet) => (
<option key={s.subnetId} value={s.subnetId}>
{s.availabilityZone} — {s.cidrBlock}
</option>
))}
</optgroup>
)
})
: subnets.map((s: Ec2Subnet) => (
<option key={s.subnetId} value={s.subnetId}>
{s.availabilityZone} — {s.cidrBlock}
</option>
))
}
</select>
</label>
{/* ── Security Groups ───────────────────────────────────── */}
<div className="launch-field launch-field--span">
{step === 3 && <>
<div className="launch-form-group">Network and security</div>
<label className="launch-field launch-field--span">
<span>Subnet</span>
<select className="input" value={subnetId} onChange={(e) => setSubnetId(e.target.value)}>
<option value="">Default subnet selection</option>
{groupByVpc ? vpcs.map((vpc: Ec2Vpc) => {
const vpcSubnets = subnets.filter((subnet: Ec2Subnet) => subnet.vpcId === vpc.vpcId)
return vpcSubnets.length ? <optgroup key={vpc.vpcId} label={vpcLabel(vpc)}>{vpcSubnets.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.availabilityZone} · {subnet.cidrBlock}</option>)}</optgroup> : null
}) : subnets.map((subnet: Ec2Subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.availabilityZone} · {subnet.cidrBlock}</option>)}
</select>
</label>
<div className="launch-field launch-field--span">
<div className="sg-header">
<span>Security Groups</span>
<button
@@ -934,7 +1082,6 @@ export function LaunchInstanceForm({
</select>
</label>
{/* Inbound Rules */}
<div className="sg-rules-section">
<div className="sg-rules-header">
<span className="sg-rules-label">Inbound Rules</span>
@@ -1008,18 +1155,45 @@ export function LaunchInstanceForm({
</div>
</div>
)}
</div>
</div>
</>}
{createMut.error instanceof Error && (
<div className="form-error launch-field--span">{createMut.error.message}</div>
)}
{step === 4 && <>
<div className="launch-form-group">Storage and review</div>
<label className="launch-field">
<span>Root volume size (GiB)</span>
<input className="input" type="number" min="1" value={rootVolumeSize} onChange={(event) => setRootVolumeSize(event.target.value)} />
<small>Creates the root EBS volume at this size.</small>
</label>
<label className="launch-field">
<span>Root volume type</span>
<select className="input" value={rootVolumeType} onChange={(event) => setRootVolumeType(event.target.value)}>
<option value="gp3">General Purpose SSD (gp3)</option>
<option value="gp2">General Purpose SSD (gp2)</option>
<option value="io1">Provisioned IOPS SSD (io1)</option>
<option value="st1">Throughput Optimized HDD (st1)</option>
<option value="sc1">Cold HDD (sc1)</option>
</select>
</label>
<div className="launch-field launch-field--span">
<span>Review: fields sent to RunInstances</span>
<div className="meta-grid" style={{ marginTop: 8 }}>
<div className="meta-row"><span className="meta-label">Name</span><span className="meta-value">{name}</span></div>
<div className="meta-row"><span className="meta-label">AMI</span><span className="meta-value">{selectedAmi?.name || imageId} ({imageId})</span></div>
<div className="meta-row"><span className="meta-label">Instance type</span><span className="meta-value">{instanceType}</span></div>
<div className="meta-row"><span className="meta-label">Key pair</span><span className="meta-value">{keyName || 'Not sent'}</span></div>
<div className="meta-row"><span className="meta-label">Subnet</span><span className="meta-value">{selectedSubnet ? `${selectedSubnet.availabilityZone} (${subnetId})` : 'Not sent'}</span></div>
<div className="meta-row"><span className="meta-label">Security groups</span><span className="meta-value">{selectedGroups.length ? selectedGroups.map((group) => group.groupName || group.groupId).join(', ') : 'Not sent'}</span></div>
<div className="meta-row"><span className="meta-label">Root volume</span><span className="meta-value">{rootVolumeSize} GiB {rootVolumeType}, delete on termination</span></div>
</div>
</div>
</>}
<div className="launch-form-actions">
<button type="button" className="button" onClick={onCancel}>Cancel</button>
<button type="submit" className="button primary" disabled={createMut.isPending}>
<Plus size={14}/>
{createMut.isPending ? 'Launching…' : 'Launch instance'}
</button>
{stepError && <div className="form-error launch-field--span">{stepError}</div>}
{createMut.error instanceof Error && <div className="form-error launch-field--span">{createMut.error.message}</div>}
<div className="launch-form-actions" style={{ gridColumn: '1 / -1' }}>
<button type="button" className="button" onClick={step === 1 ? onCancel : () => { setStep((current) => (current - 1) as LaunchStep); setStepError('') }}>{step === 1 ? 'Cancel' : 'Back'}</button>
{step < 4 ? <button type="button" className="button primary" onClick={nextStep}>Next</button> : <button type="button" className="button primary" disabled={createMut.isPending} onClick={launch}><Plus size={14}/>{createMut.isPending ? 'Launching…' : 'Launch instance'}</button>}
</div>
</form>
)
@@ -48,9 +48,11 @@ import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
import { EventBridgeRulePanel } from "@/components/EventBridgeRulePanel";
import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHistoryPanel";
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
import { bucketDeleteWarning } from "@/lib/bucketDeletion";
import { SnsTopicPanel } from "@/components/SnsTopicPanel";
interface DynamicResourceViewProps {
cloud: CloudProvider;
@@ -87,6 +89,12 @@ export function DynamicResourceView({
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
const [deleteWarning, setDeleteWarning] = useState<string | null>(null);
useEffect(() => {
if (cloud === "aws" && service === "compute") {
setCreateOpen(true);
}
}, [cloud, service]);
const handleDatabaseTabChange = (tab: "instances" | "snapshots") => {
setDatabaseTab(tab);
@@ -766,7 +774,7 @@ export function DynamicResourceView({
)}
</div>
)}
{renderResourceSurface({
{!(cloud === "aws" && service === "compute" && createOpen) && renderResourceSurface({
schema,
resources,
selectedId: activeSelected?.id,
@@ -872,6 +880,13 @@ export function DynamicResourceView({
runtimeReachable={canUseRuntime}
/>
)}
{service === "events" && (
<EventBridgeRulePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
)}
{service === "configuration" && cloud === "aws" && (
<AppConfigPanel
cloud={cloud}
@@ -886,6 +901,13 @@ export function DynamicResourceView({
runtimeReachable={canUseRuntime}
/>
)}
{service === "notifications" && (
<SnsTopicPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
)}
{editingResource && schema.updateFields && (
<div
className="modal-overlay"
@@ -0,0 +1,81 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {expect, test, vi} from 'vitest'
import {EventBridgeRulePanel} from './EventBridgeRulePanel'
import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
createEventBridgeRule: vi.fn(),
deleteEventBridgeRule: vi.fn(),
listCloudResources: vi.fn(),
listEventBridgeRules: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => apiMocks)
const eventBus: CloudResource = {id: 'event-bus:orders', name: 'orders', cloud: 'aws', service: 'events', type: 'event-bus', region: null, createdAt: null, metadata: {}}
const lambda: CloudResource = {id: 'worker', name: 'worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null, metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}}
const topic: CloudResource = {id: 'orders-topic', name: 'orders-topic', cloud: 'aws', service: 'notifications', type: 'topic', region: null, createdAt: null, metadata: {arn: 'arn:aws:sns:us-east-1:000000000000:orders-topic'}}
function renderPanel() {
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(<QueryClientProvider client={queryClient}><EventBridgeRulePanel cloud="aws" resource={eventBus} runtimeReachable/></QueryClientProvider>)
}
test('creates a scheduled EventBridge rule with the selected resource ARN', async () => {
apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'serverless' ? [lambda] : []))
apiMocks.listEventBridgeRules.mockResolvedValue([])
apiMocks.createEventBridgeRule.mockResolvedValue({name: 'hourly-report'})
const user = userEvent.setup()
renderPanel()
await screen.findByRole('option', {name: 'worker'})
await user.type(screen.getByLabelText(/rule name/i), 'hourly-report')
await user.selectOptions(screen.getByLabelText(/target resource/i), String(lambda.metadata.arn))
expect(screen.getByText(/No Lambda, SQS queue, SNS topic, or Step Functions resources will be created/i)).toBeVisible()
await user.click(screen.getByRole('button', {name: 'Create rule'}))
await waitFor(() => expect(apiMocks.createEventBridgeRule).toHaveBeenCalledWith('aws', 'event-bus:orders', {
name: 'hourly-report', scheduleExpression: 'rate(5 minutes)', state: 'ENABLED', targets: [{id: 'worker', arn: lambda.metadata.arn}],
}))
})
test('creates an EventBridge rule with a selected SNS topic target', async () => {
apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'notifications' ? [topic] : []))
apiMocks.listEventBridgeRules.mockResolvedValue([])
apiMocks.createEventBridgeRule.mockResolvedValue({name: 'topic-notifications'})
const user = userEvent.setup()
renderPanel()
await screen.findByRole('option', {name: 'orders-topic'})
await user.selectOptions(screen.getByLabelText(/target service/i), 'sns')
await user.type(screen.getByLabelText(/rule name/i), 'topic-notifications')
await user.selectOptions(screen.getByLabelText(/target resource/i), String(topic.metadata.arn))
await user.click(screen.getByRole('button', {name: 'Create rule'}))
await waitFor(() => expect(apiMocks.createEventBridgeRule).toHaveBeenCalledWith('aws', eventBus.id, {
name: 'topic-notifications', scheduleExpression: 'rate(5 minutes)', state: 'ENABLED', targets: [{id: 'orders-topic', arn: topic.metadata.arn}],
}))
})
test('creates an event-pattern rule and confirms before deletion', async () => {
apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'serverless' ? [lambda] : []))
apiMocks.listEventBridgeRules.mockResolvedValue([{name: 'billing-events', busName: 'orders', busId: eventBus.id, arn: null, eventPattern: '{"source":["billing"]}', state: 'DISABLED', targets: [{id: 'worker', arn: String(lambda.metadata.arn)}]}])
apiMocks.createEventBridgeRule.mockResolvedValue({name: 'billing-events'})
apiMocks.deleteEventBridgeRule.mockResolvedValue(undefined)
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByLabelText(/mode/i))
await user.selectOptions(screen.getByLabelText(/mode/i), 'event-pattern')
await user.type(screen.getByLabelText(/rule name/i), 'billing-events')
await user.selectOptions(screen.getByLabelText(/target resource/i), String(lambda.metadata.arn))
await user.click(screen.getByRole('button', {name: 'Create rule'}))
await waitFor(() => expect(apiMocks.createEventBridgeRule).toHaveBeenCalledWith('aws', eventBus.id, expect.objectContaining({eventPattern: '{\n "source": ["orders"]\n}'})))
await user.click(await screen.findByRole('button', {name: 'Delete rule billing-events'}))
expect(screen.getByText('Delete rule?')).toBeVisible()
await user.click(screen.getByRole('button', {name: 'Delete'}))
await waitFor(() => expect(apiMocks.deleteEventBridgeRule).toHaveBeenCalledWith('aws', eventBus.id, 'billing-events'))
})
@@ -0,0 +1,170 @@
import {useEffect, useState} from 'react'
import {CalendarClock, Loader2, Plus, Radio, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {createEventBridgeRule, deleteEventBridgeRule, listCloudResources, listEventBridgeRules, type EventBridgeRuleOverview} from '@/api/cloudProxyClient'
import type {CloudProvider, CloudServiceType} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
interface EventBridgeRulePanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
}
type TargetService = 'lambda' | 'sqs' | 'sns' | 'step-functions'
const targetServiceDetails: Record<TargetService, {label: string; service: CloudServiceType; matches: (resource: CloudResource) => boolean}> = {
lambda: {label: 'Lambda', service: 'serverless', matches: (resource) => resource.type === 'lambda'},
sqs: {label: 'SQS', service: 'messaging', matches: (resource) => resource.type === 'queue' || resource.type === 'fifo-queue'},
sns: {label: 'SNS', service: 'notifications', matches: (resource) => resource.type === 'topic'},
'step-functions': {label: 'Step Functions', service: 'workflows', matches: (resource) => resource.type === 'state-machine'},
}
export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventBridgeRulePanelProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [mode, setMode] = useState<'schedule' | 'event-pattern'>('schedule')
const [scheduleExpression, setScheduleExpression] = useState('rate(5 minutes)')
const [eventPattern, setEventPattern] = useState('{\n "source": ["orders"]\n}')
const [enabled, setEnabled] = useState(true)
const [targetService, setTargetService] = useState<TargetService>('lambda')
const [targetArn, setTargetArn] = useState('')
const [deleteTarget, setDeleteTarget] = useState<EventBridgeRuleOverview | null>(null)
const queryClient = useQueryClient()
const targetQueries = {
serverless: useQuery({
queryKey: ['cloud-resources', cloud, 'serverless'],
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
}),
messaging: useQuery({
queryKey: ['cloud-resources', cloud, 'messaging'],
queryFn: ({signal}) => listCloudResources(cloud, 'messaging', undefined, signal),
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
}),
notifications: useQuery({
queryKey: ['cloud-resources', cloud, 'notifications'],
queryFn: ({signal}) => listCloudResources(cloud, 'notifications', undefined, signal),
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
}),
workflows: useQuery({
queryKey: ['cloud-resources', cloud, 'workflows'],
queryFn: ({signal}) => listCloudResources(cloud, 'workflows', undefined, signal),
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
}),
}
const rulesQuery = useQuery({
queryKey: ['eventbridge-rules', cloud, resource?.id],
queryFn: ({signal}) => listEventBridgeRules(cloud, resource!.id, signal),
enabled: cloud === 'aws' && resource?.service === 'events' && resource.type === 'event-bus' && runtimeReachable,
})
useEffect(() => {
setName('')
setDescription('')
setMode('schedule')
setScheduleExpression('rate(5 minutes)')
setEventPattern('{\n "source": ["orders"]\n}')
setEnabled(true)
setTargetService('lambda')
setTargetArn('')
setDeleteTarget(null)
}, [resource?.id])
const resourcesByTargetService = (Object.keys(targetServiceDetails) as TargetService[]).reduce<Record<TargetService, CloudResource[]>>((resources, service) => {
const details = targetServiceDetails[service]
const sourceResources = details.service === 'serverless'
? targetQueries.serverless.data ?? []
: details.service === 'messaging'
? targetQueries.messaging.data ?? []
: details.service === 'notifications'
? targetQueries.notifications.data ?? []
: targetQueries.workflows.data ?? []
resources[service] = sourceResources.filter((candidate) => details.matches(candidate) && typeof candidate.metadata.arn === 'string' && candidate.metadata.arn.length > 0)
return resources
}, {lambda: [], sqs: [], sns: [], 'step-functions': []})
const availableTargetServices = (Object.keys(targetServiceDetails) as TargetService[]).filter((service) => resourcesByTargetService[service].length > 0)
const targetResources = resourcesByTargetService[targetService]
const selectedTarget = targetResources.find((candidate) => candidate.metadata.arn === targetArn)
const patternValid = mode !== 'event-pattern' || isJsonObject(eventPattern)
useEffect(() => {
if (availableTargetServices.length > 0 && !availableTargetServices.includes(targetService)) {
setTargetService(availableTargetServices[0])
setTargetArn('')
}
}, [availableTargetServices, targetService])
const createMutation = useMutation({
mutationFn: () => createEventBridgeRule(cloud, resource!.id, {
name: name.trim(),
...(mode === 'schedule' ? {scheduleExpression: scheduleExpression.trim()} : {eventPattern: eventPattern.trim()}),
state: enabled ? 'ENABLED' : 'DISABLED',
...(description.trim() ? {description: description.trim()} : {}),
targets: [{id: targetId(selectedTarget!), arn: targetArn}],
}),
onSuccess: () => {
setName('')
setDescription('')
setTargetArn('')
void queryClient.invalidateQueries({queryKey: ['eventbridge-rules', cloud, resource?.id]})
},
})
const canCreate = Boolean(name.trim() && targetArn && (mode === 'schedule' ? scheduleExpression.trim() : eventPattern.trim()) && patternValid) && !createMutation.isPending
const deleteMutation = useMutation({
mutationFn: (rule: EventBridgeRuleOverview) => deleteEventBridgeRule(cloud, resource!.id, rule.name),
onSuccess: () => {
setDeleteTarget(null)
void queryClient.invalidateQueries({queryKey: ['eventbridge-rules', cloud, resource?.id]})
},
})
if (!resource || resource.service !== 'events' || resource.type !== 'event-bus') return null
return <section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">EventBridge Rules</p>
<h3><Radio size={15}/> 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>
<div className="resource-create-inline">
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
event.preventDefault()
if (canCreate) createMutation.mutate()
}}>
<label className="dynamic-field"><span>Rule name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)} placeholder="hourly-report" required/></label>
<label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Generate the hourly report"/></label>
<label className="dynamic-field"><span>Mode</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as 'schedule' | 'event-pattern')}><option value="schedule">Schedule</option><option value="event-pattern">Event pattern</option></select></label>
{mode === 'schedule' ? <label className="dynamic-field"><span>Schedule expression</span><input className="input" value={scheduleExpression} onChange={(event) => setScheduleExpression(event.target.value)} placeholder="rate(5 minutes)" required/><small>Use a rate or cron expression.</small></label> : <label className="dynamic-field"><span>Event pattern (JSON)</span><textarea className="textarea code-textarea small" value={eventPattern} onChange={(event) => setEventPattern(event.target.value)} spellCheck={false} required/>{!patternValid && <small className="error-text">Event pattern must be a JSON object.</small>}</label>}
<label className="dynamic-field"><span>Target service</span><select className="input" value={targetService} onChange={(event) => { setTargetService(event.target.value as TargetService); setTargetArn('') }}>{availableTargetServices.length === 0 ? <option value={targetService}>{targetServiceDetails[targetService].label}</option> : availableTargetServices.map((service) => <option key={service} value={service}>{targetServiceDetails[service].label}</option>)}</select><small>{availableTargetServices.length === 0 ? 'No supported EventBridge targets are available.' : 'Only services with discoverable resources are listed.'}</small></label>
<label className="dynamic-field"><span>Target resource</span><select className="input" value={targetArn} onChange={(event) => setTargetArn(event.target.value)} required><option value="">Select a {targetServiceDetails[targetService].label} resource</option>{targetResources.map((candidate) => <option key={candidate.id} value={String(candidate.metadata.arn)}>{candidate.name}</option>)}</select><small>{targetResources.length === 0 ? `No ${targetServiceDetails[targetService].label} resources with an ARN are available.` : 'Uses the resource ARN returned by the cloud API.'}</small></label>
<label className="dynamic-field"><span>State</span><span><input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)}/> Enabled</span></label>
<div className="apigateway-route-explorer"><p className="eyebrow">Reviewed creation plan</p><p className="muted compact-text">Create EventBridge rule <code>{name.trim() || 'your-rule'}</code> on bus <code>{resource.name}</code> with {mode === 'schedule' ? <code>{scheduleExpression.trim() || 'your schedule'}</code> : 'the supplied event pattern'}, then attach existing {targetServiceDetails[targetService].label} target <code>{selectedTarget?.name || 'your-target'}</code>. No Lambda, SQS queue, SNS topic, or Step Functions resources will be created.</p></div>
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}{createMutation.isPending ? 'Creating rule' : 'Create rule'}</button>
</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="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>}
{rulesQuery.isError && <p className="error-text compact-text">{rulesQuery.error instanceof Error ? rulesQuery.error.message : 'Unable to load EventBridge rules.'}</p>}
{rulesQuery.data?.length === 0 && <p className="muted compact-text">No rules configured for this event bus.</p>}
{rulesQuery.data?.map((rule) => <div className="apigateway-route-item" key={rule.name}><div className="apigateway-route-select"><span className="apigateway-route-method">{rule.state}</span><code>{rule.name}</code><small>{rule.scheduleExpression ?? rule.eventPattern ?? 'No pattern'}{rule.description ? ` · ${rule.description}` : ''}</small>{rule.targets.map((target) => <small key={target.id}>Target {target.id}: <code>{target.arn}</code></small>)}</div>{deleteTarget?.name === rule.name ? <div className="apigateway-route-confirm"><span>Delete rule?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(rule)}>Delete</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Delete rule ${rule.name}`} onClick={() => setDeleteTarget(rule)}><Trash2 size={13}/></button>}</div>)}
</div>
</section>
}
function isJsonObject(value: string): boolean {
try {
const parsed: unknown = JSON.parse(value)
return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed)
} catch {
return false
}
}
function targetId(resource: CloudResource): string {
return resource.id.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 64) || 'target'
}
@@ -0,0 +1,119 @@
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {fireEvent, render, screen, waitFor} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {beforeEach, expect, test, vi} from "vitest";
import {SnsTopicPanel} from "./SnsTopicPanel";
import type {CloudResource} from "@/types/resource";
const apiMocks = vi.hoisted(() => ({
createSnsSubscription: vi.fn(),
deleteSnsSubscription: vi.fn(),
listCloudResources: vi.fn(),
listSnsSubscriptions: vi.fn(),
publishNotification: vi.fn(),
}));
vi.mock("@/api/cloudProxyClient", () => apiMocks);
const topic: CloudResource = {
id: "arn:aws:sns:us-east-1:000000000000:orders",
name: "orders",
cloud: "aws",
service: "notifications",
type: "topic",
region: null,
createdAt: null,
metadata: {},
};
function renderPanel() {
const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}});
render(
<QueryClientProvider client={queryClient}>
<SnsTopicPanel cloud="aws" resource={topic} runtimeReachable />
</QueryClientProvider>,
);
}
beforeEach(() => {
Object.values(apiMocks).forEach((mock) => mock.mockReset());
apiMocks.listCloudResources.mockResolvedValue([]);
apiMocks.listSnsSubscriptions.mockResolvedValue([]);
});
test("creates a Lambda subscription from the selected resource ARN", async () => {
const lambda: CloudResource = {...topic, id: "worker", name: "worker", service: "serverless", type: "lambda", metadata: {arn: "arn:aws:lambda:us-east-1:000000000000:function:worker"}};
apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === "serverless" ? [lambda] : []));
apiMocks.createSnsSubscription.mockResolvedValue({arn: "subscription-1"});
const user = userEvent.setup();
renderPanel();
await screen.findByRole("option", {name: "worker"});
await user.selectOptions(screen.getByLabelText(/lambda endpoint/i), String(lambda.metadata.arn));
await user.click(screen.getByRole("button", {name: "Subscribe"}));
await waitFor(() => expect(apiMocks.createSnsSubscription).toHaveBeenCalledWith("aws", topic.id, {
protocol: "lambda", endpoint: lambda.metadata.arn,
}));
});
test("creates a manual HTTPS subscription and explains confirmation", async () => {
apiMocks.createSnsSubscription.mockResolvedValue({arn: "subscription-2"});
const user = userEvent.setup();
renderPanel();
await user.selectOptions(screen.getByLabelText("Protocol"), "https");
await user.type(screen.getByLabelText(/endpoint/i), "https://example.com/hooks");
expect(screen.getByText("SNS may require endpoint confirmation before delivery can begin.")).toBeVisible();
await user.click(screen.getByRole("button", {name: "Subscribe"}));
await waitFor(() => expect(apiMocks.createSnsSubscription).toHaveBeenCalledWith("aws", topic.id, {
protocol: "https", endpoint: "https://example.com/hooks",
}));
});
test("shows returned subscription confirmation and filter policy, then confirms unsubscribe", async () => {
const subscription = {arn: "arn:aws:sns:us-east-1:000000000000:orders:sub-1", protocol: "https", endpoint: "https://example.com/hooks", status: "PendingConfirmation", filterPolicy: '{"eventType":["created"]}'};
apiMocks.listSnsSubscriptions.mockResolvedValue([subscription]);
apiMocks.deleteSnsSubscription.mockResolvedValue(undefined);
const user = userEvent.setup();
renderPanel();
expect(await screen.findByText("Confirmation: PendingConfirmation")).toBeVisible();
expect(screen.getByText(/Filter policy:/i)).toHaveTextContent(subscription.filterPolicy);
await user.click(screen.getByRole("button", {name: /unsubscribe https/i}));
expect(screen.getByText("Unsubscribe this endpoint?")).toBeVisible();
await user.click(screen.getByRole("button", {name: "Unsubscribe"}));
await waitFor(() => expect(apiMocks.deleteSnsSubscription).toHaveBeenCalledWith("aws", topic.id, subscription.arn));
});
test("publishes a topic message with optional subject and attributes", async () => {
apiMocks.publishNotification.mockResolvedValue({messageId: "message-123"});
const user = userEvent.setup();
renderPanel();
await user.type(screen.getByLabelText(/subject/i), "Order created");
await user.type(screen.getByLabelText(/^message$/i), "New order");
fireEvent.change(screen.getByLabelText(/message attributes/i), {target: {value: '{"eventType":"order.created"}'}});
await user.click(screen.getByRole("button", {name: "Publish"}));
await waitFor(() => expect(apiMocks.publishNotification).toHaveBeenCalledWith("aws", topic.id, {
subject: "Order created",
message: "New order",
messageAttributes: {eventType: "order.created"},
}));
expect(await screen.findByText("message-123")).toBeVisible();
});
test("rejects message attributes that are not string-valued JSON", async () => {
const user = userEvent.setup();
renderPanel();
await user.type(screen.getByLabelText(/^message$/i), "New order");
fireEvent.change(screen.getByLabelText(/message attributes/i), {target: {value: '{"attempt":1}'}});
await user.click(screen.getByRole("button", {name: "Publish"}));
expect(screen.getByText("Message attribute values must be strings.")).toBeVisible();
expect(apiMocks.publishNotification).not.toHaveBeenCalled();
});
@@ -0,0 +1,170 @@
import {useEffect, useState} from "react";
import {BellRing, 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";
interface SnsTopicPanelProps {
cloud: CloudProvider;
resource?: CloudResource;
runtimeReachable: boolean;
}
type SubscriptionProtocol = "lambda" | "sqs" | "http" | "https" | "email" | "email-json" | "sms";
const resourceProtocols: Record<"lambda" | "sqs", {label: string; service: "serverless" | "messaging"; matches: (candidate: CloudResource) => boolean}> = {
lambda: {label: "Lambda", service: "serverless", matches: (candidate) => candidate.type === "lambda"},
sqs: {label: "SQS", service: "messaging", matches: (candidate) => candidate.type === "queue" || candidate.type === "fifo-queue"},
};
export function SnsTopicPanel({cloud, resource, runtimeReachable}: SnsTopicPanelProps) {
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [attributesText, setAttributesText] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [result, setResult] = useState<PublishNotificationResult | null>(null);
const [protocol, setProtocol] = useState<SubscriptionProtocol>("lambda");
const [endpoint, setEndpoint] = useState("");
const [deleteTarget, setDeleteTarget] = useState<SnsSubscriptionOverview | null>(null);
const queryClient = useQueryClient();
const isTopic = resource?.service === "notifications" && resource.type === "topic";
const subscriptionsEnabled = Boolean(isTopic && cloud === "aws" && runtimeReachable);
const lambdaResourcesQuery = useQuery({
queryKey: ["cloud-resources", cloud, "serverless"],
queryFn: ({signal}) => listCloudResources(cloud, "serverless", undefined, signal),
enabled: subscriptionsEnabled,
});
const sqsResourcesQuery = useQuery({
queryKey: ["cloud-resources", cloud, "messaging"],
queryFn: ({signal}) => listCloudResources(cloud, "messaging", undefined, signal),
enabled: subscriptionsEnabled,
});
const subscriptionsQuery = useQuery({
queryKey: ["sns-subscriptions", cloud, resource?.id],
queryFn: ({signal}) => listSnsSubscriptions(cloud, resource!.id, signal),
enabled: subscriptionsEnabled,
});
useEffect(() => {
setSubject("");
setMessage("");
setAttributesText("");
setValidationError(null);
setResult(null);
setProtocol("lambda");
setEndpoint("");
setDeleteTarget(null);
}, [resource?.id]);
const canPublish = Boolean(isTopic && runtimeReachable && message.trim());
const publishMutation = useMutation({
mutationFn: (input: {message: string; subject?: string; messageAttributes?: Record<string, string>}) =>
publishNotification(cloud, resource!.id, input),
onSuccess: setResult,
});
const createSubscriptionMutation = useMutation({
mutationFn: () => createSnsSubscription(cloud, resource!.id, {protocol, endpoint}),
onSuccess: () => {
setEndpoint("");
void queryClient.invalidateQueries({queryKey: ["sns-subscriptions", cloud, resource?.id]});
},
});
const deleteSubscriptionMutation = useMutation({
mutationFn: (subscription: SnsSubscriptionOverview) => deleteSnsSubscription(cloud, resource!.id, subscription.arn!),
onSuccess: () => {
setDeleteTarget(null);
void queryClient.invalidateQueries({queryKey: ["sns-subscriptions", cloud, resource?.id]});
},
});
const publish = () => {
if (!canPublish) return;
try {
const messageAttributes = parseMessageAttributes(attributesText);
setValidationError(null);
setResult(null);
publishMutation.mutate({
message,
...(subject.trim() ? {subject: subject.trim()} : {}),
...(messageAttributes ? {messageAttributes} : {}),
});
} catch (error) {
setValidationError(error instanceof Error ? error.message : "Message attributes are invalid.");
}
};
if (!isTopic) {
return <section className="table-panel"><div className="empty compact"><h3>Select an SNS topic</h3><p>Select a topic to publish a notification or manage subscriptions.</p></div></section>;
}
const endpointResources = protocol === "lambda"
? (lambdaResourcesQuery.data ?? []).filter((candidate) => resourceProtocols.lambda.matches(candidate) && typeof candidate.metadata.arn === "string" && candidate.metadata.arn.length > 0)
: protocol === "sqs"
? (sqsResourcesQuery.data ?? []).filter((candidate) => resourceProtocols.sqs.matches(candidate) && typeof candidate.metadata.arn === "string" && candidate.metadata.arn.length > 0)
: [];
const needsResourceSelector = protocol === "lambda" || protocol === "sqs";
const resourcesLoading = protocol === "lambda" ? lambdaResourcesQuery.isLoading : sqsResourcesQuery.isLoading;
const canCreateSubscription = subscriptionsEnabled && Boolean(endpoint) && !createSubscriptionMutation.isPending;
return <div className="sns-topic-workspace">
<section className="table-panel">
<div className="dynamic-stage-header">
<div><p className="eyebrow">SNS Actions</p><h3><Send size={15} /> Publish message</h3><p className="muted compact-text">Publish directly to {resource.name || resource.id}.</p></div>
<span className={`runtime-state ${runtimeReachable ? "ready" : "pending"}`}>{runtimeReachable ? "Ready" : "Runtime unavailable"}</span>
</div>
<div className="resource-create-inline">
<div className="sns-topic-summary"><span>Topic</span><code>{resource.name || resource.id}</code></div>
<form className="inspector-action-form apigateway-route-form sns-publish-form" onSubmit={(event) => { event.preventDefault(); publish(); }}>
<label className="dynamic-field" htmlFor="sns-publish-subject"><span>Subject (optional)</span><input id="sns-publish-subject" className="input" value={subject} onChange={(event) => setSubject(event.target.value)} /></label>
<label className="dynamic-field dynamic-field--span" htmlFor="sns-publish-message"><span>Message</span><textarea id="sns-publish-message" className="textarea code-textarea" value={message} onChange={(event) => setMessage(event.target.value)} required spellCheck={false} placeholder="Write the notification body" /></label>
<label className="dynamic-field dynamic-field--span" htmlFor="sns-publish-attributes"><span>Message attributes (optional JSON)</span><textarea id="sns-publish-attributes" className="textarea code-textarea small" value={attributesText} onChange={(event) => setAttributesText(event.target.value)} placeholder={'{"eventType":"order.created"}'} spellCheck={false} /><small>Use a JSON object with string values.</small></label>
<button className="button primary apigateway-route-submit" type="submit" disabled={!canPublish || publishMutation.isPending}>{publishMutation.isPending ? <Loader2 size={13} className="spin" /> : <Send size={13} />}{publishMutation.isPending ? "Publishing" : "Publish"}</button>
</form>
{validationError && <p className="error-text compact-text">{validationError}</p>}
{publishMutation.isError && <p className="error-text compact-text">{publishMutation.error instanceof Error ? publishMutation.error.message : "Unable to publish message."}</p>}
{result && <div className="inspector-section"><p className="metric-label">MessageId</p><code>{result.messageId ?? "No MessageId returned"}</code></div>}
</div>
</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>
<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>}
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreateSubscription}>{createSubscriptionMutation.isPending ? <Loader2 size={13} className="spin" /> : <Plus size={13} />}{createSubscriptionMutation.isPending ? "Subscribing" : "Subscribe"}</button>
</form>
{createSubscriptionMutation.isError && <p className="error-text compact-text">{createSubscriptionMutation.error instanceof Error ? createSubscriptionMutation.error.message : "Unable to create subscription."}</p>}
{subscriptionsQuery.isLoading && <p className="muted compact-text">Loading subscriptions...</p>}
{subscriptionsQuery.isError && <p className="error-text compact-text">{subscriptionsQuery.error instanceof Error ? subscriptionsQuery.error.message : "Unable to load subscriptions."}</p>}
{subscriptionsQuery.data?.length === 0 && <p className="muted compact-text">No subscriptions configured for this topic.</p>}
{subscriptionsQuery.data?.map((subscription) => <div className="apigateway-route-item" key={subscription.arn ?? `${subscription.protocol}:${subscription.endpoint}`}><div className="apigateway-route-select"><span className="apigateway-route-method">{subscription.protocol}</span><code>{subscription.endpoint}</code><small>Confirmation: {subscription.status}</small>{subscription.filterPolicy !== null && <small>Filter policy: <code>{subscription.filterPolicy}</code></small>}</div>{subscription.arn && (deleteTarget?.arn === subscription.arn ? <div className="apigateway-route-confirm"><span>Unsubscribe this endpoint?</span><button className="button danger compact" type="button" disabled={deleteSubscriptionMutation.isPending} onClick={() => deleteSubscriptionMutation.mutate(subscription)}>Unsubscribe</button><button className="button compact" type="button" disabled={deleteSubscriptionMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Unsubscribe ${subscription.endpoint}`} onClick={() => setDeleteTarget(subscription)}><Trash2 size={13} /></button>)}</div>)}
</section>}
</div>;
}
function manualEndpointPlaceholder(protocol: Exclude<SubscriptionProtocol, "lambda" | "sqs">): string {
if (protocol === "email" || protocol === "email-json") return "alerts@example.com";
if (protocol === "sms") return "+15551234567";
return "https://example.com/notifications";
}
function parseMessageAttributes(raw: string): Record<string, string> | undefined {
if (!raw.trim()) return undefined;
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
throw new Error("Message attributes must be valid JSON.");
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Message attributes must be a JSON object of string values.");
}
if (Object.values(value).some((entry) => typeof entry !== "string")) {
throw new Error("Message attribute values must be strings.");
}
return Object.keys(value).length > 0 ? value as Record<string, string> : undefined;
}
+216
View File
@@ -2284,6 +2284,185 @@ pre,
border-top: 1px solid var(--border);
}
.ami-catalog {
display: grid;
gap: 12px;
margin-top: 6px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
.ami-search {
position: relative;
}
.ami-search svg {
position: absolute;
top: 50%;
left: 10px;
color: var(--text-3);
pointer-events: none;
transform: translateY(-50%);
}
.ami-search .input {
width: 100%;
padding-left: 32px;
}
.ami-catalog-heading {
color: var(--text-1);
font-size: 13px;
font-weight: 700;
}
.ami-quick-start-grid {
display: grid;
grid-template-columns: repeat(6, minmax(100px, 1fr));
gap: 10px;
}
.ami-quick-start-tile {
display: grid;
grid-template-rows: 42px auto auto;
justify-items: center;
gap: 5px;
min-width: 0;
padding: 12px 8px 10px;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-1);
background: var(--raised);
cursor: pointer;
}
.ami-quick-start-tile:hover {
border-color: var(--link);
background: var(--surface);
}
.ami-quick-start-tile.selected {
border-color: var(--link);
box-shadow: inset 0 0 0 1px var(--link);
}
.ami-quick-start-tile.unavailable {
cursor: not-allowed;
opacity: 0.5;
}
.ami-quick-start-tile.unavailable:hover {
border-color: var(--border);
background: var(--raised);
}
.ami-quick-start-tile img {
width: 42px;
height: 42px;
object-fit: contain;
}
.ami-quick-start-tile span {
overflow: hidden;
max-width: 100%;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.ami-quick-start-tile small {
color: var(--text-3);
font-size: 10px;
}
.ami-browser-toggle {
justify-self: start;
padding: 0;
border: 0;
color: var(--link);
font: inherit;
font-size: 12px;
font-weight: 600;
background: transparent;
cursor: pointer;
}
.ami-browser-list {
display: grid;
gap: 2px;
max-height: 220px;
overflow-y: auto;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 4px;
}
.ami-browser-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 6px 2px;
font-size: 12px;
cursor: pointer;
}
.ami-browser-item input {
accent-color: var(--link);
}
.ami-browser-item > span {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ami-browser-item b {
display: inline-grid;
flex: 0 0 auto;
width: 20px;
height: 20px;
place-items: center;
border-radius: 3px;
color: #fff;
font-size: 11px;
}
.ami-browser-item code {
color: var(--text-3);
font-size: 10px;
}
@media (max-width: 900px) {
.ami-quick-start-grid {
grid-template-columns: repeat(3, minmax(110px, 1fr));
}
}
@media (max-width: 520px) {
.ami-catalog {
padding: 10px;
}
.ami-quick-start-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ami-browser-item {
grid-template-columns: auto minmax(0, 1fr);
}
.ami-browser-item code {
grid-column: 2;
}
}
.sg-checklist {
display: flex;
flex-direction: column;
@@ -2581,6 +2760,43 @@ pre,
justify-self: start;
}
.sns-topic-workspace {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.sns-topic-workspace .table-panel {
min-width: 0;
}
.sns-topic-summary {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 12px;
}
.sns-publish-form .dynamic-field--span {
grid-column: 1 / -1;
}
.sns-publish-form .code-textarea {
min-height: 132px;
}
.sns-publish-form .code-textarea.small {
min-height: 88px;
}
@media (max-width: 900px) {
.sns-topic-workspace {
grid-template-columns: minmax(0, 1fr);
}
}
.apigateway-route-form .dynamic-field {
grid-template-rows: 14px 30px minmax(15px, auto);
}
+1
View File
@@ -9,6 +9,7 @@ export type KnownCloudServiceType =
| 'networking'
| 'serverless'
| 'messaging'
| 'notifications'
| 'events'
| 'secrets'
| 'logs'
+263
View File
@@ -58,6 +58,9 @@ importers:
'@aws-sdk/client-sfn':
specifier: ^3.1096.0
version: 3.1123.0
'@aws-sdk/client-sns':
specifier: ^3.1140.0
version: 3.1140.0
'@aws-sdk/client-sqs':
specifier: ^3.1076.0
version: 3.1121.0
@@ -293,6 +296,10 @@ packages:
resolution: {integrity: sha512-C9O/NfDJCXCyXqMh4qrEQ03K4YLXlJOxvwrpcIB+6GJ9HsxRVjFhS73E1m9uO6d5A6Quk+efTDXfAbKmwVvKlw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-sns@3.1140.0':
resolution: {integrity: sha512-og/jr7YaSPBCfNIwDaTjM1mKsNz9ay9QIRgGcZIzxqFIP5wyLRXeifKrJknIWANMCHIzLAzc8ysXbzMTBWXgWg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-sqs@3.1121.0':
resolution: {integrity: sha512-T9NYR8Gcs30TUMbwf4QS2UyLMFheps3Kqgr//t1XfpIqgkpuBNkSiXa5LunlJL3MCTDT0bL01PkuhdHcXBwuVg==}
engines: {node: '>=20.0.0'}
@@ -324,6 +331,10 @@ packages:
resolution: {integrity: sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.978.1':
resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.54':
resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==}
engines: {node: '>=20.0.0'}
@@ -344,6 +355,10 @@ packages:
resolution: {integrity: sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.72':
resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.56':
resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==}
engines: {node: '>=20.0.0'}
@@ -364,6 +379,10 @@ packages:
resolution: {integrity: sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.74':
resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.972.61':
resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==}
engines: {node: '>=20.0.0'}
@@ -380,6 +399,10 @@ packages:
resolution: {integrity: sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.17':
resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.7':
resolution: {integrity: sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==}
engines: {node: '>=20.0.0'}
@@ -404,6 +427,10 @@ packages:
resolution: {integrity: sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.79':
resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.63':
resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==}
engines: {node: '>=20.0.0'}
@@ -428,6 +455,10 @@ packages:
resolution: {integrity: sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.84':
resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.54':
resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==}
engines: {node: '>=20.0.0'}
@@ -448,6 +479,10 @@ packages:
resolution: {integrity: sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.72':
resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.972.60':
resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==}
engines: {node: '>=20.0.0'}
@@ -464,6 +499,10 @@ packages:
resolution: {integrity: sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.16':
resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.6':
resolution: {integrity: sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==}
engines: {node: '>=20.0.0'}
@@ -488,6 +527,10 @@ packages:
resolution: {integrity: sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.78':
resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/dynamodb-codec@3.973.43':
resolution: {integrity: sha512-5nw01fhFJEKM68n0R65S1DijiSAQWZVbvH7IxTIJpFFbggg816jBVYvhK7W+XISjvtdg0rMkF/gQmuLGH713mg==}
engines: {node: '>=20.0.0'}
@@ -544,6 +587,10 @@ packages:
resolution: {integrity: sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.46':
resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.38':
resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==}
engines: {node: '>=20.0.0'}
@@ -560,6 +607,10 @@ packages:
resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.47':
resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1080.0':
resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==}
engines: {node: '>=20.0.0'}
@@ -580,6 +631,10 @@ packages:
resolution: {integrity: sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1138.0':
resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.973.15':
resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==}
engines: {node: '>=20.0.0'}
@@ -596,6 +651,10 @@ packages:
resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.6':
resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/util-dynamodb@3.996.9':
resolution: {integrity: sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==}
engines: {node: '>=20.0.0'}
@@ -622,6 +681,10 @@ packages:
resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.41':
resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
@@ -1019,6 +1082,10 @@ packages:
resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==}
engines: {node: '>=18.0.0'}
'@smithy/core@3.35.0':
resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.4.14':
resolution: {integrity: sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==}
engines: {node: '>=18.0.0'}
@@ -1031,6 +1098,10 @@ packages:
resolution: {integrity: sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.2':
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.6.11':
resolution: {integrity: sha512-o0Zkj1nKqJAoq+a+BrkhU39tRftMNjLwpc/z06Frfl43wpbHrJMaSAVZE4vTqlxtVkNaGaT0bIDxOp7tkFTuQQ==}
engines: {node: '>=18.0.0'}
@@ -1047,6 +1118,10 @@ packages:
resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.8.0':
resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==}
engines: {node: '>=18.0.0'}
'@smithy/is-array-buffer@2.2.0':
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
engines: {node: '>=14.0.0'}
@@ -1059,6 +1134,10 @@ packages:
resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.12.1':
resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.9.11':
resolution: {integrity: sha512-slbzbz8taEOzoXv/9y34YNBoE+ZHmddLykCgjDAjvMAsu2nM5s2Gzwa5OGF721tD8s+CKeFUBds5lSj9lcbuDg==}
engines: {node: '>=18.0.0'}
@@ -1079,6 +1158,10 @@ packages:
resolution: {integrity: sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.3':
resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.15.1':
resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==}
engines: {node: '>=18.0.0'}
@@ -1095,6 +1178,10 @@ packages:
resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.19.0':
resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==}
engines: {node: '>=18.0.0'}
'@smithy/util-buffer-from@2.2.0':
resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
engines: {node: '>=14.0.0'}
@@ -2668,6 +2755,17 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/client-sns@3.1140.0':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/credential-provider-node': 3.972.84
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-sqs@3.1121.0':
dependencies:
'@aws-sdk/core': 3.977.9
@@ -2746,6 +2844,17 @@ snapshots:
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/core@3.978.1':
dependencies:
'@aws-sdk/types': 3.974.6
'@aws-sdk/xml-builder': 3.972.41
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.35.0
'@smithy/signature-v4': 5.7.3
'@smithy/types': 4.19.0
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.54':
dependencies:
'@aws-sdk/core': 3.978.0
@@ -2786,6 +2895,14 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.72':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.56':
dependencies:
'@aws-sdk/core': 3.978.0
@@ -2836,6 +2953,16 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.74':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.972.61':
dependencies:
'@aws-sdk/core': 3.978.0
@@ -2900,6 +3027,22 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.17':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/credential-provider-env': 3.972.72
'@aws-sdk/credential-provider-http': 3.972.74
'@aws-sdk/credential-provider-login': 3.972.79
'@aws-sdk/credential-provider-process': 3.972.72
'@aws-sdk/credential-provider-sso': 3.973.16
'@aws-sdk/credential-provider-web-identity': 3.972.78
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.7':
dependencies:
'@aws-sdk/core': 3.978.0
@@ -2961,6 +3104,15 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.79':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.63':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.54
@@ -3045,6 +3197,20 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.84':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.72
'@aws-sdk/credential-provider-http': 3.972.74
'@aws-sdk/credential-provider-ini': 3.973.17
'@aws-sdk/credential-provider-process': 3.972.72
'@aws-sdk/credential-provider-sso': 3.973.16
'@aws-sdk/credential-provider-web-identity': 3.972.78
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.54':
dependencies:
'@aws-sdk/core': 3.978.0
@@ -3085,6 +3251,14 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.72':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.972.60':
dependencies:
'@aws-sdk/core': 3.978.0
@@ -3125,6 +3299,16 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.16':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/token-providers': 3.1138.0
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.6':
dependencies:
'@aws-sdk/core': 3.978.0
@@ -3180,6 +3364,15 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.78':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/dynamodb-codec@3.973.43':
dependencies:
'@aws-sdk/core': 3.978.0
@@ -3301,6 +3494,17 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.46':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.38':
dependencies:
'@aws-sdk/types': 3.974.5
@@ -3329,6 +3533,13 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.47':
dependencies:
'@aws-sdk/types': 3.974.6
'@smithy/signature-v4': 5.7.3
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1080.0':
dependencies:
'@aws-sdk/core': 3.978.0
@@ -3374,6 +3585,15 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/token-providers@3.1138.0':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/types@3.973.15':
dependencies:
'@smithy/types': 4.17.2
@@ -3394,6 +3614,11 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/types@3.974.6':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/util-dynamodb@3.996.9(@aws-sdk/client-dynamodb@3.1112.0)':
dependencies:
'@aws-sdk/client-dynamodb': 3.1112.0
@@ -3423,6 +3648,11 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.41':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@azure-rest/core-client@2.8.0':
@@ -3863,6 +4093,11 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/core@3.35.0':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.14':
dependencies:
'@smithy/core': 3.33.3
@@ -3881,6 +4116,12 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.2':
dependencies:
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/fetch-http-handler@5.6.11':
dependencies:
'@smithy/core': 3.30.0
@@ -3905,6 +4146,12 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/fetch-http-handler@5.8.0':
dependencies:
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/is-array-buffer@2.2.0':
dependencies:
tslib: 2.8.1
@@ -3921,6 +4168,12 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/node-http-handler@4.12.1':
dependencies:
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/node-http-handler@4.9.11':
dependencies:
'@smithy/core': 3.30.0
@@ -3951,6 +4204,12 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/signature-v4@5.7.3':
dependencies:
'@smithy/core': 3.35.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/types@4.15.1':
dependencies:
tslib: 2.8.1
@@ -3967,6 +4226,10 @@ snapshots:
dependencies:
tslib: 2.8.1
'@smithy/types@4.19.0':
dependencies:
tslib: 2.8.1
'@smithy/util-buffer-from@2.2.0':
dependencies:
'@smithy/is-array-buffer': 2.2.0
+105
View File
@@ -0,0 +1,105 @@
(fail) test infrastructure > runs in a jsdom environment with a mutable document [0.07ms]
8 |
9 | expect(document.getElementById("jsdom-marker")).toBe(marker);
10 | });
11 |
12 | it("registers the jest-dom matchers", () => {
--
(fail) test infrastructure > registers the jest-dom matchers [0.04ms]
packages/frontend/src/components/DynamicResourceView.test.tsx:
(pass) warns before deleting a bucket that contains folder prefixes [0.02ms]
(pass) allows deleting an empty bucket
--
(fail) replaces Hello World source when the Lambda runtime changes [0.48ms]
5 | import '@testing-library/dom';
6 | import { prepareValueInterceptor, prepareSelectionInterceptor, prepareRangeTextInterceptor } from './interceptor.js';
7 |
8 | const isPrepared = Symbol('Node prepared with document state workarounds');
9 | function prepareDocument(document) {
--
(fail) edits key-value fields as variable rows [0.18ms]
packages/frontend/src/components/AppConfigPanel.test.tsx:
# Unhandled error between tests
-------------------------------
--
(fail) KmsCryptoPanel > clears sensitive state and ignores an old response when the account changes [0.20ms]
packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx:
# Unhandled error between tests
-------------------------------
--
(fail) offers scratch and blueprint Lambda creation paths [0.29ms]
5 | import '@testing-library/dom';
6 | import { prepareValueInterceptor, prepareSelectionInterceptor, prepareRangeTextInterceptor } from './interceptor.js';
7 |
8 | const isPrepared = Symbol('Node prepared with document state workarounds');
9 | function prepareDocument(document) {
--
(fail) prefills the reviewed S3 upload processor blueprint [0.12ms]
5 | import '@testing-library/dom';
6 | import { prepareValueInterceptor, prepareSelectionInterceptor, prepareRangeTextInterceptor } from './interceptor.js';
7 |
8 | const isPrepared = Symbol('Node prepared with document state workarounds');
9 | function prepareDocument(document) {
--
(fail) keeps the blank creation form available [0.07ms]
5 | import '@testing-library/dom';
6 | import { prepareValueInterceptor, prepareSelectionInterceptor, prepareRangeTextInterceptor } from './interceptor.js';
7 |
8 | const isPrepared = Symbol('Node prepared with document state workarounds');
9 | function prepareDocument(document) {
--
(fail) prefills the REST Lambda API blueprint and its deployment plan [0.08ms]
5 | import '@testing-library/dom';
6 | import { prepareValueInterceptor, prepareSelectionInterceptor, prepareRangeTextInterceptor } from './interceptor.js';
7 |
8 | const isPrepared = Symbol('Node prepared with document state workarounds');
9 | function prepareDocument(document) {
--
(fail) prefills the scheduled Lambda blueprint and its EventBridge schedule [0.07ms]
5 | import '@testing-library/dom';
6 | import { prepareValueInterceptor, prepareSelectionInterceptor, prepareRangeTextInterceptor } from './interceptor.js';
7 |
8 | const isPrepared = Symbol('Node prepared with document state workarounds');
9 | function prepareDocument(document) {
--
(fail) prefills the SQS worker blueprint and submits queue mapping settings [0.10ms]
packages/api/src/adapter-gcp/GcpSchedulerAdapter.test.ts:
(pass) GcpSchedulerAdapter > lists jobs and maps the resource shape [0.29ms]
(pass) GcpSchedulerAdapter > normalizes an empty list payload [0.04ms]
(pass) GcpSchedulerAdapter > follows nextPageToken pagination and collects all jobs [0.08ms]
--
(fail) createEc2SecurityGroup > sends one JSON object through HttpClient with VPC vpc-selected [0.16ms]
9 | new Response(JSON.stringify({ groupId: "sg-created" }), {
10 | status: 200,
11 | headers: { "Content-Type": "application/json" },
12 | }),
13 | );
--
(fail) createEc2SecurityGroup > sends one JSON object through HttpClient with VPC %s [0.08ms]
19 tests failed:
(fail) test infrastructure > runs in a jsdom environment with a mutable document [0.07ms]
(fail) test infrastructure > registers the jest-dom matchers [0.04ms]
(fail) replaces Hello World source when the Lambda runtime changes [0.48ms]
(fail) edits key-value fields as variable rows [0.18ms]
(fail) KmsCryptoPanel > clears sensitive state and ignores an old response when the account changes [0.20ms]
(fail) offers scratch and blueprint Lambda creation paths [0.29ms]
(fail) prefills the reviewed S3 upload processor blueprint [0.12ms]
(fail) keeps the blank creation form available [0.07ms]
(fail) prefills the REST Lambda API blueprint and its deployment plan [0.08ms]
(fail) prefills the scheduled Lambda blueprint and its EventBridge schedule [0.07ms]
(fail) prefills the SQS worker blueprint and submits queue mapping settings [0.10ms]
(fail) createEc2SecurityGroup > sends one JSON object through HttpClient with VPC vpc-selected [0.16ms]
(fail) createEc2SecurityGroup > sends one JSON object through HttpClient with VPC %s [0.08ms]
1037 pass
19 fail
6 errors
3140 expect() calls