Skip to content

Commit

Permalink
cluster-ui: create node regions selector
Browse files Browse the repository at this point in the history
This commit adds a select component which allows users
to select nodes by region.

Epic: CRDB-37558
Fixes: #131032

Release note: None
  • Loading branch information
xinhaoz committed Sep 20, 2024
1 parent e5aaa0f commit 70a6478
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export type DatabaseMetadataRequest = {
sortBy?: string;
sortOrder?: string;
pagination: SimplePaginationState;
storeId?: number;
storeId?: number[];
};

export type DatabaseMetadataResponse =
Expand All @@ -66,8 +66,8 @@ export const getDatabaseMetadata = async (req: DatabaseMetadataRequest) => {
if (req.pagination.pageNum) {
urlParams.append("pageNum", req.pagination.pageNum.toString());
}
if (req.storeId) {
urlParams.append("storeId", req.storeId.toString());
if (req.storeId?.length) {
req.storeId.forEach(id => urlParams.append("storeId", id.toString()));
}

return fetchDataJSON<DatabaseMetadataResponse, DatabaseMetadataRequest>(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// 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 { render, screen, fireEvent, waitFor } from "@testing-library/react";
import React from "react";

import { useNodeStatuses } from "src/api";
import { getRegionFromLocality } from "src/store/nodes";
import { StoreID } from "src/types/clusterTypes";

import { NodeRegionsSelector } from "./nodeRegionsSelector";

// Mock the useNodeStatuses hook
jest.mock("src/api", () => ({
useNodeStatuses: jest.fn(),
}));

// Mock the getRegionFromLocality function
jest.mock("src/store/nodes", () => ({
getRegionFromLocality: jest.fn(),
}));

const mockNodeData = {
nodes: [
{
desc: { node_id: 1, locality: { region: "us-east" } },
store_statuses: [
{ desc: { store_id: 101 } },
{ desc: { store_id: 102 } },
],
},
{
desc: { node_id: 2, locality: { region: "us-west" } },
store_statuses: [{ desc: { store_id: 201 } }],
},
{
desc: { node_id: 3, locality: { region: "us-east" } },
store_statuses: [{ desc: { store_id: 301 } }],
},
],
};

describe("NodeRegionsSelector", () => {
beforeEach(() => {
(useNodeStatuses as jest.Mock).mockReturnValue({
isLoading: false,
data: mockNodeData,
});
(getRegionFromLocality as jest.Mock).mockImplementation(
locality => locality.region,
);
});

it("renders without crashing", () => {
render(<NodeRegionsSelector value={[]} onChange={() => {}} />);
expect(screen.getByText("Nodes")).toBeTruthy();
});

it("displays correct options based on node data", async () => {
render(<NodeRegionsSelector value={[]} onChange={() => {}} />);

const select = screen.getByText("Nodes");
fireEvent.keyDown(select, { key: "ArrowDown" });

await waitFor(() => {
expect(screen.getByText("us-east")).toBeTruthy();
expect(screen.getByText("us-west")).toBeTruthy();
expect(screen.getByText("n1")).toBeTruthy();
expect(screen.getByText("n2")).toBeTruthy();
expect(screen.getByText("n3")).toBeTruthy();
});
});

it("calls onChange with correct values when selecting options", async () => {
const value: StoreID[] = [];
const mockOnChange = jest.fn((selected: StoreID[]) => {
value.push(...selected);
});
render(<NodeRegionsSelector value={value} onChange={mockOnChange} />);

const select = screen.getByText("Nodes");
fireEvent.keyDown(select, { key: "ArrowDown" });

await waitFor(() => {
fireEvent.click(screen.getByText("n1"));
});

expect(mockOnChange).toHaveBeenCalledWith([101, 102]);
});

it("displays selected values correctly", () => {
render(
<NodeRegionsSelector
value={[101 as StoreID, 201 as StoreID]}
onChange={() => {}}
/>,
);

expect(screen.getByText("n1")).toBeTruthy();
expect(screen.getByText("n2")).toBeTruthy();
});

it("handles loading state", () => {
(useNodeStatuses as jest.Mock).mockReturnValue({
isLoading: true,
data: mockNodeData,
});

render(<NodeRegionsSelector value={[]} onChange={() => {}} />);

const select = screen.getByText("Nodes");
fireEvent.keyDown(select, { key: "ArrowDown" });

// In the loading state, the component should still render options
// based on the existing data
expect(screen.getByText("us-east")).toBeTruthy();
expect(screen.getByText("us-west")).toBeTruthy();
expect(screen.getByText("n1")).toBeTruthy();
expect(screen.getByText("n2")).toBeTruthy();
expect(screen.getByText("n3")).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// 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 React, { useMemo } from "react";
import Select, { OptionsType } from "react-select";

import { useNodeStatuses } from "src/api";
import { getRegionFromLocality } from "src/store/nodes";
import { NodeID, StoreID } from "src/types/clusterTypes";
import {
GroupedReactSelectOption,
ReactSelectOption,
} from "src/types/selectTypes";

type NodeRegionsSelectorProps = {
value: StoreID[];
onChange: (selected: StoreID[]) => void;
};

export const NodeRegionsSelector: React.FC<NodeRegionsSelectorProps> = ({
value,
onChange,
}) => {
const nodesResp = useNodeStatuses();

const nodeOptions: GroupedReactSelectOption<StoreID[]>[] = useMemo(() => {
const optionsMap: Record<string, { nid: NodeID; sids: StoreID[] }[]> = {};
if (nodesResp.isLoading && !nodesResp.data?.nodes) {
return [];
}

nodesResp.data.nodes.forEach(node => {
const region = getRegionFromLocality(node.desc.locality);
if (optionsMap[region] == null) {
optionsMap[region] = [];
}
optionsMap[region].push({
nid: node.desc.node_id as NodeID,
sids: node.store_statuses.map(s => s.desc.store_id as StoreID),
});
});

return Object.entries(optionsMap).map(([region, nodes]) => {
return {
label: region,
options: nodes.map(n => ({
label: `n${n.nid}`,
value: n.sids,
})),
};
});
}, [nodesResp]);

const onSelectChange = (
selected: OptionsType<ReactSelectOption<StoreID[]>>,
) => {
onChange(selected.map(s => s.value).reduce((acc, v) => acc.concat(v), []));
};

const selectValue: OptionsType<ReactSelectOption<StoreID[]>> =
nodeOptions.reduce((acc, region) => {
const nodes = region.options.filter(n =>
value.some(v => n.value.includes(v)),
);
return [...acc, ...nodes];
}, []);

return (
<Select
placeholder={"Nodes"}
name="nodeRegions"
options={nodeOptions}
clearable={true}
isMulti
value={selectValue}
onChange={onSelectChange}
/>
);
};
34 changes: 15 additions & 19 deletions pkg/ui/workspaces/cluster-ui/src/databasesV2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
// licenses/APL.txt.

import { Skeleton } from "antd";
import React, { useMemo, useState } from "react";
import React, { useMemo } from "react";
import { Link } from "react-router-dom";
import Select, { OptionsType } from "react-select";

import {
DatabaseMetadataRequest,
Expand All @@ -32,10 +31,11 @@ import {
TableColumnProps,
} from "src/sharedFromCloud/table";
import useTable, { TableParams } from "src/sharedFromCloud/useTable";
import { ReactSelectOption } from "src/types/selectTypes";
import { Bytes } from "src/util";

import { useNodeStatuses } from "../api";
import { NodeRegionsSelector } from "../components/nodeRegionsSelector/nodeRegionsSelector";
import { StoreID } from "../types/clusterTypes";

import { DatabaseColName } from "./constants";
import { DatabaseRow } from "./databaseTypes";
Expand All @@ -45,11 +45,6 @@ import {
rawDatabaseMetadataToDatabaseRows,
} from "./utils";

const mockRegionOptions = [
{ label: "US East (N. Virginia)", value: "us-east-1" },
{ label: "US East (Ohio)", value: "us-east-2" },
];

const COLUMNS: TableColumnProps<DatabaseRow>[] = [
{
title: DatabaseColName.NAME,
Expand Down Expand Up @@ -129,11 +124,12 @@ const createDatabaseMetadataRequestFromParams = (
sortBy: params.sort?.field ?? "name",
sortOrder: params.sort?.order ?? "asc",
name: params.search,
storeId: params.filters.storeIDs.map(sid => parseInt(sid, 10)),
};
};

export const DatabasesPageV2 = () => {
const { params, setSort, setSearch, setPagination } = useTable({
const { params, setFilters, setSort, setSearch, setPagination } = useTable({
initial: initialParams,
});

Expand All @@ -143,9 +139,10 @@ export const DatabasesPageV2 = () => {
const nodesResp = useNodeStatuses();
const paginationState = data?.pagination_info;

const [nodeRegions, setNodeRegions] = useState<ReactSelectOption[]>([]);
const onNodeRegionsChange = (selected: OptionsType<ReactSelectOption>) => {
setNodeRegions((selected ?? []).map(v => v));
const onNodeRegionsChange = (storeIDs: StoreID[]) => {
setFilters({
storeIDs: storeIDs.map(sid => sid.toString()),
});
};

const tableData = useMemo(
Expand Down Expand Up @@ -189,6 +186,10 @@ export const DatabasesPageV2 = () => {
[sort],
);

const nodeRegionsValue = params.filters.storeIDs.map(
sid => parseInt(sid, 10) as StoreID,
);

return (
<PageLayout>
<PageHeader title="Databases" />
Expand All @@ -198,13 +199,8 @@ export const DatabasesPageV2 = () => {
<Search placeholder="Search databases" onSubmit={setSearch} />
</PageConfigItem>
<PageConfigItem minWidth={"200px"}>
<Select
placeholder={"Regions"}
name="nodeRegions"
options={mockRegionOptions}
clearable={true}
isMulti
value={nodeRegions}
<NodeRegionsSelector
value={nodeRegionsValue}
onChange={onNodeRegionsChange}
/>
</PageConfigItem>
Expand Down
9 changes: 7 additions & 2 deletions pkg/ui/workspaces/cluster-ui/src/types/selectTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
// This is temporary. We'll remove this when we can access the shared
// component library from the console. In the meantime this just removes
// some type pollution from new components using react-select.
export type ReactSelectOption = {
export type ReactSelectOption<T> = {
label: string;
value: string;
value: T;
};

export type GroupedReactSelectOption<T> = {
label: string;
options: ReactSelectOption<T>[];
};

0 comments on commit 70a6478

Please sign in to comment.