diff --git a/frontend/common/services/useExperiment.ts b/frontend/common/services/useExperiment.ts index 5a6bbda8ac6d..d8feaf3f5c34 100644 --- a/frontend/common/services/useExperiment.ts +++ b/frontend/common/services/useExperiment.ts @@ -106,10 +106,28 @@ export const experimentService = service invalidatesTags: (_res, _err, { experimentId }) => [ { id: experimentId, type: 'ExperimentResults' }, ], - query: ({ environmentId, experimentId }) => ({ - method: 'POST', - url: `environments/${environmentId}/experiments/${experimentId}/results/refresh/`, - }), + queryFn: async ( + { environmentId, experimentId }, + _api, + _extraOptions, + baseQuery, + ) => { + const result = await baseQuery({ + method: 'POST', + url: `environments/${environmentId}/experiments/${experimentId}/results/refresh/`, + }) + if (result.error) { + const retryAfter = + result.meta?.response?.headers?.get('Retry-After') + return { + error: { + ...result.error, + retryAfter: retryAfter ? parseInt(retryAfter, 10) : null, + }, + } + } + return { data: undefined } + }, }), refreshExperimentExposures: builder.mutation< Res['experimentExposures'], diff --git a/frontend/documentation/components/RefreshControl.stories.tsx b/frontend/documentation/components/RefreshControl.stories.tsx new file mode 100644 index 000000000000..af02babcb39d --- /dev/null +++ b/frontend/documentation/components/RefreshControl.stories.tsx @@ -0,0 +1,121 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import RefreshControl from 'components/base/forms/RefreshControl' +import { themeClassNames } from 'components/base/forms/Button' + +const themeOptions = Object.keys(themeClassNames) as Array< + keyof typeof themeClassNames +> + +const meta: Meta = { + argTypes: { + children: { + control: 'text', + description: 'Button label. Defaults to "Refresh".', + }, + disabled: { + control: 'boolean', + description: 'Disables the button, preventing interaction.', + }, + disabledReason: { + control: 'text', + description: 'Tooltip explaining why the button is disabled.', + }, + isRefreshing: { + control: 'boolean', + description: + 'Shows a busy label and disables the button while a refresh is in flight.', + }, + label: { + control: 'text', + description: + 'Related message rendered beneath the button — e.g. a retry countdown, an in-progress notice, or an error.', + }, + theme: { + control: 'select', + description: 'Visual variant of the button.', + options: themeOptions, + table: { defaultValue: { summary: 'secondary' } }, + }, + }, + args: { + children: 'Refresh', + disabled: false, + isRefreshing: false, + onRefresh: () => {}, + theme: 'secondary', + }, + component: RefreshControl, + parameters: { layout: 'centered' }, + title: 'Components/RefreshControl', +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Refreshing: Story = { + args: { + isRefreshing: true, + label: 'Computing… results will update automatically.', + }, + parameters: { + docs: { + description: { + story: + 'While a refresh is in flight the button shows a busy label and disables itself. The `label` slot carries the in-progress message.', + }, + }, + }, +} + +export const Throttled: Story = { + args: { + disabled: true, + label: 'Computing, retry in 4m 30s', + }, + parameters: { + docs: { + description: { + story: + 'After hitting the API rate limit (HTTP 429), the caller disables the button and feeds a Retry-After countdown into `label`.', + }, + }, + }, +} + +export const Disabled: Story = { + args: { + disabled: true, + disabledReason: 'Refresh is disabled because the experiment is complete.', + }, + parameters: { + docs: { + description: { + story: + 'Disabled state with a `disabledReason` surfaced as the button tooltip.', + }, + }, + }, +} + +export const PrimaryWithError: Story = { + args: { + children: 'Refresh results', + label: ( + The last results computation failed. + ), + theme: 'primary', + }, + parameters: { + docs: { + description: { + story: + 'Primary theme as used on the experiment results header, with an error message in the `label` slot.', + }, + }, + }, +} diff --git a/frontend/web/components/base/forms/RefreshControl.tsx b/frontend/web/components/base/forms/RefreshControl.tsx new file mode 100644 index 000000000000..beeed0dae935 --- /dev/null +++ b/frontend/web/components/base/forms/RefreshControl.tsx @@ -0,0 +1,39 @@ +import { FC, ReactNode } from 'react' +import Button, { themeClassNames } from './Button' + +type RefreshControlProps = { + onRefresh: () => void + isRefreshing: boolean + disabled: boolean + disabledReason?: string + theme?: keyof typeof themeClassNames + label?: ReactNode + children?: ReactNode +} + +const RefreshControl: FC = ({ + children, + disabled, + disabledReason, + isRefreshing, + label, + onRefresh, + theme = 'secondary', +}) => ( +
+ + {label ? ( +
{label}
+ ) : null} +
+) + +export default RefreshControl diff --git a/frontend/web/components/experiments/results/AsOfRefreshControl.tsx b/frontend/web/components/experiments/results/AsOfRefreshControl.tsx deleted file mode 100644 index ddf8c707364a..000000000000 --- a/frontend/web/components/experiments/results/AsOfRefreshControl.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { FC } from 'react' -import moment from 'moment' -import Button from 'components/base/forms/Button' - -type AsOfRefreshControlProps = { - asOf: string | null - isRefreshing: boolean - disabled: boolean - disabledReason?: string - onRefresh: () => 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/ExperimentExposuresPanel.tsx b/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx index ee5d9b93ce9d..e29b94df32a8 100644 --- a/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx +++ b/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx @@ -1,8 +1,11 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import moment from 'moment' import { LineChart } from 'components/charts' import ContentCard from 'components/base/grid/ContentCard' import Button from 'components/base/forms/Button' +import RefreshControl from 'components/base/forms/RefreshControl' import Icon from 'components/icons/Icon' +import { colorIconDanger } from 'common/theme/tokens' import { useGetExperimentExposuresQuery, useRefreshExperimentExposuresMutation, @@ -21,9 +24,14 @@ import { canRefreshExposures, deriveExposuresViewState, } from './exposuresViewState' -import AsOfRefreshControl, { AsOfLabel } from './AsOfRefreshControl' import './results.scss' +const AsOfLabel: FC<{ asOf: string | null }> = ({ asOf }) => ( + + {asOf ? `As of ${moment.utc(asOf).format('D MMM YYYY, HH:mm')} UTC` : ''} + +) + const buildLegendLabels = (totals: VariantTotal[]): Record => { const labels: Record = {} totals.forEach((t) => { @@ -133,49 +141,46 @@ const ExperimentExposuresPanel: FC = ({ [payload, identities], ) - const isRefreshing = viewState.kind === 'refreshing' || isSubmitting + const isRefreshing = + refreshRequested || viewState.kind === 'refreshing' || isSubmitting const headline = payload ? getHeadlineTotal(payload) : 0 const hasData = !!payload && headline > 0 const handleRefresh = useCallback(async () => { + setRefreshRequested(true) + setPollStartedAt(Date.now()) const result = await refresh({ environmentId, experimentId: experiment.id, }) if ('error' in result && result.error) { + setRefreshRequested(false) + setPollStartedAt(null) const seconds = parseRetryAfter(result.error) if (seconds !== null) { setRetryAfter(seconds) } else { toast('Failed to refresh exposures', 'danger') } - } else { - setRefreshRequested(true) - setPollStartedAt(Date.now()) } }, [refresh, environmentId, experiment.id]) const action = ( -
- - {retryAfter !== null && ( -
- Computing, retry in {formatCountdown(retryAfter)} -
- )} -
+ ) const asOf = exposures?.as_of ?? null @@ -207,7 +212,7 @@ const ExperimentExposuresPanel: FC = ({ <>
- + The last exposure computation failed. Showing previously computed data. @@ -244,7 +249,7 @@ const ExperimentExposuresPanel: FC = ({ <>
- + The last exposure computation failed. Showing previously computed data. @@ -256,7 +261,7 @@ const ExperimentExposuresPanel: FC = ({ {!payload && viewState.kind === 'error' && (
- + The last exposure computation failed.
)} diff --git a/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx b/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx new file mode 100644 index 000000000000..19cab5e1136c --- /dev/null +++ b/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx @@ -0,0 +1,142 @@ +import { FC, ReactNode, useCallback, useEffect, useState } from 'react' +import { + useGetExperimentBayesianResultsQuery, + useRefreshExperimentBayesianResultsMutation, +} from 'common/services/useExperiment' +import { ExperimentStatus } from 'common/types/responses' +import RefreshControl from 'components/base/forms/RefreshControl' +import { + POLL_TIMEOUT_MS, + REFRESH_POLL_INTERVAL_MS, + canRefreshResults, + deriveResultsViewState, +} from './resultsViewState' + +const parseRetryAfter = (err: unknown): number | null => { + const fetchErr = err as { + status?: number + retryAfter?: number | null + } + if (fetchErr.status !== 429) return null + if (fetchErr.retryAfter) return fetchErr.retryAfter + return 300 +} + +const formatCountdown = (seconds: number): string => { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return m > 0 ? `${m}m ${s}s` : `${s}s` +} + +type ExperimentResultsRefreshControlProps = { + environmentId: string + experimentId: number + status: ExperimentStatus +} + +const REFRESH_DISABLED_COPY: Record = { + final: 'Refresh is disabled because the experiment is complete.', + not_started: 'Start the experiment to compute results.', +} + +const ExperimentResultsRefreshControl: FC< + ExperimentResultsRefreshControlProps +> = ({ environmentId, experimentId, status }) => { + const [pollInterval, setPollInterval] = useState(0) + const [refreshRequested, setRefreshRequested] = useState(false) + const [pollStartedAt, setPollStartedAt] = useState(null) + const [retryAfter, setRetryAfter] = useState(null) + + const { data: results } = useGetExperimentBayesianResultsQuery( + { environmentId, experimentId }, + { pollingInterval: pollInterval }, + ) + const [refresh, { isLoading: isSubmitting }] = + useRefreshExperimentBayesianResultsMutation() + + const viewState = deriveResultsViewState(results) + const availability = canRefreshResults(status, results) + + const pollTimedOut = + pollStartedAt !== null && Date.now() - pollStartedAt > POLL_TIMEOUT_MS + const shouldPoll = + !pollTimedOut && (viewState.kind === 'refreshing' || refreshRequested) + const nextPollInterval = shouldPoll ? REFRESH_POLL_INTERVAL_MS : 0 + useEffect(() => { + setPollInterval(nextPollInterval) + }, [nextPollInterval]) + + useEffect(() => { + if (viewState.kind === 'loaded' || viewState.kind === 'error') { + setRefreshRequested(false) + setPollStartedAt(null) + } + }, [viewState.kind]) + + useEffect(() => { + if (pollTimedOut) { + setRefreshRequested(false) + setPollStartedAt(null) + } + }, [pollTimedOut]) + + useEffect(() => { + if (retryAfter === null || retryAfter <= 0) return + const timer = setInterval(() => { + setRetryAfter((prev) => { + if (prev === null || prev <= 1) return null + return prev - 1 + }) + }, 1000) + return () => clearInterval(timer) + }, [retryAfter !== null]) // eslint-disable-line react-hooks/exhaustive-deps + + const isRefreshing = + refreshRequested || viewState.kind === 'refreshing' || isSubmitting + const hasData = !!results?.payload + + const handleRefresh = useCallback(async () => { + setRefreshRequested(true) + setPollStartedAt(Date.now()) + const result = await refresh({ environmentId, experimentId }) + if ('error' in result && result.error) { + setRefreshRequested(false) + setPollStartedAt(null) + const seconds = parseRetryAfter(result.error) + if (seconds !== null) { + setRetryAfter(seconds) + } else { + toast('Failed to refresh results', 'danger') + } + } + }, [refresh, environmentId, experimentId]) + + let label: ReactNode = undefined + if (retryAfter !== null) { + label = `Computing, retry in ${formatCountdown(retryAfter)}` + } else if (isRefreshing) { + label = 'Computing… results will update automatically.' + } else if (viewState.kind === 'error') { + label = ( + The last results computation failed. + ) + } + + return ( + + Refresh results + + ) +} + +export default ExperimentResultsRefreshControl diff --git a/frontend/web/components/experiments/results/__tests__/resultsViewState.test.ts b/frontend/web/components/experiments/results/__tests__/resultsViewState.test.ts new file mode 100644 index 000000000000..f7427b0c0e75 --- /dev/null +++ b/frontend/web/components/experiments/results/__tests__/resultsViewState.test.ts @@ -0,0 +1,82 @@ +import { + canRefreshResults, + deriveResultsViewState, +} from 'components/experiments/results/resultsViewState' +import { + ExperimentBayesianResults, + ExperimentStatus, +} from 'common/types/responses' + +const results = ( + over: Partial = {}, +): ExperimentBayesianResults => ({ + as_of: null, + is_final: false, + last_error_at: null, + payload: null, + refresh_requested_at: null, + ...over, +}) + +const loaded = results({ + as_of: '2026-06-12T10:00:00Z', + payload: { metrics: [], srm_p_value: null }, +}) + +describe('deriveResultsViewState', () => { + it('is empty when there is no payload and nothing in flight', () => { + expect(deriveResultsViewState(results()).kind).toBe('empty') + }) + + it('is loaded when a payload is present and fresh', () => { + expect(deriveResultsViewState(loaded).kind).toBe('loaded') + }) + + it('is refreshing when a request is newer than the last result', () => { + const state = deriveResultsViewState({ + ...loaded, + refresh_requested_at: '2026-06-12T11:00:00Z', + }) + expect(state.kind).toBe('refreshing') + }) + + it('is error when the last error is newer than as_of, preserving stale payload', () => { + const state = deriveResultsViewState({ + ...loaded, + last_error_at: '2026-06-12T12:00:00Z', + }) + expect(state).toEqual({ kind: 'error', staleAvailable: true }) + }) + + it('prefers refreshing over a prior error', () => { + const state = deriveResultsViewState({ + ...loaded, + last_error_at: '2026-06-12T12:00:00Z', + refresh_requested_at: '2026-06-12T13:00:00Z', + }) + expect(state.kind).toBe('refreshing') + }) +}) + +describe('canRefreshResults', () => { + const cases: [ExperimentStatus, boolean][] = [ + ['created', false], + ['running', true], + ['paused', true], + ['completed', true], + ] + cases.forEach(([status, can]) => { + it(`${status} → canRefresh=${can}`, () => { + expect(canRefreshResults(status, loaded).canRefresh).toBe(can) + }) + }) + + it('blocks refresh once the results are final', () => { + expect(canRefreshResults('running', { ...loaded, is_final: true })).toEqual( + { + canRefresh: false, + reason: 'final', + }, + ) + }) +}) diff --git a/frontend/web/components/experiments/results/results.scss b/frontend/web/components/experiments/results/results.scss index ab5329d319e0..3bb9e5b4913c 100644 --- a/frontend/web/components/experiments/results/results.scss +++ b/frontend/web/components/experiments/results/results.scss @@ -3,7 +3,7 @@ display: flex; align-items: center; justify-content: space-between; - font-size: var(--font-caption-size, 12px); + font-size: 12px; margin-bottom: 6px; } @@ -70,7 +70,7 @@ &__lift-value { flex-shrink: 0; - font-size: var(--font-caption-size, 12px); + font-size: 12px; font-weight: var(--font-weight-regular); white-space: nowrap; } @@ -88,7 +88,7 @@ align-items: center; gap: 8px; padding: 16px 20px 0; - font-size: var(--font-body-sm-size, 13px); + font-size: 13px; } &__axis-chart { @@ -104,7 +104,7 @@ &__axis-tick-label { position: absolute; transform: translateX(-50%); - font-size: var(--font-caption-size, 12px); + font-size: 12px; font-weight: var(--font-weight-bold); color: var(--color-text-secondary); white-space: nowrap; @@ -147,7 +147,7 @@ display: inline-flex; align-items: center; gap: 4px; - font-size: var(--font-caption-size, 12px); + font-size: 12px; color: var(--color-text-default); white-space: nowrap; padding-right: 6px; @@ -187,7 +187,7 @@ display: flex; align-items: center; gap: 8px; - font-size: var(--font-body-sm-size, 13px); + font-size: 13px; white-space: nowrap; } @@ -231,7 +231,7 @@ border-bottom: 1px solid var(--color-border-default); .react-tooltip.react-tooltip { - font-size: var(--font-body-sm-size, 13px); + font-size: 13px; font-weight: var(--font-weight-regular); text-transform: none; letter-spacing: normal; @@ -244,7 +244,7 @@ td { padding: 22px 20px; - font-size: var(--font-body-sm-size, 13px); + font-size: 13px; color: var(--color-text-default); border-bottom: 1px solid var(--color-border-default); vertical-align: middle; @@ -280,7 +280,7 @@ display: flex; align-items: center; gap: 10px; - font-size: var(--font-caption-size, 12px); + font-size: 12px; color: var(--color-text-secondary); } } diff --git a/frontend/web/components/experiments/results/resultsViewState.ts b/frontend/web/components/experiments/results/resultsViewState.ts new file mode 100644 index 000000000000..d1d887acb49f --- /dev/null +++ b/frontend/web/components/experiments/results/resultsViewState.ts @@ -0,0 +1,50 @@ +import { + ExperimentBayesianResults, + ExperimentStatus, +} from 'common/types/responses' + +export type ResultsViewState = + | { kind: 'empty' } + | { kind: 'loaded' } + | { kind: 'refreshing' } + | { kind: 'error'; staleAvailable: boolean } + +export type RefreshReason = 'not_started' | 'final' +export type RefreshAvailability = { + canRefresh: boolean + reason?: RefreshReason +} + +export const REFRESH_POLL_INTERVAL_MS = 10000 +export const POLL_TIMEOUT_MS = 120000 + +const ms = (iso: string | null): number => (iso ? new Date(iso).getTime() : 0) + +const isRefreshing = (r: ExperimentBayesianResults): boolean => { + const requested = ms(r.refresh_requested_at) + return requested > 0 && requested > Math.max(ms(r.as_of), ms(r.last_error_at)) +} + +const hasError = (r: ExperimentBayesianResults): boolean => + ms(r.last_error_at) > ms(r.as_of) + +export const deriveResultsViewState = ( + results: ExperimentBayesianResults | null | undefined, +): ResultsViewState => { + if (!results) return { kind: 'empty' } + if (isRefreshing(results)) return { kind: 'refreshing' } + if (hasError(results)) { + return { kind: 'error', staleAvailable: !!results.payload } + } + if (results.payload) return { kind: 'loaded' } + return { kind: 'empty' } +} + +export const canRefreshResults = ( + status: ExperimentStatus, + results: ExperimentBayesianResults | null | undefined, +): RefreshAvailability => { + if (status === 'created') return { canRefresh: false, reason: 'not_started' } + if (results?.is_final) return { canRefresh: false, reason: 'final' } + return { canRefresh: true } +} diff --git a/frontend/web/components/pages/ExperimentDetailPage.tsx b/frontend/web/components/pages/ExperimentDetailPage.tsx index 196fd06cbfd7..ecdc24fc681c 100644 --- a/frontend/web/components/pages/ExperimentDetailPage.tsx +++ b/frontend/web/components/pages/ExperimentDetailPage.tsx @@ -12,6 +12,7 @@ import ExperimentConfiguration from 'components/experiments/results/ExperimentCo import ExperimentSummaryScorecard from 'components/experiments/results/ExperimentSummaryScorecard' import ExperimentMetricScorecard from 'components/experiments/results/ExperimentMetricScorecard' import ExperimentExposuresPanel from 'components/experiments/results/ExperimentExposuresPanel' +import ExperimentResultsRefreshControl from 'components/experiments/results/ExperimentResultsRefreshControl' type ExperimentDetailParams = { projectId: string @@ -88,7 +89,14 @@ const ExperimentDetailPage: FC = () => { {experiment.status !== 'created' && ( <> -
Results
+
+
Results
+ +