feat(logs): add scenario run logs export

- Backend: logs export endpoint for CSV and Markdown formats
- Frontend: ExportLogsModal with download button on run detail page
- Updated i18n and CHANGELOG
- Version bump: 1.9.1 -> 1.10.0
This commit is contained in:
Andrii Arsenin
2026-09-15 13:04:59 +03:00
parent f1aac987db
commit ce85aa02f1
10 changed files with 230 additions and 7 deletions
+3
View File
@@ -292,6 +292,9 @@ export const runs = {
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
return request(`/scenarios/${scenarioId}/run/${runId}${qs}`);
},
exportLogs(scenarioId: string, runId: string, format: 'csv' | 'md'): Promise<string> {
return request(`/scenarios/${scenarioId}/run/${runId}/logs/export?format=${format}`);
},
};
// ── Scenario Credentials ──────────────────────────────────────────────────────
@@ -0,0 +1,27 @@
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sectionTitle {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted, #888);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.radioRow {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
.radioRow input[type='radio'] {
cursor: pointer;
accent-color: var(--color-primary);
}
@@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next';
import { X, Download } from 'lucide-react';
import { Modal, Button } from '../../ui';
import styles from './ExportLogsModal.module.css';
export type LogExportFormat = 'md' | 'csv';
export interface ExportLogsModalProps {
open: boolean;
format: LogExportFormat;
onFormatChange: (format: LogExportFormat) => void;
onClose: () => void;
onExport: () => void;
isExporting: boolean;
}
export function ExportLogsModal({
open,
format,
onFormatChange,
onClose,
onExport,
isExporting,
}: ExportLogsModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
title={t('runs.export_logs_modal_title')}
onClose={() => !isExporting && onClose()}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
<X size={14} />
{t('common.button_cancel')}
</Button>
<Button variant="primary" onClick={onExport} disabled={isExporting}>
<Download size={14} />
{t('runs.action_export')}
</Button>
</>
}
>
<div className={styles.section}>
<h4 className={styles.sectionTitle}>{t('runs.export_logs_format')}</h4>
<label className={styles.radioRow}>
<input
type="radio"
name="log-export-format"
checked={format === 'md'}
onChange={() => onFormatChange('md')}
/>
<span>{t('runs.export_logs_format_md')}</span>
</label>
<label className={styles.radioRow}>
<input
type="radio"
name="log-export-format"
checked={format === 'csv'}
onChange={() => onFormatChange('csv')}
/>
<span>{t('runs.export_logs_format_csv')}</span>
</label>
</div>
</Modal>
);
}
+2
View File
@@ -4,3 +4,5 @@ export { ExportScenarioModal } from './ExportScenarioModal';
export type { ExportScenarioModalProps } from './ExportScenarioModal';
export { RunScenarioModal } from './RunScenarioModal';
export type { RunScenarioModalProps } from './RunScenarioModal';
export { ExportLogsModal } from './ExportLogsModal';
export type { ExportLogsModalProps, LogExportFormat } from './ExportLogsModal';
+7 -1
View File
@@ -257,7 +257,13 @@
"step_description": "Result",
"log_level": "Level",
"log_message": "Message",
"log_time": "Time"
"log_time": "Time",
"action_download_logs": "Download logs",
"export_logs_modal_title": "Export logs",
"export_logs_format": "Format",
"export_logs_format_md": "Markdown (.md)",
"export_logs_format_csv": "CSV (.csv)",
"action_export": "Export"
},
"snippets": {
"title": "Snippets",
+45 -5
View File
@@ -14,6 +14,7 @@ import type {
FileMetadata,
} from '../../api';
import { scenarios } from '../../api';
import { ExportLogsModal, type LogExportFormat } from '../../components/modals';
import {
AutoRefreshIndicator,
Badge,
@@ -84,6 +85,9 @@ export function RunDetailPage() {
const [artifacts, setArtifacts] = useState<FileMetadata[]>([]);
const [artifactsTotal, setArtifactsTotal] = useState(0);
const [artifactsLoading, setArtifactsLoading] = useState(false);
const [exportLogsOpen, setExportLogsOpen] = useState(false);
const [exportLogsFormat, setExportLogsFormat] = useState<LogExportFormat>('md');
const [exportingLogs, setExportingLogs] = useState(false);
const logSearchRef = useRef('');
const LOG_PAGE_SIZE = 25;
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -106,6 +110,27 @@ export function RunDetailPage() {
const isFileExpired = (file: FileMetadata) =>
file.expiresAt != null && new Date(file.expiresAt).getTime() <= Date.now();
const handleExportLogs = async () => {
if (!id || !runId) return;
setExportingLogs(true);
try {
const content = await runs.exportLogs(id, runId, exportLogsFormat);
const mimeType = exportLogsFormat === 'csv' ? 'text/csv' : 'text/markdown';
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `run-${runId}-logs.${exportLogsFormat}`;
a.click();
URL.revokeObjectURL(url);
setExportLogsOpen(false);
} catch {
// error toast is already emitted by the API client
} finally {
setExportingLogs(false);
}
};
const manualRefresh = () => {
if (!id || !runId) return;
runs
@@ -436,11 +461,17 @@ export function RunDetailPage() {
<div className={styles.stepsSection}>
<div className={styles.sectionHeadingRow}>
<h2 className={styles.sectionHeading}>{t('runs.logs_heading')}</h2>
<Search
value={logSearch}
onChange={setLogSearch}
placeholder={t('runs.logs_search_placeholder')}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Search
value={logSearch}
onChange={setLogSearch}
placeholder={t('runs.logs_search_placeholder')}
/>
<Button variant="secondary" size="sm" onClick={() => setExportLogsOpen(true)}>
<Download size={14} />
{t('runs.action_download_logs')}
</Button>
</div>
</div>
<Table
columns={logColumns}
@@ -523,6 +554,15 @@ export function RunDetailPage() {
)}
</>
)}
<ExportLogsModal
open={exportLogsOpen}
format={exportLogsFormat}
onFormatChange={setExportLogsFormat}
onClose={() => setExportLogsOpen(false)}
onExport={handleExportLogs}
isExporting={exportingLogs}
/>
</div>
);
}