This is an automated email from the ASF dual-hosted git repository.
sandeepk318 pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ambari.git
The following commit(s) were added to refs/heads/trunk by this push:
new 356530cc95 AMBARI-26645: Persist Hosts List filters and selection
across navigation; fix pagination display when empty (#4202)
356530cc95 is described below
commit 356530cc95ab187f8353e94814eb15a9ad9c310c
Author: Himanshu Maurya <[email protected]>
AuthorDate: Fri Sep 4 17:10:26 2026 +0530
AMBARI-26645: Persist Hosts List filters and selection across navigation;
fix pagination display when empty (#4202)
---
ambari-web/latest/src/AppLoader.tsx | 9 +-
ambari-web/latest/src/Utils/db.ts | 20 ++++
ambari-web/latest/src/components/Paginator.tsx | 3 +-
.../latest/src/screens/Hosts/HostComboSearch.tsx | 8 +-
ambari-web/latest/src/screens/Hosts/HostsList.tsx | 70 ++++++++++++--
.../latest/src/store/HostsListStateContext.tsx | 105 +++++++++++++++++++++
6 files changed, 203 insertions(+), 12 deletions(-)
diff --git a/ambari-web/latest/src/AppLoader.tsx
b/ambari-web/latest/src/AppLoader.tsx
index f08df1d10e..bee8dae352 100644
--- a/ambari-web/latest/src/AppLoader.tsx
+++ b/ambari-web/latest/src/AppLoader.tsx
@@ -21,6 +21,7 @@ import { Navigate, Outlet, useLocation, useNavigate } from
"react-router-dom";
import { Alert, Button, ProgressBar } from "react-bootstrap";
import { AppContext, AppProvider } from "./store/context";
import { AlertsProvider } from "./store/AlertsContext";
+import { HostsListStateProvider } from "./store/HostsListStateContext";
import { ModalProvider } from "./store/ModalContext";
import { useAuth } from "./hooks/useAuth";
import useAuthorizationPolicy from "./hooks/useAuthorizationPolicy";
@@ -109,9 +110,11 @@ export function AuthenticatedApplication() {
return (
<AppProvider>
<AlertsProvider>
- <ModalProvider>
- <ApplicationLoader />
- </ModalProvider>
+ <HostsListStateProvider>
+ <ModalProvider>
+ <ApplicationLoader />
+ </ModalProvider>
+ </HostsListStateProvider>
</AlertsProvider>
</AppProvider>
);
diff --git a/ambari-web/latest/src/Utils/db.ts
b/ambari-web/latest/src/Utils/db.ts
index 85934fc0d6..dc2e2b3eba 100644
--- a/ambari-web/latest/src/Utils/db.ts
+++ b/ambari-web/latest/src/Utils/db.ts
@@ -213,6 +213,26 @@ interface DbData {
return JSON.parse(JSON.stringify(InitialData));
}
+ // Ember stores host selection at
app.tables.selectedItems.mainHostController.
+ getSelectedHosts(): string[] {
+ const selectedHosts = this.get('app',
'tables.selectedItems.mainHostController');
+ return Array.isArray(selectedHosts)
+ ? selectedHosts.filter((hostName: any) => typeof hostName === 'string')
+ : [];
+ }
+
+ setSelectedHosts(selectedHosts: string[]): void {
+ this.set('app', 'tables.selectedItems.mainHostController',
selectedHosts);
+ }
+
+ unselectHosts(hostsToUnselect: string[] = []): void {
+ this.setSelectedHosts(
+ this.getSelectedHosts().filter(
+ (hostName) => !hostsToUnselect.includes(hostName)
+ )
+ );
+ }
+
}
export const db = new Database()
diff --git a/ambari-web/latest/src/components/Paginator.tsx
b/ambari-web/latest/src/components/Paginator.tsx
index c23822109b..e32dd3de3d 100644
--- a/ambari-web/latest/src/components/Paginator.tsx
+++ b/ambari-web/latest/src/components/Paginator.tsx
@@ -59,7 +59,8 @@ const Paginator = ({
items.push(<Pagination.Ellipsis key={`ellipsis-${number}`}/>);
}
}
- const firstItemIndex = (currentPage - 1) * itemsPerPage + 1;
+ const firstItemIndex =
+ totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
const lastItemIndex = Math.min(currentPage * itemsPerPage, totalItems);
return (
<div className="mt-4 p-3 py-0" data-testid="pagination">
diff --git a/ambari-web/latest/src/screens/Hosts/HostComboSearch.tsx
b/ambari-web/latest/src/screens/Hosts/HostComboSearch.tsx
index ee1a8fefbc..c146de797c 100644
--- a/ambari-web/latest/src/screens/Hosts/HostComboSearch.tsx
+++ b/ambari-web/latest/src/screens/Hosts/HostComboSearch.tsx
@@ -50,6 +50,7 @@ type HostComboSearchProps = {
setSelectedFilters: (
filters: SelectedFilters | ((prev: SelectedFilters) => SelectedFilters)
) => void;
+ onResetFilters?: () => void;
};
function HostComboSearch({
@@ -59,6 +60,7 @@ function HostComboSearch({
searchCallback,
selectedFilters,
setSelectedFilters,
+ onResetFilters,
}: HostComboSearchProps) {
const { clusterName } = useContext(AppContext);
const [selectedField, setSelectedField] = useState<FilterField | null>(null);
@@ -404,7 +406,11 @@ function HostComboSearch({
function resetFilters() {
setSelectedField(null as any);
setSelectedValue(null as any);
- setSelectedFilters([]);
+ if (onResetFilters) {
+ onResetFilters();
+ } else {
+ setSelectedFilters([]);
+ }
}
return (
diff --git a/ambari-web/latest/src/screens/Hosts/HostsList.tsx
b/ambari-web/latest/src/screens/Hosts/HostsList.tsx
index 1c8101766e..1b43017ede 100644
--- a/ambari-web/latest/src/screens/Hosts/HostsList.tsx
+++ b/ambari-web/latest/src/screens/Hosts/HostsList.tsx
@@ -25,7 +25,7 @@ import {
useRef,
useState,
} from "react";
-import { useParams } from "react-router-dom";
+import { useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Form, ProgressBar } from "react-bootstrap";
import { cloneDeep, get, isEmpty, startCase } from "lodash";
import DefaultButton from "../../components/DefaultButton";
@@ -58,6 +58,7 @@ import {
import { AppContext } from "../../store/context";
import useStackVersion from "../../hooks/useStackVersion";
import { ServiceContext } from "../../store/ServiceContext";
+import { useHostsListState } from "../../store/HostsListStateContext";
import { IHostComponent } from "../../models/hostComponent";
import { IHostStackVersion } from "../../models/hostStackVersion";
import Host, { IHost } from "../../models/host";
@@ -92,17 +93,47 @@ export default function HostsList() {
versionName?: string;
versionStatus?: string;
}>();
+ const navigate = useNavigate();
const { clusterName, serviceComponentInfo } = useContext(AppContext);
const { allServiceModels: serviceModels, polledHostComponentsData } =
useContext(ServiceContext);
const [loading, setLoading] = useState(true);
const [paginationLoading, setPaginationLoading] = useState(false);
- const [showFilters, setShowFilters] = useState(false);
const [showModal, setShowModal] = useState(false);
const [currentHostModels, setCurrentHostModels] = useState<Host[]>([]);
const [allHostCount, setAllHostCount] = useState(0);
- const [selectedHosts, setSelectedHosts] = useState<string[]>([]);
- const [selectedFilters, setSelectedFilters] = useState<any>([]);
- const [filterString, setFilterString] = useState<string>("");
+ const {
+ selectedFilters,
+ setSelectedFilters,
+ selectedHosts,
+ setSelectedHosts,
+ } = useHostsListState();
+ // Open the filter bar when filters are already applied, so restored filters
+ // stay visible instead of being hidden behind a collapsed bar.
+ const [showFilters, setShowFilters] = useState(
+ () => selectedFilters.length > 0
+ );
+ // Derive from the restored filters up front: the query effect runs on
mount, so
+ // starting empty would fire an unfiltered request and show unfiltered hosts
even
+ // though the filters are shown as applied.
+ const [filterString, setFilterString] = useState<string>(() =>
+ selectedFilters.length > 0
+ ? computeParameters(getQueryParameters(selectedFilters))
+ : ""
+ );
+ // A component/version deep link seeds the filters from the URL, so clearing
the
+ // filters has to drop those params too or the effect just re-applies them.
+ const clearFilters = useCallback(() => {
+ setSelectedFilters([]);
+ if (params.componentName || params.versionName || params.versionStatus) {
+ navigate("/main/hosts", { replace: true });
+ }
+ }, [
+ navigate,
+ params.componentName,
+ params.versionName,
+ params.versionStatus,
+ setSelectedFilters,
+ ]);
const [hostApiQueryParams, setHostApiQueryParams] = useState<any>({
pageSize: 10,
startFrom: 0,
@@ -154,6 +185,14 @@ export default function HostsList() {
order: "asc",
});
+ // Filters can also arrive after mount (for example from a component or
version
+ // deep link), so reveal the bar whenever any filter becomes active.
+ useEffect(() => {
+ if (selectedFilters.length > 0) {
+ setShowFilters(true);
+ }
+ }, [selectedFilters.length]);
+
// Reuse centralized polled data from CachedServiceApi instead of making a
separate API call
// This eliminates the duplicate /components/ call that was previously
polled independently
useEffect(() => {
@@ -172,6 +211,12 @@ export default function HostsList() {
useEffect(() => {
if (params.componentName && !isEmpty(clusterComponents)) {
+ // clusterComponents comes from a poll, so this effect re-runs regularly.
+ // Seed only while no filter is set: that still applies the component
from
+ // the URL on landing, but a later re-run leaves the user's filters
alone.
+ if (selectedFilters.length > 0) {
+ return;
+ }
const component = get(clusterComponents, "items", []).find(
(component: any) =>
get(component, "ServiceComponentInfo.component_name", "") ===
@@ -198,7 +243,7 @@ export default function HostsList() {
setSelectedFilters(newFilter);
}
}
- }, [params.componentName, clusterComponents]);
+ }, [params.componentName, clusterComponents, selectedFilters.length]);
useEffect(() => {
if (
@@ -206,6 +251,11 @@ export default function HostsList() {
params.versionStatus &&
!isEmpty(stackVersionList)
) {
+ // Seed only while no filter is set, so a refreshed stackVersionList does
+ // not discard filters the user added on top of the ones from the URL.
+ if (selectedFilters.length > 0) {
+ return;
+ }
const versionExists = stackVersionList.some(
(version: any) =>
version.displayName === params.versionName &&
@@ -243,7 +293,12 @@ export default function HostsList() {
setSelectedFilters(newFilter);
}
}
- }, [params.versionName, params.versionStatus, stackVersionList]);
+ }, [
+ params.versionName,
+ params.versionStatus,
+ stackVersionList,
+ selectedFilters.length,
+ ]);
useEffect(() => {
if (
@@ -1380,6 +1435,7 @@ export default function HostsList() {
searchCallback={setSelectedFilters}
selectedFilters={selectedFilters}
setSelectedFilters={setSelectedFilters}
+ onResetFilters={clearFilters}
/>
{paginationLoading ? (
<Spinner />
diff --git a/ambari-web/latest/src/store/HostsListStateContext.tsx
b/ambari-web/latest/src/store/HostsListStateContext.tsx
new file mode 100644
index 0000000000..bfa44da675
--- /dev/null
+++ b/ambari-web/latest/src/store/HostsListStateContext.tsx
@@ -0,0 +1,105 @@
+/**
+ * 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 React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { useLocation } from "react-router-dom";
+import { db } from "../Utils/db";
+
+interface HostsListStateContextType {
+ selectedFilters: any;
+ setSelectedFilters: (filters: any | ((prev: any) => any)) => void;
+ selectedHosts: string[];
+ setSelectedHosts: (
+ hosts: string[] | ((prev: string[]) => string[])
+ ) => void;
+}
+
+const HostsListStateContext = createContext<
+ HostsListStateContextType | undefined
+>(undefined);
+
+/**
+ * Hosts List page filters and host selection are held here rather than inside
+ * HostsList so they survive the unmount that happens when navigating to a Host
+ * page. Filters are in memory and reset once the user leaves the Hosts pages;
+ * the host selection is mirrored into the local db (the same
+ * app.tables.selectedItems.mainHostController slot Ember uses) so it also
+ * survives a page refresh.
+ */
+export const HostsListStateProvider: React.FC<{
+ children: React.ReactNode;
+}> = ({ children }) => {
+ const location = useLocation();
+ const [selectedFilters, setSelectedFilters] = useState<any>([]);
+ const [selectedHosts, setSelectedHostsState] = useState<string[]>(() =>
+ db.getSelectedHosts()
+ );
+ const wasOnHostsPages = useRef(false);
+
+ const isOnHostsPages = /^\/main\/hosts(\/|$)/.test(location.pathname);
+
+ useEffect(() => {
+ if (wasOnHostsPages.current && !isOnHostsPages) {
+ setSelectedFilters([]);
+ }
+ wasOnHostsPages.current = isOnHostsPages;
+ }, [isOnHostsPages]);
+
+ // Persist as part of the setter rather than in an effect: an effect also
runs
+ // on mount/remount, which could write an empty selection over the stored one
+ // before the restore had been applied.
+ const setSelectedHosts = useCallback(
+ (hosts: string[] | ((prev: string[]) => string[])) => {
+ const next =
+ typeof hosts === "function" ? hosts(db.getSelectedHosts()) : hosts;
+ db.setSelectedHosts(next);
+ setSelectedHostsState(next);
+ },
+ []
+ );
+
+ return (
+ <HostsListStateContext.Provider
+ value={{
+ selectedFilters,
+ setSelectedFilters,
+ selectedHosts,
+ setSelectedHosts,
+ }}
+ >
+ {children}
+ </HostsListStateContext.Provider>
+ );
+};
+
+export const useHostsListState = (): HostsListStateContextType => {
+ const context = useContext(HostsListStateContext);
+ if (!context) {
+ throw new Error(
+ "useHostsListState must be used within a HostsListStateProvider"
+ );
+ }
+ return context;
+};
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]