diff --git a/ui/api/topics/actions.ts b/ui/api/topics/actions.ts index 8b8209ffc..c98032ed6 100644 --- a/ui/api/topics/actions.ts +++ b/ui/api/topics/actions.ts @@ -80,7 +80,7 @@ export async function getTopic( }, }); const rawData = await res.json(); - //log.debug("getTopic", url, JSON.stringify(rawData, null, 2)); + log.debug(rawData, "getTopic"); return TopicResponse.parse(rawData).data; } diff --git a/ui/api/topics/schema.ts b/ui/api/topics/schema.ts index 5cb1aa12a..0042778c4 100644 --- a/ui/api/topics/schema.ts +++ b/ui/api/topics/schema.ts @@ -9,9 +9,15 @@ const OffsetSchema = z.object({ timestamp: z.string().optional(), leaderEpoch: z.number().optional(), }); +const PartitionStatusSchema = z.union([ + z.literal("FullyReplicated"), + z.literal("UnderReplicated"), + z.literal("Offline"), +]); +export type PartitionStatus = z.infer; const PartitionSchema = z.object({ partition: z.number(), - status: z.string(), + status: PartitionStatusSchema, leaderId: z.number().optional(), replicas: z.array( z.object({ diff --git a/ui/app/[locale]/kafka/[kafkaId]/@header/topics/[topicId]/TopicHeader.tsx b/ui/app/[locale]/kafka/[kafkaId]/@header/topics/[topicId]/TopicHeader.tsx index fcd3f60ac..104f396ac 100644 --- a/ui/app/[locale]/kafka/[kafkaId]/@header/topics/[topicId]/TopicHeader.tsx +++ b/ui/app/[locale]/kafka/[kafkaId]/@header/topics/[topicId]/TopicHeader.tsx @@ -40,26 +40,28 @@ export function TopicHeader({ - Consumer groups  + Partitions  - Partitions  + Consumer groups  + {/* Schema +*/} @@ -97,16 +99,6 @@ async function ConnectedTopicHeader({ Messages  - - Consumer groups  - - @@ -117,11 +109,23 @@ async function ConnectedTopicHeader({ + + Consumer groups  + + + {/* Schema + */} diff --git a/ui/app/[locale]/kafka/[kafkaId]/topics/[topicId]/partitions/NoResultsEmptyState.tsx b/ui/app/[locale]/kafka/[kafkaId]/topics/[topicId]/partitions/NoResultsEmptyState.tsx new file mode 100644 index 000000000..8c06a84f3 --- /dev/null +++ b/ui/app/[locale]/kafka/[kafkaId]/topics/[topicId]/partitions/NoResultsEmptyState.tsx @@ -0,0 +1,24 @@ +"use client"; +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + Title, +} from "@/libs/patternfly/react-core"; +import { SearchIcon } from "@/libs/patternfly/react-icons"; + +export function NoResultsEmptyState({ onReset }: { onReset: () => void }) { + return ( + + + + No results + + No partitions matching the filter. + + + ); +} diff --git a/ui/app/[locale]/kafka/[kafkaId]/topics/[topicId]/partitions/PartitionsTable.tsx b/ui/app/[locale]/kafka/[kafkaId]/topics/[topicId]/partitions/PartitionsTable.tsx index d3222be11..787877cad 100644 --- a/ui/app/[locale]/kafka/[kafkaId]/topics/[topicId]/partitions/PartitionsTable.tsx +++ b/ui/app/[locale]/kafka/[kafkaId]/topics/[topicId]/partitions/PartitionsTable.tsx @@ -1,14 +1,70 @@ "use client"; import { getTopic } from "@/api/topics/actions"; -import { Topic } from "@/api/topics/schema"; +import { PartitionStatus, Topic } from "@/api/topics/schema"; +import { NoResultsEmptyState } from "@/app/[locale]/kafka/[kafkaId]/topics/[topicId]/partitions/NoResultsEmptyState"; import { Bytes } from "@/components/Bytes"; -import { Number } from "@/components/Number"; import { TableView } from "@/components/table"; -import { Label, LabelGroup, PageSection } from "@/libs/patternfly/react-core"; -import { WarningTriangleIcon } from "@/libs/patternfly/react-icons"; -import { CodeBranchIcon, ServerIcon } from "@patternfly/react-icons"; -import Link from "next/link"; -import { useEffect, useState } from "react"; +import { + Icon, + Label, + LabelGroup, + PageSection, + ToggleGroup, + ToggleGroupItem, + Tooltip, +} from "@/libs/patternfly/react-core"; +import { + CheckCircleIcon, + FlagIcon, + HelpIcon, +} from "@/libs/patternfly/react-icons"; +import { + ExclamationCircleIcon, + ExclamationTriangleIcon, +} from "@patternfly/react-icons"; +import { ReactNode, useEffect, useState } from "react"; + +const Columns = [ + "id", + "status", + "leader", + "preferredLeader", + "replicas", + "storage", +] as const; +const SortColumns = [ + "id", + "leader", + "preferredLeader", + "status", + "storage", +] as const; +const StatusLabel: Record = { + FullyReplicated: ( + <> + + + {" "} + In-sync + + ), + UnderReplicated: ( + <> + + + {" "} + Under replicated + + ), + Offline: ( + <> + + + {" "} + Offline + + ), +}; export function PartitionsTable({ topic: initialData, @@ -18,6 +74,11 @@ export function PartitionsTable({ topic: Topic | undefined; }) { const [topic, setTopic] = useState(initialData); + const [filter, setFilter] = useState<"all" | PartitionStatus>("all"); + const [sort, setSort] = useState<{ + sort: (typeof SortColumns)[number]; + dir: "asc" | "desc"; + }>({ sort: "id", dir: "asc" }); useEffect(() => { let interval: ReturnType; if (initialData) { @@ -28,48 +89,98 @@ export function PartitionsTable({ } return () => clearInterval(interval); }, [kafkaId, initialData]); + const filteredData = topic?.attributes.partitions + ?.filter((p) => (filter !== "all" ? p.status === filter : true)) + .sort((a, b) => { + switch (sort.sort) { + case "id": + return a.partition - b.partition; + case "leader": + return (a.leaderId ?? 0) - (b.leaderId ?? 0); + case "status": + return a.status.localeCompare(b.status); + case "preferredLeader": + const apl = a.leaderId === a.replicas[0]?.nodeId; + const bpl = b.leaderId === b.replicas[0]?.nodeId; + return Number(apl) - Number(bpl); + case "storage": + return (a.leaderLocalStorage ?? 0) - (b.leaderLocalStorage ?? 0); + } + }); + const sortedData = + sort.dir === "asc" ? filteredData : filteredData?.reverse(); return ( {}} - data={topic?.attributes.partitions} + data={sortedData} emptyStateNoData={
No partitions
} - emptyStateNoResults={
todo
} - ariaLabel={"Partitions"} - columns={ - [ - "id", - "replications", - "offsets", - "leaderLocalStorage", - ] as const + emptyStateNoResults={ + setFilter("all")} /> } + isFiltered={filter !== "all"} + ariaLabel={"Partitions"} + columns={Columns} renderHeader={({ column, key, Th }) => { switch (column) { case "id": return ( - - Partition Id + + Partition ID ); - case "replications": + case "status": return ( - - Replications + + Status ); - case "offsets": + case "preferredLeader": return ( - - Offsets (Earliest/Latest) + + Preferred leader{" "} + + + ); - case "leaderLocalStorage": + case "leader": + return ( + + Leader{" "} + + + + + ); + case "replicas": + return ( + + Replicas{" "} + + + + + ); + case "storage": return ( - Storage + Size ); } @@ -79,76 +190,176 @@ export function PartitionsTable({ case "id": return ( - + {row.partition} + + ); + case "status": + return ( + + {StatusLabel[row.status]} + + ); + case "preferredLeader": + return ( + + {row.leaderId !== undefined + ? row.leaderId === row.replicas[0]?.nodeId + ? "Yes" + : "No" + : "n/a"} ); - case "replications": + case "leader": const leader = row.replicas.find( (r) => r.nodeId === row.leaderId, - )!; + ); return ( - - {leader !== undefined && ( - + + {leader ? ( + + Broker ID: {leader.nodeId} +
+ Partition leader + + } + > -
+ + ) : ( + "n/a" )} - + + ); + case "replicas": + return ( + + {row.replicas .filter((r) => r.nodeId !== row.leaderId) .map((r, idx) => ( - + + ))} ); - case "offsets": - return ( - - / - - - ); - case "leaderLocalStorage": + case "storage": return ( - + ); } }} + tools={[ + + { + setFilter("all"); + }} + /> + + {StatusLabel.FullyReplicated} ( + {topic?.attributes.partitions?.filter( + (p) => p.status === "FullyReplicated", + ).length || 0} + ) + + } + buttonId="in-sync" + isSelected={filter === "FullyReplicated"} + onChange={() => { + setFilter("FullyReplicated"); + }} + /> + + {StatusLabel.UnderReplicated} ( + {topic?.attributes.partitions?.filter( + (p) => p.status === "UnderReplicated", + ).length || 0} + ) + + } + buttonId="under-replicated" + isSelected={filter === "UnderReplicated"} + onChange={() => { + setFilter("UnderReplicated"); + }} + /> + + {StatusLabel.Offline} ( + {topic?.attributes.partitions?.filter( + (p) => p.status === "Offline", + ).length || 0} + ) + + } + buttonId="offline" + isSelected={filter === "Offline"} + onChange={() => { + setFilter("Offline"); + }} + /> + , + ]} + isColumnSortable={(column) => { + if (column !== "replicas") { + return { + columnIndex: SortColumns.indexOf(column), + label: "", + onSort: (_, __, dir) => setSort({ sort: column, dir }), + sortBy: { + index: SortColumns.indexOf(sort.sort), + direction: sort.dir, + }, + }; + } + return undefined; + }} />
); diff --git a/ui/components/table/TableView.tsx b/ui/components/table/TableView.tsx index 378cc53db..a6a161b5c 100644 --- a/ui/components/table/TableView.tsx +++ b/ui/components/table/TableView.tsx @@ -247,7 +247,7 @@ export const TableView = ({ {/* icon buttons */} {tools && ( <> - + {actions && } {tools.map((t, idx) => ( {t}