@intranefr/superbackend 1.5.0 → 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 +15 -0
- package/README.md +11 -0
- package/analysis-only.skill +0 -0
- package/index.js +23 -0
- package/package.json +8 -2
- package/src/admin/endpointRegistry.js +120 -0
- package/src/controllers/admin.controller.js +90 -6
- package/src/controllers/adminBlockDefinitions.controller.js +127 -0
- package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
- package/src/controllers/adminCache.controller.js +342 -0
- package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
- package/src/controllers/adminCrons.controller.js +388 -0
- package/src/controllers/adminDbBrowser.controller.js +124 -0
- package/src/controllers/adminEjsVirtual.controller.js +13 -3
- package/src/controllers/adminExperiments.controller.js +200 -0
- package/src/controllers/adminHeadless.controller.js +9 -2
- package/src/controllers/adminHealthChecks.controller.js +570 -0
- package/src/controllers/adminI18n.controller.js +51 -29
- package/src/controllers/adminLlm.controller.js +126 -2
- package/src/controllers/adminPages.controller.js +720 -0
- package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
- package/src/controllers/adminProxy.controller.js +113 -0
- package/src/controllers/adminRateLimits.controller.js +138 -0
- package/src/controllers/adminRbac.controller.js +803 -0
- package/src/controllers/adminScripts.controller.js +126 -4
- package/src/controllers/adminSeoConfig.controller.js +71 -48
- package/src/controllers/blogAdmin.controller.js +279 -0
- package/src/controllers/blogAiAdmin.controller.js +224 -0
- package/src/controllers/blogAutomationAdmin.controller.js +141 -0
- package/src/controllers/blogInternal.controller.js +26 -0
- package/src/controllers/blogPublic.controller.js +89 -0
- package/src/controllers/experiments.controller.js +85 -0
- package/src/controllers/fileManager.controller.js +190 -0
- package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
- package/src/controllers/healthChecksPublic.controller.js +196 -0
- package/src/controllers/internalExperiments.controller.js +17 -0
- package/src/controllers/metrics.controller.js +64 -4
- package/src/controllers/orgAdmin.controller.js +80 -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/rbac.js +62 -0
- package/src/middleware.js +810 -48
- package/src/models/BlockDefinition.js +27 -0
- package/src/models/BlogAutomationLock.js +14 -0
- package/src/models/BlogAutomationRun.js +39 -0
- package/src/models/BlogPost.js +42 -0
- package/src/models/CacheEntry.js +26 -0
- package/src/models/ConsoleEntry.js +32 -0
- package/src/models/ConsoleLog.js +23 -0
- package/src/models/ContextBlockDefinition.js +33 -0
- package/src/models/CronExecution.js +47 -0
- package/src/models/CronJob.js +70 -0
- 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/ExternalDbConnection.js +49 -0
- package/src/models/FileEntry.js +22 -0
- package/src/models/GlobalSetting.js +1 -2
- package/src/models/HealthAutoHealAttempt.js +57 -0
- package/src/models/HealthCheck.js +132 -0
- package/src/models/HealthCheckRun.js +51 -0
- package/src/models/HealthIncident.js +49 -0
- package/src/models/Page.js +95 -0
- package/src/models/PageCollection.js +42 -0
- package/src/models/ProxyEntry.js +66 -0
- package/src/models/RateLimitCounter.js +19 -0
- package/src/models/RateLimitMetricBucket.js +20 -0
- package/src/models/RbacGrant.js +25 -0
- package/src/models/RbacGroup.js +16 -0
- package/src/models/RbacGroupMember.js +13 -0
- package/src/models/RbacGroupRole.js +13 -0
- package/src/models/RbacRole.js +25 -0
- package/src/models/RbacUserRole.js +13 -0
- 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/adminBlog.routes.js +21 -0
- package/src/routes/adminBlogAi.routes.js +16 -0
- package/src/routes/adminBlogAutomation.routes.js +27 -0
- package/src/routes/adminCache.routes.js +20 -0
- package/src/routes/adminConsoleManager.routes.js +302 -0
- package/src/routes/adminCrons.routes.js +25 -0
- package/src/routes/adminDbBrowser.routes.js +65 -0
- package/src/routes/adminEjsVirtual.routes.js +2 -1
- package/src/routes/adminExperiments.routes.js +29 -0
- package/src/routes/adminHeadless.routes.js +2 -1
- package/src/routes/adminHealthChecks.routes.js +28 -0
- package/src/routes/adminI18n.routes.js +4 -3
- package/src/routes/adminLlm.routes.js +4 -2
- package/src/routes/adminPages.routes.js +55 -0
- package/src/routes/adminProxy.routes.js +15 -0
- package/src/routes/adminRateLimits.routes.js +17 -0
- package/src/routes/adminRbac.routes.js +38 -0
- package/src/routes/adminSeoConfig.routes.js +5 -4
- package/src/routes/adminUiComponents.routes.js +2 -1
- package/src/routes/blogInternal.routes.js +14 -0
- package/src/routes/blogPublic.routes.js +9 -0
- package/src/routes/experiments.routes.js +30 -0
- package/src/routes/fileManager.routes.js +62 -0
- package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
- package/src/routes/healthChecksPublic.routes.js +9 -0
- package/src/routes/internalExperiments.routes.js +15 -0
- package/src/routes/log.routes.js +43 -60
- package/src/routes/metrics.routes.js +4 -2
- package/src/routes/orgAdmin.routes.js +1 -0
- package/src/routes/pages.routes.js +123 -0
- package/src/routes/proxy.routes.js +46 -0
- package/src/routes/rbac.routes.js +47 -0
- package/src/routes/webhook.routes.js +2 -1
- package/src/routes/workflows.routes.js +4 -0
- package/src/services/blockDefinitionsAi.service.js +247 -0
- package/src/services/blog.service.js +99 -0
- package/src/services/blogAutomation.service.js +978 -0
- package/src/services/blogCronsBootstrap.service.js +185 -0
- package/src/services/blogPublishing.service.js +58 -0
- package/src/services/cacheLayer.service.js +696 -0
- package/src/services/consoleManager.service.js +738 -0
- package/src/services/consoleOverride.service.js +7 -1
- package/src/services/cronScheduler.service.js +350 -0
- package/src/services/dbBrowser.service.js +536 -0
- package/src/services/ejsVirtual.service.js +102 -32
- 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/fileManager.service.js +475 -0
- package/src/services/fileManagerStoragePolicy.service.js +285 -0
- package/src/services/globalSettings.service.js +15 -0
- package/src/services/healthChecks.service.js +650 -0
- package/src/services/healthChecksBootstrap.service.js +109 -0
- package/src/services/healthChecksScheduler.service.js +106 -0
- package/src/services/jsonConfigs.service.js +2 -2
- package/src/services/llmDefaults.service.js +190 -0
- package/src/services/migrationAssets/s3.js +2 -2
- package/src/services/pages.service.js +602 -0
- package/src/services/pagesContext.service.js +331 -0
- package/src/services/pagesContextBlocksAi.service.js +349 -0
- package/src/services/proxy.service.js +535 -0
- package/src/services/rateLimiter.service.js +623 -0
- package/src/services/rbac.service.js +212 -0
- package/src/services/scriptsRunner.service.js +215 -15
- package/src/services/uiComponentsAi.service.js +6 -19
- package/src/services/workflow.service.js +23 -8
- package/src/utils/orgRoles.js +14 -0
- package/src/utils/rbac/engine.js +60 -0
- package/src/utils/rbac/rightsRegistry.js +33 -0
- package/views/admin-blog-automation.ejs +877 -0
- package/views/admin-blog-edit.ejs +542 -0
- package/views/admin-blog.ejs +399 -0
- package/views/admin-cache.ejs +681 -0
- package/views/admin-console-manager.ejs +680 -0
- package/views/admin-crons.ejs +645 -0
- package/views/admin-dashboard.ejs +28 -8
- package/views/admin-db-browser.ejs +445 -0
- package/views/admin-ejs-virtual.ejs +16 -10
- package/views/admin-experiments.ejs +91 -0
- package/views/admin-file-manager.ejs +942 -0
- package/views/admin-health-checks.ejs +725 -0
- package/views/admin-i18n.ejs +59 -5
- package/views/admin-llm.ejs +99 -1
- package/views/admin-organizations.ejs +163 -1
- package/views/admin-pages.ejs +2424 -0
- package/views/admin-proxy.ejs +491 -0
- package/views/admin-rate-limiter.ejs +625 -0
- package/views/admin-rbac.ejs +1331 -0
- package/views/admin-scripts.ejs +597 -3
- package/views/admin-seo-config.ejs +61 -7
- package/views/admin-ui-components.ejs +57 -25
- package/views/admin-workflows.ejs +7 -7
- package/views/file-manager.ejs +866 -0
- package/views/pages/blocks/contact.ejs +27 -0
- package/views/pages/blocks/cta.ejs +18 -0
- package/views/pages/blocks/faq.ejs +20 -0
- package/views/pages/blocks/features.ejs +19 -0
- package/views/pages/blocks/hero.ejs +13 -0
- package/views/pages/blocks/html.ejs +5 -0
- package/views/pages/blocks/image.ejs +14 -0
- package/views/pages/blocks/testimonials.ejs +26 -0
- package/views/pages/blocks/text.ejs +10 -0
- package/views/pages/layouts/default.ejs +51 -0
- package/views/pages/layouts/minimal.ejs +42 -0
- package/views/pages/layouts/sidebar.ejs +54 -0
- package/views/pages/partials/footer.ejs +13 -0
- package/views/pages/partials/header.ejs +12 -0
- package/views/pages/partials/sidebar.ejs +8 -0
- package/views/pages/runtime/page.ejs +10 -0
- package/views/pages/templates/article.ejs +20 -0
- package/views/pages/templates/default.ejs +12 -0
- package/views/pages/templates/landing.ejs +14 -0
- package/views/pages/templates/listing.ejs +15 -0
- package/views/partials/admin-image-upload-modal.ejs +221 -0
- package/views/partials/dashboard/nav-items.ejs +12 -0
- package/views/partials/dashboard/palette.ejs +5 -3
- package/views/partials/llm-provider-model-picker.ejs +183 -0
- package/src/routes/llmUi.routes.js +0 -26
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
const parser = require('cron-parser');
|
|
2
|
+
|
|
3
|
+
const HealthCheck = require('../models/HealthCheck');
|
|
4
|
+
const HealthCheckRun = require('../models/HealthCheckRun');
|
|
5
|
+
const HealthIncident = require('../models/HealthIncident');
|
|
6
|
+
const HealthAutoHealAttempt = require('../models/HealthAutoHealAttempt');
|
|
7
|
+
|
|
8
|
+
const globalSettingsService = require('./globalSettings.service');
|
|
9
|
+
const notificationService = require('./notification.service');
|
|
10
|
+
const { startRun } = require('./scriptsRunner.service');
|
|
11
|
+
|
|
12
|
+
function nowIso() {
|
|
13
|
+
return new Date().toISOString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeHeaders(headers) {
|
|
17
|
+
const items = Array.isArray(headers) ? headers : [];
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const it of items) {
|
|
20
|
+
if (!it || typeof it !== 'object') continue;
|
|
21
|
+
const key = String(it.key || '').trim();
|
|
22
|
+
if (!key) continue;
|
|
23
|
+
out[key] = String(it.value || '');
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildBasicAuthHeader(username, password) {
|
|
29
|
+
const encoded = Buffer.from(`${username}:${password}`).toString('base64');
|
|
30
|
+
return `Basic ${encoded}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function buildAuthHeaderFromRefs(httpAuth) {
|
|
34
|
+
const auth = httpAuth || { type: 'none' };
|
|
35
|
+
|
|
36
|
+
if (auth.type === 'bearer') {
|
|
37
|
+
const settingKey = String(auth.tokenSettingKey || '').trim();
|
|
38
|
+
const token = settingKey ? await globalSettingsService.getSettingValue(settingKey, '') : '';
|
|
39
|
+
if (!token) return null;
|
|
40
|
+
return `Bearer ${token}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (auth.type === 'basic') {
|
|
44
|
+
const username = String(auth.username || '').trim();
|
|
45
|
+
const passwordKey = String(auth.passwordSettingKey || '').trim();
|
|
46
|
+
const password = passwordKey ? await globalSettingsService.getSettingValue(passwordKey, '') : '';
|
|
47
|
+
if (!username || !password) return null;
|
|
48
|
+
return buildBasicAuthHeader(username, password);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function shouldTreatAsUnhealthy(status) {
|
|
55
|
+
return status === 'unhealthy' || status === 'timed_out' || status === 'error';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeCompileRegex(pattern) {
|
|
59
|
+
if (!pattern) return null;
|
|
60
|
+
try {
|
|
61
|
+
return new RegExp(String(pattern));
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fetchWithTimeout(url, options, timeoutMs) {
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
70
|
+
timeout.unref?.();
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(url, { ...options, signal: controller.signal });
|
|
74
|
+
return res;
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function executeHttpOnce(check) {
|
|
81
|
+
const startedAt = Date.now();
|
|
82
|
+
|
|
83
|
+
const headers = normalizeHeaders(check.httpHeaders);
|
|
84
|
+
|
|
85
|
+
const authHeader = await buildAuthHeaderFromRefs(check.httpAuth);
|
|
86
|
+
if (authHeader) headers.Authorization = authHeader;
|
|
87
|
+
|
|
88
|
+
let body = undefined;
|
|
89
|
+
if (String(check.httpMethod || 'GET') !== 'GET' && String(check.httpBody || '')) {
|
|
90
|
+
const bt = String(check.httpBodyType || 'raw');
|
|
91
|
+
if (bt === 'json') {
|
|
92
|
+
headers['Content-Type'] = 'application/json';
|
|
93
|
+
body = String(check.httpBody || '');
|
|
94
|
+
} else if (bt === 'form') {
|
|
95
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
96
|
+
body = String(check.httpBody || '');
|
|
97
|
+
} else {
|
|
98
|
+
headers['Content-Type'] = 'text/plain';
|
|
99
|
+
body = String(check.httpBody || '');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const timeoutMs = Number(check.timeoutMs || 0) || 30000;
|
|
104
|
+
|
|
105
|
+
let response;
|
|
106
|
+
let responseText = '';
|
|
107
|
+
let httpStatusCode = null;
|
|
108
|
+
let httpResponseHeaders = null;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
response = await fetchWithTimeout(
|
|
112
|
+
String(check.httpUrl),
|
|
113
|
+
{
|
|
114
|
+
method: String(check.httpMethod || 'GET'),
|
|
115
|
+
headers,
|
|
116
|
+
body,
|
|
117
|
+
},
|
|
118
|
+
timeoutMs,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
httpStatusCode = response.status;
|
|
122
|
+
httpResponseHeaders = Object.fromEntries(response.headers.entries());
|
|
123
|
+
|
|
124
|
+
// We only store a snippet to keep the DB small.
|
|
125
|
+
responseText = await response.text();
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const latencyMs = Date.now() - startedAt;
|
|
128
|
+
const isAbort = err && (err.name === 'AbortError' || String(err.message || '').includes('aborted'));
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
status: isAbort ? 'timed_out' : 'error',
|
|
132
|
+
latencyMs,
|
|
133
|
+
httpStatusCode,
|
|
134
|
+
httpResponseHeaders,
|
|
135
|
+
responseBodySnippet: '',
|
|
136
|
+
reason: isAbort ? 'Request timed out' : 'Request failed',
|
|
137
|
+
errorMessage: err?.message || 'Request failed',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const latencyMs = Date.now() - startedAt;
|
|
142
|
+
const snippet = String(responseText || '').slice(0, 4000);
|
|
143
|
+
|
|
144
|
+
const expectedCodes = Array.isArray(check.expectedStatusCodes) && check.expectedStatusCodes.length
|
|
145
|
+
? check.expectedStatusCodes.map((n) => Number(n)).filter((n) => Number.isFinite(n))
|
|
146
|
+
: [200];
|
|
147
|
+
|
|
148
|
+
if (!expectedCodes.includes(Number(httpStatusCode))) {
|
|
149
|
+
return {
|
|
150
|
+
status: 'unhealthy',
|
|
151
|
+
latencyMs,
|
|
152
|
+
httpStatusCode,
|
|
153
|
+
httpResponseHeaders,
|
|
154
|
+
responseBodySnippet: snippet,
|
|
155
|
+
reason: `Unexpected status code: ${httpStatusCode}`,
|
|
156
|
+
errorMessage: '',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const maxLatencyMs = Number(check.maxLatencyMs || 0) || null;
|
|
161
|
+
if (maxLatencyMs && latencyMs > maxLatencyMs) {
|
|
162
|
+
return {
|
|
163
|
+
status: 'unhealthy',
|
|
164
|
+
latencyMs,
|
|
165
|
+
httpStatusCode,
|
|
166
|
+
httpResponseHeaders,
|
|
167
|
+
responseBodySnippet: snippet,
|
|
168
|
+
reason: `Latency ${latencyMs}ms exceeded maxLatencyMs ${maxLatencyMs}ms`,
|
|
169
|
+
errorMessage: '',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const mustMatch = safeCompileRegex(check.bodyMustMatch);
|
|
174
|
+
if (mustMatch && !mustMatch.test(responseText)) {
|
|
175
|
+
return {
|
|
176
|
+
status: 'unhealthy',
|
|
177
|
+
latencyMs,
|
|
178
|
+
httpStatusCode,
|
|
179
|
+
httpResponseHeaders,
|
|
180
|
+
responseBodySnippet: snippet,
|
|
181
|
+
reason: 'Response body did not match bodyMustMatch',
|
|
182
|
+
errorMessage: '',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const mustNotMatch = safeCompileRegex(check.bodyMustNotMatch);
|
|
187
|
+
if (mustNotMatch && mustNotMatch.test(responseText)) {
|
|
188
|
+
return {
|
|
189
|
+
status: 'unhealthy',
|
|
190
|
+
latencyMs,
|
|
191
|
+
httpStatusCode,
|
|
192
|
+
httpResponseHeaders,
|
|
193
|
+
responseBodySnippet: snippet,
|
|
194
|
+
reason: 'Response body matched bodyMustNotMatch',
|
|
195
|
+
errorMessage: '',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
status: 'healthy',
|
|
201
|
+
latencyMs,
|
|
202
|
+
httpStatusCode,
|
|
203
|
+
httpResponseHeaders,
|
|
204
|
+
responseBodySnippet: snippet,
|
|
205
|
+
reason: 'OK',
|
|
206
|
+
errorMessage: '',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function waitForScriptCompletion(runId, timeoutMs) {
|
|
211
|
+
const ScriptRun = require('../models/ScriptRun');
|
|
212
|
+
|
|
213
|
+
const timeout = Number(timeoutMs || 0) || 5 * 60 * 1000;
|
|
214
|
+
const startedAt = Date.now();
|
|
215
|
+
|
|
216
|
+
while (Date.now() - startedAt < timeout) {
|
|
217
|
+
const run = await ScriptRun.findById(runId).lean();
|
|
218
|
+
if (!run) throw new Error('Script run not found');
|
|
219
|
+
|
|
220
|
+
if (run.status === 'queued' || run.status === 'running') {
|
|
221
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (run.status === 'succeeded') {
|
|
226
|
+
return { ok: true, outputTail: run.outputTail || '' };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { ok: false, outputTail: run.outputTail || '', error: run.error || 'Script failed' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { ok: false, outputTail: '', error: 'Script execution timeout' };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function executeScriptOnce(check) {
|
|
236
|
+
const ScriptDefinition = require('../models/ScriptDefinition');
|
|
237
|
+
|
|
238
|
+
const doc = await ScriptDefinition.findById(check.scriptId);
|
|
239
|
+
if (!doc) {
|
|
240
|
+
return { status: 'error', latencyMs: 0, reason: 'Script not found', errorMessage: 'Script not found' };
|
|
241
|
+
}
|
|
242
|
+
if (!doc.enabled) {
|
|
243
|
+
return { status: 'error', latencyMs: 0, reason: 'Script is disabled', errorMessage: 'Script is disabled' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Merge env
|
|
247
|
+
const env = Array.isArray(doc.env) ? [...doc.env.map((e) => ({ key: e.key, value: e.value }))] : [];
|
|
248
|
+
const overrides = Array.isArray(check.scriptEnv) ? check.scriptEnv : [];
|
|
249
|
+
for (const kv of overrides) {
|
|
250
|
+
const key = String(kv?.key || '').trim();
|
|
251
|
+
if (!key) continue;
|
|
252
|
+
const idx = env.findIndex((e) => e.key === key);
|
|
253
|
+
const next = { key, value: String(kv?.value || '') };
|
|
254
|
+
if (idx >= 0) env[idx] = next;
|
|
255
|
+
else env.push(next);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const modified = {
|
|
259
|
+
...doc.toObject(),
|
|
260
|
+
env,
|
|
261
|
+
timeoutMs: Number(check.timeoutMs || 0) || Number(doc.timeoutMs || 0) || 5 * 60 * 1000,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const startedAt = Date.now();
|
|
265
|
+
const runDoc = await startRun(modified, {
|
|
266
|
+
trigger: 'schedule',
|
|
267
|
+
meta: { source: 'healthCheck', healthCheckId: String(check._id) },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const out = await waitForScriptCompletion(runDoc._id, modified.timeoutMs);
|
|
271
|
+
const latencyMs = Date.now() - startedAt;
|
|
272
|
+
|
|
273
|
+
if (!out.ok) {
|
|
274
|
+
return {
|
|
275
|
+
status: 'unhealthy',
|
|
276
|
+
latencyMs,
|
|
277
|
+
httpStatusCode: null,
|
|
278
|
+
httpResponseHeaders: null,
|
|
279
|
+
responseBodySnippet: String(out.outputTail || '').slice(0, 4000),
|
|
280
|
+
reason: out.error || 'Script failed',
|
|
281
|
+
errorMessage: out.error || 'Script failed',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
status: 'healthy',
|
|
287
|
+
latencyMs,
|
|
288
|
+
httpStatusCode: null,
|
|
289
|
+
httpResponseHeaders: null,
|
|
290
|
+
responseBodySnippet: String(out.outputTail || '').slice(0, 4000),
|
|
291
|
+
reason: 'OK',
|
|
292
|
+
errorMessage: '',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function notifyIfConfigured(check, incident, event) {
|
|
297
|
+
// event: 'open' | 'resolve' | 'escalate'
|
|
298
|
+
if (!check) return;
|
|
299
|
+
|
|
300
|
+
if (event === 'open' && !check.notifyOnOpen) return;
|
|
301
|
+
if (event === 'resolve' && !check.notifyOnResolve) return;
|
|
302
|
+
if (event === 'escalate' && !check.notifyOnEscalation) return;
|
|
303
|
+
|
|
304
|
+
if (incident && incident.status === 'acknowledged' && check.suppressNotificationsWhenAcknowledged && event !== 'resolve') {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const userIds = Array.isArray(check.notifyUserIds) ? check.notifyUserIds : [];
|
|
309
|
+
if (!userIds.length) return;
|
|
310
|
+
|
|
311
|
+
const channel = String(check.notificationChannel || 'in_app');
|
|
312
|
+
|
|
313
|
+
const titlePrefix = event === 'resolve' ? 'Resolved' : event === 'escalate' ? 'Escalation' : 'Incident';
|
|
314
|
+
const title = `${titlePrefix}: ${check.name}`;
|
|
315
|
+
|
|
316
|
+
const message =
|
|
317
|
+
event === 'resolve'
|
|
318
|
+
? `Health check recovered: ${check.name}`
|
|
319
|
+
: `Health check unhealthy: ${check.name}${incident?.lastError ? ` (${incident.lastError})` : ''}`;
|
|
320
|
+
|
|
321
|
+
await notificationService.sendToUsers({
|
|
322
|
+
userIds: userIds.map((id) => String(id)),
|
|
323
|
+
type: event === 'resolve' ? 'success' : 'error',
|
|
324
|
+
title,
|
|
325
|
+
message,
|
|
326
|
+
channel,
|
|
327
|
+
metadata: {
|
|
328
|
+
source: 'healthChecks',
|
|
329
|
+
healthCheckId: String(check._id),
|
|
330
|
+
incidentId: incident?._id ? String(incident._id) : null,
|
|
331
|
+
event,
|
|
332
|
+
ts: nowIso(),
|
|
333
|
+
},
|
|
334
|
+
sentByAdminId: null,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function updateIncidentState(check, runOutcome) {
|
|
339
|
+
const failureThreshold = Math.max(1, Number(check.consecutiveFailuresToOpen || 3));
|
|
340
|
+
const successThreshold = Math.max(1, Number(check.consecutiveSuccessesToResolve || 2));
|
|
341
|
+
|
|
342
|
+
const isUnhealthy = shouldTreatAsUnhealthy(runOutcome.status);
|
|
343
|
+
|
|
344
|
+
if (isUnhealthy) {
|
|
345
|
+
check.consecutiveFailureCount = Number(check.consecutiveFailureCount || 0) + 1;
|
|
346
|
+
check.consecutiveSuccessCount = 0;
|
|
347
|
+
} else {
|
|
348
|
+
check.consecutiveSuccessCount = Number(check.consecutiveSuccessCount || 0) + 1;
|
|
349
|
+
check.consecutiveFailureCount = 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let incident = null;
|
|
353
|
+
if (check.currentIncidentId) {
|
|
354
|
+
incident = await HealthIncident.findById(check.currentIncidentId);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// If pointer is stale, try to find active incident.
|
|
358
|
+
if (!incident || (incident.status !== 'open' && incident.status !== 'acknowledged')) {
|
|
359
|
+
incident = await HealthIncident.findOne({
|
|
360
|
+
healthCheckId: check._id,
|
|
361
|
+
status: { $in: ['open', 'acknowledged'] },
|
|
362
|
+
}).sort({ openedAt: -1 });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!incident) {
|
|
366
|
+
if (isUnhealthy && check.consecutiveFailureCount >= failureThreshold) {
|
|
367
|
+
incident = await HealthIncident.create({
|
|
368
|
+
healthCheckId: check._id,
|
|
369
|
+
status: 'open',
|
|
370
|
+
severity: check.consecutiveFailureCount >= failureThreshold * 2 ? 'critical' : 'warning',
|
|
371
|
+
openedAt: new Date(),
|
|
372
|
+
lastSeenAt: new Date(),
|
|
373
|
+
consecutiveFailureCount: check.consecutiveFailureCount,
|
|
374
|
+
consecutiveSuccessCount: 0,
|
|
375
|
+
summary: runOutcome.reason || 'Unhealthy',
|
|
376
|
+
lastError: runOutcome.errorMessage || runOutcome.reason || '',
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
check.currentIncidentId = incident._id;
|
|
380
|
+
await notifyIfConfigured(check, incident, 'open');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { incident, event: null };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Update existing incident
|
|
387
|
+
incident.lastSeenAt = new Date();
|
|
388
|
+
incident.consecutiveFailureCount = Number(check.consecutiveFailureCount || 0);
|
|
389
|
+
incident.consecutiveSuccessCount = Number(check.consecutiveSuccessCount || 0);
|
|
390
|
+
incident.lastError = runOutcome.errorMessage || runOutcome.reason || '';
|
|
391
|
+
incident.summary = runOutcome.reason || incident.summary;
|
|
392
|
+
|
|
393
|
+
let event = null;
|
|
394
|
+
|
|
395
|
+
// Escalation rule: warning -> critical when >= 2x failure threshold
|
|
396
|
+
if (isUnhealthy && incident.severity !== 'critical' && check.consecutiveFailureCount >= failureThreshold * 2) {
|
|
397
|
+
incident.severity = 'critical';
|
|
398
|
+
event = 'escalate';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!isUnhealthy && check.consecutiveSuccessCount >= successThreshold) {
|
|
402
|
+
incident.status = 'resolved';
|
|
403
|
+
incident.resolvedAt = new Date();
|
|
404
|
+
event = 'resolve';
|
|
405
|
+
|
|
406
|
+
check.currentIncidentId = undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
await incident.save();
|
|
410
|
+
|
|
411
|
+
if (event === 'resolve') {
|
|
412
|
+
await notifyIfConfigured(check, incident, 'resolve');
|
|
413
|
+
} else if (event === 'escalate') {
|
|
414
|
+
await notifyIfConfigured(check, incident, 'escalate');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { incident, event };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function maybeAutoHeal(check, incident, triggerEvent) {
|
|
421
|
+
if (!check?.autoHealEnabled) return;
|
|
422
|
+
if (!incident) return;
|
|
423
|
+
if (triggerEvent !== 'open' && triggerEvent !== 'escalate') return;
|
|
424
|
+
|
|
425
|
+
if (incident.status === 'acknowledged' && check.suppressNotificationsWhenAcknowledged) {
|
|
426
|
+
// If the incident is acknowledged, we still allow auto-heal; user only requested notification suppression.
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const maxAttempts = Math.max(1, Number(check.autoHealMaxAttemptsPerIncident || 3));
|
|
430
|
+
if (Number(incident.autoHealAttemptCount || 0) >= maxAttempts) return;
|
|
431
|
+
|
|
432
|
+
const cooldownMs = Math.max(0, Number(check.autoHealCooldownMs || 0));
|
|
433
|
+
const lastAt = incident.lastAutoHealAttemptAt ? new Date(incident.lastAutoHealAttemptAt).getTime() : 0;
|
|
434
|
+
if (lastAt && cooldownMs && Date.now() - lastAt < cooldownMs) return;
|
|
435
|
+
|
|
436
|
+
const waitMs = Math.max(0, Number(check.autoHealWaitMs || 0));
|
|
437
|
+
if (waitMs) {
|
|
438
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const attemptNumber = Number(incident.autoHealAttemptCount || 0) + 1;
|
|
442
|
+
|
|
443
|
+
const attempt = await HealthAutoHealAttempt.create({
|
|
444
|
+
healthCheckId: check._id,
|
|
445
|
+
incidentId: incident._id,
|
|
446
|
+
attemptNumber,
|
|
447
|
+
status: 'running',
|
|
448
|
+
startedAt: new Date(),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
incident.autoHealAttemptCount = attemptNumber;
|
|
452
|
+
incident.lastAutoHealAttemptAt = new Date();
|
|
453
|
+
await incident.save();
|
|
454
|
+
|
|
455
|
+
const actionResults = [];
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const actions = Array.isArray(check.autoHealActions) ? check.autoHealActions : [];
|
|
459
|
+
|
|
460
|
+
for (const action of actions) {
|
|
461
|
+
if (!action || typeof action !== 'object') continue;
|
|
462
|
+
|
|
463
|
+
if (action.type === 'notify_only') {
|
|
464
|
+
actionResults.push({ actionType: 'notify_only', status: 'succeeded', output: 'noop', error: '' });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (action.type === 'http') {
|
|
469
|
+
const timeoutMs = Number(action.timeoutMs || 0) || 30000;
|
|
470
|
+
const headers = normalizeHeaders(action.httpHeaders);
|
|
471
|
+
const authHeader = await buildAuthHeaderFromRefs(action.httpAuth);
|
|
472
|
+
if (authHeader) headers.Authorization = authHeader;
|
|
473
|
+
|
|
474
|
+
let body = undefined;
|
|
475
|
+
const method = String(action.httpMethod || 'POST');
|
|
476
|
+
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
477
|
+
const bt = String(action.httpBodyType || 'raw');
|
|
478
|
+
if (bt === 'json') {
|
|
479
|
+
headers['Content-Type'] = 'application/json';
|
|
480
|
+
body = String(action.httpBody || '');
|
|
481
|
+
} else if (bt === 'form') {
|
|
482
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
483
|
+
body = String(action.httpBody || '');
|
|
484
|
+
} else {
|
|
485
|
+
headers['Content-Type'] = 'text/plain';
|
|
486
|
+
body = String(action.httpBody || '');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const res = await fetchWithTimeout(
|
|
491
|
+
String(action.httpUrl || ''),
|
|
492
|
+
{ method, headers, body },
|
|
493
|
+
timeoutMs,
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const out = await res.text();
|
|
497
|
+
if (!res.ok) {
|
|
498
|
+
actionResults.push({
|
|
499
|
+
actionType: 'http',
|
|
500
|
+
status: 'failed',
|
|
501
|
+
output: String(out || '').slice(0, 2000),
|
|
502
|
+
error: `HTTP ${res.status} ${res.statusText}`,
|
|
503
|
+
});
|
|
504
|
+
throw new Error(`Auto-heal HTTP action failed: ${res.status}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
actionResults.push({
|
|
508
|
+
actionType: 'http',
|
|
509
|
+
status: 'succeeded',
|
|
510
|
+
output: String(out || '').slice(0, 2000),
|
|
511
|
+
error: '',
|
|
512
|
+
});
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (action.type === 'script') {
|
|
517
|
+
// Reuse script execution semantics from executeScriptOnce.
|
|
518
|
+
const tmpCheck = { ...check.toObject(), scriptId: action.scriptId, scriptEnv: action.scriptEnv };
|
|
519
|
+
const res = await executeScriptOnce(tmpCheck);
|
|
520
|
+
if (res.status !== 'healthy') {
|
|
521
|
+
actionResults.push({
|
|
522
|
+
actionType: 'script',
|
|
523
|
+
status: 'failed',
|
|
524
|
+
output: String(res.responseBodySnippet || '').slice(0, 2000),
|
|
525
|
+
error: res.errorMessage || res.reason || 'script failed',
|
|
526
|
+
});
|
|
527
|
+
throw new Error('Auto-heal script action failed');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
actionResults.push({
|
|
531
|
+
actionType: 'script',
|
|
532
|
+
status: 'succeeded',
|
|
533
|
+
output: String(res.responseBodySnippet || '').slice(0, 2000),
|
|
534
|
+
error: '',
|
|
535
|
+
});
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
attempt.status = 'succeeded';
|
|
541
|
+
attempt.finishedAt = new Date();
|
|
542
|
+
attempt.actionResults = actionResults;
|
|
543
|
+
await attempt.save();
|
|
544
|
+
|
|
545
|
+
return attempt;
|
|
546
|
+
} catch (err) {
|
|
547
|
+
attempt.status = 'failed';
|
|
548
|
+
attempt.finishedAt = new Date();
|
|
549
|
+
attempt.actionResults = actionResults;
|
|
550
|
+
await attempt.save();
|
|
551
|
+
return attempt;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function runHealthCheckOnce(healthCheckId, { trigger = 'schedule' } = {}) {
|
|
556
|
+
const check = await HealthCheck.findById(healthCheckId);
|
|
557
|
+
if (!check) {
|
|
558
|
+
throw new Error('HealthCheck not found');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (!check.enabled && trigger === 'schedule') {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const runDoc = await HealthCheckRun.create({
|
|
566
|
+
healthCheckId: check._id,
|
|
567
|
+
status: 'running',
|
|
568
|
+
startedAt: new Date(),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
let finalOutcome = null;
|
|
572
|
+
|
|
573
|
+
const retries = Math.max(0, Number(check.retries || 0));
|
|
574
|
+
const retryDelayMs = Math.max(0, Number(check.retryDelayMs || 0));
|
|
575
|
+
|
|
576
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
577
|
+
// eslint-disable-next-line no-await-in-loop
|
|
578
|
+
if (attempt > 0 && retryDelayMs) await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
579
|
+
|
|
580
|
+
// eslint-disable-next-line no-await-in-loop
|
|
581
|
+
const outcome =
|
|
582
|
+
check.checkType === 'script'
|
|
583
|
+
? await executeScriptOnce(check)
|
|
584
|
+
: await executeHttpOnce(check);
|
|
585
|
+
|
|
586
|
+
finalOutcome = { ...outcome, attempt };
|
|
587
|
+
|
|
588
|
+
if (!shouldTreatAsUnhealthy(outcome.status)) {
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
await HealthCheckRun.updateOne(
|
|
594
|
+
{ _id: runDoc._id },
|
|
595
|
+
{
|
|
596
|
+
$set: {
|
|
597
|
+
status: finalOutcome.status,
|
|
598
|
+
finishedAt: new Date(),
|
|
599
|
+
latencyMs: finalOutcome.latencyMs,
|
|
600
|
+
httpStatusCode: finalOutcome.httpStatusCode,
|
|
601
|
+
httpResponseHeaders: finalOutcome.httpResponseHeaders,
|
|
602
|
+
responseBodySnippet: finalOutcome.responseBodySnippet,
|
|
603
|
+
reason: finalOutcome.reason,
|
|
604
|
+
errorMessage: finalOutcome.errorMessage,
|
|
605
|
+
attempt: finalOutcome.attempt,
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
check.lastRunAt = new Date();
|
|
611
|
+
check.lastLatencyMs = finalOutcome.latencyMs;
|
|
612
|
+
check.lastStatus = finalOutcome.status === 'healthy' ? 'healthy' : 'unhealthy';
|
|
613
|
+
|
|
614
|
+
const { incident, event } = await updateIncidentState(check, finalOutcome);
|
|
615
|
+
|
|
616
|
+
if (incident) {
|
|
617
|
+
await HealthCheckRun.updateOne({ _id: runDoc._id }, { $set: { incidentId: incident._id } });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
await check.save();
|
|
621
|
+
|
|
622
|
+
if (event === 'open' || event === 'escalate') {
|
|
623
|
+
await maybeAutoHeal(check, incident, event);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
runId: String(runDoc._id),
|
|
628
|
+
status: finalOutcome.status,
|
|
629
|
+
incidentId: incident?._id ? String(incident._id) : null,
|
|
630
|
+
event,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function calculateNextRun(cronExpression, timezone = 'UTC') {
|
|
635
|
+
const interval = parser.parseExpression(String(cronExpression || '').trim(), { tz: String(timezone || 'UTC') });
|
|
636
|
+
return interval.next().toDate();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function cleanupRunsOlderThanDays(days) {
|
|
640
|
+
const d = Math.max(0, Number(days || 0));
|
|
641
|
+
const cutoff = new Date(Date.now() - d * 24 * 60 * 60 * 1000);
|
|
642
|
+
const res = await HealthCheckRun.deleteMany({ startedAt: { $lt: cutoff } });
|
|
643
|
+
return { deletedCount: res.deletedCount || 0, cutoff };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
module.exports = {
|
|
647
|
+
calculateNextRun,
|
|
648
|
+
runHealthCheckOnce,
|
|
649
|
+
cleanupRunsOlderThanDays,
|
|
650
|
+
};
|