From 77e72a08e9307809dfa51f5164ff65490e6d9150 Mon Sep 17 00:00:00 2001 From: Qindeel Ishtiaq Date: Fri, 2 Oct 2026 19:13:04 -0400 Subject: [PATCH] console: add resource usage charts to the redesigned cluster page Third in the cluster details redesign stack. The Metrics tab gets a resource usage card: CPU, memory and replica status share one time axis and one hover tooltip, with a toggle per replica. - CPU: a line per replica, as a share of the replica's CPU allocation. Readings past 100% extend the axis, with a dashed line marking the allocation. - Memory: a bar per replica as a share of the heap limit (RAM plus swap), on a fixed 0 to 100% axis so swap-heavy sizes don't stretch it. On 26.44 and later the bar splits into RAM and swap, and the legend gives the RAM and heap limits in bytes. Earlier environments show heap alone. Where no sample has a heap limit, the labels say the bars are a share of the memory allocation. - Replica status: a row per replica, including one offline the whole window. Running, finished hydration and offline stretches, a marker for each out-of-memory kill, and a badge with the count of objects still hydrating. A window with no usage still shows the status rows. - Dotted markers where an index or materialized view was created on the cluster. - Replicas of one cluster that share a name, such as a scheduled cluster's refreshes, share one toggle, color, status row and tooltip section. A past blue-green deployment's replicas keep their own. A time's memory bars split only among the replicas reporting then, and a CPU reading with no neighbor draws as a dot. - Processes: when a replica's size runs more than one process, a Processes button on the CPU chart opens a drawer with a tab per such replica. Each tab compares processes by their latest reading, their peak (the default) or their average, and names the busiest process for CPU and for memory by that reading against the others. One CPU and one memory chart draw every process as its own line. A table lists each process's latest, peak and average readings, with a checkbox per row and one in the header to pick which lines are drawn. Readings are shares of one process's allocation over the card's window up to 24h, and the drawer says so when the card's window is longer. It reads each process's samples from a SUBSCRIBE that is open only while the drawer is. Wide windows merge neighboring buckets, keeping each metric's peak, so bars stay at least 10px wide for as many replicas as report at once. The time axis matches the classic graphs. The mark layers are memoized, so hovering only redraws the cursor and tooltip. --- .../cluster/replicaUtilizationBinning.ts | 2 + console/src/hooks/useChartLegend.ts | 2 +- console/src/mz-doc-urls.json | 2 + .../platform/clusters/ClusterDetail.test.tsx | 9 +- .../src/platform/clusters/ClusterOverview.tsx | 6 +- .../clusters/ClusterOverview/types.ts | 5 + .../ClusterPage/ClusterMetrics.test.tsx | 73 + .../clusters/ClusterPage/ClusterMetrics.tsx | 109 ++ .../clusters/ClusterPage/ClusterPage.tsx | 6 +- .../ResourceUsage/ProcessesDrawer.tsx | 988 ++++++++++ .../ResourceUsage/ResourceUsage.test.tsx | 1588 +++++++++++++++++ .../ResourceUsage/ResourceUsage.tsx | 802 +++++++++ .../ResourceUsage/ResourceUsageCharts.tsx | 1121 ++++++++++++ .../processReadingsModel.test.ts | 263 +++ .../ResourceUsage/processReadingsModel.ts | 210 +++ .../ResourceUsage/resourceUsageModel.test.ts | 199 +++ .../ResourceUsage/resourceUsageModel.ts | 205 ++- .../ResourceUsage/resourceUsageStyles.ts | 75 + .../ResourceUsage/useReplicaProcessSamples.ts | 47 + .../ResourceUsage/useResourceUsageData.ts | 298 ++++ .../ClusterPage/useSelectedReplica.ts | 43 + console/src/platform/clusters/constants.ts | 3 + console/src/platform/clusters/queries.ts | 4 +- console/src/test/mockSubscribeSocket.ts | 74 + 24 files changed, 6116 insertions(+), 18 deletions(-) create mode 100644 console/src/platform/clusters/ClusterPage/ClusterMetrics.test.tsx create mode 100644 console/src/platform/clusters/ClusterPage/ClusterMetrics.tsx create mode 100644 console/src/platform/clusters/ClusterPage/ResourceUsage/ProcessesDrawer.tsx create mode 100644 console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsage.test.tsx create mode 100644 console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsage.tsx create mode 100644 console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsageCharts.tsx create mode 100644 console/src/platform/clusters/ClusterPage/ResourceUsage/processReadingsModel.test.ts create mode 100644 console/src/platform/clusters/ClusterPage/ResourceUsage/processReadingsModel.ts create mode 100644 console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageStyles.ts create mode 100644 console/src/platform/clusters/ClusterPage/ResourceUsage/useReplicaProcessSamples.ts create mode 100644 console/src/platform/clusters/ClusterPage/ResourceUsage/useResourceUsageData.ts create mode 100644 console/src/platform/clusters/ClusterPage/useSelectedReplica.ts create mode 100644 console/src/test/mockSubscribeSocket.ts diff --git a/console/src/api/materialize/cluster/replicaUtilizationBinning.ts b/console/src/api/materialize/cluster/replicaUtilizationBinning.ts index 21e1ecd5d8146..18332809610de 100644 --- a/console/src/api/materialize/cluster/replicaUtilizationBinning.ts +++ b/console/src/api/materialize/cluster/replicaUtilizationBinning.ts @@ -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, diff --git a/console/src/hooks/useChartLegend.ts b/console/src/hooks/useChartLegend.ts index 8a91fbd2b7704..dad923c6b229c 100644 --- a/console/src/hooks/useChartLegend.ts +++ b/console/src/hooks/useChartLegend.ts @@ -35,7 +35,7 @@ export function useChartLegend({ const toggleLegendItem = ( key: string, - event: React.MouseEvent, + event: Pick, ) => { const isModifierPressed = event.metaKey || event.ctrlKey; diff --git a/console/src/mz-doc-urls.json b/console/src/mz-doc-urls.json index 50e3474b07ab9..eeae4c3e003c8 100644 --- a/console/src/mz-doc-urls.json +++ b/console/src/mz-doc-urls.json @@ -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/", diff --git a/console/src/platform/clusters/ClusterDetail.test.tsx b/console/src/platform/clusters/ClusterDetail.test.tsx index 93a4c535e03a1..d68e354354e89 100644 --- a/console/src/platform/clusters/ClusterDetail.test.tsx +++ b/console/src/platform/clusters/ClusterDetail.test.tsx @@ -33,6 +33,9 @@ vi.mock("~/platform/clusters/ClusterOverview", () => ({ return
ClusterOverview component
; }, })); +vi.mock("~/platform/clusters/ClusterPage/ClusterMetrics", () => ({ + ClusterMetrics: () =>
ClusterMetrics component
, +})); vi.mock("~/platform/clusters/ClusterReplicas", () => ({ default: () =>
ClusterReplicas component
, })); @@ -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 () => { @@ -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 () => { diff --git a/console/src/platform/clusters/ClusterOverview.tsx b/console/src/platform/clusters/ClusterOverview.tsx index f267edf02c511..2e15e48423b0e 100644 --- a/console/src/platform/clusters/ClusterOverview.tsx +++ b/console/src/platform/clusters/ClusterOverview.tsx @@ -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"; @@ -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 = () => { @@ -271,7 +269,7 @@ const ClusterOverview = () => { ); }; -const ClusterInfoBox = ({ cluster }: { cluster: Cluster }) => { +export const ClusterInfoBox = ({ cluster }: { cluster: Cluster }) => { const appConfig = useAppConfig(); let message = null; diff --git a/console/src/platform/clusters/ClusterOverview/types.ts b/console/src/platform/clusters/ClusterOverview/types.ts index 1671d8f9faaab..aa7438b8c7ebf 100644 --- a/console/src/platform/clusters/ClusterOverview/types.ts +++ b/console/src/platform/clusters/ClusterOverview/types.ts @@ -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; diff --git a/console/src/platform/clusters/ClusterPage/ClusterMetrics.test.tsx b/console/src/platform/clusters/ClusterPage/ClusterMetrics.test.tsx new file mode 100644 index 0000000000000..0ad4ee8c4d1dc --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ClusterMetrics.test.tsx @@ -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( + + } /> + , + { initialRouterEntries: ["/u1/default"] }, + ); + +describe("ClusterMetrics", () => { + it("shows an error when the clusters subscribe fails before its snapshot", async () => { + getStore().set( + allClusters, + mockSubscribeState({ + 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(); + }); +}); diff --git a/console/src/platform/clusters/ClusterPage/ClusterMetrics.tsx b/console/src/platform/clusters/ClusterPage/ClusterMetrics.tsx new file mode 100644 index 0000000000000..8cf59a642998c --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ClusterMetrics.tsx @@ -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 ( + + + + + + Replica + + + setSelectedReplicaName(event.target.value || undefined) + } + > + + {sort(cluster.replicas, ({ name }) => name).map( + ({ id, name, size }) => ( + + ), + )} + + + + + + + + + + + + ); +}; + +export const ClusterMetrics = () => { + const { clusterId } = useParams(); + 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 ? ( + + ) : ( + + ); + } + + return ; +}; diff --git a/console/src/platform/clusters/ClusterPage/ClusterPage.tsx b/console/src/platform/clusters/ClusterPage/ClusterPage.tsx index a35cc344450b1..b37703a4496e1 100644 --- a/console/src/platform/clusters/ClusterPage/ClusterPage.tsx +++ b/console/src/platform/clusters/ClusterPage/ClusterPage.tsx @@ -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"; @@ -77,12 +78,13 @@ export const ClusterPageHeader = () => { const ClusterPage = () => { const { clusterName } = useParams(); assert(clusterName); + useSubscribeToAllObjectsCollection(); // Keying route elements on the cluster name avoids stale state when the // breadcrumb menu switches clusters. return ( - } /> + } /> } diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/ProcessesDrawer.tsx b/console/src/platform/clusters/ClusterPage/ResourceUsage/ProcessesDrawer.tsx new file mode 100644 index 0000000000000..5b95b9aa0455f --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/ProcessesDrawer.tsx @@ -0,0 +1,988 @@ +// 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, + Checkbox, + Flex, + HStack, + Select, + Spinner, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + useTheme, + VStack, +} from "@chakra-ui/react"; +import { createColumnHelper, RowSelectionState } from "@tanstack/react-table"; +import { useParentSize } from "@visx/responsive"; +import { scaleTime } from "@visx/scale"; +import { LinePath } from "@visx/shape"; +import { useTooltip, useTooltipInPortal } from "@visx/tooltip"; +import { max } from "d3"; +import React from "react"; + +import { SizeAllocation } from "~/api/materialize/cluster/replicaProcessMetrics"; +import Alert from "~/components/Alert"; +import { + GraphLineCursor, + GraphTooltip, + GraphTooltipBucketRange, + TooltipColorSwatch, +} from "~/components/graphComponents"; +import SideDrawer from "~/components/SideDrawer"; +import { sortingFunctions } from "~/components/Table/tableColumnBuilders"; +import { UniversalTable } from "~/components/Table/UniversalTable"; +import { useUniversalTable } from "~/components/Table/useUniversalTable"; +import { MIN_BUCKET_SIZE_MS } from "~/platform/clusters/constants"; +import { MaterializeTheme } from "~/theme"; +import { formatPercentage } from "~/utils/format"; +import { buildXYGraphLayoutProps } from "~/utils/graph"; + +import { + bucketProcessReadings, + BusiestProcess, + busiestProcess, + ProcessBucket, + processIdsOf, + ProcessMetric, + ProcessStatistic, + ProcessSummary, + readProcessSamples, + reportsHeapLimit, + standsOut, + summarizeProcesses, +} from "./processReadingsModel"; +import { + ChartHeader, + ChartHeaderProps, + HoverOverlay, + Layout, + LegendSwatch, + PercentAxis, + PercentScale, + TimeAxis, + TimeScale, +} from "./ResourceUsageCharts"; +import { CHART_MARGIN, percentScale } from "./resourceUsageStyles"; +import { useReplicaProcessSamples } from "./useReplicaProcessSamples"; + +// The per-process history is raw per-minute samples, so the drawer reads at +// most a day of them, whatever the card's window. +const MAX_WINDOW_MINUTES = 24 * 60; +// The theme's chart palette has five strong colors. Past five processes, only +// the ones that stand out get a color; the rest stay grey but named. +const MAX_COLORED_PROCESSES = 5; +const PLOT_HEIGHT_PX = 150; +// The most processes the tooltip lists before summarizing the rest. +const TOOLTIP_PROCESS_LIMIT = 6; + +export interface ProcessReplica { + id: string; + name: string; + size: string; + /** One process's allocation. */ + allocation: SizeAllocation; +} + +export interface ProcessesDrawerProps { + isOpen: boolean; + onClose: () => void; + /** Replicas whose size runs more than one process. */ + replicas: ProcessReplica[]; + timePeriodMinutes: number; +} + +/** + * How each multi-process replica's processes share its work: which process is + * busiest, a line per process over time, and each one's latest, peak and + * average reading, read live while open. One tab per replica, and only the + * open tab reads. + */ +export const ProcessesDrawer = ({ + isOpen, + onClose, + replicas, + timePeriodMinutes, +}: ProcessesDrawerProps) => ( + + + + {replicas.map((replica) => ( + {replica.name} + ))} + + + {replicas.map((replica) => ( + + MAX_WINDOW_MINUTES} + /> + + ))} + + + +); + +const formatReading = (percent: number | null) => + percent === null ? "-" : formatPercentage(percent / 100, 1); + +/** + * How each statistic is written. `menu` names it where a reader chooses + * between them; `sentence` reads as prose in the findings. + */ +const STATISTIC_LABELS: Record< + ProcessStatistic, + { menu: string; sentence: string } +> = { + current: { menu: "right now", sentence: "right now" }, + peak: { menu: "at any moment (peak)", sentence: "at any moment" }, + average: { menu: "on average", sentence: "on average" }, +}; + +const finding = ( + label: string, + noun: string, + statistic: ProcessStatistic, + busiest: BusiestProcess | undefined, +) => { + if (!busiest) return undefined; + const when = STATISTIC_LABELS[statistic].sentence; + const others = + busiest.otherCount === 1 + ? "the other process" + : `the other ${busiest.otherCount} processes`; + if (!standsOut(busiest)) { + return `${label} is spread evenly ${when}: process ${busiest.processId} read ${formatReading(busiest.value)}, against ${formatReading(busiest.othersMean)} for ${others}.`; + } + const ratio = + busiest.othersMean > 0 + ? `, ${(busiest.value / busiest.othersMean).toFixed(1)}× ${others} (${formatReading(busiest.othersMean)})` + : ""; + return `Process ${busiest.processId} used the most ${noun} ${when}: ${formatReading(busiest.value)}${ratio}.`; +}; + +const windowLabel = (minutes: number) => { + if (minutes === 60) return "hour"; + if (minutes < 60) return `${minutes} minutes`; + return `${minutes / 60} hours`; +}; + +const ProcessesPanel = ({ + replica, + windowMinutes, + isWindowCapped, +}: { + replica: ProcessReplica; + windowMinutes: number; + /** The card shows more than the drawer reads. */ + isWindowCapped: boolean; +}) => { + const { colors } = useTheme(); + const [statistic, setStatistic] = React.useState("peak"); + const state = useReplicaProcessSamples({ + replicaId: replica.id, + windowMinutes, + }); + const { parentRef, width } = useParentSize({ debounceTime: 10 }); + const data = React.useMemo(() => { + const endMs = Date.now(); + const startMs = endMs - windowMinutes * 60_000; + const readings = readProcessSamples({ + samples: state.data, + allocation: replica.allocation, + startMs, + }); + const processIds = processIdsOf(readings); + const summaries = summarizeProcesses(processIds, readings); + return { + startMs, + endMs, + processIds, + buckets: bucketProcessReadings( + readings, + Math.max(windowMinutes * 1000, MIN_BUCKET_SIZE_MS), + startMs, + ), + summaries, + hasHeapLimit: reportsHeapLimit(state.data), + unreadNowIds: summaries.flatMap(({ processId, cpu, memory }) => + cpu.current === null && memory.current === null ? [processId] : [], + ), + }; + }, [state.data, replica.allocation, windowMinutes]); + const view = React.useMemo(() => { + const busiestCpu = busiestProcess(data.summaries, "cpu", statistic); + const busiestMemory = busiestProcess(data.summaries, "memory", statistic); + return { + ...data, + busiestCpu, + busiestMemory, + busiestCpuId: standsOut(busiestCpu) ? busiestCpu?.processId : undefined, + busiestMemoryId: standsOut(busiestMemory) + ? busiestMemory?.processId + : undefined, + }; + }, [data, statistic]); + const { processIds, busiestCpuId, busiestMemoryId } = view; + const colorById = React.useMemo(() => { + const palette = colors.lineGraph.slice(0, MAX_COLORED_PROCESSES); + const coloredIds = + processIds.length <= MAX_COLORED_PROCESSES + ? processIds + : [ + ...new Set( + [busiestCpuId, busiestMemoryId].flatMap((id) => + id === undefined ? [] : [id], + ), + ), + ]; + return new Map(coloredIds.map((id, index) => [id, palette[index]])); + }, [processIds, busiestCpuId, busiestMemoryId, colors.lineGraph]); + + // Every process starts selected, and a new set of processes resets that. + const processKey = view.processIds.join(","); + const [selection, setSelection] = React.useState({ + key: processKey, + ids: new Set(view.processIds), + }); + const selectedIds = + selection.key === processKey ? selection.ids : new Set(view.processIds); + const setSelected = (ids: Set) => + setSelection({ key: processKey, ids }); + const [hoveredId, setHoveredId] = React.useState(); + + if (state.error) { + return ( + + ); + } + if (!state.snapshotComplete) { + return ( + + + + ); + } + if (view.buckets.length === 0) { + return ( + + No process readings in this time period. + + ); + } + const memoryLimit = view.hasHeapLimit ? "heap limit" : "memory allocation"; + return ( + + + + + Compare processes + + + {statistic !== "current" && ( + + over the last {windowLabel(windowMinutes)} + + )} + + {[ + finding("CPU", "CPU", statistic, view.busiestCpu), + finding("Memory", "memory", statistic, view.busiestMemory), + ].map( + (text) => + text && ( + + {text} + + ), + )} + {statistic === "current" && view.unreadNowIds.length > 0 && ( + + No reading right now from{" "} + {view.unreadNowIds.map((id) => `process ${id}`).join(", ")}. + + )} + + {replica.size} runs each replica as {replica.allocation.processes}{" "} + processes. CPU reads as a share of one process's CPU allocation + and memory as a share of its {memoryLimit}, from the last{" "} + {windowLabel(windowMinutes)} + {isWindowCapped ? ", the most this drawer reads" : ""}. + + + + {width > 0 && ( + selectedIds.has(id))} + buckets={view.buckets} + colorById={colorById} + highlightedId={hoveredId} + memoryLimit={memoryLimit} + /> + )} + + + + ); +}; + +const ProcessLineCharts = ({ + replicaName, + width, + startMs, + endMs, + processIds, + buckets, + colorById, + highlightedId, + memoryLimit, +}: { + replicaName: string; + width: number; + startMs: number; + endMs: number; + /** The selected processes, in process order. */ + processIds: string[]; + buckets: ProcessBucket[]; + colorById: Map; + highlightedId: string | undefined; + memoryLimit: string; +}) => { + const { colors } = useTheme(); + const { tooltipData, tooltipLeft, tooltipTop, showTooltip, hideTooltip } = + useTooltip<{ timeMs: number; metric: ProcessMetric }>(); + const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal( + { detectBounds: true, scroll: true }, + ); + const xScale = React.useMemo( + () => + scaleTime({ + domain: [new Date(startMs), new Date(endMs)], + range: buildXYGraphLayoutProps({ + width, + height: 0, + margin: CHART_MARGIN, + }).xScaleRange, + }), + [startMs, endMs, width], + ); + const hoveredBucket = + tooltipData === undefined + ? undefined + : buckets.find( + (bucket) => + bucket.bucketStart <= tooltipData.timeMs && + tooltipData.timeMs < bucket.bucketEnd, + ); + const chartProps = { + width, + xScale, + processIds, + buckets, + colorById, + highlightedId, + hoverTimeMs: tooltipData?.timeMs, + onHover: ( + hover: { timeMs: number; metric: ProcessMetric }, + event: React.PointerEvent, + ) => + showTooltip({ + tooltipData: hover, + tooltipLeft: event.clientX - containerBounds.left, + tooltipTop: event.clientY - containerBounds.top, + }), + onLeave: hideTooltip, + }; + + // Both charts draw the same processes in the same colors, so the CPU chart + // carries the one legend. + const legend: ChartHeaderProps["legend"] = [ + ...processIds.flatMap((id) => { + const color = colorById.get(id); + return color + ? [ + { + label: `${replicaName} · process ${id}`, + color, + variant: "line" as const, + }, + ] + : []; + }), + ...(processIds.some((id) => !colorById.has(id)) + ? [ + { + label: "Other processes", + color: colors.foreground.tertiary, + variant: "line" as const, + }, + ] + : []), + ]; + + return ( + + + + + + + + + {hoveredBucket && + tooltipData && + tooltipLeft !== undefined && + tooltipTop !== undefined && ( + + + + )} + + ); +}; + +interface ProcessLine { + processId: string; + points: Array<{ atMs: number; value: number | null }>; +} + +const hasReading = (point: ProcessLine["points"][number] | undefined) => + point !== undefined && point.value !== null; + +// Memoized so pointer moves, which only move the cursor, skip redrawing the +// lines. +const ProcessLines = React.memo( + ({ + layout, + xScale, + yScale, + ticks, + lines, + colorById, + highlightedId, + }: { + layout: Layout; + xScale: TimeScale; + yScale: PercentScale; + ticks: number[]; + lines: ProcessLine[]; + colorById: Map; + highlightedId: string | undefined; + }) => { + const { colors, fonts } = useTheme(); + // Outside the palette, so a highlighted grey process can't be read as + // a colored one. + const highlightColor = colors.foreground.primary; + const coloredOrder = [...colorById.keys()]; + // Grey lines first, then colored ones widest first: where processes read + // the same, the narrower line on top leaves the wider one's edges showing, + // so every color stays visible. + const order = [ + ...lines.filter(({ processId }) => !colorById.has(processId)), + ...coloredOrder.flatMap((id) => + lines.filter(({ processId }) => processId === id), + ), + ]; + const strokeWidth = (processId: string) => { + const index = coloredOrder.indexOf(processId); + return index < 0 ? 1 : Math.max(1.5, 4.5 - index * 0.75); + }; + const highlighted = lines.find( + ({ processId }) => processId === highlightedId, + ); + const highlightedLast = highlighted?.points.findLast( + ({ value }) => value !== null, + ); + const [, rightEdge] = layout.xScaleRange; + const draw = ( + { processId, points }: ProcessLine, + style: { color: string; opacity?: number; strokeWidth: number }, + ) => ( + + value !== null} + x={({ atMs }) => xScale(atMs)} + y={({ value }) => yScale(value ?? 0)} + stroke={style.color} + strokeWidth={style.strokeWidth} + /> + {/* A reading with no defined neighbor draws no line segment, so it + gets a dot. */} + {points.flatMap(({ atMs, value }, index) => + value === null || + hasReading(points[index - 1]) || + hasReading(points[index + 1]) + ? [] + : [ + , + ], + )} + + ); + + return ( + <> + + {order.map((processLine) => + draw(processLine, { + color: + colorById.get(processLine.processId) ?? + colors.foreground.tertiary, + opacity: + highlighted && processLine !== highlighted + ? 0.2 + : colorById.has(processLine.processId) + ? 1 + : 0.5, + strokeWidth: strokeWidth(processLine.processId), + }), + )} + {highlighted && + draw(highlighted, { + color: colorById.get(highlighted.processId) ?? highlightColor, + strokeWidth: 2.5, + })} + {highlighted && highlightedLast && highlightedLast.value !== null && ( + + p{highlighted.processId} + + )} + + ); + }, +); + +const ProcessLineChart = ({ + width, + xScale, + processIds, + buckets, + colorById, + highlightedId, + metric, + title, + description, + legend, + hoverTimeMs, + onHover, + onLeave, +}: { + width: number; + xScale: TimeScale; + processIds: string[]; + buckets: ProcessBucket[]; + colorById: Map; + highlightedId: string | undefined; + metric: ProcessMetric; + title: string; + description: string; + legend?: ChartHeaderProps["legend"]; + hoverTimeMs: number | undefined; + onHover: ( + hover: { timeMs: number; metric: ProcessMetric }, + event: React.PointerEvent, + ) => void; + onLeave: () => void; +}) => { + const layout = React.useMemo( + () => + buildXYGraphLayoutProps({ + width, + height: CHART_MARGIN.top + PLOT_HEIGHT_PX + CHART_MARGIN.bottom, + margin: CHART_MARGIN, + }), + [width], + ); + const lines = React.useMemo( + () => + processIds.map((processId) => ({ + processId, + points: buckets.map((bucket) => ({ + atMs: bucket.bucketStart, + value: bucket.readings.get(processId)?.[metric] ?? null, + })), + })), + [processIds, buckets, metric], + ); + const peak = React.useMemo( + () => max(lines, ({ points }) => max(points, ({ value }) => value)) ?? 0, + [lines], + ); + const { yScale, ticks } = React.useMemo( + () => percentScale(peak, layout), + [peak, layout], + ); + + return ( + + + + + {hoverTimeMs !== undefined && ( + + )} + onHover({ timeMs, metric }, event)} + onPointerLeave={onLeave} + /> + + + ); +}; + +const LinesTooltipContent = ({ + bucket, + metric, + processIds, + colorById, +}: { + bucket: ProcessBucket; + metric: ProcessMetric; + processIds: string[]; + colorById: Map; +}) => { + const { colors } = useTheme(); + const ranked = processIds + .flatMap((processId) => { + const value = bucket.readings.get(processId)?.[metric] ?? null; + return value === null ? [] : [{ processId, value }]; + }) + .sort((a, b) => b.value - a.value); + const missing = processIds.filter( + (processId) => (bucket.readings.get(processId)?.[metric] ?? null) === null, + ); + const rest = ranked.length - TOOLTIP_PROCESS_LIMIT; + const missingRest = missing.length - TOOLTIP_PROCESS_LIMIT; + return ( + <> + + + {metric === "cpu" ? "CPU" : "Memory"} + + {ranked.slice(0, TOOLTIP_PROCESS_LIMIT).map(({ processId, value }) => ( + + + + + Process {processId} + + + {formatReading(value)} + + ))} + {rest > 0 && ( + + {rest} more {rest === 1 ? "process" : "processes"} + + )} + {missing.length > 0 && ( + + No reading from{" "} + {missing + .slice(0, TOOLTIP_PROCESS_LIMIT) + .map((processId) => `process ${processId}`) + .join(", ")} + {missingRest > 0 && ` and ${missingRest} more`} + + )} + + + + ); +}; + +interface ProcessRow extends ProcessSummary { + color: string | undefined; + isBusiestCpu: boolean; + isBusiestMemory: boolean; +} + +const columnHelper = createColumnHelper(); + +interface ProcessTableMeta { + /** The statistic the findings compare by, whose column reads bold. */ + statistic: ProcessStatistic; +} + +const STATISTIC_HEADERS: Record = { + current: "now", + peak: "peak", + average: "average", +}; + +const readingColumns = (metric: ProcessMetric, label: string) => + (Object.keys(STATISTIC_HEADERS) as ProcessStatistic[]).map((key) => + columnHelper.accessor((row) => row[metric][key], { + id: `${metric}-${key}`, + header: `${label} ${STATISTIC_HEADERS[key]}`, + sortingFn: sortingFunctions.numericNullsLast, + meta: { isNumeric: true }, + cell: (info) => { + const { statistic } = info.table.options.meta as ProcessTableMeta; + const row = info.row.original; + const isBusiest = + key === statistic && + (metric === "cpu" ? row.isBusiestCpu : row.isBusiestMemory); + return ( + + {formatReading(info.getValue())} + + ); + }, + }), + ); + +const ProcessName = ({ row }: { row: ProcessRow }) => { + const { colors } = useTheme(); + return ( + + + Process {row.processId} + {(row.isBusiestCpu || row.isBusiestMemory) && ( + + {row.isBusiestCpu && row.isBusiestMemory + ? "busiest" + : row.isBusiestCpu + ? "busiest CPU" + : "busiest memory"} + + )} + + ); +}; + +const processColumns = [ + columnHelper.display({ + id: "select", + size: 40, + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ), + }), + columnHelper.accessor((row) => Number(row.processId), { + id: "process", + header: "Process", + cell: (info) => , + }), + ...readingColumns("cpu", "CPU"), + ...readingColumns("memory", "Memory"), +]; + +// The process whose row an event happened in. +const processIdAt = (target: EventTarget) => + target instanceof Element + ? (target + .closest("tr") + ?.querySelector("[data-process-id]") + ?.getAttribute("data-process-id") ?? undefined) + : undefined; + +const ProcessTable = ({ + summaries, + colorById, + busiestCpuId, + busiestMemoryId, + statistic, + memoryLimit, + selectedIds, + onSelect, + onHover, +}: { + summaries: ProcessSummary[]; + colorById: Map; + busiestCpuId: string | undefined; + busiestMemoryId: string | undefined; + statistic: ProcessStatistic; + memoryLimit: string; + /** The processes whose lines are drawn. */ + selectedIds: Set; + onSelect: (ids: Set) => void; + onHover: (id: string | undefined) => void; +}) => { + const { colors } = useTheme(); + const rows = React.useMemo( + () => + summaries.map((summary): ProcessRow => ({ + ...summary, + color: colorById.get(summary.processId), + isBusiestCpu: summary.processId === busiestCpuId, + isBusiestMemory: summary.processId === busiestMemoryId, + })), + [summaries, colorById, busiestCpuId, busiestMemoryId], + ); + const rowSelection: RowSelectionState = Object.fromEntries( + [...selectedIds].map((id) => [id, true]), + ); + const table = useUniversalTable({ + data: rows, + columns: processColumns, + getRowId: ({ processId }) => processId, + state: { rowSelection }, + onRowSelectionChange: (updater) => { + const next = + typeof updater === "function" ? updater(rowSelection) : updater; + onSelect(new Set(Object.keys(next).filter((id) => next[id]))); + }, + meta: { statistic } satisfies ProcessTableMeta, + // A replica runs at most a few dozen processes, so one page holds them. + pageSize: 1000, + }); + return ( + + + Processes + + Check a process to draw its line. Memory is a share of each + process's {memoryLimit}. + + + { + const id = processIdAt(event.target); + onHover(id !== undefined && selectedIds.has(id) ? id : undefined); + }} + onMouseLeave={() => onHover(undefined)} + > + + + + ); +}; diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsage.test.tsx b/console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsage.test.tsx new file mode 100644 index 0000000000000..f9c75b3a4ce70 --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsage.test.tsx @@ -0,0 +1,1588 @@ +// 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 { + act, + fireEvent, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { sort } from "d3"; +import { http, HttpResponse } from "msw"; +import React from "react"; +import { Route, Routes, useLocation } from "react-router-dom"; + +import { Cluster } from "~/api/materialize/cluster/clusterList"; +import { ErrorCode, MzDataType } from "~/api/materialize/types"; +import { + buildColumn, + buildColumns, + buildSqlQueryHandlerV2, + mapKyselyToTabular, +} from "~/api/mocks/buildSqlQueryHandler"; +import server from "~/api/mocks/server"; +import { getStore } from "~/jotai"; +import { buildClusterServerResponse } from "~/platform/clusters/clustersTestUtils"; +import { CLUSTERS_FETCH_ERROR_MESSAGE } from "~/platform/clusters/constants"; +import { + clusterQueryKeys, + replicaStatusesUpdatedAt, +} from "~/platform/clusters/queries"; +import { allClusters } from "~/store/allClusters"; +import { allObjectsCollection } from "~/store/allObjectsCollection"; +import { mockSubscribeState } from "~/test/mockSubscribe"; +import { mockSubscribeSocket } from "~/test/mockSubscribeSocket"; +import { + healthyEnvironment, + renderComponent, + setFakeEnvironment, +} from "~/test/utils"; +import { lightColors } from "~/theme/light"; +import { formatDate, FRIENDLY_DATETIME_FORMAT } from "~/utils/dateFormat"; +import { parseDbVersion } from "~/version/api"; + +import { ClusterMetrics } from "../ClusterMetrics"; +import { ResourceUsage } from "./ResourceUsage"; +import { CHART_MARGIN } from "./resourceUsageStyles"; + +// The Metrics page's other card has its own tests and queries. +vi.mock("~/platform/clusters/LargestMaintainedQueries", () => ({ + default: () => null, +})); + +// jsdom lays nothing out, so give the charts a real width. +const WIDTH_PX = 900; +vi.mock("@visx/responsive", async (importOriginal) => ({ + ...(await importOriginal()), + useParentSize: () => ({ + parentRef: () => {}, + width: WIDTH_PX, + height: 0, + resize: () => {}, + }), +})); +// jsdom has no SVG geometry, so read pointer positions straight off the event. +vi.mock("@visx/event", async (importOriginal) => ({ + ...(await importOriginal()), + localPoint: (event: { clientX: number; clientY: number }) => ({ + x: event.clientX, + y: event.clientY, + }), +})); + +const HOUR_MS = 60 * 60_000; +const DAY_MS = 24 * HOUR_MS; +// The 7-day window reads the polled hourly overview view, not a subscribe. +const TIME_PERIOD_MINUTES = 7 * 24 * 60; +const NOW_MS = Date.now(); +const WINDOW_START_MS = NOW_MS - TIME_PERIOD_MINUTES * 60_000; +const FIRST_BUCKET_MS = Math.ceil(WINDOW_START_MS / HOUR_MS) * HOUR_MS; +const BUCKET_COUNT = 160; +const OOM_AT_MS = FIRST_BUCKET_MS + 100 * HOUR_MS + 10 * 60_000; +const DDL_AT_MS = FIRST_BUCKET_MS + 20 * HOUR_MS; +const HEAP_LIMIT_BYTES = 25 * 2 ** 30; + +interface CreatedObject { + id: string; + name: string; + objectType: string; + occurredAtMs: number; +} + +const CREATED_INDEX: CreatedObject = { + id: "u100", + name: "orders_by_day_idx", + objectType: "index", + occurredAtMs: DDL_AT_MS, +}; + +const setClusterObjects = (objects: CreatedObject[]) => + allObjectsCollection.applySnapshot({ + data: objects.map(({ id, name, objectType }) => ({ + databaseName: "materialize", + databaseId: "u1", + name, + schemaName: "public", + schemaId: "u2", + id, + objectType, + sourceType: null, + isWebhookTable: null, + clusterId: "u9", + clusterName: "prod", + })), + snapshotComplete: true, + error: undefined, + }); + +const NEW_ENVIRONMENT = { + ...healthyEnvironment, + status: { + ...healthyEnvironment.status, + version: parseDbVersion("v26.45.0 (0000000000)"), + }, +}; + +const REPLICA_NAMES: Record = { u1: "r1", u2: "r2" }; + +const buildCluster = (replicaNames: Record) => + buildClusterServerResponse( + { id: "u9", name: "prod" }, + Object.entries(replicaNames).map(([id, name]) => ({ + id, + name, + size: "100cc", + disk: false, + statuses: [], + })), + ); +// The status history query is keyed on it. +const STATUSES_UPDATED_AT = replicaStatusesUpdatedAt(buildCluster({}).replicas); +// A replica's status transitions are its process 0's. +const onProcessZero = (rows: T[]) => + rows.map((row) => ({ processId: "0", ...row })); + +const timestamptz = (name: string) => + buildColumn({ name, type_oid: MzDataType.timestamptz }); +const float8 = (name: string) => + buildColumn({ name, type_oid: MzDataType.float8 }); + +const utilizationRows = ({ + withSwap, + withoutHeapLimit, + emptyReadings, + r2CpuPercent, + replicaIds, + replicaNames, +}: { + withSwap: boolean; + withoutHeapLimit: boolean; + emptyReadings: boolean; + r2CpuPercent: number; + replicaIds: string[]; + replicaNames: Record; +}) => + replicaIds.flatMap((replicaId) => + Array.from({ length: BUCKET_COUNT }, (_, bucketIndex) => { + const bucketStartMs = FIRST_BUCKET_MS + bucketIndex * HOUR_MS; + const at = String(bucketStartMs); + return { + bucketStart: at, + replicaId, + maxMemoryPercent: emptyReadings ? null : 0.5, + maxMemoryAt: at, + maxDiskPercent: null, + maxDiskAt: at, + maxCpuPercent: emptyReadings + ? null + : replicaId === "u1" + ? 0.45 + : r2CpuPercent, + maxCpuAt: at, + maxHeapPercent: emptyReadings ? null : 0.6, + maxHeapAt: at, + maxMemoryAndDiskPercent: null, + maxMemoryAndDiskMemoryPercent: null, + maxMemoryAndDiskDiskPercent: null, + maxMemoryAndDiskAt: at, + offlineEvents: null, + bucketEnd: String(bucketStartMs + HOUR_MS), + name: replicaNames[replicaId], + clusterId: "u9", + size: "100cc", + ...(withSwap ? { swapOfRamPercent: 0.25, heapLimitPercent: 1.25 } : {}), + ...(withoutHeapLimit + ? { swapOfRamPercent: null, heapLimitPercent: null } + : {}), + }; + }), + ); + +const UTILIZATION_COLUMNS = [ + ...[ + "bucketStart", + "bucketEnd", + "maxMemoryAt", + "maxDiskAt", + "maxCpuAt", + "maxHeapAt", + "maxMemoryAndDiskAt", + ].map(timestamptz), + ...[ + "maxMemoryPercent", + "maxDiskPercent", + "maxCpuPercent", + "maxHeapPercent", + "swapOfRamPercent", + "heapLimitPercent", + ].map(float8), +]; + +const registerHandlers = ({ + withSwap, + withoutHeapLimit = false, + r2CpuPercent = 0.9, + r2OfflineAllWindow = false, + emptyReadings = false, + statusRows, + statusDelayMs, + r2UnhydratedObjectIds = [], + utilizationError = false, + utilizationRowCount, + createdObjects = [CREATED_INDEX], + objectHydrations = [], + replicaNames, +}: { + withSwap: boolean; + /** Rows carry the memory breakdown, but no sample has a heap limit. */ + withoutHeapLimit?: boolean; + r2CpuPercent?: number; + /** r2 went offline before the window and reports no samples in it. */ + r2OfflineAllWindow?: boolean; + /** Every sample has empty readings, as a down replica reports in cloud. */ + emptyReadings?: boolean; + /** Replaces the replica status history. */ + statusRows?: Array<{ + replicaId: string; + occurredAt: string; + status: string; + reason: string | null; + }>; + statusDelayMs?: number; + r2UnhydratedObjectIds?: string[]; + utilizationError?: boolean; + utilizationRowCount?: number; + /** Indexes and materialized views on the cluster, with creation times. */ + createdObjects?: CreatedObject[]; + /** Each replica's hydration time for the created objects. */ + objectHydrations?: Array<{ + replicaId: string; + objectId: string; + hydrationMs: number | null; + }>; + replicaNames: Record; +}) => { + setClusterObjects(createdObjects); + const utilizationKey = clusterQueryKeys.replicaUtilizationHistory({ + bucketSizeMs: TIME_PERIOD_MINUTES * 1000, + timePeriodMinutes: TIME_PERIOD_MINUTES, + clusterIds: ["u9"], + replicaId: undefined, + includeMemoryBreakdown: withSwap || withoutHeapLimit || undefined, + }); + const rows = utilizationRows({ + withSwap, + withoutHeapLimit, + emptyReadings, + r2CpuPercent, + replicaIds: r2OfflineAllWindow ? ["u1"] : ["u1", "u2"], + replicaNames, + }).slice(0, utilizationRowCount); + server.use( + buildSqlQueryHandlerV2({ + queryKey: [...utilizationKey, "deploymentLineage"], + results: mapKyselyToTabular({ + rows: [], + columns: buildColumns([ + "clusterId", + "currentDeploymentClusterId", + "clusterName", + ]), + }), + }), + buildSqlQueryHandlerV2({ + queryKey: utilizationKey, + results: utilizationError + ? { + notices: [], + error: { + message: "Something went wrong", + code: ErrorCode.INTERNAL_ERROR, + }, + } + : mapKyselyToTabular({ + rows, + columns: + rows.length > 0 + ? UTILIZATION_COLUMNS + : buildColumns(["bucketStart"]), + }), + }), + buildSqlQueryHandlerV2( + { + queryKey: clusterQueryKeys.replicaStatusHistory({ + replicaIds: ["u1", "u2"], + statusesUpdatedAt: STATUSES_UPDATED_AT, + }), + results: mapKyselyToTabular({ + rows: onProcessZero( + statusRows ?? + (r2OfflineAllWindow + ? [ + { + replicaId: "u1", + occurredAt: String(WINDOW_START_MS - DAY_MS), + status: "online", + reason: null, + }, + { + replicaId: "u2", + occurredAt: String(WINDOW_START_MS - DAY_MS), + status: "offline", + reason: null, + }, + ] + : [ + { + replicaId: "u1", + occurredAt: String(WINDOW_START_MS - DAY_MS), + status: "online", + reason: null, + }, + { + replicaId: "u2", + occurredAt: String(WINDOW_START_MS - DAY_MS), + status: "online", + reason: null, + }, + { + replicaId: "u2", + occurredAt: String(OOM_AT_MS), + status: "offline", + reason: "oom-killed", + }, + { + replicaId: "u2", + occurredAt: String(OOM_AT_MS + 5 * 60_000), + status: "online", + reason: null, + }, + ]), + ), + columns: [timestamptz("occurredAt")], + }), + }, + { waitTimeMs: statusDelayMs }, + ), + // r2 came back online inside the window, so its hydration is looked up. + buildSqlQueryHandlerV2({ + queryKey: clusterQueryKeys.unhydratedComputeObjects({ + replicaIds: ["u1", "u2"], + }), + results: mapKyselyToTabular({ + rows: r2UnhydratedObjectIds.map((objectId) => ({ + replicaId: "u2", + objectId, + })), + columns: buildColumns(["replicaId", "objectId"]), + }), + }), + buildSqlQueryHandlerV2({ + queryKey: clusterQueryKeys.replicaHydrationEpisodes({ + replicaIds: ["u2"], + timePeriodMinutes: TIME_PERIOD_MINUTES, + }), + results: mapKyselyToTabular({ + rows: [ + { + replicaId: "u2", + startedAt: String(OOM_AT_MS + 5 * 60_000), + finishedAt: String(OOM_AT_MS + 30 * 60_000), + }, + ], + columns: [timestamptz("startedAt"), timestamptz("finishedAt")], + }), + }), + buildSqlQueryHandlerV2({ + queryKey: clusterQueryKeys.replicaHeapLimits({ + replicaIds: ["u1", "u2"], + }), + results: mapKyselyToTabular({ + rows: ["u1", "u2"].map((replicaId) => ({ + replicaId, + processId: "0", + heapLimit: withoutHeapLimit ? null : String(HEAP_LIMIT_BYTES), + })), + columns: buildColumns(["replicaId", "processId", "heapLimit"]), + }), + }), + buildSqlQueryHandlerV2({ + queryKey: clusterQueryKeys.objectCreationTimes({ + objectIds: createdObjects.map(({ id }) => id).sort(), + timePeriodMinutes: TIME_PERIOD_MINUTES, + }), + results: mapKyselyToTabular({ + rows: createdObjects.map(({ id, occurredAtMs }) => ({ + id, + occurredAt: String(occurredAtMs), + })), + columns: [timestamptz("occurredAt")], + }), + }), + buildSqlQueryHandlerV2({ + queryKey: clusterQueryKeys.objectHydrationTimes({ + replicaIds: ["u1", "u2"], + objects: sort(createdObjects, ({ id }) => id).map( + ({ id, occurredAtMs }) => ({ id, createdAtMs: occurredAtMs }), + ), + }), + results: [ + mapKyselyToTabular({ + rows: objectHydrations, + columns: [float8("hydrationMs")], + }), + mapKyselyToTabular({ + rows: [{ startedAt: String(WINDOW_START_MS - DAY_MS) }], + columns: [timestamptz("startedAt")], + }), + ], + }), + ); +}; + +// Query keys embed the environment version, so set the environment before +// building the handlers that match on them. +const renderResourceUsage = async ({ + environment = NEW_ENVIRONMENT, + replicaNames = REPLICA_NAMES, + selectedReplicaId, + ...handlerOptions +}: Omit[0], "replicaNames"> & { + environment?: typeof healthyEnvironment; + replicaNames?: Record; + selectedReplicaId?: string; +}) => { + await setFakeEnvironment(getStore().set, "aws/us-east-1", environment); + registerHandlers({ ...handlerOptions, replicaNames }); + return renderComponent( + , + { + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", environment), + }, + ); +}; + +const LocationSearch = () => ( +
{useLocation().search}
+); + +// The whole Metrics page, whose toolbar picks the replica and time period. +const renderClusterMetrics = async (search = "") => { + await setFakeEnvironment(getStore().set, "aws/us-east-1", NEW_ENVIRONMENT); + registerHandlers({ withSwap: true, replicaNames: REPLICA_NAMES }); + getStore().set( + allClusters, + mockSubscribeState({ data: [buildCluster(REPLICA_NAMES)] }), + ); + return renderComponent( + + + + + + } + /> + , + { + initialRouterEntries: [`/u9/prod${search}`], + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", NEW_ENVIRONMENT), + }, + ); +}; + +// Hovers the memory chart at a time inside the selected window. +const hoverAt = async (timeMs: number) => { + const svg = await screen.findByRole("img", { name: "Memory usage" }); + const overlay = svg.querySelector('rect[fill="transparent"]'); + if (!overlay) throw new Error("hover overlay not rendered"); + const plotWidthPx = WIDTH_PX - CHART_MARGIN.left - CHART_MARGIN.right; + const clientX = + CHART_MARGIN.left + + ((timeMs - WINDOW_START_MS) / (NOW_MS - WINDOW_START_MS)) * plotWidthPx; + fireEvent.pointerMove(overlay, { clientX, clientY: 80 }); + return screen.findByTestId("chart-tooltip"); +}; + +describe("ResourceUsage", () => { + beforeAll(() => { + // jsdom has no PointerEvent, and the fallback Event drops clientX. + if (!window.PointerEvent) { + window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent; + } + }); + + beforeEach(() => { + window.localStorage.setItem( + "mz-cluster-graph-time-period", + String(TIME_PERIOD_MINUTES), + ); + setClusterObjects([CREATED_INDEX]); + }); + + it("shows each replica's CPU, memory and disk for the hovered time, in bytes against the heap limit", async () => { + await renderResourceUsage({ withSwap: true }); + + const tooltip = await hoverAt(FIRST_BUCKET_MS + 50 * HOUR_MS); + // A 25 GB heap limit, 60% full: 40% memory and 20% disk. + expect(tooltip).toHaveTextContent( + /r1.*Running.*CPU45\.0%.*Memory40\.0% · 10 GB.*Disk20\.0% · 5 GB.*Total60\.0% of heap limit · 15 GB/, + ); + expect(tooltip).toHaveTextContent(/r2.*Running.*CPU90\.0%/); + }); + + it("marks an out-of-memory kill in the tooltip", async () => { + await renderResourceUsage({ withSwap: true }); + + const tooltip = await hoverAt(OOM_AT_MS); + expect(tooltip).toHaveTextContent(/r2.*Out of Memory/); + }); + + it("labels the memory limits with their sizes", async () => { + await renderResourceUsage({ withSwap: true }); + + expect(await screen.findByText("heap limit · 25 GB")).toBeVisible(); + // The heap limit is 125% of memory, so memory ends at 80% of it. + expect(screen.getByText("memory limit · 20 GB")).toBeVisible(); + expect(screen.getByText("disk")).toBeVisible(); + }); + + it("shows heap alone on environments without the swap columns", async () => { + await renderResourceUsage({ + withSwap: false, + environment: healthyEnvironment, + }); + + expect(await screen.findByText("heap")).toBeVisible(); + expect(screen.queryByText("disk")).not.toBeInTheDocument(); + const tooltip = await hoverAt(FIRST_BUCKET_MS + 50 * HOUR_MS); + expect(tooltip).toHaveTextContent(/r1.*CPU45\.0%.*Heap60\.0% · 15 GB/); + expect(tooltip).not.toHaveTextContent("Disk"); + expect(tooltip).not.toHaveTextContent("Total"); + }); + + it("labels memory as a share of the allocation when no sample has a heap limit", async () => { + await renderResourceUsage({ withSwap: false, withoutHeapLimit: true }); + + expect(await screen.findByText("memory limit")).toBeVisible(); + expect(screen.queryByText(/heap/i)).not.toBeInTheDocument(); + const tooltip = await hoverAt(FIRST_BUCKET_MS + 50 * HOUR_MS); + expect(tooltip).toHaveTextContent(/r1.*CPU45\.0%.*Memory60\.0%/); + expect(tooltip).not.toHaveTextContent("GB"); + }); + + it("extends the CPU axis past 100% when a replica exceeds its allocation", async () => { + await renderResourceUsage({ withSwap: true, r2CpuPercent: 2.5 }); + + const cpu = await screen.findByRole("img", { name: "CPU usage" }); + expect(within(cpu).getByText("250%")).toBeInTheDocument(); + expect(screen.getByText("CPU allocation")).toBeVisible(); + const tooltip = await hoverAt(FIRST_BUCKET_MS + 50 * HOUR_MS); + expect(tooltip).toHaveTextContent(/r2.*CPU250\.0%/); + }); + + it("keeps the CPU axis at 100% while replicas stay within their allocation", async () => { + await renderResourceUsage({ withSwap: true }); + + const cpu = await screen.findByRole("img", { name: "CPU usage" }); + expect(within(cpu).getByText("100%")).toBeInTheDocument(); + expect(screen.queryByText("CPU allocation")).not.toBeInTheDocument(); + }); + + it("shows a status row for a replica offline the whole window", async () => { + await renderResourceUsage({ withSwap: true, r2OfflineAllWindow: true }); + + const status = await screen.findByRole("img", { + name: "Replica status over time", + }); + expect(within(status).getByText("r1")).toBeInTheDocument(); + expect(within(status).getByText("r2")).toBeInTheDocument(); + }); + + it("orders the status rows like the replica pills", async () => { + // Display order is by name, the reverse of id order here. + await renderResourceUsage({ + withSwap: true, + replicaNames: { u1: "zeta", u2: "alpha" }, + }); + + const status = await screen.findByRole("img", { + name: "Replica status over time", + }); + const pills = screen.getByRole("list", { name: "Replicas" }); + const pillNames = within(pills) + .getAllByText(/^(alpha|zeta)$/) + .map((label) => label.textContent); + const rowNames = within(status) + .getAllByText(/^(alpha|zeta)$/) + .map((label) => label.textContent); + expect(pillNames).toEqual(["alpha", "zeta"]); + expect(rowNames).toEqual(pillNames); + }); + + it("shows how many objects a replica has left to hydrate", async () => { + await renderResourceUsage({ + withSwap: true, + r2UnhydratedObjectIds: ["u100", "u101"], + }); + + const status = await screen.findByRole("img", { + name: "Replica status over time", + }); + expect( + await within(status).findByText("Hydrating · 2 objects"), + ).toBeInTheDocument(); + }); + + it("hides the other replicas when one replica's toggle is clicked", async () => { + const user = userEvent.setup(); + await renderResourceUsage({ withSwap: true }); + + const pills = await screen.findByRole("list", { name: "Replicas" }); + await user.click(within(pills).getByRole("button", { name: /r1/ })); + + expect(within(pills).getByRole("button", { name: /r1/ })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(within(pills).getByRole("button", { name: /r2/ })).toHaveAttribute( + "aria-pressed", + "false", + ); + const tooltip = await hoverAt(FIRST_BUCKET_MS + 50 * HOUR_MS); + expect(tooltip).toHaveTextContent("r1"); + expect(tooltip).not.toHaveTextContent("r2"); + }); + + const findChangesLane = () => screen.findByRole("group", { name: "Changes" }); + + it("marks an index created on the cluster in the Changes row, and guides the charts to it only on hover", async () => { + await renderResourceUsage({ withSwap: true }); + + const tick = await within(await findChangesLane()).findByRole("img", { + name: /^1 object created, /, + }); + const cpu = screen.getByRole("img", { name: "CPU usage" }); + const memory = screen.getByRole("img", { name: "Memory usage" }); + expect( + within(cpu).queryByText(/orders_by_day_idx/), + ).not.toBeInTheDocument(); + expect(within(cpu).queryByTestId("change-guide")).not.toBeInTheDocument(); + + await userEvent.hover(tick); + const tooltip = await screen.findByTestId("chart-tooltip"); + expect(tooltip).toHaveTextContent( + `${formatDate(DDL_AT_MS, FRIENDLY_DATETIME_FORMAT)}1 object createdIndexorders_by_day_idx`, + ); + expect(within(cpu).getByTestId("change-guide")).toBeInTheDocument(); + expect(within(memory).getByTestId("change-guide")).toBeInTheDocument(); + + await userEvent.unhover(tick); + expect(screen.queryByTestId("chart-tooltip")).not.toBeInTheDocument(); + expect(within(cpu).queryByTestId("change-guide")).not.toBeInTheDocument(); + }); + + it("merges objects created close together into one tick that names the first few", async () => { + // Seven views a minute apart share a pixel of the week; the index is days + // later. + const views = Array.from({ length: 7 }, (_, index) => ({ + id: `u${200 + index}`, + name: `view_${index}`, + objectType: "materialized-view", + occurredAtMs: DDL_AT_MS + index * 60_000, + })); + await renderResourceUsage({ + withSwap: true, + createdObjects: [ + ...views, + { ...CREATED_INDEX, occurredAtMs: DDL_AT_MS + 3 * DAY_MS }, + ], + }); + + const lane = await findChangesLane(); + await waitFor(() => + expect(within(lane).getAllByRole("img")).toHaveLength(2), + ); + const burst = within(lane).getByRole("img", { + name: /^7 objects created, /, + }); + act(() => burst.focus()); + const tooltip = await screen.findByTestId("chart-tooltip"); + expect(tooltip).toHaveTextContent( + /7 objects created(Materialized viewview_\d){5}and 2 more/, + ); + expect(tooltip).not.toHaveTextContent("view_5"); + expect( + within(screen.getByRole("img", { name: "CPU usage" })).getByTestId( + "change-guide", + ), + ).toBeInTheDocument(); + + act(() => burst.blur()); + expect(screen.queryByTestId("chart-tooltip")).not.toBeInTheDocument(); + }); + + it("keeps the Changes row when nothing was created in the window", async () => { + await renderResourceUsage({ withSwap: true, createdObjects: [] }); + + const lane = await findChangesLane(); + expect(within(lane).getByText("Changes")).toBeInTheDocument(); + expect(within(lane).queryAllByRole("img")).toHaveLength(0); + }); + + it("draws a brief hydration wide enough to see", async () => { + // r2's 25-minute hydration after its restart spans about 2px of the week. + await renderResourceUsage({ withSwap: true }); + + const status = await screen.findByRole("img", { + name: "Replica status over time", + }); + await waitFor(() => { + const hydrating = [...status.querySelectorAll("rect")].filter( + (rect) => rect.getAttribute("fill") === lightColors.accent.darkYellow, + ); + expect(hydrating).toHaveLength(1); + expect(Number(hydrating[0].getAttribute("width"))).toBe(4); + }); + }); + + it("draws an object's hydration on a replica that was already running", async () => { + await renderResourceUsage({ + withSwap: true, + objectHydrations: [ + { + replicaId: "u1", + objectId: CREATED_INDEX.id, + hydrationMs: 6 * HOUR_MS, + }, + ], + }); + + const status = await screen.findByRole("img", { + name: "Replica status over time", + }); + // r2's brief hydration after its restart, and r1's of the new index. + await waitFor(() => { + const widths = [...status.querySelectorAll("rect")] + .filter( + (rect) => rect.getAttribute("fill") === lightColors.accent.darkYellow, + ) + .map((rect) => Number(rect.getAttribute("width"))); + expect(widths).toHaveLength(2); + expect(Math.max(...widths)).toBeGreaterThan(4); + }); + expect( + screen.queryByText("Hydration history is unavailable."), + ).not.toBeInTheDocument(); + }); + + it("says replicas ran unmeasured when the window has no readings", async () => { + await renderResourceUsage({ withSwap: true, utilizationRowCount: 0 }); + + expect( + await screen.findByText( + /Your replicas were running, but no resource usage was recorded/, + ), + ).toBeVisible(); + expect(screen.getByRole("link", { name: "contact support" })).toBeVisible(); + expect( + await screen.findByRole("img", { name: "Replica status over time" }), + ).toBeInTheDocument(); + }); + + it("explains a window where every replica was down after running out of memory", async () => { + await renderResourceUsage({ + withSwap: true, + emptyReadings: true, + statusRows: [ + { + replicaId: "u1", + occurredAt: String(WINDOW_START_MS - DAY_MS), + status: "offline", + reason: null, + }, + { + replicaId: "u2", + occurredAt: String(WINDOW_START_MS - HOUR_MS), + status: "offline", + reason: "oom-killed", + }, + ], + }); + + expect( + await screen.findByText( + /All replicas were offline for this period. They last went offline after running out of memory/, + ), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: "Troubleshoot memory usage" }), + ).toBeVisible(); + const status = screen.getByRole("img", { + name: "Replica status over time", + }); + expect(within(status).getByText("r2")).toBeInTheDocument(); + expect(screen.queryByText(/Cluster metrics are unavailable/)).toBeNull(); + }); + + it("says when every replica was down without a recorded reason", async () => { + await renderResourceUsage({ + withSwap: true, + emptyReadings: true, + statusRows: ["u1", "u2"].map((replicaId) => ({ + replicaId, + occurredAt: String(WINDOW_START_MS - DAY_MS), + status: "offline", + reason: null, + })), + }); + + expect( + await screen.findByText(/Materialize didn.t record why/), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: "Troubleshoot hydration failures" }), + ).toBeVisible(); + }); + + it("waits for replica status before explaining a window without readings", async () => { + await renderResourceUsage({ + withSwap: true, + emptyReadings: true, + statusDelayMs: 300, + statusRows: ["u1", "u2"].map((replicaId) => ({ + replicaId, + occurredAt: String(WINDOW_START_MS - DAY_MS), + status: "offline", + reason: "oom-killed", + })), + }); + + // `waitFor` reruns on every DOM change, so it sees the fallback message + // even if it renders only until status arrives. + let sawFallback = false; + await waitFor(() => { + sawFallback ||= screen.queryByText(/metrics may be unavailable/) !== null; + expect( + screen.getByText(/They last went offline after running out of memory/), + ).toBeVisible(); + }); + expect(sawFallback).toBe(false); + }); + + it("says it can't tell why without replica status", async () => { + await renderResourceUsage({ + withSwap: true, + emptyReadings: true, + statusRows: [], + }); + + expect( + await screen.findByText( + /No resource usage was recorded for this period. The replicas may have been offline, or metrics may be unavailable/, + ), + ).toBeVisible(); + }); + + it("overlaps the replicas' memory bars at each time", async () => { + await renderResourceUsage({ withSwap: true }); + + const memory = await screen.findByRole("img", { name: "Memory usage" }); + const bars = [...memory.querySelectorAll("rect")] + .filter((rect) => rect.getAttribute("fill") !== "transparent") + .map((rect) => ({ + x: Number(rect.getAttribute("x")), + width: Number(rect.getAttribute("width")), + })) + .sort((a, b) => a.x - b.x); + const first = bars[0]; + const next = bars.find((bar) => bar.x > first.x); + expect(next?.x).toBeLessThan(first.x + first.width); + }); + + // Each replica's hourly rows from the week fixture, starting and ending + // where `keep` says, for the given cluster. + const replicaRows = ( + replicaId: string, + name: string, + keep: (bucketStartMs: number) => boolean, + clusterId = "u9", + ) => + utilizationRows({ + withSwap: true, + withoutHeapLimit: false, + emptyReadings: false, + r2CpuPercent: 0.9, + replicaIds: [replicaId], + replicaNames: { [replicaId]: name }, + }) + .filter(({ bucketStart }) => keep(Number(bucketStart))) + .map((row) => ({ ...row, clusterId })); + + const renderWeek = async ({ + rows, + replicaIds, + currentReplicas = {}, + }: { + rows: ReturnType; + replicaIds: string[]; + currentReplicas?: Record; + }) => { + await setFakeEnvironment(getStore().set, "aws/us-east-1", NEW_ENVIRONMENT); + const utilizationKey = clusterQueryKeys.replicaUtilizationHistory({ + bucketSizeMs: TIME_PERIOD_MINUTES * 1000, + timePeriodMinutes: TIME_PERIOD_MINUTES, + clusterIds: ["u9"], + replicaId: undefined, + includeMemoryBreakdown: true, + }); + server.use( + buildSqlQueryHandlerV2({ + queryKey: [...utilizationKey, "deploymentLineage"], + results: mapKyselyToTabular({ + rows: [], + columns: buildColumns([ + "clusterId", + "currentDeploymentClusterId", + "clusterName", + ]), + }), + }), + buildSqlQueryHandlerV2({ + queryKey: utilizationKey, + results: mapKyselyToTabular({ rows, columns: UTILIZATION_COLUMNS }), + }), + buildSqlQueryHandlerV2({ + queryKey: clusterQueryKeys.replicaStatusHistory({ + replicaIds: [...replicaIds].sort(), + statusesUpdatedAt: STATUSES_UPDATED_AT, + }), + results: mapKyselyToTabular({ + rows: [], + columns: [ + ...buildColumns(["replicaId", "status", "reason"]), + timestamptz("occurredAt"), + ], + }), + }), + ); + renderComponent( + , + { + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", NEW_ENVIRONMENT), + }, + ); + return screen.findByRole("img", { name: "CPU usage" }); + }; + + it("charts a scheduled cluster's refreshes as one replica", async () => { + // Replication factor 0 between refreshes, and a new replica id under the + // same name every 12h, up for 6 hourly buckets each time. Six hours span at + // least two of the 3h buckets the week merges into, however they align. + const refreshIds = Array.from( + { length: 13 }, + (_, index) => `u${index + 10}`, + ); + const refreshStartMs = (index: number) => + FIRST_BUCKET_MS + index * 12 * HOUR_MS; + const cpu = await renderWeek({ + rows: refreshIds.flatMap((replicaId, index) => + replicaRows( + replicaId, + "r1", + (startMs) => + startMs >= refreshStartMs(index) && + startMs < refreshStartMs(index) + 6 * HOUR_MS, + ), + ), + replicaIds: refreshIds, + }); + + expect( + within(screen.getByRole("list", { name: "Replicas" })).getAllByRole( + "button", + ), + ).toHaveLength(1); + expect( + screen.getByRole("button", { name: /r1 \(dropped\)/ }), + ).toBeVisible(); + const status = await screen.findByRole("img", { + name: "Replica status over time", + }); + expect(within(status).getAllByText("r1")).toHaveLength(1); + // Every refresh's line has more than one point. + const lines = [...cpu.querySelectorAll("path")].filter((path) => + path.getAttribute("d")?.includes("L"), + ); + expect(lines).toHaveLength(refreshIds.length); + const memory = screen.getByRole("img", { name: "Memory usage" }); + const barWidths = [...memory.querySelectorAll("rect")] + .filter((rect) => rect.getAttribute("fill") !== "transparent") + .map((rect) => Number(rect.getAttribute("width"))); + expect(barWidths.length).toBeGreaterThan(0); + // No bar is split with replicas that weren't running at its time: each + // spans at least one hourly bucket, less the 2px gap between times. + const plotWidthPx = WIDTH_PX - CHART_MARGIN.left - CHART_MARGIN.right; + const hourPx = (plotWidthPx * HOUR_MS) / (TIME_PERIOD_MINUTES * 60_000); + expect(Math.min(...barWidths)).toBeGreaterThan(hourPx - 2 - 0.01); + + // Inside one refresh, the tooltip has one section for the name. + const tooltip = await hoverAt(refreshStartMs(3) + 2 * HOUR_MS); + expect(within(tooltip).getAllByText("r1")).toHaveLength(1); + }); + + it("draws a refresh that fits in one merged bucket as a dot", async () => { + // Three hourly buckets starting on a 3h boundary merge into one of the + // week's 3h buckets, a single reading with no line to draw. + const alignedMs = Math.ceil(FIRST_BUCKET_MS / (3 * HOUR_MS)) * 3 * HOUR_MS; + const lastMs = FIRST_BUCKET_MS + BUCKET_COUNT * HOUR_MS; + const refreshIds = Array.from( + { + length: Math.floor((lastMs - alignedMs - 3 * HOUR_MS) / (6 * HOUR_MS)), + }, + (_, index) => `u${index + 10}`, + ); + const cpu = await renderWeek({ + rows: refreshIds.flatMap((replicaId, index) => + replicaRows( + replicaId, + "r1", + (startMs) => + startMs >= alignedMs + index * 6 * HOUR_MS && + startMs < alignedMs + (index * 6 + 3) * HOUR_MS, + ), + ), + replicaIds: refreshIds, + }); + + expect(cpu.querySelectorAll("circle")).toHaveLength(refreshIds.length); + }); + + it("keeps a past blue-green deployment's replica apart from the current one", async () => { + // The past deployment's r1 ran alongside the current r1 while it hydrated. + await renderWeek({ + rows: [ + ...replicaRows("u1", "r1", () => true), + ...replicaRows( + "u5", + "r1", + (startMs) => startMs < FIRST_BUCKET_MS + 80 * HOUR_MS, + "u8", + ), + ], + replicaIds: ["u1", "u5"], + currentReplicas: { u1: "r1" }, + }); + + expect( + within(screen.getByRole("list", { name: "Replicas" })) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["r1100cc", "r1 (dropped)100cc"]); + }); + + const sizeAllocationsHandler = (processes: number) => + buildSqlQueryHandlerV2({ + queryKey: clusterQueryKeys.replicaSizeAllocations({ sizes: ["100cc"] }), + results: mapKyselyToTabular({ + columns: [ + buildColumn({ name: "size" }), + ...["processes", "cpuNanoCores", "memoryBytes", "diskBytes"].map( + float8, + ), + ], + rows: [ + { + size: "100cc", + processes: String(processes), + cpuNanoCores: "1000000000", + memoryBytes: String(8 * 2 ** 30), + diskBytes: "0", + }, + ], + }), + }); + + // r1's sample for one process, in percent of a one-core CPU allocation and + // a 1000-byte heap limit. Null readings are a failed fetch. + const processRow = ( + processId: string, + atMs: number, + cpuPercent: number | null, + heapPercent: number | null, + ) => [ + "u1", + processId, + String(atMs), + cpuPercent === null ? null : String(cpuPercent * 1e7), + cpuPercent === null ? null : "1", + null, + heapPercent === null ? null : String(heapPercent * 10), + heapPercent === null ? null : "1000", + ]; + + const processSamplesSocket = (rows: unknown[][]) => + mockSubscribeSocket((sql) => + sql.includes("mz_cluster_replica_metrics_history") + ? { + columns: [ + buildColumn({ name: "replicaId" }), + buildColumn({ name: "processId" }), + timestamptz("occurredAt"), + ...[ + "cpuNanoCores", + "memoryBytes", + "diskBytes", + "heapBytes", + "heapLimit", + ].map(float8), + ], + rows, + } + : undefined, + ); + + const openProcessesDrawer = async (processes: number, rows: unknown[][]) => { + await setFakeEnvironment(getStore().set, "aws/us-east-1", NEW_ENVIRONMENT); + server.use(sizeAllocationsHandler(processes), processSamplesSocket(rows)); + await renderResourceUsage({ withSwap: true }); + await userEvent.click( + await screen.findByRole("button", { name: "Processes" }), + ); + return screen.findByRole("dialog"); + }; + + it("has no processes button when every size runs one process", async () => { + // Query keys embed the environment version. + await setFakeEnvironment(getStore().set, "aws/us-east-1", NEW_ENVIRONMENT); + server.use(sizeAllocationsHandler(1)); + await renderResourceUsage({ withSwap: true }); + + await screen.findByRole("img", { name: "CPU usage" }); + // Give the allocations time to arrive. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect( + screen.queryByRole("button", { name: "Processes" }), + ).not.toBeInTheDocument(); + }); + + it("shows a multi-process replica's busiest process and a selectable line per process in a drawer", async () => { + // A day's window has 24-minute buckets, so samples 30 minutes apart land + // in two and each process draws a segment. + const drawer = await openProcessesDrawer( + 2, + [NOW_MS - 60 * 60_000, NOW_MS - 30 * 60_000].flatMap((atMs) => [ + processRow("0", atMs, 90, 40), + processRow("1", atMs, 30, 20), + ]), + ); + expect( + within(drawer) + .getAllByRole("tab") + .map((tab) => tab.textContent), + ).toEqual(["r1", "r2"]); + expect( + await within(drawer).findByText( + "Process 0 used the most CPU at any moment: 90.0%, 3.0× the other process (30.0%).", + ), + ).toBeVisible(); + expect( + within(drawer).getByText( + "Process 0 used the most memory at any moment: 40.0%, 2.0× the other process (20.0%).", + ), + ).toBeVisible(); + // The card shows 7 days, more than the drawer reads. + expect( + within(drawer).getByText( + /from the last 24 hours, the most this drawer reads\.$/, + ), + ).toBeVisible(); + + const statistic = within(drawer).getByRole("combobox", { + name: "Which reading to compare processes by", + }); + await userEvent.selectOptions(statistic, "on average"); + expect( + within(drawer).getByText( + "Process 0 used the most CPU on average: 90.0%, 3.0× the other process (30.0%).", + ), + ).toBeVisible(); + expect(within(drawer).getByText("over the last 24 hours")).toBeVisible(); + await userEvent.selectOptions(statistic, "right now"); + expect( + within(drawer).queryByText("over the last 24 hours"), + ).not.toBeInTheDocument(); + + // A path through a lone point has no segment. + const lineCount = (name: string) => + [ + ...within(drawer) + .getByRole("img", { name }) + .querySelectorAll(".visx-linepath"), + ].filter((path) => path.getAttribute("d")?.includes("L")).length; + expect(lineCount("CPU by Process")).toEqual(2); + expect(lineCount("Memory by Process")).toEqual(2); + const legendLabels = () => + within(drawer) + .queryAllByText(/^r1 · process \d+$/) + .map((label) => label.textContent); + expect(legendLabels()).toEqual(["r1 · process 0", "r1 · process 1"]); + expect( + within(drawer).getByText( + "Check a process to draw its line. Memory is a share of each process's heap limit.", + ), + ).toBeVisible(); + + const table = within(drawer).getByRole("table"); + const showEvery = within(table).getByRole("checkbox", { + name: "Show every process", + }); + expect(showEvery).toBeChecked(); + await userEvent.click( + within(table).getByRole("checkbox", { name: "Show process 1" }), + ); + expect(lineCount("CPU by Process")).toEqual(1); + expect(legendLabels()).toEqual(["r1 · process 0"]); + expect(showEvery).toBePartiallyChecked(); + await userEvent.click(showEvery); + expect(lineCount("Memory by Process")).toEqual(2); + await userEvent.click(showEvery); + expect(lineCount("Memory by Process")).toEqual(0); + expect(showEvery).not.toBeChecked(); + + expect( + within(table) + .getAllByRole("columnheader") + .map((header) => header.textContent), + ).toEqual([ + "", + "Process", + "CPU now", + "CPU peak", + "CPU average", + "Memory now", + "Memory peak", + "Memory average", + ]); + expect( + within(table) + .getAllByRole("row") + .slice(1) + .map((row) => row.textContent), + ).toEqual([ + "Process 0busiest90.0%90.0%90.0%40.0%40.0%40.0%", + "Process 130.0%30.0%30.0%20.0%20.0%20.0%", + ]); + }); + + it("reads a process that is down in the newest round as having no reading right now", async () => { + const beforeMs = NOW_MS - 32 * 60_000; + const latestMs = NOW_MS - 31 * 60_000; + // Process 1 read 94% before it went down. + const drawer = await openProcessesDrawer(2, [ + processRow("0", beforeMs, 30, 30), + processRow("1", beforeMs, 94, 94), + processRow("0", latestMs, 30, 30), + processRow("1", latestMs, null, null), + ]); + await userEvent.selectOptions( + await within(drawer).findByRole("combobox", { + name: "Which reading to compare processes by", + }), + "right now", + ); + + expect( + within(drawer).getByText("No reading right now from process 1."), + ).toBeVisible(); + expect( + within(drawer).queryByText(/used the most CPU right now/), + ).not.toBeInTheDocument(); + expect( + within(within(drawer).getByRole("table")) + .getAllByRole("row") + .slice(1) + .map((tableRow) => tableRow.textContent), + ).toEqual([ + "Process 030.0%30.0%30.0%30.0%30.0%30.0%", + "Process 1-94.0%94.0%-94.0%94.0%", + ]); + }); + + it("reads idle processes as even, however large the ratio between them", async () => { + const atMs = NOW_MS - 30 * 60_000; + const drawer = await openProcessesDrawer(2, [ + processRow("0", atMs, 0, 0.1), + processRow("1", atMs, 0, 0.3), + ]); + + expect( + await within(drawer).findByText( + "CPU is spread evenly at any moment: process 0 read 0.0%, against 0.0% for the other process.", + ), + ).toBeVisible(); + expect( + within(drawer).getByText( + "Memory is spread evenly at any moment: process 1 read 0.3%, against 0.1% for the other process.", + ), + ).toBeVisible(); + expect( + within(within(drawer).getByRole("table")).queryByText(/busiest/), + ).not.toBeInTheDocument(); + }); + + describe("with more processes than colors", () => { + const BUCKET_MS = DAY_MS / 60; + const OLDER_MS = NOW_MS - 60 * 60_000; + const NEWER_MS = NOW_MS - 30 * 60_000; + // Process 0 reads 90% CPU against 10% for the other seven, and process + // 3's 0.3% heap leads their 0.1%. Only process 0 reports in the newer + // sample, a bucket later. + const rows = [ + ...Array.from({ length: 8 }, (_, process) => + processRow( + String(process), + OLDER_MS, + process === 0 ? 90 : 10, + process === 3 ? 0.3 : 0.1, + ), + ), + processRow("0", NEWER_MS, 90, 0.1), + ...Array.from({ length: 7 }, (_, index) => + processRow(String(index + 1), NEWER_MS, null, null), + ), + ]; + + it("colors only a process that stands out", async () => { + const drawer = await openProcessesDrawer(8, rows); + + expect( + await within(drawer).findByText( + "Memory is spread evenly at any moment: process 3 read 0.3%, against 0.1% for the other 7 processes.", + ), + ).toBeVisible(); + expect( + within(drawer) + .queryAllByText(/^r1 · process \d+$/) + .map((label) => label.textContent), + ).toEqual(["r1 · process 0"]); + expect(within(drawer).getByText("Other processes")).toBeVisible(); + }); + + it("highlights a hovered grey process in a color outside the palette", async () => { + const drawer = await openProcessesDrawer(8, rows); + + fireEvent.mouseOver( + within(await within(drawer).findByRole("table")).getByText("Process 2"), + ); + const label = within( + within(drawer).getByRole("img", { name: "CPU by Process" }), + ).getByText("p2"); + expect(label).toHaveAttribute("fill", lightColors.foreground.primary); + expect(lightColors.lineGraph).not.toContain(label.getAttribute("fill")); + }); + + it("draws a reading with no neighbor as a dot", async () => { + const drawer = await openProcessesDrawer(8, rows); + + const chart = await within(drawer).findByRole("img", { + name: "CPU by Process", + }); + expect(chart.querySelectorAll("circle")).toHaveLength(7); + }); + + it("names at most six processes with no reading in the tooltip", async () => { + const drawer = await openProcessesDrawer(8, rows); + + const chart = await within(drawer).findByRole("img", { + name: "CPU by Process", + }); + const overlay = chart.querySelector('rect[fill="transparent"]'); + if (!overlay) throw new Error("hover overlay not rendered"); + const endMs = Date.now(); + const hoverMs = + Math.floor(NEWER_MS / BUCKET_MS) * BUCKET_MS + BUCKET_MS / 2; + const plotWidthPx = WIDTH_PX - CHART_MARGIN.left - CHART_MARGIN.right; + fireEvent.pointerMove(overlay, { + clientX: + CHART_MARGIN.left + + ((hoverMs - (endMs - DAY_MS)) / DAY_MS) * plotWidthPx, + clientY: 80, + }); + + expect( + within(await screen.findByTestId("chart-tooltip")).getByText( + "No reading from process 1, process 2, process 3, process 4, process 5, process 6 and 1 more", + ), + ).toBeVisible(); + }); + }); + + it("refetches status history when a replica changes status, though the cluster's latest status update doesn't move", async () => { + // As on a system cluster, whose `latestStatusUpdate` is always null. + const cluster = buildCluster(REPLICA_NAMES); + let setCluster: (next: Cluster) => void = () => {}; + const Harness = () => { + const [shown, setShown] = React.useState(cluster); + React.useEffect(() => { + setCluster = setShown; + }, []); + return ( + + ); + }; + await setFakeEnvironment(getStore().set, "aws/us-east-1", NEW_ENVIRONMENT); + registerHandlers({ withSwap: true, replicaNames: REPLICA_NAMES }); + await renderComponent(, { + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", NEW_ENVIRONMENT), + }); + await screen.findByRole("img", { name: "CPU usage" }); + const requestedKeys: unknown[] = []; + server.use( + http.post("*/api/sql", ({ request }) => { + const queryKey = JSON.parse( + new URL(request.url).searchParams.get("query_key") ?? "[]", + ); + if (queryKey[1]?.scope !== "replicaStatusHistory") return undefined; + requestedKeys.push(queryKey[1].statusesUpdatedAt); + return HttpResponse.json({ + results: [ + mapKyselyToTabular({ + rows: [], + columns: [timestamptz("occurredAt")], + }), + ], + }); + }), + ); + const changedAt = "2026-10-20T00:07:00.000Z"; + act(() => + setCluster({ + ...cluster, + replicas: cluster.replicas.map((replica) => ({ + ...replica, + statuses: [ + { + replica_id: replica.id, + process_id: "0", + reason: null, + status: "online", + updated_at: changedAt, + }, + ], + })), + }), + ); + + await waitFor(() => + expect(requestedKeys).toEqual([new Date(changedAt).getTime()]), + ); + }); + + it("shows an error when utilization fails to load", async () => { + await renderResourceUsage({ withSwap: true, utilizationError: true }); + + expect(await screen.findByText(CLUSTERS_FETCH_ERROR_MESSAGE)).toBeVisible(); + }); + + describe("on the Metrics page", () => { + const findReplicaSelect = () => + screen.findByRole("combobox", { name: "Replica" }); + const statusRowNames = async () => + within( + await screen.findByRole("img", { name: "Replica status over time" }), + ) + .getAllByText(/^r\d$/) + .map((label) => label.textContent); + + it("narrows the card to the replica picked in the toolbar, and keeps the pick in the URL", async () => { + await renderClusterMetrics(); + + const select = await findReplicaSelect(); + expect( + within(select) + .getAllByRole("option") + .map((option) => option.textContent), + ).toEqual(["All replicas", "r1 · 100cc", "r2 · 100cc"]); + expect(await statusRowNames()).toEqual(["r1", "r2"]); + + await userEvent.selectOptions(select, "r2 · 100cc"); + expect(screen.getByTestId("location-search")).toHaveTextContent( + "?replica=r2", + ); + expect( + screen.queryByRole("list", { name: "Replicas" }), + ).not.toBeInTheDocument(); + expect(await statusRowNames()).toEqual(["r2"]); + const tooltip = await hoverAt(FIRST_BUCKET_MS + 50 * HOUR_MS); + expect(tooltip).toHaveTextContent(/r2.*CPU90\.0%/); + expect(tooltip).not.toHaveTextContent("r1"); + + await userEvent.selectOptions(select, "All replicas"); + expect(screen.getByTestId("location-search")).not.toHaveTextContent( + "replica=", + ); + expect(screen.getByRole("list", { name: "Replicas" })).toBeVisible(); + expect(await statusRowNames()).toEqual(["r1", "r2"]); + }); + + it("opens on the replica the URL names", async () => { + await renderClusterMetrics("?replica=r2"); + + expect(await findReplicaSelect()).toHaveValue("r2"); + expect(await statusRowNames()).toEqual(["r2"]); + }); + + it("shows every replica when the URL names one that no longer exists", async () => { + await renderClusterMetrics("?replica=r9"); + + expect(await findReplicaSelect()).toHaveValue(""); + expect(await statusRowNames()).toEqual(["r1", "r2"]); + expect(screen.getByRole("list", { name: "Replicas" })).toBeVisible(); + }); + + it("shows only the selected replica in the processes drawer", async () => { + await setFakeEnvironment( + getStore().set, + "aws/us-east-1", + NEW_ENVIRONMENT, + ); + server.use(sizeAllocationsHandler(2), processSamplesSocket([])); + await renderClusterMetrics("?replica=r2"); + + await userEvent.click( + await screen.findByRole("button", { name: "Processes" }), + ); + const drawer = await screen.findByRole("dialog"); + expect( + within(drawer) + .getAllByRole("tab") + .map((tab) => tab.textContent), + ).toEqual(["r2"]); + }); + }); +}); diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsage.tsx b/console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsage.tsx new file mode 100644 index 0000000000000..2fc3021cccf94 --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsage.tsx @@ -0,0 +1,802 @@ +// 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, + Button, + Card, + Flex, + HStack, + Spinner, + Text, + useTheme, + VStack, +} from "@chakra-ui/react"; +import { useParentSize } from "@visx/responsive"; +import { scaleTime } from "@visx/scale"; +import { useTooltip, useTooltipInPortal } from "@visx/tooltip"; +import React from "react"; + +import { Cluster } from "~/api/materialize/cluster/clusterList"; +import { hasUtilizationReading } from "~/api/materialize/cluster/replicaUtilizationBinning"; +import Alert from "~/components/Alert"; +import ErrorBox from "~/components/ErrorBox"; +import { + GraphTooltip, + GraphTooltipBucketRange, + TooltipColorSwatch, +} from "~/components/graphComponents"; +import TextLink from "~/components/TextLink"; +import { useAppConfig } from "~/config/useAppConfig"; +import { useChartLegend } from "~/hooks/useChartLegend"; +import docUrls from "~/mz-doc-urls.json"; +import { + DataPoint, + ReplicaData, +} from "~/platform/clusters/ClusterOverview/types"; +import { CLUSTERS_FETCH_ERROR_MESSAGE } from "~/platform/clusters/constants"; +import { useReplicaSizeAllocations } from "~/platform/clusters/queries"; +import { MaterializeTheme } from "~/theme"; +import { capitalizeSentence } from "~/util"; +import { formatBytesShort, formatPercentage } from "~/utils/format"; +import { buildXYGraphLayoutProps } from "~/utils/graph"; + +import { ProcessesDrawer, ProcessReplica } from "./ProcessesDrawer"; +import { + ChangesLane, + ChangeTooltipContent, + CpuChart, + MemoryChart, + ReplicaStatusTimeline, + StatusTimelineRow, + TimeAxis, +} from "./ResourceUsageCharts"; +import { + canSwap, + ChangeTick, + datumAt, + DdlEvent, + hasOomBetween, + memoryBar, + mergeBuckets, + mergedBucketMs, + MissingUsageCause, + mostBarsAtOnce, + sharedHeapLimitBytes, + stateAt, +} from "./resourceUsageModel"; +import { + CHART_MARGIN, + MIN_BAR_SLOT_PX, + useStatusStyles, +} from "./resourceUsageStyles"; +import { ColoredReplica, useResourceUsageData } from "./useResourceUsageData"; + +interface ReadingRow { + label: string; + percent: number | null; + bytes?: number; + /** The limit the percentage is a share of, when the row names it. */ + limit?: string; +} + +// Readings in percent, 0 to 100, with bytes when `heapLimitBytes` is known. It +// is one process's limit, as the readings are one process's shares. Memory and +// disk are the bar's two segments, and the total is its height. +const readingRows = ( + point: DataPoint, + hasHeapLimit: boolean, + heapLimitBytes: number | undefined, +): ReadingRow[] => { + const bar = memoryBar(point); + const memory = (label: string, percent: number | null): ReadingRow => ({ + label, + percent, + bytes: + percent === null || heapLimitBytes === undefined + ? undefined + : (heapLimitBytes * percent) / 100, + }); + return [ + { label: "CPU", percent: point.cpuPercent }, + point.swapPercent === null + ? memory(hasHeapLimit ? "Heap" : "Memory", point.heapPercent) + : memory("Memory", bar ? bar.heap - bar.swap : null), + ...(canSwap(point) + ? [ + memory( + "Disk", + point.swapPercent === null ? null : (bar?.swap ?? null), + ), + { ...memory("Total", bar?.heap ?? null), limit: "heap limit" }, + ] + : []), + ]; +}; + +const formatReadingRow = ({ percent, bytes, limit }: ReadingRow) => { + if (percent === null) return "-"; + const share = formatPercentage(percent / 100, 1); + return [ + limit ? `${share} of ${limit}` : share, + ...(bytes === undefined + ? [] + : [formatBytesShort(BigInt(Math.round(bytes)))]), + ].join(" · "); +}; + +/** + * One section per replica group at `timeMs`: each member with a reading then, + * or the group's status alone when none has one. Groups with neither are + * skipped. + */ +const tooltipSectionsAt = ( + series: ReplicaData[], + timelines: Map, + groupIdById: Map, + timeMs: number, +) => { + const sections = series.map(({ id, data }) => { + const groupId = groupIdById.get(id) ?? id; + const point = datumAt(data, timeMs); + const timeline = timelines.get(groupId); + const state = timeline ? stateAt(timeline, timeMs) : undefined; + const isOom = Boolean( + point && + timeline && + hasOomBetween(timeline, point.bucketStart, point.bucketEnd), + ); + return { id, groupId, point, state, isOom }; + }); + const groupsWithReading = new Set( + sections.flatMap(({ groupId, point }) => (point ? [groupId] : [])), + ); + return sections.filter( + ({ id, groupId, point, state }) => + point || + (state !== undefined && + id === groupId && + !groupsWithReading.has(groupId)), + ); +}; + +const ResourceUsageTooltipContent = ({ + sections, + replicasById, + hasHeapLimit, + heapLimitBytesBySize, +}: { + sections: ReturnType; + replicasById: Map; + hasHeapLimit: boolean; + heapLimitBytesBySize: Map; +}) => { + const { colors } = useTheme(); + const statusStyles = useStatusStyles(); + const bucket = sections.find((section) => section.point)?.point; + + return ( + <> + {sections.map(({ id, point, state, isOom }) => { + const replica = replicasById.get(id); + // Without a heap limit, readings are shares of the memory allocation. + const heapLimitBytes = + hasHeapLimit && replica?.size + ? heapLimitBytesBySize.get(replica.size) + : undefined; + return ( + + + + + + {replica?.name ?? id} + + {replica?.size && ( + + {replica.size} + + )} + + + {isOom + ? "Out of Memory" + : state && + capitalizeSentence(statusStyles[state].label, false)} + + + {point && + readingRows(point, hasHeapLimit, heapLimitBytes).map((row) => ( + + + {row.label} + + {formatReadingRow(row)} + + ))} + + ); + })} + {bucket && ( + + )} + + ); +}; + +// Where readings stay empty, the next step depends on the deployment: cloud +// customers can't install metrics collection themselves. +const MissingUsageAdvice = () => { + const { mode } = useAppConfig(); + return mode === "cloud" ? ( + <> + If this continues,{" "} + + contact support + + . + + ) : ( + <>Check that metrics-server is installed in your Kubernetes cluster. + ); +}; + +const MissingUsageMessage = ({ + cause, + hasReplicas, +}: { + cause: MissingUsageCause; + hasReplicas: boolean; +}) => { + const { colors } = useTheme(); + let message: React.ReactNode; + if (!hasReplicas) { + message = "No resource usage in this time period."; + } else if (cause === "unmeasured") { + message = ( + <> + Your replicas were running, but no resource usage was recorded for this + period. + + ); + } else if (cause === "offlineOom") { + message = ( + <> + All replicas were offline for this period. They last went offline after + running out of memory. See{" "} + + Troubleshoot memory usage + + . + + ); + } else if (cause === "offline") { + message = ( + <> + All replicas were offline for this period, and Materialize didn't + record why. If they keep restarting, see{" "} + + Troubleshoot hydration failures + + . + + ); + } else { + message = ( + <> + No resource usage was recorded for this period. The replicas may have + been offline, or metrics may be unavailable. + + ); + } + return ( + + {message} + + ); +}; + +interface ResourceUsageContentProps { + width: number; + startDate: Date; + endDate: Date; + series: ReplicaData[]; + hasUsage: boolean; + /** Replicas whose status rows show, which may have no samples. */ + statusReplicaIds: string[]; + replicasById: Map; + groupIdById: Map; + colorMap: Map; + /** By group id from `groupIdById`. */ + timelines: Map; + ddlEvents: DdlEvent[]; + heapLimitBytes: number | undefined; + heapLimitBytesBySize: Map; + hasHeapLimit: boolean; + unhydratedObjectCounts: Map | undefined; + missingUsage: MissingUsageCause; + isDdlError: boolean; + cpuHeaderAction?: React.ReactNode; + isStatusLoading: boolean; + isStatusError: boolean; + isHydrationError: boolean; +} + +const ResourceUsageContent = ({ + width, + startDate, + endDate, + series, + hasUsage, + statusReplicaIds, + replicasById, + groupIdById, + colorMap, + timelines, + ddlEvents, + heapLimitBytes, + heapLimitBytesBySize, + hasHeapLimit, + unhydratedObjectCounts, + missingUsage, + isDdlError, + isStatusLoading, + isStatusError, + isHydrationError, + cpuHeaderAction, +}: ResourceUsageContentProps) => { + const { colors } = useTheme(); + const { tooltipData, tooltipLeft, tooltipTop, showTooltip, hideTooltip } = + useTooltip(); + const changeTooltip = useTooltip(); + const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal( + { detectBounds: true, scroll: true }, + ); + const xScale = React.useMemo( + () => + scaleTime({ + domain: [startDate, endDate], + range: buildXYGraphLayoutProps({ + width, + height: 0, + margin: CHART_MARGIN, + }).xScaleRange, + }), + [startDate, endDate, width], + ); + // Wide windows have more buckets than the plot fits as bars, so they merge + // into wider ones. The charts and the tooltip all read the merged series. + const chartSeries = React.useMemo(() => { + const [left, right] = xScale.range(); + const sourceBucketMs = series.reduce( + (widest, { data }) => + data.reduce( + (max, point) => Math.max(max, point.bucketEnd - point.bucketStart), + widest, + ), + 0, + ); + const groupMs = mergedBucketMs({ + sourceBucketMs, + domainMs: endDate.getTime() - startDate.getTime(), + plotWidthPx: right - left, + // Replicas that never ran together don't share a time's slot. + replicaCount: mostBarsAtOnce(series), + minSlotPx: MIN_BAR_SLOT_PX, + }); + if (groupMs === sourceBucketMs) return series; + return series.map((replica) => ({ + ...replica, + data: mergeBuckets(replica.data, groupMs, { + startMs: startDate.getTime(), + endMs: endDate.getTime(), + }), + })); + }, [series, xScale, startDate, endDate]); + const statusRows = React.useMemo( + () => + statusReplicaIds.flatMap((id) => { + const timeline = timelines.get(id); + return timeline + ? [ + { + id, + name: replicasById.get(id)?.name ?? id, + timeline, + unhydratedCount: unhydratedObjectCounts?.get(id), + }, + ] + : []; + }), + [statusReplicaIds, timelines, replicasById, unhydratedObjectCounts], + ); + const onPointerMove = (timeMs: number, event: React.PointerEvent) => + showTooltip({ + tooltipData: timeMs, + tooltipLeft: event.clientX - containerBounds.left, + tooltipTop: event.clientY - containerBounds.top, + }); + // Anchored to the tick, since a focused tick has no pointer position. + const onChangeActivate = (tick: ChangeTick, target: Element) => { + const bounds = target.getBoundingClientRect(); + changeTooltip.showTooltip({ + tooltipData: tick, + tooltipLeft: bounds.left + bounds.width / 2 - containerBounds.left, + tooltipTop: bounds.top - containerBounds.top, + }); + }; + const tooltipSections = + tooltipData === undefined + ? [] + : tooltipSectionsAt(chartSeries, timelines, groupIdById, tooltipData); + const plotProps = { + width, + xScale, + series: chartSeries, + colorMap, + changeGuideMs: changeTooltip.tooltipData?.atMs, + hoverTimeMs: tooltipData, + onPointerMove, + onPointerLeave: hideTooltip, + }; + + return ( + + + {hasUsage ? ( + <> + + + + + + + ) : ( + + 0} + /> + + + )} + + {/* No gap between them, so the Changes row reads as one more row. */} + + {isStatusError ? ( + + ) : isStatusLoading ? ( + + + + ) : ( + + )} + + + {!isStatusError && !isStatusLoading && isHydrationError && ( + + Hydration history is unavailable. + + )} + {isDdlError && ( + + Object creation markers are unavailable. + + )} + + + {tooltipSections.length > 0 && + tooltipLeft !== undefined && + tooltipTop !== undefined && ( + + + + )} + {changeTooltip.tooltipData && + changeTooltip.tooltipLeft !== undefined && + changeTooltip.tooltipTop !== undefined && ( + + + + )} + + ); +}; + +export interface ResourceUsageProps { + cluster: Cluster; + timePeriodMinutes: number; + /** + * A current replica to show alone, with the dropped replicas of its name. + * Without one, every replica shows, with a toggle each. + */ + selectedReplicaId?: string; +} + +/** + * CPU, memory and replica status for a cluster's replicas over a shared time + * axis, with one crosshair and tooltip across all three. + */ +export const ResourceUsage = ({ + cluster, + timePeriodMinutes, + selectedReplicaId, +}: ResourceUsageProps) => { + const { colors } = useTheme(); + const { + history, + replicasById, + replicaGroups, + groupIdById, + colorMap, + timelines, + ddlEvents, + heapLimitBytesBySize, + hasHeapLimit, + unhydratedObjectCounts, + missingUsage, + isDdlError, + isStatusLoading, + isStatusError, + isHydrationError, + chartData, + } = useResourceUsageData({ cluster, timePeriodMinutes }); + // Measure only the width. visx's `ParentSize` collapses to 0px tall here, + // since the charts set their own height instead of filling a parent. + const { parentRef, width } = useParentSize({ debounceTime: 10 }); + const groupIds = React.useMemo( + () => replicaGroups.map(({ id }) => id), + [replicaGroups], + ); + const { visibleLegendItems, toggleLegendItem } = useChartLegend({ + allLegendItems: groupIds, + }); + // A current replica's id is its group's id. + const visibleGroupIds = React.useMemo( + () => + selectedReplicaId === undefined + ? visibleLegendItems + : new Set([selectedReplicaId]), + [selectedReplicaId, visibleLegendItems], + ); + const series = React.useMemo( + () => + chartData.filter(({ id }) => + visibleGroupIds.has(groupIdById.get(id) ?? id), + ), + [chartData, groupIdById, visibleGroupIds], + ); + const statusReplicaIds = React.useMemo( + () => groupIds.filter((id) => visibleGroupIds.has(id)), + [groupIds, visibleGroupIds], + ); + const heapLimitBytes = sharedHeapLimitBytes( + series.map(({ id }) => replicasById.get(id)?.size), + heapLimitBytesBySize, + ); + const hasUsage = Boolean( + history.data?.graphData.some(({ data }) => + data.some(hasUtilizationReading), + ), + ); + + const [isProcessesOpen, setIsProcessesOpen] = React.useState(false); + const { data: allocationsBySize } = useReplicaSizeAllocations( + [ + ...new Set(cluster.replicas.flatMap(({ size }) => (size ? [size] : []))), + ].sort(), + ); + // Only a size that runs more than one process has processes to compare. + const processReplicas = cluster.replicas.flatMap( + ({ id, name, size }): ProcessReplica[] => { + if (selectedReplicaId !== undefined && id !== selectedReplicaId) { + return []; + } + const allocation = size ? allocationsBySize?.get(size) : undefined; + return size && allocation && allocation.processes > 1 + ? [{ id, name, size, allocation }] + : []; + }, + ); + + let body: React.ReactNode; + if (history.isError) { + body = ; + } else if ( + history.isLoading || + !history.data || + // Without readings, replica status picks which message explains why. + (!hasUsage && isStatusLoading) + ) { + body = ( + + + + ); + } else { + const { startDate, endDate } = history.data; + body = ( + + {width > 0 && ( + 0 && ( + + ) + } + /> + )} + + ); + } + + return ( + + + + + Resource Usage + + + {history.isRefreshing && ( + + )} + {selectedReplicaId === undefined && ( + + {replicaGroups.map((replica) => ( + + toggleLegendItem(replica.id, event)} + opacity={visibleLegendItems.has(replica.id) ? 1 : 0.5} + > + + + {replica.isCurrent + ? replica.name + : `${replica.name} (dropped)`} + + {replica.size && ( + + {replica.size} + + )} + + + ))} + + )} + + + {body} + + setIsProcessesOpen(false)} + replicas={processReplicas} + timePeriodMinutes={timePeriodMinutes} + /> + + ); +}; diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsageCharts.tsx b/console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsageCharts.tsx new file mode 100644 index 0000000000000..cb9708d168077 --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/ResourceUsageCharts.tsx @@ -0,0 +1,1121 @@ +// 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, Flex, HStack, Text, useTheme, VStack } from "@chakra-ui/react"; +import { AxisBottom, AxisLeft, AxisScale } from "@visx/axis"; +import { GridRows } from "@visx/grid"; +import { scaleLinear, scaleTime } from "@visx/scale"; +import { Line, LinePath } from "@visx/shape"; +import { max } from "d3"; +import React from "react"; + +import { + GraphEventOverlay, + GraphLineCursor, + GraphTooltipCursor, + TooltipColorSwatch, +} from "~/components/graphComponents"; +import { + DataPoint, + ReplicaData, +} from "~/platform/clusters/ClusterOverview/types"; +import { MaterializeTheme } from "~/theme"; +import { kebabToSentenceCase, notNullOrUndefined, pluralize } from "~/util"; +import { + DATE_FORMAT, + formatDate, + FRIENDLY_DATETIME_FORMAT, + TIME_FORMAT, +} from "~/utils/dateFormat"; +import { formatBytesShort } from "~/utils/format"; +import { + buildXYGraphLayoutProps, + eventToTimestamp, + relativeTimeTickFormat, +} from "~/utils/graph"; + +import { + canSwap, + ChangeTick, + datumAt, + DdlEvent, + memoryBar, + placeChangeTicks, + placeStatusSegments, + ramLimitPercent, + ReplicaTimeline, + seriesWithBarAt, +} from "./resourceUsageModel"; +import { + CHART_MARGIN, + PERCENT_TICKS, + percentScale, + useStatusStyles, +} from "./resourceUsageStyles"; + +export type TimeScale = ReturnType>; + +export type PercentScale = ReturnType>; + +export type Layout = ReturnType; + +const PLOT_HEIGHT_PX = 168; +const PLOT_SVG_HEIGHT_PX = + CHART_MARGIN.top + PLOT_HEIGHT_PX + CHART_MARGIN.bottom; +const STATUS_ROW_HEIGHT_PX = 14; +const STATUS_ROW_GAP_PX = 10; +const STATUS_MARGIN = { ...CHART_MARGIN, top: 0, bottom: 0 }; +const MIN_STATUS_SEGMENT_PX = 4; +const CHANGES_ROW_HEIGHT_PX = 16; +// Creations closer than this share a tick, which is also each tick's hover +// target width. +const CHANGE_MERGE_PX = 8; +const CHANGE_TICK_WIDTH_PX = 3; +const CHANGE_TICK_MIN_HEIGHT_PX = 6; +const CHANGE_TICK_STEP_PX = 2; +// The most objects a change tick's tooltip names before summarizing the rest. +const CHANGE_TOOLTIP_OBJECT_LIMIT = 5; +const TIME_TICK_COUNT = 8; +// Replicas' bars at one time overlap by this share of a bar, so each group +// reads as one point in time. +const BAR_OVERLAP = 0.5; +const BADGE_FONT_PX = 11; +// Monospace glyphs are about 0.6em wide, which sizes a badge to its label. +const BADGE_CHAR_PX = BADGE_FONT_PX * 0.6; +const BADGE_PADDING_PX = 4; + +interface LegendSwatchProps { + /** Several colors split the swatch, one stripe per replica it stands for. */ + color: string | string[]; + variant: "square" | "line" | "dashed" | "diamond"; +} + +export const LegendSwatch = ({ color, variant }: LegendSwatchProps) => { + const palette = typeof color === "string" ? [color] : color; + if (variant === "square") { + if (palette.length === 1) return ; + return ( + + {palette.map((stripe, stripeIndex) => ( + + ))} + + ); + } + if (variant === "diamond") { + return ( + + ); + } + const segmentWidth = 16 / palette.length; + return ( + + {palette.map((stroke, segmentIndex) => ( + + ))} + + ); +}; + +/** The charted replicas' colors, for a swatch that stands for all of them. */ +const replicaColors = ( + series: ReplicaData[], + colorMap: Map, + fallback: string, +) => { + const palette = series.flatMap(({ id }) => { + const color = colorMap.get(id); + return color ? [color] : []; + }); + return palette.length > 0 ? palette : fallback; +}; + +export interface ChartHeaderProps { + title: string; + description?: string; + legend?: Array<{ label: string } & LegendSwatchProps>; + /** A control after the legend, such as a button opening more detail. */ + action?: React.ReactNode; +} + +export const ChartHeader = ({ + title, + description, + legend, + action, +}: ChartHeaderProps) => { + const { colors } = useTheme(); + return ( + + + {title} + {description && ( + + {description} + + )} + + {legend && ( + + {legend.map(({ label, color, variant }) => ( + + + + {label} + + + ))} + + )} + {action} + + ); +}; + +export const PercentAxis = ({ + layout, + yScale, + ticks, +}: { + layout: Layout; + yScale: PercentScale; + ticks: number[]; +}) => { + const { colors, fonts } = useTheme(); + return ( + <> + + > + {...layout.axisLeftProps} + scale={yScale} + tickValues={ticks} + hideAxisLine + hideTicks + tickFormat={(value) => `${value.valueOf()}%`} + tickLabelProps={() => ({ + fill: colors.foreground.secondary, + fontFamily: fonts.mono, + fontSize: 12, + textAnchor: "end", + dx: -4, + dy: 4, + })} + /> + + ); +}; + +const ThresholdLine = ({ + layout, + y, + color, +}: { + layout: Layout; + y: number; + color: string; +}) => { + const [left, right] = layout.xScaleRange; + return ( + + ); +}; + +/** A faint line at a hovered or focused change tick's time. */ +const ChangeGuide = ({ + layout, + x, +}: { + layout: Layout; + x: number | undefined; +}) => { + const { colors } = useTheme(); + if (x === undefined) return null; + const { graphTop, graphBottom } = layout.graphLineCursorProps; + return ( + + ); +}; + +interface PlotProps { + width: number; + xScale: TimeScale; + series: ReplicaData[]; + colorMap: Map; + /** The time of the change tick being hovered or focused. */ + changeGuideMs?: number; + hoverTimeMs?: number; + onPointerMove: (timeMs: number, event: React.PointerEvent) => void; + onPointerLeave: () => void; +} + +export const HoverOverlay = ({ + layout, + xScale, + onPointerMove, + onPointerLeave, +}: { layout: Layout; xScale: TimeScale } & Pick< + PlotProps, + "onPointerMove" | "onPointerLeave" +>) => { + const [startTime, endTime] = xScale.domain().map((date) => date.getTime()); + return ( + { + const timeMs = eventToTimestamp({ event, xScale, startTime, endTime }); + if (timeMs !== null) onPointerMove(timeMs, event); + }} + onPointerLeave={onPointerLeave} + /> + ); +}; + +// Memoized so pointer moves, which only change the cursor, skip redrawing the +// lines. +const CpuMarks = React.memo( + ({ + layout, + xScale, + yScale, + ticks, + series, + colorMap, + showLimit, + }: { + layout: Layout; + xScale: TimeScale; + yScale: PercentScale; + ticks: number[]; + series: ReplicaData[]; + colorMap: Map; + showLimit: boolean; + }) => { + const { colors } = useTheme(); + return ( + <> + + {series.map((replica) => ( + point.cpuPercent !== null} + x={(point) => xScale(point.bucketStart)} + y={(point) => yScale(point.cpuPercent ?? 0)} + stroke={colorMap.get(replica.id)} + strokeWidth={2} + pointerEvents="none" + /> + ))} + {/* A reading with no defined neighbor draws no line segment, such as a + short-lived replica merged into one bucket, so it gets a dot. */} + {series.flatMap((replica) => + replica.data.flatMap((point, index, data) => { + const hasReading = (neighbor: DataPoint | undefined) => + neighbor !== undefined && neighbor.cpuPercent !== null; + if ( + point.cpuPercent === null || + hasReading(data[index - 1]) || + hasReading(data[index + 1]) + ) { + return []; + } + return [ + , + ]; + }), + )} + {showLimit && ( + + )} + + ); + }, +); + +export type CpuChartProps = PlotProps & { + headerAction?: React.ReactNode; +}; + +/** + * A line per replica as a share of its CPU allocation. Readings past 100% + * extend the axis, with a dashed line marking the allocation. + */ +export const CpuChart = ({ + width, + xScale, + series, + colorMap, + changeGuideMs, + hoverTimeMs, + onPointerMove, + onPointerLeave, + headerAction, +}: CpuChartProps) => { + const { colors } = useTheme(); + const layout = React.useMemo( + () => + buildXYGraphLayoutProps({ + width, + height: PLOT_SVG_HEIGHT_PX, + margin: CHART_MARGIN, + }), + [width], + ); + const peak = React.useMemo( + () => + max(series, (replica) => + max(replica.data, (point) => point.cpuPercent), + ) ?? 0, + [series], + ); + const isOverflowing = peak > 100; + const { yScale, ticks } = React.useMemo( + () => percentScale(peak, layout), + [peak, layout], + ); + + return ( + + + + + + {hoverTimeMs !== undefined && ( + <> + + { + const point = datumAt(replica.data, hoverTimeMs); + const value = point?.cpuPercent; + if (!point || !notNullOrUndefined(value)) return []; + return [ + { + key: replica.id, + color: colorMap.get(replica.id) ?? colors.lineGraph[0], + x: xScale(point.bucketStart), + y: yScale(value), + }, + ]; + })} + /> + + )} + + + + ); +}; + +const MemoryMarks = React.memo( + ({ + layout, + xScale, + yScale, + series, + colorMap, + ramLimit, + }: { + layout: Layout; + xScale: TimeScale; + yScale: PercentScale; + series: ReplicaData[]; + colorMap: Map; + ramLimit: number | undefined; + }) => { + const { colors } = useTheme(); + const { graphBottom } = layout.graphLineCursorProps; + // A surface ring keeps overlapping bars apart. + const ring = { stroke: colors.background.primary, strokeWidth: 2 }; + const idsByBucketStart = seriesWithBarAt(series); + const bars = series.map((replica) => + replica.data.map((point) => { + const bar = memoryBar(point); + if (!bar) return null; + const barIds = idsByBucketStart.get(point.bucketStart) ?? [replica.id]; + const slotIndex = barIds.indexOf(replica.id); + const bucketLeft = xScale(point.bucketStart); + // 2px gap between neighboring times. + const groupWidth = Math.max( + 1, + xScale(point.bucketEnd) - bucketLeft - 2, + ); + const step = 1 - BAR_OVERLAP; + const barWidth = groupWidth / (1 + (barIds.length - 1) * step); + const x = bucketLeft + 1 + slotIndex * barWidth * step; + const radius = Math.min(2, barWidth / 2); + const ramTop = yScale(bar.heap - bar.swap); + const barRing = barIds.length > 1 ? ring : {}; + return ( + + + {bar.swap > 0 && ( + + )} + + ); + }), + ); + return ( + <> + + {bars} + {ramLimit !== undefined && ( + + )} + + + ); + }, +); + +/** A limit's legend label, with its size in bytes when the heap limit is known. */ +const limitLabel = ( + name: string, + heapLimitBytes: number | undefined, + percentOfHeapLimit: number, +) => { + if (heapLimitBytes === undefined) return name; + const bytes = BigInt(Math.round((heapLimitBytes * percentOfHeapLimit) / 100)); + return `${name} · ${formatBytesShort(bytes)}`; +}; + +export type MemoryChartProps = PlotProps & { + heapLimitBytes: number | undefined; + /** False when readings are shares of the memory allocation instead. */ + hasHeapLimit: boolean; +}; + +export const MemoryChart = ({ + width, + xScale, + series, + colorMap, + changeGuideMs, + hoverTimeMs, + onPointerMove, + onPointerLeave, + heapLimitBytes, + hasHeapLimit, +}: MemoryChartProps) => { + const { colors } = useTheme(); + const layout = React.useMemo( + () => + buildXYGraphLayoutProps({ + width, + height: PLOT_SVG_HEIGHT_PX, + margin: CHART_MARGIN, + }), + [width], + ); + const yScale = React.useMemo( + () => + scaleLinear({ + domain: [0, 100], + range: layout.yScaleRange, + clamp: true, + }), + [layout], + ); + const { hasSplit, hasSwap, ramLimit } = React.useMemo(() => { + const seriesData = series.map((replica) => replica.data); + return { + // Without the split, a bar is all of heap, swap included. + hasSplit: seriesData.some((data) => + data.some((point) => point.swapPercent !== null), + ), + hasSwap: seriesData.some((data) => data.some(canSwap)), + ramLimit: ramLimitPercent(seriesData), + }; + }, [series]); + + const legend: ChartHeaderProps["legend"] = [ + { + label: hasSplit || !hasHeapLimit ? "memory" : "heap", + color: replicaColors(series, colorMap, colors.foreground.tertiary), + variant: "square", + }, + ]; + if (hasSwap) { + legend.push({ + label: "disk", + color: colors.accent.orange, + variant: "square", + }); + } + if (ramLimit !== undefined) { + legend.push({ + label: limitLabel("memory limit", heapLimitBytes, ramLimit), + color: colors.accent.orange, + variant: "dashed", + }); + } + legend.push({ + label: limitLabel( + hasHeapLimit ? "heap limit" : "memory limit", + heapLimitBytes, + 100, + ), + color: colors.accent.red, + variant: "dashed", + }); + + return ( + + + + + + {hoverTimeMs !== undefined && ( + + )} + + + + ); +}; + +export interface TimeAxisProps { + width: number; + xScale: TimeScale; +} + +export const TimeAxis = ({ width, xScale }: TimeAxisProps) => { + const { colors, fonts } = useTheme(); + const [startDate, endDate] = xScale.domain(); + // Evenly spaced and labeled by time before now, matching `UtilizationGraph`. + const tickValues = Array.from( + { length: TIME_TICK_COUNT }, + (_, tickIndex) => + startDate.getTime() + + (tickIndex * (endDate.getTime() - startDate.getTime())) / TIME_TICK_COUNT, + ); + return ( + // Visible overflow keeps the edge labels, centered on the plot's ends, from + // being clipped. The card's padding leaves room for them. + + > + top={0} + scale={xScale} + tickValues={tickValues} + hideTicks + stroke={colors.border.secondary} + strokeWidth={2} + tickFormat={(value) => + relativeTimeTickFormat(value.valueOf(), startDate, endDate) + } + tickLabelProps={() => ({ + fill: colors.foreground.secondary, + fontFamily: fonts.mono, + fontSize: 12, + textAnchor: "middle", + dy: 4, + })} + /> + + ); +}; + +export interface StatusTimelineRow { + id: string; + name: string; + timeline: ReplicaTimeline; + /** Objects the replica has yet to hydrate. */ + unhydratedCount?: number; +} + +// An in-progress hydration has no recorded start, so it's a badge at the now +// edge, not a stretch. +const HydratingBadge = ({ + count, + right, + top, +}: { + count: number; + right: number; + top: number; +}) => { + const { colors, fonts } = useTheme(); + const statusStyles = useStatusStyles(); + const label = `Hydrating · ${count} ${pluralize(count, "object", "objects")}`; + const width = label.length * BADGE_CHAR_PX + 2 * BADGE_PADDING_PX; + return ( + + + + {label} + + + ); +}; + +const StatusRows = React.memo( + ({ + layout, + xScale, + rows, + }: { + layout: Layout; + xScale: TimeScale; + rows: StatusTimelineRow[]; + }) => { + const { colors, fonts } = useTheme(); + const statusStyles = useStatusStyles(); + const [rangeStart, rangeEnd] = layout.xScaleRange; + const rowPitch = STATUS_ROW_HEIGHT_PX + STATUS_ROW_GAP_PX; + return ( + <> + {rows.map((row, rowIndex) => { + const top = rowIndex * rowPitch + STATUS_ROW_GAP_PX / 2; + const center = top + STATUS_ROW_HEIGHT_PX / 2; + return ( + + + {row.name} + + + {placeStatusSegments( + row.timeline.segments, + xScale, + rangeEnd, + MIN_STATUS_SEGMENT_PX, + ).map(({ segment, x, width: segmentWidth, isWidened }) => { + const style = statusStyles[segment.state]; + const rect = { + x, + y: top, + width: segmentWidth, + height: STATUS_ROW_HEIGHT_PX, + rx: 3, + }; + return ( + + {/* Segments are translucent, so a widened one masks the + stretch it overlaps instead of blending with it. */} + {isWidened && ( + + )} + + + ); + })} + {row.timeline.oomAtMs.map((atMs) => ( + + ))} + {row.unhydratedCount !== undefined && + row.unhydratedCount > 0 && + row.timeline.segments.at(-1)?.state !== "offline" && ( + + )} + + ); + })} + + ); + }, +); + +export interface ReplicaStatusTimelineProps { + width: number; + xScale: TimeScale; + rows: StatusTimelineRow[]; + hoverTimeMs?: number; + onPointerMove: PlotProps["onPointerMove"]; + onPointerLeave: PlotProps["onPointerLeave"]; +} + +export const ReplicaStatusTimeline = ({ + width, + xScale, + rows, + hoverTimeMs, + onPointerMove, + onPointerLeave, +}: ReplicaStatusTimelineProps) => { + const { colors } = useTheme(); + const statusStyles = useStatusStyles(); + const height = + Math.max(1, rows.length) * (STATUS_ROW_HEIGHT_PX + STATUS_ROW_GAP_PX); + const layout = React.useMemo( + () => buildXYGraphLayoutProps({ width, height, margin: STATUS_MARGIN }), + [width, height], + ); + + return ( + + ({ + label, + color, + variant: "square" as const, + })), + { + label: "out of memory", + color: colors.accent.red, + variant: "diamond", + }, + ]} + /> + + + {hoverTimeMs !== undefined && ( + + )} + + + + ); +}; + +/** "5 objects created". */ +const changeCountLabel = (count: number) => + `${count} ${pluralize(count, "object", "objects")} created`; + +/** A tick's time, or the span of its events' times. */ +const changeTimeLabel = ({ startMs, endMs }: ChangeTick) => { + if (startMs === endMs) return formatDate(startMs, FRIENDLY_DATETIME_FORMAT); + if (formatDate(startMs, DATE_FORMAT) !== formatDate(endMs, DATE_FORMAT)) { + return `${formatDate(startMs, FRIENDLY_DATETIME_FORMAT)} - ${formatDate(endMs, FRIENDLY_DATETIME_FORMAT)}`; + } + return `${formatDate(startMs, `${DATE_FORMAT} ${TIME_FORMAT}`)}-${formatDate(endMs, `${TIME_FORMAT} z`)}`; +}; + +/** The time, count and objects of a change tick. */ +export const ChangeTooltipContent = ({ tick }: { tick: ChangeTick }) => { + const { colors } = useTheme(); + const rest = tick.events.length - CHANGE_TOOLTIP_OBJECT_LIMIT; + return ( + + + {changeTimeLabel(tick)} + + + {changeCountLabel(tick.events.length)} + + {tick.events + .slice(0, CHANGE_TOOLTIP_OBJECT_LIMIT) + .map(({ id, name, objectType }) => ( + + + {kebabToSentenceCase(objectType)} + + {name} + + ))} + {rest > 0 && ( + + and {rest} more + + )} + + ); +}; + +export interface ChangesLaneProps { + width: number; + xScale: TimeScale; + events: DdlEvent[]; + /** The `startMs` of the tick whose tooltip is open. */ + activeStartMs?: number; + onActivate: (tick: ChangeTick, target: Element) => void; + onDeactivate: () => void; +} + +/** + * A tick where indexes and materialized views were created, taller for more + * objects, in a row that lines up with the status rows. Hovering or focusing a + * tick activates it. + */ +export const ChangesLane = ({ + width, + xScale, + events, + activeStartMs, + onActivate, + onDeactivate, +}: ChangesLaneProps) => { + const { colors, fonts } = useTheme(); + const layout = React.useMemo( + () => + buildXYGraphLayoutProps({ + width, + height: CHANGES_ROW_HEIGHT_PX + STATUS_ROW_GAP_PX, + margin: STATUS_MARGIN, + }), + [width], + ); + const [rangeStart, rangeEnd] = layout.xScaleRange; + const ticks = React.useMemo( + () => + placeChangeTicks(events, xScale, [rangeStart, rangeEnd], CHANGE_MERGE_PX), + [events, xScale, rangeStart, rangeEnd], + ); + const top = STATUS_ROW_GAP_PX / 2; + const bottom = top + CHANGES_ROW_HEIGHT_PX; + + return ( + + + Changes + + + {ticks.map((tick) => { + const x = xScale(tick.atMs); + const height = Math.min( + CHANGES_ROW_HEIGHT_PX, + CHANGE_TICK_MIN_HEIGHT_PX + + (tick.events.length - 1) * CHANGE_TICK_STEP_PX, + ); + const activate = (event: React.SyntheticEvent) => + onActivate(tick, event.currentTarget); + return ( + + + + + ); + })} + + ); +}; diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/processReadingsModel.test.ts b/console/src/platform/clusters/ClusterPage/ResourceUsage/processReadingsModel.test.ts new file mode 100644 index 0000000000000..28a47d16106ea --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/processReadingsModel.test.ts @@ -0,0 +1,263 @@ +// 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 { ProcessMetricsSample } from "~/api/materialize/cluster/replicaProcessMetrics"; + +import { + bucketProcessReadings, + busiestProcess, + processIdsOf, + readProcessSamples, + standsOut, + summarizeProcesses, +} from "./processReadingsModel"; + +const MINUTE = 60_000; +const ALLOCATION = { + processes: 3, + cpuNanoCores: 1e9, + memoryBytes: 1000, + diskBytes: 0, +}; + +const sample = ( + processId: string, + atMinute: number, + overrides: Partial = {}, +): ProcessMetricsSample => ({ + replicaId: "u1", + processId, + occurredAt: new Date(atMinute * MINUTE), + cpuNanoCores: null, + memoryBytes: null, + diskBytes: null, + heapBytes: null, + heapLimit: null, + ...overrides, +}); + +const read = (samples: ProcessMetricsSample[], startMs = 0) => + readProcessSamples({ samples, allocation: ALLOCATION, startMs }); + +describe("readProcessSamples", () => { + it("reads each sample as a share of one process's allocation, from the window's start", () => { + const readings = read( + [ + sample("0", 1, { cpuNanoCores: 2e8 }), + sample("0", 3, { cpuNanoCores: 6e8 }), + ], + 2 * MINUTE, + ); + + expect(readings).toEqual([ + { processId: "0", atMs: 3 * MINUTE, cpu: 60, memory: null }, + ]); + }); + + it("reads memory against the heap limit, not a failed fetch's page cache", () => { + const readings = read([ + sample("0", 0, { heapBytes: 50, heapLimit: 100, memoryBytes: 300 }), + // Usage fetch failed: heap and its limit null, memory near the whole + // allocation. + sample("1", 0, { memoryBytes: 990 }), + ]); + + expect(readings.map(({ memory }) => memory)).toEqual([50, null]); + }); + + it("falls back to the share of the memory allocation without heap limits", () => { + expect(read([sample("0", 0, { memoryBytes: 250 })])[0].memory).toEqual(25); + }); + + it("lists processes numerically", () => { + expect( + processIdsOf(read([sample("10", 0), sample("2", 0), sample("2", 1)])), + ).toEqual(["2", "10"]); + }); +}); + +describe("bucketProcessReadings", () => { + it("keeps each process's peak in a bucket, buckets in time order", () => { + const buckets = bucketProcessReadings( + read([ + sample("0", 3, { cpuNanoCores: 1e8 }), + sample("0", 0, { cpuNanoCores: 2e8 }), + sample("0", 1, { cpuNanoCores: 6e8 }), + sample("1", 1, { cpuNanoCores: 4e8 }), + ]), + 2 * MINUTE, + ); + + expect( + buckets.map(({ bucketStart, readings }) => [ + bucketStart / MINUTE, + readings.get("0")?.cpu, + readings.get("1")?.cpu, + ]), + ).toEqual([ + [0, 60, 40], + [2, 10, undefined], + ]); + }); + + it("starts the first bucket at the window's start, with only the readings since", () => { + const buckets = bucketProcessReadings( + read([ + sample("0", 1, { cpuNanoCores: 9e8 }), + sample("0", 3, { cpuNanoCores: 1e8 }), + sample("0", 5, { cpuNanoCores: 2e8 }), + ]), + 4 * MINUTE, + 3 * MINUTE, + ); + + expect( + buckets.map(({ bucketStart, bucketEnd, readings }) => [ + bucketStart / MINUTE, + bucketEnd / MINUTE, + readings.get("0")?.cpu, + ]), + ).toEqual([ + [3, 4, 10], + [4, 8, 20], + ]); + }); +}); + +describe("summarizeProcesses and busiestProcess", () => { + const readings = read([ + sample("0", 0, { cpuNanoCores: 3e8 }), + sample("1", 0, { cpuNanoCores: 9e8 }), + sample("2", 0, { cpuNanoCores: 3e8 }), + sample("0", 2, { cpuNanoCores: 1e8 }), + sample("1", 2, { cpuNanoCores: 7e8 }), + // Process 2's fetch failed here. + sample("2", 2), + ]); + const summaries = summarizeProcesses(processIdsOf(readings), readings); + + it("reads each process's newest-round, peak and mean readings over the samples it reported", () => { + expect(summaries.map(({ processId, cpu }) => [processId, cpu])).toEqual([ + ["0", { current: 10, peak: 30, average: 20 }], + ["1", { current: 70, peak: 90, average: 80 }], + ["2", { current: null, peak: 30, average: 30 }], + ]); + }); + + it("names the process with the highest statistic against the others", () => { + expect(busiestProcess(summaries, "cpu", "average")).toEqual({ + processId: "1", + value: 80, + othersMean: 25, + otherCount: 2, + }); + expect(busiestProcess(summaries, "cpu", "current")).toEqual({ + processId: "1", + value: 70, + othersMean: 10, + otherCount: 1, + }); + }); + + it("reads no current value for a process that is down in the newest round", () => { + // Process 1 read 94% until it went down, and its rows since are null. + const downReadings = read([ + sample("0", 0, { cpuNanoCores: 3e8 }), + sample("1", 0, { cpuNanoCores: 9.4e8 }), + sample("0", 1, { cpuNanoCores: 3e8 }), + sample("1", 1), + ]); + const downSummaries = summarizeProcesses( + processIdsOf(downReadings), + downReadings, + ); + + expect(downSummaries.map(({ cpu }) => cpu.current)).toEqual([30, null]); + expect(busiestProcess(downSummaries, "cpu", "current")).toBeUndefined(); + expect(busiestProcess(downSummaries, "cpu", "peak")).toMatchObject({ + processId: "1", + value: 94, + }); + }); + + it("names no process when fewer than two reported", () => { + expect(busiestProcess(summaries, "memory", "peak")).toBeUndefined(); + }); + + it("names a climbing process by its latest and peak readings, though its mean matches a steady one", () => { + // Process 0 climbs from 10% to 90%, process 1 holds 50%: equal means. + const climb = [1e8, 3e8, 5e8, 7e8, 9e8].flatMap((cpuNanoCores, minute) => [ + sample("0", minute, { cpuNanoCores }), + sample("1", minute, { cpuNanoCores: 5e8 }), + ]); + const climbReadings = read(climb); + const climbSummaries = summarizeProcesses( + processIdsOf(climbReadings), + climbReadings, + ); + + expect(busiestProcess(climbSummaries, "cpu", "average")).toMatchObject({ + value: 50, + othersMean: 50, + }); + expect(busiestProcess(climbSummaries, "cpu", "current")).toMatchObject({ + processId: "0", + value: 90, + othersMean: 50, + }); + expect(busiestProcess(climbSummaries, "cpu", "peak")).toMatchObject({ + processId: "0", + value: 90, + othersMean: 50, + }); + }); + + it("names the steady process over a bursty one on average however wide the buckets", () => { + // A day of per-minute samples, as the 24h window buckets them: process 0 + // bursts to 100% one minute in twenty and idles, process 1 holds 40%. + const day = Array.from({ length: 24 * 60 }, (_, minute) => [ + sample("0", minute, { cpuNanoCores: minute % 20 === 0 ? 1e9 : 0 }), + sample("1", minute, { cpuNanoCores: 4e8 }), + ]).flat(); + const dayReadings = read(day); + const daySummaries = summarizeProcesses( + processIdsOf(dayReadings), + dayReadings, + ); + + expect(busiestProcess(daySummaries, "cpu", "average")?.processId).toEqual( + "1", + ); + expect(daySummaries[0].cpu).toMatchObject({ average: 5, peak: 100 }); + }); +}); + +describe("standsOut", () => { + const busiest = (value: number, othersMean: number) => ({ + processId: "0", + value, + othersMean, + otherCount: 1, + }); + + it("reads idle processes as even", () => { + expect(standsOut(busiest(0, 0))).toBe(false); + }); + + it("reads a near-idle lead as even, however large its ratio", () => { + expect(standsOut(busiest(0.3, 0.1))).toBe(false); + }); + + it("needs a lead to clear both the ratio and the gap", () => { + expect(standsOut(busiest(10, 0))).toBe(true); + expect(standsOut(busiest(90, 30))).toBe(true); + // 10 points ahead, but only 1.2× the others. + expect(standsOut(busiest(60, 50))).toBe(false); + }); +}); diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/processReadingsModel.ts b/console/src/platform/clusters/ClusterPage/ResourceUsage/processReadingsModel.ts new file mode 100644 index 0000000000000..2d83d20e4dab6 --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/processReadingsModel.ts @@ -0,0 +1,210 @@ +// 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 { greatest, group, max, mean } from "d3"; + +import { + ProcessMetricsSample, + SizeAllocation, +} from "~/api/materialize/cluster/replicaProcessMetrics"; + +export type ProcessMetric = "cpu" | "memory"; + +export type ProcessReading = Record; + +export interface ProcessBucket { + bucketStart: number; + bucketEnd: number; + /** Each reporting process's peak reading in the bucket, by process id. */ + readings: Map; +} + +const percentOf = (value: number | null, total: number | null) => + value === null || !total ? null : (value / total) * 100; + +const greatestOf = (a: number | null | undefined, b: number | null) => + a === null || a === undefined ? b : b === null ? a : Math.max(a, b); + +export interface ProcessSampleReading extends ProcessReading { + processId: string; + atMs: number; +} + +/** Whether memory reads against the heap limit, not the memory allocation. */ +export const reportsHeapLimit = (samples: ProcessMetricsSample[]) => + samples.some(({ heapLimit }) => heapLimit !== null); + +/** + * Each sample from `startMs` on, in percent of one process's allocation: CPU + * of its CPU allocation, memory of its heap limit, or of its memory allocation + * where the replica never reports one. + */ +export function readProcessSamples({ + samples, + allocation, + startMs, +}: { + samples: ProcessMetricsSample[]; + allocation: SizeAllocation; + startMs: number; +}): ProcessSampleReading[] { + const hasHeapLimit = reportsHeapLimit(samples); + return samples.flatMap((sample) => { + const atMs = sample.occurredAt.getTime(); + if (atMs < startMs) return []; + return [ + { + processId: sample.processId, + atMs, + cpu: percentOf(sample.cpuNanoCores, allocation.cpuNanoCores), + // NOTE: a failed usage fetch leaves heap and its limit null while the + // memory reading survives, near the whole allocation since it counts + // page cache. Falling back to it there would show a false near-OOM + // process. + memory: hasHeapLimit + ? percentOf(sample.heapBytes, sample.heapLimit) + : percentOf(sample.memoryBytes, allocation.memoryBytes), + }, + ]; + }); +} + +/** The processes with a sample, in numeric order. */ +export const processIdsOf = (readings: ProcessSampleReading[]) => + [...new Set(readings.map(({ processId }) => processId))].sort( + (a, b) => Number(a) - Number(b), + ); + +/** + * Each bucket's peak reading per process from `startMs` on, in time order. + * Buckets align to the epoch, so the first can begin before `startMs`; it + * begins at `startMs` instead, which keeps it inside the window's time axis. + */ +export function bucketProcessReadings( + readings: ProcessSampleReading[], + bucketSizeMs: number, + startMs = Number.NEGATIVE_INFINITY, +): ProcessBucket[] { + const byBucket = new Map>(); + for (const { processId, atMs, cpu, memory } of readings) { + if (atMs < startMs) continue; + const bucketStart = Math.floor(atMs / bucketSizeMs) * bucketSizeMs; + const peaks = byBucket.get(bucketStart) ?? new Map(); + const previous = peaks.get(processId); + peaks.set(processId, { + cpu: greatestOf(previous?.cpu, cpu), + memory: greatestOf(previous?.memory, memory), + }); + byBucket.set(bucketStart, peaks); + } + return [...byBucket] + .sort(([a], [b]) => a - b) + .map(([bucketStart, peaks]) => ({ + bucketStart: Math.max(bucketStart, startMs), + bucketEnd: bucketStart + bucketSizeMs, + readings: peaks, + })); +} + +/** Which reading the drawer compares processes by. */ +export type ProcessStatistic = "current" | "peak" | "average"; + +export interface ProcessStats { + /** + * The reading in the newest round of samples. Null when the process has + * none there, as when it's down. + */ + current: number | null; + peak: number | null; + average: number | null; +} + +export interface ProcessSummary { + processId: string; + cpu: ProcessStats; + memory: ProcessStats; +} + +/** + * Each process's latest reading, peak and mean over its samples. Bucket peaks + * would inflate a bursty process's mean more the wider the window's buckets. + */ +export function summarizeProcesses( + processIds: string[], + readings: ProcessSampleReading[], +): ProcessSummary[] { + // The controller stamps every process's sample in a round with one time, + // and writes nulls for a process it couldn't read (`record_replica_metrics` + // in mz-controller), so a down process's last reading is older than this. + const latestRoundMs = max(readings, ({ atMs }) => atMs); + const readingsByProcess = group(readings, ({ processId }) => processId); + const stats = ( + own: ProcessSampleReading[], + metric: ProcessMetric, + ): ProcessStats => { + const reported = own.flatMap((reading) => { + const value = reading[metric]; + return value === null ? [] : [{ atMs: reading.atMs, value }]; + }); + return { + current: + reported.find(({ atMs }) => atMs === latestRoundMs)?.value ?? null, + peak: max(reported, ({ value }) => value) ?? null, + average: mean(reported, ({ value }) => value) ?? null, + }; + }; + return processIds.map((processId) => { + const own = readingsByProcess.get(processId) ?? []; + return { processId, cpu: stats(own, "cpu"), memory: stats(own, "memory") }; + }); +} + +export interface BusiestProcess { + processId: string; + value: number; + /** The mean of the other processes' values. */ + othersMean: number; + otherCount: number; +} + +// A busiest process within this factor of the others' mean reads as even. +const EVEN_RATIO = 1.25; +// Near idle, a gap too small to matter is still a large ratio (0.3% against +// 0.1% is 3×), so a lead also has to clear this many percentage points. +const MIN_LEAD_POINTS = 5; + +/** Whether the busiest process leads the others by enough to name it. */ +export const standsOut = (busiest: BusiestProcess | undefined) => + busiest !== undefined && + busiest.value - busiest.othersMean >= MIN_LEAD_POINTS && + busiest.value >= busiest.othersMean * EVEN_RATIO; + +/** + * The process with the highest `statistic` for `metric`, against the others. + * Undefined unless at least two processes reported it. + */ +export function busiestProcess( + summaries: ProcessSummary[], + metric: ProcessMetric, + statistic: ProcessStatistic, +): BusiestProcess | undefined { + const reported = summaries.flatMap(({ processId, [metric]: stats }) => { + const value = stats[statistic]; + return value === null ? [] : [{ processId, value }]; + }); + const busiest = greatest(reported, ({ value }) => value); + if (!busiest || reported.length < 2) return undefined; + const others = reported.filter((process) => process !== busiest); + return { + processId: busiest.processId, + value: busiest.value, + othersMean: mean(others, ({ value }) => value) ?? 0, + otherCount: others.length, + }; +} diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageModel.test.ts b/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageModel.test.ts index 47973bccd351a..f2c3e9db000ba 100644 --- a/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageModel.test.ts +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageModel.test.ts @@ -13,13 +13,18 @@ import { assignReplicaColors, canSwap, datumAt, + groupReplicasByName, hasOomBetween, HydrationEpisode, memoryBar, mergeBuckets, mergedBucketMs, + mergeTimelines, + mostBarsAtOnce, OOM_REASON, paintObjectHydration, + placeChangeTicks, + placeStatusSegments, ramLimitPercent, replicasRestartedSince, sharedHeapLimitBytes, @@ -579,3 +584,197 @@ describe("transformDdlEvents", () => { ]); }); }); + +describe("placeChangeTicks", () => { + const created = (id: string, occurredAtMs: number) => ({ + id, + name: id, + objectType: "index", + occurredAtMs, + }); + // One pixel per millisecond. + const place = (events: ReturnType[]) => + placeChangeTicks(events, (timeMs) => timeMs, [0, 1000], 8); + + it("merges each creation less than the threshold after the one before it", () => { + const ticks = place([ + created("a", 100), + created("b", 105), + created("c", 112), + created("d", 120), + ]); + + expect( + ticks.map(({ startMs, endMs, atMs, events }) => ({ + startMs, + endMs, + atMs, + ids: events.map(({ id }) => id), + })), + ).toEqual([ + { startMs: 100, endMs: 112, atMs: 106, ids: ["a", "b", "c"] }, + { startMs: 120, endMs: 120, atMs: 120, ids: ["d"] }, + ]); + }); + + it("drops creations outside the plot", () => { + expect( + place([created("a", -1), created("b", 500), created("c", 1001)]).map( + ({ events }) => events.map(({ id }) => id), + ), + ).toEqual([["b"]]); + }); + + it("places no ticks without creations", () => { + expect(place([])).toEqual([]); + }); +}); + +describe("placeStatusSegments", () => { + const segments = [ + { state: "running" as const, startMs: 0, endMs: 100 }, + { state: "hydrating" as const, startMs: 100, endMs: 101 }, + { state: "running" as const, startMs: 101, endMs: 199 }, + { state: "offline" as const, startMs: 199, endMs: 200 }, + ]; + + it("widens brief stretches and draws them over the others", () => { + const placed = placeStatusSegments(segments, (timeMs) => timeMs, 200, 4); + + expect( + placed.map(({ segment, x, width }) => [segment.state, x, width]), + ).toEqual([ + ["running", 0, 100], + ["running", 101, 98], + ["hydrating", 100, 4], + // Kept inside the plot's right edge. + ["offline", 196, 4], + ]); + }); +}); + +describe("groupReplicasByName", () => { + const replica = ( + id: string, + name: string, + isCurrent = false, + clusterId = "u9", + ) => ({ + id, + name, + clusterId, + isCurrent, + }); + + it("groups a scheduled cluster's refreshes under its current replica", () => { + const groups = groupReplicasByName( + [replica("u3", "r1", true), replica("u1", "r1"), replica("u2", "r1")], + new Map([ + ["u1", 100], + ["u2", 200], + ]), + ); + + expect([...groups]).toEqual([ + ["u3", "u3"], + ["u1", "u3"], + ["u2", "u3"], + ]); + }); + + it("keys dropped replicas by the one that reported last", () => { + const groups = groupReplicasByName( + [replica("u1", "r1"), replica("u2", "r1"), replica("u4", "r2")], + new Map([ + ["u1", 100], + ["u2", 200], + ["u4", 50], + ]), + ); + + expect([...groups]).toEqual([ + ["u1", "u2"], + ["u2", "u2"], + ["u4", "u4"], + ]); + }); + + it("keeps a past blue-green deployment's replica apart from the current one", () => { + const groups = groupReplicasByName( + [replica("u3", "r1", true), replica("u1", "r1", false, "u8")], + new Map([["u1", 100]]), + ); + + expect([...groups]).toEqual([ + ["u3", "u3"], + ["u1", "u1"], + ]); + }); +}); + +describe("mergeTimelines", () => { + it("joins replicas that ran one after another into one row", () => { + const timeline = mergeTimelines([ + { + segments: [{ state: "running", startMs: 40, endMs: 60 }], + oomAtMs: [55], + }, + { + segments: [{ state: "running", startMs: 0, endMs: 20 }], + oomAtMs: [], + }, + ]); + + expect(timeline).toEqual({ + segments: [ + { state: "running", startMs: 0, endMs: 20 }, + { state: "running", startMs: 40, endMs: 60 }, + ], + oomAtMs: [55], + }); + }); + + it("lets the later replica's segment win where two overlap", () => { + const timeline = mergeTimelines([ + { + segments: [{ state: "running", startMs: 0, endMs: 50 }], + oomAtMs: [], + }, + { + segments: [{ state: "offline", startMs: 30, endMs: 60 }], + oomAtMs: [], + }, + ]); + + expect(timeline.segments).toEqual([ + { state: "running", startMs: 0, endMs: 30 }, + { state: "offline", startMs: 30, endMs: 60 }, + ]); + }); +}); + +describe("mostBarsAtOnce", () => { + const series = (id: string, bucketStarts: number[]) => ({ + id, + data: bucketStarts.map((bucketStart) => + mkPoint({ bucketStart, bucketEnd: bucketStart + 60, heapPercent: 50 }), + ), + }); + + it("counts only replicas with a bar at the same time", () => { + expect( + mostBarsAtOnce([ + series("u1", [0, 60]), + series("u2", [120, 180]), + series("u3", [240]), + ]), + ).toEqual(1); + expect( + mostBarsAtOnce([series("u1", [0, 60]), series("u2", [60, 120])]), + ).toEqual(2); + }); + + it("is 1 without any bar", () => { + expect(mostBarsAtOnce([])).toEqual(1); + }); +}); diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageModel.ts b/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageModel.ts index be11ef2b048c4..b70f365a4b961 100644 --- a/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageModel.ts +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageModel.ts @@ -11,7 +11,10 @@ import { greatest, max, sort } from "d3"; import { millisecondsInHour, millisecondsInMinute } from "date-fns/constants"; import { OOM_REASON } from "~/api/materialize/cluster/replicaStatusTimeline"; -import { DataPoint } from "~/platform/clusters/ClusterOverview/types"; +import { + DataPoint, + ReplicaData, +} from "~/platform/clusters/ClusterOverview/types"; export { OOM_REASON }; @@ -277,6 +280,110 @@ export function transformDdlEvents( return sort(events, (event) => event.occurredAtMs); } +export interface ChangeTick { + /** The middle of its events' span, where the tick is drawn. */ + atMs: number; + startMs: number; + endMs: number; + events: DdlEvent[]; +} + +/** + * The Changes row's ticks for `events`, oldest first, that land in + * `[rangeStartPx, rangeEndPx]`. An event less than `mergePx` after the one + * before it joins that one's tick, so no two ticks are closer than `mergePx`. + */ +export function placeChangeTicks( + events: DdlEvent[], + xOf: (timeMs: number) => number, + [rangeStartPx, rangeEndPx]: [number, number], + mergePx: number, +): ChangeTick[] { + const ticks: Array> = []; + let lastX = Number.NEGATIVE_INFINITY; + for (const event of events) { + const x = xOf(event.occurredAtMs); + if (x < rangeStartPx || x > rangeEndPx) continue; + const open = ticks.at(-1); + if (open && x - lastX < mergePx) { + open.events.push(event); + open.endMs = event.occurredAtMs; + } else { + ticks.push({ + startMs: event.occurredAtMs, + endMs: event.occurredAtMs, + events: [event], + }); + } + lastX = x; + } + return ticks.map((tick) => ({ + ...tick, + atMs: (tick.startMs + tick.endMs) / 2, + })); +} + +/** + * Where to draw a timeline's segments, each at least `minWidthPx` wide so a + * brief stretch, such as a hydration after a restart, stays visible. A widened + * segment keeps its start unless that would cross `rangeEndPx`, and comes last + * so the stretch after it doesn't cover it. Only the drawing widens: `stateAt` + * still reads the true times. + */ +export function placeStatusSegments( + segments: StatusSegment[], + xOf: (timeMs: number) => number, + rangeEndPx: number, + minWidthPx: number, +) { + const placed = segments.map((segment) => { + const x = xOf(segment.startMs); + const width = xOf(segment.endMs) - x; + return width >= minWidthPx + ? { segment, x, width, isWidened: false } + : { + segment, + x: Math.min(x, rangeEndPx - minWidthPx), + width: minWidthPx, + isWidened: true, + }; + }); + return [ + ...placed.filter(({ isWidened }) => !isWidened), + ...placed.filter(({ isWidened }) => isWidened), + ]; +} + +/** + * Why a window has no readings. A down replica and missing metrics collection + * both report empty readings, so replica status tells them apart: + * `unmeasured` when a replica was up, `offlineOom` or `offline` when every + * replica was down (by the last offline reason), and `unknown` without status. + */ +export type MissingUsageCause = + "unmeasured" | "offlineOom" | "offline" | "unknown"; + +export function missingUsageCause({ + timelines, + transitions, + isStatusError, +}: { + timelines: ReplicaTimeline[]; + transitions: StatusTransition[]; + isStatusError: boolean; +}): MissingUsageCause { + const states = timelines.flatMap(({ segments }) => + segments.map(({ state }) => state), + ); + if (isStatusError || states.length === 0) return "unknown"; + if (states.some((state) => state !== "offline")) return "unmeasured"; + const lastOffline = greatest( + transitions.filter(({ status }) => status !== "online"), + (transition) => transition.occurredAt.getTime(), + ); + return lastOffline?.reason === OOM_REASON ? "offlineOom" : "offline"; +} + export const stateAt = (timeline: ReplicaTimeline, timeMs: number) => timeline.segments.find( (segment) => segment.startMs <= timeMs && timeMs < segment.endMs, @@ -334,6 +441,94 @@ export function mergeBuckets( }); } +/** + * Each replica's group, keyed by the id that stands for its cluster and name: + * the current replica of that name, else the one that reported last. A + * scheduled cluster gets a new replica id under the same name on every + * refresh. A cluster holds one live replica per name (`replica_id_by_name_` in + * the catalog), so a name's replicas in one cluster ran one after another + * unless one was renamed. A past blue-green deployment is another cluster, and + * its replicas run alongside the current ones while those hydrate, so they + * keep their own groups. + */ +export function groupReplicasByName( + replicas: Array<{ + id: string; + name: string; + clusterId: string; + isCurrent: boolean; + }>, + lastReportedMs: Map, +) { + const groupKey = ({ clusterId, name }: (typeof replicas)[number]) => + `${clusterId}\0${name}`; + const membersByKey = new Map(); + for (const replica of replicas) { + membersByKey.set(groupKey(replica), [ + ...(membersByKey.get(groupKey(replica)) ?? []), + replica, + ]); + } + const groupIdByKey = new Map( + [...membersByKey].map(([key, members]) => [ + key, + ( + members.find((replica) => replica.isCurrent) ?? + greatest(members, (replica) => lastReportedMs.get(replica.id) ?? 0) ?? + members[0] + ).id, + ]), + ); + return new Map( + replicas.map((replica) => [ + replica.id, + groupIdByKey.get(groupKey(replica)) ?? replica.id, + ]), + ); +} + +/** + * One status timeline for replicas that ran one after another. Where they + * overlap, the later segment wins. + */ +export function mergeTimelines(timelines: ReplicaTimeline[]): ReplicaTimeline { + if (timelines.length === 1) return timelines[0]; + const segments = sort( + timelines.flatMap((timeline) => timeline.segments), + (segment) => segment.startMs, + ).map((segment, index, sorted) => ({ + ...segment, + endMs: Math.min(segment.endMs, sorted[index + 1]?.startMs ?? Infinity), + })); + return { + segments: mergeAdjacent( + segments.filter((segment) => segment.endMs > segment.startMs), + ), + oomAtMs: sort(timelines.flatMap((timeline) => timeline.oomAtMs)), + }; +} + +/** + * The series with a memory bar in each bucket, in series order, so bars at one + * time share only that time's slot. + */ +export function seriesWithBarAt(series: ReplicaData[]) { + const idsByBucketStart = new Map(); + for (const { id, data } of series) { + for (const point of data) { + if (!memoryBar(point)) continue; + const ids = idsByBucketStart.get(point.bucketStart); + if (ids) ids.push(id); + else idsByBucketStart.set(point.bucketStart, [id]); + } + } + return idsByBucketStart; +} + +/** The most memory bars at one time, at least 1. */ +export const mostBarsAtOnce = (series: ReplicaData[]) => + max([...seriesWithBarAt(series).values()], (ids) => ids.length) ?? 1; + const BUCKET_WIDTHS_MS = [ ...[1, 2, 3, 5, 6, 10, 15, 20, 30].map( (minutes) => minutes * millisecondsInMinute, @@ -344,10 +539,10 @@ const BUCKET_WIDTHS_MS = [ ]; /** - * The bucket width that gives each replica's bar at least `minSlotPx`: the - * source width when that already does, else the narrowest round width that - * is a multiple of it. Capped at the widest multiple of the source width that - * fits in the domain. + * The bucket width that gives each bar at least `minSlotPx` when + * `replicaCount` bars share a time: the source width when that already does, + * else the narrowest round width that is a multiple of it. Capped at the + * widest multiple of the source width that fits in the domain. */ export function mergedBucketMs({ sourceBucketMs, diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageStyles.ts b/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageStyles.ts new file mode 100644 index 0000000000000..641b61afbb8a9 --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/resourceUsageStyles.ts @@ -0,0 +1,75 @@ +// 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 { useTheme } from "@chakra-ui/react"; +import { scaleLinear } from "@visx/scale"; + +import { MaterializeTheme } from "~/theme"; +import { buildXYGraphLayoutProps } from "~/utils/graph"; + +import { ReplicaState } from "./resourceUsageModel"; + +/** Shared by every chart so their plot areas line up on one time axis. */ +export const CHART_MARGIN = { top: 24, right: 16, bottom: 8, left: 56 }; + +export const PERCENT_TICKS = [0, 25, 50, 75, 100]; + +const OVERFLOW_TICK_COUNT = 4; + +/** + * A 0 to 100% scale, extended to `peak` with nice ticks when a reading passes + * 100%. + */ +export const percentScale = ( + peak: number, + layout: ReturnType, +) => { + if (peak <= 100) { + return { + yScale: scaleLinear({ + domain: [0, 100], + range: layout.yScaleRange, + clamp: true, + }), + ticks: PERCENT_TICKS, + }; + } + const yScale = scaleLinear({ + domain: [0, peak], + range: layout.yScaleRange, + nice: OVERFLOW_TICK_COUNT, + }); + return { yScale, ticks: yScale.ticks(OVERFLOW_TICK_COUNT) }; +}; + +/** + * Wide windows merge buckets until a full bucket spans at least this many + * pixels per bar charted at one time. + */ +export const MIN_BAR_SLOT_PX = 10; + +export const useStatusStyles = () => { + const { colors } = useTheme(); + return { + running: { label: "running", color: colors.accent.green, opacity: 0.45 }, + hydrating: { + label: "hydrating", + color: colors.accent.darkYellow, + opacity: 0.7, + }, + offline: { + label: "offline", + color: colors.foreground.tertiary, + opacity: 0.45, + }, + } satisfies Record< + ReplicaState, + { label: string; color: string; opacity: number } + >; +}; diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/useReplicaProcessSamples.ts b/console/src/platform/clusters/ClusterPage/ResourceUsage/useReplicaProcessSamples.ts new file mode 100644 index 0000000000000..d9ebfe0110dd0 --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/useReplicaProcessSamples.ts @@ -0,0 +1,47 @@ +// 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 React from "react"; + +import { + buildReplicaProcessMetricsSubscribe, + processSampleKey, +} from "~/api/materialize/cluster/replicaProcessMetrics"; +import { useSubscribe } from "~/api/materialize/useSubscribe"; +import { useRollingWindowStartMs } from "~/platform/clusters/queries"; + +/** + * A replica's per-process samples over at least the last `windowMinutes`, + * streamed while the calling component is mounted. + */ +export function useReplicaProcessSamples({ + replicaId, + windowMinutes, +}: { + replicaId: string; + windowMinutes: number; +}) { + const startMs = useRollingWindowStartMs(windowMinutes); + const subscribe = React.useMemo( + () => + startMs === undefined + ? undefined + : buildReplicaProcessMetricsSubscribe({ + replicaIds: [replicaId], + since: new Date(startMs), + }), + [replicaId, startMs], + ); + const { data, snapshotComplete, error } = useSubscribe({ + subscribe, + upsertKey: (row) => processSampleKey(row.data), + select: (row) => row.data, + }); + return { data, snapshotComplete, error }; +} diff --git a/console/src/platform/clusters/ClusterPage/ResourceUsage/useResourceUsageData.ts b/console/src/platform/clusters/ClusterPage/ResourceUsage/useResourceUsageData.ts new file mode 100644 index 0000000000000..20a963c5dac54 --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ResourceUsage/useResourceUsageData.ts @@ -0,0 +1,298 @@ +// 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 { useTheme } from "@chakra-ui/react"; +import { group } from "d3"; +import React from "react"; + +import { Cluster } from "~/api/materialize/cluster/clusterList"; +import { hasUtilizationReading } from "~/api/materialize/cluster/replicaUtilizationBinning"; +import { ReplicaData } from "~/platform/clusters/ClusterOverview/types"; +import { MIN_BUCKET_SIZE_MS } from "~/platform/clusters/constants"; +import { + REPLICA_MEMORY_BREAKDOWN_MIN_VERSION, + replicaStatusesUpdatedAt, + useObjectCreationTimes, + useObjectHydrationStretches, + useReplicaHeapLimits, + useReplicaHydrationEpisodes, + useReplicaStatusHistory, + useReplicaUtilizationHistory, + useUnhydratedObjectCounts, +} from "~/platform/clusters/queries"; +import { useClusterObjectsLive } from "~/store/allObjectsCollection"; +import { useEnvironmentGate } from "~/store/environments"; +import { MaterializeTheme } from "~/theme"; + +import { + assignReplicaColors, + groupReplicasByName, + mergeTimelines, + missingUsageCause, + paintObjectHydration, + replicasRestartedSince, + ReplicaTimeline, + sortReplicasForDisplay, + transformDdlEvents, + transformReplicaTimeline, +} from "./resourceUsageModel"; + +const DDL_OBJECT_TYPES = ["index", "materialized-view"]; +const EMPTY_SERIES: ReplicaData[] = []; + +export interface ColoredReplica { + id: string; + name: string; + size?: string; + isCurrent: boolean; + color: string; +} + +/** Charted replicas, then current replicas with no data yet, current first. */ +const summarizeReplicas = (cluster: Cluster, graphData: ReplicaData[]) => { + const currentIds = new Set(cluster.replicas.map((replica) => replica.id)); + const charted = graphData.map(({ id, data }) => { + const latest = data.at(-1); + return { + id, + name: latest?.name ?? id, + clusterId: latest?.clusterId ?? cluster.id, + size: latest?.size ?? undefined, + isCurrent: currentIds.has(id), + }; + }); + const chartedIds = new Set(charted.map((replica) => replica.id)); + const uncharted = cluster.replicas + .filter((replica) => !chartedIds.has(replica.id)) + .map(({ id, name, size }) => ({ + id, + name, + clusterId: cluster.id, + size: size ?? undefined, + isCurrent: true, + })); + return sortReplicasForDisplay([...charted, ...uncharted]); +}; + +/** + * Loads and derives what the resource usage card draws for a cluster. Covers + * every charted replica, and leaves which ones are visible to the caller. + */ +export function useResourceUsageData({ + cluster, + timePeriodMinutes, +}: { + cluster: Cluster; + timePeriodMinutes: number; +}) { + const { colors } = useTheme(); + // The status timeline reads offline stretches from status history. + const history = useReplicaUtilizationHistory( + { + bucketSizeMs: Math.max(timePeriodMinutes * 1000, MIN_BUCKET_SIZE_MS), + timePeriodMinutes, + clusterIds: [cluster.id], + replicaId: undefined, + includeMemoryBreakdown: true, + }, + { includeOfflineEvents: false }, + ); + const graphData = history.data?.graphData; + + // Replicas that share a name, such as a scheduled cluster's refreshes, share + // a color, a toggle and a status row, keyed by the id standing for the name. + const { replicasById, replicaGroups, groupIdById, colorMap } = + React.useMemo(() => { + const replicas = summarizeReplicas(cluster, graphData ?? []); + const lastReportedMs = new Map( + (graphData ?? []).map(({ id, data }) => [ + id, + data.at(-1)?.bucketEnd ?? 0, + ]), + ); + const groupIds = groupReplicasByName(replicas, lastReportedMs); + const groups = replicas.filter(({ id }) => groupIds.get(id) === id); + const groupColors = assignReplicaColors(groups, colors.lineGraph); + const colorOf = (id: string) => + groupColors.get(groupIds.get(id) ?? id) ?? colors.lineGraph[0]; + const withColor = (replica: (typeof replicas)[number]) => ({ + ...replica, + color: colorOf(replica.id), + }); + return { + replicasById: new Map( + replicas.map((replica): [string, ColoredReplica] => [ + replica.id, + withColor(replica), + ]), + ), + // Display order: current replicas first, then by name. + replicaGroups: groups.map(withColor), + groupIdById: groupIds, + colorMap: new Map(replicas.map(({ id }) => [id, colorOf(id)])), + }; + }, [cluster, graphData, colors.lineGraph]); + // Sorted by id for stable query keys, unlike display order. + const replicaIds = React.useMemo( + () => [...replicasById.keys()].sort(), + [replicasById], + ); + const chartData = graphData ?? EMPTY_SERIES; + + const currentReplicaIds = cluster.replicas + .map((replica) => replica.id) + .sort(); + + const statusHistory = useReplicaStatusHistory( + replicaIds, + replicaStatusesUpdatedAt(cluster.replicas), + ); + // Only a replica that came back online in the window has a hydration the + // timeline can place, so the episodes query skips the rest. + const restartedReplicaIds = history.data + ? replicasRestartedSince( + statusHistory.data?.transitions ?? [], + new Set(currentReplicaIds), + history.data.startDate.getTime(), + ) + : []; + const hydrationEpisodes = useReplicaHydrationEpisodes({ + replicaIds: restartedReplicaIds, + timePeriodMinutes, + }); + const unhydratedObjectCounts = useUnhydratedObjectCounts(currentReplicaIds); + + const { data: clusterObjects, isError: isObjectsError } = + useClusterObjectsLive(cluster.id, DDL_OBJECT_TYPES); + const creationTimes = useObjectCreationTimes({ + objectIds: clusterObjects.map((object) => object.id).sort(), + timePeriodMinutes, + }); + const ddlEvents = React.useMemo( + () => transformDdlEvents(clusterObjects, creationTimes.data ?? []), + [clusterObjects, creationTimes.data], + ); + // Hydrations after a replica's start leave no episode, so objects created in + // the window paint theirs from each replica's per-object hydration times. + const objectHydrations = useObjectHydrationStretches({ + replicaIds: currentReplicaIds, + objects: (creationTimes.data ?? []).flatMap(({ id, occurredAt }) => + id ? [{ id, createdAt: occurredAt }] : [], + ), + }); + + const heapLimits = useReplicaHeapLimits(currentReplicaIds); + // Only running replicas report a heap limit, and replicas of one size share + // it, so a dropped replica's limit comes from a running one of its size. + const heapLimitBytesBySize = new Map( + cluster.replicas.flatMap(({ id, size }) => { + const bytes = heapLimits.data?.get(id); + return size && typeof bytes === "number" ? [[size, bytes] as const] : []; + }), + ); + // A sample without a heap limit has `heap_percent` as a share of the memory + // allocation instead, and no RAM limit share. Only rows on 26.44 and later + // carry that share, so older environments keep the heap labels. + const rowsCarryHeapLimit = + useEnvironmentGate(REPLICA_MEMORY_BREAKDOWN_MIN_VERSION) === true; + const hasHeapLimit = + !rowsCarryHeapLimit || + chartData.some(({ data }) => + data.some((point) => point.ramLimitPercent !== null), + ); + + const timelines = React.useMemo(() => { + const timelinesById = new Map(); + if (!history.data) return timelinesById; + const { startDate, endDate } = history.data; + const dataById = new Map( + history.data.graphData.map(({ id, data }) => [id, data]), + ); + // Every replica gets a row, including one with no samples in the window, + // whose status history may still say it was offline. + for (const id of replicasById.keys()) { + const data = dataById.get(id) ?? []; + const last = data.at(-1); + // A down replica still reports, with empty readings, so only readings + // show it running. + const measured = data.filter(hasUtilizationReading); + const firstMeasured = measured.at(0); + const lastMeasured = measured.at(-1); + const timeline = transformReplicaTimeline({ + transitions: (statusHistory.data?.transitions ?? []).filter( + (transition) => transition.replicaId === id, + ), + oomKills: (statusHistory.data?.oomKills ?? []).filter( + (kill) => kill.replicaId === id, + ), + hydrationEpisodes: (hydrationEpisodes.data ?? []).filter( + (episode) => episode.replicaId === id, + ), + windowStartMs: startDate.getTime(), + // A dropped replica stops reporting, so end its row with its data. + windowEndMs: replicasById.get(id)?.isCurrent + ? endDate.getTime() + : (last?.bucketEnd ?? endDate.getTime()), + sampleRange: + firstMeasured && lastMeasured + ? { + startMs: firstMeasured.bucketStart, + endMs: lastMeasured.bucketEnd, + } + : undefined, + }); + timelinesById.set( + id, + paintObjectHydration(timeline, objectHydrations.data?.get(id) ?? []), + ); + } + return new Map( + [...group([...timelinesById], ([id]) => groupIdById.get(id) ?? id)].map( + ([groupId, entries]) => [ + groupId, + mergeTimelines(entries.map(([, timeline]) => timeline)), + ], + ), + ); + }, [ + history.data, + statusHistory.data, + hydrationEpisodes.data, + objectHydrations.data, + replicasById, + groupIdById, + ]); + const missingUsage = missingUsageCause({ + timelines: [...timelines.values()], + transitions: statusHistory.data?.transitions ?? [], + isStatusError: statusHistory.isError, + }); + + return { + history, + chartData, + replicasById, + replicaGroups, + groupIdById, + colorMap, + timelines, + ddlEvents, + heapLimitBytesBySize, + hasHeapLimit, + unhydratedObjectCounts: unhydratedObjectCounts.data, + missingUsage, + isDdlError: isObjectsError || creationTimes.isError, + isStatusLoading: statusHistory.isLoading, + isStatusError: statusHistory.isError, + isHydrationError: + hydrationEpisodes.isError || + unhydratedObjectCounts.isError || + objectHydrations.isError, + }; +} diff --git a/console/src/platform/clusters/ClusterPage/useSelectedReplica.ts b/console/src/platform/clusters/ClusterPage/useSelectedReplica.ts new file mode 100644 index 0000000000000..7475e55cfc7e9 --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/useSelectedReplica.ts @@ -0,0 +1,43 @@ +// 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 { useSearchParams } from "react-router-dom"; + +import { Cluster } from "~/api/materialize/cluster/clusterList"; + +export const REPLICA_SEARCH_PARAM = "replica"; + +/** + * The replica the Metrics page is narrowed to, by name in the `replica` search + * param, so a narrowed view is a link. Undefined means all replicas, which is + * also what a name no current replica has reads as. The stale name stays in + * the URL, so the selection comes back if a replica of that name does. + */ +export function useSelectedReplica(cluster: Cluster) { + const [searchParams, setSearchParams] = useSearchParams(); + const name = searchParams.get(REPLICA_SEARCH_PARAM); + const selectedReplica = + name === null + ? undefined + : cluster.replicas.find((replica) => replica.name === name); + + /** Undefined selects all replicas. */ + const setSelectedReplicaName = (replicaName: string | undefined) => + setSearchParams( + (prev) => { + if (replicaName === undefined) prev.delete(REPLICA_SEARCH_PARAM); + else prev.set(REPLICA_SEARCH_PARAM, replicaName); + return prev; + }, + // Switching replicas would otherwise fill the back button's history. + { replace: true }, + ); + + return { selectedReplica, setSelectedReplicaName }; +} diff --git a/console/src/platform/clusters/constants.ts b/console/src/platform/clusters/constants.ts index dc346b2151e4e..1311b4b60af56 100644 --- a/console/src/platform/clusters/constants.ts +++ b/console/src/platform/clusters/constants.ts @@ -7,6 +7,9 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. +// because the data is sampled on 60s intervals, we don't want to show more granular data than this. +export const MIN_BUCKET_SIZE_MS = 60 * 1000; + export const CLUSTERS_FETCH_ERROR_MESSAGE = "An error occurred loading cluster data"; diff --git a/console/src/platform/clusters/queries.ts b/console/src/platform/clusters/queries.ts index 0d26a445ce5ff..53df21aa6b2a6 100644 --- a/console/src/platform/clusters/queries.ts +++ b/console/src/platform/clusters/queries.ts @@ -749,7 +749,7 @@ export function useMaterializationLag(params: MaterializationLagParams) { // live SUBSCRIBE (push). Beyond that we poll. Bounds are the views' retentions. const SUBSCRIBE_UNBINNED_MAX_MINUTES = 180; // 3h: live un-binned base, client-binned const SUBSCRIBE_BINNED_MAX_MINUTES = 1440; // 24h: live 5-min binned view -const OVERVIEW_MAX_MINUTES = 20160; // 14d: polled overview view; beyond, ad-hoc +export const OVERVIEW_MAX_MINUTES = 20160; // 14d: polled overview view; beyond, ad-hoc // A blue-green cutover changes a cluster's lineage. Refetching on this interval // bounds how long that takes to reach an open chart. @@ -834,7 +834,7 @@ export function useReplicaOfflineEvents( // TODO: remove these gates once all environments are past them. const UTILIZATION_INDEXED_VIEWS_MIN_VERSION = "26.32.0"; -const REPLICA_MEMORY_BREAKDOWN_MIN_VERSION = "26.44.0"; +export const REPLICA_MEMORY_BREAKDOWN_MIN_VERSION = "26.44.0"; const REPLICA_HYDRATION_HISTORY_MIN_VERSION = "26.43.0"; /** diff --git a/console/src/test/mockSubscribeSocket.ts b/console/src/test/mockSubscribeSocket.ts new file mode 100644 index 0000000000000..2ac4a1646f724 --- /dev/null +++ b/console/src/test/mockSubscribeSocket.ts @@ -0,0 +1,74 @@ +// 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 { ws } from "msw"; + +import { Column, MzDataType } from "~/api/materialize/types"; +import { buildColumn } from "~/api/mocks/buildSqlQueryHandler"; + +export interface SubscribeSnapshot { + columns: Column[]; + /** Values in `columns` order. */ + rows: unknown[][]; +} + +const METADATA_COLUMNS = [ + buildColumn({ name: "mz_timestamp", type_oid: MzDataType.numeric }), + buildColumn({ name: "mz_progressed", type_oid: MzDataType.bool }), + buildColumn({ name: "mz_state", type_oid: MzDataType.text }), +]; + +/** + * Answers each SUBSCRIBE sent over the WebSocket with one snapshot of `upsert` + * rows and the progress row that completes it, then stays silent. + * `snapshotFor` picks a query's rows from its SQL and parameters. A query it + * returns undefined for never completes, like a subscribe still loading. + */ +export function mockSubscribeSocket( + snapshotFor: ( + sql: string, + params: unknown[], + ) => SubscribeSnapshot | undefined, +) { + return ws.link(/^wss?:\/\//).addEventListener("connection", ({ client }) => { + let hasOptions = false; + const send = (message: unknown) => client.send(JSON.stringify(message)); + client.addEventListener("message", (event) => { + // The first message carries the session options. + if (!hasOptions) { + hasOptions = true; + send({ type: "ReadyForQuery", payload: "I" }); + return; + } + const request = JSON.parse(String(event.data)) as { + queries: Array<{ query: string; params?: unknown[] }>; + }; + const [query] = request.queries; + const snapshot = snapshotFor(query?.query ?? "", query?.params ?? []); + if (!snapshot) return; + const progressRow = (timestamp: string) => [ + timestamp, + true, + null, + ...snapshot.columns.map(() => null), + ]; + send({ + type: "Rows", + payload: { columns: [...METADATA_COLUMNS, ...snapshot.columns] }, + }); + // The manager counts the snapshot complete at the first progress row + // after a timestamp has opened. + send({ type: "Row", payload: progressRow("1") }); + for (const row of snapshot.rows) { + send({ type: "Row", payload: ["1", false, "upsert", ...row] }); + } + send({ type: "Row", payload: progressRow("2") }); + }); + }); +}