Skip to content

Commit

Permalink
fix(coral): Subscription details: Fix consumer offsets (#2017)
Browse files Browse the repository at this point in the history
* feat(coral): Do not eagerly fetch Consumer offsets, render all partitions
* fix(core/coral): Make OffsetDetails properties required, remove prettier check on openapi.yaml

---------

Signed-off-by: Mathieu Anderson <[email protected]>
  • Loading branch information
Mathieu Anderson authored Nov 24, 2023
1 parent d74673e commit f0aff57
Show file tree
Hide file tree
Showing 10 changed files with 339 additions and 95 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package io.aiven.klaw.clusterapi.models.consumergroup;

import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
public class OffsetDetails {
private String topicPartitionId;
@NotNull private String topicPartitionId;

private String currentOffset;
@NotNull private String currentOffset;

private String endOffset;
@NotNull private String endOffset;

private String lag;
@NotNull private String lag;
}
3 changes: 0 additions & 3 deletions coral/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@
],
"**/*.{md, css}": [
"prettier --check"
],
"../openapi.yaml": [
"prettier --check"
]
},
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { cleanup, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { ConsumerOffsetsValues } from "src/app/features/topics/details/subscriptions/components/ConsumerOffsetsValues";
import { getConsumerOffsets } from "src/domain/acl/acl-api";
import { ConsumerOffsets } from "src/domain/acl/acl-types";
import { customRender } from "src/services/test-utils/render-with-wrappers";

jest.mock("src/domain/acl/acl-api");

const mockGetConsumerOffsets = getConsumerOffsets as jest.MockedFunction<
typeof getConsumerOffsets
>;

const testOffsetsDataOnePartition: ConsumerOffsets[] = [
{
topicPartitionId: "0",
currentOffset: "0",
endOffset: "0",
lag: "0",
},
];

const testOffsetsDataTwoPartitions: ConsumerOffsets[] = [
{
topicPartitionId: "0",
currentOffset: "0",
endOffset: "0",
lag: "0",
},
{
topicPartitionId: "1",
currentOffset: "0",
endOffset: "0",
lag: "0",
},
];

const testOffsetsNoData: ConsumerOffsets[] = [];

const props = {
topicName: "aivtopic3",
consumerGroup: "-na-",
environment: "1",
setError: jest.fn(),
};

describe("ConsumerOffsetValues.tsx", () => {
beforeEach(() => {
customRender(<ConsumerOffsetsValues {...props} />, {
queryClient: true,
});
});
afterEach(() => {
jest.resetAllMocks();
cleanup();
});

it("does not call getConsumerOffsets on load", () => {
expect(mockGetConsumerOffsets).not.toHaveBeenCalled();
});
it("renders correct initial state", () => {
const fetchButton = screen.getByRole("button", {
name: "Fetch the consumer offsets of the current subscription",
});
const offsetsText = screen.getByText("Fetch offsets to display data.");

expect(fetchButton).toBeEnabled();
expect(offsetsText).toBeVisible();
});
it("renders Consumer offsets when clicking Fetch offsets button (one partition)", async () => {
mockGetConsumerOffsets.mockResolvedValue(testOffsetsDataOnePartition);

const fetchButton = screen.getByRole("button", {
name: "Fetch the consumer offsets of the current subscription",
});

await userEvent.click(fetchButton);

const offsets = screen.getByText(
"Partition 0: Current offset 0 | End offset 0 | Lag 0"
);
expect(offsets).toBeVisible();

const refetchButton = screen.getByRole("button", {
name: "Refetch the consumer offsets of the current subscription",
});

expect(refetchButton).toBeEnabled();
});
it("renders Consumer offsets when clicking Fetch offsets button (two partitions)", async () => {
mockGetConsumerOffsets.mockResolvedValue(testOffsetsDataTwoPartitions);

const fetchButton = screen.getByRole("button", {
name: "Fetch the consumer offsets of the current subscription",
});

await userEvent.click(fetchButton);

const offsetsPartitionOne = screen.getByText(
"Partition 0: Current offset 0 | End offset 0 | Lag 0"
);
const offsetsPartitionTwo = screen.getByText(
"Partition 1: Current offset 0 | End offset 0 | Lag 0"
);
expect(offsetsPartitionOne).toBeVisible();
expect(offsetsPartitionTwo).toBeVisible();

const refetchButton = screen.getByRole("button", {
name: "Refetch the consumer offsets of the current subscription",
});

expect(refetchButton).toBeEnabled();
});
it("renders no data message when clicking Fetch offsets button (no data)", async () => {
mockGetConsumerOffsets.mockResolvedValue(testOffsetsNoData);

const fetchButton = screen.getByRole("button", {
name: "Fetch the consumer offsets of the current subscription",
});

await userEvent.click(fetchButton);

const noOffsets = screen.getByText("No offsets are currently retained.");

expect(noOffsets).toBeVisible();

const refetchButton = screen.getByRole("button", {
name: "Refetch the consumer offsets of the current subscription",
});

expect(refetchButton).toBeEnabled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Box, Button, Grid, Skeleton, Typography } from "@aivenio/aquarium";
import refreshIcon from "@aivenio/aquarium/dist/src/icons/refresh";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { getConsumerOffsets } from "src/domain/acl/acl-api";
import { ConsumerOffsets } from "src/domain/acl/acl-types";
import { HTTPError } from "src/services/api";
import { parseErrorMsg } from "src/services/mutation-utils";

interface ConsumerOffsetsProps {
setError: (error: string) => void;
topicName: string;
environment: string;
consumerGroup?: string;
}

const parseOffsetsContent = ({
topicPartitionId,
currentOffset,
endOffset,
lag,
}: ConsumerOffsets) => {
return `Partition ${topicPartitionId}: Current offset ${currentOffset} | End offset ${endOffset} | Lag ${lag}`;
};

const ConsumerOffsetsValues = ({
setError,
topicName,
environment,
consumerGroup,
}: ConsumerOffsetsProps) => {
const [shouldFetch, setShouldFetch] = useState(false);
const {
data: offsetsData = [],
error: offsetsError,
isFetched: offsetsDataFetched,
isFetching,
refetch,
} = useQuery<ConsumerOffsets[], HTTPError>(
["getConsumerOffsets", topicName, environment, consumerGroup],
{
queryFn: () => {
return getConsumerOffsets({
topicName,
env: environment,
consumerGroupId: consumerGroup || "",
});
},
enabled: shouldFetch,
}
);

useEffect(() => {
if (offsetsError !== null) {
setError(parseErrorMsg(offsetsError));
}
}, [offsetsError]);

return (
<Grid style={{ gridTemplateColumns: "70% 30%" }}>
<Grid.Item>
{!shouldFetch && (
<Typography.Default htmlTag="dd">
Fetch offsets to display data.
</Typography.Default>
)}
<Box.Flex flexDirection="column">
{offsetsData.length === 0 && offsetsDataFetched && (
<Typography.Default htmlTag="dd">
No offsets are currently retained.
</Typography.Default>
)}
{isFetching ? (
<Box.Flex
flexDirection={"column"}
gap={"2"}
data-testid={"offsets-skeleton"}
>
{/* Render one skeleton on first fetch
Render as many skeletons as partitions for refetch */}
{(offsetsData.length === 0 ? ["skeleton"] : offsetsData).map(
(_, index) => {
return <Skeleton key={index} height={22} width={350} />;
}
)}
</Box.Flex>
) : (
offsetsData.map((data, index) => {
return (
<Typography.Default
key={data.topicPartitionId || index}
htmlTag="dd"
>
{parseOffsetsContent(data)}
</Typography.Default>
);
})
)}
</Box.Flex>
</Grid.Item>
<Grid.Item justifySelf="right" alignSelf="end">
<Button.Secondary
key="button"
onClick={() => {
if (!shouldFetch) {
setShouldFetch(true);
return;
}
refetch();
}}
disabled={isFetching}
loading={isFetching}
aria-label={`${
shouldFetch ? "Refetch" : "Fetch"
} the consumer offsets of the current subscription`}
icon={refreshIcon}
>
{shouldFetch ? "Refetch" : "Fetch"} offsets
</Button.Secondary>
</Grid.Item>
</Grid>
);
};

export { ConsumerOffsetsValues };
Loading

0 comments on commit f0aff57

Please sign in to comment.