@@ -342,13 +347,13 @@ const ExperimentActionDropdown: React.FC
= ({
onEditComplete={handleEditComplete}
/>
diff --git a/webui/react/src/components/RunActionDropdown.test.mock.tsx b/webui/react/src/components/RunActionDropdown.test.mock.tsx
new file mode 100644
index 00000000000..67defcde2c2
--- /dev/null
+++ b/webui/react/src/components/RunActionDropdown.test.mock.tsx
@@ -0,0 +1,108 @@
+import { GridCell, GridCellKind } from '@glideapps/glide-data-grid';
+
+import { DateString, decode, optional } from 'ioTypes';
+import { FlatRun } from 'types';
+
+import { FilterFormSetWithoutId } from './FilterForm/components/type';
+
+export const run: FlatRun = {
+ archived: false,
+ checkpointCount: 1,
+ checkpointSize: 43090,
+ duration: 256,
+ endTime: decode(optional(DateString), '2024-06-03T17:50:38.703259Z'),
+ experiment: {
+ description: '',
+ forkedFrom: 6634,
+ id: 6833,
+ isMultitrial: true,
+ name: 'iris_tf_keras_adaptive_search',
+ progress: 0.9444444,
+ resourcePool: 'compute-pool',
+ searcherMetric: 'val_categorical_accuracy',
+ searcherType: 'adaptive_asha',
+ unmanaged: false,
+ },
+ hyperparameters: {
+ global_batch_size: 22,
+ layer1_dense_size: 29,
+ learning_rate: 0.00004998215062737775,
+ learning_rate_decay: 0.000001,
+ },
+ id: 45888,
+ labels: ['a', 'b'],
+ parentArchived: false,
+ projectId: 1,
+ projectName: 'Uncategorized',
+ searcherMetricValue: 0.46666666865348816,
+ startTime: decode(optional(DateString), '2024-06-03T17:46:22.682019Z'),
+ state: 'COMPLETED',
+ summaryMetrics: {
+ avgMetrics: {
+ categorical_accuracy: {
+ count: 1,
+ last: 0.2968127429485321,
+ max: 0.2968127429485321,
+ min: 0.2968127429485321,
+ sum: 0.2968127429485321,
+ type: 'number',
+ },
+ loss: {
+ count: 1,
+ last: 2.4582924842834473,
+ max: 2.4582924842834473,
+ min: 2.4582924842834473,
+ sum: 2.4582924842834473,
+ type: 'number',
+ },
+ },
+ validationMetrics: {
+ val_categorical_accuracy: {
+ count: 1,
+ last: 0.46666666865348816,
+ max: 0.46666666865348816,
+ min: 0.46666666865348816,
+ sum: 0.46666666865348816,
+ type: 'number',
+ },
+ val_loss: {
+ count: 1,
+ last: 1.8627476692199707,
+ max: 1.8627476692199707,
+ min: 1.8627476692199707,
+ sum: 1.8627476692199707,
+ type: 'number',
+ },
+ },
+ },
+ userId: 1354,
+ workspaceId: 1,
+ workspaceName: 'Uncategorized',
+};
+
+export const cell: GridCell = {
+ allowOverlay: false,
+ copyData: '45888',
+ cursor: 'pointer',
+ data: {
+ kind: 'link-cell',
+ link: {
+ href: '/experiments/6833/trials/45888',
+ title: '45888',
+ unmanaged: false,
+ },
+ navigateOn: 'click',
+ underlineOffset: 6,
+ },
+ kind: GridCellKind.Custom,
+ readonly: true,
+};
+
+export const filterFormSetWithoutId: FilterFormSetWithoutId = {
+ filterGroup: {
+ children: [],
+ conjunction: 'and',
+ kind: 'group',
+ },
+ showArchived: false,
+};
diff --git a/webui/react/src/components/RunActionDropdown.test.tsx b/webui/react/src/components/RunActionDropdown.test.tsx
new file mode 100644
index 00000000000..2a43008bec9
--- /dev/null
+++ b/webui/react/src/components/RunActionDropdown.test.tsx
@@ -0,0 +1,179 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import UIProvider, { DefaultTheme } from 'hew/Theme';
+import { ConfirmationProvider } from 'hew/useConfirm';
+
+import { handlePath } from 'routes/utils';
+import { archiveRuns, deleteRuns, killRuns, unarchiveRuns } from 'services/api';
+import { RunState } from 'types';
+
+import RunActionDropdown, { Action } from './RunActionDropdown';
+import { cell, run } from './RunActionDropdown.test.mock';
+
+const mockNavigatorClipboard = () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: {
+ readText: vi.fn(),
+ writeText: vi.fn(),
+ },
+ writable: true,
+ });
+};
+
+vi.mock('routes/utils', () => ({
+ handlePath: vi.fn(),
+ serverAddress: () => 'http://localhost',
+}));
+
+vi.mock('services/api', () => ({
+ archiveRuns: vi.fn(),
+ deleteRuns: vi.fn(),
+ killRuns: vi.fn(),
+ unarchiveRuns: vi.fn(),
+}));
+
+const mocks = vi.hoisted(() => {
+ return {
+ canDeleteFlatRun: vi.fn(),
+ canModifyFlatRun: vi.fn(),
+ canMoveFlatRun: vi.fn(),
+ };
+});
+
+vi.mock('hooks/usePermissions', () => {
+ const usePermissions = vi.fn(() => {
+ return {
+ canDeleteFlatRun: mocks.canDeleteFlatRun,
+ canModifyFlatRun: mocks.canModifyFlatRun,
+ canMoveFlatRun: mocks.canMoveFlatRun,
+ };
+ });
+ return {
+ default: usePermissions,
+ };
+});
+
+const setup = (link?: string, state?: RunState, archived?: boolean) => {
+ const onComplete = vi.fn();
+ const onVisibleChange = vi.fn();
+ render(
+
+
+
+
+ ,
+ );
+ return {
+ onComplete,
+ onVisibleChange,
+ };
+};
+
+const user = userEvent.setup();
+
+describe('RunActionDropdown', () => {
+ it('should provide Copy Data option', async () => {
+ setup();
+ mockNavigatorClipboard();
+ await user.click(screen.getByText(Action.Copy));
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(cell.copyData);
+ });
+
+ it('should provide Link option', async () => {
+ const link = 'https://www.google.com/';
+ setup(link);
+ await user.click(screen.getByText(Action.NewTab));
+ const tabClick = vi.mocked(handlePath).mock.calls[0];
+ expect(tabClick[0]).toMatchObject({ type: 'click' });
+ expect(tabClick[1]).toMatchObject({
+ path: link,
+ popout: 'tab',
+ });
+ await user.click(screen.getByText(Action.NewWindow));
+ const windowClick = vi.mocked(handlePath).mock.calls[1];
+ expect(windowClick[0]).toMatchObject({ type: 'click' });
+ expect(windowClick[1]).toMatchObject({
+ path: link,
+ popout: 'window',
+ });
+ });
+
+ it('should provide Delete option', async () => {
+ mocks.canDeleteFlatRun.mockImplementation(() => true);
+ setup();
+ await user.click(screen.getByText(Action.Delete));
+ await user.click(screen.getByRole('button', { name: Action.Delete }));
+ expect(vi.mocked(deleteRuns)).toBeCalled();
+ });
+
+ it('should hide Delete option without permissions', () => {
+ mocks.canDeleteFlatRun.mockImplementation(() => false);
+ setup();
+ expect(screen.queryByText(Action.Delete)).not.toBeInTheDocument();
+ });
+
+ it('should provide Kill option', async () => {
+ mocks.canModifyFlatRun.mockImplementation(() => true);
+ setup(undefined, RunState.Paused, undefined);
+ await user.click(screen.getByText(Action.Kill));
+ await user.click(screen.getByRole('button', { name: Action.Kill }));
+ expect(vi.mocked(killRuns)).toBeCalled();
+ });
+
+ it('should hide Kill option without permissions', () => {
+ mocks.canModifyFlatRun.mockImplementation(() => false);
+ setup(undefined, RunState.Paused, undefined);
+ expect(screen.queryByText(Action.Kill)).not.toBeInTheDocument();
+ });
+
+ it('should provide Archive option', async () => {
+ mocks.canModifyFlatRun.mockImplementation(() => true);
+ setup();
+ await user.click(screen.getByText(Action.Archive));
+ expect(vi.mocked(archiveRuns)).toBeCalled();
+ });
+
+ it('should hide Archive option without permissions', () => {
+ mocks.canModifyFlatRun.mockImplementation(() => false);
+ setup();
+ expect(screen.queryByText(Action.Archive)).not.toBeInTheDocument();
+ });
+
+ it('should provide Unarchive option', async () => {
+ mocks.canModifyFlatRun.mockImplementation(() => true);
+ setup(undefined, undefined, true);
+ await user.click(screen.getByText(Action.Unarchive));
+ expect(vi.mocked(unarchiveRuns)).toBeCalled();
+ });
+
+ it('should hide Unarchive option without permissions', () => {
+ mocks.canModifyFlatRun.mockImplementation(() => false);
+ setup(undefined, undefined, true);
+ expect(screen.queryByText(Action.Unarchive)).not.toBeInTheDocument();
+ });
+
+ it('should provide Move option', () => {
+ mocks.canMoveFlatRun.mockImplementation(() => true);
+ setup();
+ expect(screen.getByText(Action.Move)).toBeInTheDocument();
+ });
+
+ it('should hide Move option without permissions', () => {
+ mocks.canMoveFlatRun.mockImplementation(() => false);
+ setup();
+ expect(screen.queryByText(Action.Move)).not.toBeInTheDocument();
+ });
+});
diff --git a/webui/react/src/components/RunActionDropdown.tsx b/webui/react/src/components/RunActionDropdown.tsx
new file mode 100644
index 00000000000..647f5d213ef
--- /dev/null
+++ b/webui/react/src/components/RunActionDropdown.tsx
@@ -0,0 +1,188 @@
+import { GridCell } from '@glideapps/glide-data-grid';
+import { ContextMenuCompleteHandlerProps } from 'hew/DataGrid/contextMenu';
+import Dropdown, { DropdownEvent, MenuItem } from 'hew/Dropdown';
+import { useModal } from 'hew/Modal';
+import { useToast } from 'hew/Toast';
+import useConfirm from 'hew/useConfirm';
+import { copyToClipboard } from 'hew/utils/functions';
+import { isString } from 'lodash';
+import React, { useCallback, useMemo } from 'react';
+
+import usePermissions from 'hooks/usePermissions';
+import FlatRunMoveModalComponent from 'pages/FlatRuns/FlatRunMoveModal';
+import { handlePath } from 'routes/utils';
+import { archiveRuns, deleteRuns, killRuns, unarchiveRuns } from 'services/api';
+import { FlatRun, FlatRunAction, ValueOf } from 'types';
+import handleError, { ErrorLevel, ErrorType } from 'utils/error';
+import { getActionsForFlatRun } from 'utils/flatRun';
+import { capitalize } from 'utils/string';
+
+interface Props {
+ children?: React.ReactNode;
+ cell?: GridCell;
+ run: FlatRun;
+ link?: string;
+ makeOpen?: boolean;
+ onComplete?: ContextMenuCompleteHandlerProps;
+ onLink?: () => void;
+ onVisibleChange?: (visible: boolean) => void;
+ projectId: number;
+}
+
+export const Action = {
+ Copy: 'Copy Value',
+ NewTab: 'Open Link in New Tab',
+ NewWindow: 'Open Link in New Window',
+ ...FlatRunAction,
+};
+
+type Action = ValueOf;
+
+const dropdownActions = [Action.Archive, Action.Unarchive, Action.Kill, Action.Move, Action.Delete];
+
+const RunActionDropdown: React.FC = ({
+ run,
+ cell,
+ link,
+ makeOpen,
+ onComplete,
+ onLink,
+ onVisibleChange,
+ projectId,
+}: Props) => {
+ const { Component: FlatRunMoveComponentModal, open: flatRunMoveModalOpen } =
+ useModal(FlatRunMoveModalComponent);
+ const confirm = useConfirm();
+ const { openToast } = useToast();
+
+ const menuItems = getActionsForFlatRun(run, dropdownActions, usePermissions()).map(
+ (action: FlatRunAction) => {
+ return { danger: action === Action.Delete, key: action, label: action };
+ },
+ );
+
+ const cellCopyData = useMemo(() => {
+ if (cell && 'displayData' in cell && isString(cell.displayData)) return cell.displayData;
+ if (cell?.copyData) return cell.copyData;
+ return undefined;
+ }, [cell]);
+
+ const dropdownMenu = useMemo(() => {
+ const items: MenuItem[] = [];
+ if (link) {
+ items.push(
+ { key: Action.NewTab, label: Action.NewTab },
+ { key: Action.NewWindow, label: Action.NewWindow },
+ { type: 'divider' },
+ );
+ }
+ if (cellCopyData) {
+ items.push({ key: Action.Copy, label: Action.Copy });
+ }
+ items.push(...menuItems);
+ return items;
+ }, [link, menuItems, cellCopyData]);
+
+ const handleDropdown = useCallback(
+ async (action: string, e: DropdownEvent) => {
+ try {
+ switch (action) {
+ case Action.NewTab:
+ handlePath(e, { path: link, popout: 'tab' });
+ await onLink?.();
+ break;
+ case Action.NewWindow:
+ handlePath(e, { path: link, popout: 'window' });
+ await onLink?.();
+ break;
+ case Action.Archive:
+ await archiveRuns({ projectId, runIds: [run.id] });
+ await onComplete?.(action, run.id);
+ break;
+ case Action.Kill:
+ confirm({
+ content: `Are you sure you want to kill run ${run.id}?`,
+ danger: true,
+ okText: 'Kill',
+ onConfirm: async () => {
+ await killRuns({ projectId, runIds: [run.id] });
+ await onComplete?.(action, run.id);
+ },
+ onError: handleError,
+ title: 'Confirm Run Kill',
+ });
+ break;
+ case Action.Unarchive:
+ await unarchiveRuns({ projectId, runIds: [run.id] });
+ await onComplete?.(action, run.id);
+ break;
+ case Action.Delete:
+ confirm({
+ content: `Are you sure you want to delete run ${run.id}?`,
+ danger: true,
+ okText: 'Delete',
+ onConfirm: async () => {
+ await deleteRuns({ projectId, runIds: [run.id] });
+ await onComplete?.(action, run.id);
+ },
+ onError: handleError,
+ title: 'Confirm Run Deletion',
+ });
+ break;
+ case Action.Move:
+ flatRunMoveModalOpen();
+ break;
+ case Action.Copy:
+ await copyToClipboard(cellCopyData ?? '');
+ openToast({
+ severity: 'Confirm',
+ title: 'Value has been copied to clipboard.',
+ });
+ break;
+ }
+ } catch (e) {
+ handleError(e, {
+ level: ErrorLevel.Error,
+ publicMessage: `Unable to ${action} experiment ${run.id}.`,
+ publicSubject: `${capitalize(action)} failed.`,
+ silent: false,
+ type: ErrorType.Server,
+ });
+ } finally {
+ onVisibleChange?.(false);
+ }
+ },
+ [
+ link,
+ onLink,
+ run.id,
+ onComplete,
+ confirm,
+ cellCopyData,
+ openToast,
+ onVisibleChange,
+ projectId,
+ flatRunMoveModalOpen,
+ ],
+ );
+
+ const shared = (
+ onComplete?.(FlatRunAction.Move, run.id)}
+ />
+ );
+
+ return (
+ <>
+
+
+
+ {shared}
+ >
+ );
+};
+
+export default RunActionDropdown;
diff --git a/webui/react/src/pages/FlatRuns/FlatRuns.tsx b/webui/react/src/pages/FlatRuns/FlatRuns.tsx
index 60a91fa448b..e544ede7684 100644
--- a/webui/react/src/pages/FlatRuns/FlatRuns.tsx
+++ b/webui/react/src/pages/FlatRuns/FlatRuns.tsx
@@ -28,6 +28,7 @@ import Link from 'hew/Link';
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';
@@ -52,6 +53,7 @@ import {
rowHeightMap,
settingsConfigGlobal,
} from 'components/OptionsMenu.settings';
+import RunActionDropdown from 'components/RunActionDropdown';
import useUI from 'components/ThemeProvider';
import { useAsync } from 'hooks/useAsync';
import { useDebouncedSettings } from 'hooks/useDebouncedSettings';
@@ -72,7 +74,7 @@ import { getProjectColumns, getProjectNumericMetricsRange, searchRuns } from 'se
import { V1ColumnType, V1LocationType, V1TableType } from 'services/api-ts-sdk';
import userStore from 'stores/users';
import userSettings from 'stores/userSettings';
-import { DetailedUser, ExperimentAction, FlatRun, ProjectColumn } from 'types';
+import { DetailedUser, FlatRun, FlatRunAction, ProjectColumn, RunState } from 'types';
import handleError from 'utils/error';
import { eagerSubscribe } from 'utils/observable';
import { pluralizer } from 'utils/string';
@@ -185,6 +187,7 @@ const FlatRuns: React.FC = ({ projectId, workspaceId, searchId }) => {
const [canceler] = useState(new AbortController());
const users = useObservable>(userStore.getUsers());
+ const { openToast } = useToast();
const { width: containerWidth } = useResize(contentRef);
const {
@@ -712,8 +715,59 @@ const FlatRuns: React.FC = ({ projectId, workspaceId, searchId }) => {
await fetchRuns();
}, [fetchRuns, handleSelectionChange]);
- const handleContextMenuComplete: ContextMenuCompleteHandlerProps =
- useCallback(() => {}, []);
+ const handleActionSuccess = useCallback(
+ (action: FlatRunAction, id: number): void => {
+ const updateRun = (updated: Partial) => {
+ setRuns((prev) =>
+ prev.map((runs) =>
+ Loadable.map(runs, (run) => {
+ if (run.id === id) {
+ return { ...run, ...updated };
+ }
+ return run;
+ }),
+ ),
+ );
+ };
+ switch (action) {
+ case FlatRunAction.Archive:
+ updateRun({ archived: true });
+ break;
+ case FlatRunAction.Kill:
+ updateRun({ state: RunState.StoppingKilled });
+ break;
+ case FlatRunAction.Unarchive:
+ updateRun({ archived: false });
+ break;
+ case FlatRunAction.Move:
+ case FlatRunAction.Delete:
+ setRuns((prev) =>
+ prev.filter((runs) =>
+ Loadable.match(runs, {
+ _: () => true,
+ Loaded: (run) => run.id !== id,
+ }),
+ ),
+ );
+ break;
+ default:
+ break;
+ }
+ openToast({
+ severity: 'Confirm',
+ title: `Run ${action.split('')[action.length - 1] === 'e' ? action.toLowerCase() : `${action.toLowerCase()}e`}d successfully`,
+ });
+ },
+ [openToast],
+ );
+
+ const handleContextMenuComplete: ContextMenuCompleteHandlerProps =
+ useCallback(
+ (action: FlatRunAction, id: number) => {
+ handleActionSuccess(action, id);
+ },
+ [handleActionSuccess],
+ );
const handleColumnsOrderChange = useCallback(
// changing both column order and pinned count should happen in one update:
@@ -1056,13 +1110,35 @@ const FlatRuns: React.FC = ({ projectId, workspaceId, searchId }) => {
projectId={projectId}
selectedRuns={loadedSelectedRuns}
onWidthChange={handleCompareWidthChange}>
-
columns={columns}
data={runs}
getHeaderMenuItems={getHeaderMenuItems}
getRowAccentColor={getRowAccentColor}
imperativeRef={dataGridRef}
pinnedColumnsCount={isLoadingSettings ? 0 : settings.pinnedColumnsCount}
+ renderContextMenuComponent={({
+ cell,
+ rowData,
+ link,
+ open,
+ onComplete,
+ onClose,
+ onVisibleChange,
+ }) => {
+ return (
+
+ );
+ }}
rowHeight={rowHeightMap[globalSettings.rowHeight as RowHeight]}
selection={selection}
sorts={sorts}
diff --git a/webui/react/src/pages/FlatRuns/columns.ts b/webui/react/src/pages/FlatRuns/columns.ts
index c0c80e13e29..6523d6f4fbe 100644
--- a/webui/react/src/pages/FlatRuns/columns.ts
+++ b/webui/react/src/pages/FlatRuns/columns.ts
@@ -473,7 +473,7 @@ export const getColumnDefs = ({
});
return {
allowOverlay: true,
- copyData: String(record.userId),
+ copyData: String(displayName),
data: {
image: undefined,
initials: getInitials(displayName),
diff --git a/webui/react/src/routes/utils.ts b/webui/react/src/routes/utils.ts
index 03ef0f83b5b..14494d4d10e 100644
--- a/webui/react/src/routes/utils.ts
+++ b/webui/react/src/routes/utils.ts
@@ -1,4 +1,5 @@
import { pathToRegexp } from 'path-to-regexp';
+import { KeyboardEvent } from 'react';
import { globalStorage } from 'globalStorage';
import { ClusterApi, Configuration } from 'services/api-ts-sdk';
@@ -9,6 +10,7 @@ import {
AnyMouseEventHandler,
isAbsolutePath,
isFullPath,
+ isMouseEvent,
isNewTabClickEvent,
openBlank,
reactHostAddress,
@@ -200,7 +202,7 @@ export const routeAll = (path: string): void => {
}
};
export const handlePath = (
- event: AnyMouseEvent,
+ event: AnyMouseEvent | KeyboardEvent,
options: {
external?: boolean;
onClick?: AnyMouseEventHandler;
@@ -212,10 +214,10 @@ export const handlePath = (
const href = options.path ? linkPath(options.path, options.external) : undefined;
- if (options.onClick) {
+ if (options.onClick && isMouseEvent(event)) {
options.onClick(event);
} else if (href) {
- if (isNewTabClickEvent(event) || options.popout) {
+ if ((isMouseEvent(event) && isNewTabClickEvent(event)) || options.popout) {
/**
* `location=0` forces a new window instead of a tab to open.
* https://stackoverflow.com/questions/726761/javascript-open-in-a-new-window-not-tab