diff --git a/console/src/platform/clusters/ClusterRoutes.test.tsx b/console/src/platform/clusters/ClusterRoutes.test.tsx index 40ff340e501e5..b9bc942568fe4 100644 --- a/console/src/platform/clusters/ClusterRoutes.test.tsx +++ b/console/src/platform/clusters/ClusterRoutes.test.tsx @@ -12,24 +12,14 @@ import React from "react"; import { Cluster } from "~/api/materialize/cluster/clusterList"; import { ErrorCode } from "~/api/materialize/types"; -import { - buildSqlQueryHandlerV2, - mapKyselyToTabular, -} from "~/api/mocks/buildSqlQueryHandler"; -import server from "~/api/mocks/server"; import { getStore } from "~/jotai"; import { allClusters } from "~/store/allClusters"; -import { - clustersFetchColumns, - emptyClustersResponse, -} from "~/test/clusterQueryBuilders"; import { mockSubscribeState } from "~/test/mockSubscribe"; import { renderComponent, RenderWithPathname } from "~/test/utils"; import ClusterRoutes from "./ClusterRoutes"; import { buildClusterServerResponse } from "./clustersTestUtils"; import { CLUSTERS_FETCH_ERROR_MESSAGE } from "./constants"; -import { clusterQueryKeys } from "./queries"; vi.mock("~/platform/clusters/ClusterDetail", () => ({ default: function () { @@ -73,30 +63,6 @@ const validCluster: Cluster = { latestStatusUpdate: "2024-01-01T00:00:00.000Z", }; -export const noSystemObjectClustersResponse = buildSqlQueryHandlerV2({ - queryKey: clusterQueryKeys.list({ - includeSystemObjects: false, - }), - results: mapKyselyToTabular({ - columns: clustersFetchColumns, - rows: [ - buildClusterServerResponse({ id: "u1", name: "default" }), - buildClusterServerResponse({ id: "u2", name: "user_cluster" }), - ], - }), -}); - -export const errorClustersResponse = buildSqlQueryHandlerV2({ - queryKey: clusterQueryKeys.list({ includeSystemObjects: false }), - results: { - error: { - message: "Something went wrong", - code: ErrorCode.INTERNAL_ERROR, - }, - notices: [], - }, -}); - describe("ClusterRoutes", () => { beforeEach(() => { const store = getStore(); @@ -104,7 +70,11 @@ describe("ClusterRoutes", () => { }); it("shows a spinner initially", async () => { - server.use(emptyClustersResponse); + const store = getStore(); + store.set( + allClusters, + mockSubscribeState({ data: [], snapshotComplete: false }), + ); renderComponent(); expect(await screen.findByText("Clusters")).toBeVisible(); @@ -114,22 +84,42 @@ describe("ClusterRoutes", () => { }); it("shows the empty state when there are no results", async () => { - server.use(emptyClustersResponse); + const store = getStore(); + store.set(allClusters, mockSubscribeState({ data: [] })); renderComponent(); expect(await screen.findByText("No available clusters")).toBeVisible(); }); - it("shows an error state when clusters fail to load", async () => { - server.use(errorClustersResponse); + it("shows an error state when the clusters subscribe fails", async () => { + const store = getStore(); + store.set( + allClusters, + mockSubscribeState({ + data: [], + snapshotComplete: false, + error: { + code: ErrorCode.INTERNAL_ERROR, + message: "Something went wrong", + }, + }), + ); renderComponent(); expect(await screen.findByText(CLUSTERS_FETCH_ERROR_MESSAGE)).toBeVisible(); }); it("renders the cluster list", async () => { - // The cluster routes use the unfiltered response and the list uses the filtered response - server.use(noSystemObjectClustersResponse); + const store = getStore(); + store.set( + allClusters, + mockSubscribeState({ + data: [ + buildClusterServerResponse({ id: "u1", name: "default" }), + buildClusterServerResponse({ id: "u2", name: "user_cluster" }), + ], + }), + ); renderComponent(); expect(await screen.findByText("Clusters")).toBeVisible(); diff --git a/console/src/platform/clusters/ClustersList.tsx b/console/src/platform/clusters/ClustersList.tsx index 887cf3bae69ce..17e26559ebbb2 100644 --- a/console/src/platform/clusters/ClustersList.tsx +++ b/console/src/platform/clusters/ClustersList.tsx @@ -27,6 +27,7 @@ import useLatestOfflineReplica, { import { AppErrorBoundary } from "~/components/AppErrorBoundary"; import { CodeBlock } from "~/components/copyableComponents"; import DeleteObjectMenuItem from "~/components/DeleteObjectMenuItem"; +import ErrorBox from "~/components/ErrorBox"; import { LoadingContainer } from "~/components/LoadingContainer"; import OverflowMenu, { OVERFLOW_BUTTON_WIDTH } from "~/components/OverflowMenu"; import { sortingFunctions } from "~/components/Table/tableColumnBuilders"; @@ -50,6 +51,7 @@ import { } from "~/layouts/listPageComponents"; import docUrls from "~/mz-doc-urls.json"; import { relativeClusterPath } from "~/platform/routeHelpers"; +import { useAllClusters } from "~/store/allClusters"; import WarningIcon from "~/svg/WarningIcon"; import { truncateMaxWidth } from "~/theme/components/Table"; import { @@ -59,7 +61,7 @@ import { import AlterClusterMenuItem from "./AlterClusterMenuItem"; import { CLUSTERS_FETCH_ERROR_MESSAGE } from "./constants"; -import { useClusters } from "./queries"; +import { useOwners } from "./queries"; import { useShowSystemObjects } from "./useShowSystemObjects"; const createClusterSuggestion = { @@ -73,7 +75,6 @@ const createClusterSuggestion = { * Read from `info.table.options.meta` and cast to this shape inside cells. */ interface ClusterTableMeta { - refetchClusters: () => void; offlineReplicaMap: LatestOfflineReplicaMap | undefined; } @@ -137,13 +138,7 @@ const LastStatusChangeCell = ({ ); }; -const ClusterActionsCell = ({ - cluster, - refetchClusters, -}: { - cluster: ClusterWithOwnership; - refetchClusters: () => void; -}) => ( +const ClusterActionsCell = ({ cluster }: { cluster: ClusterWithOwnership }) => ( undefined} objectType="CLUSTER" /> @@ -210,15 +206,7 @@ const columns = [ columnHelper.display({ id: "actions", header: "", - cell: (info) => { - const meta = info.table.options.meta as ClusterTableMeta; - return ( - - ); - }, + cell: (info) => , enableSorting: false, size: OVERFLOW_BUTTON_WIDTH, }), @@ -229,20 +217,42 @@ const ClustersListContent = ({ }: { showSystemObjects: boolean; }) => { - const { data: clusters, refetch } = useClusters({ - includeSystemObjects: showSystemObjects, - }); + const { data: clusters, snapshotComplete, isError } = useAllClusters(); + const { data: ownersById, isPending: isOwnersPending } = useOwners(); const orderedClusters = React.useMemo(() => { - if (!clusters) return []; - const systemClusters = clusters.filter((c) => isSystemCluster(c.id)); - const nonSystemClusters = clusters + const visibleClusters = clusters + .filter((c) => showSystemObjects || !isSystemCluster(c.id)) + .map((c) => ({ + ...c, + // Treat an in-flight owners query as non-owner so owner-only menu items + // stay hidden until ownership is known. + isOwner: !isOwnersPending && (ownersById?.get(c.ownerId) ?? false), + })); + // The subscribe upserts by id, so the atom's order is arbitrary. Sort each + // group by name and keep system clusters at the end. + const byName = (a: ClusterWithOwnership, b: ClusterWithOwnership) => + a.name.localeCompare(b.name); + const systemClusters = visibleClusters + .filter((c) => isSystemCluster(c.id)) + .sort(byName); + const nonSystemClusters = visibleClusters .filter((c) => !isSystemCluster(c.id)) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort(byName); return [...nonSystemClusters, ...systemClusters]; - }, [clusters]); + }, [clusters, isOwnersPending, ownersById, showSystemObjects]); + + if (isError) { + return ; + } + + // The atom starts out empty, so the empty state has to wait for the snapshot + // or it would flash before the first rows arrive. + if (!snapshotComplete) { + return ; + } - if (clusters !== null && clusters.length === 0) { + if (orderedClusters.length === 0) { return ( @@ -270,19 +280,18 @@ const ClustersListContent = ({ ); } - return ; + return ; }; interface ClusterTableProps { clusters: ClusterWithOwnership[]; - refetchClusters: () => void; } -const ClusterTable = ({ clusters, refetchClusters }: ClusterTableProps) => { +const ClusterTable = ({ clusters }: ClusterTableProps) => { const { data: offlineReplicaMap, error: offlineReplicaError } = useLatestOfflineReplica(); - const meta: ClusterTableMeta = { refetchClusters, offlineReplicaMap }; + const meta: ClusterTableMeta = { offlineReplicaMap }; const table = useUniversalTable({ data: clusters,