From c6a3938b30c03db7d7c37410ea9ab11feee4c8b5 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 17 Jun 2026 17:53:29 +0200 Subject: [PATCH 01/10] feat(experiments): add experiment detail page with exposures panel Introduce the experiment detail page accessible from the experiments list. Includes the detail header with inline-editable hypothesis, experiment configuration section, and the full exposures panel with polling, refresh, and cumulative enrolment chart. --- frontend/common/services/useExperiment.ts | 52 ++- frontend/common/types/requests.ts | 8 + frontend/common/types/responses.ts | 84 ++++- .../base/SelectableCard/SelectableCard.scss | 2 +- .../base/grid/ContentCard/ContentCard.scss | 11 +- .../base/grid/ContentCard/ContentCard.tsx | 12 +- frontend/web/components/charts/LineChart.tsx | 16 +- .../ExperimentsTable/ExperimentsTable.scss | 9 +- .../ExperimentsTable/ExperimentsTable.tsx | 25 +- .../MetricsTable/MetricsTable.scss | 2 +- .../results/AsOfRefreshControl.tsx | 36 +++ .../results/ExperimentConfiguration.tsx | 88 ++++++ .../results/ExperimentDetailHeader.tsx | 279 +++++++++++++++++ .../results/ExperimentExposuresPanel.tsx | 170 ++++++++++ .../results/VariantShareLegend.tsx | 43 +++ .../results/__tests__/derive.test.ts | 182 +++++++++++ .../__tests__/exposuresViewState.test.ts | 104 ++++++ .../components/experiments/results/derive.ts | 146 +++++++++ .../experiments/results/exposuresViewState.ts | 52 +++ .../experiments/results/results.scss | 296 ++++++++++++++++++ .../components/pages/ExperimentDetailPage.tsx | 74 +++++ .../web/components/pages/ExperimentsPage.tsx | 3 + frontend/web/components/pages/HomePage.tsx | 4 +- frontend/web/routes.js | 8 + 24 files changed, 1663 insertions(+), 43 deletions(-) create mode 100644 frontend/web/components/experiments/results/AsOfRefreshControl.tsx create mode 100644 frontend/web/components/experiments/results/ExperimentConfiguration.tsx create mode 100644 frontend/web/components/experiments/results/ExperimentDetailHeader.tsx create mode 100644 frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx create mode 100644 frontend/web/components/experiments/results/VariantShareLegend.tsx create mode 100644 frontend/web/components/experiments/results/__tests__/derive.test.ts create mode 100644 frontend/web/components/experiments/results/__tests__/exposuresViewState.test.ts create mode 100644 frontend/web/components/experiments/results/derive.ts create mode 100644 frontend/web/components/experiments/results/exposuresViewState.ts create mode 100644 frontend/web/components/experiments/results/results.scss create mode 100644 frontend/web/components/pages/ExperimentDetailPage.tsx diff --git a/frontend/common/services/useExperiment.ts b/frontend/common/services/useExperiment.ts index 8f7549f3908e..0998c26121b4 100644 --- a/frontend/common/services/useExperiment.ts +++ b/frontend/common/services/useExperiment.ts @@ -5,7 +5,7 @@ import Utils from 'common/utils/utils' import transformCorePaging from 'common/transformCorePaging' export const experimentService = service - .enhanceEndpoints({ addTagTypes: ['Experiment'] }) + .enhanceEndpoints({ addTagTypes: ['Experiment', 'ExperimentExposures'] }) .injectEndpoints({ endpoints: (builder) => ({ completeExperiment: builder.mutation< @@ -36,6 +36,26 @@ export const experimentService = service url: `environments/${environmentId}/experiments/${experimentId}/`, }), }), + getExperiment: builder.query({ + providesTags: (res) => [{ id: res?.id, type: 'Experiment' }], + query: ({ environmentId, experimentId }) => ({ + url: `environments/${environmentId}/experiments/${experimentId}/`, + }), + }), + getExperimentExposures: builder.query< + Res['experimentExposures'] | null, + Req['getExperimentExposures'] + >({ + providesTags: (_res, _err, { experimentId }) => [ + { id: experimentId, type: 'ExperimentExposures' }, + ], + query: ({ environmentId, experimentId }) => ({ + url: `environments/${environmentId}/experiments/${experimentId}/exposures/`, + }), + transformResponse: (res: { + exposures: Res['experimentExposures'] | null + }) => res.exposures, + }), getExperiments: builder.query({ providesTags: [{ id: 'LIST', type: 'Experiment' }], query: ({ environmentId, ...rest }) => ({ @@ -55,6 +75,18 @@ export const experimentService = service url: `environments/${environmentId}/experiments/${experimentId}/pause/`, }), }), + refreshExperimentExposures: builder.mutation< + Res['experimentExposures'], + Req['refreshExperimentExposures'] + >({ + invalidatesTags: (_res, _err, { experimentId }) => [ + { id: experimentId, type: 'ExperimentExposures' }, + ], + query: ({ environmentId, experimentId }) => ({ + method: 'POST', + url: `environments/${environmentId}/experiments/${experimentId}/exposures/refresh/`, + }), + }), startExperiment: builder.mutation< Res['experiment'], Req['experimentAction'] @@ -65,6 +97,20 @@ export const experimentService = service url: `environments/${environmentId}/experiments/${experimentId}/start/`, }), }), + updateExperiment: builder.mutation< + Res['experiment'], + Req['updateExperiment'] + >({ + invalidatesTags: (res) => [ + { id: res?.id, type: 'Experiment' }, + { id: 'LIST', type: 'Experiment' }, + ], + query: ({ body, environmentId, experimentId }) => ({ + body, + method: 'PATCH', + url: `environments/${environmentId}/experiments/${experimentId}/`, + }), + }), }), }) @@ -72,7 +118,11 @@ export const { useCompleteExperimentMutation, useCreateExperimentMutation, useDeleteExperimentMutation, + useGetExperimentExposuresQuery, + useGetExperimentQuery, useGetExperimentsQuery, usePauseExperimentMutation, + useRefreshExperimentExposuresMutation, useStartExperimentMutation, + useUpdateExperimentMutation, } = experimentService diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 8617803fbcfb..7a8802c56a47 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -1043,7 +1043,15 @@ export type Req = { } } experimentAction: { environmentId: string; experimentId: number } + updateExperiment: { + environmentId: string + experimentId: number + body: { hypothesis?: string } + } deleteExperiment: { environmentId: string; experimentId: number } + getExperiment: { environmentId: string; experimentId: number } + getExperimentExposures: { environmentId: string; experimentId: number } + refreshExperimentExposures: { environmentId: string; experimentId: number } getMetrics: PagedRequest<{ environmentId: string q?: string diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index cd2815e0a1f8..3331b7fc3b10 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -611,21 +611,6 @@ export type Metric = { updated_at: string } -export type ExpectedDirection = - | 'increase' - | 'decrease' - | 'not_increase' - | 'not_decrease' - -export type ExperimentMetric = { - id: number - metric: number - metric_name: string - aggregation: MetricAggregation - expected_direction: ExpectedDirection - created_at: string -} - export type ExperimentFeature = { id: number name: string @@ -647,6 +632,73 @@ export type Experiment = { ended_at: string | null } +export type ExpectedDirection = + | 'increase' + | 'decrease' + | 'not_increase' + | 'not_decrease' + +// Join object returned on the experiment-detail `metrics` array +// (api/experimentation ExperimentMetricSerializer). +export type ExperimentMetric = { + id: number + metric: number + metric_name: string + aggregation: MetricAggregation + expected_direction: ExpectedDirection + created_at: string +} + +// --- Exposures (live) — mirrors api/experimentation dataclasses --- +export type ExposureGranularity = 'hour' | 'day' + +export type ExposuresTimeseriesPoint = { + bucket: string + new_identities: Record +} + +export type ExposuresTimeseries = { + granularity: ExposureGranularity + points: ExposuresTimeseriesPoint[] +} + +export type ExposuresSummary = { + excluded_identities: number + timeseries: ExposuresTimeseries +} + +export type ExperimentExposures = { + as_of: string | null + last_error_at: string | null + refresh_requested_at: string | null + payload: ExposuresSummary | null +} + +// --- Bayesian results (defined now, consumed when the endpoint ships) --- +export type VariantStats = { + n: number + sum: number + sum_squares: number +} + +export type Inference = { + lift: number + ci_low: number + ci_high: number + chance_to_win: number +} + +export type BayesianMetricResult = { + metric_id: number + variants: Record + inference: Record +} + +export type BayesianResultsSummary = { + srm_p_value: number | null + metrics: BayesianMetricResult[] +} + export enum TagStrategy { INTERSECTION = 'INTERSECTION', UNION = 'UNION', @@ -1427,6 +1479,8 @@ export type Res = { status_counts?: ExperimentStatusCounts } experiment: Experiment + experimentExposures: ExperimentExposures + experimentBayesianResults: BayesianResultsSummary metric: Metric metrics: PagedResponse multivariateOption: MultivariateOption diff --git a/frontend/web/components/base/SelectableCard/SelectableCard.scss b/frontend/web/components/base/SelectableCard/SelectableCard.scss index cf76f233d77a..0315b3f4a852 100644 --- a/frontend/web/components/base/SelectableCard/SelectableCard.scss +++ b/frontend/web/components/base/SelectableCard/SelectableCard.scss @@ -71,7 +71,7 @@ font-weight: var(--font-weight-regular); padding: 2px 8px; border-radius: var(--radius-sm); - background: var(--color-surface-emphasis); + background: var(--color-surface-muted); color: var(--color-text-secondary); } diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.scss b/frontend/web/components/base/grid/ContentCard/ContentCard.scss index d3f10360e007..2cbf8100b455 100644 --- a/frontend/web/components/base/grid/ContentCard/ContentCard.scss +++ b/frontend/web/components/base/grid/ContentCard/ContentCard.scss @@ -1,8 +1,13 @@ .content-card { display: flex; flex-direction: column; - gap: 24px; + gap: 16px; padding: 24px; + + &--compact { + gap: 8px; + padding: 16px; + } background: var(--color-surface-subtle); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); @@ -30,8 +35,8 @@ &__title { font-size: var(--font-body-size, 0.875rem); - font-weight: var(--font-weight-bold, 700); - color: var(--color-text-default); + font-weight: var(--font-weight-regular, 400); + color: var(--color-text-secondary); margin: 0; } } diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.tsx b/frontend/web/components/base/grid/ContentCard/ContentCard.tsx index 0eb19fdae542..16f252162db5 100644 --- a/frontend/web/components/base/grid/ContentCard/ContentCard.tsx +++ b/frontend/web/components/base/grid/ContentCard/ContentCard.tsx @@ -7,6 +7,7 @@ type ContentCardProps = { description?: ReactNode action?: ReactNode className?: string + compact?: boolean children: ReactNode } @@ -14,16 +15,23 @@ const ContentCard: FC = ({ action, children, className, + compact, description, title, }) => { return ( -
+
{(title || action || description) && (
{(title || action) && (
- {title &&

{title}

} + {title && {title}} {action}
)} diff --git a/frontend/web/components/charts/LineChart.tsx b/frontend/web/components/charts/LineChart.tsx index 8424e9aaee82..ea53f6ca6b5e 100644 --- a/frontend/web/components/charts/LineChart.tsx +++ b/frontend/web/components/charts/LineChart.tsx @@ -17,27 +17,17 @@ type LineChartProps = { data: ChartDataPoint[] series: string[] colorMap: Record + height?: number xAxisInterval?: number - /** - * Render recharts' built-in `` below the chart. Default `false` — - * most consumers already expose a coloured filter UI (tags / MultiSelect) - * that serves the same purpose, so a second legend is redundant and can - * display raw dataKeys (e.g. numeric env IDs) that are meaningless to users. - */ showLegend?: boolean - /** - * Optional dataKey → display name map, threaded through to the tooltip (and - * the legend when enabled). Use this when dataKeys are opaque identifiers - * (e.g. numeric env ids) that need a human-readable label on display. - */ seriesLabels?: Record - /** Render vertical grid lines (one per x tick). Default `true`. */ verticalGrid?: boolean } const LineChart: FC = ({ colorMap, data, + height = 400, series, seriesLabels, showLegend = false, @@ -45,7 +35,7 @@ const LineChart: FC = ({ xAxisInterval = 0, }) => { return ( - + = ({ environmentId, experiments, + projectId, }) => { + const history = useHistory() return ( @@ -31,8 +35,22 @@ const ExperimentsTable: FC = ({ {experiments.map((exp) => { const primaryMetric = getPrimaryMetric(exp) + const hasMetric = !!primaryMetric return ( - + + history.push( + `/project/${projectId}/environment/${environmentId}/experiments/${exp.id}`, + ) + : undefined + } + > -
{exp.name} {exp.feature?.name && ( @@ -49,7 +67,10 @@ const ExperimentsTable: FC = ({ {primaryMetric?.metric_name ?? <>—} {moment(exp.updated_at).fromNow()} + e.stopPropagation()} + > void +} + +export const AsOfLabel: FC<{ asOf: string | null }> = ({ asOf }) => ( + + {asOf ? `As of ${moment.utc(asOf).format('D MMM YYYY, HH:mm')} UTC` : ''} + +) + +const AsOfRefreshControl: FC = ({ + disabled, + disabledReason, + isRefreshing, + onRefresh, +}) => ( + +) + +export default AsOfRefreshControl diff --git a/frontend/web/components/experiments/results/ExperimentConfiguration.tsx b/frontend/web/components/experiments/results/ExperimentConfiguration.tsx new file mode 100644 index 000000000000..055b24eda8c5 --- /dev/null +++ b/frontend/web/components/experiments/results/ExperimentConfiguration.tsx @@ -0,0 +1,88 @@ +import { FC, useMemo } from 'react' +import ContentCard from 'components/base/grid/ContentCard' +import ColorSwatch from 'components/ColorSwatch' +import { Experiment, ExpectedDirection } from 'common/types/responses' +import { getVariantIdentities } from './derive' +import './results.scss' + +const EXPECTED_DIRECTION_CHIP: Record = { + decrease: '↓ should decrease', + increase: '↑ should increase', + not_decrease: 'should not decrease', + not_increase: 'should not increase', +} + +type ExperimentConfigurationProps = { + experiment: Experiment +} + +const ExperimentConfiguration: FC = ({ + experiment, +}) => { + const metric = experiment.metrics[0] + const identities = useMemo( + () => getVariantIdentities(experiment.feature), + [experiment.feature], + ) + + const treatmentTotal = experiment.feature.multivariate_options.reduce( + (sum, mv) => sum + mv.default_percentage_allocation, + 0, + ) + + const getAllocation = (index: number): number => + index === 0 + ? 100 - treatmentTotal + : experiment.feature.multivariate_options[index - 1] + ?.default_percentage_allocation ?? 0 + + return ( +
+
+ + + {experiment.feature.name} + + +
+
+ + {metric ? ( +
+
{metric.metric_name}
+
+ + {EXPECTED_DIRECTION_CHIP[metric.expected_direction]} + +
+
+ ) : ( + + )} +
+
+
+ +
+ {identities.map((v, i) => ( +
+ + + {v.name} + + + {Math.round(getAllocation(i))}% + +
+ ))} +
+
+
+
+ ) +} + +export default ExperimentConfiguration diff --git a/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx b/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx new file mode 100644 index 000000000000..6bb805a294b6 --- /dev/null +++ b/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx @@ -0,0 +1,279 @@ +import { FC, useCallback, useMemo, useState } from 'react' +import moment from 'moment' +import StatusBadge from 'components/experiments/StatusBadge' +import Button from 'components/base/forms/Button' +import ButtonDropdown from 'components/base/forms/ButtonDropdown' +import Icon from 'components/icons/Icon' +import { colorIconAction, colorIconSecondary } from 'common/theme/tokens' +import { + useCompleteExperimentMutation, + useDeleteExperimentMutation, + usePauseExperimentMutation, + useStartExperimentMutation, + useUpdateExperimentMutation, +} from 'common/services/useExperiment' +import { Experiment } from 'common/types/responses' +import 'components/base/SelectableCard/SelectableCard.scss' +import './results.scss' + +type ExperimentDetailHeaderProps = { + experiment: Experiment + environmentId: string +} + +const ExperimentDetailHeader: FC = ({ + environmentId, + experiment, +}) => { + const [startExperiment] = useStartExperimentMutation() + const [pauseExperiment] = usePauseExperimentMutation() + const [completeExperiment] = useCompleteExperimentMutation() + const [deleteExperiment] = useDeleteExperimentMutation() + const [updateExperiment] = useUpdateExperimentMutation() + + const [isEditingHypothesis, setIsEditingHypothesis] = useState(false) + const [hypothesisDraft, setHypothesisDraft] = useState('') + + const params = useMemo( + () => ({ environmentId, experimentId: experiment.id }), + [environmentId, experiment.id], + ) + + const handleStart = useCallback(async () => { + try { + await startExperiment(params).unwrap() + toast('Experiment started') + } catch { + toast('Failed to start experiment', 'danger') + } + }, [startExperiment, params]) + + const handlePause = useCallback(async () => { + try { + await pauseExperiment(params).unwrap() + toast('Experiment paused') + } catch { + toast('Failed to pause experiment', 'danger') + } + }, [pauseExperiment, params]) + + const handleComplete = useCallback(() => { + openConfirm({ + body: ( + + Are you sure you want to end {experiment.name}? This + action cannot be undone. + + ), + noText: 'Cancel', + onYes: async () => { + try { + await completeExperiment(params).unwrap() + toast('Experiment completed') + } catch { + toast('Failed to complete experiment', 'danger') + } + }, + title: 'End experiment?', + yesText: 'End Experiment', + }) + }, [completeExperiment, experiment.name, params]) + + const handleDelete = useCallback(() => { + openConfirm({ + body: ( + + Are you sure you want to delete {experiment.name}? + This action cannot be undone. + + ), + destructive: true, + noText: 'Cancel', + onYes: async () => { + try { + await deleteExperiment(params).unwrap() + toast('Experiment deleted') + } catch { + toast('Failed to delete experiment', 'danger') + } + }, + title: 'Delete experiment?', + yesText: 'Delete', + }) + }, [deleteExperiment, experiment.name, params]) + + const startEditingHypothesis = () => { + setHypothesisDraft(experiment.hypothesis ?? '') + setIsEditingHypothesis(true) + } + + const cancelHypothesis = () => { + setIsEditingHypothesis(false) + } + + const commitHypothesis = async () => { + const trimmed = hypothesisDraft.trim() + if (trimmed === (experiment.hypothesis ?? '')) { + setIsEditingHypothesis(false) + return + } + try { + await updateExperiment({ + body: { hypothesis: trimmed }, + ...params, + }).unwrap() + setIsEditingHypothesis(false) + } catch { + toast('Failed to update hypothesis', 'danger') + } + } + + const metric = experiment.metrics[0] + const metricName = metric?.metric_name + const startedFact = experiment.started_at + ? `started ${moment(experiment.started_at).format('D MMM YYYY')}` + : null + const endedFact = experiment.ended_at + ? `ended ${moment(experiment.ended_at).format('D MMM YYYY')}` + : null + + const renderActions = () => { + switch (experiment.status) { + case 'created': + return ( + + ) + case 'paused': + return ( + + ) + case 'running': + return ( + + End Experiment + + ) + case 'completed': + return ( + + ) + default: + return null + } + } + + const renderHypothesis = () => { + if (isEditingHypothesis) { + return ( +
+ Hypothesis +
+