From c342d1b2406a5453959f64ea9d5760ec6e6306c4 Mon Sep 17 00:00:00 2001 From: Francesco Giovannini Date: Wed, 8 Apr 2026 15:38:33 +0200 Subject: [PATCH] feat: add S3 folder management and SQS queue attribute editing Add create and delete folder operations for S3 object browser, update queue attributes support for SQS, and fix test coverage for new endpoints. - S3: create folder (empty object with trailing slash) and delete folder (recursive batch delete of all objects under prefix) - SQS: update queue attributes (visibility timeout, delay, etc.) - Frontend: folder create/delete dialogs in ObjectBrowser, queue edit attributes dialog in QueueDetail - Fix missing useUpdateQueueAttributes mock in QueueDetail tests - Stub window.scrollTo in jsdom test setup --- packages/backend/src/plugins/s3/routes.ts | 45 +++++ packages/backend/src/plugins/s3/schemas.ts | 4 + packages/backend/src/plugins/s3/service.ts | 48 +++++ packages/backend/src/plugins/sqs/routes.ts | 24 +++ packages/backend/src/plugins/sqs/schemas.ts | 19 ++ packages/backend/src/plugins/sqs/service.ts | 36 ++++ packages/backend/test/plugins/s3.test.ts | 65 +++++++ .../backend/test/plugins/s3/service.test.ts | 113 ++++++++++++ .../backend/test/plugins/sqs/routes.test.ts | 34 ++++ .../backend/test/plugins/sqs/service.test.ts | 64 +++++++ packages/frontend/src/api/s3.ts | 31 ++++ packages/frontend/src/api/sqs.ts | 24 +++ .../src/components/s3/FolderCreateDialog.tsx | 84 +++++++++ .../src/components/s3/ObjectBrowser.tsx | 75 +++++++- .../src/components/sqs/QueueDetail.tsx | 41 ++++- .../sqs/QueueEditAttributesDialog.tsx | 170 ++++++++++++++++++ .../test/components/sqs/QueueDetail.test.tsx | 9 + packages/frontend/test/setup.ts | 3 + packages/frontend/tsconfig.tsbuildinfo | 2 +- 19 files changed, 886 insertions(+), 5 deletions(-) create mode 100644 packages/frontend/src/components/s3/FolderCreateDialog.tsx create mode 100644 packages/frontend/src/components/sqs/QueueEditAttributesDialog.tsx diff --git a/packages/backend/src/plugins/s3/routes.ts b/packages/backend/src/plugins/s3/routes.ts index 119f265..2dfdc4e 100644 --- a/packages/backend/src/plugins/s3/routes.ts +++ b/packages/backend/src/plugins/s3/routes.ts @@ -4,6 +4,7 @@ import { BucketListResponseSchema, BucketParamsSchema, CreateBucketBodySchema, + CreateFolderBodySchema, DeleteResponseSchema, ListObjectsQuerySchema, ListObjectsResponseSchema, @@ -108,6 +109,50 @@ export async function s3Routes(app: FastifyInstance) { }, }); + // Create folder + app.post("/:bucketName/objects/folder", { + schema: { + params: BucketParamsSchema, + body: CreateFolderBodySchema, + response: { + 201: UploadResponseSchema, + 404: ErrorResponseSchema, + }, + }, + handler: async (request, reply) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new S3Service(clients.s3); + const { bucketName } = request.params as { bucketName: string }; + const { name } = request.body as { name: string }; + const result = await service.createFolder(bucketName, name); + return reply.status(201).send(result); + }, + }); + + // Delete folder + app.delete("/:bucketName/objects/folder", { + schema: { + params: BucketParamsSchema, + querystring: ObjectKeyQuerySchema, + response: { + 200: DeleteResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new S3Service(clients.s3); + const { bucketName } = request.params as { bucketName: string }; + const { key } = request.query as { key: string }; + return service.deleteFolder(bucketName, key); + }, + }); + // Get object properties app.get("/:bucketName/objects/properties", { schema: { diff --git a/packages/backend/src/plugins/s3/schemas.ts b/packages/backend/src/plugins/s3/schemas.ts index 6548b27..cee82ae 100644 --- a/packages/backend/src/plugins/s3/schemas.ts +++ b/packages/backend/src/plugins/s3/schemas.ts @@ -18,6 +18,10 @@ export const BucketParamsSchema = Type.Object({ bucketName: Type.String(), }); +export const CreateFolderBodySchema = Type.Object({ + name: Type.String({ minLength: 1, maxLength: 1024 }), +}); + export const ListObjectsQuerySchema = Type.Object({ prefix: Type.Optional(Type.String()), delimiter: Type.Optional(Type.String()), diff --git a/packages/backend/src/plugins/s3/service.ts b/packages/backend/src/plugins/s3/service.ts index 989f546..7101d95 100644 --- a/packages/backend/src/plugins/s3/service.ts +++ b/packages/backend/src/plugins/s3/service.ts @@ -2,6 +2,7 @@ import { CreateBucketCommand, DeleteBucketCommand, DeleteObjectCommand, + DeleteObjectsCommand, GetObjectCommand, HeadObjectCommand, ListBucketsCommand, @@ -139,6 +140,53 @@ export class S3Service { } } + async createFolder(bucket: string, folderName: string) { + const key = folderName.endsWith("/") ? folderName : `${folderName}/`; + await this.client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: "", + ContentType: "application/x-directory", + }), + ); + return { key, bucket }; + } + + async deleteFolder(bucket: string, prefix: string) { + const folderPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`; + let continuationToken: string | undefined; + + do { + const listResponse = await this.client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: folderPrefix, + ContinuationToken: continuationToken, + }), + ); + + const objects = listResponse.Contents ?? []; + if (objects.length > 0) { + await this.client.send( + new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: objects.map((obj) => ({ Key: obj.Key })), + Quiet: true, + }, + }), + ); + } + + continuationToken = listResponse.IsTruncated + ? listResponse.NextContinuationToken + : undefined; + } while (continuationToken); + + return { success: true }; + } + async uploadObject( bucket: string, key: string, diff --git a/packages/backend/src/plugins/sqs/routes.ts b/packages/backend/src/plugins/sqs/routes.ts index 336a5a0..4506312 100644 --- a/packages/backend/src/plugins/sqs/routes.ts +++ b/packages/backend/src/plugins/sqs/routes.ts @@ -12,6 +12,8 @@ import { ReceiveMessagesResponseSchema, SendMessageBodySchema, SendMessageResponseSchema, + type UpdateQueueAttributesBody, + UpdateQueueAttributesBodySchema, } from "./schemas.js"; import { SQSService } from "./service.js"; @@ -119,6 +121,28 @@ export async function sqsRoutes(app: FastifyInstance) { }, }); + // Update queue attributes + app.put("/:queueName/attributes", { + schema: { + params: QueueParamsSchema, + body: UpdateQueueAttributesBodySchema, + response: { + 200: DeleteResponseSchema, + 404: ErrorResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new SQSService(clients.sqs); + const { queueName } = request.params as { queueName: string }; + const body = request.body as UpdateQueueAttributesBody; + return service.updateQueueAttributes(queueName, body); + }, + }); + // Send message app.post("/:queueName/messages", { schema: { diff --git a/packages/backend/src/plugins/sqs/schemas.ts b/packages/backend/src/plugins/sqs/schemas.ts index 460ed1e..708e3a1 100644 --- a/packages/backend/src/plugins/sqs/schemas.ts +++ b/packages/backend/src/plugins/sqs/schemas.ts @@ -40,6 +40,25 @@ export const QueueDetailResponseSchema = Type.Object({ }); export type QueueDetailResponse = Static; +export const UpdateQueueAttributesBodySchema = Type.Object({ + delaySeconds: Type.Optional(Type.Integer({ minimum: 0, maximum: 900 })), + maximumMessageSize: Type.Optional( + Type.Integer({ minimum: 1024, maximum: 262144 }), + ), + messageRetentionPeriod: Type.Optional( + Type.Integer({ minimum: 60, maximum: 1209600 }), + ), + receiveMessageWaitTimeSeconds: Type.Optional( + Type.Integer({ minimum: 0, maximum: 20 }), + ), + visibilityTimeout: Type.Optional( + Type.Integer({ minimum: 0, maximum: 43200 }), + ), +}); +export type UpdateQueueAttributesBody = Static< + typeof UpdateQueueAttributesBodySchema +>; + export const MessageAttributeSchema = Type.Object({ name: Type.String(), dataType: Type.String(), diff --git a/packages/backend/src/plugins/sqs/service.ts b/packages/backend/src/plugins/sqs/service.ts index 05ccf17..a52a625 100644 --- a/packages/backend/src/plugins/sqs/service.ts +++ b/packages/backend/src/plugins/sqs/service.ts @@ -9,6 +9,7 @@ import { PurgeQueueCommand, ReceiveMessageCommand, SendMessageCommand, + SetQueueAttributesCommand, type SQSClient, } from "@aws-sdk/client-sqs"; import { AppError } from "../../shared/errors.js"; @@ -161,6 +162,41 @@ export class SQSService { } } + async updateQueueAttributes( + queueName: string, + attributes: { + delaySeconds?: number; + maximumMessageSize?: number; + messageRetentionPeriod?: number; + receiveMessageWaitTimeSeconds?: number; + visibilityTimeout?: number; + }, + ) { + const queueUrl = await this.getQueueUrl(queueName); + const attrs: Record = {}; + if (attributes.delaySeconds !== undefined) + attrs.DelaySeconds = String(attributes.delaySeconds); + if (attributes.maximumMessageSize !== undefined) + attrs.MaximumMessageSize = String(attributes.maximumMessageSize); + if (attributes.messageRetentionPeriod !== undefined) + attrs.MessageRetentionPeriod = String(attributes.messageRetentionPeriod); + if (attributes.receiveMessageWaitTimeSeconds !== undefined) + attrs.ReceiveMessageWaitTimeSeconds = String( + attributes.receiveMessageWaitTimeSeconds, + ); + if (attributes.visibilityTimeout !== undefined) + attrs.VisibilityTimeout = String(attributes.visibilityTimeout); + + await this.client.send( + new SetQueueAttributesCommand({ + QueueUrl: queueUrl, + Attributes: attrs, + }), + ); + + return { success: true }; + } + async sendMessage( queueName: string, body: string, diff --git a/packages/backend/test/plugins/s3.test.ts b/packages/backend/test/plugins/s3.test.ts index 9180305..5b7961a 100644 --- a/packages/backend/test/plugins/s3.test.ts +++ b/packages/backend/test/plugins/s3.test.ts @@ -18,6 +18,8 @@ interface MockS3Service { createBucket: Mock; deleteBucket: Mock; listObjects: Mock; + createFolder: Mock; + deleteFolder: Mock; getObjectProperties: Mock; uploadObject: Mock; downloadObject: Mock; @@ -37,6 +39,10 @@ function createMockS3Service(): MockS3Service { message: "Bucket 'new-bucket' created successfully", }), deleteBucket: vi.fn().mockResolvedValue({ success: true }), + createFolder: vi + .fn() + .mockResolvedValue({ key: "new-folder/", bucket: "test-bucket" }), + deleteFolder: vi.fn().mockResolvedValue({ success: true }), listObjects: vi.fn().mockResolvedValue({ objects: [ { @@ -239,6 +245,65 @@ describe("S3 Routes", () => { }); }); + describe("POST /:bucketName/objects/folder (createFolder)", () => { + it("should create a folder and return 201", async () => { + const response = await app.inject({ + method: "POST", + url: "/test-bucket/objects/folder", + payload: { name: "new-folder" }, + }); + expect(response.statusCode).toBe(201); + const body = response.json<{ key: string; bucket: string }>(); + expect(body.key).toBe("new-folder/"); + expect(body.bucket).toBe("test-bucket"); + expect(mockService.createFolder).toHaveBeenCalledWith( + "test-bucket", + "new-folder", + ); + }); + + it("should return 400 for missing name in body", async () => { + const response = await app.inject({ + method: "POST", + url: "/test-bucket/objects/folder", + payload: {}, + }); + expect(response.statusCode).toBe(400); + }); + + it("should return 400 for empty body", async () => { + const response = await app.inject({ + method: "POST", + url: "/test-bucket/objects/folder", + }); + expect(response.statusCode).toBe(400); + }); + }); + + describe("DELETE /:bucketName/objects/folder (deleteFolder)", () => { + it("should delete a folder and return success", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/test-bucket/objects/folder?key=my-folder/", + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ success: boolean }>(); + expect(body.success).toBe(true); + expect(mockService.deleteFolder).toHaveBeenCalledWith( + "test-bucket", + "my-folder/", + ); + }); + + it("should return 400 when key query param is missing", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/test-bucket/objects/folder", + }); + expect(response.statusCode).toBe(400); + }); + }); + describe("GET /:bucketName/objects/properties (getObjectProperties)", () => { it("should return object properties", async () => { const response = await app.inject({ diff --git a/packages/backend/test/plugins/s3/service.test.ts b/packages/backend/test/plugins/s3/service.test.ts index 3d472d9..518991c 100644 --- a/packages/backend/test/plugins/s3/service.test.ts +++ b/packages/backend/test/plugins/s3/service.test.ts @@ -425,6 +425,119 @@ describe("S3Service", () => { }); }); + describe("createFolder", () => { + it("creates a folder with trailing slash and returns key and bucket", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createFolder("my-bucket", "new-folder"); + + expect(result).toEqual({ key: "new-folder/", bucket: "my-bucket" }); + expect(client.send).toHaveBeenCalledOnce(); + const putCall = (client.send as ReturnType).mock + .calls[0][0]; + expect(putCall.input).toMatchObject({ + Bucket: "my-bucket", + Key: "new-folder/", + Body: "", + ContentType: "application/x-directory", + }); + }); + + it("does not double trailing slash when name already ends with /", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createFolder("my-bucket", "folder/"); + + expect(result).toEqual({ key: "folder/", bucket: "my-bucket" }); + const putCall = (client.send as ReturnType).mock + .calls[0][0]; + expect(putCall.input.Key).toBe("folder/"); + }); + + it("supports nested folder paths", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createFolder( + "my-bucket", + "parent/child/subfolder", + ); + + expect(result).toEqual({ + key: "parent/child/subfolder/", + bucket: "my-bucket", + }); + }); + }); + + describe("deleteFolder", () => { + it("deletes all objects under a folder prefix", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ + Contents: [{ Key: "folder/file1.txt" }, { Key: "folder/file2.txt" }], + IsTruncated: false, + }) + .mockResolvedValueOnce({}); // DeleteObjectsCommand + + const result = await service.deleteFolder("my-bucket", "folder"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledTimes(2); + const deleteCall = (client.send as ReturnType).mock + .calls[1][0]; + expect(deleteCall.input).toMatchObject({ + Bucket: "my-bucket", + Delete: { + Objects: [{ Key: "folder/file1.txt" }, { Key: "folder/file2.txt" }], + Quiet: true, + }, + }); + }); + + it("handles pagination when deleting many objects", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ + Contents: [{ Key: "folder/file1.txt" }], + IsTruncated: true, + NextContinuationToken: "token-1", + }) + .mockResolvedValueOnce({}) // DeleteObjectsCommand for first batch + .mockResolvedValueOnce({ + Contents: [{ Key: "folder/file2.txt" }], + IsTruncated: false, + }) + .mockResolvedValueOnce({}); // DeleteObjectsCommand for second batch + + const result = await service.deleteFolder("my-bucket", "folder/"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledTimes(4); + }); + + it("handles empty folder (no objects to delete)", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Contents: [], + IsTruncated: false, + }); + + const result = await service.deleteFolder("my-bucket", "empty-folder"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledTimes(1); + }); + + it("does not double trailing slash when prefix already ends with /", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + IsTruncated: false, + }); + + await service.deleteFolder("my-bucket", "folder/"); + + const listCall = (client.send as ReturnType).mock + .calls[0][0]; + expect(listCall.input.Prefix).toBe("folder/"); + }); + }); + describe("uploadObject", () => { it("uploads an object and returns key and bucket", async () => { (client.send as ReturnType).mockResolvedValueOnce({}); diff --git a/packages/backend/test/plugins/sqs/routes.test.ts b/packages/backend/test/plugins/sqs/routes.test.ts index 989aeb4..38d2b9d 100644 --- a/packages/backend/test/plugins/sqs/routes.test.ts +++ b/packages/backend/test/plugins/sqs/routes.test.ts @@ -19,6 +19,7 @@ interface MockSQSService { deleteQueue: Mock; purgeQueue: Mock; getQueueDetail: Mock; + updateQueueAttributes: Mock; sendMessage: Mock; receiveMessages: Mock; deleteMessage: Mock; @@ -49,6 +50,7 @@ function createMockSQSService(): MockSQSService { delaySeconds: 0, receiveMessageWaitTimeSeconds: 0, }), + updateQueueAttributes: vi.fn().mockResolvedValue({ success: true }), sendMessage: vi.fn().mockResolvedValue({ messageId: "msg-123" }), receiveMessages: vi.fn().mockResolvedValue([]), deleteMessage: vi.fn().mockResolvedValue({ success: true }), @@ -103,6 +105,38 @@ describe("SQS Routes", () => { await app.close(); }); + describe("PUT /:queueName/attributes (updateQueueAttributes)", () => { + it("should update queue attributes", async () => { + const response = await app.inject({ + method: "PUT", + url: "/test-queue/attributes", + payload: { + visibilityTimeout: 60, + delaySeconds: 5, + }, + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ success: boolean }>(); + expect(body.success).toBe(true); + expect(mockService.updateQueueAttributes).toHaveBeenCalledWith( + "test-queue", + expect.objectContaining({ + visibilityTimeout: 60, + delaySeconds: 5, + }), + ); + }); + + it("should accept empty attributes object", async () => { + const response = await app.inject({ + method: "PUT", + url: "/test-queue/attributes", + payload: {}, + }); + expect(response.statusCode).toBe(200); + }); + }); + describe("DELETE /:queueName/messages (deleteMessage)", () => { it("should delete a message with a receipt handle", async () => { const response = await app.inject({ diff --git a/packages/backend/test/plugins/sqs/service.test.ts b/packages/backend/test/plugins/sqs/service.test.ts index 0814d15..18204ba 100644 --- a/packages/backend/test/plugins/sqs/service.test.ts +++ b/packages/backend/test/plugins/sqs/service.test.ts @@ -505,6 +505,70 @@ describe("SQSService", () => { }); }); + describe("updateQueueAttributes", () => { + it("updates all provided attributes", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ + QueueUrl: "http://localhost:4566/000000000000/my-queue", + }) + .mockResolvedValueOnce({}); + + const result = await service.updateQueueAttributes("my-queue", { + delaySeconds: 10, + maximumMessageSize: 131072, + messageRetentionPeriod: 86400, + receiveMessageWaitTimeSeconds: 5, + visibilityTimeout: 60, + }); + + expect(result).toEqual({ success: true }); + const setCall = (client.send as ReturnType).mock + .calls[1][0]; + expect(setCall.input).toMatchObject({ + QueueUrl: "http://localhost:4566/000000000000/my-queue", + Attributes: { + DelaySeconds: "10", + MaximumMessageSize: "131072", + MessageRetentionPeriod: "86400", + ReceiveMessageWaitTimeSeconds: "5", + VisibilityTimeout: "60", + }, + }); + }); + + it("updates only provided attributes (partial update)", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ + QueueUrl: "http://localhost:4566/000000000000/my-queue", + }) + .mockResolvedValueOnce({}); + + await service.updateQueueAttributes("my-queue", { + visibilityTimeout: 45, + }); + + const setCall = (client.send as ReturnType).mock + .calls[1][0]; + expect(setCall.input.Attributes).toEqual({ + VisibilityTimeout: "45", + }); + }); + + it("sends empty attributes when none provided", async () => { + (client.send as ReturnType) + .mockResolvedValueOnce({ + QueueUrl: "http://localhost:4566/000000000000/my-queue", + }) + .mockResolvedValueOnce({}); + + await service.updateQueueAttributes("my-queue", {}); + + const setCall = (client.send as ReturnType).mock + .calls[1][0]; + expect(setCall.input.Attributes).toEqual({}); + }); + }); + describe("deleteMessage", () => { it("deletes a message with the given receipt handle", async () => { (client.send as ReturnType) diff --git a/packages/frontend/src/api/s3.ts b/packages/frontend/src/api/s3.ts index e9c848a..c7fdcaa 100644 --- a/packages/frontend/src/api/s3.ts +++ b/packages/frontend/src/api/s3.ts @@ -89,6 +89,37 @@ export function useObjectProperties(bucketName: string, key: string) { }); } +export function useCreateFolder(bucketName: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => + apiClient.post<{ key: string; bucket: string }>( + `/s3/${bucketName}/objects/folder`, + { name }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["s3", "objects", bucketName], + }); + }, + }); +} + +export function useDeleteFolder(bucketName: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (prefix: string) => + apiClient.delete<{ success: boolean }>( + `/s3/${bucketName}/objects/folder?key=${encodeURIComponent(prefix)}`, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["s3", "objects", bucketName], + }); + }, + }); +} + export function useUploadObject(bucketName: string) { const queryClient = useQueryClient(); return useMutation({ diff --git a/packages/frontend/src/api/sqs.ts b/packages/frontend/src/api/sqs.ts index 796ce48..e1d29ed 100644 --- a/packages/frontend/src/api/sqs.ts +++ b/packages/frontend/src/api/sqs.ts @@ -53,6 +53,14 @@ interface ReceiveMessagesResponse { messages: Message[]; } +interface UpdateQueueAttributesRequest { + delaySeconds?: number; + maximumMessageSize?: number; + messageRetentionPeriod?: number; + receiveMessageWaitTimeSeconds?: number; + visibilityTimeout?: number; +} + interface CreateQueueRequest { name: string; } @@ -143,6 +151,22 @@ export function useQueueSubscriptions(queueArn: string) { // --- Mutation hooks --- +export function useUpdateQueueAttributes(queueName: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: UpdateQueueAttributesRequest) => + apiClient.put<{ success: boolean }>( + `/sqs/${queueName}/attributes`, + request, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["sqs", "attributes", queueName], + }); + }, + }); +} + export function useCreateQueue() { const queryClient = useQueryClient(); return useMutation({ diff --git a/packages/frontend/src/components/s3/FolderCreateDialog.tsx b/packages/frontend/src/components/s3/FolderCreateDialog.tsx new file mode 100644 index 0000000..d330e7d --- /dev/null +++ b/packages/frontend/src/components/s3/FolderCreateDialog.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { useCreateFolder } from "@/api/s3"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; + +interface FolderCreateDialogProps { + bucketName: string; + prefix: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function FolderCreateDialog({ + bucketName, + prefix, + open, + onOpenChange, +}: FolderCreateDialogProps) { + const [name, setName] = useState(""); + const createFolder = useCreateFolder(bucketName); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + const folderKey = `${prefix}${name.trim()}`; + createFolder.mutate(folderKey, { + onSuccess: () => { + setName(""); + onOpenChange(false); + }, + }); + }; + + return ( + + + + Create Folder + + Create a new folder in {bucketName}/{prefix} + + +
+
+ setName(e.target.value)} + autoFocus + /> + {createFolder.isError && ( +

+ {createFolder.error.message} +

+ )} +
+ + + + +
+
+
+ ); +} diff --git a/packages/frontend/src/components/s3/ObjectBrowser.tsx b/packages/frontend/src/components/s3/ObjectBrowser.tsx index 73664a1..37e3df8 100644 --- a/packages/frontend/src/components/s3/ObjectBrowser.tsx +++ b/packages/frontend/src/components/s3/ObjectBrowser.tsx @@ -3,11 +3,17 @@ import { Download, FileText, Folder, + FolderPlus, Trash2, Upload, } from "lucide-react"; import { useState } from "react"; -import { getDownloadUrl, useDeleteObject, useListObjects } from "@/api/s3"; +import { + getDownloadUrl, + useDeleteFolder, + useDeleteObject, + useListObjects, +} from "@/api/s3"; import { Breadcrumb, BreadcrumbItem, @@ -32,6 +38,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { FolderCreateDialog } from "./FolderCreateDialog"; import { ObjectUploadDialog } from "./ObjectUploadDialog"; function formatBytes(bytes: number | undefined): string { @@ -49,10 +56,15 @@ interface ObjectBrowserProps { export function ObjectBrowser({ bucketName }: ObjectBrowserProps) { const [prefix, setPrefix] = useState(""); const [uploadOpen, setUploadOpen] = useState(false); + const [folderOpen, setFolderOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteFolderTarget, setDeleteFolderTarget] = useState( + null, + ); const { data, isLoading, error } = useListObjects(bucketName, prefix); const deleteObject = useDeleteObject(bucketName); + const deleteFolder = useDeleteFolder(bucketName); const pathParts = prefix.split("/").filter(Boolean); @@ -120,6 +132,10 @@ export function ObjectBrowser({ bucketName }: ObjectBrowserProps) { Back )} + + ))} {data?.objects @@ -200,6 +227,13 @@ export function ObjectBrowser({ bucketName }: ObjectBrowserProps) { onOpenChange={setUploadOpen} /> + + setDeleteTarget(null)}> @@ -228,6 +262,43 @@ export function ObjectBrowser({ bucketName }: ObjectBrowserProps) { + + setDeleteFolderTarget(null)} + > + + + Delete Folder + + Are you sure you want to delete folder " + {deleteFolderTarget?.replace(prefix, "").replace(/\/$/, "")} + " and all its contents? + + + + + + + + ); } diff --git a/packages/frontend/src/components/sqs/QueueDetail.tsx b/packages/frontend/src/components/sqs/QueueDetail.tsx index 7e76be5..7937f8e 100644 --- a/packages/frontend/src/components/sqs/QueueDetail.tsx +++ b/packages/frontend/src/components/sqs/QueueDetail.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { ArrowLeft, Trash2 } from "lucide-react"; +import { ArrowLeft, Pencil, Trash2 } from "lucide-react"; import { useState } from "react"; import { usePurgeQueue, useQueueAttributes } from "@/api/sqs"; import { Badge } from "@/components/ui/badge"; @@ -14,6 +14,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { MessageViewer } from "./MessageViewer"; +import { QueueEditAttributesDialog } from "./QueueEditAttributesDialog"; import { QueueSubscriptions } from "./QueueSubscriptions"; import { SendMessageForm } from "./SendMessageForm"; @@ -67,6 +68,7 @@ interface QueueDetailProps { export function QueueDetail({ queueName }: QueueDetailProps) { const [activeTab, setActiveTab] = useState("attributes"); const [purgeDialogOpen, setPurgeDialogOpen] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); const { data: attributes, isLoading, error } = useQueueAttributes(queueName); const purgeQueue = usePurgeQueue(); @@ -158,8 +160,16 @@ export function QueueDetail({ queueName }: QueueDetailProps) { {/* Tab: Attributes */} {activeTab === "attributes" && ( - + Queue Attributes +
@@ -207,6 +217,16 @@ export function QueueDetail({ queueName }: QueueDetailProps) { : undefined } /> + )} + {/* Edit attributes dialog */} + {attributes && ( + + )} + {/* Purge confirmation dialog */} diff --git a/packages/frontend/src/components/sqs/QueueEditAttributesDialog.tsx b/packages/frontend/src/components/sqs/QueueEditAttributesDialog.tsx new file mode 100644 index 0000000..10eb9ba --- /dev/null +++ b/packages/frontend/src/components/sqs/QueueEditAttributesDialog.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from "react"; +import { useUpdateQueueAttributes } from "@/api/sqs"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface QueueAttributes { + delaySeconds: number; + maximumMessageSize: number; + messageRetentionPeriod: number; + receiveMessageWaitTimeSeconds: number; + visibilityTimeout: number; +} + +interface QueueEditAttributesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + queueName: string; + currentAttributes: QueueAttributes; +} + +const FIELDS: { + key: keyof QueueAttributes; + label: string; + min: number; + max: number; + unit: string; +}[] = [ + { + key: "delaySeconds", + label: "Delay Seconds", + min: 0, + max: 900, + unit: "seconds (0–900)", + }, + { + key: "visibilityTimeout", + label: "Visibility Timeout", + min: 0, + max: 43200, + unit: "seconds (0–43200)", + }, + { + key: "maximumMessageSize", + label: "Maximum Message Size", + min: 1024, + max: 262144, + unit: "bytes (1024–262144)", + }, + { + key: "messageRetentionPeriod", + label: "Message Retention Period", + min: 60, + max: 1209600, + unit: "seconds (60–1209600)", + }, + { + key: "receiveMessageWaitTimeSeconds", + label: "Receive Message Wait Time", + min: 0, + max: 20, + unit: "seconds (0–20)", + }, +]; + +export function QueueEditAttributesDialog({ + open, + onOpenChange, + queueName, + currentAttributes, +}: QueueEditAttributesDialogProps) { + const [values, setValues] = useState(currentAttributes); + const updateAttributes = useUpdateQueueAttributes(queueName); + + useEffect(() => { + if (open) { + setValues(currentAttributes); + } + }, [open, currentAttributes]); + + const handleChange = (key: keyof QueueAttributes, raw: string) => { + const num = Number(raw); + if (!Number.isNaN(num)) { + setValues((prev) => ({ ...prev, [key]: num })); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const changed: Partial = {}; + for (const field of FIELDS) { + if (values[field.key] !== currentAttributes[field.key]) { + changed[field.key] = values[field.key]; + } + } + + if (Object.keys(changed).length === 0) { + onOpenChange(false); + return; + } + + updateAttributes.mutate(changed, { + onSuccess: () => onOpenChange(false), + }); + }; + + const isValid = FIELDS.every( + (f) => values[f.key] >= f.min && values[f.key] <= f.max, + ); + + return ( + + + + Edit Queue Attributes + + Update the configuration for {queueName}. + + +
+
+ {FIELDS.map((field) => ( +
+ + handleChange(field.key, e.target.value)} + /> +

{field.unit}

+
+ ))} + {updateAttributes.isError && ( +

+ {updateAttributes.error.message} +

+ )} +
+ + + + +
+
+
+ ); +} diff --git a/packages/frontend/test/components/sqs/QueueDetail.test.tsx b/packages/frontend/test/components/sqs/QueueDetail.test.tsx index d256579..8a2f23d 100644 --- a/packages/frontend/test/components/sqs/QueueDetail.test.tsx +++ b/packages/frontend/test/components/sqs/QueueDetail.test.tsx @@ -16,6 +16,7 @@ const mockUsePurgeQueue = vi.fn(); const mockUseReceiveMessages = vi.fn(); const mockUseDeleteMessage = vi.fn(); const mockUseSendMessage = vi.fn(); +const mockUseUpdateQueueAttributes = vi.fn(); vi.mock("../../../src/api/sqs", () => ({ useQueueAttributes: () => mockUseQueueAttributes(), @@ -23,6 +24,7 @@ vi.mock("../../../src/api/sqs", () => ({ useReceiveMessages: () => mockUseReceiveMessages(), useDeleteMessage: () => mockUseDeleteMessage(), useSendMessage: () => mockUseSendMessage(), + useUpdateQueueAttributes: () => mockUseUpdateQueueAttributes(), })); const TEST_QUEUE_NAME = "test-queue"; @@ -86,6 +88,13 @@ describe("QueueDetail", () => { error: null, isSuccess: false, }); + mockUseUpdateQueueAttributes.mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + isSuccess: false, + }); }); it("should render queue attributes", async () => { diff --git a/packages/frontend/test/setup.ts b/packages/frontend/test/setup.ts index f149f27..3c18887 100644 --- a/packages/frontend/test/setup.ts +++ b/packages/frontend/test/setup.ts @@ -1 +1,4 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom does not implement window.scrollTo +window.scrollTo = (() => {}) as typeof window.scrollTo; diff --git a/packages/frontend/tsconfig.tsbuildinfo b/packages/frontend/tsconfig.tsbuildinfo index 76f1e07..7b3e5ce 100644 --- a/packages/frontend/tsconfig.tsbuildinfo +++ b/packages/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/api/cloudformation.ts","./src/api/config.ts","./src/api/dynamodb.ts","./src/api/iam.ts","./src/api/lambda.ts","./src/api/s3.ts","./src/api/services.ts","./src/api/sns.ts","./src/api/sqs.ts","./src/components/cloudformation/eventstimeline.tsx","./src/components/cloudformation/resourcelist.tsx","./src/components/cloudformation/stackcreatedialog.tsx","./src/components/cloudformation/stackdetail.tsx","./src/components/cloudformation/stackfilters.tsx","./src/components/cloudformation/stacklist.tsx","./src/components/cloudformation/stackupdatedialog.tsx","./src/components/cloudformation/templateviewer.tsx","./src/components/dynamodb/batchoperations.tsx","./src/components/dynamodb/creategsidialog.tsx","./src/components/dynamodb/createtabledialog.tsx","./src/components/dynamodb/indexmanager.tsx","./src/components/dynamodb/itembrowser.tsx","./src/components/dynamodb/itemeditordialog.tsx","./src/components/dynamodb/partiqleditor.tsx","./src/components/dynamodb/querybuilder.tsx","./src/components/dynamodb/streamviewer.tsx","./src/components/dynamodb/tabledetail.tsx","./src/components/dynamodb/tablelist.tsx","./src/components/iam/creategroupdialog.tsx","./src/components/iam/createpolicydialog.tsx","./src/components/iam/createuserdialog.tsx","./src/components/iam/groupdetail.tsx","./src/components/iam/grouplist.tsx","./src/components/iam/policydetail.tsx","./src/components/iam/policylist.tsx","./src/components/iam/userdetail.tsx","./src/components/iam/userlist.tsx","./src/components/lambda/functioncreatedialog.tsx","./src/components/lambda/functiondetail.tsx","./src/components/lambda/functionlist.tsx","./src/components/lambda/invokefunctionform.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/s3/bucketcreatedialog.tsx","./src/components/s3/bucketlist.tsx","./src/components/s3/objectbrowser.tsx","./src/components/s3/objectuploaddialog.tsx","./src/components/settings/connectionguard.tsx","./src/components/settings/endpointmodal.tsx","./src/components/settings/regionselector.tsx","./src/components/sns/filterpolicydialog.tsx","./src/components/sns/publishmessageform.tsx","./src/components/sns/subscriptioncreatedialog.tsx","./src/components/sns/subscriptionlist.tsx","./src/components/sns/tagmanager.tsx","./src/components/sns/topicattributes.tsx","./src/components/sns/topiccreatedialog.tsx","./src/components/sns/topicdetail.tsx","./src/components/sns/topiclist.tsx","./src/components/sqs/messageviewer.tsx","./src/components/sqs/queuecreatedialog.tsx","./src/components/sqs/queuedetail.tsx","./src/components/sqs/queuelist.tsx","./src/components/sqs/queuesubscriptions.tsx","./src/components/sqs/sendmessageform.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/index.tsx","./src/routes/cloudformation/$stackname.tsx","./src/routes/cloudformation/index.tsx","./src/routes/dynamodb/$tablename.tsx","./src/routes/dynamodb/index.tsx","./src/routes/iam/index.tsx","./src/routes/iam/groups/$groupname.tsx","./src/routes/iam/policies/$policyarn.tsx","./src/routes/iam/users/$username.tsx","./src/routes/lambda/$functionname.tsx","./src/routes/lambda/index.tsx","./src/routes/s3/$bucketname.tsx","./src/routes/s3/index.tsx","./src/routes/sns/$topicname.tsx","./src/routes/sns/index.tsx","./src/routes/sqs/$queuename.tsx","./src/routes/sqs/index.tsx","./src/stores/app.ts","./src/stores/config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/api/cloudformation.ts","./src/api/config.ts","./src/api/dynamodb.ts","./src/api/iam.ts","./src/api/lambda.ts","./src/api/s3.ts","./src/api/services.ts","./src/api/sns.ts","./src/api/sqs.ts","./src/components/cloudformation/eventstimeline.tsx","./src/components/cloudformation/resourcelist.tsx","./src/components/cloudformation/stackcreatedialog.tsx","./src/components/cloudformation/stackdetail.tsx","./src/components/cloudformation/stackfilters.tsx","./src/components/cloudformation/stacklist.tsx","./src/components/cloudformation/stackupdatedialog.tsx","./src/components/cloudformation/templateviewer.tsx","./src/components/dynamodb/batchoperations.tsx","./src/components/dynamodb/creategsidialog.tsx","./src/components/dynamodb/createtabledialog.tsx","./src/components/dynamodb/indexmanager.tsx","./src/components/dynamodb/itembrowser.tsx","./src/components/dynamodb/itemeditordialog.tsx","./src/components/dynamodb/partiqleditor.tsx","./src/components/dynamodb/querybuilder.tsx","./src/components/dynamodb/streamviewer.tsx","./src/components/dynamodb/tabledetail.tsx","./src/components/dynamodb/tablelist.tsx","./src/components/iam/creategroupdialog.tsx","./src/components/iam/createpolicydialog.tsx","./src/components/iam/createuserdialog.tsx","./src/components/iam/groupdetail.tsx","./src/components/iam/grouplist.tsx","./src/components/iam/policydetail.tsx","./src/components/iam/policylist.tsx","./src/components/iam/userdetail.tsx","./src/components/iam/userlist.tsx","./src/components/lambda/functioncreatedialog.tsx","./src/components/lambda/functiondetail.tsx","./src/components/lambda/functionlist.tsx","./src/components/lambda/invokefunctionform.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/s3/bucketcreatedialog.tsx","./src/components/s3/bucketlist.tsx","./src/components/s3/foldercreatedialog.tsx","./src/components/s3/objectbrowser.tsx","./src/components/s3/objectuploaddialog.tsx","./src/components/settings/connectionguard.tsx","./src/components/settings/endpointmodal.tsx","./src/components/settings/regionselector.tsx","./src/components/sns/filterpolicydialog.tsx","./src/components/sns/publishmessageform.tsx","./src/components/sns/subscriptioncreatedialog.tsx","./src/components/sns/subscriptionlist.tsx","./src/components/sns/tagmanager.tsx","./src/components/sns/topicattributes.tsx","./src/components/sns/topiccreatedialog.tsx","./src/components/sns/topicdetail.tsx","./src/components/sns/topiclist.tsx","./src/components/sqs/messageviewer.tsx","./src/components/sqs/queuecreatedialog.tsx","./src/components/sqs/queuedetail.tsx","./src/components/sqs/queueeditattributesdialog.tsx","./src/components/sqs/queuelist.tsx","./src/components/sqs/queuesubscriptions.tsx","./src/components/sqs/sendmessageform.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/index.tsx","./src/routes/cloudformation/$stackname.tsx","./src/routes/cloudformation/index.tsx","./src/routes/dynamodb/$tablename.tsx","./src/routes/dynamodb/index.tsx","./src/routes/iam/index.tsx","./src/routes/iam/groups/$groupname.tsx","./src/routes/iam/policies/$policyarn.tsx","./src/routes/iam/users/$username.tsx","./src/routes/lambda/$functionname.tsx","./src/routes/lambda/index.tsx","./src/routes/s3/$bucketname.tsx","./src/routes/s3/index.tsx","./src/routes/sns/$topicname.tsx","./src/routes/sns/index.tsx","./src/routes/sqs/$queuename.tsx","./src/routes/sqs/index.tsx","./src/stores/app.ts","./src/stores/config.ts"],"version":"5.9.3"} \ No newline at end of file