diff --git a/coral/src/app/features/topics/details/messages/TopicMessages.test.tsx b/coral/src/app/features/topics/details/messages/TopicMessages.test.tsx index bd1ab0950b..5279483604 100644 --- a/coral/src/app/features/topics/details/messages/TopicMessages.test.tsx +++ b/coral/src/app/features/topics/details/messages/TopicMessages.test.tsx @@ -1,10 +1,10 @@ import { cleanup, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { Outlet, Route, Routes } from "react-router-dom"; +import { TopicMessages } from "src/app/features/topics/details/messages/TopicMessages"; import { getTopicMessages } from "src/domain/topic/topic-api"; import { mockIntersectionObserver } from "src/services/test-utils/mock-intersection-observer"; -import { TopicMessages } from "src/app/features/topics/details/messages/TopicMessages"; import { customRender } from "src/services/test-utils/render-with-wrappers"; -import { Outlet, Route, Routes } from "react-router-dom"; -import { userEvent } from "@testing-library/user-event"; jest.mock("src/domain/topic/topic-api.ts"); @@ -12,6 +12,12 @@ const mockGetTopicMessages = getTopicMessages as jest.MockedFunction< typeof getTopicMessages >; +const mockTopicOverview = { + topicInfo: { + noOfPartitions: 5, + }, +}; + const mockGetTopicMessagesResponse = { 0: "HELLO", 1: "WORLD", @@ -21,8 +27,18 @@ const mockGetTopicMessagesNoContentResponse = { status: "failed", }; +const selectModeOptions = ["Default", "Custom", "Range"]; + function DummyParent() { - return ; + return ( + + ); } describe("TopicMessages", () => { @@ -33,7 +49,7 @@ describe("TopicMessages", () => { cleanup(); jest.resetAllMocks(); }); - it("allows to switch between Default and Custom modes", async () => { + it("allows to select between Default, Custom and Range modes", async () => { mockGetTopicMessages.mockResolvedValue( mockGetTopicMessagesNoContentResponse ); @@ -49,26 +65,21 @@ describe("TopicMessages", () => { } ); - const switchInput = screen.getByRole("checkbox"); - - const switchGroupDefault = screen.getByRole("group", { - name: "Fetching mode Select message offset", + const select = screen.getByRole("combobox", { + name: "Select mode Choose mode to fetch messages", }); - expect(switchGroupDefault).toBeVisible(); - expect(switchInput).not.toBeChecked(); - - await userEvent.click(switchInput); + expect(select).toBeEnabled(); - const switchGroupCustom = screen.getByRole("group", { - name: "Fetching mode Specify message offset", + selectModeOptions.forEach((selectMode) => { + const option = screen.getByRole("option", { + name: selectMode, + }); + expect(option).toBeEnabled(); }); - - expect(switchGroupCustom).toBeVisible(); - expect(switchInput).toBeChecked(); }); - it("shows switch as Default mode according to URL search params", async () => { + it("shows selection as Default mode according to URL search params", async () => { mockGetTopicMessages.mockResolvedValue( mockGetTopicMessagesNoContentResponse ); @@ -85,17 +96,12 @@ describe("TopicMessages", () => { } ); - const switchInput = screen.getByRole("checkbox"); + const select = screen.getByRole("combobox"); - const switchGroupDefault = screen.getByRole("group", { - name: "Fetching mode Select message offset", - }); - - expect(switchGroupDefault).toBeVisible(); - expect(switchInput).not.toBeChecked(); + expect(select).toHaveValue("default"); }); - it("shows switch as Custom mode according to URL search params", async () => { + it("shows selection as Custom mode according to URL search params", async () => { mockGetTopicMessages.mockResolvedValue( mockGetTopicMessagesNoContentResponse ); @@ -112,14 +118,17 @@ describe("TopicMessages", () => { } ); - const switchInput = screen.getByRole("checkbox"); - - const switchGroupCustom = screen.getByRole("group", { - name: "Fetching mode Specify message offset", - }); - - expect(switchGroupCustom).toBeVisible(); - expect(switchInput).toBeChecked(); + expect(screen.getByRole("combobox")).toHaveValue("custom"); + expect( + screen.getByRole("spinbutton", { + name: "Partition ID * Enter partition ID to retrieve last messages", + }) + ).toBeVisible(); + expect( + screen.getByRole("spinbutton", { + name: "Number of messages * Set the number of recent messages to display from this partition", + }) + ).toBeVisible(); }); it("informs user to specify offset and fetch topic messages", async () => { @@ -141,6 +150,42 @@ describe("TopicMessages", () => { "To view messages in this topic, select the number of messages you'd like to view and select Fetch messages." ); }); + + it("shows selection as Range mode according to URL search params", async () => { + mockGetTopicMessages.mockResolvedValue( + mockGetTopicMessagesNoContentResponse + ); + customRender( + + }> + } /> + + , + { + memoryRouter: true, + queryClient: true, + customRoutePath: "/?defaultOffset=range", + } + ); + + expect(screen.getByRole("combobox")).toHaveValue("range"); + expect( + screen.getByRole("spinbutton", { + name: "Partition ID * Enter partition ID to retrieve last messages", + }) + ).toBeVisible(); + expect( + screen.getByRole("spinbutton", { + name: "Start Offset * Set the start offset", + }) + ).toBeVisible(); + expect( + screen.getByRole("spinbutton", { + name: "End Offset * Set the end offset", + }) + ).toBeVisible(); + }); + it("requests and displays all messages when Update results is pressed", async () => { mockGetTopicMessages.mockResolvedValue(mockGetTopicMessagesResponse); customRender( @@ -167,6 +212,8 @@ describe("TopicMessages", () => { offsetId: "5", selectedNumberOfOffsets: 0, selectedPartitionId: 0, + selectedOffsetRangeStart: 0, + selectedOffsetRangeEnd: 0, }); screen.getByText("HELLO"); screen.getByText("WORLD"); @@ -232,6 +279,8 @@ describe("TopicMessages", () => { offsetId: "25", selectedNumberOfOffsets: 0, selectedPartitionId: 0, + selectedOffsetRangeStart: 0, + selectedOffsetRangeEnd: 0, }); }); }); @@ -262,6 +311,8 @@ describe("TopicMessages", () => { offsetId: "custom", selectedNumberOfOffsets: 20, selectedPartitionId: 1, + selectedOffsetRangeStart: 0, + selectedOffsetRangeEnd: 0, }); }); }); @@ -291,6 +342,41 @@ describe("TopicMessages", () => { offsetId: "50", selectedNumberOfOffsets: 0, selectedPartitionId: 0, + selectedOffsetRangeStart: 0, + selectedOffsetRangeEnd: 0, + }); + }); + }); + it("populates the filter from the url search parameters (partitionId, rangeOffsetStart and rangeOffsetEnd)", async () => { + customRender( + + }> + } /> + + , + { + queryClient: true, + memoryRouter: true, + customRoutePath: + "/?defaultOffset=range&partitionId=1&rangeOffsetStart=5&rangeOffsetEnd=10", + } + ); + + await userEvent.click( + screen.getByRole("button", { + name: "Fetch and display the messages from offset 5 to offset 10 from partiton 1 of topic test", + }) + ); + await waitFor(() => { + expect(getTopicMessages).toHaveBeenNthCalledWith(1, { + topicName: "test", + consumerGroupId: "notdefined", + envId: "2", + offsetId: "range", + selectedNumberOfOffsets: 0, + selectedPartitionId: 1, + selectedOffsetRangeStart: 5, + selectedOffsetRangeEnd: 10, }); }); }); diff --git a/coral/src/app/features/topics/details/messages/TopicMessages.tsx b/coral/src/app/features/topics/details/messages/TopicMessages.tsx index f4e9168e85..2df149ed36 100644 --- a/coral/src/app/features/topics/details/messages/TopicMessages.tsx +++ b/coral/src/app/features/topics/details/messages/TopicMessages.tsx @@ -2,9 +2,8 @@ import { Box, Button, EmptyState, + NativeSelect, PageHeader, - Switch, - SwitchGroup, Typography, } from "@aivenio/aquarium"; import refreshIcon from "@aivenio/aquarium/dist/src/icons/refresh"; @@ -16,6 +15,7 @@ import { TopicMessageFilters } from "src/app/features/topics/details/messages/co import { TopicMessageList } from "src/app/features/topics/details/messages/components/TopicMessageList"; import { DefaultOffset, + TopicMessagesFetchModeTypes, useMessagesFilters, } from "src/app/features/topics/details/messages/useMessagesFilters"; import { getTopicMessages } from "src/domain/topic/topic-api"; @@ -32,7 +32,8 @@ function isNoContentResult( } function TopicMessages() { - const { topicName, environmentId } = useTopicDetails(); + const { topicName, environmentId, topicOverview } = useTopicDetails(); + const numberOfPartitions = topicOverview.topicInfo.noOfPartitions; const { validateFilters, @@ -40,12 +41,12 @@ function TopicMessages() { getFetchingMode, defaultOffsetFilters, customOffsetFilters, + rangeOffsetFilters, partitionIdFilters, } = useMessagesFilters(); - const [fetchingMode, setFetchingMode] = useState<"Default" | "Custom">( - getFetchingMode() - ); + const [fetchingMode, setFetchingMode] = + useState(getFetchingMode()); const { data: consumeResult, @@ -66,6 +67,8 @@ function TopicMessages() { offsetId: defaultOffsetFilters.defaultOffset, selectedPartitionId: Number(partitionIdFilters.partitionId), selectedNumberOfOffsets: Number(customOffsetFilters.customOffset), + selectedOffsetRangeStart: Number(rangeOffsetFilters.rangeOffsetStart), + selectedOffsetRangeEnd: Number(rangeOffsetFilters.rangeOffsetEnd), }), keepPreviousData: true, refetchOnWindowFocus: true, @@ -74,7 +77,7 @@ function TopicMessages() { const isConsuming = isInitialLoading || isRefetching; function handleUpdateResultClick(): void { - const isValid = validateFilters(); + const isValid = validateFilters(numberOfPartitions); if (isValid) { updateResults(); @@ -93,14 +96,26 @@ function TopicMessages() { customOffsetFilters.setCustomOffset(customOffset); } - function handleFetchModeChange(): void { - if (fetchingMode === "Default") { - setFetchingMode("Custom"); - defaultOffsetFilters.setDefaultOffset("custom"); - } else { - setFetchingMode("Default"); + function handleRangeOffsetStartChange(rangeOffsetStart: string): void { + rangeOffsetFilters.setRangeOffsetStart(rangeOffsetStart); + } + + function handleRangeOffsetEndChange(rangeOffsetEnd: string): void { + rangeOffsetFilters.setRangeOffsetEnd(rangeOffsetEnd); + } + + function handleFetchModeChange( + selectedFetchMode: TopicMessagesFetchModeTypes + ): void { + if (fetchingMode === selectedFetchMode) { + return; + } + if (selectedFetchMode === "default") { defaultOffsetFilters.setDefaultOffset("5"); + } else { + defaultOffsetFilters.setDefaultOffset(selectedFetchMode); } + setFetchingMode(selectedFetchMode); } function getMessagesUpdatedAt(): string { @@ -110,6 +125,16 @@ function TopicMessages() { }).format(messagesUpdatedAt); } + function getButtonLabel(): string { + if (fetchingMode === "custom") { + return `Fetch and display the latest ${customOffsetFilters.customOffset} messages from partiton ${partitionIdFilters.partitionId} of topic ${topicName}`; + } + if (fetchingMode === "range") { + return `Fetch and display the messages from offset ${rangeOffsetFilters.rangeOffsetStart} to offset ${rangeOffsetFilters.rangeOffsetEnd} from partiton ${partitionIdFilters.partitionId} of topic ${topicName}`; + } + return `Fetch and display the latest ${defaultOffsetFilters.defaultOffset} messages from topic ${topicName}`; + } + function getTableContent() { if (!consumeResult) { return ( @@ -147,22 +172,27 @@ function TopicMessages() { filters={[ - { + handleFetchModeChange( + event.target.value as TopicMessagesFetchModeTypes + ); + }} > - - {fetchingMode} - - + + {"Default"} + + + {"Custom"} + + + {"Range"} + + @@ -185,11 +219,7 @@ function TopicMessages() { onClick={handleUpdateResultClick} disabled={isConsuming} loading={isConsuming} - aria-label={ - fetchingMode === "Default" - ? `Fetch and display the latest ${defaultOffsetFilters.defaultOffset} messages from topic ${topicName}` - : `Fetch and display the latest ${customOffsetFilters.customOffset} messages from partiton ${partitionIdFilters.partitionId} of topic ${topicName}` - } + aria-label={getButtonLabel()} icon={refreshIcon} > Fetch messages diff --git a/coral/src/app/features/topics/details/messages/components/TopicMessageFilters.test.tsx b/coral/src/app/features/topics/details/messages/components/TopicMessageFilters.test.tsx index 6807b26a5a..d4a3faf5a9 100644 --- a/coral/src/app/features/topics/details/messages/components/TopicMessageFilters.test.tsx +++ b/coral/src/app/features/topics/details/messages/components/TopicMessageFilters.test.tsx @@ -1,9 +1,9 @@ import { cleanup, render, screen, within } from "@testing-library/react"; -import { TopicMessageFilters } from "src/app/features/topics/details/messages/components/TopicMessageFilters"; import { userEvent } from "@testing-library/user-event"; +import { TopicMessageFilters } from "src/app/features/topics/details/messages/components/TopicMessageFilters"; describe("TopicMessageFilters", () => { - describe("mode: Default", () => { + describe("mode: default", () => { afterEach(() => { cleanup(); }); @@ -14,13 +14,22 @@ describe("TopicMessageFilters", () => { defaultOffset: "25", customOffset: null, partitionId: null, + rangeOffsetStart: null, + rangeOffsetEnd: null, }} onDefaultOffsetChange={jest.fn()} onPartitionIdChange={jest.fn()} onCustomOffsetChange={jest.fn()} + onRangeOffsetStartChange={jest.fn()} + onRangeOffsetEndChange={jest.fn()} disabled={false} - mode={"Default"} - filterErrors={{ partitionIdFilters: null, customOffsetFilters: null }} + mode={"default"} + filterErrors={{ + partitionIdFilters: null, + customOffsetFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, + }} /> ); expect(screen.getByRole("radio", { name: "5" })).toBeVisible(); @@ -36,13 +45,22 @@ describe("TopicMessageFilters", () => { defaultOffset: "25", customOffset: null, partitionId: null, + rangeOffsetStart: null, + rangeOffsetEnd: null, }} onDefaultOffsetChange={onDefaultOffsetChange} onPartitionIdChange={jest.fn()} onCustomOffsetChange={jest.fn()} + onRangeOffsetStartChange={jest.fn()} + onRangeOffsetEndChange={jest.fn()} disabled={false} - mode={"Default"} - filterErrors={{ partitionIdFilters: null, customOffsetFilters: null }} + mode={"default"} + filterErrors={{ + partitionIdFilters: null, + customOffsetFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, + }} /> ); await userEvent.click(screen.getByRole("radio", { name: "50" })); @@ -57,13 +75,22 @@ describe("TopicMessageFilters", () => { defaultOffset: "25", customOffset: null, partitionId: null, + rangeOffsetStart: null, + rangeOffsetEnd: null, }} onDefaultOffsetChange={jest.fn()} onPartitionIdChange={jest.fn()} onCustomOffsetChange={jest.fn()} + onRangeOffsetStartChange={jest.fn()} + onRangeOffsetEndChange={jest.fn()} disabled={true} - mode={"Default"} - filterErrors={{ partitionIdFilters: null, customOffsetFilters: null }} + mode={"default"} + filterErrors={{ + partitionIdFilters: null, + customOffsetFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, + }} /> ); within( @@ -75,7 +102,7 @@ describe("TopicMessageFilters", () => { .forEach((option) => expect(option).toBeDisabled()); }); }); - describe("mode: Custom", () => { + describe("mode: custom", () => { afterEach(() => { cleanup(); }); @@ -86,13 +113,22 @@ describe("TopicMessageFilters", () => { defaultOffset: "custom", customOffset: null, partitionId: null, + rangeOffsetStart: null, + rangeOffsetEnd: null, }} onDefaultOffsetChange={jest.fn()} onPartitionIdChange={jest.fn()} onCustomOffsetChange={jest.fn()} + onRangeOffsetStartChange={jest.fn()} + onRangeOffsetEndChange={jest.fn()} disabled={false} - mode={"Custom"} - filterErrors={{ partitionIdFilters: null, customOffsetFilters: null }} + mode={"custom"} + filterErrors={{ + partitionIdFilters: null, + customOffsetFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, + }} /> ); expect( @@ -107,7 +143,7 @@ describe("TopicMessageFilters", () => { ).toBeVisible(); }); - it("is possible to enter values in fields", async () => { + it("is possible to enter values in fields for custom select mode", async () => { const onPartitionIdChange = jest.fn(); const onCustomOffsetChange = jest.fn(); render( @@ -116,13 +152,22 @@ describe("TopicMessageFilters", () => { defaultOffset: "custom", customOffset: null, partitionId: null, + rangeOffsetStart: null, + rangeOffsetEnd: null, }} onDefaultOffsetChange={jest.fn()} onPartitionIdChange={onPartitionIdChange} onCustomOffsetChange={onCustomOffsetChange} + onRangeOffsetStartChange={jest.fn()} + onRangeOffsetEndChange={jest.fn()} disabled={false} - mode={"Custom"} - filterErrors={{ partitionIdFilters: null, customOffsetFilters: null }} + mode={"custom"} + filterErrors={{ + partitionIdFilters: null, + customOffsetFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, + }} /> ); @@ -139,4 +184,98 @@ describe("TopicMessageFilters", () => { expect(onCustomOffsetChange).toHaveBeenCalledWith("10"); }); }); + + describe("mode: range", () => { + afterEach(() => { + cleanup(); + }); + it("displays required Partion ID and Range offset start and end fields", () => { + render( + + ); + expect( + screen.getByRole("spinbutton", { + name: "Partition ID * Enter partition ID to retrieve last messages", + }) + ).toBeVisible(); + expect( + screen.getByRole("spinbutton", { + name: "Start Offset * Set the start offset", + }) + ).toBeVisible(); + expect( + screen.getByRole("spinbutton", { + name: "End Offset * Set the end offset", + }) + ).toBeVisible(); + }); + + it("is possible to enter values in fields for range select mode", async () => { + const onPartitionIdChange = jest.fn(); + const onRangeOffsetStartChange = jest.fn(); + const onRangeOffsetEndChange = jest.fn(); + render( + + ); + + const partitionIdInput = screen.getByRole("spinbutton", { + name: "Partition ID * Enter partition ID to retrieve last messages", + }); + const startOffsetInput = screen.getByRole("spinbutton", { + name: "Start Offset * Set the start offset", + }); + const endOffsetInput = screen.getByRole("spinbutton", { + name: "End Offset * Set the end offset", + }); + + await userEvent.type(partitionIdInput, "1"); + await userEvent.type(startOffsetInput, "5"); + await userEvent.type(endOffsetInput, "10"); + expect(onPartitionIdChange).toHaveBeenCalledWith("1"); + expect(onRangeOffsetStartChange).toHaveBeenCalledWith("5"); + expect(onRangeOffsetEndChange).toHaveBeenCalledWith("10"); + }); + }); }); diff --git a/coral/src/app/features/topics/details/messages/components/TopicMessageFilters.tsx b/coral/src/app/features/topics/details/messages/components/TopicMessageFilters.tsx index 1f1f04536e..e315346477 100644 --- a/coral/src/app/features/topics/details/messages/components/TopicMessageFilters.tsx +++ b/coral/src/app/features/topics/details/messages/components/TopicMessageFilters.tsx @@ -7,6 +7,7 @@ import { } from "@aivenio/aquarium"; import { defaultOffsets, + TopicMessagesFetchModeTypes, type DefaultOffset, type FilterErrors, } from "src/app/features/topics/details/messages/useMessagesFilters"; @@ -16,12 +17,16 @@ type Props = { defaultOffset: DefaultOffset; customOffset: string | null; partitionId: string | null; + rangeOffsetStart: string | null; + rangeOffsetEnd: string | null; }; disabled?: RadioButtonGroupProps["disabled"]; onDefaultOffsetChange: (defaultOffset: DefaultOffset) => void; onPartitionIdChange: (partitionId: string) => void; onCustomOffsetChange: (customOffset: string) => void; - mode: "Default" | "Custom"; + onRangeOffsetStartChange: (rangeOffsetStart: string) => void; + onRangeOffsetEndChange: (rangeOffsetEnd: string) => void; + mode: TopicMessagesFetchModeTypes; filterErrors: FilterErrors; }; @@ -31,30 +36,67 @@ function TopicMessageFilters({ onDefaultOffsetChange, onPartitionIdChange, onCustomOffsetChange, + onRangeOffsetStartChange, + onRangeOffsetEndChange, mode, filterErrors, }: Props) { - return mode === "Default" ? ( - onDefaultOffsetChange(value as DefaultOffset)} - description={"Select number of messages to display from this topic"} - > - {defaultOffsets.map((defaultOffset) => { - if (defaultOffset === "custom") { - return; - } - return ( - - {defaultOffset} - - ); - })} - - ) : ( + if (mode === "default") { + return ( + onDefaultOffsetChange(value as DefaultOffset)} + description={"Select number of messages to display from this topic"} + > + {defaultOffsets.map((defaultOffset) => { + if (defaultOffset === "custom" || defaultOffset === "range") { + return; + } + return ( + + {defaultOffset} + + ); + })} + + ); + } + + if (mode === "custom") { + return ( + + onPartitionIdChange(e.target.value)} + type="number" + helperText={filterErrors.partitionIdFilters || undefined} + valid={filterErrors.partitionIdFilters === null} + required + /> + onCustomOffsetChange(e.target.value)} + type="number" + helperText={filterErrors.customOffsetFilters || undefined} + valid={filterErrors.customOffsetFilters === null} + required + /> + + ); + } + + return ( - + { + //Empty node prevents stale value of custom's 'Number of messages' + //being populated into range's 'Start Offset' and vice versa } - onChange={(e) => onCustomOffsetChange(e.target.value)} + > + onRangeOffsetStartChange(e.target.value)} + type="number" + helperText={filterErrors.rangeOffsetStartFilters || undefined} + valid={filterErrors.rangeOffsetStartFilters === null} + required + /> + onRangeOffsetEndChange(e.target.value)} type="number" - helperText={filterErrors.customOffsetFilters || undefined} - valid={filterErrors.customOffsetFilters === null} + helperText={filterErrors.rangeOffsetEndFilters || undefined} + valid={filterErrors.rangeOffsetEndFilters === null} required /> diff --git a/coral/src/app/features/topics/details/messages/useMessagesFilters.test.tsx b/coral/src/app/features/topics/details/messages/useMessagesFilters.test.tsx index 79f8bbc4ba..722eeb56c5 100644 --- a/coral/src/app/features/topics/details/messages/useMessagesFilters.test.tsx +++ b/coral/src/app/features/topics/details/messages/useMessagesFilters.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, renderHook, act } from "@testing-library/react"; +import { act, cleanup, renderHook } from "@testing-library/react"; import { BrowserRouter, MemoryRouter } from "react-router-dom"; import { useMessagesFilters } from "src/app/features/topics/details/messages/useMessagesFilters"; @@ -189,13 +189,15 @@ describe("useMessagesFilters.tsx", () => { let isValid; act(() => { - isValid = result.current.validateFilters(); + isValid = result.current.validateFilters(5); }); expect(isValid).toBe(true); expect(result.current.filterErrors).toStrictEqual({ customOffsetFilters: null, partitionIdFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, }); }); it("validateFilters returns false (missing partitionId)", () => { @@ -212,7 +214,7 @@ describe("useMessagesFilters.tsx", () => { let isValid; act(() => { - isValid = result.current.validateFilters(); + isValid = result.current.validateFilters(5); }); expect(isValid).toBe(false); @@ -220,14 +222,71 @@ describe("useMessagesFilters.tsx", () => { expect(result.current.filterErrors).toStrictEqual({ customOffsetFilters: null, partitionIdFilters: "Please enter a partition ID", + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, }); }); + it("validateFilters returns false (negative partitionId)", () => { + const { result } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + let isValid; + + act(() => { + isValid = result.current.validateFilters(5); + }); + + expect(isValid).toBe(false); + + expect(result.current.filterErrors).toStrictEqual({ + customOffsetFilters: null, + partitionIdFilters: "Partition ID cannot be negative", + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, + }); + }); + it("validateFilters returns false (invalid partitionId)", () => { + const { result } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + let isValid; + + act(() => { + isValid = result.current.validateFilters(5); + }); + expect(isValid).toBe(false); + + expect(result.current.filterErrors).toStrictEqual({ + customOffsetFilters: null, + partitionIdFilters: "Invalid partition ID", + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, + }); + }); it("validateFilters returns false (missing customOffset)", () => { const { result } = renderHook(() => useMessagesFilters(), { wrapper: ({ children }) => ( {children} @@ -237,7 +296,7 @@ describe("useMessagesFilters.tsx", () => { let isValid; act(() => { - isValid = result.current.validateFilters(); + isValid = result.current.validateFilters(5); }); expect(isValid).toBe(false); @@ -246,6 +305,8 @@ describe("useMessagesFilters.tsx", () => { customOffsetFilters: "Please enter the number of recent offsets you want to view", partitionIdFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, }); }); it("validateFilters returns false (too high customOffset)", () => { @@ -253,7 +314,7 @@ describe("useMessagesFilters.tsx", () => { wrapper: ({ children }) => ( {children} @@ -264,7 +325,7 @@ describe("useMessagesFilters.tsx", () => { let isValid; act(() => { - isValid = result.current.validateFilters(); + isValid = result.current.validateFilters(5); }); expect(isValid).toBe(false); @@ -273,6 +334,8 @@ describe("useMessagesFilters.tsx", () => { customOffsetFilters: "Entered value exceeds the view limit for offsets: 100", partitionIdFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, }); }); it("getFetchingMode returns Custom", () => { @@ -289,7 +352,7 @@ describe("useMessagesFilters.tsx", () => { ), }); - expect(current.getFetchingMode()).toBe("Custom"); + expect(current.getFetchingMode()).toBe("custom"); }); it("getFetchingMode returns Default", () => { const { @@ -301,7 +364,250 @@ describe("useMessagesFilters.tsx", () => { ), }); - expect(current.getFetchingMode()).toBe("Default"); + expect(current.getFetchingMode()).toBe("default"); + }); + it("partitionId not deleted when changing from custom to range", () => { + const { + result: { current }, + } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + expect(current.getFetchingMode()).toBe("custom"); + expect(current.partitionIdFilters.partitionId).toBe("2"); + + current.defaultOffsetFilters.setDefaultOffset("range"); + expect(current.getFetchingMode()).toBe("range"); + expect(current.partitionIdFilters.partitionId).toBe("2"); + }); + it("partitionId not deleted when changing from range to custom", () => { + const { + result: { current }, + } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + expect(current.getFetchingMode()).toBe("range"); + expect(current.partitionIdFilters.partitionId).toBe("2"); + + current.defaultOffsetFilters.setDefaultOffset("custom"); + expect(current.getFetchingMode()).toBe("custom"); + expect(current.partitionIdFilters.partitionId).toBe("2"); + }); + }); + + describe("rangeOffsetFilters", () => { + afterEach(cleanup); + it("returns null as the default offset value", () => { + const { + result: { current }, + } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => {children}, + }); + + expect(current.rangeOffsetFilters.rangeOffsetStart).toBe(null); + expect(current.rangeOffsetFilters.rangeOffsetEnd).toBe(null); + }); + it("gets the start and end offset from search params", () => { + const { + result: { current }, + } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + const startOffset = current.rangeOffsetFilters.rangeOffsetStart; + const endOffset = current.rangeOffsetFilters.rangeOffsetEnd; + expect(startOffset).toEqual("20"); + expect(endOffset).toEqual("40"); + }); + it("sets the offset to search params", async () => { + const { + result: { current }, + } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => {children}, + }); + const setRangeOffsetStart = + current.rangeOffsetFilters.setRangeOffsetStart; + const setRangeOffsetEnd = current.rangeOffsetFilters.setRangeOffsetEnd; + setRangeOffsetStart("25"); + setRangeOffsetEnd("50"); + expect(window.location.search).toBe( + "?defaultOffset=range&rangeOffsetStart=25&rangeOffsetEnd=50" + ); + }); + it("deletes the offset from search params", () => { + const { + result: { current }, + } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(current.rangeOffsetFilters.rangeOffsetStart).toEqual("25"); + expect(current.rangeOffsetFilters.rangeOffsetEnd).toEqual("50"); + current.rangeOffsetFilters.deleteRangeOffsetStart(); + current.rangeOffsetFilters.deleteRangeOffsetEnd(); + expect(window.location.search).toBe(""); + }); + it("validateFilters returns true", () => { + const { result } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + let isValid; + + act(() => { + isValid = result.current.validateFilters(5); + }); + + expect(isValid).toBe(true); + expect(result.current.filterErrors).toStrictEqual({ + customOffsetFilters: null, + partitionIdFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, + }); + }); + it("validateFilters returns false (missing partitionId)", () => { + const { result } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + let isValid; + + act(() => { + isValid = result.current.validateFilters(5); + }); + + expect(isValid).toBe(false); + + expect(result.current.filterErrors).toStrictEqual({ + customOffsetFilters: null, + partitionIdFilters: "Please enter a partition ID", + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, + }); + }); + it("validateFilters returns false (missing rangeOffsetStart and rangeOffsetEnd)", () => { + const { result } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + let isValid; + + act(() => { + isValid = result.current.validateFilters(5); + }); + + expect(isValid).toBe(false); + + expect(result.current.filterErrors).toStrictEqual({ + customOffsetFilters: null, + partitionIdFilters: null, + rangeOffsetStartFilters: "Please enter the start offset", + rangeOffsetEndFilters: "Please enter the end offset", + }); + }); + it("validateFilters returns false (negative rangeOffsetStart and rangeOffsetEnd)", () => { + const { result } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + let isValid; + + act(() => { + isValid = result.current.validateFilters(5); + }); + + expect(isValid).toBe(false); + + expect(result.current.filterErrors).toStrictEqual({ + customOffsetFilters: null, + partitionIdFilters: null, + rangeOffsetStartFilters: "Start offset cannot be negative.", + rangeOffsetEndFilters: "End offset cannot be negative.", + }); + }); + it("validateFilters returns false (rangeOffsetStart bigger than rangeOffsetEnd)", () => { + const { result } = renderHook(() => useMessagesFilters(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + let isValid; + + act(() => { + isValid = result.current.validateFilters(5); + }); + + expect(isValid).toBe(false); + + expect(result.current.filterErrors).toStrictEqual({ + customOffsetFilters: null, + partitionIdFilters: null, + rangeOffsetStartFilters: "Start must me less than end.", + rangeOffsetEndFilters: null, + }); }); }); }); diff --git a/coral/src/app/features/topics/details/messages/useMessagesFilters.tsx b/coral/src/app/features/topics/details/messages/useMessagesFilters.tsx index 0d6f41770c..003a896097 100644 --- a/coral/src/app/features/topics/details/messages/useMessagesFilters.tsx +++ b/coral/src/app/features/topics/details/messages/useMessagesFilters.tsx @@ -4,22 +4,29 @@ import { useSearchParams } from "react-router-dom"; interface FilterErrors { partitionIdFilters: string | null; customOffsetFilters: string | null; + rangeOffsetStartFilters: string | null; + rangeOffsetEndFilters: string | null; } -const defaultOffsets = ["5", "25", "50", "custom"] as const; +const defaultOffsets = ["5", "25", "50", "custom", "range"] as const; type DefaultOffset = (typeof defaultOffsets)[number]; +const fetchModeTypes = ["default", "custom", "range"] as const; +type TopicMessagesFetchModeTypes = (typeof fetchModeTypes)[number]; + const NAMES = { defaultOffset: "defaultOffset", customOffset: "customOffset", + rangeOffsetStart: "rangeOffsetStart", + rangeOffsetEnd: "rangeOffsetEnd", partitionId: "partitionId", }; const initialDefaultOffset: (typeof defaultOffsets)[0] = "5"; interface OffsetFilters { - validateFilters: () => boolean; + validateFilters: (totalNumberOfPartitions: number) => boolean; filterErrors: FilterErrors; - getFetchingMode: () => "Custom" | "Default"; + getFetchingMode: () => TopicMessagesFetchModeTypes; defaultOffsetFilters: { defaultOffset: DefaultOffset; setDefaultOffset: (defaultOffset: DefaultOffset) => void; @@ -30,6 +37,14 @@ interface OffsetFilters { setCustomOffset: (customOffset: string) => void; deleteCustomOffset: () => void; }; + rangeOffsetFilters: { + rangeOffsetStart: string | null; + setRangeOffsetStart: (rangeOffsetStart: string) => void; + deleteRangeOffsetStart: () => void; + rangeOffsetEnd: string | null; + setRangeOffsetEnd: (rangeOffsetEnd: string) => void; + deleteRangeOffsetEnd: () => void; + }; partitionIdFilters: { partitionId: string | null; setPartitionId: (partitionId: string) => void; @@ -47,46 +62,82 @@ function isDefaultOffset( function useMessagesFilters(): OffsetFilters { const [searchParams, setSearchParams] = useSearchParams(); - const defaultOffset = searchParams.get(NAMES.defaultOffset); const [filterErrors, setFilterErrors] = useState({ partitionIdFilters: null, customOffsetFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, }); - function validateFilters() { - if (getFetchingMode() === "Default") { + function validateFilters(totalNumberOfPartitions: number) { + if (getFetchingMode() === "default") { return true; } const partitionIdFiltersError = getPartitionId() === "" || getPartitionId() === null ? "Please enter a partition ID" - : null; - const customOffsetFiltersError = - getCustomOffset() === "" || getCustomOffset() === null - ? "Please enter the number of recent offsets you want to view" - : Number(getCustomOffset()) > 100 - ? "Entered value exceeds the view limit for offsets: 100" - : null; + : Number(getPartitionId()) < 0 + ? "Partition ID cannot be negative" + : Number(getPartitionId()) >= totalNumberOfPartitions + ? "Invalid partition ID" + : null; + + let customOffsetFiltersError = null; + let rangeOffsetStartFiltersError = null; + let rangeOffsetEndFiltersError = null; + if (getFetchingMode() === "custom") { + customOffsetFiltersError = + getCustomOffset() === "" || getCustomOffset() === null + ? "Please enter the number of recent offsets you want to view" + : Number(getCustomOffset()) > 100 + ? "Entered value exceeds the view limit for offsets: 100" + : null; + } else { + rangeOffsetStartFiltersError = + getRangeOffsetStart() === "" || getRangeOffsetStart() === null + ? "Please enter the start offset" + : Number(getRangeOffsetStart()) < 0 + ? "Start offset cannot be negative." + : null; + rangeOffsetEndFiltersError = + getRangeOffsetEnd() === "" || getRangeOffsetEnd() === null + ? "Please enter the end offset" + : Number(getRangeOffsetEnd()) < 0 + ? "End offset cannot be negative." + : null; + + if ( + rangeOffsetStartFiltersError === null && + rangeOffsetEndFiltersError === null && + Number(getRangeOffsetStart()) > Number(getRangeOffsetEnd()) + ) { + rangeOffsetStartFiltersError = "Start must me less than end."; + } + } setFilterErrors({ partitionIdFilters: partitionIdFiltersError, customOffsetFilters: customOffsetFiltersError, + rangeOffsetStartFilters: rangeOffsetStartFiltersError, + rangeOffsetEndFilters: rangeOffsetEndFiltersError, }); return ( - partitionIdFiltersError === null && customOffsetFiltersError === null + partitionIdFiltersError === null && + customOffsetFiltersError === null && + rangeOffsetStartFiltersError === null && + rangeOffsetEndFiltersError === null ); } function getDefaultOffset(): DefaultOffset { - return isDefaultOffset(defaultOffset) - ? defaultOffset - : initialDefaultOffset; + return searchParams.get(NAMES.defaultOffset) as DefaultOffset; } function setDefaultOffset(defaultOffset: DefaultOffset): void { + const oldValue = searchParams.get(NAMES.defaultOffset); if (!isDefaultOffset(defaultOffset)) { searchParams.set(NAMES.defaultOffset, initialDefaultOffset); } else { @@ -95,9 +146,21 @@ function useMessagesFilters(): OffsetFilters { setFilterErrors({ partitionIdFilters: null, customOffsetFilters: null, + rangeOffsetStartFilters: null, + rangeOffsetEndFilters: null, }); - deletePartitionId(); + + // If mode is changing from range to custom or vice versa, then no need to delete partition id + if ( + (oldValue !== "custom" && oldValue !== "range") || + (defaultOffset !== "custom" && defaultOffset !== "range") + ) { + deletePartitionId(); + } + deleteCustomOffset(); + deleteRangeOffsetStart(); + deleteRangeOffsetEnd(); setSearchParams(searchParams); } @@ -133,6 +196,8 @@ function useMessagesFilters(): OffsetFilters { : null, })); searchParams.set(NAMES.customOffset, customOffset); + deleteRangeOffsetStart(); + deleteRangeOffsetEnd(); setSearchParams(searchParams); } @@ -141,12 +206,74 @@ function useMessagesFilters(): OffsetFilters { setSearchParams(searchParams); } + function getRangeOffsetStart(): string | null { + return searchParams.get(NAMES.rangeOffsetStart); + } + + function setRangeOffsetStart(rangeOffsetStart: string): void { + if (getDefaultOffset() !== "range") { + setDefaultOffset("range"); + } + if (rangeOffsetStart.length === 0) { + setFilterErrors((prev) => ({ + ...prev, + rangeOffsetFilters: "Please enter the starting offset", + })); + deleteRangeOffsetStart(); + return; + } + setFilterErrors((prev) => ({ + ...prev, + rangeOffsetFilters: + rangeOffsetStart === "" ? "Please enter the starting offset" : null, + })); + searchParams.set(NAMES.rangeOffsetStart, rangeOffsetStart); + deleteCustomOffset(); + setSearchParams(searchParams); + } + + function deleteRangeOffsetStart() { + searchParams.delete(NAMES.rangeOffsetStart); + setSearchParams(searchParams); + } + + function getRangeOffsetEnd(): string | null { + return searchParams.get(NAMES.rangeOffsetEnd); + } + + function setRangeOffsetEnd(rangeOffsetEnd: string): void { + if (getDefaultOffset() !== "range") { + setDefaultOffset("range"); + } + if (rangeOffsetEnd.length === 0) { + setFilterErrors((prev) => ({ + ...prev, + rangeOffsetFilters: "Please enter the ending offset", + })); + deleteRangeOffsetEnd(); + return; + } + setFilterErrors((prev) => ({ + ...prev, + rangeOffsetFilters: + rangeOffsetEnd === "" ? "Please enter the ending offset" : null, + })); + searchParams.set(NAMES.rangeOffsetEnd, rangeOffsetEnd); + deleteCustomOffset(); + setSearchParams(searchParams); + } + + function deleteRangeOffsetEnd() { + searchParams.delete(NAMES.rangeOffsetEnd); + setSearchParams(searchParams); + } + function getPartitionId(): string | null { return searchParams.get(NAMES.partitionId); } function setPartitionId(partitionId: string): void { - if (getDefaultOffset() !== "custom") { + if (getDefaultOffset() !== "custom" && getDefaultOffset() !== "range") { setDefaultOffset("custom"); } if (partitionId.length === 0) { @@ -172,22 +299,28 @@ function useMessagesFilters(): OffsetFilters { setSearchParams(searchParams); } - function getFetchingMode() { - if (defaultOffset === "custom") { - return "Custom"; + function getFetchingMode(): TopicMessagesFetchModeTypes { + if (getDefaultOffset() === "custom") { + return "custom"; + } + if (getDefaultOffset() === "range") { + return "range"; } - return "Default"; + return "default"; } useEffect(() => { - if (defaultOffset !== "custom" || defaultOffset === null) { - searchParams.set( - NAMES.defaultOffset, - defaultOffset === null ? initialDefaultOffset : defaultOffset - ); - setSearchParams(searchParams); - } + const toSetDefaultOffset = !isDefaultOffset( + searchParams.get(NAMES.defaultOffset) + ) + ? initialDefaultOffset + : searchParams.get(NAMES.defaultOffset); + searchParams.set( + NAMES.defaultOffset, + toSetDefaultOffset === null ? initialDefaultOffset : toSetDefaultOffset + ); + setSearchParams(searchParams); }, []); return { @@ -204,6 +337,14 @@ function useMessagesFilters(): OffsetFilters { setCustomOffset, deleteCustomOffset, }, + rangeOffsetFilters: { + rangeOffsetStart: getRangeOffsetStart(), + setRangeOffsetStart, + deleteRangeOffsetStart, + rangeOffsetEnd: getRangeOffsetEnd(), + setRangeOffsetEnd, + deleteRangeOffsetEnd, + }, partitionIdFilters: { partitionId: getPartitionId(), setPartitionId, @@ -213,9 +354,10 @@ function useMessagesFilters(): OffsetFilters { } export { - useMessagesFilters, defaultOffsets, isDefaultOffset, + useMessagesFilters, type DefaultOffset, type FilterErrors, + type TopicMessagesFetchModeTypes, }; diff --git a/coral/types/api.d.ts b/coral/types/api.d.ts index df4d4bbef8..b6a7f05721 100644 --- a/coral/types/api.d.ts +++ b/coral/types/api.d.ts @@ -6307,6 +6307,8 @@ export interface operations { offsetId: string; selectedPartitionId: number; selectedNumberOfOffsets: number; + selectedOffsetRangeStart: number; + selectedOffsetRangeEnd: number; }; header?: never; path?: never; diff --git a/core/src/main/java/io/aiven/klaw/controller/TopicController.java b/core/src/main/java/io/aiven/klaw/controller/TopicController.java index 677cd08f41..a79efe150f 100644 --- a/core/src/main/java/io/aiven/klaw/controller/TopicController.java +++ b/core/src/main/java/io/aiven/klaw/controller/TopicController.java @@ -321,7 +321,9 @@ public ResponseEntity> getTopicEvents( @RequestParam(value = "consumerGroupId") String consumerGroupId, @RequestParam(value = "offsetId") String offsetId, @Valid @RequestParam(value = "selectedPartitionId") Integer selectedPartitionId, - @Valid @RequestParam(value = "selectedNumberOfOffsets") Integer selectedNumberOfOffsets) + @Valid @RequestParam(value = "selectedNumberOfOffsets") Integer selectedNumberOfOffsets, + @Valid @RequestParam(value = "selectedOffsetRangeStart") Integer selectedOffsetRangeStart, + @Valid @RequestParam(value = "selectedOffsetRangeEnd") Integer selectedOffsetRangeEnd) throws KlawException { return new ResponseEntity<>( topicControllerService.getTopicEvents( @@ -330,7 +332,9 @@ public ResponseEntity> getTopicEvents( topicName, offsetId, selectedPartitionId, - selectedNumberOfOffsets), + selectedNumberOfOffsets, + selectedOffsetRangeStart, + selectedOffsetRangeEnd), HttpStatus.OK); } diff --git a/core/src/main/java/io/aiven/klaw/error/KlawErrorMessages.java b/core/src/main/java/io/aiven/klaw/error/KlawErrorMessages.java index 859631d429..4d2346855a 100644 --- a/core/src/main/java/io/aiven/klaw/error/KlawErrorMessages.java +++ b/core/src/main/java/io/aiven/klaw/error/KlawErrorMessages.java @@ -342,6 +342,10 @@ public class KlawErrorMessages { public static final String TOPICS_ERR_116 = "Please check if permission " + APPROVE_TOPICS_CREATE + " is assigned to you."; + public static final String TOPICS_ERR_117 = + "PartitionId cannot be empty or less than zero. Offset range start or end cannot be less than " + + "zero and end cannot be larger than start."; + // Topic Validation public static final String TOPICS_VLD_ERR_101 = "Failure. Invalid Topic request type. Possible Value : Create/Promote"; diff --git a/core/src/main/java/io/aiven/klaw/model/enums/TopicContentType.java b/core/src/main/java/io/aiven/klaw/model/enums/TopicContentType.java new file mode 100644 index 0000000000..8cd57345a2 --- /dev/null +++ b/core/src/main/java/io/aiven/klaw/model/enums/TopicContentType.java @@ -0,0 +1,15 @@ +package io.aiven.klaw.model.enums; + +import lombok.Getter; + +@Getter +public enum TopicContentType { + CUSTOM("custom"), + RANGE("range"); + + private final String value; + + TopicContentType(String value) { + this.value = value; + } +} diff --git a/core/src/main/java/io/aiven/klaw/service/ClusterApiService.java b/core/src/main/java/io/aiven/klaw/service/ClusterApiService.java index aff6c73229..b43c717b6e 100644 --- a/core/src/main/java/io/aiven/klaw/service/ClusterApiService.java +++ b/core/src/main/java/io/aiven/klaw/service/ClusterApiService.java @@ -258,6 +258,8 @@ public Map getTopicEvents( String offsetId, Integer selectedPartitionId, Integer selectedNumberOfOffsets, + Integer selectedOffsetRangeStart, + Integer selectedOffsetRangeEnd, String consumerGroupId, int tenantId) throws KlawException { @@ -282,8 +284,8 @@ public Map getTopicEvents( String.valueOf(selectedNumberOfOffsets), clusterIdentification, RANGE_OFFSETS, - String.valueOf(-1), - String.valueOf(-1)); + String.valueOf(selectedOffsetRangeStart), + String.valueOf(selectedOffsetRangeEnd)); ResponseEntity> resultBody = getRestTemplate(null) diff --git a/core/src/main/java/io/aiven/klaw/service/TopicControllerService.java b/core/src/main/java/io/aiven/klaw/service/TopicControllerService.java index 8588643940..eaaa01550f 100644 --- a/core/src/main/java/io/aiven/klaw/service/TopicControllerService.java +++ b/core/src/main/java/io/aiven/klaw/service/TopicControllerService.java @@ -16,6 +16,7 @@ import static io.aiven.klaw.error.KlawErrorMessages.TOPICS_ERR_114; import static io.aiven.klaw.error.KlawErrorMessages.TOPICS_ERR_115; import static io.aiven.klaw.error.KlawErrorMessages.TOPICS_ERR_116; +import static io.aiven.klaw.error.KlawErrorMessages.TOPICS_ERR_117; import static io.aiven.klaw.error.KlawErrorMessages.TOPICS_VLD_ERR_121; import static io.aiven.klaw.error.KlawErrorMessages.TOPICS_VLD_ERR_125; import static io.aiven.klaw.error.KlawErrorMessages.TOPICS_VLD_ERR_126; @@ -89,7 +90,6 @@ public class TopicControllerService { public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public static final String CUSTOM_OFFSET_SELECTION = "custom"; @Autowired private final ClusterApiService clusterApiService; @Autowired ManageDatabase manageDatabase; @@ -1322,7 +1322,9 @@ public Map getTopicEvents( String topicName, String offsetId, Integer selectedPartitionId, - Integer selectedNumberOfOffsets) { + Integer selectedNumberOfOffsets, + Integer selectedOffsetRangeStart, + Integer selectedOffsetRangeEnd) { Map topicEvents = new TreeMap<>(); int tenantId = commonUtilsService.getTenantId(getUserName()); try { @@ -1330,13 +1332,24 @@ public Map getTopicEvents( manageDatabase .getClusters(KafkaClustersType.KAFKA, tenantId) .get(getEnvDetails(envId).getClusterId()); - if (offsetId != null && offsetId.equals(CUSTOM_OFFSET_SELECTION)) { - if (selectedPartitionId == null - || selectedNumberOfOffsets == null - || selectedPartitionId < 0 - || selectedNumberOfOffsets <= 0) { + if (offsetId != null) { + if (offsetId.equals(TopicContentType.CUSTOM.getValue()) + && (selectedPartitionId == null + || selectedNumberOfOffsets == null + || selectedPartitionId < 0 + || selectedNumberOfOffsets <= 0)) { throw new KlawException(TOPICS_ERR_115); } + if (offsetId.equals(TopicContentType.RANGE.getValue()) + && (selectedPartitionId == null + || selectedPartitionId < 0 + || selectedOffsetRangeStart == null + || selectedOffsetRangeEnd == null + || selectedOffsetRangeStart < 0 + || selectedOffsetRangeEnd < 0 + || selectedOffsetRangeEnd < selectedOffsetRangeStart)) { + throw new KlawException(TOPICS_ERR_117); + } } topicEvents = clusterApiService.getTopicEvents( @@ -1347,6 +1360,8 @@ public Map getTopicEvents( offsetId, selectedPartitionId, selectedNumberOfOffsets, + selectedOffsetRangeStart, + selectedOffsetRangeEnd, consumerGroupId, tenantId); } catch (Exception e) { diff --git a/core/src/main/resources/static/js/browseAcls.js b/core/src/main/resources/static/js/browseAcls.js index 599e9b5c5b..73c5a0ecd6 100644 --- a/core/src/main/resources/static/js/browseAcls.js +++ b/core/src/main/resources/static/js/browseAcls.js @@ -898,6 +898,13 @@ app.controller("browseAclsCtrl", function($scope, $http, $location, $window) { return; } + if($scope.selectedPartitionId >= $scope.topicOverview[0].noOfPartitions) + { + $scope.alert = "Please fill in a valid partition id."; + $scope.showAlertToast(); + return; + } + if(!$scope.selectedNumberOfOffsets || $scope.selectedNumberOfOffsets === ""){ $scope.alert = "Please fill how many events/offsets to be displayed"; $scope.showAlertToast(); @@ -910,9 +917,59 @@ app.controller("browseAclsCtrl", function($scope, $http, $location, $window) { $scope.showAlertToast(); return; } - }else{ + + $scope.selectedOffsetRangeStart = 0; + $scope.selectedOffsetRangeEnd = 0; + + }else if($scope.topicOffsetsVal === 'range'){ + if($scope.selectedPartitionId === ""){ + $scope.alert = "Please fill in a partition id."; + $scope.showAlertToast(); + return; + } + + if($scope.selectedPartitionId < 0 || isNaN($scope.selectedPartitionId)) + { + $scope.alert = "Please fill in a valid partition id."; + $scope.showAlertToast(); + return; + } + + if($scope.selectedPartitionId >= $scope.topicOverview[0].noOfPartitions) + { + $scope.alert = "Please fill in a valid partition id."; + $scope.showAlertToast(); + return; + } + + if($scope.selectedOffsetRangeStart === "" || $scope.selectedOffsetRangeEnd === "" + ){ + $scope.alert = "Please fill how many offsets range start and end."; + $scope.showAlertToast(); + return; + } + + if($scope.selectedOffsetRangeStart < 0 || isNaN($scope.selectedOffsetRangeStart) || + $scope.selectedOffsetRangeEnd < 0 || isNaN($scope.selectedOffsetRangeEnd)) + { + $scope.alert = "Please fill in a valid number topic offsets start and end."; + $scope.showAlertToast(); + return; + } + + if($scope.selectedOffsetRangeEnd < $scope.selectedOffsetRangeStart) + { + $scope.alert = "Offset range end cannot be less than offset range start."; + $scope.showAlertToast(); + return; + } + + $scope.selectedNumberOfOffsets = 0; + }else{ $scope.selectedPartitionId = 0; $scope.selectedNumberOfOffsets = 0; + $scope.selectedOffsetRangeStart = 0; + $scope.selectedOffsetRangeEnd = 0; } $scope.ShowSpinnerStatus = true; @@ -925,6 +982,8 @@ app.controller("browseAclsCtrl", function($scope, $http, $location, $window) { 'offsetId' : $scope.topicOffsetsVal, 'selectedPartitionId' : $scope.selectedPartitionId, 'selectedNumberOfOffsets' : $scope.selectedNumberOfOffsets, + 'selectedOffsetRangeStart' : $scope.selectedOffsetRangeStart, + 'selectedOffsetRangeEnd' : $scope.selectedOffsetRangeEnd, 'envId' : $scope.topicOverview[0].envId, 'consumerGroupId': "notdefined" } diff --git a/core/src/main/resources/templates/browseAcls.html b/core/src/main/resources/templates/browseAcls.html index 0a7bf453c0..bbba9e0a54 100644 --- a/core/src/main/resources/templates/browseAcls.html +++ b/core/src/main/resources/templates/browseAcls.html @@ -1019,18 +1019,19 @@ Team{{ aclRequest.teamname }} - Last Offsets of Topic Partitions + Offsets Select Offsets - 5 - 25 - 50 - Custom + Last 5 + Last 25 + Last 50 + Last Custom + Range - + Partition id @@ -1038,6 +1039,14 @@ Team{{ aclRequest.teamname }} Number of Offsets + + Start Offset ID + + + + End Offset ID + + Display topic events diff --git a/core/src/test/java/io/aiven/klaw/controller/TopicControllerTest.java b/core/src/test/java/io/aiven/klaw/controller/TopicControllerTest.java index c148e239c9..bed3a6f137 100644 --- a/core/src/test/java/io/aiven/klaw/controller/TopicControllerTest.java +++ b/core/src/test/java/io/aiven/klaw/controller/TopicControllerTest.java @@ -27,6 +27,7 @@ import io.aiven.klaw.service.TopicControllerService; import io.aiven.klaw.service.TopicSyncControllerService; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -41,6 +42,7 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.validation.Validator; @@ -315,4 +317,36 @@ public void updateTopic() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message", is(ApiResultStatus.SUCCESS.value))); } + + @Test + @Order(13) + public void getTopicEvents() throws Exception { + when(topicControllerService.getTopicEvents( + anyString(), + anyString(), + anyString(), + anyString(), + anyInt(), + anyInt(), + anyInt(), + anyInt())) + .thenReturn(Collections.emptyMap()); + + MvcResult mvcResult = + mvc.perform( + MockMvcRequestBuilders.get("/getTopicEvents") + .param("envId", "1") + .param("topicName", "test") + .param("consumerGroupId", "1") + .param("offsetId", "0") + .param("selectedPartitionId", "0") + .param("selectedNumberOfOffsets", "0") + .param("selectedOffsetRangeStart", "0") + .param("selectedOffsetRangeEnd", "0") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + assertThat(mvcResult.getResponse().getContentAsString()).isEqualTo("{}"); + } } diff --git a/core/src/test/java/io/aiven/klaw/service/TopicControllerServiceTest.java b/core/src/test/java/io/aiven/klaw/service/TopicControllerServiceTest.java index 2fa7c6db11..6e202b2dd0 100644 --- a/core/src/test/java/io/aiven/klaw/service/TopicControllerServiceTest.java +++ b/core/src/test/java/io/aiven/klaw/service/TopicControllerServiceTest.java @@ -48,12 +48,16 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; @@ -1098,12 +1102,15 @@ public void getTopicEvents() throws KlawException { anyString(), anyInt(), anyInt(), + anyInt(), + anyInt(), anyString(), anyInt())) .thenReturn(eventsMap); Map topicEventsMap = - topicControllerService.getTopicEvents(envId, consumerGroupId, topicName, offsetId, 0, 0); + topicControllerService.getTopicEvents( + envId, consumerGroupId, topicName, offsetId, 0, 0, 0, 0); assertThat(topicEventsMap).hasSize(2); } @@ -1554,12 +1561,15 @@ public void getTopicEventsForCustomOffset() throws KlawException { anyString(), anyInt(), anyInt(), + anyInt(), + anyInt(), anyString(), anyInt())) .thenReturn(eventsMap); Map topicEventsMap = - topicControllerService.getTopicEvents(envId, consumerGroupId, topicName, offsetId, 0, 3); + topicControllerService.getTopicEvents( + envId, consumerGroupId, topicName, offsetId, 0, 3, 0, 0); assertThat(topicEventsMap).hasSize(3); } @@ -1589,17 +1599,80 @@ public void getTopicEventsForCustomOffsetFailureForZeroOffsets() throws KlawExce anyString(), anyInt(), anyInt(), + anyInt(), + anyInt(), anyString(), anyInt())) .thenReturn(eventsMap); Map topicEventsMap = - topicControllerService.getTopicEvents(envId, consumerGroupId, topicName, offsetId, 0, 0); + topicControllerService.getTopicEvents( + envId, consumerGroupId, topicName, offsetId, 0, 0, 0, 0); assertThat(topicEventsMap.get("status")).isEqualTo("false"); } - @Test + @ParameterizedTest + @MethodSource("getTopicEventsForCustomRange") @Order(59) + public void getTopicEventsForCustomRange( + Integer partitionId, Integer start, Integer end, boolean success) throws KlawException { + String envId = "1", + consumerGroupId = "consuemrgroup", + topicName = "testtopic", + offsetId = "range"; + stubUserInfo(); + when(commonUtilsService.getTenantId(anyString())).thenReturn(101); + + Map kwClustersMap = new HashMap<>(); + kwClustersMap.put(1, utilMethods.getKwClusters()); + when(manageDatabase.getClusters(any(), anyInt())).thenReturn(kwClustersMap); + when(manageDatabase.getKafkaEnvList(anyInt())).thenReturn(utilMethods.getEnvLists()); + Map eventsMap = new HashMap<>(); + eventsMap.put("1", "hello world1"); + when(clusterApiService.getTopicEvents( + anyString(), + any(), + anyString(), + anyString(), + anyString(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyString(), + anyInt())) + .thenReturn(eventsMap); + + Map topicEventsMap = + topicControllerService.getTopicEvents( + envId, consumerGroupId, topicName, offsetId, partitionId, -1, start, end); + + assertThat(topicEventsMap.size()).isEqualTo(1); + if (success) { + assertThat(topicEventsMap.get("1")).isEqualTo("hello world1"); + } else { + assertThat(topicEventsMap.get("status")).isEqualTo("false"); + } + } + + private static Stream getTopicEventsForCustomRange() { + return Stream.of( + Arguments.of(0, 1, 3, true), // standard + Arguments.of(0, 1, 1, true), // start and end equal + Arguments.of(0, 2, 1, false), // end greater than start + Arguments.of(0, 0, 1, true), // start is 0 + Arguments.of(0, 0, 0, true), // end is o + Arguments.of(0, -1, 1, false), // start is negative + Arguments.of(0, -2, -1, false), // end is negative + Arguments.of(0, null, 3, false), // start is null + Arguments.of(0, 1, null, false), // end is null + Arguments.of(-1, 1, 3, false), // partition ID less than 0 + Arguments.of(null, 1, 3, false) // partition null + ); + } + + @Test + @Order(60) public void getTopicsWithProducerFilterAndEnv() throws KlawNotAuthorizedException { String envSel = "1", pageNo = "1"; @@ -1629,7 +1702,7 @@ public void getTopicsWithProducerFilterAndEnv() throws KlawNotAuthorizedExceptio } @Test - @Order(60) + @Order(61) public void getTopicsWithConsumerFilterNoResults() throws KlawNotAuthorizedException { String envSel = "1", pageNo = "1", topicNameSearch = "top"; @@ -1659,7 +1732,7 @@ public void getTopicsWithConsumerFilterNoResults() throws KlawNotAuthorizedExcep } @Test - @Order(61) + @Order(62) public void getTopicsWithPatternFilterOneResult() throws KlawNotAuthorizedException { String envSel = "1", pageNo = "1", topicNameSearch = "2"; @@ -1694,7 +1767,7 @@ public void getTopicsWithPatternFilterOneResult() throws KlawNotAuthorizedExcept } @Test - @Order(59) + @Order(63) public void approvePromoteTopicRequests() throws KlawException { int topicId = 1001; TopicRequest topicRequest = getTopicRequest(TOPIC_1); diff --git a/openapi.yaml b/openapi.yaml index 48e281a55a..f894d097f7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3232,6 +3232,22 @@ "type" : "integer", "format" : "int32" } + }, { + "name" : "selectedOffsetRangeStart", + "in" : "query", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int32" + } + }, { + "name" : "selectedOffsetRangeEnd", + "in" : "query", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int32" + } } ], "responses" : { "200" : {