fhirsmith 0.7.5 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +8 -0
- package/library/html.js +4 -0
- package/package.json +1 -1
- package/packages/package-crawler.js +104 -49
- package/packages/packages.js +14 -0
- package/publisher/publisher.js +117 -27
- package/registry/registry.js +97 -89
- package/root-bare-template.html +93 -0
- package/security.md +32 -0
- package/server.js +94 -47
- package/stats.js +6 -4
- package/tx/README.md +6 -6
- package/tx/cs/cs-api.js +3 -0
- package/tx/cs/cs-api.md +285 -0
- package/tx/importers/readme.md +3 -1
- package/tx/library.js +10 -4
- package/tx/provider.js +2 -1
- package/tx/tx-html.js +36 -9
- package/tx/tx.js +34 -11
- package/tx/vs/vs-database.js +42 -5
- package/tx/vs/vs-vsac.js +48 -0
- package/utilities/dashboard.html +274 -0
package/tx/vs/vs-database.js
CHANGED
|
@@ -22,6 +22,27 @@ class ValueSetDatabase {
|
|
|
22
22
|
this._writeDb = null; // Write connection (opened only when needed)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Apply any pending schema migrations
|
|
27
|
+
* @param {sqlite3.Database} db
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
* @private
|
|
30
|
+
*/
|
|
31
|
+
_migrateIfNeeded(db) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
db.all("PRAGMA table_info(valuesets)", [], (err, cols) => {
|
|
34
|
+
if (err) { reject(err); return; }
|
|
35
|
+
const hasCol = cols.some(c => c.name === 'date_first_seen');
|
|
36
|
+
if (hasCol) { resolve(); return; }
|
|
37
|
+
db.run(
|
|
38
|
+
"ALTER TABLE valuesets ADD COLUMN date_first_seen INTEGER DEFAULT 0",
|
|
39
|
+
[],
|
|
40
|
+
(err) => err ? reject(err) : resolve()
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
/**
|
|
26
47
|
* Get a read-only database connection (opens lazily if needed)
|
|
27
48
|
* @returns {Promise<sqlite3.Database>}
|
|
@@ -62,7 +83,7 @@ class ValueSetDatabase {
|
|
|
62
83
|
this._writeDb = null;
|
|
63
84
|
reject(new Error(`Failed to open database for writing: ${err.message}`));
|
|
64
85
|
} else {
|
|
65
|
-
resolve(this._writeDb);
|
|
86
|
+
this._migrateIfNeeded(this._writeDb).then(() => resolve(this._writeDb)).catch(reject);
|
|
66
87
|
}
|
|
67
88
|
});
|
|
68
89
|
});
|
|
@@ -144,7 +165,8 @@ class ValueSetDatabase {
|
|
|
144
165
|
status TEXT,
|
|
145
166
|
title TEXT,
|
|
146
167
|
content TEXT NOT NULL,
|
|
147
|
-
last_seen INTEGER DEFAULT (strftime('%s', 'now'))
|
|
168
|
+
last_seen INTEGER DEFAULT (strftime('%s', 'now')),
|
|
169
|
+
date_first_seen INTEGER DEFAULT (strftime('%s', 'now'))
|
|
148
170
|
)
|
|
149
171
|
`);
|
|
150
172
|
|
|
@@ -190,6 +212,7 @@ class ValueSetDatabase {
|
|
|
190
212
|
db.run('CREATE INDEX idx_valuesets_title ON valuesets(title)');
|
|
191
213
|
db.run('CREATE INDEX idx_valuesets_publisher ON valuesets(publisher)');
|
|
192
214
|
db.run('CREATE INDEX idx_valuesets_last_seen ON valuesets(last_seen)');
|
|
215
|
+
db.run('CREATE INDEX idx_valuesets_date_first_seen ON valuesets(date_first_seen)');
|
|
193
216
|
db.run('CREATE INDEX idx_identifiers_system ON valueset_identifiers(system)');
|
|
194
217
|
db.run('CREATE INDEX idx_identifiers_value ON valueset_identifiers(value)');
|
|
195
218
|
db.run('CREATE INDEX idx_jurisdictions_system ON valueset_jurisdictions(system)');
|
|
@@ -246,10 +269,24 @@ class ValueSetDatabase {
|
|
|
246
269
|
const expansionId = valueSet.expansion?.identifier || null;
|
|
247
270
|
|
|
248
271
|
db.run(`
|
|
249
|
-
INSERT
|
|
272
|
+
INSERT INTO valuesets (
|
|
250
273
|
id, url, version, date, description, effectivePeriod_start, effectivePeriod_end,
|
|
251
|
-
expansion_identifier, name, publisher, status, title, content, last_seen
|
|
252
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
|
|
274
|
+
expansion_identifier, name, publisher, status, title, content, last_seen, date_first_seen
|
|
275
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
|
|
276
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
277
|
+
url=excluded.url,
|
|
278
|
+
version=excluded.version,
|
|
279
|
+
date=excluded.date,
|
|
280
|
+
description=excluded.description,
|
|
281
|
+
effectivePeriod_start=excluded.effectivePeriod_start,
|
|
282
|
+
effectivePeriod_end=excluded.effectivePeriod_end,
|
|
283
|
+
expansion_identifier=excluded.expansion_identifier,
|
|
284
|
+
name=excluded.name,
|
|
285
|
+
publisher=excluded.publisher,
|
|
286
|
+
status=excluded.status,
|
|
287
|
+
title=excluded.title,
|
|
288
|
+
content=excluded.content,
|
|
289
|
+
last_seen=strftime('%s', 'now')
|
|
253
290
|
`, [
|
|
254
291
|
valueSet.id,
|
|
255
292
|
valueSet.url,
|
package/tx/vs/vs-vsac.js
CHANGED
|
@@ -70,6 +70,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
70
70
|
if (!(await this.database.exists())) {
|
|
71
71
|
await this.database.create();
|
|
72
72
|
} else {
|
|
73
|
+
// Ensure schema is up to date (e.g. date_first_seen column added after initial deploy)
|
|
74
|
+
await this.database._migrateIfNeeded(await this.database._getWriteConnection());
|
|
73
75
|
// Load existing data
|
|
74
76
|
await this._reloadMap();
|
|
75
77
|
}
|
|
@@ -511,6 +513,52 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
511
513
|
this.stats.task('VSAC Sync', logMsg);
|
|
512
514
|
|
|
513
515
|
}
|
|
516
|
+
|
|
517
|
+
name() {
|
|
518
|
+
return "VSAC";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
infoName() {
|
|
522
|
+
return "history";
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async info() {
|
|
526
|
+
const db = await this.database._getReadConnection();
|
|
527
|
+
const rows = await new Promise((resolve, reject) => {
|
|
528
|
+
db.all(
|
|
529
|
+
`SELECT url, version, date_first_seen
|
|
530
|
+
FROM valuesets
|
|
531
|
+
WHERE date_first_seen > 0
|
|
532
|
+
ORDER BY date_first_seen DESC
|
|
533
|
+
LIMIT 100`,
|
|
534
|
+
[],
|
|
535
|
+
(err, rows) => err ? reject(err) : resolve(rows)
|
|
536
|
+
);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const escape = require('escape-html');
|
|
540
|
+
let html = '<h3>Recently Value Sets Added to VSAC</h3>';
|
|
541
|
+
html += '<p>The last ' + rows.length + ' value sets found from VSAC, most recent first.</p>';
|
|
542
|
+
html += '<table class="grid">';
|
|
543
|
+
html += '<thead><tr><th>URL</th><th>Version</th><th>Date Observed</th></tr></thead>';
|
|
544
|
+
html += '<tbody>';
|
|
545
|
+
for (const row of rows) {
|
|
546
|
+
const date = row.date_first_seen
|
|
547
|
+
? new Date(row.date_first_seen * 1000).toISOString().replace('T', ' ').substring(0, 19) + ' UTC'
|
|
548
|
+
: 'unknown';
|
|
549
|
+
html += '<tr>';
|
|
550
|
+
html += `<td>${escape(row.url || '')}</td>`;
|
|
551
|
+
html += `<td>${escape(row.version || '')}</td>`;
|
|
552
|
+
html += `<td>${escape(date)}</td>`;
|
|
553
|
+
html += '</tr>';
|
|
554
|
+
}
|
|
555
|
+
html += '</tbody></table>';
|
|
556
|
+
return html;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
id() {
|
|
560
|
+
return "vsac";
|
|
561
|
+
}
|
|
514
562
|
}
|
|
515
563
|
|
|
516
564
|
// Usage examples:
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>FHIR Server Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
font-family: system-ui, sans-serif;
|
|
12
|
+
background: #111;
|
|
13
|
+
color: #e0e0e0;
|
|
14
|
+
height: 100vh;
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
header {
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: space-between;
|
|
24
|
+
padding: 8px 16px;
|
|
25
|
+
background: #1a1a1a;
|
|
26
|
+
border-bottom: 1px solid #333;
|
|
27
|
+
flex-shrink: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
header h1 {
|
|
31
|
+
font-size: 15px;
|
|
32
|
+
font-weight: 500;
|
|
33
|
+
color: #ccc;
|
|
34
|
+
letter-spacing: 0.03em;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#global-status {
|
|
38
|
+
font-size: 12px;
|
|
39
|
+
color: #666;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.grid {
|
|
43
|
+
flex: 1;
|
|
44
|
+
display: grid;
|
|
45
|
+
grid-template-columns: 1fr 1fr;
|
|
46
|
+
grid-template-rows: 1fr 1fr;
|
|
47
|
+
gap: 1px;
|
|
48
|
+
background: #222;
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.panel {
|
|
53
|
+
background: #0e0e0e;
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
overflow: hidden;
|
|
57
|
+
position: relative;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.panel-header {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: 8px;
|
|
64
|
+
padding: 7px 12px;
|
|
65
|
+
background: #181818;
|
|
66
|
+
border-bottom: 1px solid #2a2a2a;
|
|
67
|
+
flex-shrink: 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.panel-header .hostname {
|
|
71
|
+
font-size: 13px;
|
|
72
|
+
font-weight: 500;
|
|
73
|
+
color: #bbb;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.panel-header .status-dot {
|
|
77
|
+
width: 7px;
|
|
78
|
+
height: 7px;
|
|
79
|
+
border-radius: 50%;
|
|
80
|
+
background: #444;
|
|
81
|
+
flex-shrink: 0;
|
|
82
|
+
transition: background 0.4s;
|
|
83
|
+
}
|
|
84
|
+
.status-dot.ok { background: #3a9c5a; }
|
|
85
|
+
.status-dot.error { background: #b84040; }
|
|
86
|
+
.status-dot.loading { background: #888; animation: pulse 1s ease-in-out infinite; }
|
|
87
|
+
|
|
88
|
+
@keyframes pulse {
|
|
89
|
+
0%, 100% { opacity: 1; }
|
|
90
|
+
50% { opacity: 0.3; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.panel-header .last-updated {
|
|
94
|
+
margin-left: auto;
|
|
95
|
+
font-size: 11px;
|
|
96
|
+
color: #555;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.countdown {
|
|
100
|
+
font-size: 11px;
|
|
101
|
+
color: #444;
|
|
102
|
+
margin-left: 6px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.panel-body {
|
|
106
|
+
flex: 1;
|
|
107
|
+
overflow: auto;
|
|
108
|
+
padding: 0;
|
|
109
|
+
position: relative;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.panel-body iframe {
|
|
113
|
+
width: 100%;
|
|
114
|
+
height: 100%;
|
|
115
|
+
border: none;
|
|
116
|
+
display: block;
|
|
117
|
+
background: #fff;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.panel-body .message {
|
|
121
|
+
position: absolute;
|
|
122
|
+
inset: 0;
|
|
123
|
+
display: flex;
|
|
124
|
+
flex-direction: column;
|
|
125
|
+
align-items: center;
|
|
126
|
+
justify-content: center;
|
|
127
|
+
gap: 8px;
|
|
128
|
+
color: #555;
|
|
129
|
+
font-size: 13px;
|
|
130
|
+
text-align: center;
|
|
131
|
+
padding: 16px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.message .error-detail {
|
|
135
|
+
font-size: 11px;
|
|
136
|
+
color: #3d3d3d;
|
|
137
|
+
max-width: 340px;
|
|
138
|
+
line-height: 1.5;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.refresh-btn {
|
|
142
|
+
font-size: 11px;
|
|
143
|
+
padding: 4px 10px;
|
|
144
|
+
background: transparent;
|
|
145
|
+
border: 1px solid #333;
|
|
146
|
+
border-radius: 4px;
|
|
147
|
+
color: #666;
|
|
148
|
+
cursor: pointer;
|
|
149
|
+
margin-top: 4px;
|
|
150
|
+
}
|
|
151
|
+
.refresh-btn:hover { border-color: #555; color: #999; }
|
|
152
|
+
</style>
|
|
153
|
+
</head>
|
|
154
|
+
<body>
|
|
155
|
+
|
|
156
|
+
<header>
|
|
157
|
+
<h1>FHIR Server Dashboard</h1>
|
|
158
|
+
<span id="global-status">Initialising...</span>
|
|
159
|
+
</header>
|
|
160
|
+
|
|
161
|
+
<div class="grid" id="grid"></div>
|
|
162
|
+
|
|
163
|
+
<script>
|
|
164
|
+
const SERVERS = [
|
|
165
|
+
{ id: 'npm', host: 'npm.fhir.org' },
|
|
166
|
+
{ id: 'tx', host: 'tx.fhir.org' },
|
|
167
|
+
{ id: 'www', host: 'www.fhir.org' },
|
|
168
|
+
{ id: 'health', host: 'www.healthintersections.com.au' },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const INTERVAL_MS = 60_000;
|
|
172
|
+
|
|
173
|
+
function dashboardUrl(host) {
|
|
174
|
+
return `https://${host}/dashboard`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function now() {
|
|
178
|
+
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const panels = {};
|
|
182
|
+
|
|
183
|
+
function buildPanels() {
|
|
184
|
+
const grid = document.getElementById('grid');
|
|
185
|
+
for (const srv of SERVERS) {
|
|
186
|
+
const panel = document.createElement('div');
|
|
187
|
+
panel.className = 'panel';
|
|
188
|
+
panel.innerHTML = `
|
|
189
|
+
<div class="panel-header">
|
|
190
|
+
<div class="status-dot loading" id="dot-${srv.id}"></div>
|
|
191
|
+
<span class="hostname">${srv.host}</span>
|
|
192
|
+
<span class="last-updated" id="updated-${srv.id}">—</span>
|
|
193
|
+
<span class="countdown" id="countdown-${srv.id}"></span>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="panel-body" id="body-${srv.id}">
|
|
196
|
+
<div class="message"><span>Loading...</span></div>
|
|
197
|
+
</div>`;
|
|
198
|
+
grid.appendChild(panel);
|
|
199
|
+
panels[srv.id] = srv;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function fetchDashboard(srv) {
|
|
204
|
+
const dot = document.getElementById(`dot-${srv.id}`);
|
|
205
|
+
const body = document.getElementById(`body-${srv.id}`);
|
|
206
|
+
const updatedEl = document.getElementById(`updated-${srv.id}`);
|
|
207
|
+
|
|
208
|
+
dot.className = 'status-dot loading';
|
|
209
|
+
|
|
210
|
+
const url = dashboardUrl(srv.host);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const res = await fetch(url, { cache: 'no-store' });
|
|
214
|
+
|
|
215
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
216
|
+
|
|
217
|
+
const html = await res.text();
|
|
218
|
+
|
|
219
|
+
dot.className = 'status-dot ok';
|
|
220
|
+
updatedEl.textContent = now();
|
|
221
|
+
|
|
222
|
+
const iframe = document.createElement('iframe');
|
|
223
|
+
iframe.sandbox = 'allow-same-origin allow-scripts';
|
|
224
|
+
body.innerHTML = '';
|
|
225
|
+
body.appendChild(iframe);
|
|
226
|
+
|
|
227
|
+
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
|
228
|
+
doc.open();
|
|
229
|
+
doc.write(html);
|
|
230
|
+
doc.close();
|
|
231
|
+
|
|
232
|
+
} catch (err) {
|
|
233
|
+
dot.className = 'status-dot error';
|
|
234
|
+
updatedEl.textContent = now();
|
|
235
|
+
body.innerHTML = `
|
|
236
|
+
<div class="message">
|
|
237
|
+
<span>Could not load dashboard</span>
|
|
238
|
+
<span class="error-detail">${url}<br>${err.message}</span>
|
|
239
|
+
<button class="refresh-btn" onclick="fetchDashboard(panels['${srv.id}'])">Retry now</button>
|
|
240
|
+
</div>`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const nextRefresh = {};
|
|
245
|
+
|
|
246
|
+
function scheduleRefresh(srv) {
|
|
247
|
+
nextRefresh[srv.id] = Date.now() + INTERVAL_MS;
|
|
248
|
+
setTimeout(() => {
|
|
249
|
+
fetchDashboard(srv).then(() => scheduleRefresh(srv));
|
|
250
|
+
}, INTERVAL_MS);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function updateCountdowns() {
|
|
254
|
+
for (const srv of SERVERS) {
|
|
255
|
+
const el = document.getElementById(`countdown-${srv.id}`);
|
|
256
|
+
if (!el || !nextRefresh[srv.id]) continue;
|
|
257
|
+
const secs = Math.max(0, Math.round((nextRefresh[srv.id] - Date.now()) / 1000));
|
|
258
|
+
el.textContent = secs > 0 ? `↻ ${secs}s` : '';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const gs = document.getElementById('global-status');
|
|
262
|
+
gs.textContent = `Auto-refresh every ${INTERVAL_MS / 1000}s`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
buildPanels();
|
|
266
|
+
|
|
267
|
+
for (const srv of SERVERS) {
|
|
268
|
+
fetchDashboard(srv).then(() => scheduleRefresh(srv));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
setInterval(updateCountdowns, 1000);
|
|
272
|
+
</script>
|
|
273
|
+
</body>
|
|
274
|
+
</html>
|