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.
@@ -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