Skip to content

Commit 6a60932

Browse files
authored
Merge pull request #70 from getlantern/reflog/promoted-comparison-graph
experiments: promoted-vs-original goodput scatter
2 parents 1e2595f + 367179c commit 6a60932

3 files changed

Lines changed: 316 additions & 18 deletions

File tree

src/api/client.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,10 +711,43 @@ export interface ExperimentSettingsResponse {
711711
applyDelaySeconds: number;
712712
}
713713

714+
// One promoted track (an experiment's challenger) paired with its original (the
715+
// control), both measured over the same now-relative window in the experiment's
716+
// target market. Backs the promoted-vs-original scatter.
717+
export interface PromotedComparisonPoint {
718+
experimentId: number;
719+
targetCountry: string;
720+
protocolName: string;
721+
providerName: string;
722+
locationName: string;
723+
promotedTrackName: string;
724+
originalTrackName: string;
725+
promotedGoodput: number; // median bytes/sec
726+
originalGoodput: number; // median bytes/sec
727+
promotedSamples: number;
728+
originalSamples: number;
729+
promotedAt?: string; // decided_at (when the experiment was promoted)
730+
}
731+
732+
export interface PromotedComparisonResponse {
733+
points: PromotedComparisonPoint[];
734+
windowStart: string;
735+
windowEnd: string;
736+
windowHours: number;
737+
statsError?: string;
738+
}
739+
714740
export function fetchExperiments(): Promise<ExperimentsResponse> {
715741
return apiFetch("/experiments");
716742
}
717743

744+
// fetchPromotedComparison returns every promoted track vs its original over a
745+
// now-relative window (hours). Server-side single SigNoz query, so the whole
746+
// scatter loads in one round-trip.
747+
export function fetchPromotedComparison(hours: number): Promise<PromotedComparisonResponse> {
748+
return apiFetch("/experiments/promoted-comparison", { hours: String(hours) });
749+
}
750+
718751
export function fetchExperimentDetail(id: number): Promise<ExperimentDetail> {
719752
return apiFetch("/experiments/detail", { id: String(id) });
720753
}

src/components/ExperimentsOverview.tsx

Lines changed: 246 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { useEffect, useMemo, useState, type CSSProperties } from "react";
1+
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
22
import {
33
Bar,
44
BarChart,
55
CartesianGrid,
66
Legend,
77
Line,
88
LineChart,
9+
ReferenceLine,
910
ResponsiveContainer,
11+
Scatter,
12+
ScatterChart,
1013
Tooltip,
1114
XAxis,
1215
YAxis,
16+
ZAxis,
1317
} from "recharts";
1418
import {
1519
abortExperiment,
@@ -20,13 +24,26 @@ import {
2024
type ExperimentStratum,
2125
type ExperimentSummary,
2226
type ExperimentPipeline,
27+
type PromotedComparisonPoint,
2328
} from "../api/client";
24-
import { useExperiments, useExperimentDetail, useExperimentSettings } from "../hooks/useExperiments";
29+
import { useExperiments, useExperimentDetail, useExperimentSettings, usePromotedComparison } from "../hooks/useExperiments";
2530
import { useAuth } from "../hooks/useAuth";
2631
import ExperimentSettings from "./ExperimentSettings";
2732

2833
const CHALLENGER_COLOR = "#00e5c8";
2934
const CONTROL_COLOR = "#f0a030";
35+
// Promoted-vs-original scatter: a promotion still beating its original is a win
36+
// (green, matching the "promoted" lifecycle color), still trailing is a loss.
37+
const WIN_COLOR = "#20e070";
38+
const LOSS_COLOR = "#ff4060";
39+
40+
// Selectable now-relative windows for the promoted-vs-original scatter.
41+
const COMPARISON_WINDOWS: Array<{ label: string; hours: number }> = [
42+
{ label: "6h", hours: 6 },
43+
{ label: "24h", hours: 24 },
44+
{ label: "7d", hours: 168 },
45+
{ label: "30d", hours: 720 },
46+
];
3047

3148
// Lifecycle status → display color, matching the dashboard's accent palette.
3249
const STATUS_COLORS: Record<string, string> = {
@@ -92,27 +109,47 @@ function StatusBadge({ status }: { status: string }) {
92109

93110
// ── Lifecycle pipeline strip ──
94111

95-
function PipelineStrip({ pipeline }: { pipeline: ExperimentPipeline | null }) {
112+
// PipelineStrip doubles as the table's status filter: each stage card is a toggle
113+
// button. A stage that's toggled off (dimmed + struck through) is hidden from the
114+
// experiments list below. Counts always reflect the full pipeline, not the filter.
115+
function PipelineStrip({ pipeline, hiddenStatuses, onToggle }: {
116+
pipeline: ExperimentPipeline | null;
117+
hiddenStatuses: Set<string>;
118+
onToggle: (status: string) => void;
119+
}) {
96120
if (!pipeline) return null;
97121
return (
98122
<div style={card}>
99-
<div style={sectionLabel}>Lifecycle pipeline</div>
123+
<div style={{ ...sectionLabel, display: "flex", justifyContent: "space-between", gap: "1rem" }}>
124+
<span>Lifecycle pipeline</span>
125+
<span style={{ textTransform: "none", letterSpacing: 0 }}>click a stage to show / hide it in the list</span>
126+
</div>
100127
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
101128
{pipeline.order.map((status) => {
102129
const count = pipeline.counts[status] ?? 0;
103130
const color = STATUS_COLORS[status] || "#8890a0";
104-
const active = count > 0;
131+
const hasCount = count > 0;
132+
const on = !hiddenStatuses.has(status);
105133
return (
106-
<div key={status} style={{
107-
flex: "1 1 6rem", minWidth: "5.5rem",
108-
borderRadius: "var(--radius-sm)",
109-
border: `1px solid ${active ? `${color}40` : "#ffffff0d"}`,
110-
background: active ? `${color}12` : "#ffffff05",
111-
padding: "0.5rem 0.6rem",
112-
}}>
113-
<div style={{ ...mono, fontSize: "1.3rem", fontWeight: 600, color: active ? color : "#5a6472" }}>{count}</div>
114-
<div style={{ ...mono, fontSize: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--text-muted)" }}>{status}</div>
115-
</div>
134+
<button
135+
type="button"
136+
key={status}
137+
onClick={() => onToggle(status)}
138+
aria-pressed={on}
139+
title={on ? `Hide ${status} from the list` : `Show ${status} in the list`}
140+
style={{
141+
flex: "1 1 6rem", minWidth: "5.5rem", textAlign: "left",
142+
appearance: "none", cursor: "pointer",
143+
borderRadius: "var(--radius-sm)",
144+
border: `1px solid ${on && hasCount ? `${color}40` : "#ffffff0d"}`,
145+
background: on && hasCount ? `${color}12` : "#ffffff05",
146+
padding: "0.5rem 0.6rem",
147+
opacity: on ? 1 : 0.45,
148+
}}
149+
>
150+
<div style={{ ...mono, fontSize: "1.3rem", fontWeight: 600, color: on && hasCount ? color : "#5a6472" }}>{count}</div>
151+
<div style={{ ...mono, fontSize: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--text-muted)", textDecoration: on ? "none" : "line-through" }}>{status}</div>
152+
</button>
116153
);
117154
})}
118155
</div>
@@ -464,7 +501,7 @@ function ExperimentsTable({ experiments, selectedId, onSelect, onChanged }: {
464501
experiments: ExperimentSummary[]; selectedId: number | null; onSelect: (id: number | null) => void; onChanged: () => void;
465502
}) {
466503
if (experiments.length === 0) {
467-
return <div style={{ ...mono, color: "var(--text-muted)", padding: "1rem" }}>No experiments yet.</div>;
504+
return <div style={{ ...mono, color: "var(--text-muted)", padding: "1rem" }}>No experiments in the selected stages.</div>;
468505
}
469506
const headerStyle: CSSProperties = {
470507
display: "grid", gridTemplateColumns: colTemplate, gap: "0.5rem",
@@ -515,14 +552,204 @@ function ExperimentsTable({ experiments, selectedId, onSelect, onChanged }: {
515552
);
516553
}
517554

555+
// ── Promoted vs original scatter ──
556+
557+
const filterLabel: CSSProperties = {
558+
...mono, fontSize: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em",
559+
color: "var(--text-muted)", marginBottom: "0.2rem",
560+
};
561+
562+
const filterSelect: CSSProperties = {
563+
...mono, fontSize: "0.65rem", color: "var(--text-primary)",
564+
background: "#ffffff08", border: "1px solid #ffffff10",
565+
borderRadius: "var(--radius-sm)", padding: "0.35rem 0.55rem",
566+
};
567+
568+
function chip(active: boolean): CSSProperties {
569+
return {
570+
...mono, fontSize: "0.6rem", padding: "0.3rem 0.7rem", borderRadius: "var(--radius-sm)",
571+
cursor: "pointer", userSelect: "none", appearance: "none",
572+
background: active ? "var(--accent-primary-dim)" : "#ffffff08",
573+
color: active ? "var(--accent-primary)" : "var(--text-muted)",
574+
border: `1px solid ${active ? "#00e5c830" : "#ffffff10"}`,
575+
};
576+
}
577+
578+
function ComparisonTooltip({ active, payload }: {
579+
active?: boolean;
580+
payload?: Array<{ payload: PromotedComparisonPoint }>;
581+
}) {
582+
if (!active || !payload || payload.length === 0) return null;
583+
const p = payload[0].payload;
584+
const delta = p.originalGoodput > 0 ? ((p.promotedGoodput - p.originalGoodput) / p.originalGoodput) * 100 : 0;
585+
const deltaColor = delta >= 0 ? WIN_COLOR : LOSS_COLOR;
586+
return (
587+
<div style={{ ...mono, fontSize: "0.62rem", background: "var(--bg-secondary)", border: "1px solid #ffffff14", borderRadius: "var(--radius-sm)", padding: "0.5rem 0.6rem", lineHeight: 1.5 }}>
588+
<div style={{ color: "var(--text-muted)" }}>#{p.experimentId} · {p.targetCountry} · {p.protocolName || "—"}{p.providerName ? ` · ${p.providerName}` : ""}</div>
589+
<div><span style={{ color: WIN_COLOR }}>{p.promotedTrackName}</span> <span style={{ color: "var(--text-muted)" }}>(promoted)</span></div>
590+
<div><span style={{ color: CONTROL_COLOR }}>{p.originalTrackName}</span> <span style={{ color: "var(--text-muted)" }}>(original)</span></div>
591+
<div style={{ marginTop: "0.25rem" }}>promoted: {formatBytesPerSec(p.promotedGoodput)} <span style={{ color: "var(--text-muted)" }}>({p.promotedSamples} sess)</span></div>
592+
<div>original: {formatBytesPerSec(p.originalGoodput)} <span style={{ color: "var(--text-muted)" }}>({p.originalSamples} sess)</span></div>
593+
<div style={{ color: deltaColor, marginTop: "0.15rem" }}>{delta >= 0 ? "+" : ""}{delta.toFixed(0)}% vs original</div>
594+
</div>
595+
);
596+
}
597+
598+
function PromotedComparison({ enabled }: { enabled: boolean }) {
599+
const [hours, setHours] = useState(168);
600+
const [country, setCountry] = useState("");
601+
const [protocol, setProtocol] = useState("");
602+
const [provider, setProvider] = useState("");
603+
const { data, isLoading, error } = usePromotedComparison(enabled, hours);
604+
605+
const allPoints = useMemo(() => data?.points ?? [], [data]);
606+
607+
// Filter option lists come from all points (before country/protocol/provider
608+
// filtering) so choosing one filter never empties the others' dropdowns.
609+
const countries = useMemo(() => [...new Set(allPoints.map((p) => p.targetCountry).filter(Boolean))].sort(), [allPoints]);
610+
const protocols = useMemo(() => [...new Set(allPoints.map((p) => p.protocolName).filter(Boolean))].sort(), [allPoints]);
611+
const providers = useMemo(() => [...new Set(allPoints.map((p) => p.providerName).filter(Boolean))].sort(), [allPoints]);
612+
613+
const matchesFilters = useCallback((p: PromotedComparisonPoint) =>
614+
(!country || p.targetCountry === country) &&
615+
(!protocol || p.protocolName === protocol) &&
616+
(!provider || p.providerName === provider),
617+
[country, protocol, provider]);
618+
619+
// A point is plottable only when both arms have live samples in the window;
620+
// otherwise its goodput is a meaningless 0 that would pile on the origin.
621+
const hasSamples = (p: PromotedComparisonPoint) => p.promotedSamples > 0 && p.originalSamples > 0;
622+
const filtered = useMemo(() => allPoints.filter((p) => hasSamples(p) && matchesFilters(p)), [allPoints, matchesFilters]);
623+
624+
// Hidden = points matching the active filters but lacking live samples, so the
625+
// "N hidden" note stays consistent with the filtered track count above it.
626+
const hidden = useMemo(() => allPoints.filter((p) => matchesFilters(p) && !hasSamples(p)).length, [allPoints, matchesFilters]);
627+
628+
const wins = filtered.filter((p) => p.promotedGoodput >= p.originalGoodput);
629+
const losses = filtered.filter((p) => p.promotedGoodput < p.originalGoodput);
630+
const axisMax = useMemo(() => {
631+
// reduce (not Math.max(...spread)) so any number of points is safe.
632+
const m = filtered.reduce((acc, p) => Math.max(acc, p.promotedGoodput, p.originalGoodput), 0);
633+
return m > 0 ? m * 1.08 : 1; // headroom so parity line + points aren't clipped
634+
}, [filtered]);
635+
636+
const hasFilters = Boolean(country || protocol || provider);
637+
638+
return (
639+
<div style={card}>
640+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "1rem", flexWrap: "wrap", marginBottom: "0.6rem" }}>
641+
<div>
642+
<div style={{ ...sectionLabel, marginBottom: "0.15rem" }}>Promoted vs original — median goodput</div>
643+
<div style={{ ...mono, fontSize: "0.55rem", color: "var(--text-muted)" }}>
644+
Each point is a promoted track vs the original it beat, over the last {COMPARISON_WINDOWS.find((w) => w.hours === hours)?.label ?? `${hours}h`}, in its target market. Above the parity line = promotion still winning.
645+
</div>
646+
</div>
647+
<div style={{ display: "flex", gap: "0.3rem", alignItems: "center", flexWrap: "wrap" }}>
648+
{COMPARISON_WINDOWS.map((w) => (
649+
<button type="button" key={w.hours} onClick={() => setHours(w.hours)} style={chip(hours === w.hours)} aria-pressed={hours === w.hours}>{w.label}</button>
650+
))}
651+
</div>
652+
</div>
653+
654+
<div style={{ display: "flex", gap: "0.7rem", flexWrap: "wrap", alignItems: "flex-end", marginBottom: "0.6rem" }}>
655+
<div style={{ display: "flex", flexDirection: "column", minWidth: 110 }}>
656+
<span style={filterLabel}>Country</span>
657+
<select style={filterSelect} value={country} onChange={(e) => setCountry(e.target.value)}>
658+
<option value="">All</option>
659+
{countries.map((c) => <option key={c} value={c}>{c}</option>)}
660+
</select>
661+
</div>
662+
<div style={{ display: "flex", flexDirection: "column", minWidth: 130 }}>
663+
<span style={filterLabel}>Protocol</span>
664+
<select style={filterSelect} value={protocol} onChange={(e) => setProtocol(e.target.value)}>
665+
<option value="">All</option>
666+
{protocols.map((p) => <option key={p} value={p}>{p}</option>)}
667+
</select>
668+
</div>
669+
<div style={{ display: "flex", flexDirection: "column", minWidth: 130 }}>
670+
<span style={filterLabel}>Provider</span>
671+
<select style={filterSelect} value={provider} onChange={(e) => setProvider(e.target.value)}>
672+
<option value="">All</option>
673+
{providers.map((p) => <option key={p} value={p}>{p}</option>)}
674+
</select>
675+
</div>
676+
{hasFilters && (
677+
<button type="button" onClick={() => { setCountry(""); setProtocol(""); setProvider(""); }} style={chip(false)}>Clear</button>
678+
)}
679+
</div>
680+
681+
{error ? (
682+
<div style={{ ...mono, fontSize: "0.65rem", color: "var(--accent-danger, #ff4060)" }}>{error}</div>
683+
) : data?.statsError ? (
684+
<div style={{ ...mono, fontSize: "0.62rem", color: "#e0a060", background: "#f0a03012", border: "1px solid #f0a03030", borderRadius: "var(--radius-sm)", padding: "0.5rem 0.7rem" }}>{data.statsError}</div>
685+
) : !data || (isLoading && filtered.length === 0) ? (
686+
<div style={{ ...mono, fontSize: "0.65rem", color: "var(--text-muted)" }}>Loading comparison…</div>
687+
) : filtered.length === 0 ? (
688+
<div style={{ ...mono, fontSize: "0.65rem", color: "var(--text-muted)" }}>
689+
No promoted tracks with live samples in this window{hasFilters ? " for the selected filters" : ""}.
690+
</div>
691+
) : (
692+
<>
693+
<ResponsiveContainer width="100%" height={340}>
694+
<ScatterChart margin={{ top: 8, right: 16, bottom: 24, left: 16 }}>
695+
<CartesianGrid strokeDasharray="3 3" stroke="#ffffff10" />
696+
<XAxis
697+
type="number" dataKey="originalGoodput" name="Original goodput"
698+
domain={[0, axisMax]} tickFormatter={formatBytesPerSec}
699+
tick={{ fontSize: 10, fill: "#8890a0" }}
700+
label={{ value: "Original (control) goodput", position: "insideBottom", offset: -14, fontSize: 10, fill: "#8890a0" }}
701+
/>
702+
<YAxis
703+
type="number" dataKey="promotedGoodput" name="Promoted goodput"
704+
domain={[0, axisMax]} tickFormatter={formatBytesPerSec} width={64}
705+
tick={{ fontSize: 10, fill: "#8890a0" }}
706+
label={{ value: "Promoted goodput", angle: -90, position: "insideLeft", fontSize: 10, fill: "#8890a0" }}
707+
/>
708+
<ZAxis range={[60, 60]} />
709+
<ReferenceLine
710+
segment={[{ x: 0, y: 0 }, { x: axisMax, y: axisMax }]}
711+
stroke="#8890a0" strokeDasharray="4 4" ifOverflow="hidden"
712+
/>
713+
<Tooltip content={<ComparisonTooltip />} cursor={{ strokeDasharray: "3 3" }} />
714+
<Legend wrapperStyle={{ fontSize: 11 }} />
715+
<Scatter name="Promotion winning" data={wins} fill={WIN_COLOR} />
716+
<Scatter name="Promotion losing" data={losses} fill={LOSS_COLOR} />
717+
</ScatterChart>
718+
</ResponsiveContainer>
719+
<div style={{ ...mono, fontSize: "0.55rem", color: "var(--text-muted)", marginTop: "0.4rem" }}>
720+
{filtered.length} promoted {filtered.length === 1 ? "track" : "tracks"} · {wins.length} winning · {losses.length} losing
721+
{hidden > 0 && ` · ${hidden} hidden (no live samples in window)`}
722+
</div>
723+
</>
724+
)}
725+
</div>
726+
);
727+
}
728+
518729
// ── Top-level tab content ──
519730

520731
export default function ExperimentsOverview({ enabled }: { enabled: boolean }) {
521732
const [view, setView] = useState<"experiments" | "settings">("experiments");
522733
const [selectedId, setSelectedId] = useState<number | null>(null);
734+
// Status filter driven by the pipeline strip. Retired experiments are hidden by
735+
// default — they're the bulk of terminal history and rarely what you're after.
736+
const [hiddenStatuses, setHiddenStatuses] = useState<Set<string>>(() => new Set(["retired"]));
523737
const { experiments, pipeline, isLoading, hasLoaded, error, refresh } = useExperiments(enabled);
524738
const settings = useExperimentSettings(enabled);
525739

740+
const toggleStatus = useCallback((status: string) => {
741+
setHiddenStatuses((prev) => {
742+
const next = new Set(prev);
743+
if (next.has(status)) next.delete(status); else next.add(status);
744+
return next;
745+
});
746+
}, []);
747+
748+
const visibleExperiments = useMemo(
749+
() => experiments.filter((e) => !hiddenStatuses.has(e.status)),
750+
[experiments, hiddenStatuses],
751+
);
752+
526753
// Surface a banner when the core automation workers are paused.
527754
const automationOff = useMemo(() => {
528755
const ed = settings.settings?.editable ?? [];
@@ -559,11 +786,12 @@ export default function ExperimentsOverview({ enabled }: { enabled: boolean }) {
559786
{error && (
560787
<div style={{ ...mono, fontSize: "0.65rem", color: "var(--accent-danger, #ff4060)", background: "#ff406012", border: "1px solid #ff406030", borderRadius: "var(--radius-sm)", padding: "0.5rem 0.75rem" }}>{error}</div>
561788
)}
562-
<PipelineStrip pipeline={pipeline} />
789+
<PipelineStrip pipeline={pipeline} hiddenStatuses={hiddenStatuses} onToggle={toggleStatus} />
790+
<PromotedComparison enabled={enabled && view === "experiments"} />
563791
{isLoading && !hasLoaded ? (
564792
<div style={{ ...mono, color: "var(--text-muted)", padding: "1rem" }}>Loading experiments…</div>
565793
) : (
566-
<ExperimentsTable experiments={experiments} selectedId={selectedId} onSelect={setSelectedId} onChanged={refresh} />
794+
<ExperimentsTable experiments={visibleExperiments} selectedId={selectedId} onSelect={setSelectedId} onChanged={refresh} />
567795
)}
568796
</>
569797
)}

0 commit comments

Comments
 (0)