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:
@@ -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