fhirsmith 0.18.1 → 1.0.0
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/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +190 -9
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +199 -24
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +193 -67
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +861 -95
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +125 -61
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/tho-precedence.js +40 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +175 -15
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
package/server.js
CHANGED
|
@@ -6,34 +6,83 @@
|
|
|
6
6
|
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
7
7
|
//
|
|
8
8
|
|
|
9
|
+
// Production unless told otherwise: Express and other libraries check NODE_ENV (in development
|
|
10
|
+
// mode Express's error pages include stack traces), and a server where nobody set it should get
|
|
11
|
+
// the safe behaviour. `npm run dev` (node --watch) runs in development mode
|
|
12
|
+
process.env.NODE_ENV ??= process.execArgv.includes('--watch') ? 'development' : 'production';
|
|
13
|
+
|
|
14
|
+
// Logging starts before anything else, so that a server that fails to start (bad config.json, a
|
|
15
|
+
// missing dependency, an unwritable folder) says why in the log as well as on stderr. It starts with
|
|
16
|
+
// the logger defaults; config.logging is applied once config.json has been read
|
|
17
|
+
const folders = require('./library/folder-setup');
|
|
18
|
+
const Logger = require('./library/logger');
|
|
19
|
+
let bootLog;
|
|
20
|
+
try {
|
|
21
|
+
bootLog = Logger.getInstance().child({ module: 'server' });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error('Failed to start logging:', error.message);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
bootLog.info(`FHIRsmith starting (PID ${process.pid}, Node.js ${process.version}), data directory ${folders.dataDir()}`);
|
|
27
|
+
|
|
28
|
+
// Until startup is complete, an uncaught exception is fatal: log it and exit (the logger flushes on exit).
|
|
29
|
+
// Replaced by the long-running handlers once the server is set up
|
|
30
|
+
function startupFailure(error) {
|
|
31
|
+
console.error('FATAL - startup failed:', error);
|
|
32
|
+
bootLog.error('FATAL - startup failed: ' + (error?.message || error), error);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
process.on('uncaughtException', startupFailure);
|
|
36
|
+
|
|
9
37
|
const express = require('express');
|
|
10
38
|
const cors = require('cors');
|
|
11
39
|
const path = require('path');
|
|
12
40
|
const fs = require('fs');
|
|
13
41
|
const os = require('os');
|
|
14
|
-
const v8 = require('v8');
|
|
15
|
-
const folders = require('./library/folder-setup'); // <-- ADD: load early
|
|
16
42
|
const { statSync, readdirSync } = require('fs');
|
|
17
43
|
const escape = require('escape-html');
|
|
18
44
|
const { resolveWithin } = require('./library/path-safety');
|
|
19
|
-
const {
|
|
45
|
+
const { readProcessMemory } = require('./library/cgroup-memory');
|
|
46
|
+
const memoryGuard = require('./library/memory-guard');
|
|
47
|
+
const { applyKeepAliveTimeouts } = require('./library/keep-alive');
|
|
48
|
+
const { countStaticResponses } = require('./library/static-response-stats');
|
|
49
|
+
const { trimNativeMemory } = require('./library/native-memory');
|
|
20
50
|
const { NpmAudit } = require('./library/npm-audit');
|
|
21
51
|
const Mailer = require('./library/mailer');
|
|
22
52
|
const DailyReport = require('./library/daily-report');
|
|
53
|
+
const { maxBodyBytes } = require('./library/body-limit');
|
|
23
54
|
|
|
24
|
-
// Load configuration
|
|
55
|
+
// Load configuration (from the data directory)
|
|
25
56
|
let config;
|
|
57
|
+
const configPath = folders.filePath('config.json');
|
|
58
|
+
bootLog.info(`Reading configuration from ${configPath}`);
|
|
26
59
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
60
|
+
// JSON, with // and /* */ comments allowed (library/config-reader.js)
|
|
61
|
+
config = require('./library/config-reader').readConfig(configPath);
|
|
62
|
+
// the server block is optional (every setting in it has a default)
|
|
63
|
+
config.server = config.server || {};
|
|
30
64
|
} catch (error) {
|
|
31
|
-
console.error(
|
|
65
|
+
console.error(`Failed to load configuration from ${configPath}:`, error.message);
|
|
66
|
+
bootLog.error(`Failed to load configuration from ${configPath}: ${error.message}`);
|
|
32
67
|
process.exit(1);
|
|
33
68
|
}
|
|
34
69
|
|
|
35
|
-
|
|
36
|
-
|
|
70
|
+
// --role=master|tx-worker (cluster.md). With no role this is null and the configuration is
|
|
71
|
+
// untouched: every `if (role)` below is skipped
|
|
72
|
+
const { parseRole, applyRole, describeRole } = require('./library/role');
|
|
73
|
+
let role;
|
|
74
|
+
try {
|
|
75
|
+
role = parseRole(process.argv.slice(2), process.env);
|
|
76
|
+
if (role) {
|
|
77
|
+
applyRole(config, role);
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(error.message);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Logger.getInstance().configure(config.logging || {});
|
|
85
|
+
const serverLog = Logger.getInstance().child({ module: 'server' });
|
|
37
86
|
const packageJson = require('./package.json');
|
|
38
87
|
|
|
39
88
|
// Startup banner
|
|
@@ -43,11 +92,10 @@ serverLog.info(`========================================`);
|
|
|
43
92
|
serverLog.info(`FHIRsmith v${packageJson.version} starting (PID ${process.pid})`);
|
|
44
93
|
serverLog.info(`Node.js ${process.version} on ${os.type()} ${os.release()} (${os.arch()})`);
|
|
45
94
|
serverLog.info(`Memory: ${freeMemGB} GB free / ${totalMemGB} GB total`);
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
: 'Memory limit: none found in cgroup - RSS guard disabled');
|
|
95
|
+
memoryGuard.configure(config.server.memory || {});
|
|
96
|
+
serverLog.info(memoryGuard.describe());
|
|
97
|
+
if (role) {
|
|
98
|
+
serverLog.info(describeRole(role, config));
|
|
51
99
|
}
|
|
52
100
|
serverLog.info(`Data directory: ${folders.dataDir()}`);
|
|
53
101
|
serverLog.info(`========================================`);
|
|
@@ -72,12 +120,17 @@ const IpsModule = require('./ips/ips.js');
|
|
|
72
120
|
|
|
73
121
|
const htmlServer = require('./library/html-server');
|
|
74
122
|
const ServerStats = require("./stats");
|
|
123
|
+
const { connectMetrics, graphData, externalSummary } = require('./library/cluster-metrics');
|
|
124
|
+
const { connectCounts } = require('./library/cluster-counts');
|
|
125
|
+
const { connectClusterEvents } = require('./library/cluster-events');
|
|
126
|
+
const { connectStatus, ownFigures, clusterView, processView, storageView, clusterSummary, processSummary, STATUS_INTERVAL_MS } = require('./library/cluster-status');
|
|
75
127
|
const {Liquid} = require("liquidjs");
|
|
76
128
|
const FolderModule = require("./folder/folder");
|
|
77
129
|
const ExtensionTrackerModule = require("./extension-tracker/extension-tracker");
|
|
78
130
|
|
|
79
131
|
htmlServer.useLog(serverLog);
|
|
80
132
|
htmlServer.setSponsorMessage(config.sponsorMessage ? config.sponsorMessage : '');
|
|
133
|
+
htmlServer.setAccessibilityPanel(config.accessibility?.checkPanel === true);
|
|
81
134
|
|
|
82
135
|
const app = express();
|
|
83
136
|
|
|
@@ -90,18 +143,42 @@ const app = express();
|
|
|
90
143
|
const DEFAULT_TRUST_PROXY = 'loopback, linklocal, uniquelocal';
|
|
91
144
|
app.set('trust proxy', config.server.trustProxy ?? DEFAULT_TRUST_PROXY);
|
|
92
145
|
|
|
93
|
-
const PORT = process.env.PORT || config.server.port || 3000;
|
|
146
|
+
const PORT = (role && role.port) || process.env.PORT || config.server.port || 3000;
|
|
147
|
+
// with a role, loopback unless server.cluster.bindAddress says otherwise (cluster.md); with
|
|
148
|
+
// none, every interface, as before
|
|
149
|
+
const HOST = role ? require('./library/role').bindAddress(config) : null;
|
|
94
150
|
|
|
95
151
|
// Middleware
|
|
152
|
+
// The requests this process has in flight, for the status panel (library/cluster-status.js)
|
|
153
|
+
let inFlight = 0;
|
|
154
|
+
app.use((req, res, next) => {
|
|
155
|
+
inFlight++;
|
|
156
|
+
let done = false;
|
|
157
|
+
const finished = () => {
|
|
158
|
+
if (!done) {
|
|
159
|
+
done = true;
|
|
160
|
+
inFlight--;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
res.once('finish', finished);
|
|
164
|
+
res.once('close', finished);
|
|
165
|
+
next();
|
|
166
|
+
});
|
|
167
|
+
// Refuse new requests (503) while memory is near the limit - first, so a refused request
|
|
168
|
+
// doesn't even have its body parsed. /dashboard stays reachable so the state can be seen.
|
|
169
|
+
app.use(memoryGuard.middleware({ log: serverLog, exempt: ['/dashboard'],
|
|
170
|
+
onReject: role ? () => stats?.countRefusedRequest() : undefined }));
|
|
96
171
|
// The body parsers, for every module but those that read their own bodies with their own
|
|
97
172
|
// limits and checks (the ips module: a document is read only once its submitter's token has
|
|
98
173
|
// been checked, and no bigger than the module's maxSize)
|
|
99
174
|
const OWN_BODIES = ['/ips'];
|
|
100
175
|
const ownsBody = (req) => OWN_BODIES.some(p => req.path === p || req.path.startsWith(p + '/'));
|
|
101
176
|
const unlessOwnBody = (parser) => (req, res, next) => (ownsBody(req) ? next() : parser(req, res, next));
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
app.use(unlessOwnBody(express.
|
|
177
|
+
// Bodies up to server.maxBodyMB (default 50); a bigger one gets a 413
|
|
178
|
+
const maxBody = maxBodyBytes(config.server);
|
|
179
|
+
app.use(unlessOwnBody(express.raw({ type: 'application/fhir+json', limit: maxBody })));
|
|
180
|
+
app.use(unlessOwnBody(express.raw({ type: 'application/fhir+xml', limit: maxBody })));
|
|
181
|
+
app.use(unlessOwnBody(express.json({ limit: maxBody })));
|
|
105
182
|
app.use((req, res, next) => {
|
|
106
183
|
const requestId = req.headers['x-request-id'];
|
|
107
184
|
if (requestId) {
|
|
@@ -120,6 +197,19 @@ app.use(cors(config.server.cors));
|
|
|
120
197
|
const modules = {};
|
|
121
198
|
|
|
122
199
|
let stats = null;
|
|
200
|
+
let disconnectMetrics = () => {};
|
|
201
|
+
let disconnectCounts = null;
|
|
202
|
+
let disconnectClusterEvents = () => {};
|
|
203
|
+
let disconnectStatus = () => {};
|
|
204
|
+
// The graphs on / and /dashboard, one component for every server (library/cluster-metrics.js):
|
|
205
|
+
// on the master under the launcher, every process it reports; otherwise this process's own
|
|
206
|
+
// samples as one series, with its memory guard's refusal threshold. `level`: the graphs' titles'
|
|
207
|
+
function metricsTemplate(page, level = 4) {
|
|
208
|
+
const label = !role ? 'This process' : `${role.role === 'master' ? 'Master' : 'Worker'} ${config.modules.tx.instanceCode}`;
|
|
209
|
+
const guard = memoryGuard._state;
|
|
210
|
+
// (legend: false - statusContent puts the graphs' key under "More")
|
|
211
|
+
return [`cluster-${page}`, { metricsJson: graphData(stats, label, Date.now(), { rejectAt: guard.rejectAt, group: Boolean(guard.group) }), level, legend: false }];
|
|
212
|
+
}
|
|
123
213
|
// periodic npm advisory check of the installed packages - see library/npm-audit.js
|
|
124
214
|
let npmAudit = null;
|
|
125
215
|
// the daily report, emailed at midnight when "email" and "dailyReport.to" are configured - see library/daily-report.js
|
|
@@ -128,6 +218,18 @@ let dailyReport = null;
|
|
|
128
218
|
// Initialize modules based on configuration
|
|
129
219
|
async function initializeModules() {
|
|
130
220
|
stats = new ServerStats(config.stats, serverLog);
|
|
221
|
+
if (role) {
|
|
222
|
+
disconnectMetrics = connectMetrics(stats);
|
|
223
|
+
// a worker's request counts go to the master's statistics (library/cluster-counts.js)
|
|
224
|
+
disconnectCounts = connectCounts(stats, process, { role: role.role });
|
|
225
|
+
// and on the master, what the launcher saw, for the daily report (library/cluster-events.js)
|
|
226
|
+
disconnectClusterEvents = connectClusterEvents(stats, process, { role: role.role });
|
|
227
|
+
// this process's figures for the status panel, and on the master the launcher's snapshot
|
|
228
|
+
// of them all (library/cluster-status.js)
|
|
229
|
+
disconnectStatus = connectStatus(stats, process, { role: role.role,
|
|
230
|
+
intervalMs: Number(config.server.cluster?.statusSeconds ?? STATUS_INTERVAL_MS / 1000) * 1000,
|
|
231
|
+
figures: () => ownFigures(stats, { inFlight: () => inFlight, refusing: () => memoryGuard.shouldReject() }) });
|
|
232
|
+
}
|
|
131
233
|
npmAudit = new NpmAudit(config.npmAudit || {}, Logger.getInstance().child({ module: 'npm-audit' }), __dirname, stats);
|
|
132
234
|
|
|
133
235
|
// Initialize SHL module
|
|
@@ -334,6 +436,154 @@ async function loadTemplates() {
|
|
|
334
436
|
}
|
|
335
437
|
}
|
|
336
438
|
|
|
439
|
+
// The request count in /dashboard/status.json: on the master under the launcher, what its
|
|
440
|
+
// statistics hold, its own and the workers' it has merged (library/cluster-counts.js), which
|
|
441
|
+
// arrive up to a minute late; otherwise this process's
|
|
442
|
+
function requestCounts() {
|
|
443
|
+
if (role && role.role === 'master' && process.send) {
|
|
444
|
+
return {
|
|
445
|
+
count: stats.requestCount + stats.mergedRequestCount,
|
|
446
|
+
detail: `master: ${stats.requestCount}, workers: ${stats.mergedRequestCount}, static: ${stats.staticRequestCount}`
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return { count: stats.requestCount, detail: `static: ${stats.staticRequestCount}` };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// The status panel's view (library/cluster-status.js, tx/html/status-panel.liquid): the
|
|
453
|
+
// cluster's on the master under the launcher, once its first snapshot has come; else this
|
|
454
|
+
// process's own, labelled as what it is
|
|
455
|
+
function statusView() {
|
|
456
|
+
const [uptime, uptimeSub] = uptimeParts(Date.now() - stats.startTime);
|
|
457
|
+
const host = { used: os.totalmem() - os.freemem(), total: os.totalmem() };
|
|
458
|
+
const cluster = clusterStatus();
|
|
459
|
+
if (cluster) {
|
|
460
|
+
return clusterView(cluster, { uptime, uptimeSub, host });
|
|
461
|
+
}
|
|
462
|
+
// a fresh enough measure, as the guard takes it
|
|
463
|
+
memoryGuard.shouldReject();
|
|
464
|
+
const g = memoryGuard._state;
|
|
465
|
+
const own = readProcessMemory('self');
|
|
466
|
+
const minutes = (Date.now() - stats.startTime) / 60000;
|
|
467
|
+
const code = role ? config.modules.tx.instanceCode : null;
|
|
468
|
+
return processView({
|
|
469
|
+
figures: ownFigures(stats, { inFlight: () => inFlight, refusing: () => memoryGuard.shouldReject() }),
|
|
470
|
+
uptime, uptimeSub, host, static: stats.staticRequestCount,
|
|
471
|
+
avgPerMin: minutes >= 1 ? stats.requestCount / minutes : null,
|
|
472
|
+
anon: own ? own.anon : null,
|
|
473
|
+
guard: { measure: g.sampleRss, limit: g.limit, rejectAt: g.rejectAt, abortAt: g.abortAt, rejectFraction: g.rejectFraction,
|
|
474
|
+
abortFraction: g.abortFraction, group: Boolean(g.group), fileBacked: g.excludeFileBacked }
|
|
475
|
+
}, !role ? { heading: 'Server', meta: ['one process'] } : {
|
|
476
|
+
heading: `${role.role === 'master' ? 'Master' : 'Worker'} ${code}`,
|
|
477
|
+
meta: role.role === 'master' && process.send ? ['this process', 'waiting for the launcher\'s first snapshot'] : ['this process only']
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Every background task, in the status panel's table style (tx/html/status-panel.liquid), in a
|
|
482
|
+
// box of its own that scrolls rather than the page; under "More"
|
|
483
|
+
function backgroundTasks(level) {
|
|
484
|
+
const table = stats.taskDetails();
|
|
485
|
+
return table ? `<section class="fs-status fs-status-tasks"><h${level} class="fs-status-head">All background tasks</h${level}>` +
|
|
486
|
+
`<div class="fs-status-scroll">${table}</div></section>` : '';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// The status line at the top of / and /dashboard (tx/html/status-line.liquid): requests, uptime
|
|
490
|
+
// and heap, the cluster's on the master under the launcher, else this process's; and the problems
|
|
491
|
+
function statusLine() {
|
|
492
|
+
const [uptime, uptimeSub] = uptimeParts(Date.now() - stats.startTime);
|
|
493
|
+
// low disk space is a problem, and also on its own line above "More", in red (see statusContent)
|
|
494
|
+
const logs = logFigures();
|
|
495
|
+
const disk = logs ? logs.disk : null;
|
|
496
|
+
const failedTasks = stats.failedTasks();
|
|
497
|
+
const other = npmAudit && npmAudit.enabled && npmAudit.checkedAt && npmAudit.isSerious() ? [`npm audit: ${npmAudit.summary()}`] : [];
|
|
498
|
+
const cluster = clusterStatus();
|
|
499
|
+
if (cluster) {
|
|
500
|
+
return clusterSummary(cluster, { uptime, uptimeSub, disk, failedTasks, other });
|
|
501
|
+
}
|
|
502
|
+
// a fresh enough measure, as the guard takes it
|
|
503
|
+
memoryGuard.shouldReject();
|
|
504
|
+
const g = memoryGuard._state;
|
|
505
|
+
const minutes = (Date.now() - stats.startTime) / 60000;
|
|
506
|
+
return processSummary({
|
|
507
|
+
figures: ownFigures(stats, { inFlight: () => inFlight, refusing: () => memoryGuard.shouldReject() }),
|
|
508
|
+
uptime, uptimeSub, avgPerMin: minutes >= 1 ? stats.requestCount / minutes : null,
|
|
509
|
+
guard: { measure: g.sampleRss, rejectAt: g.rejectAt }, disk, failedTasks, other
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// under this share of the disk free, a warning in red above "More"
|
|
514
|
+
const DISK_LOW = 0.1;
|
|
515
|
+
|
|
516
|
+
// Low disk space (the disk the logs are on), as a warning in red; '' when there is enough
|
|
517
|
+
function diskWarning() {
|
|
518
|
+
const logs = logFigures();
|
|
519
|
+
const disk = logs ? logs.disk : null;
|
|
520
|
+
if (!disk || !(disk.total > 0) || disk.free >= DISK_LOW * disk.total) {
|
|
521
|
+
return '';
|
|
522
|
+
}
|
|
523
|
+
const gb = (bytes) => (bytes / 2 ** 30).toFixed(1);
|
|
524
|
+
return `<span style="color:#b00"><strong><span aria-hidden="true">⚠ </span>Disk space low:</strong> ` +
|
|
525
|
+
`${gb(disk.free)} GB free of ${gb(disk.total)} GB (${Math.round(100 * disk.free / disk.total)}%)</span>`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// The top of / and /dashboard: the status line, the graphs, the background tasks at work, one
|
|
529
|
+
// line of what the server needs looking at (`notes`: html, with low disk space added), then under
|
|
530
|
+
// "More" the graphs' key, the status panel, storage, every background task and `more` (html).
|
|
531
|
+
// Headings start at `level`, the page's sections' own.
|
|
532
|
+
async function statusContent(liquid, page, level, { notes = [], details = '', more = '' } = {}) {
|
|
533
|
+
let content = await liquid.renderFile('status-line', { line: statusLine(), level });
|
|
534
|
+
content += await liquid.renderFile(...metricsTemplate(page, level + 1));
|
|
535
|
+
content += await liquid.renderFile('status-tasks', { tasks: stats.taskSummary(), level });
|
|
536
|
+
const line = [...notes, diskWarning()].filter(Boolean);
|
|
537
|
+
if (line.length) {
|
|
538
|
+
content += `<div id="npm-audit" style="font-size:13px;margin:0 0 8px"><p style="margin:0 0 4px">${line.join(' · ')}</p>${details}</div>`;
|
|
539
|
+
}
|
|
540
|
+
content += `<details class="fs-status-more"><summary><h${level}>More</h${level}>: the graphs' key, every process, memory, storage, every background task</summary>`;
|
|
541
|
+
// the graphs' key (tx/html/cluster-metrics-script.liquid puts it here)
|
|
542
|
+
content += '<div id="serverMetricsLegend" class="server-metrics-legend"></div>';
|
|
543
|
+
content += await liquid.renderFile('status-panel', { view: statusView(), level: level + 1 });
|
|
544
|
+
const external = externalStatus();
|
|
545
|
+
if (external) {
|
|
546
|
+
content += await liquid.renderFile('external-metrics', { external, level: level + 1 });
|
|
547
|
+
}
|
|
548
|
+
content += await liquid.renderFile('status-panel', { view: storageStatusView(), level: level + 1 });
|
|
549
|
+
content += backgroundTasks(level + 1);
|
|
550
|
+
content += more;
|
|
551
|
+
content += '</details>';
|
|
552
|
+
return content;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// The external requests - those that had to ask another server (tx ecosystem resolution,
|
|
556
|
+
// ServerStats.countExternalRequest) - in the graphs' samples (every process's, on the master
|
|
557
|
+
// under the launcher): {count, average, span} for tx/html/external-metrics.liquid, or null if
|
|
558
|
+
// there have been none in them
|
|
559
|
+
function externalStatus(now = Date.now()) {
|
|
560
|
+
const summary = externalSummary(stats, now);
|
|
561
|
+
if (!summary) {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
const { count, average, hours } = summary;
|
|
565
|
+
return {
|
|
566
|
+
count: count.toLocaleString(),
|
|
567
|
+
average: average >= 1000 ? `${(average / 1000).toFixed(1)} s` : `${Math.round(average)} ms`,
|
|
568
|
+
span: hours >= 23.5 ? '24 hours' : hours >= 1.5 ? `${Math.round(hours)} hours` : hours >= 1 || Math.round(hours * 60) <= 1 ? (hours >= 1 ? 'hour' : 'minute') : `${Math.round(hours * 60)} minutes`
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// The storage tiles below the graphs: closure tables, logs, disk
|
|
573
|
+
function storageStatusView() {
|
|
574
|
+
const logs = logFigures();
|
|
575
|
+
const closure = stats.closureStats();
|
|
576
|
+
return storageView({
|
|
577
|
+
heading: 'Storage', closure, logs: logs && logs.files !== null ? logs : null, disk: logs && logs.disk
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// The launcher's snapshot of every process (library/cluster-status.js), on the master under
|
|
582
|
+
// the launcher once one has come; else null, and the table is this process's
|
|
583
|
+
function clusterStatus() {
|
|
584
|
+
return role && role.role === 'master' && stats.clusterStatus ? stats.clusterStatus : null;
|
|
585
|
+
}
|
|
586
|
+
|
|
337
587
|
async function buildRootPageContent() {
|
|
338
588
|
stats.requestCount++;
|
|
339
589
|
let content = npmAudit ? npmAudit.renderBanner() : '';
|
|
@@ -431,6 +681,7 @@ async function buildRootPageContent() {
|
|
|
431
681
|
content += '</li>';
|
|
432
682
|
}
|
|
433
683
|
content += '</ul>';
|
|
684
|
+
content += '</li>';
|
|
434
685
|
}
|
|
435
686
|
|
|
436
687
|
|
|
@@ -454,72 +705,12 @@ async function buildRootPageContent() {
|
|
|
454
705
|
|
|
455
706
|
content += '<hr/>';
|
|
456
707
|
|
|
457
|
-
//
|
|
458
|
-
const uptimeMs = Date.now() - stats.startTime;
|
|
459
|
-
const uptimeStr = uptimeText(uptimeMs);
|
|
460
|
-
|
|
461
|
-
// Memory usage
|
|
462
|
-
const memUsage = process.memoryUsage();
|
|
463
|
-
const heapStats = v8.getHeapStatistics();
|
|
464
|
-
|
|
465
|
-
// V8 heap: used vs limit
|
|
466
|
-
const v8UsedMB = (memUsage.heapUsed / 1024 / 1024).toFixed(0);
|
|
467
|
-
const v8LimitMB = (heapStats.heap_size_limit / 1024 / 1024).toFixed(0);
|
|
468
|
-
const v8PCT = (memUsage.heapUsed * 100) / heapStats.heap_size_limit;
|
|
469
|
-
|
|
470
|
-
// Process RSS vs cgroup limit (or system total)
|
|
471
|
-
const rssMB = (memUsage.rss / 1024 / 1024).toFixed(0);
|
|
472
|
-
const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
|
|
473
|
-
const memLimitMB = (memLimit / 1024 / 1024).toFixed(0);
|
|
474
|
-
const processPCT = (memUsage.rss * 100) / memLimit;
|
|
475
|
-
|
|
476
|
-
// System memory
|
|
477
|
-
const totalMemMB = (os.totalmem() / 1024 / 1024).toFixed(0);
|
|
478
|
-
const usedMemMB = ((os.totalmem() - os.freemem()) / 1024 / 1024).toFixed(0);
|
|
479
|
-
const sysMemPCT = ((os.totalmem() - os.freemem()) * 100) / os.totalmem();
|
|
480
|
-
|
|
481
|
-
// Average requests per minute
|
|
482
|
-
const uptimeMinutesTotal = uptimeMs / 60000;
|
|
483
|
-
const avgReqPerMin = uptimeMinutesTotal > 0 ? (stats.requestCount / uptimeMinutesTotal).toFixed(1) : '0.0';
|
|
484
|
-
|
|
485
|
-
content += '<table class="grid">';
|
|
486
|
-
content += '<tr>';
|
|
487
|
-
content += `<td><strong>Uptime:</strong> ${escape(uptimeStr)}</td>`;
|
|
488
|
-
content += `<td><strong>Request Count:</strong> ${stats.requestCount} (static: ${stats.staticRequestCount})</td>`;
|
|
489
|
-
content += `<td><strong>Avg Requests/min:</strong> ${avgReqPerMin}</td>`;
|
|
490
|
-
content += '</tr>';
|
|
491
|
-
content += '<tr>';
|
|
492
|
-
content += `<td style="background-color:${pctColor(v8PCT)}"><strong>V8 Memory:</strong> ${v8UsedMB} MB of ${v8LimitMB} MB (${v8PCT.toFixed(0)}%)</td>`;
|
|
493
|
-
content += `<td style="background-color:${pctColor(processPCT)}"><strong>Process Memory:</strong> ${rssMB} MB of ${memLimitMB} MB (${processPCT.toFixed(0)}%)</td>`;
|
|
494
|
-
content += `<td style="background-color:${pctColor(sysMemPCT)}"><strong>System Memory:</strong> ${usedMemMB} MB of ${totalMemMB} MB (${sysMemPCT.toFixed(0)}%)</td>`;
|
|
495
|
-
content += '</tr>';
|
|
496
|
-
content += '<tr>';
|
|
497
|
-
content += `<td><strong>Expansion Cache:</strong> ${stats.expansionItems()} items</td>`;
|
|
498
|
-
content += `<td><strong>Client Cache:</strong> ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts</td>`;
|
|
499
|
-
content += `<td><strong>Max Client Cache:</strong> ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts</td>`;
|
|
500
|
-
content += '</tr>';
|
|
501
|
-
const closure = stats.closureStats();
|
|
502
|
-
if (closure) {
|
|
503
|
-
content += '<tr>';
|
|
504
|
-
content += `<td><strong>Closure Tables:</strong> ${closure.tables} tables` +
|
|
505
|
-
(closure.retentionDays ? ` (unused for ${closure.retentionDays} days = deleted)` : '') + `</td>`;
|
|
506
|
-
content += `<td><strong>Closure Entries:</strong> ${closure.entries} entries / ${closure.concepts} concepts</td>`;
|
|
507
|
-
content += `<td><strong>Closure Database:</strong> ${closure.bytes === null ? 'in memory' : formatBytes(closure.bytes)}</td>`;
|
|
508
|
-
content += '</tr>';
|
|
509
|
-
}
|
|
510
|
-
content += getLogStats();
|
|
511
|
-
content += '</table>';
|
|
512
|
-
|
|
513
|
-
// ===== Metrics Graphs =====
|
|
708
|
+
// the status line, the graphs and the tasks at work; the rest under "More" (library/cluster-status.js)
|
|
514
709
|
const liquid = new Liquid({
|
|
515
710
|
root: path.join(__dirname, 'tx', 'html'),
|
|
516
711
|
extname: '.liquid'
|
|
517
712
|
});
|
|
518
|
-
content += await liquid
|
|
519
|
-
historyJson: JSON.stringify(stats.history),
|
|
520
|
-
startTime: stats.startTime
|
|
521
|
-
});
|
|
522
|
-
content += stats.taskDetails();
|
|
713
|
+
content += await statusContent(liquid, 'home-metrics', 3);
|
|
523
714
|
|
|
524
715
|
content += '</div>';
|
|
525
716
|
return content;
|
|
@@ -531,11 +722,16 @@ process.on('unhandledRejection', (reason, promise) => {
|
|
|
531
722
|
serverLog.error('Unhandled Rejection:', reason);
|
|
532
723
|
});
|
|
533
724
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
process.
|
|
538
|
-
|
|
725
|
+
// Once the server is listening, startup is over: an uncaught exception is logged, and the
|
|
726
|
+
// server carries on (startupFailure, which exits, covers everything until then - see startServer)
|
|
727
|
+
function startupComplete() {
|
|
728
|
+
process.off('uncaughtException', startupFailure);
|
|
729
|
+
process.on('uncaughtException', (error) => {
|
|
730
|
+
console.error('FATAL - Uncaught Exception:', error);
|
|
731
|
+
serverLog.error('FATAL - Uncaught Exception:', error);
|
|
732
|
+
process.exitCode = 1;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
539
735
|
|
|
540
736
|
app.get('/', async (req, res) => {
|
|
541
737
|
// If an override index.html exists, serve it instead of the FHIRsmith home page
|
|
@@ -597,20 +793,14 @@ app.get('/', async (req, res) => {
|
|
|
597
793
|
app.get('/fhirsmith', (req, res) => serveFhirsmithHome(req, res));
|
|
598
794
|
|
|
599
795
|
// Serve static files
|
|
600
|
-
// Count static file hits separately from API/page requests
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
604
|
-
stats.staticRequestCount++;
|
|
605
|
-
}
|
|
606
|
-
});
|
|
607
|
-
next();
|
|
608
|
-
});
|
|
796
|
+
// Count static file hits separately from API/page requests: only responses the static
|
|
797
|
+
// handlers below send themselves, not a later route's response to a request that fell through
|
|
798
|
+
const staticFiles = express.Router();
|
|
609
799
|
if (config.server?.webBase) {
|
|
610
800
|
const overrideDir = path.resolve(config.server.webBase);
|
|
611
801
|
// Anything under overrideDir may be served; nothing outside it may - see
|
|
612
802
|
// library/path-safety.js for why path.join is not enough on its own.
|
|
613
|
-
|
|
803
|
+
staticFiles.use((req, res, next) => {
|
|
614
804
|
const filePath = resolveWithin(overrideDir, req.path);
|
|
615
805
|
if (filePath === null) {
|
|
616
806
|
return next();
|
|
@@ -627,7 +817,36 @@ if (config.server?.webBase) {
|
|
|
627
817
|
});
|
|
628
818
|
});
|
|
629
819
|
}
|
|
630
|
-
|
|
820
|
+
staticFiles.use(express.static(path.join(__dirname, 'static')));
|
|
821
|
+
app.use(countStaticResponses(staticFiles, () => { stats.staticRequestCount++; }));
|
|
822
|
+
|
|
823
|
+
// The status panel's figures, machine-readable (cluster.md, "Status"). Under /dashboard, so the
|
|
824
|
+
// memory guard never refuses it. On the master under the launcher: the launcher's snapshot of
|
|
825
|
+
// every process (library/cluster-status.js); otherwise this process's own figures
|
|
826
|
+
app.get('/dashboard/status.json', (req, res) => {
|
|
827
|
+
stats.requestCount++;
|
|
828
|
+
const requests = requestCounts();
|
|
829
|
+
const counts = { count: requests.count, detail: requests.detail };
|
|
830
|
+
const cluster = clusterStatus();
|
|
831
|
+
let body;
|
|
832
|
+
if (cluster) {
|
|
833
|
+
body = { scope: 'cluster', generatedAt: Date.now(), requests: counts, ...cluster };
|
|
834
|
+
} else {
|
|
835
|
+
const guard = memoryGuard._state;
|
|
836
|
+
const own = readProcessMemory('self');
|
|
837
|
+
body = {
|
|
838
|
+
scope: 'process', generatedAt: Date.now(), requests: counts,
|
|
839
|
+
role: role ? role.role : null, code: role ? config.modules.tx.instanceCode : null,
|
|
840
|
+
process: { ...ownFigures(stats, { inFlight: () => inFlight, refusing: () => memoryGuard.shouldReject() }),
|
|
841
|
+
uptimeMs: Date.now() - stats.startTime, rss: process.memoryUsage().rss,
|
|
842
|
+
anon: own ? own.anon : null, file: own ? own.file : null },
|
|
843
|
+
memory: { limit: guard.limit, limitSource: guard.source, rejectAt: guard.rejectAt, abortAt: guard.abortAt,
|
|
844
|
+
rejectFraction: guard.rejectFraction, abortFraction: guard.abortFraction, group: Boolean(guard.group) }
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
848
|
+
res.json(body);
|
|
849
|
+
});
|
|
631
850
|
|
|
632
851
|
// Dashboard endpoint - server name, stats, graphs, and background tasks (no modules list)
|
|
633
852
|
app.get('/dashboard', async (req, res) => {
|
|
@@ -639,52 +858,21 @@ app.get('/dashboard', async (req, res) => {
|
|
|
639
858
|
}
|
|
640
859
|
|
|
641
860
|
const startTime = Date.now();
|
|
642
|
-
//
|
|
643
|
-
const uptimeMs = Date.now() - stats.startTime;
|
|
644
|
-
const uptimeStr = uptimeText(uptimeMs);
|
|
645
|
-
|
|
646
|
-
// Memory usage
|
|
647
|
-
const memUsage = process.memoryUsage();
|
|
648
|
-
const heapStats = v8.getHeapStatistics();
|
|
649
|
-
const v8PCT = (memUsage.heapUsed * 100) / heapStats.heap_size_limit; // % of V8 heap limit used
|
|
650
|
-
|
|
651
|
-
// Process RSS as % of cgroup memory limit (or system total as fallback)
|
|
652
|
-
const memLimit = readCgroupMemoryLimit().limit || os.totalmem();
|
|
653
|
-
const processPCT = (memUsage.rss * 100) / memLimit;
|
|
654
|
-
|
|
655
|
-
const totalMemBytes = os.totalmem();
|
|
656
|
-
const freeMemBytes = os.freemem();
|
|
657
|
-
const sysMemPCT = ((totalMemBytes - freeMemBytes) * 100) / totalMemBytes; // % of system memory used
|
|
658
|
-
|
|
659
|
-
const memMaxPCT = Math.max(v8PCT, processPCT, sysMemPCT);
|
|
660
|
-
|
|
661
|
-
const fstats = fs.statfsSync(folders.logsDir());
|
|
662
|
-
const diskPCT = 100 - ((fstats.bavail * 100) / fstats.blocks); // % of disk used
|
|
663
|
-
|
|
664
|
-
let content = '<style>table.grid{margin-bottom:10px;border:1px solid black;margin-right:auto}table.grid th,table.grid td{border:1px solid silver;padding:3px 7px 2px;font-size:12px;line-height:1.4em;font-family:verdana;vertical-align:top}table.grid th{font-weight:bold}table.grid td{font-weight:normal}</style>';
|
|
665
|
-
content += '<table class="grid">';
|
|
666
|
-
content += '<tr>';
|
|
667
|
-
content += `<td><strong>Uptime:</strong> ${escape(uptimeStr)}</td>`;
|
|
668
|
-
content += `<td><strong>Request Count:</strong> ${stats.requestCount} (static: ${stats.staticRequestCount})</td>`;
|
|
669
|
-
content += `<td style="background-color:${pctColor(memMaxPCT)}"><strong>Memory:</strong> ${v8PCT.toFixed(0)}% V8, ${processPCT.toFixed(0)}% Process, ${sysMemPCT.toFixed(0)}% System</td>`;
|
|
670
|
-
content += `<td style="background-color:${pctColor(diskPCT)}"><strong>Disk:</strong> ${diskPCT.toFixed(0)}%</td>`;
|
|
671
|
-
content += '</tr>';
|
|
672
|
-
content += '</table>';
|
|
673
|
-
|
|
674
|
-
// ===== Metrics Graphs =====
|
|
861
|
+
// the status line, the graphs and the tasks at work; the rest under "More" (library/cluster-status.js)
|
|
675
862
|
const liquid = new Liquid({
|
|
676
863
|
root: path.join(__dirname, 'tx', 'html'),
|
|
677
864
|
extname: '.liquid'
|
|
678
865
|
});
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
866
|
+
// the background tasks' table (the bare template has no fhir.css)
|
|
867
|
+
let content = '<style>table.grid{margin-bottom:10px;border:1px solid black;margin-right:auto}table.grid th,table.grid td{border:1px solid silver;padding:3px 7px 2px;font-size:12px;line-height:1.4em;font-family:verdana;vertical-align:top}table.grid th{font-weight:bold}table.grid td{font-weight:normal}</style>';
|
|
868
|
+
// the page's title is its h1 (root-bare-template.html), so its sections are h2
|
|
869
|
+
// the release and npm audit on one line above "More" (the advice and the findings, when there
|
|
870
|
+
// are any, after it); the data folder under "More"
|
|
871
|
+
content += await statusContent(liquid, 'dash-metrics', 2, {
|
|
872
|
+
notes: npmAudit ? [npmAudit.renderLine()] : [],
|
|
873
|
+
details: npmAudit ? npmAudit.renderFindings() : '',
|
|
874
|
+
more: `<p>Data: ${escape(folders.dataDir())}</p>`
|
|
682
875
|
});
|
|
683
|
-
content += stats.taskDetails();
|
|
684
|
-
if (npmAudit) {
|
|
685
|
-
content += npmAudit.renderDashboard();
|
|
686
|
-
}
|
|
687
|
-
content += `<p>Data: ${folders.dataDir()}</p>`;
|
|
688
876
|
|
|
689
877
|
content = '<div class="row mb-4"><div class="col-12">' + content + '</div></div>';
|
|
690
878
|
|
|
@@ -704,23 +892,17 @@ app.get('/dashboard', async (req, res) => {
|
|
|
704
892
|
}
|
|
705
893
|
});
|
|
706
894
|
|
|
707
|
-
function formatBytes(bytes) {
|
|
708
|
-
if (bytes < 1024) {
|
|
709
|
-
return `${bytes} bytes`;
|
|
710
|
-
}
|
|
711
|
-
if (bytes < 1024 * 1024) {
|
|
712
|
-
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
713
|
-
}
|
|
714
|
-
if (bytes < 1024 * 1024 * 1024) {
|
|
715
|
-
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
716
|
-
}
|
|
717
|
-
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
895
|
/**
|
|
721
896
|
* "3d 4h 5m 6s" for this run, followed by the proportion of time the server has
|
|
722
897
|
* been up over all its recorded runs, when the statistics database is keeping that.
|
|
723
898
|
*/
|
|
899
|
+
// [the uptime, "97.3% overall" or null], for the Uptime tile
|
|
900
|
+
function uptimeParts(uptimeMs) {
|
|
901
|
+
const text = uptimeText(uptimeMs);
|
|
902
|
+
const m = / \((.*)\)$/.exec(text);
|
|
903
|
+
return m ? [text.substring(0, m.index), m[1]] : [text, null];
|
|
904
|
+
}
|
|
905
|
+
|
|
724
906
|
function uptimeText(uptimeMs) {
|
|
725
907
|
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
|
726
908
|
const uptimeDays = Math.floor(uptimeSeconds / 86400);
|
|
@@ -741,15 +923,6 @@ function uptimeText(uptimeMs) {
|
|
|
741
923
|
return uptimeStr;
|
|
742
924
|
}
|
|
743
925
|
|
|
744
|
-
function pctColor(pct) {
|
|
745
|
-
// Gradient from green (#deffe0) at 0% to red (#ffd3d1) at 100%
|
|
746
|
-
const t = Math.max(0, Math.min(100, pct)) / 100;
|
|
747
|
-
const r = Math.round(222 + 33 * t); // 222 -> 255
|
|
748
|
-
const g = Math.round(255 - 44 * t); // 255 -> 211
|
|
749
|
-
const b = Math.round(224 - 15 * t); // 224 -> 209
|
|
750
|
-
return `rgb(${r}, ${g}, ${b})`;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
926
|
// Health check endpoint
|
|
754
927
|
app.get('/health', async (req, res) => {
|
|
755
928
|
const healthStatus = {
|
|
@@ -781,45 +954,29 @@ app.get('/health', async (req, res) => {
|
|
|
781
954
|
* Get log directory statistics: file count, total size, and disk space info
|
|
782
955
|
* @returns {string} HTML table row(s) with log stats
|
|
783
956
|
*/
|
|
784
|
-
|
|
957
|
+
// The logs and the disk they are on, for the storage tiles: files and bytes null if the logs
|
|
958
|
+
// can't be read, disk null if the disk can't
|
|
959
|
+
function logFigures() {
|
|
785
960
|
const logDir = folders.logsDir();
|
|
786
|
-
|
|
961
|
+
let disk = null;
|
|
962
|
+
try {
|
|
963
|
+
// statfs available in Node 18.15+
|
|
964
|
+
const fstats = fs.statfsSync(logDir);
|
|
965
|
+
disk = { free: fstats.bavail * fstats.bsize, total: fstats.blocks * fstats.bsize };
|
|
966
|
+
} catch {
|
|
967
|
+
// unavailable
|
|
968
|
+
}
|
|
787
969
|
try {
|
|
788
970
|
const files = readdirSync(logDir).filter(f => f.endsWith('.log'));
|
|
789
|
-
let
|
|
971
|
+
let bytes = 0;
|
|
790
972
|
for (const file of files) {
|
|
791
|
-
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
|
|
795
|
-
|
|
796
|
-
let diskInfo = '';
|
|
797
|
-
try {
|
|
798
|
-
// statfs available in Node 18.15+
|
|
799
|
-
const fstats = fs.statfsSync(logDir);
|
|
800
|
-
const blockSize = fstats.bsize;
|
|
801
|
-
const freeSpace = fstats.bavail * blockSize;
|
|
802
|
-
const totalSpace = fstats.blocks * blockSize;
|
|
803
|
-
const freeGB = (freeSpace / 1024 / 1024 / 1024).toFixed(2);
|
|
804
|
-
const totalGB = (totalSpace / 1024 / 1024 / 1024).toFixed(2);
|
|
805
|
-
diskInfo = `<td><strong>Disk Space:</strong> ${freeGB} GB of ${totalGB} GB</td>`;
|
|
806
|
-
} catch {
|
|
807
|
-
diskInfo = '<td><strong>Disk Space:</strong> unavailable</td>';
|
|
973
|
+
bytes += statSync(path.join(logDir, file)).size;
|
|
808
974
|
}
|
|
809
|
-
|
|
810
|
-
// Log rotation limit from logger config
|
|
975
|
+
// how many files the logger keeps (its rotation)
|
|
811
976
|
const loggerOpts = Logger.getInstance().options;
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
return '<tr>'
|
|
817
|
-
+ `<td><strong>Existing Logs:</strong> ${files.length} (${sizeMB} MB)</td>`
|
|
818
|
-
+ `<td><strong>Retention Policy:</strong> ${limitInfo}</td>`
|
|
819
|
-
+ diskInfo
|
|
820
|
-
+ '</tr>';
|
|
821
|
-
} catch (e) {
|
|
822
|
-
return `<tr><td colspan="3"><strong>Logs:</strong> unable to read (${e.message})</td></tr>`;
|
|
977
|
+
return { files: files.length, bytes, kept: loggerOpts.file.maxFiles, disk };
|
|
978
|
+
} catch {
|
|
979
|
+
return { files: null, bytes: null, kept: null, disk };
|
|
823
980
|
}
|
|
824
981
|
}
|
|
825
982
|
|
|
@@ -835,11 +992,33 @@ async function startServer() {
|
|
|
835
992
|
throw error;
|
|
836
993
|
});
|
|
837
994
|
|
|
995
|
+
// With a role: loading the library leaves 0.6-0.7 GB free but resident in the C allocator;
|
|
996
|
+
// give it back before taking requests, since N+1 processes share one limit (cluster.md)
|
|
997
|
+
if (role && memoryGuard._state.trimAllocator) {
|
|
998
|
+
const before = process.memoryUsage.rss();
|
|
999
|
+
if (trimNativeMemory() !== null) {
|
|
1000
|
+
serverLog.info(`Memory: malloc_trim after loading - RSS ${memoryGuard.gb(before)} GB -> ${memoryGuard.gb(process.memoryUsage.rss())} GB`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
838
1004
|
// Start server
|
|
839
|
-
|
|
1005
|
+
const listening = () => {
|
|
840
1006
|
stats.markStarted(packageJson.version);
|
|
841
|
-
serverLog.info(`=== Server running on http
|
|
842
|
-
|
|
1007
|
+
serverLog.info(HOST ? `=== Server running on http://${HOST.includes(':') ? `[${HOST}]` : HOST}:${PORT} (server.cluster.bindAddress) ===`
|
|
1008
|
+
: `=== Server running on http://localhost:${PORT} ===`);
|
|
1009
|
+
startupComplete();
|
|
1010
|
+
if (role && process.send) {
|
|
1011
|
+
// to the launcher (cluster.js): ready, from this process itself
|
|
1012
|
+
try {
|
|
1013
|
+
process.send({ type: require('./library/role').READY, pid: process.pid });
|
|
1014
|
+
} catch {
|
|
1015
|
+
// the launcher has gone; the disconnect handler below shuts this process down
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
const server = HOST ? app.listen(PORT, HOST, listening) : app.listen(PORT, listening);
|
|
1020
|
+
// idle connections stay open longer than nginx keeps them (see library/keep-alive.js)
|
|
1021
|
+
applyKeepAliveTimeouts(server);
|
|
843
1022
|
npmAudit.start();
|
|
844
1023
|
dailyReport = new DailyReport(config.dailyReport, {
|
|
845
1024
|
log: Logger.getInstance().child({ module: 'daily-report' }),
|
|
@@ -848,7 +1027,9 @@ async function startServer() {
|
|
|
848
1027
|
mailer: new Mailer(config.email, Logger.getInstance().child({ module: 'email' })),
|
|
849
1028
|
npmAudit: npmAudit,
|
|
850
1029
|
version: packageJson.version,
|
|
851
|
-
serverId: config.hostName
|
|
1030
|
+
serverId: config.hostName,
|
|
1031
|
+
// under the launcher: the report has a paragraph on the cluster
|
|
1032
|
+
cluster: Boolean(role && role.role === 'master' && process.send)
|
|
852
1033
|
});
|
|
853
1034
|
dailyReport.start();
|
|
854
1035
|
if (modules.packages && config.modules?.packages?.enabled) {
|
|
@@ -884,6 +1065,10 @@ async function shutdown() {
|
|
|
884
1065
|
}
|
|
885
1066
|
shuttingDown = true;
|
|
886
1067
|
serverLog.info('\nShutting down server...');
|
|
1068
|
+
if (disconnectCounts) {
|
|
1069
|
+
// a worker's last request counts, while the launcher still listens
|
|
1070
|
+
await disconnectCounts.flush();
|
|
1071
|
+
}
|
|
887
1072
|
if (npmAudit) {
|
|
888
1073
|
npmAudit.stop();
|
|
889
1074
|
}
|
|
@@ -904,11 +1089,26 @@ async function shutdown() {
|
|
|
904
1089
|
}
|
|
905
1090
|
}
|
|
906
1091
|
stats.finishStats();
|
|
1092
|
+
disconnectMetrics();
|
|
1093
|
+
disconnectStatus();
|
|
1094
|
+
if (disconnectCounts) {
|
|
1095
|
+
disconnectCounts();
|
|
1096
|
+
}
|
|
1097
|
+
disconnectClusterEvents();
|
|
907
1098
|
serverLog.info('Server shutdown complete');
|
|
908
1099
|
process.exit(0);
|
|
909
1100
|
}
|
|
910
1101
|
process.on('SIGINT', () => void shutdown());
|
|
911
1102
|
process.on('SIGTERM', () => void shutdown());
|
|
1103
|
+
// Started by the launcher (cluster.js, an IPC channel): when the launcher goes, even
|
|
1104
|
+
// SIGKILLed, so does this process, rather than hold its port against the next launcher's
|
|
1105
|
+
// workers. With no one left to SIGKILL a shutdown that hangs, exit after 30 s regardless
|
|
1106
|
+
if (role && process.connected) {
|
|
1107
|
+
process.once('disconnect', () => {
|
|
1108
|
+
setTimeout(() => process.exit(1), 30000).unref();
|
|
1109
|
+
void shutdown();
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
912
1112
|
|
|
913
1113
|
async function serveFhirsmithHome(req, res) {
|
|
914
1114
|
// Check if client wants HTML response
|
|
@@ -993,4 +1193,4 @@ async function serveFhirsmithHome(req, res) {
|
|
|
993
1193
|
startServer().catch((err) => {
|
|
994
1194
|
serverLog.error('Fatal error during startup:', err);
|
|
995
1195
|
process.exit(1);
|
|
996
|
-
});
|
|
1196
|
+
});
|