From 3c2b86ca8c4e71e9067dad52c39f8918a7a43602 Mon Sep 17 00:00:00 2001 From: Sandeep Kumar Date: Tue, 2 Jun 2026 00:57:17 +0530 Subject: [PATCH] AMBARI-26613: Ambari Admin React Implementation: complete the ambari admin Co-authered-by: himanshumaurya09876, vanshuhassija, Kowshic-V, shaur97 --- .../resources/ui/ambari-admin/.eslintrc.cjs | 35 + .../resources/ui/ambari-admin/package.json | 61 +- .../ui/ambari-admin/src/AmbariAboutModal.tsx | 102 ++ .../resources/ui/ambari-admin/src/App.tsx | 144 +- .../ui/ambari-admin/src/InactivityTimeout.tsx | 101 ++ .../resources/ui/ambari-admin/src/NavBar.tsx | 116 ++ .../resources/ui/ambari-admin/src/SideBar.tsx | 166 +++ .../ui/ambari-admin/src/SideItem.tsx | 30 + .../ui/ambari-admin/src/SideItemList.tsx | 134 ++ .../src/__mocks__/mockClusterBluePrintInfo.ts | 19 + .../src/__mocks__/mockEditInstance.ts | 484 ++++++ .../src/__mocks__/mockHostClusterInfo.ts | 27 + .../src/__mocks__/mockUpdateClusterName.ts | 28 + .../ui/ambari-admin/src/api/logout.ts | 53 + .../ui/ambari-admin/src/clusterHost.ts | 19 + .../src/components/AddVersionModal.tsx | 134 ++ .../components/ClusterInformationNavigate.tsx | 33 + .../src/components/ComboSearch.tsx | 223 +++ .../src/components/ConfirmationModal.tsx | 72 + .../src/components/DefaultButton.tsx | 25 + .../src/components/ErrorOverlay.tsx | 38 + .../src/components/InstallClusterButton.tsx | 39 + .../src/components/LostNetworkModal.tsx | 56 + .../ambari-admin/src/components/Paginator.tsx | 106 ++ .../components/RedhatSatelliteInfoModal.tsx | 56 + .../src/components/SidebarItem.tsx | 66 + .../src/components/SidebarItemCollapsed.tsx | 109 ++ .../ambari-admin/src/components/Spinner.tsx | 26 + .../ui/ambari-admin/src/components/Table.tsx | 158 ++ .../ui/ambari-admin/src/components/style.css | 36 + .../ui/ambari-admin/src/hooks/usePolling.ts | 26 + .../ui/ambari-admin/src/router/RoutesList.tsx | 38 +- .../ClusterInformation/index.tsx | 195 +++ .../ClusterManagement/Dasboard/index.tsx | 21 + .../ClusterManagement/StackVersions/List.tsx | 321 ++++ .../StackVersions/Register.tsx | 1309 +++++++++++++++++ .../ClusterManagement/StackVersions/index.tsx | 20 + .../StackVersions/types/Os.d.ts | 76 + .../types/VersionDefinitions.d.ts | 84 ++ .../src/screens/Users/constants.tsx | 38 + .../src/screens/Views/CreateShortUrl.tsx | 338 +++++ .../src/screens/Views/EditInstance.tsx | 1150 +++++++++++++++ .../ClusterIsInstalled.test.tsx | 181 +++ .../ClusterIsNotInstalled.test.tsx | 164 +++ .../src/tests/CreateInstance.test.tsx | 398 +++++ .../src/tests/CreateShortUrl.test.tsx | 198 +++ .../src/tests/EditInstance.test.tsx | 713 +++++++++ .../DeregisterRemoteCluster.test.tsx | 104 ++ .../RemoteCluster/EditRemoteCluster.test.tsx | 386 +++++ .../RegisterRemoteCluster.test.tsx | 206 +++ .../RemoteCluster/RemoteClusters.test.tsx | 119 ++ .../resources/ui/ambari-admin/src/types.ts | 34 + 52 files changed, 8763 insertions(+), 52 deletions(-) create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/.eslintrc.cjs create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/AmbariAboutModal.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/InactivityTimeout.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/NavBar.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/SideBar.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/SideItem.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/SideItemList.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockClusterBluePrintInfo.ts create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockEditInstance.ts create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockHostClusterInfo.ts create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUpdateClusterName.ts create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/api/logout.ts create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/clusterHost.ts create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/AddVersionModal.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/ClusterInformationNavigate.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/ComboSearch.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/ConfirmationModal.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/DefaultButton.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/ErrorOverlay.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/InstallClusterButton.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/LostNetworkModal.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/Paginator.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/RedhatSatelliteInfoModal.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/SidebarItem.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/SidebarItemCollapsed.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/Spinner.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/Table.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/components/style.css create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/hooks/usePolling.ts create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/ClusterInformation/index.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/Dasboard/index.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/List.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/Register.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/index.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/types/Os.d.ts create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/types/VersionDefinitions.d.ts create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/constants.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/CreateShortUrl.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/EditInstance.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/tests/ClusterInformation/ClusterIsInstalled.test.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/tests/ClusterInformation/ClusterIsNotInstalled.test.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/tests/CreateInstance.test.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/tests/CreateShortUrl.test.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditInstance.test.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/DeregisterRemoteCluster.test.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/EditRemoteCluster.test.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/RegisterRemoteCluster.test.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/RemoteClusters.test.tsx create mode 100644 ambari-admin/src/main/resources/ui/ambari-admin/src/types.ts diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/.eslintrc.cjs b/ambari-admin/src/main/resources/ui/ambari-admin/.eslintrc.cjs new file mode 100644 index 00000000000..21536921ac7 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/.eslintrc.cjs @@ -0,0 +1,35 @@ +/** + * 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. + */ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/package.json b/ambari-admin/src/main/resources/ui/ambari-admin/package.json index 748f3390086..1653c10c7ee 100644 --- a/ambari-admin/src/main/resources/ui/ambari-admin/package.json +++ b/ambari-admin/src/main/resources/ui/ambari-admin/package.json @@ -5,50 +5,53 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "test": "vitest", "coverage": "vitest run --coverage" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", - "@fortawesome/react-fontawesome": "^0.2.2", - "@tanstack/react-table": "^8.20.5", - "@types/lodash": "^4.17.12", - "axios": "^1.7.7", + "@fortawesome/react-fontawesome": "^3.1.1", + "@tanstack/react-table": "^8.17.3", + "@types/react-table": "^7.7.20", + "axios": "^1.7.2", "bootstrap": "^5.3.3", - "history": "^5.3.0", "lodash": "^4.17.21", - "path": "^0.12.7", - "react": "^18.3.1", - "react-bootstrap": "^2.10.5", - "react-dom": "^18.3.1", + "react": "^18.2.0", + "react-bootstrap": "^2.10.2", + "react-bootstrap-icons": "^1.11.4", + "react-cookie": "^7.1.4", + "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "react-router-dom": "^5.3.4", - "react-select": "^5.8.3", - "sass": "^1.77.6" + "react-select": "^5.8.0", + "react-table": "^7.8.0", + "sass": "1.77.6" }, "devDependencies": { - "@eslint/js": "^9.11.1", - "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.6.3", + "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", - "@types/axios": "^0.14.4", - "@types/history": "^5.0.0", - "@types/react": "^18.3.10", - "@types/react-dom": "^18.3.0", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.13", + "@types/lodash": "^4.17.6", + "@types/node": "^22.5.0", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", "@types/react-router-dom": "^5.3.3", - "@vitejs/plugin-react": "^4.3.2", + "@types/testing-library__react": "^10.2.0", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-istanbul": "^2.1.1", - "eslint": "^9.11.1", - "eslint-plugin-react-hooks": "^5.1.0-rc.0", - "eslint-plugin-react-refresh": "^0.4.12", - "globals": "^15.9.0", - "jsdom": "^25.0.1", - "typescript": "^5.5.3", - "typescript-eslint": "^8.7.0", - "vite": "^5.4.8", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "jsdom": "^25.0.0", + "typescript": "^5.5.4", + "vite": "^5.4.21", "vitest": "^2.1.1" } } diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/AmbariAboutModal.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/AmbariAboutModal.tsx new file mode 100644 index 00000000000..18bd71ad4d2 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/AmbariAboutModal.tsx @@ -0,0 +1,102 @@ +/** + * 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 { Button, Image, Modal } from "react-bootstrap"; +import ClusterApi from "./api/clusterApi"; +import AppContent from "./context/AppContext"; +import { get } from "lodash"; +import Spinner from "./components/Spinner"; +import AmbariLogo from "./assets/img/ambari-logo.png" +type AmbariAboutModalProps = { + isOpen: boolean; + onClose: () => void; +}; + +export default function AmbariAboutModal({ + isOpen, + onClose, +}: AmbariAboutModalProps) { + const { ambariVersion, setAmbariVersion } = useContext(AppContent); + const [loading, setLoading] = useState(false); + + useEffect(() => { + async function getAmbariAboutInfo() { + setLoading(true); + const data: any = await ClusterApi.adminAboutInfo( + "RootServiceComponents/component_version,RootServiceComponents/properties/server.os_family&minimal_response=true" + ); + console.log("version", get(data, "RootServiceComponents.component_version")) + setAmbariVersion(get(data, "RootServiceComponents.component_version")); + setLoading(false); + } + if (!ambariVersion) { + getAmbariAboutInfo(); + } + }, []); + + return ( + + + +

About

+
+
+ + {loading ? ( + + ) : ( +
+ +
+

Apache Ambari

+
Version {ambariVersion}
+ + +
+
+ )} +
+ + + +
+ ); +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/App.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/App.tsx index f37ef639dc4..fb9e299182c 100644 --- a/ambari-admin/src/main/resources/ui/ambari-admin/src/App.tsx +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/App.tsx @@ -16,36 +16,126 @@ * limitations under the License. */ import Routes from "./router/Routes"; -import { SideItemLabels } from "./layout/SideItemList"; -import SideBar from "./layout/SideBar"; +import { SideItemLabels } from "./SideItemList"; +import SideBar from "./SideBar"; import { Container, Card } from "react-bootstrap"; -import { useState } from "react"; -import NavBar from "./layout/NavBar"; +import { useEffect, useState } from "react"; +import NavBar from "./NavBar"; import AppContent from "./context/AppContext"; +import { HostCluster } from "./types"; +import { get } from "lodash"; import { Toaster } from "react-hot-toast"; -import { HashRouter } from "react-router-dom"; +import { HashRouter, Route } from "react-router-dom"; +import ClusterApi from "./api/clusterApi"; +import Spinner from "./components/Spinner"; +import InstallClusterButton from "./components/InstallClusterButton.tsx"; +import { Form } from "react-bootstrap"; +import ClusterInformationNavigate from "./components/ClusterInformationNavigate.tsx"; +import usePolling from "./hooks/usePolling.ts"; +import clusterApi from "./api/clusterApi"; +import InactivityTimeout from "./InactivityTimeout.tsx"; +import InstallBox from "./assets/img/install-box.svg" function App() { + const [clusterInfo, setClusterInfo] = useState( + {} as HostCluster + ); + const [loading, setLoading] = useState(false); const [selectedOption, setSelectedOption] = useState( SideItemLabels.CLUSTERINFORMATION ); const [rbacData, setRbacData] = useState({}); const [ambariVersion, setAmbariVersion] = useState(""); const [permissionLabelList, setPermissionLabelList] = useState([]); + const [clusterExists, setClusterExists] = useState(false); + const [clusterInfoLoading, setClusterInfoLoading] = useState(true); + const [isInstallWizardLaunched, setInstallWizardLaunched] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [userSessiontTimeout, setUserSessiontTimeout] = useState(); + + useEffect(() => { + async function getUserTimeout() { + try { + const response = await clusterApi.getUserTimeout(); + if (response.status === 200) { + const userTimeoutInSeconds = response.data.RootServiceComponents.properties["server.http.session.inactive_timeout"]; + setUserSessiontTimeout(userTimeoutInSeconds * 1000); + } + } catch (error) { + + } + } + getUserTimeout(); + }, []); + + async function pollNoopUserTimeout() { + try { + const response = await clusterApi.noopPolling(); + if (response.status === 403) { + localStorage.clear(); + window.location.replace("/#/login"); + } + } catch (error) { + console.error("Error in noop polling", error); + localStorage.clear(); + window.location.replace("/#/login"); + } + } + usePolling(pollNoopUserTimeout, 10000); + + useEffect(() => { + async function getClusterInfoData() { + setLoading(true); + const data = await ClusterApi.hostClustersInfo(); + const hostClusterInfo = get(data, "items[0].Clusters", ""); + setClusterInfo(hostClusterInfo); + setLoading(false); + } + getClusterInfoData(); + }, []); - console.log("In App"); + useEffect(() => { + async function checkClusterExists() { + console.log("checking cluster exists"); + const response = await ClusterApi.clusterInfo( + "Clusters/provisioning_state,Clusters/security_type,Clusters/version,Clusters/cluster_id" + ); + if ( + response && + response.items && + response.items.length > 0 && + response.items[0].Clusters.provisioning_state === "INSTALLED" + ) { + setClusterExists(true); + setClusterInfoLoading(false); + } else { + setClusterExists(false); + setClusterInfoLoading(true); + } + } + checkClusterExists(); + }, []); + if (loading) { + return ; + } + const handleRedirectToInstallCluster = () => { + setInstallWizardLaunched(true); + }; return (
+ {clusterInfoLoading && !isInstallWizardLaunched && !clusterExists && + (window.location.hash.endsWith('/clusterInformation') || window.location.hash.endsWith('/')) ? ( + <> + + Welcome to Apache Ambari + + + Provision a cluster, manage who can access the cluster, and customize views for Ambari users. + + + ) : null} + {clusterInfoLoading && !isInstallWizardLaunched && !clusterExists && + (window.location.hash.endsWith('/clusterInformation') || window.location.hash.endsWith('/')) ? ( + + Create a Cluster + + Use the Install Wizard to select services and configure + your cluster + + + + + ) : null} + + +
+
); } diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/InactivityTimeout.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/InactivityTimeout.tsx new file mode 100644 index 00000000000..2f16e3777cd --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/InactivityTimeout.tsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect, useRef } from 'react'; +import {Button, Modal} from 'react-bootstrap'; + +interface InactivityTimeoutProps { + timeout: number; +} + +const InactivityTimeout: React.FC = ({ timeout }) => { + const TIME_OUT = timeout; + const [lastActiveTime, setLastActiveTime] = useState(Date.now()); + const [isModalOpen, setIsModalOpen] = useState(false); + const [remainTime, setRemainTime] = useState(60); + const intervalRef = useRef(null); + + const keepActive = () => { + setLastActiveTime(Date.now()); + }; + + useEffect(() => { + const handleMouseMove = keepActive; + const handleKeyPress = keepActive; + const handleClick = keepActive; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('keypress', handleKeyPress); + window.addEventListener('click', handleClick); + + intervalRef.current = setInterval(async () => { + const timeElapsed = Date.now() - lastActiveTime; + const remainingTime = TIME_OUT - timeElapsed; + + if (remainingTime < 0) { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('keypress', handleKeyPress); + window.removeEventListener('click', handleClick); + if (intervalRef.current) clearInterval(intervalRef.current); + localStorage.clear(); + window.location.replace("/#/login") + } else if (remainingTime < 60000 && !isModalOpen) { + setIsModalOpen(true); + } + }, 1000); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('keypress', handleKeyPress); + window.removeEventListener('click', handleClick); + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [lastActiveTime, TIME_OUT, isModalOpen]); + + useEffect(() => { + if (isModalOpen) { + const countdownInterval = setInterval(() => { + setRemainTime(prev => { + if (prev === 1) { + localStorage.clear(); + window.location.replace("/#/login"); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(countdownInterval); + } + }, [isModalOpen]); + + const handleRemainLoggedIn = () => { + setIsModalOpen(false); + setRemainTime(60); + setLastActiveTime(Date.now()); + }; + + const handleLogout = async () => { + setIsModalOpen(false); + localStorage.clear(); + window.location.replace("/#/login"); + }; + + return ( + + + Automatic Logout + + +

You will be automatically logged out in {remainTime} seconds due to inactivity

+
+ + + + +
+ ); +}; + +export default InactivityTimeout; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/NavBar.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/NavBar.tsx new file mode 100644 index 00000000000..7a8a364f601 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/NavBar.tsx @@ -0,0 +1,116 @@ +/** + * 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 { faUser } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import {useEffect, useState} from "react"; +import { + Container, + Navbar, + Nav, + Dropdown, + DropdownDivider, + NavDropdown +} from "react-bootstrap"; +import { + decryptData, + getFromLocalStorage, + parseJSONData} from "./api/Utility.ts"; +import { get }from "lodash"; +import AmbariAboutModal from "./AmbariAboutModal"; +import signOut from "./api/logout.ts"; + +type NavBarProps = { + subPath: string; + clusterName: string; +}; + +export default function NavBar({ subPath, clusterName }: NavBarProps) { + const [showAmbariAboutModal, setShowAmbariAboutModal] = useState(false); + const [loginUserName, setLoginUserName] = useState(""); + const [ambariLsVal, setAmbariLsVal] = useState(null); + + useEffect(() => { + let ambariKey = getFromLocalStorage('ambari'); + if (ambariKey) { + setAmbariLsVal(parseJSONData(decryptData(ambariKey))); + } + }, []); + + useEffect(() => { + if (ambariLsVal) { + const loginName = get(ambariLsVal, 'app.loginName'); + if (loginName) { + setLoginUserName(loginName); + } + } + }, [ambariLsVal]); + + return ( +
+ {showAmbariAboutModal ? ( + setShowAmbariAboutModal(false)} + /> + ) : null} + + + + {" "} + Admin / +
+ {subPath} +
+
+
+ + {clusterName} + + + + +
{loginUserName}
+
+ + + { + setShowAmbariAboutModal(true); + }} + > + About + + + Sign out + +
+
+
+
+
+ ); +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/SideBar.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/SideBar.tsx new file mode 100644 index 00000000000..10ebc88944c --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/SideBar.tsx @@ -0,0 +1,166 @@ +/** + * 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. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Collapse } from "react-bootstrap"; +import SideItem from "./SideItem"; +import { useContext, useEffect, useState } from "react"; +import { SideItemLabels } from "./SideItemList"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faAngleDoubleLeft, + faAngleDoubleRight, +} from "@fortawesome/free-solid-svg-icons"; +import AppContent from "./context/AppContext"; +import SidebarItem from "./components/SidebarItem"; +import SidebarItemCollapsed from "./components/SidebarItemCollapsed"; +import RoutesList from "./router/RoutesList"; +import getSideItemList from "./SideItemList"; + +type SideBarProps = { + isRoot?: boolean; + isSidebarCollapsed:boolean; + setIsSidebarCollapsed: React.Dispatch>; + clusterExists?: boolean; +}; + +const SideBar = ({ clusterExists, isSidebarCollapsed,setIsSidebarCollapsed }: SideBarProps) => { + const SideItemList: SideItem[] = getSideItemList(clusterExists ?? false); + const [openOptions, setOpenOptions] = useState([ + SideItemLabels.CLUSTERMANAGEMENT, + ]); + const { selectedOption, setSelectedOption } = useContext(AppContent); + const isElementOpen = (id: string) => { + return openOptions.includes(id); + }; + + useEffect(() => { + const currentHash = window.location.hash; + const currentPath = currentHash.replace("#", ""); + const matchedRoute = RoutesList.find( + (route: any) => route.path === currentPath + ); + if (matchedRoute) { + setSelectedOption(matchedRoute.name); + } + }, []); + + const handleSideItemClick = (itemId: string) => { + if (isElementOpen(itemId)) { + setOpenOptions(openOptions.filter((opt) => opt !== itemId)); + } else { + setOpenOptions([...openOptions, itemId]); + } + }; + if (!isSidebarCollapsed) { + return ( +
+
+ {SideItemList.map((ele) => { + if (ele.children.length) { + return ( + <> + 0} + onClick={() => { + handleSideItemClick(ele.id); + }} + /> + +
+ {ele.children.map((child) => { + return ( + { + setSelectedOption(child.id); + }} + /> + ); + })} +
+
+ + ); + } else { + return ( + { + setSelectedOption(ele.id); + }} + ele={ele} + /> + ); + } + })} +
+
{ + setIsSidebarCollapsed(!isSidebarCollapsed); + }} + > + +
+
+ ); + } else { + return ( +
+
+ {SideItemList.map((ele) => { + return ( + + ); + })} +
+
{ + setIsSidebarCollapsed(!isSidebarCollapsed); + }} + > + +
+
+ ); + } +}; + +export default SideBar; diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/SideItem.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/SideItem.tsx new file mode 100644 index 00000000000..784cb139224 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/SideItem.tsx @@ -0,0 +1,30 @@ +/** + * 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 { ReactNode } from "react"; + +type SideItem = { + id: string; + icon: ReactNode; + name: ReactNode; + path?: string; + children: SideItem[]; + style?: unknown; + className?: string; +}; + +export default SideItem; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/SideItemList.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/SideItemList.tsx new file mode 100644 index 00000000000..3103eb328e3 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/SideItemList.tsx @@ -0,0 +1,134 @@ +/** + * 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 SideItem from "./SideItem"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faTachometerAlt, + faCloud, + faUsers, + faTh, +} from "@fortawesome/free-solid-svg-icons"; +import { Image } from "react-bootstrap"; +import AmbariLogo from "./assets/img/ambari-logo.png"; + +export enum SideItemLabels { + LOGO = "logo", + DASHBOARD = "dashboard", + CLUSTERMANAGEMENT = "Cluster Management", + CLUSTERINFORMATION = "Cluster Information", + VERSIONS = "Versions", + REMOTECLUSTERS = "Remote Clusters", + USERS = "Users", + VIEWS = "Views", +} +// START GENAI@CHATGPT4 +const getSideItemList = (clusterExists: boolean): SideItem[] => { + const baseList: SideItem[] = [...SideItemList]; + + const clusterMgmtItem = baseList.find( + (item) => item.id === SideItemLabels.CLUSTERMANAGEMENT + ); + + if (clusterMgmtItem) { + const versionsChildIndex = clusterMgmtItem.children.findIndex(child => child.id === SideItemLabels.VERSIONS); + + if (clusterExists) { + // If cluster exists, add the VERSIONS child to CLUSTERMANAGEMENT + if (versionsChildIndex === -1) { + clusterMgmtItem.children.push({ + id: SideItemLabels.VERSIONS, + icon: null, + name: "Versions", + path: "/stackVersions", + children: [], + }); + } + } else { + // If cluster does not exist, remove the VERSIONS child from CLUSTERMANAGEMENT + if (versionsChildIndex !== -1) { + clusterMgmtItem.children.splice(versionsChildIndex, 1); + } + } + } + + return baseList; +}; + +const SideItemList: SideItem[] = [ + { + id: SideItemLabels.LOGO, + icon: ( + + ), + name:
Ambari
, + path: "/dashboard", + children: [], + style: { background: "#313d54", height: "60px" }, + }, + { + id: SideItemLabels.DASHBOARD, + icon: , + name: "Dashboard", + path: "/dashboard", + children: [], + }, + { + id: SideItemLabels.CLUSTERMANAGEMENT, + icon: , + name: "Cluster Management", + children: [ + { + id: SideItemLabels.CLUSTERINFORMATION, + icon: null, + name: "Cluster Information", + path: "/clusterInformation", + children: [], + }, + { + id: SideItemLabels.VERSIONS, + icon: null, + name: "Versions", + path: "/stackVersions", + children: [], + }, + { + id: SideItemLabels.REMOTECLUSTERS, + icon: null, + name: "Remote Clusters", + path: "/remoteClusters", + children: [], + }, + ], + }, + { + id: SideItemLabels.USERS, + icon: , + name: "Users", + path: "/userManagement?tab=users", + children: [], + }, + { + id: SideItemLabels.VIEWS, + icon: , + name: "Views", + path: "/views", + children: [], + }, +]; + +export default getSideItemList; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockClusterBluePrintInfo.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockClusterBluePrintInfo.ts new file mode 100644 index 00000000000..e1f2432d0df --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockClusterBluePrintInfo.ts @@ -0,0 +1,19 @@ +/** + * 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. + */ +const mockClusterBluePrintInfo = {}; +export default mockClusterBluePrintInfo; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockEditInstance.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockEditInstance.ts new file mode 100644 index 00000000000..d0ae5d98f16 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockEditInstance.ts @@ -0,0 +1,484 @@ +/** + * 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 mockInstanceDetails = { + href: 'http://example.com/root', + ViewInstanceInfo: { + cluster_handle: 123, + cluster_type: 'LOCAL_AMBARI', + context_path: '/context/path', + description: 'A description of the view instance', + icon64_path: null, + icon_path: null, + instance_name: 'Instance1', + label: 'Instance Label', + short_url: 'http://short.url', + short_url_name: 'short-url', + static: false, + validation_result: { + valid: true, + detail: 'Validation successful' + }, + version: '1.0', + view_name: 'View1', + visible: true, + instance_data: {}, + properties: { + "hdfs.auth_to_local": { + viewInfo: { + name: 'hdfs.auth_to_local', + description: 'Description for hdfs.auth_to_local', + label: 'HDFS Auth To Local', + placeholder: null, + defaultValue: null, + clusterConfig: 'cluster-config', + required: true, + masked: false, + value: null, + isSetting: false + } + }, + "hdfs.umask-mode": { + viewInfo: { + name: 'hdfs.umask-mode', + description: 'Description for hdfs.umask-mode', + label: 'HDFS Umask Mode', + placeholder: null, + defaultValue: '022', + clusterConfig: 'cluster-config', + required: true, + masked: false, + value: '022', + isSetting: false + } + }, + "tmp.dir": { + viewInfo: { + name: 'tmp.dir', + description: 'Description for tmp.dir', + label: 'Temporary Directory', + placeholder: '/tmp', + defaultValue: '/tmp', + clusterConfig: null, + required: true, + masked: false, + value: '/tmp', + isSetting: false + } + }, + "view.conf.keyvalues": { + viewInfo: { + name: 'view.conf.keyvalues', + description: 'Description for view.conf.keyvalues', + label: 'View Config Key Values', + placeholder: null, + defaultValue: null, + clusterConfig: null, + required: false, + masked: false, + value: null, + isSetting: false + } + }, + "webhdfs.auth": { + viewInfo: { + name: 'webhdfs.auth', + description: 'Description for webhdfs.auth', + label: 'WebHDFS Auth', + placeholder: 'auth-placeholder', + defaultValue: null, + clusterConfig: null, + required: true, + masked: false, + value: null, + isSetting: false + } + }, + "webhdfs.client.failover.proxy.provider": { + viewInfo: { + name: 'webhdfs.client.failover.proxy.provider', + description: 'Description for webhdfs.client.failover.proxy.provider', + label: 'WebHDFS Client Failover Proxy Provider', + placeholder: null, + defaultValue: null, + clusterConfig: 'cluster-config', + required: true, + masked: false, + value: null, + isSetting: false + } + }, + "webhdfs.ha.namenode.http-address.list": { + viewInfo: { + name: 'webhdfs.ha.namenode.http-address.list', + description: 'Description for webhdfs.ha.namenode.http-address.list', + label: 'WebHDFS HA Namenode HTTP Address List', + placeholder: null, + defaultValue: null, + clusterConfig: 'cluster-config', + required: true, + masked: false, + value: null, + isSetting: false + } + }, + "webhdfs.ha.namenode.https-address.list": { + viewInfo: { + name: 'webhdfs.ha.namenode.https-address.list', + description: 'Description for webhdfs.ha.namenode.https-address.list', + label: 'WebHDFS HA Namenode HTTPS Address List', + placeholder: null, + defaultValue: null, + clusterConfig: 'cluster-config', + required: true, + masked: false, + value: null, + isSetting: false + } + }, + "webhdfs.ha.namenode.rpc-address.list": { + viewInfo: { + name: 'webhdfs.ha.namenode.rpc-address.list', + description: 'Description for webhdfs.ha.namenode.rpc-address.list', + label: 'WebHDFS HA Namenode RPC Address List', + placeholder: null, + defaultValue: null, + clusterConfig: 'cluster-config', + required: true, + masked: false, + value: null, + isSetting: false + } + }, + "webhdfs.ha.namenodes.list": { + viewInfo: { + name: 'webhdfs.ha.namenodes.list', + description: 'Description for webhdfs.ha.namenodes.list', + label: 'WebHDFS HA Namenodes List', + placeholder: null, + defaultValue: null, + clusterConfig: 'cluster-config', + required: true, + masked: false, + value: null, + isSetting: false + } + }, + "webhdfs.nameservices": { + viewInfo: { + name: 'webhdfs.nameservices', + description: 'Description for webhdfs.nameservices', + label: 'WebHDFS Nameservices', + placeholder: null, + defaultValue: null, + clusterConfig: 'cluster-config', + required: true, + masked: false, + value: null, + isSetting: false + } + }, + "webhdfs.url": { + viewInfo: { + name: 'webhdfs.url', + description: 'Description for webhdfs.url', + label: 'WebHDFS URL', + placeholder: null, + defaultValue: null, + clusterConfig: 'cluster-config', + required: true, + masked: false, + value: null, + isSetting: false + } + }, + "webhdfs.username": { + viewInfo: { + name: 'webhdfs.username', + description: 'Description for webhdfs.username', + label: 'WebHDFS Username', + placeholder: 'username-placeholder', + defaultValue: 'default-username', + clusterConfig: null, + required: true, + masked: false, + value: 'default-username', + isSetting: false + } + } + }, + property_validation_results: { + "hdfs.auth_to_local": { + valid: true, + detail: 'Validation successful for hdfs.auth_to_local' + }, + "hdfs.umask-mode": { + valid: true, + detail: 'Validation successful for hdfs.umask-mode' + }, + "tmp.dir": { + valid: true, + detail: 'Validation successful for tmp.dir' + }, + "view.conf.keyvalues": { + valid: true, + detail: 'Validation successful for view.conf.keyvalues' + }, + "webhdfs.auth": { + valid: true, + detail: 'Validation successful for webhdfs.auth' + }, + "webhdfs.client.failover.proxy.provider": { + valid: true, + detail: 'Validation successful for webhdfs.client.failover.proxy.provider' + }, + "webhdfs.ha.namenode.http-address.list": { + valid: true, + detail: 'Validation successful for webhdfs.ha.namenode.http-address.list' + }, + "webhdfs.ha.namenode.https-address.list": { + valid: true, + detail: 'Validation successful for webhdfs.ha.namenode.https-address.list' + }, + "webhdfs.ha.namenode.rpc-address.list": { + valid: true, + detail: 'Validation successful for webhdfs.ha.namenode.rpc-address.list' + }, + "webhdfs.ha.namenodes.list": { + valid: true, + detail: 'Validation successful for webhdfs.ha.namenodes.list' + }, + "webhdfs.nameservices": { + valid: true, + detail: 'Validation successful for webhdfs.nameservices' + }, + "webhdfs.url": { + valid: true, + detail: 'Validation successful for webhdfs.url' + }, + "webhdfs.username": { + valid: true, + detail: 'Validation successful for webhdfs.username' + } + } + }, + privileges: [ + { + href: 'http://example.com/privilege/1', + PrivilegeInfo: { + instance_name: 'Instance1', + permission_label: 'Read', + permission_name: 'READ_PRIVILEGE', + principal_name: 'User1', + principal_type: 'USER', + privilege_id: 1, + version: '1.0', + view_name: 'View1', + }, + }, + { + href: 'http://example.com/privilege/2', + PrivilegeInfo: { + instance_name: 'Instance1', + permission_label: 'Write', + permission_name: 'WRITE_PRIVILEGE', + principal_name: 'User2', + principal_type: 'USER', + privilege_id: 2, + version: '1.0', + view_name: 'View1', + }, + }, + ], + resources: [ + { + href: 'http://example.com/resource/1', + instance_name: 'Instance1', + name: 'Resource1', + version: '1.0', + view_name: 'View1', + }, + { + href: 'http://example.com/resource/2', + instance_name: 'Instance1', + name: 'Resource2', + version: '1.0', + view_name: 'View1', + }, + ], +}; + +export const mockGroupData = { + href: 'http://example.com', + items: [ + { + href: 'http://example.com/group/1', + Groups: { + group_name: 'Group One', + group_type: 'LOCAL', + ldap_group: false, + }, + }, + { + href: 'http://example.com/group/2', + Groups: { + group_name: 'Group Two', + group_type: 'LOCAL', + ldap_group: true, + }, + }, + ], +}; + +export const mockPrivileges = +{ + href: 'http://example.com/root', + ViewInstanceInfo: { + instance_name: 'Instance1', + version: '1.0', + view_name: 'View1', + }, + privileges: [ + { + href: 'http://example.com/privilege/1', + PrivilegeInfo: { + instance_name: 'Instance1', + permission_label: 'Read', + permission_name: 'READ_PRIVILEGE', + principal_name: 'User1', + principal_type: 'USER', + privilege_id: 1, + version: '1.0', + view_name: 'View1', + }, + }, + { + href: 'http://example.com/privilege/2', + PrivilegeInfo: { + instance_name: 'Instance1', + permission_label: 'Write', + permission_name: 'WRITE_PRIVILEGE', + principal_name: 'User2', + principal_type: 'USER', + privilege_id: 2, + version: '1.0', + view_name: 'View1', + }, + }, + { + href: 'http://example.com/privilege/2', + PrivilegeInfo: { + instance_name: 'Instance1', + permission_label: 'Write', + permission_name: 'WRITE_PRIVILEGE', + principal_name: 'Group1', + principal_type: 'GROUP', + privilege_id: 2, + version: '1.0', + view_name: 'View1', + }, + }, + ], +}; + +export const mockViewsData = { + href: 'http://example.com', + ViewVersionInfo: { + archive: 'archive.zip', + build_number: '100', + cluster_configurable: true, + description: null, + label: 'Label 1', + masker_class: null, + max_ambari_version: null, + min_ambari_version: 'v1.0', + parameters: [ + { + name: 'param1', + description: 'This is parameter 1', + label: 'Label 1', + placeholder: 'Placeholder 1', + defaultValue: 'Default 1', + clusterConfig: 'Config 1', + required: true, + masked: false, + }, + ], + status: 'active', + status_detail: 'Detail 1', + system: false, + version: 'v1.0', + view_name: 'View 1', + }, + instances: [ + { + href: 'http://example.com/instance/1', + ViewInstanceInfo: { + instance_name: 'Instance 1', + version: 'v1.0', + view_name: 'View 1', + }, + }, + ], + permissions: [ + { + href: 'http://example.com/permission/1', + PermissionInfo: { + permission_id: 1, + version: 'v1.0', + view_name: 'View 1', + }, + }, + ], +}; + +export const mockUsersdata = { + href: 'http://example.com', + items: [ + { + href: 'http://example.com/user/1', + Users: { + active: true, + admin: false, + consecutive_failures: 0, + created: 1622470423, + display_name: 'User One', + groups: ['group1', 'group2'], + ldap_user: false, + local_user_name: 'userone', + user_name: 'userone', + user_type: 'LOCAL', + }, + }, + { + href: 'http://example.com/user/2', + Users: { + active: false, + admin: true, + consecutive_failures: 3, + created: 1622470424, + display_name: 'User Two', + groups: ['group3'], + ldap_user: true, + local_user_name: 'usertwo', + user_name: 'usertwo', + user_type: 'LOCAL', + }, + }, + ], +}; + diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockHostClusterInfo.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockHostClusterInfo.ts new file mode 100644 index 00000000000..164a31f0093 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockHostClusterInfo.ts @@ -0,0 +1,27 @@ +/** + * 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. + */ +const mockHostClusterInfo = { + items: [ + { + Clusters: { + provisioning_state: "NOT INSTALLED" + } + } + ] +} +export default mockHostClusterInfo; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUpdateClusterName.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUpdateClusterName.ts new file mode 100644 index 00000000000..9718ed7ea28 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUpdateClusterName.ts @@ -0,0 +1,28 @@ +/** + * 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. + */ +const mockUpdateClusterName = (clusterName :string, updatedClusterName :string) => { + return new Promise((resolve, reject) => { + if (!clusterName || !updatedClusterName) { + reject(new Error('Both clusterName and updatedClusterName must be truthy')); + } else { + resolve({ clusterName, updatedClusterName }); + } + }); +}; + +export default mockUpdateClusterName; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/api/logout.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/api/logout.ts new file mode 100644 index 00000000000..de867e96fed --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/api/logout.ts @@ -0,0 +1,53 @@ +/** + * 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 {encryptData, decryptData, getFromLocalStorage, parseJSONData, setInLocalStorage} from "./Utility.ts"; +import {adminApi} from "./configs/axiosConfig.ts"; +import { AxiosError } from 'axios'; + +const signOut = async () => { + let ambariKey = getFromLocalStorage('ambari'); + let data; + if (ambariKey) { + data = parseJSONData(decryptData(ambariKey)); + } + delete data.app.authenticated; + delete data.app.loginName; + delete data.app.user; + + //with encrypting set data in LS + setInLocalStorage('ambari', encryptData(JSON.stringify(data))); + + const headers = { + 'Authorization': 'Basic' + }; + + try { + const url = "/logout" + await adminApi.request({ + url: url, + method: 'GET', + headers: headers + }); + localStorage.clear(); + window.location.replace("/#/login"); + } catch (error) { + const axiosError = error as AxiosError; + throw new Error(`Logout failed with status: ${axiosError.response?.status}`); + } +} +export default signOut; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/clusterHost.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/clusterHost.ts new file mode 100644 index 00000000000..aa150642102 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/clusterHost.ts @@ -0,0 +1,19 @@ +// export const DEV_VITE_API_PROXY_TARGET="http://sl73tskrapd1164.visa.com:8080" +// export const PROD_VITE_API_PROXY_TARGET="" + +// // # When setting up the project, make sure to set the TOKEN environment variable. +// // # This should be a Basic Auth token generated from your username and password for the given cluster. +// // # You can do this by uncommenting the following line and replacing 'your-basic-auth-token' with your actual Basic Auth token. ( eg 'YWRtaW46VmlzYUAxMjM=') +// export const DEV_VITE_TOKEN="YWRtaW46VmlzYUAxMjM=" + + +export const config={ + development:{ + VITE_API_PROXY_TARGET:"http://##REPLACE_YOUR_AMBARI_SERVER_URL_HERE", + VITE_TOKEN:"##REPLACE_YOUR_AUTH_TOKEN_HERE" + }, + production:{ + VITE_API_PROXY_TARGET:"", + VITE_TOKEN:"" + } + } \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/AddVersionModal.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/AddVersionModal.tsx new file mode 100644 index 00000000000..ffafb0403d9 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/AddVersionModal.tsx @@ -0,0 +1,134 @@ +/** + * 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. + */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/ban-types */ +import { useState } from "react"; +import { Button, Form, FormControl, Modal } from "react-bootstrap"; +import DefaultButton from "./DefaultButton"; +import { ReadOptions } from "../constants"; +import VersionsApi from "../api/versions"; +import toast from "react-hot-toast"; +import { get } from "lodash"; + +type ModalProps = { + isOpen: boolean; + onClose: () => void; + onReadVersion: Function; +}; + +const AddVersionModal = ({ isOpen, onClose, onReadVersion }: ModalProps) => { + const [uploadOption, setUploadOption] = useState(ReadOptions.FILE); + const [file, setFile] = useState(); + const [fileUrl, setFileUrl] = useState(""); + const readVersionInfo = async () => { + try { + if (uploadOption === ReadOptions.FILE && file) { + const reader = new FileReader(); + reader.onload = async function (event) { + const fileContents = get(event, "target.result", undefined); + const versionResources = await VersionsApi.readVersionInfo( + fileContents, + { + "Content-Type": "text/xml", + } + ); + onReadVersion(versionResources, fileContents, null); + }; + reader.readAsText(file); + } else if (uploadOption === ReadOptions.URL && fileUrl) { + // Fetch the content from the URL + const versionResources = await VersionsApi.readVersionInfo({ + VersionDefinition: { + version_url: fileUrl, + }, + }); + onReadVersion(versionResources, null, fileUrl); + } + } catch (err) { + toast.error("Could not read version defintion"); + } + }; + const handleClose = () => { + setFile(undefined); + setFileUrl(""); + onClose(); + }; + return ( + + + Add Version + + + { + setUploadOption(ReadOptions.FILE); + }} + label="Upload Version Definition File" + className="px-0" + /> + { + //@ts-ignore + setFile((event?.target as HTMLInputElement)?.files?.[0]); + }} + disabled={uploadOption !== ReadOptions.FILE} + /> + { + setUploadOption(ReadOptions.URL); + }} + type="radio" + label="Version Definition File URL" + className="px-0 mt-3" + /> + { + setFileUrl(e.target.value); + }} + /> + + + + CANCEL + + + + + ); +}; + +export default AddVersionModal; diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ClusterInformationNavigate.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ClusterInformationNavigate.tsx new file mode 100644 index 00000000000..6912ddfab16 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ClusterInformationNavigate.tsx @@ -0,0 +1,33 @@ +/** + * 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 { useEffect } from 'react'; + +interface ClusterInformationNavigateProps { + setInstallWizardLaunched: (value: boolean) => void; +} + +const ClusterInformationNavigate = ({ setInstallWizardLaunched }: ClusterInformationNavigateProps) => { + + useEffect(() => { + setInstallWizardLaunched(false); + }, []); + + return null; // Remember to return something to avoid runtime error +}; + +export default ClusterInformationNavigate; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ComboSearch.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ComboSearch.tsx new file mode 100644 index 00000000000..f3bf86b48fa --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ComboSearch.tsx @@ -0,0 +1,223 @@ +/** + * 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. + */ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect, useState } from "react"; +import Select from "react-select"; +import { get } from "lodash"; +import { Badge, Button } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faClose } from "@fortawesome/free-solid-svg-icons"; +type FilterField = { label: string; value: string }; +type PropTypes = { + fields: FilterField[]; + data: any; + valueMappings?: { [key: string]: string }; + searchCallback: Function; +}; +function ComboSearch({ fields, data, searchCallback }: PropTypes) { + const [selectedFilters, setSelectedFilters] = useState< + { field: FilterField; value: FilterField }[] + >([]); + const [selectedField, setSelectedField] = useState( + {} as FilterField + ); + const [selectedValue, setSelectedValue] = useState( + {} as FilterField + ); + const [valueOptions, setValueOptions] = useState([]); + function getCorrespondingValues(){ + const mappedKey = selectedField?.value; + let allValues: any[] = []; + data.forEach((item: any) => { + const value = get(item, mappedKey, ""); + if (!value) { + // Ignore empty value + return; + } + if (Array.isArray(value)) { + allValues = [...allValues, ...value]; + } else if (typeof value !== "object") { + allValues.push(value); + } + }); + const uniqueValues = [...new Set(allValues)]; + console.log("Unique values", uniqueValues); + const correspondingValues = uniqueValues.filter(item=>{ + return selectedValue?.value!==item&&!!!selectedFilters?.find(fil=>fil?.value?.value===item) + }).map((item: any) => { + return { + label: item, + value: item, + }; + }); + return correspondingValues; + } + // START GENAI@CHATGPT4 + useEffect(() => { + if (selectedField) { + const correspondingValues = getCorrespondingValues(); + setSelectedValue({} as FilterField); + setValueOptions(correspondingValues); + } + }, [selectedField]); + + useEffect(()=>{ + if(selectedValue){ + const correspondingValues=getCorrespondingValues(); + setValueOptions(correspondingValues); + } + },[selectedValue,selectedFilters]) + + const filterData = (data: any) => { + const categoryFilters: { [key: string]: any } = {}; + + selectedFilters.forEach((filter) => { + const category = filter?.field?.value; + if (!categoryFilters[category]) { + categoryFilters[category] = []; + } + categoryFilters[category].push(filter?.value?.value); + }); + + const filteredData = data.filter((item: any) => { + return Object.keys(categoryFilters).every((category) => { + // Apply OR logic within the same category + const itemValue = get(item, category); + return Array.isArray(itemValue) + ? itemValue.some((v) => categoryFilters[category].includes(v)) + : categoryFilters[category].includes(itemValue); + }); + }); + return filteredData; + }; + useEffect(() => { + if (selectedFilters.length) { + searchCallback(filterData(data)); + } else { + searchCallback(data); + } + console.log("Selected Filters", selectedFilters); + }, [selectedFilters.length]); + function addFilter(e: any) { + e.preventDefault(); + const newFilter = { field: selectedField, value: selectedValue }; + if ( + !selectedFilters.some( + (filter) => + filter?.field?.value === newFilter?.field?.value && + filter?.value?.value === newFilter?.value?.value + ) + ) { + setSelectedFilters([...selectedFilters, newFilter]); + setSelectedField(null as any); + setSelectedValue(null as any) + } + } + function deleteFilter(filterToDelete: { + field: { label: string; value: any }; + value: { label: string; value: any }; + }) { + setSelectedFilters((prevFilters) => { + return prevFilters.filter((filter) => { + return !( + filter?.field?.value === filterToDelete?.field?.value && + filter?.value?.value === filterToDelete?.value?.value + ); + }); + }); + } + function resetFilters() { + setSelectedField(null as any); + setSelectedValue(null as any); + setSelectedFilters([]); + } + return ( +
+
+ Select filter(s) to tailor your search. Records update immediately to + reflect your preferences. +
+
+
+ + + + +
+
+
+ {selectedFilters.map((fil, index) => { + return ( + 0 ? "ms-2" : "" + }`} + > +
{fil.field.label}:
+
{fil?.value?.label}
+ { + deleteFilter(fil); + }} + className="delete-filter cursot-pointer ms-2" + /> +
+ ); + })} +
+
+ ); +} + +export default ComboSearch; diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ConfirmationModal.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ConfirmationModal.tsx new file mode 100644 index 00000000000..3d2b9279214 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ConfirmationModal.tsx @@ -0,0 +1,72 @@ +/** + * 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 { Button, Modal } from "react-bootstrap"; +import DefaultButton from "./DefaultButton"; + +type ConfirmationModalProps = { + isOpen: boolean; + onClose: () => void; + modalTitle: string; + modalBody: string; + successCallback: () => void; + buttonVariant?: string; +}; + +export default function ConfirmationModal({ + isOpen, + onClose, + modalTitle, + modalBody, + successCallback, + buttonVariant = "success", +}: ConfirmationModalProps) { + return ( + + + +

{modalTitle}

+
+
+ {modalBody} + + + CANCEL + + + +
+ ); +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/DefaultButton.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/DefaultButton.tsx new file mode 100644 index 00000000000..9bc09cfa866 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/DefaultButton.tsx @@ -0,0 +1,25 @@ +/** + * 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 { Button } from "react-bootstrap"; + +function DefaultButton({ className, ...props }: any) { + className = className ? className + " btn-default" : "btn-default"; + return ; +} + +export default DefaultButton; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ErrorOverlay.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ErrorOverlay.tsx new file mode 100644 index 00000000000..cd39597484d --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/ErrorOverlay.tsx @@ -0,0 +1,38 @@ +/** + * 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 { Overlay, Tooltip } from "react-bootstrap"; + +type ErrorOverlayProps = { + target: React.RefObject; + showTooltip: boolean; + errorMessage: string; +} + +export default function ErrorOverlay({target, showTooltip, errorMessage}: ErrorOverlayProps) { + return + {(props) => ( + + {errorMessage} + + )} + +} \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/InstallClusterButton.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/InstallClusterButton.tsx new file mode 100644 index 00000000000..ece3a1d41b0 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/InstallClusterButton.tsx @@ -0,0 +1,39 @@ +/** + * 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 {Button} from "react-bootstrap"; + +interface InstallClusterButtonProps { + onButtonClick: () => void; + setInstallWizardLaunched: (value: boolean) => void; +} + +const InstallClusterButton: React.FC = ({ onButtonClick, setInstallWizardLaunched }) => { + const handleRedirectToInstallCluster = () => { + setInstallWizardLaunched(false); + window.location.href = "/#/installer/step0"; + onButtonClick(); + }; + + return ( + + ); +} + +export default InstallClusterButton; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/LostNetworkModal.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/LostNetworkModal.tsx new file mode 100644 index 00000000000..62dc353c47f --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/LostNetworkModal.tsx @@ -0,0 +1,56 @@ +/** + * 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 { Button, Modal } from "react-bootstrap"; + +type PropTypes = { + onClose: () => void; + isOpen: boolean; +}; + +const LostNetworkModal = ({ onClose, isOpen }: PropTypes) => { + const options = [ + "Configure your hosts for access to the Internet.", + " If you are using an Internet Proxy, refer to the Ambari Documentation on how to configure Ambari to use the Internet Proxy.", + "Use the Local Repository option.", + ]; + return ( + + + Public Repository Option Disabled + + + Ambari does not have access to the Internet and cannot use the Public + Repository for installing the software. Your Options: +
    + {options.map((opt:string)=>{ + return
  • + {opt} +
  • + })} +
+
+ + + +
+ ); +}; + +export default LostNetworkModal; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/Paginator.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/Paginator.tsx new file mode 100644 index 00000000000..b38ba54c1da --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/Paginator.tsx @@ -0,0 +1,106 @@ +/** + * 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 { Dropdown, DropdownButton, Pagination } from "react-bootstrap"; + +type PaginatorProps = { + currentPage: number; + maxPage: number; + changePage: (pageNumber: number) => void; + itemsPerPage: number; + setItemsPerPage: (recordsCount: number) => void; + totalItems: number; +}; + +const Paginator = ({ + currentPage, + maxPage, + changePage, + itemsPerPage, + setItemsPerPage, + totalItems, +}: PaginatorProps) => { + const items = []; + const perPageOptions = [10, 25, 50, 100]; + for (let number = 1; number <= maxPage; number++) { + if ( + number === currentPage - 1 || + number === currentPage || + number === currentPage + 1 || + number === 1 || + number === maxPage + ) { + items.push( + changePage(number)} + className="pagination-btn" + > + {number} + + ); + } else if (number === currentPage - 2 || number === currentPage + 2) { + items.push(); + } + } + const firstItemIndex = (currentPage - 1) * itemsPerPage + 1; + const lastItemIndex = Math.min(currentPage * itemsPerPage, totalItems); + return ( + <> + {totalItems > 10 ? ( +
+
+
+ Showing {firstItemIndex}-{lastItemIndex} of {totalItems} items +
+
+ + {perPageOptions.map((perPageOption) => { + return ( + { + setItemsPerPage(perPageOption); + }} + > + {perPageOption} + + ); + })} + + + changePage(currentPage - 1)} + /> + {items} + changePage(currentPage + 1)} + /> + +
+
+
+ ) : null} + + ); +}; + +export default Paginator; diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/RedhatSatelliteInfoModal.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/RedhatSatelliteInfoModal.tsx new file mode 100644 index 00000000000..4169cba959a --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/RedhatSatelliteInfoModal.tsx @@ -0,0 +1,56 @@ +/** + * 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 { Button, Modal } from "react-bootstrap"; +import DefaultButton from "./DefaultButton"; + +type PropTypes = { + isOpen: boolean; + onClose: () => void; + onCancel: () => void; +}; + +const RedhatSatelliteUsageInfo = ({ isOpen, onClose, onCancel }: PropTypes) => { + return ( + + + Use RedHat Satellite/Spacewalk + + + In order for Ambari to install packages from the right repositories, it + is recommended that you edit the names of the repo's for each operating + system so they match the channel names in your RedHat + Satellite/Spacewalk instance. + + + + Cancel + + + + + ); +}; + +export default RedhatSatelliteUsageInfo; diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/SidebarItem.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/SidebarItem.tsx new file mode 100644 index 00000000000..74c2b866917 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/SidebarItem.tsx @@ -0,0 +1,66 @@ +/** + * 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 { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { MouseEventHandler } from "react"; +import { Link } from "react-router-dom"; + +const SidebarItem = ({ + ele, + onClick, + isSelected, + isOpen = false, + hasChildren = false, + }: { + ele: any; + onClick?: MouseEventHandler; + isSelected: boolean; + isOpen?: boolean; + hasChildren?: boolean; + }) => { + return ( + +
+
+
{ele.icon}
+
{ele.name}
+
+ {hasChildren ? ( + + ) : null} +
+ + ); + }; + + export default SidebarItem \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/SidebarItemCollapsed.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/SidebarItemCollapsed.tsx new file mode 100644 index 00000000000..560e9833171 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/SidebarItemCollapsed.tsx @@ -0,0 +1,109 @@ +/** + * 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 { MouseEventHandler, useState } from "react"; +import { Dropdown } from "react-bootstrap"; +import SidebarItem from "./SidebarItem"; + +const SidebarItemCollapsed = ({ + ele, + onClick, + isSelected, + childElements, + selectedOption, + setSelectedOption, + }: { + ele: any; + onClick?: MouseEventHandler; + isSelected: boolean; + isOpen?: boolean; + hasChildren?: boolean; + childElements?: any[]; + selectedOption?: string; + setSelectedOption?: any; + }) => { + const [showDropdown, setShowDropdown] = useState(false); + if (childElements?.length) { + return ( + setShowDropdown(false)} + onMouseOver={() => setShowDropdown(true)} + // style={{ width: "166px" }} + > + +
+
{ele.icon}
+
+
+ {showDropdown ? ( + + {childElements?.map((ele) => { + return ( + { + setSelectedOption(ele.id); + }} + ele={ele} + isSelected={selectedOption === ele.id} + /> + ); + })} + + ) : null} +
+ ); + } else { + return ( +
{ + setSelectedOption(ele.id); + }} + > +
{ele.icon}
+
+ ); + } + }; + + export default SidebarItemCollapsed \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/Spinner.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/Spinner.tsx new file mode 100644 index 00000000000..c4a47c2ea24 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/Spinner.tsx @@ -0,0 +1,26 @@ +/** + * 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 { Spinner as DefaultSpinner } from "react-bootstrap"; + +export default function Spinner() { + return ( +
+ +
+ ); +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/Table.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/Table.tsx new file mode 100644 index 00000000000..6b8bb09dc89 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/Table.tsx @@ -0,0 +1,158 @@ +/** + * 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. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + OnChangeFn, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { Table as BootstrapTable } from "react-bootstrap"; +import "./style.css"; +import { get } from "lodash"; +interface TableProps { + columns: ColumnDef[]; + data: unknown[]; + onSortingChange?: OnChangeFn; + sorting?: SortingState; + className?: string; + restProps?: any; + striped?: boolean; + bordered?: boolean; + hover?: boolean; + entityName?: string; +} + +const Table: React.FC = ({ + columns, + data, + sorting, + onSortingChange, + entityName, + ...restProps +}) => { + const table = useReactTable({ + columns, + data, + debugTable: true, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), //client-side sorting + onSortingChange, + state: { + sorting, + }, + }); + + if (!data.length && entityName) { + return ( +
+

NO {entityName.toUpperCase()} TO DISPLAY.

+
+ ); + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : ( +
{ + if (!header.column.getCanSort()) return; + onSortingChange?.([ + { + id: header.id, + desc: + sorting?.[0].id === header.id + ? !sorting[0].desc + : false, + }, + ]); + header.column.getToggleSortingHandler(); + }} + title={ + header.column.getCanSort() + ? header.column.getNextSortingOrder() === "asc" + ? "Sort ascending" + : header.column.getNextSortingOrder() === "desc" + ? "Sort descending" + : "Clear sort" + : undefined + } + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: "a", + desc: "d", + }[header.column.getIsSorted() as string] ?? null} +
+ )} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ + ); + })} + + ); + })} + +
+
+ ); +}; + +export default Table; diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/components/style.css b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/style.css new file mode 100644 index 00000000000..8a88e1150f4 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/components/style.css @@ -0,0 +1,36 @@ +/** + * 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. + */ +.table thead, +.table thead th { + color: #999 !important; + font-weight: 700 !important; +} +.table tbody td { + color: #666 !important; +} +.table tbody td:hover { + cursor: default; +} +.table > thead > tr > th, +.table > tbody > tr > th, +.table > tfoot > tr > th, +.table > thead > tr > td, +.table > tbody > tr > td, +.table > tfoot > tr > td { + padding: 12px!important; +} \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/hooks/usePolling.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/hooks/usePolling.ts new file mode 100644 index 00000000000..40bf1ec7b8f --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/hooks/usePolling.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { useEffect, useRef } from 'react'; + +function usePolling(apiFunction:Function, interval=10000) { + const savedCallback = useRef(); + + // Remember the latest callback. + useEffect(() => { + savedCallback.current = apiFunction; + }, [apiFunction]); + +// Set up the interval. + useEffect(() => { + function tick() { + if (savedCallback.current) { + savedCallback.current?.(); + } + } + if (interval !== null) { + const id = setInterval(tick, interval); + return () => clearInterval(id); + } + }, [interval]); +} + +export default usePolling \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/router/RoutesList.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/router/RoutesList.tsx index c49cc2dbad7..888ba5dbfb1 100644 --- a/ambari-admin/src/main/resources/ui/ambari-admin/src/router/RoutesList.tsx +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/router/RoutesList.tsx @@ -15,11 +15,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Redirect } from "react-router-dom"; +import ClusterInformation from "../screens/ClusterManagement/ClusterInformation"; +import RemoteClusters from "../screens/ClusterManagement/RemoteClusters"; +import RegisterRemoteCluster from "../screens/ClusterManagement/RemoteClusters/RegisterRemoteCluster"; +import EditRemoteCluster from "../screens/ClusterManagement/RemoteClusters/EditRemoteCluster"; import Users from "../screens/Users"; -import WIP from "../components/WIP"; -import Register from "../screens/StackVersions/Register"; -import { VersionsList } from "../screens/StackVersions"; +import EditGroup from "../screens/Users/EditGroup"; +import EditUser from "../screens/Users/EditUser"; +import Views from "../screens/Views"; +import EditInstance from "../screens/Views/EditInstance"; +import CreateShortUrl from "../screens/Views/CreateShortUrl"; +import Dashboard from "../screens/ClusterManagement/Dasboard"; +import { Redirect } from "react-router-dom"; +import { Register, VersionsList } from "../screens/ClusterManagement/StackVersions"; @@ -27,19 +35,19 @@ export default [ { path: "/main/dashboard", exact: true, - Element: () => , + Element: () => , name: "Home", }, { path: "/dashboard", exact: true, - Element: () => , + Element: () => , name: "Dashboard", }, { path: "/clusterInformation", exact: true, - Element: () => , + Element: () => , name: "Cluster Information", }, { @@ -63,19 +71,19 @@ export default [ { path: "/remoteClusters/:clusterName/edit", exact: true, - Element: () => , + Element: () => , name: "Edit Remote Cluster", }, { path: "/remoteClusters/create", exact: true, - Element: () => , + Element: () => , name: "Register Remote Cluster", }, { path: "/remoteClusters", exact: true, - Element: () => , + Element: () => , name: "Remote Clusters", }, { @@ -87,31 +95,31 @@ export default [ { path: "/users/:userName/edit", exact: true, - Element: () => , + Element: () => , name: "Users", }, { path: "/groups/:groupName/edit", exact: true, - Element: () => , + Element: () => , name: "Groups", }, { path:`/views/:viewName/versions/:version/instances/:instanceName/edit`, exact:true, - Element:()=>, + Element:()=>, name:"Views", }, { path: "/views", exact: true, - Element: () => , + Element: () => , name: "Views", }, { path: '/urls/link/:view_name/:version/:instance_name', exact: true, - Element: () => , + Element: () => , name: 'Views' }, { diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/ClusterInformation/index.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/ClusterInformation/index.tsx new file mode 100644 index 00000000000..ed5ae4b7db0 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/ClusterInformation/index.tsx @@ -0,0 +1,195 @@ +/** + * 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. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect, useState } from "react"; +import {Form, OverlayTrigger, Tooltip} from "react-bootstrap"; +import DefaultButton from "../../../components/DefaultButton"; +import AppContent from "../../../context/AppContext"; +import ClusterApi from "../../../api/clusterApi"; +import Spinner from "../../../components/Spinner"; +import toast from "react-hot-toast"; +import { cloneDeep } from "lodash"; +import ConfirmationModal from "../../../components/ConfirmationModal"; +import { useContext } from "react"; + +export default function ClusterInformation() { + const [infoData, setInfoData] = useState({}); + const [loading, setLoading] = useState(false); + const { + setClusterInfo, + cluster, + cluster: { cluster_name: clusterName }, + } = useContext(AppContent); + const [clusterNameInput, setClusterNameInput] = useState(clusterName); + const [clusterNameError, setClusterNameError] = useState(""); + const [showConfirmationModal, setShowConfirmationModal] = useState(false); + console.log("Clutser is", cluster); + const { clusterExists, setSelectedOption } = useContext(AppContent); + const [showTooltip, setShowTooltip] = useState(false); + const handleFocus = () => setShowTooltip(true); + const handleBlur = () => setShowTooltip(false); + + useEffect(() => { + setSelectedOption("Cluster Information"); + }, []); + + useEffect(() => { + setClusterNameInput(clusterName); + }, [clusterName]); + + useEffect(() => { + if (!clusterNameInput) { + setClusterNameError("Cluster Name is required"); + } else if (clusterNameInput.length > 80) { + setClusterNameError("Cluster Name should be less than 80 characters"); + } + //Should contain only alphanumeric characters + else if (!/^[a-zA-Z0-9_]*$/.test(clusterNameInput)) { + setClusterNameError( + "Cluster Name should contain \n only alphanumeric characters" + ); + } else { + setClusterNameError(""); + } + }, [clusterNameInput]); + + async function getClusterInfoData(requiredClusterName: string = clusterName) { + setLoading(true); + const data = await ClusterApi.blueprintInfo(requiredClusterName); + setInfoData(data as any); + setLoading(false); + } + + useEffect(() => { + if (clusterName) getClusterInfoData(); + }, [clusterName]); + + function downloadBlueprint() { + const dataStr = + "data:text/json;charset=utf-8," + + encodeURIComponent(JSON.stringify(infoData, null, 4)); + const downloadAnchorNode = document.createElement("a"); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", "blueprint.json"); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + } + + const handleInputChange = (event: any) => { + setClusterNameInput(event.target.value); + }; + + const saveNewClusterName = async () => { + try { + await ClusterApi.updateClusterName(clusterName, clusterNameInput); + const clusterInfoCopy = cloneDeep(cluster); + clusterInfoCopy.cluster_name = clusterNameInput; + setClusterInfo(clusterInfoCopy); + } catch (err) { + console.log("Error is", err); + toast.error("Could not update cluster name"); + } finally { + setShowConfirmationModal(false); + } + }; + + return clusterExists ? ( +
+ { + setShowConfirmationModal(false); + }} + modalTitle="Confirm Cluster Name Change" + modalBody={`Are you sure you want to change the cluster name to ${clusterNameInput}?`} + /> +
{ + if (!clusterNameError && clusterNameInput !== clusterName) + setShowConfirmationModal(true); + }} + > + + Cluster Name* +
+
+ +
+ Only alpha-numeric characters, up to 80 characters +
+ + } + > + +
+ {!clusterNameError ? null : ( +
{clusterNameError}
+ )} +
+ {clusterNameInput !== clusterName && ( + 80} + > + Save + + )} +
+
+ + Cluster Blueprint + + Download + + {loading ? ( + + ) : ( + + )} + +
+
+ ) : null; +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/Dasboard/index.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/Dasboard/index.tsx new file mode 100644 index 00000000000..838d5fd4619 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/Dasboard/index.tsx @@ -0,0 +1,21 @@ +/** + * 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 default function Dashboard() { + window.location.replace('/#/main/dashboard'); + return null; +} \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/List.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/List.tsx new file mode 100644 index 00000000000..93c9d60985f --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/List.tsx @@ -0,0 +1,321 @@ +/** + * 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. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { useContext, useEffect, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFilter } from "@fortawesome/free-solid-svg-icons"; +import { Badge, Dropdown, Form } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import AppContent from "../../../context/AppContext"; +import usePagination from "../../../hooks/usePagination"; +import VersionsApi from "../../../api/versions"; +import DefaultButton from "../../../components/DefaultButton"; +import Spinner from "../../../components/Spinner"; +import ComboSearch from "../../../components/ComboSearch"; +import Paginator from "../../../components/Paginator"; +import Table from "../../../components/Table"; + +enum RepoStatus { + CURRENT = "CURRENT", + INSTALLED = "INSTALLED", +} + +const StackVersionsList = () => { + const [repos, setRepos] = useState< + unknown[] | ((prevState: never[]) => never[]) + >([]); + + const [loading, setLoading] = useState(false); + const [showFilters, setShowFilters] = useState(false); + const [filteredRepos, setFilteredRepos] = useState< + unknown[] | ((prevState: never[]) => never[]) + >([]); + const [clusterInformation, setClusterInformation] = useState({}); + const { + cluster: { cluster_name: clusterName }, + setSelectedOption, + } = useContext(AppContent); + const { + currentItems, + changePage, + currentPage, + maxPage, + itemsPerPage, + setItemsPerPage, + } = usePagination(filteredRepos); + useEffect(() => { + setSelectedOption("Versions"); + async function getReposData() { + setLoading(true); + let tempRepos: unknown[] | ((prevState: never[]) => never[]) = []; + const allRepos = await VersionsApi.getRepos(); + + const clusterData = await VersionsApi.getClusterInfo(); + const clusterInfo = clusterData.items?.[0]; + setClusterInformation(clusterInfo); + const data = allRepos.items; + for (const stack of data) { + const stackVersions = stack.versions; + for (const stackVersion of stackVersions) { + tempRepos = [...tempRepos, ...stackVersion.repository_versions]; + } + } + for (const repo of tempRepos as any) { + const stackVersion = await VersionsApi.versionsList( + repo.RepositoryVersions.id, + clusterName + ); + const repoStackVersion = stackVersion?.items?.[0]; + repo.isPatch = repo.RepositoryVersions.type === "PATCH"; + repo.isMaint = repo.RepositoryVersions.type === "MAINT"; + repo.stackName = `${repo.RepositoryVersions.stack_name}-${repo.RepositoryVersions.stack_version}`; + repo.status = repoStackVersion?.ClusterStackVersions.state; + const hostStatus = repoStackVersion?.ClusterStackVersions?.host_states; + const currentHosts = hostStatus?.["CURRENT"]?.length; + const installedHosts = hostStatus?.["INSTALLED"]?.length; + let totalHosts = 0; + for (const status in hostStatus) { + totalHosts += hostStatus[status].length; + } + repo.currentHosts = currentHosts; + repo.installedHosts = installedHosts; + repo.totalHosts = totalHosts; + repo.stackVersionId = repoStackVersion?.ClusterStackVersions.id; + repo.cluster = + repo.status === RepoStatus.CURRENT || + repo.status === RepoStatus.INSTALLED + ? clusterInfo?.Clusters?.cluster_name + : ""; + } + setLoading(false); + setRepos(tempRepos); + setFilteredRepos(tempRepos); + } + if (clusterName) getReposData(); + }, [clusterName]); + const columns = [ + { + header: "Stack", + accessorKey: "stackName", + id: "Stack Name", + }, + { + header: "Name", + accessorKey: "RepositoryVersions.display_name", + id: "name", + cell: ({ row }: { row: any }) => { + return ( + + {row.original.RepositoryVersions.display_name} + + ); + }, + }, + { + header: "Type", + accessorKey: "RepositoryVersions.type", + id: "type", + }, + { + header: "Version", + accessorKey: "RepositoryVersions.repository_version", + id: "version", + }, + { + header: "Cluster", + accessorKey: "cluster", + id: "cluster", + cell: ({ row }: { row: any }) => { + return !row.original.cluster ? ( +
None
+ ) : ( + + {row.original.cluster} + + ); + }, + }, + { + header: "Status", + id: "status", + cell: ({ row }: { row: any }) => { + const { status, currentHosts, totalHosts } = row.original; + const statusString = `${status}: ${currentHosts}/${totalHosts}`; + if (row.original.cluster) { + if(status === 'CURRENT') { + return ( + + {statusString} + + ); + } + if(status === 'INSTALLED') { + return ( + + {statusString} + + ); + } + } + return ( + + + INSTALL ON + + + +
{ + window.location.replace("/#/main/admin/stack/versions"); + }} + > + {((clusterInformation as any)?.Clusters + ?.cluster_name as string) || ""} +
+
+
+
+ ); + }, + }, + + { + header: "Hidden", + id: "hidden", + cell: ({ row }: { row: any }) => { + return ( +
+ { + toggleHiddenFor(row.original, e.target.checked); + }} + type="checkbox" + checked={row.original.RepositoryVersions.hidden} + id={`${row.original.RepositoryVersions.id}`} + /> + + ); + }, + }, + ]; + + const toggleHiddenFor = async (repo: any, checked: boolean) => { + const repoIndex = (repos as any[]).findIndex( + (repository) => + repository.RepositoryVersions.id === repo.RepositoryVersions.id + ); + const newRepos: any[] = [...(repos as any[])]; + newRepos[repoIndex].RepositoryVersions.hidden = checked; + setRepos(newRepos); + await VersionsApi.saveRepoVersions( + repo.stackName, + repo.RepositoryVersions.repository_version, + repo.RepositoryVersions.id, + { RepositoryVersions: { hidden: checked } } + ); + }; + + if (loading) { + return ; + } + return ( +
+
+ { + setShowFilters(!showFilters); + }} + className="d-flex align-items-center p-2" + > + + + + REGISTER VERSION + +
+ + {showFilters ? ( +
+ never[]) + > + ) => { + setFilteredRepos(filteredData); + }} + data={repos} + /> +
+ ) : null} + + + {currentItems.map((item: any) => ( +
{item.name}
+ ))} +
+ +
+ + ); +}; + +export default StackVersionsList; diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/Register.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/Register.tsx new file mode 100644 index 00000000000..34289b58a1e --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/Register.tsx @@ -0,0 +1,1309 @@ +/** + * 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. + */ +/* eslint-disable no-useless-escape */ +/* eslint-disable no-unsafe-optional-chaining */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useContext, useEffect, useState } from "react"; +import { + Item, + VersionDefinition, + VersionDefinitionResponse, +} from "./types/VersionDefinitions"; +import { Link, useHistory, useParams } from "react-router-dom"; +import { + Col, + Dropdown, + DropdownButton, + Nav, + Row, + Tab, + Form, + OverlayTrigger, + Alert, + Button, + Tooltip, + InputGroup, +} from "react-bootstrap"; +import { TransformedOperatingSystem, TransformedRepo } from "./types/Os"; +import { find, set, cloneDeep, isEmpty, map } from "lodash"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faAdd, + faMinus, + faPencil, + faQuestionCircle, + faUndo, +} from "@fortawesome/free-solid-svg-icons"; +import AppContent from "../../../context/AppContext"; +import VersionsApi from "../../../api/versions"; +import toast from "react-hot-toast"; +import Spinner from "../../../components/Spinner"; +import ConfirmationModal from "../../../components/ConfirmationModal"; +import AddVersionModal from "../../../components/AddVersionModal"; +import LostNetworkModal from "../../../components/LostNetworkModal"; +import RedhatSatelliteUsageInfo from "../../../components/RedhatSatelliteInfoModal"; +import Table from "../../../components/Table"; +import DefaultButton from "../../../components/DefaultButton"; + +enum RepositoryType { + PUBLIC = "public", + LOCAL = "local", +} + +enum OSOperations { + GET = "get", + EDITALL = "editall", +} + +const Register = ({ readOnly }: { readOnly: boolean }): JSX.Element => { + const [versionDefinitions, setVersionDefinitions] = useState([]); + const [selectedVersion, setSelectedVersion] = useState({}); + const [networkLost, setNetworkLost] = useState(false); + const [showRegistrationModal, setShowRegistrationModal] = + useState(false); + const [selectedStack, setSelectedStack] = useState( + {} as VersionDefinition + ); + const history = useHistory(); + const [selectedChoice, setSelectedChoice] = useState( + RepositoryType.PUBLIC + ); + const { + cluster: { cluster_name: clusterName }, + setSelectedOption, + } = useContext(AppContent); + const { stack, version } = useParams(); + const [versionNumber, setVersionNumber] = useState( + version?.substring(4, version.length) || "" + ); + const [versionValidationError, setVersionValidationError] = useState(false); + const [showNetworkModal, setShowNetworkModal] = useState(false); + const [skipValidation, setSkipValidation] = useState(false); + const [redhatSatellite, setRedhatSatellite] = useState(false); + const [repoInfo, setRepoInfo] = useState<{ status: string }>( + {} as { status: string } + ); + const [savedRepositoryVersionDetails, setSavedRepositoryVersionDetails] = + useState({}); + const [showRepoValidationBanner, setShowRepoValidationBanner] = + useState(false); + const [showRedhatInfoModal, setShowRedhatInfoModal] = + useState(false); + const [showAddVersionModal, setShowAddVersionModal] = useState(false); + const [addedVersions, setAddedVersions] = useState<{ + [key: string]: { label: string; value: string }[]; + }>({}); + const [operatingSystems, setOperatingSystems] = useState<{ + [key: string]: TransformedOperatingSystem[]; + }>({}); + const [confirmDeregister, setConfirmDeregister] = useState(false); + + console.log("Added Versions", addedVersions, selectedStack); + + const selectNewVersion = ( + version: VersionDefinition, + newVersion?: { label: string; value: string }, + newVersionDefinition?: VersionDefinition + ) => { + if (version.id) { + const addedVersionsCopy: { + [key: string]: { label: string; value: any }[]; + } = cloneDeep(addedVersions); + const defaultVersion = { + label: `${version.id} (Default Version definition)`, + value: version, + }; + if (addedVersionsCopy[version.id]) { + if (newVersion) + addedVersionsCopy[version.id] = [ + ...addedVersionsCopy[version.id], + newVersion, + ]; + } else { + if (newVersion) { + addedVersionsCopy[version.id] = [defaultVersion, newVersion]; + } else { + addedVersionsCopy[version.id] = [defaultVersion]; + } + } + setAddedVersions(addedVersionsCopy); + setSelectedVersion(newVersionDefinition ? newVersionDefinition : version); + } + }; + + useEffect(() => { + setSelectedOption("Versions"); + async function getVersionDefinitions() { + const definitions: VersionDefinitionResponse = + await VersionsApi.getVersionDefinitions(); + const sortedItems = definitions.items.sort((a: any, b: any) => { + const versionA = parseFloat(a.VersionDefinition.id.split("-")[1]); + const versionB = parseFloat(b.VersionDefinition.id.split("-")[1]); + + return versionB - versionA; + }); + setVersionDefinitions(sortedItems); + setNetworkIssues(definitions.items); + //@ts-ignore + setSelectedStack(sortedItems[0]?.VersionDefinition); + //@ts-ignore + selectNewVersion(sortedItems[0]?.VersionDefinition); + } + getVersionDefinitions(); + }, []); + + useEffect(() => { + async function getReposData() { + const stackVersion = await VersionsApi.versionsList( + savedRepositoryVersionDetails.id, + clusterName + ); + const repoStackVersion = stackVersion?.items?.[0]; + setRepoInfo({ + ...repoInfo, + status: repoStackVersion?.ClusterStackVersions.state, + }); + } + if (!isEmpty(savedRepositoryVersionDetails)) { + getReposData(); + } + }, [savedRepositoryVersionDetails]); + + useEffect(() => { + if (!versionNumber) { + setVersionValidationError(false); + } else { + // Pattern for two numbers separated by a dot + const twoNumbersPattern = /^\d+\.\d+$/; + // Pattern for three numbers separated by a dot and a dash + const threeNumbersPattern = /^\d+\.\d+-\d+$/; + + // Check if the string matches any of the patterns + if ( + twoNumbersPattern.test(versionNumber) || + threeNumbersPattern.test(versionNumber) + ) { + setVersionValidationError(false); + } else { + setVersionValidationError(true); + } + } + }, [versionNumber]); + + useEffect(() => { + if (redhatSatellite) { + setShowRedhatInfoModal(true); + const updatedOs = editAllRepos("isEditable", false); + setOperatingSystems(updatedOs); + } else { + const updatedOs = editAllRepos("isEditable", true); + setOperatingSystems(updatedOs); + } + }, [redhatSatellite]); + + const getOsfromRepoDetails = (oSystem: string, alreadyAddedOs: any) => { + let operatingSystem; + for (const addedOs of alreadyAddedOs?.[0]?.repository_versions?.[0] + ?.operating_systems) { + if (addedOs.OperatingSystems.os_type === oSystem) { + operatingSystem = addedOs; + } + } + return operatingSystem; + }; + + useEffect(() => { + async function getVersionOs() { + const versionOperatingSystems = + await VersionsApi.getVersionOperatingSystems( + selectedStack.stack_version + ); + let alreadyAddedOs: any = []; + if (readOnly) { + const repoDetails = await VersionsApi.getRepoDetails(stack, version); + setSavedRepositoryVersionDetails( + repoDetails?.items?.[0]?.repository_versions?.[0]?.RepositoryVersions + ); + alreadyAddedOs = repoDetails.items; + } + const allOs: TransformedOperatingSystem[] = + versionOperatingSystems.operating_systems.map((os: any) => { + const defaultRepos = os.repositories.map((repo: any) => { + return { + id: repo.Repositories.repo_id, + defaultId: repo.Repositories.repo_id, + baseUrl: repo?.Repositories?.base_url, + name: repo?.Repositories?.repo_name, + defaultUrl: repo?.Repositories?.default_base_url, + }; + }); + const matchingOs = readOnly + ? getOsfromRepoDetails(os.OperatingSystems.os_type, alreadyAddedOs) + : undefined; + const isOsAdded = readOnly ? (matchingOs ? true : false) : true; + return { + os: os.OperatingSystems.os_type, + isAdded: isOsAdded, + repos: readOnly + ? matchingOs + ? matchingOs.repositories.map((repo: any) => { + return { + id: repo.Repositories.repo_id, + defaultId: repo.Repositories.repo_id, + baseUrl: repo?.Repositories?.base_url, + name: repo?.Repositories?.repo_name, + defaultUrl: repo?.Repositories?.base_url, + }; + }) + : defaultRepos + : defaultRepos, + }; + }); + const operatingSystemsCopy = cloneDeep(operatingSystems); + operatingSystemsCopy[selectedVersion.id] = [...allOs]; + setOperatingSystems(operatingSystemsCopy); + } + if ( + selectedStack && + selectedVersion && + selectedVersion.id && + !operatingSystems[selectedVersion.id] + ) { + getVersionOs(); + } + }, [selectedVersion]); + + function getColumns() { + return [ + { + header: "", + accessorKey: "display_name", + id: "name", + }, + { + header: "", + id: "versions", + cell: (info: any) => { + const allVersions = isEmpty(savedRepositoryVersionDetails) + ? info.row.original.versions + : map(info.row.original.versions, "version"); + return allVersions.join(","); + }, + }, + ]; + } + + const getSystemsWithKeyValue = ( + key: string, + value: any, + osOperation: string + ) => { + const osCopy = cloneDeep(operatingSystems); + switch (osOperation) { + case OSOperations.GET: + return osCopy?.[selectedVersion.id]?.filter( + (oSystem: TransformedOperatingSystem) => + oSystem[key as keyof TransformedOperatingSystem] === value + ); + case OSOperations.EDITALL: + return osCopy?.[selectedVersion]?.map( + (oSystem: TransformedOperatingSystem) => { + //@ts-ignore + oSystem[key as keyof TransformedOperatingSystem] = value; + return oSystem; + } + ); + } + }; + + const redirectToList = () => { + history.push("/stackVersions"); + }; + + const deRegisterVersion = async () => { + if (repoInfo?.status == "CURRENT" || repoInfo?.status == "INSTALLED") { + toast.error("The version cannot be deregistered."); + } else { + try { + await VersionsApi.deleteRepositoryVersion( + savedRepositoryVersionDetails.stack_name, + savedRepositoryVersionDetails.stack_version, + savedRepositoryVersionDetails.id + ); + toast.success("Version Deleted Successfully"); + redirectToList(); + } catch (err) { + toast.error("Version Delete Error"); + } + } + }; + + const getAddableOperatingSystems = () => { + const addableSystems = getSystemsWithKeyValue( + "isAdded", + false, + OSOperations.GET + ); + return addableSystems || []; + }; + + const handleModalVisibility = (show: boolean) => { + setShowNetworkModal(show); + }; + + const setNetworkIssues = (versions: any[]) => { + const isNetworkLost = !versions.find( + (_version) => !_version.VersionDefinition.stack_default + ); + + if (isNetworkLost) { + setSelectedChoice(RepositoryType.LOCAL); + // clearRepoVersions(); + } + setNetworkLost(isNetworkLost); + }; + + const addBackOperatingSystem = (os: TransformedOperatingSystem) => { + const osCopy = cloneDeep(operatingSystems); + if (osCopy?.[selectedVersion.id]) { + const matchingOs = osCopy?.[selectedVersion.id].find( + (oSystem) => oSystem.os === os.os + ); + if (matchingOs) { + matchingOs.isAdded = true; + setOperatingSystems(osCopy); + } + } + }; + + const osListHeaders = [ + { + label: "OS", + columnCount: 3, + }, + { + label: "name", + columnCount: 3, + }, + { label: "Base URL", columnCount: 5 }, + { + columnCount: 1, + label: ( + + + + Add + + + + {getAddableOperatingSystems()?.map((oSystem) => { + return ( + { + addBackOperatingSystem(oSystem); + }} + key={oSystem.os} + className="text-dark" + > + {oSystem.os} + + ); + })} + + + ), + }, + ]; + + const isAllOsValidated = () => { + let allOsRemoved = true; + const remotePattern = + /^(?:(?:https?|ftp):\/{2})(?:\S+(?::\S*)?@)?(?:(?:(?:[\w\-.]))*)(?::[0-9]+)?(?:\/\S*)?$/; + const localPattern = + /^file:\/{2,3}([a-zA-Z][:|]\/){0,1}[\w~!*'();@&=\/\\\-+$,?%#.\[\]]+$/; + if (operatingSystems?.[selectedVersion.id]) { + for (const oSystem of operatingSystems?.[selectedVersion.id]) { + if (oSystem.isAdded) { + allOsRemoved = false; + } + for (const repo of oSystem.repos) { + if ( + oSystem.isAdded && + !( + remotePattern.test(repo.baseUrl) || + localPattern.test(repo.baseUrl) + ) + ) { + return false; + } + } + } + return allOsRemoved ? false : true; + } + return false; + }; + + function editAllRepos(key: string, value: any) { + const operatingSystemsCopy = cloneDeep(operatingSystems); + const allOs = operatingSystemsCopy?.[selectedVersion.id]; + allOs?.map((os: TransformedOperatingSystem) => { + os.repos.map((repo) => { + //@ts-ignore + repo[key as keyof TransformedRepo] = value; + return repo; + }); + return os; + }); + return operatingSystemsCopy; + } + + function editOsOrRepo( + operatingSystem: string, + repoId: string, + key: string, + value: any + ) { + const operatingSystemsCopy = cloneDeep(operatingSystems); + const matchingOperatingSystem = find( + operatingSystemsCopy?.[selectedVersion.id], + { + os: operatingSystem, + } + ); + + if (matchingOperatingSystem) { + if (repoId) { + const matchingRepo = find(matchingOperatingSystem.repos, { + id: repoId, + }); + + if (matchingRepo) { + //@ts-ignore + matchingRepo[key as keyof TransformedRepo] = + value as TransformedRepo[keyof TransformedRepo]; + matchingRepo.hasError = false; + setOperatingSystems(operatingSystemsCopy); + } + } else { + set(matchingOperatingSystem, key, value); + setOperatingSystems(operatingSystemsCopy); + } + } + } + + async function saveVersion() { + let createdVersionDefinition: any = {}; + if (!readOnly) + try { + const versionInfoPayload = selectedStack.fileContent + ? selectedStack.fileContent + : selectedStack.version_url + ? { VersionDefinition: { version_url: selectedStack.version_url } } + : { + VersionDefinition: { + available: selectedStack.id, + display_name: `${selectedStack.id}.${versionNumber}`, + }, + }; + + const headers = selectedStack.fileContent + ? { "Content-Type": "text/xml" } + : {}; + + createdVersionDefinition = await VersionsApi.readVersionInfo( + versionInfoPayload, + headers, + false + ); + } catch (err) { + toast.error("Could not read version info"); + } + const addedOs = operatingSystems?.[selectedVersion.id]?.filter( + (os: TransformedOperatingSystem) => os.isAdded + ); + let payload = {}; + + payload = { + ...(readOnly && { + RepositoryVersions: savedRepositoryVersionDetails, + }), + operating_systems: addedOs?.map((os: TransformedOperatingSystem) => { + return { + OperatingSystems: { + os_type: os.os, + ambari_managed_repositories: !redhatSatellite, + stack_name: selectedStack.stack_name, + stack_version: selectedStack.stack_version, + ...(readOnly && { + repository_version_id: savedRepositoryVersionDetails.id, + }), + ...(!readOnly && { version_defintion_id: selectedStack.id }), + }, + repositories: os.repos.map((repo: TransformedRepo) => { + return { + Repositories: { + applicable_services: [], + base_url: repo.baseUrl, + components: null, + default_base_url: "", + distribution: null, + intial_base_url: repo.defaultUrl, + initial__repo_id: selectedStack.id, + mirrors_list: null, + os_type: os.os, + stack_name: selectedStack.stack_name, + stack_version: selectedStack.stack_version, + tags: [], + unique: false, + version_defintion_id: selectedStack.id, + repo_id: repo.id, + repo_name: repo.name, + }, + hasError: false, + invalidBaseUrl: false, + }; + }), + selected: true, + }; + }), + }; + try { + await VersionsApi.saveRepoVersions( + selectedStack.stack_name, + selectedStack.stack_version, + readOnly + ? savedRepositoryVersionDetails?.id + : //@ts-ignore + createdVersionDefinition?.resources?.[0]?.VersionDefinition?.id, + payload + ); + toast.success("Version saved successfully"); + redirectToList(); + } catch (err: any) { + const errorMessage = err?.response?.data?.message; + if ( + errorMessage?.includes( + "is already defined for another repository version" + ) + ) { + setShowRegistrationModal(true); + await VersionsApi.deleteRepositoryVersion( + selectedStack.stack_name, + selectedStack.stack_version, + createdVersionDefinition?.resources?.[0]?.VersionDefinition?.id + ); + } + // toast.error("Could not save version"); + } + } + + async function validateRepos() { + if (!isAllOsValidated) { + return; + } + if (skipValidation) { + saveVersion(); + } else { + const operatingSystemsCopy = cloneDeep(operatingSystems); + let allOsValidated = true; + const versionValidationPromises = []; + const allAddedOs = operatingSystemsCopy?.[selectedVersion.id].filter( + (oSystem) => oSystem.isAdded + ); + for (const oSystem of allAddedOs) { + const repos = oSystem.repos; + for (const repo of repos) { + versionValidationPromises.push( + VersionsApi.validateRepos( + selectedStack.stack_version, + selectedStack.stack_version, + oSystem.os, + repo.id, + { + base_url: repo.baseUrl, + repo_name: repo.name, + } + ) + ); + } + } + try { + const validationResponses = await Promise.allSettled( + versionValidationPromises + ); + //In the response array map the response operation to corresponding repo via matching index + //If the response at nth index is empty then the nth repo is valid add a key called hasError false + //If the response at nth index is not empty then the nth repo is invalid add a key called hasError true + const osWithValidationStatus = allAddedOs.map((os, osIndex) => { + os.repos.map((repo, repoIndex) => { + if ( + validationResponses[osIndex * os.repos.length + repoIndex] + ?.status === "rejected" + ) { + allOsValidated = false; + repo.hasError = true; + } else { + repo.hasError = false; + } + return repo; + }); + return os; + }); + const osCopy = cloneDeep(operatingSystems); + osCopy[selectedVersion.id].map((oSystem) => { + const matchingOs = osWithValidationStatus.find( + (os) => os.os === oSystem.os + ); + if (matchingOs) { + oSystem.repos = matchingOs.repos; + } + return oSystem; + }); + if (!allOsValidated) { + setShowRepoValidationBanner(true); + } else { + setShowRepoValidationBanner(false); + saveVersion(); + } + setOperatingSystems({ + ...operatingSystems, + [selectedVersion.id]: osCopy[selectedVersion.id], + }); + } catch (error) { + console.log("Error", error); + } + } + } + + const readVersionCallback = async ( + versionResources: any, + fileContent: string, + version_file_url: string + ) => { + try { + const addedVersionOperatingSystems = + versionResources?.resources?.[0]?.operating_systems; + const addedVersion = versionResources?.resources?.[0]?.VersionDefinition; + setSavedRepositoryVersionDetails( + versionResources?.resources?.[0]?.VersionDefinition + ); + + const versionOperatingSystems = + await VersionsApi.getVersionOperatingSystems( + selectedStack.stack_version + ); + const newVersion = { + label: `${addedVersion.stack_name}-${addedVersion.repository_version}`, + value: { + ...addedVersion, + id: addedVersion.repository_version, + defaultId: addedVersion.repository_version, + }, + }; + const stackVersion = addedVersion.stack_version; + const belongingStack = versionDefinitions.find((definition) => { + return definition.VersionDefinition.stack_version === stackVersion; + }); + const addedOperatingSystems = addedVersionOperatingSystems.map( + (os: any) => { + return os.OperatingSystems.os_type; + } + ); + const allOs: TransformedOperatingSystem[] = + versionOperatingSystems.operating_systems.map((os: any) => { + const matchingOs = + versionResources?.resources?.[0]?.operating_systems.find( + (oS: any) => { + return ( + oS.OperatingSystems.os_type === os.OperatingSystems.os_type + ); + } + ); + return { + os: os.OperatingSystems.os_type, + isAdded: addedOperatingSystems.includes(os.OperatingSystems.os_type) + ? true + : false, + repos: (addedOperatingSystems.includes(os.OperatingSystems.os_type) + ? matchingOs + : os + ).repositories.map((repo: any) => { + return { + id: repo.Repositories.repo_id, + defaultId: repo.Repositories.repo_id, + baseUrl: repo?.Repositories?.base_url, + name: repo?.Repositories?.repo_name, + defaultUrl: repo?.Repositories?.default_base_url || "", + }; + }), + }; + }); + + const operatingSystemsCopy = cloneDeep(operatingSystems); + operatingSystemsCopy[addedVersion.repository_version] = [...allOs]; + setOperatingSystems(operatingSystemsCopy); + if (belongingStack) { + belongingStack.VersionDefinition.fileContent = fileContent; + belongingStack.VersionDefinition.version_url = version_file_url; + } + + setSelectedStack(belongingStack?.VersionDefinition as VersionDefinition); + selectNewVersion( + belongingStack?.VersionDefinition as VersionDefinition, + newVersion, + { + ...addedVersion, + id: addedVersion.repository_version, + } + ); + const addedVersionName = addedVersion.repository_version.split("."); + setVersionNumber( + addedVersionName.splice(2, addedVersionName.length).join(".") + ); + setShowAddVersionModal(false); + } catch (err) { + toast.error("Could not read version"); + console.log("Error", err); + } + }; + + const readOnlyRepoProperties = [ + { + label: "Stack", + value: `${savedRepositoryVersionDetails?.stack_name}-${savedRepositoryVersionDetails?.stack_version}`, + }, + { + label: "Name", + value: savedRepositoryVersionDetails?.display_name, + }, + { + label: "Version", + value: savedRepositoryVersionDetails?.repository_version, + }, + ]; + + if (!versionDefinitions.length || isEmpty(operatingSystems)) { + return ; + } + + return ( + <> + { + setConfirmDeregister(false); + }} + modalTitle="Deregister Version" + modalBody={`Are you sure you want to deregister the version ${savedRepositoryVersionDetails.stack_name}-${savedRepositoryVersionDetails.repository_version}?`} + successCallback={deRegisterVersion} + /> + { + setShowAddVersionModal(false); + }} + /> + { + handleModalVisibility(false); + }} + > + { + setShowRedhatInfoModal(false); + setRedhatSatellite(false); + }} + onClose={() => { + setShowRedhatInfoModal(false); + }} + /> + { + setShowRegistrationModal(false); + }} + onClose={() => { + setShowRegistrationModal(false); + }} + /> +
+ + {" "} +

Versions

+ +
+

/

+
+
+
+

+ {readOnly + ? savedRepositoryVersionDetails?.display_name + : "Register Version"} +

+ {readOnly ? ( +
+ ({savedRepositoryVersionDetails?.stack_name}- + {savedRepositoryVersionDetails?.repository_version}) +
+ ) : null} +
+ {readOnly ? ( + + ) : null} +
+
+ + {readOnly ? ( +
+ {readOnlyRepoProperties.map((property) => { + return ( +
+
{property.label}
+
{property.value}
+
+ ); + })} + + ) : ( + + + + + + )} + {/* */} + + {readOnly ? null : ( +
+ + + {addedVersions[selectedStack.id] + ? addedVersions?.[selectedStack.id]?.map((vers, index) => { + return ( + { + setSelectedVersion(vers.value); + }} + > + {vers.label} + + ); + }) + : null} + { + setShowAddVersionModal(true); + }} + data-testid="add-version" + > + Add Version + + + + {readOnly ? null : ( + + + {" "} + Name: {selectedStack.stack_name}- + {selectedStack.stack_version}. + + + { + setVersionNumber(e.target.value); + }} + /> +
+ {selectedStack.stack_name}-{selectedStack.stack_version}. + {versionNumber} invalid +
+
+
+ )} +
+ )} +
+
+ definition.VersionDefinition.stack_version === + selectedVersion.stack_version + )?.VersionDefinition.stack_services as unknown[]) || [] + : savedRepositoryVersionDetails?.services) || [] + } + /> + + + + +
+ + {networkLost ? ( +
{ + handleModalVisibility(true); + }} + > + Why is this disabled? +
+ ) : null} +
+ + +
+ +
+
Repositories
+
+
+ + Provide Base URLs for the Operating Systems you are configuring. + + + {showRepoValidationBanner ? ( + + Some of the repositories failed validation. Make changes to the + base url or skip validation if you are sure that urls are correct + + ) : null} + + {osListHeaders.map((header) => { + return
{header.label}; + })} + + {operatingSystems?.[selectedVersion.id] + ?.filter((oSystem: TransformedOperatingSystem) => oSystem.isAdded) + ?.map((oSystem: any) => { + return ( + + {oSystem.os} + + {oSystem?.repos?.map((repo: any, index: any) => { + return ( + 0 ? "mt-4" : "" + }`} + > + {" "} + + {repo.isEditing ? ( + { + e.preventDefault(); + editOsOrRepo( + oSystem.os, + repo.id, + "isEditing", + false + ); + }} + > + { + editOsOrRepo( + oSystem.os, + repo.id, + "id", + e.target.value + ); + }} + type="text" + data-testid="repo-id-input" + > + + ) : ( + repo.id + )} + {redhatSatellite && !repo.isEditing ? ( + { + editOsOrRepo( + oSystem.os, + repo.id, + "isEditing", + true + ); + }} + /> + ) : null} + {repo.isEditing && repo.id !== repo.defaultId ? ( + { + editOsOrRepo( + oSystem.os, + repo.id, + "id", + repo.defaultId + ); + }} + > + ) : null} + + + { + editOsOrRepo( + oSystem.os, + repo.id, + "baseUrl", + e.target.value + ); + }} + value={repo.baseUrl} + type="text" + className={`${ + repo.hasError ? "border border-danger" : "" + }`} + disabled={repo.isEditable === false} + placeholder="Enter Base URL or remove this OS" + data-testid="repo-base-url-input" + > + {repo.baseUrl !== repo.defaultUrl ? ( + { + editOsOrRepo( + oSystem.os, + repo.id, + "baseUrl", + repo.defaultUrl + ); + }} + /> + ) : ( +
+ )} + + + ); + })} + +
+
{ + editOsOrRepo(oSystem.os, "", "isAdded", false); + }} + > + + Remove +
+ + + ); + })} + +
+
+ { + setSkipValidation(!skipValidation); + }} + label={ +
+ Skip Repository Base URL validation (Advanced) + + Warning! This is for advanced users only. Use this + optionif you want to skip validation for Repository Base + URLs. + + } + > + + +
+ } + >
+ + { + setRedhatSatellite(!redhatSatellite); + }} + label={ +
+ Use RedHat Satellite/Spacewalk + + Disable distributed repositories and use RedHat + Satellite/Spacewalk channels instead + + } + > + + +
+ } + >
+
+ +
+ + CANCEL + + +
+ + ); +}; + +export default Register; diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/index.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/index.tsx new file mode 100644 index 00000000000..2365a243439 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/index.tsx @@ -0,0 +1,20 @@ +/** + * 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 VersionsList from "./List"; +import Register from "./Register" +export {VersionsList,Register} \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/types/Os.d.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/types/Os.d.ts new file mode 100644 index 00000000000..d3b334c029c --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/types/Os.d.ts @@ -0,0 +1,76 @@ +/** + * 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 interface VersionOperatingSystem { + href: string + Versions: Versions + operating_systems: OperatingSystem[] +} + +export interface Versions { + stack_name: string + stack_version: string +} + +export interface OperatingSystem { + href: string + OperatingSystems: OperatingSystems + repositories: Repository[] +} + +export interface OperatingSystems { + os_type: string + stack_name: string + stack_version: string +} + +export interface Repository { + href: string + Repositories: Repositories +} + +export interface Repositories { + applicable_services: any[] + base_url: string + components: any + default_base_url: string + distribution: any + mirrors_list: any + os_type: string + repo_id: string + repo_name: string + stack_name: string + stack_version: string + tags: string[] + unique: boolean +} + +export interface TransformedOperatingSystem{ + os:string; + isAdded:boolean; + repos: TransformedRepo[] +} + +export interface TransformedRepo{ + id:string; + baseUrl:string; + defaultUrl:string; + name:string; + isEditable?:boolean; + defaultId:string; + hasError?:boolean; +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/types/VersionDefinitions.d.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/types/VersionDefinitions.d.ts new file mode 100644 index 00000000000..19a2d87de82 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/StackVersions/types/VersionDefinitions.d.ts @@ -0,0 +1,84 @@ +/** + * 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 interface VersionDefinitionResponse { + href: string + items: Item[] + } + + export interface Item { + href: string + VersionDefinition: VersionDefinition + operating_systems: OperatingSystem[] + } + + export interface VersionDefinition { + id: string + repository_version: string + show_available: boolean + stack_default: boolean + stack_name: string + stack_repo_update_link_exists: boolean + stack_services: StackService[] + stack_version: string + type: string + fileContent?: string + version_url?: string + } + + export interface StackService { + name: string + display_name: string + comment: string + versions: string[] + } + + export interface OperatingSystem { + href: string + OperatingSystems: OperatingSystems + repositories: Repository[] + } + + export interface OperatingSystems { + os_type: string + stack_name: string + stack_version: string + version_definition_id: string + } + + export interface Repository { + href: string + Repositories: Repositories + } + + export interface Repositories { + applicable_services: any[] + base_url: string + components: any + default_base_url: string + distribution: any + mirrors_list: any + os_type: string + repo_id: string + repo_name: string + stack_name: string + stack_version: string + tags: string[] + unique: boolean + version_definition_id: string + } + \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/constants.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/constants.tsx new file mode 100644 index 00000000000..02c9b49c071 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/constants.tsx @@ -0,0 +1,38 @@ +/** + * 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 { PermissionLabel } from "./types"; + +const userAccessOptions = [ + "None", + "Cluster User", + "Cluster Administrator", + "Cluster Operator", + "Service Administrator", + "Service Operator", +]; + +const permissionLabelToName : Record = { + "None": "NONE", + "Cluster User": "CLUSTER.USER", + "Cluster Administrator": "CLUSTER.ADMINISTRATOR", + "Cluster Operator": "CLUSTER.OPERATOR", + "Service Administrator": "SERVICE.ADMINISTRATOR", + "Service Operator": "SERVICE.OPERATOR", +}; + +export { userAccessOptions, permissionLabelToName }; \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/CreateShortUrl.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/CreateShortUrl.tsx new file mode 100644 index 00000000000..5d7c4854c4b --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/CreateShortUrl.tsx @@ -0,0 +1,338 @@ +/** + * 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 { Button, Col, Form, InputGroup, Row } from "react-bootstrap"; +import ViewsInformationApi from "../../api/viewsApiInfo"; +import { cloneDeep, isEmpty } from "lodash"; +import ReactSelect from "react-select"; +import { useHistory, useParams } from "react-router-dom"; +import { get } from "lodash"; +import Spinner from "../../components/Spinner"; +import AppContent from "../../context/AppContext"; +import toast from "react-hot-toast"; + +type ParamsType = { + view_name: string; + version: string; + instance_name: string; +}; + +type OptionsType = { + label: string; + value: any; +}; + +type FormDataType = { + name: string; + view: string; + instance: string; + shortUrl: string; + [key: string]: string; +}; + +export default function CreateShortUrl() { + const [loading, setLoading] = useState(false); + const history = useHistory(); + + const [viewOptions, setViewOptions] = useState([]); + const [selectedView, setSelectedView] = useState(); + const [instanceOptions, setInstanceOptions] = useState([]); + const [selectedInstance, setSelectedInstance] = useState( + null + ); + const [submittedOnce, setSubmittedonce] = useState(false); + + const params = useParams(); + + const [formData, setFormData] = useState({ + name: "", + view: "", + instance: "", + shortUrl: "", + }); + + const [nameValid, setNameValid] = useState(false); + const [urlValid, setUrlValid] = useState(false); + + const { + setSelectedOption + } = useContext(AppContent); + + + + const handleValueChange = (key: string, value: string) => { + setSelectedOption("Views"); + const formDataCopy = cloneDeep(formData); + formDataCopy[key] = value; + if (formDataCopy.name) { + setNameValid(true); + } else { + setNameValid(false); + } + + if (formDataCopy.shortUrl) { + const isValidPattern = (str: string): boolean => + /^[a-z0-9-_]+$/.test(str); + const minLengthCheck = formDataCopy.shortUrl.length >= 2; + const maxLengthCheck = formDataCopy.shortUrl.length <= 25; + setUrlValid(isValidPattern(formDataCopy.shortUrl) && minLengthCheck && maxLengthCheck); + } else { + setUrlValid(false); + } + + setFormData(formDataCopy); + }; + + useEffect(() => { + async function getViewsList() { + setLoading(true); + const data: any = await ViewsInformationApi.viewsListAPI(); + const viewOptionsLocal = []; + for (const viewOption of data.items) { + for (const viewOptionVersion of viewOption.versions) { + viewOptionsLocal.push({ + label: `${get(viewOption, "ViewInfo.view_name")} (${get( + viewOptionVersion, + "ViewVersionInfo.version" + )})`, + value: get(viewOptionVersion, "instances", []), + }); + } + } + + const currentViewNameMapping = params.view_name; + const currentViewVersionMapping = params.version; + const currentViewInstanceMapping = params.instance_name; + + const selectedViewMapping = viewOptionsLocal.find( + (view: any) => + view.label === + `${currentViewNameMapping} (${currentViewVersionMapping})` + ); + setSelectedView(selectedViewMapping); + + const selectedViewInstanceMapping = selectedViewMapping?.value.find( + (instance: any) => + instance.ViewInstanceInfo.instance_name === currentViewInstanceMapping + ); + setSelectedInstance({ + label: get( + selectedViewInstanceMapping, + "ViewInstanceInfo.instance_name" + ), + value: get( + selectedViewInstanceMapping, + "ViewInstanceInfo.instance_name" + ), + }); + + if (selectedViewMapping) + handleValueChange("view", selectedViewMapping?.label); + handleValueChange( + "instance", + selectedViewInstanceMapping?.ViewInstanceInfo?.instance_name + ); + + setViewOptions(viewOptionsLocal); + + setLoading(false); + } + getViewsList(); + }, []); + + useEffect(() => { + if (!isEmpty(selectedView)) { + // setSelectedInstance({}); + //Check if selected instance does not exist in value options of the selected view + const selectedInstanceExists = selectedView.value.find( + (instance: any) => + instance.ViewInstanceInfo.instance_name === selectedInstance?.value + ); + if (!selectedInstanceExists) { + setSelectedInstance(null); + } + const instancesList = selectedView.value; + setInstanceOptions( + instancesList.map((instance: any) => { + return { + label: instance.ViewInstanceInfo.instance_name, + value: instance.ViewInstanceInfo.instance_name, + }; + }) + ); + + if (selectedInstance) + handleValueChange("instance", selectedInstance?.label); + } + }, [selectedView]); + + const setValues = async () => { + const data: any = { + ViewUrlInfo: { + url_name: formData.name, + url_suffix: formData.shortUrl, + view_instance_version: + formData.view !== "" ? formData.view.split(" ")[0] : params.version, + view_instance_name: formData.instance, + view_instance_common_name: + formData.view !== "" ? formData.view.split(" ")[0] : params.view_name, + }, + }; + + if (nameValid && urlValid) { + try{ + await ViewsInformationApi.createShortUrl(formData.name, data); + toast.success("URL created successfully"); + history.push("/views"); + }catch{ + toast.error("Error creating URL"); + } + + } + }; + + if(loading) { + return + } + + return ( +
+
+

Create New URL

+
+
+
+ + + Name + +
+ handleValueChange("name", e.target.value)} + data-testid = "name-input" + > + + This field is required + + + + + + View + + + { + setSelectedView(value); + handleValueChange("view", value?.label); + }} + className={selectedView ? "" : "is-invalid"} + aria-label="view-select" + /> + {!selectedView &&
This is required
} + + + + + + Instance + +
+ { + setSelectedInstance(value); + handleValueChange("instance", value ? value.label : ""); + }} + className={selectedInstance ? "" : "is-invalid"} + aria-label="instance-select" + + /> + {!selectedInstance &&
This is required
} + + + + + Short URL + +
+ + + /main/view/{selectedView?.label.split(" ")[0]} + + + handleValueChange("shortUrl", e.target.value) + } + data-testid = "shorturl-input" + /> + + {[ + !formData.shortUrl && "This field is required", + formData.shortUrl && + formData.shortUrl.length < 2 && + "Length should be at least 2 characters", + formData.shortUrl && + formData.shortUrl.length > 25 && + "Length should not exceed 25 characters", + formData.shortUrl && + !/^[a-z0-9-_]+$/.test(formData.shortUrl) && + "Only lowercase alphanumeric characters, hyphens, and underscores are allowed", + ] + .filter(Boolean) + .join(", ")} + + + + + + +
+ +
+ + ); +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/EditInstance.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/EditInstance.tsx new file mode 100644 index 00000000000..1f152fd3d76 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/EditInstance.tsx @@ -0,0 +1,1150 @@ +/** + * 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 ViewsInformationApi from "../../api/viewsApiInfo"; +import { Link, useHistory, useParams } from "react-router-dom"; +import { Alert, Button, Col, Form, Row } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCheck, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { cloneDeep, get, set, startCase } from "lodash"; +import DefaultButton from "../../components/DefaultButton"; +import Select from "react-select"; +import { + FieldType, + Group, + Options, + PrivilegesType, + setDetailsType, + setPermissionsType, + User, + ViewSectionsType, +} from "./types"; + +import { ClusterType, ControlType, EntityType } from "./enums"; + +import ConfirmationModal from "../../components/ConfirmationModal"; +import UserGroupApi from "../../api/userGroupApi"; +import AppContent from "../../context/AppContext"; +import Spinner from "../../components/Spinner"; +import toast from "react-hot-toast"; + +export default function EditInstance() { + const [loading, setLoading] = useState(true); + const { viewName, version, instanceName } = useParams<{ + viewName: string; + instanceName: string; + version: string; + }>(); + const [instanceData, setInstanceData] = useState(); + const [ViewData, setViewData] = useState(); + const [clusterApiPermissions, setClusterApiPermissions] = useState([]); + const [permissionsEditLoading, setPermissionsEditLoading] = useState(false); + const [isInstanceDataTransformed, setIsInstanceDataTransformed] = + useState(false); + + const [showDeleteShortUrlModal, setShowDeleteShortUrlModal] = useState(false); + + const deleteShortUrlModal = () => { + setShowDeleteShortUrlModal(true); + }; + + const deleteShortUrl = async (urlName: string) => { + try { + await ViewsInformationApi.deleteShortUrl(urlName); + toast.success("Short Url deleted"); + viewSections.details.isEditing = false; + getInstanceDetails(); + } catch (error) { + toast.error("Failed to delete URL"); + } + }; + + const { + cluster: { cluster_name: clusterName }, + setSelectedOption, + } = useContext(AppContent); + + const viewSectionsObj: ViewSectionsType = { + details: { + isEditable: true, + isEditing: false, + apiResponsible: ViewData, + fields: [ + { + label: "Instance Name", + type: ControlType.INPUT, + hasError: false, + isEditable: false, + id: "instanceName", + value: "", + originalValue: "", + apiResponseKey: ["instance_name"], + required: true, + }, + { + label: "Display Name", + type: ControlType.INPUT, + hasError: false, + isEditable: true, + id: "displayName", + value: "", + originalValue: "", + apiResponseKey: ["label"], + required: true, + validationRegEx: /^[^\s][a-zA-Z0-9_. ]*$/, + errorMessage: "Must not contain any special characters", + }, + { + label: "Description", + type: ControlType.INPUT, + hasError: false, + isEditable: true, + id: "description", + value: "", + originalValue: "", + apiResponseKey: ["description"], + required: true, + }, + { + label: "Short URL", + type: ControlType.LINK, + hasError: false, + isEditable: false, + id: "shortUrl", + value: "Create New URL", + href: `/urls/link/${viewName}/${version}/${instanceName}`, + prefixUrl: `/main/view/${viewName}/`, + valuePlaceholder: "Create New URL", + defaultUrl: `/urls/link/${viewName}/${version}/${instanceName}`, + originalValue: "", + isDeletable: false, + deleteCallBack: deleteShortUrlModal, + apiResponseKey: ["short_url"], + }, + { + label: "Visible", + type: ControlType.CHECKBOX, + value: false, + hasError: false, + isEditable: true, + id: "visible", + originalValue: "", + apiResponseKey: ["visible"], + }, + ], + }, + }; + + const [viewSections, setViewSections] = + useState(viewSectionsObj); + + async function getInstanceDetails() { + const data: unknown = await ViewsInformationApi.getInstanceLabel( + viewName, + version, + instanceName + ); + setInstanceData(data); + } + + async function getViewDetails() { + const data: any = await ViewsInformationApi.getViewDetails( + viewName, + version + ); + setViewData(data); + } + + const mapApiDataToFields = (viewSectionsToBeUpdated = viewSections) => { + setLoading(true); + const updatedSections = cloneDeep(viewSectionsToBeUpdated); + Object.keys(updatedSections).map((section) => { + updatedSections[section]?.fields.map((field: any) => { + if (field.apiResponseKey) { + //Pass the values to get in an array because response keys contain key in it + field.value = get( + instanceData.ViewInstanceInfo, + field.apiResponseKey, + field.type === ControlType.LINK ? field.valuePlaceholder : "" + ); + + field.originalValue = get( + instanceData.ViewInstanceInfo, + field.apiResponseKey, + field.type === ControlType.LINK ? field.valuePlaceholder : "" + ); + + field.placeholderValue = get( + instanceData?.ViewInstanceInfo, + field.placeholder, + "" + ); + + if (field.prefixUrl) { + if (get(instanceData.ViewInstanceInfo, field.apiResponseKey)) { + field.value = field.prefixUrl + field.value; + field.href = field.value; + field.isDeletable = true; + field.originalValue = field.value; + } else { + field.value = field.valuePlaceholder || ""; + field.href = field.defaultUrl || ""; + field.isDeletable = false; + } + } + } + }); + }); + setViewSections(updatedSections); + setLoading(false); + }; + + useEffect(() => { + setSelectedOption("Views"); + getViewDetails(); + getInstanceDetails(); + }, []); + + useEffect(() => { + if (instanceData?.ViewInstanceInfo && isInstanceDataTransformed) { + modifySections(); + } + }, [isInstanceDataTransformed]); + + useEffect(() => { + const transformInstanceData = () => { + const instanceDataCopy = cloneDeep(instanceData); + const viewInstanceInfo = instanceDataCopy.ViewInstanceInfo; + const viewVersionInfo = ViewData.ViewVersionInfo; + Object.keys(viewInstanceInfo.properties).map((propertyKey) => { + const matchingKeyFromViewVersionInfo = viewVersionInfo.parameters.find( + (parameter: any) => { + return parameter.name === propertyKey; + } + ); + const matchingKeyFromSettings = null; + + if (matchingKeyFromViewVersionInfo) { + set(viewInstanceInfo, ["properties", propertyKey, "viewInfo"], { + ...matchingKeyFromViewVersionInfo, + value: viewInstanceInfo.properties[propertyKey], + isSetting: matchingKeyFromSettings ? true : false, + }); + } + }); + setInstanceData(instanceDataCopy); + setIsInstanceDataTransformed(true); + }; + if ( + instanceData?.ViewInstanceInfo && + ViewData?.ViewVersionInfo && + !isInstanceDataTransformed + ) + transformInstanceData(); + }, [ViewData, instanceData]); + + const modifySections = () => { + const transformedInstanceData = cloneDeep(instanceData); + const updatedSections = cloneDeep(viewSections); + set(updatedSections, "settings", { + isEditable: true, + isEditing: false, + fields: Object.keys(instanceData.ViewInstanceInfo?.properties) + ?.filter((property) => { + const currentProperty = get( + transformedInstanceData?.ViewInstanceInfo?.properties, + [property], + undefined + ); + return !currentProperty?.viewInfo?.clusterConfig; + }) + .map((property) => { + const currentProperty = get( + transformedInstanceData?.ViewInstanceInfo?.properties, + [property], + undefined + ); + return { + label: currentProperty.viewInfo.label, + type: ControlType.INPUT, + hasError: false, + isEditable: true, + id: property, + originalValue: "", + value: "", + placeholder: ["properties", property, "viewInfo", "placeholder"], + apiResponseKey: ["properties", property, "viewInfo", "value"], + required: currentProperty.viewInfo.required, + }; + }), + }); + set(updatedSections, "clusterConfiguration", { + isEditable: transformedInstanceData.clustertype === ClusterType.CUSTOM, + isEditing: false, + fields: Object.keys(transformedInstanceData?.ViewInstanceInfo?.properties) + ?.filter((property) => { + const currentProperty = get( + transformedInstanceData?.ViewInstanceInfo?.properties, + [property], + undefined + ); + return get(currentProperty, "viewInfo.clusterConfig"); + }) + .map((property) => { + const currentProperty = get( + transformedInstanceData?.ViewInstanceInfo?.properties, + [property], + undefined + ); + + return { + label: currentProperty.viewInfo.label, + type: ControlType.INPUT, + hasError: false, + isEditable: true, + id: property, + originalValue: "", + value: "", + placeholder: ["properties", property, "viewInfo", "placeholder"], + apiResponseKey: ["properties", property, "viewInfo", "value"], + required: currentProperty.viewInfo.required, + }; + }), + }); + + setViewSections(updatedSections); + mapApiDataToFields(updatedSections); + }; + + const [users, setUsers] = useState(); + const [groups, setGroups] = useState(); + const [selectedUsers, setSelectedUsers] = useState([]); + const [selectedGroups, setSelectedGroups] = useState([]); + const [privileges, setPrivileges] = useState(); + const history = useHistory(); + + async function getPrivilegePermission() { + const data: any = await ViewsInformationApi.getPrivileges( + viewName, + version, + instanceName + ); + setPrivileges(data.privileges); + setClusterApiPermissions(data.privileges); + } + + useEffect(() => { + async function getUserDetails() { + const data: any = await UserGroupApi.usersList("Users"); + setUsers(data.items); + } + getUserDetails(); + async function getGroupsDetails() { + const data: any = await UserGroupApi.groupsList("Groups"); + setGroups(data.items); + } + getGroupsDetails(); + + getPrivilegePermission(); + }, []); + useEffect(() => { + if (privileges) { + const usersTemp: Options[] = []; + const groupsTemp: Options[] = []; + privileges.map((privilege: PrivilegesType) => { + const option: Options = { + value: privilege.PrivilegeInfo.principal_name, + label: privilege.PrivilegeInfo.principal_name, + }; + if (privilege.PrivilegeInfo.principal_type === EntityType.USER) { + usersTemp.push(option); + } else if ( + privilege.PrivilegeInfo.principal_type === EntityType.GROUP + ) { + groupsTemp.push(option); + } + return privilege; + }); + setSelectedUsers(usersTemp); + setSelectedGroups(groupsTemp); + } + }, [privileges]); + + useEffect(() => { + if (privileges) { + const permissionsCopy = cloneDeep(localClusterPermissions); + + privileges?.map((privilege: PrivilegesType) => { + if (privilege?.PrivilegeInfo?.principal_type === EntityType.ROLE) { + permissionsCopy[ + privilege?.PrivilegeInfo?.principal_name + ].permissionGranted = true; + } + }); + + setLocalClusterPermissions(permissionsCopy); + } + }, [privileges]); + + type Roles = { + [key: string]: { + permissionGranted: boolean; + }; + }; + + const userOptions = users?.map((user) => ({ + value: user.Users.user_name, + label: user.Users.user_name, + })); + const groupOptions = groups?.map((group) => ({ + value: group.Groups.group_name, + label: group.Groups.group_name, + })); + + const [localClusterPermissions, setLocalClusterPermissions] = useState( + { + "CLUSTER.ADMINISTRATOR": { + permissionGranted: false, + }, + "CLUSTER.OPERATOR": { + permissionGranted: false, + }, + "SERVICE.OPERATOR": { + permissionGranted: false, + }, + "SERVICE.ADMINISTRATOR": { + permissionGranted: false, + }, + "CLUSTER.USER": { + permissionGranted: false, + }, + } + ); + + const setPermissions = async (permissionsObj = localClusterPermissions) => { + const data: setPermissionsType[] = []; + + if (selectedUsers.length !== 0) { + selectedUsers.forEach((user: Options) => { + const addPrivilege: setPermissionsType = { + PrivilegeInfo: { + permission_name: "VIEW.USER", + principal_name: user.label, + principal_type: "USER", + }, + }; + data.push(addPrivilege); + }); + } + + if (selectedGroups.length !== 0) { + selectedGroups.forEach((group: Options) => { + const addPrivilege: setPermissionsType = { + PrivilegeInfo: { + permission_name: "VIEW.USER", + principal_name: group.label, + principal_type: "GROUP", + }, + }; + data.push(addPrivilege); + }); + } + + for (const clusterPermission in permissionsObj) { + if (permissionsObj[clusterPermission].permissionGranted === true) { + const addPrivilege: setPermissionsType = { + PrivilegeInfo: { + permission_name: "VIEW.USER", + principal_name: clusterPermission, + principal_type: "ROLE", + }, + }; + data.push(addPrivilege); + } + } + if (clusterApiPermissions.length !== data.length) { + setPermissionsEditLoading(true); + try { + await ViewsInformationApi.updatePrivileges( + viewName, + version, + instanceName, + data + ); + toast.success("Updated permissions"); + console.log("Updated permissions"); + } catch (error) { + toast.error("Could not update permissions"); + console.log("Could not update permissions", error); + } + + getPrivilegePermission(); + setPermissionsEditLoading(false); + + } + }; + + const updateRolePrivileges = (principal_name: string) => { + const permissionsCopy = cloneDeep(localClusterPermissions); + + if (permissionsCopy[principal_name]) { + permissionsCopy[principal_name].permissionGranted = + !permissionsCopy[principal_name].permissionGranted; + } + + setLocalClusterPermissions(permissionsCopy); + setPermissions(permissionsCopy); + }; + + const [showDeleteModal, setShowDeleteModal] = useState(Boolean); + const deleteInstance = async () => { + try { + await ViewsInformationApi.deleteInstance(viewName, version, instanceName); + console.log("Instance deleted successfully"); + } catch (error) { + console.error("Failed to delete instance", error); + } + }; + + const handleEditSection = (section: string, key: string, value: any) => { + const updatedSections = cloneDeep(viewSections); + if (updatedSections[section]) + updatedSections[section][key as keyof typeof viewSections.details] = + value; + updatedSections[section]?.fields.map((field: any) => { + if ( + (field.validationRegEx && !field.validationRegEx.test(field.value)) || + (field.required && !field.value) + ) { + field.hasError = true; + } + }); + setViewSections(updatedSections); + }; + + const handleEditField = ( + section: string, + fieldId: string, + key: string, + value: any + ) => { + const updatedSections = cloneDeep(viewSections); + updatedSections[section]?.fields.map((field: any) => { + if (field.id === fieldId) { + field[key] = value; + } + if ( + (field.validationRegEx && + !field.validationRegEx.test( + field.value ? field.value.toString() : "" + )) || + (field.required && !field.value) + ) { + field.hasError = true; + } else { + field.hasError = false; + } + }); + setViewSections(updatedSections); + }; + + const renderField = ( + field: FieldType, + isSectionEditing: boolean, + sectionName: string + ) => { + switch (field.type) { + case ControlType.INPUT: + return ( + <> + + {field.label} + + +
+ { + handleEditField( + sectionName, + field.id, + "value", + e.target.value + ); + }} + isInvalid={ + (isSectionEditing && field.required && !field.value) || + (field.validationRegEx && + !field.validationRegEx.test(String(field.value))) + } + disabled={!isSectionEditing || !field.isEditable} + > + {isSectionEditing && + field.required && + (!field.value || + (typeof field.value === "string" && + (!field.value.trim() || + field.value.startsWith(" ")))) && ( + + Field is required + + )} + {isSectionEditing && + field.validationRegEx && + !field.validationRegEx.test(String(field.value)) ? ( + + {field.errorMessage} + + ) : null} +
+ + + ); + case ControlType.LINK: + return ( + <> + {field.label} + + {field.href && ( + + {field.value} + + )} + {field.isDeletable && isSectionEditing ? ( + { + e.preventDefault(); + field.deleteCallBack && field.deleteCallBack(); + }} + > + Delete + + ) : null} + + + ); + case ControlType.CHECKBOX: + return ( + <> + + + { + handleCheckboxChange(sectionName, field.id, "value"); + }} + > + + + ); + } + }; + + const handleCheckboxChange = (section: string, + fieldId: string, + key: string) => { + const updatedSections = cloneDeep(viewSections); + updatedSections[section]?.fields.map((field: any) => { + if (field.id === fieldId) { + field[key] = !field[key]; + } + }); + setViewSections(updatedSections); + } + + + + const setDetails = async () => { + const data: setDetailsType = { + ViewInstanceInfo: { + visible: String(viewSections.details.fields[4].value), + label: String(viewSections.details.fields[1].value), + description: String(viewSections.details.fields[2].value), + }, + }; + try { + await ViewsInformationApi.updateDetails( + viewName, + version, + instanceName, + data + ); + toast.success("Updated instance details successfully"); + } catch { + toast.error("Failed to update instance details"); + } + }; + + const createSettingsPayload = (section: string) => { + let properties: any = {}; + + viewSections[section]?.fields.map((field) => { + properties[field.id] = field.value; + }); + return { + ViewInstanceInfo: { + properties: properties, + }, + }; + }; + + const setSettings = async () => { + const data = createSettingsPayload("settings"); + try { + await ViewsInformationApi.updateSettings( + viewName, + version, + instanceName, + data + ); + toast.success("Updated instance settings successfully"); + } catch { + toast.error("Failed to update instance settings"); + } + }; + + const createClusterConfiguartionPayload = (section: string) => { + let properties: any = {}; + + viewSections[section]?.fields.map((field) => { + properties[field.id] = field.value; + }); + return { + ViewInstanceInfo: { + properties: properties, + }, + }; + }; + + const setclusterconfiguration = async () => { + const data = createClusterConfiguartionPayload("clusterConfiguration"); + try { + await ViewsInformationApi.updateSettings( + viewName, + version, + instanceName, + data + ); + toast.success("Updated instance configuration successfully"); + } catch { + toast.error("Failed to update instance configuration"); + } + }; + + const saveSection = (section: string) => { + const updatedSections = cloneDeep(viewSections); + let hasError = false; + updatedSections[section]?.fields.map((field: any) => { + if (field.hasError) { + hasError = true; + } + }); + if (hasError) { + return; + } else { + if (updatedSections[section]) updatedSections[section].isEditing = false; + updatedSections[section]?.fields.map((field: any) => { + field.originalValue = field.value; + }); + setViewSections(updatedSections); + switch (section) { + case "details": + setDetails(); + break; + + case "settings": + setSettings(); + break; + + case "clusterConfiguration": + setclusterconfiguration(); + break; + + default: + console.log("Api call failed"); + } + } + }; + + const handleCancelEditing = (section: string) => { + const updatedSections = cloneDeep(viewSections); + if (updatedSections[section]) + set(updatedSections, `[${section}].isEditing`, false); + if (updatedSections[section]) + updatedSections[section].fields.map((field) => { + field.hasError = false; + field.value = field.originalValue; + }); + setViewSections(updatedSections); + }; + + const renderSections = () => { + return Object.keys(viewSections).map((section) => { + const currentSection = viewSections[section]; + return get(viewSections[section], "fields").length !== 0 ? ( +
+
+
{startCase(section)}
+ {currentSection.isEditable && !currentSection.isEditing ? ( +
{ + handleEditSection(section, "isEditing", true); + }} + > + +
+ Edit +
+
+ ) : null} +
+ {currentSection?.fields.map((currentSectionField) => { + return ( +
+ +
+ {renderField( + currentSectionField, + currentSection.isEditing, + section + )} + + + ); + })} + {currentSection?.isEditing ? ( +
+ { + handleCancelEditing(section); + }} + > + CANCEL + + +
+ ) : null} + + ) : null; + }); + }; + + const selectAllPermissions = async () => { + const permissionsCopy = cloneDeep(localClusterPermissions); + for (const permissions in permissionsCopy) { + permissionsCopy[permissions].permissionGranted = true; + } + setLocalClusterPermissions(permissionsCopy); + setPermissions(permissionsCopy); + }; + + const deselectAllPermissions = async () => { + const permissionsCopy = cloneDeep(localClusterPermissions); + for (const permissions in permissionsCopy) { + permissionsCopy[permissions].permissionGranted = false; + } + setLocalClusterPermissions(permissionsCopy); + setPermissions(permissionsCopy); + }; + + function goToInstance() { + window.location.replace( + `/#main/views/${viewName}/${version}/${instanceName}` + ); + } + + return ( +
+ {loading ? ( + + ) : ( +
+
+
+ + {""} +

Views

+ +
+

/

+
+
+
+ {/*

{instanceData?.ViewInstanceInfo?.label}

*/} +

{viewSections.details.fields[1].originalValue}

+ {/* TO DO - Add link */} + +
+ Go to instance +
+
+
+ + setShowDeleteModal(false)} + modalTitle={"Delete View Instance"} + modalBody={`Are you sure you want to delete view instance ${instanceData?.ViewInstanceInfo.label} + ?`} + successCallback={async () => { + try { + await deleteInstance(); + setShowDeleteModal(false); + toast.success("Instance deleted successfully"); + history.push("/views"); + } catch (error) { + toast.error("Failed to delete instance"); + console.error("Failed to delete instance:", error); + } + }} + /> +
+
+ +
View + {viewName} + + + Version + + + + + {renderSections()} + +
+
+
+
Permissions
+
+
+
+ +
Permission + Grant permission to these users + Grant permission to these groups + + + Use + + setSelectedGroups(e)} + aria-label="select-groups" + /> + setPermissions()} + > + + + + + + + {instanceData?.ViewInstanceInfo.cluster_type === + ClusterType.LOCAL ? ( +
+ +
Local Cluster Permissons + + + + Grant Use permission for the following {clusterName} Roles: + + + + + updateRolePrivileges("CLUSTER.ADMINISTRATOR") + } + /> + updateRolePrivileges("CLUSTER.OPERATOR")} + /> + + updateRolePrivileges("SERVICE.OPERATOR")} + /> + + + updateRolePrivileges("SERVICE.ADMINISTRATOR") + } + /> + + updateRolePrivileges("CLUSTER.USER")} + /> + + + + { + e.preventDefault(); + if (!permissionsEditLoading) selectAllPermissions(); + }} + className="text-info" + > + Check all + {" "} + |{" "} + { + e.preventDefault(); + if (!permissionsEditLoading) deselectAllPermissions(); + }} + className="text-info" + > + Clear all + + + + + ) : ( +
+ +
Local Cluster Permissons + + + The ability to inherit view Use permission based on Cluster + Roles is only available when using a Local Cluster + configuration. + + + )} + + setShowDeleteShortUrlModal(false)} + modalTitle={"Delete URL"} + modalBody={`Are you sure you want to delete url "${instanceData?.ViewInstanceInfo.short_url_name}" + ?`} + successCallback={async () => { + setShowDeleteShortUrlModal(false); + deleteShortUrl(instanceData?.ViewInstanceInfo.short_url_name); + }} + /> + + )} + + ); +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/ClusterInformation/ClusterIsInstalled.test.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/ClusterInformation/ClusterIsInstalled.test.tsx new file mode 100644 index 00000000000..f7367456850 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/ClusterInformation/ClusterIsInstalled.test.tsx @@ -0,0 +1,181 @@ +/** + * 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, beforeEach, expect} from "vitest"; +import {render, screen, waitFor} from "@testing-library/react"; +import {Router} from "react-router-dom"; +import AppContent from "../../../src/context/AppContext"; +import "@testing-library/jest-dom/vitest"; +import { createMemoryHistory } from "history"; +import ClusterApi from "../../../src/api/clusterApi"; +import ClusterInformation from "../../../src/screens/ClusterManagement/ClusterInformation"; +import mockClusterBluePrintInfo from "../../__mocks__/mockClusterBluePrintInfo.ts"; +import mockUpdateClusterName from "../../__mocks__/mockUpdateClusterName.ts"; + +describe('Cluster is Installed', () => { + const mockData = { + isInstallWizardLaunched: false, + clusterExists: true, + selectedOption: '', + cluster: { + cluster_name: "abc" + }, + setClusterInfo: () => {}, + rbacData: {}, + setRbacData: () => {}, + permissionLabelList: [], + setPermissionLabelList: () => {}, + setSelectedOption: () => "Versions", + }; + + + beforeEach(() => { + //Mock window object + const { location } = window; + //delete global.window.location; + // @ts-ignore + global.window.location = { ...location, + replace: ():any => {}, + hash: '/clusterInformation' // this line is for setting the hash + }; + const url = "http://localhost"; + global.window.location.href = url; + ClusterApi.blueprintInfo = async () => mockClusterBluePrintInfo; + ClusterApi.updateClusterName = mockUpdateClusterName; + }); + + it('should render without crashing', () => { + render( + + + + + + ); + }); + + it('should update the cluster name', async () => { + render( + + + + + + ); + const oldClusterName = 'oldName'; + const newClusterName = 'newName'; + const expectedParams = { clusterName: oldClusterName, updatedClusterName: newClusterName }; + const result = await ClusterApi.updateClusterName(oldClusterName, newClusterName); + // Assert + expect(result).toEqual(expectedParams); + }); + + it('Cluster Blueprint should be present', async () => { + render( + + + + + + ); + await waitFor(() => { + const textElement = screen.getByText(/Cluster Blueprint/i); + console.log("text element is ", textElement.textContent); + expect(textElement).toBeInTheDocument(); + }); + }); + + it('Download button should be present', async () => { + render( + + + + + + ); + + await waitFor(() => { + const downloadButton = screen.getByText(/Download/i); + console.log("text element is ", downloadButton.textContent); + expect(downloadButton).toBeTruthy(); + }); + }); + + it('Compulsory field Cluster Name should be present', async () => { + render( + + + + + + ); + + await waitFor(() => { + const downloadButton = screen.getByText(/Cluster Name*/i); + console.log("text element is ", downloadButton.textContent); + expect(downloadButton).toBeTruthy(); + }); + }); + + it('should call the download methods the correct number of times', () => { + render( + + + + + + ); + // Save original methods + const originalCreateElement = document.createElement; + const originalAppendChild = document.body.appendChild; + + // Setup our mock methods with counters + let createElementCounter = 0; + let setAttributeCounter = 0; + let appendChildCounter = 0; + let clickCounter = 0; + let removeCounter = 0; + + // Mock the relevant methods to increment counters + document.createElement = (() => { + createElementCounter++; + return { + setAttribute: () => setAttributeCounter++, + click: () => clickCounter++, + remove: () => removeCounter++ + }; + }) as any; + + document.body.appendChild = (() => appendChildCounter++) as any; + + // Simulate click event on the download button + const downloadButton = screen.getByText(/Download/i); + console.log("download button is ", downloadButton); + downloadButton.click(); + + // Check that the methods were called + expect(createElementCounter).toBe(1); + expect(setAttributeCounter).toBe(2); + expect(appendChildCounter).toBe(1); + expect(clickCounter).toBe(1); + expect(removeCounter).toBe(1); + + // Restore original methods + document.createElement = originalCreateElement; + document.body.appendChild = originalAppendChild; + }); +}); \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/ClusterInformation/ClusterIsNotInstalled.test.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/ClusterInformation/ClusterIsNotInstalled.test.tsx new file mode 100644 index 00000000000..44092e3238b --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/ClusterInformation/ClusterIsNotInstalled.test.tsx @@ -0,0 +1,164 @@ +/** + * 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, beforeEach, expect} from "vitest"; +import {render, screen, waitFor} from "@testing-library/react"; +import {Router} from "react-router-dom"; +import App from "../../../src/App"; +import AppContent from "../../../src/context/AppContext"; +import "@testing-library/jest-dom/vitest"; +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory, MemoryHistory } from "history"; +import ClusterApi from "../../../src/api/clusterApi"; +import mockClusterInfo from "../../__mocks__/mockClusterInfo"; +import mockHostClusterInfo from "../../__mocks__/mockHostClusterInfo"; +import ClusterInformation from "../../screens/ClusterManagement/ClusterInformation"; +describe('Cluster is not installed', () => { + //The mockData consists of variables and values only with initial state + //once the apis update the necessary variables the state changes accordingly + const mockData = { + clusterInfoLoading: false, + isInstallWizardLaunched: false, + clusterExists: false, + selectedOption: '', + cluster: {}, + setClusterInfo: () => {}, + rbacData: {}, + setRbacData: () => {}, + permissionLabelList: [], + setPermissionLabelList: () => {}, + setSelectedOption: () => "Versions", + }; + const history: MemoryHistory = createMemoryHistory(); + // let originalLocationHref: string; + // let originalLocationHash: string; + + // beforeAll(() => { + // // Save the original location + // originalLocationHref = global.window.location.href; + // originalLocationHash = global.window.location.hash; + // Object.defineProperty(window, 'matchMedia', { + // writable: true, + // value: (query) => ({ + // matches: false, + // media: query, + // onchange: null, + // addListener: () => {}, // Deprecated + // removeListener: () => {}, // Deprecated + // addEventListener: () => {}, + // removeEventListener: () => {}, + // dispatchEvent: () => {}, + // }), + // }); + // }); + // + // afterAll(() => { + // // Restore the original location properties + // global.window.location.href = originalLocationHref; + // global.window.location.hash = originalLocationHash; + // }); + + beforeAll(() => { + window.matchMedia = window.matchMedia || function() { + return { + matches: false, + addListener: function() {}, + removeListener: function() {} + }; + }; + }); + beforeEach(() => { + // history = createMemoryHistory(); + //Mock window object + const { location } = window; + //delete global.window.location; + global.window.location = { ...location, + //@ts-ignore + replace: () => {}, + hash: '/clusterInformation' // add this line to set the hash + }; + const url = "http://localhost"; + global.window.location.href = url; + ClusterApi.clusterInfo = async () => mockClusterInfo; + ClusterApi.hostClustersInfo = async () => mockHostClusterInfo; + }); + it('should render without crashing', () => { + render( + + + + + + ); + }); + + const texts = [ + 'Welcome to Apache Ambari', + 'Provision a cluster, manage who can access the cluster, and customize views for Ambari users.', + 'Create a Cluster', + 'Use the Install Wizard to select services and configure your cluster' + ]; + + it.each(texts)('displays the text "%s"', async (text) => { + render( + + + + + + ); + await waitFor(async () => { + const textElement = await screen.findByText(new RegExp(text, 'i')); + console.log("text element is ", textElement.textContent); + expect(textElement).toBeInTheDocument(); + }); + }); + + it('renders the launch install wizard button and navigates to the installer route on click', async () => { + render( + + + + + + ); + await waitFor(async () => { + const launchInstallWizardButton = await screen.findByRole('button', {name: /launch install wizard/i}); + expect(launchInstallWizardButton).toBeInTheDocument(); + + await userEvent.click(launchInstallWizardButton); + + // Wait for the URL to change + expect(window.location.href).toBe('/#/installer/step0'); + }); + }); + + it('renders installBox image when conditions are met', async () => { + render( + + + + + + ); + await waitFor(() => { + const installBoxImgElement = screen.getByAltText('Install Box') as HTMLImageElement; + expect(installBoxImgElement).toBeInTheDocument(); + expect(installBoxImgElement.src).toContain("install-box.svg"); + }) + }); +}); \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/CreateInstance.test.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/CreateInstance.test.tsx new file mode 100644 index 00000000000..bcc4dae7418 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/CreateInstance.test.tsx @@ -0,0 +1,398 @@ +/** + * 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 { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, beforeEach, expect, vi } from "vitest"; +import { createMemoryHistory } from "history"; +import { Router } from "react-router-dom"; +import AppContent from "../context/AppContext"; +import CreateInstance from "../screens/Views/CreateInstance"; +import ClusterApi from "../api/clusterApi"; +import { + mockClusterInfo, + mockRemoteClusterInfo, + mockViewsDetails, +} from "../__mocks__/mockCreateInstance"; +import "@testing-library/jest-dom/vitest"; +import toast from "react-hot-toast"; +import ViewApi from "../api/viewApi"; + + +let mockToastSuccessMessage = ""; +let mockToastErrorMessage = ""; + +toast.success = (message) => { + mockToastSuccessMessage = message as string; + return ""; +}; + +toast.error = (message) => { + mockToastErrorMessage = message as string; + return ""; +}; + +type CreateInstanceProps = { + isOpen: boolean; + onClose: () => void; + viewDetails: any; + successCallback: (() => void); + viewInstanceInfoToBeCloned?: any; +}; + +describe("Create Instance UTs", () => { + const mockContext = { + cluster: { cluster_name: "testCluster" }, + setSelectedOption: () => "Views", + }; + + const renderCreateInstanceComponent = (props : CreateInstanceProps) => { + render( + + + + + + ); + }; + + beforeEach(async () => { + ClusterApi.clusterInfo = async () => mockClusterInfo; + ClusterApi.remoteClusterInfo = async () => mockRemoteClusterInfo; + }); + + it("renders CreateInstance component without crashing", () => { + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + }); + + it("renders loading spinner when data is being fetched", async () => { + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + const spinner = screen.getByTestId("admin-spinner"); + expect(spinner).toBeInTheDocument(); + }); + + it("renders the component correctly", async () => { + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + expect(screen.getByText(/Create Instance/i)).toBeInTheDocument(); + expect(screen.getByText(/Instance Name/i)).toBeInTheDocument(); + expect(screen.getByText(/Display Name/i)).toBeInTheDocument(); + expect(screen.getByText(/Description/i)).toBeInTheDocument(); + expect(screen.getByText(/Visible/i)).toBeInTheDocument(); + }); + + it("handles form field changes", async () => { + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const instanceNameInput = screen.getByTestId(/instance-name/i); + fireEvent.change(instanceNameInput, { + target: { value: "Updated Instance Name" }, + }); + expect((instanceNameInput as HTMLInputElement).value).toBe( + "Updated Instance Name" + ); + + const displayNameInput = screen.getByTestId(/display-name/i); + fireEvent.change(displayNameInput, { target: { value: "Updated Label" } }); + expect((displayNameInput as HTMLInputElement).value).toBe("Updated Label"); + + const descriptionInput = screen.getByTestId(/description/i); + fireEvent.change(descriptionInput, { + target: { value: "Updated Description" }, + }); + expect((descriptionInput as HTMLInputElement).value).toBe( + "Updated Description" + ); + }); + + it("calls onClose when the close button is clicked", async () => { + const mockOnClose = vi.fn(); + renderCreateInstanceComponent({ + isOpen: true, + onClose: mockOnClose, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const closeButton = screen.getByText(/CANCEL/i); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("calls the create instance API when the form is submitted", async () => { + ViewApi.addView = async () => { + toast.success("Created instance Updated Instance Name"); + return { status: 200 }; + }; + + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const instanceNameInput = screen.getByTestId(/instance-name/i); + fireEvent.change(instanceNameInput, { + target: { value: "UpdatedInstanceName" }, + }); + const displayNameInput = screen.getByTestId(/display-name/i); + fireEvent.change(displayNameInput, { target: { value: "UpdatedLabel" } }); + const descriptionInput = screen.getByTestId(/description/i); + fireEvent.change(descriptionInput, { + target: { value: "UpdatedDescription" }, + }); + + const saveButton = screen.getByText(/SAVE/i); + fireEvent.click(saveButton); + + + await waitFor(() => { + expect(mockToastSuccessMessage).not.toBeUndefined; + expect(mockToastSuccessMessage).toBe("Created instance Updated Instance Name"); + }); + }); + + it("should display remote clusters", async () => { + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const remoteClusterSelect = screen.getByTestId(/remote-toggle-button/i); + fireEvent.click(remoteClusterSelect); + + expect(screen.getByText(/test_cluster_remote/i)).toBeInTheDocument(); + }); + + it("should display select options", async () => { + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const selectView = screen.getByLabelText(/Select view/i); + fireEvent.click(selectView); + + expect(screen.getByText(/Item 1 view/i)).toBeInTheDocument(); + }); + + it("should render the warning modal", async () => { + ViewApi.addView = async () => { + toast.success("Instance created successfully"); + return { status: 200 }; + }; + + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const instanceNameInput = screen.getByTestId(/instance-name/i); + fireEvent.change(instanceNameInput, { + target: { value: "Updated Instance Name" }, + }); + + const cancelButton = screen.getByText(/CANCEL/i); + fireEvent.click(cancelButton); + + expect(screen.getByText(/Warning/i)).toBeInTheDocument(); + }); + + it("displays the correct version options", async () => { + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const versionSelect = screen.getByText(/Select Version/i); + fireEvent.click(versionSelect); + + mockViewsDetails.items[0].versions.forEach((version) => { + expect( + screen.getByText(version.ViewVersionInfo.version) + ).toBeInTheDocument(); + }); + }); + + it("handles API errors gracefully", async () => { + ViewApi.addView = async () => { + toast.error("Failed to create instance"); + return { status: 404 }; + }; + + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const instanceNameInput = screen.getByTestId(/instance-name/i); + fireEvent.change(instanceNameInput, { + target: { value: "UpdatedInstanceName" }, + }); + const displayNameInput = screen.getByTestId(/display-name/i); + fireEvent.change(displayNameInput, { target: { value: "UpdatedLabel" } }); + const descriptionInput = screen.getByTestId(/description/i); + fireEvent.change(descriptionInput, { + target: { value: "UpdatedDescription" }, + }); + + const saveButton = screen.getByText(/SAVE/i); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockToastErrorMessage).not.toBeUndefined; + expect(mockToastErrorMessage).toBe("Failed to create instance"); + }); + }); + + it("toggles visibility checkbox", async () => { + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const visibilityCheckbox = screen.getByLabelText(/Visible/i); + expect(visibilityCheckbox).toBeChecked(); + + fireEvent.click(visibilityCheckbox); + expect(visibilityCheckbox).not.toBeChecked(); + + fireEvent.click(visibilityCheckbox); + expect(visibilityCheckbox).toBeChecked(); + }); + + it("renders the component when isOpen is false", () => { + renderCreateInstanceComponent({ + isOpen: false, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + expect(screen.queryByText(/Create Instance/i)).not.toBeInTheDocument(); + }); + + it("displays error message when required fields are empty", async () => { + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const saveButton = screen.getByText(/SAVE/i); + fireEvent.click(saveButton); + + await waitFor(() => { + const warningMessages = screen.getAllByText(/Field required!/i); + expect(warningMessages.length).toBeGreaterThanOrEqual(3); + }); + }); + + it("resets form after successful submission", async () => { + ViewApi.addView = async () => { + toast.success("Instance created successfully"); + return { status: 200 }; + }; + + renderCreateInstanceComponent({ + isOpen: true, + onClose: () => {}, + viewDetails: mockViewsDetails, + successCallback: () => {}, + }); + + await waitFor(() => screen.getByText(/Create Instance/i)); + + const instanceNameInput = screen.getByTestId(/instance-name/i); + fireEvent.change(instanceNameInput, { + target: { value: "UpdatedInstanceName" }, + }); + const displayNameInput = screen.getByTestId(/display-name/i); + fireEvent.change(displayNameInput, { target: { value: "UpdatedLabel" } }); + const descriptionInput = screen.getByTestId(/description/i); + fireEvent.change(descriptionInput, { + target: { value: "UpdatedDescription" }, + }); + + const saveButton = screen.getByText(/SAVE/i); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockToastSuccessMessage).not.toBeUndefined; + expect(mockToastSuccessMessage).toBe("Instance created successfully"); + }); + + expect((instanceNameInput as HTMLInputElement).value).toBe(""); + expect((displayNameInput as HTMLInputElement).value).toBe(""); + expect((descriptionInput as HTMLInputElement).value).toBe(""); + }); +}); diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/CreateShortUrl.test.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/CreateShortUrl.test.tsx new file mode 100644 index 00000000000..b7ff526b8f2 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/CreateShortUrl.test.tsx @@ -0,0 +1,198 @@ +/** + * 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 { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, beforeEach, expect } from "vitest"; +import { createMemoryHistory } from "history"; +import { Router } from "react-router-dom"; +import CreateShortUrl from "../screens/Views/CreateShortUrl"; +import ViewsInformationApi from "../api/viewsApiInfo"; +import AppContent from "../context/AppContext"; +import toast from "react-hot-toast"; +import { mockViewsList } from "../__mocks__/mockViewsList"; +import "@testing-library/jest-dom/vitest"; + +let mockToastSuccessMessage = ""; +let mockToastErrorMessage = ""; + +toast.success = (message) => { + mockToastSuccessMessage = message as string; + return ""; +}; + +toast.error = (message) => { + mockToastErrorMessage = message as string; + return ""; +}; + +describe("CreateShortUrl", () => { + const mockContext = { + cluster: { cluster_name: "testCluster" }, + setSelectedOption: () => "Views", + }; + + const renderCreateShortUrlComponent = () => { + render( + + + + + + ); + }; + + beforeEach(async () => { + ViewsInformationApi.viewsListAPI = async () => mockViewsList; + }); + + it("renders CreateShortUrl component without crashing", () => { + renderCreateShortUrlComponent(); + }); + + it("renders loading spinner when data is being fetched", async () => { + renderCreateShortUrlComponent(); + const spinner = screen.getByTestId("admin-spinner"); + expect(spinner).toBeInTheDocument(); + }); + + it("renders form fields correctly", async () => { + renderCreateShortUrlComponent(); + await waitFor(() => screen.getByText(/Create New URL/i)); + + const nameInput = screen.getByText(/Name/i); + const shortUrlInput = screen.getByText(/Short URL/i); + + expect(nameInput).toBeInTheDocument(); + expect(shortUrlInput).toBeInTheDocument(); + }); + + it("handles form inputs correctly", async () => { + renderCreateShortUrlComponent(); + await waitFor(() => screen.getByText(/Create New URL/i)); + + const nameInput = screen.getByTestId(/name-input/i); + const shortUrlInput = screen.getByTestId(/shorturl-input/i); + + fireEvent.change(nameInput, { target: { value: "TestName" } }); + fireEvent.change(shortUrlInput, { target: { value: "testurl" } }); + + expect((nameInput as HTMLInputElement).value).toBe("TestName"); + expect((shortUrlInput as HTMLInputElement).value).toBe("testurl"); + }); + + it("should render views, instance select", async () => { + renderCreateShortUrlComponent(); + await waitFor(() => screen.getByText(/Create New URL/i)); + + const viewSelect = screen.getByLabelText(/view-select/i); + const instanceSelect = screen.getByLabelText(/instance-select/i); + + fireEvent.mouseDown(viewSelect); + await waitFor(() => { + expect(screen.getByText(/Item 1 view/i)).toBeInTheDocument(); + }); + + const selectedView = screen.getByText(/Item 1 view/i); + fireEvent.click(selectedView); + + fireEvent.mouseDown(instanceSelect); + await waitFor(() => { + expect(screen.getByText(/Instance 1/i)).toBeInTheDocument(); + }); + + const selectedInstance = screen.getByText(/Instance 1/i); + fireEvent.click(selectedInstance); + + expect(screen.getByText(/Instance 1/i)).toBeInTheDocument(); + expect(screen.getByText(/Item 1 view/i)).toBeInTheDocument(); + }); + + it("submits the form and shows success toast on successful API call", async () => { + ViewsInformationApi.createShortUrl = async () => { + toast.success("URL created successfully"); + return { status: 200 }; + }; + renderCreateShortUrlComponent(); + await waitFor(() => screen.getByText(/Name/i)); + + const nameInput = screen.getByTestId(/name-input/i); + const shortUrlInput = screen.getByTestId(/shorturl-input/i); + const viewSelect = screen.getByLabelText(/view-select/i); + const instanceSelect = screen.getByLabelText(/instance-select/i); + fireEvent.change(nameInput, { target: { value: "TestName" } }); + fireEvent.change(shortUrlInput, { target: { value: "testurl" } }); + fireEvent.mouseDown(viewSelect); + const selectedView = screen.getByText(/Item 1 view/i); + fireEvent.click(selectedView); + + fireEvent.mouseDown(instanceSelect); + const selectedInstance = screen.getByText(/Instance 1/i); + fireEvent.click(selectedInstance); + + const saveButton = screen.getByText(/SAVE/i); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockToastSuccessMessage).not.toBeUndefined(); + expect(mockToastSuccessMessage).toBe("URL created successfully"); + }); + }); + + it("shows warnings on submitting empty inputs", async () => { + renderCreateShortUrlComponent(); + + await waitFor(() => screen.getByText(/Name/i)); + const saveButton = screen.getByText(/SAVE/i); + fireEvent.click(saveButton); + const warnings = screen.getAllByText(/Required/i); + + expect(warnings.length).toBe(3); + }); + + it("shows error toast on API call failure", async () => { + ViewsInformationApi.createShortUrl = async () => { + toast.error("Error creating URL"); + return { status: 404 }; + }; + renderCreateShortUrlComponent(); + await waitFor(() => screen.getByText(/Name/i)); + + const nameInput = screen.getByTestId(/name-input/i); + const shortUrlInput = screen.getByTestId(/shorturl-input/i); + const viewSelect = screen.getByLabelText(/view-select/i); + const instanceSelect = screen.getByLabelText(/instance-select/i); + fireEvent.change(nameInput, { target: { value: "TestName" } }); + fireEvent.change(shortUrlInput, { target: { value: "testurl" } }); + fireEvent.mouseDown(viewSelect); + const selectedView = screen.getByText(/Item 1 view/i); + fireEvent.click(selectedView); + + fireEvent.mouseDown(instanceSelect); + const selectedInstance = screen.getByText(/Instance 1/i); + fireEvent.click(selectedInstance); + + const saveButton = screen.getByText(/SAVE/i); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockToastErrorMessage).not.toBeUndefined(); + expect(mockToastErrorMessage).toBe("Error creating URL"); + }); + }); +}); diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditInstance.test.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditInstance.test.tsx new file mode 100644 index 00000000000..412da94db6d --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditInstance.test.tsx @@ -0,0 +1,713 @@ +/** + * 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 { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { Router } from "react-router-dom"; +import EditInstance from "../screens/Views/EditInstance"; +import { createMemoryHistory } from "history"; +import AppContent from "../context/AppContext"; +import toast from "react-hot-toast"; +import ViewsInformationApi from "../api/viewsApiInfo"; +import "@testing-library/jest-dom/vitest"; +import { + mockGroupData, + mockInstanceDetails, + mockPrivileges, + mockUsersdata, + mockViewsData, +} from "../__mocks__/mockEditInstance"; +import { beforeEach, describe, expect, it } from "vitest"; +import UserGroupApi from "../api/userGroupApi"; + +const mockClusterName = "TestCluster"; +const mockContext = { + cluster: { cluster_name: mockClusterName }, + setSelectedOption: () => "Views", +}; + +let mockToastSuccessMessage = ""; +let mockToastErrorMessage = ""; + +toast.success = (message) => { + mockToastSuccessMessage = message as string; + return ""; +}; + +toast.error = (message) => { + mockToastErrorMessage = message as string; + return ""; +}; + +describe("EditInstance Component Tests", () => { + beforeEach(async () => { + ViewsInformationApi.getInstanceLabel = async () => mockInstanceDetails; + ViewsInformationApi.getViewDetails = async () => mockViewsData; + UserGroupApi.usersList = async () => mockUsersdata; + UserGroupApi.groupsList = async () => mockGroupData; + ViewsInformationApi.getPrivileges = async () => mockPrivileges; + ViewsInformationApi.getViewDetails = async () => mockViewsData; + }); + + const renderEditInstanceComponent = () => { + render( + + + + + + ); + }; + + it("Should render the Edit Instance component without crashing", () => { + renderEditInstanceComponent(); + }); + + it("renders loading spinner when data is being fetched", async () => { + renderEditInstanceComponent(); + const spinner = screen.getByTestId("admin-spinner"); + expect(spinner).toBeInTheDocument(); + }); + + it("should render all sections", async () => { + renderEditInstanceComponent(); + + await waitFor(() => screen.getByText(/Details/i)); + + const details = await screen.getByText(/Details/i); + expect(details).toBeInTheDocument(); + const settings = await screen.getByText(/Settings/i); + expect(settings).toBeInTheDocument(); + const permissions = await screen.getByText(/Permissions/i); + expect(permissions).toBeInTheDocument(); + }); + + it("renders form fields correctly", async () => { + renderEditInstanceComponent(); + await waitFor(() => screen.getByTestId(/instanceName/i)); + + const instanceNameInput = screen.getByTestId(/instanceName/i); + const displayNameInput = screen.getByTestId(/displayName/i); + const descriptionInput = screen.getByTestId(/description/i); + + expect(instanceNameInput).toBeInTheDocument(); + expect(displayNameInput).toBeInTheDocument(); + expect(descriptionInput).toBeInTheDocument(); + }); + + it("handles form inputs correctly", async () => { + renderEditInstanceComponent(); + await waitFor(() => screen.getByTestId(/instanceName/i)); + + const displayNameInput = screen.getByTestId(/displayName/i); + const descriptionInput = screen.getByTestId(/description/i); + + fireEvent.change(displayNameInput, { + target: { value: "Updated Display" }, + }); + fireEvent.change(descriptionInput, { + target: { value: "Updated Description" }, + }); + + expect((displayNameInput as HTMLInputElement).value).toBe( + "Updated Display" + ); + expect((descriptionInput as HTMLInputElement).value).toBe( + "Updated Description" + ); + }); + + it("submits the form and shows success toast on successful API call", async () => { + ViewsInformationApi.updateDetails = async () => { + toast.success("Updated instance details successfully"); + return { status: 200 }; + }; + + renderEditInstanceComponent(); + await waitFor(() => screen.getByTestId(/instanceName/i)); + + const displayNameInput = screen.getByTestId(/displayName/i); + const descriptionInput = screen.getByTestId(/description/i); + + const editButton = screen.getByTestId("details"); + fireEvent.click(editButton); + + const saveButton = screen.getByTestId("details"); + + fireEvent.change(displayNameInput, { + target: { value: "Updated Display" }, + }); + fireEvent.change(descriptionInput, { + target: { value: "Updated Description" }, + }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockToastSuccessMessage).not.toBeUndefined; + expect(mockToastSuccessMessage).toBe( + "Updated instance details successfully" + ); + }); + }); + + it("should display warnings for incorrect form inputs", async () => { + renderEditInstanceComponent(); + await waitFor(() => screen.getByTestId(/instanceName/i)); + + const displayNameInput = screen.getByTestId(/displayName/i); + + const editButton = screen.getByTestId("details"); + fireEvent.click(editButton); + + const saveButton = screen.getByTestId("details"); + fireEvent.change(displayNameInput, { target: { value: "" } }); + + fireEvent.click(saveButton); + + const warning1 = screen.getByText(/Field is required/i); + const warning2 = screen.getByText( + /Must not contain any special characters/i + ); + expect(warning1).toBeInTheDocument(); + expect(warning2).toBeInTheDocument(); + }); + + it("should display Create Short URL", async () => { + const mockInstanceDateCreateURL = { + href: "http://example.com/root", + ViewInstanceInfo: { + cluster_handle: 123, + cluster_type: "HDFS", + context_path: "/context/path", + description: "A description of the view instance", + icon64_path: null, + icon_path: null, + instance_name: "Instance1", + label: "Instance Label", + static: false, + validation_result: { + valid: true, + detail: "Validation successful", + }, + version: "1.0", + view_name: "View1", + visible: true, + instance_data: {}, + properties: { + "hdfs.auth_to_local": { + viewInfo: { + name: "hdfs.auth_to_local", + description: "Description for hdfs.auth_to_local", + label: "HDFS Auth To Local", + placeholder: null, + defaultValue: null, + clusterConfig: "cluster-config", + required: true, + masked: false, + value: null, + isSetting: false, + }, + }, + "hdfs.umask-mode": { + viewInfo: { + name: "hdfs.umask-mode", + description: "Description for hdfs.umask-mode", + label: "HDFS Umask Mode", + placeholder: null, + defaultValue: "022", + clusterConfig: "cluster-config", + required: true, + masked: false, + value: "022", + isSetting: false, + }, + }, + "tmp.dir": { + viewInfo: { + name: "tmp.dir", + description: "Description for tmp.dir", + label: "Temporary Directory", + placeholder: "/tmp", + defaultValue: "/tmp", + clusterConfig: null, + required: true, + masked: false, + value: "/tmp", + isSetting: false, + }, + }, + "view.conf.keyvalues": { + viewInfo: { + name: "view.conf.keyvalues", + description: "Description for view.conf.keyvalues", + label: "View Config Key Values", + placeholder: null, + defaultValue: null, + clusterConfig: null, + required: false, + masked: false, + value: null, + isSetting: false, + }, + }, + "webhdfs.auth": { + viewInfo: { + name: "webhdfs.auth", + description: "Description for webhdfs.auth", + label: "WebHDFS Auth", + placeholder: "auth-placeholder", + defaultValue: null, + clusterConfig: null, + required: true, + masked: false, + value: null, + isSetting: false, + }, + }, + "webhdfs.client.failover.proxy.provider": { + viewInfo: { + name: "webhdfs.client.failover.proxy.provider", + description: + "Description for webhdfs.client.failover.proxy.provider", + label: "WebHDFS Client Failover Proxy Provider", + placeholder: null, + defaultValue: null, + clusterConfig: "cluster-config", + required: true, + masked: false, + value: null, + isSetting: false, + }, + }, + "webhdfs.ha.namenode.http-address.list": { + viewInfo: { + name: "webhdfs.ha.namenode.http-address.list", + description: + "Description for webhdfs.ha.namenode.http-address.list", + label: "WebHDFS HA Namenode HTTP Address List", + placeholder: null, + defaultValue: null, + clusterConfig: "cluster-config", + required: true, + masked: false, + value: null, + isSetting: false, + }, + }, + "webhdfs.ha.namenode.https-address.list": { + viewInfo: { + name: "webhdfs.ha.namenode.https-address.list", + description: + "Description for webhdfs.ha.namenode.https-address.list", + label: "WebHDFS HA Namenode HTTPS Address List", + placeholder: null, + defaultValue: null, + clusterConfig: "cluster-config", + required: true, + masked: false, + value: null, + isSetting: false, + }, + }, + "webhdfs.ha.namenode.rpc-address.list": { + viewInfo: { + name: "webhdfs.ha.namenode.rpc-address.list", + description: + "Description for webhdfs.ha.namenode.rpc-address.list", + label: "WebHDFS HA Namenode RPC Address List", + placeholder: null, + defaultValue: null, + clusterConfig: "cluster-config", + required: true, + masked: false, + value: null, + isSetting: false, + }, + }, + "webhdfs.ha.namenodes.list": { + viewInfo: { + name: "webhdfs.ha.namenodes.list", + description: "Description for webhdfs.ha.namenodes.list", + label: "WebHDFS HA Namenodes List", + placeholder: null, + defaultValue: null, + clusterConfig: "cluster-config", + required: true, + masked: false, + value: null, + isSetting: false, + }, + }, + "webhdfs.nameservices": { + viewInfo: { + name: "webhdfs.nameservices", + description: "Description for webhdfs.nameservices", + label: "WebHDFS Nameservices", + placeholder: null, + defaultValue: null, + clusterConfig: "cluster-config", + required: true, + masked: false, + value: null, + isSetting: false, + }, + }, + "webhdfs.url": { + viewInfo: { + name: "webhdfs.url", + description: "Description for webhdfs.url", + label: "WebHDFS URL", + placeholder: null, + defaultValue: null, + clusterConfig: "cluster-config", + required: true, + masked: false, + value: null, + isSetting: false, + }, + }, + "webhdfs.username": { + viewInfo: { + name: "webhdfs.username", + description: "Description for webhdfs.username", + label: "WebHDFS Username", + placeholder: "username-placeholder", + defaultValue: "default-username", + clusterConfig: null, + required: true, + masked: false, + value: "default-username", + isSetting: false, + }, + }, + }, + property_validation_results: { + "hdfs.auth_to_local": { + valid: true, + detail: "Validation successful for hdfs.auth_to_local", + }, + "hdfs.umask-mode": { + valid: true, + detail: "Validation successful for hdfs.umask-mode", + }, + "tmp.dir": { + valid: true, + detail: "Validation successful for tmp.dir", + }, + "view.conf.keyvalues": { + valid: true, + detail: "Validation successful for view.conf.keyvalues", + }, + "webhdfs.auth": { + valid: true, + detail: "Validation successful for webhdfs.auth", + }, + "webhdfs.client.failover.proxy.provider": { + valid: true, + detail: + "Validation successful for webhdfs.client.failover.proxy.provider", + }, + "webhdfs.ha.namenode.http-address.list": { + valid: true, + detail: + "Validation successful for webhdfs.ha.namenode.http-address.list", + }, + "webhdfs.ha.namenode.https-address.list": { + valid: true, + detail: + "Validation successful for webhdfs.ha.namenode.https-address.list", + }, + "webhdfs.ha.namenode.rpc-address.list": { + valid: true, + detail: + "Validation successful for webhdfs.ha.namenode.rpc-address.list", + }, + "webhdfs.ha.namenodes.list": { + valid: true, + detail: "Validation successful for webhdfs.ha.namenodes.list", + }, + "webhdfs.nameservices": { + valid: true, + detail: "Validation successful for webhdfs.nameservices", + }, + "webhdfs.url": { + valid: true, + detail: "Validation successful for webhdfs.url", + }, + "webhdfs.username": { + valid: true, + detail: "Validation successful for webhdfs.username", + }, + }, + }, + privileges: [ + { + href: "http://example.com/privilege/1", + PrivilegeInfo: { + instance_name: "Instance1", + permission_label: "Read", + permission_name: "READ_PRIVILEGE", + principal_name: "User1", + principal_type: "USER", + privilege_id: 1, + version: "1.0", + view_name: "View1", + }, + }, + { + href: "http://example.com/privilege/2", + PrivilegeInfo: { + instance_name: "Instance1", + permission_label: "Write", + permission_name: "WRITE_PRIVILEGE", + principal_name: "User2", + principal_type: "USER", + privilege_id: 2, + version: "1.0", + view_name: "View1", + }, + }, + ], + resources: [ + { + href: "http://example.com/resource/1", + instance_name: "Instance1", + name: "Resource1", + version: "1.0", + view_name: "View1", + }, + { + href: "http://example.com/resource/2", + instance_name: "Instance1", + name: "Resource2", + version: "1.0", + view_name: "View1", + }, + ], + }; + + ViewsInformationApi.getInstanceLabel = async () => + mockInstanceDateCreateURL; + + renderEditInstanceComponent(); + + await waitFor(() => screen.getByText(/Instance Name/i)); + + const permissions = await screen.getByText(/Create new URL/i); + expect(permissions).toBeInTheDocument(); + }); + + it("should display options in the select users according to the data obtained from the API", async () => { + renderEditInstanceComponent(); + await waitFor(() => screen.getByText(/Grant permission to these users/i)); + + const userSelect = screen.getByLabelText("select-users"); + const groupSelect = screen.getByLabelText("select-groups"); + + expect(userSelect).toBeInTheDocument(); + expect(groupSelect).toBeInTheDocument(); + }); + + it("should update selected users and groups correctly", async () => { + renderEditInstanceComponent(); + await waitFor(() => screen.getByText(/Grant permission to these users/i)); + + const userSelect = screen.getByLabelText("select-users"); + const groupSelect = screen.getByLabelText("select-groups"); + + if (userSelect.firstChild) { + fireEvent.keyDown(userSelect.firstChild, { key: "ArrowDown" }); + } + await waitFor(() => screen.getByText("User1")); + fireEvent.click(screen.getByText("User1")); + + if (groupSelect.firstChild) { + fireEvent.keyDown(groupSelect.firstChild, { key: "ArrowDown" }); + } + await waitFor(() => screen.getByText("Group1")); + fireEvent.click(screen.getByText("Group1")); + + // Verify the selected values + expect(screen.getByText("User1")).toBeInTheDocument(); + expect(screen.getByText("Group1")).toBeInTheDocument(); + }); + + it("calls delete short URL API and shows success toast on confirmation, if URL exists ", async () => { + ViewsInformationApi.deleteShortUrl = async () => { + toast.success("short Url deleted"); + return { status: 200 }; + }; + renderEditInstanceComponent(); + await waitFor(() => screen.getByText(/Details/i)); + const editButton = screen.getByTestId("details"); + fireEvent.click(editButton); + + const deleteButton = screen.getByText("Delete"); + fireEvent.click(deleteButton); + + const confirmButton = await screen.findByText(/OK/i); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockToastSuccessMessage).not.toBeUndefined; + expect(mockToastSuccessMessage).toBe("Short Url deleted"); + }); + }); + + it("Error toast on Delete short URL API failure ", async () => { + ViewsInformationApi.deleteShortUrl = async () => { + toast.error("Failed to delete URL"); + return { status: 400 }; + }; + renderEditInstanceComponent(); + await waitFor(() => screen.getByText(/Details/i)); + const editButton = screen.getByTestId("details"); + fireEvent.click(editButton); + + const deleteButton = screen.getByText("Delete"); + fireEvent.click(deleteButton); + + const confirmButton = await screen.findByText(/OK/i); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockToastErrorMessage).not.toBeUndefined; + expect(mockToastErrorMessage).toBe("Failed to delete URL"); + }); + }); + + it("should delete the instance", async () => { + ViewsInformationApi.deleteInstance = async () => { + toast.success(`Instance deleted successfully`); + return { status: 200 }; + }; + + renderEditInstanceComponent(); + await waitFor(() => screen.getByText(/DELETE INSTANCE/i)); + + const deleteIcon = screen.getByText(/DELETE INSTANCE/i); + fireEvent.click(deleteIcon); + const yesButton = await screen.findByText("OK"); + fireEvent.click(yesButton); + + await waitFor(() => { + expect(mockToastSuccessMessage).not.toBeUndefined; + expect(mockToastSuccessMessage).toBe(`Instance deleted successfully`); + }); + }); + + it("updates checkbox state correctly when clicked", async () => { + renderEditInstanceComponent(); + await waitFor(() => screen.getByText("Local Cluster Permissons"), { + timeout: 5000, + }); + + const clusterAdministratorBox = screen.getByLabelText( + "Cluster Administrator" + ); + const clusterOperatorBox = screen.getByLabelText("Cluster Operator"); + const serviceAdministratorBox = screen.getByLabelText( + "Service Administrator" + ); + const clusterUserCheckbox = screen.getByLabelText("Cluster User"); + const serviceOperator = screen.getByLabelText("Service Operator"); + + expect(clusterAdministratorBox).toBeInTheDocument(); + expect(clusterOperatorBox).toBeInTheDocument(); + expect(clusterAdministratorBox).not.toBeChecked(); + expect(clusterOperatorBox).not.toBeChecked(); + expect(serviceAdministratorBox).not.toBeChecked(); + expect(serviceAdministratorBox).not.toBeChecked(); + expect(clusterUserCheckbox).not.toBeChecked(); + expect(serviceOperator).not.toBeChecked(); + + fireEvent.click(clusterAdministratorBox); + fireEvent.click(clusterOperatorBox); + fireEvent.click(serviceAdministratorBox); + fireEvent.click(clusterUserCheckbox); + fireEvent.click(serviceOperator); + + await waitFor(() => { + expect(clusterAdministratorBox).toBeChecked(); + expect(clusterOperatorBox).toBeChecked(); + expect(serviceAdministratorBox).toBeChecked(); + expect(clusterUserCheckbox).toBeChecked(); + expect(serviceOperator).toBeChecked(); + }); + }); + + it("should select all and clear all", async () => { + renderEditInstanceComponent(); + await waitFor(() => screen.getByText("Local Cluster Permissons"), { + timeout: 5000, + }); + + const checkAll = screen.getByTestId("check-all"); + expect(checkAll).toBeInTheDocument(); + + fireEvent.click(checkAll); + const clusterAdministratorBox = screen.getByLabelText( + "Cluster Administrator" + ); + const clusterOperatorBox = screen.getByLabelText("Cluster Operator"); + const serviceAdministratorBox = screen.getByLabelText( + "Service Administrator" + ); + + expect(clusterAdministratorBox).toBeChecked(); + expect(clusterOperatorBox).toBeChecked(); + expect(serviceAdministratorBox).toBeChecked(); + }); + + it("Calls the update privileges API, and displays a success toast", async () => { + ViewsInformationApi.updatePrivileges = async () => { + toast.success("Updated permissions"); + return { status: 200 }; + }; + + renderEditInstanceComponent(); + await waitFor(() => screen.getByText("Local Cluster Permissons"), { + timeout: 5000, + }); + + const clusterUserCheckbox = screen.getByLabelText("Cluster Administrator"); + expect(clusterUserCheckbox).toBeInTheDocument(); + expect(clusterUserCheckbox).not.toBeChecked(); + + fireEvent.click(clusterUserCheckbox); + + await waitFor(() => { + expect(clusterUserCheckbox).toBeChecked(); + expect(mockToastSuccessMessage).not.toBeUndefined; + expect(mockToastSuccessMessage).toBe("Updated permissions"); + }); + }); + + it("should not render cluster role permissions when clusster type is not local ambari", async () => { + mockInstanceDetails.ViewInstanceInfo.cluster_type = "REOTE_AMBARI"; + ViewsInformationApi.getInstanceLabel = async () => mockInstanceDetails; + + renderEditInstanceComponent(); + + await waitFor(() => screen.getByText(/Permissions/i)); + + const clusterPermissions = screen.queryByText( + "Cluster Roles is only available" + ); + + expect(clusterPermissions).not.toBeInTheDocument(); + }); +}); diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/DeregisterRemoteCluster.test.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/DeregisterRemoteCluster.test.tsx new file mode 100644 index 00000000000..4117027cd4a --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/DeregisterRemoteCluster.test.tsx @@ -0,0 +1,104 @@ +/** + * 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, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createMemoryHistory } from "history"; +import { Router } from "react-router-dom"; +import "@testing-library/jest-dom/vitest"; +import RemoteClusterApi from "../../../../api/remoteCluster"; +import DeregisterRemoteCluster from "../../../../screens/ClusterManagement/RemoteClusters/DeregisterRemoteCluster"; +import AppContent from "../../../../context/AppContext"; +import toast from "react-hot-toast"; +import { get } from "lodash"; + +const mockClusterName = "TestCluster1"; +const mockContext = { + cluster: { cluster_name: mockClusterName }, + setSelectedOption: () => "RemoteCluster", +}; + +const renderDeregisterRemoteCluster = () => { + render( + + + + + + ); +}; + +let mockToastSuccessMessage: string; +let mockToastErrorMessage: string; + +toast.success = (message) => { + mockToastSuccessMessage = message as string; + return ""; +}; + +toast.error = (message) => { + mockToastErrorMessage = message as string; + return ""; +}; + +describe("DeregisterRemoteCluster component", () => { + it("renders without crashing", () => { + renderDeregisterRemoteCluster(); + }); + + it("should open the confirmation modal on clicking DEREGISTER CLUSTER button", async () => { + renderDeregisterRemoteCluster(); + const deregisterButton = screen.getByText("DEREGISTER CLUSTER"); + fireEvent.click(deregisterButton); + const modalTitle = await screen.findByText("Deregister cluster"); + expect(modalTitle).toBeInTheDocument(); + }); + + it("should call API and show success toast on clicking Ok button in the modal", async () => { + vi.spyOn(RemoteClusterApi, "deregisterRemoteCluster").mockResolvedValue({ + status: 200, + }); + + renderDeregisterRemoteCluster(); + const deregisterButton = screen.getByText("DEREGISTER CLUSTER"); + fireEvent.click(deregisterButton); + const OkButton = await screen.findByText("OK"); + fireEvent.click(OkButton); + + await waitFor(() => { + expect(get(mockToastSuccessMessage, "props.children", []).join("")).toBe( + `De-registered cluster "${mockClusterName}" successfully!` + ); + }); + }); + + it("should show error toast when API call fails", async () => { + vi.spyOn(RemoteClusterApi, 'deregisterRemoteCluster').mockRejectedValue(new Error("API call failed")); + + renderDeregisterRemoteCluster(); + const deregisterButton = screen.getByText("DEREGISTER CLUSTER"); + fireEvent.click(deregisterButton); + const OkButton = await screen.findByText("OK"); + fireEvent.click(OkButton); + + await waitFor(() => { + expect(get(mockToastErrorMessage, "props.children", []).join("")).toBe( + "Error while deregistering cluster: API call failed" + ); + }); + }); +}); \ No newline at end of file diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/EditRemoteCluster.test.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/EditRemoteCluster.test.tsx new file mode 100644 index 00000000000..2a1428b6b52 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/EditRemoteCluster.test.tsx @@ -0,0 +1,386 @@ +/** + * 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, beforeEach } from "vitest"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { createMemoryHistory } from "history"; +import { Router } from "react-router-dom"; +import { mockClusterDataForEdit } from "../../../../__mocks__/mockRemoteCluster"; +import "@testing-library/jest-dom/vitest"; +import RemoteClusterApi from "../../../../api/remoteCluster"; +import EditRemoteCluster from "../../../../screens/ClusterManagement/RemoteClusters/EditRemoteCluster"; +import AppContent from "../../../../context/AppContext"; +import toast from "react-hot-toast"; + +const mockClusterName = "TestCluster1"; +const mockContext = { + cluster: { cluster_name: mockClusterName }, + setSelectedOption: () => "RemoteCluster", +}; + +const renderEditRemoteCluster = () => { + render( + + + + + + ); +}; + +let mockToastSuccessMessage: string; +let mockToastErrorMessage: string; + +toast.success = (message) => { + mockToastSuccessMessage = message as string; + return ""; +}; + +toast.error = (message) => { + mockToastErrorMessage = message as string; + return ""; +}; + +describe("EditRemoteCluster component", () => { + beforeEach(() => { + mockToastErrorMessage = ""; + mockToastSuccessMessage = ""; + }); + + it("renders without crashing", () => { + renderEditRemoteCluster(); + }); + + it("shows loading spinner when data is being fetched.", async () => { + RemoteClusterApi.getRemoteClusterByName = async () => []; + renderEditRemoteCluster(); + + const spinner = screen.getByTestId("admin-spinner"); + expect(spinner).toBeInTheDocument(); + }); + + it("should display current details of remote cluster", async () => { + RemoteClusterApi.getRemoteClusterByName = async () => + mockClusterDataForEdit; + renderEditRemoteCluster(); + + await waitFor(() => {}); + expect(getClusterNameInput()).toHaveValue( + mockClusterDataForEdit.ClusterInfo.name + ); + expect(getclusterUrlInput()).toHaveValue( + mockClusterDataForEdit.ClusterInfo.url + ); + }); + + it("should redirect to /remoteCluster on clicking cancel button", async () => { + RemoteClusterApi.getRemoteClusterByName = async () => + mockClusterDataForEdit; + const history = createMemoryHistory(); + render( + + + + + + ); + + await waitFor(() => {}); + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + expect(cancelButton).toBeInTheDocument(); + + fireEvent.click(cancelButton); + await waitFor(() => {}); + + await waitFor(() => { + expect(history.location.pathname).toBe("/remoteClusters"); + }); + }); + + it("loads data and displays update credential modal with username, password input fields, and buttons", async () => { + RemoteClusterApi.getRemoteClusterByName = async () => + mockClusterDataForEdit; + renderEditRemoteCluster(); + + await waitFor(() => { + expect(getClusterNameInput()).toHaveValue( + mockClusterDataForEdit.ClusterInfo.name + ); + expect(getclusterUrlInput()).toHaveValue( + mockClusterDataForEdit.ClusterInfo.url + ); + }); + + const updateCredentialButton = screen + .getByTestId("updateCredentialButton") + .querySelector("button"); + if (updateCredentialButton) { + fireEvent.click(updateCredentialButton); + } else { + throw new Error("Update credential button not found"); + } + + // Wait for the modal to be rendered + await waitFor(() => { + const dialogs = screen.getAllByRole("dialog"); + expect(dialogs.length).toBe(1); + + const updateCredentialsDialog = dialogs[0]; + expect( + within(updateCredentialsDialog).getByText(/Update Credentials/i) + ).toBeInTheDocument(); + }); + + // Check if the user and password fields are rendered + const updateCredentialsDialog = screen.getAllByRole("dialog")[0]; + expect( + within(updateCredentialsDialog).getByLabelText(/Cluster user/i) + ).toBeInTheDocument(); + expect( + within(updateCredentialsDialog).getByTestId("password-input") + ).toBeInTheDocument(); + + // Check if the cancel and update buttons are rendered + expect( + within(updateCredentialsDialog).getByRole("button", { name: /Cancel/i }) + ).toBeInTheDocument(); + expect( + within(updateCredentialsDialog).getByRole("button", { name: /Update/i }) + ).toBeInTheDocument(); + }); + + it("should update cluster name after submitting", async () => { + RemoteClusterApi.getRemoteClusterByName = async () => + mockClusterDataForEdit; + RemoteClusterApi.updateRemoteCluster = async () => { + toast.success(`Cluster "UpdatedClusterName" updated successfully`); + return { status: 200 }; + }; + renderEditRemoteCluster(); + + await waitFor(() => { + expect( + screen.getByDisplayValue(mockClusterDataForEdit.ClusterInfo.name) + ).toBeInTheDocument(); + }); + fireEvent.change( + screen.getByDisplayValue(mockClusterDataForEdit.ClusterInfo.name), + { target: { value: "UpdatedClusterName" } } + ); + fireEvent.click(getSaveButton()); + await waitFor(() => { + expect(mockToastSuccessMessage).toBe( + 'Cluster "UpdatedClusterName" updated successfully' + ); + }); + }); + + it("should display error when API call fails for clustername", async () => { + RemoteClusterApi.getRemoteClusterByName = async () => + mockClusterDataForEdit; + RemoteClusterApi.updateRemoteCluster = async () => { + toast.error("Error while updating remote cluster"); + return { status: 400 }; + }; + renderEditRemoteCluster(); + + await waitFor(() => { + expect( + screen.getByDisplayValue(mockClusterDataForEdit.ClusterInfo.name) + ).toBeInTheDocument(); + }); + fireEvent.change( + screen.getByDisplayValue(mockClusterDataForEdit.ClusterInfo.name), + { target: { value: "UpdatedClusterName" } } + ); + fireEvent.click(getSaveButton()); + + await waitFor(() => { + expect(mockToastErrorMessage).toBe("Error while updating remote cluster"); + }); + }); + + it("should show modal and populate username when clicking update credentials button", async () => { + RemoteClusterApi.getRemoteClusterByName = async () => + mockClusterDataForEdit; + renderEditRemoteCluster(); + + await waitFor(() => { + expect(getClusterNameInput()).toHaveValue( + mockClusterDataForEdit.ClusterInfo.name + ); + }); + const updateCredentialButton = screen + .getByTestId("updateCredentialButton") + .querySelector("button"); + if (updateCredentialButton) { + fireEvent.click(updateCredentialButton); + } else { + throw new Error("Update credential button not found"); + } + + // Wait for the modal to be rendered + await waitFor(() => { + const dialogs = screen.getAllByRole("dialog"); + expect(dialogs.length).toBe(1); + + const updateCredentialsDialog = dialogs[0]; + expect( + within(updateCredentialsDialog).getByText(/Update Credentials/i) + ).toBeInTheDocument(); + }); + + // Check if the username field is populated + const updateCredentialsDialog = screen.getAllByRole("dialog")[0]; + expect( + within(updateCredentialsDialog).getByLabelText(/Cluster user/i) + ).toHaveValue(mockClusterDataForEdit.ClusterInfo.username); + }); + + it("should display success message when updating credentials successfully", async () => { + RemoteClusterApi.getRemoteClusterByName = async () => + mockClusterDataForEdit; + RemoteClusterApi.updateRemoteCluster = async () => { + toast.success( + `Credentials for Cluster "${mockClusterName}" updated successfully.` + ); + return { status: 200 }; + }; + renderEditRemoteCluster(); + + await waitFor(() => { + expect(getClusterNameInput()).toHaveValue( + mockClusterDataForEdit.ClusterInfo.name + ); + }); + + const updateCredentialButton = screen + .getByTestId("updateCredentialButton") + .querySelector("button"); + if (updateCredentialButton) { + fireEvent.click(updateCredentialButton); + } else { + throw new Error("Update credential button not found"); + } + + // Wait for the modal to be rendered + await waitFor(() => { + const dialogs = screen.getAllByRole("dialog"); + expect(dialogs.length).toBe(1); + + const updateCredentialsDialog = dialogs[0]; + expect( + within(updateCredentialsDialog).getByText(/Update Credentials/i) + ).toBeInTheDocument(); + }); + + // Update username and password + const updateCredentialsDialog = screen.getAllByRole("dialog")[0]; + fireEvent.change( + within(updateCredentialsDialog).getByLabelText(/Cluster user/i), + { target: { value: "newuser" } } + ); + fireEvent.change( + within(updateCredentialsDialog).getByTestId("password-input"), + { target: { value: "newPassword" } } + ); + fireEvent.click( + within(updateCredentialsDialog).getByRole("button", { name: /Update/i }) + ); + + await waitFor(() => { + expect(mockToastSuccessMessage).toBe( + `Credentials for Cluster "${mockClusterName}" updated successfully.` + ); + }); + }); + + it("should display error message when updating credentials fails", async () => { + RemoteClusterApi.getRemoteClusterByName = async () => + mockClusterDataForEdit; + RemoteClusterApi.updateRemoteCluster = async () => { + toast.error("Error while updating credentials"); + return { status: 400 }; + }; + + renderEditRemoteCluster(); + + await waitFor(() => { + expect(getClusterNameInput()).toHaveValue( + mockClusterDataForEdit.ClusterInfo.name + ); + }); + + const updateCredentialButton = screen + .getByTestId("updateCredentialButton") + .querySelector("button"); + if (updateCredentialButton) { + fireEvent.click(updateCredentialButton); + } else { + throw new Error("Update credential button not found"); + } + + // Wait for the modal to be rendered + await waitFor(() => { + const dialogs = screen.getAllByRole("dialog"); + expect(dialogs.length).toBe(1); + + const updateCredentialsDialog = dialogs[0]; + expect( + within(updateCredentialsDialog).getByText(/Update Credentials/i) + ).toBeInTheDocument(); + }); + + // Update username and password + const updateCredentialsDialog = screen.getAllByRole("dialog")[0]; + fireEvent.change( + within(updateCredentialsDialog).getByLabelText(/Cluster User/i), + { target: { value: "newUser" } } + ); + fireEvent.change( + within(updateCredentialsDialog).getByTestId("password-input"), + { target: { value: "newPassword" } } + ); + fireEvent.click( + within(updateCredentialsDialog).getByRole("button", { name: /Update/i }) + ); + + await waitFor(() => { + expect(mockToastErrorMessage).toBe("Error while updating credentials"); + }); + }); +}); + +function getClusterNameInput() { + return screen.getByLabelText(/Cluster Name/i); +} + +function getclusterUrlInput() { + return screen.getByLabelText(/Ambari Cluster URL/i); +} + +function getSaveButton() { + return screen.getByRole("button", { + name: /save/i, + }); +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/RegisterRemoteCluster.test.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/RegisterRemoteCluster.test.tsx new file mode 100644 index 00000000000..71c8880498d --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/RegisterRemoteCluster.test.tsx @@ -0,0 +1,206 @@ +/** + * 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, screen, waitFor, fireEvent } from "@testing-library/react"; +import { createMemoryHistory } from "history"; +import { Router } from "react-router-dom"; +import "@testing-library/jest-dom/vitest"; +import RemoteClusterApi from "../../../../api/remoteCluster"; +import RegisterRemoteCluster from "../../../../screens/ClusterManagement/RemoteClusters/RegisterRemoteCluster"; +import AppContent from "../../../../context/AppContext"; +import toast from "react-hot-toast"; + +const mockClusterName = "TestCluster1"; +const mockContext = { + cluster: { cluster_name: mockClusterName }, + setSelectedOption: () => "RemoteCluster", +}; + +const renderRegisterRemoteCluster = () => { + render( + + + + + + ); +}; + +let mockToastSuccessMessage: string; +let mockToastErrorMessage: string; + +toast.success = (message) => { + mockToastSuccessMessage = message as string; + return ""; +}; + +toast.error = (message) => { + mockToastErrorMessage = message as string; + return ""; +}; + +describe("RegisterRemoteCluster component", () => { + it("renders without creshing", () => { + renderRegisterRemoteCluster(); + }); + + it("render form for registering remote Cluster", () => { + renderRegisterRemoteCluster(); + expect(getClusterName()).toBeInTheDocument(); + expect(getClusterUrl()).toBeInTheDocument(); + expect(getClusterUserName()).toBeInTheDocument(); + expect(getPassword()).toBeInTheDocument(); + }); + + it("should redirect to /remoteCluster on clicking cancel button", async () => { + const history = createMemoryHistory(); + render( + + + + + + ); + expect(getClusterName()).toBeInTheDocument(); + expect(getClusterUrl()).toBeInTheDocument(); + expect(getClusterUserName()).toBeInTheDocument(); + expect(getPassword()).toBeInTheDocument(); + + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + expect(cancelButton).toBeInTheDocument(); + + fireEvent.click(cancelButton); + await waitFor(() => {}); + + await waitFor(() => { + expect(history.location.pathname).toBe("/remoteClusters"); + }); + }); + + it("should display required errors when input fields are empty", async () => { + renderRegisterRemoteCluster(); + fireEvent.click(getSaveButton()); + + await waitFor(async () => { + const errorMessages = await screen.findAllByText(/is required/i); + expect(errorMessages).toHaveLength(4); + }); + }); + + it("should display error when cluster name includes special characters or spaces", async () => { + renderRegisterRemoteCluster(); + const clusterNameInput = getClusterName(); + fireEvent.change(clusterNameInput, {target: {value: "invalid name@"}}); + fireEvent.keyDown(clusterNameInput, { key: "Tab", code: "Tab" }); + fireEvent.keyUp(clusterNameInput, { key: "Tab", code: "Tab" }); + + await waitFor(() => { + expect( + screen.getByText("Must not contain any special characters or spaces.") + ).toBeInTheDocument(); + }); + }); + + it("should display error when url is invalid", async () => { + renderRegisterRemoteCluster(); + const clusterUrlInput = getClusterUrl(); + fireEvent.change(clusterUrlInput, {target: {value: "invalid_url"}}); + fireEvent.keyDown(clusterUrlInput, { key: "Tab", code: "Tab" }); + fireEvent.keyUp(clusterUrlInput, { key: "Tab", code: "Tab" }); + + await waitFor(() => { + expect(screen.getByText("Must be a valid URL.")).toBeInTheDocument(); + }); + }); + + it("should submit form when all values are valid", async () => { + RemoteClusterApi.addRemoteCluster = async () => { + toast.success(`Cluster "${mockClusterName}" registered successfully`); + return { status: 200 }; + }; + + renderRegisterRemoteCluster(); + fireEvent.change(getClusterName(), { target: { value: "TestCluster1" } }); + fireEvent.change(getClusterUrl(), { + target: { + value: "http://clusterHost.visa.com:8080/api/v1/clusters/clusterName", + }, + }); + fireEvent.change(getClusterUserName(), { target: { value: "admin" } }); + fireEvent.change(getPassword(), { target: { value: "admin" } }); + fireEvent.click(getSaveButton()); + + await waitFor(() => { + expect(mockToastSuccessMessage).not.toBeUndefined; + expect(mockToastSuccessMessage).toBe( + `Cluster "TestCluster1" registered successfully` + ); + }); + }); + + it("should display error when API call fails", async () => { + RemoteClusterApi.addRemoteCluster = async () => { + toast.error("Error while adding remote cluster"); + return { status: 400 }; + }; + + renderRegisterRemoteCluster(); + fireEvent.change(getClusterName(), { target: { value: "TestCluster1" } }); + fireEvent.change(getClusterUrl(), { + target: { + value: "http://clusterHost.visa.com:8080/api/v1/clusters/clusterName", + }, + }); + fireEvent.change(getClusterUserName(), { target: { value: "admin" } }); + fireEvent.change(getPassword(), { target: { value: "admin" } }); + fireEvent.click(getSaveButton()); + + await waitFor(() => { + expect(mockToastErrorMessage).not.toBeUndefined; + expect(mockToastErrorMessage).toBe("Error while adding remote cluster"); + }); + }); +}); + +function getClusterName() { + return screen.getByRole("textbox", { + name: /cluster name/i, + }); +} + +function getClusterUrl() { + return screen.getByRole("textbox", { + name: /ambari cluster/i, + }); +} + +function getClusterUserName() { + return screen.getByRole("textbox", { + name: /cluster user/i, + }); +} + +function getPassword() { + return screen.getByLabelText(/password/i); +} + +function getSaveButton() { + return screen.getByRole("button", { + name: /save/i, + }); +} diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/RemoteClusters.test.tsx b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/RemoteClusters.test.tsx new file mode 100644 index 00000000000..9f8d1fc0eb7 --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/components/ClusterManagement/RemoteCluster/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( + + + + + + ); +}; + +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( + + + + + + ); + 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(); + }); +}); diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/types.ts b/ambari-admin/src/main/resources/ui/ambari-admin/src/types.ts new file mode 100644 index 00000000000..fbf635a369a --- /dev/null +++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/types.ts @@ -0,0 +1,34 @@ +/** + * 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. + */ +interface ClusterInfoType { + href: string; + items: { + href: string; + Clusters: { + cluster_name: string; + provisioning_state: string; + }; + }[]; +}; + +interface HostCluster{ + cluster_name: string; + provisioning_state: string; +} + +export type { ClusterInfoType, HostCluster }; \ No newline at end of file