@delmaredigital/payload-experiments 0.1.0-rc.7 → 0.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/admin/LayerUsage.d.ts +1 -1
- package/dist/admin/LayerUsage.js +23 -11
- package/dist/admin/MetricSourceSelect.js +2 -1
- package/dist/admin/TestsNavLink.d.ts +1 -0
- package/dist/admin/TestsNavLink.js +30 -0
- package/dist/admin/allocation.model.js +1 -1
- package/dist/admin/copy/actions.d.ts +0 -2
- package/dist/admin/copy/actions.js +0 -2
- package/dist/admin/copy/advanced.d.ts +55 -0
- package/dist/admin/copy/advanced.js +55 -0
- package/dist/admin/copy/armSelect.js +1 -1
- package/dist/admin/copy/arms.d.ts +0 -13
- package/dist/admin/copy/arms.js +0 -13
- package/dist/admin/copy/{who.d.ts → audience.d.ts} +1 -9
- package/dist/admin/copy/{who.js → audience.js} +1 -9
- package/dist/admin/copy/history.d.ts +0 -4
- package/dist/admin/copy/history.js +3 -7
- package/dist/admin/copy/metrics.d.ts +2 -2
- package/dist/admin/copy/metrics.js +2 -2
- package/dist/admin/copy/newTest.d.ts +32 -0
- package/dist/admin/copy/newTest.js +32 -0
- package/dist/admin/copy/readiness.d.ts +45 -0
- package/dist/admin/copy/readiness.js +50 -0
- package/dist/admin/copy/recommendation.d.ts +26 -0
- package/dist/admin/copy/recommendation.js +25 -0
- package/dist/admin/copy/results.d.ts +0 -1
- package/dist/admin/copy/results.js +0 -1
- package/dist/admin/copy/review.d.ts +0 -17
- package/dist/admin/copy/review.js +1 -112
- package/dist/admin/copy/setting.d.ts +10 -0
- package/dist/admin/copy/setting.js +10 -0
- package/dist/admin/copy/surface.d.ts +36 -0
- package/dist/admin/copy/surface.js +47 -0
- package/dist/admin/copy/testPage.d.ts +88 -0
- package/dist/admin/copy/testPage.js +88 -0
- package/dist/admin/paths.d.ts +5 -13
- package/dist/admin/paths.js +5 -13
- package/dist/admin/review/review.model.d.ts +3 -26
- package/dist/admin/review/review.model.js +3 -30
- package/dist/admin/surface/ResultsRoute.d.ts +7 -0
- package/dist/admin/surface/ResultsRoute.js +58 -0
- package/dist/admin/surface/SurfaceShell.d.ts +6 -0
- package/dist/admin/surface/SurfaceShell.js +35 -0
- package/dist/admin/surface/TestsHome.d.ts +4 -0
- package/dist/admin/surface/TestsHome.js +212 -0
- package/dist/admin/surface/TestsSurface.d.ts +2 -0
- package/dist/admin/surface/TestsSurface.js +36 -0
- package/dist/admin/surface/contracts.d.ts +48 -0
- package/dist/admin/surface/contracts.js +8 -0
- package/dist/admin/surface/home.model.d.ts +41 -0
- package/dist/admin/surface/home.model.js +59 -0
- package/dist/admin/surface/newTest/NewTest.d.ts +7 -0
- package/dist/admin/surface/newTest/NewTest.js +355 -0
- package/dist/admin/surface/newTest/newTest.css +9 -0
- package/dist/admin/surface/pageBlock/pageBlock.model.d.ts +6 -0
- package/dist/admin/surface/pageBlock/pageBlock.model.js +8 -0
- package/dist/admin/surface/pageBlock/parts.d.ts +2 -0
- package/dist/admin/surface/pageBlock/parts.js +64 -0
- package/dist/admin/surface/readiness/ActionDialogs.d.ts +15 -0
- package/dist/admin/surface/readiness/ActionDialogs.js +274 -0
- package/dist/admin/surface/readiness/Banner.d.ts +2 -0
- package/dist/admin/surface/readiness/Banner.js +138 -0
- package/dist/admin/surface/readiness/SaveStatus.d.ts +4 -0
- package/dist/admin/surface/readiness/SaveStatus.js +50 -0
- package/dist/admin/surface/readiness/parts.d.ts +2 -0
- package/dist/admin/surface/readiness/parts.js +9 -0
- package/dist/admin/surface/readiness/readiness.model.d.ts +22 -0
- package/dist/admin/surface/readiness/readiness.model.js +107 -0
- package/dist/admin/surface/readiness/useTransition.d.ts +10 -0
- package/dist/admin/surface/readiness/useTransition.js +34 -0
- package/dist/admin/surface/results/ResultsBody.d.ts +7 -0
- package/dist/admin/surface/results/ResultsBody.js +262 -0
- package/dist/admin/surface/results/recommendation.model.d.ts +12 -0
- package/dist/admin/surface/results/recommendation.model.js +59 -0
- package/dist/admin/surface/results/useResults.d.ts +56 -0
- package/dist/admin/surface/results/useResults.js +183 -0
- package/dist/admin/surface/setting/parts.d.ts +3 -0
- package/dist/admin/surface/setting/parts.js +263 -0
- package/dist/admin/surface/setting/setting.css +29 -0
- package/dist/admin/surface/setting/setting.model.d.ts +8 -0
- package/dist/admin/surface/setting/setting.model.js +25 -0
- package/dist/admin/surface/surface.css +50 -0
- package/dist/admin/surface/test/AdvancedSection.d.ts +2 -0
- package/dist/admin/surface/test/AdvancedSection.js +153 -0
- package/dist/admin/surface/test/GoalSection.d.ts +2 -0
- package/dist/admin/surface/test/GoalSection.js +198 -0
- package/dist/admin/surface/test/LengthSection.d.ts +2 -0
- package/dist/admin/surface/test/LengthSection.js +87 -0
- package/dist/admin/surface/test/Section.d.ts +7 -0
- package/dist/admin/surface/test/Section.js +27 -0
- package/dist/admin/surface/test/TargetingSection.d.ts +2 -0
- package/dist/admin/surface/test/TargetingSection.js +164 -0
- package/dist/admin/surface/test/TestHeader.d.ts +4 -0
- package/dist/admin/surface/test/TestHeader.js +69 -0
- package/dist/admin/surface/test/TestPage.d.ts +7 -0
- package/dist/admin/surface/test/TestPage.js +84 -0
- package/dist/admin/surface/test/VersionsSection.d.ts +4 -0
- package/dist/admin/surface/test/VersionsSection.js +208 -0
- package/dist/admin/surface/test/fields.d.ts +55 -0
- package/dist/admin/surface/test/fields.js +185 -0
- package/dist/admin/surface/test/pageParts.d.ts +2 -0
- package/dist/admin/surface/test/pageParts.js +39 -0
- package/dist/admin/surface/test/testPage.css +23 -0
- package/dist/admin/surface/test/testPage.model.d.ts +17 -0
- package/dist/admin/surface/test/testPage.model.js +53 -0
- package/dist/admin/surface/testDocument.model.d.ts +39 -0
- package/dist/admin/surface/testDocument.model.js +61 -0
- package/dist/admin/surface/useTestDocument.d.ts +22 -0
- package/dist/admin/surface/useTestDocument.js +345 -0
- package/dist/admin/targeting/targeting.model.js +2 -2
- package/dist/admin/useLayerUsage.d.ts +0 -5
- package/dist/admin/useLayerUsage.js +1 -39
- package/dist/admin/views/CollectionEditRedirect.d.ts +4 -0
- package/dist/admin/views/CollectionEditRedirect.js +14 -0
- package/dist/admin/views/CollectionListRedirect.d.ts +2 -0
- package/dist/admin/views/CollectionListRedirect.js +8 -0
- package/dist/admin/views/ExperimentsRootView.d.ts +2 -0
- package/dist/admin/views/ExperimentsRootView.js +175 -0
- package/dist/admin/views/access.model.d.ts +7 -0
- package/dist/admin/views/access.model.js +5 -0
- package/dist/collections/experiments/config.js +18 -125
- package/dist/collections/experiments/createDefaults.js +6 -0
- package/dist/collections/layers.js +43 -12
- package/dist/collections/metrics.js +70 -14
- package/dist/collections/serialize.js +4 -1
- package/dist/db/snapshots.d.ts +2 -1
- package/dist/db/snapshots.js +2 -2
- package/dist/endpoints/results.js +7 -0
- package/dist/endpoints/summary.d.ts +1 -1
- package/dist/endpoints/summary.js +37 -1
- package/dist/exports/adminClient.d.ts +2 -13
- package/dist/exports/adminClient.js +2 -13
- package/dist/exports/adminRsc.d.ts +3 -0
- package/dist/exports/adminRsc.js +3 -0
- package/dist/exports/index.d.ts +1 -1
- package/dist/options.js +29 -0
- package/dist/plugin.js +25 -0
- package/dist/puck/editor/CreateExperimentDrawer.d.ts +2 -1
- package/dist/puck/editor/CreateExperimentDrawer.js +17 -57
- package/dist/puck/editor/ExperimentSlotEditor.js +6 -0
- package/dist/puck/editor/copy.d.ts +10 -9
- package/dist/puck/editor/copy.js +11 -10
- package/dist/puck/editor/createTest.d.ts +7 -0
- package/dist/puck/editor/createTest.js +50 -0
- package/dist/puck/editor/editor.model.d.ts +28 -1
- package/dist/puck/editor/editor.model.js +61 -0
- package/dist/results/analyse.js +4 -1
- package/dist/results/verdict.d.ts +1 -0
- package/dist/results/verdict.js +7 -4
- package/dist/transitions/checkStart.d.ts +1 -0
- package/dist/transitions/placements.d.ts +5 -1
- package/dist/transitions/placements.js +14 -7
- package/dist/transitions/preflightData.d.ts +7 -1
- package/dist/transitions/preflightData.js +199 -148
- package/dist/transitions/start.js +2 -2
- package/dist/types/admin.d.ts +1 -0
- package/dist/types/contracts.d.ts +17 -0
- package/dist/types/endpoints.d.ts +11 -1
- package/dist/types/snapshot.d.ts +1 -0
- package/dist/types/surface.d.ts +29 -0
- package/dist/types/surface.js +1 -0
- package/package.json +7 -1
- package/dist/access.d.ts +0 -1
- package/dist/access.js +0 -2
- package/dist/admin/AllocationField.d.ts +0 -2
- package/dist/admin/AllocationField.js +0 -80
- package/dist/admin/FlagPointSelect.d.ts +0 -2
- package/dist/admin/FlagPointSelect.js +0 -54
- package/dist/admin/LockedFieldNotice.d.ts +0 -2
- package/dist/admin/LockedFieldNotice.js +0 -26
- package/dist/admin/PhaseHistory.d.ts +0 -2
- package/dist/admin/PhaseHistory.js +0 -76
- package/dist/admin/StatusCell.d.ts +0 -3
- package/dist/admin/StatusCell.js +0 -19
- package/dist/admin/SummaryCell.d.ts +0 -2
- package/dist/admin/SummaryCell.js +0 -32
- package/dist/admin/actions/AddMetricDialog.d.ts +0 -6
- package/dist/admin/actions/AddMetricDialog.js +0 -138
- package/dist/admin/actions/AllocateDialog.d.ts +0 -6
- package/dist/admin/actions/AllocateDialog.js +0 -81
- package/dist/admin/actions/ExperimentActions.d.ts +0 -3
- package/dist/admin/actions/ExperimentActions.js +0 -110
- package/dist/admin/arms/ArmsField.d.ts +0 -2
- package/dist/admin/arms/ArmsField.js +0 -310
- package/dist/admin/arms/ParamsEditor.d.ts +0 -16
- package/dist/admin/arms/ParamsEditor.js +0 -79
- package/dist/admin/copy/tabs.d.ts +0 -7
- package/dist/admin/copy/tabs.js +0 -7
- package/dist/admin/permissions.model.d.ts +0 -10
- package/dist/admin/permissions.model.js +0 -4
- package/dist/admin/phaseHistory.model.d.ts +0 -19
- package/dist/admin/phaseHistory.model.js +0 -19
- package/dist/admin/results/ResultsView.d.ts +0 -3
- package/dist/admin/results/ResultsView.js +0 -312
- package/dist/admin/review/ReviewPanel.d.ts +0 -2
- package/dist/admin/review/ReviewPanel.js +0 -268
- package/dist/admin/summaryBatcher.d.ts +0 -8
- package/dist/admin/summaryBatcher.js +0 -33
- package/dist/admin/targeting/TargetingAttributeField.d.ts +0 -2
- package/dist/admin/targeting/TargetingAttributeField.js +0 -31
- package/dist/admin/targeting/TargetingOpField.d.ts +0 -2
- package/dist/admin/targeting/TargetingOpField.js +0 -45
- package/dist/admin/targeting/TargetingValueField.d.ts +0 -2
- package/dist/admin/targeting/TargetingValueField.js +0 -142
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import '../../styles.js';
|
|
2
|
+
import type { DocId } from '../../../types/contracts.js';
|
|
3
|
+
import type { SurfaceProps } from '../contracts.js';
|
|
4
|
+
export declare function ResultsBody({ id, surface }: {
|
|
5
|
+
id: DocId;
|
|
6
|
+
surface: SurfaceProps;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import '../../styles.js';
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { Button } from '@payloadcms/ui';
|
|
6
|
+
import { RESULTS_COPY as C } from '../../copy/results.js';
|
|
7
|
+
import { RESULTS_SURFACE_COPY } from '../../copy/recommendation.js';
|
|
8
|
+
import { ACTIONS_COPY } from '../../copy/actions.js';
|
|
9
|
+
import { API_MESSAGES } from '../../copy/common.js';
|
|
10
|
+
import { armLabel } from '../../labels.js';
|
|
11
|
+
import { PreviewLinks } from '../../PreviewLinks.js';
|
|
12
|
+
import { VerdictCard } from '../../results/VerdictCard.js';
|
|
13
|
+
import { ArmTable } from '../../results/ArmTable.js';
|
|
14
|
+
import { DataQualityPanel } from '../../results/DataQualityPanel.js';
|
|
15
|
+
import { CiChart } from '../../results/CiChart.js';
|
|
16
|
+
import { armRows, collectingLine, dataQuality, defaultRound, guardrailChips, powerLine, rolloutLine, roundOptions, verdictCard } from '../../results/results.model.js';
|
|
17
|
+
import { recommendation } from './recommendation.model.js';
|
|
18
|
+
import { useResults } from './useResults.js';
|
|
19
|
+
import { DecideDialog } from '../../actions/DecideDialog.js';
|
|
20
|
+
export function ResultsBody({ id, surface }) {
|
|
21
|
+
// A different test owns an independent selection and action lifecycle.
|
|
22
|
+
return /*#__PURE__*/ _jsx(ResultsContent, {
|
|
23
|
+
id: id,
|
|
24
|
+
surface: surface
|
|
25
|
+
}, `${surface.apiRoute}/${id}`);
|
|
26
|
+
}
|
|
27
|
+
function ResultsContent({ id, surface }) {
|
|
28
|
+
const results = useResults(id, surface);
|
|
29
|
+
const { api, doc, round, phases, state, data } = results;
|
|
30
|
+
const manage = surface.canManage && surface.canReadResults;
|
|
31
|
+
const [placements, setPlacements] = useState(null);
|
|
32
|
+
const [previewError, setPreviewError] = useState(null);
|
|
33
|
+
const [previewRefresh, setPreviewRefresh] = useState(0);
|
|
34
|
+
const [busy, setBusy] = useState(false);
|
|
35
|
+
const inFlight = useRef(false);
|
|
36
|
+
const mounted = useRef(true);
|
|
37
|
+
const [actionError, setActionError] = useState(null);
|
|
38
|
+
const [deciding, setDeciding] = useState(false);
|
|
39
|
+
useEffect(()=>{
|
|
40
|
+
mounted.current = true;
|
|
41
|
+
return ()=>{
|
|
42
|
+
mounted.current = false;
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
useEffect(()=>{
|
|
46
|
+
let active = true;
|
|
47
|
+
setPlacements(null);
|
|
48
|
+
setPreviewError(null);
|
|
49
|
+
if (manage) void api.preflight(id).then((result)=>{
|
|
50
|
+
if (!active) return;
|
|
51
|
+
if (result.ok && result.data) setPlacements(result.data.placements);
|
|
52
|
+
else setPreviewError(result.ok ? API_MESSAGES.unreadable : result.message);
|
|
53
|
+
});
|
|
54
|
+
return ()=>{
|
|
55
|
+
active = false;
|
|
56
|
+
};
|
|
57
|
+
}, [
|
|
58
|
+
api,
|
|
59
|
+
id,
|
|
60
|
+
manage,
|
|
61
|
+
previewRefresh
|
|
62
|
+
]);
|
|
63
|
+
async function recompute() {
|
|
64
|
+
if (!manage || inFlight.current) return;
|
|
65
|
+
inFlight.current = true;
|
|
66
|
+
setBusy(true);
|
|
67
|
+
setActionError(null);
|
|
68
|
+
try {
|
|
69
|
+
const result = await api.recompute(id);
|
|
70
|
+
if (!mounted.current) return;
|
|
71
|
+
if (result.ok) results.reload();
|
|
72
|
+
else setActionError(result.message);
|
|
73
|
+
} finally{
|
|
74
|
+
inFlight.current = false;
|
|
75
|
+
if (mounted.current) setBusy(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const phase = data?.phases.find((phase)=>phase.phase === round);
|
|
79
|
+
const snapshot = data?.snapshot;
|
|
80
|
+
const newestRound = defaultRound({
|
|
81
|
+
phases,
|
|
82
|
+
status: 'running'
|
|
83
|
+
});
|
|
84
|
+
const latestPhase = phases.find((phase)=>phase.phase === newestRound);
|
|
85
|
+
const decision = data?.decision ?? doc?.decision;
|
|
86
|
+
const status = doc?.status ?? (data?.decision ? 'decided' : latestPhase?.phaseKind === 'test' && latestPhase.endedAt === null ? 'running' : 'stopped');
|
|
87
|
+
// ADM-06b: an older round never inherits the live test's running status.
|
|
88
|
+
const lineStatus = status === 'running' && round === newestRound ? 'running' : 'stopped';
|
|
89
|
+
const isDecidedTestRound = status === 'decided' && phase?.phaseKind === 'test' && decision?.phase === round;
|
|
90
|
+
const winner = phase?.arms.find((arm)=>arm.key === decision?.winnerArm)?.label ?? armLabel(decision?.winnerArm ?? 'control');
|
|
91
|
+
const card = snapshot ? verdictCard({
|
|
92
|
+
snapshot,
|
|
93
|
+
decisionSnapshot: data?.decisionSnapshot,
|
|
94
|
+
isDecidedTestRound,
|
|
95
|
+
winnerLabel: winner
|
|
96
|
+
}) : null;
|
|
97
|
+
const context = {
|
|
98
|
+
phase,
|
|
99
|
+
inLayer: doc?.layer != null,
|
|
100
|
+
layerSlices: doc?.layerSlices,
|
|
101
|
+
allocation: doc?.allocation
|
|
102
|
+
};
|
|
103
|
+
const primary = phase?.metrics.find((metric)=>metric.role === 'primary');
|
|
104
|
+
// The endpoint decides the current round; historical rounds are read-only.
|
|
105
|
+
const canDecide = manage && (status === 'running' || status === 'stopped') && phase?.phaseKind === 'test' && round === newestRound && state === 'ready' && Boolean(snapshot);
|
|
106
|
+
return /*#__PURE__*/ _jsxs("div", {
|
|
107
|
+
className: "xp-results",
|
|
108
|
+
style: {
|
|
109
|
+
color: 'var(--theme-text)'
|
|
110
|
+
},
|
|
111
|
+
children: [
|
|
112
|
+
phases.length > 0 ? /*#__PURE__*/ _jsxs("label", {
|
|
113
|
+
children: [
|
|
114
|
+
C.round,
|
|
115
|
+
/*#__PURE__*/ _jsx("select", {
|
|
116
|
+
disabled: deciding,
|
|
117
|
+
value: round ?? '',
|
|
118
|
+
onChange: (event)=>results.setRound(Number(event.target.value)),
|
|
119
|
+
children: roundOptions(phases).map((option)=>/*#__PURE__*/ _jsx("option", {
|
|
120
|
+
value: option.value,
|
|
121
|
+
children: option.label
|
|
122
|
+
}, option.value))
|
|
123
|
+
})
|
|
124
|
+
]
|
|
125
|
+
}) : null,
|
|
126
|
+
state === 'loading' ? /*#__PURE__*/ _jsx("p", {
|
|
127
|
+
role: "status",
|
|
128
|
+
children: data ? C.refreshing : C.loading
|
|
129
|
+
}) : state === 'forbidden' ? /*#__PURE__*/ _jsx("p", {
|
|
130
|
+
role: "alert",
|
|
131
|
+
children: C.forbidden
|
|
132
|
+
}) : state === 'missing' ? /*#__PURE__*/ _jsx("p", {
|
|
133
|
+
role: "alert",
|
|
134
|
+
children: RESULTS_SURFACE_COPY.missing
|
|
135
|
+
}) : state === 'error' ? /*#__PURE__*/ _jsxs(_Fragment, {
|
|
136
|
+
children: [
|
|
137
|
+
/*#__PURE__*/ _jsx("p", {
|
|
138
|
+
role: "alert",
|
|
139
|
+
children: results.error
|
|
140
|
+
}),
|
|
141
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
142
|
+
onClick: results.reload,
|
|
143
|
+
children: C.retry
|
|
144
|
+
})
|
|
145
|
+
]
|
|
146
|
+
}) : null,
|
|
147
|
+
phase && snapshot ? /*#__PURE__*/ _jsxs(_Fragment, {
|
|
148
|
+
children: [
|
|
149
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
150
|
+
style: {
|
|
151
|
+
background: 'var(--theme-elevation-50)',
|
|
152
|
+
paddingTop: '1rem'
|
|
153
|
+
},
|
|
154
|
+
children: [
|
|
155
|
+
/*#__PURE__*/ _jsx("p", {
|
|
156
|
+
className: "xp-recommendation",
|
|
157
|
+
style: {
|
|
158
|
+
margin: '0 1rem'
|
|
159
|
+
},
|
|
160
|
+
children: recommendation({
|
|
161
|
+
snapshot,
|
|
162
|
+
phase,
|
|
163
|
+
status: lineStatus,
|
|
164
|
+
plannedDurationDays: round === newestRound ? doc?.analysis?.plannedDurationDays : undefined
|
|
165
|
+
})
|
|
166
|
+
}),
|
|
167
|
+
/*#__PURE__*/ _jsx(VerdictCard, {
|
|
168
|
+
model: card,
|
|
169
|
+
guardrails: guardrailChips(snapshot),
|
|
170
|
+
power: powerLine({
|
|
171
|
+
snapshot,
|
|
172
|
+
phaseKind: phase.phaseKind,
|
|
173
|
+
status: lineStatus
|
|
174
|
+
}),
|
|
175
|
+
collecting: collectingLine({
|
|
176
|
+
snapshot,
|
|
177
|
+
phaseKind: phase.phaseKind,
|
|
178
|
+
status: lineStatus
|
|
179
|
+
}, context),
|
|
180
|
+
sinceShipping: rolloutLine(snapshot, phase, phases, decision ?? undefined)
|
|
181
|
+
})
|
|
182
|
+
]
|
|
183
|
+
}),
|
|
184
|
+
/*#__PURE__*/ _jsx(DataQualityPanel, {
|
|
185
|
+
items: dataQuality(snapshot, context)
|
|
186
|
+
}),
|
|
187
|
+
[
|
|
188
|
+
'primary',
|
|
189
|
+
'secondary',
|
|
190
|
+
'guardrail'
|
|
191
|
+
].map((role)=>{
|
|
192
|
+
const metrics = phase.metrics.filter((metric)=>metric.role === role);
|
|
193
|
+
return metrics.length ? /*#__PURE__*/ _jsxs("section", {
|
|
194
|
+
children: [
|
|
195
|
+
/*#__PURE__*/ _jsx("h2", {
|
|
196
|
+
children: C[role]
|
|
197
|
+
}),
|
|
198
|
+
metrics.map((metric)=>/*#__PURE__*/ _jsx(ArmTable, {
|
|
199
|
+
caption: metric.name,
|
|
200
|
+
method: snapshot.method,
|
|
201
|
+
metricKind: metric.kind,
|
|
202
|
+
rows: armRows({
|
|
203
|
+
snapshot,
|
|
204
|
+
phase,
|
|
205
|
+
metricId: metric.metricId
|
|
206
|
+
})
|
|
207
|
+
}, metric.metricId))
|
|
208
|
+
]
|
|
209
|
+
}, role) : null;
|
|
210
|
+
}),
|
|
211
|
+
primary ? /*#__PURE__*/ _jsx(CiChart, {
|
|
212
|
+
series: data?.series,
|
|
213
|
+
metricId: primary.metricId,
|
|
214
|
+
metricName: primary.name,
|
|
215
|
+
phase: phase,
|
|
216
|
+
contentChanges: snapshot.contentChanges
|
|
217
|
+
}) : null
|
|
218
|
+
]
|
|
219
|
+
}) : state === 'empty' || state === 'ready' ? /*#__PURE__*/ _jsx("p", {
|
|
220
|
+
children: C.empty
|
|
221
|
+
}) : null,
|
|
222
|
+
manage && placements ? /*#__PURE__*/ _jsx(PreviewLinks, {
|
|
223
|
+
experimentId: id,
|
|
224
|
+
placements: placements,
|
|
225
|
+
arms: doc?.arms ?? []
|
|
226
|
+
}) : null,
|
|
227
|
+
manage && previewError ? /*#__PURE__*/ _jsxs(_Fragment, {
|
|
228
|
+
children: [
|
|
229
|
+
/*#__PURE__*/ _jsx("p", {
|
|
230
|
+
role: "alert",
|
|
231
|
+
children: previewError
|
|
232
|
+
}),
|
|
233
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
234
|
+
onClick: ()=>setPreviewRefresh((value)=>value + 1),
|
|
235
|
+
children: C.retryPreview
|
|
236
|
+
})
|
|
237
|
+
]
|
|
238
|
+
}) : null,
|
|
239
|
+
manage ? /*#__PURE__*/ _jsx(Button, {
|
|
240
|
+
disabled: busy || deciding,
|
|
241
|
+
onClick: ()=>{
|
|
242
|
+
void recompute();
|
|
243
|
+
},
|
|
244
|
+
children: busy ? C.recomputing : C.recompute
|
|
245
|
+
}) : null,
|
|
246
|
+
canDecide ? /*#__PURE__*/ _jsx(Button, {
|
|
247
|
+
disabled: busy || deciding,
|
|
248
|
+
onClick: ()=>setDeciding(true),
|
|
249
|
+
children: ACTIONS_COPY.decide
|
|
250
|
+
}) : null,
|
|
251
|
+
deciding && manage && phase ? /*#__PURE__*/ _jsx(DecideDialog, {
|
|
252
|
+
id: id,
|
|
253
|
+
arms: phase.arms,
|
|
254
|
+
onClose: ()=>setDeciding(false)
|
|
255
|
+
}) : null,
|
|
256
|
+
actionError ? /*#__PURE__*/ _jsx("p", {
|
|
257
|
+
role: "alert",
|
|
258
|
+
children: actionError
|
|
259
|
+
}) : null
|
|
260
|
+
]
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PhaseSummary } from '../../../types/endpoints.js';
|
|
2
|
+
import type { Status } from '../../../types/contracts.js';
|
|
3
|
+
import type { Snapshot, VerdictCode } from '../../../types/snapshot.js';
|
|
4
|
+
export interface RecommendationContext {
|
|
5
|
+
snapshot: Snapshot;
|
|
6
|
+
phase?: PhaseSummary;
|
|
7
|
+
plannedDurationDays?: number | null;
|
|
8
|
+
status?: Status;
|
|
9
|
+
}
|
|
10
|
+
/** Select stored findings and format them; never run inference in the admin. */
|
|
11
|
+
export declare const RECOMMENDATION: Record<VerdictCode, (ctx: RecommendationContext) => string>;
|
|
12
|
+
export declare const recommendation: (ctx: RecommendationContext) => string;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { armLabel } from '../../labels.js';
|
|
2
|
+
import { formatCount, formatPercent } from '../../results/format.js';
|
|
3
|
+
import { RECOMMENDATION_COPY as C, collectingRecommendation, guardrailRecommendation, mixedRecommendation, rolloutCollectingRecommendation, rolloutReadoutRecommendation, runningRecommendation, winnerRecommendation, worseRecommendation, storedGuardrailFinding } from '../../copy/recommendation.js';
|
|
4
|
+
function label(ctx, key, fallback = C.group) {
|
|
5
|
+
const stored = ctx.phase?.arms.find((arm)=>arm.key === key)?.label.trim();
|
|
6
|
+
return stored || (key && [
|
|
7
|
+
'control',
|
|
8
|
+
'variantA',
|
|
9
|
+
'variantB',
|
|
10
|
+
'variantC',
|
|
11
|
+
'holdout'
|
|
12
|
+
].includes(key) ? armLabel(key) : fallback);
|
|
13
|
+
}
|
|
14
|
+
const primary = ({ snapshot })=>snapshot.metrics.find((metric)=>metric.role === 'primary');
|
|
15
|
+
const worse = (ctx)=>primary(ctx)?.comparisons.find((comparison)=>comparison.significant === 'worse' && ctx.phase?.arms.some((arm)=>arm.key === comparison.arm && arm.kind === 'variant'))?.arm;
|
|
16
|
+
const winner = (ctx)=>label(ctx, ctx.snapshot.winnerArm ?? ctx.snapshot.verdict.recommendedArm, C.winner);
|
|
17
|
+
/** Select stored findings and format them; never run inference in the admin. */ export const RECOMMENDATION = {
|
|
18
|
+
unavailable: ()=>C.unavailable,
|
|
19
|
+
'data-problem': ()=>C.dataProblem,
|
|
20
|
+
'guardrail-failing': (ctx)=>{
|
|
21
|
+
const guardrail = ctx.snapshot.guardrails.find((guardrail)=>guardrail.status === 'failing');
|
|
22
|
+
const metric = ctx.snapshot.metrics.find((metric)=>metric.metricId === guardrail?.metricId);
|
|
23
|
+
// Guardrail safety uses one-sided tests, distinct from comparison significance.
|
|
24
|
+
// Select the group the server named; do not re-infer harm from the table.
|
|
25
|
+
const group = ctx.phase?.arms.find((arm)=>ctx.snapshot.verdict.finding === storedGuardrailFinding(arm.label.trim() || armLabel(arm.key), metric?.name ?? C.guardrail));
|
|
26
|
+
return guardrailRecommendation(label(ctx, ctx.snapshot.verdict.worstArm ?? group?.key), metric?.name ?? C.guardrail);
|
|
27
|
+
},
|
|
28
|
+
collecting: (ctx)=>{
|
|
29
|
+
if (ctx.status === 'stopped') return C.stoppedCollecting;
|
|
30
|
+
const needed = ctx.snapshot.power.exposuresNeededPerArm;
|
|
31
|
+
const groups = ctx.phase?.arms.filter((arm)=>arm.kind !== 'holdout').length ?? ctx.snapshot.k + 1;
|
|
32
|
+
return needed === null ? C.collecting : collectingRecommendation(formatCount(Math.round(needed * groups)));
|
|
33
|
+
},
|
|
34
|
+
'original-better': ()=>C.originalBetter,
|
|
35
|
+
'worse-none-better': (ctx)=>worseRecommendation(label(ctx, worse(ctx))),
|
|
36
|
+
winner: (ctx)=>winnerRecommendation(label(ctx, ctx.snapshot.verdict.recommendedArm)),
|
|
37
|
+
mixed: (ctx)=>mixedRecommendation(label(ctx, ctx.snapshot.verdict.recommendedArm), label(ctx, worse(ctx))),
|
|
38
|
+
'keep-running': (ctx)=>{
|
|
39
|
+
// Same condition as the server's long-duration branch (src/results/verdict.ts).
|
|
40
|
+
const power = ctx.snapshot.power;
|
|
41
|
+
if (!power.feasible || power.daysRemaining !== null && power.daysRemaining > 90) return C.longDuration;
|
|
42
|
+
const days = ctx.plannedDurationDays;
|
|
43
|
+
const start = Date.parse(ctx.phase?.startedAt ?? '');
|
|
44
|
+
const end = days != null && Number.isFinite(days) && days > 0 ? start + days * 86_400_000 : NaN;
|
|
45
|
+
return Number.isFinite(end) ? runningRecommendation(new Intl.DateTimeFormat('en-US', {
|
|
46
|
+
month: 'short',
|
|
47
|
+
day: 'numeric',
|
|
48
|
+
year: 'numeric',
|
|
49
|
+
timeZone: 'UTC'
|
|
50
|
+
}).format(end)) : C.keepRunning;
|
|
51
|
+
},
|
|
52
|
+
'no-difference': ()=>C.noDifference,
|
|
53
|
+
'rollout-collecting': (ctx)=>rolloutCollectingRecommendation(winner(ctx)),
|
|
54
|
+
'rollout-readout': (ctx)=>rolloutReadoutRecommendation(winner(ctx), formatPercent(primary(ctx)?.comparisons.find((comparison)=>comparison.arm === (ctx.snapshot.winnerArm ?? ctx.snapshot.verdict.recommendedArm))?.lift, true))
|
|
55
|
+
};
|
|
56
|
+
export const recommendation = (ctx)=>{
|
|
57
|
+
const sentence = RECOMMENDATION[ctx.snapshot.verdict.code];
|
|
58
|
+
return sentence(ctx);
|
|
59
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { DocId, Status } from '../../../types/contracts.js';
|
|
2
|
+
import type { PhaseSummary, ResultsResponse } from '../../../types/endpoints.js';
|
|
3
|
+
import { type ApiResult } from '../../api.js';
|
|
4
|
+
export interface ResultsDocument {
|
|
5
|
+
status: Status;
|
|
6
|
+
arms: Array<{
|
|
7
|
+
key: string;
|
|
8
|
+
kind?: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
}>;
|
|
11
|
+
decision?: {
|
|
12
|
+
phase: number;
|
|
13
|
+
winnerArm?: string;
|
|
14
|
+
} | null;
|
|
15
|
+
analysis?: {
|
|
16
|
+
plannedDurationDays?: number | null;
|
|
17
|
+
} | null;
|
|
18
|
+
layer?: unknown;
|
|
19
|
+
layerSlices?: number[];
|
|
20
|
+
allocation?: number;
|
|
21
|
+
}
|
|
22
|
+
export type ResultsLoadState = 'loading' | 'ready' | 'empty' | 'forbidden' | 'missing' | 'error';
|
|
23
|
+
export declare function useResults(id: DocId, { apiRoute, canReadResults, canManage }: {
|
|
24
|
+
apiRoute: string;
|
|
25
|
+
canReadResults: boolean;
|
|
26
|
+
canManage?: boolean;
|
|
27
|
+
}): {
|
|
28
|
+
api: {
|
|
29
|
+
start: (id: DocId) => Promise<ApiResult<import("../../../types/endpoints.js").TransitionSuccess>>;
|
|
30
|
+
allocate: (id: DocId, body: import("../../../types/endpoints.js").AllocateBody) => Promise<ApiResult<import("../../../types/endpoints.js").TransitionSuccess>>;
|
|
31
|
+
addMetric: (id: DocId, body: import("../../../types/endpoints.js").AddMetricBody) => Promise<ApiResult<import("../../../types/endpoints.js").TransitionSuccess>>;
|
|
32
|
+
stop: (id: DocId) => Promise<ApiResult<import("../../../types/endpoints.js").TransitionSuccess>>;
|
|
33
|
+
decide: (id: DocId, body: import("../../../types/endpoints.js").DecideBody) => Promise<ApiResult<import("../../../types/endpoints.js").TransitionSuccess>>;
|
|
34
|
+
releaseHoldout: (id: DocId) => Promise<ApiResult<import("../../../types/endpoints.js").TransitionSuccess>>;
|
|
35
|
+
makeContent: (id: DocId) => Promise<ApiResult<import("../../../types/endpoints.js").MakeContentResult>>;
|
|
36
|
+
archive: (id: DocId, body: import("../../../types/endpoints.js").ArchiveBody) => Promise<ApiResult<import("../../../types/endpoints.js").TransitionSuccess>>;
|
|
37
|
+
preflight: (id: DocId) => Promise<ApiResult<import("../../../types/endpoints.js").PreflightResult>>;
|
|
38
|
+
previewToken: (id: DocId, arm: string) => Promise<ApiResult<import("../../../types/endpoints.js").PreviewTokenResult>>;
|
|
39
|
+
results: (id: DocId, options?: {
|
|
40
|
+
phase?: number;
|
|
41
|
+
series?: boolean;
|
|
42
|
+
}) => Promise<ApiResult<ResultsResponse>>;
|
|
43
|
+
recompute: (id: DocId) => Promise<ApiResult<{
|
|
44
|
+
snapshotId: string;
|
|
45
|
+
}>>;
|
|
46
|
+
summary: (ids: readonly DocId[]) => Promise<ApiResult<import("../../../types/endpoints.js").SummaryResponse>>;
|
|
47
|
+
};
|
|
48
|
+
state: ResultsLoadState;
|
|
49
|
+
data: ResultsResponse | undefined;
|
|
50
|
+
phases: PhaseSummary[];
|
|
51
|
+
round: number | null;
|
|
52
|
+
doc: ResultsDocument | undefined;
|
|
53
|
+
error: string | undefined;
|
|
54
|
+
setRound: (next: number) => void;
|
|
55
|
+
reload: () => void;
|
|
56
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { createApi } from '../../api.js';
|
|
4
|
+
import { API_MESSAGES, refusalMessage } from '../../copy/common.js';
|
|
5
|
+
import { defaultRound } from '../../results/results.model.js';
|
|
6
|
+
// The shared API has no collection-document read.
|
|
7
|
+
async function read(url, signal) {
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch(url, {
|
|
10
|
+
method: 'GET',
|
|
11
|
+
credentials: 'include',
|
|
12
|
+
signal
|
|
13
|
+
});
|
|
14
|
+
const data = await response.json().catch(()=>undefined);
|
|
15
|
+
if (response.ok) return {
|
|
16
|
+
ok: true,
|
|
17
|
+
data
|
|
18
|
+
};
|
|
19
|
+
const envelope = data;
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
status: response.status,
|
|
23
|
+
message: refusalMessage(undefined, response.status, envelope?.errors?.[0]?.message),
|
|
24
|
+
retryable: response.status >= 500
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
status: 0,
|
|
30
|
+
message: API_MESSAGES.network,
|
|
31
|
+
retryable: true
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function useResults(id, { apiRoute, canReadResults, canManage = true }) {
|
|
36
|
+
const base = apiRoute.replace(/\/$/, '');
|
|
37
|
+
const key = JSON.stringify([
|
|
38
|
+
base,
|
|
39
|
+
String(id),
|
|
40
|
+
canReadResults,
|
|
41
|
+
canManage
|
|
42
|
+
]);
|
|
43
|
+
const api = useMemo(()=>createApi(base), [
|
|
44
|
+
base
|
|
45
|
+
]);
|
|
46
|
+
const [selection, setSelection] = useState({
|
|
47
|
+
key,
|
|
48
|
+
round: null
|
|
49
|
+
});
|
|
50
|
+
const round = selection.key === key ? selection.round : null;
|
|
51
|
+
const [refresh, setRefresh] = useState(0);
|
|
52
|
+
const [view, setView] = useState({
|
|
53
|
+
key,
|
|
54
|
+
round: null,
|
|
55
|
+
state: 'loading'
|
|
56
|
+
});
|
|
57
|
+
useEffect(()=>{
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const signal = controller.signal;
|
|
60
|
+
if (!canReadResults) {
|
|
61
|
+
setView({
|
|
62
|
+
key,
|
|
63
|
+
round,
|
|
64
|
+
state: 'forbidden'
|
|
65
|
+
});
|
|
66
|
+
return ()=>controller.abort();
|
|
67
|
+
}
|
|
68
|
+
setView((previous)=>({
|
|
69
|
+
key,
|
|
70
|
+
round,
|
|
71
|
+
state: 'loading',
|
|
72
|
+
...previous.key === key ? {
|
|
73
|
+
doc: previous.doc,
|
|
74
|
+
phases: previous.phases,
|
|
75
|
+
...previous.round === round ? {
|
|
76
|
+
data: previous.data
|
|
77
|
+
} : {}
|
|
78
|
+
} : {}
|
|
79
|
+
}));
|
|
80
|
+
const encoded = encodeURIComponent(id);
|
|
81
|
+
void Promise.all([
|
|
82
|
+
canManage ? read(`${base}/xp-experiments/${encoded}?depth=0`, signal) : Promise.resolve({
|
|
83
|
+
ok: true,
|
|
84
|
+
data: undefined
|
|
85
|
+
}),
|
|
86
|
+
api.results(id, {
|
|
87
|
+
phase: round ?? undefined,
|
|
88
|
+
series: true
|
|
89
|
+
})
|
|
90
|
+
]).then(([document, result])=>{
|
|
91
|
+
if (signal.aborted) return;
|
|
92
|
+
const failure = !document.ok ? document : !result.ok ? result : undefined;
|
|
93
|
+
if (failure || !document.ok || !result.ok || canManage && !document.data || !result.data) {
|
|
94
|
+
const state = failure?.status === 403 ? 'forbidden' : failure?.status === 404 ? 'missing' : 'error';
|
|
95
|
+
setView((previous)=>({
|
|
96
|
+
key,
|
|
97
|
+
round,
|
|
98
|
+
state,
|
|
99
|
+
phases: previous.key === key ? previous.phases : undefined,
|
|
100
|
+
error: failure?.message ?? API_MESSAGES.unreadable,
|
|
101
|
+
...state === 'error' && previous.key === key && previous.round === round ? {
|
|
102
|
+
data: previous.data,
|
|
103
|
+
doc: previous.doc
|
|
104
|
+
} : {}
|
|
105
|
+
}));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (round === null) {
|
|
109
|
+
// Readers cannot GET the manager-only experiment collection. The results
|
|
110
|
+
// endpoint already selects the decision round (or latest test round).
|
|
111
|
+
const decision = result.data.decision ?? document.data?.decision;
|
|
112
|
+
const initial = defaultRound({
|
|
113
|
+
phases: result.data.phases,
|
|
114
|
+
decision,
|
|
115
|
+
status: result.data.decision ? 'decided' : document.data?.status ?? 'running'
|
|
116
|
+
});
|
|
117
|
+
if (initial !== null && initial !== result.data.snapshot?.phase) {
|
|
118
|
+
setView({
|
|
119
|
+
key,
|
|
120
|
+
round: null,
|
|
121
|
+
state: 'loading',
|
|
122
|
+
doc: document.data,
|
|
123
|
+
phases: result.data.phases,
|
|
124
|
+
data: {
|
|
125
|
+
...result.data,
|
|
126
|
+
snapshot: null
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
setSelection({
|
|
130
|
+
key,
|
|
131
|
+
round: initial
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
setView({
|
|
137
|
+
key,
|
|
138
|
+
round: round ?? result.data.snapshot?.phase ?? null,
|
|
139
|
+
state: result.data.snapshot ? 'ready' : 'empty',
|
|
140
|
+
doc: document.data,
|
|
141
|
+
phases: result.data.phases,
|
|
142
|
+
data: result.data
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
return ()=>controller.abort();
|
|
146
|
+
}, [
|
|
147
|
+
api,
|
|
148
|
+
base,
|
|
149
|
+
id,
|
|
150
|
+
key,
|
|
151
|
+
canReadResults,
|
|
152
|
+
canManage,
|
|
153
|
+
round,
|
|
154
|
+
refresh
|
|
155
|
+
]);
|
|
156
|
+
const current = view.key === key;
|
|
157
|
+
const matching = current && (round === null || view.round === round);
|
|
158
|
+
const selectedRound = round ?? (current ? view.round : null);
|
|
159
|
+
const state = !canReadResults ? 'forbidden' : matching ? view.state : 'loading';
|
|
160
|
+
const data = matching && canReadResults ? view.data : undefined;
|
|
161
|
+
// Preserve options while another round loads; its snapshot is never displayed.
|
|
162
|
+
const phases = current && canReadResults ? view.phases ?? [] : [];
|
|
163
|
+
return {
|
|
164
|
+
api,
|
|
165
|
+
state,
|
|
166
|
+
data,
|
|
167
|
+
phases,
|
|
168
|
+
round: selectedRound,
|
|
169
|
+
doc: current && canReadResults ? view.doc : undefined,
|
|
170
|
+
error: matching ? view.error : undefined,
|
|
171
|
+
setRound: (next)=>setSelection({
|
|
172
|
+
key,
|
|
173
|
+
round: next
|
|
174
|
+
}),
|
|
175
|
+
reload: ()=>{
|
|
176
|
+
setSelection({
|
|
177
|
+
key,
|
|
178
|
+
round: selectedRound
|
|
179
|
+
});
|
|
180
|
+
setRefresh((value)=>value + 1);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|