This is an automated email from the ASF dual-hosted git repository.
sandeepk318 pushed a commit to branch frontend-refactor
in repository https://gitbox.apache.org/repos/asf/ambari.git
The following commit(s) were added to refs/heads/frontend-refactor by this push:
new 68f4b55616 AMBARI-26618 Ambari Web React: Implement
authorization-aware Sidebar navigation (#4140)
68f4b55616 is described below
commit 68f4b556163a0b92a3479f24aac5a4ab2fa61eba
Author: Nikita Pande <[email protected]>
AuthorDate: Mon Jun 22 17:48:55 2026 +0530
AMBARI-26618 Ambari Web React: Implement authorization-aware Sidebar
navigation (#4140)
---
.../Sidebar/{Sidebar.tsx => SideItem.tsx} | 52 ++-
.../latest/src/components/Sidebar/SideItemList.tsx | 275 ++++++++++++++
.../latest/src/components/Sidebar/Sidebar.tsx | 404 ++++++++++++++++++++-
.../latest/src/components/Sidebar/SidebarItem.tsx | 332 +++++++++++++++++
.../components/Sidebar/SidebarItemCollapsed.tsx | 112 ++++++
5 files changed, 1127 insertions(+), 48 deletions(-)
diff --git a/ambari-web/latest/src/components/Sidebar/Sidebar.tsx
b/ambari-web/latest/src/components/Sidebar/SideItem.tsx
similarity index 53%
copy from ambari-web/latest/src/components/Sidebar/Sidebar.tsx
copy to ambari-web/latest/src/components/Sidebar/SideItem.tsx
index c1bbca5ab0..c4df0bf659 100644
--- a/ambari-web/latest/src/components/Sidebar/Sidebar.tsx
+++ b/ambari-web/latest/src/components/Sidebar/SideItem.tsx
@@ -15,37 +15,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { ReactNode } from "react";
-type SideBarProps = {
- isRoot?: boolean;
- isSidebarCollapsed: boolean;
- setIsSidebarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
- clusterExists?: boolean;
+type SideItem = {
+ id: string;
+ icon: ReactNode;
+ name: ReactNode;
+ path?: string;
+ children: SideItem[];
+ style?: unknown;
+ className?: string;
+ link?: string;
+ dataHref?: string;
+ noAlerts?: boolean;
+ hasCriticalAlerts?: boolean;
+ alertsCountDisplay?: string;
+ goToConfigs?: () => void;
+ isRestartRequired?: boolean;
+ restartRequiredMessage?: string;
+ isInPassive?: boolean;
+ maintenanceModeTooltip?: string;
+ dropDownIcon?: any;
+ sideItems?:boolean;
};
-const SideBar = ({
- isSidebarCollapsed,
- setIsSidebarCollapsed,
-}: SideBarProps) => {
- return isSidebarCollapsed ? (
- <div className="sidebar-collapsed">
- <button
- className="toggle-button"
- onClick={() => setIsSidebarCollapsed(false)}
- >
- ☰
- </button>
- </div>
- ) : (
- <div className="sidebar">
- <button
- className="toggle-button"
- onClick={() => setIsSidebarCollapsed(true)}
- >
- ☰
- </button>
- </div>
- );
-};
-export default SideBar;
+export default SideItem;
diff --git a/ambari-web/latest/src/components/Sidebar/SideItemList.tsx
b/ambari-web/latest/src/components/Sidebar/SideItemList.tsx
new file mode 100644
index 0000000000..e98b5b3cb0
--- /dev/null
+++ b/ambari-web/latest/src/components/Sidebar/SideItemList.tsx
@@ -0,0 +1,275 @@
+/**
+ * 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 { Image } from "react-bootstrap";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ faTachometerAlt,
+ faBriefcase,
+ faTasksAlt,
+ faBell,
+ faWrench
+} from "@fortawesome/free-solid-svg-icons";
+import AmbariLogo from "../../assets/img/ambari-logo.png"
+
+enum SideItemLabels {
+ LOGO = "logo",
+ DASHBOARD = "dashboard",
+ SERVICES = "services",
+ HOSTS = "hosts",
+ ALERTS = "alerts",
+ CLUSTER_ADMIN = "cluster_admin",
+ STACK_AND_VERSIONS = "Stack and Versions",
+ SERVICE_ACCOUNTS = "Service Accounts",
+ KERBEROS = "kerberos",
+ SERVICE_AUTO_START = "Service Auto Start",
+}
+/**
+ * Generate sidebar items with authorization checks
+ * Based on Ember.js ui/app/views/main/admin.js authorization patterns
+ */
+const getSideItemList = (hasAuthorization: (auth: string) => boolean,
upgradeInProgress: boolean = false, upgradeHolding: boolean = false):
SideItem[] => {
+ const adminChildren: SideItem[] = [];
+
+ // Stack and Versions - Requires CLUSTER.VIEW_STACK_DETAILS OR
CLUSTER.UPGRADE_DOWNGRADE_STACK (matches Ember.js)
+ if (hasAuthorization('CLUSTER.VIEW_STACK_DETAILS,
CLUSTER.UPGRADE_DOWNGRADE_STACK') || upgradeInProgress || upgradeHolding) {
+ adminChildren.push({
+ id: SideItemLabels.STACK_AND_VERSIONS,
+ icon: <></>,
+ name: "Stack And Versions",
+ path: "/main/admin/stack/services",
+ children: [],
+ style: {}
+ });
+ }
+
+ // Service Accounts - Requires SERVICE.SET_SERVICE_USERS_GROUPS
+ if (hasAuthorization('SERVICE.SET_SERVICE_USERS_GROUPS') ||
upgradeInProgress || upgradeHolding) {
+ adminChildren.push({
+ id: SideItemLabels.SERVICE_ACCOUNTS,
+ icon: <></>,
+ name: "Service Accounts",
+ path: "/main/admin/serviceAccounts",
+ children: [],
+ style: {}
+ });
+ }
+
+ // Kerberos - Requires CLUSTER.TOGGLE_KERBEROS
+ if (hasAuthorization('CLUSTER.TOGGLE_KERBEROS') || upgradeInProgress ||
upgradeHolding) {
+ adminChildren.push({
+ id: SideItemLabels.KERBEROS,
+ icon: <></>,
+ name: "Kerberos",
+ path: "/main/admin/kerberos",
+ children: [],
+ style: {}
+ });
+ }
+
+ // Service Auto Start - Requires SERVICE.START_STOP authorization (matches
ServiceAutoStart component)
+ if (hasAuthorization('SERVICE.START_STOP') || upgradeInProgress ||
upgradeHolding) {
+ adminChildren.push({
+ id: SideItemLabels.SERVICE_AUTO_START,
+ icon: <></>,
+ name: "Service Auto Start",
+ path: "/main/admin/serviceAutoStart",
+ children: [],
+ style: {}
+ });
+ }
+
+ const baseItems: SideItem[] = [
+ {
+ id: SideItemLabels.LOGO,
+ icon: (
+ <Image src={AmbariLogo} height={25} width={25} />
+ ),
+ name: <div className="fs-4">Ambari</div>,
+ path: "/main/dashboard",
+ children: [],
+ style: { background: "#313d54", height: "60px" },
+ },
+ {
+ id: SideItemLabels.DASHBOARD,
+ icon: <FontAwesomeIcon icon={faTachometerAlt} height={15} width={15} />,
+ name: "Dashboard",
+ path: "/main/dashboard",
+ children: [],
+ style: {},
+ },
+ {
+ id: SideItemLabels.SERVICES,
+ icon: <FontAwesomeIcon icon={faBriefcase} height={15} width={15} />,
+ name: "Services",
+ path: "/main/dashboard",
+ style: { position: "relative" },
+ sideItems: true,
+ children: [],
+ },
+ {
+ id: SideItemLabels.HOSTS,
+ icon: <FontAwesomeIcon icon={faTasksAlt} height={15} width={15} />,
+ name: "Hosts",
+ path: "/main/hosts",
+ children: [],
+ style: {}
+ },
+ {
+ id: SideItemLabels.ALERTS,
+ icon: <FontAwesomeIcon icon={faBell} height={15} width={15} />,
+ name: "Alerts",
+ path: "/main/alerts",
+ children: [],
+ style: {}
+ }
+ ];
+
+ // Only add Cluster Admin if user has any admin permissions
+ // This matches Ember.js ui/app/views/main/menu.js logic
+ const hasAnyAdminPermissions = hasAuthorization('CLUSTER.TOGGLE_KERBEROS') ||
+ hasAuthorization('CLUSTER.MODIFY_CONFIGS') ||
+ hasAuthorization('SERVICE.START_STOP') ||
+
hasAuthorization('SERVICE.SET_SERVICE_USERS_GROUPS') ||
+
hasAuthorization('CLUSTER.UPGRADE_DOWNGRADE_STACK') ||
+
hasAuthorization('CLUSTER.VIEW_STACK_DETAILS') ||
+ upgradeInProgress || upgradeHolding;
+
+ if (hasAnyAdminPermissions && adminChildren.length > 0) {
+ baseItems.push({
+ id: SideItemLabels.CLUSTER_ADMIN,
+ icon: <FontAwesomeIcon icon={faWrench} height={15} width={15} />,
+ path: "/main/admin",
+ name: "Cluster Admin",
+ children: adminChildren,
+ sideItems: true,
+ style: {}
+ });
+ }
+
+ return baseItems;
+};
+
+// Default export for backward compatibility
+const SideItemList: SideItem[] = [
+ {
+ id: SideItemLabels.LOGO,
+ icon: (
+ <Image src={AmbariLogo} height={25} width={25} />
+ ),
+ name: <div className="fs-4">Ambari</div>,
+ path: "/dashboard",
+ children: [],
+ style: { background: "#313d54", height: "60px" },
+ },
+ {
+ id: SideItemLabels.DASHBOARD,
+ icon: <FontAwesomeIcon icon={faTachometerAlt} height={15} width={15} />,
+ name: "Dashboard",
+ path: "/main/dashboard",
+ children: [],
+ style: {},
+ },
+ {
+ id: SideItemLabels.SERVICES,
+ icon: <FontAwesomeIcon icon={faBriefcase} height={15} width={15} />,
+ name: "Services",
+ path: "/main/dashboard",
+ style: { position: "relative" },
+ sideItems: true,
+ children: [],
+ },
+ {
+ id: SideItemLabels.HOSTS,
+ icon: <FontAwesomeIcon icon={faTasksAlt} height={15} width={15} />,
+ name: "Hosts",
+ path: "/main/hosts",
+ children: [],
+ style: {}
+ },
+ {
+ id: SideItemLabels.ALERTS,
+ icon: <FontAwesomeIcon icon={faBell} height={15} width={15} />,
+ name: "Alerts",
+ path: "/main/alerts",
+ children: [],
+ style: {}
+ },
+ {
+ id: SideItemLabels.CLUSTER_ADMIN,
+ icon: <FontAwesomeIcon icon={faWrench} height={15} width={15} />,
+ path: "/main/admin",
+ name: "Cluster Admin",
+ children: [
+ {
+ id: SideItemLabels.STACK_AND_VERSIONS,
+ icon: <></>,
+ name: "Stack And Versions",
+ path: "/main/admin/stack/services",
+ children: [],
+ style: {}
+ },
+ {
+ id: SideItemLabels.SERVICE_ACCOUNTS,
+ icon: <></>,
+ name: "Service Accounts",
+ path: "/main/admin/serviceAccounts",
+ children: [],
+ style: {}
+ },
+ {
+ id: SideItemLabels.KERBEROS,
+ icon: <></>,
+ name: "Kerberos",
+ path: "/main/admin/kerberos",
+ children: [],
+ style: {}
+ },
+ {
+ id: SideItemLabels.SERVICE_AUTO_START,
+ icon: <></>,
+ name: "Service Auto Start",
+ path: "/main/admin/serviceAutoStart",
+ children: [],
+ style: {}
+ }
+ ],
+ sideItems: true,
+ style: {}
+ },
+];
+
+// const SideItemListComponent = () => {
+// return (
+// <nav className="sidebar-nav">
+// <ul>
+// {SideItemList.map((item, index) => (
+// <li key={index}>
+// <Link to="" className="sidebar-item">
+// {item.icon}
+// {item.name}
+// <span className="dropdown-icon">{item.dropDownIcon}</span>
+// </Link>
+// </li>
+// ))}
+// </ul>
+// </nav>
+// );
+// };
+
+export { SideItemList, SideItemLabels, getSideItemList };
diff --git a/ambari-web/latest/src/components/Sidebar/Sidebar.tsx
b/ambari-web/latest/src/components/Sidebar/Sidebar.tsx
index c1bbca5ab0..718fcdb701 100644
--- a/ambari-web/latest/src/components/Sidebar/Sidebar.tsx
+++ b/ambari-web/latest/src/components/Sidebar/Sidebar.tsx
@@ -15,7 +15,30 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
+import React, { useEffect, useState } from "react";
+import SidebarItem from "./SidebarItem";
+import SideItem from "./SideItem.tsx";
+import SidebarItemCollapsed from "./SidebarItemCollapsed";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ faAngleDoubleLeft,
+ faAngleDoubleRight,
+ faBriefcase,
+ faSync
+} from "@fortawesome/free-solid-svg-icons";
+import { SideItemLabels, getSideItemList } from "./SideItemList";
+import { AppContext } from "../../store/context.tsx";
+import { useContext } from "react";
+import { useAuth } from "../../hooks/useAuth";
+import { Collapse } from "react-bootstrap";
+import { ServiceContext } from "../../store/ServiceContext.tsx";
+import {
+ serviceNameModelMapping,
+ serviceNameDisplayMapping,
+} from "../../constants.ts";
+import { displayOrder } from "../../screens/ClusterWizard/constants";
+import { isEmpty } from "lodash";
+import { useLocation } from "react-router-dom";
type SideBarProps = {
isRoot?: boolean;
isSidebarCollapsed: boolean;
@@ -27,25 +50,370 @@ const SideBar = ({
isSidebarCollapsed,
setIsSidebarCollapsed,
}: SideBarProps) => {
- return isSidebarCollapsed ? (
- <div className="sidebar-collapsed">
- <button
- className="toggle-button"
- onClick={() => setIsSidebarCollapsed(false)}
+ const { clusterName, services: contextServices, upgradeInProgress,
upgradeHolding } = useContext(AppContext);
+ const [openOptions, setOpenOptions] =
useState<string[]>([SideItemLabels.SERVICES]);
+ const [selectedOption, setSelectedOption] = useState<string>("");
+ const { allServiceModels } = useContext(ServiceContext);
+ const location = useLocation();
+
+ // Authorization hooks - implementing Ember.js menu authorization patterns
+ const { hasAuthorization } = useAuth();
+
+ // Get authorization-aware sidebar items using computed upgrade properties
+ const authorizedSideItemList = getSideItemList(hasAuthorization,
upgradeInProgress, upgradeHolding);
+ const [services, setServices] = useState<
+ {
+ name: string;
+ state: string;
+ alertsCountDisplay?: string;
+ noAlerts?: boolean;
+ hasCriticalAlerts?: boolean;
+ isClientOnlyService?: boolean;
+ isInPassiveForService?: boolean;
+ isRestartRequiredForService?: boolean;
+ }[]
+ >([]);
+
+ const isElementOpen = (id: string) => {
+ return openOptions.includes(id);
+ };
+
+ const handleSideItemClick = (itemId: string) => {
+ if (isElementOpen(itemId)) {
+ setOpenOptions(openOptions.filter((opt) => opt !== itemId));
+ } else {
+ setOpenOptions([...openOptions, itemId]);
+ }
+ };
+
+ useEffect(() => {
+ if (!clusterName || isEmpty(allServiceModels)) {
+ setServices([]);
+ return;
+ }
+
+ const processServices = () => {
+
+ const processedServices = contextServices
+ ?.map((service: any) => {
+ const serviceName = service?.ServiceInfo?.service_name;
+ // Type assertion to tell TypeScript this is a valid key
+ const modelKey = serviceName && serviceNameModelMapping[serviceName
as keyof typeof serviceNameModelMapping];
+ const currentServiceModel = allServiceModels[modelKey];
+
+ // Skip if model doesn't exist
+ if (!currentServiceModel) {
+ console.warn(
+ `No model found for service ${serviceName} with key ${modelKey}`
+ );
+ return null;
+ }
+
+ // Create the service object with proper state handling
+ const serviceData = {
+ name:
+ serviceNameDisplayMapping[
+ serviceName as keyof typeof serviceNameDisplayMapping
+ ] || serviceName,
+ serviceName: serviceName, // Keep original service name for sorting
+ state:
+ currentServiceModel["serviceState"] &&
+ currentServiceModel["serviceState"] !== ""
+ ? currentServiceModel["serviceState"]
+ : "UNKNOWN",
+ alertsCountDisplay:
+ currentServiceModel["alertsCount"] > 0
+ ? currentServiceModel["alertsCount"]
+ : undefined,
+ noAlerts: currentServiceModel["alertsCount"] === 0,
+ hasCriticalAlerts:
+ currentServiceModel["hasCriticalAlerts"] || false,
+ isClientOnlyService: currentServiceModel["isClientOnlyService"] ||
false,
+ isInPassiveForService:
currentServiceModel["isInPassiveForService"] || false,
+ isRestartRequiredForService:
currentServiceModel["isRestartRequiredForService"] || false
+ };
+
+ return serviceData;
+ })
+ .filter(Boolean)
+ // Sort services according to Ember.js displayOrder (matching
App.StackService.displayOrder)
+ .sort((a: any, b: any) => {
+ const aIndex = displayOrder.indexOf(a.serviceName);
+ const bIndex = displayOrder.indexOf(b.serviceName);
+
+ // If service is in displayOrder, use its index; otherwise, put it
at the end
+ const aOrder = aIndex !== -1 ? aIndex : displayOrder.length;
+ const bOrder = bIndex !== -1 ? bIndex : displayOrder.length;
+
+ return aOrder - bOrder;
+ }) as {
+ name: string;
+ serviceName: string;
+ state: string;
+ alertsCountDisplay?: string;
+ noAlerts?: boolean;
+ hasCriticalAlerts?: boolean;
+ isClientOnlyService?: boolean;
+ isInPassiveForService?: boolean;
+ isRestartRequiredForService?: boolean;
+ }[];; // Filter out any null values
+
+ setServices(processedServices);
+ };
+
+ processServices();
+ }, [JSON.stringify(allServiceModels), clusterName, contextServices]);
+
+ const getStateColor = (
+ state: string,
+ isClientOnlyService: boolean = false,
+ ): string | undefined => {
+ if (isClientOnlyService) {
+ return undefined; // Return undefined for client-only services to make
the icon transparent
+ }
+ switch (state) {
+ case "STARTED":
+ return "#1eb475";
+ case "STOPPED" :
+ case "INSTALLED":
+ return "#ef6162";
+ default:
+ return "gray";
+ }
+ };
+
+ const updatedSideItemList = authorizedSideItemList.map((item: SideItem) => {
+ if (item.id === SideItemLabels.SERVICES) {
+ return {
+ ...item,
+ children: services.map((service) => ({
+ id: service.name,
+ path: `/main/services/${service?.name?.replace("
","_")?.toUpperCase()}/summary`,
+ name: (
+ <div className="d-flex align-items-center w-100 pe-3">
+ <div
+ className="rounded-circle me-2"
+ style={{
+ width: "8px",
+ height: "8px",
+ backgroundColor: getStateColor(
+ service.state,
+ service.isClientOnlyService ?? false,
+ ),
+ }}
+ ></div>
+ {/* Service name with conditional color based on state */}
+ <div className="flex-grow-1 text-truncate me-2" style={{
+ color: service.isClientOnlyService ? "inherit" :
(service.state === "STOPPED" || service.state === "INSTALLED") ? "#ef6162" :
"inherit",marginRight: "10px"}}>{service.name}</div>
+ {/* Maintenance icon (suitcase) - only show if service is in
passive state */}
+ {service.isInPassiveForService === true && (
+ <FontAwesomeIcon icon={faBriefcase} className="me-2 " style={{
color: "#adb5bd" }} />
+ )}
+
+ {/* Refresh icon - only show if refresh is needed */}
+ {service.isRestartRequiredForService === true && (
+ <FontAwesomeIcon icon={faSync} className="me-2" style={{
color: "#adb5bd" }} />
+ )}
+
+ {service.alertsCountDisplay &&
+ Number(service.alertsCountDisplay) > 0 && (
+ <div className="ms-auto">
+ <div
+ className={`badge ${service.noAlerts ? "d-none" : ""} ${
+ service.hasCriticalAlerts
+ ? "bg-danger"
+ : "bg-warning"
+ } rounded-circle d-flex align-items-center
justify-content-center`}
+ style={{ width: "20px", height: "20px", fontSize: "10px"
}}
+ >
+ {service.alertsCountDisplay}
+ </div>
+ </div>
+ )}
+ </div>
+ ),
+ })),
+ };
+ }
+ return item;
+ });
+
+ useEffect(() => {
+ const currentPath = location.pathname;
+
+ const findMatchingItem = (items: any[]): string | null => {
+ let bestMatch: { id: string; pathLength: number } | null = null;
+
+ for (const item of items) {
+ if (item.id === SideItemLabels.LOGO) {
+ continue;
+ }
+
+ // Check if current path matches this item's path
+ if (item.path && currentPath.startsWith(item.path)) {
+ // Keep track of the best (longest) match
+ if (!bestMatch || item.path.length > bestMatch.pathLength) {
+ bestMatch = { id: item.id, pathLength: item.path.length };
+ }
+ }
+
+ // Check children if they exist
+ if (item.children && item.children.length > 0) {
+ const childMatch = findMatchingItem(item.children);
+ if (childMatch) {
+ // If a child matches, also ensure parent is open
+ if (!openOptions.includes(item.id)) {
+ setOpenOptions(prev => [...prev, item.id]);
+ }
+ return childMatch;
+ }
+ }
+ }
+
+ return bestMatch ? bestMatch.id : null;
+ };
+
+ // Find matching item in the updated sidebar list
+ const matchingItemId = findMatchingItem(updatedSideItemList);
+
+ if (matchingItemId && matchingItemId !== selectedOption) {
+ setSelectedOption(matchingItemId);
+ }
+
+ // Special handling for admin routes - ensure Cluster Admin is open when
on admin pages
+ if (currentPath.startsWith('/main/admin') &&
!openOptions.includes(SideItemLabels.CLUSTER_ADMIN)) {
+ setOpenOptions(prev => [...prev, SideItemLabels.CLUSTER_ADMIN]);
+ }
+ }, [location.pathname, updatedSideItemList, selectedOption, openOptions]);
+
+ if (!isSidebarCollapsed) {
+ return (
+ <div
+ className="bg-secondary h-100 d-flex flex-column
justify-content-between overflow-scroll no-scrollbar"
+ style={{ width: 230, position: "fixed", zIndex: 10 }}
>
- ☰
- </button>
- </div>
- ) : (
- <div className="sidebar">
- <button
- className="toggle-button"
- onClick={() => setIsSidebarCollapsed(true)}
+ <div>
+ {updatedSideItemList.map((ele) => {
+ if (ele.children.length) {
+ return (
+ <div key={ele.id}>
+ <SidebarItem
+ isSelected={selectedOption === ele.id}
+ ele={ele}
+ isOpen={isElementOpen(ele.id)}
+ hasChildren={ele.children.length > 0}
+ onClick={() => {
+ handleSideItemClick(ele.id);
+ }}
+ />
+ <Collapse in={openOptions.includes(ele.id)}>
+ <div>
+ {ele.children.map((child: any) => {
+ if (child.children && child.children.length > 0) {
+ return (
+ <div key={child.id}>
+ <SidebarItem
+ isSelected={selectedOption === child.id}
+ ele={child}
+ isOpen={isElementOpen(child.id)}
+ hasChildren={child.children.length > 0}
+ onClick={() => {
+ handleSideItemClick(child.id);
+ }}
+ />
+ <Collapse in={openOptions.includes(child.id)}>
+ <div>
+ {child.children.map((grandChild: any) => {
+ return (
+ <SidebarItem
+ key={grandChild.id}
+ isSelected={selectedOption ===
grandChild.id}
+ ele={grandChild}
+ onClick={() => {
+ setSelectedOption(grandChild.id);
+ }}
+ />
+ );
+ })}
+ </div>
+ </Collapse>
+ </div>
+ );
+ } else {
+ return (
+ <SidebarItem
+ key={child.id}
+ isSelected={selectedOption === child.id}
+ ele={child}
+ onClick={() => {
+ setSelectedOption(child.id);
+ }}
+ />
+ );
+ }
+ })}
+ </div>
+ </Collapse>
+ </div>
+ );
+ } else {
+ return (
+ <SidebarItem
+ key={ele.id}
+ isSelected={selectedOption === ele.id}
+ onClick={() => {
+ setSelectedOption(ele.id);
+ }}
+ ele={ele}
+ />
+ );
+ }
+ })}
+ </div>
+ <div
+ className="py-3 d-flex justify-content-center text-primary"
+ style={{ background: "#313d54", cursor: "pointer" }}
+ onClick={() => {
+ setIsSidebarCollapsed(!isSidebarCollapsed);
+ }}
+ >
+ <FontAwesomeIcon icon={faAngleDoubleLeft} />
+ </div>
+ </div>
+ );
+ } else {
+ return (
+ <div
+ className="bg-secondary h-100 d-flex flex-column
justify-content-between"
+ style={{ width: 60 }}
>
- ☰
- </button>
- </div>
- );
+ <div>
+ {authorizedSideItemList.map((ele) => {
+ return (
+ <SidebarItemCollapsed
+ key={ele.id}
+ isSelected={selectedOption === ele.id}
+ ele={ele}
+ isOpen={isElementOpen(ele.id)}
+ childElements={ele.children}
+ setSelectedOption={setSelectedOption}
+ selectedOption={selectedOption}
+ />
+ );
+ })}
+ </div>
+ <div
+ className="py-3 d-flex justify-content-center text-primary"
+ style={{ background: "#313d54", cursor: "pointer" }}
+ onClick={() => {
+ setIsSidebarCollapsed(!isSidebarCollapsed);
+ }}
+ >
+ <FontAwesomeIcon icon={faAngleDoubleRight} />
+ </div>
+ </div>
+ );
+ }
};
export default SideBar;
diff --git a/ambari-web/latest/src/components/Sidebar/SidebarItem.tsx
b/ambari-web/latest/src/components/Sidebar/SidebarItem.tsx
new file mode 100644
index 0000000000..f13db30b7c
--- /dev/null
+++ b/ambari-web/latest/src/components/Sidebar/SidebarItem.tsx
@@ -0,0 +1,332 @@
+/**
+ * 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 {
+ faAdd,
+ faChevronDown,
+ faChevronRight,
+ faDownload,
+ faEllipsisH,
+ faPlay,
+ faRefresh,
+ faStop,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { MouseEventHandler, useContext } from "react";
+import { Dropdown, DropdownButton } from "react-bootstrap";
+import { Link, useLocation, useNavigate } from "react-router-dom";
+import {
+ downloadClientConfigsCall,
+ restartAllRequired,
+ startAllServices,
+ stopAllServices,
+} from "../../Utils/taskUtils";
+import { AppContext } from "../../store/context";
+import { ServiceContext } from "../../store/ServiceContext";
+import showBackgroundModal from "../../Utils/showBg";
+import modalManager from "../../store/ModalManager";
+import { useAuth } from "../../hooks/useAuth";
+import { serviceNameModelMapping } from "../../constants";
+import RunAllServiceCheck from "./RunAllServiceCheck";
+
+// interface SidebarElement {
+// id: string;
+// path: string;
+// className?: string;
+// style?: React.CSSProperties;
+// icon?: React.ReactNode;
+// name: React.ReactNode;
+// children?: Array<SidebarElement>;
+// sideItems?: React.ReactNode;
+// }
+
+interface SidebarItemProps {
+ ele: any;
+ onClick?: MouseEventHandler<HTMLDivElement>;
+ isSelected: boolean;
+ isOpen?: boolean;
+ hasChildren?: boolean;
+}
+
+const SidebarItem = ({
+ ele,
+ onClick,
+ isSelected,
+ isOpen = false,
+ hasChildren = false,
+}: SidebarItemProps) => {
+ const navigate = useNavigate();
+ const {
+ clusterName,
+ upgradeIsRunning,
+ upgradeSuspended,
+ services: contextServices,
+ } = useContext(AppContext);
+ const { allServiceModels } = useContext(ServiceContext);
+ const location = useLocation();
+
+ // Authorization hooks - implementing Ember.js service menu authorization
patterns
+ const { hasAuthorization } = useAuth();
+
+ // Check specific authorizations for service operations
+ const canAddDeleteServices = hasAuthorization("SERVICE.ADD_DELETE_SERVICES");
+ const canStartStopServices = hasAuthorization("SERVICE.START_STOP");
+ const canDownloadConfigs =
+ hasAuthorization("SERVICE.VIEW_CONFIGS") ||
+ hasAuthorization("CLUSTER.VIEW_CONFIGS");
+
+ // Check if upgrade is blocking operations (matches Ember.js logic)
+ const isUpgradeBlocking = upgradeIsRunning && !upgradeSuspended;
+
+ // Check service states for conditional button enabling/disabling
+ // Following Ember.js pattern where buttons are enabled based on actual
service states
+ const getServiceStates = () => {
+ if (!contextServices || !allServiceModels) {
+ return {
+ hasStoppedServices: false,
+ hasStartedServices: false,
+ hasServicesRequiringRestart: false,
+ allServicesStarted: false,
+ };
+ }
+
+ let hasStoppedServices = false;
+ let hasStartedServices = false;
+ let hasServicesRequiringRestart = false;
+ let totalServices = 0;
+ let startedServicesCount = 0;
+ let stoppedServicesCount = 0;
+
+ contextServices.forEach((service: any) => {
+ const serviceName = service.ServiceInfo?.service_name;
+ const serviceState =
+ allServiceModels?.[serviceNameModelMapping[serviceName]]?.serviceState;
+
+ // Count total services (excluding client-only services for start/stop
operations)
+ const isClientOnlyService =
allServiceModels?.[serviceNameModelMapping[serviceName]]?.isClientOnlyService;
+ if (!isClientOnlyService) {
+ totalServices++;
+ }
+
+ // Following Ember.js logic: check for red status (stopped/installed
services)
+ // Ember: stoppedServices = content.filter(_service =>
_service.get('healthStatus') === 'red')
+ if (
+ serviceState === "INSTALLED" ||
+ serviceState === "INIT" ||
+ serviceState === "INSTALL_FAILED" ||
+ serviceState === "STOPPED"
+ ) {
+ hasStoppedServices = true;
+ if (!isClientOnlyService) {
+ stoppedServicesCount++;
+ }
+ }
+
+ // Following Ember.js logic: check for green status (started services)
+ // Ember: !content.someProperty('healthStatus', 'green')
+ if (serviceState === "STARTED") {
+ hasStartedServices = true;
+ if (!isClientOnlyService) {
+ startedServicesCount++;
+ }
+ }
+
+ if
(allServiceModels?.[serviceNameModelMapping[serviceName]]?.isRestartRequiredForService)
{
+ hasServicesRequiringRestart = true;
+ }
+ });
+
+ // Check if ALL non-client-only services are started
+ const allServicesStarted = totalServices > 0 && startedServicesCount ===
totalServices;
+
+ // Check if ALL non-client-only services are stopped
+ const allServicesStopped = totalServices > 0 && stoppedServicesCount ===
totalServices;
+
+ return {
+ hasStoppedServices,
+ hasStartedServices,
+ hasServicesRequiringRestart,
+ allServicesStarted,
+ allServicesStopped,
+ };
+ };
+
+ const {
+ hasServicesRequiringRestart,
+ allServicesStarted,
+ allServicesStopped,
+ } = getServiceStates();
+
+ // Check if user has any service operation permissions and upgrade is not
blocking
+ const hasAnyServiceOperationPermissions =
+ (canAddDeleteServices || canStartStopServices) && !isUpgradeBlocking;
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
+ // If this is the Cluster Admin item and it's currently closed, navigate
to the first admin route
+ // Only navigate when opening the menu, not when closing it, and only if
not already on an admin page
+ if (ele.id === "cluster_admin" && ele.sideItems && !isOpen &&
!location.pathname.startsWith("/main/admin")) {
+ e.preventDefault();
+ navigate("/main/admin");
+ }
+ // Call the original onClick handler
+ if (onClick) {
+ onClick(e);
+ }
+ };
+
+ return (
+ <Link
+ to={ele.sideItems ? location.pathname : ele.path}
+ className="text-decoration-none"
+ >
+ <div
+ className={`d-flex justify-content-between ${
+ ele.className
+ } sideitem align-items-center ${isSelected ? "selected-item" : ""}`}
+ style={{
+ ...(ele.style as any),
+ fontSize: 14,
+ cursor: "pointer",
+ padding: "10px 5px 10px 20px",
+ position: "relative",
+ }}
+ onClick={handleClick}
+ >
+ <div className="d-flex">
+ <div>{ele.icon}</div>
+ <div className="ms-2">{ele.name}</div>
+ </div>
+ <div className="d-flex align-items-center">
+ {ele.sideItems &&
+ ele.id === "services" &&
+ hasAnyServiceOperationPermissions ? (
+ <Dropdown>
+ <DropdownButton
+ variant="transparent"
+ onClick={(e) => {
+ e.stopPropagation();
+ }}
+ className="service-dropdown"
+ title={
+ <FontAwesomeIcon
+ className="text-light me-1"
+ icon={faEllipsisH}
+ />
+ }
+ >
+ {/* Add Service - Requires SERVICE.ADD_DELETE_SERVICES
authorization and no upgrade blocking */}
+ {canAddDeleteServices && !isUpgradeBlocking && (
+ <Dropdown.Item
+ onClick={() => {
+ navigate("/main/service/add/step1");
+ }}
+ >
+ <FontAwesomeIcon icon={faAdd} className="me-1" />
+ Add Service
+ </Dropdown.Item>
+ )}
+
+ {/* Start All - Disabled when all services are started
(TLHASD-997 fix) */}
+ {canStartStopServices && (
+ <Dropdown.Item
+ disabled={allServicesStarted}
+ onClick={async () => {
+ if (!allServicesStarted) {
+ await startAllServices(clusterName);
+ showBackgroundModal();
+ }
+ }}
+ >
+ <FontAwesomeIcon icon={faPlay} className="me-1" />
+ Start All
+ </Dropdown.Item>
+ )}
+
+ {/* Stop All - Disabled when all services are stopped
(TLHASD-997 consistency fix) */}
+ {canStartStopServices && (
+ <Dropdown.Item
+ disabled={allServicesStopped}
+ onClick={async () => {
+ if (!allServicesStopped) {
+ await stopAllServices(clusterName);
+ showBackgroundModal();
+ }
+ }}
+ >
+ <FontAwesomeIcon icon={faStop} className="me-1" />
+ Stop All
+ </Dropdown.Item>
+ )}
+
+ {/* Restart All Required - Disabled when no services require
restart (following Ember.js isRestartAllRequiredDisabled logic) */}
+ {canStartStopServices && (
+ <Dropdown.Item
+ disabled={!hasServicesRequiringRestart}
+ onClick={async () => {
+ if (hasServicesRequiringRestart) {
+ modalManager.show({
+ modalTitle: "Confirmation",
+ modalBody:
+ "This will trigger alerts as the service is
restarted. To suppress alerts, turn on Maintenance Mode for services listed
above prior to running Restart All Required",
+ successCallback: async () => {
+ modalManager.hide();
+ await restartAllRequired(clusterName);
+ showBackgroundModal();
+ },
+ onClose: () => {
+ modalManager.hide();
+ },
+ options: {},
+ });
+ }
+ }}
+ >
+ <FontAwesomeIcon icon={faRefresh} className="me-1" />
+ Restart All required
+ </Dropdown.Item>
+ )}
+
+ {/* Download All Client Configs - Requires config view
permissions */}
+ {canDownloadConfigs && (
+ <Dropdown.Item
+ onClick={() => {
+ downloadClientConfigsCall({}, clusterName);
+ }}
+ >
+ <FontAwesomeIcon icon={faDownload} className="me-1" />
+ Download All Client Configs
+ </Dropdown.Item>
+ )}
+ {/* Run All Service Check - Requires
SERVICE.RUN_CUSTOM_COMMAND, SERVICE.RUN_SERVICE_CHECK,
SERVICE.TOGGLE_MAINTENANCE, or SERVICE.ENABLE_HA */}
+ <RunAllServiceCheck />
+ </DropdownButton>
+ </Dropdown>
+ ) : null}
+ {hasChildren ? (
+ <>
+ <FontAwesomeIcon
+ icon={isOpen ? faChevronRight : faChevronDown}
+ className="me-1"
+ />
+ </>
+ ) : null}
+ </div>
+ </div>
+ </Link>
+ );
+};
+
+export default SidebarItem;
diff --git a/ambari-web/latest/src/components/Sidebar/SidebarItemCollapsed.tsx
b/ambari-web/latest/src/components/Sidebar/SidebarItemCollapsed.tsx
new file mode 100644
index 0000000000..c5f1bcc79b
--- /dev/null
+++ b/ambari-web/latest/src/components/Sidebar/SidebarItemCollapsed.tsx
@@ -0,0 +1,112 @@
+/**
+ * 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";
+import { Link } from "react-router-dom";
+
+const SidebarItemCollapsed = ({
+ ele,
+ onClick,
+ isSelected,
+ childElements,
+ selectedOption,
+ setSelectedOption,
+}: {
+ ele: any;
+ onClick?: MouseEventHandler<HTMLDivElement>;
+ isSelected: boolean;
+ isOpen?: boolean;
+ hasChildren?: boolean;
+ childElements?: any[];
+ selectedOption?: string;
+ setSelectedOption?: any;
+}) => {
+ const [showDropdown, setShowDropdown] = useState(false);
+ if (childElements?.length) {
+ return (
+ <Dropdown
+ drop="end"
+ className="collapsed-sidebar-item"
+ onMouseLeave={() => setShowDropdown(false)}
+ onMouseOver={() => setShowDropdown(true)}
+ // style={{ width: "166px" }}
+ >
+ <Dropdown.Toggle as="div" className="main-style" id="dropdown-basic">
+ <div
+ className={`d-flex justify-content-between ${
+ ele.className
+ } sideitem align-items-center ${isSelected ? "selected-item" :
""}`}
+ style={{
+ ...(ele.style as any),
+ fontSize: 14,
+ cursor: "pointer",
+ padding: "10px 5px 10px 20px",
+ position: "relative",
+ }}
+ onClick={onClick}
+ >
+ <div>{ele.icon}</div>
+ </div>
+ </Dropdown.Toggle>
+ {showDropdown ? (
+ <Dropdown.Menu
+ show={showDropdown}
+ style={{ width: "300px" }}
+ className="bg-secondary d-flex flex-column justify-content-between
rounded-0 py-0"
+ >
+ {childElements?.map((ele) => {
+ return (
+ <SidebarItem
+ onClick={() => {
+ setSelectedOption(ele.id);
+ }}
+ ele={ele}
+ isSelected={selectedOption === ele.id}
+ />
+ );
+ })}
+ </Dropdown.Menu>
+ ) : null}
+ </Dropdown>
+ );
+ } else {
+ return (
+ <div
+ className={`d-flex justify-content-between ${
+ ele.className
+ } sideitem align-items-center ${isSelected ? "selected-item" : ""}`}
+ style={{
+ ...(ele.style as any),
+ cursor: "pointer",
+ padding: "10px 5px 10px 20px",
+ position: "relative",
+ }}
+ onClick={() => {
+ setSelectedOption(ele.id);
+ }}
+ >
+ <Link to={ele.path} className="sideitem">
+ <div style={{ fontSize: 20 }}>{ele.icon}</div>
+ </Link>
+ </div>
+ );
+ }
+};
+
+export default SidebarItemCollapsed;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]