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,261 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'sequel'
4
+ require 'uri'
5
+ require 'time'
6
+ require_relative 'parameters'
7
+ require_relative 'registry'
8
+
9
+ module InfernoSessionBrowser
10
+ class Query
11
+ ACTIVE_STATES = %w[queued running waiting cancelling].freeze
12
+ RESULT_STATES = %w[pass fail skip error omit cancel wait running].freeze
13
+ BATCH_SIZE = 500
14
+
15
+ def initialize(db:, configuration:, base_path: '', registry: Registry.new)
16
+ @db, @configuration, @registry = db, configuration, registry
17
+ @base_path = normalize_base(base_path)
18
+ end
19
+
20
+ def sessions(raw)
21
+ parameters = Parameters.new(raw)
22
+ dataset, bindings = filtered_sessions(parameters)
23
+ rows, total = session_page(dataset, bindings, parameters)
24
+ ids = rows.map { |row| row[:id] }
25
+ urls = fhir_urls(ids)
26
+ counts = result_counts(@db[:results].where(test_session_id: ids), :test_session_id)
27
+ data = rows.map do |row|
28
+ suite = @registry.suite(row[:test_suite_id])
29
+ {
30
+ id: row[:id], suite_id: suite&.id || row[:test_suite_id],
31
+ suite_title: suite&.title || row[:test_suite_id], suite_available: !suite.nil?,
32
+ suite_options: display_options(row[:suite_options], suite), fhir_url: urls[row[:id]],
33
+ created_at: timestamp(row[:created_at]), latest_activity: timestamp(row[:latest_activity]),
34
+ state: row[:state], run_count: row[:run_count].to_i,
35
+ result_counts: counts.fetch(row[:id], empty_counts),
36
+ session_url: suite ? "#{@base_path}/#{encode(suite.id)}/#{encode(row[:id])}" : nil
37
+ }
38
+ end
39
+ { data: data, pagination: parameters.metadata(total) }
40
+ end
41
+
42
+ def options
43
+ suites = {}
44
+ selections = Hash.new { |hash, key| hash[key] = {} }
45
+ each_batch(@db[:test_sessions].select(:id, :test_suite_id, :suite_options).order(:id), {}) do |rows|
46
+ rows.each do |row|
47
+ suite = @registry.suite(row[:test_suite_id])
48
+ suites[row[:test_suite_id]] ||= { id: row[:test_suite_id], title: suite&.title || row[:test_suite_id] }
49
+ display_options(row[:suite_options], suite).each do |option|
50
+ key = option[:id]
51
+ selections[key][:title] ||= option[:title]
52
+ selections[key][:values] ||= {}
53
+ selections[key][:values][option[:value]] ||= option[:label]
54
+ end
55
+ end
56
+ end
57
+ {
58
+ suites: suites.values.sort_by { |suite| [suite[:title].to_s.downcase, suite[:id]] },
59
+ suite_options: selections.sort.map do |id, option|
60
+ { id: id, title: option[:title], values: option[:values].sort.map { |value, label| { value: value, label: label } } }
61
+ end,
62
+ states: Parameters::STATES
63
+ }
64
+ end
65
+
66
+ def runs(session_id, raw)
67
+ raise NotFound, 'Session not found' unless @db[:test_sessions].where(id: :$id).call(:first, id: session_id)
68
+ parameters = Parameters.new(raw, history: true)
69
+ dataset = @db[:test_runs].where(test_session_id: :$session_id)
70
+ bindings = { session_id: session_id }
71
+ total = dataset.select(Sequel.function(:count, Sequel.lit('*')).as(:total)).call(:first, bindings)[:total]
72
+ rows = dataset.select(:id, :status, :created_at, :updated_at, :test_suite_id, :test_group_id, :test_id)
73
+ .order(Sequel.desc(:created_at), Sequel.desc(:id))
74
+ .limit(parameters.page_size, parameters.offset).call(:select, bindings)
75
+ ids = rows.map { |row| row[:id] }
76
+ counts = result_counts(@db[:results].where(test_run_id: ids), :test_run_id)
77
+ # Select only outcome metadata; messages, input/output JSON, and requests are never loaded.
78
+ outcomes = if rows.empty?
79
+ {}
80
+ else
81
+ conditions = rows.map { |row| { test_run_id: row[:id], target(row).last => row[target(row).last] } }
82
+ @db[:results].where(Sequel.|(*conditions))
83
+ .select(:test_run_id, :result, row_number(:test_run_id).as(:position))
84
+ .from_self.where(position: 1).all.to_h { |result| [result[:test_run_id], result[:result]] }
85
+ end
86
+ data = rows.map do |row|
87
+ type, column = target(row)
88
+ id = row[column]
89
+ runnable = @registry.runnable(type, id)
90
+ { id: row[:id], status: row[:status], created_at: timestamp(row[:created_at]),
91
+ updated_at: timestamp(row[:updated_at]),
92
+ target: { type: type, id: runnable&.id || id, title: runnable&.title || id },
93
+ outcome: outcomes[row[:id]], result_counts: counts.fetch(row[:id], empty_counts) }
94
+ end
95
+ { data: data, pagination: parameters.metadata(total) }
96
+ end
97
+
98
+ private
99
+
100
+ def base_sessions
101
+ s = Sequel[:test_sessions]
102
+ statistics = @db[:test_runs].group(:test_session_id)
103
+ .select(:test_session_id, Sequel.function(:count, Sequel.lit('*')).as(:run_count),
104
+ Sequel.function(:max, :updated_at).as(:run_activity))
105
+ activity = @db[:results].group(:test_session_id)
106
+ .select(:test_session_id, Sequel.function(:max, :updated_at).as(:result_activity))
107
+ active = @db[:test_runs].where(status: ACTIVE_STATES)
108
+ .select(:test_session_id, :status, row_number(:test_session_id).as(:position))
109
+ .from_self.where(position: 1)
110
+ latest = [Sequel[:run_stats][:run_activity], Sequel[:result_stats][:result_activity]]
111
+ .reduce(s[:created_at]) { |maximum, date| Sequel.case({ (date > maximum) => date }, maximum) }
112
+ state = Sequel.case({ Sequel.~(Sequel[:active_run][:status] => nil) => Sequel[:active_run][:status],
113
+ { Sequel[:run_stats][:run_count] => nil } => 'never_run' }, 'idle')
114
+ @db[:test_sessions]
115
+ .left_join(statistics, { Sequel[:run_stats][:test_session_id] => s[:id] }, table_alias: :run_stats)
116
+ .left_join(activity, { Sequel[:result_stats][:test_session_id] => s[:id] }, table_alias: :result_stats)
117
+ .left_join(active, { Sequel[:active_run][:test_session_id] => s[:id] }, table_alias: :active_run)
118
+ .select(s[:id], s[:test_suite_id], s[:suite_options], s[:created_at],
119
+ Sequel[:run_stats][:run_count], latest.as(:latest_activity), state.as(:state))
120
+ .from_self(alias: :session_summary)
121
+ end
122
+
123
+ def filtered_sessions(parameters)
124
+ filters = parameters.filters
125
+ dataset = base_sessions
126
+ bindings = {}
127
+ if filters['q']
128
+ dataset = dataset.where(Sequel.ilike(:id, :$q))
129
+ bindings[:q] = "%#{filters['q'].gsub(/[\\%_]/) { |char| "\\#{char}" }}%"
130
+ end
131
+ { 'suite_id' => :test_suite_id, 'state' => :state }.each do |key, column|
132
+ next unless filters[key]
133
+ dataset = dataset.where(column => :"$#{key}")
134
+ bindings[key.to_sym] = filters[key]
135
+ end
136
+ { 'from' => :>=, 'to' => :< }.each do |key, comparison|
137
+ next unless filters[key]
138
+ dataset = dataset.where(Sequel[filters['date_field'].to_sym].public_send(comparison, :"$#{key}"))
139
+ bindings[key.to_sym] = filters[key]
140
+ end
141
+ column = parameters.sort == 'suite' ? :test_suite_id : parameters.sort.to_sym
142
+ order = parameters.direction == 'asc' ? Sequel.asc(column) : Sequel.desc(column)
143
+ [dataset.order(order, Sequel.asc(:id)), bindings]
144
+ end
145
+
146
+ def session_page(dataset, bindings, parameters)
147
+ filters = parameters.filters
148
+ if filters['suite_options'].empty? && !filters['fhir_url']
149
+ total = dataset.unordered.select(Sequel.function(:count, Sequel.lit('*')).as(:total)).call(:first, bindings)[:total]
150
+ return [dataset.limit(parameters.page_size, parameters.offset).call(:select, bindings), total]
151
+ end
152
+
153
+ # Option JSON and URLs are normalized in Ruby for identical SQLite/Postgres behavior.
154
+ # Scan lightweight candidate metadata in bounded batches, retaining only the requested page.
155
+ # This also ensures URL filters cannot match hidden userinfo/query/fragment secrets.
156
+ page = []
157
+ total = 0
158
+ each_batch(dataset, bindings) do |rows|
159
+ urls = filters['fhir_url'] ? fhir_urls(rows.map { |row| row[:id] }) : {}
160
+ rows.each do |row|
161
+ options = parse_options(row[:suite_options]).to_h { |option| [option['id'].to_s, option['value'].to_s] }
162
+ next unless filters['suite_options'].all? { |key, value| options[key] == value }
163
+ next if filters['fhir_url'] && !urls[row[:id]].to_s.downcase.include?(filters['fhir_url'].downcase)
164
+ page << row if total >= parameters.offset && page.length < parameters.page_size
165
+ total += 1
166
+ end
167
+ end
168
+ [page, total]
169
+ end
170
+
171
+ def each_batch(dataset, bindings)
172
+ offset = 0
173
+ loop do
174
+ rows = dataset.limit(BATCH_SIZE, offset).call(:select, bindings)
175
+ break if rows.empty?
176
+ yield rows
177
+ break if rows.length < BATCH_SIZE
178
+ offset += BATCH_SIZE
179
+ end
180
+ end
181
+
182
+ def fhir_urls(ids)
183
+ return {} if ids.empty?
184
+ candidates = @db[:session_data].where(test_session_id: ids, name: @configuration.fhir_url_input_names)
185
+ .select(:test_session_id, :name, :value).all.group_by { |row| row[:test_session_id] }
186
+ candidates.transform_values do |rows|
187
+ @configuration.fhir_url_input_names.lazy.map do |name|
188
+ value = rows.find { |row| row[:name] == name }&.fetch(:value)
189
+ sanitized_url(value)
190
+ end.find { |value| !value.nil? }
191
+ end
192
+ end
193
+
194
+ def sanitized_url(value)
195
+ return nil unless value.is_a?(String)
196
+ uri = URI.parse(value.strip)
197
+ return nil unless %w[http https].include?(uri.scheme&.downcase) && uri.host
198
+ # URI#userinfo = nil does not clear existing user/password in all Ruby versions.
199
+ # Rebuild from an explicit allowlist instead of mutating the original URI.
200
+ type = uri.scheme.downcase == 'https' ? URI::HTTPS : URI::HTTP
201
+ type.build(host: uri.host, port: uri.port, path: uri.path).normalize.to_s
202
+ rescue URI::InvalidURIError, URI::InvalidComponentError
203
+ nil
204
+ end
205
+
206
+ def result_counts(dataset, partition)
207
+ ranked = dataset.exclude(test_id: nil)
208
+ .select(partition, :result, row_number([partition, :test_id]).as(:position)).from_self
209
+ ranked.where(position: 1).group(partition, :result)
210
+ .select(partition, :result, Sequel.function(:count, Sequel.lit('*')).as(:total))
211
+ .all.each_with_object({}) do |row, counts|
212
+ counts[row[partition]] ||= empty_counts
213
+ counts[row[partition]][row[:result]] = row[:total] if RESULT_STATES.include?(row[:result])
214
+ end
215
+ end
216
+
217
+ def row_number(partition)
218
+ Sequel.function(:row_number).over(partition: partition,
219
+ order: [Sequel.desc(:updated_at), Sequel.desc(:created_at), Sequel.desc(:id)])
220
+ end
221
+
222
+ def empty_counts
223
+ RESULT_STATES.to_h { |state| [state, 0] }
224
+ end
225
+
226
+ def parse_options(value)
227
+ parsed = JSON.parse(value || '[]')
228
+ parsed.is_a?(Array) ? parsed.select { |option| option.is_a?(Hash) && option['id'] && option.key?('value') } : []
229
+ rescue JSON::ParserError, TypeError
230
+ []
231
+ end
232
+
233
+ def display_options(value, suite)
234
+ parse_options(value).map do |selected|
235
+ definition = suite&.suite_options&.find { |option| option.id.to_s == selected['id'].to_s }
236
+ choice = definition&.list_options&.find { |option| option[:value].to_s == selected['value'].to_s }
237
+ { id: selected['id'].to_s, title: definition&.title || selected['id'].to_s,
238
+ value: selected['value'].to_s, label: choice&.fetch(:label, nil) || selected['value'].to_s }
239
+ end
240
+ end
241
+
242
+ def target(row)
243
+ return ['test', :test_id] if row[:test_id]
244
+ return ['group', :test_group_id] if row[:test_group_id]
245
+ ['suite', :test_suite_id]
246
+ end
247
+
248
+ def timestamp(value)
249
+ value = @db.to_application_timestamp(value) if value.is_a?(String)
250
+ value&.to_time&.utc&.iso8601(3)
251
+ end
252
+
253
+ def encode(value)
254
+ URI.encode_www_form_component(value.to_s).gsub('+', '%20')
255
+ end
256
+
257
+ def normalize_base(path)
258
+ path.to_s.split('/').reject(&:empty?).map { |part| "/#{part}" }.join
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ module InfernoSessionBrowser
3
+ class Registry
4
+ def suite(id)
5
+ Inferno::Repositories::TestSuites.new.find(id)
6
+ end
7
+
8
+ def runnable(type, id)
9
+ repository = { 'suite' => 'TestSuites', 'group' => 'TestGroups', 'test' => 'Tests' }.fetch(type)
10
+ Inferno::Repositories.const_get(repository).new.find(id)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,42 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Inferno Session Browser</title>
7
+ <link rel="stylesheet" href="<%= ERB::Util.html_escape(mount_path) %>/assets/<%= version %>/app.css">
8
+ <script defer src="<%= ERB::Util.html_escape(mount_path) %>/assets/<%= version %>/app.js"></script>
9
+ </head>
10
+ <body data-browser-path="<%= ERB::Util.html_escape(mount_path) %>">
11
+ <main>
12
+ <header class="page-header">
13
+ <div><p class="eyebrow">INFERNO</p><h1>Session Browser</h1><p class="subtitle">Explore test sessions and their run history.</p></div>
14
+ <div class="refresh-controls"><label><input id="auto-refresh" type="checkbox" checked> Refresh every 10 seconds</label><button id="refresh" type="button">Refresh now</button></div>
15
+ </header>
16
+ <form id="filters" aria-label="Session filters">
17
+ <div class="filter-grid">
18
+ <label>Session ID<input name="q" type="search" placeholder="Search session IDs"></label>
19
+ <label>Test suite<select name="suite_id"><option value="">All suites</option></select></label>
20
+ <label>FHIR server URL<input name="fhir_url" type="search" placeholder="Host or path"></label>
21
+ <label>Run state<select name="state"><option value="">All states</option><option value="never_run">Never run</option><option value="queued">Queued</option><option value="running">Running</option><option value="waiting">Waiting</option><option value="cancelling">Cancelling</option><option value="idle">Idle</option></select></label>
22
+ <label>Date field<select name="date_field"><option value="created_at">Created</option><option value="latest_activity">Latest activity</option></select></label>
23
+ <label>From (local time)<input name="from" type="datetime-local" step="1"></label>
24
+ <label>Until (exclusive, local time)<input name="to" type="datetime-local" step="1"></label>
25
+ </div>
26
+ <div id="option-filters" class="filter-grid"></div>
27
+ <div class="filter-actions"><button type="submit" class="primary">Apply filters</button><button id="clear-filters" type="button">Clear filters</button><span id="options-error" role="status"></span></div>
28
+ </form>
29
+ <div id="error" role="alert" hidden></div>
30
+ <section aria-label="Test sessions">
31
+ <div class="table-toolbar">
32
+ <p id="status" role="status" aria-live="polite">Loading sessions…</p>
33
+ <div class="sorting"><label>Sort by<select id="sort"><option value="latest_activity">Latest activity</option><option value="created_at">Created</option><option value="suite">Test suite</option></select></label><label>Order<select id="direction"><option value="desc">Descending</option><option value="asc">Ascending</option></select></label></div>
34
+ </div>
35
+ <div class="table-scroll"><table id="sessions-table" aria-busy="true"><caption class="sr-only">Test sessions. Expand a session to view its run history.</caption><thead><tr><th scope="col">Session</th><th scope="col">Suite / options</th><th scope="col">FHIR server</th><th scope="col"><button type="button" class="sort-heading" data-sort="created_at">Created</button></th><th scope="col"><button type="button" class="sort-heading" data-sort="latest_activity">Latest activity</button></th><th scope="col">Run state</th><th scope="col">Runs</th><th scope="col">Current test results</th><th scope="col">Actions</th></tr></thead><tbody id="session-rows"></tbody></table></div>
36
+ <p class="result-note">Result counts include optional tests. Run state and result counts do not indicate certification completion.</p>
37
+ <nav class="pagination" aria-label="Session pagination"><label>Rows per page<select id="page-size"><option value="25">25</option><option value="50">50</option><option value="100">100</option></select></label><div><button id="previous-page" type="button" disabled>Previous</button><span id="page-number">Page 1</span><button id="next-page" type="button" disabled>Next</button></div></nav>
38
+ </section>
39
+ <footer>Inferno Session Browser <%= version %> · Read-only</footer>
40
+ </main>
41
+ </body>
42
+ </html>
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module InfernoSessionBrowser
3
+ VERSION = '0.1.0'
4
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'inferno/version'
4
+ require_relative 'inferno_session_browser/version'
5
+ require_relative 'inferno_session_browser/configuration'
6
+ require_relative 'inferno_session_browser/middleware'
7
+
8
+ module InfernoSessionBrowser
9
+ class << self
10
+ def configuration
11
+ @configuration ||= Configuration.new
12
+ end
13
+
14
+ def configure
15
+ yield configuration
16
+ end
17
+ end
18
+
19
+ module WebAppExtension
20
+ def app
21
+ original = super
22
+ Middleware.new(original, configuration: InfernoSessionBrowser.configuration.snapshot,
23
+ base_path: Inferno::Application['base_path'].to_s)
24
+ end
25
+ end
26
+ end
27
+
28
+ # Defining this namespace does not load Inferno's web provider or database.
29
+ # The provider can define .app before or after this file is required.
30
+ module Inferno
31
+ module Web
32
+ end
33
+ end
34
+ Inferno::Web.singleton_class.prepend(InfernoSessionBrowser::WebAppExtension) unless
35
+ Inferno::Web.singleton_class.ancestors.include?(InfernoSessionBrowser::WebAppExtension)
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: inferno_session_browser
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel Alistar
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: inferno_core
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.4.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '1.5'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 1.4.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.5'
33
+ - !ruby/object:Gem::Dependency
34
+ name: rack
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.2'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '3'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '2.2'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '3'
53
+ - !ruby/object:Gem::Dependency
54
+ name: sequel
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '5.42'
60
+ type: :runtime
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '5.42'
67
+ description: Adds a paginated session dashboard and run history to an existing Inferno
68
+ host on require.
69
+ email:
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - CHANGELOG.md
75
+ - LICENSE
76
+ - NOTICE
77
+ - README.md
78
+ - RELEASING.md
79
+ - examples/host/Gemfile
80
+ - examples/host/config.ru
81
+ - examples/host/config/database.yml
82
+ - examples/host/lib/demo.rb
83
+ - examples/host/prepare.rb
84
+ - lib/inferno_session_browser.rb
85
+ - lib/inferno_session_browser/assets/app.css
86
+ - lib/inferno_session_browser/assets/app.js
87
+ - lib/inferno_session_browser/configuration.rb
88
+ - lib/inferno_session_browser/middleware.rb
89
+ - lib/inferno_session_browser/parameters.rb
90
+ - lib/inferno_session_browser/query.rb
91
+ - lib/inferno_session_browser/registry.rb
92
+ - lib/inferno_session_browser/templates/index.html.erb
93
+ - lib/inferno_session_browser/version.rb
94
+ homepage: https://github.com/daniel-alistar/inferno_session_browser
95
+ licenses:
96
+ - Apache-2.0
97
+ metadata:
98
+ source_code_uri: https://github.com/daniel-alistar/inferno_session_browser
99
+ documentation_uri: https://github.com/daniel-alistar/inferno_session_browser/blob/main/README.md
100
+ bug_tracker_uri: https://github.com/daniel-alistar/inferno_session_browser/issues
101
+ changelog_uri: https://github.com/daniel-alistar/inferno_session_browser/blob/main/CHANGELOG.md
102
+ allowed_push_host: https://rubygems.org
103
+ rubygems_mfa_required: 'true'
104
+ post_install_message:
105
+ rdoc_options: []
106
+ require_paths:
107
+ - lib
108
+ required_ruby_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: 3.3.6
113
+ required_rubygems_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ requirements: []
119
+ rubygems_version: 3.5.22
120
+ signing_key:
121
+ specification_version: 4
122
+ summary: A read-only session browser for the Inferno Framework
123
+ test_files: []