From 47dfdabc56252f11bbe194d01ec6461a582a6db0 Mon Sep 17 00:00:00 2001 From: Xin Hao Zhang Date: Fri, 20 Sep 2024 11:02:12 -0400 Subject: [PATCH] cluster-ui: add getTableMetadataApi and connect tables page This commit connects the v2 db details - tables page to the new `api/v2/table_metadata` api route. The following components are now connected to the api: - listing tables for a db - sorting by column - searching by table name - filtering by node id Epic: CRDB-37558 Fixes: #131122 Release note: None --- .../api/databases/getDatabaseMetadataApi.ts | 7 +- .../src/api/databases/getTableMetadataApi.ts | 111 ++++++ pkg/ui/workspaces/cluster-ui/src/api/types.ts | 2 +- .../src/databaseDetailsV2/constants.ts | 1 - .../src/databaseDetailsV2/index.tsx | 1 + .../src/databaseDetailsV2/tablesView.tsx | 368 ++++++++++-------- .../cluster-ui/src/databaseDetailsV2/types.ts | 1 - .../src/databaseDetailsV2/utils.tsx | 55 +++ .../cluster-ui/src/hooks/useRouteParams.ts | 19 + 9 files changed, 393 insertions(+), 172 deletions(-) create mode 100644 pkg/ui/workspaces/cluster-ui/src/api/databases/getTableMetadataApi.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/utils.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/hooks/useRouteParams.ts diff --git a/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts index 88557511332f..b0b5dc250f43 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts @@ -32,7 +32,7 @@ export type DatabaseMetadata = { db_name: string; size_bytes: number; table_count: number; - store_ids: number[] | null; + store_ids: number[]; last_updated: string; }; @@ -44,8 +44,9 @@ export type DatabaseMetadataRequest = { storeIds?: number[]; }; -export type DatabaseMetadataResponse = - APIV2ResponseWithPaginationState; +export type DatabaseMetadataResponse = APIV2ResponseWithPaginationState< + DatabaseMetadata[] +>; export const getDatabaseMetadata = async (req: DatabaseMetadataRequest) => { const urlParams = new URLSearchParams(); diff --git a/pkg/ui/workspaces/cluster-ui/src/api/databases/getTableMetadataApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/databases/getTableMetadataApi.ts new file mode 100644 index 000000000000..3e3ae982993d --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/api/databases/getTableMetadataApi.ts @@ -0,0 +1,111 @@ +// Copyright 2024 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// 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, included in the file +// licenses/APL.txt. + +import useSWR from "swr"; + +import { StoreID } from "../../types/clusterTypes"; +import { fetchDataJSON } from "../fetchData"; +import { + APIV2ResponseWithPaginationState, + SimplePaginationState, +} from "../types"; + +const TABLE_METADATA_API_PATH = "api/v2/table_metadata/"; + +export enum TableSortOption { + NAME = "name", + REPLICATION_SIZE = "replicationSize", + RANGES = "ranges", + LIVE_DATA = "liveData", + COLUMNS = "columns", + INDEXES = "indexes", + LAST_UPDATED = "lastUpdated", +} + +export type TableMetadata = { + db_id: number; + db_name: string; + table_id: number; + schema_name: string; + table_name: string; + replication_size_bytes: number; + range_count: number; + column_count: number; + index_count: number; + percent_live_data: number; + total_live_data_bytes: number; + total_data_bytes: number; + store_ids: number[]; + last_updated: string; +}; + +type TableMetadataResponse = APIV2ResponseWithPaginationState; + +export type TableMetadataRequest = { + dbId?: number; + sortBy?: string; + sortOrder?: "asc" | "desc"; + storeIds?: StoreID[]; + pagination: SimplePaginationState; + name?: string; +}; + +export async function getTableMetadata( + req: TableMetadataRequest, +): Promise { + const urlParams = new URLSearchParams(); + if (req.dbId) { + urlParams.append("dbId", req.dbId.toString()); + } + if (req.sortBy) { + urlParams.append("sortBy", req.sortBy); + } + if (req.sortOrder) { + urlParams.append("sortOrder", req.sortOrder); + } + if (req.pagination.pageSize) { + urlParams.append("pageSize", req.pagination.pageSize.toString()); + } + if (req.pagination.pageNum) { + urlParams.append("pageNum", req.pagination.pageNum.toString()); + } + if (req.storeIds) { + req.storeIds.forEach(storeID => { + urlParams.append("storeId", storeID.toString()); + }); + } + if (req.name) { + urlParams.append("name", req.name); + } + return fetchDataJSON(TABLE_METADATA_API_PATH + "?" + urlParams.toString()); +} + +const createKey = (req: TableMetadataRequest) => { + const { dbId, sortBy, sortOrder, pagination, storeIds, name } = req; + return [ + "tableMetadata", + dbId, + sortBy, + sortOrder, + pagination.pageSize, + pagination.pageNum, + storeIds, + name, + ].join("|"); +}; + +export const useTableMetadata = (req: TableMetadataRequest) => { + const key = createKey(req); + const { data, error, isLoading } = useSWR(key, () => + getTableMetadata(req), + ); + + return { data, error, isLoading }; +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/api/types.ts b/pkg/ui/workspaces/cluster-ui/src/api/types.ts index 514a79760d36..0dc1b619eac8 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/types.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/types.ts @@ -43,6 +43,6 @@ export type APIV2PaginationResponse = { }; export type APIV2ResponseWithPaginationState = { - results: T[]; + results: T; pagination_info: APIV2PaginationResponse; }; diff --git a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/constants.ts b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/constants.ts index 6abdb5ea5d03..32ab28a0b2d2 100644 --- a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/constants.ts +++ b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/constants.ts @@ -15,6 +15,5 @@ export enum TableColName { COLUMN_COUNT = "Columns", NODE_REGIONS = "Regions / Nodes", LIVE_DATA_PERCENTAGE = "% of Live data", - AUTO_STATS_COLLECTION = "Table auto stats collection", STATS_LAST_UPDATED = "Stats last updated", } diff --git a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/index.tsx b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/index.tsx index 7d8a2fb421fa..e1f082c071d0 100644 --- a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/index.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/index.tsx @@ -26,6 +26,7 @@ enum TabKeys { export const DatabaseDetailsPageV2 = () => { const [currentTab, setCurrentTab] = useState(TabKeys.TABLES); + // TODO (xinhaoz) #131119 - Populate db name here. return ( diff --git a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/tablesView.tsx b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/tablesView.tsx index 8fbd7c19c6df..8cee69e279b4 100644 --- a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/tablesView.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsV2/tablesView.tsx @@ -8,216 +8,252 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import { Badge, BadgeIntent } from "@cockroachlabs/ui-components"; -import moment from "moment-timezone"; -import React, { useState } from "react"; +import React, { useMemo } from "react"; import { Link } from "react-router-dom"; -import Select, { OptionsType } from "react-select"; +import { useNodeStatuses } from "src/api"; +import { + TableMetadataRequest, + TableSortOption, + useTableMetadata, +} from "src/api/databases/getTableMetadataApi"; +import { NodeRegionsSelector } from "src/components/nodeRegionsSelector/nodeRegionsSelector"; import { RegionNodesLabel } from "src/components/regionNodesLabel"; +import { useRouteParams } from "src/hooks/useRouteParams"; import { PageSection } from "src/layouts"; +import { Loading } from "src/loading"; import { PageConfig, PageConfigItem } from "src/pageConfig"; import PageCount from "src/sharedFromCloud/pageCount"; import { Search } from "src/sharedFromCloud/search"; -import { Table, TableColumnProps } from "src/sharedFromCloud/table"; -import useTable from "src/sharedFromCloud/useTable"; -import { NodeID } from "src/types/clusterTypes"; -import { ReactSelectOption } from "src/types/selectTypes"; +import { + SortDirection, + Table, + TableChangeFn, + TableColumnProps, +} from "src/sharedFromCloud/table"; +import useTable, { TableParams } from "src/sharedFromCloud/useTable"; +import { StoreID } from "src/types/clusterTypes"; import { Bytes, EncodeDatabaseTableUri } from "src/util"; import { TableColName } from "./constants"; import { TableRow } from "./types"; +import { rawTableMetadataToRows } from "./utils"; -const mockRegionOptions = [ - { label: "US East (N. Virginia)", value: "us-east-1" }, - { label: "US East (Ohio)", value: "us-east-2" }, -]; - -const mockLastUpdated = moment.utc(); -const mockData: TableRow[] = new Array(20).fill(1).map((_, i) => ({ - name: `myDB-${i}`, - qualifiedNameWithSchema: `public.table-${i}`, - dbName: `myDB-${i}`, - dbID: i, - replicationSizeBytes: i * 100, - rangeCount: i, - columnCount: i, - nodesByRegion: - i % 2 === 0 - ? { - [mockRegionOptions[0].value]: [1, 2] as NodeID[], - [mockRegionOptions[1].value]: [3] as NodeID[], - } - : null, - liveDataPercentage: 1, - liveDataBytes: i * 100, - totalDataBytes: i * 100, - autoStatsCollectionEnabled: i % 2 === 0, - statsLastUpdated: mockLastUpdated, - key: i.toString(), -})); - -const filters = {}; - -const initialParams = { - filters, - pagination: { - page: 1, - pageSize: 10, - }, - search: "", - sort: { - field: "name", - order: "asc" as const, - }, -}; - -const columns: TableColumnProps[] = [ - { - title: TableColName.NAME, - width: "15%", - sorter: true, - render: (t: TableRow) => { - // This linking is just temporary. We'll need to update it to the correct path - // using db ID and table ID once we have the table details page. - const encodedDBPath = EncodeDatabaseTableUri(t.dbName, t.name); - return {t.qualifiedNameWithSchema}; +const COLUMNS: (TableColumnProps & { sortKey?: TableSortOption })[] = + [ + { + title: TableColName.NAME, + width: "15%", + sorter: true, + render: (t: TableRow) => { + // This linking is just temporary. We'll need to update it to the correct path + // using db ID and table ID once we have the table details page. + const encodedDBPath = EncodeDatabaseTableUri(t.dbName, t.name); + return {t.qualifiedNameWithSchema}; + }, + sortKey: TableSortOption.NAME, }, - }, - { - title: TableColName.REPLICATION_SIZE, - width: "fit-content", - sorter: true, - render: (t: TableRow) => { - return Bytes(t.replicationSizeBytes); + { + title: TableColName.REPLICATION_SIZE, + width: "fit-content", + sorter: true, + render: (t: TableRow) => { + return Bytes(t.replicationSizeBytes); + }, + sortKey: TableSortOption.REPLICATION_SIZE, }, - }, - { - title: TableColName.RANGE_COUNT, - width: "fit-content", - sorter: true, - render: (t: TableRow) => { - return t.rangeCount; + { + title: TableColName.RANGE_COUNT, + width: "fit-content", + sorter: true, + render: (t: TableRow) => { + return t.rangeCount; + }, + sortKey: TableSortOption.RANGES, }, - }, - { - title: TableColName.COLUMN_COUNT, - width: "fit-content", - sorter: true, - render: (t: TableRow) => { - return t.columnCount; + { + title: TableColName.COLUMN_COUNT, + width: "fit-content", + sorter: true, + render: (t: TableRow) => { + return t.columnCount; + }, + sortKey: TableSortOption.COLUMNS, }, - }, - { - title: TableColName.NODE_REGIONS, - width: "20%", - render: (t: TableRow) => ( -
- {Object.entries(t.nodesByRegion ?? {}).map(([region, nodes]) => ( - - ))} -
- ), - }, - { - title: TableColName.LIVE_DATA_PERCENTAGE, - sorter: true, - render: (t: TableRow) => { - return ( + { + title: TableColName.NODE_REGIONS, + width: "20%", + render: (t: TableRow) => (
-
{t.liveDataPercentage * 100}%
+ {Object.entries(t.nodesByRegion ?? {}).map(([region, nodes]) => ( + + ))} +
+ ), + }, + { + title: TableColName.LIVE_DATA_PERCENTAGE, + sorter: true, + width: "fit-content", + sortKey: TableSortOption.LIVE_DATA, + render: (t: TableRow) => { + return (
- {Bytes(t.liveDataBytes)} / {Bytes(t.totalDataBytes)} +
{t.liveDataPercentage * 100}%
+
+ {Bytes(t.liveDataBytes)} / {Bytes(t.totalDataBytes)} +
- - ); + ); + }, }, - }, - { - title: TableColName.AUTO_STATS_COLLECTION, - sorter: true, - render: (t: TableRow) => { - let intent: BadgeIntent = "success"; - let text = "Enabled"; - if (!t.autoStatsCollectionEnabled) { - intent = "warning"; - text = "Disabled"; - } - return ( - - {text} - - ); + { + title: TableColName.STATS_LAST_UPDATED, + sorter: true, + render: (t: TableRow) => { + return t.statsLastUpdated.format("YYYY-MM-DD HH:mm:ss"); + }, }, - }, - { - title: TableColName.STATS_LAST_UPDATED, - sorter: true, - render: (t: TableRow) => { - return t.statsLastUpdated.format("YYYY-MM-DD HH:mm:ss"); + ]; + +const createTableMetadataRequestFromParams = ( + dbID: string, + params: TableParams, +): TableMetadataRequest => { + return { + pagination: { + pageSize: params.pagination.pageSize, + pageNum: params.pagination.page, }, + sortBy: params.sort?.field ?? "name", + sortOrder: params.sort?.order, + dbId: parseInt(dbID, 10), + storeIds: params.filters.storeIDs.map(sid => parseInt(sid, 10) as StoreID), + name: params.search, + }; +}; + +const initialParams: TableParams = { + filters: { storeIDs: [] as string[] }, + pagination: { + page: 1, + pageSize: 10, + }, + search: "", + sort: { + field: TableSortOption.NAME, + order: "asc", }, -]; +}; export const TablesPageV2 = () => { - const { params, setSearch } = useTable({ + const { params, setFilters, setSort, setSearch, setPagination } = useTable({ initial: initialParams, }); - const data = mockData; - const [nodeRegions, setNodeRegions] = useState[]>( - [], + // Get db id from the URL. + const { dbID } = useRouteParams(); + const { data, error, isLoading } = useTableMetadata( + createTableMetadataRequestFromParams(dbID, params), + ); + const nodesResp = useNodeStatuses(); + const paginationState = data?.pagination_info; + + const onNodeRegionsChange = (storeIDs: StoreID[]) => { + setFilters({ + storeIDs: storeIDs.map(sid => sid.toString()), + }); + }; + + const tableData = useMemo( + () => + rawTableMetadataToRows(data?.results ?? [], { + nodeIDToRegion: nodesResp.nodeIDToRegion, + storeIDToNodeID: nodesResp.storeIDToNodeID, + isLoading: nodesResp.isLoading, + }), + [data, nodesResp], ); - const onNodeRegionsChange = ( - selected: OptionsType>, - ) => { - setNodeRegions((selected ?? []).map(v => v)); + + const onTableChange: TableChangeFn = (pagination, sorter) => { + setPagination({ page: pagination.current, pageSize: pagination.pageSize }); + if (sorter) { + const colKey = sorter.columnKey; + if (typeof colKey !== "number") { + // CockroachDB table component sets the col idx as the column key. + return; + } + setSort({ + field: COLUMNS[colKey].sortKey, + order: sorter.order === "descend" ? "desc" : "asc", + }); + } }; + const nodeRegionsValue = params.filters.storeIDs.map( + sid => parseInt(sid, 10) as StoreID, + ); + + const sort = params.sort; + const colsWithSort = useMemo( + () => + COLUMNS.map((col, i) => { + const colInd = COLUMNS.findIndex(c => c.sortKey === sort.field); + const sortOrder: SortDirection = + sort?.order === "desc" ? "descend" : "ascend"; + return { + ...col, + sortOrder: colInd === i && col.sorter ? sortOrder : null, + }; + }), + [sort], + ); + return ( <> - + -