fase(15): reporting module with pdf generation

This commit is contained in:
debian
2026-03-06 05:57:05 -05:00
parent 3ff36f0b6a
commit cffa1aeea9
64 changed files with 3462 additions and 87 deletions

View File

@@ -1 +1 @@
7526a5bc154e79ca03948cc30e23de24af7e18dc
3ff36f0b6a2c3e92b24febd488ef6abfe37ada6a

View File

@@ -223,65 +223,65 @@ Spec: `.ralph/specs/phase-11-dashboard.md`
---
## Phase 12: Sessions Pages [PENDIENTE]
## Phase 12: Sessions Pages [COMPLETO]
Spec: `.ralph/specs/phase-12-sessions-pages.md`
- [ ] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section
- [ ] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
- [ ] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs
- [ ] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly)
- [ ] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges
- [ ] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
- [ ] 12.7: Progress bar estados explorados / maxStates
- [ ] 12.8: Stop button funcional (DELETE /api/sessions/:id)
- [ ] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
- [x] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section
- [x] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
- [x] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs
- [x] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly)
- [x] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges
- [x] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
- [x] 12.7: Progress bar estados explorados / maxStates
- [x] 12.8: Stop button funcional (DELETE /api/sessions/:id)
- [x] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
---
## Phase 13: Findings Pages [PENDIENTE]
## Phase 13: Findings Pages [COMPLETO]
Spec: `.ralph/specs/phase-13-findings-pages.md`
- [ ] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search
- [ ] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout
- [ ] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb
- [ ] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer)
- [ ] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI"
- [ ] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
- [ ] 13.7: Status workflow buttons: open → investigating → resolved → closed
- [ ] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul
- [ ] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view`
- [x] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search
- [x] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout
- [x] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb
- [x] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer)
- [x] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI"
- [x] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
- [x] 13.7: Status workflow buttons: open → investigating → resolved → closed
- [x] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul
- [x] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view`
---
## Phase 14: Settings Pages [PENDIENTE]
## Phase 14: Settings Pages [COMPLETO]
Spec: `.ralph/specs/phase-14-settings-pages.md`
- [ ] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
- [ ] 14.2: Section "Profile" — cambiar nombre, email, password
- [ ] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
- [ ] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
- [ ] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
- [ ] 14.6: Section "Notifications" — Slack webhook URL, min severity
- [ ] 14.7: Section "Appearance" — tema dark/light, accent color
- [ ] 14.8: Section "License" — ver status licencia, input para activar key
- [ ] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
- [x] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
- [x] 14.2: Section "Profile" — cambiar nombre, email, password
- [x] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
- [x] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
- [x] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
- [x] 14.6: Section "Notifications" — Slack webhook URL, min severity
- [x] 14.7: Section "Appearance" — tema dark/light, accent color
- [x] 14.8: Section "License" — ver status licencia, input para activar key
- [x] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
---
## Phase 15: Reporting Module [PENDIENTE]
## Phase 15: Reporting Module [COMPLETO]
Spec: `.ralph/specs/phase-15-reporting.md`
- [ ] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
- [ ] 15.2: Crear port: `IReportGenerator.ts`
- [ ] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
- [ ] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
- [ ] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
- [ ] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download
- [ ] 15.7: Integrar con job queue: generación async
- [ ] 15.8: Migración Kysely: tabla reports
- [ ] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
- [ ] 15.10: Tests: GenerateReportCommand con mock generator
- [ ] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
- [x] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
- [x] 15.2: Crear port: `IReportGenerator.ts`
- [x] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
- [x] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
- [x] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
- [x] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download
- [x] 15.7: Integrar con job queue: generación async
- [x] 15.8: Migración Kysely: tabla reports
- [x] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
- [x] 15.10: Tests: GenerateReportCommand con mock generator
- [x] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
---

View File

@@ -1 +1 @@
{"status": "completed", "timestamp": "2026-03-05 09:58:03"}
{"status": "failed", "timestamp": "2026-03-06 04:11:47"}

2
dist/api/router.js vendored
View File

@@ -8,6 +8,7 @@ const express_1 = require("express");
const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController");
const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController");
const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController");
const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController");
const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController");
const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware");
function createRouter(deps) {
@@ -21,5 +22,6 @@ function createRouter(deps) {
router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps));
router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps));
router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps));
router.use('/reports', (0, ReportingController_1.createReportingRouter)(deps.reportingDeps));
return router;
}

25
dist/db/migrations/005_reports_table.js vendored Normal file
View File

@@ -0,0 +1,25 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema
.createTable('reports')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('title', 'text', (col) => col.notNull())
.addColumn('format', 'text', (col) => col.notNull())
.addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
.addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}'))
.addColumn('file_path', 'text')
.addColumn('error_message', 'text')
.addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0))
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('completed_at', 'integer')
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('reports').ifExists().execute();
}

View File

@@ -2,15 +2,49 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.REPORT_JOB_TYPE = void 0;
exports.createReportJobHandler = createReportJobHandler;
const HTMLReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/HTMLReportGenerator");
const JSONReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/JSONReportGenerator");
const PDFReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/PDFReportGenerator");
exports.REPORT_JOB_TYPE = 'report:generate';
function createReportJobHandler(deps) {
const htmlGen = new HTMLReportGenerator_1.HTMLReportGenerator();
const jsonGen = new JSONReportGenerator_1.JSONReportGenerator();
const pdfGen = new PDFReportGenerator_1.PDFReportGenerator();
return async (payload) => {
const log = deps.logger.child({ jobType: exports.REPORT_JOB_TYPE, reportId: payload.reportId });
log.info({ format: payload.format }, 'Report generation job executing');
// Full implementation in Phase 15 (Reporting Module)
// For now, return a placeholder result
const filePath = `./reports/${payload.reportId}.${payload.format}`;
log.info({ filePath }, 'Report job complete');
const report = await deps.reportRepository.findById(payload.reportId);
if (!report) {
throw new Error(`Report not found: ${payload.reportId}`);
}
report.markGenerating();
await deps.reportRepository.update(report);
// Load findings with filters from report
const findings = await deps.findingRepository.findAll({
sessionId: report.filters.sessionId,
severity: report.filters.severity,
});
let filePath;
try {
if (payload.format === 'pdf') {
filePath = await pdfGen.generate(report, findings);
}
else if (payload.format === 'json') {
filePath = await jsonGen.generate(report, findings);
}
else {
filePath = await htmlGen.generate(report, findings);
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
report.markFailed(msg);
await deps.reportRepository.update(report);
throw err;
}
report.markReady(filePath, findings.length);
await deps.reportRepository.update(report);
log.info({ filePath, totalFindings: findings.length }, 'Report job complete');
return { reportId: payload.reportId, filePath };
};
}

19
dist/main.js vendored
View File

@@ -49,6 +49,9 @@ const CreateApiKeyCommand_1 = require("./modules/auth/application/commands/Creat
const GetUserQuery_1 = require("./modules/auth/application/queries/GetUserQuery");
const ListOrgMembersQuery_1 = require("./modules/auth/application/queries/ListOrgMembersQuery");
const PasswordService_1 = require("./modules/auth/infrastructure/auth/PasswordService");
// Reporting module
const KyselyReportRepository_1 = require("./modules/reporting/infrastructure/repositories/KyselyReportRepository");
const GenerateReportCommand_1 = require("./modules/reporting/application/commands/GenerateReportCommand");
// Job queue
const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue");
const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker");
@@ -72,6 +75,7 @@ async function bootstrap() {
const sessionRepo = new KyselyCrawlSessionRepository_1.KyselyCrawlSessionRepository(db);
const stateRepo = new KyselyStateRepository_1.KyselyStateRepository(db);
const findingRepo = new KyselyFindingRepository_1.KyselyFindingRepository(db);
const reportRepo = new KyselyReportRepository_1.KyselyReportRepository(db);
const fuzzRepo = new InMemoryFuzzSessionRepository_1.InMemoryFuzzSessionRepository();
// Suppress unused warning for stateRepo — used by crawling infrastructure
void stateRepo;
@@ -108,7 +112,14 @@ async function bootstrap() {
const createApiKeyCommand = new CreateApiKeyCommand_1.CreateApiKeyCommand(apiKeyRepo, userRepo);
const getUserQuery = new GetUserQuery_1.GetUserQuery(userRepo);
const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, userRepo);
// 11. HTTP server
// 11. Reporting use cases
const generateReport = new GenerateReportCommand_1.GenerateReportCommand(reportRepo, eventBus);
// 12. Job queue (created before HTTP server so it can be injected)
const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger }));
jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
jobQueue.start();
// 13. HTTP server
const app = (0, server_1.createServer)({
config,
logger,
@@ -116,6 +127,7 @@ async function bootstrap() {
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
fuzzingDeps: { runFuzz, repository: fuzzRepo },
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
authDeps: {
registerCommand,
loginCommand,
@@ -130,11 +142,6 @@ async function bootstrap() {
},
});
const httpServer = http_1.default.createServer(app);
// 11. Job queue
const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger }));
jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger }));
jobQueue.start();
// 12. Socket.io + gateway
const io = new socket_io_1.Server(httpServer, {
cors: { origin: config.cors.origin, credentials: true },

View File

@@ -38,7 +38,7 @@ function createFindingsRouter(deps) {
}
res.json(result.value);
});
// GET /api/findings/:id — finding detail
// GET /api/findings/:id — finding detail (includes actionTrace)
router.get('/:id', async (req, res) => {
const findingId = req.params['id'];
const result = await deps.getFinding.execute({ findingId });
@@ -46,7 +46,8 @@ function createFindingsRouter(deps) {
res.status(404).json({ error: result.error });
return;
}
res.json(toDTO(result.value));
const f = result.value;
res.json({ ...toDTO(f), actionTrace: f.actionTrace });
});
// PATCH /api/findings/:id/status — update status
router.patch('/:id/status', async (req, res) => {

View File

@@ -0,0 +1,33 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GenerateReportCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const Report_1 = require("../../domain/entities/Report");
const ReportFormat_1 = require("../../domain/value-objects/ReportFormat");
class GenerateReportCommand {
constructor(reportRepository, eventBus) {
this.reportRepository = reportRepository;
this.eventBus = eventBus;
}
async execute(request) {
let format;
try {
format = ReportFormat_1.ReportFormat.fromString(request.format);
}
catch {
return (0, Result_1.Err)(`Invalid format: ${request.format}`);
}
const report = Report_1.Report.create({
title: request.title,
format,
filters: request.filters ?? {},
});
await this.reportRepository.save(report);
const events = report.clearEvents();
for (const event of events) {
await this.eventBus.publish(event);
}
return (0, Result_1.Ok)({ reportId: report.id.toString(), status: report.status.value });
}
}
exports.GenerateReportCommand = GenerateReportCommand;

View File

@@ -0,0 +1,59 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Report = void 0;
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const ReportStatus_1 = require("../value-objects/ReportStatus");
const ReportRequested_1 = require("../events/ReportRequested");
const ReportGenerated_1 = require("../events/ReportGenerated");
const ReportFailed_1 = require("../events/ReportFailed");
class Report extends AggregateRoot_1.AggregateRoot {
static create(props, id) {
const reportId = id ?? UniqueId_1.UniqueId.create();
const report = new Report({
...props,
status: ReportStatus_1.ReportStatus.pending(),
totalFindings: 0,
createdAt: new Date(),
}, reportId);
report.addDomainEvent(new ReportRequested_1.ReportRequested(reportId.toString(), {
title: props.title,
format: props.format.value,
filters: props.filters,
}));
return report;
}
static reconstitute(props, id) {
return new Report(props, id);
}
get title() { return this.props.title; }
get format() { return this.props.format; }
get status() { return this.props.status; }
get filters() { return this.props.filters; }
get filePath() { return this.props.filePath; }
get errorMessage() { return this.props.errorMessage; }
get totalFindings() { return this.props.totalFindings; }
get createdAt() { return this.props.createdAt; }
get completedAt() { return this.props.completedAt; }
markGenerating() {
this.props.status = ReportStatus_1.ReportStatus.generating();
}
markReady(filePath, totalFindings) {
this.props.status = ReportStatus_1.ReportStatus.ready();
this.props.filePath = filePath;
this.props.totalFindings = totalFindings;
this.props.completedAt = new Date();
this.addDomainEvent(new ReportGenerated_1.ReportGenerated(this.id.toString(), {
filePath,
totalFindings,
format: this.props.format.value,
}));
}
markFailed(errorMessage) {
this.props.status = ReportStatus_1.ReportStatus.failed();
this.props.errorMessage = errorMessage;
this.props.completedAt = new Date();
this.addDomainEvent(new ReportFailed_1.ReportFailed(this.id.toString(), { errorMessage }));
}
}
exports.Report = Report;

View File

@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReportFailed = void 0;
const crypto_1 = require("crypto");
class ReportFailed {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'reporting.report_failed';
this.occurredOn = new Date();
}
}
exports.ReportFailed = ReportFailed;

View File

@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReportGenerated = void 0;
const crypto_1 = require("crypto");
class ReportGenerated {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'reporting.report_generated';
this.occurredOn = new Date();
}
}
exports.ReportGenerated = ReportGenerated;

View File

@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReportRequested = void 0;
const crypto_1 = require("crypto");
class ReportRequested {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'reporting.report_requested';
this.occurredOn = new Date();
}
}
exports.ReportRequested = ReportRequested;

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DateRange = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class DateRange extends ValueObject_1.ValueObject {
get startDate() { return this.props.startDate; }
get endDate() { return this.props.endDate; }
static create(startDate, endDate) {
if (startDate > endDate)
throw new Error('startDate must be before endDate');
return new DateRange({ startDate, endDate });
}
}
exports.DateRange = DateRange;

View File

@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReportFormat = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class ReportFormat extends ValueObject_1.ValueObject {
get value() { return this.props.value; }
static html() { return new ReportFormat({ value: 'html' }); }
static json() { return new ReportFormat({ value: 'json' }); }
static pdf() { return new ReportFormat({ value: 'pdf' }); }
static fromString(s) {
if (s === 'html' || s === 'json' || s === 'pdf') {
return new ReportFormat({ value: s });
}
throw new Error(`Invalid report format: ${s}`);
}
}
exports.ReportFormat = ReportFormat;

View File

@@ -0,0 +1,18 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReportStatus = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class ReportStatus extends ValueObject_1.ValueObject {
get value() { return this.props.value; }
static pending() { return new ReportStatus({ value: 'pending' }); }
static generating() { return new ReportStatus({ value: 'generating' }); }
static ready() { return new ReportStatus({ value: 'ready' }); }
static failed() { return new ReportStatus({ value: 'failed' }); }
static fromString(s) {
if (s === 'pending' || s === 'generating' || s === 'ready' || s === 'failed') {
return new ReportStatus({ value: s });
}
throw new Error(`Invalid report status: ${s}`);
}
}
exports.ReportStatus = ReportStatus;

View File

@@ -0,0 +1,138 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.HTMLReportGenerator = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class HTMLReportGenerator {
async generate(report, findings) {
const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
fs.mkdirSync(outputDir, { recursive: true });
const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
for (const f of findings) {
const sev = f.severity.value;
severityCounts[sev] = (severityCounts[sev] ?? 0) + 1;
}
const findingsHtml = findings.map(f => `
<div class="finding severity-${f.severity.value}">
<div class="finding-header">
<span class="badge badge-${f.severity.value}">${f.severity.value.toUpperCase()}</span>
<span class="finding-type">${f.type.value}</span>
<span class="finding-status">${f.status.value}</span>
</div>
<p class="finding-desc">${escapeHtml(f.description)}</p>
<small class="finding-meta">
Session: ${f.sessionId} &nbsp;·&nbsp;
${new Date(f.createdAt).toLocaleString()}
</small>
</div>
`).join('\n');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(report.title)}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem; color: #1a1a1a; }
h1 { font-size: 1.75rem; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; }
.meta { color: #64748b; font-size: 0.875rem; margin-bottom: 2rem; }
.stats { display: flex; gap: 1rem; margin: 1.5rem 0; }
.stat-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem 1.5rem; min-width: 100px; text-align: center; }
.stat-card .value { font-size: 2rem; font-weight: 700; }
.stat-card .label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.finding { border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
.finding-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.7rem; font-weight: 700; }
.badge-critical { background: #fee2e2; color: #dc2626; }
.badge-high { background: #ffedd5; color: #ea580c; }
.badge-medium { background: #fef9c3; color: #ca8a04; }
.badge-low { background: #dbeafe; color: #2563eb; }
.finding-type { font-family: monospace; font-size: 0.8rem; color: #475569; }
.finding-status { margin-left: auto; font-size: 0.75rem; color: #64748b; }
.finding-desc { margin: 0.25rem 0; font-size: 0.9rem; }
.finding-meta { color: #94a3b8; font-size: 0.75rem; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.75rem; text-align: center; }
</style>
</head>
<body>
<h1>${escapeHtml(report.title)}</h1>
<div class="meta">
Generated by ABE &nbsp;·&nbsp; ${new Date().toLocaleString()}
${report.filters.sessionId ? `&nbsp;·&nbsp; Session: ${report.filters.sessionId}` : ''}
</div>
<div class="stats">
<div class="stat-card">
<div class="value">${findings.length}</div>
<div class="label">Total</div>
</div>
<div class="stat-card">
<div class="value" style="color:#dc2626">${severityCounts['critical'] ?? 0}</div>
<div class="label">Critical</div>
</div>
<div class="stat-card">
<div class="value" style="color:#ea580c">${severityCounts['high'] ?? 0}</div>
<div class="label">High</div>
</div>
<div class="stat-card">
<div class="value" style="color:#ca8a04">${severityCounts['medium'] ?? 0}</div>
<div class="label">Medium</div>
</div>
<div class="stat-card">
<div class="value" style="color:#2563eb">${severityCounts['low'] ?? 0}</div>
<div class="label">Low</div>
</div>
</div>
<h2>Findings (${findings.length})</h2>
${findings.length === 0 ? '<p style="color:#64748b">No findings match the selected filters.</p>' : findingsHtml}
<footer>Generated by ABE — Autonomous Bug Explorer</footer>
</body>
</html>`;
const filePath = path.join(outputDir, `report.html`);
fs.writeFileSync(filePath, html, 'utf8');
return filePath;
}
}
exports.HTMLReportGenerator = HTMLReportGenerator;
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -0,0 +1,88 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.JSONReportGenerator = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class JSONReportGenerator {
async generate(report, findings) {
const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
fs.mkdirSync(outputDir, { recursive: true });
const data = {
reportId: report.id.toString(),
title: report.title,
generatedAt: new Date().toISOString(),
filters: report.filters,
summary: {
total: findings.length,
bySeverity: buildSeverityCount(findings),
byStatus: buildStatusCount(findings),
},
findings: findings.map(f => ({
id: f.id.toString(),
sessionId: f.sessionId,
type: f.type.value,
severity: f.severity.value,
description: f.description,
status: f.status.value,
browser: f.browser,
createdAt: f.createdAt.toISOString(),
resolvedAt: f.resolvedAt?.toISOString() ?? null,
evidence: f.evidence.toJSON(),
actionTraceLength: f.actionTrace.length,
})),
};
const filePath = path.join(outputDir, 'report.json');
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
return filePath;
}
}
exports.JSONReportGenerator = JSONReportGenerator;
function buildSeverityCount(findings) {
const counts = {};
for (const f of findings) {
const s = f.severity.value;
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}
function buildStatusCount(findings) {
const counts = {};
for (const f of findings) {
const s = f.status.value;
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}

View File

@@ -0,0 +1,81 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.PDFReportGenerator = void 0;
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const HTMLReportGenerator_1 = require("./HTMLReportGenerator");
/**
* PDF report generator — uses Playwright to render the HTML report to PDF.
* Requires Playwright + Chromium to be installed.
*/
class PDFReportGenerator {
constructor() {
this.htmlGenerator = new HTMLReportGenerator_1.HTMLReportGenerator();
}
async generate(report, findings) {
// First generate the HTML version
const htmlPath = await this.htmlGenerator.generate(report, findings);
const outputDir = path.dirname(htmlPath);
const pdfPath = path.join(outputDir, 'report.pdf');
// Use Playwright to convert HTML to PDF
let chromium;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pw = require('playwright');
chromium = pw.chromium;
}
catch {
throw new Error('Playwright not available — install playwright to generate PDF reports');
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const htmlContent = fs.readFileSync(htmlPath, 'utf8');
await page.setContent(htmlContent, { waitUntil: 'networkidle' });
await page.pdf({
path: pdfPath,
format: 'A4',
printBackground: true,
margin: { top: '1cm', bottom: '1cm', left: '1cm', right: '1cm' },
});
}
finally {
await browser.close();
}
return pdfPath;
}
}
exports.PDFReportGenerator = PDFReportGenerator;

View File

@@ -0,0 +1,134 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.createReportingRouter = createReportingRouter;
const express_1 = require("express");
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const ReportWorker_1 = require("../../../../jobs/workers/ReportWorker");
function createReportingRouter(deps) {
const router = (0, express_1.Router)();
// POST /api/reports — create and enqueue report
router.post('/', async (req, res) => {
const { title, format, filters } = req.body;
if (!title || !format) {
res.status(400).json({ error: 'title and format are required' });
return;
}
const result = await deps.generateReport.execute({
title,
format: format,
filters: filters
? {
sessionId: filters.sessionId,
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
endDate: filters.endDate ? new Date(filters.endDate) : undefined,
severity: filters.severity,
}
: undefined,
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
// Enqueue background job
await deps.jobQueue.enqueue(ReportWorker_1.REPORT_JOB_TYPE, {
reportId: result.value.reportId,
format: format,
filters: filters,
});
res.status(201).json(result.value);
});
// GET /api/reports — list all reports
router.get('/', async (_req, res) => {
const reports = await deps.reportRepository.findAll();
res.json(reports.map(r => ({
id: r.id.toString(),
title: r.title,
format: r.format.value,
status: r.status.value,
totalFindings: r.totalFindings,
createdAt: r.createdAt.toISOString(),
completedAt: r.completedAt?.toISOString() ?? null,
})));
});
// GET /api/reports/:id — report detail
router.get('/:id', async (req, res) => {
const report = await deps.reportRepository.findById(req.params['id']);
if (!report) {
res.status(404).json({ error: 'Report not found' });
return;
}
res.json({
id: report.id.toString(),
title: report.title,
format: report.format.value,
status: report.status.value,
filters: report.filters,
totalFindings: report.totalFindings,
errorMessage: report.errorMessage,
createdAt: report.createdAt.toISOString(),
completedAt: report.completedAt?.toISOString() ?? null,
});
});
// GET /api/reports/:id/download — download the generated file
router.get('/:id/download', async (req, res) => {
const report = await deps.reportRepository.findById(req.params['id']);
if (!report) {
res.status(404).json({ error: 'Report not found' });
return;
}
if (report.status.value !== 'ready' || !report.filePath) {
res.status(409).json({ error: 'Report is not ready yet', status: report.status.value });
return;
}
if (!fs.existsSync(report.filePath)) {
res.status(410).json({ error: 'Report file no longer exists' });
return;
}
const ext = path.extname(report.filePath);
const contentTypes = {
'.html': 'text/html',
'.json': 'application/json',
'.pdf': 'application/pdf',
};
const contentType = contentTypes[ext] ?? 'application/octet-stream';
const filename = `report-${report.id.toString()}${ext}`;
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
fs.createReadStream(report.filePath).pipe(res);
});
return router;
}

View File

@@ -0,0 +1,85 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyReportRepository = void 0;
const Report_1 = require("../../domain/entities/Report");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const ReportFormat_1 = require("../../domain/value-objects/ReportFormat");
const ReportStatus_1 = require("../../domain/value-objects/ReportStatus");
class KyselyReportRepository {
constructor(db) {
this.db = db;
}
async save(report) {
const row = {
id: report.id.toString(),
title: report.title,
format: report.format.value,
status: report.status.value,
filters_json: JSON.stringify(report.filters),
file_path: report.filePath ?? null,
error_message: report.errorMessage ?? null,
total_findings: report.totalFindings,
created_at: report.createdAt.getTime(),
completed_at: report.completedAt ? report.completedAt.getTime() : null,
};
await this.db.insertInto('reports').values(row).execute();
}
async findById(id) {
const row = await this.db
.selectFrom('reports')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findAll() {
const rows = await this.db
.selectFrom('reports')
.selectAll()
.orderBy('created_at', 'desc')
.execute();
return rows.map(r => this.toDomain(r));
}
async update(report) {
await this.db
.updateTable('reports')
.set({
status: report.status.value,
file_path: report.filePath ?? null,
error_message: report.errorMessage ?? null,
total_findings: report.totalFindings,
completed_at: report.completedAt ? report.completedAt.getTime() : null,
})
.where('id', '=', report.id.toString())
.execute();
}
toDomain(row) {
const filters = this.parseJson(row.filters_json, {});
const props = {
title: row.title,
format: ReportFormat_1.ReportFormat.fromString(row.format),
status: ReportStatus_1.ReportStatus.fromString(row.status),
filters: {
sessionId: filters.sessionId,
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
endDate: filters.endDate ? new Date(filters.endDate) : undefined,
severity: filters.severity,
},
filePath: row.file_path ?? undefined,
errorMessage: row.error_message ?? undefined,
totalFindings: row.total_findings,
createdAt: new Date(row.created_at),
completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
};
return Report_1.Report.reconstitute(props, UniqueId_1.UniqueId.from(row.id));
}
parseJson(json, fallback) {
try {
return JSON.parse(json);
}
catch {
return fallback;
}
}
}
exports.KyselyReportRepository = KyselyReportRepository;

View File

@@ -11,21 +11,21 @@ import { Setup } from '@/pages/Setup'
import { Dashboard } from '@/pages/Dashboard'
import { SessionList } from '@/pages/sessions/SessionList'
import { SessionDetail } from '@/pages/sessions/SessionDetail'
function FindingsList() {
return <div className="text-muted-foreground p-4">Findings Coming in Phase 13</div>
}
function FindingDetail() {
return <div className="text-muted-foreground p-4">Finding Detail Coming in Phase 13</div>
}
function Reports() {
return <div className="text-muted-foreground p-4">Reports Coming in Phase 15</div>
}
import { FindingsList } from '@/pages/findings/FindingsList'
import { FindingDetail } from '@/pages/findings/FindingDetail'
import { SettingsLayout } from '@/pages/settings/SettingsLayout'
import { ProfileSection } from '@/pages/settings/ProfileSection'
import { OrganizationSection } from '@/pages/settings/OrganizationSection'
import { ApiKeysSection } from '@/pages/settings/ApiKeysSection'
import { ExplorationDefaultsSection } from '@/pages/settings/ExplorationDefaultsSection'
import { NotificationsSection } from '@/pages/settings/NotificationsSection'
import { AppearanceSection } from '@/pages/settings/AppearanceSection'
import { LicenseSection } from '@/pages/settings/LicenseSection'
import { Reports } from '@/pages/Reports'
function VisualReview() {
return <div className="text-muted-foreground p-4">Visual Review Coming in Phase 20</div>
}
function Settings() {
return <div className="text-muted-foreground p-4">Settings Coming in Phase 14</div>
}
export default function App() {
return (
@@ -50,7 +50,16 @@ export default function App() {
<Route path="/findings/:id" element={<FindingDetail />} />
<Route path="/reports" element={<Reports />} />
<Route path="/visual-review" element={<VisualReview />} />
<Route path="/settings/*" element={<Settings />} />
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="profile" replace />} />
<Route path="profile" element={<ProfileSection />} />
<Route path="organization" element={<OrganizationSection />} />
<Route path="api-keys" element={<ApiKeysSection />} />
<Route path="defaults" element={<ExplorationDefaultsSection />} />
<Route path="notifications" element={<NotificationsSection />} />
<Route path="appearance" element={<AppearanceSection />} />
<Route path="license" element={<LicenseSection />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -0,0 +1,122 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Sparkles } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useQueryClient } from '@tanstack/react-query'
import type { AIEnrichment } from '../../types'
interface AIAnalysisPanelProps {
findingId: string
enrichment?: AIEnrichment | null
}
const CONFIDENCE_COLOR: Record<string, string> = {
high: 'bg-green-500/15 text-green-600 border-green-500/30',
medium: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
low: 'bg-red-500/15 text-red-500 border-red-500/30',
}
export function AIAnalysisPanel({ findingId, enrichment }: AIAnalysisPanelProps) {
const queryClient = useQueryClient()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleAnalyze() {
setLoading(true)
setError(null)
try {
await apiFetch(`/api/findings/${findingId}/enrich`, { method: 'POST' })
await queryClient.invalidateQueries({ queryKey: ['findings', findingId] })
} catch (e) {
setError(e instanceof Error ? e.message : 'Analysis failed')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI Analysis
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" />
</CardContent>
</Card>
)
}
if (!enrichment) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI Analysis
</CardTitle>
</CardHeader>
<CardContent>
{error && <p className="text-sm text-destructive mb-3">{error}</p>}
<p className="text-sm text-muted-foreground mb-3">
Get AI-powered root cause analysis, user impact assessment, and suggested fixes.
</p>
<Button size="sm" onClick={() => void handleAnalyze()} className="gap-2">
<Sparkles className="h-3.5 w-3.5" />
Analyze with AI
</Button>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI Analysis
</CardTitle>
<Badge variant="outline" className={`text-xs ${CONFIDENCE_COLOR[enrichment.confidence]}`}>
{enrichment.confidence} confidence
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{enrichment.provider} / {enrichment.model} ·{' '}
{new Date(enrichment.generatedAt).toLocaleString()}
</p>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Root Cause</h4>
<p className="text-sm">{enrichment.rootCause}</p>
</div>
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">User Impact</h4>
<p className="text-sm">{enrichment.userImpact}</p>
</div>
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Suggested Fix</h4>
<p className="text-sm">{enrichment.suggestedFix}</p>
</div>
{enrichment.debugPrompt && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Debug Prompt</h4>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto whitespace-pre-wrap">
{enrichment.debugPrompt}
</pre>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,78 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import type { AnomalyEvidence } from '../../types'
interface EvidencePanelProps {
evidence: AnomalyEvidence
}
export function EvidencePanel({ evidence }: EvidencePanelProps) {
return (
<Tabs defaultValue="console">
<TabsList>
<TabsTrigger value="console">Console</TabsTrigger>
<TabsTrigger value="network">Network</TabsTrigger>
{evidence.domSnapshotPath && <TabsTrigger value="dom">DOM</TabsTrigger>}
</TabsList>
<TabsContent value="console" className="mt-3">
{evidence.rawErrors && evidence.rawErrors.length > 0 ? (
<ScrollArea className="h-48 rounded border bg-black/80 p-3 font-mono text-xs">
{evidence.rawErrors.map((err, i) => (
<div key={i} className="text-red-400 mb-1">{err}</div>
))}
</ScrollArea>
) : (
<p className="text-sm text-muted-foreground">No console errors captured.</p>
)}
</TabsContent>
<TabsContent value="network" className="mt-3">
{evidence.httpLog && evidence.httpLog.length > 0 ? (
<div className="rounded border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Method</TableHead>
<TableHead>Status</TableHead>
<TableHead>URL</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{evidence.httpLog.map((req, i) => (
<TableRow key={i}>
<TableCell className="font-mono text-xs">{req.method}</TableCell>
<TableCell className={`font-mono text-xs ${req.status >= 400 ? 'text-destructive' : ''}`}>
{req.status}
</TableCell>
<TableCell className="font-mono text-xs max-w-xs truncate">{req.url}</TableCell>
<TableCell className="text-xs text-muted-foreground">{req.durationMs}ms</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-sm text-muted-foreground">No network requests captured.</p>
)}
</TabsContent>
{evidence.domSnapshotPath && (
<TabsContent value="dom" className="mt-3">
<div className="text-sm text-muted-foreground rounded border p-3">
<p>DOM snapshot: <code className="font-mono text-xs">{evidence.domSnapshotPath}</code></p>
</div>
</TabsContent>
)}
</Tabs>
)
}

View File

@@ -0,0 +1,62 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { Action } from '../../types'
interface ReproductionStepsProps {
steps: Action[]
}
const ACTION_ICONS: Record<string, string> = {
click: '🖱️',
fill: '⌨️',
navigate: '🔗',
scroll: '📜',
hover: '🎯',
select: '📋',
press: '⌨️',
}
export function ReproductionSteps({ steps }: ReproductionStepsProps) {
if (steps.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Reproduction Steps</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No action trace available.</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Reproduction Steps</CardTitle>
</CardHeader>
<CardContent>
<ol className="space-y-2">
{steps.map((step, i) => (
<li key={step.id} className="flex gap-3 text-sm">
<span className="text-muted-foreground font-mono w-6 shrink-0 text-right">{i + 1}.</span>
<span className="shrink-0">{ACTION_ICONS[step.type] ?? '▶️'}</span>
<div className="flex-1 min-w-0">
<span className="font-medium">{step.type}</span>
{step.selector && (
<code className="ml-2 text-xs bg-muted px-1.5 py-0.5 rounded font-mono truncate block mt-0.5">
{step.selector}
</code>
)}
{step.value && (
<span className="text-muted-foreground ml-2 text-xs">
value: &quot;{step.value}&quot;
</span>
)}
</div>
</li>
))}
</ol>
</CardContent>
</Card>
)
}

View File

@@ -6,6 +6,7 @@ interface User {
email: string
name: string
role: string
orgId?: string
}
export function useAuth() {

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import type { AnomalySummary, Stats } from '../types'
import type { AnomalySummary, Finding, Stats } from '../types'
export function useFindings(params?: { sessionId?: string; severity?: string }) {
const qs = new URLSearchParams()
@@ -14,6 +14,14 @@ export function useFindings(params?: { sessionId?: string; severity?: string })
})
}
export function useFinding(id: string) {
return useQuery<Finding>({
queryKey: ['findings', id],
queryFn: () => apiFetch<Finding>(`/api/findings/${id}`),
enabled: !!id,
})
}
export function useFindingStats() {
return useQuery<Stats>({
queryKey: ['findings', 'stats'],

View File

@@ -0,0 +1,257 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { toast } from 'sonner'
interface Report {
id: string
title: string
format: 'html' | 'json' | 'pdf'
status: 'pending' | 'generating' | 'ready' | 'failed'
totalFindings: number
createdAt: string
completedAt: string | null
}
interface GenerateForm {
title: string
format: 'html' | 'json' | 'pdf'
sessionId: string
startDate: string
endDate: string
severity: string
}
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
generating: 'secondary',
ready: 'default',
failed: 'destructive',
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString()
}
export function Reports() {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [form, setForm] = useState<GenerateForm>({
title: '',
format: 'html',
sessionId: '',
startDate: '',
endDate: '',
severity: '',
})
const { data: reports = [], isLoading } = useQuery<Report[]>({
queryKey: ['reports'],
queryFn: () => apiFetch<Report[]>('/api/reports'),
refetchInterval: 5000,
})
const generate = useMutation({
mutationFn: (body: object) =>
apiFetch<{ reportId: string }>('/api/reports', {
method: 'POST',
body: JSON.stringify(body),
}),
onSuccess: () => {
toast.success('Report queued — it will be ready shortly')
setOpen(false)
void qc.invalidateQueries({ queryKey: ['reports'] })
},
onError: (e: Error) => toast.error(e.message),
})
function handleSubmit() {
if (!form.title) { toast.error('Title is required'); return }
const filters: Record<string, string> = {}
if (form.sessionId) filters['sessionId'] = form.sessionId
if (form.startDate) filters['startDate'] = form.startDate
if (form.endDate) filters['endDate'] = form.endDate
if (form.severity) filters['severity'] = form.severity
generate.mutate({ title: form.title, format: form.format, filters })
}
function handleDownload(report: Report) {
window.open(`/api/reports/${report.id}/download`, '_blank')
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Reports</h1>
<p className="text-sm text-muted-foreground mt-1">
Generate and download bug reports in multiple formats.
</p>
</div>
<Button onClick={() => setOpen(true)}>Generate Report</Button>
</div>
{isLoading ? (
<p className="text-muted-foreground text-sm">Loading...</p>
) : reports.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center border rounded-lg bg-muted/20">
<p className="text-muted-foreground">No reports yet.</p>
<p className="text-sm text-muted-foreground mt-1">Click "Generate Report" to create one.</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Format</TableHead>
<TableHead>Status</TableHead>
<TableHead>Findings</TableHead>
<TableHead>Created</TableHead>
<TableHead>Completed</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map(r => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.title}</TableCell>
<TableCell>
<Badge variant="outline">{r.format.toUpperCase()}</Badge>
</TableCell>
<TableCell>
<Badge variant={STATUS_COLORS[r.status] ?? 'outline'}>
{r.status}
</Badge>
</TableCell>
<TableCell>{r.status === 'ready' ? r.totalFindings : '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(r.createdAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{r.completedAt ? formatDate(r.completedAt) : '—'}
</TableCell>
<TableCell>
{r.status === 'ready' && (
<Button size="sm" variant="outline" onClick={() => handleDownload(r)}>
Download
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Generate Report</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Title</Label>
<Input
placeholder="e.g. Weekly Security Report"
value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label>Format</Label>
<Select
value={form.format}
onValueChange={v => setForm(f => ({ ...f, format: v as GenerateForm['format'] }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="html">HTML</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Session ID (optional)</Label>
<Input
placeholder="Filter by session"
value={form.sessionId}
onChange={e => setForm(f => ({ ...f, sessionId: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Start Date</Label>
<Input
type="date"
value={form.startDate}
onChange={e => setForm(f => ({ ...f, startDate: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label>End Date</Label>
<Input
type="date"
value={form.endDate}
onChange={e => setForm(f => ({ ...f, endDate: e.target.value }))}
/>
</div>
</div>
<div className="space-y-1">
<Label>Min Severity (optional)</Label>
<Select
value={form.severity || '_all'}
onValueChange={v => setForm(f => ({ ...f, severity: v === '_all' ? '' : v }))}
>
<SelectTrigger>
<SelectValue placeholder="All severities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All severities</SelectItem>
<SelectItem value="low">Low+</SelectItem>
<SelectItem value="medium">Medium+</SelectItem>
<SelectItem value="high">High+</SelectItem>
<SelectItem value="critical">Critical only</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={generate.isPending}>
{generate.isPending ? 'Queuing…' : 'Generate'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,176 @@
import { useParams, useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Download, FileText, FileJson, Code2, CheckCircle2, Search, XCircle } from 'lucide-react'
import { SeverityBadge } from '@/components/common/SeverityBadge'
import { ReproductionSteps } from '@/components/findings/ReproductionSteps'
import { EvidencePanel } from '@/components/findings/EvidencePanel'
import { AIAnalysisPanel } from '@/components/findings/AIAnalysisPanel'
import { useFinding } from '@/hooks/useFindings'
import { apiFetch } from '@/lib/api'
import type { FindingStatus } from '../../types'
const STATUS_COLOR: Record<FindingStatus, string> = {
open: 'bg-red-500/15 text-red-500 border-red-500/30',
investigating: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
resolved: 'bg-green-500/15 text-green-600 border-green-500/30',
closed: 'bg-slate-500/15 text-slate-500 border-slate-500/30',
}
const STATUS_TRANSITIONS: Record<FindingStatus, Array<{ action: 'investigate' | 'resolve' | 'close'; label: string; icon: React.ElementType }>> = {
open: [
{ action: 'investigate', label: 'Investigate', icon: Search },
{ action: 'resolve', label: 'Resolve', icon: CheckCircle2 },
],
investigating: [
{ action: 'resolve', label: 'Resolve', icon: CheckCircle2 },
{ action: 'close', label: 'Close', icon: XCircle },
],
resolved: [
{ action: 'close', label: 'Close', icon: XCircle },
],
closed: [],
}
function downloadHref(findingId: string, format: 'markdown' | 'json' | 'playwright') {
const API_URL = import.meta.env.VITE_API_URL || ''
return `${API_URL}/api/findings/${findingId}/export/${format}`
}
export function FindingDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: finding, isLoading } = useFinding(id ?? '')
async function handleStatusChange(action: 'investigate' | 'resolve' | 'close') {
if (!id) return
try {
await apiFetch(`/api/findings/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ action }),
})
await queryClient.invalidateQueries({ queryKey: ['findings', id] })
await queryClient.invalidateQueries({ queryKey: ['findings'] })
} catch (e) {
console.error('Failed to update status:', e)
}
}
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!finding) {
return <div className="text-muted-foreground">Finding not found.</div>
}
const transitions = STATUS_TRANSITIONS[finding.status] ?? []
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate('/findings')} className="mt-0.5 shrink-0">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<SeverityBadge severity={finding.severity} />
<Badge variant="outline" className={STATUS_COLOR[finding.status]}>
{finding.status}
</Badge>
<span className="font-mono text-xs text-muted-foreground">{finding.type}</span>
</div>
<h1 className="text-lg font-bold mt-1 leading-snug">{finding.description}</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{finding.browser && <span>{finding.browser} · </span>}
Found {new Date(finding.createdAt).toLocaleString()}
{finding.resolvedAt && ` · Resolved ${new Date(finding.resolvedAt).toLocaleString()}`}
</p>
</div>
</div>
{/* Action bar */}
<div className="flex items-center gap-2 flex-wrap">
{/* Status workflow */}
{transitions.map(t => (
<Button
key={t.action}
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void handleStatusChange(t.action)}
>
<t.icon className="h-3.5 w-3.5" />
{t.label}
</Button>
))}
<div className="flex-1" />
{/* Export buttons */}
<a href={downloadHref(finding.id, 'playwright')} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="gap-1.5">
<Code2 className="h-3.5 w-3.5" />
Playwright
</Button>
</a>
<a href={downloadHref(finding.id, 'markdown')} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="gap-1.5">
<FileText className="h-3.5 w-3.5" />
Markdown
</Button>
</a>
<a href={downloadHref(finding.id, 'json')} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="gap-1.5">
<FileJson className="h-3.5 w-3.5" />
JSON
</Button>
</a>
<Button variant="ghost" size="sm" className="gap-1.5" disabled>
<Download className="h-3.5 w-3.5" />
Export
</Button>
</div>
{/* Main split layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Evidence + Reproduction */}
<div className="space-y-4">
<Tabs defaultValue="evidence">
<TabsList>
<TabsTrigger value="evidence">Evidence</TabsTrigger>
<TabsTrigger value="steps">
Steps
{finding.actionTrace.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs">{finding.actionTrace.length}</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="evidence" className="mt-3">
<EvidencePanel evidence={finding.evidence} />
</TabsContent>
<TabsContent value="steps" className="mt-3">
<ReproductionSteps steps={finding.actionTrace} />
</TabsContent>
</Tabs>
</div>
{/* Right: AI Analysis */}
<div>
<AIAnalysisPanel findingId={finding.id} enrichment={finding.aiEnrichment} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowUpDown, X } from 'lucide-react'
import { SeverityBadge } from '@/components/common/SeverityBadge'
import { useFindings } from '@/hooks/useFindings'
import type { AnomalySummary, Severity } from '../../types'
const SEVERITIES: Severity[] = ['critical', 'high', 'medium', 'low']
const columns: ColumnDef<AnomalySummary>[] = [
{
accessorKey: 'severity',
header: 'Severity',
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
},
{
accessorKey: 'type',
header: ({ column }) => (
<button className="flex items-center gap-1 hover:text-foreground" onClick={() => column.toggleSorting()}>
Type <ArrowUpDown className="h-3 w-3" />
</button>
),
cell: ({ row }) => <span className="font-mono text-xs">{row.original.type}</span>,
},
{
accessorKey: 'description',
header: 'Description',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground max-w-sm block truncate">
{row.original.description}
</span>
),
},
{
accessorKey: 'timestamp',
header: ({ column }) => (
<button className="flex items-center gap-1 hover:text-foreground" onClick={() => column.toggleSorting()}>
Time <ArrowUpDown className="h-3 w-3" />
</button>
),
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(row.original.timestamp).toLocaleString()}
</span>
),
},
]
export function FindingsList() {
const navigate = useNavigate()
const [severity, setSeverity] = useState<string>('all')
const [search, setSearch] = useState('')
const [sorting, setSorting] = useState<SortingState>([{ id: 'timestamp', desc: true }])
const { data: allFindings = [], isLoading } = useFindings(
severity !== 'all' ? { severity } : undefined
)
const table = useReactTable({
data: allFindings,
columns,
state: { sorting, globalFilter: search },
onSortingChange: setSorting,
onGlobalFilterChange: setSearch,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Findings</h1>
<p className="text-sm text-muted-foreground">{table.getRowModel().rows.length} findings</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<Input
placeholder="Search findings..."
value={search}
onChange={e => setSearch(e.target.value)}
className="max-w-xs"
/>
<Select value={severity} onValueChange={setSeverity}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Severity" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All severities</SelectItem>
{SEVERITIES.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
{(severity !== 'all' || search) && (
<Button
variant="ghost"
size="sm"
onClick={() => { setSeverity('all'); setSearch('') }}
className="gap-1"
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
)}
</div>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map(i => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (
<TableRow key={hg.id}>
{hg.headers.map(h => (
<TableHead key={h.id}>
{flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-muted-foreground">
No findings yet. Start an exploration!
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
className="cursor-pointer"
onClick={() => navigate(`/findings/${row.original.id}`)}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Key, Trash2, Copy, CheckCircle2 } from 'lucide-react'
interface ApiKey {
id: string
name: string
keyPrefix: string
permissions: string[]
expiresAt: string | null
lastUsedAt: string | null
createdAt: string
}
interface CreatedKey {
id: string
name: string
keyPrefix: string
token: string
}
export function ApiKeysSection() {
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [submitting, setSubmitting] = useState(false)
const [created, setCreated] = useState<CreatedKey | null>(null)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const { data: keys = [], isLoading } = useQuery<ApiKey[]>({
queryKey: ['api-keys'],
queryFn: () => apiFetch<ApiKey[]>('/api/auth/api-keys'),
})
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!name) return
setSubmitting(true)
setError(null)
try {
const result = await apiFetch<CreatedKey>('/api/auth/api-keys', {
method: 'POST',
body: JSON.stringify({ name, permissions: ['*'] }),
})
setCreated(result)
setName('')
await queryClient.invalidateQueries({ queryKey: ['api-keys'] })
} catch (err) {
setError(err instanceof Error ? err.message : 'Create failed')
} finally {
setSubmitting(false)
}
}
async function handleRevoke(id: string) {
try {
await apiFetch(`/api/auth/api-keys/${id}`, { method: 'DELETE' })
await queryClient.invalidateQueries({ queryKey: ['api-keys'] })
} catch (err) {
console.error('Revoke failed:', err)
}
}
function copyToken() {
if (created?.token) {
void navigator.clipboard.writeText(created.token)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
function handleDialogChange(v: boolean) {
setOpen(v)
if (!v) {
setCreated(null)
setError(null)
}
}
return (
<div className="space-y-6 max-w-xl">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">API Keys</h2>
<p className="text-sm text-muted-foreground">Manage API keys for programmatic access.</p>
</div>
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1.5">
<Key className="h-3.5 w-3.5" />
New Key
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
</DialogHeader>
{created ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Copy your API key now it won&apos;t be shown again.
</p>
<div className="flex items-center gap-2 p-2 rounded border bg-muted font-mono text-xs break-all">
<span className="flex-1">{created.token}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={copyToken}
>
{copied
? <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
: <Copy className="h-3.5 w-3.5" />
}
</Button>
</div>
<Button size="sm" onClick={() => setOpen(false)}>Done</Button>
</div>
) : (
<form onSubmit={handleCreate} className="space-y-3">
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="space-y-1">
<Label htmlFor="key-name">Name</Label>
<Input
id="key-name"
placeholder="e.g. CI Pipeline"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<Button type="submit" size="sm" disabled={submitting}>
{submitting ? 'Creating...' : 'Create Key'}
</Button>
</form>
)}
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : keys.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No API keys yet.</p>
) : (
<div className="space-y-2">
{keys.map(k => (
<div key={k.id} className="flex items-center gap-3 py-2 border-b last:border-0">
<Key className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{k.name}</p>
<p className="text-xs text-muted-foreground font-mono">{k.keyPrefix}***</p>
</div>
{k.lastUsedAt && (
<span className="text-xs text-muted-foreground">
Last used {new Date(k.lastUsedAt).toLocaleDateString()}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive shrink-0"
onClick={() => void handleRevoke(k.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { useTheme } from '@/components/layout/ThemeProvider'
import { Card, CardContent } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
export function AppearanceSection() {
const { theme, toggleTheme } = useTheme()
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Appearance</h2>
<p className="text-sm text-muted-foreground">Customize how ABE looks.</p>
</div>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Dark Mode</Label>
<p className="text-xs text-muted-foreground">Toggle between dark and light theme.</p>
</div>
<Switch
checked={theme === 'dark'}
onCheckedChange={toggleTheme}
/>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { ServerConfig } from '@/types'
export function ExplorationDefaultsSection() {
const queryClient = useQueryClient()
const { data: config, isLoading } = useQuery<ServerConfig>({
queryKey: ['config'],
queryFn: () => apiFetch<ServerConfig>('/api/config'),
})
const [saving, setSaving] = useState(false)
const [maxStates, setMaxStates] = useState<number | undefined>()
const [maxDepth, setMaxDepth] = useState<number | undefined>()
const [actionDelayMs, setActionDelayMs] = useState<number | undefined>()
const effectiveMaxStates = maxStates ?? config?.defaultMaxStates ?? 50
const effectiveMaxDepth = maxDepth ?? config?.defaultMaxDepth ?? 5
const effectiveDelay = actionDelayMs ?? config?.defaultActionDelayMs ?? 500
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
try {
await apiFetch('/api/config', {
method: 'PATCH',
body: JSON.stringify({
defaultMaxStates: effectiveMaxStates,
defaultMaxDepth: effectiveMaxDepth,
defaultActionDelayMs: effectiveDelay,
}),
})
await queryClient.invalidateQueries({ queryKey: ['config'] })
} finally {
setSaving(false)
}
}
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Exploration Defaults</h2>
<p className="text-sm text-muted-foreground">Default values for new explorations.</p>
</div>
<Card>
<CardContent className="pt-4">
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="max-states">Max States</Label>
<Input
id="max-states"
type="number"
min={1}
max={1000}
value={effectiveMaxStates}
onChange={e => setMaxStates(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Maximum number of states to explore per session.
</p>
</div>
<div className="space-y-1">
<Label htmlFor="max-depth">Max Depth</Label>
<Input
id="max-depth"
type="number"
min={1}
max={20}
value={effectiveMaxDepth}
onChange={e => setMaxDepth(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">Maximum BFS depth of exploration.</p>
</div>
<div className="space-y-1">
<Label htmlFor="action-delay">Action Delay (ms)</Label>
<Input
id="action-delay"
type="number"
min={0}
max={5000}
value={effectiveDelay}
onChange={e => setActionDelayMs(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Delay between actions in milliseconds.
</p>
</div>
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Saving...' : 'Save Defaults'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Shield } from 'lucide-react'
export function LicenseSection() {
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">License</h2>
<p className="text-sm text-muted-foreground">Manage your ABE license.</p>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle className="text-base">Current Plan</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Plan</span>
<Badge>Free / OSS</Badge>
</div>
<CardDescription>
License activation will be available in Phase 17 (RSA-signed keys with feature entitlements).
</CardDescription>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ServerConfig } from '@/types'
export function NotificationsSection() {
const queryClient = useQueryClient()
const { data: config, isLoading } = useQuery<ServerConfig>({
queryKey: ['config'],
queryFn: () => apiFetch<ServerConfig>('/api/config'),
})
const [saving, setSaving] = useState(false)
const [webhookUrl, setWebhookUrl] = useState<string | undefined>()
const [minSeverity, setMinSeverity] = useState<string | undefined>()
const effectiveWebhook = webhookUrl ?? config?.slackWebhookUrl ?? ''
const effectiveMinSev = minSeverity ?? config?.notifyMinSeverity ?? 'high'
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
try {
await apiFetch('/api/config', {
method: 'PATCH',
body: JSON.stringify({
slackWebhookUrl: effectiveWebhook || null,
notifyMinSeverity: effectiveMinSev,
}),
})
await queryClient.invalidateQueries({ queryKey: ['config'] })
} finally {
setSaving(false)
}
}
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Notifications</h2>
<p className="text-sm text-muted-foreground">Configure Slack alerts for findings.</p>
</div>
<Card>
<CardContent className="pt-4">
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="slack-webhook">Slack Webhook URL</Label>
<Input
id="slack-webhook"
type="url"
placeholder="https://hooks.slack.com/services/..."
value={effectiveWebhook}
onChange={e => setWebhookUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Leave empty to disable Slack notifications.
</p>
</div>
<div className="space-y-1">
<Label htmlFor="min-severity">Minimum Severity</Label>
<Select value={effectiveMinSev} onValueChange={setMinSeverity}>
<SelectTrigger id="min-severity">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Only send alerts for findings at or above this severity.
</p>
</div>
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '@/hooks/useAuth'
import { apiFetch } from '@/lib/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { UserPlus } from 'lucide-react'
interface OrgMember {
userId: string
email: string
name: string
role: string
}
export function OrganizationSection() {
const { user } = useAuth()
const queryClient = useQueryClient()
const orgId = user?.orgId
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
queryKey: ['org', orgId, 'members'],
queryFn: () => apiFetch<OrgMember[]>(`/api/auth/organizations/${orgId}/members`),
enabled: !!orgId,
})
const [email, setEmail] = useState('')
const [role, setRole] = useState('member')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
async function handleInvite(e: React.FormEvent) {
e.preventDefault()
if (!orgId || !email) return
setSubmitting(true)
setError(null)
try {
await apiFetch(`/api/auth/organizations/${orgId}/members`, {
method: 'POST',
body: JSON.stringify({ email, role }),
})
setEmail('')
await queryClient.invalidateQueries({ queryKey: ['org', orgId, 'members'] })
} catch (err) {
setError(err instanceof Error ? err.message : 'Invite failed')
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Organization</h2>
<p className="text-sm text-muted-foreground">Manage members and roles.</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm">Members</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : members.length === 0 ? (
<p className="text-sm text-muted-foreground">No members yet.</p>
) : (
members.map(m => (
<div key={m.userId} className="flex items-center gap-3 py-1">
<Avatar className="h-7 w-7">
<AvatarFallback className="text-xs">
{m.name?.charAt(0).toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{m.name}</p>
<p className="text-xs text-muted-foreground truncate">{m.email}</p>
</div>
<Badge variant="outline" className="text-xs">{m.role}</Badge>
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<UserPlus className="h-4 w-4" />
Invite Member
</CardTitle>
<CardDescription>Add a new member to your organization.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="space-y-3">
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="space-y-1">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
placeholder="colleague@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="invite-role">Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger id="invite-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={submitting}>
{submitting ? 'Inviting...' : 'Send Invite'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useAuth } from '@/hooks/useAuth'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
export function ProfileSection() {
const { user, isLoading } = useAuth()
if (isLoading) {
return (
<div className="space-y-4 max-w-xl">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
</div>
)
}
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm text-muted-foreground">Your account information.</p>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarFallback className="text-base">
{user?.name?.charAt(0).toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-base">{user?.name}</CardTitle>
<CardDescription>{user?.email}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Role</span>
<Badge variant="secondary">{user?.role}</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">User ID</span>
<code className="text-xs font-mono text-muted-foreground">{user?.id}</code>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Change Password</CardTitle>
<CardDescription>Password management coming in a future release.</CardDescription>
</CardHeader>
</Card>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { NavLink, Outlet } from 'react-router-dom'
import { User, Building, Key, Sliders, Bell, Palette, Shield } from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ label: 'Profile', href: '/settings/profile', icon: User },
{ label: 'Organization', href: '/settings/organization', icon: Building },
{ label: 'API Keys', href: '/settings/api-keys', icon: Key },
{ label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders },
{ label: 'Notifications', href: '/settings/notifications', icon: Bell },
{ label: 'Appearance', href: '/settings/appearance', icon: Palette },
{ label: 'License', href: '/settings/license', icon: Shield },
]
export function SettingsLayout() {
return (
<div className="flex gap-8">
<nav className="w-48 shrink-0 space-y-1">
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-3 mb-3">
Settings
</h2>
{navItems.map(item => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)
}
>
<item.icon className="h-4 w-4 shrink-0" />
{item.label}
</NavLink>
))}
</nav>
<div className="flex-1 min-w-0">
<Outlet />
</div>
</div>
)
}

View File

@@ -88,6 +88,40 @@ export interface AnomalySummary {
browser?: BrowserType;
}
export type FindingStatus = 'open' | 'investigating' | 'resolved' | 'closed';
export interface Finding {
id: string;
sessionId: string;
type: string;
severity: Severity;
description: string;
status: FindingStatus;
browser?: BrowserType;
browserVersion?: string;
actionTraceLength: number;
actionTrace: Action[];
evidence: AnomalyEvidence;
aiEnrichment?: AIEnrichment | null;
createdAt: string;
resolvedAt?: string | null;
}
export interface FindingSummary {
id: string;
sessionId: string;
type: string;
severity: Severity;
description: string;
status: FindingStatus;
browser?: BrowserType;
actionTraceLength: number;
evidence: AnomalyEvidence;
aiEnrichment?: AIEnrichment | null;
createdAt: string;
resolvedAt?: string | null;
}
export interface Session {
sessionId: string;
url: string;

View File

@@ -5,6 +5,7 @@ import { Router } from 'express';
import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController';
import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController';
import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController';
import { createReportingRouter } from '../modules/reporting/infrastructure/http/ReportingController';
import { createAuthController } from '../modules/auth/infrastructure/http/AuthController';
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
import { ServerDependencies } from './server';
@@ -64,6 +65,7 @@ export function createRouter(deps: ServerDependencies): Router {
router.use('/sessions', createCrawlingRouter(deps.crawlingDeps));
router.use('/findings', createFindingsRouter(deps.findingsDeps));
router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps));
router.use('/reports', createReportingRouter(deps.reportingDeps));
return router;
}

View File

@@ -18,6 +18,7 @@ import { createRouter } from './router';
import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController';
import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController';
import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController';
import { ReportingControllerDeps } from '../modules/reporting/infrastructure/http/ReportingController';
import { AuthControllerDeps } from './router';
export interface ServerDependencies {
@@ -27,6 +28,7 @@ export interface ServerDependencies {
crawlingDeps: CrawlingControllerDeps;
findingsDeps: FindingsControllerDeps;
fuzzingDeps: FuzzingControllerDeps;
reportingDeps: ReportingControllerDeps;
authDeps: AuthControllerDeps;
}

View File

@@ -0,0 +1,24 @@
import { Kysely } from 'kysely';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('reports')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('title', 'text', (col) => col.notNull())
.addColumn('format', 'text', (col) => col.notNull())
.addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
.addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}'))
.addColumn('file_path', 'text')
.addColumn('error_message', 'text')
.addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0))
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('completed_at', 'integer')
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('reports').ifExists().execute();
}

View File

@@ -1,9 +1,14 @@
/**
* ReportWorker — handles 'report:generate' jobs.
* Generates reports in the background (full implementation in Phase 15).
* Generates HTML, JSON, or PDF reports in the background.
*/
import { JobHandler } from '../JobQueue';
import { Logger } from '../../shared/infrastructure/Logger';
import { IReportRepository } from '../../modules/reporting/domain/ports/IReportRepository';
import { IFindingRepository } from '../../modules/findings/domain/ports/IFindingRepository';
import { HTMLReportGenerator } from '../../modules/reporting/infrastructure/generators/HTMLReportGenerator';
import { JSONReportGenerator } from '../../modules/reporting/infrastructure/generators/JSONReportGenerator';
import { PDFReportGenerator } from '../../modules/reporting/infrastructure/generators/PDFReportGenerator';
export const REPORT_JOB_TYPE = 'report:generate';
@@ -25,16 +30,51 @@ export interface ReportJobResult {
export function createReportJobHandler(deps: {
logger: Logger;
reportRepository: IReportRepository;
findingRepository: IFindingRepository;
}): JobHandler<ReportJobPayload, ReportJobResult> {
const htmlGen = new HTMLReportGenerator();
const jsonGen = new JSONReportGenerator();
const pdfGen = new PDFReportGenerator();
return async (payload: ReportJobPayload): Promise<ReportJobResult> => {
const log = deps.logger.child({ jobType: REPORT_JOB_TYPE, reportId: payload.reportId });
log.info({ format: payload.format }, 'Report generation job executing');
// Full implementation in Phase 15 (Reporting Module)
// For now, return a placeholder result
const filePath = `./reports/${payload.reportId}.${payload.format}`;
log.info({ filePath }, 'Report job complete');
const report = await deps.reportRepository.findById(payload.reportId);
if (!report) {
throw new Error(`Report not found: ${payload.reportId}`);
}
report.markGenerating();
await deps.reportRepository.update(report);
// Load findings with filters from report
const findings = await deps.findingRepository.findAll({
sessionId: report.filters.sessionId,
severity: report.filters.severity,
});
let filePath: string;
try {
if (payload.format === 'pdf') {
filePath = await pdfGen.generate(report, findings);
} else if (payload.format === 'json') {
filePath = await jsonGen.generate(report, findings);
} else {
filePath = await htmlGen.generate(report, findings);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
report.markFailed(msg);
await deps.reportRepository.update(report);
throw err;
}
report.markReady(filePath, findings.length);
await deps.reportRepository.update(report);
log.info({ filePath, totalFindings: findings.length }, 'Report job complete');
return { reportId: payload.reportId, filePath };
};
}

View File

@@ -51,6 +51,10 @@ import { GetUserQuery } from './modules/auth/application/queries/GetUserQuery';
import { ListOrgMembersQuery } from './modules/auth/application/queries/ListOrgMembersQuery';
import { hashPassword, verifyPassword } from './modules/auth/infrastructure/auth/PasswordService';
// Reporting module
import { KyselyReportRepository } from './modules/reporting/infrastructure/repositories/KyselyReportRepository';
import { GenerateReportCommand } from './modules/reporting/application/commands/GenerateReportCommand';
// Job queue
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker';
@@ -80,6 +84,7 @@ async function bootstrap(): Promise<void> {
const sessionRepo = new KyselyCrawlSessionRepository(db);
const stateRepo = new KyselyStateRepository(db);
const findingRepo = new KyselyFindingRepository(db);
const reportRepo = new KyselyReportRepository(db);
const fuzzRepo = new InMemoryFuzzSessionRepository();
// Suppress unused warning for stateRepo — used by crawling infrastructure
@@ -125,7 +130,19 @@ async function bootstrap(): Promise<void> {
const getUserQuery = new GetUserQuery(userRepo);
const listOrgMembersQuery = new ListOrgMembersQuery(orgRepo, userRepo);
// 11. HTTP server
// 11. Reporting use cases
const generateReport = new GenerateReportCommand(reportRepo, eventBus);
// 12. Job queue (created before HTTP server so it can be injected)
const jobQueue = new SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
jobQueue.registerHandler(
EXPLORATION_JOB_TYPE,
createExplorationJobHandler({ sessionRepo, eventBus, logger }),
);
jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
jobQueue.start();
// 13. HTTP server
const app = createServer({
config,
logger,
@@ -133,6 +150,7 @@ async function bootstrap(): Promise<void> {
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
fuzzingDeps: { runFuzz, repository: fuzzRepo },
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
authDeps: {
registerCommand,
loginCommand,
@@ -149,15 +167,6 @@ async function bootstrap(): Promise<void> {
const httpServer = http.createServer(app);
// 11. Job queue
const jobQueue = new SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
jobQueue.registerHandler(
EXPLORATION_JOB_TYPE,
createExplorationJobHandler({ sessionRepo, eventBus, logger }),
);
jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger }));
jobQueue.start();
// 12. Socket.io + gateway
const io = new SocketIOServer(httpServer, {
cors: { origin: config.cors.origin, credentials: true },

View File

@@ -50,7 +50,7 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router {
res.json(result.value);
});
// GET /api/findings/:id — finding detail
// GET /api/findings/:id — finding detail (includes actionTrace)
router.get('/:id', async (req: Request, res: Response) => {
const findingId = req.params['id'] as string;
const result = await deps.getFinding.execute({ findingId });
@@ -58,7 +58,8 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router {
res.status(404).json({ error: result.error });
return;
}
res.json(toDTO(result.value));
const f = result.value;
res.json({ ...toDTO(f), actionTrace: f.actionTrace });
});
// PATCH /api/findings/:id/status — update status

View File

@@ -0,0 +1,50 @@
import { UseCase } from '../../../../shared/application/UseCase';
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { EventBus } from '../../../../shared/application/EventBus';
import { IReportRepository } from '../../domain/ports/IReportRepository';
import { Report, ReportFilters } from '../../domain/entities/Report';
import { ReportFormat } from '../../domain/value-objects/ReportFormat';
export interface GenerateReportRequest {
title: string;
format: 'html' | 'json' | 'pdf';
filters?: ReportFilters;
}
export interface GenerateReportResponse {
reportId: string;
status: string;
}
export class GenerateReportCommand
implements UseCase<GenerateReportRequest, GenerateReportResponse, string>
{
constructor(
private readonly reportRepository: IReportRepository,
private readonly eventBus: EventBus
) {}
async execute(request: GenerateReportRequest): Promise<Result<GenerateReportResponse, string>> {
let format: ReportFormat;
try {
format = ReportFormat.fromString(request.format);
} catch {
return Err(`Invalid format: ${request.format}`);
}
const report = Report.create({
title: request.title,
format,
filters: request.filters ?? {},
});
await this.reportRepository.save(report);
const events = report.clearEvents();
for (const event of events) {
await this.eventBus.publish(event);
}
return Ok({ reportId: report.id.toString(), status: report.status.value });
}
}

View File

@@ -0,0 +1,90 @@
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { ReportFormat } from '../value-objects/ReportFormat';
import { ReportStatus } from '../value-objects/ReportStatus';
import { ReportRequested } from '../events/ReportRequested';
import { ReportGenerated } from '../events/ReportGenerated';
import { ReportFailed } from '../events/ReportFailed';
export interface ReportFilters {
sessionId?: string;
startDate?: Date;
endDate?: Date;
severity?: string;
}
export interface ReportProps {
title: string;
format: ReportFormat;
status: ReportStatus;
filters: ReportFilters;
filePath?: string;
errorMessage?: string;
totalFindings: number;
createdAt: Date;
completedAt?: Date;
}
export class Report extends AggregateRoot<ReportProps> {
static create(props: { title: string; format: ReportFormat; filters: ReportFilters }, id?: UniqueId): Report {
const reportId = id ?? UniqueId.create();
const report = new Report(
{
...props,
status: ReportStatus.pending(),
totalFindings: 0,
createdAt: new Date(),
},
reportId
);
report.addDomainEvent(
new ReportRequested(reportId.toString(), {
title: props.title,
format: props.format.value,
filters: props.filters as Record<string, unknown>,
})
);
return report;
}
static reconstitute(props: ReportProps, id: UniqueId): Report {
return new Report(props, id);
}
get title(): string { return this.props.title; }
get format(): ReportFormat { return this.props.format; }
get status(): ReportStatus { return this.props.status; }
get filters(): ReportFilters { return this.props.filters; }
get filePath(): string | undefined { return this.props.filePath; }
get errorMessage(): string | undefined { return this.props.errorMessage; }
get totalFindings(): number { return this.props.totalFindings; }
get createdAt(): Date { return this.props.createdAt; }
get completedAt(): Date | undefined { return this.props.completedAt; }
markGenerating(): void {
this.props.status = ReportStatus.generating();
}
markReady(filePath: string, totalFindings: number): void {
this.props.status = ReportStatus.ready();
this.props.filePath = filePath;
this.props.totalFindings = totalFindings;
this.props.completedAt = new Date();
this.addDomainEvent(
new ReportGenerated(this.id.toString(), {
filePath,
totalFindings,
format: this.props.format.value,
})
);
}
markFailed(errorMessage: string): void {
this.props.status = ReportStatus.failed();
this.props.errorMessage = errorMessage;
this.props.completedAt = new Date();
this.addDomainEvent(
new ReportFailed(this.id.toString(), { errorMessage })
);
}
}

View File

@@ -0,0 +1,9 @@
import { randomUUID } from 'crypto';
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
export class ReportFailed implements DomainEvent {
readonly eventId = randomUUID();
readonly eventName = 'reporting.report_failed';
readonly occurredOn = new Date();
constructor(readonly aggregateId: string, readonly payload: Record<string, unknown>) {}
}

View File

@@ -0,0 +1,9 @@
import { randomUUID } from 'crypto';
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
export class ReportGenerated implements DomainEvent {
readonly eventId = randomUUID();
readonly eventName = 'reporting.report_generated';
readonly occurredOn = new Date();
constructor(readonly aggregateId: string, readonly payload: Record<string, unknown>) {}
}

View File

@@ -0,0 +1,9 @@
import { randomUUID } from 'crypto';
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
export class ReportRequested implements DomainEvent {
readonly eventId = randomUUID();
readonly eventName = 'reporting.report_requested';
readonly occurredOn = new Date();
constructor(readonly aggregateId: string, readonly payload: Record<string, unknown>) {}
}

View File

@@ -0,0 +1,6 @@
import { Finding } from '../../../findings/domain/entities/Finding';
import { Report } from '../entities/Report';
export interface IReportGenerator {
generate(report: Report, findings: Finding[]): Promise<string>;
}

View File

@@ -0,0 +1,8 @@
import { Report } from '../entities/Report';
export interface IReportRepository {
save(report: Report): Promise<void>;
findById(id: string): Promise<Report | undefined>;
findAll(): Promise<Report[]>;
update(report: Report): Promise<void>;
}

View File

@@ -0,0 +1,16 @@
import { ValueObject } from '../../../../shared/domain/ValueObject';
interface DateRangeProps {
startDate: Date;
endDate: Date;
}
export class DateRange extends ValueObject<DateRangeProps> {
get startDate(): Date { return this.props.startDate; }
get endDate(): Date { return this.props.endDate; }
static create(startDate: Date, endDate: Date): DateRange {
if (startDate > endDate) throw new Error('startDate must be before endDate');
return new DateRange({ startDate, endDate });
}
}

View File

@@ -0,0 +1,20 @@
import { ValueObject } from '../../../../shared/domain/ValueObject';
interface ReportFormatProps {
value: 'html' | 'json' | 'pdf';
}
export class ReportFormat extends ValueObject<ReportFormatProps> {
get value(): 'html' | 'json' | 'pdf' { return this.props.value; }
static html(): ReportFormat { return new ReportFormat({ value: 'html' }); }
static json(): ReportFormat { return new ReportFormat({ value: 'json' }); }
static pdf(): ReportFormat { return new ReportFormat({ value: 'pdf' }); }
static fromString(s: string): ReportFormat {
if (s === 'html' || s === 'json' || s === 'pdf') {
return new ReportFormat({ value: s });
}
throw new Error(`Invalid report format: ${s}`);
}
}

View File

@@ -0,0 +1,21 @@
import { ValueObject } from '../../../../shared/domain/ValueObject';
interface ReportStatusProps {
value: 'pending' | 'generating' | 'ready' | 'failed';
}
export class ReportStatus extends ValueObject<ReportStatusProps> {
get value(): 'pending' | 'generating' | 'ready' | 'failed' { return this.props.value; }
static pending(): ReportStatus { return new ReportStatus({ value: 'pending' }); }
static generating(): ReportStatus { return new ReportStatus({ value: 'generating' }); }
static ready(): ReportStatus { return new ReportStatus({ value: 'ready' }); }
static failed(): ReportStatus { return new ReportStatus({ value: 'failed' }); }
static fromString(s: string): ReportStatus {
if (s === 'pending' || s === 'generating' || s === 'ready' || s === 'failed') {
return new ReportStatus({ value: s });
}
throw new Error(`Invalid report status: ${s}`);
}
}

View File

@@ -0,0 +1,110 @@
import * as fs from 'fs';
import * as path from 'path';
import { IReportGenerator } from '../../domain/ports/IReportGenerator';
import { Report } from '../../domain/entities/Report';
import { Finding } from '../../../findings/domain/entities/Finding';
export class HTMLReportGenerator implements IReportGenerator {
async generate(report: Report, findings: Finding[]): Promise<string> {
const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
fs.mkdirSync(outputDir, { recursive: true });
const severityCounts: Record<string, number> = { critical: 0, high: 0, medium: 0, low: 0 };
for (const f of findings) {
const sev = f.severity.value;
severityCounts[sev] = (severityCounts[sev] ?? 0) + 1;
}
const findingsHtml = findings.map(f => `
<div class="finding severity-${f.severity.value}">
<div class="finding-header">
<span class="badge badge-${f.severity.value}">${f.severity.value.toUpperCase()}</span>
<span class="finding-type">${f.type.value}</span>
<span class="finding-status">${f.status.value}</span>
</div>
<p class="finding-desc">${escapeHtml(f.description)}</p>
<small class="finding-meta">
Session: ${f.sessionId} &nbsp;·&nbsp;
${new Date(f.createdAt).toLocaleString()}
</small>
</div>
`).join('\n');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(report.title)}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem; color: #1a1a1a; }
h1 { font-size: 1.75rem; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; }
.meta { color: #64748b; font-size: 0.875rem; margin-bottom: 2rem; }
.stats { display: flex; gap: 1rem; margin: 1.5rem 0; }
.stat-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem 1.5rem; min-width: 100px; text-align: center; }
.stat-card .value { font-size: 2rem; font-weight: 700; }
.stat-card .label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.finding { border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
.finding-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.7rem; font-weight: 700; }
.badge-critical { background: #fee2e2; color: #dc2626; }
.badge-high { background: #ffedd5; color: #ea580c; }
.badge-medium { background: #fef9c3; color: #ca8a04; }
.badge-low { background: #dbeafe; color: #2563eb; }
.finding-type { font-family: monospace; font-size: 0.8rem; color: #475569; }
.finding-status { margin-left: auto; font-size: 0.75rem; color: #64748b; }
.finding-desc { margin: 0.25rem 0; font-size: 0.9rem; }
.finding-meta { color: #94a3b8; font-size: 0.75rem; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.75rem; text-align: center; }
</style>
</head>
<body>
<h1>${escapeHtml(report.title)}</h1>
<div class="meta">
Generated by ABE &nbsp;·&nbsp; ${new Date().toLocaleString()}
${report.filters.sessionId ? `&nbsp;·&nbsp; Session: ${report.filters.sessionId}` : ''}
</div>
<div class="stats">
<div class="stat-card">
<div class="value">${findings.length}</div>
<div class="label">Total</div>
</div>
<div class="stat-card">
<div class="value" style="color:#dc2626">${severityCounts['critical'] ?? 0}</div>
<div class="label">Critical</div>
</div>
<div class="stat-card">
<div class="value" style="color:#ea580c">${severityCounts['high'] ?? 0}</div>
<div class="label">High</div>
</div>
<div class="stat-card">
<div class="value" style="color:#ca8a04">${severityCounts['medium'] ?? 0}</div>
<div class="label">Medium</div>
</div>
<div class="stat-card">
<div class="value" style="color:#2563eb">${severityCounts['low'] ?? 0}</div>
<div class="label">Low</div>
</div>
</div>
<h2>Findings (${findings.length})</h2>
${findings.length === 0 ? '<p style="color:#64748b">No findings match the selected filters.</p>' : findingsHtml}
<footer>Generated by ABE — Autonomous Bug Explorer</footer>
</body>
</html>`;
const filePath = path.join(outputDir, `report.html`);
fs.writeFileSync(filePath, html, 'utf8');
return filePath;
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -0,0 +1,59 @@
import * as fs from 'fs';
import * as path from 'path';
import { IReportGenerator } from '../../domain/ports/IReportGenerator';
import { Report } from '../../domain/entities/Report';
import { Finding } from '../../../findings/domain/entities/Finding';
export class JSONReportGenerator implements IReportGenerator {
async generate(report: Report, findings: Finding[]): Promise<string> {
const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
fs.mkdirSync(outputDir, { recursive: true });
const data = {
reportId: report.id.toString(),
title: report.title,
generatedAt: new Date().toISOString(),
filters: report.filters,
summary: {
total: findings.length,
bySeverity: buildSeverityCount(findings),
byStatus: buildStatusCount(findings),
},
findings: findings.map(f => ({
id: f.id.toString(),
sessionId: f.sessionId,
type: f.type.value,
severity: f.severity.value,
description: f.description,
status: f.status.value,
browser: f.browser,
createdAt: f.createdAt.toISOString(),
resolvedAt: f.resolvedAt?.toISOString() ?? null,
evidence: f.evidence.toJSON(),
actionTraceLength: f.actionTrace.length,
})),
};
const filePath = path.join(outputDir, 'report.json');
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
return filePath;
}
}
function buildSeverityCount(findings: Finding[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const f of findings) {
const s = f.severity.value;
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}
function buildStatusCount(findings: Finding[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const f of findings) {
const s = f.status.value;
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}

View File

@@ -0,0 +1,48 @@
import * as path from 'path';
import * as fs from 'fs';
import { IReportGenerator } from '../../domain/ports/IReportGenerator';
import { Report } from '../../domain/entities/Report';
import { Finding } from '../../../findings/domain/entities/Finding';
import { HTMLReportGenerator } from './HTMLReportGenerator';
/**
* PDF report generator — uses Playwright to render the HTML report to PDF.
* Requires Playwright + Chromium to be installed.
*/
export class PDFReportGenerator implements IReportGenerator {
private readonly htmlGenerator = new HTMLReportGenerator();
async generate(report: Report, findings: Finding[]): Promise<string> {
// First generate the HTML version
const htmlPath = await this.htmlGenerator.generate(report, findings);
const outputDir = path.dirname(htmlPath);
const pdfPath = path.join(outputDir, 'report.pdf');
// Use Playwright to convert HTML to PDF
let chromium;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pw = require('playwright') as typeof import('playwright');
chromium = pw.chromium;
} catch {
throw new Error('Playwright not available — install playwright to generate PDF reports');
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const htmlContent = fs.readFileSync(htmlPath, 'utf8');
await page.setContent(htmlContent, { waitUntil: 'networkidle' });
await page.pdf({
path: pdfPath,
format: 'A4',
printBackground: true,
margin: { top: '1cm', bottom: '1cm', left: '1cm', right: '1cm' },
});
} finally {
await browser.close();
}
return pdfPath;
}
}

View File

@@ -0,0 +1,131 @@
import { Router, Request, Response } from 'express';
import * as path from 'path';
import * as fs from 'fs';
import { GenerateReportCommand } from '../../application/commands/GenerateReportCommand';
import { IReportRepository } from '../../domain/ports/IReportRepository';
import { IJobQueue } from '../../../../jobs/JobQueue';
import { REPORT_JOB_TYPE, ReportJobPayload } from '../../../../jobs/workers/ReportWorker';
export interface ReportingControllerDeps {
generateReport: GenerateReportCommand;
reportRepository: IReportRepository;
jobQueue: IJobQueue;
}
export function createReportingRouter(deps: ReportingControllerDeps): Router {
const router = Router();
// POST /api/reports — create and enqueue report
router.post('/', async (req: Request, res: Response) => {
const { title, format, filters } = req.body as {
title?: string;
format?: string;
filters?: {
sessionId?: string;
startDate?: string;
endDate?: string;
severity?: string;
};
};
if (!title || !format) {
res.status(400).json({ error: 'title and format are required' });
return;
}
const result = await deps.generateReport.execute({
title,
format: format as 'html' | 'json' | 'pdf',
filters: filters
? {
sessionId: filters.sessionId,
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
endDate: filters.endDate ? new Date(filters.endDate) : undefined,
severity: filters.severity,
}
: undefined,
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
// Enqueue background job
await deps.jobQueue.enqueue(REPORT_JOB_TYPE, {
reportId: result.value.reportId,
format: format as 'html' | 'json' | 'pdf',
filters: filters as ReportJobPayload['filters'],
});
res.status(201).json(result.value);
});
// GET /api/reports — list all reports
router.get('/', async (_req: Request, res: Response) => {
const reports = await deps.reportRepository.findAll();
res.json(
reports.map(r => ({
id: r.id.toString(),
title: r.title,
format: r.format.value,
status: r.status.value,
totalFindings: r.totalFindings,
createdAt: r.createdAt.toISOString(),
completedAt: r.completedAt?.toISOString() ?? null,
}))
);
});
// GET /api/reports/:id — report detail
router.get('/:id', async (req: Request, res: Response) => {
const report = await deps.reportRepository.findById(req.params['id'] as string);
if (!report) {
res.status(404).json({ error: 'Report not found' });
return;
}
res.json({
id: report.id.toString(),
title: report.title,
format: report.format.value,
status: report.status.value,
filters: report.filters,
totalFindings: report.totalFindings,
errorMessage: report.errorMessage,
createdAt: report.createdAt.toISOString(),
completedAt: report.completedAt?.toISOString() ?? null,
});
});
// GET /api/reports/:id/download — download the generated file
router.get('/:id/download', async (req: Request, res: Response) => {
const report = await deps.reportRepository.findById(req.params['id'] as string);
if (!report) {
res.status(404).json({ error: 'Report not found' });
return;
}
if (report.status.value !== 'ready' || !report.filePath) {
res.status(409).json({ error: 'Report is not ready yet', status: report.status.value });
return;
}
if (!fs.existsSync(report.filePath)) {
res.status(410).json({ error: 'Report file no longer exists' });
return;
}
const ext = path.extname(report.filePath);
const contentTypes: Record<string, string> = {
'.html': 'text/html',
'.json': 'application/json',
'.pdf': 'application/pdf',
};
const contentType = contentTypes[ext] ?? 'application/octet-stream';
const filename = `report-${report.id.toString()}${ext}`;
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
fs.createReadStream(report.filePath).pipe(res);
});
return router;
}

View File

@@ -0,0 +1,84 @@
import { Kysely } from 'kysely';
import { Database, ReportTable } from '../../../../shared/infrastructure/DatabaseConnection';
import { IReportRepository } from '../../domain/ports/IReportRepository';
import { Report, ReportProps, ReportFilters } from '../../domain/entities/Report';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { ReportFormat } from '../../domain/value-objects/ReportFormat';
import { ReportStatus } from '../../domain/value-objects/ReportStatus';
export class KyselyReportRepository implements IReportRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(report: Report): Promise<void> {
const row: ReportTable = {
id: report.id.toString(),
title: report.title,
format: report.format.value,
status: report.status.value,
filters_json: JSON.stringify(report.filters),
file_path: report.filePath ?? null,
error_message: report.errorMessage ?? null,
total_findings: report.totalFindings,
created_at: report.createdAt.getTime(),
completed_at: report.completedAt ? report.completedAt.getTime() : null,
};
await this.db.insertInto('reports').values(row).execute();
}
async findById(id: string): Promise<Report | undefined> {
const row = await this.db
.selectFrom('reports')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findAll(): Promise<Report[]> {
const rows = await this.db
.selectFrom('reports')
.selectAll()
.orderBy('created_at', 'desc')
.execute();
return rows.map(r => this.toDomain(r));
}
async update(report: Report): Promise<void> {
await this.db
.updateTable('reports')
.set({
status: report.status.value,
file_path: report.filePath ?? null,
error_message: report.errorMessage ?? null,
total_findings: report.totalFindings,
completed_at: report.completedAt ? report.completedAt.getTime() : null,
})
.where('id', '=', report.id.toString())
.execute();
}
private toDomain(row: ReportTable): Report {
const filters = this.parseJson<ReportFilters>(row.filters_json, {});
const props: ReportProps = {
title: row.title,
format: ReportFormat.fromString(row.format),
status: ReportStatus.fromString(row.status),
filters: {
sessionId: filters.sessionId,
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
endDate: filters.endDate ? new Date(filters.endDate) : undefined,
severity: filters.severity,
},
filePath: row.file_path ?? undefined,
errorMessage: row.error_message ?? undefined,
totalFindings: row.total_findings,
createdAt: new Date(row.created_at),
completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
};
return Report.reconstitute(props, UniqueId.from(row.id));
}
private parseJson<T>(json: string, fallback: T): T {
try { return JSON.parse(json) as T; } catch { return fallback; }
}
}

View File

@@ -201,6 +201,19 @@ export interface AuthSessionTable {
created_at: number;
}
export interface ReportTable {
id: string;
title: string;
format: string;
status: string;
filters_json: string;
file_path: string | null;
error_message: string | null;
total_findings: number;
created_at: number;
completed_at: number | null;
}
export interface Database {
sessions: SessionTable;
states: StateTable;
@@ -218,6 +231,7 @@ export interface Database {
org_members: OrgMemberTable;
api_keys: ApiKeyTable;
auth_sessions: AuthSessionTable;
reports: ReportTable;
}
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {

View File

@@ -0,0 +1,198 @@
import { Report } from '../../src/modules/reporting/domain/entities/Report';
import { ReportFormat } from '../../src/modules/reporting/domain/value-objects/ReportFormat';
import { ReportStatus } from '../../src/modules/reporting/domain/value-objects/ReportStatus';
import { GenerateReportCommand } from '../../src/modules/reporting/application/commands/GenerateReportCommand';
import { IReportRepository } from '../../src/modules/reporting/domain/ports/IReportRepository';
import { EventBus } from '../../src/shared/application/EventBus';
import { DomainEvent } from '../../src/shared/domain/DomainEvent';
import { EventHandler } from '../../src/shared/application/EventHandler';
// ─── Mock Repository ──────────────────────────────────────────────────────────
class InMemoryReportRepository implements IReportRepository {
private store = new Map<string, Report>();
async save(report: Report): Promise<void> {
this.store.set(report.id.toString(), report);
}
async findById(id: string): Promise<Report | undefined> {
return this.store.get(id);
}
async findAll(): Promise<Report[]> {
return Array.from(this.store.values());
}
async update(report: Report): Promise<void> {
this.store.set(report.id.toString(), report);
}
}
// ─── Mock EventBus ────────────────────────────────────────────────────────────
class MockEventBus implements EventBus {
published: DomainEvent[] = [];
async publish(event: DomainEvent): Promise<void> {
this.published.push(event);
}
subscribe(_name: string, _handler: EventHandler): void {
// no-op
}
}
// ─── Report domain tests ───────────────────────────────────────────────────────
describe('ReportFormat', () => {
it('parses valid formats', () => {
expect(ReportFormat.fromString('html').value).toBe('html');
expect(ReportFormat.fromString('json').value).toBe('json');
expect(ReportFormat.fromString('pdf').value).toBe('pdf');
});
it('throws on invalid format', () => {
expect(() => ReportFormat.fromString('xml')).toThrow();
});
});
describe('ReportStatus', () => {
it('creates statuses', () => {
expect(ReportStatus.pending().value).toBe('pending');
expect(ReportStatus.generating().value).toBe('generating');
expect(ReportStatus.ready().value).toBe('ready');
expect(ReportStatus.failed().value).toBe('failed');
});
it('parses from string', () => {
expect(ReportStatus.fromString('ready').value).toBe('ready');
});
it('throws on unknown status', () => {
expect(() => ReportStatus.fromString('unknown')).toThrow();
});
});
describe('Report aggregate', () => {
it('creates a report with pending status and emits ReportRequested event', () => {
const report = Report.create({
title: 'Test Report',
format: ReportFormat.fromString('html'),
filters: { severity: 'high' },
});
expect(report.title).toBe('Test Report');
expect(report.status.value).toBe('pending');
expect(report.totalFindings).toBe(0);
const events = report.clearEvents();
expect(events).toHaveLength(1);
expect(events[0]!.eventName).toBe('reporting.report_requested');
});
it('marks report as generating', () => {
const report = Report.create({
title: 'T',
format: ReportFormat.fromString('json'),
filters: {},
});
report.clearEvents();
report.markGenerating();
expect(report.status.value).toBe('generating');
});
it('marks report as ready and emits ReportGenerated', () => {
const report = Report.create({
title: 'T',
format: ReportFormat.fromString('json'),
filters: {},
});
report.clearEvents();
report.markReady('/reports/123/report.json', 5);
expect(report.status.value).toBe('ready');
expect(report.filePath).toBe('/reports/123/report.json');
expect(report.totalFindings).toBe(5);
expect(report.completedAt).toBeInstanceOf(Date);
const events = report.clearEvents();
expect(events[0]!.eventName).toBe('reporting.report_generated');
});
it('marks report as failed and emits ReportFailed', () => {
const report = Report.create({
title: 'T',
format: ReportFormat.fromString('pdf'),
filters: {},
});
report.clearEvents();
report.markFailed('Playwright error');
expect(report.status.value).toBe('failed');
expect(report.errorMessage).toBe('Playwright error');
const events = report.clearEvents();
expect(events[0]!.eventName).toBe('reporting.report_failed');
});
});
// ─── GenerateReportCommand tests ───────────────────────────────────────────────
describe('GenerateReportCommand', () => {
let repo: InMemoryReportRepository;
let eventBus: MockEventBus;
let cmd: GenerateReportCommand;
beforeEach(() => {
repo = new InMemoryReportRepository();
eventBus = new MockEventBus();
cmd = new GenerateReportCommand(repo, eventBus);
});
it('creates and persists a report, returns Ok with reportId', async () => {
const result = await cmd.execute({ title: 'My Report', format: 'html' });
expect(result.ok).toBe(true);
if (!result.ok) return;
const { reportId, status } = result.value;
expect(status).toBe('pending');
const saved = await repo.findById(reportId);
expect(saved).toBeDefined();
expect(saved!.title).toBe('My Report');
expect(saved!.format.value).toBe('html');
});
it('publishes ReportRequested event after creation', async () => {
await cmd.execute({ title: 'Report', format: 'json' });
expect(eventBus.published).toHaveLength(1);
expect(eventBus.published[0]!.eventName).toBe('reporting.report_requested');
});
it('applies filters when provided', async () => {
const result = await cmd.execute({
title: 'Filtered',
format: 'pdf',
filters: { sessionId: 'abc-123', severity: 'critical' },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saved = await repo.findById(result.value.reportId);
expect(saved!.filters.sessionId).toBe('abc-123');
expect(saved!.filters.severity).toBe('critical');
});
it('returns Err for invalid format', async () => {
const result = await cmd.execute({ title: 'Bad', format: 'xml' as 'html' });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain('Invalid format');
});
});