fase(15): reporting module with pdf generation
This commit is contained in:
@@ -1 +1 @@
|
|||||||
7526a5bc154e79ca03948cc30e23de24af7e18dc
|
3ff36f0b6a2c3e92b24febd488ef6abfe37ada6a
|
||||||
|
|||||||
@@ -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`
|
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
|
- [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
|
||||||
- [ ] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
|
- [x] 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
|
- [x] 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)
|
- [x] 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
|
- [x] 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
|
- [x] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
|
||||||
- [ ] 12.7: Progress bar estados explorados / maxStates
|
- [x] 12.7: Progress bar estados explorados / maxStates
|
||||||
- [ ] 12.8: Stop button funcional (DELETE /api/sessions/:id)
|
- [x] 12.8: Stop button funcional (DELETE /api/sessions/:id)
|
||||||
- [ ] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
|
- [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`
|
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
|
- [x] 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
|
- [x] 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
|
- [x] 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)
|
- [x] 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"
|
- [x] 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"
|
- [x] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
|
||||||
- [ ] 13.7: Status workflow buttons: open → investigating → resolved → closed
|
- [x] 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
|
- [x] 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.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`
|
Spec: `.ralph/specs/phase-14-settings-pages.md`
|
||||||
|
|
||||||
- [ ] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
|
- [x] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
|
||||||
- [ ] 14.2: Section "Profile" — cambiar nombre, email, password
|
- [x] 14.2: Section "Profile" — cambiar nombre, email, password
|
||||||
- [ ] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
|
- [x] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
|
||||||
- [ ] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
|
- [x] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
|
||||||
- [ ] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
|
- [x] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
|
||||||
- [ ] 14.6: Section "Notifications" — Slack webhook URL, min severity
|
- [x] 14.6: Section "Notifications" — Slack webhook URL, min severity
|
||||||
- [ ] 14.7: Section "Appearance" — tema dark/light, accent color
|
- [x] 14.7: Section "Appearance" — tema dark/light, accent color
|
||||||
- [ ] 14.8: Section "License" — ver status licencia, input para activar key
|
- [x] 14.8: Section "License" — ver status licencia, input para activar key
|
||||||
- [ ] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
|
- [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`
|
Spec: `.ralph/specs/phase-15-reporting.md`
|
||||||
|
|
||||||
- [ ] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
|
- [x] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
|
||||||
- [ ] 15.2: Crear port: `IReportGenerator.ts`
|
- [x] 15.2: Crear port: `IReportGenerator.ts`
|
||||||
- [ ] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
|
- [x] 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
|
- [x] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
|
||||||
- [ ] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
|
- [x] 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
|
- [x] 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
|
- [x] 15.7: Integrar con job queue: generación async
|
||||||
- [ ] 15.8: Migración Kysely: tabla reports
|
- [x] 15.8: Migración Kysely: tabla reports
|
||||||
- [ ] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
|
- [x] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
|
||||||
- [ ] 15.10: Tests: GenerateReportCommand con mock generator
|
- [x] 15.10: Tests: GenerateReportCommand con mock generator
|
||||||
- [ ] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
|
- [x] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
2
dist/api/router.js
vendored
@@ -8,6 +8,7 @@ const express_1 = require("express");
|
|||||||
const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController");
|
const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController");
|
||||||
const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController");
|
const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController");
|
||||||
const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController");
|
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 AuthController_1 = require("../modules/auth/infrastructure/http/AuthController");
|
||||||
const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware");
|
const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware");
|
||||||
function createRouter(deps) {
|
function createRouter(deps) {
|
||||||
@@ -21,5 +22,6 @@ function createRouter(deps) {
|
|||||||
router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps));
|
router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps));
|
||||||
router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps));
|
router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps));
|
||||||
router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps));
|
router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps));
|
||||||
|
router.use('/reports', (0, ReportingController_1.createReportingRouter)(deps.reportingDeps));
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
25
dist/db/migrations/005_reports_table.js
vendored
Normal file
25
dist/db/migrations/005_reports_table.js
vendored
Normal 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();
|
||||||
|
}
|
||||||
42
dist/jobs/workers/ReportWorker.js
vendored
42
dist/jobs/workers/ReportWorker.js
vendored
@@ -2,15 +2,49 @@
|
|||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.REPORT_JOB_TYPE = void 0;
|
exports.REPORT_JOB_TYPE = void 0;
|
||||||
exports.createReportJobHandler = createReportJobHandler;
|
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';
|
exports.REPORT_JOB_TYPE = 'report:generate';
|
||||||
function createReportJobHandler(deps) {
|
function createReportJobHandler(deps) {
|
||||||
|
const htmlGen = new HTMLReportGenerator_1.HTMLReportGenerator();
|
||||||
|
const jsonGen = new JSONReportGenerator_1.JSONReportGenerator();
|
||||||
|
const pdfGen = new PDFReportGenerator_1.PDFReportGenerator();
|
||||||
return async (payload) => {
|
return async (payload) => {
|
||||||
const log = deps.logger.child({ jobType: exports.REPORT_JOB_TYPE, reportId: payload.reportId });
|
const log = deps.logger.child({ jobType: exports.REPORT_JOB_TYPE, reportId: payload.reportId });
|
||||||
log.info({ format: payload.format }, 'Report generation job executing');
|
log.info({ format: payload.format }, 'Report generation job executing');
|
||||||
// Full implementation in Phase 15 (Reporting Module)
|
const report = await deps.reportRepository.findById(payload.reportId);
|
||||||
// For now, return a placeholder result
|
if (!report) {
|
||||||
const filePath = `./reports/${payload.reportId}.${payload.format}`;
|
throw new Error(`Report not found: ${payload.reportId}`);
|
||||||
log.info({ filePath }, 'Report job complete');
|
}
|
||||||
|
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 };
|
return { reportId: payload.reportId, filePath };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
19
dist/main.js
vendored
19
dist/main.js
vendored
@@ -49,6 +49,9 @@ const CreateApiKeyCommand_1 = require("./modules/auth/application/commands/Creat
|
|||||||
const GetUserQuery_1 = require("./modules/auth/application/queries/GetUserQuery");
|
const GetUserQuery_1 = require("./modules/auth/application/queries/GetUserQuery");
|
||||||
const ListOrgMembersQuery_1 = require("./modules/auth/application/queries/ListOrgMembersQuery");
|
const ListOrgMembersQuery_1 = require("./modules/auth/application/queries/ListOrgMembersQuery");
|
||||||
const PasswordService_1 = require("./modules/auth/infrastructure/auth/PasswordService");
|
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
|
// Job queue
|
||||||
const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue");
|
const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue");
|
||||||
const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker");
|
const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker");
|
||||||
@@ -72,6 +75,7 @@ async function bootstrap() {
|
|||||||
const sessionRepo = new KyselyCrawlSessionRepository_1.KyselyCrawlSessionRepository(db);
|
const sessionRepo = new KyselyCrawlSessionRepository_1.KyselyCrawlSessionRepository(db);
|
||||||
const stateRepo = new KyselyStateRepository_1.KyselyStateRepository(db);
|
const stateRepo = new KyselyStateRepository_1.KyselyStateRepository(db);
|
||||||
const findingRepo = new KyselyFindingRepository_1.KyselyFindingRepository(db);
|
const findingRepo = new KyselyFindingRepository_1.KyselyFindingRepository(db);
|
||||||
|
const reportRepo = new KyselyReportRepository_1.KyselyReportRepository(db);
|
||||||
const fuzzRepo = new InMemoryFuzzSessionRepository_1.InMemoryFuzzSessionRepository();
|
const fuzzRepo = new InMemoryFuzzSessionRepository_1.InMemoryFuzzSessionRepository();
|
||||||
// Suppress unused warning for stateRepo — used by crawling infrastructure
|
// Suppress unused warning for stateRepo — used by crawling infrastructure
|
||||||
void stateRepo;
|
void stateRepo;
|
||||||
@@ -108,7 +112,14 @@ async function bootstrap() {
|
|||||||
const createApiKeyCommand = new CreateApiKeyCommand_1.CreateApiKeyCommand(apiKeyRepo, userRepo);
|
const createApiKeyCommand = new CreateApiKeyCommand_1.CreateApiKeyCommand(apiKeyRepo, userRepo);
|
||||||
const getUserQuery = new GetUserQuery_1.GetUserQuery(userRepo);
|
const getUserQuery = new GetUserQuery_1.GetUserQuery(userRepo);
|
||||||
const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, 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)({
|
const app = (0, server_1.createServer)({
|
||||||
config,
|
config,
|
||||||
logger,
|
logger,
|
||||||
@@ -116,6 +127,7 @@ async function bootstrap() {
|
|||||||
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
|
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
|
||||||
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
|
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
|
||||||
fuzzingDeps: { runFuzz, repository: fuzzRepo },
|
fuzzingDeps: { runFuzz, repository: fuzzRepo },
|
||||||
|
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
|
||||||
authDeps: {
|
authDeps: {
|
||||||
registerCommand,
|
registerCommand,
|
||||||
loginCommand,
|
loginCommand,
|
||||||
@@ -130,11 +142,6 @@ async function bootstrap() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const httpServer = http_1.default.createServer(app);
|
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
|
// 12. Socket.io + gateway
|
||||||
const io = new socket_io_1.Server(httpServer, {
|
const io = new socket_io_1.Server(httpServer, {
|
||||||
cors: { origin: config.cors.origin, credentials: true },
|
cors: { origin: config.cors.origin, credentials: true },
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function createFindingsRouter(deps) {
|
|||||||
}
|
}
|
||||||
res.json(result.value);
|
res.json(result.value);
|
||||||
});
|
});
|
||||||
// GET /api/findings/:id — finding detail
|
// GET /api/findings/:id — finding detail (includes actionTrace)
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
const findingId = req.params['id'];
|
const findingId = req.params['id'];
|
||||||
const result = await deps.getFinding.execute({ findingId });
|
const result = await deps.getFinding.execute({ findingId });
|
||||||
@@ -46,7 +46,8 @@ function createFindingsRouter(deps) {
|
|||||||
res.status(404).json({ error: result.error });
|
res.status(404).json({ error: result.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json(toDTO(result.value));
|
const f = result.value;
|
||||||
|
res.json({ ...toDTO(f), actionTrace: f.actionTrace });
|
||||||
});
|
});
|
||||||
// PATCH /api/findings/:id/status — update status
|
// PATCH /api/findings/:id/status — update status
|
||||||
router.patch('/:id/status', async (req, res) => {
|
router.patch('/:id/status', async (req, res) => {
|
||||||
|
|||||||
33
dist/modules/reporting/application/commands/GenerateReportCommand.js
vendored
Normal file
33
dist/modules/reporting/application/commands/GenerateReportCommand.js
vendored
Normal 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;
|
||||||
59
dist/modules/reporting/domain/entities/Report.js
vendored
Normal file
59
dist/modules/reporting/domain/entities/Report.js
vendored
Normal 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;
|
||||||
14
dist/modules/reporting/domain/events/ReportFailed.js
vendored
Normal file
14
dist/modules/reporting/domain/events/ReportFailed.js
vendored
Normal 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;
|
||||||
14
dist/modules/reporting/domain/events/ReportGenerated.js
vendored
Normal file
14
dist/modules/reporting/domain/events/ReportGenerated.js
vendored
Normal 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;
|
||||||
14
dist/modules/reporting/domain/events/ReportRequested.js
vendored
Normal file
14
dist/modules/reporting/domain/events/ReportRequested.js
vendored
Normal 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;
|
||||||
2
dist/modules/reporting/domain/ports/IReportGenerator.js
vendored
Normal file
2
dist/modules/reporting/domain/ports/IReportGenerator.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
2
dist/modules/reporting/domain/ports/IReportRepository.js
vendored
Normal file
2
dist/modules/reporting/domain/ports/IReportRepository.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
14
dist/modules/reporting/domain/value-objects/DateRange.js
vendored
Normal file
14
dist/modules/reporting/domain/value-objects/DateRange.js
vendored
Normal 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;
|
||||||
17
dist/modules/reporting/domain/value-objects/ReportFormat.js
vendored
Normal file
17
dist/modules/reporting/domain/value-objects/ReportFormat.js
vendored
Normal 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;
|
||||||
18
dist/modules/reporting/domain/value-objects/ReportStatus.js
vendored
Normal file
18
dist/modules/reporting/domain/value-objects/ReportStatus.js
vendored
Normal 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;
|
||||||
138
dist/modules/reporting/infrastructure/generators/HTMLReportGenerator.js
vendored
Normal file
138
dist/modules/reporting/infrastructure/generators/HTMLReportGenerator.js
vendored
Normal 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} ·
|
||||||
|
${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 · ${new Date().toLocaleString()}
|
||||||
|
${report.filters.sessionId ? ` · 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
88
dist/modules/reporting/infrastructure/generators/JSONReportGenerator.js
vendored
Normal file
88
dist/modules/reporting/infrastructure/generators/JSONReportGenerator.js
vendored
Normal 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;
|
||||||
|
}
|
||||||
81
dist/modules/reporting/infrastructure/generators/PDFReportGenerator.js
vendored
Normal file
81
dist/modules/reporting/infrastructure/generators/PDFReportGenerator.js
vendored
Normal 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;
|
||||||
134
dist/modules/reporting/infrastructure/http/ReportingController.js
vendored
Normal file
134
dist/modules/reporting/infrastructure/http/ReportingController.js
vendored
Normal 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;
|
||||||
|
}
|
||||||
85
dist/modules/reporting/infrastructure/repositories/KyselyReportRepository.js
vendored
Normal file
85
dist/modules/reporting/infrastructure/repositories/KyselyReportRepository.js
vendored
Normal 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;
|
||||||
@@ -11,21 +11,21 @@ import { Setup } from '@/pages/Setup'
|
|||||||
import { Dashboard } from '@/pages/Dashboard'
|
import { Dashboard } from '@/pages/Dashboard'
|
||||||
import { SessionList } from '@/pages/sessions/SessionList'
|
import { SessionList } from '@/pages/sessions/SessionList'
|
||||||
import { SessionDetail } from '@/pages/sessions/SessionDetail'
|
import { SessionDetail } from '@/pages/sessions/SessionDetail'
|
||||||
function FindingsList() {
|
import { FindingsList } from '@/pages/findings/FindingsList'
|
||||||
return <div className="text-muted-foreground p-4">Findings — Coming in Phase 13</div>
|
import { FindingDetail } from '@/pages/findings/FindingDetail'
|
||||||
}
|
import { SettingsLayout } from '@/pages/settings/SettingsLayout'
|
||||||
function FindingDetail() {
|
import { ProfileSection } from '@/pages/settings/ProfileSection'
|
||||||
return <div className="text-muted-foreground p-4">Finding Detail — Coming in Phase 13</div>
|
import { OrganizationSection } from '@/pages/settings/OrganizationSection'
|
||||||
}
|
import { ApiKeysSection } from '@/pages/settings/ApiKeysSection'
|
||||||
function Reports() {
|
import { ExplorationDefaultsSection } from '@/pages/settings/ExplorationDefaultsSection'
|
||||||
return <div className="text-muted-foreground p-4">Reports — Coming in Phase 15</div>
|
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() {
|
function VisualReview() {
|
||||||
return <div className="text-muted-foreground p-4">Visual Review — Coming in Phase 20</div>
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +50,16 @@ export default function App() {
|
|||||||
<Route path="/findings/:id" element={<FindingDetail />} />
|
<Route path="/findings/:id" element={<FindingDetail />} />
|
||||||
<Route path="/reports" element={<Reports />} />
|
<Route path="/reports" element={<Reports />} />
|
||||||
<Route path="/visual-review" element={<VisualReview />} />
|
<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>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
122
frontend/src/components/findings/AIAnalysisPanel.tsx
Normal file
122
frontend/src/components/findings/AIAnalysisPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
frontend/src/components/findings/EvidencePanel.tsx
Normal file
78
frontend/src/components/findings/EvidencePanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
frontend/src/components/findings/ReproductionSteps.tsx
Normal file
62
frontend/src/components/findings/ReproductionSteps.tsx
Normal 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: "{step.value}"
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ interface User {
|
|||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
role: string
|
role: string
|
||||||
|
orgId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { apiFetch } from '@/lib/api'
|
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 }) {
|
export function useFindings(params?: { sessionId?: string; severity?: string }) {
|
||||||
const qs = new URLSearchParams()
|
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() {
|
export function useFindingStats() {
|
||||||
return useQuery<Stats>({
|
return useQuery<Stats>({
|
||||||
queryKey: ['findings', 'stats'],
|
queryKey: ['findings', 'stats'],
|
||||||
|
|||||||
257
frontend/src/pages/Reports.tsx
Normal file
257
frontend/src/pages/Reports.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
176
frontend/src/pages/findings/FindingDetail.tsx
Normal file
176
frontend/src/pages/findings/FindingDetail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
184
frontend/src/pages/findings/FindingsList.tsx
Normal file
184
frontend/src/pages/findings/FindingsList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
frontend/src/pages/settings/ApiKeysSection.tsx
Normal file
190
frontend/src/pages/settings/ApiKeysSection.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
frontend/src/pages/settings/AppearanceSection.tsx
Normal file
32
frontend/src/pages/settings/AppearanceSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
frontend/src/pages/settings/ExplorationDefaultsSection.tsx
Normal file
104
frontend/src/pages/settings/ExplorationDefaultsSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
frontend/src/pages/settings/LicenseSection.tsx
Normal file
32
frontend/src/pages/settings/LicenseSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
frontend/src/pages/settings/NotificationsSection.tsx
Normal file
98
frontend/src/pages/settings/NotificationsSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
frontend/src/pages/settings/OrganizationSection.tsx
Normal file
140
frontend/src/pages/settings/OrganizationSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
frontend/src/pages/settings/ProfileSection.tsx
Normal file
60
frontend/src/pages/settings/ProfileSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
frontend/src/pages/settings/SettingsLayout.tsx
Normal file
46
frontend/src/pages/settings/SettingsLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -88,6 +88,40 @@ export interface AnomalySummary {
|
|||||||
browser?: BrowserType;
|
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 {
|
export interface Session {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Router } from 'express';
|
|||||||
import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController';
|
import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController';
|
||||||
import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController';
|
import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController';
|
||||||
import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController';
|
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 { createAuthController } from '../modules/auth/infrastructure/http/AuthController';
|
||||||
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
|
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
|
||||||
import { ServerDependencies } from './server';
|
import { ServerDependencies } from './server';
|
||||||
@@ -64,6 +65,7 @@ export function createRouter(deps: ServerDependencies): Router {
|
|||||||
router.use('/sessions', createCrawlingRouter(deps.crawlingDeps));
|
router.use('/sessions', createCrawlingRouter(deps.crawlingDeps));
|
||||||
router.use('/findings', createFindingsRouter(deps.findingsDeps));
|
router.use('/findings', createFindingsRouter(deps.findingsDeps));
|
||||||
router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps));
|
router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps));
|
||||||
|
router.use('/reports', createReportingRouter(deps.reportingDeps));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { createRouter } from './router';
|
|||||||
import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController';
|
import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController';
|
||||||
import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController';
|
import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController';
|
||||||
import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController';
|
import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController';
|
||||||
|
import { ReportingControllerDeps } from '../modules/reporting/infrastructure/http/ReportingController';
|
||||||
import { AuthControllerDeps } from './router';
|
import { AuthControllerDeps } from './router';
|
||||||
|
|
||||||
export interface ServerDependencies {
|
export interface ServerDependencies {
|
||||||
@@ -27,6 +28,7 @@ export interface ServerDependencies {
|
|||||||
crawlingDeps: CrawlingControllerDeps;
|
crawlingDeps: CrawlingControllerDeps;
|
||||||
findingsDeps: FindingsControllerDeps;
|
findingsDeps: FindingsControllerDeps;
|
||||||
fuzzingDeps: FuzzingControllerDeps;
|
fuzzingDeps: FuzzingControllerDeps;
|
||||||
|
reportingDeps: ReportingControllerDeps;
|
||||||
authDeps: AuthControllerDeps;
|
authDeps: AuthControllerDeps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
src/db/migrations/005_reports_table.ts
Normal file
24
src/db/migrations/005_reports_table.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* ReportWorker — handles 'report:generate' jobs.
|
* 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 { JobHandler } from '../JobQueue';
|
||||||
import { Logger } from '../../shared/infrastructure/Logger';
|
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';
|
export const REPORT_JOB_TYPE = 'report:generate';
|
||||||
|
|
||||||
@@ -25,16 +30,51 @@ export interface ReportJobResult {
|
|||||||
|
|
||||||
export function createReportJobHandler(deps: {
|
export function createReportJobHandler(deps: {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
reportRepository: IReportRepository;
|
||||||
|
findingRepository: IFindingRepository;
|
||||||
}): JobHandler<ReportJobPayload, ReportJobResult> {
|
}): JobHandler<ReportJobPayload, ReportJobResult> {
|
||||||
|
const htmlGen = new HTMLReportGenerator();
|
||||||
|
const jsonGen = new JSONReportGenerator();
|
||||||
|
const pdfGen = new PDFReportGenerator();
|
||||||
|
|
||||||
return async (payload: ReportJobPayload): Promise<ReportJobResult> => {
|
return async (payload: ReportJobPayload): Promise<ReportJobResult> => {
|
||||||
const log = deps.logger.child({ jobType: REPORT_JOB_TYPE, reportId: payload.reportId });
|
const log = deps.logger.child({ jobType: REPORT_JOB_TYPE, reportId: payload.reportId });
|
||||||
log.info({ format: payload.format }, 'Report generation job executing');
|
log.info({ format: payload.format }, 'Report generation job executing');
|
||||||
|
|
||||||
// Full implementation in Phase 15 (Reporting Module)
|
const report = await deps.reportRepository.findById(payload.reportId);
|
||||||
// For now, return a placeholder result
|
if (!report) {
|
||||||
const filePath = `./reports/${payload.reportId}.${payload.format}`;
|
throw new Error(`Report not found: ${payload.reportId}`);
|
||||||
log.info({ filePath }, 'Report job complete');
|
}
|
||||||
|
|
||||||
|
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 };
|
return { reportId: payload.reportId, filePath };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/main.ts
29
src/main.ts
@@ -51,6 +51,10 @@ import { GetUserQuery } from './modules/auth/application/queries/GetUserQuery';
|
|||||||
import { ListOrgMembersQuery } from './modules/auth/application/queries/ListOrgMembersQuery';
|
import { ListOrgMembersQuery } from './modules/auth/application/queries/ListOrgMembersQuery';
|
||||||
import { hashPassword, verifyPassword } from './modules/auth/infrastructure/auth/PasswordService';
|
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
|
// Job queue
|
||||||
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
|
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
|
||||||
import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker';
|
import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker';
|
||||||
@@ -80,6 +84,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
const sessionRepo = new KyselyCrawlSessionRepository(db);
|
const sessionRepo = new KyselyCrawlSessionRepository(db);
|
||||||
const stateRepo = new KyselyStateRepository(db);
|
const stateRepo = new KyselyStateRepository(db);
|
||||||
const findingRepo = new KyselyFindingRepository(db);
|
const findingRepo = new KyselyFindingRepository(db);
|
||||||
|
const reportRepo = new KyselyReportRepository(db);
|
||||||
const fuzzRepo = new InMemoryFuzzSessionRepository();
|
const fuzzRepo = new InMemoryFuzzSessionRepository();
|
||||||
|
|
||||||
// Suppress unused warning for stateRepo — used by crawling infrastructure
|
// Suppress unused warning for stateRepo — used by crawling infrastructure
|
||||||
@@ -125,7 +130,19 @@ async function bootstrap(): Promise<void> {
|
|||||||
const getUserQuery = new GetUserQuery(userRepo);
|
const getUserQuery = new GetUserQuery(userRepo);
|
||||||
const listOrgMembersQuery = new ListOrgMembersQuery(orgRepo, 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({
|
const app = createServer({
|
||||||
config,
|
config,
|
||||||
logger,
|
logger,
|
||||||
@@ -133,6 +150,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
|
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
|
||||||
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
|
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
|
||||||
fuzzingDeps: { runFuzz, repository: fuzzRepo },
|
fuzzingDeps: { runFuzz, repository: fuzzRepo },
|
||||||
|
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
|
||||||
authDeps: {
|
authDeps: {
|
||||||
registerCommand,
|
registerCommand,
|
||||||
loginCommand,
|
loginCommand,
|
||||||
@@ -149,15 +167,6 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
const httpServer = http.createServer(app);
|
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
|
// 12. Socket.io + gateway
|
||||||
const io = new SocketIOServer(httpServer, {
|
const io = new SocketIOServer(httpServer, {
|
||||||
cors: { origin: config.cors.origin, credentials: true },
|
cors: { origin: config.cors.origin, credentials: true },
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router {
|
|||||||
res.json(result.value);
|
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) => {
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
const findingId = req.params['id'] as string;
|
const findingId = req.params['id'] as string;
|
||||||
const result = await deps.getFinding.execute({ findingId });
|
const result = await deps.getFinding.execute({ findingId });
|
||||||
@@ -58,7 +58,8 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router {
|
|||||||
res.status(404).json({ error: result.error });
|
res.status(404).json({ error: result.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json(toDTO(result.value));
|
const f = result.value;
|
||||||
|
res.json({ ...toDTO(f), actionTrace: f.actionTrace });
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/findings/:id/status — update status
|
// PATCH /api/findings/:id/status — update status
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/modules/reporting/domain/entities/Report.ts
Normal file
90
src/modules/reporting/domain/entities/Report.ts
Normal 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 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/reporting/domain/events/ReportFailed.ts
Normal file
9
src/modules/reporting/domain/events/ReportFailed.ts
Normal 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>) {}
|
||||||
|
}
|
||||||
9
src/modules/reporting/domain/events/ReportGenerated.ts
Normal file
9
src/modules/reporting/domain/events/ReportGenerated.ts
Normal 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>) {}
|
||||||
|
}
|
||||||
9
src/modules/reporting/domain/events/ReportRequested.ts
Normal file
9
src/modules/reporting/domain/events/ReportRequested.ts
Normal 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>) {}
|
||||||
|
}
|
||||||
6
src/modules/reporting/domain/ports/IReportGenerator.ts
Normal file
6
src/modules/reporting/domain/ports/IReportGenerator.ts
Normal 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>;
|
||||||
|
}
|
||||||
8
src/modules/reporting/domain/ports/IReportRepository.ts
Normal file
8
src/modules/reporting/domain/ports/IReportRepository.ts
Normal 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>;
|
||||||
|
}
|
||||||
16
src/modules/reporting/domain/value-objects/DateRange.ts
Normal file
16
src/modules/reporting/domain/value-objects/DateRange.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/modules/reporting/domain/value-objects/ReportFormat.ts
Normal file
20
src/modules/reporting/domain/value-objects/ReportFormat.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/modules/reporting/domain/value-objects/ReportStatus.ts
Normal file
21
src/modules/reporting/domain/value-objects/ReportStatus.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} ·
|
||||||
|
${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 · ${new Date().toLocaleString()}
|
||||||
|
${report.filters.sessionId ? ` · 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/modules/reporting/infrastructure/http/ReportingController.ts
Normal file
131
src/modules/reporting/infrastructure/http/ReportingController.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -201,6 +201,19 @@ export interface AuthSessionTable {
|
|||||||
created_at: number;
|
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 {
|
export interface Database {
|
||||||
sessions: SessionTable;
|
sessions: SessionTable;
|
||||||
states: StateTable;
|
states: StateTable;
|
||||||
@@ -218,6 +231,7 @@ export interface Database {
|
|||||||
org_members: OrgMemberTable;
|
org_members: OrgMemberTable;
|
||||||
api_keys: ApiKeyTable;
|
api_keys: ApiKeyTable;
|
||||||
auth_sessions: AuthSessionTable;
|
auth_sessions: AuthSessionTable;
|
||||||
|
reports: ReportTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {
|
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {
|
||||||
|
|||||||
198
tests/modules/reporting.test.ts
Normal file
198
tests/modules/reporting.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user