4bnode 4.0.3 → 4.0.4
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/README.md +18 -0
- package/add-crud.js +1 -1
- package/add-mongo-update.js +1 -1
- package/index.js +4 -6
- package/lib/ui.js +0 -1
- package/package.json +2 -2
- package/skeleton/.4bnode/assets/js/App.js +4 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +3 -1
- package/skeleton/.4bnode/assets/js/lib.js +20 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +55 -36
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +205 -0
- package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +667 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +176 -147
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +131 -4
- package/skeleton/.4bnode/dev-api.js +774 -39
|
@@ -3,11 +3,51 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { execSync, exec } from 'child_process';
|
|
5
5
|
import { promisify } from 'util';
|
|
6
|
+
import { pathToFileURL } from 'url';
|
|
6
7
|
const execAsync = promisify(exec);
|
|
7
8
|
import crypto from 'crypto';
|
|
8
9
|
|
|
9
10
|
const router = express.Router();
|
|
10
11
|
|
|
12
|
+
// ── Console Log Capture ─────────────────────────────────────────────────────
|
|
13
|
+
const _logBuffer = [];
|
|
14
|
+
const _logMaxSize = 500;
|
|
15
|
+
const _logClients = new Set();
|
|
16
|
+
|
|
17
|
+
function pushLog(type, args) {
|
|
18
|
+
const entry = {
|
|
19
|
+
id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
|
20
|
+
type,
|
|
21
|
+
message: args.map(a => (typeof a === 'string' ? a : JSON.stringify(a, null, 2))).join(' '),
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
};
|
|
24
|
+
_logBuffer.push(entry);
|
|
25
|
+
if (_logBuffer.length > _logMaxSize) _logBuffer.splice(0, _logBuffer.length - _logMaxSize);
|
|
26
|
+
for (const client of _logClients) {
|
|
27
|
+
client.write(`data: ${JSON.stringify(entry)}\n\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const _origLog = console.log.bind(console);
|
|
32
|
+
const _origError = console.error.bind(console);
|
|
33
|
+
const _origWarn = console.warn.bind(console);
|
|
34
|
+
const _origInfo = console.info.bind(console);
|
|
35
|
+
|
|
36
|
+
console.log = (...args) => { _origLog(...args); pushLog('log', args); };
|
|
37
|
+
console.error = (...args) => { _origError(...args); pushLog('error', args); };
|
|
38
|
+
console.warn = (...args) => { _origWarn(...args); pushLog('warn', args); };
|
|
39
|
+
console.info = (...args) => { _origInfo(...args); pushLog('info', args); };
|
|
40
|
+
|
|
41
|
+
// Capture uncaught errors
|
|
42
|
+
process.on('uncaughtException', (err) => {
|
|
43
|
+
pushLog('error', [`Uncaught Exception: ${err.stack || err.message}`]);
|
|
44
|
+
_origError('Uncaught Exception:', err);
|
|
45
|
+
});
|
|
46
|
+
process.on('unhandledRejection', (reason) => {
|
|
47
|
+
pushLog('error', [`Unhandled Rejection: ${reason?.stack || reason}`]);
|
|
48
|
+
_origError('Unhandled Rejection:', reason);
|
|
49
|
+
});
|
|
50
|
+
|
|
11
51
|
// Serve dashboard UI and assets
|
|
12
52
|
const __4bnodeDir = path.join(process.cwd(), '.4bnode');
|
|
13
53
|
router.get('/', (req, res) => {
|
|
@@ -40,6 +80,8 @@ router.post('/api/auth', (req, res) => {
|
|
|
40
80
|
// Auth middleware — protect all /api/* routes below
|
|
41
81
|
router.use('/api', (req, res, next) => {
|
|
42
82
|
if (req.path === '/auth') return next();
|
|
83
|
+
// SSE endpoint handles its own auth via query param (EventSource can't send headers)
|
|
84
|
+
if (req.path === '/logs/stream') return next();
|
|
43
85
|
const stored = getPasskeyHash();
|
|
44
86
|
if (!stored) return next();
|
|
45
87
|
const passkey = req.headers['x-passkey'];
|
|
@@ -133,12 +175,12 @@ function getModelSchemaDetailed(modelName) {
|
|
|
133
175
|
modelName + ".js"
|
|
134
176
|
);
|
|
135
177
|
if (!fs.existsSync(modelFilePath)) {
|
|
136
|
-
return [];
|
|
178
|
+
return { fields: [], indexes: [] };
|
|
137
179
|
}
|
|
138
180
|
|
|
139
181
|
const content = fs.readFileSync(modelFilePath, "utf8");
|
|
140
182
|
const schemaContent = extractSchemaBlock(content);
|
|
141
|
-
if (!schemaContent) return [];
|
|
183
|
+
if (!schemaContent) return { fields: [], indexes: [] };
|
|
142
184
|
const fields = [];
|
|
143
185
|
// Match each field block: fieldName: { type: ..., required: ..., unique: ..., default: ... }
|
|
144
186
|
const fieldRegex = /(\w+)\s*:\s*\{([^}]*)\}/g;
|
|
@@ -152,16 +194,41 @@ function getModelSchemaDetailed(modelName) {
|
|
|
152
194
|
: "String";
|
|
153
195
|
const required = /required:\s*true/.test(props);
|
|
154
196
|
const unique = /unique:\s*true/.test(props);
|
|
197
|
+
const indexed = /index:\s*true/.test(props);
|
|
155
198
|
const refMatch = props.match(/ref:\s*['"](\w+)['"]/);
|
|
156
199
|
const defaultMatch = props.match(/default:\s*(.+?)(?:,\s*\w+:|$)/);
|
|
157
200
|
const field = { name, type, required, unique };
|
|
201
|
+
if (indexed) field.index = true;
|
|
158
202
|
if (refMatch) field.ref = refMatch[1];
|
|
159
203
|
if (defaultMatch) {
|
|
160
204
|
field.default = defaultMatch[1].trim();
|
|
161
205
|
}
|
|
162
206
|
fields.push(field);
|
|
163
207
|
}
|
|
164
|
-
|
|
208
|
+
|
|
209
|
+
// Parse compound/text indexes: SchemaName.index({ ... })
|
|
210
|
+
const indexes = [];
|
|
211
|
+
const indexRegex = /\w+Schema\.index\(\s*\{([^}]+)\}(?:\s*,\s*\{([^}]*)\})?\s*\)/g;
|
|
212
|
+
let idxMatch;
|
|
213
|
+
while ((idxMatch = indexRegex.exec(content)) !== null) {
|
|
214
|
+
const fieldsStr = idxMatch[1];
|
|
215
|
+
const optsStr = idxMatch[2] || '';
|
|
216
|
+
const idxFields = [];
|
|
217
|
+
const fieldEntries = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
218
|
+
for (const entry of fieldEntries) {
|
|
219
|
+
const [key, val] = entry.split(':').map(s => s.trim());
|
|
220
|
+
if (val === "'text'" || val === '"text"') {
|
|
221
|
+
idxFields.push({ field: key, direction: 'text' });
|
|
222
|
+
} else {
|
|
223
|
+
idxFields.push({ field: key, direction: parseInt(val) || 1 });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const isUnique = /unique:\s*true/.test(optsStr);
|
|
227
|
+
const isText = idxFields.some(f => f.direction === 'text');
|
|
228
|
+
indexes.push({ fields: idxFields, unique: isUnique, type: isText ? 'text' : 'compound' });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { fields, indexes };
|
|
165
232
|
}
|
|
166
233
|
|
|
167
234
|
function getModelSchema(modelName) {
|
|
@@ -323,7 +390,7 @@ ${assignments}
|
|
|
323
390
|
};
|
|
324
391
|
${passwordLogic}
|
|
325
392
|
try {
|
|
326
|
-
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, {
|
|
393
|
+
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { returnDocument: 'after' });
|
|
327
394
|
if (!updatedDocument) {
|
|
328
395
|
return res.status(404).json({ message: 'Document not found' });
|
|
329
396
|
}
|
|
@@ -355,23 +422,35 @@ router.delete('/:id', async (req, res) => {
|
|
|
355
422
|
// Schema file generation helper
|
|
356
423
|
// ---------------------------------------------------------------------------
|
|
357
424
|
|
|
358
|
-
function generateSchemaFileContent(pascalName, fields) {
|
|
425
|
+
function generateSchemaFileContent(pascalName, fields, indexes = []) {
|
|
359
426
|
const fieldLines = fields.map((f) => {
|
|
360
427
|
const type = f.type || 'String';
|
|
361
428
|
const parts = [type === 'ObjectId' ? 'type: mongoose.Schema.Types.ObjectId' : `type: ${type}`];
|
|
362
429
|
if (f.ref) parts.push(`ref: '${f.ref}'`);
|
|
363
430
|
if (f.required) parts.push('required: true');
|
|
364
431
|
if (f.unique) parts.push('unique: true');
|
|
432
|
+
if (f.index) parts.push('index: true');
|
|
365
433
|
if (f.default !== undefined && f.default !== '') parts.push(`default: ${f.default}`);
|
|
366
434
|
return ` ${f.name}: { ${parts.join(', ')} }`;
|
|
367
435
|
});
|
|
368
436
|
|
|
437
|
+
// Add index lines
|
|
438
|
+
let indexLines = '';
|
|
439
|
+
for (const idx of indexes) {
|
|
440
|
+
const fieldObj = idx.fields.map(f => {
|
|
441
|
+
if (f.direction === 'text') return `${f.field}: 'text'`;
|
|
442
|
+
return `${f.field}: ${f.direction || 1}`;
|
|
443
|
+
}).join(', ');
|
|
444
|
+
const options = idx.unique ? ', { unique: true }' : '';
|
|
445
|
+
indexLines += `\n${pascalName}Schema.index({ ${fieldObj} }${options});`;
|
|
446
|
+
}
|
|
447
|
+
|
|
369
448
|
return `import mongoose from 'mongoose';
|
|
370
449
|
|
|
371
450
|
const ${pascalName}Schema = new mongoose.Schema({
|
|
372
451
|
${fieldLines.join(',\n')}
|
|
373
452
|
}, { timestamps: true });
|
|
374
|
-
|
|
453
|
+
${indexLines}
|
|
375
454
|
const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
|
|
376
455
|
|
|
377
456
|
export default ${pascalName};
|
|
@@ -388,8 +467,8 @@ router.get('/api/dashboard', (req, res) => {
|
|
|
388
467
|
const modelFiles = listModels();
|
|
389
468
|
const models = modelFiles.map((f) => {
|
|
390
469
|
const name = path.basename(f, '.js');
|
|
391
|
-
const fields = getModelSchemaDetailed(name);
|
|
392
|
-
return { name, fields };
|
|
470
|
+
const { fields, indexes } = getModelSchemaDetailed(name);
|
|
471
|
+
return { name, fields, indexes };
|
|
393
472
|
});
|
|
394
473
|
const routes = listRoutes();
|
|
395
474
|
const mongoConfigured = checkMongoConfig();
|
|
@@ -435,8 +514,8 @@ router.get('/api/models', (req, res) => {
|
|
|
435
514
|
router.get('/api/models/:name', (req, res) => {
|
|
436
515
|
try {
|
|
437
516
|
const name = req.params.name;
|
|
438
|
-
const fields = getModelSchemaDetailed(name);
|
|
439
|
-
res.json({ name, fields });
|
|
517
|
+
const { fields, indexes } = getModelSchemaDetailed(name);
|
|
518
|
+
res.json({ name, fields, indexes });
|
|
440
519
|
} catch (err) {
|
|
441
520
|
res.status(500).json({ error: err.message });
|
|
442
521
|
}
|
|
@@ -456,7 +535,7 @@ router.get('/api/models/:name/data', async (req, res) => {
|
|
|
456
535
|
if (!fs.existsSync(projectMongoose)) {
|
|
457
536
|
return res.status(400).json({ error: 'Mongoose is not installed. Set up MongoDB first.' });
|
|
458
537
|
}
|
|
459
|
-
const mongoose = (await import(projectMongoose)).default;
|
|
538
|
+
const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
|
|
460
539
|
|
|
461
540
|
// Connect if not already connected
|
|
462
541
|
if (mongoose.connection.readyState === 0) {
|
|
@@ -482,7 +561,7 @@ router.get('/api/models/:name/data', async (req, res) => {
|
|
|
482
561
|
router.get('/api/models/:name/share', (req, res) => {
|
|
483
562
|
try {
|
|
484
563
|
const name = req.params.name;
|
|
485
|
-
const fields = getModelSchemaDetailed(name);
|
|
564
|
+
const { fields } = getModelSchemaDetailed(name);
|
|
486
565
|
const routes = listRoutes();
|
|
487
566
|
const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
|
|
488
567
|
|
|
@@ -519,7 +598,7 @@ router.post('/api/models', (req, res) => {
|
|
|
519
598
|
return res.status(400).json({ error: 'Database must be configured before creating schemas. Set up MongoDB first.' });
|
|
520
599
|
}
|
|
521
600
|
|
|
522
|
-
const { name, fields } = req.body;
|
|
601
|
+
const { name, fields, indexes } = req.body;
|
|
523
602
|
const safeName = sanitizeName(name);
|
|
524
603
|
const pascalName = toPascalCase(safeName);
|
|
525
604
|
|
|
@@ -533,7 +612,7 @@ router.post('/api/models', (req, res) => {
|
|
|
533
612
|
return res.status(409).json({ error: `Model "${safeName}" already exists.` });
|
|
534
613
|
}
|
|
535
614
|
|
|
536
|
-
const content = generateSchemaFileContent(pascalName, fields);
|
|
615
|
+
const content = generateSchemaFileContent(pascalName, fields, indexes);
|
|
537
616
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
538
617
|
res.json({ message: `Model "${safeName}" created.`, file: `src/models/${safeName}.js` });
|
|
539
618
|
} catch (err) {
|
|
@@ -545,7 +624,7 @@ router.post('/api/models', (req, res) => {
|
|
|
545
624
|
router.put('/api/models/:name', (req, res) => {
|
|
546
625
|
try {
|
|
547
626
|
const name = req.params.name;
|
|
548
|
-
const { fields } = req.body;
|
|
627
|
+
const { fields, indexes } = req.body;
|
|
549
628
|
const pascalName = toPascalCase(name);
|
|
550
629
|
|
|
551
630
|
const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
|
|
@@ -553,7 +632,7 @@ router.put('/api/models/:name', (req, res) => {
|
|
|
553
632
|
return res.status(404).json({ error: `Model "${name}" not found.` });
|
|
554
633
|
}
|
|
555
634
|
|
|
556
|
-
const content = generateSchemaFileContent(pascalName, fields);
|
|
635
|
+
const content = generateSchemaFileContent(pascalName, fields, indexes);
|
|
557
636
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
558
637
|
res.json({ message: `Model "${name}" updated.`, file: `src/models/${name}.js` });
|
|
559
638
|
} catch (err) {
|
|
@@ -749,18 +828,56 @@ router.post('/api/routes/:name/add-method', async (req, res) => {
|
|
|
749
828
|
} else if (routeType === 'crud' && modelName) {
|
|
750
829
|
addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
|
|
751
830
|
const crudMethods = newMethods.length > 0 ? newMethods : ['get', 'post', 'put', 'delete'].filter(m => !existingMethods.includes(m));
|
|
831
|
+
const allCrudFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
|
|
832
|
+
const crudBodyFields = allCrudFields.filter(f => f.type !== 'File');
|
|
833
|
+
const crudFileFields = allCrudFields.filter(f => f.type === 'File');
|
|
834
|
+
const crudHasFiles = crudFileFields.length > 0;
|
|
835
|
+
const crudHasFields = allCrudFields.length > 0;
|
|
836
|
+
|
|
837
|
+
if (crudHasFiles) {
|
|
838
|
+
addImport("import multer from 'multer';");
|
|
839
|
+
if (!content.includes('const upload = multer')) {
|
|
840
|
+
content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });");
|
|
841
|
+
}
|
|
842
|
+
packagesToInstall.push('multer');
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
let dest = '';
|
|
846
|
+
let obj = 'req.body';
|
|
847
|
+
if (crudHasFields) {
|
|
848
|
+
const parts = [];
|
|
849
|
+
if (crudBodyFields.length > 0) {
|
|
850
|
+
dest = `const { ${crudBodyFields.map(f => f.name).join(', ')} } = req.body;`;
|
|
851
|
+
parts.push(...crudBodyFields.map(f => f.name));
|
|
852
|
+
}
|
|
853
|
+
if (crudFileFields.length > 0) {
|
|
854
|
+
const fileLines = crudFileFields.map(f => {
|
|
855
|
+
if (crudFileFields.length === 1) return `const ${f.name} = req.file ? req.file.path : undefined;`;
|
|
856
|
+
return `const ${f.name} = req.files?.['${f.name}']?.[0]?.path || undefined;`;
|
|
857
|
+
}).join('\\n ');
|
|
858
|
+
dest = (dest ? dest + '\\n ' : '') + fileLines;
|
|
859
|
+
parts.push(...crudFileFields.map(f => f.name));
|
|
860
|
+
}
|
|
861
|
+
obj = `{ ${parts.join(', ')} }`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
let mw = '';
|
|
865
|
+
if (crudHasFiles) {
|
|
866
|
+
if (crudFileFields.length === 1) mw = `upload.single('${crudFileFields[0].name}'), `;
|
|
867
|
+
else mw = `upload.fields([${crudFileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `;
|
|
868
|
+
}
|
|
752
869
|
|
|
753
870
|
if (crudMethods.includes('get')) {
|
|
754
871
|
handlers += `\n// Get all\nrouter.get('/', async (req, res) => {\n try {\n const items = await ${pascalModel}.find();\n res.json(items);\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n\n// Get by ID\nrouter.get('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findById(req.params.id);\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
|
|
755
872
|
}
|
|
756
873
|
if (crudMethods.includes('post')) {
|
|
757
|
-
handlers += `\n// Create\nrouter.post('/', async (req, res) => {\n try {\n const item = new ${pascalModel}(
|
|
874
|
+
handlers += `\n// Create\nrouter.post('/', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = new ${pascalModel}(${obj});\n const saved = await item.save();\n res.status(201).json(saved);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
|
|
758
875
|
}
|
|
759
876
|
if (crudMethods.includes('put')) {
|
|
760
|
-
handlers += `\n// Update\nrouter.put('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id,
|
|
877
|
+
handlers += `\n// Update\nrouter.put('/:id', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${obj}, { returnDocument: 'after', runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
|
|
761
878
|
}
|
|
762
879
|
if (crudMethods.includes('patch')) {
|
|
763
|
-
handlers += `\n// Partial Update\nrouter.patch('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id,
|
|
880
|
+
handlers += `\n// Partial Update\nrouter.patch('/:id', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${obj}, { returnDocument: 'after', runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
|
|
764
881
|
}
|
|
765
882
|
if (crudMethods.includes('delete')) {
|
|
766
883
|
handlers += `\n// Delete\nrouter.delete('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndDelete(req.params.id);\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json({ message: '${pascalModel} deleted' });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
|
|
@@ -849,7 +966,7 @@ router.get('/api/routes/:name/share', (req, res) => {
|
|
|
849
966
|
const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
|
|
850
967
|
let modelFields = [];
|
|
851
968
|
if (modelImportMatch) {
|
|
852
|
-
modelFields = getModelSchemaDetailed(modelImportMatch[2]);
|
|
969
|
+
modelFields = getModelSchemaDetailed(modelImportMatch[2]).fields;
|
|
853
970
|
}
|
|
854
971
|
|
|
855
972
|
// Parse each endpoint with body/query fields
|
|
@@ -1322,6 +1439,46 @@ router.${m}('/', async (req, res) => {
|
|
|
1322
1439
|
}
|
|
1323
1440
|
} else if (type === 'crud' && modelName) {
|
|
1324
1441
|
const crudMethods = methodList.length > 0 ? methodList : ['get', 'post', 'put', 'delete'];
|
|
1442
|
+
const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
|
|
1443
|
+
const bodyFields = allFields.filter(f => f.type !== 'File');
|
|
1444
|
+
const fileFields = allFields.filter(f => f.type === 'File');
|
|
1445
|
+
const hasFileFields = fileFields.length > 0;
|
|
1446
|
+
const hasFields = allFields.length > 0;
|
|
1447
|
+
|
|
1448
|
+
if (hasFileFields) {
|
|
1449
|
+
imports += "import multer from 'multer';\n";
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Build destructure and object construction
|
|
1453
|
+
let destructure = '';
|
|
1454
|
+
let buildObj = 'req.body';
|
|
1455
|
+
if (hasFields) {
|
|
1456
|
+
const parts = [];
|
|
1457
|
+
if (bodyFields.length > 0) {
|
|
1458
|
+
destructure = `const { ${bodyFields.map(f => f.name).join(', ')} } = req.body;`;
|
|
1459
|
+
parts.push(...bodyFields.map(f => f.name));
|
|
1460
|
+
}
|
|
1461
|
+
if (fileFields.length > 0) {
|
|
1462
|
+
const fileLines = fileFields.map(f => {
|
|
1463
|
+
if (fileFields.length === 1) return `const ${f.name} = req.file ? req.file.path : undefined;`;
|
|
1464
|
+
return `const ${f.name} = req.files?.['${f.name}']?.[0]?.path || undefined;`;
|
|
1465
|
+
}).join('\n ');
|
|
1466
|
+
destructure = (destructure ? destructure + '\n ' : '') + fileLines;
|
|
1467
|
+
parts.push(...fileFields.map(f => f.name));
|
|
1468
|
+
}
|
|
1469
|
+
buildObj = `{ ${parts.join(', ')} }`;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Multer middleware string for write methods
|
|
1473
|
+
let middlewareStr = '';
|
|
1474
|
+
if (hasFileFields) {
|
|
1475
|
+
if (fileFields.length === 1) {
|
|
1476
|
+
middlewareStr = `upload.single('${fileFields[0].name}'), `;
|
|
1477
|
+
} else {
|
|
1478
|
+
const fieldsArg = fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ');
|
|
1479
|
+
middlewareStr = `upload.fields([${fieldsArg}]), `;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1325
1482
|
|
|
1326
1483
|
if (crudMethods.includes('get')) {
|
|
1327
1484
|
handlers += `
|
|
@@ -1351,9 +1508,10 @@ router.get('/:id', async (req, res) => {
|
|
|
1351
1508
|
if (crudMethods.includes('post')) {
|
|
1352
1509
|
handlers += `
|
|
1353
1510
|
// Create
|
|
1354
|
-
router.post('/', async (req, res) => {
|
|
1511
|
+
router.post('/', ${middlewareStr}async (req, res) => {
|
|
1355
1512
|
try {
|
|
1356
|
-
|
|
1513
|
+
${destructure}
|
|
1514
|
+
const item = new ${pascalModel}(${buildObj});
|
|
1357
1515
|
const saved = await item.save();
|
|
1358
1516
|
res.status(201).json(saved);
|
|
1359
1517
|
} catch (err) {
|
|
@@ -1366,9 +1524,10 @@ router.post('/', async (req, res) => {
|
|
|
1366
1524
|
if (crudMethods.includes('put')) {
|
|
1367
1525
|
handlers += `
|
|
1368
1526
|
// Update
|
|
1369
|
-
router.put('/:id', async (req, res) => {
|
|
1527
|
+
router.put('/:id', ${middlewareStr}async (req, res) => {
|
|
1370
1528
|
try {
|
|
1371
|
-
|
|
1529
|
+
${destructure}
|
|
1530
|
+
const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${buildObj}, { returnDocument: 'after', runValidators: true });
|
|
1372
1531
|
if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
|
|
1373
1532
|
res.json(item);
|
|
1374
1533
|
} catch (err) {
|
|
@@ -1381,9 +1540,10 @@ router.put('/:id', async (req, res) => {
|
|
|
1381
1540
|
if (crudMethods.includes('patch')) {
|
|
1382
1541
|
handlers += `
|
|
1383
1542
|
// Partial Update
|
|
1384
|
-
router.patch('/:id', async (req, res) => {
|
|
1543
|
+
router.patch('/:id', ${middlewareStr}async (req, res) => {
|
|
1385
1544
|
try {
|
|
1386
|
-
|
|
1545
|
+
${destructure}
|
|
1546
|
+
const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${buildObj}, { returnDocument: 'after', runValidators: true });
|
|
1387
1547
|
if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
|
|
1388
1548
|
res.json(item);
|
|
1389
1549
|
} catch (err) {
|
|
@@ -1407,6 +1567,10 @@ router.delete('/:id', async (req, res) => {
|
|
|
1407
1567
|
});
|
|
1408
1568
|
`;
|
|
1409
1569
|
}
|
|
1570
|
+
|
|
1571
|
+
if (hasFileFields) {
|
|
1572
|
+
handlers = `\nconst upload = multer({ dest: 'uploads/' });\n` + handlers;
|
|
1573
|
+
}
|
|
1410
1574
|
} else if (type === 'register' && modelName) {
|
|
1411
1575
|
const regFields = registerFields.length > 0 ? registerFields : [findField];
|
|
1412
1576
|
const allDestructured = regFields.join(', ');
|
|
@@ -2307,7 +2471,7 @@ router.get('/api/api-tester/routes', (req, res) => {
|
|
|
2307
2471
|
const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
|
|
2308
2472
|
let modelFields = [];
|
|
2309
2473
|
if (modelImportMatch) {
|
|
2310
|
-
modelFields = getModelSchemaDetailed(modelImportMatch[2]);
|
|
2474
|
+
modelFields = getModelSchemaDetailed(modelImportMatch[2]).fields;
|
|
2311
2475
|
}
|
|
2312
2476
|
|
|
2313
2477
|
const endpoints = [];
|
|
@@ -2383,6 +2547,414 @@ router.get('/api/api-tester/routes', (req, res) => {
|
|
|
2383
2547
|
}
|
|
2384
2548
|
});
|
|
2385
2549
|
|
|
2550
|
+
// ---------------------------------------------------------------------------
|
|
2551
|
+
// Reports — schema info & Excel generation
|
|
2552
|
+
// ---------------------------------------------------------------------------
|
|
2553
|
+
router.get('/api/reports/schema-info', (req, res) => {
|
|
2554
|
+
try {
|
|
2555
|
+
const modelFiles = listModels();
|
|
2556
|
+
const models = modelFiles.map(f => {
|
|
2557
|
+
const name = path.basename(f, '.js');
|
|
2558
|
+
const detail = getModelSchemaDetailed(name);
|
|
2559
|
+
const fields = Array.isArray(detail) ? detail : detail.fields;
|
|
2560
|
+
return { name, fields: fields.map(field => {
|
|
2561
|
+
const result = { ...field };
|
|
2562
|
+
if (field.type === 'ObjectId' && field.ref) {
|
|
2563
|
+
const refDetail = getModelSchemaDetailed(field.ref);
|
|
2564
|
+
const refFields = Array.isArray(refDetail) ? refDetail : refDetail.fields;
|
|
2565
|
+
result.refFields = refFields.map(rf => rf.name);
|
|
2566
|
+
}
|
|
2567
|
+
return result;
|
|
2568
|
+
})};
|
|
2569
|
+
});
|
|
2570
|
+
res.json({ models });
|
|
2571
|
+
} catch (err) {
|
|
2572
|
+
res.status(500).json({ error: err.message });
|
|
2573
|
+
}
|
|
2574
|
+
});
|
|
2575
|
+
|
|
2576
|
+
// Date formatting helper
|
|
2577
|
+
function formatDateValue(val, format, tz) {
|
|
2578
|
+
if (!(val instanceof Date)) {
|
|
2579
|
+
try { val = new Date(val); } catch { return String(val); }
|
|
2580
|
+
if (isNaN(val.getTime())) return String(val);
|
|
2581
|
+
}
|
|
2582
|
+
if (format === 'ISO') return val.toISOString();
|
|
2583
|
+
if (format === 'UNIX') return Math.floor(val.getTime() / 1000);
|
|
2584
|
+
const isAmPm = format.includes(' A');
|
|
2585
|
+
const opts = { timeZone: tz || 'UTC', hour12: isAmPm };
|
|
2586
|
+
const parts = {};
|
|
2587
|
+
try {
|
|
2588
|
+
const fmt = new Intl.DateTimeFormat('en-US', { ...opts, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
2589
|
+
fmt.formatToParts(val).forEach(p => { parts[p.type] = p.value; });
|
|
2590
|
+
} catch { return val.toISOString(); }
|
|
2591
|
+
const Y = parts.year, M = parts.month, D = parts.day;
|
|
2592
|
+
const H = parts.hour, m = parts.minute, s = parts.second;
|
|
2593
|
+
const ampm = parts.dayPeriod || '';
|
|
2594
|
+
const time = isAmPm ? `${H}:${m}:${s} ${ampm}` : `${H}:${m}:${s}`;
|
|
2595
|
+
// Normalize hour for 24h (en-US with hour12:false gives "24" for midnight, fix to "00")
|
|
2596
|
+
const H24 = !isAmPm && H === '24' ? '00' : H;
|
|
2597
|
+
const time24 = `${H24}:${m}:${s}`;
|
|
2598
|
+
switch (format) {
|
|
2599
|
+
case 'YYYY-MM-DD HH:mm:ss': return `${Y}-${M}-${D} ${time24}`;
|
|
2600
|
+
case 'DD/MM/YYYY HH:mm:ss': return `${D}/${M}/${Y} ${time24}`;
|
|
2601
|
+
case 'MM/DD/YYYY HH:mm:ss': return `${M}/${D}/${Y} ${time24}`;
|
|
2602
|
+
case 'YYYY-MM-DD hh:mm:ss A': return `${Y}-${M}-${D} ${time}`;
|
|
2603
|
+
case 'DD/MM/YYYY hh:mm:ss A': return `${D}/${M}/${Y} ${time}`;
|
|
2604
|
+
case 'MM/DD/YYYY hh:mm:ss A': return `${M}/${D}/${Y} ${time}`;
|
|
2605
|
+
case 'YYYY-MM-DD': return `${Y}-${M}-${D}`;
|
|
2606
|
+
case 'DD/MM/YYYY': return `${D}/${M}/${Y}`;
|
|
2607
|
+
case 'MM/DD/YYYY': return `${M}/${D}/${Y}`;
|
|
2608
|
+
default: return `${Y}-${M}-${D} ${time24}`;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
function formatVal(val, dateFormat, timezone) {
|
|
2613
|
+
if (val === null || val === undefined) return null;
|
|
2614
|
+
// Native Date
|
|
2615
|
+
if (val instanceof Date) return formatDateValue(val, dateFormat, timezone);
|
|
2616
|
+
// String that looks like ISO date
|
|
2617
|
+
if (typeof val === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(val)) return formatDateValue(val, dateFormat, timezone);
|
|
2618
|
+
// Non-null objects (BSON types from MongoDB driver)
|
|
2619
|
+
if (typeof val === 'object') {
|
|
2620
|
+
const str = val.toString();
|
|
2621
|
+
// BSON Date — toString gives a date string, or has getTime
|
|
2622
|
+
if (typeof val.getTime === 'function') return formatDateValue(new Date(val.getTime()), dateFormat, timezone);
|
|
2623
|
+
// ObjectId or other BSON — toString gives hex string
|
|
2624
|
+
if (str && str !== '[object Object]') return str;
|
|
2625
|
+
return JSON.stringify(val);
|
|
2626
|
+
}
|
|
2627
|
+
return val;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
router.post('/api/reports/preview', async (req, res) => {
|
|
2631
|
+
try {
|
|
2632
|
+
const { name, fields = [], systemFields = [], populatedFields = {}, dateFormat = 'YYYY-MM-DD HH:mm:ss', timezone = 'UTC', fieldPrefixes = {} } = req.body;
|
|
2633
|
+
if (!name) return res.status(400).json({ error: 'Model name required' });
|
|
2634
|
+
|
|
2635
|
+
const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
|
|
2636
|
+
if (!fs.existsSync(projectMongoose)) return res.status(400).json({ error: 'Mongoose not installed.' });
|
|
2637
|
+
const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
|
|
2638
|
+
if (mongoose.connection.readyState === 0) {
|
|
2639
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
2640
|
+
const uri = getEnvValue(devEnv, 'MONGO_URI');
|
|
2641
|
+
if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
|
|
2642
|
+
await mongoose.connect(uri);
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
const collection = mongoose.connection.db.collection(name + 's');
|
|
2646
|
+
const rawDocs = await collection.find({}).limit(10).toArray();
|
|
2647
|
+
// Convert BSON to plain JSON objects for reliable field access
|
|
2648
|
+
const docs = JSON.parse(JSON.stringify(rawDocs));
|
|
2649
|
+
|
|
2650
|
+
// Populate referenced fields
|
|
2651
|
+
const lookups = {};
|
|
2652
|
+
for (const [refField, subFields] of Object.entries(populatedFields)) {
|
|
2653
|
+
if (!subFields || subFields.length === 0) continue;
|
|
2654
|
+
const detail = getModelSchemaDetailed(name);
|
|
2655
|
+
const schemaFields = detail.fields || [];
|
|
2656
|
+
const fieldDef = schemaFields.find(f => f.name === refField);
|
|
2657
|
+
if (!fieldDef || !fieldDef.ref) continue;
|
|
2658
|
+
const ids = [...new Set(docs.map(d => d[refField]).filter(Boolean).map(id => typeof id === 'object' ? (id.$oid || id.toString()) : String(id)))];
|
|
2659
|
+
if (ids.length === 0) continue;
|
|
2660
|
+
const { ObjectId } = mongoose.Types;
|
|
2661
|
+
const rawRefDocs = await mongoose.connection.db.collection(fieldDef.ref + 's')
|
|
2662
|
+
.find({ _id: { $in: ids.map(id => { try { return new ObjectId(id); } catch { return id; } }) } }).toArray();
|
|
2663
|
+
const refDocs = JSON.parse(JSON.stringify(rawRefDocs));
|
|
2664
|
+
const map = {};
|
|
2665
|
+
for (const rd of refDocs) {
|
|
2666
|
+
const rdId = typeof rd._id === 'object' ? (rd._id.$oid || rd._id) : rd._id;
|
|
2667
|
+
map[String(rdId)] = rd;
|
|
2668
|
+
}
|
|
2669
|
+
lookups[refField] = { subFields, map };
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
function applyPrefix(key, val) {
|
|
2673
|
+
if (val === null || val === undefined) return val;
|
|
2674
|
+
const prefix = fieldPrefixes[key];
|
|
2675
|
+
return prefix ? prefix + val : val;
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
const rows = docs.map(doc => {
|
|
2679
|
+
const row = {};
|
|
2680
|
+
// System fields first
|
|
2681
|
+
for (const sf of systemFields) {
|
|
2682
|
+
let val = doc[sf];
|
|
2683
|
+
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2684
|
+
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2685
|
+
row[sf] = applyPrefix(sf, formatVal(val, dateFormat, timezone));
|
|
2686
|
+
}
|
|
2687
|
+
// Model fields
|
|
2688
|
+
for (const f of fields) {
|
|
2689
|
+
let val = doc[f];
|
|
2690
|
+
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2691
|
+
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2692
|
+
row[f] = applyPrefix(f, formatVal(val, dateFormat, timezone));
|
|
2693
|
+
}
|
|
2694
|
+
// Populated ref fields
|
|
2695
|
+
for (const [refField, { subFields, map }] of Object.entries(lookups)) {
|
|
2696
|
+
let refId = doc[refField];
|
|
2697
|
+
if (refId && typeof refId === 'object' && refId.$oid) refId = refId.$oid;
|
|
2698
|
+
const refDoc = refId ? map[String(refId)] : null;
|
|
2699
|
+
for (const sf of subFields) {
|
|
2700
|
+
const colKey = refField + '.' + sf;
|
|
2701
|
+
let val = refDoc ? refDoc[sf] : null;
|
|
2702
|
+
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2703
|
+
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2704
|
+
row[colKey] = applyPrefix(colKey, formatVal(val, dateFormat, timezone));
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
return row;
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
// Reorder columns if columnOrder provided
|
|
2711
|
+
const columnOrder = req.body.columnOrder;
|
|
2712
|
+
if (columnOrder && columnOrder.length > 0) {
|
|
2713
|
+
const ordered = rows.map(row => {
|
|
2714
|
+
const r = {};
|
|
2715
|
+
columnOrder.forEach(col => { if (col in row) r[col] = row[col]; });
|
|
2716
|
+
return r;
|
|
2717
|
+
});
|
|
2718
|
+
return res.json({ rows: ordered });
|
|
2719
|
+
}
|
|
2720
|
+
res.json({ rows });
|
|
2721
|
+
} catch (err) {
|
|
2722
|
+
res.status(500).json({ error: err.message });
|
|
2723
|
+
}
|
|
2724
|
+
});
|
|
2725
|
+
|
|
2726
|
+
router.post('/api/reports/generate', async (req, res) => {
|
|
2727
|
+
try {
|
|
2728
|
+
const { models: reportModels, filename, dateFormat = 'YYYY-MM-DD HH:mm:ss', timezone = 'UTC' } = req.body;
|
|
2729
|
+
if (!reportModels || !reportModels.length) {
|
|
2730
|
+
return res.status(400).json({ error: 'No models selected' });
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
// Try to import xlsx-js-style (supports styling) or fall back to xlsx
|
|
2734
|
+
let XLSX;
|
|
2735
|
+
try {
|
|
2736
|
+
const styledPkg = path.join(process.cwd(), 'node_modules', 'xlsx-js-style');
|
|
2737
|
+
const plainPkg = path.join(process.cwd(), 'node_modules', 'xlsx');
|
|
2738
|
+
if (!fs.existsSync(styledPkg)) {
|
|
2739
|
+
await execAsync('npm install xlsx-js-style', { cwd: process.cwd() });
|
|
2740
|
+
}
|
|
2741
|
+
// Use createRequire for CJS packages
|
|
2742
|
+
const { createRequire } = await import('module');
|
|
2743
|
+
const require = createRequire(process.cwd() + '/');
|
|
2744
|
+
XLSX = fs.existsSync(styledPkg) ? require('xlsx-js-style') : require('xlsx');
|
|
2745
|
+
} catch (e) {
|
|
2746
|
+
return res.status(400).json({ error: 'Failed to load xlsx. Run: npm install xlsx-js-style' });
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// Connect to MongoDB
|
|
2750
|
+
const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
|
|
2751
|
+
if (!fs.existsSync(projectMongoose)) {
|
|
2752
|
+
return res.status(400).json({ error: 'Mongoose is not installed.' });
|
|
2753
|
+
}
|
|
2754
|
+
const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
|
|
2755
|
+
if (mongoose.connection.readyState === 0) {
|
|
2756
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
2757
|
+
const uri = getEnvValue(devEnv, 'MONGO_URI');
|
|
2758
|
+
if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
|
|
2759
|
+
await mongoose.connect(uri);
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
const wb = XLSX.utils.book_new();
|
|
2763
|
+
|
|
2764
|
+
for (const rm of reportModels) {
|
|
2765
|
+
const collectionName = rm.name + 's';
|
|
2766
|
+
const collection = mongoose.connection.db.collection(collectionName);
|
|
2767
|
+
const rowLimit = req.body.limit > 0 ? Math.min(req.body.limit, 50000) : 10000;
|
|
2768
|
+
const rawDocs = await collection.find({}).limit(rowLimit).toArray();
|
|
2769
|
+
const docs = JSON.parse(JSON.stringify(rawDocs));
|
|
2770
|
+
|
|
2771
|
+
// Build lookup maps for populated fields
|
|
2772
|
+
const lookups = {};
|
|
2773
|
+
if (rm.populatedFields) {
|
|
2774
|
+
for (const [refField, subFields] of Object.entries(rm.populatedFields)) {
|
|
2775
|
+
if (!subFields || subFields.length === 0) continue;
|
|
2776
|
+
const detail = getModelSchemaDetailed(rm.name);
|
|
2777
|
+
const schemaFields = detail.fields || [];
|
|
2778
|
+
const fieldDef = schemaFields.find(f => f.name === refField);
|
|
2779
|
+
if (!fieldDef || !fieldDef.ref) continue;
|
|
2780
|
+
|
|
2781
|
+
const ids = [...new Set(docs.map(d => d[refField]).filter(Boolean).map(id => typeof id === 'object' ? (id.$oid || id) : String(id)))];
|
|
2782
|
+
if (ids.length === 0) continue;
|
|
2783
|
+
|
|
2784
|
+
const { ObjectId } = mongoose.Types;
|
|
2785
|
+
const rawRefDocs = await mongoose.connection.db.collection(fieldDef.ref + 's')
|
|
2786
|
+
.find({ _id: { $in: ids.map(id => { try { return new ObjectId(id); } catch { return id; } }) } }).toArray();
|
|
2787
|
+
const refDocs = JSON.parse(JSON.stringify(rawRefDocs));
|
|
2788
|
+
|
|
2789
|
+
const map = {};
|
|
2790
|
+
for (const rd of refDocs) {
|
|
2791
|
+
const rdId = typeof rd._id === 'object' ? (rd._id.$oid || rd._id) : rd._id;
|
|
2792
|
+
map[String(rdId)] = rd;
|
|
2793
|
+
}
|
|
2794
|
+
lookups[refField] = { subFields, map };
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// Build rows
|
|
2799
|
+
const sysFields = rm.systemFields || [];
|
|
2800
|
+
const rmPrefixes = rm.fieldPrefixes || {};
|
|
2801
|
+
function applyRmPrefix(key, val) {
|
|
2802
|
+
if (val === null || val === undefined || val === '') return val;
|
|
2803
|
+
const prefix = rmPrefixes[key];
|
|
2804
|
+
return prefix ? prefix + val : val;
|
|
2805
|
+
}
|
|
2806
|
+
const rows = docs.map(doc => {
|
|
2807
|
+
const row = {};
|
|
2808
|
+
// System fields first
|
|
2809
|
+
for (const sf of sysFields) {
|
|
2810
|
+
let val = doc[sf];
|
|
2811
|
+
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2812
|
+
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2813
|
+
const fv = formatVal(val, dateFormat, timezone);
|
|
2814
|
+
row[sf] = applyRmPrefix(sf, fv === null ? '' : fv);
|
|
2815
|
+
}
|
|
2816
|
+
// Model fields
|
|
2817
|
+
for (const field of rm.fields) {
|
|
2818
|
+
let val = doc[field];
|
|
2819
|
+
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2820
|
+
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2821
|
+
const fv = formatVal(val, dateFormat, timezone);
|
|
2822
|
+
row[field] = applyRmPrefix(field, fv === null ? '' : fv);
|
|
2823
|
+
}
|
|
2824
|
+
// Populated ref fields
|
|
2825
|
+
for (const [refField, { subFields, map }] of Object.entries(lookups)) {
|
|
2826
|
+
let refId = doc[refField];
|
|
2827
|
+
if (refId && typeof refId === 'object' && refId.$oid) refId = refId.$oid;
|
|
2828
|
+
const refDoc = refId ? map[String(refId)] : null;
|
|
2829
|
+
for (const sf of subFields) {
|
|
2830
|
+
const colKey = refField + '.' + sf;
|
|
2831
|
+
let val = refDoc ? refDoc[sf] : null;
|
|
2832
|
+
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2833
|
+
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2834
|
+
const fv = formatVal(val, dateFormat, timezone);
|
|
2835
|
+
row[colKey] = applyRmPrefix(colKey, fv === null ? '' : fv);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
return row;
|
|
2839
|
+
});
|
|
2840
|
+
|
|
2841
|
+
// Reorder columns if columnOrder provided
|
|
2842
|
+
const colOrder = rm.columnOrder;
|
|
2843
|
+
const headerLabels = rm.headerLabels || {};
|
|
2844
|
+
let finalRows = rows;
|
|
2845
|
+
if (colOrder && colOrder.length > 0) {
|
|
2846
|
+
finalRows = rows.map(row => {
|
|
2847
|
+
const r = {};
|
|
2848
|
+
colOrder.forEach(col => {
|
|
2849
|
+
const label = headerLabels[col] || col;
|
|
2850
|
+
if (col in row) r[label] = row[col];
|
|
2851
|
+
});
|
|
2852
|
+
return r;
|
|
2853
|
+
});
|
|
2854
|
+
} else if (Object.keys(headerLabels).length > 0) {
|
|
2855
|
+
finalRows = rows.map(row => {
|
|
2856
|
+
const r = {};
|
|
2857
|
+
Object.keys(row).forEach(col => {
|
|
2858
|
+
const label = headerLabels[col] || col;
|
|
2859
|
+
r[label] = row[col];
|
|
2860
|
+
});
|
|
2861
|
+
return r;
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// Build headers with custom labels
|
|
2866
|
+
const rawHeaders = colOrder && colOrder.length > 0 ? colOrder : Object.keys(rows[0] || {});
|
|
2867
|
+
const displayHeaders = rawHeaders.map(h => headerLabels[h] || h);
|
|
2868
|
+
|
|
2869
|
+
// Create worksheet with explicit column order
|
|
2870
|
+
const ws = XLSX.utils.json_to_sheet(finalRows, { header: displayHeaders });
|
|
2871
|
+
|
|
2872
|
+
// Professional styling
|
|
2873
|
+
const range = XLSX.utils.decode_range(ws['!ref'] || 'A1');
|
|
2874
|
+
// Style header row — bold with background
|
|
2875
|
+
const thinBorder = { style: "thin", color: { rgb: "B4C6E7" } };
|
|
2876
|
+
const allBorders = { top: thinBorder, bottom: thinBorder, left: thinBorder, right: thinBorder };
|
|
2877
|
+
const headerBorder = { top: { style: "thin", color: { rgb: "2F5597" } }, bottom: { style: "thin", color: { rgb: "2F5597" } }, left: { style: "thin", color: { rgb: "2F5597" } }, right: { style: "thin", color: { rgb: "2F5597" } } };
|
|
2878
|
+
// Style header row
|
|
2879
|
+
for (let C = range.s.c; C <= range.e.c; C++) {
|
|
2880
|
+
const addr = XLSX.utils.encode_cell({ r: 0, c: C });
|
|
2881
|
+
if (!ws[addr]) continue;
|
|
2882
|
+
ws[addr].s = {
|
|
2883
|
+
font: { bold: true, color: { rgb: "FFFFFF" }, sz: 12, name: "Calibri" },
|
|
2884
|
+
fill: { fgColor: { rgb: "4472C4" } },
|
|
2885
|
+
alignment: { horizontal: "center", vertical: "center" },
|
|
2886
|
+
border: headerBorder,
|
|
2887
|
+
};
|
|
2888
|
+
}
|
|
2889
|
+
// Alternate row colors with full borders
|
|
2890
|
+
for (let R = 1; R <= range.e.r; R++) {
|
|
2891
|
+
const isEven = R % 2 === 0;
|
|
2892
|
+
for (let C = range.s.c; C <= range.e.c; C++) {
|
|
2893
|
+
const addr = XLSX.utils.encode_cell({ r: R, c: C });
|
|
2894
|
+
if (!ws[addr]) ws[addr] = { v: '', t: 's' };
|
|
2895
|
+
ws[addr].s = {
|
|
2896
|
+
fill: isEven ? { fgColor: { rgb: "D6E4F0" } } : { fgColor: { rgb: "FFFFFF" } },
|
|
2897
|
+
font: { sz: 11, color: { rgb: "333333" }, name: "Calibri" },
|
|
2898
|
+
alignment: { vertical: "center" },
|
|
2899
|
+
border: allBorders,
|
|
2900
|
+
};
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
// Column widths with breathing room
|
|
2904
|
+
ws['!cols'] = displayHeaders.map((key, i) => {
|
|
2905
|
+
const rawKey = rawHeaders[i];
|
|
2906
|
+
const maxLen = Math.max(key.length, ...finalRows.map(r => String(r[key] || '').length).slice(0, 100));
|
|
2907
|
+
return { wch: maxLen + 4 };
|
|
2908
|
+
});
|
|
2909
|
+
// Row heights — uniform for all rows
|
|
2910
|
+
const totalRows = range.e.r + 1;
|
|
2911
|
+
ws['!rows'] = Array.from({ length: totalRows }, () => ({ hpt: 26 }));
|
|
2912
|
+
|
|
2913
|
+
const sheetName = rm.name.length > 31 ? rm.name.slice(0, 31) : rm.name;
|
|
2914
|
+
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
|
2918
|
+
const fname = (filename || 'report') + '.xlsx';
|
|
2919
|
+
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
2920
|
+
res.setHeader('Content-Disposition', 'attachment; filename="' + fname + '"');
|
|
2921
|
+
res.send(Buffer.from(buf));
|
|
2922
|
+
} catch (err) {
|
|
2923
|
+
res.status(500).json({ error: err.message });
|
|
2924
|
+
}
|
|
2925
|
+
});
|
|
2926
|
+
|
|
2927
|
+
// ---------------------------------------------------------------------------
|
|
2928
|
+
// Logs — history, SSE stream, clear
|
|
2929
|
+
// ---------------------------------------------------------------------------
|
|
2930
|
+
router.get('/api/logs', (req, res) => {
|
|
2931
|
+
res.json(_logBuffer);
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
router.get('/api/logs/stream', (req, res) => {
|
|
2935
|
+
// EventSource can't send custom headers, so verify passkey from query param
|
|
2936
|
+
const stored = getPasskeyHash();
|
|
2937
|
+
if (stored) {
|
|
2938
|
+
const pk = req.query.passkey;
|
|
2939
|
+
if (!pk || hashPasskey(pk) !== stored) {
|
|
2940
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
res.writeHead(200, {
|
|
2944
|
+
'Content-Type': 'text/event-stream',
|
|
2945
|
+
'Cache-Control': 'no-cache',
|
|
2946
|
+
Connection: 'keep-alive',
|
|
2947
|
+
});
|
|
2948
|
+
res.write(':\n\n');
|
|
2949
|
+
_logClients.add(res);
|
|
2950
|
+
req.on('close', () => _logClients.delete(res));
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
router.delete('/api/logs', (req, res) => {
|
|
2954
|
+
_logBuffer.length = 0;
|
|
2955
|
+
res.json({ message: 'Logs cleared' });
|
|
2956
|
+
});
|
|
2957
|
+
|
|
2386
2958
|
// ---------------------------------------------------------------------------
|
|
2387
2959
|
// Share Collection — save and serve standalone shared page
|
|
2388
2960
|
// ---------------------------------------------------------------------------
|
|
@@ -2406,6 +2978,8 @@ router.post('/api/api-tester/share-collection', (req, res) => {
|
|
|
2406
2978
|
|
|
2407
2979
|
router.get('/shared/:id', (req, res) => {
|
|
2408
2980
|
try {
|
|
2981
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
2982
|
+
res.setHeader('Pragma', 'no-cache');
|
|
2409
2983
|
const filePath = path.join(sharedDir, req.params.id + '.json');
|
|
2410
2984
|
if (!fs.existsSync(filePath)) return res.status(404).send('Collection not found');
|
|
2411
2985
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
@@ -2438,7 +3012,7 @@ body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:
|
|
|
2438
3012
|
.sidebar-search input:focus{border-color:#7c3aed}
|
|
2439
3013
|
.sidebar-search input::placeholder{color:#475569}
|
|
2440
3014
|
.sidebar-list{flex:1;overflow-y:auto}
|
|
2441
|
-
.sidebar-item{padding:10px 14px;cursor:pointer;border-bottom:1px solid rgba(30,36,51,0.5);display:flex;align-items:center;gap:10px;transition:all 0.12s;border-left:3px solid transparent}
|
|
3015
|
+
.sidebar-item{padding:10px 14px;cursor:pointer;border-bottom:1px solid rgba(30,36,51,0.5);display:flex;align-items:center;gap:10px;transition:all 0.12s;border-left:3px solid transparent;color:#e2e8f0}
|
|
2442
3016
|
.sidebar-item:hover{background:rgba(124,58,237,0.06)}
|
|
2443
3017
|
.sidebar-item.active{background:rgba(124,58,237,0.1);border-left-color:#7c3aed}
|
|
2444
3018
|
.sidebar-child{padding:8px 14px 8px 48px;cursor:pointer;border-bottom:1px solid rgba(30,36,51,0.3);display:flex;align-items:center;gap:8px;transition:all 0.12s;border-left:3px solid transparent}
|
|
@@ -2450,7 +3024,7 @@ body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:
|
|
|
2450
3024
|
.mb-put{background:rgba(59,130,246,0.15);color:#3b82f6}
|
|
2451
3025
|
.mb-patch{background:rgba(139,92,246,0.15);color:#8b5cf6}
|
|
2452
3026
|
.mb-delete{background:rgba(248,113,113,0.15);color:#f87171}
|
|
2453
|
-
.item-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
3027
|
+
.item-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e2e8f0}
|
|
2454
3028
|
.item-arrow{font-size:10px;color:#475569;width:14px;text-align:center;flex-shrink:0}
|
|
2455
3029
|
.item-badge{font-size:9px;padding:2px 7px;border-radius:8px;background:rgba(124,58,237,0.15);color:#a78bfa;font-weight:600}
|
|
2456
3030
|
.sb{font-size:9px;font-weight:700;padding:3px 7px;border-radius:4px;font-family:'SF Mono','Fira Code',monospace;flex-shrink:0}
|
|
@@ -2554,7 +3128,15 @@ pre{font-family:'SF Mono','Fira Code',monospace;font-size:12px;line-height:1.7;c
|
|
|
2554
3128
|
if (code) text = code.querySelector('code').textContent;
|
|
2555
3129
|
}
|
|
2556
3130
|
if (text) {
|
|
2557
|
-
navigator.clipboard.writeText
|
|
3131
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
3132
|
+
navigator.clipboard.writeText(text);
|
|
3133
|
+
} else {
|
|
3134
|
+
var ta = document.createElement('textarea');
|
|
3135
|
+
ta.value = text; ta.style.cssText = 'position:fixed;opacity:0';
|
|
3136
|
+
document.body.appendChild(ta); ta.select();
|
|
3137
|
+
document.execCommand('copy');
|
|
3138
|
+
document.body.removeChild(ta);
|
|
3139
|
+
}
|
|
2558
3140
|
var orig = btn.textContent;
|
|
2559
3141
|
btn.textContent = 'Copied!';
|
|
2560
3142
|
btn.style.color = '#34d399';
|
|
@@ -2737,24 +3319,177 @@ pre{font-family:'SF Mono','Fira Code',monospace;font-size:12px;line-height:1.7;c
|
|
|
2737
3319
|
var main = document.getElementById('main-content');
|
|
2738
3320
|
|
|
2739
3321
|
if (tt === 'socketio' || tt === 'websocket') {
|
|
3322
|
+
var nl = String.fromCharCode(10);
|
|
3323
|
+
var resps = responses[id] || [];
|
|
3324
|
+
var sentLogs = resps.filter(function(r) { return r.dir === 'sent'; });
|
|
3325
|
+
var recvLogs = resps.filter(function(r) { return r.dir === 'received'; });
|
|
3326
|
+
var sysLogs = resps.filter(function(r) { return r.dir === 'system'; });
|
|
3327
|
+
|
|
2740
3328
|
var h = '<div class="card"><div class="card-header">';
|
|
2741
3329
|
if (tt === 'socketio') h += '<span class="mb type-sio" style="font-size:11px;padding:4px 10px">Socket.IO</span>';
|
|
2742
3330
|
else h += '<span class="mb type-ws" style="font-size:11px;padding:4px 10px">WebSocket</span>';
|
|
2743
3331
|
h += '<span style="font-size:16px;font-weight:600">' + esc(t.name) + '</span></div>';
|
|
3332
|
+
|
|
2744
3333
|
h += '<div class="stitle">Connection URL</div>';
|
|
2745
3334
|
h += '<div class="endpoint-url"><code>' + esc(t.url) + '</code><button class="copy-btn" data-copy="url">Copy</button></div>';
|
|
2746
|
-
|
|
3335
|
+
h += '</div>';
|
|
3336
|
+
|
|
3337
|
+
// ── Emit Events Section ──
|
|
3338
|
+
h += '<div class="card">';
|
|
3339
|
+
h += '<div class="card-header"><span style="font-size:14px;font-weight:600;color:#f59e0b">↑ Emit Events</span>';
|
|
3340
|
+
h += '<span style="font-size:10px;padding:3px 10px;border-radius:10px;background:rgba(245,158,11,0.12);color:#f59e0b;font-weight:600;margin-left:auto">Client → Server</span></div>';
|
|
3341
|
+
|
|
3342
|
+
if (tt === 'socketio' && t.event) {
|
|
3343
|
+
h += '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">';
|
|
3344
|
+
h += '<span class="stitle" style="margin:0;min-width:70px">Event</span>';
|
|
3345
|
+
h += '<span style="font-size:13px;padding:6px 14px;border-radius:6px;background:rgba(245,158,11,0.1);color:#f59e0b;font-family:SF Mono,monospace;font-weight:600;border:1px solid rgba(245,158,11,0.2)">' + esc(t.event) + '</span>';
|
|
3346
|
+
h += '</div>';
|
|
3347
|
+
}
|
|
3348
|
+
if (tt === 'websocket' && t.message) {
|
|
3349
|
+
h += '<div class="stitle">Message</div>';
|
|
3350
|
+
}
|
|
3351
|
+
|
|
2747
3352
|
if (t.data || t.message) {
|
|
2748
|
-
h += '<div class="stitle"
|
|
3353
|
+
h += '<div class="stitle" style="margin-bottom:6px">Payload</div>';
|
|
2749
3354
|
var d = t.data || t.message || ''; try { d = JSON.stringify(JSON.parse(d), null, 2); } catch(e) {}
|
|
2750
|
-
h += '<pre style="margin-bottom:
|
|
3355
|
+
h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(d) + '</pre>';
|
|
3356
|
+
h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
|
|
2751
3357
|
}
|
|
3358
|
+
|
|
3359
|
+
// Emit code example
|
|
3360
|
+
if (tt === 'socketio') {
|
|
3361
|
+
h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
|
|
3362
|
+
var emitCode = "// Client-side emit" + nl;
|
|
3363
|
+
emitCode += "import { io } from 'socket.io-client';" + nl;
|
|
3364
|
+
emitCode += "const socket = io('" + t.url + "');" + nl + nl;
|
|
3365
|
+
if (t.event) {
|
|
3366
|
+
var ed = t.data || '{}'; try { ed = JSON.stringify(JSON.parse(ed), null, 2); } catch(e) {}
|
|
3367
|
+
emitCode += "socket.emit('" + t.event + "', " + ed + ");" + nl + nl;
|
|
3368
|
+
}
|
|
3369
|
+
emitCode += "// Server-side handler" + nl;
|
|
3370
|
+
emitCode += "io.on('connection', (socket) => {" + nl;
|
|
3371
|
+
if (t.event) {
|
|
3372
|
+
emitCode += " socket.on('" + t.event + "', (data) => {" + nl;
|
|
3373
|
+
emitCode += " console.log('" + t.event + ":', data);" + nl;
|
|
3374
|
+
emitCode += " });" + nl;
|
|
3375
|
+
}
|
|
3376
|
+
emitCode += "});" + nl + nl;
|
|
3377
|
+
emitCode += "// Emit from Express route" + nl;
|
|
3378
|
+
if (t.event) {
|
|
3379
|
+
var ed2 = t.data || '{}'; try { ed2 = JSON.stringify(JSON.parse(ed2), null, 2); } catch(e) {}
|
|
3380
|
+
emitCode += "req.io.emit('" + t.event + "', " + ed2 + ");";
|
|
3381
|
+
} else {
|
|
3382
|
+
emitCode += "req.io.emit('event_name', { key: 'value' });";
|
|
3383
|
+
}
|
|
3384
|
+
h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(emitCode) + '</pre>';
|
|
3385
|
+
h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
|
|
3386
|
+
} else {
|
|
3387
|
+
h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
|
|
3388
|
+
var wsSendCode = "const ws = new WebSocket('" + t.url + "');" + nl + nl;
|
|
3389
|
+
wsSendCode += "ws.onopen = () => {" + nl;
|
|
3390
|
+
if (t.message) {
|
|
3391
|
+
wsSendCode += " ws.send(JSON.stringify(" + (t.message || "{}") + "));" + nl;
|
|
3392
|
+
} else {
|
|
3393
|
+
wsSendCode += " ws.send(JSON.stringify({ key: 'value' }));" + nl;
|
|
3394
|
+
}
|
|
3395
|
+
wsSendCode += "};";
|
|
3396
|
+
h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(wsSendCode) + '</pre>';
|
|
3397
|
+
h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
// Saved emitted logs
|
|
3401
|
+
if (sentLogs.length > 0) {
|
|
3402
|
+
h += '<div class="stitle" style="margin-bottom:8px">Saved Emits (' + sentLogs.length + ')</div>';
|
|
3403
|
+
sentLogs.forEach(function(r) {
|
|
3404
|
+
var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
|
|
3405
|
+
h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;padding:10px 14px;margin-bottom:6px">';
|
|
3406
|
+
h += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:' + (body ? '8px' : '0') + '">';
|
|
3407
|
+
h += '<span class="sb dir-sent" style="font-size:9px;padding:2px 6px">↑ EMIT</span>';
|
|
3408
|
+
h += '<span style="font-size:12px;font-weight:500">' + esc(r.label) + '</span>';
|
|
3409
|
+
h += '</div>';
|
|
3410
|
+
if (body) {
|
|
3411
|
+
h += '<div style="position:relative"><pre style="margin:0;font-size:11px">' + esc(body) + '</pre>';
|
|
3412
|
+
h += '<button class="copy-btn" style="position:absolute;top:4px;right:4px;font-size:10px;padding:2px 8px" data-copy="prev-pre">Copy</button></div>';
|
|
3413
|
+
}
|
|
3414
|
+
h += '</div>';
|
|
3415
|
+
});
|
|
3416
|
+
}
|
|
3417
|
+
h += '</div>';
|
|
3418
|
+
|
|
3419
|
+
// ── Event Listeners Section ──
|
|
3420
|
+
h += '<div class="card">';
|
|
3421
|
+
h += '<div class="card-header"><span style="font-size:14px;font-weight:600;color:#34d399">↓ Event Listeners</span>';
|
|
3422
|
+
h += '<span style="font-size:10px;padding:3px 10px;border-radius:10px;background:rgba(52,211,153,0.12);color:#34d399;font-weight:600;margin-left:auto">Server → Client</span></div>';
|
|
3423
|
+
|
|
2752
3424
|
if (tt === 'socketio' && t.listeners && t.listeners.length > 0) {
|
|
2753
|
-
h += '<div class="stitle"
|
|
2754
|
-
|
|
3425
|
+
h += '<div class="stitle" style="margin-bottom:8px">Listening On</div>';
|
|
3426
|
+
h += '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">';
|
|
3427
|
+
t.listeners.forEach(function(l) {
|
|
3428
|
+
h += '<span style="font-size:12px;padding:6px 14px;border-radius:6px;background:rgba(52,211,153,0.1);color:#34d399;font-family:SF Mono,monospace;font-weight:600;border:1px solid rgba(52,211,153,0.2)">' + esc(l) + '</span>';
|
|
3429
|
+
});
|
|
2755
3430
|
h += '</div>';
|
|
3431
|
+
|
|
3432
|
+
// Listener code example
|
|
3433
|
+
h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
|
|
3434
|
+
var listenCode = "// Client-side listeners" + nl;
|
|
3435
|
+
listenCode += "import { io } from 'socket.io-client';" + nl;
|
|
3436
|
+
listenCode += "const socket = io('" + t.url + "');" + nl;
|
|
3437
|
+
t.listeners.forEach(function(l) {
|
|
3438
|
+
listenCode += nl + "socket.on('" + l + "', (data) => {" + nl;
|
|
3439
|
+
listenCode += " console.log('" + l + ":', data);" + nl;
|
|
3440
|
+
listenCode += "});" + nl;
|
|
3441
|
+
});
|
|
3442
|
+
listenCode += nl + "// Server-side broadcast" + nl;
|
|
3443
|
+
t.listeners.forEach(function(l) {
|
|
3444
|
+
listenCode += "io.emit('" + l + "', data);" + nl;
|
|
3445
|
+
});
|
|
3446
|
+
h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(listenCode) + '</pre>';
|
|
3447
|
+
h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
|
|
3448
|
+
} else if (tt === 'websocket') {
|
|
3449
|
+
h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
|
|
3450
|
+
var wsRecvCode = "const ws = new WebSocket('" + t.url + "');" + nl + nl;
|
|
3451
|
+
wsRecvCode += "ws.onmessage = (event) => {" + nl;
|
|
3452
|
+
wsRecvCode += " const data = JSON.parse(event.data);" + nl;
|
|
3453
|
+
wsRecvCode += " console.log('Received:', data);" + nl;
|
|
3454
|
+
wsRecvCode += "};" + nl + nl;
|
|
3455
|
+
wsRecvCode += "ws.onclose = () => console.log('Disconnected');";
|
|
3456
|
+
h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(wsRecvCode) + '</pre>';
|
|
3457
|
+
h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
|
|
3458
|
+
} else {
|
|
3459
|
+
h += '<div style="font-size:12px;color:#475569;padding:8px 0">No listeners configured</div>';
|
|
2756
3460
|
}
|
|
2757
|
-
|
|
3461
|
+
|
|
3462
|
+
// Saved received logs
|
|
3463
|
+
if (recvLogs.length > 0) {
|
|
3464
|
+
h += '<div class="stitle" style="margin-bottom:8px">Received Events (' + recvLogs.length + ')</div>';
|
|
3465
|
+
recvLogs.forEach(function(r) {
|
|
3466
|
+
var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
|
|
3467
|
+
h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;padding:10px 14px;margin-bottom:6px">';
|
|
3468
|
+
h += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:' + (body ? '8px' : '0') + '">';
|
|
3469
|
+
h += '<span class="sb dir-recv" style="font-size:9px;padding:2px 6px">↓ RECV</span>';
|
|
3470
|
+
h += '<span style="font-size:12px;font-weight:500">' + esc(r.label) + '</span>';
|
|
3471
|
+
h += '</div>';
|
|
3472
|
+
if (body) {
|
|
3473
|
+
h += '<div style="position:relative"><pre style="margin:0;font-size:11px">' + esc(body) + '</pre>';
|
|
3474
|
+
h += '<button class="copy-btn" style="position:absolute;top:4px;right:4px;font-size:10px;padding:2px 8px" data-copy="prev-pre">Copy</button></div>';
|
|
3475
|
+
}
|
|
3476
|
+
h += '</div>';
|
|
3477
|
+
});
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
// System logs
|
|
3481
|
+
if (sysLogs.length > 0) {
|
|
3482
|
+
h += '<div class="stitle" style="margin-top:12px;margin-bottom:8px">System Events (' + sysLogs.length + ')</div>';
|
|
3483
|
+
sysLogs.forEach(function(r) {
|
|
3484
|
+
h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;padding:8px 14px;margin-bottom:4px;display:flex;align-items:center;gap:8px">';
|
|
3485
|
+
h += '<span class="sb dir-sys" style="font-size:9px;padding:2px 6px">● SYS</span>';
|
|
3486
|
+
h += '<span style="font-size:12px;color:#94a3b8">' + esc(r.label) + '</span>';
|
|
3487
|
+
h += '</div>';
|
|
3488
|
+
});
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
h += '</div>';
|
|
3492
|
+
main.innerHTML = h; return;
|
|
2758
3493
|
}
|
|
2759
3494
|
|
|
2760
3495
|
var mc = { GET:'#34d399', POST:'#f59e0b', PUT:'#3b82f6', PATCH:'#8b5cf6', DELETE:'#f87171' };
|
|
@@ -2910,13 +3645,13 @@ pre{font-family:'SF Mono','Fira Code',monospace;font-size:12px;line-height:1.7;c
|
|
|
2910
3645
|
}
|
|
2911
3646
|
});
|
|
2912
3647
|
|
|
2913
|
-
renderSidebar();
|
|
3648
|
+
try { renderSidebar(); } catch(e) { console.error('renderSidebar error:', e); }
|
|
2914
3649
|
if (tests.length > 0) {
|
|
2915
3650
|
activeTestId = tests[0].id;
|
|
2916
3651
|
var firstResps = responses[tests[0].id] || [];
|
|
2917
3652
|
if (firstResps.length > 0) expanded[tests[0].id] = true;
|
|
2918
|
-
renderSidebar();
|
|
2919
|
-
renderTestView(tests[0].id);
|
|
3653
|
+
try { renderSidebar(); } catch(e) { console.error('renderSidebar error:', e); }
|
|
3654
|
+
try { renderTestView(tests[0].id); } catch(e) { console.error('renderTestView error:', e); }
|
|
2920
3655
|
}
|
|
2921
3656
|
})();
|
|
2922
3657
|
</script></body></html>`;
|