diff --git a/frontend/common/hooks/__tests__/useCountdown.test.ts b/frontend/common/hooks/__tests__/useCountdown.test.ts new file mode 100644 index 000000000000..811167f8bc26 --- /dev/null +++ b/frontend/common/hooks/__tests__/useCountdown.test.ts @@ -0,0 +1,14 @@ +import { formatCountdown } from 'common/hooks/useCountdown' + +describe('formatCountdown', () => { + it('renders seconds only under a minute', () => { + expect(formatCountdown(0)).toBe('0s') + expect(formatCountdown(45)).toBe('45s') + }) + + it('renders minutes and seconds at or above a minute', () => { + expect(formatCountdown(60)).toBe('1m 0s') + expect(formatCountdown(90)).toBe('1m 30s') + expect(formatCountdown(305)).toBe('5m 5s') + }) +}) diff --git a/frontend/common/hooks/useCountdown.ts b/frontend/common/hooks/useCountdown.ts new file mode 100644 index 000000000000..fa10356179d2 --- /dev/null +++ b/frontend/common/hooks/useCountdown.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useState } from 'react' + +export const formatCountdown = (seconds: number): string => { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return m > 0 ? `${m}m ${s}s` : `${s}s` +} + +// Counts down a number of seconds to zero, one tick per second. +// Returns the remaining value (null once finished) and a setter to (re)start it. +export const useCountdown = (): [number | null, (seconds: number) => void] => { + const [remaining, setRemaining] = useState(null) + + useEffect(() => { + if (remaining === null || remaining <= 0) return + const timer = setTimeout( + () => setRemaining((prev) => (prev && prev > 1 ? prev - 1 : null)), + 1000, + ) + return () => clearTimeout(timer) + }, [remaining]) + + const start = useCallback( + (seconds: number) => setRemaining(seconds > 0 ? seconds : null), + [], + ) + + return [remaining, start] +} + +export default useCountdown 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..d6e433e34dc0 --- /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/experiments/results/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 spinner and disables the button while a refresh is in flight. The label stays unchanged.', + }, + 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: 'Experiments/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 spinner and disables itself, keeping its label. 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/Button.tsx b/frontend/web/components/base/forms/Button.tsx index 611348647e22..8c0e3dda0b07 100644 --- a/frontend/web/components/base/forms/Button.tsx +++ b/frontend/web/components/base/forms/Button.tsx @@ -1,6 +1,7 @@ import React from 'react' import cn from 'classnames' import { ButtonHTMLAttributes, HTMLAttributeAnchorTarget } from 'react' +import Loader from 'components/Loader' export const themeClassNames = { danger: 'btn-danger', @@ -26,6 +27,7 @@ export type ButtonType = ButtonHTMLAttributes & { target?: HTMLAttributeAnchorTarget theme?: keyof typeof themeClassNames size?: keyof typeof sizeClassNames + isLoading?: boolean } export const Button = React.forwardRef< @@ -36,7 +38,9 @@ export const Button = React.forwardRef< { children, className, + disabled, href, + isLoading = false, onMouseUp, size = 'default', target, @@ -51,6 +55,7 @@ export const Button = React.forwardRef< className, themeClassNames[theme], sizeClassNames[size], + isLoading && 'd-inline-flex align-items-center gap-2', ) return href ? ( } > + {isLoading && } {children} ) 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..af5d84d69aab 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 Icon from 'components/icons/Icon' +import useCountdown from 'common/hooks/useCountdown' +import { colorIconDanger } from 'common/theme/tokens' import { useGetExperimentExposuresQuery, useRefreshExperimentExposuresMutation, @@ -16,14 +19,22 @@ import { } from './derive' import type { VariantTotal } from './derive' import { + DEFAULT_RETRY_AFTER_S, POLL_TIMEOUT_MS, REFRESH_POLL_INTERVAL_MS, canRefreshExposures, deriveExposuresViewState, + getExposuresRefreshLabel, } from './exposuresViewState' -import AsOfRefreshControl, { AsOfLabel } from './AsOfRefreshControl' +import RefreshControl from './RefreshControl' 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) => { @@ -41,13 +52,7 @@ const parseRetryAfter = (err: unknown): 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` + return DEFAULT_RETRY_AFTER_S } type ExperimentExposuresPanelProps = { @@ -69,7 +74,7 @@ const ExperimentExposuresPanel: FC = ({ const [pollInterval, setPollInterval] = useState(0) const [refreshRequested, setRefreshRequested] = useState(false) const [pollStartedAt, setPollStartedAt] = useState(null) - const [retryAfter, setRetryAfter] = useState(null) + const [retryAfter, startRetryCountdown] = useCountdown() const { data: fetched } = useGetExperimentExposuresQuery( { environmentId, experimentId: experiment.id }, { @@ -109,17 +114,6 @@ const ExperimentExposuresPanel: FC = ({ } }, [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 identities = useMemo( () => getVariantIdentities(experiment.feature), [experiment.feature], @@ -133,49 +127,54 @@ 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) + startRetryCountdown(seconds) } else { toast('Failed to refresh exposures', 'danger') } - } else { - setRefreshRequested(true) - setPollStartedAt(Date.now()) } - }, [refresh, environmentId, experiment.id]) + }, [refresh, environmentId, experiment.id, startRetryCountdown]) + + const refreshLabel = getExposuresRefreshLabel(retryAfter, isRefreshing) const action = ( -
- - {retryAfter !== null && ( -
- Computing, retry in {formatCountdown(retryAfter)} -
- )} -
+ + {refreshLabel.message} + + ) + } + onRefresh={handleRefresh} + /> ) const asOf = exposures?.as_of ?? null @@ -188,11 +187,6 @@ const ExperimentExposuresPanel: FC = ({ > {chart && hasData && ( <> - {isRefreshing && ( -
- Computing… this will refresh automatically. -
- )} = ({ <>
- + The last exposure computation failed. Showing previously computed data. @@ -244,7 +238,7 @@ const ExperimentExposuresPanel: FC = ({ <>
- + The last exposure computation failed. Showing previously computed data. @@ -256,7 +250,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..70382fd88259 --- /dev/null +++ b/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx @@ -0,0 +1,124 @@ +import { FC, useCallback, useEffect, useState } from 'react' +import useCountdown from 'common/hooks/useCountdown' +import { + useGetExperimentBayesianResultsQuery, + useRefreshExperimentBayesianResultsMutation, +} from 'common/services/useExperiment' +import { ExperimentStatus } from 'common/types/responses' +import RefreshControl from './RefreshControl' +import { + DEFAULT_RETRY_AFTER_S, + POLL_TIMEOUT_MS, + REFRESH_POLL_INTERVAL_MS, + canRefreshResults, + deriveResultsViewState, + getResultsRefreshLabel, +} 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 DEFAULT_RETRY_AFTER_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, startRetryCountdown] = useCountdown() + + 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]) + + const isRefreshing = + refreshRequested || viewState.kind === 'refreshing' || isSubmitting + + 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) { + startRetryCountdown(seconds) + } else { + toast('Failed to refresh results', 'danger') + } + } + }, [refresh, environmentId, experimentId, startRetryCountdown]) + + const label = getResultsRefreshLabel(retryAfter, isRefreshing, viewState) + + return ( + + {label.message} + + ) + } + onRefresh={handleRefresh} + > + Refresh results + + ) +} + +export default ExperimentResultsRefreshControl diff --git a/frontend/web/components/experiments/results/RefreshControl.tsx b/frontend/web/components/experiments/results/RefreshControl.tsx new file mode 100644 index 000000000000..c159040f0e9c --- /dev/null +++ b/frontend/web/components/experiments/results/RefreshControl.tsx @@ -0,0 +1,40 @@ +import { FC, ReactNode } from 'react' +import Button, { themeClassNames } from 'components/base/forms/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/__tests__/exposuresViewState.test.ts b/frontend/web/components/experiments/results/__tests__/exposuresViewState.test.ts index 21273e8119d8..7142512a4237 100644 --- a/frontend/web/components/experiments/results/__tests__/exposuresViewState.test.ts +++ b/frontend/web/components/experiments/results/__tests__/exposuresViewState.test.ts @@ -1,6 +1,7 @@ import { canRefreshExposures, deriveExposuresViewState, + getExposuresRefreshLabel, } from 'components/experiments/results/exposuresViewState' import { ExperimentExposures, ExperimentStatus } from 'common/types/responses' @@ -75,3 +76,23 @@ describe('canRefreshExposures', () => { }) }) }) + +describe('getExposuresRefreshLabel', () => { + it('prefers a retry countdown over the in-progress message', () => { + expect(getExposuresRefreshLabel(90, true)).toEqual({ + message: 'Computing… retry in 1m 30s', + tone: 'muted', + }) + }) + + it('shows an in-progress message while refreshing', () => { + expect(getExposuresRefreshLabel(null, true)).toEqual({ + message: 'Computing… exposures will update automatically.', + tone: 'muted', + }) + }) + + it('is null when idle', () => { + expect(getExposuresRefreshLabel(null, false)).toBeNull() + }) +}) 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..42e404d69946 --- /dev/null +++ b/frontend/web/components/experiments/results/__tests__/resultsViewState.test.ts @@ -0,0 +1,114 @@ +import { + canRefreshResults, + deriveResultsViewState, + getResultsRefreshLabel, +} 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', + }, + ) + }) +}) + +describe('getResultsRefreshLabel', () => { + it('prefers a retry countdown over everything else', () => { + expect( + getResultsRefreshLabel(90, true, { kind: 'error', staleAvailable: true }), + ).toEqual({ message: 'Computing… retry in 1m 30s', tone: 'muted' }) + }) + + it('shows an in-progress message while refreshing', () => { + expect(getResultsRefreshLabel(null, true, { kind: 'loaded' })).toEqual({ + message: 'Computing… results will update automatically.', + tone: 'muted', + }) + }) + + it('surfaces a danger message on error when idle', () => { + expect( + getResultsRefreshLabel(null, false, { + kind: 'error', + staleAvailable: false, + }), + ).toEqual({ + message: 'The last results computation failed.', + tone: 'danger', + }) + }) + + it('is null when idle and healthy', () => { + expect(getResultsRefreshLabel(null, false, { kind: 'loaded' })).toBeNull() + }) +}) diff --git a/frontend/web/components/experiments/results/exposuresViewState.ts b/frontend/web/components/experiments/results/exposuresViewState.ts index 4ed610b9a396..311d6763af8f 100644 --- a/frontend/web/components/experiments/results/exposuresViewState.ts +++ b/frontend/web/components/experiments/results/exposuresViewState.ts @@ -1,4 +1,5 @@ import { ExperimentExposures, ExperimentStatus } from 'common/types/responses' +import { formatCountdown } from 'common/hooks/useCountdown' export type ExposuresViewState = | { kind: 'empty' } @@ -12,8 +13,11 @@ export type RefreshAvailability = { reason?: RefreshReason } +export type RefreshLabel = { message: string; tone: 'muted' | 'danger' } + export const REFRESH_POLL_INTERVAL_MS = 10000 export const POLL_TIMEOUT_MS = 120000 +export const DEFAULT_RETRY_AFTER_S = 300 const ms = (iso: string | null): number => (iso ? new Date(iso).getTime() : 0) @@ -47,3 +51,22 @@ export const canRefreshExposures = ( } return { canRefresh: true } } + +export const getExposuresRefreshLabel = ( + retryAfter: number | null, + isRefreshing: boolean, +): RefreshLabel | null => { + if (retryAfter !== null) { + return { + message: `Computing… retry in ${formatCountdown(retryAfter)}`, + tone: 'muted', + } + } + if (isRefreshing) { + return { + message: 'Computing… exposures will update automatically.', + tone: 'muted', + } + } + return null +} 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..876c4c00d626 --- /dev/null +++ b/frontend/web/components/experiments/results/resultsViewState.ts @@ -0,0 +1,77 @@ +import { + ExperimentBayesianResults, + ExperimentStatus, +} from 'common/types/responses' +import { formatCountdown } from 'common/hooks/useCountdown' + +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 type RefreshLabel = { message: string; tone: 'muted' | 'danger' } + +export const REFRESH_POLL_INTERVAL_MS = 10000 +export const POLL_TIMEOUT_MS = 120000 +export const DEFAULT_RETRY_AFTER_S = 300 + +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 } +} + +export const getResultsRefreshLabel = ( + retryAfter: number | null, + isRefreshing: boolean, + viewState: ResultsViewState, +): RefreshLabel | null => { + if (retryAfter !== null) { + return { + message: `Computing… retry in ${formatCountdown(retryAfter)}`, + tone: 'muted', + } + } + if (isRefreshing) { + return { + message: 'Computing… results will update automatically.', + tone: 'muted', + } + } + if (viewState.kind === 'error') { + return { message: 'The last results computation failed.', tone: 'danger' } + } + return null +} 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
+ +