inferno_session_browser 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +201 -0
- data/NOTICE +6 -0
- data/README.md +180 -0
- data/RELEASING.md +76 -0
- data/examples/host/Gemfile +3 -0
- data/examples/host/config/database.yml +4 -0
- data/examples/host/config.ru +6 -0
- data/examples/host/lib/demo.rb +9 -0
- data/examples/host/prepare.rb +14 -0
- data/lib/inferno_session_browser/assets/app.css +5 -0
- data/lib/inferno_session_browser/assets/app.js +212 -0
- data/lib/inferno_session_browser/configuration.rb +25 -0
- data/lib/inferno_session_browser/middleware.rb +87 -0
- data/lib/inferno_session_browser/parameters.rb +79 -0
- data/lib/inferno_session_browser/query.rb +261 -0
- data/lib/inferno_session_browser/registry.rb +13 -0
- data/lib/inferno_session_browser/templates/index.html.erb +42 -0
- data/lib/inferno_session_browser/version.rb +4 -0
- data/lib/inferno_session_browser.rb +35 -0
- metadata +123 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
(() => {
|
|
3
|
+
const root = document.body.dataset.browserPath;
|
|
4
|
+
const form = document.querySelector('#filters');
|
|
5
|
+
const rowsElement = document.querySelector('#session-rows');
|
|
6
|
+
const status = document.querySelector('#status');
|
|
7
|
+
const error = document.querySelector('#error');
|
|
8
|
+
const params = new URLSearchParams(window.location.search);
|
|
9
|
+
const expanded = new Map();
|
|
10
|
+
const labels = { never_run: 'Never run', queued: 'Queued', running: 'Running', waiting: 'Waiting', cancelling: 'Cancelling', idle: 'Idle' };
|
|
11
|
+
let currentRows = [];
|
|
12
|
+
let busy = false;
|
|
13
|
+
let pending = false;
|
|
14
|
+
let controller;
|
|
15
|
+
let initialized = false;
|
|
16
|
+
let optionsLoaded = false;
|
|
17
|
+
|
|
18
|
+
function element(tag, text, className) {
|
|
19
|
+
const node = document.createElement(tag);
|
|
20
|
+
if (text !== undefined) node.textContent = text;
|
|
21
|
+
if (className) node.className = className;
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
function showError(message) { error.textContent = message; error.hidden = !message; }
|
|
25
|
+
function value(key, fallback) { return params.get(key) || fallback; }
|
|
26
|
+
function page() { return Number(value('page', '1')); }
|
|
27
|
+
function saveUrl() { history.replaceState(null, '', `${location.pathname}${params.size ? `?${params}` : ''}`); }
|
|
28
|
+
function localInput(iso) {
|
|
29
|
+
if (!iso) return '';
|
|
30
|
+
const date = new Date(iso);
|
|
31
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
32
|
+
const offset = date.getTimezoneOffset() * 60000;
|
|
33
|
+
return new Date(date.getTime() - offset).toISOString().slice(0, 19);
|
|
34
|
+
}
|
|
35
|
+
function restoreControls() {
|
|
36
|
+
for (const control of form.elements) {
|
|
37
|
+
if (!control.name) continue;
|
|
38
|
+
control.value = ['from', 'to'].includes(control.name) ? localInput(params.get(control.name)) : value(control.name, control.name === 'date_field' ? 'created_at' : '');
|
|
39
|
+
}
|
|
40
|
+
document.querySelector('#sort').value = value('sort', 'latest_activity');
|
|
41
|
+
document.querySelector('#direction').value = value('direction', 'desc');
|
|
42
|
+
document.querySelector('#page-size').value = value('page_size', '25');
|
|
43
|
+
}
|
|
44
|
+
async function request(path, signal) {
|
|
45
|
+
const response = await fetch(`${root}${path}`, { signal, headers: { Accept: 'application/json' }, cache: 'no-store' });
|
|
46
|
+
let body;
|
|
47
|
+
try { body = await response.json(); } catch (_) { throw new Error('The server returned an unexpected response.'); }
|
|
48
|
+
if (!response.ok) throw new Error(body.error || 'Unable to load sessions.');
|
|
49
|
+
return body;
|
|
50
|
+
}
|
|
51
|
+
async function loadOptions() {
|
|
52
|
+
try {
|
|
53
|
+
const choices = await request('/api/options');
|
|
54
|
+
const suiteControl = form.elements.suite_id;
|
|
55
|
+
const selectedSuite = optionsLoaded ? suiteControl.value : value('suite_id', '');
|
|
56
|
+
suiteControl.replaceChildren(new Option('All suites', ''));
|
|
57
|
+
for (const suite of choices.suites) suiteControl.add(new Option(suite.title, suite.id));
|
|
58
|
+
if (selectedSuite && !choices.suites.some(suite => suite.id === selectedSuite)) suiteControl.add(new Option(selectedSuite, selectedSuite));
|
|
59
|
+
suiteControl.value = selectedSuite;
|
|
60
|
+
const optionContainer = document.querySelector('#option-filters');
|
|
61
|
+
const oldSelections = new Map(Array.from(optionContainer.querySelectorAll('select')).map(control => [control.name, control.value]));
|
|
62
|
+
optionContainer.replaceChildren();
|
|
63
|
+
for (const option of choices.suite_options) {
|
|
64
|
+
const label = element('label', option.title);
|
|
65
|
+
const select = element('select');
|
|
66
|
+
select.name = `suite_options[${option.id}]`;
|
|
67
|
+
select.add(new Option('All versions / values', ''));
|
|
68
|
+
for (const choice of option.values) select.add(new Option(choice.label, choice.value));
|
|
69
|
+
select.value = oldSelections.get(select.name) ?? value(select.name, '');
|
|
70
|
+
label.append(select); optionContainer.append(label);
|
|
71
|
+
}
|
|
72
|
+
document.querySelector('#options-error').textContent = '';
|
|
73
|
+
optionsLoaded = true;
|
|
74
|
+
} catch (_) { document.querySelector('#options-error').textContent = 'Filter choices could not be loaded. Refresh to retry.'; }
|
|
75
|
+
}
|
|
76
|
+
function dateCell(iso) {
|
|
77
|
+
const cell = element('td', undefined, 'time');
|
|
78
|
+
if (!iso) { cell.textContent = '—'; return cell; }
|
|
79
|
+
const date = new Date(iso);
|
|
80
|
+
const time = element('time', date.toLocaleDateString());
|
|
81
|
+
time.dateTime = iso;
|
|
82
|
+
time.title = date.toLocaleString();
|
|
83
|
+
time.append(element('small', date.toLocaleTimeString()));
|
|
84
|
+
cell.append(time); return cell;
|
|
85
|
+
}
|
|
86
|
+
function badge(text, type) { return element('span', text, `badge ${type || ''}`); }
|
|
87
|
+
function countsCell(counts) {
|
|
88
|
+
const cell = element('td');
|
|
89
|
+
const container = element('div', undefined, 'counts');
|
|
90
|
+
for (const [result, count] of Object.entries(counts)) if (count) container.append(badge(`${count} ${result}`, result));
|
|
91
|
+
if (!container.childNodes.length) container.append(element('span', 'No results', 'muted'));
|
|
92
|
+
cell.append(container); return cell;
|
|
93
|
+
}
|
|
94
|
+
function historyPanel(id) {
|
|
95
|
+
const state = expanded.get(id);
|
|
96
|
+
const panel = element('div');
|
|
97
|
+
panel.append(element('p', 'Run history', 'history-title'));
|
|
98
|
+
if (state.error) { const alert = element('p', state.error, 'history-error'); alert.setAttribute('role', 'alert'); panel.append(alert); }
|
|
99
|
+
if (!state.data) { panel.append(element('p', state.error ? 'Use Refresh now to retry.' : 'Loading run history…', 'muted')); return panel; }
|
|
100
|
+
if (!state.data.data.length) { panel.append(element('p', 'This session has no runs.', 'muted')); return panel; }
|
|
101
|
+
const table = element('table', undefined, 'history-table');
|
|
102
|
+
const head = element('thead'); const headRow = element('tr');
|
|
103
|
+
for (const title of ['Run / target', 'Execution status', 'Created', 'Last update', 'Target outcome', 'Test results']) { const th = element('th', title); th.scope = 'col'; headRow.append(th); }
|
|
104
|
+
head.append(headRow); table.append(head);
|
|
105
|
+
const body = element('tbody');
|
|
106
|
+
for (const run of state.data.data) {
|
|
107
|
+
const row = element('tr');
|
|
108
|
+
const target = element('td'); target.append(element('div', run.target.title, 'suite-title'), element('div', `${run.target.type} · ${run.id}`, 'muted')); row.append(target);
|
|
109
|
+
const runState = element('td'); runState.append(badge(run.status, run.status)); row.append(runState, dateCell(run.created_at), dateCell(run.updated_at));
|
|
110
|
+
const outcome = element('td'); outcome.append(run.outcome ? badge(run.outcome, run.outcome) : element('span', '—')); row.append(outcome, countsCell(run.result_counts)); body.append(row);
|
|
111
|
+
}
|
|
112
|
+
table.append(body); panel.append(table);
|
|
113
|
+
const pagination = element('nav', undefined, 'history-pagination'); pagination.setAttribute('aria-label', `Run history pagination for ${id}`);
|
|
114
|
+
const previous = element('button', 'Previous runs'); previous.type = 'button'; previous.disabled = state.page <= 1;
|
|
115
|
+
previous.dataset.focusKey = `runs-previous-${id}`;
|
|
116
|
+
previous.addEventListener('click', () => { state.page--; refresh(); });
|
|
117
|
+
const next = element('button', 'Next runs'); next.type = 'button'; next.disabled = state.page * 25 >= state.data.pagination.total;
|
|
118
|
+
next.dataset.focusKey = `runs-next-${id}`;
|
|
119
|
+
next.addEventListener('click', () => { state.page++; refresh(); });
|
|
120
|
+
pagination.append(previous, element('span', `Page ${state.page} · ${state.data.pagination.total} runs`, 'muted'), next); panel.append(pagination);
|
|
121
|
+
return panel;
|
|
122
|
+
}
|
|
123
|
+
function renderRows() {
|
|
124
|
+
const focusKey = document.activeElement?.dataset.focusKey;
|
|
125
|
+
rowsElement.replaceChildren();
|
|
126
|
+
if (!currentRows.length) { const row = element('tr'); const cell = element('td', 'No sessions match these filters.', 'empty-row'); cell.colSpan = 9; row.append(cell); rowsElement.append(row); return; }
|
|
127
|
+
for (const session of currentRows) {
|
|
128
|
+
const row = element('tr'); row.dataset.sessionId = session.id;
|
|
129
|
+
row.append(element('td', session.id, 'session-id'));
|
|
130
|
+
const suite = element('td'); suite.append(element('div', session.suite_title, 'suite-title'));
|
|
131
|
+
for (const option of session.suite_options) suite.append(element('div', `${option.title}: ${option.label}`, 'suite-options'));
|
|
132
|
+
if (!session.suite_available) suite.append(element('div', 'Suite unavailable', 'muted'));
|
|
133
|
+
row.append(suite, element('td', session.fhir_url || '—', 'fhir-url'), dateCell(session.created_at), dateCell(session.latest_activity));
|
|
134
|
+
const state = element('td'); state.append(badge(labels[session.state] || session.state, session.state)); row.append(state, element('td', session.run_count), countsCell(session.result_counts));
|
|
135
|
+
const actions = element('td', undefined, 'actions');
|
|
136
|
+
if (session.session_url) { const link = element('a', 'Open session'); link.href = session.session_url; actions.append(link); }
|
|
137
|
+
const expand = element('button', expanded.has(session.id) ? 'Hide history' : 'Run history', 'expand-button');
|
|
138
|
+
expand.type = 'button'; expand.dataset.focusKey = `expand-${session.id}`;
|
|
139
|
+
expand.setAttribute('aria-expanded', String(expanded.has(session.id))); expand.setAttribute('aria-controls', `history-${session.id}`);
|
|
140
|
+
expand.addEventListener('click', () => {
|
|
141
|
+
if (expanded.has(session.id)) expanded.delete(session.id); else expanded.set(session.id, { page: 1, data: null, error: null });
|
|
142
|
+
renderRows(); if (expanded.has(session.id)) refresh();
|
|
143
|
+
});
|
|
144
|
+
actions.append(expand); row.append(actions); rowsElement.append(row);
|
|
145
|
+
if (expanded.has(session.id)) { const historyRow = element('tr'); const cell = element('td', undefined, 'history-cell'); cell.colSpan = 9; cell.id = `history-${session.id}`; cell.append(historyPanel(session.id)); historyRow.append(cell); rowsElement.append(historyRow); }
|
|
146
|
+
}
|
|
147
|
+
if (focusKey) rowsElement.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)?.focus({ preventScroll: true });
|
|
148
|
+
}
|
|
149
|
+
async function refresh() {
|
|
150
|
+
if (busy) { pending = true; return; }
|
|
151
|
+
busy = true; pending = false;
|
|
152
|
+
controller = new AbortController();
|
|
153
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
154
|
+
const query = params.toString();
|
|
155
|
+
const table = document.querySelector('#sessions-table'); table.setAttribute('aria-busy', 'true');
|
|
156
|
+
if (!initialized) status.textContent = 'Loading sessions…';
|
|
157
|
+
try {
|
|
158
|
+
const response = await request(`/api/sessions?${query}`, controller.signal);
|
|
159
|
+
if (query !== params.toString()) { pending = true; return; }
|
|
160
|
+
currentRows = response.data; initialized = true; showError('');
|
|
161
|
+
const total = response.pagination.total;
|
|
162
|
+
const start = total && currentRows.length ? (response.pagination.page - 1) * response.pagination.page_size + 1 : 0;
|
|
163
|
+
status.textContent = `Showing ${start}–${start ? start + currentRows.length - 1 : 0} of ${total} sessions`;
|
|
164
|
+
document.querySelector('#page-number').textContent = `Page ${response.pagination.page}`;
|
|
165
|
+
document.querySelector('#previous-page').disabled = page() <= 1;
|
|
166
|
+
document.querySelector('#next-page').disabled = page() * response.pagination.page_size >= total;
|
|
167
|
+
renderRows();
|
|
168
|
+
await Promise.all(currentRows.filter(session => expanded.has(session.id)).map(async session => {
|
|
169
|
+
const state = expanded.get(session.id); const requestedPage = state.page;
|
|
170
|
+
try {
|
|
171
|
+
const result = await request(`/api/sessions/${encodeURIComponent(session.id)}/runs?page=${requestedPage}`, controller.signal);
|
|
172
|
+
if (expanded.get(session.id) === state && state.page === requestedPage) { state.data = result; state.error = null; }
|
|
173
|
+
} catch (failure) { if (expanded.get(session.id) === state) state.error = failure.name === 'AbortError' ? 'Run history refresh timed out.' : failure.message; }
|
|
174
|
+
}));
|
|
175
|
+
if (query === params.toString()) renderRows();
|
|
176
|
+
} catch (failure) { showError(failure.name === 'AbortError' ? 'Refresh timed out. Use Refresh now to retry.' : failure.message); if (!initialized) status.textContent = 'Sessions could not be loaded.'; }
|
|
177
|
+
finally { clearTimeout(timeout); busy = false; table.setAttribute('aria-busy', 'false'); if (pending && !document.hidden) refresh(); }
|
|
178
|
+
}
|
|
179
|
+
function change(key, selected, resetPage = true) {
|
|
180
|
+
if (selected) params.set(key, selected); else params.delete(key);
|
|
181
|
+
if (resetPage) params.set('page', '1');
|
|
182
|
+
saveUrl(); restoreControls(); refresh();
|
|
183
|
+
}
|
|
184
|
+
form.addEventListener('submit', event => {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
const next = new URLSearchParams();
|
|
187
|
+
for (const [key, entered] of new FormData(form)) {
|
|
188
|
+
if (!entered) continue;
|
|
189
|
+
next.set(key, ['from', 'to'].includes(key) ? new Date(entered).toISOString() : entered);
|
|
190
|
+
}
|
|
191
|
+
for (const key of ['sort', 'direction', 'page_size']) if (params.has(key)) next.set(key, params.get(key));
|
|
192
|
+
next.set('page', '1');
|
|
193
|
+
// Clear via a key snapshot: deleting while iterating URLSearchParams skips adjacent keys.
|
|
194
|
+
for (const key of Array.from(params.keys())) params.delete(key);
|
|
195
|
+
next.forEach((entry, key) => params.set(key, entry)); saveUrl(); refresh();
|
|
196
|
+
});
|
|
197
|
+
document.querySelector('#clear-filters').addEventListener('click', () => { for (const key of Array.from(params.keys())) params.delete(key); form.reset(); restoreControls(); saveUrl(); refresh(); });
|
|
198
|
+
document.querySelector('#sort').addEventListener('change', event => change('sort', event.target.value));
|
|
199
|
+
document.querySelector('#direction').addEventListener('change', event => change('direction', event.target.value));
|
|
200
|
+
document.querySelector('#page-size').addEventListener('change', event => change('page_size', event.target.value));
|
|
201
|
+
document.querySelector('#previous-page').addEventListener('click', () => change('page', String(page() - 1), false));
|
|
202
|
+
document.querySelector('#next-page').addEventListener('click', () => change('page', String(page() + 1), false));
|
|
203
|
+
document.querySelectorAll('[data-sort]').forEach(button => button.addEventListener('click', () => {
|
|
204
|
+
params.set('direction', value('sort', 'latest_activity') === button.dataset.sort && value('direction', 'desc') === 'desc' ? 'asc' : 'desc');
|
|
205
|
+
change('sort', button.dataset.sort);
|
|
206
|
+
}));
|
|
207
|
+
document.querySelector('#refresh').addEventListener('click', () => { loadOptions(); refresh(); });
|
|
208
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && document.querySelector('#auto-refresh').checked) refresh(); });
|
|
209
|
+
window.addEventListener('popstate', () => { for (const key of Array.from(params.keys())) params.delete(key); new URLSearchParams(location.search).forEach((entry, key) => params.set(key, entry)); restoreControls(); refresh(); });
|
|
210
|
+
setInterval(() => { if (!document.hidden && document.querySelector('#auto-refresh').checked) refresh(); }, 10000);
|
|
211
|
+
restoreControls(); loadOptions(); refresh();
|
|
212
|
+
})();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module InfernoSessionBrowser
|
|
3
|
+
class Configuration
|
|
4
|
+
attr_accessor :mount_path, :fhir_url_input_names
|
|
5
|
+
|
|
6
|
+
def initialize
|
|
7
|
+
@mount_path = '/sessions'
|
|
8
|
+
@fhir_url_input_names = ['url']
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def snapshot
|
|
12
|
+
path = mount_path.to_s.sub(%r{/+\z}, '')
|
|
13
|
+
unless path.match?(%r{\A/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\z})
|
|
14
|
+
raise ArgumentError, 'mount_path must be a non-root path containing letters, numbers, underscores, or hyphens'
|
|
15
|
+
end
|
|
16
|
+
names = Array(fhir_url_input_names).map { |name| name.to_s.downcase }.uniq
|
|
17
|
+
raise ArgumentError, 'fhir_url_input_names must contain at least one nonempty input name' if names.empty? || names.any?(&:empty?)
|
|
18
|
+
|
|
19
|
+
copy = dup
|
|
20
|
+
copy.mount_path = path.freeze
|
|
21
|
+
copy.fhir_url_input_names = names.map(&:freeze).freeze
|
|
22
|
+
copy.freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'rack'
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'query'
|
|
6
|
+
|
|
7
|
+
module InfernoSessionBrowser
|
|
8
|
+
class Middleware
|
|
9
|
+
ASSETS = { 'app.js' => 'application/javascript; charset=utf-8', 'app.css' => 'text/css; charset=utf-8' }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(app, configuration:, base_path: '', db: nil, registry: Registry.new)
|
|
12
|
+
@app, @configuration, @db, @registry = app, configuration, db, registry
|
|
13
|
+
@base_path = base_path.to_s.split('/').reject(&:empty?).map { |part| "/#{part}" }.join
|
|
14
|
+
@mount_path = "#{@base_path}#{configuration.mount_path}"
|
|
15
|
+
@template = ERB.new(File.read(File.join(__dir__, 'templates/index.html.erb')))
|
|
16
|
+
@assets = ASSETS.to_h { |name, type| [name, [type, File.binread(File.join(__dir__, 'assets', name)).freeze]] }.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
path = env['PATH_INFO'].to_s
|
|
21
|
+
return @app.call(env) unless path == @mount_path || path == "#{@mount_path}/" || path.start_with?("#{@mount_path}/api/", "#{@mount_path}/assets/")
|
|
22
|
+
response = browser_response(path, env)
|
|
23
|
+
env['REQUEST_METHOD'] == 'HEAD' ? [response[0], response[1], []] : response
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def browser_response(path, env)
|
|
29
|
+
return json(405, { error: 'Only GET and HEAD are supported' }, 'Allow' => 'GET, HEAD') unless %w[GET HEAD].include?(env['REQUEST_METHOD'])
|
|
30
|
+
dispatch(path, env)
|
|
31
|
+
rescue InvalidParameters, Rack::QueryParser::ParameterTypeError, Rack::QueryParser::InvalidParameterError, Rack::QueryParser::ParamsTooDeepError => e
|
|
32
|
+
json(400, { error: e.message })
|
|
33
|
+
rescue NotFound => e
|
|
34
|
+
json(404, { error: e.message })
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
# Host logging receives the class only, never SQL, session data, or URL credentials.
|
|
37
|
+
log_failure(e)
|
|
38
|
+
json(500, { error: 'Unable to load sessions. Please try again.' })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def log_failure(error)
|
|
42
|
+
return unless defined?(Inferno::Application)
|
|
43
|
+
Inferno::Application['logger']&.error("inferno_session_browser request failed: #{error.class}")
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dispatch(path, env)
|
|
49
|
+
if path == @mount_path || path == "#{@mount_path}/"
|
|
50
|
+
return [200, { 'Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => 'no-store',
|
|
51
|
+
'Content-Security-Policy' => "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'",
|
|
52
|
+
'X-Content-Type-Options' => 'nosniff' }, [@template.result_with_hash(mount_path: @mount_path, version: VERSION)]]
|
|
53
|
+
end
|
|
54
|
+
ASSETS.each_key do |name|
|
|
55
|
+
next unless path == "#{@mount_path}/assets/#{VERSION}/#{name}"
|
|
56
|
+
type, content = @assets.fetch(name)
|
|
57
|
+
return [200, { 'Content-Type' => type, 'Cache-Control' => 'public, max-age=31536000, immutable',
|
|
58
|
+
'X-Content-Type-Options' => 'nosniff' }, [content]]
|
|
59
|
+
end
|
|
60
|
+
request = Rack::Request.new(env)
|
|
61
|
+
parameters = request.GET
|
|
62
|
+
case path
|
|
63
|
+
when "#{@mount_path}/api/sessions"
|
|
64
|
+
json(200, query.sessions(parameters))
|
|
65
|
+
when "#{@mount_path}/api/options"
|
|
66
|
+
raise InvalidParameters, 'Options endpoint does not accept parameters' unless parameters.empty?
|
|
67
|
+
json(200, query.options)
|
|
68
|
+
else
|
|
69
|
+
match = path.match(%r{\A#{Regexp.escape(@mount_path)}/api/sessions/([^/]+)/runs\z})
|
|
70
|
+
return json(404, { error: 'Browser route not found' }) unless match
|
|
71
|
+
session_id = Rack::Utils.unescape_path(match[1]).force_encoding(Encoding::UTF_8)
|
|
72
|
+
raise InvalidParameters, 'Invalid session ID encoding' unless session_id.valid_encoding?
|
|
73
|
+
json(200, query.runs(session_id, parameters))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def query
|
|
78
|
+
Query.new(db: @db || Inferno::Application['db.connection'], configuration: @configuration,
|
|
79
|
+
base_path: @base_path, registry: @registry)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def json(status, body, headers = {})
|
|
83
|
+
[status, { 'Content-Type' => 'application/json; charset=utf-8', 'Cache-Control' => 'no-store',
|
|
84
|
+
'X-Content-Type-Options' => 'nosniff' }.merge(headers), [JSON.generate(body)]]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'time'
|
|
3
|
+
|
|
4
|
+
module InfernoSessionBrowser
|
|
5
|
+
class InvalidParameters < StandardError; end
|
|
6
|
+
class NotFound < StandardError; end
|
|
7
|
+
|
|
8
|
+
class Parameters
|
|
9
|
+
STATES = %w[never_run queued running waiting cancelling idle].freeze
|
|
10
|
+
SORTS = %w[latest_activity created_at suite].freeze
|
|
11
|
+
KEYS = %w[page page_size q suite_id suite_options fhir_url state date_field from to sort direction].freeze
|
|
12
|
+
attr_reader :page, :page_size, :filters, :sort, :direction
|
|
13
|
+
|
|
14
|
+
def initialize(raw, history: false)
|
|
15
|
+
unknown = raw.keys - (history ? %w[page page_size] : KEYS)
|
|
16
|
+
raise InvalidParameters, "Unknown parameter: #{unknown.first}" unless unknown.empty?
|
|
17
|
+
|
|
18
|
+
@page = integer(raw.fetch('page', '1'), 'page')
|
|
19
|
+
@page_size = integer(raw.fetch('page_size', '25'), 'page_size')
|
|
20
|
+
raise InvalidParameters, 'page_size must be 25, 50, or 100' unless [25, 50, 100].include?(@page_size)
|
|
21
|
+
@sort = scalar(raw.fetch('sort', 'latest_activity'), 'sort')
|
|
22
|
+
@direction = scalar(raw.fetch('direction', 'desc'), 'direction')
|
|
23
|
+
raise InvalidParameters, 'Invalid sort field' unless SORTS.include?(@sort)
|
|
24
|
+
raise InvalidParameters, 'direction must be asc or desc' unless %w[asc desc].include?(@direction)
|
|
25
|
+
|
|
26
|
+
@filters = raw.slice('q', 'suite_id', 'fhir_url', 'state', 'date_field').transform_values do |value|
|
|
27
|
+
scalar(value, 'filter')
|
|
28
|
+
end
|
|
29
|
+
@filters.reject! { |_key, value| value.empty? }
|
|
30
|
+
state = @filters['state']
|
|
31
|
+
raise InvalidParameters, 'Invalid run state' if state && !STATES.include?(state)
|
|
32
|
+
date_field = @filters.fetch('date_field', 'created_at')
|
|
33
|
+
raise InvalidParameters, 'date_field must be created_at or latest_activity' unless %w[created_at latest_activity].include?(date_field)
|
|
34
|
+
@filters['date_field'] = date_field
|
|
35
|
+
%w[from to].each { |key| @filters[key] = timestamp(raw[key], key) if raw[key] && raw[key] != '' }
|
|
36
|
+
if @filters['from'] && @filters['to'] && @filters['from'] >= @filters['to']
|
|
37
|
+
raise InvalidParameters, 'from must be earlier than to'
|
|
38
|
+
end
|
|
39
|
+
options = raw.fetch('suite_options', {})
|
|
40
|
+
raise InvalidParameters, 'suite_options must be an object of option IDs and values' unless options.is_a?(Hash)
|
|
41
|
+
@filters['suite_options'] = options.each_with_object({}) do |(key, value), selected|
|
|
42
|
+
raise InvalidParameters, 'Invalid suite option ID' unless key.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
43
|
+
selected[key] = scalar(value, 'suite option') unless value == ''
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def offset
|
|
48
|
+
(page - 1) * page_size
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def metadata(total)
|
|
52
|
+
{ page: page, page_size: page_size, total: total }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def scalar(value, name)
|
|
58
|
+
raise InvalidParameters, "#{name} must be a string" unless value.is_a?(String)
|
|
59
|
+
raise InvalidParameters, "#{name} is too long" if value.length > 2048
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def integer(value, name)
|
|
64
|
+
text = scalar(value, name)
|
|
65
|
+
raise InvalidParameters, "#{name} must be a positive integer" unless text.match?(/\A[1-9]\d{0,8}\z/)
|
|
66
|
+
text.to_i
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def timestamp(value, name)
|
|
70
|
+
text = scalar(value, name)
|
|
71
|
+
unless text.match?(/(?:Z|[+-]\d{2}:\d{2})\z/)
|
|
72
|
+
raise InvalidParameters, "#{name} must be an ISO 8601 timestamp with a timezone"
|
|
73
|
+
end
|
|
74
|
+
Time.iso8601(text).utc
|
|
75
|
+
rescue ArgumentError
|
|
76
|
+
raise InvalidParameters, "#{name} must be an ISO 8601 timestamp with a timezone"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|