diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c26b7a..23f126c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/client/src/api/client.ts b/client/src/api/client.ts index e49e710..9a3d7c9 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 { + return request(`/scenarios/${scenarioId}/run/${runId}/logs/export?format=${format}`); + }, }; // ── Scenario Credentials ────────────────────────────────────────────────────── diff --git a/client/src/components/modals/ExportLogsModal.module.css b/client/src/components/modals/ExportLogsModal.module.css new file mode 100644 index 0000000..4ce230b --- /dev/null +++ b/client/src/components/modals/ExportLogsModal.module.css @@ -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); +} diff --git a/client/src/components/modals/ExportLogsModal.tsx b/client/src/components/modals/ExportLogsModal.tsx new file mode 100644 index 0000000..4e60f30 --- /dev/null +++ b/client/src/components/modals/ExportLogsModal.tsx @@ -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 ( + !isExporting && onClose()} + footer={ + <> + + + + } + > +
+

{t('runs.export_logs_format')}

+ + +
+
+ ); +} diff --git a/client/src/components/modals/index.ts b/client/src/components/modals/index.ts index 67a9010..6ca120a 100644 --- a/client/src/components/modals/index.ts +++ b/client/src/components/modals/index.ts @@ -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'; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 0547db1..b5856a1 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/pages/run/RunDetailPage.tsx b/client/src/pages/run/RunDetailPage.tsx index b7cd4ba..91ff9ad 100644 --- a/client/src/pages/run/RunDetailPage.tsx +++ b/client/src/pages/run/RunDetailPage.tsx @@ -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([]); const [artifactsTotal, setArtifactsTotal] = useState(0); const [artifactsLoading, setArtifactsLoading] = useState(false); + const [exportLogsOpen, setExportLogsOpen] = useState(false); + const [exportLogsFormat, setExportLogsFormat] = useState('md'); + const [exportingLogs, setExportingLogs] = useState(false); const logSearchRef = useRef(''); const LOG_PAGE_SIZE = 25; const pollRef = useRef | 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() {

{t('runs.logs_heading')}

- +
+ + +
)} + + setExportLogsOpen(false)} + onExport={handleExportLogs} + isExporting={exportingLogs} + /> ); } diff --git a/package.json b/package.json index e841844..d383ba1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "liqa", - "version": "1.9.1", + "version": "1.10.0", "private": true, "workspaces": [ "server", diff --git a/server/src/scenario/scenario.controller.ts b/server/src/scenario/scenario.controller.ts index 71c8c3a..f61f38d 100644 --- a/server/src/scenario/scenario.controller.ts +++ b/server/src/scenario/scenario.controller.ts @@ -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({ diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index d93c6f1..75322fd 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -407,6 +407,51 @@ export class ScenarioService { return Object.assign(run, { logs }); } + async exportRunLogs( + scenarioId: string, + runId: string, + format: "csv" | "md", + ): Promise { + 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, "
")} |`, + ), + ]; + return lines.join("\n"); + } + async waitForRun( scenarioId: string, runId: string,