Skip to content

Commit 058f9d3

Browse files
ldionneclaude
andcommitted
[UI] Optimize Graph page with per-test caching, hard cap, and POST /query
Restructure the Graph page data loading to fetch only the tests being displayed instead of all data for a machine+metric: - Discover matching test names server-side via GET /tests with machine, metric, and name_contains filters - Fetch data only for discovered tests via POST /query with multi-value test in the JSON body (eliminates URL length limits) - Cache data per (machine, metric, test) with LRU eviction at 500 entries -- filter changes only fetch uncached tests (the delta) - Enforce a hard cap of 50 displayed tests (replaces soft cap of 20) - Case-sensitive test filtering (matches server-side SQL LIKE behavior) - Baseline data fetching scoped to discovered tests only Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 088755b commit 058f9d3

4 files changed

Lines changed: 401 additions & 358 deletions

File tree

lnt/server/ui/v5/frontend/src/__tests__/api.test.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,7 @@ describe('updateOrderTag', () => {
892892
});
893893

894894
describe('queryDataPoints', () => {
895-
it('passes machine and metric as query params', async () => {
895+
it('sends POST with JSON body containing machine and metric', async () => {
896896
const pt: QueryDataPoint = {
897897
test: 't1', machine: 'm1', metric: 'exec_time', value: 1.0,
898898
order: { rev: '100' }, run_uuid: 'r1', timestamp: null,
@@ -904,35 +904,40 @@ describe('queryDataPoints', () => {
904904
expect(result).toHaveLength(1);
905905
const url = new URL(mockFetch.mock.calls[0][0]);
906906
expect(url.pathname).toBe('/api/v5/nts/query');
907-
expect(url.searchParams.get('machine')).toBe('m1');
908-
expect(url.searchParams.get('metric')).toBe('exec_time');
907+
const init = mockFetch.mock.calls[0][1] as RequestInit;
908+
expect(init.method).toBe('POST');
909+
const body = JSON.parse(init.body as string);
910+
expect(body.machine).toBe('m1');
911+
expect(body.metric).toBe('exec_time');
909912
});
910913

911-
it('passes optional filter params', async () => {
914+
it('passes optional filter params in POST body', async () => {
912915
mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([])));
913916

914917
await queryDataPoints('nts', {
915918
machine: 'm1', metric: 'exec_time',
916919
test: 't1', afterOrder: '100', beforeOrder: '200',
917920
});
918921

919-
const url = new URL(mockFetch.mock.calls[0][0]);
920-
expect(url.searchParams.get('test')).toBe('t1');
921-
expect(url.searchParams.get('after_order')).toBe('100');
922-
expect(url.searchParams.get('before_order')).toBe('200');
922+
const init = mockFetch.mock.calls[0][1] as RequestInit;
923+
const body = JSON.parse(init.body as string);
924+
expect(body.test).toEqual(['t1']);
925+
expect(body.after_order).toBe('100');
926+
expect(body.before_order).toBe('200');
923927
});
924928

925-
it('passes order exact-match param', async () => {
929+
it('passes order exact-match param in POST body', async () => {
926930
mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([])));
927931

928932
await queryDataPoints('nts', {
929933
machine: 'm1', metric: 'exec_time', order: '100',
930934
});
931935

932-
const url = new URL(mockFetch.mock.calls[0][0]);
933-
expect(url.searchParams.get('order')).toBe('100');
934-
expect(url.searchParams.has('after_order')).toBe(false);
935-
expect(url.searchParams.has('before_order')).toBe(false);
936+
const init = mockFetch.mock.calls[0][1] as RequestInit;
937+
const body = JSON.parse(init.body as string);
938+
expect(body.order).toBe('100');
939+
expect(body.after_order).toBeUndefined();
940+
expect(body.before_order).toBeUndefined();
936941
});
937942

938943
it('auto-paginates across multiple pages', async () => {
@@ -948,6 +953,10 @@ describe('queryDataPoints', () => {
948953

949954
expect(result).toHaveLength(2);
950955
expect(mockFetch).toHaveBeenCalledTimes(2);
956+
// Second call should include cursor in body
957+
const init2 = mockFetch.mock.calls[1][1] as RequestInit;
958+
const body2 = JSON.parse(init2.body as string);
959+
expect(body2.cursor).toBe('cursor-2');
951960
});
952961
});
953962

lnt/server/ui/v5/frontend/src/__tests__/pages/graph.test.ts

Lines changed: 33 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ describe('buildTraces', () => {
8686
makePoint('test-B', '100', 3.0),
8787
];
8888

89-
const traces = buildTraces(points, '', 'median', 'median');
89+
const traces = buildTraces(points, 'median', 'median');
9090

9191
expect(traces).toHaveLength(2);
9292
expect(traces[0].testName).toBe('test-A');
@@ -95,17 +95,16 @@ describe('buildTraces', () => {
9595
expect(traces[1].points).toHaveLength(1);
9696
});
9797

98-
it('applies test filter (case-insensitive substring)', () => {
98+
it('handles pre-filtered input (single test)', () => {
9999
const points = [
100100
makePoint('compile/test-A', '100', 1.0),
101-
makePoint('exec/test-B', '100', 2.0),
102-
makePoint('compile/test-C', '100', 3.0),
101+
makePoint('compile/test-A', '101', 2.0),
103102
];
104103

105-
const traces = buildTraces(points, 'compile', 'median', 'median');
104+
const traces = buildTraces(points, 'median', 'median');
106105

107-
expect(traces).toHaveLength(2);
108-
expect(traces.map(t => t.testName).sort()).toEqual(['compile/test-A', 'compile/test-C']);
106+
expect(traces).toHaveLength(1);
107+
expect(traces[0].testName).toBe('compile/test-A');
109108
});
110109

111110
it('aggregates multiple runs at same order using run aggregation', () => {
@@ -116,7 +115,7 @@ describe('buildTraces', () => {
116115
];
117116

118117
// Median of [1.0, 3.0, 5.0] = 3.0
119-
const traces = buildTraces(points, '', 'median', 'median');
118+
const traces = buildTraces(points, 'median', 'median');
120119
expect(traces).toHaveLength(1);
121120
expect(traces[0].points).toHaveLength(1);
122121
expect(traces[0].points[0].value).toBe(3.0);
@@ -130,19 +129,17 @@ describe('buildTraces', () => {
130129
];
131130

132131
// Mean of [1.0, 3.0] = 2.0
133-
const traces = buildTraces(points, '', 'mean', 'median');
132+
const traces = buildTraces(points, 'mean', 'median');
134133
expect(traces[0].points[0].value).toBe(2.0);
135134
});
136135

137-
it('returns empty array when no points match filter', () => {
138-
const points = [makePoint('test-A', '100', 1.0)];
139-
140-
const traces = buildTraces(points, 'nonexistent', 'median', 'median');
141-
expect(traces).toHaveLength(0);
136+
it('returns empty array for single empty-points input', () => {
137+
const traces = buildTraces([makePoint('test-A', '100', 1.0)], 'median', 'median');
138+
expect(traces).toHaveLength(1);
142139
});
143140

144141
it('returns empty array for empty input', () => {
145-
const traces = buildTraces([], '', 'median', 'median');
142+
const traces = buildTraces([], 'median', 'median');
146143
expect(traces).toHaveLength(0);
147144
});
148145

@@ -153,7 +150,7 @@ describe('buildTraces', () => {
153150
makePoint('middle', '100', 3.0),
154151
];
155152

156-
const traces = buildTraces(points, '', 'median', 'median');
153+
const traces = buildTraces(points, 'median', 'median');
157154
expect(traces.map(t => t.testName)).toEqual(['alpha', 'middle', 'zebra']);
158155
});
159156

@@ -164,7 +161,7 @@ describe('buildTraces', () => {
164161
makePoint('test-A', '102', 3.0),
165162
];
166163

167-
const traces = buildTraces(points, '', 'median', 'median');
164+
const traces = buildTraces(points, 'median', 'median');
168165
const orderValues = traces[0].points.map(p => p.orderValue);
169166
expect(orderValues).toEqual(['100', '101', '102']);
170167
});
@@ -176,7 +173,7 @@ describe('buildTraces', () => {
176173
makePoint('test-A', '100', 1.0),
177174
];
178175

179-
const traces = buildTraces(points, '', 'median', 'median');
176+
const traces = buildTraces(points, 'median', 'median');
180177
// buildTraces preserves Map insertion order, so reversed input stays reversed
181178
const orderValues = traces[0].points.map(p => p.orderValue);
182179
expect(orderValues).toEqual(['102', '101', '100']);
@@ -192,7 +189,7 @@ describe('buildTraces', () => {
192189
makePoint('test-B', '100', 4.0),
193190
];
194191

195-
const traces = buildTraces(points, '', 'median', 'median');
192+
const traces = buildTraces(points, 'median', 'median');
196193
expect(traces).toHaveLength(2);
197194
// test-A comes first alphabetically
198195
expect(traces[0].testName).toBe('test-A');
@@ -209,89 +206,62 @@ describe('computeActiveTests', () => {
209206
'papa', 'quebec', 'romeo', 'sierra', 'tango',
210207
'uniform', 'victor', 'whiskey'];
211208

212-
it('caps at 20 when autoCapped=true and no filter/hidden', () => {
213-
const active = computeActiveTests(names, '', new Set(), true);
214-
expect(active.size).toBe(20);
215-
expect(active.has('alpha')).toBe(true);
216-
expect(active.has('tango')).toBe(true); // 20th
217-
expect(active.has('uniform')).toBe(false); // 21st
218-
});
219-
220-
it('disables cap when filter is non-empty', () => {
221-
const active = computeActiveTests(names, 'a', new Set(), true);
222-
// 'alpha', 'charlie', 'delta', 'tango', 'papa', 'sierra', 'whiskey' — all with 'a'
223-
for (const n of names) {
224-
if (n.toLowerCase().includes('a')) {
225-
expect(active.has(n)).toBe(true);
226-
} else {
227-
expect(active.has(n)).toBe(false);
228-
}
229-
}
209+
it('returns all names when no hidden set', () => {
210+
const active = computeActiveTests(names, '', new Set());
211+
expect(active.size).toBe(names.length);
230212
});
231213

232214
it('removes manually hidden tests', () => {
233215
const hidden = new Set(['beta', 'charlie']);
234-
const active = computeActiveTests(names, '', hidden, false);
216+
const active = computeActiveTests(names, '', hidden);
235217
expect(active.has('alpha')).toBe(true);
236218
expect(active.has('beta')).toBe(false);
237219
expect(active.has('charlie')).toBe(false);
238220
expect(active.has('delta')).toBe(true);
239221
});
240222

241-
it('cap is disabled when manuallyHidden is non-empty', () => {
242-
const hidden = new Set(['alpha']);
243-
// autoCapped=true but hidden is non-empty → cap disabled
244-
const active = computeActiveTests(names, '', hidden, true);
245-
// All names except 'alpha' should be active (no 20-cap)
246-
expect(active.size).toBe(names.length - 1);
247-
expect(active.has('alpha')).toBe(false);
248-
expect(active.has('uniform')).toBe(true); // would be capped otherwise
249-
});
250-
251223
it('returns empty set for empty input', () => {
252-
const active = computeActiveTests([], '', new Set(), true);
224+
const active = computeActiveTests([], '', new Set());
253225
expect(active.size).toBe(0);
254226
});
255227

256228
it('double-click isolation is just manuallyHidden with all others hidden', () => {
257-
// Simulates what onIsolate does: hide all except 'charlie'
258229
const hidden = new Set(names.filter(n => n !== 'charlie'));
259-
const active = computeActiveTests(names, '', hidden, false);
230+
const active = computeActiveTests(names, '', hidden);
260231
expect(active.size).toBe(1);
261232
expect(active.has('charlie')).toBe(true);
262233
});
263234

264235
it('after isolation, single-click unhide works naturally', () => {
265-
// After isolating 'charlie', user single-clicks 'alpha' to unhide it
266236
const hidden = new Set(names.filter(n => n !== 'charlie'));
267237
hidden.delete('alpha');
268-
const active = computeActiveTests(names, '', hidden, false);
238+
const active = computeActiveTests(names, '', hidden);
269239
expect(active.size).toBe(2);
270240
expect(active.has('charlie')).toBe(true);
271241
expect(active.has('alpha')).toBe(true);
272242
});
273243

274-
it('filters multi-machine trace names by test name portion only', () => {
244+
it('handles multi-machine trace names', () => {
275245
const traceNames = [
276246
`compile/test-A${TRACE_SEP}clang-x86`,
277247
`compile/test-A${TRACE_SEP}gcc-arm`,
278248
`exec/test-B${TRACE_SEP}clang-x86`,
279249
`exec/test-B${TRACE_SEP}gcc-arm`,
280250
];
281-
const active = computeActiveTests(traceNames, 'compile', new Set(), false);
282-
expect(active.size).toBe(2);
283-
expect(active.has(`compile/test-A${TRACE_SEP}clang-x86`)).toBe(true);
284-
expect(active.has(`compile/test-A${TRACE_SEP}gcc-arm`)).toBe(true);
285-
expect(active.has(`exec/test-B${TRACE_SEP}clang-x86`)).toBe(false);
251+
const active = computeActiveTests(traceNames, 'compile', new Set());
252+
// No client-side filtering — all traces are active
253+
expect(active.size).toBe(4);
286254
});
287255

288-
it('filter does not match machine name', () => {
256+
it('hidden traces are removed regardless of filter string', () => {
289257
const traceNames = [
290258
`test-A${TRACE_SEP}clang-x86`,
291259
`test-A${TRACE_SEP}gcc-arm`,
292260
];
293-
const active = computeActiveTests(traceNames, 'clang', new Set(), false);
294-
expect(active.size).toBe(0);
261+
const hidden = new Set([`test-A${TRACE_SEP}clang-x86`]);
262+
const active = computeActiveTests(traceNames, '', hidden);
263+
expect(active.size).toBe(1);
264+
expect(active.has(`test-A${TRACE_SEP}gcc-arm`)).toBe(true);
295265
});
296266
});
297267

@@ -359,7 +329,7 @@ describe('buildBaselinesFromData', () => {
359329
const cache = buildCache(baselines, 'exec_time', [points]);
360330

361331
const blResult = buildBaselinesFromData(baselines, cache, 'exec_time', median);
362-
const traces = buildTraces(points, '', 'median', 'median');
332+
const traces = buildTraces(points, 'median', 'median');
363333

364334
// Both should produce exactly the same value for test-A at order 100
365335
const traceValue = traces.find(t => t.testName === 'test-A')!.points

0 commit comments

Comments
 (0)