From bdab9e47ea709eb49d156f8bf7728118baa68223 Mon Sep 17 00:00:00 2001 From: johnkim-det <97752292+johnkim-det@users.noreply.github.com> Date: Fri, 5 Apr 2024 16:56:04 -0400 Subject: [PATCH] feat: create Searches view (#9089) --- .../ColumnPickerMenu.module.scss | 0 .../ColumnPickerMenu.tsx | 68 +- webui/react/src/components/DynamicTabs.tsx | 14 +- .../components/Searches/Searches.module.scss | 16 + .../components/Searches/Searches.settings.ts | 96 ++ .../src/components/Searches/Searches.tsx | 946 ++++++++++++++++++ .../react/src/components/Searches/columns.ts | 488 +++++++++ webui/react/src/components/TableActionBar.tsx | 49 +- webui/react/src/hooks/useFeature.ts | 8 +- .../src/pages/F_ExpList/F_ExperimentList.tsx | 7 + .../src/pages/F_ExpList/expListColumns.ts | 3 +- webui/react/src/pages/F_ExpList/utils.ts | 46 - webui/react/src/pages/ProjectDetails.tsx | 18 +- webui/react/src/utils/datetime.ts | 38 +- 14 files changed, 1691 insertions(+), 106 deletions(-) rename webui/react/src/{pages/F_ExpList => components}/ColumnPickerMenu.module.scss (100%) rename webui/react/src/{pages/F_ExpList => components}/ColumnPickerMenu.tsx (83%) create mode 100644 webui/react/src/components/Searches/Searches.module.scss create mode 100644 webui/react/src/components/Searches/Searches.settings.ts create mode 100644 webui/react/src/components/Searches/Searches.tsx create mode 100644 webui/react/src/components/Searches/columns.ts delete mode 100644 webui/react/src/pages/F_ExpList/utils.ts diff --git a/webui/react/src/pages/F_ExpList/ColumnPickerMenu.module.scss b/webui/react/src/components/ColumnPickerMenu.module.scss similarity index 100% rename from webui/react/src/pages/F_ExpList/ColumnPickerMenu.module.scss rename to webui/react/src/components/ColumnPickerMenu.module.scss diff --git a/webui/react/src/pages/F_ExpList/ColumnPickerMenu.tsx b/webui/react/src/components/ColumnPickerMenu.tsx similarity index 83% rename from webui/react/src/pages/F_ExpList/ColumnPickerMenu.tsx rename to webui/react/src/components/ColumnPickerMenu.tsx index 27736f84515..e614b04bac7 100644 --- a/webui/react/src/pages/F_ExpList/ColumnPickerMenu.tsx +++ b/webui/react/src/components/ColumnPickerMenu.tsx @@ -43,6 +43,7 @@ interface ColumnMenuProps { onVisibleColumnChange?: (newColumns: string[]) => void; projectColumns: Loadable; projectId: number; + tabs: (V1LocationType | V1LocationType[])[]; } interface ColumnTabProps { @@ -233,6 +234,7 @@ const ColumnPickerMenu: React.FC = ({ projectId, isMobile = false, onVisibleColumnChange, + tabs, }) => { const [searchString, setSearchString] = useState(''); const [open, setOpen] = useState(false); @@ -259,32 +261,46 @@ const ColumnPickerMenu: React.FC = ({ - { - const canonicalTab = Array.isArray(tab) ? tab[0] : tab; - return { - children: ( - - ), - forceRender: true, - key: canonicalTab, - label: locationLabelMap[canonicalTab], - }; - })} - /> + {tabs.length > 1 && ( + { + const canonicalTab = Array.isArray(tab) ? tab[0] : tab; + return { + children: ( + + ), + forceRender: true, + key: canonicalTab, + label: locationLabelMap[canonicalTab], + }; + })} + /> + )} + {tabs.length === 1 && ( + + )} } open={open} diff --git a/webui/react/src/components/DynamicTabs.tsx b/webui/react/src/components/DynamicTabs.tsx index 60cb7511280..cf7b95d2b0b 100644 --- a/webui/react/src/components/DynamicTabs.tsx +++ b/webui/react/src/components/DynamicTabs.tsx @@ -6,18 +6,13 @@ import { useNavigate, useParams } from 'react-router-dom'; interface DynamicTabBarProps extends Omit { basePath: string; type?: PivotTabType; - children?: React.ReactNode; } type TabBarUpdater = (node?: JSX.Element) => void; const TabBarContext = createContext(undefined); -const DynamicTabs: React.FC = ({ - basePath, - children, - ...props -}): JSX.Element => { +const DynamicTabs: React.FC = ({ basePath, items, ...props }): JSX.Element => { const [tabBarExtraContent, setTabBarExtraContent] = useState(); const navigate = useNavigate(); @@ -25,9 +20,9 @@ const DynamicTabs: React.FC = ({ const [tabKeys, setTabKeys] = useState([]); useEffect(() => { - const newTabKeys = React.Children.map(children, (c) => (c as { key: string })?.key ?? ''); + const newTabKeys = items?.map((c) => c.key ?? ''); if (Array.isArray(newTabKeys) && !_.isEqual(newTabKeys, tabKeys)) setTabKeys(newTabKeys); - }, [children, tabKeys]); + }, [items, tabKeys]); const { tab } = useParams<{ tab: string }>(); @@ -46,7 +41,7 @@ const DynamicTabs: React.FC = ({ }, [tab]); useEffect(() => { - if (!activeKey && tabKeys.length) { + if ((!activeKey || !tabKeys.includes(activeKey)) && tabKeys.length) { navigate(`${basePath}/${tabKeys[0]}`, { replace: true }); } }, [activeKey, tabKeys, handleTabSwitch, basePath, navigate]); @@ -60,6 +55,7 @@ const DynamicTabs: React.FC = ({ diff --git a/webui/react/src/components/Searches/Searches.module.scss b/webui/react/src/components/Searches/Searches.module.scss new file mode 100644 index 00000000000..e1291689153 --- /dev/null +++ b/webui/react/src/components/Searches/Searches.module.scss @@ -0,0 +1,16 @@ +.content { + height: 100%; + overflow: hidden; + + .paneWrapper { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + height: 100%; + width: 100%; + + & > *:first-child { + min-height: 0; + } + } +} diff --git a/webui/react/src/components/Searches/Searches.settings.ts b/webui/react/src/components/Searches/Searches.settings.ts new file mode 100644 index 00000000000..8e01b9dd062 --- /dev/null +++ b/webui/react/src/components/Searches/Searches.settings.ts @@ -0,0 +1,96 @@ +import * as t from 'io-ts'; + +import { INIT_FORMSET } from 'components/FilterForm/components/FilterFormStore'; +import { ioRowHeight, ioTableViewMode, RowHeight, TableViewMode } from 'components/OptionsMenu'; +import { SettingsConfig } from 'hooks/useSettings'; + +import { defaultColumnWidths, defaultExperimentColumns } from './columns'; + +const SelectAllType = t.type({ + exclusions: t.array(t.number), + type: t.literal('ALL_EXCEPT'), +}); + +const RegularSelectionType = t.type({ + selections: t.array(t.number), + type: t.literal('ONLY_IN'), +}); + +export const SelectionType = t.union([RegularSelectionType, SelectAllType]); +export type SelectionType = t.TypeOf; +export const DEFAULT_SELECTION: t.TypeOf = { + selections: [], + type: 'ONLY_IN', +}; + +// have to intersect with an empty object bc of settings store type issue +export const ProjectSettings = t.intersection([ + t.type({}), + t.partial({ + columns: t.array(t.string), + columnWidths: t.record(t.string, t.number), + compare: t.boolean, + filterset: t.string, // save FilterFormSet as string + heatmapOn: t.boolean, + heatmapSkipped: t.array(t.string), + pageLimit: t.number, + pinnedColumnsCount: t.number, + selection: SelectionType, + sortString: t.string, + }), +]); +export type ProjectSettings = t.TypeOf; + +export const ProjectUrlSettings = t.partial({ + compare: t.boolean, + page: t.number, +}); + +export const settingsPathForProject = (id: number): string => `searchesForProject${id}`; +export const defaultProjectSettings: Required = { + columns: defaultExperimentColumns, + columnWidths: defaultColumnWidths, + compare: false, + filterset: JSON.stringify(INIT_FORMSET), + heatmapOn: false, + heatmapSkipped: [], + pageLimit: 20, + pinnedColumnsCount: 3, + selection: DEFAULT_SELECTION, + sortString: 'id=desc', +}; + +export interface SearchesGlobalSettings { + rowHeight: RowHeight; + tableViewMode: TableViewMode; +} + +export const experimentListGlobalSettingsConfig = t.partial({ + rowHeight: ioRowHeight, + tableViewMode: ioTableViewMode, +}); + +export const experimentListGlobalSettingsDefaults = { + rowHeight: RowHeight.MEDIUM, + tableViewMode: 'scroll', +} as const; + +export const experimentListGlobalSettingsPath = 'globalTableSettings'; + +export const settingsConfigGlobal: SettingsConfig = { + settings: { + rowHeight: { + defaultValue: RowHeight.MEDIUM, + skipUrlEncoding: true, + storageKey: 'rowHeight', + type: ioRowHeight, + }, + tableViewMode: { + defaultValue: 'scroll', + skipUrlEncoding: true, + storageKey: 'tableViewMode', + type: ioTableViewMode, + }, + }, + storagePath: experimentListGlobalSettingsPath, +}; diff --git a/webui/react/src/components/Searches/Searches.tsx b/webui/react/src/components/Searches/Searches.tsx new file mode 100644 index 00000000000..1202eda799c --- /dev/null +++ b/webui/react/src/components/Searches/Searches.tsx @@ -0,0 +1,946 @@ +import { CompactSelection, GridSelection } from '@glideapps/glide-data-grid'; +import { isLeft } from 'fp-ts/lib/Either'; +import Column from 'hew/Column'; +import { + ColumnDef, + defaultDateColumn, + defaultNumberColumn, + defaultSelectionColumn, + defaultTextColumn, + MULTISELECT, +} from 'hew/DataGrid/columns'; +import DataGrid, { + DataGridHandle, + HandleSelectionChangeType, + RangelessSelectionType, + SelectionType, + Sort, + validSort, + ValidSort, +} from 'hew/DataGrid/DataGrid'; +import { MenuItem } from 'hew/Dropdown'; +import Icon from 'hew/Icon'; +import Message from 'hew/Message'; +import Pagination from 'hew/Pagination'; +import Row from 'hew/Row'; +import { useToast } from 'hew/Toast'; +import { Loadable, Loaded, NotLoaded } from 'hew/utils/loadable'; +import { useObservable } from 'micro-observables'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import { Error, NoExperiments } from 'components/exceptions'; +import ExperimentActionDropdown from 'components/ExperimentActionDropdown'; +import { FilterFormStore, ROOT_ID } from 'components/FilterForm/components/FilterFormStore'; +import { + AvailableOperators, + FilterFormSet, + FormField, + FormGroup, + FormKind, + IOFilterFormSet, + Operator, + SpecialColumnNames, +} from 'components/FilterForm/components/type'; +import { EMPTY_SORT, sortMenuItemsForColumn } from 'components/MultiSortMenu'; +import { RowHeight, TableViewMode } from 'components/OptionsMenu'; +import TableActionBar from 'components/TableActionBar'; +import useUI from 'components/ThemeProvider'; +import { useAsync } from 'hooks/useAsync'; +import { useGlasbey } from 'hooks/useGlasbey'; +import useMobile from 'hooks/useMobile'; +import usePolling from 'hooks/usePolling'; +import { useSettings } from 'hooks/useSettings'; +import { useTypedParams } from 'hooks/useTypedParams'; +import { getProjectColumns, searchExperiments } from 'services/api'; +import { V1BulkExperimentFilters, V1ColumnType, V1LocationType } from 'services/api-ts-sdk'; +import usersStore from 'stores/users'; +import userSettings from 'stores/userSettings'; +import { + ExperimentAction, + ExperimentItem, + ExperimentWithTrial, + Project, + ProjectColumn, + RunState, +} from 'types'; +import handleError from 'utils/error'; +import { getProjectExperimentForExperimentItem } from 'utils/experiment'; +import { eagerSubscribe } from 'utils/observable'; +import { pluralizer } from 'utils/string'; + +import { getColumnDefs, searcherMetricsValColumn } from './columns'; +import css from './Searches.module.scss'; +import { + DEFAULT_SELECTION, + defaultProjectSettings, + ProjectSettings, + ProjectUrlSettings, + SearchesGlobalSettings, + SelectionType as SelectionState, + settingsConfigGlobal, + settingsPathForProject, +} from './Searches.settings'; + +interface Props { + project: Project; +} + +type ExperimentWithIndex = { index: number; experiment: ExperimentItem }; + +const makeSortString = (sorts: ValidSort[]): string => + sorts.map((s) => `${s.column}=${s.direction}`).join(','); + +const parseSortString = (sortString: string): Sort[] => { + if (!sortString) return [EMPTY_SORT]; + const components = sortString.split(','); + return components.map((c) => { + const [column, direction] = c.split('=', 2); + return { + column, + direction: direction === 'asc' || direction === 'desc' ? direction : undefined, + }; + }); +}; + +const formStore = new FilterFormStore(); + +export const PAGE_SIZE = 100; +const INITIAL_LOADING_EXPERIMENTS: Loadable[] = new Array(PAGE_SIZE).fill( + NotLoaded, +); + +const STATIC_COLUMNS = [MULTISELECT]; + +const rowHeightMap: Record = { + [RowHeight.EXTRA_TALL]: 44, + [RowHeight.TALL]: 40, + [RowHeight.MEDIUM]: 36, + [RowHeight.SHORT]: 32, +}; + +const Searches: React.FC = ({ project }) => { + const dataGridRef = useRef(null); + const contentRef = useRef(null); + + const settingsPath = useMemo(() => settingsPathForProject(project.id), [project.id]); + const projectSettingsObs = useMemo( + () => userSettings.get(ProjectSettings, settingsPath), + [settingsPath], + ); + const projectSettings = useObservable(projectSettingsObs); + const isLoadingSettings = useMemo(() => projectSettings.isNotLoaded, [projectSettings]); + const updateSettings = useCallback( + (p: Partial) => userSettings.setPartial(ProjectSettings, settingsPath, p), + [settingsPath], + ); + const settings = useMemo( + () => + projectSettings + .map((s) => ({ ...defaultProjectSettings, ...s })) + .getOrElse(defaultProjectSettings), + [projectSettings], + ); + + const { params, updateParams } = useTypedParams(ProjectUrlSettings, {}); + const page = params.page || 0; + const setPage = useCallback( + (p: number) => updateParams({ page: p || undefined }), + [updateParams], + ); + + const { settings: globalSettings, updateSettings: updateGlobalSettings } = + useSettings(settingsConfigGlobal); + const isPagedView = globalSettings.tableViewMode === 'paged'; + const [sorts, setSorts] = useState(() => { + if (!isLoadingSettings) { + return parseSortString(settings.sortString); + } + return [EMPTY_SORT]; + }); + const sortString = useMemo(() => makeSortString(sorts.filter(validSort.is)), [sorts]); + const [experiments, setExperiments] = useState[]>( + INITIAL_LOADING_EXPERIMENTS, + ); + const [total, setTotal] = useState>(NotLoaded); + const [isOpenFilter, setIsOpenFilter] = useState(false); + const filtersString = useObservable(formStore.asJsonString); + const loadableFormset = useObservable(formStore.formset); + const rootFilterChildren: Array = Loadable.match(loadableFormset, { + _: () => [], + Loaded: (formset: FilterFormSet) => formset.filterGroup.children, + }); + const isMobile = useMobile(); + const { openToast } = useToast(); + + const selectAll = useMemo( + () => !isLoadingSettings && settings.selection.type === 'ALL_EXCEPT', + [isLoadingSettings, settings.selection], + ); + + const handlePinnedColumnsCountChange = useCallback( + (newCount: number) => updateSettings({ pinnedColumnsCount: newCount }), + [updateSettings], + ); + const handleIsOpenFilterChange = useCallback((newOpen: boolean) => { + setIsOpenFilter(newOpen); + if (!newOpen) { + formStore.sweep(); + } + }, []); + + const resetPagination = useCallback(() => { + setIsLoading(true); + setPage(0); + setExperiments(INITIAL_LOADING_EXPERIMENTS); + }, [setPage]); + + useEffect(() => { + let cleanup: () => void; + // eagerSubscribe is like subscribe but it runs once before the observed value changes. + cleanup = eagerSubscribe(projectSettingsObs, (ps, prevPs) => { + // init formset once from settings when loaded, then flip the sync + // direction -- when formset changes, update settings + if (!prevPs?.isLoaded) { + ps.forEach((s) => { + cleanup?.(); + if (!s?.filterset) { + formStore.init(); + } else { + const formSetValidation = IOFilterFormSet.decode(JSON.parse(s.filterset)); + if (isLeft(formSetValidation)) { + handleError(formSetValidation.left, { + publicSubject: 'Unable to initialize filterset from settings', + }); + } else { + formStore.init(formSetValidation.right); + } + } + cleanup = formStore.asJsonString.subscribe(() => { + resetPagination(); + const loadableFormset = formStore.formset.get(); + Loadable.forEach(loadableFormset, (formSet) => + updateSettings({ filterset: JSON.stringify(formSet) }), + ); + }); + }); + } + }); + return () => cleanup?.(); + }, [projectSettingsObs, resetPagination, updateSettings]); + + const [isLoading, setIsLoading] = useState(true); + const [error] = useState(false); + const [canceler] = useState(new AbortController()); + + // partition experiment list into not selected/selected with indices and experiments so we only iterate the result list once + const [excludedExperimentIds, selectedExperimentIds] = useMemo(() => { + const selectedMap = new Map(); + const excludedMap = new Map(); + if (isLoadingSettings) { + return [excludedMap, selectedMap]; + } + const selectedIdSet = new Set( + settings.selection.type === 'ONLY_IN' ? settings.selection.selections : [], + ); + const excludedIdSet = new Set( + settings.selection.type === 'ALL_EXCEPT' ? settings.selection.exclusions : [], + ); + experiments.forEach((e, index) => { + Loadable.forEach(e, ({ experiment }) => { + const mapToAdd = + (selectAll && !excludedIdSet.has(experiment.id)) || selectedIdSet.has(experiment.id) + ? selectedMap + : excludedMap; + mapToAdd.set(experiment.id, { experiment, index }); + }); + }); + return [excludedMap, selectedMap]; + }, [isLoadingSettings, selectAll, settings.selection, experiments]); + + const selection = useMemo(() => { + let rows = CompactSelection.empty(); + if (selectAll) { + Loadable.forEach(total, (t) => { + rows = rows.add([0, t]); + }); + excludedExperimentIds.forEach((info) => { + rows = rows.remove(info.index); + }); + } else { + selectedExperimentIds.forEach((info) => { + rows = rows.add(info.index); + }); + } + return { + columns: CompactSelection.empty(), + rows, + }; + }, [selectAll, selectedExperimentIds, excludedExperimentIds, total]); + + const colorMap = useGlasbey([...selectedExperimentIds.keys()]); + + const experimentFilters = useMemo(() => { + const filters: V1BulkExperimentFilters = { + projectId: project.id, + }; + return filters; + }, [project.id]); + + const numFilters = useMemo(() => { + return ( + Object.values(experimentFilters).filter((x) => x !== undefined).length - + 1 + + rootFilterChildren.length + ); + }, [experimentFilters, rootFilterChildren.length]); + + const handleSortChange = useCallback( + (sorts: Sort[]) => { + setSorts(sorts); + const newSortString = makeSortString(sorts.filter(validSort.is)); + if (newSortString !== sortString) { + resetPagination(); + } + updateSettings({ sortString: newSortString }); + }, + [resetPagination, sortString, updateSettings], + ); + + useEffect(() => { + if (!isLoadingSettings && settings.sortString) { + setSorts(parseSortString(settings.sortString)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoadingSettings]); + + const fetchExperiments = useCallback(async (): Promise => { + if (isLoadingSettings || Loadable.isNotLoaded(loadableFormset)) return; + try { + const tableOffset = Math.max((page - 0.5) * PAGE_SIZE, 0); + + // always filter out single trial experiments + const filters = JSON.parse(filtersString); + const existingFilterGroup = { ...filters.filterGroup }; + const singleTrialFilter = { + columnName: 'numTrials', + kind: 'field', + location: 'LOCATION_TYPE_EXPERIMENT', + operator: '>', + type: 'COLUMN_TYPE_NUMBER', + value: 1, + }; + filters.filterGroup = { + children: [existingFilterGroup, singleTrialFilter], + conjunction: 'and', + kind: 'group', + }; + + const response = await searchExperiments( + { + ...experimentFilters, + filter: JSON.stringify(filters), + limit: isPagedView ? settings.pageLimit : 2 * PAGE_SIZE, + offset: isPagedView ? page * settings.pageLimit : tableOffset, + sort: sortString || undefined, + }, + { signal: canceler.signal }, + ); + const total = response.pagination.total ?? 0; + const loadedExperiments = response.experiments; + + setExperiments((prev) => { + if (isPagedView) { + return loadedExperiments.map((experiment) => Loaded(experiment)); + } + + // Ensure experiments array has enough space for full result set + const newExperiments = prev.length !== total ? new Array(total).fill(NotLoaded) : [...prev]; + + // Update the list with the fetched results. + Array.prototype.splice.apply(newExperiments, [ + tableOffset, + loadedExperiments.length, + ...loadedExperiments.map((experiment) => Loaded(experiment)), + ]); + + return newExperiments; + }); + setTotal( + response.pagination.total !== undefined ? Loaded(response.pagination.total) : NotLoaded, + ); + } catch (e) { + handleError(e, { publicSubject: 'Unable to fetch experiments.' }); + } finally { + setIsLoading(false); + } + }, [ + canceler.signal, + experimentFilters, + filtersString, + isLoadingSettings, + isPagedView, + loadableFormset, + page, + sortString, + settings.pageLimit, + ]); + + const { stopPolling } = usePolling(fetchExperiments, { rerunOnNewFn: true }); + + const projectColumns = useAsync(async () => { + try { + const columns = await getProjectColumns({ id: project.id }); + return columns.filter((c) => c.location === V1LocationType.EXPERIMENT); + } catch (e) { + handleError(e, { publicSubject: 'Unable to fetch project columns' }); + return NotLoaded; + } + }, [project.id]); + + useEffect(() => { + return () => { + canceler.abort(); + stopPolling(); + }; + }, [canceler, stopPolling]); + + const rowRangeToIds = useCallback( + (range: [number, number]) => { + const slice = experiments.slice(range[0], range[1]); + return Loadable.filterNotLoaded(slice).map(({ experiment }) => experiment.id); + }, + [experiments], + ); + + const handleSelectionChange: HandleSelectionChangeType = useCallback( + (selectionType: SelectionType | RangelessSelectionType, range?: [number, number]) => { + let newSettings: SelectionState = { ...settings.selection }; + + switch (selectionType) { + case 'add': + if (!range) return; + if (newSettings.type === 'ALL_EXCEPT') { + const excludedSet = new Set(newSettings.exclusions); + rowRangeToIds(range).forEach((id) => excludedSet.delete(id)); + newSettings.exclusions = Array.from(excludedSet); + } else { + const includedSet = new Set(newSettings.selections); + rowRangeToIds(range).forEach((id) => includedSet.add(id)); + newSettings.selections = Array.from(includedSet); + } + + break; + case 'add-all': + newSettings = { + exclusions: [], + type: 'ALL_EXCEPT' as const, + }; + + break; + case 'remove': + if (!range) return; + if (newSettings.type === 'ALL_EXCEPT') { + const excludedSet = new Set(newSettings.exclusions); + rowRangeToIds(range).forEach((id) => excludedSet.add(id)); + newSettings.exclusions = Array.from(excludedSet); + } else { + const includedSet = new Set(newSettings.selections); + rowRangeToIds(range).forEach((id) => includedSet.delete(id)); + newSettings.selections = Array.from(includedSet); + } + + break; + case 'remove-all': + newSettings = DEFAULT_SELECTION; + + break; + case 'set': + if (!range) return; + newSettings = { + ...DEFAULT_SELECTION, + selections: Array.from(rowRangeToIds(range)), + }; + + break; + } + + updateSettings({ selection: newSettings }); + }, + [rowRangeToIds, settings.selection, updateSettings], + ); + + const handleActionComplete = useCallback(async () => { + /** + * Deselect selected rows since their states may have changed where they + * are no longer part of the filter criteria. + */ + handleSelectionChange('remove-all'); + + // Re-fetch experiment list to get updates based on batch action. + await fetchExperiments(); + }, [handleSelectionChange, fetchExperiments]); + + const handleActionSuccess = useCallback( + (action: ExperimentAction, successfulIds: number[], data?: Partial): void => { + const idSet = new Set(successfulIds); + const updateExperiment = (updated: Partial) => { + setExperiments((prev) => + prev.map((expLoadable) => + Loadable.map(expLoadable, (experiment) => + idSet.has(experiment.experiment.id) + ? { ...experiment, experiment: { ...experiment.experiment, ...updated } } + : experiment, + ), + ), + ); + }; + switch (action) { + case ExperimentAction.Activate: + updateExperiment({ state: RunState.Active }); + break; + case ExperimentAction.Archive: + updateExperiment({ archived: true }); + break; + case ExperimentAction.Cancel: + updateExperiment({ state: RunState.StoppingCanceled }); + break; + case ExperimentAction.Kill: + updateExperiment({ state: RunState.StoppingKilled }); + break; + case ExperimentAction.Pause: + updateExperiment({ state: RunState.Paused }); + break; + case ExperimentAction.Unarchive: + updateExperiment({ archived: false }); + break; + case ExperimentAction.Edit: + if (data) updateExperiment(data); + openToast({ severity: 'Confirm', title: 'Experiment updated successfully' }); + break; + case ExperimentAction.Move: + case ExperimentAction.Delete: + setExperiments((prev) => + prev.filter((expLoadable) => + Loadable.match(expLoadable, { + _: () => true, + Loaded: (experiment) => !idSet.has(experiment.experiment.id), + }), + ), + ); + break; + case ExperimentAction.RetainLogs: + break; + // Exhaustive cases to ignore. + default: + break; + } + handleSelectionChange('remove-all'); + }, + [handleSelectionChange, openToast], + ); + + const handleContextMenuComplete = useCallback( + (action: ExperimentAction, id: number, data?: Partial) => + handleActionSuccess(action, [id], data), + [handleActionSuccess], + ); + + const handleColumnsOrderChange = useCallback( + (newColumnsOrder: string[]) => { + updateSettings({ columns: newColumnsOrder }); + }, + [updateSettings], + ); + + const handleRowHeightChange = useCallback( + (newRowHeight: RowHeight) => { + updateGlobalSettings({ rowHeight: newRowHeight }); + }, + [updateGlobalSettings], + ); + + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleSelectionChange('remove-all'); + } + }; + window.addEventListener('keydown', handleEsc); + + return () => { + window.removeEventListener('keydown', handleEsc); + }; + }, [handleSelectionChange]); + + const handleTableViewModeChange = useCallback( + (mode: TableViewMode) => { + // Reset page index when table view mode changes. + resetPagination(); + updateGlobalSettings({ tableViewMode: mode }); + }, + [resetPagination, updateGlobalSettings], + ); + + const onPageChange = useCallback( + (cPage: number, cPageSize: number) => { + updateSettings({ pageLimit: cPageSize }); + // Pagination component is assuming starting index of 1. + if (cPage - 1 !== page) { + setExperiments(Array(cPageSize).fill(NotLoaded)); + } + setPage(cPage - 1); + }, + [page, updateSettings, setPage], + ); + + const handleColumnWidthChange = useCallback( + (columnId: string, width: number) => { + updateSettings({ + columnWidths: { + ...settings.columnWidths, + [columnId]: width, + }, + }); + }, + [updateSettings, settings.columnWidths], + ); + + const columnsIfLoaded = useMemo( + () => (isLoadingSettings ? [] : settings.columns), + [isLoadingSettings, settings.columns], + ); + + const showPagination = useMemo(() => { + return isPagedView && !isMobile; + }, [isMobile, isPagedView]); + + const { + ui: { theme: appTheme }, + isDarkMode, + } = useUI(); + + const users = useObservable(usersStore.getUsers()); + + const columns: ColumnDef[] = useMemo(() => { + const projectColumnsMap: Loadable> = Loadable.map( + projectColumns, + (columns) => { + return columns.reduce((acc, col) => ({ ...acc, [col.column]: col }), {}); + }, + ); + const columnDefs = getColumnDefs({ + appTheme, + columnWidths: settings.columnWidths, + themeIsDark: isDarkMode, + users, + }); + const gridColumns = [...STATIC_COLUMNS, ...columnsIfLoaded] + .map((columnName) => { + if (columnName === MULTISELECT) { + return (columnDefs[columnName] = defaultSelectionColumn(selection.rows, selectAll)); + } + if (columnName in columnDefs) return columnDefs[columnName]; + if (!Loadable.isLoaded(projectColumnsMap)) return; + const currentColumn = projectColumnsMap.data[columnName]; + if (!currentColumn) return; + let dataPath: string | undefined = undefined; + switch (currentColumn.location) { + case V1LocationType.EXPERIMENT: + dataPath = `experiment.${currentColumn.column}`; + break; + case V1LocationType.UNSPECIFIED: + default: + break; + } + switch (currentColumn.type) { + case V1ColumnType.NUMBER: { + columnDefs[currentColumn.column] = defaultNumberColumn( + currentColumn.column, + currentColumn.displayName || currentColumn.column, + settings.columnWidths[currentColumn.column], + dataPath, + ); + break; + } + case V1ColumnType.DATE: + columnDefs[currentColumn.column] = defaultDateColumn( + currentColumn.column, + currentColumn.displayName || currentColumn.column, + settings.columnWidths[currentColumn.column], + dataPath, + ); + break; + case V1ColumnType.TEXT: + case V1ColumnType.UNSPECIFIED: + default: + columnDefs[currentColumn.column] = defaultTextColumn( + currentColumn.column, + currentColumn.displayName || currentColumn.column, + settings.columnWidths[currentColumn.column], + dataPath, + ); + } + if (currentColumn.column === 'searcherMetricsVal') { + columnDefs[currentColumn.column] = searcherMetricsValColumn( + settings.columnWidths[currentColumn.column], + ); + } + return columnDefs[currentColumn.column]; + }) + .flatMap((col) => (col ? [col] : [])); + return gridColumns; + }, [ + projectColumns, + settings.columnWidths, + columnsIfLoaded, + appTheme, + isDarkMode, + selectAll, + selection.rows, + users, + ]); + + const getHeaderMenuItems = (columnId: string, colIdx: number): MenuItem[] => { + if (columnId === MULTISELECT) { + const items: MenuItem[] = [ + selection.rows.length > 0 + ? { + key: 'select-none', + label: 'Clear selected', + onClick: () => { + handleSelectionChange?.('remove-all'); + }, + } + : null, + ...[5, 10, 25].map((n) => ({ + key: `select-${n}`, + label: `Select first ${n}`, + onClick: () => { + handleSelectionChange?.('set', [0, n]); + dataGridRef.current?.scrollToTop(); + }, + })), + { + key: 'select-all', + label: 'Select all', + onClick: () => { + handleSelectionChange?.('add-all'); + }, + }, + ]; + return items; + } + const column = Loadable.getOrElse([], projectColumns).find((c) => c.column === columnId); + if (!column) { + return []; + } + + const filterCount = formStore.getFieldCount(column.column).get(); + + const BANNED_FILTER_COLUMNS = ['searcherMetricsVal']; + const loadableFormset = formStore.formset.get(); + const filterMenuItemsForColumn = () => { + const isSpecialColumn = (SpecialColumnNames as ReadonlyArray).includes(column.column); + formStore.addChild(ROOT_ID, FormKind.Field, { + index: Loadable.match(loadableFormset, { + _: () => 0, + Loaded: (formset) => formset.filterGroup.children.length, + }), + item: { + columnName: column.column, + id: uuidv4(), + kind: FormKind.Field, + location: column.location, + operator: isSpecialColumn ? Operator.Eq : AvailableOperators[column.type][0], + type: column.type, + value: null, + }, + }); + handleIsOpenFilterChange?.(true); + }; + const clearFilterForColumn = () => { + formStore.removeByField(column.column); + }; + + const isPinned = colIdx <= settings.pinnedColumnsCount + STATIC_COLUMNS.length - 1; + const items: MenuItem[] = [ + // Column is pinned if the index is inside of the frozen columns + colIdx < STATIC_COLUMNS.length || isMobile + ? null + : !isPinned + ? { + icon: , + key: 'pin', + label: 'Pin column', + onClick: () => { + const newColumnsOrder = columnsIfLoaded.filter((c) => c !== column.column); + newColumnsOrder.splice(settings.pinnedColumnsCount, 0, column.column); + handleColumnsOrderChange?.(newColumnsOrder); + handlePinnedColumnsCountChange?.( + Math.min(settings.pinnedColumnsCount + 1, columnsIfLoaded.length), + ); + }, + } + : { + disabled: settings.pinnedColumnsCount <= 1, + icon: , + key: 'unpin', + label: 'Unpin column', + onClick: () => { + const newColumnsOrder = columnsIfLoaded.filter((c) => c !== column.column); + newColumnsOrder.splice(settings.pinnedColumnsCount - 1, 0, column.column); + handleColumnsOrderChange?.(newColumnsOrder); + handlePinnedColumnsCountChange?.(Math.max(settings.pinnedColumnsCount - 1, 0)); + }, + }, + { + icon: , + key: 'hide', + label: 'Hide column', + onClick: () => { + const newColumnsOrder = columnsIfLoaded.filter((c) => c !== column.column); + handleColumnsOrderChange?.(newColumnsOrder); + if (isPinned) { + handlePinnedColumnsCountChange?.(Math.max(settings.pinnedColumnsCount - 1, 0)); + } + }, + }, + { type: 'divider' as const }, + ...(BANNED_FILTER_COLUMNS.includes(column.column) + ? [] + : [ + ...sortMenuItemsForColumn(column, sorts, handleSortChange), + { type: 'divider' as const }, + { + icon: , + key: 'filter', + label: 'Add Filter', + onClick: () => { + setTimeout(filterMenuItemsForColumn, 5); + }, + }, + ]), + filterCount > 0 + ? { + icon: , + key: 'filter-clear', + label: `Clear ${pluralizer(filterCount, 'Filter')} (${filterCount})`, + onClick: () => { + setTimeout(clearFilterForColumn, 5); + }, + } + : null, + ]; + return items; + }; + + const getRowAccentColor = (rowData: ExperimentWithTrial) => { + return colorMap[rowData.experiment.id]; + }; + + return ( + <> + +
+ {!isLoading && experiments.length === 0 ? ( + numFilters === 0 ? ( + + ) : ( + + ) + ) : error ? ( + + ) : ( +
+ + columns={columns} + data={experiments} + getHeaderMenuItems={getHeaderMenuItems} + getRowAccentColor={getRowAccentColor} + imperativeRef={dataGridRef} + isPaginated={isPagedView} + page={page} + pageSize={PAGE_SIZE} + pinnedColumnsCount={isLoadingSettings ? 0 : settings.pinnedColumnsCount} + renderContextMenuComponent={({ + cell, + rowData, + link, + open, + onComplete, + onClose, + onVisibleChange, + }) => { + return ( + +
+ + ); + }} + rowHeight={rowHeightMap[globalSettings.rowHeight as RowHeight]} + selection={selection} + sorts={sorts} + staticColumns={STATIC_COLUMNS} + total={Loadable.getOrElse(PAGE_SIZE, total)} + onColumnResize={handleColumnWidthChange} + onColumnsOrderChange={handleColumnsOrderChange} + onContextMenuComplete={handleContextMenuComplete} + onPageUpdate={setPage} + onPinnedColumnsCountChange={handlePinnedColumnsCountChange} + onSelectionChange={handleSelectionChange} + /> + {showPagination && ( + + + + + + )} +
+ )} +
+ + ); +}; + +export default Searches; diff --git a/webui/react/src/components/Searches/columns.ts b/webui/react/src/components/Searches/columns.ts new file mode 100644 index 00000000000..02c1173ea45 --- /dev/null +++ b/webui/react/src/components/Searches/columns.ts @@ -0,0 +1,488 @@ +import { CellClickedEventArgs, GridCellKind } from '@glideapps/glide-data-grid'; +import { getColor, getInitials } from 'hew/Avatar'; +import { ColumnDef, ColumnDefs, DEFAULT_COLUMN_WIDTH } from 'hew/DataGrid/columns'; +import { + LINK_CELL, + State, + STATE_CELL, + TAGS_CELL, + TEXT_CELL, + USER_AVATAR_CELL, +} from 'hew/DataGrid/custom-renderers/index'; +import { Theme } from 'hew/Theme'; +import { Loadable } from 'hew/utils/loadable'; + +import { handlePath, paths } from 'routes/utils'; +import { CompoundRunState, DetailedUser, ExperimentWithTrial, JobState, RunState } from 'types'; +import { getDurationInEnglish, getTimeInEnglish } from 'utils/datetime'; +import { humanReadableNumber } from 'utils/number'; +import { AnyMouseEvent } from 'utils/routes'; +import { floatToPercent, humanReadableBytes } from 'utils/string'; +import { getDisplayName } from 'utils/user'; + +// order used in ColumnPickerMenu +export const experimentColumns = [ + 'id', + 'name', + 'state', + 'startTime', + 'user', + 'numTrials', + 'searcherType', + 'searcherMetric', + 'searcherMetricsVal', + 'description', + 'tags', + 'forkedFrom', + 'progress', + 'duration', + 'resourcePool', + 'checkpointCount', + 'checkpointSize', + 'externalExperimentId', + 'externalTrialId', + 'archived', +] as const; + +export type ExperimentColumn = (typeof experimentColumns)[number]; + +export const defaultExperimentColumns: ExperimentColumn[] = [ + 'id', + 'name', + 'state', + 'startTime', + 'user', + 'numTrials', + 'searcherType', + 'searcherMetric', + 'searcherMetricsVal', + 'description', + 'tags', + 'progress', + 'duration', + 'resourcePool', + 'checkpointCount', + 'checkpointSize', +]; + +function getCellStateFromExperimentState(expState: CompoundRunState) { + switch (expState) { + case JobState.SCHEDULED: + case JobState.SCHEDULEDBACKFILLED: + case JobState.QUEUED: + case RunState.Queued: { + return State.QUEUED; + } + case RunState.Starting: + case RunState.Pulling: { + return State.STARTING; + } + case RunState.Running: { + return State.RUNNING; + } + case RunState.Paused: { + return State.PAUSED; + } + case RunState.Completed: { + return State.SUCCESS; + } + case RunState.Error: + case RunState.Deleted: + case RunState.Deleting: + case RunState.DeleteFailed: { + return State.ERROR; + } + case RunState.Active: + case RunState.Unspecified: + case JobState.UNSPECIFIED: { + return State.ACTIVE; + } + default: { + return State.STOPPED; + } + } +} + +interface Params { + appTheme: Theme; + columnWidths: Record; + themeIsDark: boolean; + users: Loadable; +} +export const getColumnDefs = ({ + columnWidths, + themeIsDark, + users, + appTheme, +}: Params): ColumnDefs => ({ + archived: { + id: 'archived', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + data: String(record.experiment.archived), + displayData: record.experiment.archived ? '📦' : '', + kind: GridCellKind.Text, + }), + title: 'Archived', + tooltip: () => undefined, + width: columnWidths.archived, + }, + checkpointCount: { + id: 'checkpointCount', + isNumerical: true, + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + data: Number(record.experiment.checkpoints), + displayData: String(record.experiment.checkpoints), + kind: GridCellKind.Number, + }), + title: 'Checkpoints', + tooltip: () => undefined, + width: columnWidths.checkpointCount, + }, + checkpointSize: { + id: 'checkpointSize', + isNumerical: true, + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: record.experiment.checkpointSize + ? humanReadableBytes(record.experiment.checkpointSize) + : '', + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }), + title: 'Checkpoint Size', + tooltip: () => undefined, + width: columnWidths.checkpointSize, + }, + description: { + id: 'description', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: String(record.experiment.description), + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }), + title: 'Description', + tooltip: () => undefined, + width: columnWidths.description, + }, + duration: { + id: 'duration', + isNumerical: true, + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: getDurationInEnglish(record.experiment), + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }), + title: 'Duration', + tooltip: () => undefined, + width: columnWidths.duration, + }, + externalExperimentId: { + id: 'externalExperimentId', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: record.experiment.externalExperimentId ?? '', + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }), + title: 'External Experiment ID', + tooltip: () => undefined, + width: columnWidths.externalExperimentId, + }, + externalTrialId: { + id: 'externalTrialId', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: record.experiment.externalTrialId ?? '', + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }), + title: 'External Trial ID', + tooltip: () => undefined, + width: columnWidths.externalTrialId, + }, + forkedFrom: { + id: 'forkedFrom', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: String(record.experiment.forkedFrom ?? ''), + cursor: record.experiment.forkedFrom ? 'pointer' : undefined, + data: { + kind: LINK_CELL, + link: + record.experiment.forkedFrom !== undefined + ? { + href: record.experiment.forkedFrom + ? paths.experimentDetails(record.experiment.forkedFrom) + : undefined, + title: String(record.experiment.forkedFrom ?? ''), + } + : undefined, + navigateOn: 'click', + underlineOffset: 6, + }, + kind: GridCellKind.Custom, + onClick: (e: CellClickedEventArgs) => { + if (record.experiment.forkedFrom) { + handlePath(e as unknown as AnyMouseEvent, { + path: String(record.experiment.forkedFrom), + }); + } + }, + readonly: true, + }), + title: 'Forked From', + tooltip: () => undefined, + width: columnWidths.forkedFrom, + }, + id: { + id: 'id', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: String(record.experiment.id), + cursor: 'pointer', + data: { + kind: LINK_CELL, + link: { + href: paths.experimentDetails(record.experiment.id), + title: String(record.experiment.id), + }, + navigateOn: 'click', + onClick: (e: CellClickedEventArgs) => { + handlePath(e as unknown as AnyMouseEvent, { + path: paths.experimentDetails(record.experiment.id), + }); + }, + underlineOffset: 6, + }, + kind: GridCellKind.Custom, + readonly: true, + }), + title: 'ID', + tooltip: () => undefined, + width: columnWidths.id, + }, + name: { + id: 'name', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: String(record.experiment.name), + cursor: 'pointer', + data: { + kind: LINK_CELL, + link: { + href: paths.experimentDetails(record.experiment.id), + title: String(record.experiment.name), + unmanaged: record.experiment.unmanaged, + }, + navigateOn: 'click', + onClick: (e: CellClickedEventArgs) => { + handlePath(e as unknown as AnyMouseEvent, { + path: paths.experimentDetails(record.experiment.id), + }); + }, + underlineOffset: 6, + }, + kind: GridCellKind.Custom, + readonly: true, + }), + title: 'Name', + tooltip: () => undefined, + width: columnWidths.name, + }, + numTrials: { + id: 'numTrials', + isNumerical: true, + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + data: record.experiment.numTrials, + displayData: String(record.experiment.numTrials), + kind: GridCellKind.Number, + }), + title: 'Trials', + tooltip: () => undefined, + width: columnWidths.numTrials, + }, + progress: { + id: 'progress', + renderer: (record: ExperimentWithTrial) => { + const percentage = floatToPercent(record.experiment.progress ?? 0, 0); + + return { + allowOverlay: false, + data: percentage, + displayData: percentage, + kind: GridCellKind.Text, + }; + }, + title: 'Progress', + tooltip: () => undefined, + width: columnWidths.progress, + }, + resourcePool: { + id: 'resourcePool', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: String(record.experiment.resourcePool), + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }), + title: 'Resource Pool', + tooltip: () => undefined, + width: columnWidths.resourcePool, + }, + searcherMetric: { + id: 'searcherMetric', + isNumerical: false, + renderer: (record: ExperimentWithTrial) => { + const sMetric = record.experiment.searcherMetric ?? ''; + return { + allowOverlay: false, + copyData: sMetric, + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }; + }, + title: 'Searcher Metric', + tooltip: () => undefined, + width: columnWidths.searcherMetric, + }, + searcherType: { + id: 'searcherType', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: String(record.experiment.searcherType), + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }), + title: 'Searcher', + tooltip: () => undefined, + width: columnWidths.searcherType, + }, + startTime: { + id: 'startTime', + isNumerical: true, + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: false, + copyData: getTimeInEnglish(new Date(record.experiment.startTime)), + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }), + title: 'Start Time', + tooltip: () => undefined, + width: columnWidths.startTime, + }, + state: { + id: 'state', + renderer: (record: ExperimentWithTrial) => ({ + allowAdd: false, + allowOverlay: true, + copyData: record.experiment.state.toLocaleLowerCase(), + data: { + appTheme, + kind: STATE_CELL, + state: getCellStateFromExperimentState(record.experiment.state), + }, + kind: GridCellKind.Custom, + }), + themeOverride: { cellHorizontalPadding: 13 }, + title: 'State', + tooltip: (record: ExperimentWithTrial) => record.experiment.state.toLocaleLowerCase(), + width: columnWidths.state, + }, + tags: { + id: 'tags', + renderer: (record: ExperimentWithTrial) => ({ + allowOverlay: true, + copyData: record.experiment['labels'].join(', '), + data: { + kind: TAGS_CELL, + possibleTags: [], + readonly: true, + tags: record.experiment['labels'], + }, + kind: GridCellKind.Custom, + }), + title: 'Tags', + tooltip: () => undefined, + width: columnWidths.tags, + }, + user: { + id: 'user', + renderer: (record: ExperimentWithTrial) => { + const displayName = Loadable.match(users, { + _: () => undefined, + Loaded: (users) => getDisplayName(users?.find((u) => u.id === record.experiment.userId)), + }); + return { + allowOverlay: true, + copyData: String(record.experiment.userId), + data: { + image: undefined, + initials: getInitials(displayName), + kind: USER_AVATAR_CELL, + tint: getColor(displayName, themeIsDark), + }, + kind: GridCellKind.Custom, + }; + }, + title: 'User', + tooltip: (record: ExperimentWithTrial) => { + return Loadable.match(users, { + _: () => undefined, + Loaded: (users) => getDisplayName(users?.find((u) => u.id === record.experiment.userId)), + }); + }, + width: columnWidths.user, + }, +}); + +export const searcherMetricsValColumn = (columnWidth?: number): ColumnDef => { + return { + id: 'searcherMetricsVal', + isNumerical: true, + renderer: (record: ExperimentWithTrial) => { + const sMetricValue = record.bestTrial?.searcherMetricsVal; + + return { + allowOverlay: false, + copyData: sMetricValue + ? typeof sMetricValue === 'number' + ? humanReadableNumber(sMetricValue) + : sMetricValue + : '', + data: { kind: TEXT_CELL }, + kind: GridCellKind.Custom, + }; + }, + title: 'Searcher Metric Value', + tooltip: () => undefined, + width: columnWidth ?? DEFAULT_COLUMN_WIDTH, + }; +}; + +export const defaultColumnWidths: Record = { + archived: 80, + checkpointCount: 120, + checkpointSize: 110, + description: 148, + duration: 86, + externalExperimentId: 160, + externalTrialId: 130, + forkedFrom: 86, + id: 50, + name: 290, + numTrials: 50, + progress: 65, + resourcePool: 140, + searcherMetric: 120, + searcherMetricsVal: 120, + searcherType: 120, + startTime: 118, + state: 60, + tags: 106, + user: 50, +}; diff --git a/webui/react/src/components/TableActionBar.tsx b/webui/react/src/components/TableActionBar.tsx index 23caee4c614..37adb5923f0 100644 --- a/webui/react/src/components/TableActionBar.tsx +++ b/webui/react/src/components/TableActionBar.tsx @@ -11,14 +11,16 @@ import { Loadable } from 'hew/utils/loadable'; import React, { useCallback, useMemo, useState } from 'react'; import BatchActionConfirmModalComponent from 'components/BatchActionConfirmModal'; +import ColumnPickerMenu from 'components/ColumnPickerMenu'; import ExperimentMoveModalComponent from 'components/ExperimentMoveModal'; import ExperimentRetainLogsModalComponent from 'components/ExperimentRetainLogsModal'; import ExperimentTensorBoardModal from 'components/ExperimentTensorBoardModal'; import { FilterFormStore } from 'components/FilterForm/components/FilterFormStore'; import TableFilter from 'components/FilterForm/TableFilter'; +import MultiSortMenu from 'components/MultiSortMenu'; +import { OptionsMenu, RowHeight, TableViewMode } from 'components/OptionsMenu'; import useMobile from 'hooks/useMobile'; import usePermissions from 'hooks/usePermissions'; -import ColumnPickerMenu from 'pages/F_ExpList/ColumnPickerMenu'; import { SelectionType } from 'pages/F_ExpList/F_ExperimentList.settings'; import { activateExperiments, @@ -30,7 +32,7 @@ import { pauseExperiments, unarchiveExperiments, } from 'services/api'; -import { V1BulkExperimentFilters } from 'services/api-ts-sdk'; +import { V1BulkExperimentFilters, V1LocationType } from 'services/api-ts-sdk'; import { BulkActionResult, ExperimentAction, @@ -45,11 +47,9 @@ import { getActionsForExperimentsUnion, getProjectExperimentForExperimentItem, } from 'utils/experiment'; -import { pluralizer } from 'utils/string'; +import { capitalizeWord, pluralizer } from 'utils/string'; import { openCommandResponse } from 'utils/wait'; -import MultiSortMenu from './MultiSortMenu'; -import { OptionsMenu, RowHeight, TableViewMode } from './OptionsMenu'; import css from './TableActionBar.module.scss'; const batchActions = [ @@ -86,8 +86,8 @@ interface Props { experiments: Loadable[]; filters: V1BulkExperimentFilters; formStore: FilterFormStore; - heatmapBtnVisible: boolean; - heatmapOn: boolean; + heatmapBtnVisible?: boolean; + heatmapOn?: boolean; initialVisibleColumns: string[]; isOpenFilter: boolean; onActionComplete?: () => Promise; @@ -108,6 +108,9 @@ interface Props { sorts: Sort[]; tableViewMode: TableViewMode; total: Loadable; + labelSingular: string; + labelPlural: string; + columnGroups: (V1LocationType | V1LocationType[])[]; } const TableActionBar: React.FC = ({ @@ -138,6 +141,9 @@ const TableActionBar: React.FC = ({ sorts, tableViewMode, total, + labelSingular, + labelPlural, + columnGroups, }) => { const permissions = usePermissions(); const [batchAction, setBatchAction] = useState(); @@ -272,18 +278,20 @@ const TableActionBar: React.FC = ({ if (numSuccesses === 0 && numFailures === 0) { openToast({ - description: `No selected experiments were eligible for ${action.toLowerCase()}`, - title: 'No eligible experiments', + description: `No selected ${labelPlural.toLowerCase()} were eligible for ${action.toLowerCase()}`, + title: `No eligible ${labelPlural.toLowerCase()}`, }); } else if (numFailures === 0) { openToast({ closeable: true, - description: `${action} succeeded for ${results.successful.length} experiments`, + description: `${action} succeeded for ${ + results.successful.length + } ${labelPlural.toLowerCase()}`, title: `${action} Success`, }); } else if (numSuccesses === 0) { openToast({ - description: `Unable to ${action.toLowerCase()} ${numFailures} experiments`, + description: `Unable to ${action.toLowerCase()} ${numFailures} ${labelPlural.toLowerCase()}`, severity: 'Warning', title: `${action} Failure`, }); @@ -293,7 +301,7 @@ const TableActionBar: React.FC = ({ description: `${action} succeeded for ${numSuccesses} out of ${ numFailures + numSuccesses } eligible - experiments`, + ${labelPlural.toLowerCase()}`, severity: 'Warning', title: `Partial ${action} Failure`, }); @@ -301,8 +309,8 @@ const TableActionBar: React.FC = ({ } catch (e) { const publicSubject = action === ExperimentAction.OpenTensorBoard - ? 'Unable to View TensorBoard for Selected Experiments' - : `Unable to ${action} Selected Experiments`; + ? `Unable to View TensorBoard for Selected ${capitalizeWord(labelPlural)}` + : `Unable to ${action} Selected ${capitalizeWord(labelPlural)}`; handleError(e, { isUserTriggered: true, level: ErrorLevel.Error, @@ -314,7 +322,7 @@ const TableActionBar: React.FC = ({ onActionComplete?.(); } }, - [sendBatchActions, onActionComplete, onActionSuccess, openToast], + [sendBatchActions, onActionComplete, onActionSuccess, openToast, labelPlural], ); const handleBatchAction = useCallback( @@ -362,23 +370,23 @@ const TableActionBar: React.FC = ({ Loaded: (totalExperiments) => { let label = `${totalExperiments.toLocaleString()} ${pluralizer( totalExperiments, - 'experiment', + labelSingular.toLowerCase(), )}`; if (selection.type === 'ALL_EXCEPT') { const all = selection.exclusions.length === 0 ? 'All ' : ''; const totalSelected = (totalExperiments - (selection.exclusions.length ?? 0)).toLocaleString() + ' '; - label = `${all}${totalSelected}experiments selected`; + label = `${all}${totalSelected}${labelPlural.toLowerCase()} selected`; } else if (selection.selections.length > 0) { label = `${selection.selections.length} of ${label} selected`; } return label; }, - NotLoaded: () => 'Loading experiments...', + NotLoaded: () => `Loading ${labelPlural.toLowerCase()}...`, }); - }, [selection, total]); + }, [selection, total, labelPlural, labelSingular]); const handleAction = useCallback((key: string) => handleBatchAction(key), [handleBatchAction]); @@ -405,6 +413,7 @@ const TableActionBar: React.FC = ({ isMobile={isMobile} projectColumns={projectColumns} projectId={project.id} + tabs={columnGroups} onVisibleColumnChange={onVisibleColumnChange} /> = ({