diff --git a/frontend/src/__tests__/cypress/cypress/e2e/pipelines/CompareRuns.cy.ts b/frontend/src/__tests__/cypress/cypress/e2e/pipelines/CompareRuns.cy.ts index ae77eef8aa..968e88f614 100644 --- a/frontend/src/__tests__/cypress/cypress/e2e/pipelines/CompareRuns.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/e2e/pipelines/CompareRuns.cy.ts @@ -36,6 +36,16 @@ const mockRun = buildMockRunKF({ experiment_id: mockExperiment.experiment_id, }); +const mockRun2 = buildMockRunKF({ + display_name: 'Run 2', + run_id: 'run-2', + pipeline_version_reference: { + pipeline_id: initialMockPipeline.pipeline_id, + pipeline_version_id: initialMockPipelineVersion.pipeline_version_id, + }, + experiment_id: mockExperiment.experiment_id, +}); + describe('Compare runs', () => { beforeEach(() => { initIntercepts(); @@ -47,9 +57,15 @@ describe('Compare runs', () => { }); it('valid number of runs', () => { - compareRunsGlobal.visit(projectName, mockExperiment.experiment_id, [mockRun.run_id]); + compareRunsGlobal.visit(projectName, mockExperiment.experiment_id, [ + mockRun.run_id, + mockRun2.run_id, + ]); cy.wait('@validRun'); compareRunsGlobal.findInvalidRunsError().should('not.exist'); + + compareRunsGlobal.findRunListRowByName('Run 1').should('exist'); + compareRunsGlobal.findRunListRowByName('Run 2').should('exist'); }); it('valid number of runs but it is invalid', () => { cy.intercept( @@ -169,4 +185,10 @@ const initIntercepts = () => { }, mockRun, ).as('validRun'); + cy.intercept( + { + pathname: `/api/proxy/apis/v2beta1/runs/${mockRun2.run_id}`, + }, + mockRun2, + ); }; diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/compareRuns.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/compareRuns.ts index 735ce616c5..ea54cb5add 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/compareRuns.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/compareRuns.ts @@ -8,6 +8,14 @@ class CompareRunsGlobal { findInvalidRunsError() { return cy.findByTestId('compare-runs-invalid-number-runs'); } + + findRunList() { + return cy.findByTestId('compare-runs-table'); + } + + findRunListRowByName(name: string) { + return this.findRunList().findByText(name); + } } export const compareRunsGlobal = new CompareRunsGlobal(); diff --git a/frontend/src/concepts/pipelines/content/compareRuns/CompareRunTableRow.tsx b/frontend/src/concepts/pipelines/content/compareRuns/CompareRunTableRow.tsx new file mode 100644 index 0000000000..3e5274dcb6 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/compareRuns/CompareRunTableRow.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { TableText, Td, Tr } from '@patternfly/react-table'; +import { Link } from 'react-router-dom'; +import { Skeleton } from '@patternfly/react-core'; +import { PipelineRunKFv2 } from '~/concepts/pipelines/kfTypes'; +import { CheckboxTd, TableRowTitleDescription } from '~/components/table'; +import { + RunCreated, + RunDuration, + RunStatus, +} from '~/concepts/pipelines/content/tables/renderUtils'; +import useExperimentById from '~/concepts/pipelines/apiHooks/useExperimentById'; +import usePipelineRunVersionInfo from '~/concepts/pipelines/content/tables/usePipelineRunVersionInfo'; +import { PipelineVersionLink } from '~/concepts/pipelines/content/PipelineVersionLink'; +import { runDetailsRoute } from '~/routes'; +import { usePipelinesAPI } from '~/concepts/pipelines/context'; + +type CompareRunTableRowProps = { + isChecked: boolean; + onToggleCheck: () => void; + run: PipelineRunKFv2; +}; + +const CompareRunTableRow: React.FC = ({ + isChecked, + onToggleCheck, + run, +}) => { + const { namespace } = usePipelinesAPI(); + const [experiment, isExperimentLoaded] = useExperimentById(run.experiment_id); + const { version, loaded: isVersionLoaded, error: versionError } = usePipelineRunVersionInfo(run); + + return ( + + + + + + {run.display_name} + + + } + description={run.description} + /> + + + {isExperimentLoaded ? experiment?.display_name || 'Default' : } + + + + + + + + + + + + + + + ); +}; + +export default CompareRunTableRow; diff --git a/frontend/src/concepts/pipelines/content/compareRuns/CompareRunTableToolbar.tsx b/frontend/src/concepts/pipelines/content/compareRuns/CompareRunTableToolbar.tsx new file mode 100644 index 0000000000..e6869c129a --- /dev/null +++ b/frontend/src/concepts/pipelines/content/compareRuns/CompareRunTableToolbar.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { Button, TextInput, ToolbarItem } from '@patternfly/react-core'; +import { useNavigate, useParams } from 'react-router'; +import PipelineFilterBar from '~/concepts/pipelines/content/tables/PipelineFilterBar'; +import SimpleDropdownSelect from '~/components/SimpleDropdownSelect'; +import { FilterOptions } from '~/concepts/pipelines/content/tables/usePipelineFilter'; +import ExperimentSearchInput from '~/concepts/pipelines/content/tables/ExperimentSearchInput'; +import { RuntimeStateKF, runtimeStateLabels } from '~/concepts/pipelines/kfTypes'; +import DashboardDatePicker from '~/components/DashboardDatePicker'; +import PipelineVersionSelect from '~/concepts/pipelines/content/pipelineSelector/CustomPipelineVersionSelect'; +import { PipelineRunVersionsContext } from '~/pages/pipelines/global/runs/PipelineRunVersionsContext'; +import { experimentsBaseRoute, experimentsManageCompareRunsRoute } from '~/routes'; +import { useCompareRuns } from '~/concepts/pipelines/content/compareRuns/CompareRunsContext'; + +export type FilterProps = Pick< + React.ComponentProps, + 'filterData' | 'onFilterUpdate' | 'onClearFilters' +>; + +const CompareRunTableToolbar: React.FC = ({ ...toolbarProps }) => { + const { versions } = React.useContext(PipelineRunVersionsContext); + const { runs } = useCompareRuns(); + const navigate = useNavigate(); + const { namespace, experimentId } = useParams(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [RuntimeStateKF.RUNTIME_STATE_UNSPECIFIED]: unspecifiedState, ...statusRuntimeStates } = + runtimeStateLabels; + + const options = React.useMemo( + () => ({ + [FilterOptions.NAME]: 'Run', + [FilterOptions.EXPERIMENT]: 'Experiment', + [FilterOptions.PIPELINE_VERSION]: 'Pipeline version', + [FilterOptions.CREATED_AT]: 'Started', + [FilterOptions.STATUS]: 'Status', + }), + [], + ); + + if (!namespace || !experimentId) { + navigate(experimentsBaseRoute(namespace)); + return null; + } + + return ( + ( + onChange(value)} + /> + ), + [FilterOptions.EXPERIMENT]: ({ onChange, value, label }) => ( + onChange(data?.value, data?.label)} + selected={value && label ? { value, label } : undefined} + /> + ), + [FilterOptions.PIPELINE_VERSION]: ({ onChange, label }) => ( + onChange(version.pipeline_version_id, version.display_name)} + /> + ), + [FilterOptions.CREATED_AT]: ({ onChange, ...props }) => ( + { + if (date || !value) { + onChange(value); + } + }} + /> + ), + [FilterOptions.STATUS]: ({ value, onChange, ...props }) => ( + ({ + key: v, + label: v, + }))} + onChange={(v) => onChange(v)} + data-testid="runtime-status-dropdown" + /> + ), + }} + > + + + + + ); +}; + +export default CompareRunTableToolbar; diff --git a/frontend/src/concepts/pipelines/content/compareRuns/CompareRunsRunList.tsx b/frontend/src/concepts/pipelines/content/compareRuns/CompareRunsRunList.tsx new file mode 100644 index 0000000000..5205924870 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/compareRuns/CompareRunsRunList.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { ExpandableSection } from '@patternfly/react-core'; +import { TableVariant } from '@patternfly/react-table'; +import { PipelinesFilter } from '~/concepts/pipelines/types'; +import { Table } from '~/components/table'; +import CompareRunTableRow from '~/concepts/pipelines/content/compareRuns/CompareRunTableRow'; +import { compareRunColumns } from '~/concepts/pipelines/content/compareRuns/columns'; +import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; +import usePipelineFilter, { + FilterOptions, + getDataValue, +} from '~/concepts/pipelines/content/tables/usePipelineFilter'; +import CompareRunTableToolbar from '~/concepts/pipelines/content/compareRuns/CompareRunTableToolbar'; +import { useCompareRuns } from '~/concepts/pipelines/content/compareRuns/CompareRunsContext'; +import useCompareRunsCheckboxTable from '~/concepts/pipelines/content/compareRuns/useCompareRunsCheckboxTable'; + +const CompareRunsRunList: React.FC = () => { + const { runs, loaded } = useCompareRuns(); + const [isExpanded, setExpanded] = React.useState(true); + const [, setFilter] = React.useState(); + const filterToolbarProps = usePipelineFilter(setFilter); + const { + tableProps: checkboxTableProps, + toggleSelection, + isSelected, + } = useCompareRunsCheckboxTable(); + + const filteredRuns = React.useMemo(() => { + const { filterData: data } = filterToolbarProps; + const runName = getDataValue(data[FilterOptions.NAME])?.toLowerCase(); + const startedTime = getDataValue(data[FilterOptions.CREATED_AT]); + const startedDate = startedTime && new Date(startedTime); + const state = getDataValue(data[FilterOptions.STATUS])?.toLowerCase(); + const experimentId = getDataValue(data[FilterOptions.EXPERIMENT]); + const pipelineVersionId = getDataValue(data[FilterOptions.PIPELINE_VERSION]); + return runs.filter((run) => { + const nameMatch = !runName || run.display_name.toLowerCase().includes(runName); + const dateTimeMatch = !startedDate || new Date(run.created_at) >= startedDate; + const stateMatch = !state || run.state.toLowerCase() === state; + const experimentIdMatch = !experimentId || run.experiment_id === experimentId; + const pipelineVersionIdMatch = + !pipelineVersionId || + run.pipeline_version_reference.pipeline_version_id === pipelineVersionId; + + return ( + nameMatch && dateTimeMatch && stateMatch && experimentIdMatch && pipelineVersionIdMatch + ); + }); + }, [runs, filterToolbarProps]); + + return ( + setExpanded(expanded)} + > + + } + toolbarContent={ + + } + rowRenderer={(run) => ( + toggleSelection(run)} + run={run} + /> + )} + variant={TableVariant.compact} + data-testid="compare-runs-table" + id="compare-runs-table" + /> + + ); +}; + +export default CompareRunsRunList; diff --git a/frontend/src/concepts/pipelines/content/compareRuns/columns.ts b/frontend/src/concepts/pipelines/content/compareRuns/columns.ts new file mode 100644 index 0000000000..ca7019a244 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/compareRuns/columns.ts @@ -0,0 +1,41 @@ +import { checkboxTableColumn, SortableData } from '~/components/table'; +import { PipelineRunKFv2 } from '~/concepts/pipelines/kfTypes'; + +export const compareRunColumns: SortableData[] = [ + checkboxTableColumn(), + { + label: 'Run', + field: 'name', + sortable: (a, b) => a.display_name.localeCompare(b.display_name), + }, + { + label: 'Experiment', + field: 'experiment', + sortable: false, + width: 15, + }, + { + label: 'Pipeline version', + field: 'pipeline_version', + sortable: false, + width: 15, + }, + { + label: 'Started', + field: 'created_at', + sortable: true, + width: 15, + }, + { + label: 'Duration', + field: 'duration', + sortable: false, + width: 15, + }, + { + label: 'Status', + field: 'status', + sortable: (a, b) => a.state.localeCompare(b.state), + width: 10, + }, +]; diff --git a/frontend/src/concepts/pipelines/content/compareRuns/useCompareRunsCheckboxTable.ts b/frontend/src/concepts/pipelines/content/compareRuns/useCompareRunsCheckboxTable.ts new file mode 100644 index 0000000000..c810ef8532 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/compareRuns/useCompareRunsCheckboxTable.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { useCheckboxTableBase, UseCheckboxTableBaseProps } from '~/components/table'; +import { useCompareRuns } from '~/concepts/pipelines/content/compareRuns/CompareRunsContext'; +import { PipelineRunKFv2 } from '~/concepts/pipelines/kfTypes'; + +const useCompareRunsCheckboxTable = (): UseCheckboxTableBaseProps => { + const { selectedRuns, setSelectedRuns, runs } = useCompareRuns(); + return useCheckboxTableBase( + runs, + selectedRuns, + setSelectedRuns, + React.useCallback((d) => d.run_id, []), + ); +}; + +export default useCompareRunsCheckboxTable; diff --git a/frontend/src/pages/ApplicationsPage.tsx b/frontend/src/pages/ApplicationsPage.tsx index b5a359ee1d..c581831840 100644 --- a/frontend/src/pages/ApplicationsPage.tsx +++ b/frontend/src/pages/ApplicationsPage.tsx @@ -34,6 +34,7 @@ type ApplicationsPageProps = { removeChildrenTopPadding?: boolean; subtext?: React.ReactNode; loadingContent?: React.ReactNode; + noHeader?: boolean; }; const ApplicationsPage: React.FC = ({ @@ -53,6 +54,7 @@ const ApplicationsPage: React.FC = ({ removeChildrenTopPadding, subtext, loadingContent, + noHeader, }) => { const renderHeader = () => ( @@ -142,7 +144,7 @@ const ApplicationsPage: React.FC = ({ return ( <> {breadcrumb && {breadcrumb}} - {renderHeader()} + {!noHeader && renderHeader()} {renderContents()} ); diff --git a/frontend/src/pages/pipelines/global/experiments/compareRuns/CompareRunsPage.tsx b/frontend/src/pages/pipelines/global/experiments/compareRuns/CompareRunsPage.tsx index 792e715bd6..5014896df5 100644 --- a/frontend/src/pages/pipelines/global/experiments/compareRuns/CompareRunsPage.tsx +++ b/frontend/src/pages/pipelines/global/experiments/compareRuns/CompareRunsPage.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; +import { Breadcrumb, BreadcrumbItem, Stack, StackItem } from '@patternfly/react-core'; import ApplicationsPage from '~/pages/ApplicationsPage'; import { PathProps } from '~/concepts/pipelines/content/types'; import { useCompareRuns } from '~/concepts/pipelines/content/compareRuns/CompareRunsContext'; import { CompareRunsInvalidRunCount } from '~/concepts/pipelines/content/compareRuns/CompareRunInvalidRunCount'; +import CompareRunsRunList from '~/concepts/pipelines/content/compareRuns/CompareRunsRunList'; const CompareRunsPage: React.FC = ({ breadcrumbPath }) => { const { runs, loaded } = useCompareRuns(); @@ -22,10 +23,16 @@ const CompareRunsPage: React.FC = ({ breadcrumbPath }) => { Compare runs } + provideChildrenPadding loaded={loaded} empty={false} + noHeader > - {/* TODO: CompareRuns page */} + + + + + ); }; diff --git a/frontend/src/pages/pipelines/global/experiments/compareRuns/GlobalComparePipelineRunsLoader.tsx b/frontend/src/pages/pipelines/global/experiments/compareRuns/GlobalComparePipelineRunsLoader.tsx index de566eacfc..1dd58f36ac 100644 --- a/frontend/src/pages/pipelines/global/experiments/compareRuns/GlobalComparePipelineRunsLoader.tsx +++ b/frontend/src/pages/pipelines/global/experiments/compareRuns/GlobalComparePipelineRunsLoader.tsx @@ -2,11 +2,14 @@ import * as React from 'react'; import { Outlet } from 'react-router-dom'; import EnsureAPIAvailability from '~/concepts/pipelines/EnsureAPIAvailability'; import { CompareRunsContextProvider } from '~/concepts/pipelines/content/compareRuns/CompareRunsContext'; +import PipelineRunVersionsContextProvider from '~/pages/pipelines/global/runs/PipelineRunVersionsContext'; const GlobalComparePipelineRunsLoader: React.FC = () => ( - + + + );