diff --git a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx new file mode 100644 index 0000000000..cfc55ee54d --- /dev/null +++ b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Root as DataViewRoot } from '@nemo/common/src/components/DataView/internal'; +import { StudioDataView } from '@nemo/common/src/components/DataView/StudioDataView'; +import { ErrorMessage } from '@nemo/common/src/components/ErrorMessage'; +import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; +import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState'; +import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; +import { getSortParamWithWhitelist } from '@nemo/common/src/utils/query'; +import { useGetExperimentGroup, useListExperiments } from '@nemo/sdk/generated/platform/api'; +import type { ExperimentResponse, ListExperimentsSort } from '@nemo/sdk/generated/platform/schema'; +import { Text, Tooltip } from '@nvidia/foundations-react-core'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { tooltipClassName } from '@studio/styles/common'; +import { keepPreviousData } from '@tanstack/react-query'; +import { ComponentProps, FC, useMemo } from 'react'; + +export type ExperimentRow = ExperimentResponse & { id: string }; + +const SORTABLE_FIELDS = ['name', 'created_at'] as const; +const DEFAULT_SORT = '-created_at'; + +interface ExperimentGroupDataViewProps { + experimentGroupName: string; +} + +/** Formats an experiment's aggregate scores into a single average-percent string. */ +const formatScores = (aggregateScores: ExperimentResponse['aggregate_scores']): string => { + const means = Object.values(aggregateScores ?? {}) + .map((score) => score?.mean) + .filter((mean): mean is number => mean !== undefined && mean !== null); + if (means.length === 0) return '-'; + const avg = means.reduce((a, b) => a + b, 0) / means.length; + return `${(avg * 100).toFixed(1)}%`; +}; + +/** Lists the experiments that belong to a single experiment group. */ +export const ExperimentGroupDataView: FC = ({ + experimentGroupName, +}) => { + const workspace = useWorkspaceFromPath(); + const { + data: group, + isLoading: isGroupLoading, + error: groupError, + } = useGetExperimentGroup(workspace, experimentGroupName); + const experimentGroupId = group?.id ?? ''; + + const dataViewState = useStudioDataViewState({ + defaultSort: { id: 'created_at', desc: true }, + }); + + const page = dataViewState.pagination.state.pageIndex + 1; + const pageSize = dataViewState.pagination.state.pageSize; + const sortParam = getSortParamWithWhitelist( + dataViewState.sorting.state, + SORTABLE_FIELDS, + DEFAULT_SORT + ); + + const { + data: experimentsResponse, + isLoading, + error, + } = useListExperiments( + workspace, + { + page, + page_size: pageSize, + sort: sortParam as ListExperimentsSort, + filter: { experiment_group_id: experimentGroupId }, + }, + { query: { placeholderData: keepPreviousData, enabled: !!experimentGroupId } } + ); + + const experimentsData = experimentsResponse?.data; + const totalCount = experimentsResponse?.pagination?.total_results ?? experimentsData?.length ?? 0; + + const tableData = useMemo( + () => + (experimentsData ?? []).map((experiment) => ({ + ...experiment, + id: experiment.id ?? experiment.name ?? '', + })), + [experimentsData] + ); + + if (groupError) { + return ; + } + + const makeColumns: ComponentProps>['makeColumns'] = ({ + accessor, + }) => [ + accessor('name', { + header: 'Name', + enableSorting: true, + meta: { title: false }, + size: 300, + cell: ({ row }) => { + const { name, summary } = row.original; + if (!summary) return {name}; + return ( + + {name} + + ); + }, + }), + accessor('agent_name', { + header: 'Agent Name', + enableSorting: false, + cell: ({ row }) => {row.original.agent_name || '-'}, + }), + accessor('agent_version', { + header: 'Agent Version', + enableSorting: false, + cell: ({ row }) => {row.original.agent_version || '-'}, + }), + accessor('dataset_name', { + header: 'Dataset Name', + enableSorting: false, + cell: ({ row }) => {row.original.dataset_name || '-'}, + }), + accessor('dataset_version', { + header: 'Dataset Version', + enableSorting: false, + cell: ({ row }) => {row.original.dataset_version || '-'}, + }), + accessor((original) => original.model_names?.join(', '), { + id: 'model_names', + header: 'Models names', + enableSorting: false, + cell: ({ row }) => {row.original.model_names?.join(', ') || '-'}, + }), + accessor((original) => formatScores(original.aggregate_scores), { + id: 'aggregate_scores', + header: 'Aggregate Scores', + enableSorting: false, + }), + accessor((original) => original.cost_usd?.mean, { + id: 'cost_usd', + header: 'Avg Cost', + enableSorting: false, + cell: ({ row }) => { + const mean = row.original.cost_usd?.mean; + return {mean != null ? `$${mean.toFixed(3)}` : '-'}; + }, + }), + accessor((original) => original.latency_ms?.mean, { + id: 'latency_ms', + header: 'Avg Latency', + enableSorting: false, + cell: ({ row }) => { + const mean = row.original.latency_ms?.mean; + return {mean != null ? `${Math.round(mean)} ms` : '-'}; + }, + }), + accessor((original) => original.run_count, { + id: 'run_count', + header: 'Run Count', + enableSorting: false, + cell: ({ row }) => {String(row.original.run_count ?? 0)}, + }), + accessor('created_at', { + header: 'Created', + size: 200, + enableSorting: true, + cell: ({ row }) => + row.original.created_at ? ( + + ) : ( + - + ), + }), + ]; + + if (error) { + return ; + } + + return ( + ( + + ), + }, + }} + /> + ); +}; diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index 149ec78a9c..fc9833d555 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -38,7 +38,7 @@ export const ROUTE_PARAMS = { jobName: 'jobName', /** Benchmark entity name segment under evaluation/benchmarks/:name */ benchmarkName: 'benchmarkName', - experimentGroupId: 'experimentGroupId', + experimentGroupName: 'experimentGroupName', } as const; // Just an alias to make the routes more readable @@ -71,7 +71,7 @@ export const ROUTES = { evaluationResultDetails: `/workspaces/:${P.workspace}/evaluation/results/:${P.evaluationJobId}`, /** Empty landing page for the EXPERIMENT feature (gated by VITE_FF_EXPERIMENT). */ experiment: `/workspaces/:${P.workspace}/experiment`, - experimentGroupDetail: `/workspaces/:${P.workspace}/experiment/:${P.experimentGroupId}`, + experimentGroupDetail: `/workspaces/:${P.workspace}/experiment/:${P.experimentGroupName}`, customizationJobList: `/workspaces/:${P.workspace}/customizations`, customizationJobDetails: `/workspaces/:${P.workspace}/customizations/:${P.customizationJobName}`, newCustomizationJob: `/workspaces/:${P.workspace}/customizations/fine-tuned/new`, diff --git a/web/packages/studio/src/routes/ExperimentGroupDetailRoute/ExperimentGroupMetrics.tsx b/web/packages/studio/src/routes/ExperimentGroupDetailRoute/ExperimentGroupMetrics.tsx new file mode 100644 index 0000000000..ba3dcc2b7c --- /dev/null +++ b/web/packages/studio/src/routes/ExperimentGroupDetailRoute/ExperimentGroupMetrics.tsx @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KVPair } from '@nemo/common/src/components/KVPair'; +import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; +import { useGetExperimentGroup } from '@nemo/sdk/generated/platform/api'; +import { Divider } from '@nvidia/foundations-react-core'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { useAllGroupExperiments } from '@studio/routes/ExperimentGroupDetailRoute/useAllGroupExperiments'; +import { type FC, useMemo } from 'react'; + +interface ExperimentGroupMetricsProps { + experimentGroupName: string; +} + +export const ExperimentGroupMetrics: FC = ({ + experimentGroupName, +}) => { + const workspace = useWorkspaceFromPath(); + const { data: group } = useGetExperimentGroup(workspace, experimentGroupName); + const { experiments, isFetching } = useAllGroupExperiments(workspace, group?.id ?? ''); + + const agentNames = useMemo( + () => [...new Set(experiments.map((e) => e.agent_name).filter(Boolean))].join(', '), + [experiments] + ); + + const datasetNames = useMemo( + () => [...new Set(experiments.map((e) => e.dataset_name).filter(Boolean))].join(', '), + [experiments] + ); + + return ( +
+ + + + + : undefined} + orientation="vertical" + /> + + : undefined} + orientation="vertical" + /> +
+ ); +}; diff --git a/web/packages/studio/src/routes/ExperimentGroupDetailRoute/index.tsx b/web/packages/studio/src/routes/ExperimentGroupDetailRoute/index.tsx index 8bc1e4ed08..fd8fd11445 100644 --- a/web/packages/studio/src/routes/ExperimentGroupDetailRoute/index.tsx +++ b/web/packages/studio/src/routes/ExperimentGroupDetailRoute/index.tsx @@ -3,21 +3,32 @@ import { PageHeader, Stack } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; +import { ExperimentGroupDataView } from '@studio/components/dataViews/ExperimentGroupDataView'; +import { ROUTE_PARAMS } from '@studio/constants/routes'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; +import { ExperimentGroupMetrics } from '@studio/routes/ExperimentGroupDetailRoute/ExperimentGroupMetrics'; +import { getExperimentRoute } from '@studio/routes/utils'; +import { useRequiredPathParams } from '@studio/util/hooks/useRequiredPathParams'; import { type FC } from 'react'; -/** Placeholder for the experiment group detail page. */ export const ExperimentGroupDetailRoute: FC = () => { - useBreadcrumbs({ items: [{ slotLabel: 'Experiments' }, { slotLabel: 'Detail' }] }); + const workspace = useWorkspaceFromPath(); + const { experimentGroupName } = useRequiredPathParams([ROUTE_PARAMS.experimentGroupName]); + + useBreadcrumbs({ + items: [ + { href: getExperimentRoute(workspace), slotLabel: 'Experiments' }, + { slotLabel: experimentGroupName }, + ], + }); return ( - - - + + + + + ); diff --git a/web/packages/studio/src/routes/ExperimentGroupDetailRoute/useAllGroupExperiments.ts b/web/packages/studio/src/routes/ExperimentGroupDetailRoute/useAllGroupExperiments.ts new file mode 100644 index 0000000000..a7f69b1f8f --- /dev/null +++ b/web/packages/studio/src/routes/ExperimentGroupDetailRoute/useAllGroupExperiments.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { listExperiments } from '@nemo/sdk/generated/platform/api'; +import type { + ExperimentResponse, + ExperimentResponsesPage, +} from '@nemo/sdk/generated/platform/schema'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; + +const PAGE_SIZE = 100; + +interface UseAllGroupExperimentsResult { + experiments: ExperimentResponse[]; + isFetching: boolean; +} + +/** Fetches every experiment belonging to a group, auto-paging until exhausted. */ +export const useAllGroupExperiments = ( + workspace: string, + experimentGroupId: string +): UseAllGroupExperimentsResult => { + const { data, isFetching, isError, hasNextPage, fetchNextPage } = useInfiniteQuery({ + queryKey: ['experiment-group-experiments', workspace, experimentGroupId], + queryFn: ({ pageParam, signal }) => + listExperiments( + workspace, + { + page: pageParam, + page_size: PAGE_SIZE, + filter: { experiment_group_id: experimentGroupId }, + }, + signal + ), + initialPageParam: 1, + getNextPageParam: (lastPage: ExperimentResponsesPage) => { + const { page, total_pages } = lastPage.pagination ?? {}; + return page !== undefined && total_pages !== undefined && page < total_pages + ? page + 1 + : undefined; + }, + enabled: !!experimentGroupId, + }); + + useEffect(() => { + if (!isFetching && !isError && hasNextPage) { + void fetchNextPage(); + } + }, [isFetching, isError, hasNextPage, fetchNextPage]); + + const experiments = useMemo(() => data?.pages.flatMap((page) => page.data) ?? [], [data?.pages]); + + return { experiments, isFetching }; +}; diff --git a/web/packages/studio/src/routes/ExperimentRoute/ExperimentGroupCard.tsx b/web/packages/studio/src/routes/ExperimentRoute/ExperimentGroupCard.tsx index 74dcc15e77..ed0806a5d6 100644 --- a/web/packages/studio/src/routes/ExperimentRoute/ExperimentGroupCard.tsx +++ b/web/packages/studio/src/routes/ExperimentRoute/ExperimentGroupCard.tsx @@ -44,15 +44,15 @@ export const ExperimentGroupCard: FC = ({ group, works navigate(getExperimentGroupDetailRoute(workspace, group.id))} + onClick={() => navigate(getExperimentGroupDetailRoute(workspace, group.name))} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') { - navigate(getExperimentGroupDetailRoute(workspace, group.id)); + navigate(getExperimentGroupDetailRoute(workspace, group.name)); } else if (e.key === ' ') { e.preventDefault(); - navigate(getExperimentGroupDetailRoute(workspace, group.id)); + navigate(getExperimentGroupDetailRoute(workspace, group.name)); } }} > diff --git a/web/packages/studio/src/routes/utils.ts b/web/packages/studio/src/routes/utils.ts index b20196e3af..e75c9695ba 100644 --- a/web/packages/studio/src/routes/utils.ts +++ b/web/packages/studio/src/routes/utils.ts @@ -314,8 +314,11 @@ export const getExperimentRoute = (workspace: string) => { return generatePath(ROUTES.workspace.experiment, { workspace }); }; -export const getExperimentGroupDetailRoute = (workspace: string, experimentGroupId: string) => { - return generatePath(ROUTES.workspace.experimentGroupDetail, { workspace, experimentGroupId }); +export const getExperimentGroupDetailRoute = (workspace: string, experimentGroupName: string) => { + return generatePath(ROUTES.workspace.experimentGroupDetail, { + workspace, + experimentGroupName: encodeURIComponent(experimentGroupName), + }); }; export const getPromptTuningFormRoute = (workspace: string, options?: { model?: string }) => { diff --git a/web/packages/studio/src/tests/title-change.spec.tsx b/web/packages/studio/src/tests/title-change.spec.tsx index 24644cb7ff..5d6d0755be 100644 --- a/web/packages/studio/src/tests/title-change.spec.tsx +++ b/web/packages/studio/src/tests/title-change.spec.tsx @@ -34,7 +34,7 @@ const pathParams = { [RP.agentEvalJobName]: 'test-agent-eval-job', [RP.jobName]: 'test-job', [RP.benchmarkName]: 'test-benchmark', - [RP.experimentGroupId]: 'test-experiment-group', + [RP.experimentGroupName]: 'test-experiment-group', }; describe('AccessibleTitleE2E', () => {