react-doctor-cli-dev 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/dist/db.js +69 -0
- package/backend/dist/index.js +22 -23
- package/backend/dist/routes/reports.js +221 -31
- package/backend/src/db.ts +61 -15
- package/backend/src/index.ts +32 -35
- package/backend/src/routes/reports.ts +289 -71
- package/package.json +2 -8
- package/react-doctor-cli-dev-1.0.0.tgz +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─────────────────────────────────────────────────────────────
|
|
3
|
+
// backend/src/db.ts
|
|
4
|
+
//
|
|
5
|
+
// SQLite database via better-sqlite3 (synchronous, fast).
|
|
6
|
+
//
|
|
7
|
+
// SCHEMA:
|
|
8
|
+
//
|
|
9
|
+
// reports — one row per analysis run
|
|
10
|
+
// id — auto-increment PK
|
|
11
|
+
// project — project name / hostname
|
|
12
|
+
// score — 0-100 overall performance score
|
|
13
|
+
// grade — A+, A, B, C, D, F
|
|
14
|
+
// analyzed_at — ISO timestamp from the CLI
|
|
15
|
+
// created_at — when the row was inserted
|
|
16
|
+
// static_json — full StaticReport as JSON string
|
|
17
|
+
// runtime_json — full RuntimeReport map as JSON string
|
|
18
|
+
// (screenshots stripped — stored as files)
|
|
19
|
+
// suggestions — full Suggestion[] as JSON string
|
|
20
|
+
//
|
|
21
|
+
// WHY SPLIT COLUMNS INSTEAD OF ONE payload COLUMN?
|
|
22
|
+
// The old schema stored everything in a single `payload` TEXT
|
|
23
|
+
// column. This meant the dashboard couldn't query individual
|
|
24
|
+
// fields without parsing the whole blob. Splitting into three
|
|
25
|
+
// typed columns lets us:
|
|
26
|
+
// • SELECT only the part we need (e.g. just static_json for
|
|
27
|
+
// the issues page)
|
|
28
|
+
// • Keep the list endpoint fast (no giant blobs per row)
|
|
29
|
+
// • Add indices on score/grade later without changing schema
|
|
30
|
+
//
|
|
31
|
+
// SCREENSHOT STORAGE:
|
|
32
|
+
// Screenshots are base64 PNGs — up to 200KB each. Storing them
|
|
33
|
+
// in SQLite bloats the DB and makes every query slower. Instead:
|
|
34
|
+
// • The upload route strips dataUrls from runtime_json
|
|
35
|
+
// • Saves each screenshot as a .png file in data/screenshots/
|
|
36
|
+
// • Replaces dataUrl with a relative path /screenshots/<file>
|
|
37
|
+
// The dashboard fetches screenshots via the static file route.
|
|
38
|
+
// ─────────────────────────────────────────────────────────────
|
|
39
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
40
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
41
|
+
};
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.screenshotsDir = void 0;
|
|
44
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
45
|
+
const path_1 = __importDefault(require("path"));
|
|
46
|
+
const fs_1 = __importDefault(require("fs"));
|
|
47
|
+
const dotenv_1 = __importDefault(require("dotenv"));
|
|
48
|
+
dotenv_1.default.config({ path: path_1.default.join(__dirname, "..", ".env") });
|
|
49
|
+
const PACKAGE_ROOT = path_1.default.resolve(__dirname, "..", "..");
|
|
50
|
+
const DEFAULT_DB = path_1.default.join(PACKAGE_ROOT, "backend", "data", "reports.db");
|
|
51
|
+
const dbPath = process.env.DB_PATH || DEFAULT_DB;
|
|
52
|
+
fs_1.default.mkdirSync(path_1.default.dirname(dbPath), { recursive: true });
|
|
53
|
+
fs_1.default.mkdirSync(path_1.default.join(path_1.default.dirname(dbPath), "screenshots"), { recursive: true });
|
|
54
|
+
const db = new better_sqlite3_1.default(dbPath);
|
|
55
|
+
db.exec(`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
57
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
58
|
+
project TEXT NOT NULL,
|
|
59
|
+
score INTEGER NOT NULL,
|
|
60
|
+
grade TEXT NOT NULL,
|
|
61
|
+
analyzed_at TEXT NOT NULL,
|
|
62
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
63
|
+
static_json TEXT NOT NULL,
|
|
64
|
+
runtime_json TEXT NOT NULL,
|
|
65
|
+
suggestions TEXT NOT NULL
|
|
66
|
+
);
|
|
67
|
+
`);
|
|
68
|
+
exports.screenshotsDir = path_1.default.join(path_1.default.dirname(dbPath), "screenshots");
|
|
69
|
+
exports.default = db;
|
package/backend/dist/index.js
CHANGED
|
@@ -7,37 +7,36 @@ const express_1 = __importDefault(require("express"));
|
|
|
7
7
|
const cors_1 = __importDefault(require("cors"));
|
|
8
8
|
const helmet_1 = __importDefault(require("helmet"));
|
|
9
9
|
const dotenv_1 = __importDefault(require("dotenv"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
10
11
|
const reports_1 = __importDefault(require("./routes/reports"));
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
console.log(`[Debug] API_KEY loaded: ${process.env.API_KEY ? '✓' : '✗'}`);
|
|
14
|
-
console.log(`[Debug] API_KEY value: ${process.env.API_KEY || 'NOT SET'}`);
|
|
12
|
+
const db_1 = require("./db");
|
|
13
|
+
dotenv_1.default.config({ path: path_1.default.join(__dirname, "..", ".env") });
|
|
15
14
|
const app = (0, express_1.default)();
|
|
16
15
|
const PORT = process.env.PORT || 3000;
|
|
17
|
-
//
|
|
18
|
-
app.use((0, helmet_1.default)());
|
|
19
|
-
app.use((0, cors_1.default)());
|
|
20
|
-
app.use(express_1.default.json({ limit:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
});
|
|
16
|
+
// ── Middleware ────────────────────────────────────────────────
|
|
17
|
+
app.use((0, helmet_1.default)());
|
|
18
|
+
app.use((0, cors_1.default)());
|
|
19
|
+
app.use(express_1.default.json({ limit: "50mb" }));
|
|
20
|
+
// ── Static: screenshots saved by the upload route ────────────
|
|
21
|
+
// Served at /screenshots/<filename>
|
|
22
|
+
// The dashboard uses these as <img src="/screenshots/...">
|
|
23
|
+
app.use("/screenshots", express_1.default.static(db_1.screenshotsDir));
|
|
24
|
+
// ── Routes ────────────────────────────────────────────────────
|
|
25
|
+
app.use("/api/reports", reports_1.default);
|
|
26
|
+
// ── Health check ──────────────────────────────────────────────
|
|
27
|
+
app.get("/health", (_req, res) => {
|
|
28
|
+
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
30
29
|
});
|
|
31
|
-
//
|
|
32
|
-
app.use((
|
|
33
|
-
res.status(404).json({ message:
|
|
30
|
+
// ── 404 ───────────────────────────────────────────────────────
|
|
31
|
+
app.use((_req, res) => {
|
|
32
|
+
res.status(404).json({ message: "Route not found" });
|
|
34
33
|
});
|
|
35
|
-
//
|
|
34
|
+
// ── Error handler ─────────────────────────────────────────────
|
|
36
35
|
app.use((err, _req, res, _next) => {
|
|
37
36
|
console.error(err.stack);
|
|
38
|
-
res.status(500).json({ message:
|
|
37
|
+
res.status(500).json({ message: "Internal Server Error" });
|
|
39
38
|
});
|
|
40
|
-
//
|
|
39
|
+
// ── Start ─────────────────────────────────────────────────────
|
|
41
40
|
app.listen(PORT, () => {
|
|
42
41
|
console.log(`React Doctor backend running on http://localhost:${PORT}`);
|
|
43
42
|
});
|
|
@@ -1,74 +1,171 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
// ─────────────────────────────────────────────────────────────
|
|
3
|
+
// backend/src/routes/reports.ts
|
|
4
|
+
//
|
|
5
|
+
// All report endpoints.
|
|
6
|
+
//
|
|
7
|
+
// ENDPOINTS:
|
|
8
|
+
//
|
|
9
|
+
// GET /api/reports
|
|
10
|
+
// Returns a summary list (no blobs) — fast for the dashboard
|
|
11
|
+
// history page. Each row has id, project, score, grade, dates.
|
|
12
|
+
//
|
|
13
|
+
// GET /api/reports/:id
|
|
14
|
+
// Returns the full report for one run — static + runtime +
|
|
15
|
+
// suggestions all parsed back to objects.
|
|
16
|
+
//
|
|
17
|
+
// GET /api/reports/project/:name
|
|
18
|
+
// All runs for a named project, summary only.
|
|
19
|
+
//
|
|
20
|
+
// POST /api/reports/upload (requires x-api-key header)
|
|
21
|
+
// Accepts a FinalReport from the CLI.
|
|
22
|
+
// Strips screenshot dataUrls → saves as .png files.
|
|
23
|
+
// Stores static_json, runtime_json, suggestions in DB.
|
|
24
|
+
//
|
|
25
|
+
// SCREENSHOT HANDLING ON UPLOAD:
|
|
26
|
+
// The CLI sends the full FinalReport including base64 screenshots
|
|
27
|
+
// (up to 200KB each). We extract those before storing so the DB
|
|
28
|
+
// stays lean. Each screenshot is saved as:
|
|
29
|
+
// data/screenshots/<reportId>-<routeKey>-<label>.png
|
|
30
|
+
// And the dataUrl in runtime_json is replaced with:
|
|
31
|
+
// /screenshots/<filename>
|
|
32
|
+
// so the dashboard can load them as normal <img> tags.
|
|
33
|
+
// ─────────────────────────────────────────────────────────────
|
|
34
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
35
|
+
if (k2 === undefined) k2 = k;
|
|
36
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
37
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
38
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
39
|
+
}
|
|
40
|
+
Object.defineProperty(o, k2, desc);
|
|
41
|
+
}) : (function(o, m, k, k2) {
|
|
42
|
+
if (k2 === undefined) k2 = k;
|
|
43
|
+
o[k2] = m[k];
|
|
44
|
+
}));
|
|
45
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
46
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
47
|
+
}) : function(o, v) {
|
|
48
|
+
o["default"] = v;
|
|
49
|
+
});
|
|
50
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
51
|
+
var ownKeys = function(o) {
|
|
52
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
53
|
+
var ar = [];
|
|
54
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
55
|
+
return ar;
|
|
56
|
+
};
|
|
57
|
+
return ownKeys(o);
|
|
58
|
+
};
|
|
59
|
+
return function (mod) {
|
|
60
|
+
if (mod && mod.__esModule) return mod;
|
|
61
|
+
var result = {};
|
|
62
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
63
|
+
__setModuleDefault(result, mod);
|
|
64
|
+
return result;
|
|
65
|
+
};
|
|
66
|
+
})();
|
|
2
67
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
68
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
69
|
};
|
|
5
70
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
71
|
const express_1 = require("express");
|
|
7
|
-
const
|
|
72
|
+
const path_1 = __importDefault(require("path"));
|
|
73
|
+
const fs_1 = __importDefault(require("fs"));
|
|
74
|
+
const db_1 = __importStar(require("../db"));
|
|
8
75
|
const auth_1 = require("../middleware/auth");
|
|
9
76
|
const router = (0, express_1.Router)();
|
|
10
|
-
|
|
77
|
+
// ── GET /api/reports ─────────────────────────────────────────
|
|
78
|
+
// Summary list — no blobs, just the columns the history page needs.
|
|
79
|
+
router.get("/", (_req, res) => {
|
|
11
80
|
try {
|
|
12
81
|
const rows = db_1.default.prepare(`
|
|
13
82
|
SELECT id, project, score, grade, analyzed_at, created_at
|
|
14
|
-
FROM
|
|
15
|
-
ORDER
|
|
16
|
-
LIMIT
|
|
83
|
+
FROM reports
|
|
84
|
+
ORDER BY created_at DESC
|
|
85
|
+
LIMIT 100
|
|
17
86
|
`).all();
|
|
18
87
|
res.json({ count: rows.length, reports: rows });
|
|
19
88
|
}
|
|
20
89
|
catch (err) {
|
|
90
|
+
console.error("GET / error:", err.message);
|
|
21
91
|
res.status(500).json({ error: "Internal server error" });
|
|
22
92
|
}
|
|
23
93
|
});
|
|
94
|
+
// ── GET /api/reports/project/:name ───────────────────────────
|
|
95
|
+
// All runs for one project, summary only.
|
|
24
96
|
router.get("/project/:name", (req, res) => {
|
|
25
97
|
try {
|
|
26
98
|
const rows = db_1.default.prepare(`
|
|
27
99
|
SELECT id, project, score, grade, analyzed_at, created_at
|
|
28
|
-
FROM
|
|
29
|
-
WHERE
|
|
30
|
-
ORDER
|
|
100
|
+
FROM reports
|
|
101
|
+
WHERE project = ?
|
|
102
|
+
ORDER BY created_at DESC
|
|
31
103
|
`).all(req.params.name);
|
|
32
|
-
res.json({
|
|
33
|
-
project: req.params.name,
|
|
34
|
-
count: rows.length,
|
|
35
|
-
reports: rows,
|
|
36
|
-
});
|
|
104
|
+
res.json({ project: req.params.name, count: rows.length, reports: rows });
|
|
37
105
|
}
|
|
38
106
|
catch (err) {
|
|
107
|
+
console.error("GET /project/:name error:", err.message);
|
|
39
108
|
res.status(500).json({ error: "Internal server error" });
|
|
40
109
|
}
|
|
41
110
|
});
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// ==========================================
|
|
111
|
+
// ── POST /api/reports/upload ──────────────────────────────────
|
|
112
|
+
// Receives a FinalReport from the CLI, strips screenshots to disk,
|
|
113
|
+
// and stores the three JSON blobs in separate columns.
|
|
46
114
|
router.post("/upload", auth_1.requireApiKey, (req, res) => {
|
|
47
115
|
try {
|
|
48
|
-
const
|
|
49
|
-
//
|
|
50
|
-
if (!
|
|
51
|
-
|
|
116
|
+
const body = req.body;
|
|
117
|
+
// ── Validate required top-level fields ──────────────────
|
|
118
|
+
if (!body ||
|
|
119
|
+
!body.projectName ||
|
|
120
|
+
!body.analyzedAt ||
|
|
121
|
+
body.performanceScore === undefined ||
|
|
122
|
+
!body.static ||
|
|
123
|
+
!body.runtime ||
|
|
124
|
+
!body.suggestions) {
|
|
125
|
+
res.status(400).json({
|
|
126
|
+
error: "Invalid report",
|
|
127
|
+
missing: getMissingFields(body),
|
|
128
|
+
});
|
|
52
129
|
return;
|
|
53
130
|
}
|
|
54
|
-
|
|
55
|
-
|
|
131
|
+
// ── Extract grade from static report ────────────────────
|
|
132
|
+
const grade = body.static?.grade ?? "N/A";
|
|
133
|
+
// ── Strip screenshots from runtime, save as .png files ──
|
|
134
|
+
// We do this BEFORE inserting so the DB never holds base64.
|
|
135
|
+
// The row ID isn't known yet — we'll rename after insert.
|
|
136
|
+
// For now we use a temp prefix and rename below.
|
|
137
|
+
const { cleanedRuntime, pendingScreenshots } = extractScreenshots(body.runtime);
|
|
138
|
+
// ── Insert the row ───────────────────────────────────────
|
|
56
139
|
const stmt = db_1.default.prepare(`
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
140
|
+
INSERT INTO reports
|
|
141
|
+
(project, score, grade, analyzed_at, static_json, runtime_json, suggestions)
|
|
142
|
+
VALUES
|
|
143
|
+
(?, ?, ?, ?, ?, ?, ?)
|
|
144
|
+
`);
|
|
145
|
+
const result = stmt.run(body.projectName, body.performanceScore, grade, body.analyzedAt, JSON.stringify(body.static), JSON.stringify(cleanedRuntime), JSON.stringify(body.suggestions));
|
|
146
|
+
const reportId = result.lastInsertRowid;
|
|
147
|
+
// ── Save screenshots with final filenames ────────────────
|
|
148
|
+
const savedScreenshots = saveScreenshots(reportId, pendingScreenshots);
|
|
149
|
+
// ── Patch runtime_json with final screenshot paths ───────
|
|
150
|
+
// Now that we have the reportId we can write the correct paths.
|
|
151
|
+
if (savedScreenshots.length > 0) {
|
|
152
|
+
const patchedRuntime = patchScreenshotPaths(cleanedRuntime, savedScreenshots);
|
|
153
|
+
db_1.default.prepare("UPDATE reports SET runtime_json = ? WHERE id = ?").run(JSON.stringify(patchedRuntime), reportId);
|
|
154
|
+
}
|
|
62
155
|
res.status(201).json({
|
|
63
156
|
message: "Report saved successfully",
|
|
64
|
-
id:
|
|
157
|
+
id: reportId,
|
|
158
|
+
screenshots: savedScreenshots.length,
|
|
65
159
|
});
|
|
66
160
|
}
|
|
67
161
|
catch (err) {
|
|
68
|
-
console.error("
|
|
162
|
+
console.error("POST /upload error:", err.message);
|
|
69
163
|
res.status(500).json({ error: "Internal server error" });
|
|
70
164
|
}
|
|
71
165
|
});
|
|
166
|
+
// ── GET /api/reports/:id ─────────────────────────────────────
|
|
167
|
+
// Full report for one run — parses all three JSON columns back
|
|
168
|
+
// to objects and returns a unified response.
|
|
72
169
|
router.get("/:id", (req, res) => {
|
|
73
170
|
try {
|
|
74
171
|
const row = db_1.default.prepare("SELECT * FROM reports WHERE id = ?").get(req.params.id);
|
|
@@ -83,11 +180,104 @@ router.get("/:id", (req, res) => {
|
|
|
83
180
|
grade: row.grade,
|
|
84
181
|
analyzedAt: row.analyzed_at,
|
|
85
182
|
createdAt: row.created_at,
|
|
86
|
-
|
|
183
|
+
// Parse the three JSON blobs back into objects
|
|
184
|
+
static: JSON.parse(row.static_json),
|
|
185
|
+
runtime: JSON.parse(row.runtime_json),
|
|
186
|
+
suggestions: JSON.parse(row.suggestions),
|
|
87
187
|
});
|
|
88
188
|
}
|
|
89
189
|
catch (err) {
|
|
190
|
+
console.error("GET /:id error:", err.message);
|
|
90
191
|
res.status(500).json({ error: "Internal server error" });
|
|
91
192
|
}
|
|
92
193
|
});
|
|
93
194
|
exports.default = router;
|
|
195
|
+
// ─────────────────────────────────────────────────────────────
|
|
196
|
+
// HELPERS
|
|
197
|
+
// ─────────────────────────────────────────────────────────────
|
|
198
|
+
function getMissingFields(body) {
|
|
199
|
+
const required = ["projectName", "analyzedAt", "performanceScore", "static", "runtime", "suggestions"];
|
|
200
|
+
return required.filter(f => body?.[f] === undefined || body?.[f] === null);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Walk the runtime map, strip every screenshot.dataUrl,
|
|
204
|
+
* and collect them as Buffers ready to write to disk.
|
|
205
|
+
*
|
|
206
|
+
* Returns:
|
|
207
|
+
* cleanedRuntime — runtime map with dataUrls replaced by tempPath markers
|
|
208
|
+
* pendingScreenshots — list of screenshots to save once we have a reportId
|
|
209
|
+
*/
|
|
210
|
+
function extractScreenshots(runtime) {
|
|
211
|
+
const pending = [];
|
|
212
|
+
const cleaned = {};
|
|
213
|
+
for (const [routeKey, routeData] of Object.entries(runtime)) {
|
|
214
|
+
const routeClone = { ...routeData };
|
|
215
|
+
if (Array.isArray(routeClone.screenshots)) {
|
|
216
|
+
routeClone.screenshots = routeClone.screenshots.map((shot) => {
|
|
217
|
+
if (!shot.dataUrl || !shot.dataUrl.startsWith("data:image/png;base64,")) {
|
|
218
|
+
return shot;
|
|
219
|
+
}
|
|
220
|
+
const base64 = shot.dataUrl.replace("data:image/png;base64,", "");
|
|
221
|
+
const buffer = Buffer.from(base64, "base64");
|
|
222
|
+
// Sanitise routeKey for use in a filename — replace "/" and ":" with "-"
|
|
223
|
+
const safeRoute = routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
|
|
224
|
+
const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
|
|
225
|
+
const tempPath = `__PENDING__${safeRoute}__${safeLabel}`;
|
|
226
|
+
pending.push({ routeKey, label: shot.label, buffer, tempPath });
|
|
227
|
+
return { ...shot, dataUrl: tempPath };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
cleaned[routeKey] = routeClone;
|
|
231
|
+
}
|
|
232
|
+
return { cleanedRuntime: cleaned, pendingScreenshots: pending };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Write each screenshot buffer to data/screenshots/<reportId>-<route>-<label>.png
|
|
236
|
+
* Returns the list of saved files with their final URL paths.
|
|
237
|
+
*/
|
|
238
|
+
function saveScreenshots(reportId, pending) {
|
|
239
|
+
const saved = [];
|
|
240
|
+
for (const shot of pending) {
|
|
241
|
+
const safeRoute = shot.routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
|
|
242
|
+
const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
|
|
243
|
+
const filename = `${reportId}-${safeRoute}-${safeLabel}.png`;
|
|
244
|
+
const fullPath = path_1.default.join(db_1.screenshotsDir, filename);
|
|
245
|
+
try {
|
|
246
|
+
fs_1.default.writeFileSync(fullPath, shot.buffer);
|
|
247
|
+
saved.push({
|
|
248
|
+
routeKey: shot.routeKey,
|
|
249
|
+
label: shot.label,
|
|
250
|
+
tempPath: shot.tempPath,
|
|
251
|
+
filePath: `/screenshots/${filename}`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
console.warn(`Could not save screenshot ${filename}: ${err.message}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return saved;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Replace the __PENDING__ markers in runtime_json with
|
|
262
|
+
* the final /screenshots/<file> URL paths.
|
|
263
|
+
*/
|
|
264
|
+
function patchScreenshotPaths(runtime, saved) {
|
|
265
|
+
// Build a lookup from tempPath → filePath
|
|
266
|
+
const lookup = {};
|
|
267
|
+
for (const s of saved)
|
|
268
|
+
lookup[s.tempPath] = s.filePath;
|
|
269
|
+
const patched = {};
|
|
270
|
+
for (const [routeKey, routeData] of Object.entries(runtime)) {
|
|
271
|
+
const routeClone = { ...routeData };
|
|
272
|
+
if (Array.isArray(routeClone.screenshots)) {
|
|
273
|
+
routeClone.screenshots = routeClone.screenshots.map((shot) => {
|
|
274
|
+
if (shot.dataUrl && lookup[shot.dataUrl]) {
|
|
275
|
+
return { ...shot, dataUrl: lookup[shot.dataUrl] };
|
|
276
|
+
}
|
|
277
|
+
return shot;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
patched[routeKey] = routeClone;
|
|
281
|
+
}
|
|
282
|
+
return patched;
|
|
283
|
+
}
|
package/backend/src/db.ts
CHANGED
|
@@ -1,24 +1,70 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// backend/src/db.ts
|
|
3
|
+
//
|
|
4
|
+
// SQLite database via better-sqlite3 (synchronous, fast).
|
|
5
|
+
//
|
|
6
|
+
// SCHEMA:
|
|
7
|
+
//
|
|
8
|
+
// reports — one row per analysis run
|
|
9
|
+
// id — auto-increment PK
|
|
10
|
+
// project — project name / hostname
|
|
11
|
+
// score — 0-100 overall performance score
|
|
12
|
+
// grade — A+, A, B, C, D, F
|
|
13
|
+
// analyzed_at — ISO timestamp from the CLI
|
|
14
|
+
// created_at — when the row was inserted
|
|
15
|
+
// static_json — full StaticReport as JSON string
|
|
16
|
+
// runtime_json — full RuntimeReport map as JSON string
|
|
17
|
+
// (screenshots stripped — stored as files)
|
|
18
|
+
// suggestions — full Suggestion[] as JSON string
|
|
19
|
+
//
|
|
20
|
+
// WHY SPLIT COLUMNS INSTEAD OF ONE payload COLUMN?
|
|
21
|
+
// The old schema stored everything in a single `payload` TEXT
|
|
22
|
+
// column. This meant the dashboard couldn't query individual
|
|
23
|
+
// fields without parsing the whole blob. Splitting into three
|
|
24
|
+
// typed columns lets us:
|
|
25
|
+
// • SELECT only the part we need (e.g. just static_json for
|
|
26
|
+
// the issues page)
|
|
27
|
+
// • Keep the list endpoint fast (no giant blobs per row)
|
|
28
|
+
// • Add indices on score/grade later without changing schema
|
|
29
|
+
//
|
|
30
|
+
// SCREENSHOT STORAGE:
|
|
31
|
+
// Screenshots are base64 PNGs — up to 200KB each. Storing them
|
|
32
|
+
// in SQLite bloats the DB and makes every query slower. Instead:
|
|
33
|
+
// • The upload route strips dataUrls from runtime_json
|
|
34
|
+
// • Saves each screenshot as a .png file in data/screenshots/
|
|
35
|
+
// • Replaces dataUrl with a relative path /screenshots/<file>
|
|
36
|
+
// The dashboard fetches screenshots via the static file route.
|
|
37
|
+
// ─────────────────────────────────────────────────────────────
|
|
1
38
|
|
|
2
39
|
import Database from "better-sqlite3";
|
|
3
|
-
import path
|
|
4
|
-
import fs
|
|
5
|
-
import dotenv
|
|
40
|
+
import path from "path";
|
|
41
|
+
import fs from "fs";
|
|
42
|
+
import dotenv from "dotenv";
|
|
6
43
|
|
|
7
|
-
dotenv.config();
|
|
44
|
+
dotenv.config({ path: path.join(__dirname, "..", ".env") });
|
|
45
|
+
|
|
46
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
|
|
47
|
+
const DEFAULT_DB = path.join(PACKAGE_ROOT, "backend", "data", "reports.db");
|
|
48
|
+
const dbPath = process.env.DB_PATH || DEFAULT_DB;
|
|
8
49
|
|
|
9
|
-
const dbPath = process.env.DB_PATH || "./data/reports.db";
|
|
10
50
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
51
|
+
fs.mkdirSync(path.join(path.dirname(dbPath), "screenshots"), { recursive: true });
|
|
52
|
+
|
|
11
53
|
const db = new Database(dbPath);
|
|
54
|
+
|
|
12
55
|
db.exec(`
|
|
13
|
-
CREATE TABLE IF NOT EXISTS reports (
|
|
14
|
-
id
|
|
15
|
-
project
|
|
16
|
-
score
|
|
17
|
-
grade
|
|
18
|
-
analyzed_at
|
|
19
|
-
created_at
|
|
20
|
-
|
|
21
|
-
|
|
56
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
57
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
58
|
+
project TEXT NOT NULL,
|
|
59
|
+
score INTEGER NOT NULL,
|
|
60
|
+
grade TEXT NOT NULL,
|
|
61
|
+
analyzed_at TEXT NOT NULL,
|
|
62
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
63
|
+
static_json TEXT NOT NULL,
|
|
64
|
+
runtime_json TEXT NOT NULL,
|
|
65
|
+
suggestions TEXT NOT NULL
|
|
66
|
+
);
|
|
22
67
|
`);
|
|
23
68
|
|
|
24
|
-
export
|
|
69
|
+
export const screenshotsDir = path.join(path.dirname(dbPath), "screenshots");
|
|
70
|
+
export default db;
|
package/backend/src/index.ts
CHANGED
|
@@ -1,49 +1,46 @@
|
|
|
1
|
-
import express, { Request, Response, NextFunction } from
|
|
2
|
-
import cors
|
|
3
|
-
import helmet
|
|
4
|
-
import dotenv
|
|
5
|
-
import
|
|
6
|
-
import
|
|
1
|
+
import express, { Request, Response, NextFunction } from "express";
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import helmet from "helmet";
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import reportRoutes from "./routes/reports";
|
|
7
|
+
import db, { screenshotsDir } from "./db";
|
|
7
8
|
|
|
8
|
-
dotenv.config();
|
|
9
|
+
dotenv.config({ path: path.join(__dirname, "..", ".env") });
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
console.log(`[Debug] API_KEY loaded: ${process.env.API_KEY ? '✓' : '✗'}`);
|
|
12
|
-
console.log(`[Debug] API_KEY value: ${process.env.API_KEY || 'NOT SET'}`);
|
|
13
|
-
|
|
14
|
-
const app = express();
|
|
11
|
+
const app = express();
|
|
15
12
|
const PORT = process.env.PORT || 3000;
|
|
16
13
|
|
|
17
|
-
//
|
|
18
|
-
app.use(helmet());
|
|
19
|
-
app.use(cors());
|
|
20
|
-
app.use(express.json({ limit:
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// ─── HEALTH CHECK - NO AUTH, USED TO VERIFY SERVER IS UP ──────────────────────
|
|
27
|
-
app.get('/health', (_req: Request, res: Response) => {
|
|
28
|
-
res.json({
|
|
29
|
-
status: 'ok',
|
|
30
|
-
timestamp: new Date().toISOString()
|
|
31
|
-
});
|
|
32
|
-
});
|
|
14
|
+
// ── Middleware ────────────────────────────────────────────────
|
|
15
|
+
app.use(helmet());
|
|
16
|
+
app.use(cors());
|
|
17
|
+
app.use(express.json({ limit: "50mb" }));
|
|
18
|
+
|
|
19
|
+
// ── Static: screenshots saved by the upload route ────────────
|
|
20
|
+
// Served at /screenshots/<filename>
|
|
21
|
+
// The dashboard uses these as <img src="/screenshots/...">
|
|
22
|
+
app.use("/screenshots", express.static(screenshotsDir));
|
|
33
23
|
|
|
24
|
+
// ── Routes ────────────────────────────────────────────────────
|
|
25
|
+
app.use("/api/reports", reportRoutes);
|
|
34
26
|
|
|
35
|
-
//
|
|
36
|
-
app.
|
|
37
|
-
res.
|
|
27
|
+
// ── Health check ──────────────────────────────────────────────
|
|
28
|
+
app.get("/health", (_req: Request, res: Response) => {
|
|
29
|
+
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
38
30
|
});
|
|
39
31
|
|
|
40
|
-
//
|
|
32
|
+
// ── 404 ───────────────────────────────────────────────────────
|
|
33
|
+
app.use((_req: Request, res: Response) => {
|
|
34
|
+
res.status(404).json({ message: "Route not found" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── Error handler ─────────────────────────────────────────────
|
|
41
38
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
|
42
39
|
console.error(err.stack);
|
|
43
|
-
res.status(500).json({ message:
|
|
40
|
+
res.status(500).json({ message: "Internal Server Error" });
|
|
44
41
|
});
|
|
45
42
|
|
|
46
|
-
//
|
|
43
|
+
// ── Start ─────────────────────────────────────────────────────
|
|
47
44
|
app.listen(PORT, () => {
|
|
48
45
|
console.log(`React Doctor backend running on http://localhost:${PORT}`);
|
|
49
|
-
});
|
|
46
|
+
});
|
|
@@ -1,110 +1,328 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// backend/src/routes/reports.ts
|
|
3
|
+
//
|
|
4
|
+
// All report endpoints.
|
|
5
|
+
//
|
|
6
|
+
// ENDPOINTS:
|
|
7
|
+
//
|
|
8
|
+
// GET /api/reports
|
|
9
|
+
// Returns a summary list (no blobs) — fast for the dashboard
|
|
10
|
+
// history page. Each row has id, project, score, grade, dates.
|
|
11
|
+
//
|
|
12
|
+
// GET /api/reports/:id
|
|
13
|
+
// Returns the full report for one run — static + runtime +
|
|
14
|
+
// suggestions all parsed back to objects.
|
|
15
|
+
//
|
|
16
|
+
// GET /api/reports/project/:name
|
|
17
|
+
// All runs for a named project, summary only.
|
|
18
|
+
//
|
|
19
|
+
// POST /api/reports/upload (requires x-api-key header)
|
|
20
|
+
// Accepts a FinalReport from the CLI.
|
|
21
|
+
// Strips screenshot dataUrls → saves as .png files.
|
|
22
|
+
// Stores static_json, runtime_json, suggestions in DB.
|
|
23
|
+
//
|
|
24
|
+
// SCREENSHOT HANDLING ON UPLOAD:
|
|
25
|
+
// The CLI sends the full FinalReport including base64 screenshots
|
|
26
|
+
// (up to 200KB each). We extract those before storing so the DB
|
|
27
|
+
// stays lean. Each screenshot is saved as:
|
|
28
|
+
// data/screenshots/<reportId>-<routeKey>-<label>.png
|
|
29
|
+
// And the dataUrl in runtime_json is replaced with:
|
|
30
|
+
// /screenshots/<filename>
|
|
31
|
+
// so the dashboard can load them as normal <img> tags.
|
|
32
|
+
// ─────────────────────────────────────────────────────────────
|
|
1
33
|
|
|
2
34
|
import { Router, Request, Response, RequestHandler } from "express";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
35
|
+
import path from "path";
|
|
36
|
+
import fs from "fs";
|
|
37
|
+
import db, { screenshotsDir } from "../db";
|
|
38
|
+
import { requireApiKey } from "../middleware/auth";
|
|
39
|
+
|
|
6
40
|
const router = Router();
|
|
7
|
-
|
|
8
41
|
|
|
9
|
-
|
|
42
|
+
// ── GET /api/reports ─────────────────────────────────────────
|
|
43
|
+
// Summary list — no blobs, just the columns the history page needs.
|
|
44
|
+
|
|
45
|
+
router.get("/", (_req: Request, res: Response) => {
|
|
10
46
|
try {
|
|
11
47
|
const rows = db.prepare(`
|
|
12
48
|
SELECT id, project, score, grade, analyzed_at, created_at
|
|
13
|
-
FROM
|
|
14
|
-
ORDER
|
|
15
|
-
LIMIT
|
|
49
|
+
FROM reports
|
|
50
|
+
ORDER BY created_at DESC
|
|
51
|
+
LIMIT 100
|
|
16
52
|
`).all() as any[];
|
|
17
|
-
|
|
53
|
+
|
|
18
54
|
res.json({ count: rows.length, reports: rows });
|
|
19
55
|
} catch (err: any) {
|
|
56
|
+
console.error("GET / error:", err.message);
|
|
20
57
|
res.status(500).json({ error: "Internal server error" });
|
|
21
58
|
}
|
|
22
59
|
});
|
|
23
|
-
|
|
60
|
+
|
|
61
|
+
// ── GET /api/reports/project/:name ───────────────────────────
|
|
62
|
+
// All runs for one project, summary only.
|
|
24
63
|
|
|
25
64
|
router.get("/project/:name", (req: Request, res: Response) => {
|
|
26
65
|
try {
|
|
27
66
|
const rows = db.prepare(`
|
|
28
67
|
SELECT id, project, score, grade, analyzed_at, created_at
|
|
29
|
-
FROM
|
|
30
|
-
WHERE
|
|
31
|
-
ORDER
|
|
68
|
+
FROM reports
|
|
69
|
+
WHERE project = ?
|
|
70
|
+
ORDER BY created_at DESC
|
|
32
71
|
`).all(req.params.name) as any[];
|
|
33
|
-
|
|
34
|
-
res.json({
|
|
35
|
-
project: req.params.name,
|
|
36
|
-
count: rows.length,
|
|
37
|
-
reports: rows,
|
|
38
|
-
});
|
|
72
|
+
|
|
73
|
+
res.json({ project: req.params.name, count: rows.length, reports: rows });
|
|
39
74
|
} catch (err: any) {
|
|
75
|
+
console.error("GET /project/:name error:", err.message);
|
|
40
76
|
res.status(500).json({ error: "Internal server error" });
|
|
41
77
|
}
|
|
42
78
|
});
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
router.post(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
|
|
80
|
+
// ── POST /api/reports/upload ──────────────────────────────────
|
|
81
|
+
// Receives a FinalReport from the CLI, strips screenshots to disk,
|
|
82
|
+
// and stores the three JSON blobs in separate columns.
|
|
83
|
+
|
|
84
|
+
router.post(
|
|
85
|
+
"/upload",
|
|
86
|
+
requireApiKey as RequestHandler,
|
|
87
|
+
(req: Request, res: Response) => {
|
|
88
|
+
try {
|
|
89
|
+
const body = req.body;
|
|
90
|
+
|
|
91
|
+
// ── Validate required top-level fields ──────────────────
|
|
92
|
+
if (
|
|
93
|
+
!body ||
|
|
94
|
+
!body.projectName ||
|
|
95
|
+
!body.analyzedAt ||
|
|
96
|
+
body.performanceScore === undefined ||
|
|
97
|
+
!body.static ||
|
|
98
|
+
!body.runtime ||
|
|
99
|
+
!body.suggestions
|
|
100
|
+
) {
|
|
101
|
+
res.status(400).json({
|
|
102
|
+
error: "Invalid report",
|
|
103
|
+
missing: getMissingFields(body),
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Extract grade from static report ────────────────────
|
|
109
|
+
const grade: string = body.static?.grade ?? "N/A";
|
|
110
|
+
|
|
111
|
+
// ── Strip screenshots from runtime, save as .png files ──
|
|
112
|
+
// We do this BEFORE inserting so the DB never holds base64.
|
|
113
|
+
// The row ID isn't known yet — we'll rename after insert.
|
|
114
|
+
// For now we use a temp prefix and rename below.
|
|
115
|
+
const { cleanedRuntime, pendingScreenshots } = extractScreenshots(body.runtime);
|
|
116
|
+
|
|
117
|
+
// ── Insert the row ───────────────────────────────────────
|
|
118
|
+
const stmt = db.prepare(`
|
|
119
|
+
INSERT INTO reports
|
|
120
|
+
(project, score, grade, analyzed_at, static_json, runtime_json, suggestions)
|
|
121
|
+
VALUES
|
|
122
|
+
(?, ?, ?, ?, ?, ?, ?)
|
|
123
|
+
`);
|
|
124
|
+
|
|
125
|
+
const result = stmt.run(
|
|
126
|
+
body.projectName,
|
|
127
|
+
body.performanceScore,
|
|
128
|
+
grade,
|
|
129
|
+
body.analyzedAt,
|
|
130
|
+
JSON.stringify(body.static),
|
|
131
|
+
JSON.stringify(cleanedRuntime),
|
|
132
|
+
JSON.stringify(body.suggestions),
|
|
133
|
+
) as any;
|
|
134
|
+
|
|
135
|
+
const reportId: number = result.lastInsertRowid;
|
|
136
|
+
|
|
137
|
+
// ── Save screenshots with final filenames ────────────────
|
|
138
|
+
const savedScreenshots = saveScreenshots(reportId, pendingScreenshots);
|
|
139
|
+
|
|
140
|
+
// ── Patch runtime_json with final screenshot paths ───────
|
|
141
|
+
// Now that we have the reportId we can write the correct paths.
|
|
142
|
+
if (savedScreenshots.length > 0) {
|
|
143
|
+
const patchedRuntime = patchScreenshotPaths(
|
|
144
|
+
cleanedRuntime,
|
|
145
|
+
savedScreenshots,
|
|
146
|
+
);
|
|
147
|
+
db.prepare(
|
|
148
|
+
"UPDATE reports SET runtime_json = ? WHERE id = ?"
|
|
149
|
+
).run(JSON.stringify(patchedRuntime), reportId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
res.status(201).json({
|
|
153
|
+
message: "Report saved successfully",
|
|
154
|
+
id: reportId,
|
|
155
|
+
screenshots: savedScreenshots.length,
|
|
156
|
+
});
|
|
157
|
+
} catch (err: any) {
|
|
158
|
+
console.error("POST /upload error:", err.message);
|
|
159
|
+
res.status(500).json({ error: "Internal server error" });
|
|
56
160
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
`);
|
|
65
|
-
|
|
66
|
-
// تنفيذ الاستعلام وحفظ جسم التقرير كـ string
|
|
67
|
-
const result = stmt.run(
|
|
68
|
-
report.projectName,
|
|
69
|
-
report.performanceScore,
|
|
70
|
-
grade,
|
|
71
|
-
report.analyzedAt,
|
|
72
|
-
JSON.stringify(report)
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
res.status(201).json({
|
|
76
|
-
message: "Report saved successfully",
|
|
77
|
-
id: result.lastInsertRowid,
|
|
78
|
-
});
|
|
79
|
-
} catch (err: any) {
|
|
80
|
-
console.error("Upload error:", err.message);
|
|
81
|
-
res.status(500).json({ error: "Internal server error" });
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ── GET /api/reports/:id ─────────────────────────────────────
|
|
165
|
+
// Full report for one run — parses all three JSON columns back
|
|
166
|
+
// to objects and returns a unified response.
|
|
167
|
+
|
|
85
168
|
router.get("/:id", (req: Request, res: Response) => {
|
|
86
169
|
try {
|
|
87
170
|
const row = db.prepare(
|
|
88
171
|
"SELECT * FROM reports WHERE id = ?"
|
|
89
172
|
).get(req.params.id) as any;
|
|
90
|
-
|
|
173
|
+
|
|
91
174
|
if (!row) {
|
|
92
175
|
res.status(404).json({ error: "Report not found" });
|
|
93
176
|
return;
|
|
94
177
|
}
|
|
95
|
-
|
|
178
|
+
|
|
96
179
|
res.json({
|
|
97
|
-
id:
|
|
98
|
-
project:
|
|
99
|
-
score:
|
|
100
|
-
grade:
|
|
101
|
-
analyzedAt:
|
|
102
|
-
createdAt:
|
|
103
|
-
|
|
180
|
+
id: row.id,
|
|
181
|
+
project: row.project,
|
|
182
|
+
score: row.score,
|
|
183
|
+
grade: row.grade,
|
|
184
|
+
analyzedAt: row.analyzed_at,
|
|
185
|
+
createdAt: row.created_at,
|
|
186
|
+
// Parse the three JSON blobs back into objects
|
|
187
|
+
static: JSON.parse(row.static_json),
|
|
188
|
+
runtime: JSON.parse(row.runtime_json),
|
|
189
|
+
suggestions: JSON.parse(row.suggestions),
|
|
104
190
|
});
|
|
105
191
|
} catch (err: any) {
|
|
192
|
+
console.error("GET /:id error:", err.message);
|
|
106
193
|
res.status(500).json({ error: "Internal server error" });
|
|
107
194
|
}
|
|
108
195
|
});
|
|
109
|
-
|
|
110
|
-
export default router;
|
|
196
|
+
|
|
197
|
+
export default router;
|
|
198
|
+
|
|
199
|
+
// ─────────────────────────────────────────────────────────────
|
|
200
|
+
// HELPERS
|
|
201
|
+
// ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function getMissingFields(body: any): string[] {
|
|
204
|
+
const required = ["projectName", "analyzedAt", "performanceScore", "static", "runtime", "suggestions"];
|
|
205
|
+
return required.filter(f => body?.[f] === undefined || body?.[f] === null);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface PendingScreenshot {
|
|
209
|
+
routeKey: string;
|
|
210
|
+
label: string;
|
|
211
|
+
buffer: Buffer;
|
|
212
|
+
// placeholder path written into cleanedRuntime — replaced after insert
|
|
213
|
+
tempPath: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Walk the runtime map, strip every screenshot.dataUrl,
|
|
218
|
+
* and collect them as Buffers ready to write to disk.
|
|
219
|
+
*
|
|
220
|
+
* Returns:
|
|
221
|
+
* cleanedRuntime — runtime map with dataUrls replaced by tempPath markers
|
|
222
|
+
* pendingScreenshots — list of screenshots to save once we have a reportId
|
|
223
|
+
*/
|
|
224
|
+
function extractScreenshots(
|
|
225
|
+
runtime: Record<string, any>,
|
|
226
|
+
): { cleanedRuntime: Record<string, any>; pendingScreenshots: PendingScreenshot[] } {
|
|
227
|
+
const pending: PendingScreenshot[] = [];
|
|
228
|
+
const cleaned: Record<string, any> = {};
|
|
229
|
+
|
|
230
|
+
for (const [routeKey, routeData] of Object.entries(runtime)) {
|
|
231
|
+
const routeClone = { ...routeData };
|
|
232
|
+
|
|
233
|
+
if (Array.isArray(routeClone.screenshots)) {
|
|
234
|
+
routeClone.screenshots = routeClone.screenshots.map((shot: any) => {
|
|
235
|
+
if (!shot.dataUrl || !shot.dataUrl.startsWith("data:image/png;base64,")) {
|
|
236
|
+
return shot;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const base64 = shot.dataUrl.replace("data:image/png;base64,", "");
|
|
240
|
+
const buffer = Buffer.from(base64, "base64");
|
|
241
|
+
|
|
242
|
+
// Sanitise routeKey for use in a filename — replace "/" and ":" with "-"
|
|
243
|
+
const safeRoute = routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
|
|
244
|
+
const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
|
|
245
|
+
const tempPath = `__PENDING__${safeRoute}__${safeLabel}`;
|
|
246
|
+
|
|
247
|
+
pending.push({ routeKey, label: shot.label, buffer, tempPath });
|
|
248
|
+
|
|
249
|
+
return { ...shot, dataUrl: tempPath };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
cleaned[routeKey] = routeClone;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { cleanedRuntime: cleaned, pendingScreenshots: pending };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface SavedScreenshot {
|
|
260
|
+
routeKey: string;
|
|
261
|
+
label: string;
|
|
262
|
+
tempPath: string;
|
|
263
|
+
filePath: string; // relative URL path served by express static
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Write each screenshot buffer to data/screenshots/<reportId>-<route>-<label>.png
|
|
268
|
+
* Returns the list of saved files with their final URL paths.
|
|
269
|
+
*/
|
|
270
|
+
function saveScreenshots(
|
|
271
|
+
reportId: number,
|
|
272
|
+
pending: PendingScreenshot[],
|
|
273
|
+
): SavedScreenshot[] {
|
|
274
|
+
const saved: SavedScreenshot[] = [];
|
|
275
|
+
|
|
276
|
+
for (const shot of pending) {
|
|
277
|
+
const safeRoute = shot.routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
|
|
278
|
+
const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
|
|
279
|
+
const filename = `${reportId}-${safeRoute}-${safeLabel}.png`;
|
|
280
|
+
const fullPath = path.join(screenshotsDir, filename);
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
fs.writeFileSync(fullPath, shot.buffer);
|
|
284
|
+
saved.push({
|
|
285
|
+
routeKey: shot.routeKey,
|
|
286
|
+
label: shot.label,
|
|
287
|
+
tempPath: shot.tempPath,
|
|
288
|
+
filePath: `/screenshots/${filename}`,
|
|
289
|
+
});
|
|
290
|
+
} catch (err: any) {
|
|
291
|
+
console.warn(`Could not save screenshot ${filename}: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return saved;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Replace the __PENDING__ markers in runtime_json with
|
|
300
|
+
* the final /screenshots/<file> URL paths.
|
|
301
|
+
*/
|
|
302
|
+
function patchScreenshotPaths(
|
|
303
|
+
runtime: Record<string, any>,
|
|
304
|
+
saved: SavedScreenshot[],
|
|
305
|
+
): Record<string, any> {
|
|
306
|
+
// Build a lookup from tempPath → filePath
|
|
307
|
+
const lookup: Record<string, string> = {};
|
|
308
|
+
for (const s of saved) lookup[s.tempPath] = s.filePath;
|
|
309
|
+
|
|
310
|
+
const patched: Record<string, any> = {};
|
|
311
|
+
|
|
312
|
+
for (const [routeKey, routeData] of Object.entries(runtime)) {
|
|
313
|
+
const routeClone = { ...routeData };
|
|
314
|
+
|
|
315
|
+
if (Array.isArray(routeClone.screenshots)) {
|
|
316
|
+
routeClone.screenshots = routeClone.screenshots.map((shot: any) => {
|
|
317
|
+
if (shot.dataUrl && lookup[shot.dataUrl]) {
|
|
318
|
+
return { ...shot, dataUrl: lookup[shot.dataUrl] };
|
|
319
|
+
}
|
|
320
|
+
return shot;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
patched[routeKey] = routeClone;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return patched;
|
|
328
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-doctor-cli-dev",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "React performance analyzer with static analysis, runtime profiling, rule engine, and dashboard upload",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
7
|
-
|
|
8
7
|
"bin": {
|
|
9
8
|
"react-doctor": "./cli/bin/react-doctor.js"
|
|
10
9
|
},
|
|
11
|
-
|
|
12
10
|
"scripts": {},
|
|
13
|
-
|
|
14
11
|
"keywords": [
|
|
15
12
|
"react",
|
|
16
13
|
"performance",
|
|
@@ -18,10 +15,8 @@
|
|
|
18
15
|
"cli",
|
|
19
16
|
"analyzer"
|
|
20
17
|
],
|
|
21
|
-
|
|
22
18
|
"author": "Ozma",
|
|
23
19
|
"license": "ISC",
|
|
24
|
-
|
|
25
20
|
"dependencies": {
|
|
26
21
|
"@babel/parser": "^7.29.0",
|
|
27
22
|
"@babel/traverse": "^7.29.0",
|
|
@@ -42,7 +37,6 @@
|
|
|
42
37
|
"typescript": "^5.0.0",
|
|
43
38
|
"web-vitals": "^5.1.0"
|
|
44
39
|
},
|
|
45
|
-
|
|
46
40
|
"devDependencies": {
|
|
47
41
|
"@types/babel__traverse": "^7.28.0",
|
|
48
42
|
"@types/better-sqlite3": "^7.6.13",
|
|
@@ -52,4 +46,4 @@
|
|
|
52
46
|
"@types/node": "^25.9.1",
|
|
53
47
|
"nodemon": "^3.1.14"
|
|
54
48
|
}
|
|
55
|
-
}
|
|
49
|
+
}
|
|
Binary file
|