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:
@@ -2,6 +2,15 @@
|
||||
|
||||
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
|
||||
|
||||
Changes since 1.9.0:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "liqa",
|
||||
"version": "1.9.1",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
|
||||
@@ -260,6 +260,29 @@ export class ScenarioController {
|
||||
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")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -407,6 +407,51 @@ export class ScenarioService {
|
||||
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(
|
||||
scenarioId: string,
|
||||
runId: string,
|
||||
|
||||
Reference in New Issue
Block a user