Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import Typography from '@mui/material/Typography';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useExecutionEvents } from '../../hooks/useExecutionEvents';
import { useLogDownload } from '../../hooks/useLogDownload';
import { useTaskLogs, type LogType } from '../../hooks/useTaskLogs';
import { useTaskLogs, type LogType, type StepText } from '../../hooks/useTaskLogs';
import { ExecutionEventsPanel } from './ExecutionEventsPanel';
import { LogOutputPane } from './LogOutputPane';
import { LogStepTabs } from './LogStepTabs';
Expand All @@ -53,6 +53,21 @@ export const LOG_TAIL_LINE_OPTIONS = [

export type LogTailLineChoice = (typeof LOG_TAIL_LINE_OPTIONS)[number]['value'];

const NUMERIC_LOG_TAIL_OPTIONS = LOG_TAIL_LINE_OPTIONS.map((option) => Number(option.value)).filter(
(value) => Number.isFinite(value),
);

/**
* Smallest numeric cap on offer. A proven-complete log at or below this size
* looks identical under every option, so the select has nothing left to do.
* Derived from the options list so changing the list moves the threshold.
*
* Falls back to 0 when the list holds no numeric option: `Math.min()` of an
* empty list is Infinity, which would hide the select for every finished task.
*/
const SMALLEST_LOG_TAIL_OPTION =
NUMERIC_LOG_TAIL_OPTIONS.length > 0 ? Math.min(...NUMERIC_LOG_TAIL_OPTIONS) : 0;

const LOG_TAIL_STORAGE_KEY = 'sep.taskLogViewer.tail';

const DEFAULT_LOG_TAIL_CHOICE = '1000' satisfies LogTailLineChoice;
Expand Down Expand Up @@ -85,6 +100,45 @@ function isRunningStatus(status?: string): boolean {
return (status ?? '').toLowerCase() === 'running';
}

/**
* Line count, saturating at `limit + 1`. Callers only need to know whether a
* pane is over the threshold, so a large log stops being scanned as soon as it
* provably is — no full pass over megabytes of "All lines" output.
*/
function countLinesUpTo(text: string, limit: number): number {
if (text === '') {
return 0;
}
let lines = 0;
for (let index = 0; index < text.length; index += 1) {
if (text[index] === '\n') {
lines += 1;
if (lines > limit) {
return lines;
}
}
}
// A trailing fragment without its newline is still a line on screen.
return text.endsWith('\n') ? lines : lines + 1;
}

/**
* Largest line count across every step and both streams, saturating at
* `limit + 1`. The line-cap decision uses this rather than the visible pane so
* the control does not appear and disappear as the user moves between step or
* stream tabs.
*/
function maxPaneLineCountUpTo(textByStep: Record<string, StepText>, limit: number): number {
let max = 0;
for (const pane of Object.values(textByStep)) {
max = Math.max(max, countLinesUpTo(pane.stdout, limit), countLinesUpTo(pane.stderr, limit));
if (max > limit) {
return max;
}
}
return max;
}

function resolveBadgeStatus(
finishStatus: ReturnType<typeof useTaskLogs>['finishStatus'],
error: ReturnType<typeof useTaskLogs>['error'],
Expand All @@ -100,7 +154,7 @@ export function TaskLogViewer({ taskHistoryId, taskStatus, height = 480 }: TaskL
const [logTailChoice, setLogTailChoice] = useState<LogTailLineChoice>(readStoredLogTailChoice);
const tailLines = logTailChoiceToParam(logTailChoice);
const effectiveTailLines = running ? undefined : tailLines;
const { textByStep, stepOrder, finishStatus, error } = useTaskLogs(
const { textByStep, stepOrder, streamStatus, finishStatus, error } = useTaskLogs(
taskHistoryId,
effectiveTailLines,
);
Expand Down Expand Up @@ -242,6 +296,25 @@ export function TaskLogViewer({ taskHistoryId, taskStatus, height = 480 }: TaskL

const badgeStatus = resolveBadgeStatus(finishStatus, error);

// Hide the line cap once a finished history has streamed a log that is
// provably complete and short enough that every option would show the same
// thing. Gated on the terminal stream status so the control does not flicker
// while lines are still arriving.
const showLogTailSelect = useMemo(() => {
if (running || streamStatus !== 'finished') {
return true;
}
// Saturated at one over the threshold: any pane above it keeps the select
// regardless of the requested cap, so the exact count no longer matters.
const maxLines = maxPaneLineCountUpTo(textByStep, SMALLEST_LOG_TAIL_OPTION);
if (maxLines > SMALLEST_LOG_TAIL_OPTION) {
return true;
}
// A pane sitting exactly at the requested cap may have been trimmed
// server-side, so only a count strictly below the cap proves completeness.
return effectiveTailLines !== undefined && maxLines >= effectiveTailLines;
}, [running, streamStatus, textByStep, effectiveTailLines]);

return (
<Paper variant="outlined" sx={{ display: 'flex', flexDirection: 'column' }}>
<Stack
Expand Down Expand Up @@ -281,36 +354,38 @@ export function TaskLogViewer({ taskHistoryId, taskStatus, height = 480 }: TaskL
</Tabs>
<Stack direction="row" alignItems="center" spacing={1} sx={{ pr: 1 }}>
{badgeStatus && <StatusBadge status={badgeStatus} />}
<Tooltip
title={
running
? 'Line cap applies to finished task logs only'
: 'Limit how many lines are loaded from the server'
}
>
<FormControl size="small" sx={{ minWidth: 96 }} disabled={running}>
<Select
value={logTailChoice}
onChange={(event) => handleLogTailChange(event.target.value as LogTailLineChoice)}
aria-label="Log lines to show"
disabled={running}
renderValue={(value) => (
<Typography variant="body2" component="span">
{value === 'all' ? 'All lines' : `Last ${value}`}
</Typography>
)}
sx={{
'& .MuiSelect-select': { py: 0.75, display: 'flex', alignItems: 'center' },
}}
>
{LOG_TAIL_LINE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label === 'All' ? 'All lines' : `Last ${option.label}`}
</MenuItem>
))}
</Select>
</FormControl>
</Tooltip>
{showLogTailSelect && (
<Tooltip
title={
running
? 'Line cap applies to finished task logs only'
: 'Limit how many lines are loaded from the server'
}
>
<FormControl size="small" sx={{ minWidth: 96 }} disabled={running}>
<Select
value={logTailChoice}
onChange={(event) => handleLogTailChange(event.target.value as LogTailLineChoice)}
aria-label="Log lines to show"
disabled={running}
renderValue={(value) => (
<Typography variant="body2" component="span">
{value === 'all' ? 'All lines' : `Last ${value}`}
</Typography>
)}
sx={{
'& .MuiSelect-select': { py: 0.75, display: 'flex', alignItems: 'center' },
}}
>
{LOG_TAIL_LINE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label === 'All' ? 'All lines' : `Last ${option.label}`}
</MenuItem>
))}
</Select>
</FormControl>
</Tooltip>
)}
<FormControlLabel
control={
<Switch size="small" checked={wrap} onChange={(_, checked) => setWrap(checked)} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ describe('TaskLogViewer', () => {
return screen.getByRole('combobox');
}

function queryTailSelect() {
return screen.queryByRole('combobox');
}

function lines(count: number): string {
return 'x\n'.repeat(count);
}

function fetchUrl(callIndex: number): string {
const url = mock.fetchSpy.mock.calls[callIndex]?.[0];
return typeof url === 'string' ? url : (url as URL).href;
Expand Down Expand Up @@ -202,6 +210,127 @@ describe('TaskLogViewer', () => {
expect(screen.getByText(/no output yet/i)).toBeInTheDocument();
});

it('hides the line cap when a finished log is provably shorter than the smallest option', async () => {
render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="20" taskStatus="SUCCESS" />
</QueryWrapper>,
);
await flushPromises();

const handle = getHandle('20');
act(() => {
handle.pushMessage({ msg: lines(3), step: 'setup', type: 'stdout', offset: 1 });
handle.pushMessage({ msg: lines(2), step: 'setup', type: 'stderr', offset: 1 });
handle.pushNamed('finish', { status: 'success' });
});

await waitFor(() => expect(queryTailSelect()).toBeNull());
// Hiding the control leaves the stored choice alone for the next log.
expect(globalThis.localStorage.getItem('sep.taskLogViewer.tail')).toBeNull();
// The request still carried the stored cap — size is unknown until it arrives.
expect(logFetchUrls()[0]).toBe('/stream-logs/20?tail=1000');
});

it('hides the line cap when a short finished log was fetched with All lines', async () => {
globalThis.localStorage.setItem('sep.taskLogViewer.tail', 'all');

render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="21" taskStatus="SUCCESS" />
</QueryWrapper>,
);
await flushPromises();

const handle = getHandle('21');
act(() => {
handle.pushMessage({ msg: lines(4), step: 'setup', type: 'stdout', offset: 1 });
handle.pushNamed('finish', { status: 'success' });
});

await waitFor(() => expect(queryTailSelect()).toBeNull());
});

it('keeps the line cap when a finished pane sits exactly at the requested cap', async () => {
globalThis.localStorage.setItem('sep.taskLogViewer.tail', '100');

render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="22" taskStatus="SUCCESS" />
</QueryWrapper>,
);
await flushPromises();
expect(logFetchUrls()[0]).toBe('/stream-logs/22?tail=100');

const handle = getHandle('22');
act(() => {
handle.pushMessage({ msg: lines(100), step: 'setup', type: 'stdout', offset: 1 });
handle.pushNamed('finish', { status: 'success' });
});
await waitFor(() => expect(screen.getByTestId('log-output')).toBeInTheDocument());

// 100 lines under a tail=100 request may have been trimmed server-side.
expect(getTailSelect()).toBeInTheDocument();
});

it('keeps the line cap when a finished log exceeds the smallest option', async () => {
render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="23" taskStatus="SUCCESS" />
</QueryWrapper>,
);
await flushPromises();

const handle = getHandle('23');
act(() => {
handle.pushMessage({ msg: lines(2), step: 'setup', type: 'stdout', offset: 1 });
handle.pushMessage({ msg: lines(150), step: 'build', type: 'stderr', offset: 1 });
handle.pushNamed('finish', { status: 'success' });
});
await waitFor(() => expect(screen.getByTestId('log-output')).toBeInTheDocument());

// Decision uses the largest pane, not the visible one.
expect(getTailSelect()).toBeInTheDocument();
});

it('keeps the line cap when a short finished log ends in a stream error', async () => {
render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="25" taskStatus="SUCCESS" />
</QueryWrapper>,
);
await flushPromises();

const handle = getHandle('25');
act(() => {
handle.pushMessage({ msg: lines(2), step: 'setup', type: 'stdout', offset: 1 });
handle.pushNamed('sep-error', { detail: 'gateway blew up' });
});
await waitFor(() => expect(screen.getByText('gateway blew up')).toBeInTheDocument());

// An aborted stream never proves the log is complete.
expect(getTailSelect()).toBeInTheDocument();
});

it('keeps the line cap visible but disabled for a running task with a short log', async () => {
render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="24" taskStatus="RUNNING" />
</QueryWrapper>,
);
await flushPromises();

const handle = getHandle('24');
act(() => {
handle.pushMessage({ msg: lines(2), step: 'setup', type: 'stdout', offset: 1 });
handle.pushNamed('finish', { status: 'success' });
});
await waitFor(() => expect(screen.getByText('Done')).toBeInTheDocument());

expect(getTailSelect()).toBeInTheDocument();
expect(getTailSelect()).toHaveAttribute('aria-disabled', 'true');
});

it('marks the stderr top tab as unread when stderr arrives while on stdout', async () => {
render(
<QueryWrapper>
Expand Down
Loading