From ce163de6746870799536c7c6e58bafa03e35d73b Mon Sep 17 00:00:00 2001 From: Luis Felipe Date: Fri, 17 Jul 2026 22:13:01 -0400 Subject: [PATCH 1/9] adding aws queue adapter --- .../src/adapter-aws/AwsQueueAdapter.test.ts | 119 ++++++++++ .../api/src/adapter-aws/AwsQueueAdapter.ts | 216 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 packages/api/src/adapter-aws/AwsQueueAdapter.test.ts create mode 100644 packages/api/src/adapter-aws/AwsQueueAdapter.ts diff --git a/packages/api/src/adapter-aws/AwsQueueAdapter.test.ts b/packages/api/src/adapter-aws/AwsQueueAdapter.test.ts new file mode 100644 index 0000000..29d253e --- /dev/null +++ b/packages/api/src/adapter-aws/AwsQueueAdapter.test.ts @@ -0,0 +1,119 @@ +import {describe, expect, test} from 'bun:test' +import {AwsQueueAdapter} from './AwsQueueAdapter' + +const QUEUE_URL = 'http://localhost:4566/000000000000/orders' + +function fakeSqs(overrides: Record unknown> = {}) { + return { + async send(command: {constructor: {name: string}; input: unknown}) { + const handler = overrides[command.constructor.name] + if (handler) return handler(command.input) + + switch (command.constructor.name) { + case 'ListQueuesCommand': + return {QueueUrls: [QUEUE_URL]} + case 'GetQueueAttributesCommand': + return { + Attributes: { + QueueArn: 'arn:aws:sqs:us-east-1:000000000000:orders', + ApproximateNumberOfMessages: '3', + ApproximateNumberOfMessagesNotVisible: '1', + ApproximateNumberOfMessagesDelayed: '0', + CreatedTimestamp: '1700000000', + FifoQueue: 'false', + }, + } + default: + throw new Error(`Unhandled command: ${command.constructor.name}`) + } + }, + } as never +} + +describe('AwsQueueAdapter', () => { + test('list returns mapped CloudResource array with message counts in metadata', async () => { + const adapter = new AwsQueueAdapter(fakeSqs()) + const result = await adapter.list() + + expect(result).toHaveLength(1) + expect(result[0].id).toBe(QUEUE_URL) + expect(result[0].name).toBe('orders') + expect(result[0].cloud).toBe('aws') + expect(result[0].service).toBe('queue') + expect(result[0].type).toBe('queue') + expect(result[0].metadata.messagesAvailable).toBe(3) + expect(result[0].metadata.messagesInFlight).toBe(1) + }) + + test('list filters results by search term', async () => { + const adapter = new AwsQueueAdapter(fakeSqs({ + ListQueuesCommand: () => ({QueueUrls: [QUEUE_URL, `${QUEUE_URL}-dlq`]}), + })) + + const result = await adapter.list({search: 'orders-dlq'}) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('orders-dlq') + }) + + test('create builds a queue and returns the mapped resource', async () => { + const adapter = new AwsQueueAdapter(fakeSqs({ + CreateQueueCommand: () => ({QueueUrl: QUEUE_URL}), + })) + + const resource = await adapter.create({values: {queueName: 'orders'}}) + + expect(resource.id).toBe(QUEUE_URL) + expect(resource.name).toBe('orders') + }) + + test('create rejects a missing queueName', async () => { + const adapter = new AwsQueueAdapter(fakeSqs()) + await expect(adapter.create({values: {}})).rejects.toThrow('queueName is required') + }) + + test('sendMessage returns the message id from the SDK response', async () => { + const adapter = new AwsQueueAdapter(fakeSqs({ + SendMessageCommand: () => ({MessageId: 'msg-1'}), + })) + + const message = await adapter.sendMessage(QUEUE_URL, '{"hello":"world"}') + + expect(message.id).toBe('msg-1') + expect(message.body).toBe('{"hello":"world"}') + }) + + test('receiveMessages maps SDK messages into QueueMessage records', async () => { + const adapter = new AwsQueueAdapter(fakeSqs({ + ReceiveMessageCommand: () => ({ + Messages: [ + { + MessageId: 'msg-1', + ReceiptHandle: 'receipt-1', + Body: '{"hello":"world"}', + Attributes: {SentTimestamp: '1700000000000', ApproximateReceiveCount: '2'}, + MessageAttributes: {source: {StringValue: 'flow-panel', DataType: 'String'}}, + }, + ], + }), + })) + + const messages = await adapter.receiveMessages(QUEUE_URL) + + expect(messages).toHaveLength(1) + expect(messages[0].id).toBe('msg-1') + expect(messages[0].receiptHandle).toBe('receipt-1') + expect(messages[0].receiveCount).toBe(2) + expect(messages[0].messageAttributes.source).toBe('flow-panel') + }) + + test('deleteMessage and purgeQueue delegate to the SDK without throwing', async () => { + const adapter = new AwsQueueAdapter(fakeSqs({ + DeleteMessageCommand: () => ({}), + PurgeQueueCommand: () => ({}), + })) + + await expect(adapter.deleteMessage(QUEUE_URL, 'receipt-1')).resolves.toBeUndefined() + await expect(adapter.purgeQueue(QUEUE_URL)).resolves.toBeUndefined() + }) +}) diff --git a/packages/api/src/adapter-aws/AwsQueueAdapter.ts b/packages/api/src/adapter-aws/AwsQueueAdapter.ts new file mode 100644 index 0000000..c95ea75 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsQueueAdapter.ts @@ -0,0 +1,216 @@ +import { + CreateQueueCommand, + DeleteMessageCommand, + DeleteQueueCommand, + GetQueueAttributesCommand, + ListQueuesCommand, + PurgeQueueCommand, + QueueAttributeName, + ReceiveMessageCommand, + SendMessageCommand, + type SQSClient, +} from '@aws-sdk/client-sqs' +import {awsQueueSchema} from '../cloud-spi/queueSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + QueueMessage, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' +import {sqs as defaultSqs} from '../aws' + +const ATTRIBUTES_FOR_LIST: QueueAttributeName[] = [ + QueueAttributeName.QueueArn, + QueueAttributeName.ApproximateNumberOfMessages, + QueueAttributeName.ApproximateNumberOfMessagesNotVisible, + QueueAttributeName.ApproximateNumberOfMessagesDelayed, + QueueAttributeName.CreatedTimestamp, + QueueAttributeName.VisibilityTimeout, + QueueAttributeName.DelaySeconds, + QueueAttributeName.MessageRetentionPeriod, + QueueAttributeName.RedrivePolicy, + QueueAttributeName.FifoQueue, +] + +export class AwsQueueAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'queue' as const + + constructor(private readonly sqs: SQSClient = defaultSqs) {} + + schema(): ServiceSchema { + return awsQueueSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const res = await this.sqs.send(new ListQueuesCommand({})) + const urls = res.QueueUrls ?? [] + + const resources = await Promise.all( + urls.map((url) => this.toResource(url)), + ) + + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + try { + return await this.toResource(id) + } catch (error) { + if (isQueueMissing(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const values = input.values + const queueName = stringValue(values.queueName) + if (!queueName) throw new Error('queueName is required') + + const fifo = String(values.fifo ?? '') === 'fifo' || queueName.endsWith('.fifo') + const name = fifo && !queueName.endsWith('.fifo') ? `${queueName}.fifo` : queueName + + const attributes: Record = {} + if (fifo) attributes.FifoQueue = 'true' + const visibilityTimeout = stringValue(values.visibilityTimeout) + if (visibilityTimeout) attributes.VisibilityTimeout = visibilityTimeout + const delaySeconds = stringValue(values.delaySeconds) + if (delaySeconds) attributes.DelaySeconds = delaySeconds + const messageRetentionPeriod = stringValue(values.messageRetentionPeriod) + if (messageRetentionPeriod) attributes.MessageRetentionPeriod = messageRetentionPeriod + + const res = await this.sqs.send(new CreateQueueCommand({ + QueueName: name, + Attributes: Object.keys(attributes).length ? attributes : undefined, + })) + + if (!res.QueueUrl) throw new Error('Floci runtime did not return a queue URL') + return this.toResource(res.QueueUrl) + } + + async delete(id: string): Promise { + await this.sqs.send(new DeleteQueueCommand({QueueUrl: id})) + } + + async sendMessage(queueId: string, body: string, messageAttributes?: Record): Promise { + const res = await this.sqs.send(new SendMessageCommand({ + QueueUrl: queueId, + MessageBody: body, + MessageAttributes: messageAttributes + ? Object.fromEntries( + Object.entries(messageAttributes).map(([key, value]) => [ + key, + {DataType: 'String', StringValue: value}, + ]), + ) + : undefined, + })) + + return { + id: res.MessageId ?? '', + receiptHandle: '', + body, + attributes: {}, + messageAttributes: messageAttributes ?? {}, + sentAt: new Date().toISOString(), + receiveCount: null, + } + } + + async receiveMessages(queueId: string, maxMessages = 5, waitTimeSeconds = 0): Promise { + const res = await this.sqs.send(new ReceiveMessageCommand({ + QueueUrl: queueId, + MaxNumberOfMessages: clamp(maxMessages, 1, 10), + WaitTimeSeconds: clamp(waitTimeSeconds, 0, 20), + MessageAttributeNames: ['All'], + AttributeNames: [QueueAttributeName.All], + })) + + return (res.Messages ?? []).map((message) => ({ + id: message.MessageId ?? '', + receiptHandle: message.ReceiptHandle ?? '', + body: message.Body ?? '', + attributes: message.Attributes ?? {}, + messageAttributes: Object.fromEntries( + Object.entries(message.MessageAttributes ?? {}).map(([key, value]) => [key, value.StringValue ?? '']), + ), + sentAt: message.Attributes?.SentTimestamp + ? new Date(Number(message.Attributes.SentTimestamp)).toISOString() + : null, + receiveCount: message.Attributes?.ApproximateReceiveCount + ? Number(message.Attributes.ApproximateReceiveCount) + : null, + })) + } + + async deleteMessage(queueId: string, receiptHandle: string): Promise { + await this.sqs.send(new DeleteMessageCommand({QueueUrl: queueId, ReceiptHandle: receiptHandle})) + } + + async purgeQueue(queueId: string): Promise { + await this.sqs.send(new PurgeQueueCommand({QueueUrl: queueId})) + } + + private async toResource(queueUrl: string): Promise { + const res = await this.sqs.send(new GetQueueAttributesCommand({ + QueueUrl: queueUrl, + AttributeNames: ATTRIBUTES_FOR_LIST, + })) + const attrs = res.Attributes ?? {} + const name = queueUrl.split('/').pop() ?? queueUrl + + return { + id: queueUrl, + name, + cloud: 'aws', + service: 'queue', + type: 'queue', + region: null, + createdAt: attrs.CreatedTimestamp + ? new Date(Number(attrs.CreatedTimestamp) * 1000).toISOString() + : null, + status: 'Active', + metadata: { + queueUrl, + queueArn: attrs.QueueArn, + fifo: attrs.FifoQueue === 'true', + messagesAvailable: numberOr(attrs.ApproximateNumberOfMessages, 0), + messagesInFlight: numberOr(attrs.ApproximateNumberOfMessagesNotVisible, 0), + messagesDelayed: numberOr(attrs.ApproximateNumberOfMessagesDelayed, 0), + visibilityTimeout: numberOr(attrs.VisibilityTimeout, null), + delaySeconds: numberOr(attrs.DelaySeconds, null), + messageRetentionPeriod: numberOr(attrs.MessageRetentionPeriod, null), + redrivePolicy: attrs.RedrivePolicy ? JSON.parse(attrs.RedrivePolicy) : null, + }, + } + } +} + +function numberOr(value: string | undefined, fallback: number | null): number | null { + if (value === undefined) return fallback + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min + return Math.min(max, Math.max(min, value)) +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((r) => r.name.toLowerCase().includes(normalized)) +} + +function isQueueMissing(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const name = (error as {name?: string}).name + return name === 'QueueDoesNotExist' || name === 'AWS.SimpleQueueService.NonExistentQueue' +} From b9ec3427869f6259d1dd40626ed69b7f18e8df6c Mon Sep 17 00:00:00 2001 From: Luis Felipe Date: Fri, 17 Jul 2026 22:13:45 -0400 Subject: [PATCH 2/9] adding queue schema and updating service types --- packages/api/src/cloud-spi/queueSchema.ts | 69 +++++++++++++++++++++++ packages/api/src/cloud-spi/types.ts | 18 +++++- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/cloud-spi/queueSchema.ts diff --git a/packages/api/src/cloud-spi/queueSchema.ts b/packages/api/src/cloud-spi/queueSchema.ts new file mode 100644 index 0000000..d9b05ea --- /dev/null +++ b/packages/api/src/cloud-spi/queueSchema.ts @@ -0,0 +1,69 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const queueColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Queue Name'}, + {name: 'type', label: 'Type'}, + {name: 'cloud', label: 'Cloud'}, + {name: 'status', label: 'Status'}, + {name: 'createdAt', label: 'Created At'}, +] + +const queueFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +export function awsQueueSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'queue', + displayName: 'Amazon SQS', + fields: [ + { + name: 'queueName', + label: 'Queue Name', + type: 'text', + required: true, + description: 'Up to 80 characters: alphanumeric, hyphens, and underscores. FIFO queues must end in ".fifo".', + }, + { + name: 'fifo', + label: 'Queue Type', + type: 'select', + required: false, + options: [ + {label: 'Standard', value: 'standard'}, + {label: 'FIFO', value: 'fifo'}, + ], + }, + { + name: 'visibilityTimeout', + label: 'Visibility Timeout (seconds)', + type: 'text', + required: false, + description: 'Default: 30.', + }, + { + name: 'delaySeconds', + label: 'Delivery Delay (seconds)', + type: 'text', + required: false, + description: 'Default: 0.', + }, + { + name: 'messageRetentionPeriod', + label: 'Message Retention (seconds)', + type: 'text', + required: false, + description: 'Default: 345600 (4 days).', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: queueFilters, + columns: queueColumns, + } +} + +export function queueSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'aws') return awsQueueSchema() + return null +} diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index bf9da0d..0fc037a 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -1,6 +1,6 @@ export type CloudProvider = 'aws' | 'azure' | 'gcp' -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' +export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' | 'queue' export type CloudAvailability = 'available' | 'coming_soon' @@ -83,7 +83,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'queue' region: string | null createdAt: string | null status?: string | null @@ -137,6 +137,16 @@ export interface CosmosQueryResult { count: number } +export interface QueueMessage { + id: string + receiptHandle: string + body: string + attributes: Record + messageAttributes: Record + sentAt: string | null + receiveCount: number | null +} + export interface ResourceQuery { search?: string } @@ -172,4 +182,8 @@ export interface CloudServiceAdapter { upsertCosmosItem?(databaseId: string, containerId: string, document: Record): Promise deleteCosmosItem?(databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise queryCosmosItems?(databaseId: string, containerId: string, query: string): Promise + sendMessage?(queueId: string, body: string, messageAttributes?: Record): Promise + receiveMessages?(queueId: string, maxMessages?: number, waitTimeSeconds?: number): Promise + deleteMessage?(queueId: string, receiptHandle: string): Promise + purgeQueue?(queueId: string): Promise } From f38b9a7706f60661c3d5624a5ab25972babd3fac Mon Sep 17 00:00:00 2001 From: Luis Felipe Date: Fri, 17 Jul 2026 22:16:43 -0400 Subject: [PATCH 3/9] adding queue endpoints and implementing invokations methods --- packages/api/src/routes/clouds.ts | 50 ++++++++++++++++++- packages/api/src/service/CloudProxyService.ts | 33 ++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 93cb831..06c6c8f 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -230,6 +230,54 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.post('/:cloud/services/queue/resources/:id/messages', 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 c.req.json<{body?: string; messageAttributes?: Record}>() + if (!body.body) return c.json({error: 'body is required', code: 'invalid_request', message: 'body is required'}, 400) + const message = await svc(c).sendQueueMessage(cloud, c.req.param('id'), body.body, body.messageAttributes) + return c.json(message, 201) + }) + }) + + app.get('/:cloud/services/queue/resources/:id/messages', 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 maxMessages = Number(c.req.query('maxMessages') ?? '5') + const waitTimeSeconds = Number(c.req.query('waitTimeSeconds') ?? '0') + const messages = await svc(c).receiveQueueMessages(cloud, c.req.param('id'), maxMessages, waitTimeSeconds) + return c.json(messages) + }) + }) + + app.post('/:cloud/services/queue/resources/:id/messages/delete', 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 c.req.json<{receiptHandle?: string}>() + if (!body.receiptHandle) { + return c.json({error: 'receiptHandle is required', code: 'invalid_request', message: 'receiptHandle is required'}, 400) + } + await svc(c).deleteQueueMessage(cloud, c.req.param('id'), body.receiptHandle) + return c.json({ok: true}) + }) + }) + + app.post('/:cloud/services/queue/resources/:id/purge', 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).purgeQueue(cloud, c.req.param('id')) + return c.json({ok: true}) + }) + }) + app.post('/:cloud/services/:service/resources', async (c) => { const cloud = c.req.param('cloud') as CloudProvider const serviceType = c.req.param('service') as CloudServiceType @@ -261,7 +309,7 @@ function isCloudProvider(value: string): value is CloudProvider { } function isServiceType(value: string): value is CloudServiceType { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' + return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' || value === 'queue' } async function withRuntime(c: Context, handler: () => Promise): Promise { diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 826bf12..ca82a7a 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -9,6 +9,7 @@ import type { CosmosItem, CosmosQueryResult, CreateResourceInput, + QueueMessage, ResourceQuery, ServerlessInvokeResult, ServiceSchema, @@ -18,6 +19,7 @@ import type { import {storageSchemaFor} from '../cloud-spi/storageSchema' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {serverlessSchemaFor} from '../cloud-spi/serverlessSchema' +import {queueSchemaFor} from '../cloud-spi/queueSchema' import {k8sSchemaFor} from '../cloud-spi/eksSchema' import {databaseSchemaFor} from '../cloud-spi/databaseSchema' import {azureEndpoint} from '../azure' @@ -73,6 +75,12 @@ export class CloudProxyService { displayName: 'Networking', availability: this.registry.get(cloud, 'networking') ? 'available' : 'coming_soon', }) + services.push({ + cloud, + service: 'queue', + displayName: 'Queue', + availability: this.registry.get(cloud, 'queue') ? 'available' : 'coming_soon', + }) return services } @@ -83,6 +91,7 @@ export class CloudProxyService { if (service === 'k8s') return k8sSchemaFor(cloud) if (service === 'database') return databaseSchemaFor(cloud) if (service === 'serverless') return serverlessSchemaFor(cloud) + if (service === 'queue') return queueSchemaFor(cloud) return null } @@ -169,6 +178,30 @@ async invokeResource( if (!adapter.invoke) throw new Error(`${cloud}/${service} invoke is not supported`) return adapter.invoke(id, payload) } + async sendQueueMessage(cloud: CloudProvider, queueId: string, body: string, messageAttributes?: Record): Promise { + const adapter = this.requireAdapter(cloud, 'queue') + if (!adapter.sendMessage) throw new Error(`${cloud}/queue send message is not supported`) + return adapter.sendMessage(queueId, body, messageAttributes) + } + + async receiveQueueMessages(cloud: CloudProvider, queueId: string, maxMessages?: number, waitTimeSeconds?: number): Promise { + const adapter = this.requireAdapter(cloud, 'queue') + if (!adapter.receiveMessages) throw new Error(`${cloud}/queue receive messages is not supported`) + return adapter.receiveMessages(queueId, maxMessages, waitTimeSeconds) + } + + async deleteQueueMessage(cloud: CloudProvider, queueId: string, receiptHandle: string): Promise { + const adapter = this.requireAdapter(cloud, 'queue') + if (!adapter.deleteMessage) throw new Error(`${cloud}/queue delete message is not supported`) + await adapter.deleteMessage(queueId, receiptHandle) + } + + async purgeQueue(cloud: CloudProvider, queueId: string): Promise { + const adapter = this.requireAdapter(cloud, 'queue') + if (!adapter.purgeQueue) throw new Error(`${cloud}/queue purge is not supported`) + await adapter.purgeQueue(queueId) + } + async listObjects(cloud: CloudProvider, service: CloudServiceType, resourceId: string, prefix?: string): Promise { const adapter = this.requireAdapter(cloud, service) if (!adapter.listObjects) throw new Error(`Object listing is not supported for ${cloud}/${service}`) From f8da08f483984191fcef476958440a0cd73d60aa Mon Sep 17 00:00:00 2001 From: Luis Felipe Date: Fri, 17 Jul 2026 22:17:59 -0400 Subject: [PATCH 4/9] incluiding aws sqs --- packages/api/package.json | 1 + packages/api/src/aws.ts | 4 ++++ packages/api/src/cloudProxy.ts | 2 ++ 3 files changed, 7 insertions(+) diff --git a/packages/api/package.json b/packages/api/package.json index 4c60859..9ce7d92 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -16,6 +16,7 @@ "@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-secrets-manager": "^3.1076.0", + "@aws-sdk/client-sqs": "^3.1090.0", "dotenv": "^17.4.2", "hono": "^4.12.27" }, diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index e9ae0c9..4b5c68b 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -4,6 +4,7 @@ import { EKSClient } from "@aws-sdk/client-eks"; import { EC2Client } from "@aws-sdk/client-ec2"; import { RDSClient } from "@aws-sdk/client-rds"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { SQSClient } from "@aws-sdk/client-sqs"; const endpoint = process.env.FLOCI_ENDPOINT; const region = process.env.AWS_REGION || "us-east-1"; @@ -39,6 +40,7 @@ export type AwsClients = { ec2: EC2Client; rds: RDSClient; secretsManager: SecretsManagerClient; + sqs: SQSClient; }; export type AwsClientName = keyof AwsClients; @@ -58,6 +60,7 @@ function buildClients(accountId: string): AwsClients { ec2: new EC2Client(base), rds: new RDSClient(base), secretsManager: new SecretsManagerClient(base), + sqs: new SQSClient(base), }; } @@ -89,3 +92,4 @@ export const eks = awsClients.eks; export const ec2 = awsClients.ec2; export const rds = awsClients.rds; export const secretsManager = awsClients.secretsManager; +export const sqs = awsClients.sqs; diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index b9740c0..c008826 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -11,6 +11,7 @@ import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter' import {CloudProxyService} from './service/CloudProxyService' import {AzureServerlessAdapter} from './adapter-azure/AzureServerlessAdapter' import {AwsServerlessAdapter} from './adapter-aws/AwsServerlessAdapter' +import {AwsQueueAdapter} from './adapter-aws/AwsQueueAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' import {createEksService} from './services/eks' @@ -33,6 +34,7 @@ export function createCloudProxyService(accountId?: string | null): CloudProxySe new AwsComputeAdapter(ec2Service), new AwsNetworkingAdapter(ec2Service), new AwsServerlessAdapter(clients.lambda), + new AwsQueueAdapter(clients.sqs), new AzureStorageAdapter(), new AzureDatabaseAdapter(), new GcpStorageAdapter(), From 739a4981dfa4c7fd2c396a683c9ee47582ab8bc9 Mon Sep 17 00:00:00 2001 From: Luis Felipe Date: Fri, 17 Jul 2026 22:27:03 -0400 Subject: [PATCH 5/9] frontend integration for aws/sqs --- packages/frontend/src/api/api.ts | 40 ++ packages/frontend/src/api/cloudProxyClient.ts | 66 ++- .../src/components/DynamicResourceView.tsx | 9 + packages/frontend/src/components/Layout.tsx | 3 +- .../src/components/QueueFlowPanel.tsx | 400 ++++++++++++++++++ packages/frontend/src/index.css | 257 +++++++++++ .../frontend/src/pages/CloudExplorerPage.tsx | 3 +- packages/frontend/src/types/cloud.ts | 2 +- packages/frontend/src/types/resource.ts | 12 +- 9 files changed, 787 insertions(+), 5 deletions(-) create mode 100644 packages/frontend/src/components/QueueFlowPanel.tsx diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index a45b849..27fb478 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -21,6 +21,14 @@ export const apiEndpointKeys = { delete: "clouds.services.resources.delete", invoke: "clouds.services.resources.invoke", }, + queue: { + messages: { + send: "clouds.services.queue.messages.send", + receive: "clouds.services.queue.messages.receive", + delete: "clouds.services.queue.messages.delete", + }, + purge: "clouds.services.queue.purge", + }, storage: { objects: { list: "clouds.services.storage.objects.list", @@ -252,6 +260,38 @@ export const endpointRegistry: EndpointRegistry = new Map([ }, ], + [ + apiEndpointKeys.clouds.queue.messages.send, + { + path: "/clouds/:cloud/services/queue/resources/:id/messages", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.queue.messages.receive, + { + path: "/clouds/:cloud/services/queue/resources/:id/messages", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.queue.messages.delete, + { + path: "/clouds/:cloud/services/queue/resources/:id/messages/delete", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.queue.purge, + { + path: "/clouds/:cloud/services/queue/resources/:id/purge", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], [ apiEndpointKeys.clouds.storage.objects.list, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 9bd9fc7..a3cf4f6 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -6,7 +6,7 @@ import type { CloudServiceType, CloudStatus, } from "@/types/cloud"; -import type { CloudResource, CosmosContainer, CosmosItem, CosmosQueryResult, StorageObjectList } from "@/types/resource"; +import type { CloudResource, CosmosContainer, CosmosItem, CosmosQueryResult, QueueMessage, StorageObjectList } from "@/types/resource"; import type { ServiceSchema } from "@/types/schema"; import { getAccountId } from "@/lib/accountStore"; @@ -144,6 +144,70 @@ export async function invokeCloudResource( return res.data; } +export async function sendQueueMessage( + cloud: CloudProvider, + queueId: string, + body: string, + messageAttributes?: Record, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call< + QueueMessage, + { body: string; messageAttributes?: Record } + >( + apiEndpointKeys.clouds.queue.messages.send, + requestOptions(cloud, "queue", { + signal, + body: { body, messageAttributes }, + }), + { cloud, id: queueId }, + ); + return res.data; +} + +export async function receiveQueueMessages( + cloud: CloudProvider, + queueId: string, + maxMessages?: number, + waitTimeSeconds?: number, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.queue.messages.receive, + requestOptions(cloud, "queue", { + signal, + params: { maxMessages, waitTimeSeconds }, + }), + { cloud, id: queueId }, + ); + return res.data; +} + +export async function deleteQueueMessage( + cloud: CloudProvider, + queueId: string, + receiptHandle: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.queue.messages.delete, + requestOptions(cloud, "queue", { signal, body: { receiptHandle } }), + { cloud, id: queueId }, + ); +} + +export async function purgeQueue( + cloud: CloudProvider, + queueId: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.queue.purge, + requestOptions(cloud, "queue", { signal }), + { cloud, id: queueId }, + ); +} + export async function listStorageObjects( cloud: CloudProvider, resourceId: string, diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 706b77b..198578c 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -31,6 +31,7 @@ import type { CloudResource, StorageObject } from "@/types/resource"; import type { ServiceSchema } from "@/types/schema"; import { CosmosNoSqlPanel } from "@/components/CosmosNoSqlPanel"; import { ServerlessInvokePanel } from "@/components/ServerlessInvokePanel"; +import { QueueFlowPanel } from "@/components/QueueFlowPanel"; interface DynamicResourceViewProps { cloud: CloudProvider; @@ -325,6 +326,13 @@ export function DynamicResourceView({ runtimeReachable={canUseRuntime} /> )} + {service === "queue" && ( + + )} ); } @@ -355,6 +363,7 @@ function resourceCreateLabel(schema: ServiceSchema): string { return "Create container"; if (schema.cloud === "azure" && schema.service === "database") return "Create database"; + if (schema.service === "queue") return "Create queue"; return "Create resource"; } diff --git a/packages/frontend/src/components/Layout.tsx b/packages/frontend/src/components/Layout.tsx index 31a1891..2a10d65 100644 --- a/packages/frontend/src/components/Layout.tsx +++ b/packages/frontend/src/components/Layout.tsx @@ -52,7 +52,7 @@ const CLOUD_SERVICE_ITEMS: Array<{name: CloudSidebarService; label: string; rout {name: 'networking', label: 'Networking', route: 'networking'}, {name: 'secretsmanager', label: 'Secrets Manager', route: '/secretsmanager'}, {name: 'serverless', label: 'Serverless', route: 'serverless'}, - {name: 'queue', label: 'Queue'}, + {name: 'queue', label: 'Queue', route: 'queue'}, {name: 'function', label: 'Function'}, ] @@ -71,6 +71,7 @@ function CloudServiceNav() { || (service.name === 'database' && (cloud === 'aws' || cloud === 'azure')) || ((service.name === 'k8s' || service.name === 'compute' || service.name === 'networking') && cloud === 'aws') || (service.name === 'serverless' && (cloud === 'aws' || cloud === 'azure')) + || (service.name === 'queue' && cloud === 'aws') if (service.route && available) { const target = service.route.startsWith('/') ? service.route : `/cloud-explorer/${cloud}/${service.route}` return diff --git a/packages/frontend/src/components/QueueFlowPanel.tsx b/packages/frontend/src/components/QueueFlowPanel.tsx new file mode 100644 index 0000000..8bb6c6c --- /dev/null +++ b/packages/frontend/src/components/QueueFlowPanel.tsx @@ -0,0 +1,400 @@ +import { useEffect, useState } from "react"; +import { + AlertTriangle, + Eraser, + Inbox, + Loader2, + Plus, + RefreshCw, + Send, + Trash2, + Users, + X, +} from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + deleteQueueMessage, + getCloudResource, + purgeQueue, + receiveQueueMessages, + sendQueueMessage, +} from "@/api/cloudProxyClient"; +import type { CloudProvider } from "@/types/cloud"; +import type { CloudResource, QueueMessage } from "@/types/resource"; + +interface QueueFlowPanelProps { + cloud: CloudProvider; + resource?: CloudResource; + runtimeReachable: boolean; +} + +interface AttributeRow { + key: string; + value: string; +} + +interface RedrivePolicy { + deadLetterTargetArn?: string; + maxReceiveCount?: number; +} + +export function QueueFlowPanel({ + cloud, + resource, + runtimeReachable, +}: QueueFlowPanelProps) { + const qc = useQueryClient(); + const queueId = resource?.id; + + const [sendBody, setSendBody] = useState('{\n \n}'); + const [attributeRows, setAttributeRows] = useState([]); + const [longPoll, setLongPoll] = useState(false); + const [maxMessages, setMaxMessages] = useState(5); + const [messages, setMessages] = useState([]); + const [purgeConfirm, setPurgeConfirm] = useState(false); + const [lastSentId, setLastSentId] = useState(null); + + useEffect(() => { + setSendBody('{\n \n}'); + setAttributeRows([]); + setMessages([]); + setPurgeConfirm(false); + setLastSentId(null); + }, [queueId]); + + const detailKey = ["queue-detail", cloud, queueId]; + const detailQuery = useQuery({ + queryKey: detailKey, + queryFn: ({ signal }) => + getCloudResource(cloud, "queue", queueId ?? "", signal), + enabled: Boolean(queueId) && runtimeReachable, + refetchInterval: 4000, + }); + + const live = detailQuery.data ?? resource; + const isQueue = resource?.service === "queue"; + + const sendMut = useMutation({ + mutationFn: () => + sendQueueMessage(cloud, queueId ?? "", sendBody, attributesRecord(attributeRows)), + onSuccess: (message) => { + setLastSentId(message.id); + void qc.invalidateQueries({ queryKey: detailKey }); + }, + }); + + const receiveMut = useMutation({ + mutationFn: () => + receiveQueueMessages(cloud, queueId ?? "", maxMessages, longPoll ? 5 : 0), + onSuccess: (received) => { + if (received.length === 0) return; + setMessages((current) => { + const seen = new Set(current.map((m) => m.receiptHandle)); + return [...received.filter((m) => !seen.has(m.receiptHandle)), ...current]; + }); + void qc.invalidateQueries({ queryKey: detailKey }); + }, + }); + + const deleteMut = useMutation({ + mutationFn: (message: QueueMessage) => + deleteQueueMessage(cloud, queueId ?? "", message.receiptHandle), + onSuccess: (_, message) => { + setMessages((current) => current.filter((m) => m.receiptHandle !== message.receiptHandle)); + void qc.invalidateQueries({ queryKey: detailKey }); + }, + }); + + const purgeMut = useMutation({ + mutationFn: () => purgeQueue(cloud, queueId ?? ""), + onSuccess: () => { + setMessages([]); + setPurgeConfirm(false); + void qc.invalidateQueries({ queryKey: detailKey }); + }, + }); + + if (!resource || !isQueue) { + return ( +
+
+

Select a queue

+

Select an SQS queue to send and receive messages.

+
+
+ ); + } + + const messagesAvailable = numberMetadata(live?.metadata.messagesAvailable); + const messagesInFlight = numberMetadata(live?.metadata.messagesInFlight); + const messagesDelayed = numberMetadata(live?.metadata.messagesDelayed); + const fifo = Boolean(live?.metadata.fifo); + const redrivePolicy = live?.metadata.redrivePolicy as RedrivePolicy | null | undefined; + const dlqName = redrivePolicy?.deadLetterTargetArn?.split(":").pop() ?? null; + + const canOperate = Boolean(queueId) && runtimeReachable; + + return ( +
+
+
+

Queue Actions

+

+ + Send and receive messages +

+

+ {resource.name} {fifo ? "· FIFO queue" : "· Standard queue"} +

+
+
+ + {canOperate ? "Ready" : "Runtime unavailable"} + + {purgeConfirm ? ( + + ) : ( + + )} +
+
+ +
+
+
+ +
+ Producer + Your application +
+ +
+ +
+ +
+
+ +
+ {resource.name} + Amazon SQS +
+
+ {messagesAvailable} + Available +
+
+ {messagesInFlight} + In flight +
+
+ {messagesDelayed} + Delayed +
+
+
+ +
0 ? "flow-dot-active" : "" + }`} + > + +
+ +
+
+ +
+ Consumer + Your application +
+ + {redrivePolicy && ( +
+
+
+ +
+ {dlqName ?? "Dead-letter queue"} + + After {redrivePolicy.maxReceiveCount ?? "?"} failed receives + +
+
+ )} +
+ +
+
+
+

+ + Send a message +

+
+