+
+ ,
+ ]}
+ 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;
+}