fhirsmith 0.17.0 → 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.
Files changed (121) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +422 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +7 -2
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +9 -0
  89. package/tx/README.md +52 -0
  90. package/tx/cs/cs-api.js +90 -14
  91. package/tx/cs/cs-cs.js +56 -1
  92. package/tx/cs/cs-icd11.js +366 -112
  93. package/tx/cs/cs-icf.js +418 -0
  94. package/tx/cs/cs-loinc.js +47 -25
  95. package/tx/cs/cs-omop.js +10 -3
  96. package/tx/cs/cs-snomed.js +918 -55
  97. package/tx/cs/cs-ucum.js +6 -0
  98. package/tx/cs/icd11-expressions.js +191 -78
  99. package/tx/importers/export-icd11-codesystem.js +58 -12
  100. package/tx/importers/icd11-schema.md +9 -5
  101. package/tx/library.js +98 -4
  102. package/tx/openapi-operations.js +4 -0
  103. package/tx/params.js +8 -1
  104. package/tx/provider.js +16 -4
  105. package/tx/sct/documentation.md +251 -0
  106. package/tx/sct/ecl.js +758 -46
  107. package/tx/sct/expressions.js +113 -52
  108. package/tx/syndication/syndication-feed.js +218 -0
  109. package/tx/syndication/syndication-store.js +415 -0
  110. package/tx/tx-html.js +15 -1
  111. package/tx/tx.fhir.org.yml +2 -3
  112. package/tx/tx.js +144 -2
  113. package/tx/vs/vs-vsac.js +25 -6
  114. package/tx/workers/expand.js +33 -6
  115. package/tx/workers/lookup.js +61 -6
  116. package/tx/workers/subsumes.js +1 -1
  117. package/tx/workers/translate.js +166 -15
  118. package/tx/workers/validate.js +128 -6
  119. package/utilities/make-sct-test-extension.js +264 -0
  120. package/xig/xig.js +32 -0
  121. package/registry/readme.md +0 -124
@@ -62,7 +62,7 @@
62
62
  <a href="/tx-reg/resolve" style="color: gold">Resolve</a> &nbsp;|&nbsp;
63
63
  <a href="/tx-reg/software" style="color: gold">Software</a> &nbsp;|&nbsp;
64
64
  <a href="/tx-reg/log" style="color: gold">Crawler Log</a> &nbsp;|&nbsp;
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>
@@ -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 = htmlServer.renderPage('registry', 'Terminology Server Registry API',
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 htmlServer.renderPage(
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 = htmlServer.renderPage(
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 = htmlServer.renderPage('registry', 'Terminology Server Software', content, stats);
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
- for (const registry of data.registries) {
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
- if (row.error) {
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.fhirVersion)}</td>`;
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 += `<p class="text-muted"><small>FHIRsmith release dates from ${escape(this.releases.source)}.</small></p>`;
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 = htmlServer.renderPage(
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 = htmlServer.renderPage(
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
- // Fall back to JSON if template rendering fails
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 = htmlServer.renderPage(
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 };