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
@@ -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({
+45
View File
@@ -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,