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