diff --git a/ui/api/kafka/actions.ts b/ui/api/kafka/actions.ts index bdac85f4a..b018e44ed 100644 --- a/ui/api/kafka/actions.ts +++ b/ui/api/kafka/actions.ts @@ -360,3 +360,36 @@ export async function getKafkaTopicMetrics( }; } } + +export async function updateKafkaCluster( + clusterId: string, + reconciliationPaused?: boolean, +): Promise { + const url = `${process.env.BACKEND_URL}/api/kafkas/${clusterId}`; + const body = { + data: { + type: "kafkas", + id: clusterId, + meta: { + reconciliationPaused: reconciliationPaused, + }, + attributes: {}, + }, + }; + + try { + const res = await fetch(url, { + headers: await getHeaders(), + method: "PATCH", + body: JSON.stringify(body), + }); + + if (res.status === 204) { + return true; + } else { + return false; + } + } catch (e) { + return false; + } +} diff --git a/ui/api/kafka/schema.ts b/ui/api/kafka/schema.ts index 7f97f96cf..7e39d34af 100644 --- a/ui/api/kafka/schema.ts +++ b/ui/api/kafka/schema.ts @@ -41,6 +41,11 @@ export type ClusterList = z.infer; const ClusterDetailSchema = z.object({ id: z.string(), type: z.literal("kafkas"), + meta: z + .object({ + reconciliationPaused: z.boolean(), + }) + .optional(), attributes: z.object({ name: z.string(), namespace: z.string().nullable().optional(), diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/@header/overview/ConnectButton.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/@header/overview/ConnectButton.tsx index 374ca4b39..6a161c617 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/@header/overview/ConnectButton.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/@header/overview/ConnectButton.tsx @@ -1,15 +1,56 @@ "use client"; +import { updateKafkaCluster } from "@/api/kafka/actions"; import { useOpenClusterConnectionPanel } from "@/components/ClusterDrawerContext"; -import { Button } from "@/libs/patternfly/react-core"; +import { ReconciliationModal } from "@/components/ClusterOverview/ReconciliationModal"; +import { + Button, + Flex, + FlexItem, + Grid, + GridItem, +} from "@/libs/patternfly/react-core"; import { useTranslations } from "next-intl"; +import { useState } from "react"; export function ConnectButton({ clusterId }: { clusterId: string }) { const t = useTranslations(); const open = useOpenClusterConnectionPanel(); + + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleSave = async () => { + try { + const success = await updateKafkaCluster(clusterId, true); + if (success) { + setIsModalOpen(false); + } + } catch (e: unknown) { + console.log("Unknown error occurred"); + } + }; + return ( - + <> + + + + + + + + + {isModalOpen && ( + setIsModalOpen(false)} + onClickPauseReconciliation={handleSave} + /> + )} + ); } diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/layout.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/layout.tsx index e24d09999..cc8712eec 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/layout.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/layout.tsx @@ -11,6 +11,7 @@ import { getServerSession } from "next-auth"; import { useTranslations } from "next-intl"; import { PropsWithChildren, ReactNode, Suspense } from "react"; import { KafkaParams } from "./kafka.params"; +import { getKafkaCluster } from "@/api/kafka/actions"; export default async function AsyncLayout({ children, @@ -26,6 +27,13 @@ export default async function AsyncLayout({ }>) { const authOptions = await getAuthOptions(); const session = await getServerSession(authOptions); + + const data = await getKafkaCluster(kafkaId); + if (!data) { + return null; + } + const reconciliationPaused = data.meta?.reconciliationPaused; + return ( {children} @@ -46,12 +55,14 @@ function Layout({ modal, kafkaId, username, + reconciliationPaused, }: PropsWithChildren<{ kafkaId: string; username: string; header: ReactNode; activeBreadcrumb: ReactNode; modal: ReactNode; + reconciliationPaused?: boolean; }>) { const t = useTranslations(); return ( @@ -59,6 +70,7 @@ function Layout({ } + reconciliationPaused={reconciliationPaused} > diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterCard.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterCard.tsx index 6ad78f01f..cf6c35c2d 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterCard.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterCard.tsx @@ -21,6 +21,7 @@ export async function ConnectedClusterCard({ brokersOnline={undefined} brokersTotal={undefined} kafkaVersion={res?.cluster.attributes.kafkaVersion || "n/a"} + reconciliationPuased={res?.cluster.meta?.reconciliationPaused} /> ); } @@ -55,6 +56,7 @@ export async function ConnectedClusterCard({ brokersOnline={brokersOnline} brokersTotal={brokersTotal} kafkaVersion={res?.cluster.attributes.kafkaVersion || "n/a"} + reconciliationPuased={res?.cluster.meta?.reconciliationPaused} /> ); } diff --git a/ui/components/AppLayout.tsx b/ui/components/AppLayout.tsx index c6ad7a639..502f34a92 100644 --- a/ui/components/AppLayout.tsx +++ b/ui/components/AppLayout.tsx @@ -1,4 +1,12 @@ -import { Nav, Page } from "@/libs/patternfly/react-core"; +import { + Banner, + Bullseye, + Button, + Flex, + FlexItem, + Nav, + Page, +} from "@/libs/patternfly/react-core"; import { useTranslations } from "next-intl"; import { PropsWithChildren, ReactNode } from "react"; import { AppMasthead } from "./AppMasthead"; @@ -11,7 +19,12 @@ export function AppLayout({ username, sidebar, children, -}: PropsWithChildren<{ username?: string; sidebar?: ReactNode }>) { + reconciliationPaused, +}: PropsWithChildren<{ + username?: string; + sidebar?: ReactNode; + reconciliationPaused?: boolean; +}>) { const t = useTranslations(); return ( {/**/} - {children} + + + + + + {t("reconciliation.reconciliation_paused_warning")} + + + + + + + + {children} + {/**/} diff --git a/ui/components/ClusterOverview/ClusterCard.stories.tsx b/ui/components/ClusterOverview/ClusterCard.stories.tsx index a085c0b71..4083626d1 100644 --- a/ui/components/ClusterOverview/ClusterCard.stories.tsx +++ b/ui/components/ClusterOverview/ClusterCard.stories.tsx @@ -12,6 +12,7 @@ type Story = StoryObj; export const WithData: Story = { args: { isLoading: false, + reconciliationPuased: true, name: "my-kafka-cluster", status: "ready", brokersTotal: 9999, @@ -71,6 +72,7 @@ export const NoMessages: Story = { brokersOnline: 9999, consumerGroups: 9999, kafkaVersion: "3.5.6", + reconciliationPuased: false, messages: [], }, }; diff --git a/ui/components/ClusterOverview/ClusterCard.tsx b/ui/components/ClusterOverview/ClusterCard.tsx index e3f0d4d83..828a8f6e8 100644 --- a/ui/components/ClusterOverview/ClusterCard.tsx +++ b/ui/components/ClusterOverview/ClusterCard.tsx @@ -2,6 +2,7 @@ import { DateTime } from "@/components/Format/DateTime"; import { Number } from "@/components/Format/Number"; import { + Button, Card, CardBody, DataList, @@ -37,6 +38,7 @@ type ClusterCardProps = { brokersTotal?: number; consumerGroups?: number; kafkaVersion: string; + reconciliationPuased?: boolean; messages: Array<{ variant: "danger" | "warning"; subject: { type: "cluster" | "broker" | "topic"; name: string; id: string }; @@ -54,6 +56,7 @@ export function ClusterCard({ consumerGroups, kafkaVersion, messages, + reconciliationPuased, }: | ({ isLoading: false; @@ -203,7 +206,7 @@ export function ClusterCard({ )) ) : ( <> - {messages.length === 0 && ( + {!reconciliationPuased && messages.length === 0 && ( )} + {reconciliationPuased && ( + + + + + + +   + + , + +
+ +
+
, + + + , + + + , + ]} + /> +
+
+ )} {messages .sort((a, b) => a.date.localeCompare(b.date)) .reverse() diff --git a/ui/components/ClusterOverview/ReconciliationModal.tsx b/ui/components/ClusterOverview/ReconciliationModal.tsx new file mode 100644 index 000000000..8a6bab386 --- /dev/null +++ b/ui/components/ClusterOverview/ReconciliationModal.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Button, Modal, ModalVariant } from "@/libs/patternfly/react-core"; +import { useTranslations } from "next-intl"; + +export function ReconciliationModal({ + isModalOpen, + onClickClose, + onClickPauseReconciliation, +}: { + isModalOpen: boolean; + onClickClose: () => void; + onClickPauseReconciliation: () => void; +}) { + const t = useTranslations(); + return ( + + {t("reconciliation.confirm")} + , + , + ]} + > + {t("reconciliation.pause_reconciliation_description")} + + ); +} diff --git a/ui/messages/en.json b/ui/messages/en.json index 7e5447255..382a5e9d7 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -359,6 +359,16 @@ "ConnectButton": { "cluster_connection_details": "Cluster connection details" }, + "reconciliation": { + "pause_reconciliation_button": "Pause Reconciliation", + "pause_reconciliation": "Pause cluster reconciliation", + "pause_reconciliation_description": "While paused, any changes to the cluster configuration will be ignored until reconciliation is resumed", + "confirm": "Confirm", + "cancel": "Cancel", + "reconciliation_paused_warning": "Reconciliation paused. Make sure to fetch the last transition time.", + "resume": "Resume", + "reconciliation_paused": "Reconciliation paused" + }, "AlertTopicGone": { "topic_not_found": "Topic not found", "go_back_to_the_list_of_topics": "Go back to the list of topics",