Skip to content

Commit 80dfbdd

Browse files
authored
feat(ui): handle large files in executions table and file viewer (#177)
## Summary Handle large request/response files gracefully across the executions table and file viewer. ### Executions Table - Stream request files to extract per-line byte sizes and method names (first 256 bytes) without loading full content into memory — updates progressively with loading spinners per row - Skip full file fetch for files >50MB, use streaming summaries for metadata - Lazy-load individual line content via Range requests when expanding a row (<1MB lines) - Show "too large to display" with file viewer link for lines >1MB - Display per-line request/response sizes on each row and in expanded section headers - Add pagination (100 per page) as safety net - Align execution row columns (MGas/s, Duration, Sizes, Status) with fixed widths - Remove cache-buster from immutable .request/.response file fetches ### File Viewer - Chunked loading for large files (>10MB): loads from top in 10MB chunks with correct line numbers - Auto-load next chunk via IntersectionObserver when scrolling near the bottom - Auto-load chunks to reach a URL-specified line selection, then scroll to it - Info banner with spinner showing total size, loaded bytes, and line count - Support `base` query param for viewing suite-level files (not just run-level) - Fix `parseLineSelection` to handle numeric URL params ## Test plan - [x] Open a run with large setup request files (>50MB total) — verify table renders with method names, sizes, and durations without crashing - [x] Expand a small execution row — verify request JSON loads lazily - [x] Expand a large execution row (>1MB) — verify "too large" message with file viewer link - [x] Open a large log file in file viewer — verify chunked loading with auto-scroll - [x] Share a URL with `?lines=100000` on a large file — verify it auto-loads and scrolls to the line - [x] Verify no `?_t=` cache buster on .request/.response fetches
1 parent daa4c71 commit 80dfbdd

4 files changed

Lines changed: 625 additions & 78 deletions

File tree

ui/src/api/client.ts

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,19 @@ export async function fetchHead(path: string): Promise<HeadResult> {
124124
})
125125
}
126126

127-
export async function fetchText(path: string): Promise<FetchResult<string>> {
127+
export async function fetchText(path: string, opts?: { cacheBust?: boolean }): Promise<FetchResult<string>> {
128128
const config = await loadRuntimeConfig()
129129
const url = getDataUrl(path, config)
130+
const bust = opts?.cacheBust !== false
130131

131132
let response: Response
132133

133134
if (isS3Mode(config)) {
134135
response = await fetchViaS3(url)
135136
} else if (isLocalMode(config)) {
136-
response = await fetch(cacheBustUrl(url), { credentials: 'include' })
137+
response = await fetch(bust ? cacheBustUrl(url) : url, { credentials: 'include' })
137138
} else {
138-
response = await fetch(cacheBustUrl(url))
139+
response = await fetch(bust ? cacheBustUrl(url) : url)
139140
}
140141

141142
if (!response.ok) {
@@ -151,3 +152,143 @@ export async function fetchText(path: string): Promise<FetchResult<string>> {
151152

152153
return { data, status: response.status }
153154
}
155+
156+
/**
157+
* Fetch a byte range of a text file (using Range header).
158+
* Falls back to a full fetch + truncation if the server doesn't support Range.
159+
*/
160+
export async function fetchPartialText(path: string, bytes: number, offset = 0): Promise<FetchResult<string>> {
161+
const config = await loadRuntimeConfig()
162+
const url = getDataUrl(path, config)
163+
164+
let response: Response
165+
166+
const headers: HeadersInit = { Range: `bytes=${offset}-${offset + bytes - 1}` }
167+
168+
if (isS3Mode(config)) {
169+
response = await fetchViaS3(url)
170+
} else if (isLocalMode(config)) {
171+
response = await fetch(url, { credentials: 'include', headers })
172+
} else {
173+
response = await fetch(url, { headers })
174+
}
175+
176+
if (!response.ok && response.status !== 206) {
177+
return { data: null, status: response.status }
178+
}
179+
180+
const data = await response.text()
181+
return { data: data.slice(0, bytes), status: response.status }
182+
}
183+
184+
export interface LineSummary {
185+
size: number
186+
/** First N bytes of the line (UTF-8 decoded), for extracting metadata like method names. */
187+
head: string
188+
}
189+
190+
/**
191+
* Stream a text file and return per-line byte sizes and the first
192+
* `headBytes` characters of each line. The file content is never
193+
* held in memory — only the lightweight summaries are kept.
194+
*/
195+
const LINE_HEAD_BYTES = 256
196+
197+
export async function fetchLineSummaries(path: string): Promise<FetchResult<LineSummary[]>> {
198+
const config = await loadRuntimeConfig()
199+
const url = getDataUrl(path, config)
200+
201+
let response: Response
202+
203+
if (isS3Mode(config)) {
204+
response = await fetchViaS3(url)
205+
} else if (isLocalMode(config)) {
206+
response = await fetch(url, { credentials: 'include' })
207+
} else {
208+
response = await fetch(url)
209+
}
210+
211+
if (!response.ok || !response.body) {
212+
return { data: null, status: response.status }
213+
}
214+
215+
const lines: LineSummary[] = []
216+
const reader = response.body.getReader()
217+
const decoder = new TextDecoder()
218+
const newline = 10 // '\n'
219+
let lineSize = 0
220+
const headBuf: number[] = []
221+
222+
for (;;) {
223+
const { done, value } = await reader.read()
224+
if (done) break
225+
for (let i = 0; i < value.length; i++) {
226+
if (value[i] === newline) {
227+
lines.push({ size: lineSize, head: decoder.decode(new Uint8Array(headBuf)) })
228+
lineSize = 0
229+
headBuf.length = 0
230+
} else {
231+
lineSize++
232+
if (headBuf.length < LINE_HEAD_BYTES) headBuf.push(value[i])
233+
}
234+
}
235+
}
236+
237+
// Last line (if no trailing newline)
238+
if (lineSize > 0) {
239+
lines.push({ size: lineSize, head: decoder.decode(new Uint8Array(headBuf)) })
240+
}
241+
242+
return { data: lines, status: response.status }
243+
}
244+
245+
/**
246+
* Stream a text file and call `onLine` for each completed line.
247+
* Returns a promise that resolves when the stream is complete.
248+
*/
249+
export async function streamLineSummaries(
250+
path: string,
251+
onLine: (summary: LineSummary, index: number) => void,
252+
): Promise<void> {
253+
const config = await loadRuntimeConfig()
254+
const url = getDataUrl(path, config)
255+
256+
let response: Response
257+
258+
if (isS3Mode(config)) {
259+
response = await fetchViaS3(url)
260+
} else if (isLocalMode(config)) {
261+
response = await fetch(url, { credentials: 'include' })
262+
} else {
263+
response = await fetch(url)
264+
}
265+
266+
if (!response.ok || !response.body) return
267+
268+
const reader = response.body.getReader()
269+
const decoder = new TextDecoder()
270+
const newline = 10
271+
let lineSize = 0
272+
const headBuf: number[] = []
273+
let lineIndex = 0
274+
275+
for (;;) {
276+
const { done, value } = await reader.read()
277+
if (done) break
278+
for (let i = 0; i < value.length; i++) {
279+
if (value[i] === newline) {
280+
onLine({ size: lineSize, head: decoder.decode(new Uint8Array(headBuf)) }, lineIndex)
281+
lineSize = 0
282+
headBuf.length = 0
283+
lineIndex++
284+
} else {
285+
lineSize++
286+
if (headBuf.length < LINE_HEAD_BYTES) headBuf.push(value[i])
287+
}
288+
}
289+
}
290+
291+
if (lineSize > 0) {
292+
onLine({ size: lineSize, head: decoder.decode(new Uint8Array(headBuf)) }, lineIndex)
293+
}
294+
}

ui/src/api/hooks/useTestDetails.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { useEffect, useReducer } from 'react'
12
import { useQuery } from '@tanstack/react-query'
2-
import { fetchText, fetchData } from '../client'
3+
import { fetchText, fetchData, fetchHead, streamLineSummaries, type LineSummary } from '../client'
34
import type { AggregatedStats, ResultDetails } from '../types'
45

6+
const MAX_INLINE_FILE_SIZE = 50 * 1024 * 1024 // 50MB — skip full fetch above this
7+
58
// Step types for test execution
69
export type StepType = 'setup' | 'test' | 'cleanup' | 'pre_run'
710

@@ -29,7 +32,12 @@ export function useTestResponses(runId: string, testName: string, stepType: Step
2932
return useQuery({
3033
queryKey: ['run', runId, 'test', testName, 'step', stepType, 'responses'],
3134
queryFn: async () => {
32-
const { data, status } = await fetchText(path)
35+
const head = await fetchHead(path)
36+
if (head.exists && head.size !== null && head.size > MAX_INLINE_FILE_SIZE) {
37+
return null
38+
}
39+
40+
const { data, status } = await fetchText(path, { cacheBust: false })
3341
if (!data) {
3442
throw new Error(`Failed to fetch responses: ${status}`)
3543
}
@@ -39,6 +47,61 @@ export function useTestResponses(runId: string, testName: string, stepType: Step
3947
})
4048
}
4149

50+
/** Stream response file and return per-line summaries progressively. */
51+
export function useTestResponseSummaries(runId: string, testName: string, stepType: StepType) {
52+
const path = `runs/${runId}/${testName}/${stepType}.response`
53+
return useStreamingSummaries(path, !!runId && !!testName)
54+
}
55+
56+
/**
57+
* Progressive streaming hook — streams a newline-delimited file and
58+
* updates state as each line is scanned so UI rows appear one by one.
59+
*/
60+
61+
type StreamAction =
62+
| { type: 'reset'; path: string }
63+
| { type: 'line'; summary: LineSummary }
64+
| { type: 'done' }
65+
66+
interface StreamState {
67+
data: LineSummary[] | undefined
68+
isStreaming: boolean
69+
path: string
70+
}
71+
72+
function streamReducer(state: StreamState, action: StreamAction): StreamState {
73+
switch (action.type) {
74+
case 'reset':
75+
return { data: undefined, isStreaming: true, path: action.path }
76+
case 'line':
77+
return { ...state, data: state.data ? [...state.data, action.summary] : [action.summary] }
78+
case 'done':
79+
return { ...state, isStreaming: false }
80+
}
81+
}
82+
83+
function useStreamingSummaries(path: string, enabled: boolean) {
84+
const [state, dispatch] = useReducer(streamReducer, { data: undefined, isStreaming: false, path: '' })
85+
86+
useEffect(() => {
87+
if (!enabled) return
88+
89+
let cancelled = false
90+
dispatch({ type: 'reset', path })
91+
92+
streamLineSummaries(path, (summary) => {
93+
if (!cancelled) dispatch({ type: 'line', summary })
94+
}).finally(() => {
95+
if (!cancelled) dispatch({ type: 'done' })
96+
})
97+
98+
return () => { cancelled = true }
99+
}, [path, enabled])
100+
101+
const current = state.path === path ? state : { data: undefined, isStreaming: enabled }
102+
return { data: current.data, isStreaming: current.isStreaming }
103+
}
104+
42105
export function useTestAggregated(runId: string, testName: string, stepType: StepType) {
43106
// Path: runs/{runId}/{testName}/{stepType}.result-aggregated.json
44107
const path = `runs/${runId}/${testName}/${stepType}.result-aggregated.json`
@@ -63,7 +126,13 @@ export function useTestRequests(suiteHash: string, testName: string, stepType: S
63126
return useQuery({
64127
queryKey: ['suite', suiteHash, 'test', testName, 'step', stepType, 'requests'],
65128
queryFn: async () => {
66-
const { data, status } = await fetchText(path)
129+
// Skip full fetch for very large files — streaming summaries handle the rest
130+
const head = await fetchHead(path)
131+
if (head.exists && head.size !== null && head.size > MAX_INLINE_FILE_SIZE) {
132+
return null
133+
}
134+
135+
const { data, status } = await fetchText(path, { cacheBust: false })
67136
if (!data) {
68137
throw new Error(`Failed to fetch requests: ${status}`)
69138
}
@@ -72,3 +141,12 @@ export function useTestRequests(suiteHash: string, testName: string, stepType: S
72141
enabled: !!suiteHash && !!testName,
73142
})
74143
}
144+
145+
/**
146+
* Stream the request file and return per-line summaries progressively.
147+
* Updates state as each line is scanned so early rows appear immediately.
148+
*/
149+
export function useTestRequestSummaries(suiteHash: string, testName: string, stepType: StepType) {
150+
const path = `suites/${suiteHash}/${testName}/${stepType}.request`
151+
return useStreamingSummaries(path, !!suiteHash && !!testName)
152+
}

0 commit comments

Comments
 (0)