diff --git a/ui/package.json b/ui/package.json index 89eb7b5ce..f4acd4021 100644 --- a/ui/package.json +++ b/ui/package.json @@ -44,5 +44,6 @@ "last 1 firefox version", "last 1 safari version" ] - } + }, + "proxy": "http://localhost:9324" } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5c14e2810..5fd7ddbf6 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,8 +1,13 @@ import React from "react"; import Main from "./Main/Main"; +import { SnackbarProvider } from "./context/SnackbarContext"; function App() { - return
+ return ( + +
+ + ); } export default App; diff --git a/ui/src/Main/Main.tsx b/ui/src/Main/Main.tsx index 4630f6a23..75e923176 100644 --- a/ui/src/Main/Main.tsx +++ b/ui/src/Main/Main.tsx @@ -3,12 +3,12 @@ import NavBar from "../NavBar/NavBar"; import QueuesTable from "../Queues/QueuesTable"; const Main: React.FC = () => { - return ( - <> - - - - ); + return ( + <> + + + + ); }; -export default Main; \ No newline at end of file +export default Main; diff --git a/ui/src/Queues/NewMessageModal.tsx b/ui/src/Queues/NewMessageModal.tsx new file mode 100644 index 000000000..653d48bd4 --- /dev/null +++ b/ui/src/Queues/NewMessageModal.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Button, +} from "@material-ui/core"; +import { useSnackbar } from "../context/SnackbarContext"; + +import { sendMessage } from "../services/QueueService"; +import getErrorMessage from "../utils/getErrorMessage"; + +interface NewMessageModalProps { + open: boolean; + onClose: () => void; + queueName: string; +} + +const NewMessageModal: React.FC = ({ + open, + onClose, + queueName, +}) => { + const [messageBody, setMessageBody] = useState(""); + const [loading, setLoading] = useState(false); + const { showSnackbar } = useSnackbar(); + + const handleSendMessage = async () => { + if (!messageBody.trim()) { + showSnackbar("Message body cannot be empty"); + return; + } + + setLoading(true); + try { + await sendMessage(queueName, messageBody); + showSnackbar("Message sent successfully!"); + setMessageBody(""); + onClose(); + } catch (error) { + const errorMessage = getErrorMessage(error); + showSnackbar(`Failed to send message: ${errorMessage}`); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setMessageBody(""); + onClose(); + }; + + return ( + + New Message - {queueName} + + setMessageBody(e.target.value)} + placeholder="Enter your message body here..." + /> + + + + + + + ); +}; + +export default NewMessageModal; diff --git a/ui/src/Queues/QueueMessageData.ts b/ui/src/Queues/QueueMessageData.ts index b818eb0c2..b77c35d64 100644 --- a/ui/src/Queues/QueueMessageData.ts +++ b/ui/src/Queues/QueueMessageData.ts @@ -1,25 +1,43 @@ interface QueueMessagesData { - queueName: string; - currentMessagesNumber: number; - delayedMessagesNumber: number; - notVisibleMessagesNumber: number; - isOpened: boolean + queueName: string; + currentMessagesNumber: number; + delayedMessagesNumber: number; + notVisibleMessagesNumber: number; + isOpened: boolean; + messages?: QueueMessage[]; + messagesLoading?: boolean; + messagesError?: string | null; } interface QueueStatistic { - name: string; - statistics: Statistics; + name: string; + statistics: Statistics; } interface Statistics { - approximateNumberOfVisibleMessages: number; - approximateNumberOfMessagesDelayed: number; - approximateNumberOfInvisibleMessages: number; + approximateNumberOfVisibleMessages: number; + approximateNumberOfMessagesDelayed: number; + approximateNumberOfInvisibleMessages: number; } interface QueueRedrivePolicyAttribute { - deadLetterTargetArn: string, - maxReceiveCount: number + deadLetterTargetArn: string; + maxReceiveCount: number; } -export type {QueueMessagesData, QueueStatistic, QueueRedrivePolicyAttribute} \ No newline at end of file +interface QueueMessage { + messageId: string; + body: string; + sentTimestamp: string; + receiptHandle?: string; + attributes?: Record; + messageAttributes?: Record; + isExpanded?: boolean; +} + +export type { + QueueMessagesData, + QueueStatistic, + QueueRedrivePolicyAttribute, + QueueMessage, +}; diff --git a/ui/src/Queues/QueueMessagesList.test.tsx b/ui/src/Queues/QueueMessagesList.test.tsx new file mode 100644 index 000000000..ca66d38ba --- /dev/null +++ b/ui/src/Queues/QueueMessagesList.test.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import QueueMessagesList from "./QueueMessagesList"; +import { QueueMessage } from "./QueueMessageData"; +import { renderWithSnackbarProvider } from "../tests/utils"; + +const mockUpdateMessageExpandedState = jest.fn(); +const mockOnRefreshMessages = jest.fn(); + +describe(" - New Features", () => { + describe("HTML Entity Decoding (New Feature)", () => { + test("decodes HTML entities in message body preview", () => { + const messageWithEntities: QueueMessage[] = [ + { + messageId: "msg-html-entities", + body: ""organizationId":"test"", + sentTimestamp: "1609459200000", + }, + ]; + + renderWithSnackbarProvider( + + ); + + // Should display decoded version in preview + expect(screen.getByText('"organizationId":"test"')).toBeInTheDocument(); + }); + + test("decodes HTML entities in full message body when expanded", () => { + const messageWithEntities: QueueMessage[] = [ + { + messageId: "msg-html-entities", + body: ""data":"value"&<test>", + sentTimestamp: "1609459200000", + }, + ]; + + renderWithSnackbarProvider( + + ); + + // Should display decoded version in preview (truncated) + expect(screen.getByText('"data":"value"&')).toBeInTheDocument(); + }); + }); + + describe("Props-based State Management (New Feature)", () => { + test("uses messages from props when provided", () => { + const propsMessages: QueueMessage[] = [ + { + messageId: "props-msg-1", + body: "Message from props", + sentTimestamp: "1609459200000", + }, + ]; + + renderWithSnackbarProvider( + + ); + + expect(screen.getByText("Messages (1)")).toBeInTheDocument(); + expect(screen.getByText("Message from props")).toBeInTheDocument(); + }); + + test("shows loading state from props", () => { + renderWithSnackbarProvider( + + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + test("shows error state from props", () => { + const errorMessage = "Failed to fetch messages from parent"; + renderWithSnackbarProvider( + + ); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/src/Queues/QueueMessagesList.tsx b/ui/src/Queues/QueueMessagesList.tsx new file mode 100644 index 000000000..6aa6f8830 --- /dev/null +++ b/ui/src/Queues/QueueMessagesList.tsx @@ -0,0 +1,320 @@ +import React from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, + Box, + Button, + Collapse, + IconButton, + Chip, + CircularProgress, +} from "@material-ui/core"; +import { Refresh, ExpandMore, ExpandLess, Delete } from "@material-ui/icons"; +import { useSnackbar } from "../context/SnackbarContext"; +import { QueueMessage } from "./QueueMessageData"; +import getErrorMessage from "../utils/getErrorMessage"; +import formatDate from "../utils/formatDate"; +import truncateText from "../utils/truncateText"; +import decodeHtmlEntities from "../utils/decodeHtml"; + +interface QueueMessagesListProps { + queueName: string; + messages?: QueueMessage[]; + loading?: boolean; + error?: string | null; + onRefreshMessages?: (queueName: string) => void; + onDeleteMessage?: ( + queueName: string, + messageId: string, + receiptHandle: string + ) => Promise; + updateMessageExpandedState: ( + queueName: string, + messageId: string | null + ) => void; +} + +const QueueMessagesList: React.FC = ({ + queueName, + messages = [], + loading = false, + error = null, + onRefreshMessages, + onDeleteMessage, + updateMessageExpandedState, +}) => { + const { showSnackbar } = useSnackbar(); + + const toggleMessageExpansion = (messageId: string) => { + updateMessageExpandedState(queueName, messageId); + }; + + const handleDeleteMessage = async ( + messageId: string, + receiptHandle?: string + ) => { + if (!receiptHandle) { + showSnackbar("Cannot delete message without receiptHandle"); + return; + } + + if (onDeleteMessage) { + try { + await onDeleteMessage(queueName, messageId, receiptHandle); + showSnackbar("Message deleted successfully"); + } catch (error) { + const errorMessage = getErrorMessage(error); + showSnackbar(`Failed to delete message: ${errorMessage}`); + } + } + }; + + return ( + + + + + Messages ({messages?.length || 0}) + + {loading && } + + {onRefreshMessages && ( + + )} + + + {error && ( + + + {error} + + + )} + + {(!messages || messages.length === 0) && !loading ? ( + + No messages found in this queue. + + ) : ( + + + + + Message ID + Body Preview + Sent Time + Attributes + Actions + + + + {messages?.map((message) => ( + + + + toggleMessageExpansion(message.messageId)} + > + {message.isExpanded ? : } + + + + + {message.messageId.substring(0, 20)}... + + + + + {truncateText(decodeHtmlEntities(message.body))} + + + + + {formatDate(message.sentTimestamp)} + + + + {message.attributes && + Object.keys(message.attributes).length > 0 ? ( + + ) : ( + + None + + )} + + + + handleDeleteMessage( + message.messageId, + message.receiptHandle + ) + } + disabled={!message.receiptHandle || loading} + title="Delete message" + > + + + + + + + + + + Full Message Details: + + + + Message ID: + + + {message.messageId} + + + + + Body: + + + {decodeHtmlEntities(message.body)} + + + {message.attributes && + Object.keys(message.attributes).length > 0 && ( + + + Attributes: + +
+ + {Object.entries(message.attributes).map( + ([key, value]) => ( + + + {key} + + + {value} + + + ) + )} + +
+
+ )} + {message.messageAttributes && + Object.keys(message.messageAttributes).length > 0 && ( + + + Message Attributes: + + + + {Object.entries( + message.messageAttributes + ).map(([key, value]) => ( + + + {key} + + + {JSON.stringify(value, null, 2)} + + + ))} + +
+
+ )} + + + + + + ))} + + + )} + + ); +}; + +export default React.memo(QueueMessagesList); diff --git a/ui/src/Queues/QueueRow.test.tsx b/ui/src/Queues/QueueRow.test.tsx index e0c4b919c..8d141e644 100644 --- a/ui/src/Queues/QueueRow.test.tsx +++ b/ui/src/Queues/QueueRow.test.tsx @@ -1,69 +1,88 @@ import React from "react"; import axios from "axios"; -import {act, fireEvent, render, screen} from "@testing-library/react"; +import { act, fireEvent, screen } from "@testing-library/react"; import QueueTableRow from "./QueueRow"; -import {TableBody} from "@material-ui/core"; +import { TableBody } from "@material-ui/core"; import Table from "@material-ui/core/Table"; +import { renderWithSnackbarProvider } from "../tests/utils"; jest.mock("axios"); +const mockFetchQueueMessages = jest.fn(); +const mockDeleteMessage = jest.fn(); +const mockUpdateMessageExpandedState = jest.fn(); + beforeEach(() => { - jest.clearAllMocks(); -}) + jest.clearAllMocks(); + mockFetchQueueMessages.mockClear(); + mockDeleteMessage.mockClear(); + mockUpdateMessageExpandedState.mockClear(); +}); describe("", () => { - const queue1 = { - queueName: "queueName1", - currentMessagesNumber: 1, - delayedMessagesNumber: 2, - notVisibleMessagesNumber: 3, - isOpened: false - } - - test("renders cell values", () => { - render( - - - - -
- ) + const queue1 = { + queueName: "queueName1", + currentMessagesNumber: 1, + delayedMessagesNumber: 2, + notVisibleMessagesNumber: 3, + isOpened: false, + }; - expect(screen.queryByText("queueName1")).toBeInTheDocument(); - expect(screen.queryByText("1")).toBeInTheDocument(); - expect(screen.queryByText("2")).toBeInTheDocument(); - expect(screen.queryByText("3")).toBeInTheDocument(); - expect(screen.queryByRole("button")).toBeInTheDocument() - }); + test("renders cell values", () => { + renderWithSnackbarProvider( + + + + +
+ ); - test("clicking button should expand queue attributes section", async () => { - const data = { - name: "queueName1", - attributes: { - attribute1: "value1", - attribute2: "value2" - } - }; - (axios.get as jest.Mock).mockResolvedValueOnce({data, status: 200}) + expect(screen.queryByText("queueName1")).toBeInTheDocument(); + expect(screen.queryByText("1")).toBeInTheDocument(); + expect(screen.queryByText("2")).toBeInTheDocument(); + expect(screen.queryByText("3")).toBeInTheDocument(); + expect(screen.queryByLabelText("open-details")).toBeInTheDocument(); + expect(screen.queryByTitle("New message")).toBeInTheDocument(); + }); - render( - - - - -
- ) + test("clicking button should expand queue attributes section", async () => { + const data = { + name: "queueName1", + attributes: { + attribute1: "value1", + attribute2: "value2", + }, + }; + (axios.get as jest.Mock).mockResolvedValueOnce({ data, status: 200 }); - expect(screen.queryByText("Queue attributes")).not.toBeInTheDocument() + renderWithSnackbarProvider( + + + + +
+ ); - await act(async () => { - fireEvent.click(await screen.findByRole("button")) - }) + expect(screen.queryByText("Queue attributes")).not.toBeInTheDocument(); - expect(screen.queryByText("Queue attributes")).toBeInTheDocument() - expect(screen.queryByText("attribute1")) - expect(screen.queryByText("value1")) - expect(screen.queryByText("attribute2")) - expect(screen.queryByText("value1")) + await act(async () => { + fireEvent.click(await screen.findByLabelText("open-details")); }); -}); \ No newline at end of file + + expect(screen.queryByText("Queue attributes")).toBeInTheDocument(); + expect(screen.queryByText("attribute1")); + expect(screen.queryByText("value1")); + expect(screen.queryByText("attribute2")); + expect(screen.queryByText("value1")); + }); +}); diff --git a/ui/src/Queues/QueueRow.tsx b/ui/src/Queues/QueueRow.tsx index 6cd54db82..cba35d2d6 100644 --- a/ui/src/Queues/QueueRow.tsx +++ b/ui/src/Queues/QueueRow.tsx @@ -1,37 +1,81 @@ import TableRow from "@material-ui/core/TableRow"; import TableCell from "@material-ui/core/TableCell"; import IconButton from "@material-ui/core/IconButton"; -import {KeyboardArrowDown, KeyboardArrowRight} from "@material-ui/icons"; -import React, {useState} from "react"; -import {QueueMessagesData} from "./QueueMessageData"; +import { + AddComment, + KeyboardArrowDown, + KeyboardArrowRight, +} from "@material-ui/icons"; +import React, { useState } from "react"; +import { QueueMessagesData } from "./QueueMessageData"; import RowDetails from "./QueueRowDetails"; +import NewMessageModal from "./NewMessageModal"; -function QueueTableRow(props: { row: QueueMessagesData }) { +function QueueTableRow({ + row, + fetchQueueMessages, + deleteMessage, + updateMessageExpandedState, +}: { + row: QueueMessagesData; + fetchQueueMessages: (queueName: string) => Promise; + deleteMessage: ( + queueName: string, + messageId: string, + receiptHandle: string + ) => Promise; + updateMessageExpandedState: ( + queueName: string, + messageId: string | null + ) => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - - function ExpandableArrowButton(props: { isExpanded: boolean }) { - return setIsExpanded(prevState => !prevState)}> - {props.isExpanded ? : } - - } - - const {row} = props - return ( - <> - - - - - {row.queueName} - {row.currentMessagesNumber} - {row.delayedMessagesNumber} - {row.notVisibleMessagesNumber} - - - - ) + return ( + <> + + + setIsExpanded((prevState) => !prevState)} + > + {isExpanded ? : } + + + + {row.queueName} + + {row.currentMessagesNumber} + {row.delayedMessagesNumber} + {row.notVisibleMessagesNumber} + + setIsModalOpen(true)} + title="New message" + > + + + + + + setIsModalOpen(false)} + queueName={row.queueName} + /> + + ); } -export default QueueTableRow \ No newline at end of file +export default QueueTableRow; diff --git a/ui/src/Queues/QueueRowDetails.tsx b/ui/src/Queues/QueueRowDetails.tsx index e844e7f27..be1a4a4d7 100644 --- a/ui/src/Queues/QueueRowDetails.tsx +++ b/ui/src/Queues/QueueRowDetails.tsx @@ -5,49 +5,144 @@ import Box from "@material-ui/core/Box"; import Typography from "@material-ui/core/Typography"; import Table from "@material-ui/core/Table"; import TableHead from "@material-ui/core/TableHead"; -import {TableBody} from "@material-ui/core"; -import React, {useState} from "react"; -import QueueService from "../services/QueueService"; - -const RowDetails: React.FC<{ props: { isExpanded: boolean, queueName: string } }> = ({props}) => { - - const [attributes, setAttributes] = useState>>([]); - - function getQueueAttributes() { - QueueService.getQueueAttributes(props.queueName).then(attributes => setAttributes(attributes)) - } - - return ( - - - getQueueAttributes()}> - - - Queue attributes - - - - - - Attribute Name - Attribute Value - - - - {attributes.map((attribute) => - ( - - {attribute[0]} - {attribute[1]} - - ) - )} - -
-
-
-
- ) +import { TableBody, Tabs, Tab } from "@material-ui/core"; +import React, { useState } from "react"; +import { getQueueAttributes } from "../services/QueueService"; +import QueueMessagesList from "./QueueMessagesList"; +import { QueueMessagesData } from "./QueueMessageData"; + +interface RowDetailsProps { + isExpanded: boolean; + queueName: string; + queueData: QueueMessagesData; + fetchQueueMessages: (queueName: string) => Promise; + deleteMessage: ( + queueName: string, + messageId: string, + receiptHandle: string + ) => Promise; + updateMessageExpandedState: ( + queueName: string, + messageId: string | null + ) => void; +} + +const RowDetails: React.FC = ({ + isExpanded, + queueName, + queueData, + fetchQueueMessages, + deleteMessage, + updateMessageExpandedState, +}) => { + const [attributes, setAttributes] = useState>>([]); + const [activeTab, setActiveTab] = useState(0); + + async function fetchQueueAttributes() { + const attributes = await getQueueAttributes(queueName); + setAttributes(attributes); + } + + const handleTabChange = (_event: React.ChangeEvent<{}>, newValue: number) => { + setActiveTab(newValue); + }; + + return ( + + + + + + + + + + + + + Queue attributes + + + + + Attribute Name + Attribute Value + + + + {attributes.map((attribute) => ( + + + {attribute[0]} + + {attribute[1]} + + ))} + +
+
+
+ + + + + + +
+
+
+
+ ); +}; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); } -export default RowDetails \ No newline at end of file +export default RowDetails; diff --git a/ui/src/Queues/QueuesTable.test.tsx b/ui/src/Queues/QueuesTable.test.tsx index 6885254a8..306acb1dd 100644 --- a/ui/src/Queues/QueuesTable.test.tsx +++ b/ui/src/Queues/QueuesTable.test.tsx @@ -1,135 +1,140 @@ import React from "react"; -import {act, render, screen, waitFor} from '@testing-library/react' +import { act, screen, waitFor } from "@testing-library/react"; import QueuesTable from "./QueuesTable"; import axios from "axios"; -import '@testing-library/jest-dom' +import "@testing-library/jest-dom"; +import { renderWithSnackbarProvider } from "../tests/utils"; jest.mock("axios"); -const initialData = +const initialData = { + data: [ { - data: [ - { - name: "queueName1", - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfMessagesDelayed: 8, - approximateNumberOfInvisibleMessages: 10 - } - }, - { - name: "queueName2", - statistics: { - approximateNumberOfVisibleMessages: 1, - approximateNumberOfMessagesDelayed: 3, - approximateNumberOfInvisibleMessages: 7 - } - } - ] - }; + name: "queueName1", + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfMessagesDelayed: 8, + approximateNumberOfInvisibleMessages: 10, + }, + }, + { + name: "queueName2", + statistics: { + approximateNumberOfVisibleMessages: 1, + approximateNumberOfMessagesDelayed: 3, + approximateNumberOfInvisibleMessages: 7, + }, + }, + ], +}; beforeEach(() => { - jest.useFakeTimers(); -}) + jest.useFakeTimers(); +}); afterEach(() => { - jest.clearAllMocks(); - jest.clearAllTimers() -}) + jest.clearAllMocks(); + jest.clearAllTimers(); +}); describe("", () => { - test("Basic information about queues should be retrieved for first time without waiting for interval", async () => { - (axios.get as jest.Mock).mockResolvedValueOnce(initialData) + test("Basic information about queues should be retrieved for first time without waiting for interval", async () => { + (axios.get as jest.Mock).mockResolvedValueOnce(initialData); + + renderWithSnackbarProvider(); + + await waitFor(() => screen.findByText("queueName1")); - render(); + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("5")).toBeInTheDocument(); + expect(await screen.findByText("8")).toBeInTheDocument(); + expect(await screen.findByText("10")).toBeInTheDocument(); - await waitFor(() => screen.findByText("queueName1")) + expect(await screen.findByText("queueName2")).toBeInTheDocument(); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); + expect(await screen.findByText("7")).toBeInTheDocument(); + }); - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("5")).toBeInTheDocument(); - expect(await screen.findByText("8")).toBeInTheDocument(); - expect(await screen.findByText("10")).toBeInTheDocument(); + test("Each second statistics for queue should be updated if there were updates on Backend side", async () => { + const initialData = createResponseDataForQueue("queueName1", 1, 2, 3); + const firstUpdate = createResponseDataForQueue("queueName1", 4, 5, 6); + const secondUpdate = createResponseDataForQueue("queueName1", 7, 8, 9); - expect(await screen.findByText("queueName2")).toBeInTheDocument(); - expect(await screen.findByText("1")).toBeInTheDocument(); - expect(await screen.findByText("3")).toBeInTheDocument(); - expect(await screen.findByText("7")).toBeInTheDocument(); + (axios.get as jest.Mock) + .mockResolvedValueOnce(initialData) + .mockResolvedValueOnce(firstUpdate) + .mockResolvedValue(secondUpdate); + + renderWithSnackbarProvider(); + + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(await screen.findByText("2")).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); }); - test("Each second statistics for queue should be updated if there were updates on Backend side", async () => { - const initialData = createResponseDataForQueue("queueName1", 1, 2, 3); - const firstUpdate = createResponseDataForQueue("queueName1", 4, 5, 6); - const secondUpdate = createResponseDataForQueue("queueName1", 7, 8, 9); - - (axios.get as jest.Mock) - .mockResolvedValueOnce(initialData) - .mockResolvedValueOnce(firstUpdate) - .mockResolvedValue(secondUpdate) - - render() - - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("1")).toBeInTheDocument(); - expect(await screen.findByText("2")).toBeInTheDocument(); - expect(await screen.findByText("3")).toBeInTheDocument(); - - act(() => { - jest.advanceTimersByTime(1000) - }); - - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("4")).toBeInTheDocument(); - expect(await screen.findByText("5")).toBeInTheDocument(); - expect(await screen.findByText("6")).toBeInTheDocument(); - expect(screen.queryByText("1")).not.toBeInTheDocument(); - expect(screen.queryByText("2")).not.toBeInTheDocument(); - expect(screen.queryByText("3")).not.toBeInTheDocument(); - - act(() => { - jest.advanceTimersByTime(1000) - }); - - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("7")).toBeInTheDocument(); - expect(await screen.findByText("8")).toBeInTheDocument(); - expect(await screen.findByText("9")).toBeInTheDocument(); - expect(screen.queryByText("4")).not.toBeInTheDocument(); - expect(screen.queryByText("5")).not.toBeInTheDocument(); - expect(screen.queryByText("6")).not.toBeInTheDocument(); + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("4")).toBeInTheDocument(); + expect(await screen.findByText("5")).toBeInTheDocument(); + expect(await screen.findByText("6")).toBeInTheDocument(); + expect(screen.queryByText("1")).not.toBeInTheDocument(); + expect(screen.queryByText("2")).not.toBeInTheDocument(); + expect(screen.queryByText("3")).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); }); - test("Statistics should not change if retrieved data has not been changed", async () => { - const initialData = createResponseDataForQueue("queueName1", 1, 2, 3); - (axios.get as jest.Mock).mockResolvedValue(initialData); + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("7")).toBeInTheDocument(); + expect(await screen.findByText("8")).toBeInTheDocument(); + expect(await screen.findByText("9")).toBeInTheDocument(); + expect(screen.queryByText("4")).not.toBeInTheDocument(); + expect(screen.queryByText("5")).not.toBeInTheDocument(); + expect(screen.queryByText("6")).not.toBeInTheDocument(); + }); - render() + test("Statistics should not change if retrieved data has not been changed", async () => { + const initialData = createResponseDataForQueue("queueName1", 1, 2, 3); + (axios.get as jest.Mock).mockResolvedValue(initialData); - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("1")).toBeInTheDocument(); - expect(await screen.findByText("2")).toBeInTheDocument(); - expect(await screen.findByText("3")).toBeInTheDocument(); + renderWithSnackbarProvider(); - act(() => { - jest.advanceTimersByTime(1000) - }); + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(await screen.findByText("2")).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("1")).toBeInTheDocument(); - expect(await screen.findByText("2")).toBeInTheDocument(); - expect(await screen.findByText("3")).toBeInTheDocument(); + act(() => { + jest.advanceTimersByTime(1000); }); + + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(await screen.findByText("2")).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); + }); }); -function createResponseDataForQueue(queueName: string, numberOfVisibleMessages: number, numberOfDelayedMessages: number, numberOfInvisibleMessages: number) { - return { - data: [ - { - name: queueName, - statistics: { - approximateNumberOfVisibleMessages: numberOfVisibleMessages, - approximateNumberOfMessagesDelayed: numberOfDelayedMessages, - approximateNumberOfInvisibleMessages: numberOfInvisibleMessages - } - } - ] - } -} \ No newline at end of file +function createResponseDataForQueue( + queueName: string, + numberOfVisibleMessages: number, + numberOfDelayedMessages: number, + numberOfInvisibleMessages: number +) { + return { + data: [ + { + name: queueName, + statistics: { + approximateNumberOfVisibleMessages: numberOfVisibleMessages, + approximateNumberOfMessagesDelayed: numberOfDelayedMessages, + approximateNumberOfInvisibleMessages: numberOfInvisibleMessages, + }, + }, + ], + }; +} diff --git a/ui/src/Queues/QueuesTable.tsx b/ui/src/Queues/QueuesTable.tsx index 72d4cfe51..6ad3fc494 100644 --- a/ui/src/Queues/QueuesTable.tsx +++ b/ui/src/Queues/QueuesTable.tsx @@ -5,34 +5,50 @@ import Table from "@material-ui/core/Table"; import TableHead from "@material-ui/core/TableHead"; import TableRow from "@material-ui/core/TableRow"; import TableCell from "@material-ui/core/TableCell"; -import {TableBody} from "@material-ui/core"; +import { TableBody } from "@material-ui/core"; import "../styles/queue.css"; import QueueTableRow from "./QueueRow"; import useRefreshedQueueStatistics from "./RefreshQueuesData"; const QueuesTable: React.FC = () => { - const queuesOverallData = useRefreshedQueueStatistics(); + const { + queuesData, + fetchQueueMessages, + deleteMessage, + updateMessageExpandedState, + } = useRefreshedQueueStatistics(); - return ( - - - - - - Name - Approximate number of messages - Approximate number of delayed messages - Approximate number of not visible Messages - - - - {queuesOverallData.map((row) => ( - - ))} - -
-
- ) -} + return ( + + + + + + Name + Approximate number of messages + + Approximate number of delayed messages + + + Approximate number of not visible Messages + + Actions + + + + {queuesData.map((row) => ( + + ))} + +
+
+ ); +}; -export default QueuesTable; \ No newline at end of file +export default QueuesTable; diff --git a/ui/src/Queues/RefreshQueuesData.ts b/ui/src/Queues/RefreshQueuesData.ts index 7b89aa6a2..d9a2cfb60 100644 --- a/ui/src/Queues/RefreshQueuesData.ts +++ b/ui/src/Queues/RefreshQueuesData.ts @@ -1,73 +1,190 @@ -import {useEffect, useState} from "react"; -import QueueService from "../services/QueueService"; -import {QueueMessagesData, QueueStatistic} from "./QueueMessageData"; +import { useEffect, useState, useCallback } from "react"; +import { + getQueueMessages, + getQueueListWithCorrelatedMessages, + deleteMessage as deleteMessageService, +} from "../services/QueueService"; +import { QueueMessagesData, QueueStatistic } from "./QueueMessageData"; +import getErrorMessage from "../utils/getErrorMessage"; + +interface UseRefreshedQueueStatisticsResult { + queuesData: QueueMessagesData[]; + fetchQueueMessages: (queueName: string) => Promise; + deleteMessage: ( + queueName: string, + messageId: string, + receiptHandle: string + ) => Promise; + updateMessageExpandedState: ( + queueName: string, + messageId: string | null + ) => void; +} + +export default function useRefreshedQueueStatistics(): UseRefreshedQueueStatisticsResult { + const [queuesOverallData, setQueuesOverallData] = useState< + QueueMessagesData[] + >([]); + + const updateQueue = ( + name: string, + updater: (queue: QueueMessagesData) => QueueMessagesData + ) => { + setQueuesOverallData((prevQueues) => + prevQueues.map((queue) => + queue.queueName === name ? updater(queue) : queue + ) + ); + }; + + const fetchQueueMessages = useCallback( + async (queueName: string) => { + updateQueue(queueName, (queue) => ({ + ...queue, + messagesLoading: true, + messagesError: null, + })); + + try { + const messages = await getQueueMessages(queueName, 10); + + updateQueue(queueName, (queue) => ({ + ...queue, + messages, + messagesLoading: false, + messagesError: null, + })); + } catch (error) { + const messagesError = getErrorMessage(error); + + updateQueue(queueName, (queue) => ({ + ...queue, + messagesLoading: false, + messagesError, + })); + } + }, + [updateQueue] + ); + + const updateMessageExpandedState = ( + queueName: string, + messageId: string | null + ) => { + setQueuesOverallData((prevQueues) => + prevQueues.map((queue) => { + if (queue.queueName !== queueName) return queue; -function useRefreshedQueueStatistics(): QueueMessagesData[] { - function convertQueueStatisticsToNewQueueData(newQuery: QueueStatistic) { - return { - queueName: newQuery.name, - currentMessagesNumber: newQuery.statistics.approximateNumberOfVisibleMessages, - delayedMessagesNumber: newQuery.statistics.approximateNumberOfMessagesDelayed, - notVisibleMessagesNumber: newQuery.statistics.approximateNumberOfInvisibleMessages, - isOpened: false - } as QueueMessagesData - } - - function updateNumberOfMessagesInQueue(newStatistics: QueueStatistic, knownQuery: QueueMessagesData) { return { - ...knownQuery, - currentMessagesNumber: newStatistics.statistics.approximateNumberOfVisibleMessages, - delayedMessagesNumber: newStatistics.statistics.approximateNumberOfMessagesDelayed, - notVisibleMessagesNumber: newStatistics.statistics.approximateNumberOfInvisibleMessages, - } - } - - const [queuesOverallData, setQueuesOverallData] = useState([]); - useEffect(() => { - function obtainInitialStatistics() { - return QueueService.getQueueListWithCorrelatedMessages().then(queuesStatistics => - queuesStatistics.map(convertQueueStatisticsToNewQueueData) - ); - } + ...queue, + messages: queue.messages?.map((msg) => ({ + ...msg, + isExpanded: + msg.messageId === messageId ? !msg.isExpanded : msg.isExpanded, + })), + }; + }) + ); + }; - function getQueuesListWithMessages() { - QueueService.getQueueListWithCorrelatedMessages() - .then(statistics => { - setQueuesOverallData((prevState) => { - return statistics.map(queueStatistics => { - const maybeKnownQuery = prevState.find(queueMessageData => queueMessageData.queueName === queueStatistics.name) - if (maybeKnownQuery === undefined) { - return convertQueueStatisticsToNewQueueData(queueStatistics) - } else { - return updateNumberOfMessagesInQueue(queueStatistics, maybeKnownQuery) - } - }) - }) - }) - } + const deleteMessage = useCallback( + async (queueName: string, messageId: string, receiptHandle: string) => { + try { + await deleteMessageService(queueName, messageId, receiptHandle); + + await fetchQueueMessages(queueName); + } catch (error) { + console.error("Error deleting message:", error); + } + }, + [fetchQueueMessages] + ); + + async function obtainInitialStatistics() { + const statistics = await getQueueListWithCorrelatedMessages(); + return statistics.map(convertQueueStatisticsToNewQueueData); + } + + async function getQueuesListWithMessages() { + const messages = await getQueueListWithCorrelatedMessages(); - const fetchInitialStatistics = async () => { - const initialStatistics = await obtainInitialStatistics() - setQueuesOverallData((prevState) => { - if (prevState.length === 0) { - return initialStatistics - } else { - return prevState; - } - }) + setQueuesOverallData((prevState) => { + return messages.map((queueStatistics) => { + const maybeKnownQuery = prevState.find( + (queueMessageData) => + queueMessageData.queueName === queueStatistics.name + ); + if (maybeKnownQuery === undefined) { + return convertQueueStatisticsToNewQueueData(queueStatistics); + } else { + return updateNumberOfMessagesInQueue( + queueStatistics, + maybeKnownQuery + ); } + }); + }); + } - fetchInitialStatistics() + const fetchInitialStatistics = async () => { + const initialStatistics = await obtainInitialStatistics(); + setQueuesOverallData((prevState) => { + if (prevState.length === 0) { + return initialStatistics; + } else { + return prevState; + } + }); + }; - const interval = setInterval(() => { - getQueuesListWithMessages() - }, 1000); - return () => { - clearInterval(interval); - }; - }, []); + useEffect(() => { + fetchInitialStatistics(); + + const interval = setInterval(() => { + getQueuesListWithMessages(); + }, 1000); + return () => { + clearInterval(interval); + }; + }, []); - return queuesOverallData; + return { + queuesData: queuesOverallData, + fetchQueueMessages, + deleteMessage, + updateMessageExpandedState, + }; } -export default useRefreshedQueueStatistics; \ No newline at end of file +function convertQueueStatisticsToNewQueueData( + newQuery: QueueStatistic +): QueueMessagesData { + return { + queueName: newQuery.name, + currentMessagesNumber: + newQuery.statistics.approximateNumberOfVisibleMessages, + delayedMessagesNumber: + newQuery.statistics.approximateNumberOfMessagesDelayed, + notVisibleMessagesNumber: + newQuery.statistics.approximateNumberOfInvisibleMessages, + isOpened: false, + messages: [], + messagesLoading: false, + messagesError: null, + }; +} + +function updateNumberOfMessagesInQueue( + newStatistics: QueueStatistic, + knownQuery: QueueMessagesData +) { + return { + ...knownQuery, + currentMessagesNumber: + newStatistics.statistics.approximateNumberOfVisibleMessages, + delayedMessagesNumber: + newStatistics.statistics.approximateNumberOfMessagesDelayed, + notVisibleMessagesNumber: + newStatistics.statistics.approximateNumberOfInvisibleMessages, + }; +} diff --git a/ui/src/context/SnackbarContext.tsx b/ui/src/context/SnackbarContext.tsx new file mode 100644 index 000000000..bbdc11031 --- /dev/null +++ b/ui/src/context/SnackbarContext.tsx @@ -0,0 +1,57 @@ +import React, { createContext, useContext, useState, ReactNode } from "react"; +import { Snackbar } from "@material-ui/core"; + +interface SnackbarState { + open: boolean; + message: string; +} + +interface SnackbarContextType { + showSnackbar: (message: string) => void; +} + +const SnackbarContext = createContext( + undefined +); + +interface SnackbarProviderProps { + children: ReactNode; +} + +export const SnackbarProvider: React.FC = ({ + children, +}) => { + const [snackbar, setSnackbar] = useState({ + open: false, + message: "", + }); + + const showSnackbar = (message: string) => { + setSnackbar({ open: true, message }); + }; + + const handleClose = () => { + setSnackbar((prev) => ({ ...prev, open: false })); + }; + + return ( + + {children} + + + ); +}; + +export const useSnackbar = (): SnackbarContextType => { + const context = useContext(SnackbarContext); + if (!context) { + throw new Error("useSnackbar must be used within a SnackbarProvider"); + } + return context; +}; diff --git a/ui/src/services/QueueService.test.ts b/ui/src/services/QueueService.test.ts index b896554a6..72d36ebfe 100644 --- a/ui/src/services/QueueService.test.ts +++ b/ui/src/services/QueueService.test.ts @@ -1,206 +1,423 @@ import axios from "axios"; -import QueueService from "./QueueService"; +import { + getQueueListWithCorrelatedMessages, + getQueueAttributes, + deleteMessage, + parseReceiveMessageResponse, +} from "./QueueService"; jest.mock("axios"); afterEach(() => { - jest.clearAllMocks(); -}) + jest.clearAllMocks(); +}); test("Get queue list with correlated messages should return basic information about messages in queues", async () => { - const data = - [ - { - name: "queueName1", - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfMessagesDelayed: 8, - approximateNumberOfInvisibleMessages: 10 - } - }, - { - name: "queueName2", - statistics: { - approximateNumberOfVisibleMessages: 1, - approximateNumberOfMessagesDelayed: 3, - approximateNumberOfInvisibleMessages: 7 - } - } - ]; - - (axios.get as jest.Mock).mockResolvedValueOnce({data}) - - await expect(QueueService.getQueueListWithCorrelatedMessages()).resolves.toEqual(data) - expect(axios.get).toBeCalledWith("statistics/queues") -}) + const data = [ + { + name: "queueName1", + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfMessagesDelayed: 8, + approximateNumberOfInvisibleMessages: 10, + }, + }, + { + name: "queueName2", + statistics: { + approximateNumberOfVisibleMessages: 1, + approximateNumberOfMessagesDelayed: 3, + approximateNumberOfInvisibleMessages: 7, + }, + }, + ]; + + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + await expect(getQueueListWithCorrelatedMessages()).resolves.toEqual(data); + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return empty array if response does not contain queue info", async () => { - const data: Array = []; + const data: Array = []; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); - await expect(QueueService.getQueueListWithCorrelatedMessages()).resolves.toEqual(data); - expect(axios.get).toBeCalledWith("statistics/queues") -}) + await expect(getQueueListWithCorrelatedMessages()).resolves.toEqual(data); + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return validation error if queue is missing name property", async () => { - expect.assertions(2); - - const data = - [ - { - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfMessagesDelayed: 8, - approximateNumberOfInvisibleMessages: 10 - } - } - ]; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); - - try { - await QueueService.getQueueListWithCorrelatedMessages(); - } catch (e) { - expect(e.errors).toEqual([ - "Required queueName" - ]); - } - expect(axios.get).toBeCalledWith("statistics/queues") -}) + expect.assertions(2); + + const data = [ + { + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfMessagesDelayed: 8, + approximateNumberOfInvisibleMessages: 10, + }, + }, + ]; + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + try { + await getQueueListWithCorrelatedMessages(); + } catch (e) { + expect(e.errors).toEqual(["Required queueName"]); + } + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return validation error if queue is missing approximate number of visible messages property", async () => { - expect.assertions(2); - - const data = - [ - { - queueName: "name1", - statistics: { - approximateNumberOfMessagesDelayed: 8, - approximateNumberOfInvisibleMessages: 10 - } - } - ]; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); - - try { - await QueueService.getQueueListWithCorrelatedMessages(); - } catch (e) { - expect(e.errors).toEqual([ - "Required approximateNumberOfVisibleMessages" - ]); - } - expect(axios.get).toBeCalledWith("statistics/queues") -}) + expect.assertions(2); + + const data = [ + { + queueName: "name1", + statistics: { + approximateNumberOfMessagesDelayed: 8, + approximateNumberOfInvisibleMessages: 10, + }, + }, + ]; + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + try { + await getQueueListWithCorrelatedMessages(); + } catch (e) { + expect(e.errors).toEqual(["Required approximateNumberOfVisibleMessages"]); + } + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return validation error if queue is missing approximate number of delayed messages property", async () => { - expect.assertions(2); - - const data = - [ - { - queueName: "name1", - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfInvisibleMessages: 10 - } - } - ]; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); - - try { - await QueueService.getQueueListWithCorrelatedMessages(); - } catch (e) { - expect(e.errors).toEqual([ - "Required approximateNumberOfMessagesDelayed" - ]); - } - expect(axios.get).toBeCalledWith("statistics/queues") -}) + expect.assertions(2); + + const data = [ + { + queueName: "name1", + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfInvisibleMessages: 10, + }, + }, + ]; + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + try { + await getQueueListWithCorrelatedMessages(); + } catch (e) { + expect(e.errors).toEqual(["Required approximateNumberOfMessagesDelayed"]); + } + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return validation error if queue is missing approximate number of invisible messages property", async () => { - expect.assertions(2); - - const data = - [ - { - queueName: "name1", - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfMessagesDelayed: 8 - } - } - ]; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); - - try { - await QueueService.getQueueListWithCorrelatedMessages(); - } catch (e) { - expect(e.errors).toEqual([ - "Required approximateNumberOfInvisibleMessages" - ]); - } - expect(axios.get).toBeCalledWith("statistics/queues") -}) + expect.assertions(2); + + const data = [ + { + queueName: "name1", + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfMessagesDelayed: 8, + }, + }, + ]; + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + try { + await getQueueListWithCorrelatedMessages(); + } catch (e) { + expect(e.errors).toEqual(["Required approximateNumberOfInvisibleMessages"]); + } + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Getting queue attributes should return empty array if it can't be found", async () => { - expect.assertions(2); + expect.assertions(2); - (axios.get as jest.Mock).mockResolvedValueOnce({status: 404}) + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 404 }); - await expect(QueueService.getQueueAttributes("queueName")).resolves.toEqual([]); - expect(axios.get).toBeCalledWith("statistics/queues/queueName"); -}) + await expect(getQueueAttributes("queueName")).resolves.toEqual([]); + expect(axios.get).toBeCalledWith("statistics/queues/queueName"); +}); test("Timestamp related attributes should be converted to human readable dates", async () => { - const data = { - name: "QueueName", - attributes: { - CreatedTimestamp: "1605539328", - LastModifiedTimestamp: "1605539300" - } - }; - - (axios.get as jest.Mock).mockResolvedValueOnce({status: 200, data: data}) - - await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ - ["CreatedTimestamp", "2020-11-16T15:08:48.000Z"], - ["LastModifiedTimestamp", "2020-11-16T15:08:20.000Z"] - ]) - expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); -}) + const data = { + name: "QueueName", + attributes: { + CreatedTimestamp: "1605539328", + LastModifiedTimestamp: "1605539300", + }, + }; + + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: data }); + + await expect(getQueueAttributes("QueueName")).resolves.toEqual([ + ["CreatedTimestamp", "2020-11-16T15:08:48.000Z"], + ["LastModifiedTimestamp", "2020-11-16T15:08:20.000Z"], + ]); + expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); +}); test("RedrivePolicy attribute should be converted to easier to read format", async () => { - const data = { - name: "QueueName", - attributes: { - RedrivePolicy: "{\"deadLetterTargetArn\": \"targetArn\", \"maxReceiveCount\": 10}" - } - }; - - (axios.get as jest.Mock).mockResolvedValueOnce({status: 200, data: data}) - - await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ - ["RedrivePolicy", "DeadLetterTargetArn: targetArn, MaxReceiveCount: 10"] - ]) - expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); -}) + const data = { + name: "QueueName", + attributes: { + RedrivePolicy: + '{"deadLetterTargetArn": "targetArn", "maxReceiveCount": 10}', + }, + }; + + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: data }); + + await expect(getQueueAttributes("QueueName")).resolves.toEqual([ + ["RedrivePolicy", "DeadLetterTargetArn: targetArn, MaxReceiveCount: 10"], + ]); + expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); +}); test("Attributes related to amount of messages should be filtered out", async () => { - const data = { - name: "QueueName", - attributes: { - ApproximateNumberOfMessages: 10, - ApproximateNumberOfMessagesNotVisible: 5, - ApproximateNumberOfMessagesDelayed: 8, - RandomAttribute: "09203" - } - }; - - (axios.get as jest.Mock).mockResolvedValueOnce({status: 200, data: data}) - - await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ - ["RandomAttribute", "09203"] - ]) - expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); -}) \ No newline at end of file + const data = { + name: "QueueName", + attributes: { + ApproximateNumberOfMessages: 10, + ApproximateNumberOfMessagesNotVisible: 5, + ApproximateNumberOfMessagesDelayed: 8, + RandomAttribute: "09203", + }, + }; + + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: data }); + + await expect(getQueueAttributes("QueueName")).resolves.toEqual([ + ["RandomAttribute", "09203"], + ]); + expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); +}); + +test("Delete message should call SQS DeleteMessage action", async () => { + const queueName = "test-queue"; + const messageId = "msg-123"; + const receiptHandle = "receipt-handle-456"; + + (axios.post as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: '', + }); + + await deleteMessage(queueName, messageId, receiptHandle); + + expect(axios.post).toHaveBeenCalledWith( + `queue/${queueName}`, + expect.any(URLSearchParams), + expect.objectContaining({ + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + ); + + const callArgs = (axios.post as jest.Mock).mock.calls[0]; + const params = callArgs[1] as URLSearchParams; + + expect(params.get("Action")).toBe("DeleteMessage"); + expect(params.get("ReceiptHandle")).toBe(receiptHandle); +}); + +test("parseReceiveMessageResponse should parse XML response with single message correctly", () => { + const xmlResponse = ` + + + + msg-123 + receipt-handle-456 + Test message body + + SentTimestamp + 1609459200000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + messageId: "msg-123", + receiptHandle: "receipt-handle-456", + body: "Test message body", + sentTimestamp: "1609459200000", + attributes: {}, + messageAttributes: {}, + }); +}); + +test("parseReceiveMessageResponse should parse XML response with multiple messages correctly", () => { + const xmlResponse = ` + + + + msg-123 + receipt-handle-456 + First message body + + SentTimestamp + 1609459200000 + + + + msg-456 + receipt-handle-789 + Second message body + + SentTimestamp + 1609459300000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + messageId: "msg-123", + receiptHandle: "receipt-handle-456", + body: "First message body", + sentTimestamp: "1609459200000", + attributes: {}, + messageAttributes: {}, + }); + expect(result[1]).toEqual({ + messageId: "msg-456", + receiptHandle: "receipt-handle-789", + body: "Second message body", + sentTimestamp: "1609459300000", + attributes: {}, + messageAttributes: {}, + }); +}); + +test("parseReceiveMessageResponse should handle message without receipt handle", () => { + const xmlResponse = ` + + + + msg-123 + Test message body + + SentTimestamp + 1609459200000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + messageId: "msg-123", + receiptHandle: undefined, + body: "Test message body", + sentTimestamp: "1609459200000", + attributes: {}, + messageAttributes: {}, + }); +}); + +test("parseReceiveMessageResponse should handle message without sent timestamp", () => { + const xmlResponse = ` + + + + msg-123 + receipt-handle-456 + Test message body + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + const { + messageId, + receiptHandle, + body, + sentTimestamp, + attributes, + messageAttributes, + } = result[0]; + + expect(result).toHaveLength(1); + expect(messageId).toBe("msg-123"); + expect(receiptHandle).toBe("receipt-handle-456"); + expect(body).toBe("Test message body"); + expect(sentTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(attributes).toEqual({}); + expect(messageAttributes).toEqual({}); +}); + +test("parseReceiveMessageResponse should return empty array for empty XML response", () => { + const xmlResponse = ` + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toEqual([]); +}); + +test("parseReceiveMessageResponse should return empty array for invalid XML", () => { + const xmlResponse = "invalid xml content"; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toEqual([]); +}); + +test("parseReceiveMessageResponse should handle message with missing messageId gracefully", () => { + const xmlResponse = ` + + + + receipt-handle-456 + Test message body + + SentTimestamp + 1609459200000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toEqual([]); +}); + +test("parseReceiveMessageResponse should handle message with missing body gracefully", () => { + const xmlResponse = ` + + + + msg-123 + receipt-handle-456 + + SentTimestamp + 1609459200000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toEqual([]); +}); diff --git a/ui/src/services/QueueService.ts b/ui/src/services/QueueService.ts index a94bd521d..9b34b7486 100644 --- a/ui/src/services/QueueService.ts +++ b/ui/src/services/QueueService.ts @@ -1,70 +1,219 @@ import * as Yup from "yup"; -import {QueueRedrivePolicyAttribute, QueueStatistic} from "../Queues/QueueMessageData"; +import { + QueueRedrivePolicyAttribute, + QueueStatistic, + QueueMessage, +} from "../Queues/QueueMessageData"; import axios from "axios"; -const instance = (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') ? axios.create({baseURL: "http://localhost:9325/"}) : axios; +const instance = + !process.env.NODE_ENV || process.env.NODE_ENV === "development" + ? axios.create({ baseURL: "http://localhost:9325/" }) + : axios; -const queuesBasicInformationSchema: Yup.NotRequiredArraySchema = Yup.array().of( - Yup.object().required().shape({ - name: Yup.string().required("Required queueName"), - statistics: Yup.object().required("Statistics are required").shape({ - approximateNumberOfVisibleMessages: Yup.number().required("Required approximateNumberOfVisibleMessages"), - approximateNumberOfMessagesDelayed: Yup.number().required("Required approximateNumberOfMessagesDelayed"), - approximateNumberOfInvisibleMessages: Yup.number().required("Required approximateNumberOfInvisibleMessages") - }) +const sqsInstance = + !process.env.NODE_ENV || process.env.NODE_ENV === "development" + ? axios.create({ baseURL: "/" }) + : axios; + +const queuesBasicInformationSchema = Yup.array().of( + Yup.object() + .required() + .shape({ + name: Yup.string().required("Required queueName"), + statistics: Yup.object() + .required("Statistics are required") + .shape({ + approximateNumberOfVisibleMessages: Yup.number().required( + "Required approximateNumberOfVisibleMessages" + ), + approximateNumberOfMessagesDelayed: Yup.number().required( + "Required approximateNumberOfMessagesDelayed" + ), + approximateNumberOfInvisibleMessages: Yup.number().required( + "Required approximateNumberOfInvisibleMessages" + ), + }), }) ); -async function getQueueListWithCorrelatedMessages(): Promise { - const response = await instance.get(`statistics/queues`) - const result = queuesBasicInformationSchema.validateSync(response.data) - return result === undefined ? [] : result; +export async function getQueueListWithCorrelatedMessages(): Promise< + QueueStatistic[] +> { + const response = await instance.get(`statistics/queues`); + const result = queuesBasicInformationSchema.validateSync(response.data); + return result === undefined ? [] : (result as QueueStatistic[]); } const numberOfMessagesRelatedAttributes = [ - "ApproximateNumberOfMessages", - "ApproximateNumberOfMessagesNotVisible", - "ApproximateNumberOfMessagesDelayed", -] + "ApproximateNumberOfMessages", + "ApproximateNumberOfMessagesNotVisible", + "ApproximateNumberOfMessagesDelayed", +]; interface QueueAttributes { - attributes: AttributeNameValue, - name: string + attributes: AttributeNameValue; + name: string; } interface AttributeNameValue { - [name: string]: string; + [name: string]: string; +} + +export async function getQueueAttributes(queueName: string) { + const response = await instance.get(`statistics/queues/${queueName}`); + if (response.status !== 200) { + return []; + } + const data: QueueAttributes = response.data as QueueAttributes; + return Object.entries(data.attributes) + .filter( + ([attributeKey, _]) => + !numberOfMessagesRelatedAttributes.includes(attributeKey) + ) + .map(([attributeKey, attributeValue]) => [ + attributeKey, + trimAttributeValue(attributeKey, attributeValue), + ]); +} + +export function trimAttributeValue( + attributeName: string, + attributeValue: string +) { + switch (attributeName) { + case "CreatedTimestamp": + case "LastModifiedTimestamp": + return new Date(parseInt(attributeValue) * 1000).toISOString(); + case "RedrivePolicy": + const redriveAttributeValue: QueueRedrivePolicyAttribute = + JSON.parse(attributeValue); + + const deadLetterTargetArn = `DeadLetterTargetArn: ${redriveAttributeValue.deadLetterTargetArn}`; + const maxReceiveCount = `MaxReceiveCount: ${redriveAttributeValue.maxReceiveCount}`; + + return `${deadLetterTargetArn}, ${maxReceiveCount}`; + default: + return attributeValue; + } +} + +export async function sendMessage( + queueName: string, + messageBody: string +): Promise { + const params = new URLSearchParams(); + params.append("Action", "SendMessage"); + params.append("MessageBody", messageBody); + + const response = await sqsInstance.post(`queue/${queueName}`, params, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (response.status !== 200 && response.status !== 201) { + throw new Error(`Failed to send message: ${response.statusText}`); + } } -async function getQueueAttributes(queueName: string) { - const response = await instance.get(`statistics/queues/${queueName}`) +export async function getQueueMessages( + queueName: string, + maxResults: number = 10 +): Promise { + try { + const params = new URLSearchParams(); + params.append("Action", "ReceiveMessage"); + params.append("MaxNumberOfMessages", maxResults.toString()); + params.append("VisibilityTimeout", "0"); + params.append("WaitTimeSeconds", "0"); + + const response = await sqsInstance.post(`queue/${queueName}`, params, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + if (response.status !== 200) { - console.log("Can't obtain attributes of " + queueName + " queue because of " + response.statusText) - return []; + console.log( + `Can't obtain messages from ${queueName} queue: ${response.statusText}` + ); + return []; } - const data: QueueAttributes = response.data as QueueAttributes - return Object.entries(data.attributes) - .filter(([attributeKey, _]) => !numberOfMessagesRelatedAttributes.includes(attributeKey)) - .map(([attributeKey, attributeValue]) => [ - attributeKey, - trimAttributeValue(attributeKey, attributeValue) - ]); + + const xmlData = response.data; + return parseReceiveMessageResponse(xmlData); + } catch (error) { + console.log(`Error fetching messages from ${queueName}:`, error); + return []; + } } -function trimAttributeValue(attributeName: string, attributeValue: string) { - switch (attributeName) { - case "CreatedTimestamp": - case "LastModifiedTimestamp": - return new Date(parseInt(attributeValue) * 1000).toISOString(); - case "RedrivePolicy": - const redriveAttributeValue: QueueRedrivePolicyAttribute = JSON.parse(attributeValue) - const deadLetterTargetArn = "DeadLetterTargetArn: " + redriveAttributeValue.deadLetterTargetArn - const maxReceiveCount = "MaxReceiveCount: " + redriveAttributeValue.maxReceiveCount - return deadLetterTargetArn + ", " + maxReceiveCount - default: - return attributeValue; +export function parseReceiveMessageResponse(xmlData: string): QueueMessage[] { + try { + const messages: QueueMessage[] = []; + + const messageRegex = /([\s\S]*?)<\/Message>/g; + const messageIdRegex = /([\s\S]*?)<\/MessageId>/; + const receiptHandleRegex = /([\s\S]*?)<\/ReceiptHandle>/; + const bodyRegex = /([\s\S]*?)<\/Body>/; + const sentTimestampRegex = + /SentTimestamp<\/Name>\s*([\s\S]*?)<\/Value>/; + let match; + while ((match = messageRegex.exec(xmlData)) !== null) { + const messageXml = match[1]; + + const messageIdMatch = messageIdRegex.exec(messageXml); + const receiptHandleMatch = receiptHandleRegex.exec(messageXml); + const bodyMatch = bodyRegex.exec(messageXml); + const sentTimestampMatch = sentTimestampRegex.exec(messageXml); + + if (messageIdMatch && bodyMatch) { + messages.push({ + messageId: messageIdMatch[1], + receiptHandle: receiptHandleMatch ? receiptHandleMatch[1] : undefined, + body: bodyMatch[1], + sentTimestamp: sentTimestampMatch + ? sentTimestampMatch[1] + : new Date().toISOString(), + attributes: {}, + messageAttributes: {}, + }); + } } + + return messages; + } catch (error) { + console.error("Error parsing XML response:", error); + return []; + } } -export default {getQueueListWithCorrelatedMessages, getQueueAttributes} \ No newline at end of file +export async function deleteMessage( + queueName: string, + messageId: string, + receiptHandle: string +): Promise { + try { + const params = new URLSearchParams(); + params.append("Action", "DeleteMessage"); + params.append("ReceiptHandle", receiptHandle); + + const response = await sqsInstance.post(`queue/${queueName}`, params, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (response.status !== 200) { + throw new Error(`Failed to delete message: ${response.statusText}`); + } + } catch (error) { + console.error( + `Error deleting message ${messageId} from ${queueName}:`, + error + ); + throw error; + } +} diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js index 8f2609b7b..1dd407a63 100644 --- a/ui/src/setupTests.js +++ b/ui/src/setupTests.js @@ -2,4 +2,4 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom"; diff --git a/ui/src/tests/utils.tsx b/ui/src/tests/utils.tsx new file mode 100644 index 000000000..012d132fe --- /dev/null +++ b/ui/src/tests/utils.tsx @@ -0,0 +1,7 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { SnackbarProvider } from "../context/SnackbarContext"; + +export const renderWithSnackbarProvider = (component: React.ReactElement) => { + return render({component}); +}; diff --git a/ui/src/utils/decodeHtml.test.ts b/ui/src/utils/decodeHtml.test.ts new file mode 100644 index 000000000..226bd81f9 --- /dev/null +++ b/ui/src/utils/decodeHtml.test.ts @@ -0,0 +1,41 @@ +import decodeHtmlEntities from "./decodeHtml"; + +describe("decodeHtmlEntities", () => { + test("should decode basic HTML entities", () => { + const input = "<div>Text with & special characters</div>"; + const expected = "
Text with & special characters
"; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + test("should decode numeric entities", () => { + const input = + "<span>Text with & numeric entities</span>"; + const expected = "Text with & numeric entities"; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + test("should decode hexadecimal entities", () => { + const input = + "<p>Text with & hexadecimal entities</p>"; + const expected = "

Text with & hexadecimal entities

"; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + test("should handle text without HTML entities", () => { + const input = "Text without HTML entities"; + const expected = "Text without HTML entities"; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + test("should handle empty text", () => { + const input = ""; + const expected = ""; + expect(decodeHtmlEntities(input)).toBe(expected); + }); + + test("should decode entities for accented characters", () => { + const input = "Text with accentção"; + const expected = "Text with accentção"; + expect(decodeHtmlEntities(input)).toBe(expected); + }); +}); diff --git a/ui/src/utils/decodeHtml.ts b/ui/src/utils/decodeHtml.ts new file mode 100644 index 000000000..84d4cac14 --- /dev/null +++ b/ui/src/utils/decodeHtml.ts @@ -0,0 +1,5 @@ +export default function decodeHtmlEntities(text: string) { + const parser = new DOMParser(); + const doc = parser.parseFromString(text, "text/html"); + return doc.documentElement.textContent || text; +} diff --git a/ui/src/utils/formatDate.ts b/ui/src/utils/formatDate.ts new file mode 100644 index 000000000..43d106944 --- /dev/null +++ b/ui/src/utils/formatDate.ts @@ -0,0 +1,8 @@ +export default function formatDate(timestamp: string) { + try { + const date = new Date(timestamp); + return date.toLocaleString(); + } catch { + return timestamp; + } +} diff --git a/ui/src/utils/getErrorMessage.ts b/ui/src/utils/getErrorMessage.ts new file mode 100644 index 000000000..5ba42ba4b --- /dev/null +++ b/ui/src/utils/getErrorMessage.ts @@ -0,0 +1,7 @@ +export default function getErrorMessage(error: unknown): string { + console.error(error); + + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "An unknown error occurred"; +} diff --git a/ui/src/utils/truncateText.ts b/ui/src/utils/truncateText.ts new file mode 100644 index 000000000..d7aa58cf7 --- /dev/null +++ b/ui/src/utils/truncateText.ts @@ -0,0 +1,4 @@ +export default function truncateText(text: string, maxLength: number = 100) { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + "..."; +}