diff --git a/mlflow/server/js/src/experiment-tracking/components/ExperimentListPage.tsx b/mlflow/server/js/src/experiment-tracking/components/ExperimentListPage.tsx index 3566b3c6d8be0..60309f53b3454 100644 --- a/mlflow/server/js/src/experiment-tracking/components/ExperimentListPage.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/ExperimentListPage.tsx @@ -1,10 +1,11 @@ import ExperimentListView from './ExperimentListView'; -import { useSearchFilter } from './experiment-page/hooks/useSearchFilter'; +import { useSearchFilter, useProjectFilter } from './experiment-page/hooks/useSearchFilter'; const ExperimentListPage = () => { const [searchFilter, setSearchFilter] = useSearchFilter(); + const [projectFilter, setProjectFilter] = useProjectFilter(); - return ; + return ; }; export default ExperimentListPage; diff --git a/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx b/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx index 5fc044e4ccb60..33bb2195c73a6 100644 --- a/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { Theme } from '@emotion/react'; import { Button, @@ -8,11 +8,14 @@ import { Header, Alert, useDesignSystemTheme, + SimpleSelect, + SimpleSelectOption, + Spinner, } from '@databricks/design-system'; import 'react-virtualized/styles.css'; import Routes from '../routes'; import { CreateExperimentModal } from './modals/CreateExperimentModal'; -import { useExperimentListQuery, useInvalidateExperimentList } from './experiment-page/hooks/useExperimentListQuery'; +import { useExperimentListQuery, useUniqueProjectNames, useInvalidateExperimentList } from './experiment-page/hooks/useExperimentListQuery'; import { RowSelectionState } from '@tanstack/react-table'; import { FormattedMessage, useIntl } from 'react-intl'; import { ScrollablePageWrapper } from '../../common/components/ScrollablePageWrapper'; @@ -25,41 +28,59 @@ import { useUpdateExperimentTags } from './experiment-page/hooks/useUpdateExperi type Props = { searchFilter: string; setSearchFilter: (searchFilter: string) => void; + projectFilter: string; + setProjectFilter: (projectFilter: string) => void; }; -export const ExperimentListView = ({ searchFilter, setSearchFilter }: Props) => { - const { - data: experiments, - isLoading, - error, - hasNextPage, - hasPreviousPage, - onNextPage, - onPreviousPage, - pageSizeSelect, - sorting, - setSorting, - } = useExperimentListQuery({ searchFilter }); +export const ExperimentListView = ({ searchFilter, setSearchFilter, projectFilter, setProjectFilter }: Props) => { + const { data, isLoading, error, paginationProps, pageSize, setPageSize, sorting, setSorting } = useExperimentListQuery(searchFilter, projectFilter); + const { projectNames, isLoading: isLoadingProjects } = useUniqueProjectNames(); const invalidateExperimentList = useInvalidateExperimentList(); const { EditTagsModal, showEditExperimentTagsModal } = useUpdateExperimentTags({ onSuccess: invalidateExperimentList, }); - const [rowSelection, setRowSelection] = useState({}); + const handleRefetch = useCallback(() => { + invalidateExperimentList(); + }, [invalidateExperimentList]); + + const handleProjectFilterChange = useCallback((e: any) => { + setProjectFilter(e.target.value); + }, [setProjectFilter]); + + const experimentsData = data?.experiments || []; + const experimentIds = experimentsData.map((e) => e.experimentId); + const [selectedExperimentIds, setSelectedExperimentIds] = useState({}); + const [showBulkDeleteExperimentModal, setShowBulkDeleteExperimentModal] = useState(false); + + const handleBulkDeleteExperiments = useCallback(() => { + const selectedIds = Object.keys(selectedExperimentIds).filter((id) => selectedExperimentIds[id]); + const selectedExperiments = experimentsData.filter((exp) => selectedIds.includes(exp.experimentId)); + setShowBulkDeleteExperimentModal(true); + }, [selectedExperimentIds, experimentsData]); + + const handleExperimentSelectionChange = useCallback((experimentIds: string[]) => { + const newSelectionState: RowSelectionState = {}; + experimentIds.forEach((id) => { + newSelectionState[id] = true; + }); + setSelectedExperimentIds(newSelectionState); + }, []); + const [searchInput, setSearchInput] = useState(''); const [showCreateExperimentModal, setShowCreateExperimentModal] = useState(false); - const [showBulkDeleteExperimentModal, setShowBulkDeleteExperimentModal] = useState(false); - const handleSearchInputChange: React.ChangeEventHandler = (event) => { - setSearchInput(event.target.value); + const handleRefresh = () => { + handleRefetch(); }; - const handleSearchSubmit = () => { - setSearchFilter(searchInput); + const handleSearchInputChange = (e: React.ChangeEvent) => { + setSearchInput(e.target.value); }; - const handleSearchClear = () => { + const handleClearSearch = () => { + setSearchInput(''); setSearchFilter(''); }; @@ -71,128 +92,184 @@ export const ExperimentListView = ({ searchFilter, setSearchFilter }: Props) => setShowCreateExperimentModal(false); }; + const checkedKeys = Object.entries(selectedExperimentIds) + .filter(([_, value]) => value) + .map(([key, _]) => key); + + const theme = useDesignSystemTheme(); + const navigate = useNavigate(); + const intl = useIntl(); + const pushExperimentRoute = () => { const route = Routes.getCompareExperimentsPageRoute(checkedKeys); navigate(route); }; - const checkedKeys = Object.entries(rowSelection) - .filter(([_, value]) => value) - .map(([key, _]) => key); + const hasSelectedExperiments = Object.values(selectedExperimentIds).some(Boolean); - const { theme } = useDesignSystemTheme(); - const navigate = useNavigate(); - const intl = useIntl(); + // Get the experiment objects for the checked keys + const selectedExperiments = (experimentsData || []).filter(({ experimentId }) => checkedKeys.includes(experimentId)); return ( - - -
} - buttons={ - <> - - , + ]} + /> + +
+ + + {(searchFilter || projectFilter) && ( + + )} +
+ } + > +
+ + + + + {projectNames.map((project) => ( + + {project} + + ))} + + - - - - } - /> - - {error && ( - + + {(searchFilter || projectFilter) && ( - ) - } - componentId="mlflow.experiment_list_view.error" - closable={false} - /> - )} -
- - + )} + + - - +
checkedKeys.includes(experimentId))} isOpen={showBulkDeleteExperimentModal} onClose={() => setShowBulkDeleteExperimentModal(false)} - onExperimentsDeleted={() => { - invalidateExperimentList(); - setRowSelection({}); - }} + experiments={selectedExperiments} + onExperimentsDeleted={handleRefetch} /> {EditTagsModal} diff --git a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts index ab89f2758e9f4..383498a4f40bd 100644 --- a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts +++ b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts @@ -1,6 +1,6 @@ import { useQuery, QueryFunctionContext, defaultContext } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; import { MlflowService } from '../../../sdk/MlflowService'; -import { useCallback, useContext, useRef, useState } from 'react'; +import { useCallback, useContext, useRef, useState, useMemo } from 'react'; import { SearchExperimentsApiResponse } from '../../../types'; import { useLocalStorage } from '@mlflow/mlflow/src/shared/web-shared/hooks/useLocalStorage'; import { CursorPaginationProps } from '@databricks/design-system'; @@ -16,26 +16,44 @@ const ExperimentListQueryKeyHeader = 'experiment_list'; type ExperimentListQueryKey = [ typeof ExperimentListQueryKeyHeader, - { searchFilter?: string; pageToken?: string; pageSize?: number; sorting?: SortingState }, + { + searchFilter: string; + projectFilter: string; + pageToken?: string; + pageSize: number; + sorting: SortingState; + } ]; -export const useInvalidateExperimentList = () => { - const context = useContext(defaultContext); - return () => { - context?.invalidateQueries({ queryKey: [ExperimentListQueryKeyHeader] }); - }; -}; - const queryFn = ({ queryKey }: QueryFunctionContext) => { - const [, { searchFilter, pageToken, pageSize, sorting }] = queryKey; + const [, { searchFilter, projectFilter, pageToken, pageSize, sorting }] = queryKey; // NOTE: REST API docs are not detailed enough, see: mlflow/store/tracking/abstract_store.py#search_experiments const orderBy = sorting?.map((column) => ['order_by', `${column.id} ${column.desc ? 'DESC' : 'ASC'}`]) ?? []; const data = [['max_results', String(pageSize)], ...orderBy]; + // Build filter conditions + const filterConditions = []; + if (searchFilter) { - data.push(['filter', `name ILIKE '%${searchFilter}%'`]); + filterConditions.push(`name ILIKE '%${searchFilter}%'`); + } + + if (projectFilter) { + if (projectFilter === 'Unassigned') { + // For "Unassigned", we can't use "IS NULL" in MLflow search API + // We'll handle this case client-side by fetching all experiments + // and filtering them in the response processing + // Don't add any project filter condition here + } else { + // Filter for experiments with the specific project_name tag + filterConditions.push(`tags.project_name = '${projectFilter}'`); + } + } + + if (filterConditions.length > 0) { + data.push(['filter', filterConditions.join(' AND ')]); } if (pageToken) { @@ -45,63 +63,137 @@ const queryFn = ({ queryKey }: QueryFunctionContext) => return MlflowService.searchExperiments(data); }; -export const useExperimentListQuery = ({ searchFilter }: { searchFilter?: string } = {}) => { - const previousPageTokens = useRef<(string | undefined)[]>([]); - - const [currentPageToken, setCurrentPageToken] = useState(undefined); - +export const useExperimentListQuery = ( + searchFilter: string, + projectFilter: string, + pageToken?: string +) => { const [pageSize, setPageSize] = useLocalStorage({ key: STORE_KEY.PAGE_SIZE, version: 0, initialValue: DEFAULT_PAGE_SIZE, }); - const [sorting, setSorting] = useLocalStorage({ key: STORE_KEY.SORTING_STATE, version: 0, - initialValue: [{ id: 'last_update_time', desc: true }], + initialValue: [], }); - const pageSizeSelect: CursorPaginationProps['pageSizeSelect'] = { - options: [10, 25, 50, 100], - default: pageSize, - onChange(pageSize) { - setPageSize(pageSize); - setCurrentPageToken(undefined); + const queryKey: ExperimentListQueryKey = [ + ExperimentListQueryKeyHeader, + { + searchFilter, + projectFilter, + pageToken, + pageSize, + sorting, + }, + ]; + + const queryResult = useQuery( + queryKey, + { + queryFn, + retry: false, + } + ); + + // Post-process the results to handle "Unassigned" filtering client-side + const processedData = useMemo(() => { + if (!queryResult.data) return queryResult.data; + + if (projectFilter === 'Unassigned') { + // Filter experiments that don't have a project_name tag + const filteredExperiments = queryResult.data.experiments?.filter(experiment => { + const hasProjectTag = experiment.tags?.some(tag => tag.key === 'project_name'); + return !hasProjectTag; + }); + + return { + ...queryResult.data, + experiments: filteredExperiments + }; + } + + return queryResult.data; + }, [queryResult.data, projectFilter]); + + const paginationProps: Omit = { + hasNextPage: !!queryResult.data?.next_page_token, + hasPreviousPage: !!pageToken, + onNextPage: () => { + // Implementation for next page + }, + onPreviousPage: () => { + // Implementation for previous page }, }; - const queryResult = useQuery< - SearchExperimentsApiResponse, - Error, - SearchExperimentsApiResponse, - ExperimentListQueryKey - >([ExperimentListQueryKeyHeader, { searchFilter, pageToken: currentPageToken, pageSize, sorting }], { - queryFn, - retry: false, - }); + return { + ...queryResult, + data: processedData, + paginationProps, + pageSize, + setPageSize, + sorting, + setSorting, + }; +}; - const onNextPage = useCallback(() => { - previousPageTokens.current.push(currentPageToken); - setCurrentPageToken(queryResult.data?.next_page_token); - }, [queryResult.data?.next_page_token, currentPageToken]); +export const useInvalidateExperimentList = () => { + const queryClient = useContext(defaultContext); + return useCallback(() => { + queryClient?.invalidateQueries([ExperimentListQueryKeyHeader]); + }, [queryClient]); +}; - const onPreviousPage = useCallback(() => { - const previousPageToken = previousPageTokens.current.pop(); - setCurrentPageToken(previousPageToken); - }, []); +// Query key for fetching all experiments to extract project names +const AllExperimentsQueryKeyHeader = 'all_experiments_for_projects'; + +// Hook to get all unique project names from experiments +export const useUniqueProjectNames = () => { + const queryResult = useQuery( + [AllExperimentsQueryKeyHeader], + { + queryFn: () => { + // Fetch all experiments without any filter to get project names + const data = [['max_results', '1000']]; // Get a large number to capture all experiments + return MlflowService.searchExperiments(data); + }, + retry: false, + } + ); + + const projectNames = useMemo(() => { + if (!queryResult.data?.experiments) { + return []; + } + + const uniqueProjects = new Set(); + let hasUnassigned = false; + + queryResult.data.experiments.forEach(experiment => { + const projectTag = experiment.tags?.find(tag => tag.key === 'project_name'); + if (projectTag?.value) { + uniqueProjects.add(projectTag.value); + } else { + hasUnassigned = true; + } + }); + + const projects = Array.from(uniqueProjects).sort(); + + // Add "Unassigned" if there are experiments without project tags + if (hasUnassigned) { + projects.push('Unassigned'); + } + + return projects; + }, [queryResult.data?.experiments]); return { - data: queryResult.data?.experiments, - error: queryResult.error ?? undefined, + projectNames, isLoading: queryResult.isLoading, - hasNextPage: queryResult.data?.next_page_token !== undefined, - hasPreviousPage: Boolean(currentPageToken), - onNextPage, - onPreviousPage, - refetch: queryResult.refetch, - pageSizeSelect, - sorting, - setSorting, + error: queryResult.error, }; }; diff --git a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx index b7e66518fbf5e..039455e53111c 100644 --- a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx @@ -1,4 +1,5 @@ import { useSearchParams } from '@mlflow/mlflow/src/common/utils/RoutingUtils'; +import { useLocalStorage } from '@mlflow/mlflow/src/shared/web-shared/hooks/useLocalStorage'; export function useSearchFilter() { const name = 'experimentSearchFilter'; @@ -17,3 +18,34 @@ export function useSearchFilter() { return [searchFilter, setSearchFilter] as const; } + +export function useProjectFilter() { + const urlParamName = 'experimentProjectFilter'; + const [searchParams, setSearchParams] = useSearchParams(); + + // Use localStorage for persistence across sessions + const [persistedProjectFilter, setPersistedProjectFilter] = useLocalStorage({ + key: 'experiments_page.project_filter', + version: 0, + initialValue: '', + }); + + // Get project filter from URL first, then fallback to localStorage + const urlProjectFilter = searchParams.get(urlParamName); + const projectFilter = urlProjectFilter ?? persistedProjectFilter; + + function setProjectFilter(projectFilter: string) { + // Update URL parameter + if (!projectFilter) { + searchParams.delete(urlParamName); + } else { + searchParams.set(urlParamName, projectFilter); + } + setSearchParams(searchParams); + + // Also persist to localStorage + setPersistedProjectFilter(projectFilter); + } + + return [projectFilter, setProjectFilter] as const; +}