fhirsmith 0.16.1 → 0.18.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/CHANGELOG.md +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/readme.md +1 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +107 -6
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// library/daily-report.js
|
|
2
|
+
// A daily markdown report on the server, emailed at midnight (local time) - or, if the
|
|
3
|
+
// server was down at midnight, as soon as it comes up afterwards. It's only sent when
|
|
4
|
+
// there's a way to send it: the top-level "email" block (see library/mailer.js) and
|
|
5
|
+
// someone to send it to:
|
|
6
|
+
//
|
|
7
|
+
// "dailyReport": {
|
|
8
|
+
// "enabled": true, // default true; the report still needs "email" and "to"
|
|
9
|
+
// "to": ["ops@example.org"]
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// The report is:
|
|
13
|
+
// - a paragraph on the server's own stability since the last report: how much of the
|
|
14
|
+
// time it was up, restarts, and any upgrade (from the run history ServerStats keeps)
|
|
15
|
+
// - a paragraph from each running module that has something to say. A module takes part
|
|
16
|
+
// by having `dailyReport({since, now})` return markdown (a string, or a promise of one);
|
|
17
|
+
// an empty return means no paragraph. By default modules have nothing to say
|
|
18
|
+
// - a paragraph on the system: version, Node and OS, how long this run has been up, the
|
|
19
|
+
// log files and the free disk space they're drawing on
|
|
20
|
+
//
|
|
21
|
+
// When each report was sent is kept in [data]/daily-report.json, which is how a restart
|
|
22
|
+
// knows whether last midnight's report is still owed, and what period the next one covers.
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const folders = require('./folder-setup');
|
|
28
|
+
|
|
29
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
// headings for the modules' paragraphs; a module not listed is headed by its key
|
|
31
|
+
const MODULE_TITLES = {
|
|
32
|
+
tx: 'Terminology server',
|
|
33
|
+
registry: 'Terminology server registry',
|
|
34
|
+
packages: 'Packages',
|
|
35
|
+
xig: 'XIG',
|
|
36
|
+
shl: 'SMART Health Links',
|
|
37
|
+
ips: 'IPS',
|
|
38
|
+
testing: 'Testing',
|
|
39
|
+
publisher: 'Publisher',
|
|
40
|
+
vcl: 'VCL',
|
|
41
|
+
token: 'Tokens',
|
|
42
|
+
npmprojector: 'npm projector',
|
|
43
|
+
extTracker: 'Extension tracker',
|
|
44
|
+
folder: 'Folders'
|
|
45
|
+
};
|
|
46
|
+
const STATE_FILE = 'daily-report.json';
|
|
47
|
+
const TASK_NAME = 'daily report';
|
|
48
|
+
|
|
49
|
+
function pad(n) {
|
|
50
|
+
return String(n).padStart(2, '0');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 2026-10-22 for the local date of `time` */
|
|
54
|
+
function localDate(time) {
|
|
55
|
+
const d = new Date(time);
|
|
56
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 2026-10-22 14:05 local */
|
|
60
|
+
function localDateTime(time) {
|
|
61
|
+
const d = new Date(time);
|
|
62
|
+
return `${localDate(time)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The next local midnight after `now` */
|
|
66
|
+
function nextMidnight(now) {
|
|
67
|
+
const d = new Date(now);
|
|
68
|
+
d.setHours(24, 0, 0, 0);
|
|
69
|
+
return d.getTime();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** "2d 3h 15m" - to the minute, which is all a daily report needs */
|
|
73
|
+
function formatSpan(ms) {
|
|
74
|
+
const minutes = Math.round(Math.max(0, ms) / 60000);
|
|
75
|
+
if (minutes < 1) {
|
|
76
|
+
return 'less than a minute';
|
|
77
|
+
}
|
|
78
|
+
const parts = [];
|
|
79
|
+
const days = Math.floor(minutes / 1440);
|
|
80
|
+
const hours = Math.floor((minutes % 1440) / 60);
|
|
81
|
+
const mins = minutes % 60;
|
|
82
|
+
if (days) parts.push(`${days}d`);
|
|
83
|
+
if (hours) parts.push(`${hours}h`);
|
|
84
|
+
if (mins || parts.length === 0) parts.push(`${mins}m`);
|
|
85
|
+
return parts.join(' ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function plural(n, word) {
|
|
89
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatBytes(bytes) {
|
|
93
|
+
if (bytes < 1024 * 1024) {
|
|
94
|
+
return `${(bytes / 1024).toFixed(0)} KB`;
|
|
95
|
+
}
|
|
96
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
97
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
98
|
+
}
|
|
99
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
class DailyReport {
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param config - the "dailyReport" block
|
|
106
|
+
* @param deps - {log, stats, modules, mailer, npmAudit, version, serverId, stateFile}
|
|
107
|
+
* modules is the server's {name: instance} map; npmAudit the server's NpmAudit (optional);
|
|
108
|
+
* version the server's version; serverId the name the report goes out under (config.hostName)
|
|
109
|
+
*/
|
|
110
|
+
constructor(config, deps) {
|
|
111
|
+
this.config = config || {};
|
|
112
|
+
this.log = deps.log || console;
|
|
113
|
+
this.stats = deps.stats;
|
|
114
|
+
this.modules = deps.modules || {};
|
|
115
|
+
this.mailer = deps.mailer;
|
|
116
|
+
this.npmAudit = deps.npmAudit || null;
|
|
117
|
+
this.version = deps.version;
|
|
118
|
+
this.serverId = deps.serverId || os.hostname();
|
|
119
|
+
this.stateFile = deps.stateFile || folders.filePath(STATE_FILE);
|
|
120
|
+
this.timer = null;
|
|
121
|
+
this.running = false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether reports will go out: turned on, somewhere to send them, and a
|
|
126
|
+
* way to send them.
|
|
127
|
+
*/
|
|
128
|
+
get enabled() {
|
|
129
|
+
const to = this.config.to;
|
|
130
|
+
const hasRecipient = Array.isArray(to) ? to.length > 0 : Boolean(to);
|
|
131
|
+
return this.config.enabled !== false && hasRecipient && Boolean(this.mailer && this.mailer.configured());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Say in the log why reports won't be sent, if they won't; then send the
|
|
136
|
+
* one owed from last midnight if the server was down for it, and set the
|
|
137
|
+
* timer for the next.
|
|
138
|
+
*/
|
|
139
|
+
start(now = Date.now()) {
|
|
140
|
+
if (this.config.enabled === false) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!this.enabled) {
|
|
144
|
+
const why = !(this.mailer && this.mailer.configured()) ? 'no "email" block in the configuration' : 'no "dailyReport.to" recipients';
|
|
145
|
+
this.log.info(`Daily report: not sent (${why})`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (this.stats) {
|
|
149
|
+
this.stats.addTask(TASK_NAME, 'daily');
|
|
150
|
+
}
|
|
151
|
+
const state = this.readState();
|
|
152
|
+
// owed if a midnight has passed since the last report went out (a server
|
|
153
|
+
// that has never reported waits for its first midnight)
|
|
154
|
+
if (state.sentAt && localDate(state.sentAt) !== localDate(now)) {
|
|
155
|
+
this.log.info('Daily report: the server was down at midnight, sending it now');
|
|
156
|
+
void this.run(now);
|
|
157
|
+
}
|
|
158
|
+
this.schedule(now);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
schedule(now = Date.now()) {
|
|
162
|
+
const delay = Math.max(1000, nextMidnight(now) - now);
|
|
163
|
+
this.timer = setTimeout(() => {
|
|
164
|
+
this.timer = null;
|
|
165
|
+
// run() handles its own errors, so the promise never rejects
|
|
166
|
+
void this.run().finally(() => this.schedule());
|
|
167
|
+
}, delay);
|
|
168
|
+
if (this.timer.unref) {
|
|
169
|
+
this.timer.unref();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
stop() {
|
|
174
|
+
if (this.timer) {
|
|
175
|
+
clearTimeout(this.timer);
|
|
176
|
+
this.timer = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
readState() {
|
|
181
|
+
try {
|
|
182
|
+
if (fs.existsSync(this.stateFile)) {
|
|
183
|
+
return JSON.parse(fs.readFileSync(this.stateFile, 'utf8')) || {};
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
this.log.warn(`Daily report: unable to read ${this.stateFile}: ${e.message}`);
|
|
187
|
+
}
|
|
188
|
+
return {};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
writeState(state) {
|
|
192
|
+
try {
|
|
193
|
+
fs.mkdirSync(path.dirname(this.stateFile), {recursive: true});
|
|
194
|
+
fs.writeFileSync(this.stateFile, JSON.stringify(state, null, 2));
|
|
195
|
+
} catch (e) {
|
|
196
|
+
this.log.error(`Daily report: unable to write ${this.stateFile}: ${e.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Build and send the report covering the time since the last one (or the
|
|
202
|
+
* last day, for a first report). Never throws.
|
|
203
|
+
*/
|
|
204
|
+
async run(now = Date.now()) {
|
|
205
|
+
if (this.running) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
this.running = true;
|
|
209
|
+
try {
|
|
210
|
+
const state = this.readState();
|
|
211
|
+
const since = state.sentAt || (now - DAY_MS);
|
|
212
|
+
if (this.stats) {
|
|
213
|
+
this.stats.task(TASK_NAME, 'Building');
|
|
214
|
+
if (typeof this.stats.flushNow === 'function') {
|
|
215
|
+
// so the modules' counts run right up to now
|
|
216
|
+
this.stats.flushNow(now);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const text = await this.build(since, now);
|
|
220
|
+
await this.mailer.send({to: this.config.to, subject: this.subject(), text: text});
|
|
221
|
+
this.writeState({sentAt: now, date: localDate(now)});
|
|
222
|
+
if (this.stats) {
|
|
223
|
+
this.stats.taskDone(TASK_NAME, `Sent ${localDateTime(now)}`);
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
} catch (e) {
|
|
227
|
+
this.log.error(`Daily report: not sent: ${e.message}`);
|
|
228
|
+
if (this.stats) {
|
|
229
|
+
this.stats.taskError(TASK_NAME, `Not sent: ${e.message}`);
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
} finally {
|
|
233
|
+
this.running = false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
subject() {
|
|
238
|
+
return `${this.serverId} daily report`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The report as markdown, for the period since .. now.
|
|
243
|
+
*/
|
|
244
|
+
async build(since, now = Date.now()) {
|
|
245
|
+
const parts = [];
|
|
246
|
+
parts.push(`# ${this.serverId} daily report`);
|
|
247
|
+
parts.push(`${localDateTime(now)}, covering the ${formatSpan(now - since)} since ${localDateTime(since)}.`);
|
|
248
|
+
parts.push('## Stability');
|
|
249
|
+
parts.push(this.stabilityParagraph(since, now));
|
|
250
|
+
for (const [name, module] of Object.entries(this.modules)) {
|
|
251
|
+
const text = await this.moduleParagraph(name, module, since, now);
|
|
252
|
+
if (text) {
|
|
253
|
+
parts.push(`## ${MODULE_TITLES[name] || name}`);
|
|
254
|
+
parts.push(text);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
parts.push('## System');
|
|
258
|
+
parts.push(this.systemParagraph(now));
|
|
259
|
+
const tasks = this.tasksParagraph(now);
|
|
260
|
+
if (tasks) {
|
|
261
|
+
parts.push('## Background tasks');
|
|
262
|
+
parts.push(tasks);
|
|
263
|
+
}
|
|
264
|
+
return parts.join('\n\n') + '\n';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* How much of the period the server was running, restarts, and upgrades -
|
|
269
|
+
* from the run history. Each run counts from its start to its last
|
|
270
|
+
* heartbeat (every minute), so a crash shows as down from about when it
|
|
271
|
+
* happened.
|
|
272
|
+
*/
|
|
273
|
+
stabilityParagraph(since, now) {
|
|
274
|
+
const runs = this.stats ? this.stats.runHistory(since, now) : null;
|
|
275
|
+
if (!runs) {
|
|
276
|
+
return 'No run history is available (the statistics database is not in use), so the uptime over the period is unknown.';
|
|
277
|
+
}
|
|
278
|
+
const period = now - since;
|
|
279
|
+
let running = 0;
|
|
280
|
+
const restarts = [];
|
|
281
|
+
const upgrades = [];
|
|
282
|
+
let previous = null;
|
|
283
|
+
for (const run of runs) {
|
|
284
|
+
const from = Math.max(run.started, since);
|
|
285
|
+
const to = Math.min(run.lastSeen, now);
|
|
286
|
+
if (to > from) {
|
|
287
|
+
running += to - from;
|
|
288
|
+
}
|
|
289
|
+
if (run.started >= since) {
|
|
290
|
+
restarts.push(run);
|
|
291
|
+
}
|
|
292
|
+
if (previous && run.version && previous.version && run.version !== previous.version) {
|
|
293
|
+
upgrades.push({from: previous.version, to: run.version, at: run.started});
|
|
294
|
+
}
|
|
295
|
+
previous = run;
|
|
296
|
+
}
|
|
297
|
+
const percent = period > 0 ? Math.min(100, (running * 100) / period) : 100;
|
|
298
|
+
const pct = percent >= 100 ? '100' : (Math.floor(percent * 10) / 10).toFixed(1);
|
|
299
|
+
const sentences = [];
|
|
300
|
+
sentences.push(`${this.serverId} was up for ${formatSpan(running)} of the ${formatSpan(now - since)} since the last report (${pct}%).`);
|
|
301
|
+
if (restarts.length === 0) {
|
|
302
|
+
sentences.push('It ran without interruption.');
|
|
303
|
+
} else {
|
|
304
|
+
const when = restarts.map(r => localDateTime(r.started)).join(', ');
|
|
305
|
+
sentences.push(`It ${restarts.length === 1 ? 'started' : 'started ' + plural(restarts.length, 'time')} during the period (${when}).`);
|
|
306
|
+
}
|
|
307
|
+
// the gap before a restart is the downtime; name the worst one if there is one
|
|
308
|
+
let worst = null;
|
|
309
|
+
for (let i = 1; i < runs.length; i++) {
|
|
310
|
+
const gapStart = Math.max(runs[i - 1].lastSeen, since);
|
|
311
|
+
const gap = runs[i].started - gapStart;
|
|
312
|
+
if (gap > 0 && (!worst || gap > worst.length)) {
|
|
313
|
+
worst = {length: gap, at: gapStart};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (worst && worst.length >= 60 * 1000) {
|
|
317
|
+
sentences.push(`The longest outage was ${formatSpan(worst.length)}, from ${localDateTime(worst.at)}.`);
|
|
318
|
+
}
|
|
319
|
+
for (const u of upgrades) {
|
|
320
|
+
sentences.push(`It was upgraded from v${u.from} to v${u.to} at ${localDateTime(u.at)}.`);
|
|
321
|
+
}
|
|
322
|
+
if (upgrades.length === 0) {
|
|
323
|
+
sentences.push(`It was not upgraded (v${this.version}).`);
|
|
324
|
+
}
|
|
325
|
+
return sentences.join(' ');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A module's paragraph, if it has one. A module that throws gets a line
|
|
330
|
+
* saying so rather than taking the report down.
|
|
331
|
+
*/
|
|
332
|
+
async moduleParagraph(name, module, since, now) {
|
|
333
|
+
if (!module || typeof module.dailyReport !== 'function') {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const text = await module.dailyReport({since: since, now: now});
|
|
338
|
+
return typeof text === 'string' && text.trim() ? text.trim() : null;
|
|
339
|
+
} catch (e) {
|
|
340
|
+
this.log.error(`Daily report: the ${name} module's report failed: ${e.message}`);
|
|
341
|
+
return `The ${name} module's report failed: ${e.message}`;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Version, platform, how long this run has been up, and the log files and
|
|
347
|
+
* the disk they're on.
|
|
348
|
+
*/
|
|
349
|
+
systemParagraph(now) {
|
|
350
|
+
const sentences = [];
|
|
351
|
+
sentences.push(`${this.serverId} is running FHIRsmith v${this.version} on Node.js ${process.version} (${os.type()} ${os.release()}, ${os.arch()}).`);
|
|
352
|
+
if (this.stats && typeof this.stats.startTime === 'number') {
|
|
353
|
+
let up = `This run has been up for ${formatSpan(now - this.stats.startTime)}, since ${localDateTime(this.stats.startTime)}`;
|
|
354
|
+
const overall = typeof this.stats.overallUptime === 'function' ? this.stats.overallUptime(now) : null;
|
|
355
|
+
if (overall) {
|
|
356
|
+
const pct = overall.percent >= 100 ? '100' : (Math.floor(overall.percent * 10) / 10).toFixed(1);
|
|
357
|
+
up += `; over all its recorded runs (since ${localDate(overall.since)}) the server has been up ${pct}% of the time`;
|
|
358
|
+
}
|
|
359
|
+
sentences.push(up + '.');
|
|
360
|
+
}
|
|
361
|
+
sentences.push(this.logsSentence());
|
|
362
|
+
if (this.npmAudit && typeof this.npmAudit.reportText === 'function') {
|
|
363
|
+
const audit = this.npmAudit.reportText();
|
|
364
|
+
if (audit) {
|
|
365
|
+
sentences.push(audit);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return sentences.join(' ');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The background tasks whose last run failed, if any - one line each. The
|
|
373
|
+
* npm audit has its own sentence in the system paragraph, and this report's
|
|
374
|
+
* own task is never in a failed state while it's being written.
|
|
375
|
+
*/
|
|
376
|
+
tasksParagraph(now) {
|
|
377
|
+
if (!this.stats || typeof this.stats.failedTasks !== 'function') {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const failed = this.stats.failedTasks().filter(t => t.name !== 'npm audit' && t.name !== TASK_NAME);
|
|
381
|
+
if (failed.length === 0) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const lines = failed.map(t => {
|
|
385
|
+
const when = t.date ? ` (${formatSpan(now - t.date)} ago)` : '';
|
|
386
|
+
return `- ${t.name}: the last run failed${when}: ${t.state}`;
|
|
387
|
+
});
|
|
388
|
+
return `${plural(failed.length, 'background task')} ${failed.length === 1 ? 'is' : 'are'} in a failed state:\n\n${lines.join('\n')}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
logsSentence() {
|
|
392
|
+
const logDir = folders.logsDir();
|
|
393
|
+
try {
|
|
394
|
+
const files = fs.readdirSync(logDir).filter(f => f.endsWith('.log'));
|
|
395
|
+
let total = 0;
|
|
396
|
+
for (const f of files) {
|
|
397
|
+
total += fs.statSync(path.join(logDir, f)).size;
|
|
398
|
+
}
|
|
399
|
+
let disk = '';
|
|
400
|
+
try {
|
|
401
|
+
const fstats = fs.statfsSync(logDir);
|
|
402
|
+
const free = fstats.bavail * fstats.bsize;
|
|
403
|
+
const size = fstats.blocks * fstats.bsize;
|
|
404
|
+
const freePct = size > 0 ? Math.round((free * 100) / size) : 0;
|
|
405
|
+
disk = `, on a disk with ${formatBytes(free)} free of ${formatBytes(size)} (${freePct}% free)`;
|
|
406
|
+
} catch {
|
|
407
|
+
disk = ', on a disk whose free space could not be read';
|
|
408
|
+
}
|
|
409
|
+
return `The logs folder (${logDir}) holds ${plural(files.length, 'log file')} totalling ${formatBytes(total)}${disk}.`;
|
|
410
|
+
} catch (e) {
|
|
411
|
+
return `The logs folder (${logDir}) could not be read: ${e.message}.`;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
module.exports = DailyReport;
|
|
417
|
+
module.exports.nextMidnight = nextMidnight;
|
|
418
|
+
module.exports.localDate = localDate;
|
|
419
|
+
module.exports.formatSpan = formatSpan;
|
|
420
|
+
// for modules writing their paragraph: "3 packages", "1 package"
|
|
421
|
+
module.exports.plural = plural;
|
|
422
|
+
module.exports.localDateTime = localDateTime;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The value-level parts of FHIR search that every module with a search implements the
|
|
9
|
+
* same way: date ranges, the comparison prefixes, comma-separated values, and LIKE
|
|
10
|
+
* escaping. The SQL that uses them is each module's own (testing/search.js,
|
|
11
|
+
* ips/search.js).
|
|
12
|
+
*
|
|
13
|
+
* @module library/fhir-search-values
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const PREFIXES = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'sa', 'eb', 'ap'];
|
|
17
|
+
|
|
18
|
+
const DATE_RE = /^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The range of instants a FHIR date, dateTime or instant covers, as [lo, hi) in epoch
|
|
22
|
+
* milliseconds. "2026" covers the whole year; "2026-09-24T10:00:00.5Z" covers 100ms.
|
|
23
|
+
* A time with no zone is taken as UTC (FHIR requires a zone, but search values often
|
|
24
|
+
* leave it off).
|
|
25
|
+
*
|
|
26
|
+
* @param {string} value
|
|
27
|
+
* @returns {{lo: number, hi: number}|null} null if the value isn't a date
|
|
28
|
+
*/
|
|
29
|
+
function dateRange(value) {
|
|
30
|
+
if (typeof value !== 'string') {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const m = DATE_RE.exec(value.trim());
|
|
34
|
+
if (!m) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const [, y, mo, d, h, mi, s, frac, tz] = m;
|
|
38
|
+
const year = parseInt(y, 10);
|
|
39
|
+
if (!mo) {
|
|
40
|
+
return { lo: Date.UTC(year, 0, 1), hi: Date.UTC(year + 1, 0, 1) };
|
|
41
|
+
}
|
|
42
|
+
const month = parseInt(mo, 10) - 1;
|
|
43
|
+
if (month < 0 || month > 11) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (!d) {
|
|
47
|
+
return { lo: Date.UTC(year, month, 1), hi: Date.UTC(year, month + 1, 1) };
|
|
48
|
+
}
|
|
49
|
+
const day = parseInt(d, 10);
|
|
50
|
+
if (day < 1 || day > new Date(Date.UTC(year, month + 1, 0)).getUTCDate()) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (!h) {
|
|
54
|
+
return { lo: Date.UTC(year, month, day), hi: Date.UTC(year, month, day + 1) };
|
|
55
|
+
}
|
|
56
|
+
const zone = tz || 'Z';
|
|
57
|
+
const text = `${y}-${mo}-${d}T${h}:${mi}:${s || '00'}${frac || ''}${zone}`;
|
|
58
|
+
const lo = Date.parse(text);
|
|
59
|
+
if (Number.isNaN(lo) || parseInt(h, 10) > 23 || parseInt(mi, 10) > 59 || (s && parseInt(s, 10) > 59)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
let width;
|
|
63
|
+
if (!s) {
|
|
64
|
+
width = 60000;
|
|
65
|
+
} else if (!frac) {
|
|
66
|
+
width = 1000;
|
|
67
|
+
} else {
|
|
68
|
+
// .5 is 100ms wide, .50 10ms, .500 and beyond 1ms
|
|
69
|
+
width = Math.max(1, Math.pow(10, 3 - (frac.length - 1)));
|
|
70
|
+
}
|
|
71
|
+
return { lo, hi: lo + width };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Take the comparison prefix (eq, ge, ...) off a date or number search value.
|
|
76
|
+
* @returns {{prefix: string, rest: string}} prefix is 'eq' when there is none
|
|
77
|
+
*/
|
|
78
|
+
function splitPrefix(value) {
|
|
79
|
+
const p = value.substring(0, 2);
|
|
80
|
+
if (PREFIXES.includes(p) && value.length > 2 && !/^[a-z]/.test(value.substring(2))) {
|
|
81
|
+
return { prefix: p, rest: value.substring(2) };
|
|
82
|
+
}
|
|
83
|
+
return { prefix: 'eq', rest: value };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Escape a value for use in a LIKE pattern with ESCAPE '\'. */
|
|
87
|
+
function escapeLike(value) {
|
|
88
|
+
return value.replace(/[\\%_]/g, (c) => '\\' + c);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Split a parameter value on commas, honouring \, as an escaped comma.
|
|
93
|
+
*/
|
|
94
|
+
function splitValues(value) {
|
|
95
|
+
const out = [];
|
|
96
|
+
let cur = '';
|
|
97
|
+
for (let i = 0; i < value.length; i++) {
|
|
98
|
+
const c = value[i];
|
|
99
|
+
if (c === '\\' && i + 1 < value.length) {
|
|
100
|
+
cur += value[i + 1];
|
|
101
|
+
i++;
|
|
102
|
+
} else if (c === ',') {
|
|
103
|
+
out.push(cur);
|
|
104
|
+
cur = '';
|
|
105
|
+
} else {
|
|
106
|
+
cur += c;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
out.push(cur);
|
|
110
|
+
return out.filter(v => v.length > 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* SQL comparing a stored [lo, hi) range (two column expressions) against a search
|
|
115
|
+
* range, for a comparison prefix.
|
|
116
|
+
*
|
|
117
|
+
* @param {{lo: string, hi: string}} cols - column expressions
|
|
118
|
+
* @param {string} prefix
|
|
119
|
+
* @param {{lo: number, hi: number}} range
|
|
120
|
+
* @returns {{sql: string, args: number[]}|null}
|
|
121
|
+
*/
|
|
122
|
+
function dateClause(cols, prefix, range) {
|
|
123
|
+
const { lo, hi } = cols;
|
|
124
|
+
switch (prefix) {
|
|
125
|
+
case 'eq': return { sql: `(${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
|
|
126
|
+
case 'ne': return { sql: `NOT (${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
|
|
127
|
+
case 'gt': return { sql: `${hi} > ?`, args: [range.hi] };
|
|
128
|
+
case 'lt': return { sql: `${lo} < ?`, args: [range.lo] };
|
|
129
|
+
case 'ge': return { sql: `${hi} > ?`, args: [range.lo] };
|
|
130
|
+
case 'le': return { sql: `${lo} < ?`, args: [range.hi] };
|
|
131
|
+
case 'sa': return { sql: `${lo} >= ?`, args: [range.hi] };
|
|
132
|
+
case 'eb': return { sql: `${hi} <= ?`, args: [range.lo] };
|
|
133
|
+
case 'ap': return { sql: `(${lo} < ? AND ${hi} > ?)`, args: [range.hi, range.lo] };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { PREFIXES, dateRange, splitPrefix, escapeLike, splitValues, dateClause };
|
package/library/html-server.js
CHANGED
|
@@ -68,37 +68,46 @@ class HtmlServer {
|
|
|
68
68
|
...options
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
// The placeholders, and what each becomes. Everything is substituted in one pass over
|
|
72
|
+
// the template, so what goes in - [%content%] above all, which holds what users wrote
|
|
73
|
+
// (document narratives, say) - is never itself searched for placeholders: a
|
|
74
|
+
// "[%context-link%]" inside content stays as it is, rather than becoming raw HTML in the
|
|
75
|
+
// middle of an attribute
|
|
76
|
+
const values = new Map([
|
|
77
|
+
['title', () => escape(title)],
|
|
78
|
+
['content', () => content], // Content is assumed to be already-safe HTML
|
|
75
79
|
// [%ver%] is the FHIRsmith version in every template (it follows the FHIRsmith link)
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
['ver', () => escape(packageJson.version)],
|
|
81
|
+
['download-date', () => escape(renderOptions.downloadDate)],
|
|
78
82
|
// "last updated 35 minutes ago" / "not yet updated", for the crawler-driven modules
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
['crawler-status', () => escape(renderOptions.crawlerStatus || 'not yet updated')],
|
|
84
|
+
['total-resources', () => escape(renderOptions.totalResources.toLocaleString())],
|
|
85
|
+
['total-packages', () => escape(renderOptions.totalPackages.toLocaleString())],
|
|
86
|
+
['endpoint-path', () => escape(renderOptions.endpointpath)],
|
|
87
|
+
['fhir-version', () => escape(renderOptions.fhirversion)],
|
|
88
|
+
['ms', () => escape(renderOptions.processingTime.toString())],
|
|
89
|
+
['sponsorMessage', () => sponsorMessage],
|
|
90
|
+
['about', () => renderOptions.about || ''],
|
|
87
91
|
// raw HTML, like [%about%]: the caller decides whether the nav item exists at all
|
|
88
|
-
|
|
92
|
+
['library-link', () => renderOptions.libraryLink || ''],
|
|
89
93
|
// raw HTML too: the OpenAPI description, where a template's module has one in some places only
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
['api-link', () => renderOptions.apiLink || ''],
|
|
95
|
+
// raw HTML: a link to the current project (or other context) in the nav bar, where the
|
|
96
|
+
// module has one
|
|
97
|
+
['context-link', () => renderOptions.contextLink || ''],
|
|
98
|
+
['api-head', () => renderOptions.apiHead || '']
|
|
99
|
+
]);
|
|
100
|
+
// a module's own template variables, escaped when they are strings; the standard ones win
|
|
94
101
|
if (options.templateVars) {
|
|
95
102
|
for (const [key, value] of Object.entries(options.templateVars)) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
if (!values.has(key)) {
|
|
104
|
+
values.set(key, () => (typeof value === 'string' ? escape(value) : String(value)));
|
|
105
|
+
}
|
|
99
106
|
}
|
|
100
107
|
}
|
|
101
|
-
|
|
108
|
+
// a placeholder that isn't known is left as it is, as before
|
|
109
|
+
const html = template.replace(/\[%([^%\]\s]{1,64})%\]/g, (m, name) => (values.has(name) ? values.get(name)() : m));
|
|
110
|
+
|
|
102
111
|
return html;
|
|
103
112
|
}
|
|
104
113
|
|
package/library/html.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
const {validateParameter, validateOptionalParameter} = require("./utilities");
|
|
2
2
|
const escape = require('escape-html');
|
|
3
3
|
|
|
4
|
+
// Attributes that take a URL, and the schemes that make a URL run script. Browsers ignore
|
|
5
|
+
// leading whitespace and control characters, and embedded tabs and newlines, in the scheme.
|
|
6
|
+
const URL_ATTRIBUTES = new Set(['href', 'src', 'action', 'formaction', 'xlink:href']);
|
|
7
|
+
// eslint-disable-next-line no-control-regex -- control characters are exactly what browsers skip
|
|
8
|
+
const UNSAFE_URL = /^[\s\x00-\x1f]*(j[\s]*a[\s]*v[\s]*a[\s]*s[\s]*c[\s]*r[\s]*i[\s]*p[\s]*t|v[\s]*b[\s]*s[\s]*c[\s]*r[\s]*i[\s]*p[\s]*t|d[\s]*a[\s]*t[\s]*a)[\s]*:/i;
|
|
9
|
+
|
|
4
10
|
const NodeType = {
|
|
5
11
|
Document: 'Document',
|
|
6
12
|
Element: 'Element',
|
|
@@ -25,6 +31,9 @@ class XhtmlNode {
|
|
|
25
31
|
// Attribute methods
|
|
26
32
|
setAttribute(name, value) {
|
|
27
33
|
if (value != null) {
|
|
34
|
+
if (URL_ATTRIBUTES.has(String(name).toLowerCase()) && UNSAFE_URL.test(String(value))) {
|
|
35
|
+
value = '#'; // a link that runs script (javascript:, data:, vbscript:) - from resource content
|
|
36
|
+
}
|
|
28
37
|
this.attributes.set(name, value);
|
|
29
38
|
}
|
|
30
39
|
return this;
|