Skip to content
Open
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
29 changes: 3 additions & 26 deletions frontend/web/components/experiments/CreateExperimentWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import {
useCreateExperimentMutation,
useStartExperimentMutation,
} from 'common/services/useExperiment'
import { useGetFeatureStatesQuery } from 'common/services/useFeatureState'
import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments'
import {
ENABLE_EXPERIMENT_LIFECYCLE,
METRIC_DIRECTION_TO_EXPECTED_DIRECTION,
Expand All @@ -18,7 +16,7 @@ import RolloutStep from './steps/RolloutStep'
import {
VariationSplitEntry,
getControlPercentage,
getVariationSplitDefaults,
getEvenSplit,
toRolloutFeatureValue,
} from './rollout'
import isValidPercentage from 'common/utils/isValidPercentage'
Expand Down Expand Up @@ -55,32 +53,11 @@ const CreateExperimentWizard: FC<CreateExperimentWizardProps> = ({
)
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set())

const { getEnvironmentIdFromKey } = useProjectEnvironments(projectId)
const numericEnvId = getEnvironmentIdFromKey(environmentId)

const { data: featureStatesData } = useGetFeatureStatesQuery(
{ environment: numericEnvId, feature: selectedFeature?.id },
{ skip: !selectedFeature || !numericEnvId },
)

const environmentFeatureState = useMemo(
() =>
featureStatesData?.results?.find(
(state) => !state.feature_segment && !state.identity,
),
[featureStatesData],
)

useEffect(() => {
setVariationSplit(
selectedFeature
? getVariationSplitDefaults(
selectedFeature.multivariate_options,
environmentFeatureState?.multivariate_feature_state_values,
)
: [],
selectedFeature ? getEvenSplit(selectedFeature.multivariate_options) : [],
)
}, [selectedFeature, environmentFeatureState])
}, [selectedFeature])

const [createExperiment, { isLoading: isCreating }] =
useCreateExperimentMutation()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { FC, useCallback, useMemo } 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,
Expand All @@ -19,14 +18,9 @@ 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 RefreshControl from './RefreshControl'
import './results.scss'

const AsOfLabel: FC<{ asOf: string | null }> = ({ asOf }) => (
Expand All @@ -45,75 +39,31 @@ const buildLegendLabels = (totals: VariantTotal[]): Record<string, string> => {
return labels
}

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 ExperimentExposuresPanelProps = {
experiment: Experiment
environmentId: string
exposuresOverride?: ExperimentExposures
}

const REFRESH_DISABLED_COPY: Record<string, string> = {
final: 'Refresh is disabled because the experiment is complete.',
not_started: 'Start the experiment to compute exposures.',
}

const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({
environmentId,
experiment,
exposuresOverride,
}) => {
const [pollInterval, setPollInterval] = useState(0)
const [refreshRequested, setRefreshRequested] = useState(false)
const [pollStartedAt, setPollStartedAt] = useState<number | null>(null)
const [retryAfter, startRetryCountdown] = useCountdown()
const { data: fetched } = useGetExperimentExposuresQuery(
{ environmentId, experimentId: experiment.id },
{
pollingInterval: pollInterval,
refetchOnMountOrArgChange: true,
skip: !!exposuresOverride,
},
)
const exposures = exposuresOverride ?? fetched
const [refresh, { isLoading: isSubmitting }] =
useRefreshExperimentExposuresMutation()
const [refreshExposures] = useRefreshExperimentExposuresMutation()

const viewState = deriveExposuresViewState(exposures)
const availability = canRefreshExposures(experiment.status, exposures)
const payload = exposures?.payload ?? null

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 identities = useMemo(
() => getVariantIdentities(experiment.feature),
[experiment.feature],
Expand All @@ -127,61 +77,24 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({
[payload, identities],
)

const isRefreshing =
refreshRequested || viewState.kind === 'refreshing' || isSubmitting
const isRefreshing = viewState.kind === 'refreshing'
const headline = payload ? getHeadlineTotal(payload) : 0
const hasData = !!payload && headline > 0

const handleRefresh = useCallback(async () => {
setRefreshRequested(true)
setPollStartedAt(Date.now())
const result = await refresh({
const handleComputeNow = useCallback(async () => {
const result = await refreshExposures({
environmentId,
experimentId: experiment.id,
})
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 exposures', 'danger')
}
toast('Failed to compute exposures', 'danger')
}
}, [refresh, environmentId, experiment.id, startRetryCountdown])

const refreshLabel = getExposuresRefreshLabel(retryAfter, isRefreshing)

const action = (
<RefreshControl
disabled={!availability.canRefresh || retryAfter !== null}
disabledReason={
availability.reason
? REFRESH_DISABLED_COPY[availability.reason]
: undefined
}
isRefreshing={isRefreshing}
label={
refreshLabel && (
<span
className={
refreshLabel.tone === 'danger' ? 'text-danger' : undefined
}
>
{refreshLabel.message}
</span>
)
}
onRefresh={handleRefresh}
/>
)
}, [refreshExposures, environmentId, experiment.id])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const asOf = exposures?.as_of ?? null

return (
<ContentCard
action={action}
className='experiment-results__exposures-card'
title='Enrollment over time'
>
Expand Down Expand Up @@ -262,7 +175,7 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({
: 'No exposure data computed yet.'}
{!isRefreshing && availability.canRefresh && (
<div className='mt-2'>
<Button onClick={handleRefresh} size='small' theme='secondary'>
<Button onClick={handleComputeNow} size='small' theme='secondary'>
Compute now
</Button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { FC, useCallback, useEffect, useState } from 'react'
import useCountdown from 'common/hooks/useCountdown'
import {
useGetExperimentBayesianResultsQuery,
useGetExperimentExposuresQuery,
useRefreshExperimentBayesianResultsMutation,
useRefreshExperimentExposuresMutation,
} from 'common/services/useExperiment'
import { ExperimentStatus } from 'common/types/responses'
import RefreshControl from './RefreshControl'
Expand All @@ -14,6 +16,7 @@ import {
deriveResultsViewState,
getResultsRefreshLabel,
} from './resultsViewState'
import { deriveExposuresViewState } from './exposuresViewState'

const parseRetryAfter = (err: unknown): number | null => {
const fetchErr = err as {
Expand All @@ -25,6 +28,17 @@ const parseRetryAfter = (err: unknown): number | null => {
return DEFAULT_RETRY_AFTER_S
}

const getMaxRetryAfter = (errors: unknown[]): number | null => {
let max: number | null = null
for (const err of errors) {
const seconds = parseRetryAfter(err)
if (seconds !== null && (max === null || seconds > max)) {
max = seconds
}
}
return max
}

type ExperimentResultsRefreshControlProps = {
environmentId: string
experimentId: number
Expand All @@ -48,27 +62,40 @@ const ExperimentResultsRefreshControl: FC<
{ environmentId, experimentId },
{ pollingInterval: pollInterval },
)
const [refresh, { isLoading: isSubmitting }] =
const { data: exposures } = useGetExperimentExposuresQuery(
{ environmentId, experimentId },
{ pollingInterval: pollInterval },
)
const [refreshResults, { isLoading: isSubmittingResults }] =
useRefreshExperimentBayesianResultsMutation()
const [refreshExposures, { isLoading: isSubmittingExposures }] =
useRefreshExperimentExposuresMutation()

const viewState = deriveResultsViewState(results)
const resultsViewState = deriveResultsViewState(results)
const exposuresViewState = deriveExposuresViewState(exposures)
const availability = canRefreshResults(status, results)

const eitherRefreshing =
resultsViewState.kind === 'refreshing' ||
exposuresViewState.kind === 'refreshing'
const bothSettled =
resultsViewState.kind !== 'refreshing' &&
exposuresViewState.kind !== 'refreshing'

const pollTimedOut =
pollStartedAt !== null && Date.now() - pollStartedAt > POLL_TIMEOUT_MS
const shouldPoll =
!pollTimedOut && (viewState.kind === 'refreshing' || refreshRequested)
const shouldPoll = !pollTimedOut && (eitherRefreshing || refreshRequested)
const nextPollInterval = shouldPoll ? REFRESH_POLL_INTERVAL_MS : 0
useEffect(() => {
setPollInterval(nextPollInterval)
}, [nextPollInterval])

useEffect(() => {
if (viewState.kind === 'loaded' || viewState.kind === 'error') {
if (refreshRequested && bothSettled) {
setRefreshRequested(false)
setPollStartedAt(null)
}
}, [viewState.kind])
}, [refreshRequested, bothSettled])
Comment thread
Zaimwa9 marked this conversation as resolved.

useEffect(() => {
if (pollTimedOut) {
Expand All @@ -78,25 +105,51 @@ const ExperimentResultsRefreshControl: FC<
}, [pollTimedOut])

const isRefreshing =
refreshRequested || viewState.kind === 'refreshing' || isSubmitting
refreshRequested ||
eitherRefreshing ||
isSubmittingResults ||
isSubmittingExposures

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)
const [resultsResult, exposuresResult] = await Promise.all([
refreshResults({ environmentId, experimentId }),
refreshExposures({ environmentId, experimentId }),
])
const errors: unknown[] = []
if ('error' in resultsResult && resultsResult.error) {
errors.push(resultsResult.error)
}
if ('error' in exposuresResult && exposuresResult.error) {
errors.push(exposuresResult.error)
}
if (errors.length > 0) {
const seconds = getMaxRetryAfter(errors)
const hasNonRetryable = errors.some((e) => parseRetryAfter(e) === null)
if (hasNonRetryable) {
toast('Failed to refresh experiment data', 'danger')
}
if (seconds !== null) {
startRetryCountdown(seconds)
} else {
toast('Failed to refresh results', 'danger')
setRefreshRequested(false)
setPollStartedAt(null)
}
}
}, [refresh, environmentId, experimentId, startRetryCountdown])
}, [
refreshResults,
refreshExposures,
environmentId,
experimentId,
startRetryCountdown,
])

const label = getResultsRefreshLabel(retryAfter, isRefreshing, viewState)
const label = getResultsRefreshLabel(
retryAfter,
isRefreshing,
resultsViewState,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<RefreshControl
Expand All @@ -116,7 +169,7 @@ const ExperimentResultsRefreshControl: FC<
}
onRefresh={handleRefresh}
>
Refresh results
Refresh
</RefreshControl>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ 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',
message: 'Refresh available in 1m 30s',
tone: 'muted',
})
})
Expand Down
Loading
Loading