@kaizenreport/kensho-viewer 0.1.0
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/LICENSE +21 -0
- package/README.md +19 -0
- package/assets/app.js +1396 -0
- package/assets/app.jsx +860 -0
- package/assets/charts.js +1156 -0
- package/assets/charts.jsx +593 -0
- package/assets/colors_and_type.css +219 -0
- package/assets/components.js +894 -0
- package/assets/components.jsx +520 -0
- package/assets/data-bridge.js +49 -0
- package/assets/data-bridge.jsx +55 -0
- package/assets/data-loader.js +543 -0
- package/assets/kaizen-mark.svg +5 -0
- package/assets/kensho-wordmark.svg +18 -0
- package/assets/pages.js +1472 -0
- package/assets/pages.jsx +822 -0
- package/assets/test-detail.js +1058 -0
- package/assets/test-detail.jsx +502 -0
- package/assets/tokens.css +357 -0
- package/assets/tree-detail.js +1705 -0
- package/assets/tree-detail.jsx +947 -0
- package/dist/component.d.ts +115 -0
- package/dist/component.js +698 -0
- package/index.html +35 -0
- package/package.json +65 -0
- package/scripts/build.js +117 -0
- package/src/component.d.ts +115 -0
- package/src/component.jsx +340 -0
- package/src/data.js +538 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/* global React */
|
|
2
|
+
|
|
3
|
+
// ============== TREND CHART V2 ==============
|
|
4
|
+
// Stacked area chart: four status bands stacked over time.
|
|
5
|
+
// Designed to read calm at a glance — green dominance = healthy run history.
|
|
6
|
+
// One axis (count). Pass-rate is implicit in the green band's share.
|
|
7
|
+
// Hover reveals a vertical guide + tooltip with the per-status breakdown.
|
|
8
|
+
function TrendChartV2({ runs }) {
|
|
9
|
+
const [hoverIdx, setHover] = React.useState(null);
|
|
10
|
+
const W = 760, H = 240, padL = 44, padR = 24, padT = 24, padB = 40;
|
|
11
|
+
const innerW = W - padL - padR, innerH = H - padT - padB;
|
|
12
|
+
const totals = runs.map(r => r.passed + r.failed + r.broken + r.skipped);
|
|
13
|
+
const maxY = Math.ceil(Math.max(...totals, 8) / 4) * 4;
|
|
14
|
+
const xStep = runs.length > 1 ? innerW / (runs.length - 1) : 0;
|
|
15
|
+
const xAt = i => padL + i * xStep;
|
|
16
|
+
const yAt = v => padT + innerH - (v / maxY) * innerH;
|
|
17
|
+
|
|
18
|
+
// Stacking order — passed at the bottom (so it dominates visually when healthy),
|
|
19
|
+
// then skipped, broken, failed at the top. Cumulative band paths.
|
|
20
|
+
const ORDER = [
|
|
21
|
+
{ key:'passed', color:'var(--status-passed)' },
|
|
22
|
+
{ key:'skipped', color:'var(--status-skipped)' },
|
|
23
|
+
{ key:'broken', color:'var(--status-broken)' },
|
|
24
|
+
{ key:'failed', color:'var(--status-failed)' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Build cumulative top-of-band y values per run.
|
|
28
|
+
const stack = runs.map(r => {
|
|
29
|
+
const acc = []; let c = 0;
|
|
30
|
+
ORDER.forEach(o => { c += r[o.key] || 0; acc.push(c); });
|
|
31
|
+
return acc; // [yPassed, yPassed+skipped, …]
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const bandPath = (bandIdx) => {
|
|
35
|
+
// bottom = previous band's cumulative (or 0 for first), top = this band's cumulative
|
|
36
|
+
const top = runs.map((_, i) => yAt(stack[i][bandIdx]));
|
|
37
|
+
const bot = runs.map((_, i) => yAt(bandIdx === 0 ? 0 : stack[i][bandIdx - 1]));
|
|
38
|
+
const fwd = runs.map((_, i) => `${i === 0 ? 'M' : 'L'}${xAt(i)},${top[i]}`).join(' ');
|
|
39
|
+
const back = runs.map((_, i) => `L${xAt(runs.length - 1 - i)},${bot[runs.length - 1 - i]}`).join(' ');
|
|
40
|
+
return `${fwd} ${back} Z`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// y-axis ticks — 5 nice round values
|
|
44
|
+
const yTicks = [0, maxY / 4, maxY / 2, (3 * maxY) / 4, maxY];
|
|
45
|
+
|
|
46
|
+
// Run-level summary for tooltip and "current vs previous" indicator
|
|
47
|
+
const hovered = hoverIdx != null ? runs[hoverIdx] : runs[runs.length - 1];
|
|
48
|
+
const hoveredTotal = hovered ? hovered.passed + hovered.failed + hovered.broken + hovered.skipped : 0;
|
|
49
|
+
const hoveredPassRate = hoveredTotal ? hovered.passed / hoveredTotal : 0;
|
|
50
|
+
const prevIdx = (hoverIdx ?? runs.length - 1) - 1;
|
|
51
|
+
const prev = prevIdx >= 0 ? runs[prevIdx] : null;
|
|
52
|
+
const prevPassRate = prev ? prev.passed / Math.max(1, prev.passed + prev.failed + prev.broken + prev.skipped) : 0;
|
|
53
|
+
const passRateDelta = prev ? (hoveredPassRate - prevPassRate) * 100 : 0;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div>
|
|
57
|
+
{/* Headline strip — current/hovered run at a glance, no reliance on chart precision */}
|
|
58
|
+
<div style={{
|
|
59
|
+
display:'grid', gridTemplateColumns:'1fr auto auto auto auto', gap:18,
|
|
60
|
+
alignItems:'baseline', padding:'4px 4px 14px', borderBottom:'1px solid var(--line)',
|
|
61
|
+
marginBottom:14,
|
|
62
|
+
}}>
|
|
63
|
+
<div style={{ minWidth:0 }}>
|
|
64
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--fg3)', letterSpacing:1.2, textTransform:'uppercase' }}>
|
|
65
|
+
{hoverIdx == null ? 'Current run' : `Run #${hovered.short}`}
|
|
66
|
+
</div>
|
|
67
|
+
<div style={{ display:'flex', alignItems:'baseline', gap:8, marginTop:4 }}>
|
|
68
|
+
<span style={{ fontFamily:'var(--font-display)', fontSize:26, fontWeight:700, color:'var(--fg1)', letterSpacing:-0.5, lineHeight:1, fontVariantNumeric:'tabular-nums' }}>
|
|
69
|
+
{Math.round(hoveredPassRate * 100)}<span style={{ fontSize:14, color:'var(--fg3)', marginLeft:2 }}>%</span>
|
|
70
|
+
</span>
|
|
71
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>pass rate</span>
|
|
72
|
+
{prev && (
|
|
73
|
+
<span style={{
|
|
74
|
+
fontFamily:'var(--font-mono)', fontSize:11, fontWeight:600,
|
|
75
|
+
color: passRateDelta >= 0 ? 'var(--status-passed)' : 'var(--status-failed)',
|
|
76
|
+
fontVariantNumeric:'tabular-nums',
|
|
77
|
+
}}>
|
|
78
|
+
{passRateDelta >= 0 ? '↑' : '↓'} {Math.abs(passRateDelta).toFixed(1)}pp
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
{ORDER.map(o => (
|
|
84
|
+
<div key={o.key} style={{ textAlign:'right', minWidth:60 }}>
|
|
85
|
+
<div style={{ display:'flex', alignItems:'center', gap:5, justifyContent:'flex-end', fontFamily:'var(--font-mono)', fontSize:10, color:'var(--fg3)', textTransform:'uppercase', letterSpacing:0.5 }}>
|
|
86
|
+
<span style={{ width:8, height:8, borderRadius:2, background:o.color }}/>{o.key}
|
|
87
|
+
</div>
|
|
88
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:15, fontWeight:600, color:'var(--fg1)', fontVariantNumeric:'tabular-nums', marginTop:2 }}>
|
|
89
|
+
{hovered[o.key] || 0}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Chart canvas */}
|
|
96
|
+
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display:'block', overflow:'visible' }} onMouseLeave={() => setHover(null)}>
|
|
97
|
+
{/* y-axis gridlines */}
|
|
98
|
+
{yTicks.map((t, i) => (
|
|
99
|
+
<g key={i}>
|
|
100
|
+
<line x1={padL} x2={W - padR} y1={yAt(t)} y2={yAt(t)} stroke="var(--line)" strokeWidth="1" strokeDasharray={i === 0 ? '0' : '2 4'}/>
|
|
101
|
+
<text x={padL - 10} y={yAt(t) + 4} textAnchor="end" fontFamily="var(--font-mono)" fontSize="10.5" fill="var(--fg3)" fontVariantNumeric="tabular-nums">{t}</text>
|
|
102
|
+
</g>
|
|
103
|
+
))}
|
|
104
|
+
<text x={padL - 30} y={padT - 8} fontFamily="var(--font-mono)" fontSize="10" fill="var(--fg3)" letterSpacing="1.2" textAnchor="start">TESTS</text>
|
|
105
|
+
|
|
106
|
+
{/* stacked area bands — bottom up */}
|
|
107
|
+
{ORDER.map((o, i) => (
|
|
108
|
+
<path key={o.key} d={bandPath(i)} fill={o.color} fillOpacity="0.85"/>
|
|
109
|
+
))}
|
|
110
|
+
|
|
111
|
+
{/* top edge stroke for contrast */}
|
|
112
|
+
<path
|
|
113
|
+
d={runs.map((_, i) => `${i === 0 ? 'M' : 'L'}${xAt(i)},${yAt(stack[i][ORDER.length - 1])}`).join(' ')}
|
|
114
|
+
fill="none" stroke="var(--fg1)" strokeOpacity="0.18" strokeWidth="1"
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
{/* hover guide */}
|
|
118
|
+
{hoverIdx != null && (
|
|
119
|
+
<g>
|
|
120
|
+
<line x1={xAt(hoverIdx)} x2={xAt(hoverIdx)} y1={padT} y2={padT + innerH} stroke="var(--fg1)" strokeOpacity="0.35" strokeWidth="1" strokeDasharray="3 3"/>
|
|
121
|
+
<circle cx={xAt(hoverIdx)} cy={yAt(stack[hoverIdx][ORDER.length - 1])} r="4" fill="var(--bg-elev)" stroke="var(--fg1)" strokeWidth="1.5"/>
|
|
122
|
+
</g>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{/* invisible hit-zones per run for hover */}
|
|
126
|
+
{runs.map((r, i) => (
|
|
127
|
+
<rect
|
|
128
|
+
key={i}
|
|
129
|
+
x={xAt(i) - xStep / 2}
|
|
130
|
+
y={padT}
|
|
131
|
+
width={xStep || innerW}
|
|
132
|
+
height={innerH}
|
|
133
|
+
fill="transparent"
|
|
134
|
+
onMouseEnter={() => setHover(i)}
|
|
135
|
+
/>
|
|
136
|
+
))}
|
|
137
|
+
|
|
138
|
+
{/* x-axis tick labels */}
|
|
139
|
+
{runs.map((r, i) => {
|
|
140
|
+
const isHover = hoverIdx === i;
|
|
141
|
+
const isLast = i === runs.length - 1;
|
|
142
|
+
// sparse labels: first, last, every 2nd otherwise
|
|
143
|
+
const show = i === 0 || isLast || isHover || i % 2 === 0;
|
|
144
|
+
if (!show) return null;
|
|
145
|
+
return (
|
|
146
|
+
<text
|
|
147
|
+
key={i}
|
|
148
|
+
x={xAt(i)}
|
|
149
|
+
y={H - padB + 16}
|
|
150
|
+
textAnchor="middle"
|
|
151
|
+
fontFamily="var(--font-mono)"
|
|
152
|
+
fontSize="10.5"
|
|
153
|
+
fill={isHover || isLast ? 'var(--fg1)' : 'var(--fg3)'}
|
|
154
|
+
fontWeight={isHover || isLast ? 600 : 400}
|
|
155
|
+
>
|
|
156
|
+
#{r.short}
|
|
157
|
+
</text>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
{/* "now" marker on the last run */}
|
|
161
|
+
<text x={xAt(runs.length - 1)} y={H - padB + 30} textAnchor="middle" fontFamily="var(--font-mono)" fontSize="9.5" fill="var(--fg3)" letterSpacing="0.5" textTransform="uppercase">now</text>
|
|
162
|
+
|
|
163
|
+
{/* axis frame */}
|
|
164
|
+
<line x1={padL} x2={W - padR} y1={padT + innerH} y2={padT + innerH} stroke="var(--line-strong)" strokeWidth="1"/>
|
|
165
|
+
</svg>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============== TIMELINE (Gantt by suite) ==============
|
|
171
|
+
//
|
|
172
|
+
// Rows = suites (each suite groups its tests on its own row, time-axis horizontal).
|
|
173
|
+
// X axis = wall-clock ms since run start.
|
|
174
|
+
// Bars = individual tests; gaps between bars = idle time within suite.
|
|
175
|
+
// Hover any bar → details fill the right sidebar (no floating tooltips that overflow).
|
|
176
|
+
function TimelineGantt({ tests, totalMs, onOpen }) {
|
|
177
|
+
const [hover, setHover] = React.useState(null);
|
|
178
|
+
|
|
179
|
+
// group tests by suite, preserve order, map to rows
|
|
180
|
+
const bySuite = {};
|
|
181
|
+
tests.forEach(t => {
|
|
182
|
+
if (!bySuite[t.suite]) bySuite[t.suite] = [];
|
|
183
|
+
bySuite[t.suite].push(t);
|
|
184
|
+
});
|
|
185
|
+
const suiteRows = Object.entries(bySuite); // [[suiteName, [tests]], ...]
|
|
186
|
+
|
|
187
|
+
const rowH = 44;
|
|
188
|
+
const padL = 220, padR = 16, padT = 22, padB = 28;
|
|
189
|
+
const W = 1000;
|
|
190
|
+
const H = padT + suiteRows.length * rowH + padB;
|
|
191
|
+
const innerW = W - padL - padR;
|
|
192
|
+
const x = ms => padL + (ms / totalMs) * innerW;
|
|
193
|
+
|
|
194
|
+
// tick spacing — aim for 6-8 evenly-spaced ticks regardless of scale, so
|
|
195
|
+
// the label row stays legible whether the run is 200ms or 5min long.
|
|
196
|
+
const niceStep = (max) => {
|
|
197
|
+
const target = max / 7;
|
|
198
|
+
const exp = Math.floor(Math.log10(target));
|
|
199
|
+
const f = target / Math.pow(10, exp);
|
|
200
|
+
const round = f < 1.5 ? 1 : f < 3.5 ? 2 : f < 7.5 ? 5 : 10;
|
|
201
|
+
return round * Math.pow(10, exp);
|
|
202
|
+
};
|
|
203
|
+
const tickStep = niceStep(totalMs);
|
|
204
|
+
const ticks = [];
|
|
205
|
+
for (let t = 0; t <= totalMs; t += tickStep) ticks.push(t);
|
|
206
|
+
if (ticks[ticks.length-1] < totalMs - tickStep / 2) ticks.push(totalMs);
|
|
207
|
+
|
|
208
|
+
// Format ticks per scale: ms below 2s, integer seconds below 10s,
|
|
209
|
+
// round seconds at scale, minutes-and-seconds for very long runs.
|
|
210
|
+
const fmtTick = (ms) => {
|
|
211
|
+
if (totalMs < 2000) return ms + 'ms';
|
|
212
|
+
if (totalMs < 10000) return (ms / 1000).toFixed(1) + 's';
|
|
213
|
+
if (totalMs < 120000) return Math.round(ms / 1000) + 's';
|
|
214
|
+
const m = Math.floor(ms / 60000);
|
|
215
|
+
const s = Math.round((ms % 60000) / 1000);
|
|
216
|
+
return s ? `${m}m ${s}s` : `${m}m`;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div style={{ display:'grid', gridTemplateColumns:'1fr 280px', gap:16, alignItems:'start' }}>
|
|
221
|
+
<div>
|
|
222
|
+
{/* axis caption above the chart — separated from the tick row so they
|
|
223
|
+
never collide. The "s" suffix on each tick already says "seconds". */}
|
|
224
|
+
<div style={{
|
|
225
|
+
fontFamily:'var(--font-mono)', fontSize:10, color:'var(--fg3)',
|
|
226
|
+
letterSpacing:1.5, textTransform:'uppercase',
|
|
227
|
+
paddingLeft: padL, marginBottom: 4,
|
|
228
|
+
}}>
|
|
229
|
+
Wall-clock time (s) →
|
|
230
|
+
</div>
|
|
231
|
+
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display:'block' }} onMouseLeave={() => setHover(null)}>
|
|
232
|
+
{/* row backgrounds */}
|
|
233
|
+
{suiteRows.map(([suite,_], i) => (
|
|
234
|
+
<rect key={i} x={padL} y={padT + i*rowH} width={innerW} height={rowH} fill={i%2 ? 'transparent' : 'var(--bg-2)'} fillOpacity="0.5"/>
|
|
235
|
+
))}
|
|
236
|
+
|
|
237
|
+
{/* time gridlines */}
|
|
238
|
+
{ticks.map((t,i) => (
|
|
239
|
+
<line key={i} x1={x(t)} x2={x(t)} y1={padT} y2={padT + suiteRows.length*rowH} stroke="var(--line)" strokeWidth="1" opacity={t === 0 ? 0.8 : 0.5}/>
|
|
240
|
+
))}
|
|
241
|
+
|
|
242
|
+
{/* time labels (top) */}
|
|
243
|
+
{ticks.map((t,i) => (
|
|
244
|
+
<text key={i} x={x(t)} y={padT - 6} textAnchor={i === 0 ? 'start' : i === ticks.length-1 ? 'end' : 'middle'} fontFamily="var(--font-mono)" fontSize="11" fill="var(--fg3)" fontVariantNumeric="tabular-nums">{fmtTick(t)}</text>
|
|
245
|
+
))}
|
|
246
|
+
|
|
247
|
+
{/* suite labels */}
|
|
248
|
+
{suiteRows.map(([suite, suiteTests], i) => (
|
|
249
|
+
<g key={i}>
|
|
250
|
+
<text x={padL - 12} y={padT + i*rowH + rowH/2 + 4} textAnchor="end" fontFamily="var(--font-mono)" fontSize="12" fill="var(--fg1)" fontWeight="600">{suite}</text>
|
|
251
|
+
<text x={padL - 12} y={padT + i*rowH + rowH/2 + 18} textAnchor="end" fontFamily="var(--font-mono)" fontSize="10" fill="var(--fg3)">{suiteTests.length} test{suiteTests.length !== 1 ? 's' : ''}</text>
|
|
252
|
+
</g>
|
|
253
|
+
))}
|
|
254
|
+
|
|
255
|
+
{/* bars */}
|
|
256
|
+
{suiteRows.map(([suite, suiteTests], rowIdx) =>
|
|
257
|
+
suiteTests.map((t,i) => {
|
|
258
|
+
if (t.durMs === 0) return null;
|
|
259
|
+
// Min bar width 10px so every bar is clickable; 3px slivers from
|
|
260
|
+
// sub-100ms tests in a 60s run window are unusable on hover.
|
|
261
|
+
const bx = x(t.start), bw = Math.max(10, x(t.start + t.durMs) - bx);
|
|
262
|
+
const by = padT + rowIdx*rowH + 6;
|
|
263
|
+
const bh = rowH - 12;
|
|
264
|
+
const c = `var(--status-${t.status})`;
|
|
265
|
+
const isHover = hover && hover.id === t.id;
|
|
266
|
+
return (
|
|
267
|
+
<g key={i} onMouseEnter={() => setHover(t)} onClick={() => onOpen?.(t)} style={{ cursor:'pointer' }}>
|
|
268
|
+
<title>{`${t.name} · ${t.dur} · ${t.status}`}</title>
|
|
269
|
+
<rect x={bx} y={by} width={bw} height={bh} rx="3" fill={c} fillOpacity={isHover ? 1 : 0.85} stroke={isHover ? c : 'transparent'} strokeWidth="2"/>
|
|
270
|
+
{/* Centered duration label — clipped to the bar so neighboring
|
|
271
|
+
bars never get text bleed-over. Only render when the bar is
|
|
272
|
+
wide enough to host the label without truncation. */}
|
|
273
|
+
{bw >= 44 && (
|
|
274
|
+
<text x={bx + bw/2} y={by + bh/2 + 4}
|
|
275
|
+
textAnchor="middle"
|
|
276
|
+
fontFamily="var(--font-mono)" fontSize="10.5" fill="#fff" fontWeight="700"
|
|
277
|
+
style={{ pointerEvents:'none' }}
|
|
278
|
+
clipPath={`inset(0 0 0 0)`}
|
|
279
|
+
>{t.dur}</text>
|
|
280
|
+
)}
|
|
281
|
+
</g>
|
|
282
|
+
);
|
|
283
|
+
})
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
{/* run end marker */}
|
|
287
|
+
<line x1={x(totalMs)} x2={x(totalMs)} y1={padT} y2={padT + suiteRows.length*rowH} stroke="var(--accent)" strokeWidth="2" strokeDasharray="2 2"/>
|
|
288
|
+
<text x={x(totalMs)} y={padT + suiteRows.length*rowH + 18} textAnchor="end" fontFamily="var(--font-mono)" fontSize="10" fill="var(--accent)" fontWeight="700">RUN END</text>
|
|
289
|
+
</svg>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{/* details sidebar */}
|
|
293
|
+
<TimelineDetails test={hover} totalMs={totalMs}/>
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function TimelineDetails({ test, totalMs }) {
|
|
299
|
+
if (!test) {
|
|
300
|
+
return (
|
|
301
|
+
<div style={{ border:'1px solid var(--line)', borderRadius:6, padding:14, background:'var(--bg-2)' }}>
|
|
302
|
+
<div className="k-overline" style={{ marginBottom:6 }}>How to read this</div>
|
|
303
|
+
<div style={{ fontSize:12, color:'var(--fg2)', lineHeight:1.5 }}>
|
|
304
|
+
Each row is a <b>suite</b>. Bars are the <b>tests</b> in that suite, positioned by when they started. Hover a bar for details, click to open.
|
|
305
|
+
</div>
|
|
306
|
+
<div style={{ marginTop:12, display:'flex', flexDirection:'column', gap:6 }}>
|
|
307
|
+
<LegendDot color="var(--status-passed)" label="Passed"/>
|
|
308
|
+
<LegendDot color="var(--status-failed)" label="Failed"/>
|
|
309
|
+
<LegendDot color="var(--status-broken)" label="Broken"/>
|
|
310
|
+
<LegendDot color="var(--status-skipped)" label="Skipped"/>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const c = `var(--status-${test.status})`;
|
|
316
|
+
const startPct = (test.start / totalMs) * 100;
|
|
317
|
+
return (
|
|
318
|
+
<div style={{ border:'1px solid var(--line)', borderRadius:6, padding:14, background:'var(--bg-elev)' }}>
|
|
319
|
+
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:10 }}>
|
|
320
|
+
<span style={{ width:10, height:10, borderRadius:2, background:c }}/>
|
|
321
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:10, color:'var(--fg3)', textTransform:'uppercase', letterSpacing:1 }}>{test.status}</span>
|
|
322
|
+
</div>
|
|
323
|
+
<div style={{ fontFamily:'var(--font-mono)', fontWeight:600, fontSize:13, color:'var(--fg1)', marginBottom:4, wordBreak:'break-word' }}>{test.name}</div>
|
|
324
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', marginBottom:10 }}>{test.suite}</div>
|
|
325
|
+
|
|
326
|
+
<div style={{ display:'grid', gridTemplateColumns:'auto 1fr', gap:'6px 10px', fontFamily:'var(--font-mono)', fontSize:11.5 }}>
|
|
327
|
+
<span style={{ color:'var(--fg3)' }}>started</span><span style={{ color:'var(--fg1)' }}>{(test.start/1000).toFixed(2)}s ({startPct.toFixed(0)}% in)</span>
|
|
328
|
+
<span style={{ color:'var(--fg3)' }}>duration</span><span style={{ color:'var(--fg1)', fontWeight:600 }}>{test.dur}</span>
|
|
329
|
+
<span style={{ color:'var(--fg3)' }}>platform</span><span style={{ color:'var(--fg1)' }}>{test.platform}</span>
|
|
330
|
+
<span style={{ color:'var(--fg3)' }}>retries</span><span style={{ color:'var(--fg1)' }}>{test.retries}</span>
|
|
331
|
+
<span style={{ color:'var(--fg3)' }}>severity</span><span style={{ color:'var(--fg1)' }}>{test.severity}</span>
|
|
332
|
+
<span style={{ color:'var(--fg3)' }}>file</span><span style={{ color:'var(--fg1)', fontSize:10.5 }}>{test.file}</span>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function LegendDot({ color, label }) {
|
|
339
|
+
return (
|
|
340
|
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
|
341
|
+
<span style={{ width:14, height:8, borderRadius:2, background:color }}/>
|
|
342
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg2)' }}>{label}</span>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ============== RETRY WATERFALL ==============
|
|
348
|
+
function RetryWaterfall({ attempts }) {
|
|
349
|
+
// attempts: [{ status, dur, label }]
|
|
350
|
+
const max = Math.max(...attempts.map(a => a.dur));
|
|
351
|
+
return (
|
|
352
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
353
|
+
{attempts.map((a, i) => (
|
|
354
|
+
<div key={i} style={{ display: 'grid', gridTemplateColumns: '70px 1fr 70px', alignItems: 'center', gap: 12 }}>
|
|
355
|
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)' }}>attempt {i+1}</div>
|
|
356
|
+
<div style={{ height: 18, background: 'var(--bg-sunken)', borderRadius: 3, overflow: 'hidden' }}>
|
|
357
|
+
<div style={{ width: `${(a.dur/max)*100}%`, height: '100%', background: `var(--status-${a.status})`, display: 'flex', alignItems: 'center', paddingLeft: 8, color: '#fff', fontSize: 11, fontWeight: 600 }}>{a.label}</div>
|
|
358
|
+
</div>
|
|
359
|
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg2)', textAlign: 'right' }}>{(a.dur/1000).toFixed(2)}s</div>
|
|
360
|
+
</div>
|
|
361
|
+
))}
|
|
362
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ============== SUITE HEATMAP ==============
|
|
367
|
+
function SuiteHeatmap({ suites, runs }) {
|
|
368
|
+
// suites: [{name, statuses: [last N statuses]}]
|
|
369
|
+
return (
|
|
370
|
+
<div>
|
|
371
|
+
<div style={{ display: 'grid', gridTemplateColumns: '240px repeat(' + runs + ', 1fr)', alignItems: 'center', gap: 4, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg3)', marginBottom: 6 }}>
|
|
372
|
+
<div></div>
|
|
373
|
+
{[...Array(runs)].map((_,i) => <div key={i} style={{ textAlign: 'center' }}>{i === 0 ? 'oldest' : i === runs-1 ? 'latest' : ''}</div>)}
|
|
374
|
+
</div>
|
|
375
|
+
{suites.map((s,i) => (
|
|
376
|
+
<div key={i} style={{ display: 'grid', gridTemplateColumns: '240px repeat(' + runs + ', 1fr)', alignItems: 'center', gap: 4, marginBottom: 4 }}>
|
|
377
|
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.name}</div>
|
|
378
|
+
{s.statuses.map((st, j) => (
|
|
379
|
+
<div key={j} title={st} style={{ height: 20, borderRadius: 3, background: `var(--status-${st})`, opacity: st === 'passed' ? 0.85 : 1 }}></div>
|
|
380
|
+
))}
|
|
381
|
+
</div>
|
|
382
|
+
))}
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ============== FLAKE-RATE SCATTER (quadrant) ==============
|
|
388
|
+
function FlakeScatter({ tests }) {
|
|
389
|
+
const [hoverIdx, setHover] = React.useState(null);
|
|
390
|
+
const [hoverPos, setHoverPos] = React.useState({ x:0, y:0 }); // chart-pixel coords inside .scatter-wrap
|
|
391
|
+
const wrapRef = React.useRef(null);
|
|
392
|
+
const W = 560, H = 360, padL = 56, padR = 24, padT = 24, padB = 50;
|
|
393
|
+
const innerW = W - padL - padR, innerH = H - padT - padB;
|
|
394
|
+
const maxDur = Math.max(...tests.map(t => t.avgDur));
|
|
395
|
+
const x = d => padL + (d / maxDur) * innerW;
|
|
396
|
+
const y = f => padT + innerH - (f * innerH);
|
|
397
|
+
|
|
398
|
+
const flakeMid = 0.15, flakeHigh = 0.35;
|
|
399
|
+
const sorted = [...tests].sort((a,b) => b.flakeRate - a.flakeRate);
|
|
400
|
+
|
|
401
|
+
// Convert SVG coords (which scale with viewBox) to live wrapper pixels for HTML tooltip
|
|
402
|
+
const svgToPx = (sx, sy) => {
|
|
403
|
+
const wrap = wrapRef.current;
|
|
404
|
+
if (!wrap) return { x: 0, y: 0 };
|
|
405
|
+
const r = wrap.getBoundingClientRect();
|
|
406
|
+
return { x: (sx / W) * r.width, y: (sy / H) * r.height };
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const onPointEnter = (i) => () => {
|
|
410
|
+
const t = tests[i];
|
|
411
|
+
const cx = x(t.avgDur), cy = y(t.flakeRate);
|
|
412
|
+
setHoverPos(svgToPx(cx, cy));
|
|
413
|
+
setHover(i);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<div style={{ display:'grid', gridTemplateColumns:'1fr 240px', gap:20, alignItems:'start' }}>
|
|
418
|
+
<div ref={wrapRef} className="scatter-wrap" style={{ position:'relative' }} onMouseLeave={() => setHover(null)}>
|
|
419
|
+
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display:'block' }} preserveAspectRatio="xMidYMid meet">
|
|
420
|
+
{/* quadrant fills */}
|
|
421
|
+
<rect x={padL} y={padT} width={innerW} height={y(flakeHigh) - padT} fill="var(--status-failed-bg)" fillOpacity="0.6"/>
|
|
422
|
+
<rect x={padL} y={y(flakeHigh)} width={innerW} height={y(flakeMid) - y(flakeHigh)} fill="var(--status-broken-bg)" fillOpacity="0.55"/>
|
|
423
|
+
<rect x={padL} y={y(flakeMid)} width={innerW} height={padT + innerH - y(flakeMid)} fill="var(--status-passed-bg)" fillOpacity="0.45"/>
|
|
424
|
+
|
|
425
|
+
{/* zone labels */}
|
|
426
|
+
<text x={padL + 10} y={padT + 16} fontFamily="var(--font-mono)" fontSize="10" fill="var(--status-failed)" letterSpacing="1.5">CRITICAL ≥35%</text>
|
|
427
|
+
<text x={padL + 10} y={y(flakeHigh) + 14} fontFamily="var(--font-mono)" fontSize="10" fill="var(--status-broken-fg)" letterSpacing="1.5">UNSTABLE 15–35%</text>
|
|
428
|
+
<text x={padL + 10} y={y(flakeMid) + 14} fontFamily="var(--font-mono)" fontSize="10" fill="var(--status-passed)" letterSpacing="1.5">HEALTHY <15%</text>
|
|
429
|
+
|
|
430
|
+
{/* gridlines + y ticks */}
|
|
431
|
+
{[0, 0.15, 0.35, 0.5, 0.75, 1].map(f => (
|
|
432
|
+
<g key={f}>
|
|
433
|
+
<line x1={padL} x2={padL + innerW} y1={y(f)} y2={y(f)} stroke="var(--line)" strokeWidth="1"/>
|
|
434
|
+
<text x={padL - 10} y={y(f) + 4} textAnchor="end" fontFamily="var(--font-mono)" fontSize="11" fill="var(--fg3)" fontVariantNumeric="tabular-nums">{Math.round(f*100)}%</text>
|
|
435
|
+
</g>
|
|
436
|
+
))}
|
|
437
|
+
{/* x ticks */}
|
|
438
|
+
{[0, maxDur*0.25, maxDur*0.5, maxDur*0.75, maxDur].map((d,i) => (
|
|
439
|
+
<g key={i}>
|
|
440
|
+
<line x1={x(d)} x2={x(d)} y1={padT} y2={padT + innerH} stroke="var(--line)" strokeWidth="1" opacity={i === 0 ? 0 : 0.5}/>
|
|
441
|
+
<text x={x(d)} y={padT + innerH + 16} textAnchor="middle" fontFamily="var(--font-mono)" fontSize="11" fill="var(--fg3)" fontVariantNumeric="tabular-nums">{(d/1000).toFixed(1)}s</text>
|
|
442
|
+
</g>
|
|
443
|
+
))}
|
|
444
|
+
|
|
445
|
+
{/* axis labels */}
|
|
446
|
+
<text x={padL - 44} y={padT - 8} fontFamily="var(--font-mono)" fontSize="10" fill="var(--fg3)" letterSpacing="1.5">FLAKE RATE</text>
|
|
447
|
+
<text x={padL + innerW} y={padT + innerH + 36} textAnchor="end" fontFamily="var(--font-mono)" fontSize="10" fill="var(--fg3)" letterSpacing="1.5">AVG DURATION →</text>
|
|
448
|
+
|
|
449
|
+
<line x1={padL} x2={padL + innerW} y1={padT + innerH} y2={padT + innerH} stroke="var(--line-strong)"/>
|
|
450
|
+
<line x1={padL} x2={padL} y1={padT} y2={padT + innerH} stroke="var(--line-strong)"/>
|
|
451
|
+
|
|
452
|
+
{/* points — fixed radius, no per-hover state in props (CSS handles emphasis via class) */}
|
|
453
|
+
{tests.map((t,i) => {
|
|
454
|
+
const cx = x(t.avgDur), cy = y(t.flakeRate);
|
|
455
|
+
const c = t.flakeRate >= flakeHigh ? 'var(--status-failed)' : t.flakeRate >= flakeMid ? 'var(--status-broken)' : 'var(--status-passed)';
|
|
456
|
+
const isHover = hoverIdx === i;
|
|
457
|
+
return (
|
|
458
|
+
<circle
|
|
459
|
+
key={i}
|
|
460
|
+
cx={cx} cy={cy}
|
|
461
|
+
r={isHover ? 8 : 6}
|
|
462
|
+
fill={c}
|
|
463
|
+
fillOpacity={isHover ? 1 : 0.78}
|
|
464
|
+
stroke={c}
|
|
465
|
+
strokeWidth={isHover ? 2 : 1}
|
|
466
|
+
style={{ cursor:'pointer', transition:'r 120ms, fill-opacity 120ms' }}
|
|
467
|
+
onMouseEnter={onPointEnter(i)}
|
|
468
|
+
/>
|
|
469
|
+
);
|
|
470
|
+
})}
|
|
471
|
+
</svg>
|
|
472
|
+
|
|
473
|
+
{/* HTML tooltip — fixed pixel size, never scales with the SVG viewBox */}
|
|
474
|
+
{hoverIdx !== null && (() => {
|
|
475
|
+
const t = tests[hoverIdx];
|
|
476
|
+
const wrap = wrapRef.current;
|
|
477
|
+
const w = wrap ? wrap.getBoundingClientRect().width : W;
|
|
478
|
+
const h = wrap ? wrap.getBoundingClientRect().height : H;
|
|
479
|
+
// edge-aware: flip horizontally / vertically if too close to edges
|
|
480
|
+
const TT_W = 196, TT_H = 50;
|
|
481
|
+
let left = hoverPos.x + 14;
|
|
482
|
+
let top = hoverPos.y - TT_H - 10;
|
|
483
|
+
if (left + TT_W > w - 6) left = hoverPos.x - 14 - TT_W;
|
|
484
|
+
if (top < 6) top = hoverPos.y + 14;
|
|
485
|
+
return (
|
|
486
|
+
<div
|
|
487
|
+
style={{
|
|
488
|
+
position:'absolute',
|
|
489
|
+
left, top,
|
|
490
|
+
width: TT_W,
|
|
491
|
+
pointerEvents:'none',
|
|
492
|
+
background:'#0B1220',
|
|
493
|
+
color:'#fff',
|
|
494
|
+
border:'1px solid rgba(255,255,255,0.08)',
|
|
495
|
+
borderRadius:6,
|
|
496
|
+
padding:'8px 10px',
|
|
497
|
+
boxShadow:'0 6px 18px rgba(0,0,0,0.35)',
|
|
498
|
+
fontFamily:'var(--font-mono)',
|
|
499
|
+
zIndex:10,
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
<div style={{ fontSize:12.5, fontWeight:700, lineHeight:1.2, whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>{t.name}</div>
|
|
503
|
+
<div style={{ fontSize:11, color:'rgba(255,255,255,0.65)', marginTop:3 }}>
|
|
504
|
+
{Math.round(t.flakeRate*100)}% flaky · {(t.avgDur/1000).toFixed(1)}s avg
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
})()}
|
|
509
|
+
</div>
|
|
510
|
+
|
|
511
|
+
<div>
|
|
512
|
+
<div className="k-overline" style={{ marginBottom:8 }}>Worst offenders</div>
|
|
513
|
+
<div style={{ border:'1px solid var(--line)', borderRadius:6, overflow:'hidden', background:'var(--bg-elev)' }}>
|
|
514
|
+
{sorted.slice(0,8).map((t,i) => {
|
|
515
|
+
const tIdx = tests.indexOf(t);
|
|
516
|
+
const c = t.flakeRate >= flakeHigh ? 'var(--status-failed)' : t.flakeRate >= flakeMid ? 'var(--status-broken)' : 'var(--status-passed)';
|
|
517
|
+
const active = hoverIdx === tIdx;
|
|
518
|
+
return (
|
|
519
|
+
<div
|
|
520
|
+
key={i}
|
|
521
|
+
onMouseEnter={() => {
|
|
522
|
+
// also reposition the tooltip near this point in the chart
|
|
523
|
+
const cx = x(t.avgDur), cy = y(t.flakeRate);
|
|
524
|
+
setHoverPos(svgToPx(cx, cy));
|
|
525
|
+
setHover(tIdx);
|
|
526
|
+
}}
|
|
527
|
+
onMouseLeave={() => setHover(null)}
|
|
528
|
+
style={{
|
|
529
|
+
display:'grid', gridTemplateColumns:'8px 1fr auto', gap:8, padding:'8px 10px',
|
|
530
|
+
borderBottom: i < 7 ? '1px solid var(--line)' : 'none', alignItems:'center', cursor:'pointer',
|
|
531
|
+
background: active ? 'var(--bg-hover)' : 'transparent',
|
|
532
|
+
transition:'background var(--dur-fast)'
|
|
533
|
+
}}
|
|
534
|
+
>
|
|
535
|
+
<span style={{ width:8, height:8, borderRadius:2, background:c }}/>
|
|
536
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:11.5, color:'var(--fg1)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{t.name}</div>
|
|
537
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:c, fontWeight:700, fontVariantNumeric:'tabular-nums' }}>{Math.round(t.flakeRate*100)}%</div>
|
|
538
|
+
</div>
|
|
539
|
+
);
|
|
540
|
+
})}
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ============== HORIZONTAL BAR CHART ==============
|
|
548
|
+
function HBars({ data, max }) {
|
|
549
|
+
const m = max || Math.max(...data.map(d => d.value));
|
|
550
|
+
return (
|
|
551
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
552
|
+
{data.map((d,i) => (
|
|
553
|
+
<div key={i} style={{ display: 'grid', gridTemplateColumns: '180px 1fr 50px', alignItems: 'center', gap: 12 }}>
|
|
554
|
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.label}</div>
|
|
555
|
+
<div style={{ height: 14, background: 'var(--bg-sunken)', borderRadius: 3, overflow: 'hidden' }}>
|
|
556
|
+
<div style={{ width: `${(d.value/m)*100}%`, height: '100%', background: d.color || 'var(--brand-blue-500)', borderRadius: 3 }}></div>
|
|
557
|
+
</div>
|
|
558
|
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg2)', textAlign: 'right' }}>{d.display ?? d.value}</div>
|
|
559
|
+
</div>
|
|
560
|
+
))}
|
|
561
|
+
</div>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ============== DURATION HISTOGRAM ==============
|
|
566
|
+
function DurationHistogram({ buckets }) {
|
|
567
|
+
const W = 720, H = 180, padL = 28, padR = 12, padT = 12, padB = 28;
|
|
568
|
+
const innerW = W - padL - padR, innerH = H - padT - padB;
|
|
569
|
+
const max = Math.max(...buckets.map(b => b.n));
|
|
570
|
+
const bw = innerW / buckets.length - 4;
|
|
571
|
+
return (
|
|
572
|
+
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }}>
|
|
573
|
+
{[0, max/2, max].map((m,i) => (
|
|
574
|
+
<g key={i}>
|
|
575
|
+
<line x1={padL} x2={W-padR} y1={padT + innerH - (m/max)*innerH} y2={padT + innerH - (m/max)*innerH} stroke="var(--line)" strokeDasharray="2 4"/>
|
|
576
|
+
<text x={padL-6} y={padT + innerH - (m/max)*innerH + 3} textAnchor="end" fontFamily="var(--font-mono)" fontSize="10" fill="var(--fg3)">{Math.round(m)}</text>
|
|
577
|
+
</g>
|
|
578
|
+
))}
|
|
579
|
+
{buckets.map((b,i) => {
|
|
580
|
+
const h = (b.n/max)*innerH;
|
|
581
|
+
const x = padL + i*(innerW/buckets.length) + 2;
|
|
582
|
+
return (
|
|
583
|
+
<g key={i}>
|
|
584
|
+
<rect x={x} y={padT + innerH - h} width={bw} height={h} fill="var(--brand-blue-500)" fillOpacity="0.85" rx="2"/>
|
|
585
|
+
<text x={x+bw/2} y={H-10} textAnchor="middle" fontFamily="var(--font-mono)" fontSize="10" fill="var(--fg3)">{b.label}</text>
|
|
586
|
+
</g>
|
|
587
|
+
);
|
|
588
|
+
})}
|
|
589
|
+
</svg>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
Object.assign(window, { TrendChartV2, TimelineGantt, RetryWaterfall, SuiteHeatmap, FlakeScatter, HBars, DurationHistogram });
|