diff --git a/frontend/src/concepts/pipelines/apiHooks/mlmd/useGetArtifactsByRuns.ts b/frontend/src/concepts/pipelines/apiHooks/mlmd/useGetArtifactsByRuns.ts new file mode 100644 index 0000000000..6ec4d43ca2 --- /dev/null +++ b/frontend/src/concepts/pipelines/apiHooks/mlmd/useGetArtifactsByRuns.ts @@ -0,0 +1,43 @@ +import React from 'react'; + +import { Artifact } from '~/third_party/mlmd'; +import { GetArtifactsByContextRequest } from '~/third_party/mlmd/generated/ml_metadata/proto/metadata_store_service_pb'; +import useFetchState, { FetchState, FetchStateCallbackPromise } from '~/utilities/useFetchState'; +import { usePipelinesAPI } from '~/concepts/pipelines/context'; +import { PipelineRunKFv2 } from '~/concepts/pipelines/kfTypes'; +import { MlmdContextTypes } from './types'; +import { getMlmdContext } from './useMlmdContext'; + +export const useGetArtifactsByRuns = ( + runs: PipelineRunKFv2[], +): FetchState[]> => { + const { metadataStoreServiceClient } = usePipelinesAPI(); + + const call = React.useCallback[]>>( + () => + Promise.all( + runs.map((run) => + getMlmdContext(metadataStoreServiceClient, run.run_id, MlmdContextTypes.RUN).then( + async (context) => { + if (!context) { + throw new Error(`No context for run: ${run.run_id}`); + } + + const request = new GetArtifactsByContextRequest(); + request.setContextId(context.getId()); + + const response = await metadataStoreServiceClient.getArtifactsByContext(request); + const artifacts = response.getArtifactsList(); + + return { + [run.run_id]: artifacts, + }; + }, + ), + ), + ), + [metadataStoreServiceClient, runs], + ); + + return useFetchState(call, []); +}; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/ArtifactVisualization.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/ArtifactVisualization.tsx index b22888a9c6..da3fe2d895 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/ArtifactVisualization.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/ArtifactVisualization.tsx @@ -23,6 +23,7 @@ import ROCCurve from '~/concepts/pipelines/content/artifacts/charts/ROCCurve'; import ConfusionMatrix from '~/concepts/pipelines/content/artifacts/charts/confusionMatrix/ConfusionMatrix'; import { buildConfusionMatrixConfig } from '~/concepts/pipelines/content/artifacts/charts/confusionMatrix/utils'; import { isConfusionMatrix } from '~/concepts/pipelines/content/compareRuns/metricsSection/confusionMatrix/utils'; +import { getScalarMetrics } from './utils'; interface ArtifactVisualizationProps { artifact: Artifact; @@ -69,24 +70,7 @@ export const ArtifactVisualization: React.FC = ({ ar } if (artifactType === ArtifactType.METRICS) { - const scalarMetrics = artifact - .toObject() - .customPropertiesMap.reduce( - ( - acc: { name: string; value: string }[], - [customPropKey, { stringValue, intValue, doubleValue, boolValue }], - ) => { - if (customPropKey !== 'display_name') { - acc.push({ - name: customPropKey, - value: stringValue || (intValue || doubleValue || boolValue).toString(), - }); - } - - return acc; - }, - [], - ); + const scalarMetrics = getScalarMetrics(artifact); return ( diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/utils.ts b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/utils.ts new file mode 100644 index 0000000000..baef16b6a1 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/utils.ts @@ -0,0 +1,26 @@ +import { Artifact } from '~/third_party/mlmd'; + +export interface ScalarMetrics { + name: string; + value: string; +} + +export const getScalarMetrics = (artifact: Artifact): ScalarMetrics[] => + artifact + .toObject() + .customPropertiesMap.reduce( + ( + acc: { name: string; value: string }[], + [customPropKey, { stringValue, intValue, doubleValue, boolValue }], + ) => { + if (customPropKey !== 'display_name') { + acc.push({ + name: customPropKey, + value: stringValue || (intValue || doubleValue || boolValue).toString(), + }); + } + + return acc; + }, + [], + ); diff --git a/frontend/src/concepts/pipelines/content/tables/pipelineRun/CustomMetricsColumnsModal.tsx b/frontend/src/concepts/pipelines/content/tables/pipelineRun/CustomMetricsColumnsModal.tsx new file mode 100644 index 0000000000..09020a29c5 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/tables/pipelineRun/CustomMetricsColumnsModal.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { + Button, + Checkbox, + DataListDragButton, + DragDrop, + Draggable, + DraggableItemPosition, + Droppable, + Flex, + FlexItem, + Label, + Modal, + ModalBoxBody, + ModalVariant, + Stack, + StackItem, + Tooltip, +} from '@patternfly/react-core'; +import { getMetricsColumnsLocalStorageKey } from './utils'; + +interface MetricsColumn { + id: string; + checked: boolean; +} + +interface CustomMetricsColumnsModalProps { + columns: MetricsColumn[]; + experimentId: string | undefined; + onClose: () => void; +} + +export const CustomMetricsColumnsModal: React.FC = ({ + onClose, + experimentId, + ...props +}) => { + const [columns, setColumns] = React.useState(props.columns); + const [dragDropText, setDragDropText] = React.useState(''); + const metricsColumnsLocalStorageKey = getMetricsColumnsLocalStorageKey(experimentId ?? ''); + const selectedColumns = Object.values(columns).reduce((acc: string[], column) => { + if (column.checked) { + acc.push(column.id); + } + return acc; + }, []); + + const onDrag = React.useCallback( + (source: DraggableItemPosition) => { + setDragDropText(`Started dragging ${columns[source.index].id}`); + + return true; + }, + [columns], + ); + + const onDragMove = React.useCallback( + (source: DraggableItemPosition, dest?: DraggableItemPosition | undefined) => { + const newDragDropText = dest + ? `Move ${columns[source.index].id} to ${columns[dest.index].id}` + : 'Invalid drop zone'; + + if (newDragDropText !== dragDropText) { + setDragDropText(newDragDropText); + } + }, + [columns, dragDropText], + ); + + const onDrop = React.useCallback( + (source: DraggableItemPosition, dest?: DraggableItemPosition | undefined) => { + if (dest) { + const reorderedColumns = columns; + const [removedColumns] = reorderedColumns.splice(source.index, 1); + reorderedColumns.splice(dest.index, 0, removedColumns); + + setColumns(reorderedColumns); + setDragDropText('Dragging finished.'); + + return true; + } + + return false; + }, + [columns], + ); + + const onUpdate = React.useCallback(() => { + localStorage.removeItem(metricsColumnsLocalStorageKey); + localStorage.setItem(metricsColumnsLocalStorageKey, JSON.stringify(selectedColumns)); + + onClose(); + }, [metricsColumnsLocalStorageKey, onClose, selectedColumns]); + + return ( + + + Select up to 10 metrics that will display as columns in the table. Drag and drop column + names to reorder them. + + + + + + } + isOpen + onClose={onClose} + actions={[ + , + , + ]} + > + + + + {columns.map(({ id, checked }) => { + const columnCheckbox = ( + + setColumns((prevColumns) => + prevColumns.map((prevColumn) => { + if (prevColumn.id === id) { + return { id, checked: isChecked }; + } + + return prevColumn; + }), + ) + } + /> + ); + + return ( + + + + + {selectedColumns.length === 10 && !checked ? ( + + {columnCheckbox} + + ) : ( + columnCheckbox + )} + + {id} + + + ); + })} + + + + + ); +}; diff --git a/frontend/src/concepts/pipelines/content/tables/pipelineRun/PipelineRunTable.tsx b/frontend/src/concepts/pipelines/content/tables/pipelineRun/PipelineRunTable.tsx index 57bf1e6f7f..faf81911e4 100644 --- a/frontend/src/concepts/pipelines/content/tables/pipelineRun/PipelineRunTable.tsx +++ b/frontend/src/concepts/pipelines/content/tables/pipelineRun/PipelineRunTable.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { Button, Tooltip } from '@patternfly/react-core'; -import { TableVariant } from '@patternfly/react-table'; +import { Button, Skeleton, Tooltip } from '@patternfly/react-core'; +import { TableVariant, Td } from '@patternfly/react-table'; +import { ColumnsIcon } from '@patternfly/react-icons'; + import { TableBase, getTableColumnSort, useCheckboxTable } from '~/components/table'; -import { PipelineRunKFv2 } from '~/concepts/pipelines/kfTypes'; +import { ArtifactType, PipelineRunKFv2 } from '~/concepts/pipelines/kfTypes'; import { pipelineRunColumns } from '~/concepts/pipelines/content/tables/columns'; import PipelineRunTableRow from '~/concepts/pipelines/content/tables/pipelineRun/PipelineRunTableRow'; import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; @@ -21,6 +23,13 @@ import { useSetVersionFilter } from '~/concepts/pipelines/content/tables/useSetV import { createRunRoute, experimentsCompareRunsRoute } from '~/routes'; import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; import { useContextExperimentArchived } from '~/pages/pipelines/global/experiments/ExperimentRunsContext'; +import { + ScalarMetrics, + getScalarMetrics, +} from '~/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/utils'; +import { useGetArtifactsByRuns } from '~/concepts/pipelines/apiHooks/mlmd/useGetArtifactsByRuns'; +import { CustomMetricsColumnsModal } from './CustomMetricsColumnsModal'; +import { getMetricsColumnsLocalStorageKey } from './utils'; type PipelineRunTableProps = { runs: PipelineRunKFv2[]; @@ -38,13 +47,23 @@ type PipelineRunTableProps = { runType: PipelineRunType.ACTIVE | PipelineRunType.ARCHIVED; }; -const PipelineRunTable: React.FC = ({ +interface PipelineRunTableInternalProps extends Omit { + runs: (PipelineRunKFv2 & { metrics: ScalarMetrics[] })[]; + artifactsLoaded: boolean; + artifactsError: Error | undefined; + metricsNames: Set; +} + +const PipelineRunTableInternal: React.FC = ({ runs, loading, totalSize, page, pageSize, runType, + metricsNames, + artifactsLoaded, + artifactsError, setPage, setPageSize, setFilter, @@ -65,6 +84,7 @@ const PipelineRunTable: React.FC = ({ const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false); const [isArchiveModalOpen, setIsArchiveModalOpen] = React.useState(false); const [isRestoreModalOpen, setIsRestoreModalOpen] = React.useState(false); + const [isCustomColModalOpen, setIsCustomColModalOpen] = React.useState(false); const selectedRuns = selectedIds.reduce((acc: PipelineRunKFv2[], selectedId) => { const selectedRun = runs.find((run) => run.run_id === selectedId); @@ -74,6 +94,23 @@ const PipelineRunTable: React.FC = ({ return acc; }, []); + const isExperimentsEnabled = isExperimentsAvailable && experimentId; + + const metricsColumnsLocalStorageKey = localStorage.getItem( + getMetricsColumnsLocalStorageKey(experimentId ?? ''), + ); + const metricsColumns = metricsColumnsLocalStorageKey + ? JSON.parse(metricsColumnsLocalStorageKey) + : []; + const runColumnsWithMetrics = [ + ...pipelineRunColumns.filter((column) => column.field !== 'experiment').slice(0, -1), + ...metricsColumns.map((metricName: string) => ({ + label: metricName, + field: metricName, + sortable: false, + })), + pipelineRunColumns[pipelineRunColumns.length - 1], + ]; const restoreButtonTooltipRef = React.useRef(null); const isExperimentArchived = useContextExperimentArchived(); @@ -125,7 +162,7 @@ const PipelineRunTable: React.FC = ({ ]); const compareRunsAction = - isExperimentsAvailable && experimentId && !isExperimentArchived ? ( + isExperimentsEnabled && !isExperimentArchived ? (