@intranefr/superbackend 1.5.1 → 1.5.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/.env.example +10 -0
- package/analysis-only.skill +0 -0
- package/package.json +2 -1
- package/src/controllers/admin.controller.js +68 -1
- package/src/controllers/adminExperiments.controller.js +200 -0
- package/src/controllers/adminScripts.controller.js +105 -74
- package/src/controllers/experiments.controller.js +85 -0
- package/src/controllers/internalExperiments.controller.js +17 -0
- package/src/helpers/mongooseHelper.js +258 -0
- package/src/helpers/scriptBase.js +230 -0
- package/src/helpers/scriptRunner.js +335 -0
- package/src/middleware.js +65 -11
- package/src/models/CacheEntry.js +1 -1
- package/src/models/ConsoleLog.js +1 -1
- package/src/models/Experiment.js +75 -0
- package/src/models/ExperimentAssignment.js +23 -0
- package/src/models/ExperimentEvent.js +26 -0
- package/src/models/ExperimentMetricBucket.js +30 -0
- package/src/models/GlobalSetting.js +1 -2
- package/src/models/RateLimitCounter.js +1 -1
- package/src/models/ScriptDefinition.js +1 -0
- package/src/models/Webhook.js +2 -0
- package/src/routes/admin.routes.js +2 -0
- package/src/routes/adminConsoleManager.routes.js +1 -1
- package/src/routes/adminExperiments.routes.js +29 -0
- package/src/routes/blogInternal.routes.js +2 -2
- package/src/routes/experiments.routes.js +30 -0
- package/src/routes/internalExperiments.routes.js +15 -0
- package/src/services/blogCronsBootstrap.service.js +7 -6
- package/src/services/consoleManager.service.js +56 -18
- package/src/services/consoleOverride.service.js +1 -0
- package/src/services/experiments.service.js +273 -0
- package/src/services/experimentsAggregation.service.js +308 -0
- package/src/services/experimentsCronsBootstrap.service.js +118 -0
- package/src/services/experimentsRetention.service.js +43 -0
- package/src/services/experimentsWs.service.js +134 -0
- package/src/services/globalSettings.service.js +15 -0
- package/src/services/jsonConfigs.service.js +2 -2
- package/src/services/scriptsRunner.service.js +214 -14
- package/src/utils/rbac/rightsRegistry.js +4 -0
- package/views/admin-dashboard.ejs +28 -8
- package/views/admin-experiments.ejs +91 -0
- package/views/admin-scripts.ejs +596 -2
- package/views/partials/dashboard/nav-items.ejs +1 -0
- package/views/partials/dashboard/palette.ejs +5 -3
- package/src/middleware/internalCronAuth.js +0 -29
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
const { ScriptBase } = require('./scriptBase');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Utility for running scripts with proper error handling and cleanup
|
|
5
|
+
* Provides CLI wrapper functionality and execution management
|
|
6
|
+
*/
|
|
7
|
+
class ScriptRunner {
|
|
8
|
+
/**
|
|
9
|
+
* Run a script class or function
|
|
10
|
+
* @param {Function|ScriptBase} ScriptClass - Script class or function
|
|
11
|
+
* @param {Object} options - Execution options
|
|
12
|
+
* @returns {Promise<any>} Script result
|
|
13
|
+
*/
|
|
14
|
+
static async run(ScriptClass, options = {}) {
|
|
15
|
+
let script;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
// Handle different script types
|
|
19
|
+
script = ScriptRunner._createScriptInstance(ScriptClass, options);
|
|
20
|
+
|
|
21
|
+
// Validate script before running
|
|
22
|
+
const validation = script.validate();
|
|
23
|
+
if (!validation.valid) {
|
|
24
|
+
throw new Error(`Script validation failed: ${validation.errors.join(', ')}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Log warnings if any
|
|
28
|
+
if (validation.warnings.length > 0) {
|
|
29
|
+
validation.warnings.forEach(warning => {
|
|
30
|
+
console.warn(`[ScriptRunner] Warning: ${warning}`);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return await script.run();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('[ScriptRunner] Execution failed:', error.message);
|
|
37
|
+
|
|
38
|
+
if (script) {
|
|
39
|
+
await script.cleanup(script.context);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create script instance from various input types
|
|
48
|
+
* @private
|
|
49
|
+
* @param {Function|ScriptBase} ScriptClass - Script class or function
|
|
50
|
+
* @param {Object} options - Options for script creation
|
|
51
|
+
* @returns {ScriptBase} Script instance
|
|
52
|
+
*/
|
|
53
|
+
static _createScriptInstance(ScriptClass, options = {}) {
|
|
54
|
+
// Handle plain async functions
|
|
55
|
+
if (typeof ScriptClass === 'function' && !ScriptBase.prototype.isPrototypeOf(ScriptClass.prototype)) {
|
|
56
|
+
// Wrap the function in a ScriptBase class
|
|
57
|
+
const WrappedScript = class extends ScriptBase {
|
|
58
|
+
async execute(context) {
|
|
59
|
+
return await ScriptClass(context, options);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return new WrappedScript(options);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handle ScriptBase classes
|
|
67
|
+
if (typeof ScriptClass === 'function' && ScriptBase.prototype.isPrototypeOf(ScriptClass.prototype)) {
|
|
68
|
+
return new ScriptClass(options);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Handle ScriptBase instances
|
|
72
|
+
if (ScriptBase.prototype.isPrototypeOf(ScriptClass)) {
|
|
73
|
+
return ScriptClass;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
throw new Error('Invalid script type: must be a function, ScriptBase class, or ScriptBase instance');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a CLI wrapper for scripts
|
|
81
|
+
* @param {Function|ScriptBase} ScriptClass - Script class or function
|
|
82
|
+
* @param {Object} defaultOptions - Default options
|
|
83
|
+
* @returns {Function} CLI-ready function
|
|
84
|
+
*/
|
|
85
|
+
static createCli(ScriptClass, defaultOptions = {}) {
|
|
86
|
+
return async (options = {}) => {
|
|
87
|
+
const mergedOptions = { ...defaultOptions, ...options };
|
|
88
|
+
|
|
89
|
+
// Handle CLI execution
|
|
90
|
+
if (require.main === module) {
|
|
91
|
+
try {
|
|
92
|
+
// Parse command line arguments if provided
|
|
93
|
+
const cliOptions = ScriptRunner._parseCliArgs();
|
|
94
|
+
const finalOptions = { ...mergedOptions, ...cliOptions };
|
|
95
|
+
|
|
96
|
+
await ScriptRunner.run(ScriptClass, finalOptions);
|
|
97
|
+
console.log('✅ Script completed successfully');
|
|
98
|
+
process.exit(0);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('❌ Script failed:', error.message);
|
|
101
|
+
|
|
102
|
+
// Show stack trace in debug mode
|
|
103
|
+
if (process.env.DEBUG || process.env.NODE_ENV === 'development') {
|
|
104
|
+
console.error(error.stack);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
// Return result when required as module
|
|
111
|
+
return await ScriptRunner.run(ScriptClass, mergedOptions);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse command line arguments
|
|
118
|
+
* @private
|
|
119
|
+
* @returns {Object} Parsed arguments
|
|
120
|
+
*/
|
|
121
|
+
static _parseCliArgs() {
|
|
122
|
+
const args = process.argv.slice(2);
|
|
123
|
+
const options = {};
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < args.length; i++) {
|
|
126
|
+
const arg = args[i];
|
|
127
|
+
|
|
128
|
+
// Handle --key=value or --key value format
|
|
129
|
+
if (arg.startsWith('--')) {
|
|
130
|
+
const equalsIndex = arg.indexOf('=');
|
|
131
|
+
|
|
132
|
+
if (equalsIndex > 0) {
|
|
133
|
+
// --key=value format
|
|
134
|
+
const key = arg.substring(2, equalsIndex);
|
|
135
|
+
const value = arg.substring(equalsIndex + 1);
|
|
136
|
+
options[key] = ScriptRunner._parseValue(value);
|
|
137
|
+
} else {
|
|
138
|
+
// --key value format
|
|
139
|
+
const key = arg.substring(2);
|
|
140
|
+
|
|
141
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
|
142
|
+
const value = args[i + 1];
|
|
143
|
+
options[key] = ScriptRunner._parseValue(value);
|
|
144
|
+
i++; // Skip next argument as it's a value
|
|
145
|
+
} else {
|
|
146
|
+
// Boolean flag
|
|
147
|
+
options[key] = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return options;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse string value to appropriate type
|
|
158
|
+
* @private
|
|
159
|
+
* @param {string} value - String value to parse
|
|
160
|
+
* @returns {any} Parsed value
|
|
161
|
+
*/
|
|
162
|
+
static _parseValue(value) {
|
|
163
|
+
// Try parsing as JSON
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(value);
|
|
166
|
+
} catch {
|
|
167
|
+
// Fallback to string
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create a batch script runner for multiple scripts
|
|
174
|
+
* @param {Array} scripts - Array of script configurations
|
|
175
|
+
* @param {Object} options - Batch options
|
|
176
|
+
* @returns {Function} Batch runner function
|
|
177
|
+
*/
|
|
178
|
+
static createBatch(scripts, options = {}) {
|
|
179
|
+
const {
|
|
180
|
+
stopOnError = true,
|
|
181
|
+
parallel = false,
|
|
182
|
+
maxConcurrency = 3
|
|
183
|
+
} = options;
|
|
184
|
+
|
|
185
|
+
return async (runOptions = {}) => {
|
|
186
|
+
const results = [];
|
|
187
|
+
const errors = [];
|
|
188
|
+
|
|
189
|
+
console.log(`[BatchRunner] Running ${scripts.length} scripts (${parallel ? 'parallel' : 'sequential'})`);
|
|
190
|
+
|
|
191
|
+
if (parallel) {
|
|
192
|
+
// Run scripts in parallel with concurrency limit
|
|
193
|
+
const chunks = ScriptRunner._chunkArray(scripts, maxConcurrency);
|
|
194
|
+
|
|
195
|
+
for (const chunk of chunks) {
|
|
196
|
+
const chunkPromises = chunk.map(async (scriptConfig, index) => {
|
|
197
|
+
try {
|
|
198
|
+
const { script, options: scriptOptions } = scriptConfig;
|
|
199
|
+
const result = await ScriptRunner.run(script, { ...runOptions, ...scriptOptions });
|
|
200
|
+
return { index, result, error: null };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return { index, result: null, error };
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const chunkResults = await Promise.all(chunkPromises);
|
|
207
|
+
|
|
208
|
+
for (const { index, result, error } of chunkResults) {
|
|
209
|
+
if (error) {
|
|
210
|
+
errors.push({ index, error });
|
|
211
|
+
if (stopOnError) {
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
results[index] = result;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
// Run scripts sequentially
|
|
221
|
+
for (let i = 0; i < scripts.length; i++) {
|
|
222
|
+
const { script, options: scriptOptions } = scripts[i];
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
console.log(`[BatchRunner] Running script ${i + 1}/${scripts.length}`);
|
|
226
|
+
const result = await ScriptRunner.run(script, { ...runOptions, ...scriptOptions });
|
|
227
|
+
results.push(result);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
errors.push({ index: i, error });
|
|
230
|
+
if (stopOnError) {
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { results, errors };
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Split array into chunks
|
|
243
|
+
* @private
|
|
244
|
+
* @param {Array} array - Array to split
|
|
245
|
+
* @param {number} size - Chunk size
|
|
246
|
+
* @returns {Array} Array of chunks
|
|
247
|
+
*/
|
|
248
|
+
static _chunkArray(array, size) {
|
|
249
|
+
const chunks = [];
|
|
250
|
+
for (let i = 0; i < array.length; i += size) {
|
|
251
|
+
chunks.push(array.slice(i, i + size));
|
|
252
|
+
}
|
|
253
|
+
return chunks;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Create a scheduled script runner
|
|
258
|
+
* @param {Function|ScriptBase} ScriptClass - Script to run
|
|
259
|
+
* @param {Object} schedule - Schedule configuration
|
|
260
|
+
* @returns {Object} Scheduled runner
|
|
261
|
+
*/
|
|
262
|
+
static createScheduled(ScriptClass, schedule = {}) {
|
|
263
|
+
const {
|
|
264
|
+
interval,
|
|
265
|
+
cron,
|
|
266
|
+
maxRuns = null,
|
|
267
|
+
runOnStart = false
|
|
268
|
+
} = schedule;
|
|
269
|
+
|
|
270
|
+
let runCount = 0;
|
|
271
|
+
let timer = null;
|
|
272
|
+
|
|
273
|
+
const scheduledRunner = {
|
|
274
|
+
isRunning: false,
|
|
275
|
+
start() {
|
|
276
|
+
if (this.isRunning) {
|
|
277
|
+
throw new Error('Scheduled runner is already running');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.isRunning = true;
|
|
281
|
+
|
|
282
|
+
if (runOnStart) {
|
|
283
|
+
this.runScript();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (interval) {
|
|
287
|
+
timer = setInterval(() => this.runScript(), interval);
|
|
288
|
+
} else if (cron) {
|
|
289
|
+
// Note: Would need a cron library implementation
|
|
290
|
+
throw new Error('Cron scheduling not implemented yet');
|
|
291
|
+
} else {
|
|
292
|
+
throw new Error('Must specify either interval or cron schedule');
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
stop() {
|
|
297
|
+
if (timer) {
|
|
298
|
+
clearInterval(timer);
|
|
299
|
+
timer = null;
|
|
300
|
+
}
|
|
301
|
+
this.isRunning = false;
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
async runScript() {
|
|
305
|
+
if (maxRuns && runCount >= maxRuns) {
|
|
306
|
+
console.log('[ScheduledRunner] Maximum runs reached, stopping');
|
|
307
|
+
this.stop();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
runCount++;
|
|
313
|
+
console.log(`[ScheduledRunner] Run #${runCount}`);
|
|
314
|
+
await ScriptRunner.run(ScriptClass);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
console.error('[ScheduledRunner] Scheduled run failed:', error);
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
getStatus() {
|
|
321
|
+
return {
|
|
322
|
+
isRunning: this.isRunning,
|
|
323
|
+
runCount,
|
|
324
|
+
maxRuns,
|
|
325
|
+
interval,
|
|
326
|
+
cron
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return scheduledRunner;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = { ScriptRunner };
|
package/src/middleware.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
const consoleOverride = require("./services/consoleOverride.service");
|
|
2
|
-
const consoleManager = require("./services/consoleManager.service");
|
|
2
|
+
const { consoleManager } = require("./services/consoleManager.service");
|
|
3
3
|
|
|
4
4
|
// Initialize console override service early to capture all logs
|
|
5
5
|
// Avoid keeping timers/streams alive during Jest runs.
|
|
6
6
|
if (process.env.NODE_ENV !== "test" && !process.env.JEST_WORKER_ID) {
|
|
7
7
|
consoleOverride.init()
|
|
8
|
+
|
|
9
|
+
// Initialize console manager after a short delay to ensure consoleOverride is fully set up
|
|
10
|
+
setTimeout(() => {
|
|
11
|
+
// Set module prefix for this middleware
|
|
12
|
+
consoleManager.setModulePrefix('middleware');
|
|
13
|
+
|
|
14
|
+
// Initialize console manager early to enable prefixing for all subsequent logs
|
|
15
|
+
consoleManager.init();
|
|
16
|
+
console.log("[Console Manager] Initialized - prefixing enabled");
|
|
17
|
+
}, 20);
|
|
8
18
|
}
|
|
9
19
|
|
|
10
20
|
const express = require("express");
|
|
@@ -119,6 +129,11 @@ function createMiddleware(options = {}) {
|
|
|
119
129
|
attachTerminalWebsocketServer,
|
|
120
130
|
} = require("./services/terminalsWs.service");
|
|
121
131
|
attachTerminalWebsocketServer(server, { basePathPrefix: adminPath });
|
|
132
|
+
|
|
133
|
+
const {
|
|
134
|
+
attachExperimentsWebsocketServer,
|
|
135
|
+
} = require("./services/experimentsWs.service");
|
|
136
|
+
attachExperimentsWebsocketServer(server);
|
|
122
137
|
};
|
|
123
138
|
|
|
124
139
|
if (!errorCaptureInitialized) {
|
|
@@ -156,17 +171,10 @@ function createMiddleware(options = {}) {
|
|
|
156
171
|
await healthChecksScheduler.start();
|
|
157
172
|
await healthChecksBootstrap.bootstrap();
|
|
158
173
|
await blogCronsBootstrap.bootstrap();
|
|
174
|
+
await require("./services/experimentsCronsBootstrap.service").bootstrap();
|
|
159
175
|
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
const consoleManagerEnabled = await isConsoleManagerEnabled();
|
|
163
|
-
if (consoleManagerEnabled) {
|
|
164
|
-
consoleManager.init();
|
|
165
|
-
console.log("[Console Manager] Initialized");
|
|
166
|
-
} else {
|
|
167
|
-
console.log("[Console Manager] Disabled - console methods not overridden");
|
|
168
|
-
}
|
|
169
|
-
}
|
|
176
|
+
// Console manager is already initialized early in the middleware
|
|
177
|
+
console.log("[Console Manager] MongoDB connection established");
|
|
170
178
|
|
|
171
179
|
return true;
|
|
172
180
|
})
|
|
@@ -254,12 +262,20 @@ function createMiddleware(options = {}) {
|
|
|
254
262
|
blogCronsBootstrap.bootstrap().catch((err) => {
|
|
255
263
|
console.error("Failed to bootstrap blog crons:", err);
|
|
256
264
|
});
|
|
265
|
+
|
|
266
|
+
require("./services/experimentsCronsBootstrap.service")
|
|
267
|
+
.bootstrap()
|
|
268
|
+
.catch((err) => {
|
|
269
|
+
console.error("Failed to bootstrap experiments crons:", err);
|
|
270
|
+
});
|
|
257
271
|
|
|
258
272
|
// Initialize console manager AFTER database is already connected
|
|
259
273
|
if (process.env.NODE_ENV !== "test" && !process.env.JEST_WORKER_ID) {
|
|
260
274
|
isConsoleManagerEnabled().then(consoleManagerEnabled => {
|
|
261
275
|
if (consoleManagerEnabled) {
|
|
262
276
|
consoleManager.init();
|
|
277
|
+
// Set module prefix after initialization
|
|
278
|
+
consoleManager.setModulePrefix('middleware');
|
|
263
279
|
console.log("[Console Manager] Initialized");
|
|
264
280
|
} else {
|
|
265
281
|
console.log("[Console Manager] Disabled - console methods not overridden");
|
|
@@ -268,6 +284,8 @@ function createMiddleware(options = {}) {
|
|
|
268
284
|
console.error("[Console Manager] Error checking enabled status:", error);
|
|
269
285
|
console.log("[Console Manager] Fallback to enabled due to error");
|
|
270
286
|
consoleManager.init();
|
|
287
|
+
// Set module prefix after initialization
|
|
288
|
+
consoleManager.setModulePrefix('middleware');
|
|
271
289
|
console.log("[Console Manager] Initialized (fallback)");
|
|
272
290
|
});
|
|
273
291
|
}
|
|
@@ -467,6 +485,37 @@ function createMiddleware(options = {}) {
|
|
|
467
485
|
});
|
|
468
486
|
});
|
|
469
487
|
|
|
488
|
+
router.get(`${adminPath}/experiments`, basicAuth, (req, res) => {
|
|
489
|
+
const templatePath = path.join(
|
|
490
|
+
__dirname,
|
|
491
|
+
"..",
|
|
492
|
+
"views",
|
|
493
|
+
"admin-experiments.ejs",
|
|
494
|
+
);
|
|
495
|
+
fs.readFile(templatePath, "utf8", (err, template) => {
|
|
496
|
+
if (err) {
|
|
497
|
+
console.error("Error reading template:", err);
|
|
498
|
+
return res.status(500).send("Error loading page");
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
const html = ejs.render(
|
|
502
|
+
template,
|
|
503
|
+
{
|
|
504
|
+
baseUrl: req.baseUrl,
|
|
505
|
+
adminPath,
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
filename: templatePath,
|
|
509
|
+
},
|
|
510
|
+
);
|
|
511
|
+
res.send(html);
|
|
512
|
+
} catch (renderErr) {
|
|
513
|
+
console.error("Error rendering template:", renderErr);
|
|
514
|
+
res.status(500).send("Error rendering page");
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
470
519
|
router.get(`${adminPath}/rbac`, basicAuth, (req, res) => {
|
|
471
520
|
const templatePath = path.join(__dirname, "..", "views", "admin-rbac.ejs");
|
|
472
521
|
fs.readFile(templatePath, "utf8", (err, template) => {
|
|
@@ -677,6 +726,7 @@ function createMiddleware(options = {}) {
|
|
|
677
726
|
require("./routes/adminDbBrowser.routes"),
|
|
678
727
|
);
|
|
679
728
|
router.use("/api/admin/terminals", require("./routes/adminTerminals.routes"));
|
|
729
|
+
router.use("/api/admin/experiments", require("./routes/adminExperiments.routes"));
|
|
680
730
|
router.use("/api/admin/assets", require("./routes/adminAssets.routes"));
|
|
681
731
|
router.use(
|
|
682
732
|
"/api/admin/upload-namespaces",
|
|
@@ -724,6 +774,7 @@ function createMiddleware(options = {}) {
|
|
|
724
774
|
router.use("/api/ui-components", require("./routes/uiComponentsPublic.routes"));
|
|
725
775
|
router.use("/api/rbac", require("./routes/rbac.routes"));
|
|
726
776
|
router.use("/api/file-manager", require("./routes/fileManager.routes"));
|
|
777
|
+
router.use("/api/experiments", require("./routes/experiments.routes"));
|
|
727
778
|
|
|
728
779
|
// Public blog APIs (headless)
|
|
729
780
|
router.use("/api", require("./routes/blogPublic.routes"));
|
|
@@ -731,6 +782,9 @@ function createMiddleware(options = {}) {
|
|
|
731
782
|
// Internal blog endpoints (used by HTTP CronJobs)
|
|
732
783
|
router.use("/api/internal", require("./routes/blogInternal.routes"));
|
|
733
784
|
|
|
785
|
+
// Internal experiments endpoints (used by HTTP CronJobs)
|
|
786
|
+
router.use("/api/internal", require("./routes/internalExperiments.routes"));
|
|
787
|
+
|
|
734
788
|
// Public health checks status (gated by global setting)
|
|
735
789
|
router.use(
|
|
736
790
|
"/api/health-checks",
|
package/src/models/CacheEntry.js
CHANGED
|
@@ -10,7 +10,7 @@ const cacheEntrySchema = new mongoose.Schema(
|
|
|
10
10
|
|
|
11
11
|
sizeBytes: { type: Number, default: 0 },
|
|
12
12
|
|
|
13
|
-
expiresAt: { type: Date, default: null
|
|
13
|
+
expiresAt: { type: Date, default: null },
|
|
14
14
|
|
|
15
15
|
hits: { type: Number, default: 0 },
|
|
16
16
|
lastAccessAt: { type: Date, default: null },
|
package/src/models/ConsoleLog.js
CHANGED
|
@@ -12,7 +12,7 @@ const consoleLogSchema = new mongoose.Schema(
|
|
|
12
12
|
requestId: { type: String, default: '', index: true },
|
|
13
13
|
|
|
14
14
|
createdAt: { type: Date, default: Date.now, index: true },
|
|
15
|
-
expiresAt: { type: Date, default: null
|
|
15
|
+
expiresAt: { type: Date, default: null },
|
|
16
16
|
},
|
|
17
17
|
{ timestamps: false, collection: 'console_logs' },
|
|
18
18
|
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const experimentVariantSchema = new mongoose.Schema(
|
|
4
|
+
{
|
|
5
|
+
key: { type: String, required: true },
|
|
6
|
+
weight: { type: Number, default: 0 },
|
|
7
|
+
configSlug: { type: String, default: '' },
|
|
8
|
+
},
|
|
9
|
+
{ _id: false },
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const metricDefinitionSchema = new mongoose.Schema(
|
|
13
|
+
{
|
|
14
|
+
key: { type: String, required: true },
|
|
15
|
+
kind: { type: String, enum: ['count', 'sum', 'avg', 'rate'], default: 'count' },
|
|
16
|
+
numeratorEventKey: { type: String, default: '' },
|
|
17
|
+
denominatorEventKey: { type: String, default: '' },
|
|
18
|
+
objective: { type: String, enum: ['maximize', 'minimize'], default: 'maximize' },
|
|
19
|
+
},
|
|
20
|
+
{ _id: false },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const winnerPolicySchema = new mongoose.Schema(
|
|
24
|
+
{
|
|
25
|
+
mode: { type: String, enum: ['manual', 'automatic'], default: 'manual' },
|
|
26
|
+
pickAfterMs: { type: Number, default: 0 },
|
|
27
|
+
minAssignments: { type: Number, default: 0 },
|
|
28
|
+
minExposures: { type: Number, default: 0 },
|
|
29
|
+
minConversions: { type: Number, default: 0 },
|
|
30
|
+
statMethod: { type: String, enum: ['simple_rate', 'bayesian_beta'], default: 'simple_rate' },
|
|
31
|
+
overrideWinnerVariantKey: { type: String, default: '' },
|
|
32
|
+
},
|
|
33
|
+
{ _id: false },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const experimentSchema = new mongoose.Schema(
|
|
37
|
+
{
|
|
38
|
+
organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', default: null, index: true },
|
|
39
|
+
|
|
40
|
+
code: { type: String, required: true },
|
|
41
|
+
name: { type: String, default: '' },
|
|
42
|
+
description: { type: String, default: '' },
|
|
43
|
+
|
|
44
|
+
status: { type: String, enum: ['draft', 'running', 'paused', 'completed'], default: 'draft', index: true },
|
|
45
|
+
|
|
46
|
+
startedAt: { type: Date, default: null },
|
|
47
|
+
endsAt: { type: Date, default: null },
|
|
48
|
+
|
|
49
|
+
assignment: {
|
|
50
|
+
unit: { type: String, enum: ['subjectId'], default: 'subjectId' },
|
|
51
|
+
sticky: { type: Boolean, default: true },
|
|
52
|
+
salt: { type: String, default: '' },
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
variants: { type: [experimentVariantSchema], default: [] },
|
|
56
|
+
|
|
57
|
+
primaryMetric: { type: metricDefinitionSchema, required: true },
|
|
58
|
+
secondaryMetrics: { type: [metricDefinitionSchema], default: [] },
|
|
59
|
+
|
|
60
|
+
winnerPolicy: { type: winnerPolicySchema, default: () => ({}) },
|
|
61
|
+
|
|
62
|
+
winnerVariantKey: { type: String, default: '' },
|
|
63
|
+
winnerDecidedAt: { type: Date, default: null },
|
|
64
|
+
winnerReason: { type: String, default: '' },
|
|
65
|
+
|
|
66
|
+
createdByUserId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null },
|
|
67
|
+
updatedByUserId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null },
|
|
68
|
+
},
|
|
69
|
+
{ timestamps: true, collection: 'experiments' },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
experimentSchema.index({ organizationId: 1, code: 1 }, { unique: true });
|
|
73
|
+
experimentSchema.index({ status: 1, startedAt: 1 });
|
|
74
|
+
|
|
75
|
+
module.exports = mongoose.models.Experiment || mongoose.model('Experiment', experimentSchema);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const experimentAssignmentSchema = new mongoose.Schema(
|
|
4
|
+
{
|
|
5
|
+
experimentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Experiment', required: true, index: true },
|
|
6
|
+
organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', default: null, index: true },
|
|
7
|
+
|
|
8
|
+
subjectKey: { type: String, required: true },
|
|
9
|
+
|
|
10
|
+
variantKey: { type: String, required: true },
|
|
11
|
+
assignedAt: { type: Date, default: () => new Date() },
|
|
12
|
+
|
|
13
|
+
context: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
14
|
+
},
|
|
15
|
+
{ timestamps: true, collection: 'experiment_assignments' },
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
experimentAssignmentSchema.index({ experimentId: 1, subjectKey: 1 }, { unique: true });
|
|
19
|
+
experimentAssignmentSchema.index({ organizationId: 1, subjectKey: 1 });
|
|
20
|
+
|
|
21
|
+
module.exports =
|
|
22
|
+
mongoose.models.ExperimentAssignment ||
|
|
23
|
+
mongoose.model('ExperimentAssignment', experimentAssignmentSchema);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const experimentEventSchema = new mongoose.Schema(
|
|
4
|
+
{
|
|
5
|
+
experimentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Experiment', required: true, index: true },
|
|
6
|
+
organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', default: null, index: true },
|
|
7
|
+
|
|
8
|
+
subjectKey: { type: String, required: true },
|
|
9
|
+
|
|
10
|
+
variantKey: { type: String, required: true, index: true },
|
|
11
|
+
|
|
12
|
+
eventKey: { type: String, required: true, index: true },
|
|
13
|
+
value: { type: Number, default: 1 },
|
|
14
|
+
|
|
15
|
+
ts: { type: Date, required: true, index: true },
|
|
16
|
+
|
|
17
|
+
meta: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
18
|
+
},
|
|
19
|
+
{ timestamps: true, collection: 'experiment_events' },
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
experimentEventSchema.index({ experimentId: 1, ts: 1 });
|
|
23
|
+
experimentEventSchema.index({ organizationId: 1, ts: 1 });
|
|
24
|
+
experimentEventSchema.index({ experimentId: 1, eventKey: 1, ts: 1 });
|
|
25
|
+
|
|
26
|
+
module.exports = mongoose.models.ExperimentEvent || mongoose.model('ExperimentEvent', experimentEventSchema);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const experimentMetricBucketSchema = new mongoose.Schema(
|
|
4
|
+
{
|
|
5
|
+
experimentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Experiment', required: true, index: true },
|
|
6
|
+
organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', default: null, index: true },
|
|
7
|
+
|
|
8
|
+
variantKey: { type: String, required: true, index: true },
|
|
9
|
+
metricKey: { type: String, required: true, index: true },
|
|
10
|
+
|
|
11
|
+
bucketStart: { type: Date, required: true, index: true },
|
|
12
|
+
bucketMs: { type: Number, required: true },
|
|
13
|
+
|
|
14
|
+
count: { type: Number, default: 0 },
|
|
15
|
+
sum: { type: Number, default: 0 },
|
|
16
|
+
sumSq: { type: Number, default: 0 },
|
|
17
|
+
min: { type: Number, default: null },
|
|
18
|
+
max: { type: Number, default: null },
|
|
19
|
+
},
|
|
20
|
+
{ timestamps: true, collection: 'experiment_metric_buckets' },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
experimentMetricBucketSchema.index(
|
|
24
|
+
{ experimentId: 1, variantKey: 1, metricKey: 1, bucketStart: 1, bucketMs: 1 },
|
|
25
|
+
{ unique: true },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
module.exports =
|
|
29
|
+
mongoose.models.ExperimentMetricBucket ||
|
|
30
|
+
mongoose.model('ExperimentMetricBucket', experimentMetricBucketSchema);
|
|
@@ -8,7 +8,7 @@ const rateLimitCounterSchema = new mongoose.Schema(
|
|
|
8
8
|
|
|
9
9
|
count: { type: Number, default: 0 },
|
|
10
10
|
|
|
11
|
-
expiresAt: { type: Date, default: null
|
|
11
|
+
expiresAt: { type: Date, default: null },
|
|
12
12
|
},
|
|
13
13
|
{ timestamps: true, collection: 'rate_limit_counters' },
|
|
14
14
|
);
|
|
@@ -16,6 +16,7 @@ const scriptDefinitionSchema = new mongoose.Schema(
|
|
|
16
16
|
type: { type: String, enum: ['bash', 'node', 'browser'], required: true },
|
|
17
17
|
runner: { type: String, enum: ['host', 'vm2', 'browser'], required: true },
|
|
18
18
|
script: { type: String, required: true },
|
|
19
|
+
scriptFormat: { type: String, enum: ['string', 'base64'], default: 'string' },
|
|
19
20
|
defaultWorkingDirectory: { type: String, default: '' },
|
|
20
21
|
env: { type: [envVarSchema], default: [] },
|
|
21
22
|
timeoutMs: { type: Number, default: 5 * 60 * 1000 },
|