fhirsmith 0.7.4 → 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 +40 -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 +124 -33
- 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/translations/Messages.properties +2 -0
- package/tx/README.md +6 -6
- package/tx/cs/cs-api.js +3 -0
- package/tx/cs/cs-api.md +285 -0
- package/tx/cs/cs-country.js +804 -801
- package/tx/importers/readme.md +3 -1
- package/tx/library.js +33 -6
- package/tx/provider.js +2 -1
- package/tx/tx-html.js +36 -9
- package/tx/tx.fhir.org.yml +3 -0
- package/tx/tx.js +34 -11
- package/tx/vs/vs-database.js +42 -5
- package/tx/vs/vs-vsac.js +48 -0
- package/tx/workers/validate.js +11 -6
- package/tx/workers/worker.js +2 -5
- package/utilities/dashboard.html +274 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ 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
|
+
|
|
32
|
+
## [v0.7.5] - 2026-03-19
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Support ignoring code systems when loading, and ban urn:iso:std:iso:3166#20210120 for tx.fhir.org
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- Fix handling of user defined codes for country codes
|
|
41
|
+
- Fix version bug when loading supplements
|
|
42
|
+
- FHIRsmith passed all 1460 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1-SNAPSHOT, runner v6.9.0)
|
|
43
|
+
|
|
44
|
+
### Tx Conformance Statement
|
|
45
|
+
|
|
46
|
+
FHIRsmith passed all 1452 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1-SNAPSHOT, runner v6.9.0)
|
|
47
|
+
|
|
8
48
|
## [v0.7.4] - 2026-03-19
|
|
9
49
|
|
|
10
50
|
### 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)
|
|
@@ -466,6 +479,13 @@ class PublisherModule {
|
|
|
466
479
|
// Step 1: Create/scrub task directory
|
|
467
480
|
await this.createTaskDirectory(taskDir);
|
|
468
481
|
|
|
482
|
+
// Record the log file path and local folder immediately so they're accessible
|
|
483
|
+
// even if the build fails later
|
|
484
|
+
await this.updateTaskStatus(task.id, task.status, {
|
|
485
|
+
build_output_path: logFile,
|
|
486
|
+
local_folder: taskDir
|
|
487
|
+
});
|
|
488
|
+
|
|
469
489
|
// Step 2: Download latest publisher
|
|
470
490
|
const publisherJar = await this.downloadPublisher(taskDir, task.id);
|
|
471
491
|
|
|
@@ -478,26 +498,13 @@ class PublisherModule {
|
|
|
478
498
|
// Step 5: Verify package-id and version match the task
|
|
479
499
|
await this.verifyBuildOutput(task, draftDir);
|
|
480
500
|
|
|
481
|
-
// Update task with build output path
|
|
482
|
-
await this.updateTaskStatus(task.id, task.status, {
|
|
483
|
-
build_output_path: logFile,
|
|
484
|
-
local_folder: taskDir
|
|
485
|
-
});
|
|
486
|
-
|
|
487
501
|
this.logger.info('Draft build completed for ' + task.npm_package_id + '#' + task.version);
|
|
488
502
|
}
|
|
489
503
|
|
|
490
504
|
async createTaskDirectory(taskDir) {
|
|
491
|
-
const rimraf = require('rimraf');
|
|
492
|
-
|
|
493
505
|
// Remove existing directory if it exists
|
|
494
506
|
if (fs.existsSync(taskDir)) {
|
|
495
|
-
await
|
|
496
|
-
rimraf(taskDir, (err) => {
|
|
497
|
-
if (err) reject(err);
|
|
498
|
-
else resolve();
|
|
499
|
-
});
|
|
500
|
-
});
|
|
507
|
+
await require('fs').promises.rm(taskDir, { recursive: true, force: true });
|
|
501
508
|
}
|
|
502
509
|
|
|
503
510
|
// Create fresh directory
|
|
@@ -1107,7 +1114,8 @@ class PublisherModule {
|
|
|
1107
1114
|
content += '<a href="/publisher/tasks/' + task.id + '/history" class="btn btn-sm btn-outline-secondary me-1">History</a>';
|
|
1108
1115
|
|
|
1109
1116
|
if (task.status === 'waiting for approval') {
|
|
1110
|
-
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>';
|
|
1111
1119
|
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-sm btn-outline-secondary me-1">View QA</a>';
|
|
1112
1120
|
if (canApprove) {
|
|
1113
1121
|
content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">';
|
|
@@ -1115,9 +1123,12 @@ class PublisherModule {
|
|
|
1115
1123
|
content += '</form>';
|
|
1116
1124
|
}
|
|
1117
1125
|
} else {
|
|
1118
|
-
if (task.build_output_path) {
|
|
1126
|
+
if (task.build_output_path || task.local_folder) {
|
|
1119
1127
|
content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Output</a>';
|
|
1120
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
|
+
}
|
|
1121
1132
|
if (task.failure_reason) {
|
|
1122
1133
|
content += '<span class="text-danger small me-1">' + escape(task.failure_reason) + '</span>';
|
|
1123
1134
|
}
|
|
@@ -1129,6 +1140,12 @@ class PublisherModule {
|
|
|
1129
1140
|
content += '</form>';
|
|
1130
1141
|
}
|
|
1131
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
|
+
|
|
1132
1149
|
content += '</td>';
|
|
1133
1150
|
content += '</tr>';
|
|
1134
1151
|
}
|
|
@@ -1271,15 +1288,11 @@ class PublisherModule {
|
|
|
1271
1288
|
|
|
1272
1289
|
// Remove build output directory
|
|
1273
1290
|
if (task.local_folder && fs.existsSync(task.local_folder)) {
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
}
|
|
1280
|
-
resolve(); // Continue even if directory removal fails
|
|
1281
|
-
});
|
|
1282
|
-
});
|
|
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
|
+
}
|
|
1283
1296
|
}
|
|
1284
1297
|
|
|
1285
1298
|
// Delete task logs
|
|
@@ -1303,13 +1316,44 @@ class PublisherModule {
|
|
|
1303
1316
|
res.redirect('/publisher/tasks');
|
|
1304
1317
|
} catch (error) {
|
|
1305
1318
|
this.logger.error('Error deleting task:', error);
|
|
1306
|
-
res.status(500).send('Failed to delete task');
|
|
1319
|
+
res.status(500).send('Failed to delete task: ' + error.message);
|
|
1307
1320
|
}
|
|
1308
1321
|
} finally {
|
|
1309
1322
|
this.stats.countRequest('delete-task', Date.now() - start);
|
|
1310
1323
|
}
|
|
1311
1324
|
}
|
|
1312
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
|
+
|
|
1313
1357
|
async getTaskOutput(req, res) {
|
|
1314
1358
|
const start = Date.now();
|
|
1315
1359
|
try {
|
|
@@ -1335,6 +1379,19 @@ class PublisherModule {
|
|
|
1335
1379
|
}
|
|
1336
1380
|
}
|
|
1337
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
|
+
|
|
1338
1395
|
if (req.headers.accept && req.headers.accept.includes('text/html')) {
|
|
1339
1396
|
const htmlServer = require('../library/html-server');
|
|
1340
1397
|
let content = '<h3>Task Output: #' + task.id + ' - ' + task.npm_package_id + '#' + task.version + '</h3>';
|
|
@@ -1363,6 +1420,13 @@ class PublisherModule {
|
|
|
1363
1420
|
content += '</div>';
|
|
1364
1421
|
}
|
|
1365
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
|
+
|
|
1366
1430
|
// Build log section
|
|
1367
1431
|
if (buildLog) {
|
|
1368
1432
|
content += '<h4>Build Log</h4>';
|
|
@@ -1372,6 +1436,15 @@ class PublisherModule {
|
|
|
1372
1436
|
content += '<p><em>Build in progress... Log will appear when available.</em></p>';
|
|
1373
1437
|
}
|
|
1374
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
|
+
|
|
1375
1448
|
content += '<div class="mt-3"><a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a></div>';
|
|
1376
1449
|
|
|
1377
1450
|
const html = htmlServer.renderPage('publisher', 'Task Output - FHIR Publisher', content, {
|
|
@@ -1409,6 +1482,11 @@ class PublisherModule {
|
|
|
1409
1482
|
output += buildLog;
|
|
1410
1483
|
}
|
|
1411
1484
|
|
|
1485
|
+
if (publishLog) {
|
|
1486
|
+
output += '\n--- Publication Log ---\n';
|
|
1487
|
+
output += publishLog;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1412
1490
|
res.setHeader('Content-Type', 'text/plain');
|
|
1413
1491
|
res.send(output);
|
|
1414
1492
|
}
|
|
@@ -1477,8 +1555,11 @@ class PublisherModule {
|
|
|
1477
1555
|
// Announcement section (for completed publications)
|
|
1478
1556
|
if (task.announcement) {
|
|
1479
1557
|
content += '<div class="card mb-4"><div class="card-body">';
|
|
1480
|
-
content += '<
|
|
1481
|
-
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>';
|
|
1482
1563
|
content += '</div></div>';
|
|
1483
1564
|
}
|
|
1484
1565
|
|
|
@@ -1577,12 +1658,22 @@ class PublisherModule {
|
|
|
1577
1658
|
|
|
1578
1659
|
// Links at the bottom
|
|
1579
1660
|
content += '<div class="mt-3">';
|
|
1580
|
-
if (task.build_output_path) {
|
|
1581
|
-
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>';
|
|
1582
1663
|
}
|
|
1583
1664
|
if (task.status === 'waiting for approval') {
|
|
1584
1665
|
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-outline-secondary me-2">View QA Report</a>';
|
|
1585
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
|
+
}
|
|
1586
1677
|
content += '<a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a>';
|
|
1587
1678
|
content += '</div>';
|
|
1588
1679
|
|