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
+9
View File
@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 1.10.0 - 2026-09-15
Changes since 1.9.1:
### Added
- Added a "Download logs" button on the scenario run detail page: opens a modal to choose Markdown (default) or CSV, then downloads the run's logs in that format.
- Added `GET /scenarios/:id/run/:runId/logs/export?format=csv|md` backend endpoint to export a run's logs.
## 1.9.1 - 2026-09-15 ## 1.9.1 - 2026-09-15
Changes since 1.9.0: Changes since 1.9.0:
+3
View File
@@ -292,6 +292,9 @@ export const runs = {
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''; const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
return request(`/scenarios/${scenarioId}/run/${runId}${qs}`); 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 ────────────────────────────────────────────────────── // ── 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 type { ExportScenarioModalProps } from './ExportScenarioModal';
export { RunScenarioModal } from './RunScenarioModal'; export { RunScenarioModal } from './RunScenarioModal';
export type { RunScenarioModalProps } 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", "step_description": "Result",
"log_level": "Level", "log_level": "Level",
"log_message": "Message", "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": { "snippets": {
"title": "Snippets", "title": "Snippets",
+45 -5
View File
@@ -14,6 +14,7 @@ import type {
FileMetadata, FileMetadata,
} from '../../api'; } from '../../api';
import { scenarios } from '../../api'; import { scenarios } from '../../api';
import { ExportLogsModal, type LogExportFormat } from '../../components/modals';
import { import {
AutoRefreshIndicator, AutoRefreshIndicator,
Badge, Badge,
@@ -84,6 +85,9 @@ export function RunDetailPage() {
const [artifacts, setArtifacts] = useState<FileMetadata[]>([]); const [artifacts, setArtifacts] = useState<FileMetadata[]>([]);
const [artifactsTotal, setArtifactsTotal] = useState(0); const [artifactsTotal, setArtifactsTotal] = useState(0);
const [artifactsLoading, setArtifactsLoading] = useState(false); 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 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);
@@ -106,6 +110,27 @@ export function RunDetailPage() {
const isFileExpired = (file: FileMetadata) => const isFileExpired = (file: FileMetadata) =>
file.expiresAt != null && new Date(file.expiresAt).getTime() <= Date.now(); 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 = () => { const manualRefresh = () => {
if (!id || !runId) return; if (!id || !runId) return;
runs runs
@@ -436,11 +461,17 @@ export function RunDetailPage() {
<div className={styles.stepsSection}> <div className={styles.stepsSection}>
<div className={styles.sectionHeadingRow}> <div className={styles.sectionHeadingRow}>
<h2 className={styles.sectionHeading}>{t('runs.logs_heading')}</h2> <h2 className={styles.sectionHeading}>{t('runs.logs_heading')}</h2>
<Search <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
value={logSearch} <Search
onChange={setLogSearch} value={logSearch}
placeholder={t('runs.logs_search_placeholder')} 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> </div>
<Table <Table
columns={logColumns} columns={logColumns}
@@ -523,6 +554,15 @@ export function RunDetailPage() {
)} )}
</> </>
)} )}
<ExportLogsModal
open={exportLogsOpen}
format={exportLogsFormat}
onFormatChange={setExportLogsFormat}
onClose={() => setExportLogsOpen(false)}
onExport={handleExportLogs}
isExporting={exportingLogs}
/>
</div> </div>
); );
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "liqa", "name": "liqa",
"version": "1.9.1", "version": "1.10.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"server", "server",
@@ -260,6 +260,29 @@ export class ScenarioController {
return this.scenarioService.findRun(id, runId, q); return this.scenarioService.findRun(id, runId, q);
} }
@Get(":id/run/:runId/logs/export")
@ApiOperation({ summary: "Export run logs as CSV or Markdown" })
@ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario or run not found" })
async exportRunLogs(
@Param("id", ParseUUIDPipe) id: string,
@Param("runId", ParseUUIDPipe) runId: string,
@Query("format") format: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const fmt = format === "csv" ? "csv" : "md";
const content = await this.scenarioService.exportRunLogs(id, runId, fmt);
const contentType =
fmt === "csv" ? "text/csv; charset=utf-8" : "text/markdown; charset=utf-8";
const ext = fmt === "csv" ? "csv" : "md";
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="run-${runId}-logs.${ext}"`,
);
return content;
}
@Post(":id/run/:runId/wait") @Post(":id/run/:runId/wait")
@HttpCode(200) @HttpCode(200)
@ApiOperation({ @ApiOperation({
+45
View File
@@ -407,6 +407,51 @@ export class ScenarioService {
return Object.assign(run, { logs }); return Object.assign(run, { logs });
} }
async exportRunLogs(
scenarioId: string,
runId: string,
format: "csv" | "md",
): Promise<string> {
await this.findOne(scenarioId); // 404 guard
const run = await this.runRepo.findOne({
where: { id: runId, scenarioId },
});
if (!run)
throw new NotFoundException(
`Run ${runId} not found in scenario ${scenarioId}`,
);
const logs = await this.runLogRepo.find({
where: { runId },
order: { createdAt: "ASC" },
});
if (format === "csv") {
const escapeCsv = (value: string): string =>
/[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
const rows = [
["Timestamp", "Level", "Message"].map(escapeCsv).join(","),
...logs.map((l) =>
[l.createdAt.toISOString(), l.level, l.message]
.map((v) => escapeCsv(String(v)))
.join(","),
),
];
return rows.join("\n");
}
const lines = [
`# Run ${runId} logs`,
"",
"| Timestamp | Level | Message |",
"| --- | --- | --- |",
...logs.map(
(l) =>
`| ${l.createdAt.toISOString()} | ${l.level} | ${l.message.replace(/\|/g, "\\|").replace(/\n/g, "<br>")} |`,
),
];
return lines.join("\n");
}
async waitForRun( async waitForRun(
scenarioId: string, scenarioId: string,
runId: string, runId: string,