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.
|
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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user