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.
@@ -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 OR REPLACE INTO valuesets (
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>