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") }); + }); + }); +}