diff --git a/console/src/api/materialize/cluster/replicaUtilization.test.sql.ts b/console/src/api/materialize/cluster/replicaUtilization.test.sql.ts new file mode 100644 index 0000000000000..d38c294a61904 --- /dev/null +++ b/console/src/api/materialize/cluster/replicaUtilization.test.sql.ts @@ -0,0 +1,35 @@ +// 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 { NUMBER_OR_NULL } from "~/test/sql/matchers"; +import { executeSqlHttp } from "~/test/sql/materializeSqlClient"; + +import { buildReplicaUtilizationQuery } from "./replicaUtilization"; + +describe("buildReplicaUtilizationQuery", () => { + it("runs against the maintained view", async () => { + const query = buildReplicaUtilizationQuery().compile(); + const result = await executeSqlHttp(query); + + // The environment may have no metric samples inside the window yet, so the + // assertion is on the shape rather than on a reading. What this proves is + // that the query plans and executes: in particular that `mz_now()` is + // accepted in the `WHERE` of a one-shot SELECT, and that every column named + // here exists on `mz_console_cluster_utilization_overview_3h`. + for (const row of result.rows) { + expect(row).toEqual({ + replicaId: expect.any(String), + cpuPercent: NUMBER_OR_NULL, + memoryPercent: NUMBER_OR_NULL, + diskPercent: NUMBER_OR_NULL, + heapPercent: NUMBER_OR_NULL, + }); + } + }); +}); diff --git a/console/src/api/materialize/cluster/replicaUtilization.ts b/console/src/api/materialize/cluster/replicaUtilization.ts new file mode 100644 index 0000000000000..6bb942740ef7e --- /dev/null +++ b/console/src/api/materialize/cluster/replicaUtilization.ts @@ -0,0 +1,59 @@ +// 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 { QueryKey } from "@tanstack/react-query"; +import { InferResult, sql } from "kysely"; + +import { executeSqlV2, queryBuilder } from "~/api/materialize"; + +/** + * Peak utilization per replica over the last hour. + * + * Reads the maintained `_3h` view that already backs the cluster detail page's + * utilization charts, so the list and the charts agree and no new arrangement + * is built for the list. + * + * NOTE: the view reports fractions of the replica's allocation, not + * percentages, matching the rest of the utilization-history path. The column + * names carry `percent` because the view's own columns do. + */ +export function buildReplicaUtilizationQuery() { + return ( + queryBuilder + .selectFrom("mz_console_cluster_utilization_overview_3h") + // The view retains three hours; the list reports the last hour of it. The + // `mz_now()` form is what lets the filter bound the read rather than + // filtering after the fact. + .where(sql`mz_now() <= occurred_at + INTERVAL '1 hour'`) + .groupBy("replica_id") + .select([ + "replica_id as replicaId", + sql`MAX(cpu_percent)`.as("cpuPercent"), + sql`MAX(memory_percent)`.as("memoryPercent"), + sql`MAX(disk_percent)`.as("diskPercent"), + sql`MAX(heap_percent)`.as("heapPercent"), + ]) + ); +} + +export type ReplicaUtilization = InferResult< + ReturnType +>[0]; + +/** Fetches last-hour peak utilization for every replica in the environment. */ +export async function fetchReplicaUtilization({ + queryKey, + requestOptions, +}: { + queryKey: QueryKey; + requestOptions?: RequestInit; +}) { + const compiledQuery = buildReplicaUtilizationQuery().compile(); + return executeSqlV2({ queries: compiledQuery, queryKey, requestOptions }); +} diff --git a/console/src/api/materialize/cluster/useLatestOfflineReplica.test.ts b/console/src/api/materialize/cluster/useLatestOfflineReplica.test.ts index 75a034328e52f..6cc8f6c628592 100644 --- a/console/src/api/materialize/cluster/useLatestOfflineReplica.test.ts +++ b/console/src/api/materialize/cluster/useLatestOfflineReplica.test.ts @@ -7,7 +7,95 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. -import { shouldSurfaceOom } from "./useLatestOfflineReplica"; +import { renderHook } from "@testing-library/react"; + +import useLatestOfflineReplica, { + shouldSurfaceOom, +} from "./useLatestOfflineReplica"; + +// The hook reduces the subscribe's per-process rows into one entry per replica. +// Feeding rows directly is the only way to reach that reducer. +const { subscribeRows } = vi.hoisted(() => ({ + subscribeRows: [] as { + data: { + replicaId: string | null; + processId: string; + clusterId: string; + lastOfflineAt: Date | null; + isOom: boolean; + }; + }[], +})); + +vi.mock("../useSubscribe", () => ({ + useSubscribe: () => ({ + data: subscribeRows, + snapshotComplete: true, + resubscribing: false, + isError: false, + error: undefined, + disconnect: vi.fn(), + reset: vi.fn(), + }), +})); + +const offlineRow = ( + replicaId: string | null, + processId: string, + lastOfflineAt: Date | null, + isOom = true, +) => ({ + data: { replicaId, processId, clusterId: "u1", lastOfflineAt, isOom }, +}); + +const renderMap = () => + renderHook(() => useLatestOfflineReplica()).result.current.data; + +describe("useLatestOfflineReplica", () => { + beforeEach(() => { + subscribeRows.length = 0; + }); + + it("keys entries by replica, not by cluster", () => { + const now = new Date(); + subscribeRows.push( + offlineRow("u10", "0", now), + offlineRow("u11", "0", now), + ); + + const map = renderMap(); + + // Both replicas belong to cluster u1. Keying by cluster would collapse + // them into a single entry and lose which replica was affected. + expect([...map.keys()].sort()).toEqual(["u10", "u11"]); + }); + + it("keeps the newest outage when a replica has several processes", () => { + const older = new Date("2024-03-01T08:00:00.000Z"); + const newest = new Date("2024-03-07T09:00:00.000Z"); + // Deliberately out of order: the subscribe does not sort its rows. + subscribeRows.push( + offlineRow("u10", "0", older), + offlineRow("u10", "2", newest), + offlineRow("u10", "1", new Date("2024-03-04T12:00:00.000Z")), + ); + + const map = renderMap(); + + expect(map.size).toBe(1); + expect(map.get("u10")?.lastOfflineAt).toEqual(newest); + }); + + it("skips rows with no replica id or no timestamp", () => { + const now = new Date(); + subscribeRows.push( + offlineRow(null, "0", now), + offlineRow("u12", "0", null), + ); + + expect(renderMap().size).toBe(0); + }); +}); describe("shouldSurfaceOom", () => { it("should return true when lastOomAt is within 15 minutes", () => { diff --git a/console/src/api/materialize/cluster/useLatestOfflineReplica.ts b/console/src/api/materialize/cluster/useLatestOfflineReplica.ts index 544aa706dbaab..8e2663d46e6a2 100644 --- a/console/src/api/materialize/cluster/useLatestOfflineReplica.ts +++ b/console/src/api/materialize/cluster/useLatestOfflineReplica.ts @@ -14,14 +14,14 @@ import React from "react"; import { queryBuilder } from "../db"; import { useSubscribe } from "../useSubscribe"; -type ClusterId = string; +type ReplicaId = string; export type LatestOfflineReplicaInfo = { shouldSurfaceOom: boolean; lastOfflineAt: Date; }; -export type LatestOfflineReplicaMap = Map; +export type LatestOfflineReplicaMap = Map; export type QueryPayload = { mz_state: string; @@ -95,16 +95,22 @@ const useLatestOfflineReplica = () => { }); const result = React.useMemo(() => { - const map = new Map(); + const map: LatestOfflineReplicaMap = new Map(); for (const row of data) { - const { lastOfflineAt: lastOfflineAt, isOom, clusterId } = row.data; - if (!lastOfflineAt) continue; - - const newLatestOfflineReplicaInfo: LatestOfflineReplicaInfo = { - lastOfflineAt: lastOfflineAt, + const { lastOfflineAt, isOom, replicaId } = row.data; + // `replicaId` comes from a left join, so it is nullable in the type even + // though the status filter means a matched row always has one. + if (!lastOfflineAt || !replicaId) continue; + + // The subscribe emits one row per process and does not order them, so + // compare timestamps rather than letting the last row win. + const existing = map.get(replicaId); + if (existing && existing.lastOfflineAt >= lastOfflineAt) continue; + + map.set(replicaId, { + lastOfflineAt, shouldSurfaceOom: isOom && shouldSurfaceOom(new Date(), lastOfflineAt), - }; - map.set(clusterId, newLatestOfflineReplicaInfo); + }); } return map; diff --git a/console/src/api/materialize/query-history/__snapshots__/statementLoggingMaxSampleRate.test.ts.snap b/console/src/api/materialize/query-history/__snapshots__/statementLoggingMaxSampleRate.test.ts.snap new file mode 100644 index 0000000000000..3327f0cf0910b --- /dev/null +++ b/console/src/api/materialize/query-history/__snapshots__/statementLoggingMaxSampleRate.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`queries > buildStatementLoggingMaxSampleRateQuery produces the expected query 1`] = ` +{ + "parameters": [], + "sql": "SHOW statement_logging_max_sample_rate", +} +`; diff --git a/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.test.ts b/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.test.ts new file mode 100644 index 0000000000000..0b8df14f8952d --- /dev/null +++ b/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.test.ts @@ -0,0 +1,20 @@ +// 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 { queryBuilder } from "~/api/materialize"; + +import { buildStatementLoggingMaxSampleRateQuery } from "./statementLoggingMaxSampleRate"; + +describe("queries", () => { + it("buildStatementLoggingMaxSampleRateQuery produces the expected query", () => { + const { sql, parameters } = + buildStatementLoggingMaxSampleRateQuery().compile(queryBuilder); + expect({ sql, parameters }).toMatchSnapshot(); + }); +}); diff --git a/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.ts b/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.ts new file mode 100644 index 0000000000000..b000ae0c5cd54 --- /dev/null +++ b/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.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 { QueryKey } from "@tanstack/react-query"; +import { sql } from "kysely"; + +import { executeSqlV2, queryBuilder } from "~/api/materialize"; + +export const buildStatementLoggingMaxSampleRateQuery = () => { + return sql<{ + statement_logging_max_sample_rate: string; + }>`SHOW statement_logging_max_sample_rate`; +}; + +/** + * Fetches the system-wide cap on the statement logging sample rate. The effective rate is + * `min(session statement_logging_sample_rate, this)`, so a value of `0` means statement + * logging is off for everyone and query history can never have rows. + * + * Returns `null` if the variable could not be read as a number. + */ +export default async function fetchStatementLoggingMaxSampleRate({ + queryKey, + requestOptions, +}: { + queryKey: QueryKey; + requestOptions: RequestInit; +}) { + const compiledQuery = + buildStatementLoggingMaxSampleRateQuery().compile(queryBuilder); + + const response = await executeSqlV2({ + queries: compiledQuery, + queryKey, + requestOptions, + }); + + const rate = parseFloat(response.rows[0]?.statement_logging_max_sample_rate); + + return Number.isFinite(rate) ? rate : null; +} diff --git a/console/src/components/ClusterMetrics.tsx b/console/src/components/ClusterMetrics.tsx index 14414d0d08ce4..a82377fea8b7f 100644 --- a/console/src/components/ClusterMetrics.tsx +++ b/console/src/components/ClusterMetrics.tsx @@ -197,7 +197,7 @@ export const ClusterMetricsInner = ({ )} {metrics.heapPercent && ( - Memory Utilization + Heap Utilization { + const { colors } = useTheme(); + + if (fraction === null || fraction === undefined) { + return ( + + — + + ); + } + + // Shares thresholds and palette with the utilization cards so one reading is + // coloured the same wherever it appears. Thresholds see the unrounded value, + // so a reading just above a boundary is coloured for where it sits rather + // than for its rounded display value. + const barColor = utilizationStatusToColor( + calculateMemDiskUtilizationStatus({ + thresholdPercentages: DEFAULT_THRESHOLD_PERCENTAGES, + peakMemDiskUtilizationPercent: fraction, + }), + colors, + ); + + return ( + + + {formatPercentage(fraction, 1)} + + + + + + ); +}; +export default PercentBar; diff --git a/console/src/components/Table/UniversalTable.test.tsx b/console/src/components/Table/UniversalTable.test.tsx index ea3ddda67cca4..07ce8294e6b21 100644 --- a/console/src/components/Table/UniversalTable.test.tsx +++ b/console/src/components/Table/UniversalTable.test.tsx @@ -56,9 +56,21 @@ const columns = [ }), ]; -const BasicTable = ({ data = testData }: { data?: TestCluster[] }) => { +const BasicTable = ({ + data = testData, + getRowClassName, +}: { + data?: TestCluster[]; + getRowClassName?: (row: Row) => string | undefined; +}) => { const table = useUniversalTable({ data, columns }); - return ; + return ( + + ); }; const SortableTable = ({ @@ -166,6 +178,20 @@ interface TestAccount { clusters?: TestAccount[]; } +/** + * The caret's fallback accessible name, used when the table passes no + * `expandLabel`. Shared by every group row, so lookups must be scoped to a + * single row with `within`. + */ +const CARET_LABEL = "Display child rows"; + +/** The expand caret inside the group row labelled `groupName`. */ +const caretFor = (groupName: string) => { + const groupRow = screen.getByText(groupName).closest("tr"); + if (!groupRow) throw new Error(`no row found for "${groupName}"`); + return within(groupRow).getByRole("button", { name: CARET_LABEL }); +}; + const groupedData: TestAccount[] = [ { name: "account-a", @@ -199,11 +225,13 @@ const GroupedTable = ({ onRowClick, pageSize, rowTestId, + expandLabel, }: { initialExpanded?: true; onRowClick?: (row: TestAccount) => void; pageSize?: number; rowTestId?: (row: Row) => string | undefined; + expandLabel?: (row: Row) => string | undefined; }) => { const table = useUniversalTable({ data: groupedData, @@ -223,6 +251,7 @@ const GroupedTable = ({ data-testid="test-table" onRowClick={onRowClick} rowTestId={rowTestId} + expandLabel={expandLabel} /> @@ -246,6 +275,24 @@ describe("UniversalTable", () => { expect(screen.getByText("default")).toBeInTheDocument(); expect(screen.getByText("prod")).toBeInTheDocument(); }); + + it("applies getRowClassName to the rows it matches", async () => { + // `prod` is the only row with a null size. + await renderComponent( + + row.original.size === null ? "no-size" : undefined + } + />, + ); + + const rowFor = (name: string) => screen.getByText(name).closest("tr"); + expect(rowFor("prod")).toHaveClass("no-size"); + expect(rowFor("analytics")).not.toHaveClass("no-size"); + // Chakra's own generated class has to survive alongside it, or `&.` + // selectors in `rowSx` would not match. + expect(rowFor("prod")?.className).toMatch(/\bcss-/); + }); }); describe("Sorting", () => { @@ -447,16 +494,58 @@ describe("UniversalTable", () => { }); }); + describe("Caret column", () => { + // The caret sits in its own leading column so that a child row's cells line + // up with its parent's instead of being pushed over by indentation. + const cellTextsOfRow = (label: string) => { + const row = screen.getByText(label).closest("tr"); + if (!row) throw new Error(`no row for "${label}"`); + return within(row) + .getAllByRole("cell") + .map((cell) => cell.textContent); + }; + + it("is absent from tables without getSubRows", async () => { + await renderComponent(); + + // Three columns declared, three header cells rendered. + expect(screen.getAllByRole("columnheader")).toHaveLength(columns.length); + expect(cellTextsOfRow("analytics")).toHaveLength(columns.length); + }); + + it("adds one leading column to grouped tables", async () => { + await renderComponent(); + + expect(screen.getAllByRole("columnheader")).toHaveLength( + groupColumns.length + 1, + ); + }); + + it("holds the caret on group rows and stays empty on child rows", async () => { + await renderComponent(); + + const groupCells = cellTextsOfRow("account-a"); + const childCells = cellTextsOfRow("cluster-a1"); + + // Same cell count, so every column aligns across the two tiers. + expect(childCells).toHaveLength(groupCells.length); + // The caret is a button, contributing no text of its own. + expect(childCells[0]).toBe(""); + expect( + within( + screen.getByText("account-a").closest("tr") as HTMLElement, + ).getAllByRole("cell")[0], + ).toContainElement(caretFor("account-a")); + }); + }); + describe("Group Rows", () => { it("renders groups collapsed by default", async () => { await renderComponent(); expect(screen.getByText("account-a")).toBeInTheDocument(); expect(screen.queryByText("cluster-a1")).not.toBeInTheDocument(); - expect(screen.getByText("account-a").closest("tr")).toHaveAttribute( - "aria-expanded", - "false", - ); + expect(caretFor("account-a")).toHaveAttribute("aria-expanded", "false"); }); it("expands all groups with initialExpanded", async () => { @@ -473,27 +562,90 @@ describe("UniversalTable", () => { await user.click(screen.getByText("account-a")); expect(screen.getByText("cluster-a1")).toBeInTheDocument(); expect(screen.getByText("cluster-a2")).toBeInTheDocument(); - expect(screen.getByText("account-a").closest("tr")).toHaveAttribute( - "aria-expanded", - "true", - ); + expect(caretFor("account-a")).toHaveAttribute("aria-expanded", "true"); await user.click(screen.getByText("account-a")); expect(screen.queryByText("cluster-a1")).not.toBeInTheDocument(); }); + // The caret and the row it sits in both toggle expansion. The caret must + // stop the click from reaching the row, or the two cancel out. + it("toggles children on caret click", async () => { + const user = userEvent.setup(); + await renderComponent(); + + await user.click(caretFor("account-a")); + expect(screen.getByText("cluster-a1")).toBeInTheDocument(); + + await user.click(caretFor("account-a")); + expect(screen.queryByText("cluster-a1")).not.toBeInTheDocument(); + }); + it("toggles children with the keyboard", async () => { const user = userEvent.setup(); await renderComponent(); - const groupRow = screen.getByText("account-a").closest("tr"); - if (!groupRow) throw new Error("group row not found"); - groupRow.focus(); + caretFor("account-a").focus(); await user.keyboard("{Enter}"); expect(screen.getByText("cluster-a1")).toBeInTheDocument(); }); + it("reaches every caret by tabbing", async () => { + const user = userEvent.setup(); + await renderComponent(); + + // The search box sits ahead of the table, so the carets follow it in + // document order, one tab stop per group row. + await user.tab(); + expect(screen.getByLabelText("Search accounts...")).toHaveFocus(); + + await user.tab(); + expect(caretFor("account-a")).toHaveFocus(); + + await user.tab(); + expect(caretFor("account-b")).toHaveFocus(); + + await user.tab(); + expect(caretFor("account-c")).toHaveFocus(); + }); + + it("toggles the focused caret with Enter", async () => { + const user = userEvent.setup(); + await renderComponent(); + + await user.tab(); + await user.tab(); + expect(caretFor("account-a")).toHaveFocus(); + + await user.keyboard("{Enter}"); + expect(caretFor("account-a")).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("cluster-a1")).toBeInTheDocument(); + + // Expanding inserts child rows, and the caret must survive that rerender + // as the focused element for a second Enter to reach it. + expect(caretFor("account-a")).toHaveFocus(); + + await user.keyboard("{Enter}"); + expect(caretFor("account-a")).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("cluster-a1")).not.toBeInTheDocument(); + }); + + it("names each caret with expandLabel", async () => { + await renderComponent( + `Show clusters of ${row.original.name}`} + />, + ); + + expect( + screen.getByRole("button", { name: "Show clusters of account-a" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: CARET_LABEL }), + ).not.toBeInTheDocument(); + }); + it("fires onRowClick for child rows but not group rows", async () => { const onClick = vi.fn(); const user = userEvent.setup(); diff --git a/console/src/components/Table/UniversalTable.tsx b/console/src/components/Table/UniversalTable.tsx index c3e54fc88a416..963d301a5f5b0 100644 --- a/console/src/components/Table/UniversalTable.tsx +++ b/console/src/components/Table/UniversalTable.tsx @@ -15,6 +15,7 @@ import { PopoverContent, PopoverTrigger, Skeleton, + SystemStyleObject, Table, Tbody, Td, @@ -148,93 +149,140 @@ const ColumnHeader = ({ ); }; -// Caret width (4) + caret/label gap (2), so an indented child row's label -// lines up just past its parent row's caret. -const CHILD_ROW_INDENT = 6; +// The caret column exists only to hold the toggle, so it is sized to the button. +// Child rows leave it empty so that data in the first columns is aligned. +const CARET_SIZE = 8; +const CARET_COLUMN_WIDTH = CARET_SIZE; -const GroupRowCaret = ({ isOpen }: { isOpen: boolean }) => ( -