This is an automated email from the ASF dual-hosted git repository.
jialiang pushed a commit to branch frontend-refactor
in repository https://gitbox.apache.org/repos/asf/ambari.git
The following commit(s) were added to refs/heads/frontend-refactor by this push:
new 11c7abfa92 AMBARI-26178: Ambari Admin React Implementation: List
Remote Cluster (#3887)
11c7abfa92 is described below
commit 11c7abfa92df8ba94680f60653f38295b4c39c6a
Author: Sandeep Kumar <[email protected]>
AuthorDate: Thu Nov 21 15:06:49 2024 +0530
AMBARI-26178: Ambari Admin React Implementation: List Remote Cluster (#3887)
---
.../src/__mocks__/mockRemoteCluster.ts | 140 +++++++++++++++++++
.../ui/ambari-admin/src/api/remoteCluster.ts | 86 ++++++++++++
.../ClusterManagement/RemoteClusters/Index.tsx | 155 +++++++++++++++++++++
.../ambari-admin/src/tests/RemoteClusters.test.tsx | 119 ++++++++++++++++
4 files changed, 500 insertions(+)
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockRemoteCluster.ts
b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockRemoteCluster.ts
new file mode 100644
index 0000000000..92047d340f
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockRemoteCluster.ts
@@ -0,0 +1,140 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export const mockData = [
+ {
+ ClusterInfo: {
+ cluster_id: 1,
+ name: "TestCluster1",
+ services: ["Service1", "Service2"],
+ url: "testClusterUrl1",
+ username: "testUser1",
+ },
+ },
+ ];
+
+ export const mockClusterDataForEdit = {
+ ClusterInfo: {
+ name: "TestCluster1",
+ url: "http://cluster.example.com:8080/api/v1/clusters/name",
+ services: ["service1, service2"],
+ username: "testUser",
+ },
+ };
+
+ export const paginatedMockData = [
+ {
+ ClusterInfo: {
+ cluster_id: 1,
+ name: "TestCluster1",
+ services: ["Service1", "Service2"],
+ url: "testClusterUrl1",
+ username: "testUser1",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 2,
+ name: "TestCluster2",
+ services: ["Service3", "Service4"],
+ url: "testClusterUrl2",
+ username: "testUser2",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 3,
+ name: "TestCluster3",
+ services: ["Service5", "Service6"],
+ url: "testClusterUrl3",
+ username: "testUser3",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 4,
+ name: "TestCluster4",
+ services: ["Service7", "Service8"],
+ url: "testClusterUrl4",
+ username: "testUser4",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 5,
+ name: "TestCluster5",
+ services: ["Service9", "Service10"],
+ url: "testClusterUrl5",
+ username: "testUser5",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 6,
+ name: "TestCluster6",
+ services: ["Service11", "Service12"],
+ url: "testClusterUrl6",
+ username: "testUser6",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 7,
+ name: "TestCluster7",
+ services: ["Service13", "Service14"],
+ url: "testClusterUrl7",
+ username: "testUser7",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 8,
+ name: "TestCluster8",
+ services: ["Service15", "Service16"],
+ url: "testClusterUrl8",
+ username: "testUser8",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 9,
+ name: "TestCluster9",
+ services: ["Service17", "Service18"],
+ url: "testClusterUrl9",
+ username: "testUser9",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 10,
+ name: "TestCluster10",
+ services: ["Service19", "Service20"],
+ url: "testClusterUrl10",
+ username: "testUser10",
+ },
+ },
+ {
+ ClusterInfo: {
+ cluster_id: 11,
+ name: "TestCluster11",
+ services: ["Service21", "Service22"],
+ url: "testClusterUrl11",
+ username: "testUser11",
+ },
+ },
+ ];
+
\ No newline at end of file
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/api/remoteCluster.ts
b/ambari-admin/src/main/resources/ui/ambari-admin/src/api/remoteCluster.ts
new file mode 100644
index 0000000000..63037d6266
--- /dev/null
+++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/api/remoteCluster.ts
@@ -0,0 +1,86 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { adminApi } from "./configs/axiosConfig";
+
+interface Cluster {
+ cluster_id?: number;
+ name?: string;
+ url?: string;
+ username?: string;
+ password?: string;
+}
+
+const RemoteClusterApi = {
+ getRemoteClusters: async function () {
+ const url = '/remoteclusters';
+ const response = await adminApi.request({
+ url: url,
+ method: "GET"
+ });
+ // Fetching details for each cluster
+ const clustersData = await Promise.all(
+ response.data.items.map(async (cluster: any) => {
+ const details = await
this.getRemoteClusterByName(cluster.ClusterInfo.name);
+ return details;
+ })
+ );
+ return clustersData;
+ },
+ getRemoteClusterByName: async function(clusterName: string) {
+ const url = `/remoteclusters/${clusterName}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "GET"
+ });
+ return response.data;
+ },
+ addRemoteCluster: async function (cluster: Cluster) {
+ const url = `/remoteclusters/${cluster.name}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "POST",
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ data: {ClusterInfo: cluster}
+ });
+ return response.data;
+ },
+ updateRemoteCluster: async function (clusterName: string, cluster:
Cluster) {
+ const url = `/remoteclusters/${clusterName}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "PUT",
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ data: {ClusterInfo: cluster}
+ });
+ return response.data;
+ },
+ deregisterRemoteCluster: async function (clusterName: string) {
+ const url = `/remoteclusters/${clusterName}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "DELETE"
+ });
+ return response.data;
+ }
+};
+
+export default RemoteClusterApi;
\ No newline at end of file
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/RemoteClusters/Index.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/RemoteClusters/Index.tsx
new file mode 100644
index 0000000000..6c25f19c0c
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/RemoteClusters/Index.tsx
@@ -0,0 +1,155 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { useContext, useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { get } from "lodash";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faFilter } from "@fortawesome/free-solid-svg-icons";
+import Spinner from "../../../components/Spinner";
+import Table from "../../../components/Table";
+import DefaultButton from "../../../components/DefaultButton";
+import Paginator from "../../../components/Paginator";
+import usePagination from "../../../hooks/usePagination";
+import RemoteClusterApi from "../../../api/remoteCluster.ts";
+import ComboSearch from "../../../components/ComboSearch";
+import AppContent from "../../../context/AppContext";
+
+export default function RemoteClusters() {
+ const [remoteClusters, setRemoteClusters] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [showFilters, setShowFilters] = useState(false);
+ const [filteredRemoteClusters, setFilteredRemoteClusters] = useState<
+ unknown[] | ((prevState: never[]) => never[])
+ >([]);
+ const {
+ currentItems,
+ changePage,
+ currentPage,
+ maxPage,
+ itemsPerPage,
+ setItemsPerPage,
+ } = usePagination(filteredRemoteClusters);
+
+ const {
+ setSelectedOption
+ } = useContext(AppContent);
+
+ useEffect(() => {
+ setSelectedOption("Remote Clusters");
+ async function getRemoteClusters() {
+ setLoading(true);
+ const data: any = await RemoteClusterApi.getRemoteClusters();
+ setRemoteClusters(data);
+ setFilteredRemoteClusters(data);
+ setLoading(false);
+ }
+ getRemoteClusters();
+ }, []);
+
+ const columnViewList = [
+ {
+ header: "Cluster Name",
+ accessorKey: "ClusterInfo.name",
+ cell: (info: any) => {
+ return (
+ <div>
+ <Link
+ className="custom-link"
+ to={`/remoteClusters/${get(
+ info,
+ "row.original.ClusterInfo.name"
+ )}/edit`}
+ >
+ {get(info, "row.original.ClusterInfo.name")}
+ </Link>
+ </div>
+ );
+ },
+ },
+ {
+ header: "Services",
+ accessorKey: "ClusterInfo.services",
+ cell: (info: any) => {
+ const services = get(info, "row.original.ClusterInfo.services");
+ const uniqueServices = [...new Set(services)];
+ return uniqueServices.join(", ");
+ },
+ },
+ ];
+
+ if (loading) {
+ return <Spinner />;
+ }
+ return (
+ <>
+ <div className="d-flex justify-content-end pb-3">
+ <DefaultButton
+ onClick={() => {
+ setShowFilters(!showFilters);
+ }}
+ className="me-2"
+ >
+ <FontAwesomeIcon icon={faFilter} />
+ </DefaultButton>
+ <Link to={`/remoteClusters/create`}>
+ <DefaultButton>Register Remote cluster</DefaultButton>
+ </Link>
+ </div>
+ <div>
+ {showFilters ? (
+ <div className="d-flex">
+ <ComboSearch
+ fields={[
+ { label: "Cluster Name", value: "ClusterInfo.name" },
+ { label: "Services", value: "ClusterInfo.services" },
+ ]}
+ valueMappings={{
+ clusterName: "ClusterInfo.name",
+ services: "ClusterInfo.services",
+ }}
+ searchCallback={(
+ filteredData: React.SetStateAction<
+ any[] | ((prevState: never[]) => never[])
+ >
+ ) => {
+ setFilteredRemoteClusters(filteredData);
+ }}
+ data={remoteClusters}
+ />
+ </div>
+ ) : null}
+
+ <div className="scrollable">
+ <Table
+ columns={columnViewList}
+ data={currentItems}
+ entityName="Remote Clusters"
+ />
+ </div>
+ <Paginator
+ currentPage={currentPage}
+ maxPage={maxPage}
+ changePage={changePage}
+ itemsPerPage={itemsPerPage}
+ setItemsPerPage={setItemsPerPage}
+ totalItems={filteredRemoteClusters.length}
+ />
+ </div>
+ </>
+ );
+}
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/RemoteClusters.test.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/RemoteClusters.test.tsx
new file mode 100644
index 0000000000..7f83961137
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/RemoteClusters.test.tsx
@@ -0,0 +1,119 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { describe, it, expect } from "vitest";
+import { render, waitFor, screen, fireEvent } from "@testing-library/react";
+import { createMemoryHistory } from "history";
+import { Router } from "react-router-dom";
+import "@testing-library/jest-dom/vitest";
+import RemoteClusters from "../screens/ClusterManagement/RemoteClusters/Index"
+import RemoteClusterApi from "../api/remoteCluster";
+import {
+ mockData,
+ paginatedMockData,
+} from "../__mocks__/mockRemoteCluster";
+import AppContent from "../context/AppContext";
+
+const mockClusterName = "testCluster";
+const mockContext = {
+ cluster: { cluster_name: mockClusterName },
+ setSelectedOption: () => "RemoteCluster",
+};
+
+const renderRemoteCluster = () => {
+ render(
+ <AppContent.Provider value={mockContext}>
+ <Router history={createMemoryHistory()}>
+ <RemoteClusters />
+ </Router>
+ </AppContent.Provider>
+ );
+};
+
+describe("RemoteClusters component", () => {
+ it("renders without crashing", () => {
+ RemoteClusterApi.getRemoteClusters = async () => mockData;
+ renderRemoteCluster();
+ });
+
+ it("shows loading spinner when data is being fetched.", async () => {
+ RemoteClusterApi.getRemoteClusters = async () => [];
+ renderRemoteCluster();
+
+ const spinner = screen.getByTestId("admin-spinner");
+ expect(spinner).toBeInTheDocument();
+ });
+
+ it("display appropriate message when no cluster is rendered.", async () => {
+ RemoteClusterApi.getRemoteClusters = async () => [];
+ renderRemoteCluster();
+ expect(screen.findByText(/No Remote Cluster to show/i));
+ });
+
+ it("renders correct number of items ", async () => {
+ RemoteClusterApi.getRemoteClusters = async () => mockData;
+ renderRemoteCluster();
+
+ await waitFor(() => screen.getByText(/TestCluster/i));
+
+ const items = screen.getAllByRole("listitem");
+ expect(items).toHaveLength(mockData.length);
+ });
+
+ it("renders data for a specific item correctly", async () => {
+ RemoteClusterApi.getRemoteClusters = async () => mockData;
+ renderRemoteCluster();
+
+ await waitFor(() => screen.getByText(/TestCluster/i));
+ expect(screen.getByText(/TestCluster1/i)).toBeInTheDocument();
+ expect(screen.getByText(/Service1/i)).toBeInTheDocument();
+ expect(screen.getByText(/Service2/i)).toBeInTheDocument();
+ });
+
+ it("renders the Register Remote cluster button and navigates to the create
route on click", async () => {
+ RemoteClusterApi.getRemoteClusters = async () => mockData;
+ const history = createMemoryHistory();
+ render(
+ <AppContent.Provider value={mockContext}>
+ <Router history={history}>
+ <RemoteClusters />
+ </Router>
+ </AppContent.Provider>
+ );
+ await waitFor(() => screen.getByText(/TestCluster1/i));
+ expect(screen.getByText(/TestCluster1/i)).toBeInTheDocument();
+
+ const registerButton = screen.getByRole("button", {
+ name: /Register Remote cluster/i,
+ });
+ expect(registerButton).toBeInTheDocument();
+
+ fireEvent.click(registerButton);
+ await waitFor(() =>
+ expect(history.location.pathname).toBe("/remoteClusters/create")
+ );
+ });
+
+ it("renders pagination when items are more than 10", async () => {
+ RemoteClusterApi.getRemoteClusters = async () => paginatedMockData;
+ renderRemoteCluster();
+ await waitFor(() => screen.getByText(/TestCluster2/i));
+
+ const pagination = screen.getByTestId("pagination");
+ expect(pagination).toBeInTheDocument();
+ });
+});
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]