Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -395,9 +395,11 @@ export function toReplicaUtilizationGraphData(
size,
offlineEvents,
name,
clusterId,
}) => ({
id: replicaId,
name,
clusterId,
bucketEnd: bucketEnd.getTime(),
bucketStart: bucketStart.getTime(),
cpuPercent: maxCpu?.percent ? maxCpu.percent * 100 : null,
Expand Down
2 changes: 1 addition & 1 deletion console/src/hooks/useChartLegend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function useChartLegend({

const toggleLegendItem = (
key: string,
event: React.MouseEvent<HTMLDivElement>,
event: Pick<React.MouseEvent, "metaKey" | "ctrlKey">,
) => {
const isModifierPressed = event.metaKey || event.ctrlKey;

Expand Down
2 changes: 2 additions & 0 deletions console/src/mz-doc-urls.json
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,8 @@
"/docs/sql/subscribe/": "https://materialize.com/docs/sql/subscribe/",
"/docs/sql/functions/substring/": "https://materialize.com/docs/sql/functions/substring/",
"/docs/support/": "https://materialize.com/docs/support/",
"/docs/clusters/troubleshoot-clusters/memory-spike/": "https://materialize.com/docs/clusters/troubleshoot-clusters/memory-spike/",
"/docs/clusters/troubleshoot-clusters/hydration-failures/": "https://materialize.com/docs/clusters/troubleshoot-clusters/hydration-failures/",
"/docs/sql/system-catalog/": "https://materialize.com/docs/sql/system-catalog/",
"/docs/sql/system-clusters/": "https://materialize.com/docs/sql/system-clusters/",
"/docs/sql/table/": "https://materialize.com/docs/sql/table/",
Expand Down
9 changes: 5 additions & 4 deletions console/src/platform/clusters/ClusterDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ vi.mock("~/platform/clusters/ClusterOverview", () => ({
return <div>ClusterOverview component</div>;
},
}));
vi.mock("~/platform/clusters/ClusterPage/ClusterMetrics", () => ({
ClusterMetrics: () => <div>ClusterMetrics component</div>,
}));
vi.mock("~/platform/clusters/ClusterReplicas", () => ({
default: () => <div>ClusterReplicas component</div>,
}));
Expand Down Expand Up @@ -154,7 +157,7 @@ describe("ClusterDetail redesign preview", () => {
expect(
screen.queryByRole("link", { name: "Materialized Views" }),
).not.toBeInTheDocument();
expect(screen.getByText("ClusterOverview component")).toBeVisible();
expect(screen.getByText("ClusterMetrics component")).toBeVisible();
});

it("tags an unmanaged cluster without replicas", async () => {
Expand Down Expand Up @@ -211,9 +214,7 @@ describe("ClusterDetail redesign preview", () => {
await user.click(await screen.findByRole("link", { name: "Metrics" }));

await expectPathname("/u1/default");
expect(
await screen.findByText("ClusterOverview component"),
).toBeVisible();
expect(await screen.findByText("ClusterMetrics component")).toBeVisible();
});

it("redirects classic tab URLs into the Objects tab", async () => {
Expand Down
6 changes: 2 additions & 4 deletions console/src/platform/clusters/ClusterOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { ClusterParams } from "./ClusterRoutes";
import {
CLUSTER_METRICS_UNAVAILABLE_MESSAGE,
CLUSTERS_FETCH_ERROR_MESSAGE,
MIN_BUCKET_SIZE_MS,
} from "./constants";
import LargestMaintainedQueries from "./LargestMaintainedQueries";
import { useReplicaUtilizationHistory } from "./queries";
Expand All @@ -60,9 +61,6 @@ export interface ReplicaData {
data: DataPoint[];
}

// because the data is sampled on 60s intervals, we don't want to show more granular data than this.
const MIN_BUCKET_SIZE_MS = 60 * 1000;

const GRAPH_SPACING = 24;

const ClusterOverview = () => {
Expand Down Expand Up @@ -271,7 +269,7 @@ const ClusterOverview = () => {
);
};

const ClusterInfoBox = ({ cluster }: { cluster: Cluster }) => {
export const ClusterInfoBox = ({ cluster }: { cluster: Cluster }) => {
const appConfig = useAppConfig();
let message = null;

Expand Down
5 changes: 5 additions & 0 deletions console/src/platform/clusters/ClusterOverview/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export type GraphDataKey =
export interface DataPoint {
id: string;
name: string;
/**
* The replica's cluster, which is a past blue-green deployment's for a
* replica charted through the cluster's lineage.
*/
clusterId?: string;
size: string | null;
bucketStart: number;
bucketEnd: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

import { screen } from "@testing-library/react";
import React from "react";
import { Route, Routes } from "react-router-dom";

import { Cluster } from "~/api/materialize/cluster/clusterList";
import { ErrorCode } from "~/api/materialize/types";
import { getStore } from "~/jotai";
import { buildClusterServerResponse } from "~/platform/clusters/clustersTestUtils";
import { CLUSTERS_FETCH_ERROR_MESSAGE } from "~/platform/clusters/constants";
import { allClusters } from "~/store/allClusters";
import { mockSubscribeState } from "~/test/mockSubscribe";
import { renderComponent } from "~/test/utils";

import { ClusterMetrics } from "./ClusterMetrics";

vi.mock("~/hooks/useFlags", () => ({
useFlags: () => ({ "console-freshness-2855": true }),
}));
// The page's cards have their own tests and queries.
vi.mock("./ResourceUsage/ResourceUsage", () => ({ ResourceUsage: () => null }));
vi.mock("~/platform/clusters/LargestMaintainedQueries", () => ({
default: () => null,
}));

const renderClusterMetrics = () =>
renderComponent(
<Routes>
<Route path=":clusterId/:clusterName" element={<ClusterMetrics />} />
</Routes>,
{ initialRouterEntries: ["/u1/default"] },
);

describe("ClusterMetrics", () => {
it("shows an error when the clusters subscribe fails before its snapshot", async () => {
getStore().set(
allClusters,
mockSubscribeState<Cluster>({
snapshotComplete: false,
error: {
code: ErrorCode.INTERNAL_ERROR,
message: "Something went wrong",
},
}),
);
await renderClusterMetrics();

expect(await screen.findByText(CLUSTERS_FETCH_ERROR_MESSAGE)).toBeVisible();
});

it("has no freshness card, though the classic page's flag for it is on", async () => {
getStore().set(
allClusters,
mockSubscribeState({
data: [buildClusterServerResponse({ id: "u1", name: "default" })],
}),
);
await renderClusterMetrics();

expect(
await screen.findByRole("combobox", { name: "Replica" }),
).toBeVisible();
expect(screen.queryByText("Freshness")).not.toBeInTheDocument();
});
});
109 changes: 109 additions & 0 deletions console/src/platform/clusters/ClusterPage/ClusterMetrics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

import { Box, HStack, Text, VStack } from "@chakra-ui/react";
import { sort } from "d3";
import React from "react";
import { useParams } from "react-router-dom";

import { Cluster } from "~/api/materialize/cluster/clusterList";
import ErrorBox from "~/components/ErrorBox";
import { LoadingContainer } from "~/components/LoadingContainer";
import SimpleSelect from "~/components/SimpleSelect";
import TimePeriodSelect from "~/components/TimePeriodSelect";
import { useTimePeriodMinutes } from "~/hooks/useTimePeriodSelect";
import { MainContentContainer } from "~/layouts/BaseLayout";
import { ClusterInfoBox } from "~/platform/clusters/ClusterOverview";
import { ClusterParams } from "~/platform/clusters/ClusterRoutes";
import { CLUSTERS_FETCH_ERROR_MESSAGE } from "~/platform/clusters/constants";
import LargestMaintainedQueries from "~/platform/clusters/LargestMaintainedQueries";
import { useAllClusters } from "~/store/allClusters";
import { assert } from "~/util";

import { ResourceUsage } from "./ResourceUsage/ResourceUsage";
import { useSelectedReplica } from "./useSelectedReplica";

const ClusterMetricsContent = ({ cluster }: { cluster: Cluster }) => {
const { selectedReplica, setSelectedReplicaName } =
useSelectedReplica(cluster);
const [timePeriodMinutes, setTimePeriodMinutes] = useTimePeriodMinutes({
localStorageKey: "mz-cluster-graph-time-period",
});
const replicaSelectId = React.useId();

return (
<MainContentContainer mt="10">
<VStack spacing="6">
<HStack
width="100%"
justifyContent="space-between"
flexWrap="wrap"
spacing={4}
>
<HStack spacing={2}>
<Text as="label" htmlFor={replicaSelectId} textStyle="text-ui-med">
Replica
</Text>
<SimpleSelect
id={replicaSelectId}
value={selectedReplica?.name ?? ""}
onChange={(event) =>
setSelectedReplicaName(event.target.value || undefined)
}
>
<option value="">All replicas</option>
{sort(cluster.replicas, ({ name }) => name).map(
({ id, name, size }) => (
<option key={id} value={name}>
{size ? `${name} · ${size}` : name}
</option>
),
)}
</SimpleSelect>
</HStack>
<TimePeriodSelect
timePeriodMinutes={timePeriodMinutes}
setTimePeriodMinutes={setTimePeriodMinutes}
/>
</HStack>
<ClusterInfoBox cluster={cluster} />
<ResourceUsage
cluster={cluster}
timePeriodMinutes={timePeriodMinutes}
selectedReplicaId={selectedReplica?.id}
/>
<Box width="100%">
<LargestMaintainedQueries
clusterId={cluster.id}
clusterName={cluster.name}
/>
</Box>
</VStack>
</MainContentContainer>
);
};

export const ClusterMetrics = () => {
const { clusterId } = useParams<ClusterParams>();
assert(clusterId);
const { getClusterById, isError } = useAllClusters();
const cluster = getClusterById(clusterId);

// `ClusterRoutes` redirects away from a cluster that doesn't exist, so a
// missing one is still loading, unless the subscribe failed first.
if (!cluster) {
return isError ? (
<ErrorBox message={CLUSTERS_FETCH_ERROR_MESSAGE} />
) : (
<LoadingContainer />
);
}

return <ClusterMetricsContent cluster={cluster} />;
};
6 changes: 4 additions & 2 deletions console/src/platform/clusters/ClusterPage/ClusterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import React from "react";
import { Navigate, Route, useParams } from "react-router-dom";

import { PageHeading, PageTabStrip, Tab } from "~/layouts/BaseLayout";
import ClusterOverview from "~/platform/clusters/ClusterOverview";
import ClusterReplicas from "~/platform/clusters/ClusterReplicas";
import { ClusterParams } from "~/platform/clusters/ClusterRoutes";
import { SentryRoutes } from "~/sentry";
import { useAllClusters } from "~/store/allClusters";
import { useSubscribeToAllObjectsCollection } from "~/store/allObjectsCollection";
import { assert, pluralize } from "~/util";

import { ClusterMetrics } from "./ClusterMetrics";
import { ClusterObjects } from "./ClusterObjects";
import { CLUSTER_OBJECT_TYPES } from "./clusterObjectTypes";

Expand Down Expand Up @@ -77,12 +78,13 @@ export const ClusterPageHeader = () => {
const ClusterPage = () => {
const { clusterName } = useParams<ClusterParams>();
assert(clusterName);
useSubscribeToAllObjectsCollection();

// Keying route elements on the cluster name avoids stale state when the
// breadcrumb menu switches clusters.
return (
<SentryRoutes>
<Route index element={<ClusterOverview key={clusterName} />} />
<Route index element={<ClusterMetrics key={clusterName} />} />
<Route
path="objects"
element={<Navigate to={CLUSTER_OBJECT_TYPES[0].path} replace />}
Expand Down
Loading
Loading