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:
@@ -17,6 +17,7 @@ import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Card,
|
||||
CodeBlock,
|
||||
DescriptionList,
|
||||
Notification,
|
||||
Pagination,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
type TableColumn,
|
||||
} 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';
|
||||
|
||||
const RUN_STATUS_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = {
|
||||
@@ -75,6 +76,8 @@ export function RunDetailPage() {
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
const [logSearch, setLogSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
const [expandedStepIds, setExpandedStepIds] = useState<Set<string>>(new Set());
|
||||
const logSearchRef = useRef('');
|
||||
const LOG_PAGE_SIZE = 25;
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -154,7 +157,7 @@ export function RunDetailPage() {
|
||||
}, [id, runId]);
|
||||
|
||||
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',
|
||||
header: t('runs.step_title'),
|
||||
@@ -171,8 +174,97 @@ export function RunDetailPage() {
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: t('runs.step_description'),
|
||||
render: (s) => s.description ?? <span className={styles.muted}>–</span>,
|
||||
header: (
|
||||
<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,
|
||||
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',
|
||||
header: t('runs.log_time'),
|
||||
|
||||
Reference in New Issue
Block a user