feat(run-detail): add expandable step output blocks with json pretty-printing

- add collapsible code blocks for step output with JSON syntax highlighting
- display byte count when output exists but no description available
- support both individual row expansion and header-level expand all toggle
- pretty-print JSON output for improved readability
- wrap log messages in code tags for monospace display
- fix step order numbering to start from 1 instead of 2
This commit is contained in:
2026-04-14 19:19:39 +03:00
parent 196bf4c2e8
commit dcb002304b
+97 -5
View File
@@ -17,6 +17,7 @@ import {
Badge, Badge,
Breadcrumbs, Breadcrumbs,
Card, Card,
CodeBlock,
DescriptionList, DescriptionList,
Notification, Notification,
Pagination, Pagination,
@@ -27,7 +28,7 @@ import {
type TableColumn, type TableColumn,
} from '../../ui'; } from '../../ui';
import type { BadgeVariant } from '../../ui'; import type { BadgeVariant } from '../../ui';
import { ClipboardList, Activity } from 'lucide-react'; import { ChevronDown, ChevronRight, ClipboardList, Activity } from 'lucide-react';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
const RUN_STATUS_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = { const RUN_STATUS_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = {
@@ -75,6 +76,8 @@ export function RunDetailPage() {
const [logPage, setLogPage] = useState(1); const [logPage, setLogPage] = useState(1);
const [logSearch, setLogSearch] = useState(''); const [logSearch, setLogSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [expandAll, setExpandAll] = useState(false);
const [expandedStepIds, setExpandedStepIds] = useState<Set<string>>(new Set());
const logSearchRef = useRef(''); const logSearchRef = useRef('');
const LOG_PAGE_SIZE = 25; const LOG_PAGE_SIZE = 25;
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -154,7 +157,7 @@ export function RunDetailPage() {
}, [id, runId]); }, [id, runId]);
const stepColumns: TableColumn<ScenarioRunStep>[] = [ const stepColumns: TableColumn<ScenarioRunStep>[] = [
{ key: 'order', header: t('runs.step_order'), render: (s) => s.order + 1, width: 50 }, { key: 'order', header: t('runs.step_order'), render: (s) => s.order, width: 50 },
{ {
key: 'title', key: 'title',
header: t('runs.step_title'), header: t('runs.step_title'),
@@ -171,8 +174,97 @@ export function RunDetailPage() {
}, },
{ {
key: 'description', key: 'description',
header: t('runs.step_description'), header: (
render: (s) => s.description ?? <span className={styles.muted}></span>, <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
<span>{t('runs.step_description')}</span>
{run?.stepRuns.some((s) => s.output) && (
<button
onClick={() => {
if (expandAll) {
setExpandAll(false);
setExpandedStepIds(new Set());
} else {
setExpandAll(true);
setExpandedStepIds(new Set(run?.stepRuns.filter((s) => s.output).map((s) => s.id) ?? []));
}
}}
style={{
background: 'none',
border: 'none',
padding: '0 4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
color: 'inherit',
}}
title={expandAll ? 'Collapse all' : 'Expand all'}
>
{expandAll ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
)}
</div>
),
render: (s) => {
const isExpanded = expandAll || expandedStepIds.has(s.id);
return (
<div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: isExpanded && s.output ? '8px' : 0 }}>
{s.output && (
<button
onClick={() => {
const newSet = new Set(expandedStepIds);
if (newSet.has(s.id)) {
newSet.delete(s.id);
} else {
newSet.add(s.id);
}
setExpandedStepIds(newSet);
}}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
marginTop: '2px',
}}
>
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</button>
)}
<code style={{ wordBreak: 'break-word' }}>
{s.description ? (
s.description
) : s.output ? (
<span title={`${s.output.length} bytes`}>{s.output.length} bytes</span>
) : (
<span className={styles.muted}></span>
)}
</code>
</div>
{isExpanded && s.output && (
<div style={{ marginLeft: '24px' }}>
<CodeBlock
code={(() => {
try {
return JSON.stringify(JSON.parse(s.output), null, 2);
} catch {
return s.output;
}
})()}
language="json"
/>
</div>
)}
</div>
);
},
}, },
]; ];
@@ -183,7 +275,7 @@ export function RunDetailPage() {
width: 70, width: 70,
render: (l) => <Badge variant={LOG_LEVEL_VARIANT[l.level]}>{l.level}</Badge>, render: (l) => <Badge variant={LOG_LEVEL_VARIANT[l.level]}>{l.level}</Badge>,
}, },
{ key: 'message', header: t('runs.log_message'), render: (l) => l.message }, { key: 'message', header: t('runs.log_message'), render: (l) => <code style={{ wordBreak: 'break-word' }}>{l.message}</code> },
{ {
key: 'time', key: 'time',
header: t('runs.log_time'), header: t('runs.log_time'),