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
26 changes: 22 additions & 4 deletions frontend/common/services/useExperiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
121 changes: 121 additions & 0 deletions frontend/documentation/components/RefreshControl.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RefreshControl> = {
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<typeof RefreshControl>

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: (
<span className='text-danger'>The last results computation failed.</span>
),
theme: 'primary',
},
parameters: {
docs: {
description: {
story:
'Primary theme as used on the experiment results header, with an error message in the `label` slot.',
},
},
},
}
39 changes: 39 additions & 0 deletions frontend/web/components/base/forms/RefreshControl.tsx
Original file line number Diff line number Diff line change
@@ -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<RefreshControlProps> = ({
children,
disabled,
disabledReason,
isRefreshing,
label,
onRefresh,
theme = 'secondary',
}) => (
<div className='d-flex flex-column align-items-end'>
<Button
disabled={disabled || isRefreshing}
onClick={onRefresh}
size='small'
theme={theme}
title={disabled ? disabledReason : undefined}
>
{isRefreshing ? 'Refreshing…' : children ?? 'Refresh'}
</Button>
{label ? (
<div className='text-muted fs-caption mt-1 text-end'>{label}</div>
) : null}
</div>
)

export default RefreshControl

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,9 +24,14 @@ import {
canRefreshExposures,
deriveExposuresViewState,
} from './exposuresViewState'
import AsOfRefreshControl, { AsOfLabel } from './AsOfRefreshControl'
import './results.scss'

const AsOfLabel: FC<{ asOf: string | null }> = ({ asOf }) => (
<span className='text-muted fs-caption'>
{asOf ? `As of ${moment.utc(asOf).format('D MMM YYYY, HH:mm')} UTC` : ''}
</span>
)

const buildLegendLabels = (totals: VariantTotal[]): Record<string, string> => {
const labels: Record<string, string> = {}
totals.forEach((t) => {
Expand Down Expand Up @@ -133,49 +141,46 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({
[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 = (
<div className='d-flex flex-column align-items-end'>
<AsOfRefreshControl
asOf={exposures?.as_of ?? null}
disabled={
!availability.canRefresh || isRefreshing || retryAfter !== null
}
disabledReason={
availability.reason
? REFRESH_DISABLED_COPY[availability.reason]
: undefined
}
isRefreshing={isRefreshing && hasData}
onRefresh={handleRefresh}
/>
{retryAfter !== null && (
<div className='text-muted fs-caption mt-1 text-end'>
Computing, retry in {formatCountdown(retryAfter)}
</div>
)}
</div>
<RefreshControl
disabled={!availability.canRefresh || isRefreshing || retryAfter !== null}
disabledReason={
availability.reason
? REFRESH_DISABLED_COPY[availability.reason]
: undefined
}
isRefreshing={isRefreshing && hasData}
label={
retryAfter !== null
? `Computing, retry in ${formatCountdown(retryAfter)}`
: undefined
}
onRefresh={handleRefresh}
/>
)

const asOf = exposures?.as_of ?? null
Expand Down Expand Up @@ -207,7 +212,7 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({
<>
<br />
<span className='d-inline-flex align-items-center gap-1 text-danger'>
<Icon fill='#e53e3e' name='warning' width={14} />
<Icon fill={colorIconDanger} name='warning' width={14} />
The last exposure computation failed. Showing previously
computed data.
</span>
Expand Down Expand Up @@ -244,7 +249,7 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({
<>
<br />
<span className='d-inline-flex align-items-center gap-1 text-danger'>
<Icon fill='#e53e3e' name='warning' width={14} />
<Icon fill={colorIconDanger} name='warning' width={14} />
The last exposure computation failed. Showing previously
computed data.
</span>
Expand All @@ -256,7 +261,7 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({

{!payload && viewState.kind === 'error' && (
<div className='d-flex align-items-center justify-content-center gap-1 text-danger fs-caption py-4'>
<Icon fill='#e53e3e' name='warning' width={14} />
<Icon fill={colorIconDanger} name='warning' width={14} />
The last exposure computation failed.
</div>
)}
Expand Down
Loading
Loading