Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ExperimentGroupDataViewProps> = ({
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<ExperimentRow[]>(
() =>
(experimentsData ?? []).map((experiment) => ({
...experiment,
id: experiment.id ?? experiment.name ?? '',
})),
[experimentsData]
);

if (groupError) {
return <ErrorMessage message="Failed to load experiment group." />;
}

const makeColumns: ComponentProps<typeof DataViewRoot<ExperimentRow>>['makeColumns'] = ({
accessor,
}) => [
accessor('name', {
header: 'Name',
enableSorting: true,
meta: { title: false },
size: 300,
cell: ({ row }) => {
const { name, summary } = row.original;
if (!summary) return <Text>{name}</Text>;
return (
<Tooltip slotContent={summary} className={tooltipClassName} side="bottom">
<Text className="cursor-default">{name}</Text>
</Tooltip>
);
},
}),
accessor('agent_name', {
header: 'Agent Name',
enableSorting: false,
cell: ({ row }) => <Text>{row.original.agent_name || '-'}</Text>,
}),
accessor('agent_version', {
header: 'Agent Version',
enableSorting: false,
cell: ({ row }) => <Text>{row.original.agent_version || '-'}</Text>,
}),
accessor('dataset_name', {
header: 'Dataset Name',
enableSorting: false,
cell: ({ row }) => <Text>{row.original.dataset_name || '-'}</Text>,
}),
accessor('dataset_version', {
header: 'Dataset Version',
enableSorting: false,
cell: ({ row }) => <Text>{row.original.dataset_version || '-'}</Text>,
}),
accessor((original) => original.model_names?.join(', '), {
id: 'model_names',
header: 'Models names',
enableSorting: false,
cell: ({ row }) => <Text>{row.original.model_names?.join(', ') || '-'}</Text>,
}),
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 <Text>{mean != null ? `$${mean.toFixed(3)}` : '-'}</Text>;
},
}),
accessor((original) => original.latency_ms?.mean, {
id: 'latency_ms',
header: 'Avg Latency',
enableSorting: false,
cell: ({ row }) => {
const mean = row.original.latency_ms?.mean;
return <Text>{mean != null ? `${Math.round(mean)} ms` : '-'}</Text>;
},
}),
accessor((original) => original.run_count, {
id: 'run_count',
header: 'Run Count',
enableSorting: false,
cell: ({ row }) => <Text>{String(row.original.run_count ?? 0)}</Text>,
}),
accessor('created_at', {
header: 'Created',
size: 200,
enableSorting: true,
cell: ({ row }) =>
row.original.created_at ? (
<RelativeTime datetime={row.original.created_at} />
) : (
<Text>-</Text>
),
}),
];

if (error) {
return <ErrorMessage message="Failed to load experiments." />;
}

return (
<StudioDataView
dataViewState={dataViewState}
makeColumns={makeColumns}
attributes={{
DataViewRoot: {
data: tableData,
totalCount,
requestStatus: isGroupLoading || (isLoading && !experimentsData) ? 'loading' : undefined,
},
DataViewTableContent: {
renderEmptyState: () => (
<TableEmptyState
header="No Experiments"
emptyMessage="This group has no experiments yet."
/>
),
},
}}
/>
);
};
4 changes: 2 additions & 2 deletions web/packages/studio/src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const ROUTE_PARAMS = {
jobName: 'jobName',
/** Benchmark entity name segment under evaluation/benchmarks/:name */
benchmarkName: 'benchmarkName',
experimentGroupId: 'experimentGroupId',
experimentGroupName: 'experimentGroupName',
Comment thread
nakolean marked this conversation as resolved.
} as const;

// Just an alias to make the routes more readable
Expand Down Expand Up @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
nakolean marked this conversation as resolved.
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<ExperimentGroupMetricsProps> = ({
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 (
<div className="flex gap-8">
<KVPair
label="Agent"
value={agentNames || undefined}
loading={isFetching}
orientation="vertical"
/>
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair
label="Dataset"
value={datasetNames || undefined}
loading={isFetching}
orientation="vertical"
/>
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair
label="Created"
value={group?.created_at ? <RelativeTime datetime={group.created_at} /> : undefined}
orientation="vertical"
/>
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair
label="Updated"
value={group?.updated_at ? <RelativeTime datetime={group.updated_at} /> : undefined}
orientation="vertical"
/>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AccessibleTitle title="Experiment Group">
<Stack className="h-full" gap="density-2xl" padding="density-2xl">
<PageHeader
className="p-0"
slotHeading="Experiment Group"
slotDescription="Experiment group detail page."
/>
<AccessibleTitle title={experimentGroupName}>
<Stack className="h-full overflow-auto" gap="density-2xl" padding="density-2xl">
<PageHeader className="p-0" slotHeading={experimentGroupName} />
<ExperimentGroupMetrics experimentGroupName={experimentGroupName} />
<ExperimentGroupDataView experimentGroupName={experimentGroupName} />
</Stack>
</AccessibleTitle>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 };
};
Loading
Loading