-
+
+
)
- }
-
- const totalEstimatedDays = (daysElapsed ?? 0) + remainingDays
- const progress = totalEstimatedDays > 0 ? ((daysElapsed ?? 0) / totalEstimatedDays) * 100 : 0
-
- return (
-
-
-
-
-
- )
- },
- },
- {
- title: 'Status',
- key: 'status',
- render: function Render(_, experiment: Experiment) {
- return
- },
- align: 'center',
- sorter: (a, b) => {
- const statusA = getExperimentStatus(a)
- const statusB = getExperimentStatus(b)
-
- const score: Record
= {
- [ExperimentStatus.Draft]: 1,
- [ExperimentStatus.Running]: 2,
- [ExperimentStatus.Paused]: 3,
- [ExperimentStatus.ExposureFrozen]: 4,
- [ExperimentStatus.Stopped]: 5,
- }
- return score[statusA] > score[statusB] ? 1 : -1
- },
- },
- {
- title: 'Result',
- key: 'conclusion',
- render: function Render(_, experiment: Experiment) {
- if (!experiment.conclusion) {
- return —
- }
- const config = CONCLUSION_DISPLAY_CONFIG[experiment.conclusion]
- const tooltip = experiment.conclusion_comment
- ? `${config.description} — ${experiment.conclusion_comment}`
- : config.description
- return (
-
-
-
- )
- },
- align: 'left',
- sorter: (a, b) => {
- const conclusionScore: Record = {
- [ExperimentConclusion.Won]: 1,
- [ExperimentConclusion.Lost]: 2,
- [ExperimentConclusion.Inconclusive]: 3,
- [ExperimentConclusion.StoppedEarly]: 4,
- [ExperimentConclusion.Invalid]: 5,
- }
- const aScore = a.conclusion ? conclusionScore[a.conclusion] : 6
- const bScore = b.conclusion ? conclusionScore[b.conclusion] : 6
- return aScore - bScore
+ },
+ align: 'left',
+ sorter: (a, b) => {
+ const conclusionScore: Record = {
+ [ExperimentConclusion.Won]: 1,
+ [ExperimentConclusion.Lost]: 2,
+ [ExperimentConclusion.Inconclusive]: 3,
+ [ExperimentConclusion.StoppedEarly]: 4,
+ [ExperimentConclusion.Invalid]: 5,
+ }
+ const aScore = a.conclusion ? conclusionScore[a.conclusion] : 6
+ const bScore = b.conclusion ? conclusionScore[b.conclusion] : 6
+ return aScore - bScore
+ },
},
- },
- {
- width: 0,
- render: function Render(_, experiment: Experiment) {
- return (
-
-
- View
-
- openDuplicateModal(experiment)}
- size="small"
- fullWidth
- disabledReason={
- experiment.is_legacy
- ? 'Not supported for experiments using legacy metrics. Please recreate the experiment manually.'
- : undefined
- }
- >
- Duplicate
-
- {hasMultipleProjects && (
+ {
+ width: 0,
+ render: function Render(_, experiment: Experiment) {
+ return (
+
+
+ View
+
openCopyToProjectModal(experiment)}
+ onClick={() => openDuplicateModal(experiment)}
size="small"
fullWidth
disabledReason={
experiment.is_legacy
- ? 'Copying is not supported for experiments using legacy metrics.'
+ ? 'Not supported for experiments using legacy metrics. Please recreate the experiment manually.'
: undefined
}
>
- Copy to project
+ Duplicate
- )}
- {
- openSurveyModal(experiment)
- void addProductIntentForCrossSell({
- from: ProductKey.EXPERIMENTS,
- to: ProductKey.SURVEYS,
- intent_context: ProductIntentContext.QUICK_SURVEY_STARTED,
- })
- }}
- />
- {canArchiveExperiment(experiment) && (
-
+ {hasMultipleProjects && (
- confirmArchiveExperiment(experiment, (disableFlag) =>
- archiveExperiment({
- id: experiment.id as number,
- disableFeatureFlag: disableFlag,
- })
- )
- }
- data-attr={`experiment-${experiment.id}-dropdown-archive`}
+ onClick={() => openCopyToProjectModal(experiment)}
+ size="small"
fullWidth
+ disabledReason={
+ experiment.is_legacy
+ ? 'Copying is not supported for experiments using legacy metrics.'
+ : undefined
+ }
>
- Archive experiment
+ Copy to project
-
- )}
- {experiment.archived && (
+ )}
+ {
+ openSurveyModal(experiment)
+ void addProductIntentForCrossSell({
+ from: ProductKey.EXPERIMENTS,
+ to: ProductKey.SURVEYS,
+ intent_context: ProductIntentContext.QUICK_SURVEY_STARTED,
+ })
+ }}
+ />
+ {canArchiveExperiment(experiment) && (
+
+
+ confirmArchiveExperiment(experiment, (disableFlag) =>
+ archiveExperiment({
+ id: experiment.id as number,
+ disableFeatureFlag: disableFlag,
+ })
+ )
+ }
+ data-attr={`experiment-${experiment.id}-dropdown-archive`}
+ fullWidth
+ >
+ Archive experiment
+
+
+ )}
+ {experiment.archived && (
+
+ unarchiveExperiment(experiment.id as number)}
+ data-attr={`experiment-${experiment.id}-dropdown-unarchive`}
+ fullWidth
+ >
+ Unarchive experiment
+
+
+ )}
+
unarchiveExperiment(experiment.id as number)}
- data-attr={`experiment-${experiment.id}-dropdown-unarchive`}
+ status="danger"
+ onClick={() =>
+ confirmDeleteExperiment({
+ projectId: currentProjectId,
+ experiment,
+ onDelete: () => loadExperiments(),
+ })
+ }
+ data-attr={`experiment-${experiment.id}-dropdown-remove`}
fullWidth
>
- Unarchive experiment
+ Delete experiment
- )}
-
-
-
- confirmDeleteExperiment({
- projectId: currentProjectId,
- experiment,
- onDelete: () => loadExperiments(),
- })
- }
- data-attr={`experiment-${experiment.id}-dropdown-remove`}
- fullWidth
- >
- Delete experiment
-
-
- >
- }
- />
- )
+ >
+ }
+ />
+ )
+ },
},
- },
- ]
+ ],
+ [
+ openDuplicateModal,
+ openSurveyModal,
+ openCopyToProjectModal,
+ hasMultipleProjects,
+ runningTimeEstimates,
+ runningTimeEstimatesLoading,
+ ]
+ )
return (
diff --git a/products/experiments/frontend/scenes/experimentsLogic.ts b/products/experiments/frontend/scenes/experimentsLogic.ts
index e8b5a826900e..deaa0d7a7a74 100644
--- a/products/experiments/frontend/scenes/experimentsLogic.ts
+++ b/products/experiments/frontend/scenes/experimentsLogic.ts
@@ -27,6 +27,10 @@ import {
import type { AvailableFeature, UserType } from '~/types'
import type { TeamPublicType, TeamType } from '~/types'
+import { getExperimentStatus } from 'products/experiments/frontend/experimentStatus'
+import { experimentsRunningTimeEstimatesRetrieve } from 'products/experiments/frontend/generated/api'
+import type { RunningTimeEstimatesResponseApiResults } from 'products/experiments/frontend/generated/api.schemas'
+
export const EXPERIMENTS_PER_PAGE = 100
export interface ExperimentsResult extends CountedPaginatedResponse {
@@ -181,6 +185,8 @@ export interface experimentsLogicValues {
search?: string | undefined
status?: ExperimentStatus | 'all' | undefined
}
+ runningTimeEstimates: RunningTimeEstimatesResponseApiResults
+ runningTimeEstimatesLoading: boolean
sidePanelContext: SidePanelSceneContext
tab: ExperimentsTabs
unavailableFeatureFlagKeys: Set
@@ -398,6 +404,21 @@ export interface experimentsLogicActions {
}
payload?: void
}
+ loadRunningTimeEstimates: (experimentIds: number[]) => number[]
+ loadRunningTimeEstimatesFailure: (
+ error: string,
+ errorObject?: any
+ ) => {
+ error: string
+ errorObject?: any
+ }
+ loadRunningTimeEstimatesSuccess: (
+ runningTimeEstimates: RunningTimeEstimatesResponseApiResults,
+ payload?: number[]
+ ) => {
+ runningTimeEstimates: RunningTimeEstimatesResponseApiResults
+ payload?: number[]
+ }
openFeatureFlagModal: () => {
value: true
}
@@ -590,6 +611,15 @@ export const experimentsLogic = kea([
}
actions.loadExperiments()
},
+ loadExperimentsSuccess: ({ experiments }) => {
+ // Remaining time is estimated on demand, not stored, so fetch it for the running rows on this page.
+ // Running experiments are persisted, so their id is always numeric (never 'new'/'web').
+ const runningIds = experiments.results
+ .filter((experiment) => getExperimentStatus(experiment) === ExperimentStatus.Running)
+ .map((experiment) => experiment.id)
+ .filter((id): id is number => typeof id === 'number')
+ actions.loadRunningTimeEstimates(runningIds)
+ },
setFeatureFlagModalFilters: async (_, breakpoint) => {
await breakpoint(300)
actions.loadFeatureFlagModalFeatureFlags()
@@ -722,6 +752,20 @@ export const experimentsLogic = kea([
},
},
],
+ runningTimeEstimates: [
+ {} as RunningTimeEstimatesResponseApiResults,
+ {
+ loadRunningTimeEstimates: async (experimentIds: number[]) => {
+ if (experimentIds.length === 0) {
+ return {}
+ }
+ const response = await experimentsRunningTimeEstimatesRetrieve(String(values.currentProjectId), {
+ ids: experimentIds.join(','),
+ })
+ return response.results
+ },
+ },
+ ],
featureFlagModalFeatureFlags: [
{ results: [], count: 0 } as { results: FeatureFlagType[]; count: number },
{