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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to the Health Intersections Node Server will be documented i
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [v0.7.6] - 2026-03-25
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Dashboard endpoint (see dashboard.html)
|
|
13
|
+
- Initial cs-api documentation
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Update package crawler to support archived feed entries
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- OCL improvements:
|
|
22
|
+
- Improve multilingual support and caching for non-OCL expansions
|
|
23
|
+
- cache compose instead of pre-built expansions
|
|
24
|
+
- Fix ConceptMap rendering
|
|
25
|
+
- Ongoing on work on publishing module
|
|
26
|
+
- Tidy up tx-reg to prevent hanging
|
|
27
|
+
|
|
28
|
+
### Tx Conformance Statement
|
|
29
|
+
|
|
30
|
+
FHIRsmith passed all 1464 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.3)
|
|
31
|
+
|
|
8
32
|
## [v0.7.5] - 2026-03-19
|
|
9
33
|
|
|
10
34
|
### Changed
|
package/README.md
CHANGED
|
@@ -17,6 +17,14 @@ This server provides a set of server-side services that are useful for the FHIR
|
|
|
17
17
|
* [VCL](vcl/readme.md) - **Parse VCL expressions** into FHIR ValueSet resources for http://fhir.org/vcl
|
|
18
18
|
* (Coming) Token services
|
|
19
19
|
|
|
20
|
+
## Summary Statement
|
|
21
|
+
|
|
22
|
+
* Maintainers: Grahame Grieve, Italo Macêdo, Josh Mandel, Jose Costa Teixeira
|
|
23
|
+
* Issues / Discussion: Use github issues
|
|
24
|
+
* License: BSD-3
|
|
25
|
+
* Contribution Policy: Make PRs. PRs have to pass all the tests
|
|
26
|
+
* Security Information: See [security.md](security.md)
|
|
27
|
+
|
|
20
28
|
## Build Status
|
|
21
29
|

|
|
22
30
|
[](https://github.com/HealthIntersections/fhirsmith/releases)
|
package/library/html.js
CHANGED
package/package.json
CHANGED
|
@@ -27,6 +27,25 @@ class PackageCrawler {
|
|
|
27
27
|
this.db.run('PRAGMA busy_timeout = 5000');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
async isFeedPageVisited(url) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
this.db.get('SELECT Url FROM FeedPages WHERE Url = ?', [url], (err, row) => {
|
|
33
|
+
if (err) reject(err);
|
|
34
|
+
else resolve(!!row);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async markFeedPageVisited(url) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
this.db.run(
|
|
42
|
+
'INSERT OR REPLACE INTO FeedPages (Url, VisitedAt) VALUES (?, ?)',
|
|
43
|
+
[url, new Date().toISOString()],
|
|
44
|
+
(err) => { if (err) reject(err); else resolve(); }
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
async crawl(log) {
|
|
31
50
|
this.log = log;
|
|
32
51
|
this.packages.clear();
|
|
@@ -151,7 +170,8 @@ class PackageCrawler {
|
|
|
151
170
|
const parser = new XMLParser({
|
|
152
171
|
ignoreAttributes: false,
|
|
153
172
|
attributeNamePrefix: '@_',
|
|
154
|
-
textNodeName: '#text'
|
|
173
|
+
textNodeName: '#text',
|
|
174
|
+
entityExpansionLimit: 100000
|
|
155
175
|
});
|
|
156
176
|
return parser.parse(content);
|
|
157
177
|
} else {
|
|
@@ -166,13 +186,13 @@ class PackageCrawler {
|
|
|
166
186
|
const parser = new XMLParser({
|
|
167
187
|
ignoreAttributes: false,
|
|
168
188
|
attributeNamePrefix: '@_',
|
|
169
|
-
textNodeName: '#text'
|
|
189
|
+
textNodeName: '#text',
|
|
190
|
+
entityExpansionLimit: 100000
|
|
170
191
|
});
|
|
171
|
-
|
|
172
192
|
return parser.parse(response.data);
|
|
173
193
|
}
|
|
174
194
|
} catch (error) {
|
|
175
|
-
debugLog(error);
|
|
195
|
+
debugLog(`Failed to fetch XML from ${url}: ${error.message}`);
|
|
176
196
|
if (error.response && error.response.status === 429) {
|
|
177
197
|
throw new Error(`RATE_LIMITED: Server returned 429 Too Many Requests for ${url}`);
|
|
178
198
|
}
|
|
@@ -200,7 +220,7 @@ class PackageCrawler {
|
|
|
200
220
|
return Buffer.from(response.data);
|
|
201
221
|
}
|
|
202
222
|
} catch (error) {
|
|
203
|
-
debugLog(error);
|
|
223
|
+
debugLog(`Failed to fetch ${url}: ${error.message}`);
|
|
204
224
|
if (error.response && error.response.status === 429) {
|
|
205
225
|
throw new Error(`RATE_LIMITED: Server returned 429 Too Many Requests for ${url}`);
|
|
206
226
|
}
|
|
@@ -218,62 +238,96 @@ class PackageCrawler {
|
|
|
218
238
|
this.log.info('Processing feed: ' + url);
|
|
219
239
|
const startTime = Date.now();
|
|
220
240
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
241
|
+
// The first page (the root feed URL) is always processed — it contains the
|
|
242
|
+
// latest packages. Subsequent pages (followed via atom:link rel="next") are
|
|
243
|
+
// historical archives and only need to be visited once.
|
|
244
|
+
let currentUrl = url;
|
|
245
|
+
let isFirstPage = true;
|
|
224
246
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
247
|
+
while (currentUrl) {
|
|
248
|
+
if (this.abortController?.signal.aborted) break;
|
|
249
|
+
|
|
250
|
+
if (!isFirstPage) {
|
|
251
|
+
const alreadyVisited = await this.isFeedPageVisited(currentUrl);
|
|
252
|
+
if (alreadyVisited) {
|
|
253
|
+
this.log.info(`Feed page already visited, stopping: ${currentUrl}`);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
230
256
|
}
|
|
231
257
|
|
|
232
|
-
|
|
258
|
+
try {
|
|
259
|
+
this.log.info(`Fetching feed page: ${currentUrl}`);
|
|
260
|
+
const xmlData = await this.fetchXml(currentUrl);
|
|
261
|
+
if (isFirstPage) feedLog.fetchTime = `${Date.now() - startTime}ms`;
|
|
262
|
+
|
|
263
|
+
let items = [];
|
|
264
|
+
let nextUrl = null;
|
|
265
|
+
|
|
266
|
+
if (xmlData.rss && xmlData.rss.channel) {
|
|
267
|
+
const channel = xmlData.rss.channel;
|
|
268
|
+
items = Array.isArray(channel.item) ? channel.item : [channel.item].filter(Boolean);
|
|
269
|
+
|
|
270
|
+
// Check for RFC 5005 next-page link: <atom:link rel="next" href="..."/>
|
|
271
|
+
const atomLinks = channel['atom:link'];
|
|
272
|
+
if (atomLinks) {
|
|
273
|
+
const links = Array.isArray(atomLinks) ? atomLinks : [atomLinks];
|
|
274
|
+
const nextLink = links.find(l => l['@_rel'] === 'next');
|
|
275
|
+
if (nextLink && nextLink['@_href']) {
|
|
276
|
+
nextUrl = this.fixUrl(nextLink['@_href']);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
233
280
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
281
|
+
this.log.info(`Found ${items.length} items in feed page ${currentUrl}`);
|
|
282
|
+
|
|
283
|
+
let rateLimited = false;
|
|
284
|
+
for (let i = 0; i < items.length; i++) {
|
|
285
|
+
if (this.abortController?.signal.aborted) break;
|
|
286
|
+
try {
|
|
287
|
+
await this.updateItem(currentUrl, items[i], i, packageRestrictions, feedLog);
|
|
288
|
+
} catch (itemError) {
|
|
289
|
+
if (itemError.message.includes('RATE_LIMITED')) {
|
|
290
|
+
this.log.info(`Rate limited while downloading package from ${currentUrl}, stopping feed processing`);
|
|
291
|
+
feedLog.rateLimited = true;
|
|
292
|
+
feedLog.rateLimitedAt = `item ${i}`;
|
|
293
|
+
feedLog.rateLimitMessage = itemError.message;
|
|
294
|
+
rateLimited = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
this.log.error(`Error processing item ${i} from ${currentUrl}:` + itemError.message);
|
|
246
298
|
}
|
|
247
|
-
// For other errors, log and continue with next item
|
|
248
|
-
this.log.error(`Error processing item ${i} from ${url}:` + itemError.message);
|
|
249
299
|
}
|
|
250
|
-
}
|
|
251
300
|
|
|
252
|
-
|
|
253
|
-
if (this.errors && email && !feedLog.rateLimited) {
|
|
254
|
-
this.log.info(`Would send error email to ${email} for feed ${url}`);
|
|
255
|
-
}
|
|
301
|
+
if (rateLimited) break;
|
|
256
302
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
feedLog.rateLimited = true;
|
|
263
|
-
feedLog.rateLimitMessage = error.message;
|
|
264
|
-
feedLog.failTime = `${Date.now() - startTime}ms`;
|
|
265
|
-
return; // Skip this feed entirely
|
|
266
|
-
}
|
|
303
|
+
// Mark this page as visited now that we've successfully processed it.
|
|
304
|
+
// Don't mark the first page — it must always be re-crawled for new entries.
|
|
305
|
+
if (!isFirstPage) {
|
|
306
|
+
await this.markFeedPageVisited(currentUrl);
|
|
307
|
+
}
|
|
267
308
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
this.log.error(`Exception processing feed ${url}:` + error.message);
|
|
309
|
+
currentUrl = nextUrl;
|
|
310
|
+
isFirstPage = false;
|
|
271
311
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
312
|
+
} catch (error) {
|
|
313
|
+
debugLog(error);
|
|
314
|
+
if (error.message.includes('RATE_LIMITED')) {
|
|
315
|
+
this.log.info(`Rate limited while fetching feed ${currentUrl}, stopping`);
|
|
316
|
+
feedLog.rateLimited = true;
|
|
317
|
+
feedLog.rateLimitMessage = error.message;
|
|
318
|
+
feedLog.failTime = `${Date.now() - startTime}ms`;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
feedLog.exception = error.message;
|
|
322
|
+
feedLog.failTime = `${Date.now() - startTime}ms`;
|
|
323
|
+
this.log.error(`Exception processing feed ${currentUrl}:` + error.message);
|
|
324
|
+
break;
|
|
275
325
|
}
|
|
276
326
|
}
|
|
327
|
+
|
|
328
|
+
if (this.errors && email && !feedLog.rateLimited) {
|
|
329
|
+
this.log.info(`Would send error email to ${email} for feed ${url}`);
|
|
330
|
+
}
|
|
277
331
|
}
|
|
278
332
|
|
|
279
333
|
async updateItem(source, item, index, packageRestrictions, feedLog) {
|
|
@@ -699,6 +753,7 @@ class PackageCrawler {
|
|
|
699
753
|
};
|
|
700
754
|
|
|
701
755
|
} catch (error) {
|
|
756
|
+
console.log(error);
|
|
702
757
|
throw new Error(`Failed to extract NPM package from ${source}: ${error.message}`);
|
|
703
758
|
}
|
|
704
759
|
}
|
package/packages/packages.js
CHANGED
|
@@ -617,6 +617,13 @@ class PackagesModule {
|
|
|
617
617
|
this.createTables().then(resolve).catch(reject);
|
|
618
618
|
} else {
|
|
619
619
|
pckLog.info('Packages database already exists');
|
|
620
|
+
// Run migrations for tables added after initial schema
|
|
621
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS FeedPages (
|
|
622
|
+
Url TEXT PRIMARY KEY,
|
|
623
|
+
VisitedAt TEXT NOT NULL
|
|
624
|
+
)`, (err) => {
|
|
625
|
+
if (err) pckLog.error('Failed to create FeedPages table:', err.message);
|
|
626
|
+
});
|
|
620
627
|
resolve();
|
|
621
628
|
}
|
|
622
629
|
}
|
|
@@ -692,6 +699,13 @@ class PackagesModule {
|
|
|
692
699
|
ManualToken TEXT(64) NOT NULL,
|
|
693
700
|
Email TEXT(128) NOT NULL,
|
|
694
701
|
Mask TEXT(64)
|
|
702
|
+
)`,
|
|
703
|
+
|
|
704
|
+
// FeedPages table - tracks which paginated feed archive pages have been visited
|
|
705
|
+
`CREATE TABLE FeedPages
|
|
706
|
+
(
|
|
707
|
+
Url TEXT PRIMARY KEY,
|
|
708
|
+
VisitedAt TEXT NOT NULL
|
|
695
709
|
)`
|
|
696
710
|
];
|
|
697
711
|
|
package/publisher/publisher.js
CHANGED
|
@@ -269,6 +269,7 @@ class PublisherModule {
|
|
|
269
269
|
this.router.post('/tasks', this.requireAuth.bind(this), this.createTask.bind(this));
|
|
270
270
|
this.router.post('/tasks/:id/approve', this.requireAuth.bind(this), this.approveTask.bind(this));
|
|
271
271
|
this.router.post('/tasks/:id/delete', this.requireAdmin.bind(this), this.deleteTask.bind(this));
|
|
272
|
+
this.router.post('/tasks/:id/retry', this.requireAuth.bind(this), this.retryTask.bind(this));
|
|
272
273
|
this.router.get('/tasks/:id/output', this.getTaskOutput.bind(this));
|
|
273
274
|
this.router.get('/tasks/:id/history', this.getTaskHistory.bind(this));
|
|
274
275
|
this.router.get('/tasks/:id/qa', this.getTaskQA.bind(this));
|
|
@@ -295,19 +296,31 @@ class PublisherModule {
|
|
|
295
296
|
|
|
296
297
|
// Background Task Processing
|
|
297
298
|
startTaskProcessor() {
|
|
298
|
-
const pollInterval = this.config.pollInterval || 5000;
|
|
299
|
+
const pollInterval = this.config.pollInterval || 5000;
|
|
299
300
|
|
|
300
301
|
this.logger.info('Starting task processor with ' + pollInterval + 'ms poll interval');
|
|
302
|
+
this.isProcessingStarted = null;
|
|
301
303
|
|
|
302
304
|
this.taskProcessor = setInterval(async () => {
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
+
if (this.shutdownRequested) return;
|
|
306
|
+
|
|
307
|
+
if (this.isProcessing) {
|
|
308
|
+
const stuckMs = this.isProcessingStarted ? Date.now() - this.isProcessingStarted : 0;
|
|
309
|
+
if (stuckMs > 60 * 60 * 1000) {
|
|
310
|
+
this.logger.warn('Task processor appears stuck (' + Math.round(stuckMs / 60000) + ' min) — resetting');
|
|
311
|
+
this.isProcessing = false;
|
|
312
|
+
} else {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
305
315
|
}
|
|
316
|
+
|
|
317
|
+
await this.processNextTask();
|
|
306
318
|
}, pollInterval);
|
|
307
319
|
}
|
|
308
320
|
|
|
309
321
|
async processNextTask() {
|
|
310
322
|
this.isProcessing = true;
|
|
323
|
+
this.isProcessingStarted = Date.now();
|
|
311
324
|
|
|
312
325
|
try {
|
|
313
326
|
// Look for queued tasks first (draft builds)
|
|
@@ -489,16 +502,9 @@ class PublisherModule {
|
|
|
489
502
|
}
|
|
490
503
|
|
|
491
504
|
async createTaskDirectory(taskDir) {
|
|
492
|
-
const rimraf = require('rimraf');
|
|
493
|
-
|
|
494
505
|
// Remove existing directory if it exists
|
|
495
506
|
if (fs.existsSync(taskDir)) {
|
|
496
|
-
await
|
|
497
|
-
rimraf(taskDir, (err) => {
|
|
498
|
-
if (err) reject(err);
|
|
499
|
-
else resolve();
|
|
500
|
-
});
|
|
501
|
-
});
|
|
507
|
+
await require('fs').promises.rm(taskDir, { recursive: true, force: true });
|
|
502
508
|
}
|
|
503
509
|
|
|
504
510
|
// Create fresh directory
|
|
@@ -1108,7 +1114,8 @@ class PublisherModule {
|
|
|
1108
1114
|
content += '<a href="/publisher/tasks/' + task.id + '/history" class="btn btn-sm btn-outline-secondary me-1">History</a>';
|
|
1109
1115
|
|
|
1110
1116
|
if (task.status === 'waiting for approval') {
|
|
1111
|
-
content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View
|
|
1117
|
+
content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Log</a>';
|
|
1118
|
+
content += '<a href="/publisher/tasks/' + task.id + '/qa-files/index.html" class="btn btn-sm btn-outline-secondary me-1">View IG</a>';
|
|
1112
1119
|
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-sm btn-outline-secondary me-1">View QA</a>';
|
|
1113
1120
|
if (canApprove) {
|
|
1114
1121
|
content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">';
|
|
@@ -1116,9 +1123,12 @@ class PublisherModule {
|
|
|
1116
1123
|
content += '</form>';
|
|
1117
1124
|
}
|
|
1118
1125
|
} else {
|
|
1119
|
-
if (task.build_output_path) {
|
|
1126
|
+
if (task.build_output_path || task.local_folder) {
|
|
1120
1127
|
content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Output</a>';
|
|
1121
1128
|
}
|
|
1129
|
+
if (task.announcement) {
|
|
1130
|
+
content += '<a href="/publisher/tasks/' + task.id + '/history" class="btn btn-sm btn-outline-success me-1">📢 Announcement</a>';
|
|
1131
|
+
}
|
|
1122
1132
|
if (task.failure_reason) {
|
|
1123
1133
|
content += '<span class="text-danger small me-1">' + escape(task.failure_reason) + '</span>';
|
|
1124
1134
|
}
|
|
@@ -1130,6 +1140,12 @@ class PublisherModule {
|
|
|
1130
1140
|
content += '</form>';
|
|
1131
1141
|
}
|
|
1132
1142
|
|
|
1143
|
+
if (req.session.userId && task.status === 'failed') {
|
|
1144
|
+
content += ' <form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;">';
|
|
1145
|
+
content += '<button type="submit" class="btn btn-sm btn-warning">Retry</button>';
|
|
1146
|
+
content += '</form>';
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1133
1149
|
content += '</td>';
|
|
1134
1150
|
content += '</tr>';
|
|
1135
1151
|
}
|
|
@@ -1272,15 +1288,11 @@ class PublisherModule {
|
|
|
1272
1288
|
|
|
1273
1289
|
// Remove build output directory
|
|
1274
1290
|
if (task.local_folder && fs.existsSync(task.local_folder)) {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
}
|
|
1281
|
-
resolve(); // Continue even if directory removal fails
|
|
1282
|
-
});
|
|
1283
|
-
});
|
|
1291
|
+
try {
|
|
1292
|
+
await require('fs').promises.rm(task.local_folder, { recursive: true, force: true });
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
this.logger.warn('Failed to remove task directory ' + task.local_folder + ': ' + err.message);
|
|
1295
|
+
}
|
|
1284
1296
|
}
|
|
1285
1297
|
|
|
1286
1298
|
// Delete task logs
|
|
@@ -1304,13 +1316,44 @@ class PublisherModule {
|
|
|
1304
1316
|
res.redirect('/publisher/tasks');
|
|
1305
1317
|
} catch (error) {
|
|
1306
1318
|
this.logger.error('Error deleting task:', error);
|
|
1307
|
-
res.status(500).send('Failed to delete task');
|
|
1319
|
+
res.status(500).send('Failed to delete task: ' + error.message);
|
|
1308
1320
|
}
|
|
1309
1321
|
} finally {
|
|
1310
1322
|
this.stats.countRequest('delete-task', Date.now() - start);
|
|
1311
1323
|
}
|
|
1312
1324
|
}
|
|
1313
1325
|
|
|
1326
|
+
async retryTask(req, res) {
|
|
1327
|
+
const start = Date.now();
|
|
1328
|
+
try {
|
|
1329
|
+
try {
|
|
1330
|
+
const taskId = req.params.id;
|
|
1331
|
+
const task = await this.getTask(taskId);
|
|
1332
|
+
if (!task) return res.status(404).send('Task not found');
|
|
1333
|
+
if (task.status !== 'failed') return res.status(400).send('Only failed tasks can be retried');
|
|
1334
|
+
const canQueue = await this.userCanQueue(req.session.userId, task.website_id);
|
|
1335
|
+
if (!canQueue) return res.status(403).send('You do not have permission to queue tasks for this website');
|
|
1336
|
+
const existingTask = await this.findActiveTask(task.npm_package_id, task.version);
|
|
1337
|
+
if (existingTask) return res.status(400).send('An active task for this package and version is already in progress.');
|
|
1338
|
+
const newTaskId = await new Promise((resolve, reject) => {
|
|
1339
|
+
this.db.run(
|
|
1340
|
+
'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
1341
|
+
[req.session.userId, task.website_id, task.github_org, task.github_repo, task.git_branch, task.npm_package_id, task.version],
|
|
1342
|
+
function (err) { if (err) reject(err); else resolve(this.lastID); }
|
|
1343
|
+
);
|
|
1344
|
+
});
|
|
1345
|
+
this.logUserAction(req.session.userId, 'retry_task', newTaskId.toString(), req.ip);
|
|
1346
|
+
this.logger.info('Task retried: new ID=' + newTaskId + ' from task #' + taskId);
|
|
1347
|
+
res.redirect('/publisher/tasks/' + newTaskId + '/history');
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
this.logger.error('Error retrying task:', error);
|
|
1350
|
+
res.status(500).send('Failed to retry task: ' + error.message);
|
|
1351
|
+
}
|
|
1352
|
+
} finally {
|
|
1353
|
+
this.stats.countRequest('retry-task', Date.now() - start);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1314
1357
|
async getTaskOutput(req, res) {
|
|
1315
1358
|
const start = Date.now();
|
|
1316
1359
|
try {
|
|
@@ -1336,6 +1379,19 @@ class PublisherModule {
|
|
|
1336
1379
|
}
|
|
1337
1380
|
}
|
|
1338
1381
|
|
|
1382
|
+
// Get publication log if available
|
|
1383
|
+
let publishLog = '';
|
|
1384
|
+
if (task.local_folder) {
|
|
1385
|
+
const publishLogPath = path.join(task.local_folder, 'publication.log');
|
|
1386
|
+
if (fs.existsSync(publishLogPath)) {
|
|
1387
|
+
try {
|
|
1388
|
+
publishLog = fs.readFileSync(publishLogPath, 'utf8');
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
publishLog = 'Error reading publication log: ' + error.message;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1339
1395
|
if (req.headers.accept && req.headers.accept.includes('text/html')) {
|
|
1340
1396
|
const htmlServer = require('../library/html-server');
|
|
1341
1397
|
let content = '<h3>Task Output: #' + task.id + ' - ' + task.npm_package_id + '#' + task.version + '</h3>';
|
|
@@ -1364,6 +1420,13 @@ class PublisherModule {
|
|
|
1364
1420
|
content += '</div>';
|
|
1365
1421
|
}
|
|
1366
1422
|
|
|
1423
|
+
// Announcement section
|
|
1424
|
+
if (task.announcement) {
|
|
1425
|
+
content += '<h4>Announcement</h4>';
|
|
1426
|
+
content += '<button onclick="navigator.clipboard.writeText(document.getElementById(\'announcement-text\').innerText)" class="btn btn-sm btn-outline-secondary mb-2">Copy to clipboard</button>';
|
|
1427
|
+
content += '<div id="announcement-text" class="output-viewer" style="white-space: pre-wrap;">' + escape(task.announcement) + '</div>';
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1367
1430
|
// Build log section
|
|
1368
1431
|
if (buildLog) {
|
|
1369
1432
|
content += '<h4>Build Log</h4>';
|
|
@@ -1373,6 +1436,15 @@ class PublisherModule {
|
|
|
1373
1436
|
content += '<p><em>Build in progress... Log will appear when available.</em></p>';
|
|
1374
1437
|
}
|
|
1375
1438
|
|
|
1439
|
+
// Publication log section
|
|
1440
|
+
if (publishLog) {
|
|
1441
|
+
content += '<h4>Publication Log</h4>';
|
|
1442
|
+
content += '<div class="output-viewer">' + escape(publishLog) + '</div>';
|
|
1443
|
+
} else if (task.status === 'publishing') {
|
|
1444
|
+
content += '<h4>Publication Log</h4>';
|
|
1445
|
+
content += '<p><em>Publication in progress... Log will appear when available.</em></p>';
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1376
1448
|
content += '<div class="mt-3"><a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a></div>';
|
|
1377
1449
|
|
|
1378
1450
|
const html = htmlServer.renderPage('publisher', 'Task Output - FHIR Publisher', content, {
|
|
@@ -1410,6 +1482,11 @@ class PublisherModule {
|
|
|
1410
1482
|
output += buildLog;
|
|
1411
1483
|
}
|
|
1412
1484
|
|
|
1485
|
+
if (publishLog) {
|
|
1486
|
+
output += '\n--- Publication Log ---\n';
|
|
1487
|
+
output += publishLog;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1413
1490
|
res.setHeader('Content-Type', 'text/plain');
|
|
1414
1491
|
res.send(output);
|
|
1415
1492
|
}
|
|
@@ -1478,8 +1555,11 @@ class PublisherModule {
|
|
|
1478
1555
|
// Announcement section (for completed publications)
|
|
1479
1556
|
if (task.announcement) {
|
|
1480
1557
|
content += '<div class="card mb-4"><div class="card-body">';
|
|
1481
|
-
content += '<
|
|
1482
|
-
content += '<
|
|
1558
|
+
content += '<div class="d-flex justify-content-between align-items-center mb-2">';
|
|
1559
|
+
content += '<h5 class="mb-0">Announcement</h5>';
|
|
1560
|
+
content += '<button onclick="navigator.clipboard.writeText(document.getElementById(\'hist-announcement\').innerText)" class="btn btn-sm btn-outline-secondary">Copy to clipboard</button>';
|
|
1561
|
+
content += '</div>';
|
|
1562
|
+
content += '<pre id="hist-announcement" class="mb-0" style="white-space: pre-wrap;">' + escape(task.announcement) + '</pre>';
|
|
1483
1563
|
content += '</div></div>';
|
|
1484
1564
|
}
|
|
1485
1565
|
|
|
@@ -1578,12 +1658,22 @@ class PublisherModule {
|
|
|
1578
1658
|
|
|
1579
1659
|
// Links at the bottom
|
|
1580
1660
|
content += '<div class="mt-3">';
|
|
1581
|
-
if (task.build_output_path) {
|
|
1582
|
-
content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-outline-info me-2">View
|
|
1661
|
+
if (task.build_output_path || task.local_folder) {
|
|
1662
|
+
content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-outline-info me-2">View Output</a>';
|
|
1583
1663
|
}
|
|
1584
1664
|
if (task.status === 'waiting for approval') {
|
|
1585
1665
|
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-outline-secondary me-2">View QA Report</a>';
|
|
1586
1666
|
}
|
|
1667
|
+
if (req.session.userId && task.status === 'failed') {
|
|
1668
|
+
content += '<form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;" class="me-2">';
|
|
1669
|
+
content += '<button type="submit" class="btn btn-warning">Retry</button>';
|
|
1670
|
+
content += '</form>';
|
|
1671
|
+
}
|
|
1672
|
+
if (req.session.isAdmin && task.status === 'failed') {
|
|
1673
|
+
content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" class="me-2" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">';
|
|
1674
|
+
content += '<button type="submit" class="btn btn-danger">Delete</button>';
|
|
1675
|
+
content += '</form>';
|
|
1676
|
+
}
|
|
1587
1677
|
content += '<a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a>';
|
|
1588
1678
|
content += '</div>';
|
|
1589
1679
|
|