fhirsmith 0.14.0 → 0.14.2

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 (73) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +3 -2
  3. package/config-template.json +20 -0
  4. package/library/fhir-openapi-schema.js +441 -0
  5. package/library/html-server.js +9 -3
  6. package/library/languages.js +15 -0
  7. package/library/npm-audit.js +265 -0
  8. package/library/openapi-doc.js +557 -0
  9. package/library/same-origin.js +62 -0
  10. package/library/utilities.js +21 -0
  11. package/nginx.md +17 -9
  12. package/package.json +10 -4
  13. package/packages/openapi.js +13 -0
  14. package/packages/openapi.yaml +603 -0
  15. package/packages/packages-template.html +4 -2
  16. package/packages/packages.js +286 -81
  17. package/packages/readme.md +120 -0
  18. package/publisher/publisher-template.html +1 -0
  19. package/publisher/publisher.js +95 -42
  20. package/registry/api.js +117 -41
  21. package/registry/crawler.js +24 -0
  22. package/registry/fhirsmith-releases.js +170 -0
  23. package/registry/model.js +10 -0
  24. package/registry/openapi.js +13 -0
  25. package/registry/openapi.yaml +311 -0
  26. package/registry/readme.md +107 -208
  27. package/registry/registry-template.html +4 -1
  28. package/registry/registry.js +183 -20
  29. package/server.js +25 -6
  30. package/testing/openapi-schemas.config.js +58 -0
  31. package/testing/openapi-schemas.json +1920 -0
  32. package/testing/openapi.js +15 -0
  33. package/testing/openapi.yaml +273 -0
  34. package/testing/readme.md +75 -1
  35. package/testing/render.js +270 -29
  36. package/testing/search.js +1 -1
  37. package/testing/store.js +152 -8
  38. package/testing/testing-template.html +2 -0
  39. package/testing/testing.js +307 -9
  40. package/translations/Messages.properties +1 -0
  41. package/tx/README.md +58 -0
  42. package/tx/cm/cm-database.js +10 -1
  43. package/tx/cm/cm-package.js +8 -1
  44. package/tx/cs/cs-rxnorm.js +23 -6
  45. package/tx/cs/cs-snomed.js +3 -72
  46. package/tx/html/tx-template.html +2 -0
  47. package/tx/library/canonical-resource.js +50 -1
  48. package/tx/library/codesystem.js +1 -0
  49. package/tx/library/conceptmap.js +1 -0
  50. package/tx/library/valueset.js +16 -0
  51. package/tx/library.js +17 -2
  52. package/tx/openapi-operations.js +363 -0
  53. package/tx/openapi-schemas.config.js +118 -0
  54. package/tx/openapi-schemas.json +7903 -0
  55. package/tx/openapi.js +289 -0
  56. package/tx/openapi.yaml +151 -0
  57. package/tx/operation-context.js +15 -0
  58. package/tx/params.js +3 -12
  59. package/tx/provider.js +10 -1
  60. package/tx/sct/editions.js +70 -0
  61. package/tx/tx-html.js +6 -0
  62. package/tx/tx.js +55 -0
  63. package/tx/vs/vs-database.js +14 -5
  64. package/tx/vs/vs-package.js +10 -0
  65. package/tx/workers/expand.js +37 -18
  66. package/tx/workers/metadata.js +0 -13
  67. package/tx/workers/translate.js +25 -1
  68. package/tx/workers/validate.js +74 -24
  69. package/tx/workers/worker.js +32 -43
  70. package/utilities/extract-schema-json.js +167 -0
  71. package/utilities/generate-openapi-schemas.js +49 -0
  72. package/utilities/submit-test-report.js +1 -1
  73. package/xig/xig.js +2 -4
package/testing/render.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  const escape = require('escape-html');
20
+ const { runLengthOf } = require('./store');
20
21
 
21
22
  const RESULT_COLOURS = {
22
23
  pass: '#1e7e34',
@@ -44,6 +45,9 @@ const STYLE = `
44
45
  .tr-filter .tr-lbl { color: #555; }
45
46
  .tr-filter .tr-btn { font-size: 100%; padding: 1px 10px; line-height: 1.5; }
46
47
  .tr-date { white-space: nowrap; }
48
+ /* operations and assertions; nested inside the striped tests table, so reset its colours */
49
+ table.tr-steps { margin-top: 6px; margin-bottom: 0; }
50
+ table.tr-steps > tbody > tr > td, table.tr-steps > tbody > tr > th { background-color: #fff !important; color: #333 !important; padding: 2px 6px; }
47
51
  .tr-grid td { text-align: center; }
48
52
  .tr-grid th.tr-col { font-size: 80%; word-break: break-all; min-width: 90px; }
49
53
  </style>`;
@@ -102,8 +106,81 @@ function qs(params) {
102
106
  return s ? '?' + s : '';
103
107
  }
104
108
 
105
- function testScriptLabel(ts) {
106
- return ts ? uriLink(ts) : '<i>(no test script)</i>';
109
+ /**
110
+ * A canonical URL (or participant uri), as its configured name where there is one: the
111
+ * name links to the configured link, the full URL is in the title, and any |version is
112
+ * shown after the name. Otherwise just the URL.
113
+ *
114
+ * @param {string} url
115
+ * @param {Map<string, {name: string, link: string|null}>} [names] - by unversioned canonical
116
+ * @param {string} [version] - a version to show that is not part of the url (participant.version)
117
+ */
118
+ function canonicalHtml(url, names, version) {
119
+ const s = text(url);
120
+ if (!s) {
121
+ return '';
122
+ }
123
+ const bar = s.indexOf('|');
124
+ const bare = bar < 0 ? s : s.substring(0, bar);
125
+ const ver = version || (bar < 0 ? '' : s.substring(bar + 1));
126
+ const known = names && names.get(bare);
127
+ let h;
128
+ if (known) {
129
+ h = known.link && /^https?:\/\/[^\s"'<>]+$/i.test(known.link)
130
+ ? `<a href="${escape(known.link)}" title="${escape(s)}" rel="nofollow noopener">${escape(known.name)}</a>`
131
+ : `<span title="${escape(s)}">${escape(known.name)}</span>`;
132
+ return h + (ver ? ` <small>${escape(ver)}</small>` : '');
133
+ }
134
+ return uriLink(s) + (version ? ` <small>${escape(version)}</small>` : '');
135
+ }
136
+
137
+ function testScriptLabel(ts, names) {
138
+ return ts ? canonicalHtml(ts, names) : '<i>(no test script)</i>';
139
+ }
140
+
141
+ /** "850 ms", "12.3 s", "4m 05s", "1h 02m" */
142
+ function runLength(ms) {
143
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
144
+ return '';
145
+ }
146
+ if (ms < 1000) {
147
+ return `${Math.round(ms)} ms`;
148
+ }
149
+ if (ms < 60000) {
150
+ return `${(ms / 1000).toFixed(1)} s`;
151
+ }
152
+ const secs = Math.round(ms / 1000);
153
+ if (secs < 3600) {
154
+ return `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s`;
155
+ }
156
+ const mins = Math.round(secs / 60);
157
+ return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`;
158
+ }
159
+
160
+ /** The hidden field every POST form carries: the CSRF token (lusca checks it). */
161
+ function csrfField(token) {
162
+ return token ? `<input type="hidden" name="_csrf" value="${escape(token)}"/>` : '';
163
+ }
164
+
165
+ /**
166
+ * The line at the top of every page: who is logged in, and what they can get to.
167
+ * @param {{base: string, user?: Object, csrf?: string}} ctx
168
+ */
169
+ function userBar(ctx) {
170
+ const base = escape(ctx.base);
171
+ const u = ctx.user;
172
+ if (!u) {
173
+ return `<div style="float: right; font-size: 85%"><a href="${base}/login">Login</a></div>`;
174
+ }
175
+ const parts = [`Logged in as <b>${escape(u.name)}</b>`];
176
+ if (u.canEditLinks) {
177
+ parts.push(`<a href="${base}/admin/links">Named links</a>`);
178
+ }
179
+ if (u.isAdmin) {
180
+ parts.push(`<a href="${base}/admin/users">Users</a>`);
181
+ }
182
+ parts.push(`<form method="post" action="${base}/logout" style="display: inline">${csrfField(ctx.csrf)}<button type="submit" class="btn btn-link" style="padding: 0; font-size: 100%; vertical-align: baseline">Logout</button></form>`);
183
+ return `<div style="float: right; font-size: 85%">${parts.join(' &nbsp;|&nbsp; ')}</div>`;
107
184
  }
108
185
 
109
186
  // ---- the list ------------------------------------------------------------------------
@@ -171,21 +248,26 @@ function dateOnly(v) {
171
248
  return /^\d{4}(-\d{2}(-\d{2})?)?/.test(s) ? s.match(/^\d{4}(-\d{2}(-\d{2})?)?/)[0] : s;
172
249
  }
173
250
 
251
+ // [sort parameter, heading]; null parameter = not sortable. Participants are split by type
174
252
  const LIST_COLUMNS = [
175
- ['name', 'Name'], ['status', 'Status'], ['result', 'Result'], ['score', 'Score'],
176
- ['tester', 'Tester'], ['testscript', 'Test Script'], [null, 'Participants'],
177
- ['issued', 'Issued'], ['_lastUpdated', 'Received']
253
+ ['name', 'Name'], ['result', 'Result'], ['score', 'Score'], [null, 'Tests'],
254
+ ['tester', 'Tester'], ['testscript', 'Test Script'],
255
+ [null, 'Test Engine', 'test-engine'], [null, 'Client', 'client'], [null, 'Server', 'server'],
256
+ ['issued', 'Issued'], [null, 'Run Length'], ['_lastUpdated', 'Received']
178
257
  ];
179
258
 
180
259
  /**
181
260
  * @param {Object} query - the page's own query (form fields included)
182
261
  * @param {{total: number, rows: Object[]}} results
183
262
  * @param {Object} search - the parsed search (for count/offset/sort)
184
- * @param {{statuses: string[], results: string[], base: string}} options - base is the module's path, e.g. /testing
263
+ * @param {{results: string[], base: string, names?: Map, user?: Object, showClient?: boolean}} options -
264
+ * base is the module's path, e.g. /testing; names the named links; showClient whether any
265
+ * report has a client participant
185
266
  */
186
267
  function renderList(query, results, search, options) {
187
268
  const base = escape(options.base);
188
- let h = STYLE;
269
+ const names = options.names;
270
+ let h = STYLE + userBar(options);
189
271
  h += `<p><a href="${base}/summary">Summary grid</a> &nbsp;|&nbsp; <a href="${base}/metadata">CapabilityStatement</a>` +
190
272
  ' &nbsp;|&nbsp; FHIR API: <code>POST TestReport</code>, <code>GET TestReport?...</code></p>';
191
273
 
@@ -195,7 +277,6 @@ function renderList(query, results, search, options) {
195
277
  h += input(query, 'tester:contains', 'Tester contains', 'text', 'Tester');
196
278
  h += input(query, 'testscript:below', 'Test script starts with', 'text', 'Test script');
197
279
  h += input(query, 'participant:below', 'Participant starts with', 'text', 'Participant');
198
- h += select(query, 'status', options.statuses, 'Status');
199
280
  h += select(query, 'result', options.results, 'Result');
200
281
  h += select(query, '_count', ['20', '50', '100', '200', '500'], 'Page size', 'Page: 50');
201
282
  h += '</div><div class="tr-line">';
@@ -217,7 +298,8 @@ function renderList(query, results, search, options) {
217
298
  // the table; column headers sort
218
299
  const curSort = field(query, '_sort');
219
300
  h += '<table class="table table-sm table-striped tr-table"><tr>';
220
- for (const [param, label] of LIST_COLUMNS) {
301
+ const columns = LIST_COLUMNS.filter(c => c[2] !== 'client' || options.showClient);
302
+ for (const [param, label] of columns) {
221
303
  if (!param) {
222
304
  h += `<th>${escape(label)}</th>`;
223
305
  continue;
@@ -237,13 +319,19 @@ function renderList(query, results, search, options) {
237
319
  for (const r of results.rows) {
238
320
  h += '<tr>';
239
321
  h += `<td><a href="${base}/TestReport/${encodeURIComponent(r.id)}">${escape(r.name)}</a></td>`;
240
- h += `<td>${escape(r.status)}</td>`;
241
322
  h += `<td>${badge(r.result)}</td>`;
242
323
  h += `<td>${score(r.score)}</td>`;
324
+ h += `<td>${typeof r.test_count === 'number' ? r.test_count : ''}</td>`;
243
325
  h += `<td>${escape(r.tester)}</td>`;
244
- h += `<td>${testScriptLabel(r.test_script)}</td>`;
245
- h += `<td>${r.participants.map(p => uriLink(p.uri) + (p.version ? ` <small>${escape(p.version)}</small>` : '')).join('<br/>')}</td>`;
326
+ h += `<td>${testScriptLabel(r.test_script, names)}</td>`;
327
+ for (const [, , type] of columns.filter(c => c[2])) {
328
+ // anything that is not a test engine or a client is taken to be the server
329
+ const ps = r.participants.filter(p => type === 'server'
330
+ ? p.type !== 'test-engine' && p.type !== 'client' : p.type === type);
331
+ h += `<td>${ps.map(p => canonicalHtml(p.uri, names, p.version)).join('<br/>')}</td>`;
332
+ }
246
333
  h += `<td class="tr-date" title="${escape(r.issued)}">${escape(dateOnly(r.issued))}</td>`;
334
+ h += `<td class="tr-date">${escape(runLength(r.run_ms))}</td>`;
247
335
  h += `<td class="tr-date" title="${escape(r.received)}">${escape(dateOnly(r.received))}</td>`;
248
336
  h += '</tr>';
249
337
  }
@@ -273,10 +361,11 @@ function renderList(query, results, search, options) {
273
361
  * @param {'participant'|'tester'} by
274
362
  * @param {string} baseUrl
275
363
  * @param {Array} testers - from store.testerCounts()
364
+ * @param {{names?: Map, user?: Object}} [ctx]
276
365
  */
277
- function renderSummary(cells, by, baseUrl, testers = []) {
366
+ function renderSummary(cells, by, baseUrl, testers = [], ctx = {}) {
278
367
  const base = escape(baseUrl);
279
- let h = STYLE;
368
+ let h = STYLE + userBar({ ...ctx, base: baseUrl });
280
369
  const other = by === 'tester' ? 'participant' : 'tester';
281
370
  h += `<p><a href="${base}">All reports</a> &nbsp;|&nbsp; Columns are ${by === 'tester' ? 'testers' : 'participants'} ` +
282
371
  `(<a href="${base}/summary?by=${other}">show by ${other}</a>${by === 'tester' ? '' : '; test engines are left out'}). Each cell is the latest report (by issued date) ` +
@@ -284,7 +373,7 @@ function renderSummary(cells, by, baseUrl, testers = []) {
284
373
  if (cells.length === 0) {
285
374
  return h + '<p>No reports have been received.</p>';
286
375
  }
287
- return h + summaryGrid(cells, by, base) + testerTable(testers, base);
376
+ return h + summaryGrid(cells, by, base, ctx.names) + testerTable(testers, base);
288
377
  }
289
378
 
290
379
  /** How many reports have come from each tester. */
@@ -302,18 +391,18 @@ function testerTable(testers, base) {
302
391
  return h + '</table>';
303
392
  }
304
393
 
305
- function summaryGrid(cells, by, base) {
394
+ function summaryGrid(cells, by, base, names) {
306
395
  let h = '';
307
396
  const cols = [...new Set(cells.map(c => c.col))].sort();
308
397
  const scripts = [...new Set(cells.map(c => c.test_script))];
309
398
  const at = new Map(cells.map(c => [c.test_script + '\u0000' + c.col, c]));
310
399
  h += '<div style="overflow-x: auto"><table class="table table-sm table-bordered tr-table tr-grid"><tr><th>Test Script</th>';
311
400
  for (const c of cols) {
312
- h += `<th class="tr-col">${by === 'tester' ? escape(c) : uriLink(c)}</th>`;
401
+ h += `<th class="tr-col">${by === 'tester' ? escape(c) : canonicalHtml(c, names)}</th>`;
313
402
  }
314
403
  h += '</tr>';
315
404
  for (const s of scripts) {
316
- h += `<tr><th style="text-align: left">${testScriptLabel(s)}</th>`;
405
+ h += `<tr><th style="text-align: left">${testScriptLabel(s, names)}</th>`;
317
406
  for (const c of cols) {
318
407
  const cell = at.get(s + '\u0000' + c);
319
408
  if (!cell) {
@@ -384,6 +473,51 @@ function testResult(t) {
384
473
  return best ? best.r : '';
385
474
  }
386
475
 
476
+ /**
477
+ * The operations and assertions in a list of actions, in order, each as
478
+ * {type: 'operation'|'assertion', message, detail, result}.
479
+ */
480
+ function actionSteps(actions) {
481
+ const steps = [];
482
+ for (const action of asArray(actions)) {
483
+ if (!isObject(action)) {
484
+ continue;
485
+ }
486
+ for (const [kind, type] of [['operation', 'operation'], ['assert', 'assertion']]) {
487
+ const a = action[kind];
488
+ if (isObject(a)) {
489
+ steps.push({ type, message: text(a.message), detail: text(a.detail), result: text(a.result) });
490
+ }
491
+ }
492
+ }
493
+ return steps;
494
+ }
495
+
496
+ /** A table of operations and assertions: type, message, details link, result. */
497
+ function actionsTable(actions) {
498
+ const steps = actionSteps(actions);
499
+ if (steps.length === 0) {
500
+ return '';
501
+ }
502
+ let h = '<table class="table table-sm table-bordered tr-table tr-steps"><tr><th>Type</th><th>Message</th><th>Details</th><th>Result</th></tr>';
503
+ for (const st of steps) {
504
+ const details = /^https?:\/\/[^\s"'<>]+$/i.test(st.detail)
505
+ ? `<a href="${escape(st.detail)}" rel="nofollow noopener">details</a>`
506
+ : (st.detail ? `<span title="${escape(st.detail)}">details</span>` : '');
507
+ h += `<tr><td>${st.type}</td><td class="tr-msg">${escape(st.message)}</td><td>${details}</td><td>${badge(st.result)}</td></tr>`;
508
+ }
509
+ return h + '</table>';
510
+ }
511
+
512
+ /**
513
+ * Whether a test's actions are worth a table: more than one operation or assertion, or a
514
+ * single one that says more than just its result.
515
+ */
516
+ function hasActionDetail(t) {
517
+ const steps = actionSteps(t.action);
518
+ return steps.length > 1 || (steps.length === 1 && !!(steps[0].message || steps[0].detail));
519
+ }
520
+
387
521
  function identifierText(ids) {
388
522
  return (Array.isArray(ids) ? ids : [ids]).filter(isObject).map(i =>
389
523
  (text(i.system) ? escape(text(i.system)) + ' | ' : '') + escape(text(i.value))).join('<br/>');
@@ -391,20 +525,28 @@ function identifierText(ids) {
391
525
 
392
526
  /**
393
527
  * @param {Object} report - the stored TestReport
528
+ * @param {string} baseUrl
529
+ * @param {{names?: Map, user?: Object, csrf?: string}} [ctx]
394
530
  */
395
- function renderReport(report, baseUrl) {
531
+ function renderReport(report, baseUrl, ctx = {}) {
396
532
  const base = escape(baseUrl);
397
- let h = STYLE;
533
+ const names = ctx.names;
534
+ let h = STYLE + userBar({ ...ctx, base: baseUrl });
398
535
  h += `<p><a href="${base}">All reports</a> &nbsp;|&nbsp; <a href="${base}/summary">Summary grid</a> &nbsp;|&nbsp; ` +
399
536
  `<a href="${base}/TestReport/${encodeURIComponent(report.id)}?_format=json">JSON</a> &nbsp;|&nbsp; <a href="#raw">Raw JSON below</a></p>`;
537
+ if (ctx.user && ctx.user.canDeleteReports) {
538
+ h += `<form method="post" action="${base}/TestReport/${encodeURIComponent(report.id)}/delete" ` +
539
+ 'onsubmit="return confirm(\'Delete this report?\')" style="margin-bottom: 10px">' + csrfField(ctx.csrf) +
540
+ '<button type="submit" class="btn btn-danger tr-btn" style="font-size: 85%; padding: 1px 10px">Delete this report</button></form>';
541
+ }
400
542
 
401
543
  // header
402
544
  const ts = report.testScript;
403
545
  let tsHtml;
404
546
  if (typeof ts === 'string') {
405
- tsHtml = uriLink(ts);
547
+ tsHtml = canonicalHtml(ts, names);
406
548
  } else if (isObject(ts)) {
407
- tsHtml = uriLink(ts.reference) + (text(ts.display) ? ' (' + escape(ts.display) + ')' : '');
549
+ tsHtml = canonicalHtml(ts.reference, names) + (text(ts.display) ? ' (' + escape(ts.display) + ')' : '');
408
550
  } else {
409
551
  tsHtml = '';
410
552
  }
@@ -416,6 +558,7 @@ function renderReport(report, baseUrl) {
416
558
  ['Tester', escape(text(report.tester))],
417
559
  ['Test Script', tsHtml],
418
560
  ['Issued', escape(text(report.issued))],
561
+ ['Run Length', escape(runLength(runLengthOf(report)))],
419
562
  ['Received', escape(text(report.meta && report.meta.lastUpdated))],
420
563
  ['Identifier', report.identifier ? identifierText(report.identifier) : '']
421
564
  ];
@@ -440,25 +583,34 @@ function renderReport(report, baseUrl) {
440
583
  h += '<h3>Participants</h3><table class="table table-sm tr-table" style="width: auto"><tr><th>Type</th><th>URI</th><th>Version</th><th>Display</th></tr>';
441
584
  for (const p of asArray(report.participant)) {
442
585
  if (isObject(p)) {
443
- h += `<tr><td>${escape(text(p.type))}</td><td>${uriLink(p.uri)}</td><td>${escape(text(p.version))}</td><td>${escape(text(p.display))}</td></tr>`;
586
+ h += `<tr><td>${escape(text(p.type))}</td><td>${canonicalHtml(p.uri, names)}</td><td>${escape(text(p.version))}</td><td>${escape(text(p.display))}</td></tr>`;
444
587
  }
445
588
  }
446
589
  h += '</table>';
447
590
 
448
- // tests: one row each. Actions and the log are left to the raw JSON for now
591
+ // setup, then the tests (one row each, with their operations and assertions under the
592
+ // description when there is more to them than a single result), then teardown. The log
593
+ // is left to the raw JSON
594
+ if (isObject(report.setup) && actionSteps(report.setup.action).length > 0) {
595
+ h += '<h3>Setup</h3>' + actionsTable(report.setup.action);
596
+ }
449
597
  if (tests.length > 0) {
450
- const hasDesc = tests.some(t => text(t.description));
598
+ const detailed = tests.map(t => hasActionDetail(t));
599
+ const hasDesc = tests.some((t, i) => text(t.description) || detailed[i]);
451
600
  const hasPeriod = tests.some(t => periodText(t.period));
452
601
  h += '<h3>Tests</h3><table class="table table-sm table-striped tr-table"><tr><th>Name</th>' +
453
602
  (hasDesc ? '<th>Description</th>' : '') + '<th>Result</th>' + (hasPeriod ? '<th>Period</th>' : '') + '</tr>';
454
- for (const t of tests) {
603
+ tests.forEach((t, i) => {
455
604
  h += `<tr><td>${escape(text(t.name))}</td>` +
456
- (hasDesc ? `<td class="tr-msg">${escape(text(t.description))}</td>` : '') +
605
+ (hasDesc ? `<td><div class="tr-msg">${escape(text(t.description))}</div>${detailed[i] ? actionsTable(t.action) : ''}</td>` : '') +
457
606
  `<td>${badge(testResult(t))}</td>` +
458
607
  (hasPeriod ? `<td>${periodText(t.period)}</td>` : '') + '</tr>';
459
- }
608
+ });
460
609
  h += '</table>';
461
610
  }
611
+ if (isObject(report.teardown) && actionSteps(report.teardown.action).length > 0) {
612
+ h += '<h3>Teardown</h3>' + actionsTable(report.teardown.action);
613
+ }
462
614
 
463
615
  // anything else
464
616
  const others = Object.keys(report).filter(k => !HANDLED.has(k));
@@ -477,6 +629,95 @@ function renderReport(report, baseUrl) {
477
629
  return h;
478
630
  }
479
631
 
632
+ // ---- login and administration --------------------------------------------------------
633
+
634
+ function renderLogin(baseUrl, error, csrf) {
635
+ const base = escape(baseUrl);
636
+ let h = STYLE;
637
+ if (error) {
638
+ h += `<div class="alert alert-danger">${escape(error)}</div>`;
639
+ }
640
+ h += `<form method="post" action="${base}/login" style="max-width: 360px">${csrfField(csrf)}` +
641
+ '<div class="mb-3"><label for="login" class="form-label">Username</label>' +
642
+ '<input type="text" class="form-control" id="login" name="login" required autocomplete="username"/></div>' +
643
+ '<div class="mb-3"><label for="password" class="form-label">Password</label>' +
644
+ '<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password"/></div>' +
645
+ '<button type="submit" class="btn btn-primary">Login</button></form>';
646
+ return h;
647
+ }
648
+
649
+ /**
650
+ * The named links: canonical URLs that are shown by name.
651
+ * @param {Array} links - from store.links()
652
+ * @param {{base: string, user: Object}} ctx
653
+ */
654
+ function renderLinks(links, ctx, message) {
655
+ const base = escape(ctx.base);
656
+ let h = STYLE + userBar(ctx);
657
+ h += `<p><a href="${base}">All reports</a> &nbsp;|&nbsp; <a href="${base}/summary">Summary grid</a></p>`;
658
+ h += '<p>Where a test script or participant URL (ignoring any <code>|version</code>) matches a canonical here, ' +
659
+ 'the name is shown instead, linking to the link.</p>';
660
+ if (message) {
661
+ h += `<div class="alert alert-warning">${escape(message)}</div>`;
662
+ }
663
+ h += '<table class="table table-sm tr-table"><tr><th>Canonical</th><th>Name</th><th>Link</th><th></th></tr>';
664
+ const row = (l) => {
665
+ const action = l ? `${base}/admin/links/${l.id}` : `${base}/admin/links`;
666
+ const form = l ? `link-${l.id}` : 'link-new';
667
+ return `<tr><td><input form="${form}" type="text" name="canonical" required class="form-control" style="font-size: 85%" value="${escape(l ? l.canonical : '')}" placeholder="http://..."/></td>` +
668
+ `<td><input form="${form}" type="text" name="name" required class="form-control" style="font-size: 85%" value="${escape(l ? l.name : '')}"/></td>` +
669
+ `<td><input form="${form}" type="url" name="link" class="form-control" style="font-size: 85%" value="${escape(l ? l.link || '' : '')}" placeholder="https://..."/></td>` +
670
+ `<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" style="display: inline">${csrfField(ctx.csrf)}` +
671
+ `<button type="submit" class="btn btn-primary tr-btn" style="font-size: 85%; padding: 1px 10px">${l ? 'Save' : 'Add'}</button></form>` +
672
+ (l ? ` <form method="post" action="${action}/delete" style="display: inline" onsubmit="return confirm('Delete this link?')">${csrfField(ctx.csrf)}` +
673
+ '<button type="submit" class="btn btn-default tr-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form>' : '') +
674
+ '</td></tr>';
675
+ };
676
+ for (const l of links) {
677
+ h += row(l);
678
+ }
679
+ h += row(null);
680
+ return h + '</table>';
681
+ }
682
+
683
+ /**
684
+ * User administration (the administrator only).
685
+ * @param {Array} users - from store.users()
686
+ * @param {{base: string, user: Object}} ctx
687
+ */
688
+ function renderUsers(users, ctx, message) {
689
+ const base = escape(ctx.base);
690
+ let h = STYLE + userBar(ctx);
691
+ h += `<p><a href="${base}">All reports</a> &nbsp;|&nbsp; <a href="${base}/admin/links">Named links</a></p>`;
692
+ h += '<p>The administrator (login <code>admin</code>, password in the configuration) can do everything. ' +
693
+ 'Other users get the rights ticked here. Leave the password blank to keep the current one.</p>';
694
+ if (message) {
695
+ h += `<div class="alert alert-warning">${escape(message)}</div>`;
696
+ }
697
+ h += '<table class="table table-sm tr-table"><tr><th>Login</th><th>Name</th><th>Password</th><th>Edit named links</th><th>Delete reports</th><th></th></tr>';
698
+ const cb = (form, name, on) => `<input form="${form}" type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}/>`;
699
+ for (const u of users) {
700
+ const form = `user-${u.id}`;
701
+ const action = `${base}/admin/users/${u.id}`;
702
+ h += `<tr><td>${escape(u.login)}</td>` +
703
+ `<td><input form="${form}" type="text" name="name" required class="form-control" style="font-size: 85%" value="${escape(u.name)}"/></td>` +
704
+ `<td><input form="${form}" type="password" name="password" class="form-control" style="font-size: 85%" autocomplete="new-password" placeholder="(unchanged)"/></td>` +
705
+ `<td>${cb(form, 'editLinks', u.can_edit_links)}</td><td>${cb(form, 'deleteReports', u.can_delete_reports)}</td>` +
706
+ `<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" style="display: inline">${csrfField(ctx.csrf)}` +
707
+ '<button type="submit" class="btn btn-primary tr-btn" style="font-size: 85%; padding: 1px 10px">Save</button></form> ' +
708
+ `<form method="post" action="${action}/delete" style="display: inline" onsubmit="return confirm('Delete this user?')">${csrfField(ctx.csrf)}` +
709
+ '<button type="submit" class="btn btn-default tr-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form></td></tr>';
710
+ }
711
+ h += `<tr><td><input form="user-new" type="text" name="login" required class="form-control" style="font-size: 85%" autocomplete="off"/></td>` +
712
+ '<td><input form="user-new" type="text" name="name" required class="form-control" style="font-size: 85%"/></td>' +
713
+ '<td><input form="user-new" type="password" name="password" required class="form-control" style="font-size: 85%" autocomplete="new-password"/></td>' +
714
+ `<td>${cb('user-new', 'editLinks', false)}</td><td>${cb('user-new', 'deleteReports', false)}</td>` +
715
+ `<td><form id="user-new" method="post" action="${base}/admin/users" style="display: inline">${csrfField(ctx.csrf)}` +
716
+ '<button type="submit" class="btn btn-primary tr-btn" style="font-size: 85%; padding: 1px 10px">Add</button></form></td></tr>';
717
+ return h + '</table>';
718
+ }
719
+
480
720
  module.exports = {
481
- renderList, renderSummary, renderReport, listQueryToSearch, uriLink, badge
721
+ renderList, renderSummary, renderReport, renderLogin, renderLinks, renderUsers,
722
+ listQueryToSearch, uriLink, badge, canonicalHtml, runLength
482
723
  };
package/testing/search.js CHANGED
@@ -374,5 +374,5 @@ function capabilitySearchParams() {
374
374
 
375
375
  module.exports = {
376
376
  parseSearch, dateRange, splitValues, capabilitySearchParams,
377
- PARAMS, DEFAULT_COUNT, MAX_COUNT
377
+ PARAMS, CONTROL, DEFAULT_COUNT, MAX_COUNT
378
378
  };