diff --git a/web/src/components/MetricsPage.tsx b/web/src/components/MetricsPage.tsx index cf69a21f7..7795ebaf6 100644 --- a/web/src/components/MetricsPage.tsx +++ b/web/src/components/MetricsPage.tsx @@ -118,6 +118,8 @@ import { MonitoringProvider } from '../contexts/MonitoringContext'; import { DataTestIDs } from './data-test'; import { useMonitoring } from '../hooks/useMonitoring'; import { useMonitoringNamespace } from './hooks/useMonitoringNamespace'; +import { useMultiNamespace } from './hooks/useMultiNamespace'; +import { MultiNamespaceSelector } from './multi-namespace-selector'; import { useSearchParams } from 'react-router'; // Stores information about the currently focused query input @@ -952,7 +954,8 @@ const Query: FC<{ index: number; customDatasource?: CustomDataSource; units: GraphUnits; -}> = ({ index, customDatasource, units }) => { + namespaceOverride?: string | string[]; +}> = ({ index, customDatasource, units, namespaceOverride }) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); const { plugin } = useMonitoring(); @@ -1069,7 +1072,7 @@ const Query: FC<{ @@ -1082,7 +1085,14 @@ const QueryBrowserWrapper: FC<{ customDataSource: CustomDataSource; customDatasourceError: boolean; units: GraphUnits; -}> = ({ customDataSourceName, customDataSource, customDatasourceError, units }) => { + namespaceOverride?: string | string[]; +}> = ({ + customDataSourceName, + customDataSource, + customDatasourceError, + units, + namespaceOverride, +}) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); const { plugin } = useMonitoring(); const [activeNamespace] = useActiveNamespace(); @@ -1209,6 +1219,7 @@ const QueryBrowserWrapper: FC<{ { ); }; -const QueriesList: FC<{ customDatasource?: CustomDataSource; units: GraphUnits }> = ({ - customDatasource, - units, -}) => { +const QueriesList: FC<{ + customDatasource?: CustomDataSource; + units: GraphUnits; + namespaceOverride?: string | string[]; +}> = ({ customDatasource, units, namespaceOverride }) => { const { plugin } = useMonitoring(); const count = useSelector( (state: MonitoringState) => getObserveState(plugin, state).queryBrowser.queries.length, @@ -1282,6 +1294,7 @@ const QueriesList: FC<{ customDatasource?: CustomDataSource; units: GraphUnits } key={reversedIndex} customDatasource={customDatasource} units={units} + namespaceOverride={namespaceOverride} /> ); })} @@ -1339,7 +1352,8 @@ const MetricsPage_: FC = () => { const [units, setUnits] = useQueryParam(QueryParams.Units, StringParam); const [customDataSourceName] = useQueryParam(QueryParams.Datasource, StringParam); const { namespace, setNamespace } = useMonitoringNamespace(); - const { displayNamespaceSelector } = useMonitoring(); + const { displayNamespaceSelector, useMetricsTenancy } = useMonitoring(); + const multiNs = useMultiNamespace(); const dispatch = useDispatch(); @@ -1456,6 +1470,18 @@ const MetricsPage_: FC = () => { + {useMetricsTenancy && ( + + + + )} @@ -1465,6 +1491,7 @@ const MetricsPage_: FC = () => { customDataSourceName={customDataSourceName} customDatasourceError={customDatasourceError} units={units as GraphUnits} + namespaceOverride={useMetricsTenancy ? multiNs.namespaceForQuery : undefined} /> @@ -1482,7 +1509,11 @@ const MetricsPage_: FC = () => { - + @@ -1516,7 +1547,7 @@ export const MpCmoDevMetricsPage: FC = () => { type QueryTableProps = { index: number; - namespace?: string; + namespace?: string | string[]; customDatasource?: CustomDataSource; units: GraphUnits; }; diff --git a/web/src/components/hooks/useMultiNamespace.ts b/web/src/components/hooks/useMultiNamespace.ts new file mode 100644 index 000000000..d3da5d521 --- /dev/null +++ b/web/src/components/hooks/useMultiNamespace.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + K8sResourceKind, + useActiveNamespace, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { ALL_NAMESPACES_KEY } from '../utils'; +import { ProjectModel } from '../console/models'; + +/** + * Hook to manage multi-namespace selection for the monitoring plugin. + * Returns the list of accessible namespaces, selected namespaces, and a setter. + * When "All Namespaces" is toggled, all accessible namespaces are selected. + */ +export const useMultiNamespace = () => { + const [activeNamespace] = useActiveNamespace(); + + const [namespaceResources] = useK8sWatchResource({ + kind: ProjectModel.kind, + isList: true, + optional: true, + }); + + const accessibleNamespaces = useMemo( + () => + (namespaceResources || []) + .map((ns) => ns.metadata?.name) + .filter(Boolean) + .sort() as string[], + [namespaceResources], + ); + + const [selectedNamespaces, setSelectedNamespaces] = useState([]); + const [allSelected, setAllSelected] = useState(false); + const isInitialized = useRef(false); + + useEffect(() => { + if ( + !isInitialized.current && + activeNamespace && + activeNamespace !== ALL_NAMESPACES_KEY && + selectedNamespaces.length === 0 + ) { + setSelectedNamespaces([activeNamespace]); + isInitialized.current = true; + } + }, [activeNamespace]); + + const toggleNamespace = useCallback((ns: string) => { + setAllSelected(false); + setSelectedNamespaces((prev) => + prev.includes(ns) ? prev.filter((n) => n !== ns) : [...prev, ns], + ); + }, []); + + const selectAll = useCallback(() => { + setAllSelected(true); + setSelectedNamespaces(accessibleNamespaces); + }, [accessibleNamespaces]); + + const deselectAll = useCallback(() => { + setAllSelected(false); + setSelectedNamespaces([]); + }, []); + + const toggleAll = useCallback(() => { + if (allSelected) { + deselectAll(); + } else { + selectAll(); + } + }, [allSelected, selectAll, deselectAll]); + + const namespaceForQuery = useMemo(() => { + if (selectedNamespaces.length === 0) { + return activeNamespace === ALL_NAMESPACES_KEY ? undefined : activeNamespace; + } + if (selectedNamespaces.length === 1) { + return selectedNamespaces[0]; + } + return selectedNamespaces; + }, [selectedNamespaces, activeNamespace]); + + return { + accessibleNamespaces, + selectedNamespaces, + allSelected, + toggleNamespace, + selectAll, + deselectAll, + toggleAll, + setSelectedNamespaces, + namespaceForQuery, + }; +}; diff --git a/web/src/components/multi-namespace-selector.tsx b/web/src/components/multi-namespace-selector.tsx new file mode 100644 index 000000000..61c0808cb --- /dev/null +++ b/web/src/components/multi-namespace-selector.tsx @@ -0,0 +1,182 @@ +import { + Label, + LabelGroup, + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, + Badge, + Divider, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; +import type { FC, Ref } from 'react'; +import { useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const MAX_NAMESPACES = 25; + +type MultiNamespaceSelectorProps = { + accessibleNamespaces: string[]; + selectedNamespaces: string[]; + allSelected: boolean; + toggleNamespace: (ns: string) => void; + toggleAll: () => void; + deselectAll: () => void; +}; + +export const MultiNamespaceSelector: FC = ({ + accessibleNamespaces, + selectedNamespaces, + allSelected, + toggleNamespace, + toggleAll, + deselectAll, +}) => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + const [isOpen, setIsOpen] = useState(false); + const [filterValue, setFilterValue] = useState(''); + const textInputRef = useRef(); + + const filteredNamespaces = useMemo( + () => + filterValue + ? accessibleNamespaces.filter((ns) => ns.toLowerCase().includes(filterValue.toLowerCase())) + : accessibleNamespaces, + [accessibleNamespaces, filterValue], + ); + + const onToggle = () => { + setIsOpen((prev) => !prev); + }; + + const onSelect = (_event: React.MouseEvent, value: string) => { + if (value === '__all__') { + toggleAll(); + } else { + if ( + !allSelected && + selectedNamespaces.length >= MAX_NAMESPACES && + !selectedNamespaces.includes(value) + ) { + return; + } + toggleNamespace(value); + } + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setFilterValue(value); + }; + + const toggle = (toggleRef: Ref) => ( + + + 0 + ? t('{{count}} namespace(s) selected', { count: selectedNamespaces.length }) + : t('Select namespaces...') + } + role="combobox" + isExpanded={isOpen} + aria-controls="multi-namespace-select-listbox" + /> + + {selectedNamespaces.length > 0 && ( + + )} + {selectedNamespaces.length > 0 && {selectedNamespaces.length}} + + + + ); + + return ( +
+ + {selectedNamespaces.length > 0 && selectedNamespaces.length <= 10 && ( + + {selectedNamespaces.map((ns) => ( + + ))} + + )} + {selectedNamespaces.length > MAX_NAMESPACES && ( +
+ {t('Maximum {{max}} namespaces can be queried at once.', { max: MAX_NAMESPACES })} +
+ )} +
+ ); +}; diff --git a/web/src/components/query-browser.tsx b/web/src/components/query-browser.tsx index 49d3e3c3a..3aeceba1c 100644 --- a/web/src/components/query-browser.tsx +++ b/web/src/components/query-browser.tsx @@ -597,6 +597,7 @@ const QueryBrowser_: FC = ({ GraphLink, hideControls, isStack = false, + namespace: namespaceProp, onLoadingChange, onZoom, pollInterval, @@ -659,7 +660,8 @@ const QueryBrowser_: FC = ({ const canStack = _.sumBy(graphData, 'length') <= maxStacks; - const [namespace] = useActiveNamespace(); + const [activeNs] = useActiveNamespace(); + const namespace = namespaceProp ?? activeNs; // If provided, `timespan` overrides any existing span setting useEffect(() => { @@ -1115,6 +1117,7 @@ export type QueryBrowserProps = { GraphLink?: ComponentType; hideControls?: boolean; isStack?: boolean; + namespace?: string | string[]; onLoadingChange?: (isLoading: boolean) => void; onZoom?: GraphOnZoom; pollInterval?: number; diff --git a/web/src/components/utils.ts b/web/src/components/utils.ts index f9955d286..c0c42bc9b 100644 --- a/web/src/components/utils.ts +++ b/web/src/components/utils.ts @@ -215,6 +215,7 @@ const getSearchParams = ({ endTime, timespan, samples, + namespace, ...params }: PrometheusURLProps): URLSearchParams => { const searchParams = @@ -222,8 +223,11 @@ const getSearchParams = ({ ? getRangeVectorSearchParams(endTime, samples, timespan) : new URLSearchParams(); _.each(params, (value, key) => value && searchParams.append(key, value.toString())); - if (searchParams.get(QueryParams.Namespace) === ALL_NAMESPACES_KEY) { - searchParams.delete(QueryParams.Namespace); + if (Array.isArray(namespace)) { + const filtered = namespace.filter((ns) => ns && ns !== ALL_NAMESPACES_KEY); + filtered.forEach((ns) => searchParams.append(QueryParams.Namespace, ns)); + } else if (namespace && namespace !== ALL_NAMESPACES_KEY) { + searchParams.append(QueryParams.Namespace, namespace); } return searchParams; }; @@ -257,14 +261,14 @@ export const buildPrometheusUrl = ({ prometheusUrlProps: PrometheusURLProps; basePath: string; }): string | null => { - if ( - basePath !== PROMETHEUS_TENANCY_BASE_PATH || - prometheusUrlProps.namespace === ALL_NAMESPACES_KEY - ) { + const ns = prometheusUrlProps.namespace; + const isAllNs = Array.isArray(ns) + ? ns.length === 0 || ns.includes(ALL_NAMESPACES_KEY) + : ns === ALL_NAMESPACES_KEY; + if (basePath !== PROMETHEUS_TENANCY_BASE_PATH || isAllNs) { prometheusUrlProps.namespace = undefined; } if (prometheusUrlProps.endpoint !== PrometheusEndpoint.RULES && !prometheusUrlProps.query) { - // Empty query provided, skipping API call return null; } @@ -277,7 +281,7 @@ export const buildPrometheusUrl = ({ type PrometheusURLProps = { endpoint: PrometheusEndpoint; endTime?: number; - namespace?: string; + namespace?: string | string[]; query?: string; samples?: number; timeout?: string;