fhirsmith 0.17.0 → 0.18.1
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 +113 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- 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 +432 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- 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 -3
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -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 +230 -44
- package/registry/uptime.js +154 -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/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 +10 -0
- package/tx/README.md +52 -0
- package/tx/closure/closure-store.js +14 -0
- package/tx/cs/cs-api.js +92 -14
- package/tx/cs/cs-cs.js +64 -3
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +431 -0
- package/tx/cs/cs-loinc.js +296 -27
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +1210 -80
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/csv-records.js +35 -0
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/importers/import-loinc.module.js +177 -41
- package/tx/importers/import-sct.module.js +66 -1
- package/tx/importers/subset-loinc.module.js +25 -6
- package/tx/library/codesystem.js +4 -1
- package/tx/library/designations.js +23 -30
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +6 -1
- package/tx/params.js +8 -1
- package/tx/provider.js +24 -6
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/sct/structures.js +8 -2
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +3 -4
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/closure.js +20 -0
- package/tx/workers/expand.js +35 -8
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +138 -6
- package/utilities/make-sct-loinc-subset.js +250 -0
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
<a href="/tx-reg/resolve" style="color: gold">Resolve</a> |
|
|
63
63
|
<a href="/tx-reg/software" style="color: gold">Software</a> |
|
|
64
64
|
<a href="/tx-reg/log" style="color: gold">Crawler Log</a> |
|
|
65
|
-
<a href="/tx-reg/openapi" style="color: gold">API</a>
|
|
65
|
+
<a href="/tx-reg/openapi" style="color: gold">API</a>[%account%]
|
|
66
66
|
</div> <!-- /container -->
|
|
67
67
|
</div> <!-- /container -->
|
|
68
68
|
</div>
|
package/registry/registry.js
CHANGED
|
@@ -11,6 +11,8 @@ const folders = require('../library/folder-setup');
|
|
|
11
11
|
const escape = require('escape-html');
|
|
12
12
|
const registryOpenApi = require('./openapi');
|
|
13
13
|
const { FhirsmithReleases, isFhirsmith, describeAge } = require('./fhirsmith-releases');
|
|
14
|
+
const { formatUptime } = require('./uptime');
|
|
15
|
+
const { RegistryAuth } = require('./auth');
|
|
14
16
|
|
|
15
17
|
// The query parameters of the public API. These are the contract published in openapi.yaml,
|
|
16
18
|
// and tests/registry/openapi.test.js checks that the two agree - so change both together.
|
|
@@ -25,6 +27,35 @@ function safeHref(url) {
|
|
|
25
27
|
return typeof url === 'string' && /^https?:\/\//i.test(url.trim()) ? escape(url) : '#';
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The base URL that a server's endpoints share: http://tx.fhir.org for http://tx.fhir.org/r4
|
|
32
|
+
* and http://tx.fhir.org/r5. One endpoint is its own base
|
|
33
|
+
*/
|
|
34
|
+
function commonBaseUrl(urls) {
|
|
35
|
+
const list = urls.filter(u => u);
|
|
36
|
+
if (list.length === 0) {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
if (list.length === 1) {
|
|
40
|
+
return list[0];
|
|
41
|
+
}
|
|
42
|
+
let prefix = list[0];
|
|
43
|
+
for (const url of list.slice(1)) {
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (i < prefix.length && i < url.length && prefix[i] === url[i]) {
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
prefix = prefix.substring(0, i);
|
|
49
|
+
}
|
|
50
|
+
// back up to a path boundary, unless the prefix already is one
|
|
51
|
+
if (!list.every(u => u === prefix || u.startsWith(prefix + '/'))) {
|
|
52
|
+
prefix = prefix.substring(0, prefix.lastIndexOf('/'));
|
|
53
|
+
}
|
|
54
|
+
prefix = prefix.replace(/\/+$/, '');
|
|
55
|
+
// less than scheme + host is not a base URL at all
|
|
56
|
+
return /^https?:\/\/[^/]+/.test(prefix) ? prefix : list[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
28
59
|
class RegistryModule {
|
|
29
60
|
constructor(stats) {
|
|
30
61
|
this.router = express.Router();
|
|
@@ -41,6 +72,8 @@ class RegistryModule {
|
|
|
41
72
|
this.currentData = null;
|
|
42
73
|
this.dataLock = false;
|
|
43
74
|
this.stats = stats;
|
|
75
|
+
// open until initialize() reads modules.registry.security
|
|
76
|
+
this.auth = new RegistryAuth({}, { logger: this.logger, renderPage: () => {} });
|
|
44
77
|
}
|
|
45
78
|
|
|
46
79
|
/**
|
|
@@ -57,7 +90,9 @@ class RegistryModule {
|
|
|
57
90
|
timeout: config.timeout || 30000,
|
|
58
91
|
userAgent: config.userAgent || 'FHIRRegistryServer/1.0',
|
|
59
92
|
apiKeys: config.apiKeys || {},
|
|
60
|
-
maxResponseMB: config.maxResponseMB
|
|
93
|
+
maxResponseMB: config.maxResponseMB,
|
|
94
|
+
uptimeWindowDays: config.uptimeWindowDays,
|
|
95
|
+
uptimeRetentionDays: config.uptimeRetentionDays
|
|
61
96
|
};
|
|
62
97
|
|
|
63
98
|
this.crawler = new RegistryCrawler(crawlerConfig, this.stats);
|
|
@@ -76,6 +111,17 @@ class RegistryModule {
|
|
|
76
111
|
// Load saved data if available
|
|
77
112
|
await this.loadSavedData();
|
|
78
113
|
|
|
114
|
+
// Securing the API (see auth.js). With no modules.registry.security, the API is open
|
|
115
|
+
this.auth = new RegistryAuth(config.security, {
|
|
116
|
+
logger: this.logger,
|
|
117
|
+
renderPage: (req, res, title, content, status) => {
|
|
118
|
+
res.status(status || 200);
|
|
119
|
+
res.setHeader('Content-Type', 'text/html');
|
|
120
|
+
res.send(this.renderPage('registry', title, content, this.api.getStatistics(), req));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
await this.auth.initialize();
|
|
124
|
+
|
|
79
125
|
// Set up routes
|
|
80
126
|
this.setupRoutes();
|
|
81
127
|
|
|
@@ -113,6 +159,13 @@ class RegistryModule {
|
|
|
113
159
|
} catch (error) {
|
|
114
160
|
this.logger.info('No saved registry data found, will fetch fresh data');
|
|
115
161
|
}
|
|
162
|
+
try {
|
|
163
|
+
const fs = require('fs').promises;
|
|
164
|
+
const uptimePath = folders.ensureFilePath('registry', 'registry-uptime.json');
|
|
165
|
+
this.crawler.loadUptime(JSON.parse(await fs.readFile(uptimePath, 'utf8')));
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.logger.info('No saved uptime history found');
|
|
168
|
+
}
|
|
116
169
|
}
|
|
117
170
|
|
|
118
171
|
/**
|
|
@@ -125,6 +178,8 @@ class RegistryModule {
|
|
|
125
178
|
|
|
126
179
|
const data = this.crawler.saveData();
|
|
127
180
|
await fs.writeFile(dataPath, JSON.stringify(data, null, 2));
|
|
181
|
+
const uptimePath = folders.ensureFilePath('registry', 'registry-uptime.json');
|
|
182
|
+
await fs.writeFile(uptimePath, JSON.stringify(this.crawler.saveUptime()));
|
|
128
183
|
this.logger.debug('Saved registry data to disk');
|
|
129
184
|
} catch (error) {
|
|
130
185
|
this.logger.error('Failed to save registry data:', error);
|
|
@@ -255,11 +310,23 @@ class RegistryModule {
|
|
|
255
310
|
next();
|
|
256
311
|
});
|
|
257
312
|
|
|
313
|
+
// The JSON API is what security protects; the HTML pages stay open
|
|
314
|
+
const guard = this.auth.apiGuard();
|
|
315
|
+
const apiGuard = (req, res, next) => {
|
|
316
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
317
|
+
return acceptsHtml ? next() : guard(req, res, next);
|
|
318
|
+
};
|
|
319
|
+
// failed authentications are limited (successful ones aren't counted)
|
|
320
|
+
const apiLimiter = this.auth.apiLimiter();
|
|
321
|
+
// the session, where there are users, so the pages know who is logged in (it makes none)
|
|
322
|
+
const session = this.auth.optionalSession();
|
|
323
|
+
this.auth.setupRoutes(this.router);
|
|
324
|
+
|
|
258
325
|
// Main registry page
|
|
259
|
-
this.router.get('/', this.handleMainPage.bind(this));
|
|
260
|
-
this.router.get('/resolve', this.handleResolveEndpoint.bind(this));
|
|
261
|
-
this.router.get('/log', this.handleLogEndpoint.bind(this));
|
|
262
|
-
this.router.get('/software', this.handleSoftwarePage.bind(this));
|
|
326
|
+
this.router.get('/', apiLimiter, apiGuard, session, this.handleMainPage.bind(this));
|
|
327
|
+
this.router.get('/resolve', apiLimiter, apiGuard, session, this.handleResolveEndpoint.bind(this));
|
|
328
|
+
this.router.get('/log', session, this.handleLogEndpoint.bind(this));
|
|
329
|
+
this.router.get('/software', session, this.handleSoftwarePage.bind(this));
|
|
263
330
|
|
|
264
331
|
// OpenAPI description of this API: /openapi.json, /openapi.yaml, and /openapi (an HTML
|
|
265
332
|
// reference for browsers, the JSON otherwise)
|
|
@@ -280,8 +347,8 @@ class RegistryModule {
|
|
|
280
347
|
if (!htmlServer.hasTemplate('registry')) {
|
|
281
348
|
htmlServer.loadTemplate('registry', path.join(__dirname, 'registry-template.html'));
|
|
282
349
|
}
|
|
283
|
-
const html =
|
|
284
|
-
registryOpenApi.renderHtml(), this.api ? this.api.getStatistics() : {});
|
|
350
|
+
const html = this.renderPage('registry', 'Terminology Server Registry API',
|
|
351
|
+
registryOpenApi.renderHtml(), this.api ? this.api.getStatistics() : {}, req);
|
|
285
352
|
res.setHeader('Content-Type', 'text/html');
|
|
286
353
|
res.send(html);
|
|
287
354
|
} catch (error) {
|
|
@@ -291,6 +358,18 @@ class RegistryModule {
|
|
|
291
358
|
});
|
|
292
359
|
}
|
|
293
360
|
|
|
361
|
+
/**
|
|
362
|
+
* The registry's pages: html-server's template, plus the login/account link in the
|
|
363
|
+
* navbar (pages rendered without a request show the login link)
|
|
364
|
+
*/
|
|
365
|
+
renderPage(template, title, content, stats, req) {
|
|
366
|
+
if (template === 'registry' && !htmlServer.hasTemplate('registry')) {
|
|
367
|
+
htmlServer.loadTemplate('registry', path.join(__dirname, 'registry-template.html'));
|
|
368
|
+
}
|
|
369
|
+
const html = htmlServer.renderPage(template, title, content, stats);
|
|
370
|
+
return html.replace('[%account%]', () => this.auth ? this.auth.navLinks(req || {}) : '');
|
|
371
|
+
}
|
|
372
|
+
|
|
294
373
|
/**
|
|
295
374
|
* Render HTML page for code system or value set query
|
|
296
375
|
* Combines functionality from sendHtmlCS and sendHtmlVS
|
|
@@ -341,14 +420,15 @@ class RegistryModule {
|
|
|
341
420
|
htmlServer.loadTemplate('registry', templatePath);
|
|
342
421
|
}
|
|
343
422
|
|
|
344
|
-
return
|
|
423
|
+
return this.renderPage(
|
|
345
424
|
'registry',
|
|
346
425
|
'FHIR Terminology Server Registry',
|
|
347
426
|
this.buildHtmlContent(),
|
|
348
427
|
{
|
|
349
428
|
...this.api.getStatistics(),
|
|
350
429
|
templateVars: templateVars
|
|
351
|
-
}
|
|
430
|
+
},
|
|
431
|
+
req
|
|
352
432
|
);
|
|
353
433
|
} catch (error) {
|
|
354
434
|
this.logger.error('Error rendering page:', error);
|
|
@@ -458,11 +538,11 @@ class RegistryModule {
|
|
|
458
538
|
stats.crawlInProgress = this.crawlInProgress;
|
|
459
539
|
stats.lastCrawl = this.lastCrawlTime;
|
|
460
540
|
|
|
461
|
-
const html =
|
|
541
|
+
const html = this.renderPage(
|
|
462
542
|
'registry',
|
|
463
543
|
'FHIR Terminology Server Registry',
|
|
464
544
|
content,
|
|
465
|
-
stats
|
|
545
|
+
stats, req
|
|
466
546
|
);
|
|
467
547
|
|
|
468
548
|
res.setHeader('Content-Type', 'text/html');
|
|
@@ -491,7 +571,7 @@ class RegistryModule {
|
|
|
491
571
|
const content = this.buildSoftwareContent();
|
|
492
572
|
const stats = this.api.getStatistics();
|
|
493
573
|
stats.processingTime = Date.now() - startTime;
|
|
494
|
-
const html =
|
|
574
|
+
const html = this.renderPage('registry', 'Terminology Server Software', content, stats, req);
|
|
495
575
|
res.setHeader('Content-Type', 'text/html');
|
|
496
576
|
res.send(html);
|
|
497
577
|
} catch (error) {
|
|
@@ -509,24 +589,8 @@ class RegistryModule {
|
|
|
509
589
|
'<p>The initial crawl is in progress. Please refresh in a moment.</p></div>';
|
|
510
590
|
}
|
|
511
591
|
|
|
512
|
-
const rows =
|
|
513
|
-
|
|
514
|
-
for (const server of registry.servers || []) {
|
|
515
|
-
for (const version of server.versions || []) {
|
|
516
|
-
rows.push({
|
|
517
|
-
name: server.name || server.code || '',
|
|
518
|
-
url: version.address || '',
|
|
519
|
-
fhirVersion: version.version || '',
|
|
520
|
-
software: version.software && version.software !== 'unknown' ? version.software : '',
|
|
521
|
-
softwareVersion: version.softwareVersion || '',
|
|
522
|
-
softwareReleaseDate: version.softwareReleaseDate || '',
|
|
523
|
-
error: version.error,
|
|
524
|
-
lastSuccess: version.lastSuccess
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
rows.sort((a, b) => a.name.localeCompare(b.name) || a.url.localeCompare(b.url));
|
|
592
|
+
const rows = this.softwareRows(data);
|
|
593
|
+
const uptime = this.crawler ? this.crawler.uptime : null;
|
|
530
594
|
|
|
531
595
|
let html = '<h3>Terminology Server Software</h3>';
|
|
532
596
|
const latest = this.releases ? this.releases.latest() : null;
|
|
@@ -539,29 +603,102 @@ class RegistryModule {
|
|
|
539
603
|
|
|
540
604
|
html += '<table class="grid">';
|
|
541
605
|
html += '<thead><tr><th>Server</th><th>URL</th><th>FHIR</th><th>Software</th><th>Version</th>' +
|
|
542
|
-
'<th>Released</th><th>Age</th></tr></thead><tbody>';
|
|
606
|
+
'<th>Released</th><th>Age</th><th>Uptime</th></tr></thead><tbody>';
|
|
543
607
|
for (const row of rows) {
|
|
544
608
|
html += '<tr>';
|
|
545
609
|
html += `<td>${escape(row.name)}</td>`;
|
|
546
610
|
html += `<td><a href="${safeHref(row.url)}" target="_blank" rel="noopener">${escape(row.url)}</a>`;
|
|
547
|
-
|
|
548
|
-
html += ` <span class="text-danger" title="${escape(row.error)}">(unreachable)</span>`;
|
|
549
|
-
}
|
|
611
|
+
html += this._renderUnreachable(row);
|
|
550
612
|
html += '</td>';
|
|
551
|
-
html += `<td>${escape(row.
|
|
613
|
+
html += `<td>${escape(row.fhirVersions.join(', '))}</td>`;
|
|
552
614
|
html += `<td>${row.software ? escape(row.software.replace('Reference Server', 'HealthIntersections')) : '<i>unknown</i>'}</td>`;
|
|
553
615
|
html += `<td>${row.softwareVersion ? escape(row.softwareVersion) : '<i>unknown</i>'}</td>`;
|
|
554
616
|
html += this._renderReleaseCells(row, now);
|
|
617
|
+
html += this._renderUptimeCell(uptime ? uptime.uptime(row.endpoints.map(e => e.url), now) : null);
|
|
555
618
|
html += '</tr>';
|
|
556
619
|
}
|
|
557
620
|
html += '</tbody></table>';
|
|
558
621
|
|
|
622
|
+
html += '<p class="text-muted"><small>';
|
|
623
|
+
if (uptime) {
|
|
624
|
+
const days = Math.round(uptime.windowMs / (24 * 60 * 60 * 1000));
|
|
625
|
+
html += `Uptime is the share of this registry's scans over the last ${days} days that the server answered ` +
|
|
626
|
+
'(every scan probes each of its endpoints; a server with one of two endpoints down is half down for that scan). ' +
|
|
627
|
+
'Scans the registry itself missed are not counted. ';
|
|
628
|
+
}
|
|
629
|
+
html += 'Endpoints of one server that run the same software are shown as one row. ';
|
|
559
630
|
if (this.releases && this.releases.source !== 'none') {
|
|
560
|
-
html +=
|
|
631
|
+
html += `FHIRsmith release dates from ${escape(this.releases.source)}.`;
|
|
561
632
|
}
|
|
633
|
+
html += '</small></p>';
|
|
562
634
|
return html;
|
|
563
635
|
}
|
|
564
636
|
|
|
637
|
+
/**
|
|
638
|
+
* The rows of the software page: one per server, or per software a server runs, since
|
|
639
|
+
* a server's endpoints (its FHIR versions) are nearly always the same software
|
|
640
|
+
* @returns {Array<{name, url, fhirVersions, software, softwareVersion, softwareReleaseDate, endpoints}>}
|
|
641
|
+
*/
|
|
642
|
+
softwareRows(data) {
|
|
643
|
+
const rows = [];
|
|
644
|
+
for (const registry of data.registries) {
|
|
645
|
+
for (const server of registry.servers || []) {
|
|
646
|
+
const groups = new Map(); // software|version -> row
|
|
647
|
+
for (const version of server.versions || []) {
|
|
648
|
+
const software = version.software && version.software !== 'unknown' ? version.software : '';
|
|
649
|
+
const softwareVersion = version.softwareVersion || '';
|
|
650
|
+
const key = `${software}|${softwareVersion}`;
|
|
651
|
+
let row = groups.get(key);
|
|
652
|
+
if (!row) {
|
|
653
|
+
row = {
|
|
654
|
+
name: server.name || server.code || '',
|
|
655
|
+
url: '',
|
|
656
|
+
fhirVersions: [],
|
|
657
|
+
software,
|
|
658
|
+
softwareVersion,
|
|
659
|
+
softwareReleaseDate: version.softwareReleaseDate || '',
|
|
660
|
+
endpoints: []
|
|
661
|
+
};
|
|
662
|
+
groups.set(key, row);
|
|
663
|
+
rows.push(row);
|
|
664
|
+
}
|
|
665
|
+
row.fhirVersions.push(version.version || '');
|
|
666
|
+
row.endpoints.push({ url: version.address || '', fhirVersion: version.version || '', error: version.error || '' });
|
|
667
|
+
if (!row.softwareReleaseDate && version.softwareReleaseDate) {
|
|
668
|
+
row.softwareReleaseDate = version.softwareReleaseDate;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
for (const row of groups.values()) {
|
|
672
|
+
row.url = commonBaseUrl(row.endpoints.map(e => e.url));
|
|
673
|
+
row.fhirVersions.sort();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
rows.sort((a, b) => a.name.localeCompare(b.name) || a.url.localeCompare(b.url));
|
|
678
|
+
return rows;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
_renderUnreachable(row) {
|
|
682
|
+
const down = row.endpoints.filter(e => e.error);
|
|
683
|
+
if (down.length === 0) {
|
|
684
|
+
return '';
|
|
685
|
+
}
|
|
686
|
+
const title = down.map(e => `${e.fhirVersion}: ${e.error}`).join('\n');
|
|
687
|
+
const what = down.length === row.endpoints.length ? 'unreachable' :
|
|
688
|
+
`${down.map(e => e.fhirVersion).join(', ')} unreachable`;
|
|
689
|
+
return ` <span class="text-danger" title="${escape(title)}">(${escape(what)})</span>`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
_renderUptimeCell(u) {
|
|
693
|
+
if (!u) {
|
|
694
|
+
return '<td></td>';
|
|
695
|
+
}
|
|
696
|
+
const cls = u.failures === 0 ? 'text-success' : u.percent >= 99 ? '' : u.percent >= 95 ? 'text-warning' : 'text-danger';
|
|
697
|
+
const since = new Date(u.since).toISOString().substring(0, 10);
|
|
698
|
+
const title = `${u.probes - u.failures} of ${u.probes} probes answered since ${since}`;
|
|
699
|
+
return `<td><span class="${cls}" title="${escape(title)}">${formatUptime(u)}</span></td>`;
|
|
700
|
+
}
|
|
701
|
+
|
|
565
702
|
_renderReleaseCells(row, now) {
|
|
566
703
|
if (!isFhirsmith(row.software) || !this.releases) {
|
|
567
704
|
return this._renderReportedReleaseCells(row, now);
|
|
@@ -1140,6 +1277,49 @@ class RegistryModule {
|
|
|
1140
1277
|
};
|
|
1141
1278
|
}
|
|
1142
1279
|
|
|
1280
|
+
/**
|
|
1281
|
+
* The paragraph for the server's daily report: how many client requests the
|
|
1282
|
+
* API answered, how many servers are registered, and which of them (if any)
|
|
1283
|
+
* answered none of the registry's scans in the period.
|
|
1284
|
+
*/
|
|
1285
|
+
dailyReport({since, now}) {
|
|
1286
|
+
const {plural} = require('../library/daily-report');
|
|
1287
|
+
const counts = this.stats.countsSince(since, now);
|
|
1288
|
+
const api = counts.operations.resolve || 0;
|
|
1289
|
+
const sentences = [`The API answered ${plural(api, 'client request')} (${plural(counts.total, 'request')} in all, including the web pages).`];
|
|
1290
|
+
const data = this.api ? this.api.getData() : null;
|
|
1291
|
+
if (!data || !data.registries) {
|
|
1292
|
+
sentences.push('The registry data is not available (no crawl has completed).');
|
|
1293
|
+
return sentences.join(' ');
|
|
1294
|
+
}
|
|
1295
|
+
let servers = 0;
|
|
1296
|
+
let endpoints = 0;
|
|
1297
|
+
const down = [];
|
|
1298
|
+
const uptime = this.crawler ? this.crawler.uptime : null;
|
|
1299
|
+
for (const registry of data.registries) {
|
|
1300
|
+
for (const server of registry.servers || []) {
|
|
1301
|
+
servers++;
|
|
1302
|
+
const addresses = (server.versions || []).map(v => v.address || v.url).filter(Boolean);
|
|
1303
|
+
endpoints += addresses.length;
|
|
1304
|
+
if (uptime && addresses.length > 0) {
|
|
1305
|
+
const u = uptime.uptime(addresses, now, since);
|
|
1306
|
+
if (u && u.failures === u.probes) {
|
|
1307
|
+
down.push(server.name || server.code || addresses[0]);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
sentences.push(`${plural(servers, 'server')} ${servers === 1 ? 'is' : 'are'} registered, with ${plural(endpoints, 'endpoint')}.`);
|
|
1313
|
+
if (!uptime) {
|
|
1314
|
+
sentences.push('No scan history is kept, so availability is unknown.');
|
|
1315
|
+
} else if (down.length === 0) {
|
|
1316
|
+
sentences.push('Every server answered at least one scan during the period.');
|
|
1317
|
+
} else {
|
|
1318
|
+
sentences.push(`${down.length === 1 ? 'This server' : 'These servers'} did not answer any scan during the period: ${down.join(', ')}.`);
|
|
1319
|
+
}
|
|
1320
|
+
return sentences.join(' ');
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1143
1323
|
/**
|
|
1144
1324
|
* Shutdown the module
|
|
1145
1325
|
*/
|
|
@@ -1155,6 +1335,9 @@ class RegistryModule {
|
|
|
1155
1335
|
if (this.crawler) {
|
|
1156
1336
|
this.crawler.shutdown();
|
|
1157
1337
|
}
|
|
1338
|
+
if (this.auth) {
|
|
1339
|
+
this.auth.shutdown();
|
|
1340
|
+
}
|
|
1158
1341
|
// Save current data
|
|
1159
1342
|
if (this.crawler && this.currentData) {
|
|
1160
1343
|
await this.saveData();
|
|
@@ -1239,11 +1422,11 @@ class RegistryModule {
|
|
|
1239
1422
|
const stats = this.api.getStatistics();
|
|
1240
1423
|
stats.processingTime = Date.now() - startTime;
|
|
1241
1424
|
|
|
1242
|
-
const html =
|
|
1425
|
+
const html = this.renderPage(
|
|
1243
1426
|
'registry',
|
|
1244
1427
|
'FHIR Terminology Server Resolver',
|
|
1245
1428
|
content,
|
|
1246
|
-
stats
|
|
1429
|
+
stats, req
|
|
1247
1430
|
);
|
|
1248
1431
|
|
|
1249
1432
|
res.setHeader('Content-Type', 'text/html');
|
|
@@ -1294,18 +1477,20 @@ class RegistryModule {
|
|
|
1294
1477
|
const stats = this.api.getStatistics();
|
|
1295
1478
|
stats.processingTime = Date.now() - startTime;
|
|
1296
1479
|
|
|
1297
|
-
const html =
|
|
1480
|
+
const html = this.renderPage(
|
|
1298
1481
|
'registry',
|
|
1299
1482
|
'FHIR Terminology Server Resolution Results',
|
|
1300
1483
|
content,
|
|
1301
|
-
stats
|
|
1484
|
+
stats, req
|
|
1302
1485
|
);
|
|
1303
1486
|
|
|
1304
1487
|
res.setHeader('Content-Type', 'text/html');
|
|
1305
1488
|
return res.send(html);
|
|
1306
1489
|
} catch (error) {
|
|
1307
1490
|
this.logger.error('Error rendering resolve result page:', error);
|
|
1308
|
-
//
|
|
1491
|
+
// not the JSON instead: this request was let through as a page, without a token
|
|
1492
|
+
res.status(500).setHeader('Content-Type', 'text/html');
|
|
1493
|
+
return res.send('<!DOCTYPE html><html><body><p>The resolution page could not be shown (see the server log).</p></body></html>');
|
|
1309
1494
|
}
|
|
1310
1495
|
}
|
|
1311
1496
|
res.json(result);
|
|
@@ -1566,11 +1751,11 @@ class RegistryModule {
|
|
|
1566
1751
|
const stats = this.api.getStatistics();
|
|
1567
1752
|
stats.processingTime = Date.now() - startTime;
|
|
1568
1753
|
|
|
1569
|
-
const html =
|
|
1754
|
+
const html = this.renderPage(
|
|
1570
1755
|
'registry',
|
|
1571
1756
|
'FHIR Terminology Server Registry - Logs',
|
|
1572
1757
|
content,
|
|
1573
|
-
stats
|
|
1758
|
+
stats, req
|
|
1574
1759
|
);
|
|
1575
1760
|
|
|
1576
1761
|
res.setHeader('Content-Type', 'text/html');
|
|
@@ -1649,4 +1834,5 @@ class RegistryModule {
|
|
|
1649
1834
|
RegistryModule.DISCOVERY_PARAMS = DISCOVERY_PARAMS;
|
|
1650
1835
|
RegistryModule.RESOLVE_PARAMS = RESOLVE_PARAMS;
|
|
1651
1836
|
|
|
1652
|
-
module.exports = RegistryModule;
|
|
1837
|
+
module.exports = RegistryModule;
|
|
1838
|
+
module.exports.commonBaseUrl = commonBaseUrl;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// registry/uptime.js
|
|
2
|
+
// Uptime history for the registered servers: one record per endpoint per scan, saying
|
|
3
|
+
// whether the endpoint answered. The registry's own scan is the probe - nothing else is
|
|
4
|
+
// pinged - so a scan that didn't happen (the registry was down, or the master list could
|
|
5
|
+
// not be read) is not held against any server.
|
|
6
|
+
|
|
7
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
class UptimeHistory {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} [options]
|
|
12
|
+
* @param {number} [options.retentionDays] how long records are kept (default 90)
|
|
13
|
+
* @param {number} [options.windowDays] the period uptime is reported over (default 30)
|
|
14
|
+
*/
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.retentionMs = (options.retentionDays || 90) * DAY;
|
|
17
|
+
this.windowMs = (options.windowDays || 30) * DAY;
|
|
18
|
+
this.records = new Map(); // address -> [[timeMs, ok(1|0)], ...] in time order
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Record the outcome of one probe of an endpoint
|
|
23
|
+
* @param {string} address the endpoint URL
|
|
24
|
+
* @param {boolean} ok whether the scan of it succeeded
|
|
25
|
+
* @param {number} [time] when the scan ran (ms)
|
|
26
|
+
*/
|
|
27
|
+
record(address, ok, time = Date.now()) {
|
|
28
|
+
if (!address) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let list = this.records.get(address);
|
|
32
|
+
if (!list) {
|
|
33
|
+
list = [];
|
|
34
|
+
this.records.set(address, list);
|
|
35
|
+
}
|
|
36
|
+
list.push([time, ok ? 1 : 0]);
|
|
37
|
+
this.prune(address, time);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
prune(address, now = Date.now()) {
|
|
41
|
+
const list = this.records.get(address);
|
|
42
|
+
if (!list) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const cutoff = now - this.retentionMs;
|
|
46
|
+
let drop = 0;
|
|
47
|
+
while (drop < list.length && list[drop][0] < cutoff) {
|
|
48
|
+
drop++;
|
|
49
|
+
}
|
|
50
|
+
if (drop > 0) {
|
|
51
|
+
list.splice(0, drop);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Uptime over the reporting window for a set of endpoints, as the fraction of their
|
|
57
|
+
* probes that succeeded: a server with two endpoints of which one is down for a scan
|
|
58
|
+
* is half down for that scan.
|
|
59
|
+
* @param {string|string[]} addresses one endpoint, or the endpoints of one server
|
|
60
|
+
* @param {number} [now]
|
|
61
|
+
* @param {number} [from] the start of the period; the reporting window by default
|
|
62
|
+
* @returns {{percent: number, probes: number, failures: number, since: number}|null}
|
|
63
|
+
* null when there have been no probes in the window
|
|
64
|
+
*/
|
|
65
|
+
uptime(addresses, now = Date.now(), from = now - this.windowMs) {
|
|
66
|
+
const list = Array.isArray(addresses) ? addresses : [addresses];
|
|
67
|
+
let probes = 0;
|
|
68
|
+
let failures = 0;
|
|
69
|
+
let since = Infinity;
|
|
70
|
+
for (const address of list) {
|
|
71
|
+
for (const [time, ok] of this.records.get(address) || []) {
|
|
72
|
+
if (time < from || time > now) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
probes++;
|
|
76
|
+
if (!ok) {
|
|
77
|
+
failures++;
|
|
78
|
+
}
|
|
79
|
+
if (time < since) {
|
|
80
|
+
since = time;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (probes === 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return { percent: 100 * (probes - failures) / probes, probes, failures, since };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Forget what is past retention. Only age decides: an endpoint missing from one crawl (a
|
|
92
|
+
* sub-registry that failed to fetch, a crawl cut short at shutdown) keeps its history,
|
|
93
|
+
* and one that has really gone ages out with its last probes
|
|
94
|
+
* @param {Iterable<string>} [addresses] the endpoints of the last crawl (no longer used:
|
|
95
|
+
* a crawl can miss endpoints that still exist)
|
|
96
|
+
*/
|
|
97
|
+
retain(addresses, now = Date.now()) {
|
|
98
|
+
for (const address of [...this.records.keys()]) {
|
|
99
|
+
this.prune(address, now);
|
|
100
|
+
if (this.records.get(address).length === 0) {
|
|
101
|
+
this.records.delete(address);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
toJSON() {
|
|
107
|
+
const endpoints = {};
|
|
108
|
+
for (const [address, list] of this.records) {
|
|
109
|
+
if (list.length > 0) {
|
|
110
|
+
endpoints[address] = list.map(([t, ok]) => [t, ok]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { version: '1', endpoints };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static fromJSON(json, options) {
|
|
117
|
+
const history = new UptimeHistory(options);
|
|
118
|
+
if (!json || json.version !== '1' || typeof json.endpoints !== 'object') {
|
|
119
|
+
return history;
|
|
120
|
+
}
|
|
121
|
+
for (const [address, list] of Object.entries(json.endpoints || {})) {
|
|
122
|
+
if (!Array.isArray(list)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const clean = list
|
|
126
|
+
.filter(r => Array.isArray(r) && Number.isFinite(r[0]))
|
|
127
|
+
.map(r => [r[0], r[1] ? 1 : 0])
|
|
128
|
+
.sort((a, b) => a[0] - b[0]);
|
|
129
|
+
if (clean.length > 0) {
|
|
130
|
+
history.records.set(address, clean);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return history;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Format an uptime for display: 100%, 99.9%, 97%, ... - the precision that is honest
|
|
139
|
+
* for the number of probes behind it
|
|
140
|
+
*/
|
|
141
|
+
function formatUptime(u) {
|
|
142
|
+
if (!u) {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
if (u.failures === 0) {
|
|
146
|
+
return '100%';
|
|
147
|
+
}
|
|
148
|
+
if (u.percent >= 99) {
|
|
149
|
+
return `${(Math.floor(u.percent * 10) / 10).toFixed(1)}%`;
|
|
150
|
+
}
|
|
151
|
+
return `${Math.floor(u.percent)}%`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { UptimeHistory, formatUptime };
|