fhirsmith 0.18.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
@@ -0,0 +1,468 @@
1
+ /**
2
+ * The cluster's state now, for the master's status panel (/ and /dashboard) and
3
+ * /dashboard/status.json (cluster.md, "Status").
4
+ *
5
+ * Under the launcher (cluster.js), every process sends a few figures of its own over its IPC
6
+ * channel every statusSeconds (15): its JS heap, its client caches and their concepts, its
7
+ * expansion cache, the requests it has in flight and whether its memory guard is refusing.
8
+ * The launcher adds what only it can read: each process's private memory and mapped file
9
+ * pages (/proc/<pid>/status), its status and restarts, and the cgroup's private memory and
10
+ * page cache (memory.stat), against the limit the memory guard uses. It merges them into one
11
+ * snapshot (buildSnapshot) and passes it to the master, as it passes on the request counts
12
+ * (library/cluster-counts.js). Each figure carries its scope, and its age.
13
+ *
14
+ * A worker's own page shows its own figures; a process without the launcher (no role, or roles
15
+ * started by hand behind nginx) has no IPC channel, and shows only its own.
16
+ *
17
+ * clusterSummary and processSummary shape the status line at the top of the page
18
+ * (tx/html/status-line.liquid): requests, uptime, heap and any problems. clusterView,
19
+ * processView and storageView shape the rest, under "More" (tx/html/status-panel.liquid):
20
+ * a header, tiles, the memory meter, a row per process and footnotes.
21
+ */
22
+ const v8 = require('v8');
23
+
24
+ // a process's own figures, to the launcher; the launcher's snapshot, to the master
25
+ const STATUS = 'fhirsmith:status';
26
+ const CLUSTER_STATUS = 'fhirsmith:cluster-status';
27
+ // how often each process sends its figures, and the launcher sends the master its snapshot
28
+ // (server.cluster.statusSeconds)
29
+ const STATUS_INTERVAL_MS = 15000;
30
+ // figures this many intervals old are shown with their age, and left out of the totals
31
+ const STALE_INTERVALS = 3;
32
+
33
+ const n = (v) => Number(v).toLocaleString('en-US');
34
+
35
+ /**
36
+ * This process's figures, as it sends them to the launcher.
37
+ * @param {Object} stats - the ServerStats
38
+ * @param {Object} own - { inFlight(), refusing() }, from server.js
39
+ */
40
+ function ownFigures(stats, own = {}) {
41
+ const heap = process.memoryUsage();
42
+ return {
43
+ time: Date.now(),
44
+ requests: stats.requestCount,
45
+ heapUsed: heap.heapUsed,
46
+ heapLimit: v8.getHeapStatistics().heap_size_limit,
47
+ clientCaches: stats.clientCaches(),
48
+ clientConcepts: stats.clientConcepts(),
49
+ expansionItems: stats.expansionItems(),
50
+ inFlight: own.inFlight ? own.inFlight() : null,
51
+ refusing: own.refusing ? own.refusing() : null
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Attach a process with a role to the launcher's IPC channel: it sends its own figures every
57
+ * intervalMs, and the master keeps the snapshot the launcher sends back (stats.clusterStatus).
58
+ * A process started without the launcher has no channel, and this does nothing.
59
+ * @param {Object} stats - the ServerStats
60
+ * @param {Object} [channel] - process, or a stand-in (tests)
61
+ * @param {Object} [options] - role, intervalMs, figures() (what to send)
62
+ * @returns {Function} disconnect
63
+ */
64
+ function connectStatus(stats, channel = process, { role = null, intervalMs = STATUS_INTERVAL_MS, figures } = {}) {
65
+ if (typeof channel.send !== 'function') {
66
+ return () => {};
67
+ }
68
+ let sending = false;
69
+ const send = () => {
70
+ // a figure that can't be sent is simply replaced by the next
71
+ if (sending || channel.connected === false) return;
72
+ let message;
73
+ try {
74
+ message = { type: STATUS, figures: figures() };
75
+ } catch {
76
+ return; // statistics must never stop the server
77
+ }
78
+ sending = true;
79
+ try {
80
+ channel.send(message, () => { sending = false; });
81
+ } catch {
82
+ sending = false;
83
+ }
84
+ };
85
+ const timer = setInterval(send, intervalMs > 0 ? intervalMs : STATUS_INTERVAL_MS);
86
+ if (typeof timer.unref === 'function') timer.unref();
87
+ send();
88
+ const receive = message => {
89
+ if (role === 'master' && message && message.type === CLUSTER_STATUS && message.snapshot) {
90
+ stats.clusterStatus = message.snapshot;
91
+ }
92
+ };
93
+ // a master that has lost the launcher shows its own figures again
94
+ const disconnected = () => { stats.clusterStatus = null; };
95
+ channel.on('message', receive);
96
+ channel.on('disconnect', disconnected);
97
+ return () => {
98
+ clearInterval(timer);
99
+ channel.removeListener('message', receive);
100
+ channel.removeListener('disconnect', disconnected);
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The launcher's snapshot of the cluster: what each process last sent, with what the launcher
106
+ * read itself. Totals are of the processes whose figures are fresh (STALE_INTERVALS); a
107
+ * figure that couldn't be read is null, and `missing` says what isn't known and why.
108
+ * @param {Object} input
109
+ * @param {number} input.now
110
+ * @param {number} input.intervalMs - statusSeconds, in ms
111
+ * @param {Object[]} input.procs - code, role, status, restarts, startedAt (0: not running),
112
+ * figures (what it last sent, or null), memory ({anon, file} from /proc, or null)
113
+ * @param {Object} input.launcher - startedAt, requests (proxied), memory ({anon, file}|null)
114
+ * @param {Object} input.guard - the memory guard's limit and thresholds (thresholds() in
115
+ * library/memory-guard.js): limit, source, rejectFraction, abortFraction, rejectAt, abortAt,
116
+ * and group (whether it measures the cgroup)
117
+ * @param {Object|null} input.cgroup - {anon, file} from memory.stat; null: not measured
118
+ * @param {boolean} [input.sharedCache] - server.cluster.sharedCache: the client caches are one
119
+ * store, and the count is read once from it (the master's figure) rather than summed
120
+ * @param {boolean} [input.procAvailable] - whether /proc could be read at all
121
+ */
122
+ function buildSnapshot({ now, intervalMs, procs, launcher, guard, cgroup, sharedCache = false, procAvailable = true }) {
123
+ const missing = [];
124
+ const staleMs = STALE_INTERVALS * intervalMs;
125
+ const processes = procs.map(p => {
126
+ const f = p.figures;
127
+ const age = f && Number.isFinite(f.time) ? Math.max(0, now - f.time) : null;
128
+ const fresh = f && age !== null && age <= staleMs;
129
+ return {
130
+ code: p.code, role: p.role,
131
+ label: `${p.role === 'master' ? 'Master' : 'Worker'} ${p.code}`,
132
+ status: p.status, restarts: p.restarts || 0,
133
+ uptimeMs: p.startedAt ? now - p.startedAt : null,
134
+ anon: p.memory ? p.memory.anon : null,
135
+ file: p.memory ? p.memory.file : null,
136
+ figuresAgeMs: age, fresh: Boolean(fresh),
137
+ requests: f ? f.requests ?? null : null,
138
+ heapUsed: f ? f.heapUsed ?? null : null,
139
+ heapLimit: f ? f.heapLimit ?? null : null,
140
+ clientCaches: f ? f.clientCaches ?? null : null,
141
+ clientConcepts: f ? f.clientConcepts ?? null : null,
142
+ expansionItems: f ? f.expansionItems ?? null : null,
143
+ inFlight: f ? f.inFlight ?? null : null,
144
+ refusing: f ? f.refusing ?? null : null
145
+ };
146
+ });
147
+ const fresh = processes.filter(p => p.fresh);
148
+ const sum = (list, field) => list.some(p => Number.isFinite(p[field]))
149
+ ? list.reduce((a, p) => a + (Number.isFinite(p[field]) ? p[field] : 0), 0) : null;
150
+ const running = processes.filter(p => p.uptimeMs !== null);
151
+ if (fresh.length < running.length) {
152
+ missing.push(`${running.length - fresh.length} running process(es) have sent no figures in the last ${Math.round(staleMs / 1000)} s: the totals leave them out`);
153
+ }
154
+ if (!procAvailable) {
155
+ missing.push('per-process memory: /proc is not available here (Linux only)');
156
+ }
157
+ if (!cgroup) {
158
+ missing.push(guard.group
159
+ ? 'the cgroup\'s memory: memory.stat could not be read'
160
+ : 'the cgroup\'s memory: there is no cgroup with a memory limit of the service\'s own (MemoryMax on the unit, or a container limit), so the memory guard measures each process alone, and the page cache is not known');
161
+ }
162
+ // client caches: one store with sharedCache (any process's count is the whole), else each its own
163
+ let caches;
164
+ if (sharedCache) {
165
+ const from = fresh.find(p => p.role === 'master' && Number.isFinite(p.clientCaches)) || fresh.find(p => Number.isFinite(p.clientCaches));
166
+ caches = { scope: 'shared', from: from ? from.code : null,
167
+ clientCaches: from ? from.clientCaches : null, clientConcepts: from ? from.clientConcepts : null };
168
+ } else {
169
+ caches = { scope: 'summed', clientCaches: sum(fresh, 'clientCaches'), clientConcepts: sum(fresh, 'clientConcepts') };
170
+ }
171
+ const memoryOfProcs = processes.filter(p => Number.isFinite(p.anon));
172
+ return {
173
+ sampledAt: now,
174
+ intervalMs,
175
+ memory: {
176
+ // what the memory guard measures, with a role: the cgroup's private memory
177
+ cgroup: cgroup ? { anon: cgroup.anon, file: cgroup.file } : null,
178
+ processAnon: memoryOfProcs.length ? memoryOfProcs.reduce((a, p) => a + p.anon, 0) + (launcher.memory ? launcher.memory.anon : 0) : null,
179
+ limit: guard.limit, limitSource: guard.source,
180
+ rejectAt: guard.rejectAt, abortAt: guard.abortAt,
181
+ rejectFraction: guard.rejectFraction, abortFraction: guard.abortFraction,
182
+ group: Boolean(guard.group)
183
+ },
184
+ caches: { ...caches, expansionItems: sum(fresh, 'expansionItems') },
185
+ inFlight: sum(fresh, 'inFlight'),
186
+ refusing: fresh.filter(p => p.refusing === true).map(p => p.code),
187
+ processes,
188
+ launcher: {
189
+ label: 'Launcher', status: 'running',
190
+ uptimeMs: launcher.startedAt ? now - launcher.startedAt : null,
191
+ requests: launcher.requests ?? null,
192
+ anon: launcher.memory ? launcher.memory.anon : null,
193
+ file: launcher.memory ? launcher.memory.file : null,
194
+ heapUsed: launcher.heapUsed ?? null, heapLimit: launcher.heapLimit ?? null
195
+ },
196
+ missing
197
+ };
198
+ }
199
+
200
+ function ago(ms) {
201
+ if (ms === null || ms === undefined) return '—';
202
+ return ms < 1000 ? 'now' : `${Math.round(ms / 1000)} s`;
203
+ }
204
+
205
+ function duration(ms) {
206
+ if (ms === null || ms === undefined) return '—';
207
+ const s = Math.floor(ms / 1000);
208
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
209
+ return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`;
210
+ }
211
+
212
+ // "—" where a figure doesn't apply or isn't known
213
+ const dash = (v, f = String) => (v === null || v === undefined ? '—' : f(v));
214
+ const mb = (b) => Math.round(b / 2 ** 20).toLocaleString('en-US');
215
+ const gb = (b) => (b / 2 ** 30).toFixed(1);
216
+ // a rate: whole numbers from 100 up, one decimal below
217
+ const rate = (v) => (v >= 100 ? n(Math.round(v)) : n(Number(v.toFixed(1))));
218
+ const pct = (part, whole) => (whole > 0 ? Math.max(0, Math.min(100, (part * 100) / whole)) : 0);
219
+
220
+ // The graph lines' colours, in their order (tx/html/cluster-metrics-script.liquid): told apart
221
+ // by lightness, and every other segment of the memory meter is also striped
222
+ const PALETTE = ['#08306b', '#5b9bd5', '#d98c00', '#6a3d9a', '#9e9e9e', '#8a5a00', '#b4a7d6', '#404040'];
223
+ const SYMBOLS = ['●', '■', '▲', '◆', '★', '✕', '✚', '▢'];
224
+ // the launcher, and the rest of a cgroup the processes don't account for: light, hatched
225
+ const NEUTRAL = '#d9d9d9';
226
+ const swatch = (i) => ({ color: PALETTE[i % PALETTE.length], symbol: SYMBOLS[i % SYMBOLS.length], striped: i % 2 === 1 });
227
+
228
+ function pill(status, refusing) {
229
+ const pills = [{ text: status, quiet: status === 'ready' || status === 'running' }];
230
+ if (refusing) pills.push({ text: 'refusing', quiet: false });
231
+ return pills;
232
+ }
233
+
234
+ /**
235
+ * The memory meter: what the memory guard measures against its limit, in a segment per process,
236
+ * with ticks at the refuse and abort thresholds.
237
+ * @param {Object} m - used, limit, rejectAt, abortAt, rejectFraction, abortFraction, scope (the
238
+ * bar's label), segments ({label, bytes, swatch}), notes
239
+ */
240
+ function meter({ used, limit, rejectAt, abortAt, rejectFraction, abortFraction, scope, segments, notes = [] }) {
241
+ if (!(limit > 0) || used === null || used === undefined) {
242
+ return { scope, unknown: true, notes };
243
+ }
244
+ const shown = segments.filter(s => s.bytes > 0);
245
+ const accounted = shown.reduce((a, s) => a + s.bytes, 0);
246
+ // what the processes' own figures don't account for (the measure is the cgroup's, or newer)
247
+ if (used - accounted > limit / 200) {
248
+ shown.push({ label: 'other', bytes: used - accounted, swatch: { color: NEUTRAL, striped: true } });
249
+ }
250
+ const scale = Math.max(used, accounted) > 0 ? used / Math.max(used, accounted) : 1;
251
+ return {
252
+ scope,
253
+ value: `${gb(used)} GB of ${gb(limit)} GB (${Math.round(pct(used, limit))}%)`,
254
+ segments: shown.map(s => ({ label: s.label, width: pct(s.bytes * scale, limit).toFixed(2), ...s.swatch,
255
+ title: `${s.label}: ${mb(s.bytes)} MB` })),
256
+ // at: the tick, from the left; right: where its label ends, from the right
257
+ ticks: [
258
+ { label: `refuse ${Math.round(rejectFraction * 100)}% · ${gb(rejectAt)} GB`, at: pct(rejectAt, limit).toFixed(2), right: (100 - pct(rejectAt, limit)).toFixed(2) },
259
+ { label: `abort ${Math.round(abortFraction * 100)}% · ${gb(abortAt)} GB`, at: pct(abortAt, limit).toFixed(2), right: (100 - pct(abortAt, limit)).toFixed(2) }
260
+ ],
261
+ notes
262
+ };
263
+ }
264
+
265
+ // the table's columns; the heap's limit goes in its header when every process has the same
266
+ const tableColumns = (heapLimit) => [
267
+ { label: 'Process' }, { label: 'Status' }, { label: 'Uptime', num: true }, { label: 'Restarts', num: true },
268
+ { label: 'Requests', num: true }, { label: 'Private MB', num: true }, { label: 'Mapped MB', num: true },
269
+ { label: heapLimit ? `Heap MB of ${mb(heapLimit)}` : 'Heap MB used / limit', num: true },
270
+ { label: 'Caches', num: true }, { label: 'Concepts', num: true }, { label: 'In flight', num: true }, { label: 'Age', num: true }
271
+ ];
272
+
273
+ /**
274
+ * The master's status panel under the launcher (tx/html/status-panel.liquid): a header, the
275
+ * cluster's tiles, the memory meter, a row per process, and footnotes. Requests are those each
276
+ * process served (its own count since it started), and the tile is their sum.
277
+ * @param {Object} snapshot - from buildSnapshot
278
+ * @param {Object} opts - now, uptime (the master's, text), uptimeSub, host ({used, total} bytes)
279
+ */
280
+ function clusterView(snapshot, { now = Date.now(), uptime = '—', uptimeSub = null, host = null } = {}) {
281
+ const m = snapshot.memory;
282
+ const procs = snapshot.processes;
283
+ const fresh = procs.filter(p => p.fresh);
284
+ const requests = fresh.reduce((a, p) => a + (p.requests || 0), 0);
285
+ const longest = Math.max(0, ...procs.map(p => p.uptimeMs || 0));
286
+ const restarts = procs.reduce((a, p) => a + p.restarts, 0);
287
+ const c = snapshot.caches;
288
+ const tiles = [
289
+ { label: 'Requests', value: n(requests), sub: longest > 60000 ? `${rate(requests / (longest / 60000))} /min avg` : null },
290
+ { label: 'Client caches', value: dash(c.clientCaches, n), sub: c.clientConcepts === null ? null
291
+ : `${n(c.clientConcepts)} concepts${c.scope === 'shared' ? ' · shared' : ''}` },
292
+ { label: 'Expansion cache', value: dash(c.expansionItems, n), unit: 'items' },
293
+ { label: 'In flight', value: dash(snapshot.inFlight, n), sub: snapshot.refusing.length ? `refusing: ${snapshot.refusing.join(', ')}` : 'none refusing' },
294
+ { label: 'Restarts', value: n(restarts), sub: 'since launch' },
295
+ { label: 'Uptime', value: uptime, sub: uptimeSub }
296
+ ];
297
+ const segments = procs.map((p, i) => ({ label: p.label, bytes: p.anon || 0, swatch: swatch(i) }));
298
+ segments.push({ label: 'Launcher', bytes: snapshot.launcher.anon || 0, swatch: { color: NEUTRAL, striped: false } });
299
+ const notes = host ? [`Host: ${gb(host.used)} of ${gb(host.total)} GB in use`] : [];
300
+ const memory = m.cgroup
301
+ ? meter({ ...m, used: m.cgroup.anon, scope: 'cgroup private', segments, notes })
302
+ : meter({ ...m, used: m.processAnon, scope: 'processes\' private, summed', segments, notes });
303
+ const limits = new Set([...procs, snapshot.launcher].map(p => p.heapLimit).filter(Number.isFinite));
304
+ const heapLimit = limits.size === 1 ? [...limits][0] : null;
305
+ const heap = (p) => (p.heapUsed === null ? '—' : heapLimit ? mb(p.heapUsed) : `${mb(p.heapUsed)} / ${mb(p.heapLimit)}`);
306
+ const rows = procs.map((p, i) => ({
307
+ swatch: swatch(i),
308
+ cells: [{ text: p.label }, { pills: pill(p.fresh || p.figuresAgeMs === null ? p.status : 'stale', p.refusing === true) },
309
+ { text: duration(p.uptimeMs), num: true }, { text: n(p.restarts), num: true }, { text: dash(p.requests, n), num: true },
310
+ { text: dash(p.anon, mb), num: true }, { text: dash(p.file, mb), num: true },
311
+ { text: heap(p), num: true },
312
+ { text: dash(p.clientCaches, n), num: true }, { text: dash(p.clientConcepts, n), num: true },
313
+ { text: dash(p.inFlight, n), num: true }, { text: ago(p.figuresAgeMs), num: true }]
314
+ }));
315
+ const l = snapshot.launcher;
316
+ rows.push({ swatch: { color: NEUTRAL, symbol: '' }, cells: [{ text: 'Launcher' }, { pills: pill('running') },
317
+ { text: duration(l.uptimeMs), num: true }, { text: '—', num: true }, { text: '—', num: true },
318
+ { text: dash(l.anon, mb), num: true }, { text: dash(l.file, mb), num: true },
319
+ { text: heap(l), num: true },
320
+ { text: '—', num: true }, { text: '—', num: true }, { text: '—', num: true }, { text: 'now', num: true }] });
321
+ const footnotes = [
322
+ 'Mapped: terminology files a process has mapped; shared pages count in each process and are not in the memory above.',
323
+ ...snapshot.missing.map(text => `Not shown: ${text}.`)
324
+ ];
325
+ return {
326
+ heading: 'Cluster', meta: [`${procs.length} processes`, `updated ${ago(now - snapshot.sampledAt) === 'now' ? 'just now' : ago(now - snapshot.sampledAt) + ' ago'}`],
327
+ tiles, memory, table: { columns: tableColumns(heapLimit), rows }, footnotes
328
+ };
329
+ }
330
+
331
+ /**
332
+ * The status panel of one process: a server with no role, a worker's own page, or a master
333
+ * before the launcher's first snapshot. Tiles and the meter, no per-process table.
334
+ * @param {Object} own - figures (ownFigures), uptime (text), uptimeSub, static (count), avgPerMin,
335
+ * guard ({measure, limit, rejectAt, abortAt, rejectFraction, abortFraction, group, fileBacked}),
336
+ * anon (this process's RssAnon, or null), host
337
+ * @param {Object} opts - heading, meta
338
+ */
339
+ function processView(own, { heading = 'Server', meta = ['this process'] } = {}) {
340
+ const f = own.figures;
341
+ const g = own.guard;
342
+ const tiles = [
343
+ { label: 'Requests', value: n(f.requests), sub: [Number.isFinite(own.avgPerMin) ? `${rate(own.avgPerMin)} /min avg` : null,
344
+ own.static ? `${n(own.static)} static` : null].filter(Boolean).join(' · ') || null },
345
+ { label: 'Client caches', value: n(f.clientCaches), sub: `${n(f.clientConcepts)} concepts` },
346
+ { label: 'Expansion cache', value: n(f.expansionItems), unit: 'items' },
347
+ { label: 'In flight', value: dash(f.inFlight, n), sub: f.refusing ? 'refusing' : 'not refusing' },
348
+ { label: 'Heap', value: mb(f.heapUsed), unit: 'MB', sub: `of ${mb(f.heapLimit)} MB` },
349
+ { label: 'Uptime', value: own.uptime, sub: own.uptimeSub }
350
+ ];
351
+ const scope = g.group ? 'cgroup private, every process' : g.fileBacked ? 'this process, RSS less mapped files' : 'this process, RSS';
352
+ const segments = [{ label: 'this process', bytes: g.group ? own.anon || 0 : g.measure, swatch: swatch(0) }];
353
+ const notes = own.host ? [`Host: ${gb(own.host.used)} of ${gb(own.host.total)} GB in use`] : [];
354
+ return {
355
+ heading, meta, tiles,
356
+ memory: meter({ ...g, used: g.measure, scope, segments, notes }),
357
+ table: null, footnotes: []
358
+ };
359
+ }
360
+
361
+ /**
362
+ * The storage tiles below the graphs: closure tables, logs and disk, where there are any.
363
+ * @param {Object} s - heading, closure (closureStats() or null), logs ({files, bytes, kept}
364
+ * or null), disk ({free, total} bytes, or null)
365
+ */
366
+ function storageView({ heading = 'Storage', closure = null, logs = null, disk = null }) {
367
+ const tiles = [];
368
+ if (closure) {
369
+ tiles.push({ label: 'Closure tables', value: n(closure.tables) });
370
+ tiles.push({ label: 'Closure entries', value: n(closure.entries) });
371
+ tiles.push({ label: 'Closure concepts', value: n(closure.concepts) });
372
+ const small = closure.bytes !== null && closure.bytes < 2 ** 20;
373
+ tiles.push({ label: 'Closure database', value: closure.bytes === null ? 'in memory' : small ? n(Math.ceil(closure.bytes / 1024)) : mb(closure.bytes),
374
+ unit: closure.bytes === null ? null : small ? 'KB' : 'MB', sub: closure.retentionDays ? `kept ${closure.retentionDays} days unused` : null });
375
+ }
376
+ if (logs) {
377
+ tiles.push({ label: 'Logs', value: mb(logs.bytes), unit: 'MB', sub: `${n(logs.files)} files · ${logs.kept} kept` });
378
+ }
379
+ if (disk) {
380
+ tiles.push({ label: 'Disk free', value: gb(disk.free), unit: 'GB', sub: `of ${gb(disk.total)} GB` });
381
+ }
382
+ // as many columns as tiles, up to six: the row is never short
383
+ return { heading, meta: [], tiles, columns: Math.min(6, Math.max(1, tiles.length)), memory: null, table: null, footnotes: [] };
384
+ }
385
+
386
+ // the memory guard's measure at this share of its refusal threshold or more is shown as a problem
387
+ const NEAR_REFUSING = 0.9;
388
+ // and less free disk than this share of it
389
+ const DISK_LOW = 0.1;
390
+
391
+ /**
392
+ * The problems the status line lists, each a sentence; none when all is well.
393
+ * @param {Object} p - refusing (labels), measure, rejectAt (the memory guard's), down
394
+ * ({label, status}: processes not running), stale ({label, ageMs}), restarts ({label, count}),
395
+ * disk ({free, total}|null), failedTasks ({name, state, date}, stats.failedTasks()), other
396
+ * (sentences of their own, such as npm audit's), now
397
+ */
398
+ function problems({ refusing = [], measure = null, rejectAt = null, down = [], stale = [], restarts = [], disk = null, failedTasks = [], other = [], now = Date.now() }) {
399
+ const list = [];
400
+ const memory = Number.isFinite(measure) && rejectAt > 0 ? `${gb(measure)} GB, threshold ${gb(rejectAt)} GB` : null;
401
+ if (refusing.length) {
402
+ list.push(`Refusing requests: ${refusing.join(', ')}${memory ? ` (memory ${memory})` : ''}`);
403
+ } else if (memory && measure >= NEAR_REFUSING * rejectAt) {
404
+ list.push(`Memory near the refusal threshold: ${memory}`);
405
+ }
406
+ for (const p of down) list.push(`${p.label}: ${p.status}`);
407
+ for (const p of stale) list.push(`${p.label}: no figures for ${Math.round(p.ageMs / 1000)} s`);
408
+ for (const p of restarts) list.push(`${p.label} restarted ${n(p.count)} ${p.count === 1 ? 'time' : 'times'} since launch`);
409
+ if (disk && disk.total > 0 && disk.free < DISK_LOW * disk.total) {
410
+ list.push(`Disk low: ${gb(disk.free)} GB free of ${gb(disk.total)} GB`);
411
+ }
412
+ for (const t of failedTasks) list.push(`${t.name} failed${Number.isFinite(t.date) ? ` ${duration(now - t.date)} ago` : ''}: ${t.state}`);
413
+ return list.concat(other);
414
+ }
415
+
416
+ /**
417
+ * The status line at the top of / and /dashboard (tx/html/status-line.liquid) on the master
418
+ * under the launcher: the cluster's requests, the master's uptime, the heap of every process
419
+ * summed, and the problems. The rest is in clusterView, under "More".
420
+ * @param {Object} snapshot - from buildSnapshot
421
+ * @param {Object} opts - uptime, uptimeSub, disk, failedTasks, other, now
422
+ */
423
+ function clusterSummary(snapshot, { uptime = '—', uptimeSub = null, disk = null, failedTasks = [], other = [], now = Date.now() } = {}) {
424
+ const m = snapshot.memory;
425
+ const procs = snapshot.processes;
426
+ const fresh = procs.filter(p => p.fresh);
427
+ const requests = fresh.reduce((a, p) => a + (p.requests || 0), 0);
428
+ const longest = Math.max(0, ...procs.map(p => p.uptimeMs || 0));
429
+ const heaps = [...fresh, snapshot.launcher].filter(p => Number.isFinite(p.heapUsed));
430
+ const label = (code) => (procs.find(p => p.code === code) || { label: code }).label;
431
+ return {
432
+ items: [
433
+ { label: 'Requests', value: n(requests), sub: longest > 60000 ? `${rate(requests / (longest / 60000))} /min` : null },
434
+ { label: 'Uptime', value: uptime, sub: uptimeSub },
435
+ { label: 'Heap', value: heaps.length ? mb(heaps.reduce((a, p) => a + p.heapUsed, 0)) : '—', unit: 'MB',
436
+ sub: `${heaps.length} ${heaps.length === 1 ? 'process' : 'processes'}` }
437
+ ],
438
+ problems: problems({
439
+ refusing: snapshot.refusing.map(label),
440
+ measure: m.cgroup ? m.cgroup.anon : m.processAnon, rejectAt: m.rejectAt,
441
+ down: procs.filter(p => p.status !== 'ready' && p.status !== 'running').map(p => ({ label: p.label, status: p.status })),
442
+ stale: procs.filter(p => !p.fresh && p.figuresAgeMs !== null && p.uptimeMs !== null).map(p => ({ label: p.label, ageMs: p.figuresAgeMs })),
443
+ restarts: procs.filter(p => p.restarts > 0).map(p => ({ label: p.label, count: p.restarts })),
444
+ disk, failedTasks, other, now
445
+ })
446
+ };
447
+ }
448
+
449
+ /**
450
+ * The status line of one process (processView's figures): its requests, uptime and heap, and
451
+ * the problems.
452
+ * @param {Object} own - as for processView, with disk, failedTasks and other
453
+ */
454
+ function processSummary(own, { now = Date.now() } = {}) {
455
+ const f = own.figures;
456
+ const g = own.guard;
457
+ return {
458
+ items: [
459
+ { label: 'Requests', value: n(f.requests), sub: Number.isFinite(own.avgPerMin) ? `${rate(own.avgPerMin)} /min` : null },
460
+ { label: 'Uptime', value: own.uptime, sub: own.uptimeSub },
461
+ { label: 'Heap', value: mb(f.heapUsed), unit: 'MB', sub: `of ${mb(f.heapLimit)}` }
462
+ ],
463
+ problems: problems({ refusing: f.refusing ? ['this process'] : [], measure: g.measure, rejectAt: g.rejectAt,
464
+ disk: own.disk || null, failedTasks: own.failedTasks || [], other: own.other || [], now })
465
+ };
466
+ }
467
+
468
+ module.exports = { ownFigures, connectStatus, buildSnapshot, clusterView, processView, storageView, problems, clusterSummary, processSummary, PALETTE, STATUS, CLUSTER_STATUS, STATUS_INTERVAL_MS, STALE_INTERVALS };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Reads config.json, which may have comments: // to the end of the line, and /* ... *\/ blocks,
3
+ * anywhere JSON allows white space. Otherwise it is plain JSON (no trailing commas, no single
4
+ * quotes).
5
+ *
6
+ * The comments are replaced by spaces (keeping their line breaks), so a JSON syntax error is
7
+ * reported at the line and column it has in the file.
8
+ */
9
+ const fs = require('fs');
10
+
11
+ /**
12
+ * The JSON text with its comments blanked out. Text in strings is left alone, so a URL like
13
+ * "http://example.org" is not a comment
14
+ * @param {string} text
15
+ * @returns {string}
16
+ */
17
+ function stripComments(text) {
18
+ let out = '';
19
+ let i = 0;
20
+ const n = text.length;
21
+ while (i < n) {
22
+ const c = text[i];
23
+ if (c === '"') {
24
+ // a string: copy it as is, up to the closing quote that isn't escaped
25
+ let j = i + 1;
26
+ while (j < n && text[j] !== '"') {
27
+ if (text[j] === '\\') {
28
+ j++;
29
+ }
30
+ j++;
31
+ }
32
+ out += text.substring(i, j + 1);
33
+ i = j + 1;
34
+ } else if (c === '/' && text[i + 1] === '/') {
35
+ let j = i;
36
+ while (j < n && text[j] !== '\n' && text[j] !== '\r') {
37
+ j++;
38
+ }
39
+ out += ' '.repeat(j - i);
40
+ i = j;
41
+ } else if (c === '/' && text[i + 1] === '*') {
42
+ const end = text.indexOf('*/', i + 2);
43
+ if (end < 0) {
44
+ throw new Error(`unterminated /* comment at ${position(text, i)}`);
45
+ }
46
+ out += text.substring(i, end + 2).replace(/[^\r\n]/g, ' ');
47
+ i = end + 2;
48
+ } else {
49
+ out += c;
50
+ i++;
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** "line L, column C" (1-based) of an offset in the text */
57
+ function position(text, offset) {
58
+ const before = text.substring(0, offset);
59
+ const line = before.split('\n').length;
60
+ const column = offset - before.lastIndexOf('\n');
61
+ return `line ${line}, column ${column}`;
62
+ }
63
+
64
+ /**
65
+ * Parse config.json text (JSON with comments)
66
+ * @param {string} text
67
+ * @returns {Object}
68
+ * @throws {Error} with the line and column of a syntax error
69
+ */
70
+ function parseConfig(text) {
71
+ if (text.charCodeAt(0) === 0xFEFF) {
72
+ text = text.substring(1); // a byte order mark, as some Windows editors write
73
+ }
74
+ const json = stripComments(text);
75
+ try {
76
+ return JSON.parse(json);
77
+ } catch (e) {
78
+ // V8 says "... at position N" (sometimes with "(line L column C)"): give the line and column
79
+ const m = /at position (\d+)/.exec(e.message);
80
+ if (m && !/line \d+/.test(e.message)) {
81
+ throw new Error(`${e.message} (${position(json, Number(m[1]))})`);
82
+ }
83
+ throw e;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read and parse a config file (JSON with comments)
89
+ * @param {string} filePath
90
+ * @returns {Object}
91
+ */
92
+ function readConfig(filePath) {
93
+ return parseConfig(fs.readFileSync(filePath, 'utf8'));
94
+ }
95
+
96
+ module.exports = { readConfig, parseConfig, stripComments };