fhirsmith 0.13.3 → 0.14.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 (49) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +48 -4
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +142 -27
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/extensions.js +2 -2
  31. package/tx/library/operation-outcome.js +3 -1
  32. package/tx/library/renderer.js +42 -5
  33. package/tx/operation-context.js +111 -15
  34. package/tx/provider.js +85 -0
  35. package/tx/sct/expressions.js +128 -44
  36. package/tx/sct/text-index.js +419 -0
  37. package/tx/tx-html.js +426 -44
  38. package/tx/tx.js +117 -9
  39. package/tx/workers/cache-control.js +2 -2
  40. package/tx/workers/closure.js +483 -13
  41. package/tx/workers/expand.js +20 -0
  42. package/tx/workers/metadata.js +29 -4
  43. package/tx/workers/read.js +18 -3
  44. package/tx/workers/subsumes.js +18 -2
  45. package/tx/workers/translate.js +167 -20
  46. package/tx/workers/validate.js +61 -182
  47. package/tx/workers/worker.js +9 -0
  48. package/tx/xversion/xv-conceptmap.js +17 -8
  49. package/utilities/submit-test-report.js +150 -0
@@ -16,6 +16,8 @@ const {ConceptMap} = require("../library/conceptmap");
16
16
  const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
17
17
  const {Issue} = require("../library/operation-outcome");
18
18
  const {debugLog} = require("../operation-context");
19
+ const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index");
20
+ const Logger = require("../../library/logger");
19
21
 
20
22
  // Symbol key under which a resolved ECL filter analysis is memoised directly on
21
23
  // the ValueSet compose.include.filter element (`fc`). Symbol-keyed and defined
@@ -127,6 +129,9 @@ class SnomedServices {
127
129
  this.strings = new SnomedStrings(sharedData.strings);
128
130
  this.words = new SnomedWords(sharedData.words);
129
131
  this.stems = new SnomedStems(sharedData.stems);
132
+ // Free-text search index, built on first search (see getTextIndex)
133
+ this._textIndex = null;
134
+ this._textIndexPromise = null;
130
135
  this.refs = new SnomedReferences(sharedData.refs);
131
136
  this.descriptions = new SnomedDescriptions(sharedData.desc);
132
137
  this.descriptionIndex = new SnomedDescriptionIndex(sharedData.descRef);
@@ -1482,16 +1487,104 @@ class SnomedServices {
1482
1487
  };
1483
1488
 
1484
1489
 
1485
- searchFilter(searchText, includeInactive = false, exactMatch = false) {
1486
- const result = new SnomedFilterContext();
1490
+ /**
1491
+ * Free-text search over active descriptions.
1492
+ *
1493
+ * Uses the in-memory word index (see tx/sct/text-index.js), built the first
1494
+ * time this edition is searched: every filter token (stop words aside) must
1495
+ * match a word of the concept's active descriptions, either as a prefix or
1496
+ * by sharing its stem. Filters with no indexable token (only 1-character
1497
+ * tokens), or an edition whose index failed to build, fall back to
1498
+ * scanSearch().
1499
+ *
1500
+ * Previously the third parameter was `exactMatch`, which the provider fed
1501
+ * from its unrelated `sort` flag - so $expand with a filter on an
1502
+ * include-all-codes value set matched ANY term by brute-force scan: 10k+
1503
+ * matches for a typical multi-word filter, with the event loop blocked for
1504
+ * the whole scan (tx.fhir.org incident 2026-09-17).
1505
+ *
1506
+ * @param {SearchFilterText} searchText
1507
+ * @param {boolean} includeInactive - include inactive concepts
1508
+ * @param {OperationContext|null} opContext - for yielding / deadline checks
1509
+ * @returns {Promise<SnomedFilterContext>}
1510
+ */
1511
+ async searchFilter(searchText, includeInactive = false, opContext = null) {
1512
+ const filterText = (searchText && searchText.filter ? searchText.filter : '').toLowerCase().trim();
1513
+ const tokens = filterTokens(filterText);
1514
+ if (tokens.length > 0) {
1515
+ const index = await this.getTextIndex(opContext);
1516
+ if (index) {
1517
+ const result = new SnomedFilterContext();
1518
+ const ordinals = index.search(tokens, opContext);
1519
+ if (opContext) {
1520
+ await opContext.checkAndYield('sct:searchFilter');
1521
+ }
1522
+ result.matches = rankMatches(this, index, ordinals, filterText, tokens, includeInactive);
1523
+ return result;
1524
+ }
1525
+ }
1526
+ return await this.scanSearch(filterText, includeInactive, opContext);
1527
+ }
1528
+
1529
+ /**
1530
+ * The edition's text index, building it on first use. The build is shared:
1531
+ * concurrent searches wait on the same promise, and time spent waiting is
1532
+ * not charged to the waiting operation's compute budget. Resolves to null
1533
+ * if the build failed (the next search retries it).
1534
+ * @param {OperationContext|null} opContext
1535
+ * @returns {Promise<SnomedTextIndex|null>}
1536
+ */
1537
+ async getTextIndex(opContext = null) {
1538
+ if (this._textIndex) {
1539
+ return this._textIndex;
1540
+ }
1541
+ if (!this._textIndexPromise) {
1542
+ const started = Date.now();
1543
+ // Logger fetched lazily: importers and tests require this module too
1544
+ const textIndexLog = Logger.getInstance().child({module: 'tx-sct'});
1545
+ this._textIndexPromise = SnomedTextIndex.build(this).then(index => {
1546
+ this._textIndex = index;
1547
+ textIndexLog.info(`SNOMED text index for ${this.versionUri}: ${index.words.length} words, built in ${Date.now() - started}ms`);
1548
+ return index;
1549
+ }, error => {
1550
+ this._textIndexPromise = null;
1551
+ textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}: ${error.message}`);
1552
+ return null;
1553
+ });
1554
+ }
1555
+ if (opContext && typeof opContext.waitFor === 'function') {
1556
+ return await opContext.waitFor(this._textIndexPromise, 'sct:textIndex');
1557
+ }
1558
+ return await this._textIndexPromise;
1559
+ }
1487
1560
 
1488
- // Simplified search - in full implementation would use stemming and word indexes
1489
- const searchTerms = searchText.filter.toLowerCase().split(/\s+/);
1561
+ /**
1562
+ * Brute-force scan of every concept's active descriptions: every
1563
+ * whitespace-separated term of the filter must appear (as a substring) in
1564
+ * the same description. Expensive (hundreds of ms on a full edition), so it
1565
+ * yields through opContext.checkAndYield and the compute deadline applies.
1566
+ *
1567
+ * @param {string} filterText - lower-cased, trimmed
1568
+ * @param {boolean} includeInactive
1569
+ * @param {OperationContext|null} opContext
1570
+ * @returns {Promise<SnomedFilterContext>}
1571
+ */
1572
+ async scanSearch(filterText, includeInactive = false, opContext = null) {
1573
+ const result = new SnomedFilterContext();
1574
+ const searchTerms = filterText.split(/\s+/).filter(t => t.length > 0);
1490
1575
  const matches = [];
1576
+ if (searchTerms.length === 0) {
1577
+ result.matches = matches;
1578
+ return result;
1579
+ }
1491
1580
 
1492
- // Search through all concepts
1493
- for (let i = 0; i < this.concepts.count(); i++) {
1494
- const conceptIndex = i * this.concepts.constructor.CONCEPT_SIZE;
1581
+ const conceptCount = this.concepts.count();
1582
+ const conceptSize = this.concepts.constructor.CONCEPT_SIZE;
1583
+ for (let i = 0; i < conceptCount; i++) {
1584
+ if (opContext && (i & 0x3FF) === 0) {
1585
+ await opContext.checkAndYield('sct:searchFilter');
1586
+ }
1587
+ const conceptIndex = i * conceptSize;
1495
1588
 
1496
1589
  try {
1497
1590
  const concept = this.concepts.getConcept(conceptIndex);
@@ -1510,25 +1603,23 @@ class SnomedServices {
1510
1603
  const description = this.descriptions.getDescription(descIndex);
1511
1604
  if (description.active) {
1512
1605
  const term = this.strings.getEntry(description.iDesc).toLowerCase();
1513
-
1514
- if (exactMatch) {
1515
- // All search terms must be present
1516
- matchFound = searchTerms.every(searchTerm => term.includes(searchTerm));
1517
- } else {
1518
- // Any search term can match
1519
- matchFound = searchTerms.some(searchTerm => term.includes(searchTerm));
1520
- }
1521
-
1522
- if (matchFound) {
1606
+ if (searchTerms.every(searchTerm => term.includes(searchTerm))) {
1607
+ matchFound = true;
1523
1608
  // Calculate priority based on match quality
1524
- if (term === searchText.filter.toLowerCase()) {
1525
- priority = 100; // Exact match
1526
- } else if (term.startsWith(searchText.filter.toLowerCase())) {
1527
- priority = 50; // Prefix match
1609
+ let p;
1610
+ if (term === filterText) {
1611
+ p = 100; // Exact match
1612
+ } else if (term.startsWith(filterText)) {
1613
+ p = 50; // Prefix match
1528
1614
  } else {
1529
- priority = 10; // Contains match
1615
+ p = 10; // Contains match
1616
+ }
1617
+ if (p > priority) {
1618
+ priority = p;
1619
+ }
1620
+ if (priority === 100) {
1621
+ break;
1530
1622
  }
1531
- break;
1532
1623
  }
1533
1624
  }
1534
1625
  }
@@ -1541,6 +1632,9 @@ class SnomedServices {
1541
1632
  });
1542
1633
  }
1543
1634
  } catch (error) {
1635
+ if (error instanceof Issue || (error && error.abandoned)) {
1636
+ throw error;
1637
+ }
1544
1638
  // Skip problematic concepts
1545
1639
  continue;
1546
1640
  }
@@ -1582,6 +1676,19 @@ class SnomedProvider extends BaseCSServices {
1582
1676
  return actualVersion && actualVersion.startsWith(checkVersion);
1583
1677
  }
1584
1678
 
1679
+ /**
1680
+ * The display name of the code system, as $lookup reports it.
1681
+ *
1682
+ * Without this, CodeSystemProvider's fallback applies, which returns the versioned uri -
1683
+ * so a SNOMED lookup answered "http://snomed.info/sct|http://snomed.info/sct/731000124108
1684
+ * /version/20230301" where every other code system answers something like "LOINC". The
1685
+ * edition is named because for SNOMED that is the part worth knowing; the version
1686
+ * parameter carries the uri.
1687
+ */
1688
+ name() {
1689
+ return this.sct.getDescription();
1690
+ }
1691
+
1585
1692
  description() {
1586
1693
  return this.sct.getDescription();
1587
1694
  }
@@ -2448,8 +2555,10 @@ class SnomedProvider extends BaseCSServices {
2448
2555
  }
2449
2556
 
2450
2557
  // Search filter
2558
+ // eslint-disable-next-line no-unused-vars
2451
2559
  async searchFilter(filterContext, filter, sort) {
2452
- let f = this.sct.searchFilter(filter, false, sort);
2560
+ // `sort` is not a match-mode flag: results are always ranked.
2561
+ let f = await this.sct.searchFilter(filter, false, this.opContext);
2453
2562
  filterContext.filters.push(f);
2454
2563
  return f;
2455
2564
  }
@@ -2691,7 +2800,7 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
2691
2800
 
2692
2801
  if (url.startsWith('http://snomed.info/sct?fhir_vs') ||
2693
2802
  url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) ||
2694
- url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version}?fhir_vs`)) {
2803
+ url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version()}?fhir_vs`)) {
2695
2804
  id = url.substring(qIdx);
2696
2805
  } else {
2697
2806
  return null;
@@ -2909,8 +3018,8 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
2909
3018
  description: `The concept map implicitly defined by the ${name} Association Reference Set`,
2910
3019
  copyright: 'This value set includes content from SNOMED CT, which is copyright © 2002+ International Health Terminology Standards Development Organisation (SNOMED International), and distributed by agreement between SNOMED International and HL7',
2911
3020
  status: 'active',
2912
- sourceUri: `${this.system}?fhir_vs`,
2913
- targetUri: `${this.system}?fhir_vs`,
3021
+ sourceUri: `${this.system()}?fhir_vs`,
3022
+ targetUri: `${this.system()}?fhir_vs`,
2914
3023
  group: [{
2915
3024
  source: 'http://snomed.info/sct',
2916
3025
  target: 'http://snomed.info/sct'
@@ -2931,6 +3040,9 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
2931
3040
  function getEditionName(edition) {
2932
3041
  const editionMap = {
2933
3042
  '900000000000207008': 'International Edition',
3043
+ // The terminology-ecosystem test distribution, published under xsct - see the
3044
+ // tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
3045
+ '31000003106': 'Test Edition',
2934
3046
  '449081005': 'International Spanish Edition',
2935
3047
  '11000221109': 'Argentinian Edition',
2936
3048
  '32506021000036107': 'Australian Edition (with drug extension)',
@@ -2967,6 +3079,9 @@ function getEditionName(edition) {
2967
3079
  function getEditionCode(edition) {
2968
3080
  const editionMap = {
2969
3081
  '900000000000207008': 'Intl',
3082
+ // The terminology-ecosystem test distribution, published under xsct - see the
3083
+ // tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
3084
+ '31000003106': 'Test',
2970
3085
  '449081005': 'es',
2971
3086
  '11000221109': 'AR-es',
2972
3087
  '32506021000036107': 'AU+',
@@ -1,3 +1,17 @@
1
+ <!-- Text Search: the whole code system as an anonymous value set, expanded with the
2
+ text as the filter parameter. This is what a person opening a code system usually
3
+ wants - "is there a code for X" - so it comes first. -->
4
+ <div class="operation-form" style="margin-bottom: 15px;">
5
+ <strong>Text Search</strong>
6
+ <form method="post" action="{{ expandUrl }}" id="{{ searchId }}_form"
7
+ data-system="{{ url }}" data-version="{{ systemVersion }}"
8
+ style="display: inline-block; margin-left: 10px;" onsubmit="return txSearchSubmit('{{ searchId }}')">
9
+ <input type="text" name="filter" placeholder="Text" size="30"/>
10
+ <button type="submit" class="btn btn-sm btn-primary">Search</button>
11
+ </form>
12
+ <div id="{{ searchId }}_preview" style="display: none; margin-left: 10px; margin-top: 8px;"></div>
13
+ </div>
14
+
1
15
  <div class="operation-form" style="margin-bottom: 15px;">
2
16
  <strong>Lookup</strong>
3
17
  <form method="get" action="$lookup" style="display: inline-block; margin-left: 10px;">
@@ -16,3 +30,166 @@
16
30
  <button type="submit" class="btn btn-sm btn-primary">Subsumes</button>
17
31
  </form>
18
32
  </div>
33
+
34
+
35
+ <!-- Filter: builds a throwaway ValueSet (uuid url, one include for this code system carrying
36
+ every filter row) and posts it to $expand, replacing the page with the rendered expansion. -->
37
+ <div class="operation-form" style="margin-bottom: 15px;">
38
+ <strong>Filter</strong>
39
+ <form method="post" action="{{ expandUrl }}" id="{{ filterId }}_form"
40
+ data-system="{{ url }}" data-version="{{ systemVersion }}"
41
+ style="margin-left: 10px; margin-top: 5px;" onsubmit="return txFilterSubmit('{{ filterId }}')">
42
+ <datalist id="{{ filterId }}_props">{{ filterPropertyOptions }}</datalist>
43
+ <table class="grid" cellpadding="0" cellspacing="0">
44
+ <thead>
45
+ <tr><th>Property</th><th>Operation</th><th>Value</th><th></th></tr>
46
+ </thead>
47
+ <tbody id="{{ filterId }}_rows">
48
+ <tr>
49
+ <td><input type="text" name="property" list="{{ filterId }}_props" size="24" autocomplete="off"/></td>
50
+ <td><select name="op">{{ filterOpOptions }}</select></td>
51
+ <td><input type="text" name="value" size="24"/></td>
52
+ <td><button type="button" class="btn btn-sm" title="Remove this filter" onclick="txFilterRemoveRow(this)">&#215;</button></td>
53
+ </tr>
54
+ </tbody>
55
+ </table>
56
+ <button type="button" class="btn btn-sm" onclick="txFilterAddRow('{{ filterId }}')">Add Filter</button>
57
+ <button type="submit" class="btn btn-sm btn-primary">Expand</button>
58
+ </form>
59
+ <div id="{{ filterId }}_preview" style="display: none; margin-left: 10px; margin-top: 8px;"></div>
60
+ </div>
61
+
62
+ <script>
63
+ // The two forms on this tab that expand something both do the same thing: build an
64
+ // anonymous value set over the code system being viewed and post it to $expand, then
65
+ // replace the page with whatever comes back. They differ only in what they put on the
66
+ // include (nothing, for a text search) and what they put on the request (the filter).
67
+
68
+ function txOpMessage(id, text) {
69
+ var preview = document.getElementById(id + '_preview');
70
+ preview.textContent = text;
71
+ preview.style.display = 'block';
72
+ return false;
73
+ }
74
+
75
+ function txOpUuid() {
76
+ if (window.crypto && typeof crypto.randomUUID === 'function') {
77
+ return crypto.randomUUID();
78
+ }
79
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
80
+ var r = Math.random() * 16 | 0;
81
+ return (c === 'x' ? r : ((r & 0x3) | 0x8)).toString(16);
82
+ });
83
+ }
84
+
85
+ // One include, so that filters are ANDed. The value set exists only for the duration of
86
+ // the expansion - it is never stored - so its url is a uuid.
87
+ function txOpBuildValueSet(form, filters) {
88
+ var include = { system: form.dataset.system };
89
+ if (form.dataset.version) {
90
+ include.version = form.dataset.version;
91
+ }
92
+ if (filters && filters.length > 0) {
93
+ include.filter = filters.map(function (f) {
94
+ return { property: f.property, op: f.op, value: f.value };
95
+ });
96
+ }
97
+ return {
98
+ resourceType: 'ValueSet',
99
+ // Marks the value set as one that exists only for this request, so that the server
100
+ // renders the expansion rather than a summary of a value set nobody can look up.
101
+ extension: [{ url: 'http://healthintersections.com.au/fhirsmith/StructureDefinition/valueset-transient', valueBoolean: true }],
102
+ url: 'urn:uuid:' + txOpUuid(),
103
+ status: 'active',
104
+ compose: { include: [include] }
105
+ };
106
+ }
107
+
108
+ // The value set goes in the body; anything that is an expansion parameter rather than
109
+ // part of the value set goes on the query, which $expand reads either way.
110
+ function txOpExpand(id, form, valueSet, params) {
111
+ var url = new URL(form.getAttribute('action'), window.location.href);
112
+ // The definition has to come back for the transient marker and the filters to survive
113
+ // the expansion - $expand drops both along with the rest of the definition otherwise.
114
+ url.searchParams.set('includeDefinition', 'true');
115
+ for (var name in params || {}) {
116
+ url.searchParams.set(name, params[name]);
117
+ }
118
+ txOpMessage(id, 'Expanding...');
119
+ fetch(url.toString(), {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/fhir+json', 'Accept': 'text/html' },
122
+ body: JSON.stringify(valueSet)
123
+ }).then(function (response) {
124
+ return response.text();
125
+ }).then(function (html) {
126
+ document.open();
127
+ document.write(html);
128
+ document.close();
129
+ }).catch(function (err) {
130
+ txOpMessage(id, 'Expansion failed: ' + err.message);
131
+ });
132
+ return false;
133
+ }
134
+
135
+ function txSearchSubmit(id) {
136
+ var form = document.getElementById(id + '_form');
137
+ var text = form.querySelector('input[name="filter"]').value.trim();
138
+ if (text === '') {
139
+ return txOpMessage(id, 'Enter some text to search for.');
140
+ }
141
+ return txOpExpand(id, form, txOpBuildValueSet(form, null), { filter: text });
142
+ }
143
+
144
+ function txFilterClearRow(row) {
145
+ row.querySelectorAll('input').forEach(function (input) { input.value = ''; });
146
+ row.querySelectorAll('select').forEach(function (select) { select.selectedIndex = 0; });
147
+ }
148
+
149
+ function txFilterAddRow(id) {
150
+ var rows = document.getElementById(id + '_rows');
151
+ var row = rows.rows[0].cloneNode(true);
152
+ txFilterClearRow(row);
153
+ rows.appendChild(row);
154
+ }
155
+
156
+ function txFilterRemoveRow(btn) {
157
+ var row = btn.closest('tr');
158
+ var rows = row.parentNode;
159
+ if (rows.rows.length > 1) {
160
+ rows.removeChild(row);
161
+ } else {
162
+ txFilterClearRow(row);
163
+ }
164
+ }
165
+
166
+ // Every row that has anything in it. Rows left entirely blank are ignored, so an unused
167
+ // extra row does not stop the expansion; a half-filled one is an error the user must fix.
168
+ function txFilterCollect(id) {
169
+ var filters = [];
170
+ var rows = document.getElementById(id + '_rows').rows;
171
+ for (var i = 0; i < rows.length; i++) {
172
+ var property = rows[i].querySelector('input[name="property"]').value.trim();
173
+ var op = rows[i].querySelector('select[name="op"]').value;
174
+ var value = rows[i].querySelector('input[name="value"]').value.trim();
175
+ if (property !== '' || value !== '') {
176
+ filters.push({ row: i + 1, property: property, op: op, value: value });
177
+ }
178
+ }
179
+ return filters;
180
+ }
181
+
182
+ function txFilterSubmit(id) {
183
+ var form = document.getElementById(id + '_form');
184
+ var filters = txFilterCollect(id);
185
+ if (filters.length === 0) {
186
+ return txOpMessage(id, 'No filters specified.');
187
+ }
188
+ for (var i = 0; i < filters.length; i++) {
189
+ if (filters[i].property === '' || filters[i].value === '') {
190
+ return txOpMessage(id, 'Filter ' + filters[i].row + ' needs both a property and a value.');
191
+ }
192
+ }
193
+ return txOpExpand(id, form, txOpBuildValueSet(form, filters), null);
194
+ }
195
+ </script>
@@ -0,0 +1,93 @@
1
+ <p>Expand a SNOMED CT expression constraint. The expression is run as a value set that
2
+ composes SNOMED CT with a <code>constraint</code> filter; the result appears below, and
3
+ the inputs stay where they are so the expression can be adjusted and run again.</p>
4
+
5
+ {% if hasVersions %}
6
+ <div class="operation-form" style="margin-bottom: 15px;">
7
+ <form id="{{ eclId }}_form" action="{{ expandUrl }}" data-system="{{ system }}"
8
+ data-transient="{{ transientExtension }}" onsubmit="return eclRun('{{ eclId }}')">
9
+ <table class="grid" cellpadding="0" cellspacing="0" style="width: 100%;">
10
+ <tr>
11
+ <td style="width: 100%;">ECL: <input type="text" name="ecl" id="{{ eclId }}_ecl" size="80"
12
+ style="width: 90%;" autocomplete="off" placeholder="&lt; 404684003 |Clinical finding|"/></td>
13
+ </tr>
14
+ <tr>
15
+ <td>Edition: <select name="version" id="{{ eclId }}_version">{{ versionOptions }}</select>
16
+ <button type="submit" class="btn btn-sm btn-primary" id="{{ eclId }}_go">Go</button>
17
+ </td>
18
+ </tr>
19
+ </table>
20
+ </form>
21
+ </div>
22
+
23
+ <div id="{{ eclId }}_results"></div>
24
+
25
+ <script>
26
+ function eclUuid() {
27
+ if (window.crypto && typeof crypto.randomUUID === 'function') {
28
+ return crypto.randomUUID();
29
+ }
30
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
31
+ var r = Math.random() * 16 | 0;
32
+ return (c === 'x' ? r : ((r & 0x3) | 0x8)).toString(16);
33
+ });
34
+ }
35
+
36
+ function eclMessage(id, text) {
37
+ document.getElementById(id + '_results').innerHTML = '';
38
+ var p = document.createElement('p');
39
+ p.textContent = text;
40
+ document.getElementById(id + '_results').appendChild(p);
41
+ return false;
42
+ }
43
+
44
+ // The expansion comes back as a fragment rather than a page, so it can go straight under
45
+ // the inputs. An error arrives the same way - as the rendered OperationOutcome - which is
46
+ // the point: an ECL is usually wrong several times before it is right, and the message
47
+ // belongs next to the expression that caused it.
48
+ function eclRun(id) {
49
+ var form = document.getElementById(id + '_form');
50
+ var ecl = document.getElementById(id + '_ecl').value.trim();
51
+ var version = document.getElementById(id + '_version').value;
52
+ var go = document.getElementById(id + '_go');
53
+
54
+ if (ecl === '') {
55
+ return eclMessage(id, 'Enter an expression constraint.');
56
+ }
57
+
58
+ var valueSet = {
59
+ resourceType: 'ValueSet',
60
+ extension: [{ url: form.dataset.transient, valueBoolean: true }],
61
+ url: 'urn:uuid:' + eclUuid(),
62
+ status: 'active',
63
+ compose: {
64
+ include: [{
65
+ system: form.dataset.system,
66
+ version: version,
67
+ filter: [{ property: 'constraint', op: '=', value: ecl }]
68
+ }]
69
+ }
70
+ };
71
+
72
+ eclMessage(id, 'Expanding...');
73
+ go.disabled = true;
74
+ fetch(form.getAttribute('action'), {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/fhir+json', 'Accept': 'text/html' },
77
+ body: JSON.stringify(valueSet)
78
+ }).then(function (response) {
79
+ return response.text();
80
+ }).then(function (html) {
81
+ document.getElementById(id + '_results').innerHTML = html;
82
+ }).catch(function (err) {
83
+ eclMessage(id, 'Expansion failed: ' + err.message);
84
+ }).then(function () {
85
+ go.disabled = false;
86
+ });
87
+ return false;
88
+ }
89
+ </script>
90
+ {% else %}
91
+ <p><em>This endpoint has no SNOMED CT editions loaded, so there is nothing to run an
92
+ expression constraint against.</em></p>
93
+ {% endif %}
@@ -4,8 +4,8 @@
4
4
  <canvas id="memoryChart" height="200"></canvas>
5
5
  </div>
6
6
  <div style="flex: 1;">
7
- <h6>Expansion Cache</h6>
8
- <canvas id="cacheChart" height="200"></canvas>
7
+ <h6>Requests per Minute</h6>
8
+ <canvas id="requestChart" height="200"></canvas>
9
9
  </div>
10
10
  <div style="flex: 1;">
11
11
  <h6>Turnaround Time</h6>
@@ -13,18 +13,18 @@
13
13
  </div>
14
14
  </div>
15
15
  <div style="display: flex; gap: 20px; margin-bottom: 20px;">
16
- <div style="flex: 1;">
17
- <h6>Requests per Minute</h6>
18
- <canvas id="requestChart" height="200"></canvas>
19
- </div>
20
16
  <div style="flex: 1;">
21
17
  <h6>Client Cache</h6>
22
18
  <canvas id="clientCacheChart" height="200"></canvas>
23
19
  </div>
24
20
  <div style="flex: 1;">
25
- <h6>Node Blocking</h6>
21
+ <h6>Node Blocking (mean)</h6>
26
22
  <canvas id="blockChart" height="200"></canvas>
27
23
  </div>
24
+ <div style="flex: 1;">
25
+ <h6>Node Blocking (max)</h6>
26
+ <canvas id="blockMaxChart" height="200"></canvas>
27
+ </div>
28
28
  </div>
29
29
 
30
30
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@@ -99,36 +99,6 @@
99
99
  }
100
100
  });
101
101
 
102
- // Expansion cache chart
103
- new Chart(document.getElementById('cacheChart'), {
104
- type: 'line',
105
- data: {
106
- labels: historyData.map(d => formatTime(d.time)),
107
- datasets: [{
108
- data: historyData.map(d => d.expansion),
109
- borderColor: '#7b7b7b',
110
- backgroundColor: 'rgba(123, 123, 123, 0.1)',
111
- fill: true,
112
- tension: 0.3,
113
- pointRadius: 2
114
- }]
115
- },
116
- options: {
117
- ...chartOptions,
118
- scales: {
119
- ...chartOptions.scales,
120
- y: {
121
- ...chartOptions.scales.y,
122
- title: {
123
- display: true,
124
- text: 'Expansion cache # items'
125
- }
126
- }
127
- }
128
- }
129
- });
130
-
131
-
132
102
  // Tat chart
133
103
  new Chart(document.getElementById('tatChart'), {
134
104
  type: 'line',
@@ -215,48 +185,41 @@
215
185
  }
216
186
  }
217
187
  });
218
- // Blocking chart
219
- new Chart(document.getElementById('blockChart'), {
220
- type: 'line',
221
- data: {
222
- labels: historyData.map(d => formatTime(d.time)),
223
- datasets: [{
224
- label: 'mean',
225
- data: historyData.map(d => d.block),
226
- borderColor: '#2845a7',
227
- backgroundColor: 'rgba(40, 69, 167, 0.1)',
228
- fill: true,
229
- tension: 0.3,
230
- pointRadius: 2
231
- }, {
232
- // Worst single event-loop stall in the window: the mean dilutes a
233
- // multi-second block into noise, so this is the line that shows
234
- // request-freezing stalls.
235
- label: 'max',
236
- data: historyData.map(d => d.blockMax),
237
- borderColor: '#a72828',
238
- fill: false,
239
- tension: 0.3,
240
- pointRadius: 2
241
- }]
242
- },
243
- options: {
244
- ...chartOptions,
245
- plugins: {
246
- ...chartOptions.plugins,
247
- legend: { display: true }
188
+ // Blocking charts. Mean and max are separate charts because they are on different
189
+ // scales: a multi-second stall puts max in the thousands of ms while the mean sits at
190
+ // a few ms, so on one chart the mean is a flat line along the bottom.
191
+ function blockChart(id, field, colour, fill) {
192
+ new Chart(document.getElementById(id), {
193
+ type: 'line',
194
+ data: {
195
+ labels: historyData.map(d => formatTime(d.time)),
196
+ datasets: [{
197
+ data: historyData.map(d => d[field]),
198
+ borderColor: colour,
199
+ backgroundColor: fill,
200
+ fill: true,
201
+ tension: 0.3,
202
+ pointRadius: 2
203
+ }]
248
204
  },
249
- scales: {
250
- ...chartOptions.scales,
251
- y: {
252
- ...chartOptions.scales.y,
253
- title: {
254
- display: true,
255
- text: 'ms'
205
+ options: {
206
+ ...chartOptions,
207
+ scales: {
208
+ ...chartOptions.scales,
209
+ y: {
210
+ ...chartOptions.scales.y,
211
+ title: {
212
+ display: true,
213
+ text: 'ms'
214
+ }
256
215
  }
257
216
  }
258
217
  }
259
- }
260
- });
218
+ });
219
+ }
220
+ // the average event-loop delay over each interval
221
+ blockChart('blockChart', 'block', '#2845a7', 'rgba(40, 69, 167, 0.1)');
222
+ // the worst single stall in each interval: the one that shows request-freezing blocks
223
+ blockChart('blockMaxChart', 'blockMax', '#a72828', 'rgba(167, 40, 40, 0.1)');
261
224
  })();
262
225
  </script>