From 1b5b92711ed84e9be7d2281d78252aa2cd84e3ec Mon Sep 17 00:00:00 2001 From: Boden Garman Date: Thu, 23 Jul 2026 15:23:42 +1000 Subject: [PATCH 1/3] Show matching config snippets in search results Config search now returns each matching line with two lines of context, line numbers and highlighted matches. The results page gets its own search form with an option to disable regex matching (regex remains the default). Invalid regexes return 400 instead of crashing. --- CHANGELOG.md | 6 ++ lib/oxidized/web/views/conf_search.haml | 73 +++++++++++----- lib/oxidized/web/webapp.rb | 91 ++++++++++++++++++-- spec/web/nodes_spec.rb | 109 ++++++++++++++++++++++++ 4 files changed, 249 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 251168b..b873905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- Config search results show each matching line with two lines of context, + line numbers and the match highlighted (@bpbp-boop) +- Search form on the results page, with an option to search for literal text + instead of a regular expression (@bpbp-boop) ### Changed ### Fixed +- Config search returns 400 on an invalid regular expression instead of + crashing (@bpbp-boop) ## [0.18.1 – 2026-01-19] diff --git a/lib/oxidized/web/views/conf_search.haml b/lib/oxidized/web/views/conf_search.haml index 6772bfc..be9c1cd 100644 --- a/lib/oxidized/web/views/conf_search.haml +++ b/lib/oxidized/web/views/conf_search.haml @@ -2,37 +2,64 @@ .col-8 %h4 %a{href: url_for('/nodes')} nodes - \/ Nodes that contain + \/ Nodes that contain %b - &= "#{@to_research}" + &= @search_term .col-4 %form.float-end#oxButtons %button.btn.btn-primary{type: 'button', onclick: 'history.go();'} %i.bi.bi-arrow-clockwise Refresh page -.row - .table-responsive - %table.table.table-sm.table-striped.table-hover#versionsTable - %thead - %tr - %th Name - %th Configuration +.row.mb-3 + .col-lg-6 + %form{action: url_for('/nodes/conf_search'), method: 'post'} + .input-group + %input.form-control{type: 'text', + name: 'search_in_conf_textbox', + value: @search_term, + :'aria-label' => 'Search in Configs'} + %button.btn.btn-primary{type: 'submit'} + %i.bi.bi-search + Search + .form-check.mt-1 + %input{type: 'hidden', name: 'search_regex_checkbox', value: 'off'} + %input.form-check-input#conf-search-regex{type: 'checkbox', + name: 'search_regex_checkbox', + value: 'on', + checked: @regex_search} + %label.form-check-label{for: 'conf-search-regex'} Use regular expression - %tbody - - @nodes_match.each do |x| +- if @error + .row + .col-lg-6 + .alert.alert-danger&= @error +- else + .row + .table-responsive + %table.table.table-sm.table-striped.table-hover#versionsTable + %thead %tr - %td #{x[:node]} - %td - %a{title: 'configuration', - href: url_for("/node/fetch/#{x[:full_name]}")} - %i.bi.bi-cloud-download + %th Name + %th Matches -:javascript - $(function() { - $('#versionsTable').dataTable({ - autoWidth: false, - "order": [[0, "asc"]] - }); - }); + %tbody + - @nodes_match.each do |x| + %tr + %td + %a{title: 'configuration', + href: url_for("/node/fetch/#{x[:full_name]}")} + %i.bi.bi-cloud-download + = x[:node] + %td + - x[:matches].each do |m| + %pre.border.rounded.p-2.mb-2>< + != snippet_html(m, @to_research) + :javascript + $(function() { + $('#versionsTable').dataTable({ + autoWidth: false, + "order": [[0, "asc"]] + }); + }); diff --git a/lib/oxidized/web/webapp.rb b/lib/oxidized/web/webapp.rb index 5e69e3d..d3bbf8f 100644 --- a/lib/oxidized/web/webapp.rb +++ b/lib/oxidized/web/webapp.rb @@ -65,15 +65,35 @@ class WebApp < Sinatra::Base end post '/nodes/conf_search.?:format?' do - @to_research = Regexp.new params[:search_in_conf_textbox] - nodes_list = nodes.list.map + @search_term = params[:search_in_conf_textbox].to_s + redirect url_for('/nodes') if @search_term.empty? + + # regex search is the default; the search form on the results page + # sends 'off' (via a hidden field) when the checkbox is unticked + @regex_search = params[:search_regex_checkbox] != 'off' @nodes_match = [] - nodes_list.each do |n| - node, @json = route_parse n[:name] - config = nodes.fetch node, n[:group] - @nodes_match.push({ node: n[:name], full_name: n[:full_name] }) if config[@to_research] + begin + pattern = @regex_search ? @search_term : Regexp.escape(@search_term) + @to_research = Regexp.new pattern + rescue RegexpError => e + @error = "Invalid regular expression: #{e.message}" + end + + if @error + status 400 + @data = { error: @error } + else + nodes.list.each do |n| + node, @json = route_parse n[:name] + config = convert_to_utf8 nodes.fetch(node, n[:group]).to_s + matches = config_search_matches config, @to_research + next if matches.empty? + + @nodes_match.push({ node: n[:name], full_name: n[:full_name], + matches: matches }) + end + @data = @nodes_match end - @data = @nodes_match out :conf_search end @@ -225,6 +245,9 @@ class WebApp < Sinatra::Base HTML_ESCAPE = { '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''' }.freeze HTML_ESCAPE_ONCE_REGEX = /['"><]|&(?!(?:[a-zA-Z]+|#(?:\d+|[xX][0-9a-fA-F]+));)/ + # lines of context shown around each config search match + CONF_SEARCH_CONTEXT_LINES = 2 + private def out(template = :text) @@ -263,6 +286,60 @@ def route_parse(param) [e.join('.'), json] end + # give one entry per line of config matching regexp, with the 1-based + # line number and a snippet of the line surrounded by up to + # CONF_SEARCH_CONTEXT_LINES lines of context on each side + def config_search_matches(config, regexp) + lines = config.lines.map(&:chomp) + matches = [] + lines.each_with_index do |line, index| + next unless line.match?(regexp) + + from = [index - CONF_SEARCH_CONTEXT_LINES, 0].max + to = [index + CONF_SEARCH_CONTEXT_LINES, lines.length - 1].min + snippet = (from..to).map do |i| + { number: i + 1, text: lines[i], match: i == index } + end + matches.push({ line_number: index + 1, snippet: snippet }) + end + matches + end + + # HTML-escape line, wrapping every regexp match in + def highlight_matches(line, regexp) + html = +'' + pos = 0 + while pos <= line.length && (md = regexp.match(line, pos)) + if md[0].empty? + # zero-width match: emit up to and including the character at the + # match position, so the scan always advances + html << escape_once(line[pos..md.begin(0)]) + pos = md.begin(0) + 1 + else + html << escape_once(line[pos...md.begin(0)]) + html << "#{escape_once(md[0])}" + pos = md.end(0) + end + end + html << escape_once(line[pos..].to_s) + html + end + + # HTML for one config search snippet: line-numbered text with the + # matches highlighted + def snippet_html(match, regexp) + width = match[:snippet].last[:number].to_s.length + match[:snippet].map do |line| + number = line[:number].to_s.rjust(width) + text = if line[:match] + highlight_matches(line[:text], regexp) + else + escape_once(line[:text]) + end + "#{number}: #{text}" + end.join("\n") + end + # give the time elapsed between now and a date (Time object) def time_from_now(date) return "no time specified" if date.nil? diff --git a/spec/web/nodes_spec.rb b/spec/web/nodes_spec.rb index 748c3fc..526d417 100644 --- a/spec/web/nodes_spec.rb +++ b/spec/web/nodes_spec.rb @@ -31,6 +31,90 @@ def app end end + describe '/nodes/conf_search.?:format?' do + before do + config = [ + 'interface ge-0/0/0', + ' description uplink to core', + ' mtu 9000', + 'interface ge-0/0/1', + ' description access port', + 'system {', + ' host-name sw4', + '}' + ].join("\n") + @nodes.stubs(:fetch).returns('no match here') + @nodes.stubs(:fetch).with('sw4', nil).returns(config) + end + + it 'lists only nodes whose configuration matches' do + post '/nodes/conf_search.json', search_in_conf_textbox: 'description' + + _(last_response.ok?).must_equal true + result = JSON.parse(last_response.body) + _(result.length).must_equal 1 + _(result[0]['node']).must_equal 'sw4' + end + + it 'returns a snippet with two lines of context around each match' do + post '/nodes/conf_search.json', search_in_conf_textbox: 'description' + + matches = JSON.parse(last_response.body)[0]['matches'] + _(matches.length).must_equal 2 + + first = matches[0] + _(first['line_number']).must_equal 2 + _(first['snippet'].map { |l| l['number'] }).must_equal [1, 2, 3, 4] + _(first['snippet'][1]['match']).must_equal true + _(first['snippet'][1]['text']).must_equal ' description uplink to core' + _(first['snippet'][0]['match']).must_equal false + + second = matches[1] + _(second['line_number']).must_equal 5 + _(second['snippet'].map { |l| l['number'] }).must_equal [3, 4, 5, 6, 7] + end + + it 'highlights the matched text in the html view' do + post '/nodes/conf_search', search_in_conf_textbox: 'description' + + _(last_response.ok?).must_equal true + _(last_response.body).must_include 'description' + end + + it 'treats the search term as a regular expression by default' do + post '/nodes/conf_search.json', search_in_conf_textbox: 'ge-0/0/.' + + result = JSON.parse(last_response.body) + _(result.length).must_equal 1 + _(result[0]['matches'].map { |m| m['line_number'] }).must_equal [1, 4] + end + + it 'treats the search term as literal text when regex is unticked' do + post '/nodes/conf_search.json', search_in_conf_textbox: 'ge-0/0/.', + search_regex_checkbox: 'off' + + result = JSON.parse(last_response.body) + _(result.length).must_equal 0 + end + + it 'pre-fills the search form with the term and checkbox state' do + post '/nodes/conf_search', search_in_conf_textbox: 'description' + + _(last_response.ok?).must_equal true + _(last_response.body).must_include "value='description'" + _(last_response.body).must_include "name='search_regex_checkbox'" + _(last_response.body).must_include 'checked' + end + + it 'keeps the regex checkbox unticked after a literal search' do + post '/nodes/conf_search', search_in_conf_textbox: 'description', + search_regex_checkbox: 'off' + + _(last_response.ok?).must_equal true + _(last_response.body).wont_include 'checked' + end + end + describe '/nodes/:filter/*' do it 'shows all nodes of a group' do get '/nodes/group/group1.json' @@ -62,3 +146,28 @@ def app end end end + +describe Oxidized::API::WebApp do + include Rack::Test::Methods + + def app + Oxidized::API::WebApp + end + + describe '/nodes/conf_search.?:format?' do + it 'redirects to /nodes when the search is empty' do + post '/nodes/conf_search', search_in_conf_textbox: '' + + _(last_response.redirect?).must_equal true + end + + it 'rejects an invalid regular expression' do + post '/nodes/conf_search.json', search_in_conf_textbox: '(', + search_regex_checkbox: 'on' + + _(last_response.status).must_equal 400 + result = JSON.parse(last_response.body) + _(result['error']).must_match(/Invalid regular expression/) + end + end +end From 56d3851cfce680cb7094399adcad1764e8d9079e Mon Sep 17 00:00:00 2001 From: Boden Garman Date: Wed, 29 Jul 2026 08:49:40 +1000 Subject: [PATCH 2/3] Add case sensitivity option to config search --- lib/oxidized/web/views/conf_search.haml | 7 ++++++ lib/oxidized/web/webapp.rb | 6 ++++- spec/web/nodes_spec.rb | 30 +++++++++++++++++++++++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/oxidized/web/views/conf_search.haml b/lib/oxidized/web/views/conf_search.haml index be9c1cd..bd34570 100644 --- a/lib/oxidized/web/views/conf_search.haml +++ b/lib/oxidized/web/views/conf_search.haml @@ -29,6 +29,13 @@ value: 'on', checked: @regex_search} %label.form-check-label{for: 'conf-search-regex'} Use regular expression + .form-check + %input{type: 'hidden', name: 'search_case_sensitive_checkbox', value: 'off'} + %input.form-check-input#conf-search-case-sensitive{type: 'checkbox', + name: 'search_case_sensitive_checkbox', + value: 'on', + checked: @case_sensitive_search} + %label.form-check-label{for: 'conf-search-case-sensitive'} Case sensitive - if @error .row diff --git a/lib/oxidized/web/webapp.rb b/lib/oxidized/web/webapp.rb index d3bbf8f..fb9e16f 100644 --- a/lib/oxidized/web/webapp.rb +++ b/lib/oxidized/web/webapp.rb @@ -71,10 +71,14 @@ class WebApp < Sinatra::Base # regex search is the default; the search form on the results page # sends 'off' (via a hidden field) when the checkbox is unticked @regex_search = params[:search_regex_checkbox] != 'off' + # preserve the existing case-sensitive default; the search form sends + # 'off' (via a hidden field) when the checkbox is unticked + @case_sensitive_search = params[:search_case_sensitive_checkbox] != 'off' @nodes_match = [] begin pattern = @regex_search ? @search_term : Regexp.escape(@search_term) - @to_research = Regexp.new pattern + options = @case_sensitive_search ? 0 : Regexp::IGNORECASE + @to_research = Regexp.new pattern, options rescue RegexpError => e @error = "Invalid regular expression: #{e.message}" end diff --git a/spec/web/nodes_spec.rb b/spec/web/nodes_spec.rb index 526d417..b1811cb 100644 --- a/spec/web/nodes_spec.rb +++ b/spec/web/nodes_spec.rb @@ -97,13 +97,31 @@ def app _(result.length).must_equal 0 end + it 'treats the search term as case-sensitive by default' do + post '/nodes/conf_search.json', search_in_conf_textbox: 'DESCRIPTION' + + result = JSON.parse(last_response.body) + _(result.length).must_equal 0 + end + + it 'matches case-insensitively when case sensitivity is unticked' do + post '/nodes/conf_search.json', search_in_conf_textbox: 'DESCRIPTION', + search_case_sensitive_checkbox: 'off' + + result = JSON.parse(last_response.body) + _(result.length).must_equal 1 + _(result[0]['matches'].map { |m| m['line_number'] }).must_equal [2, 5] + end + it 'pre-fills the search form with the term and checkbox state' do post '/nodes/conf_search', search_in_conf_textbox: 'description' _(last_response.ok?).must_equal true _(last_response.body).must_include "value='description'" _(last_response.body).must_include "name='search_regex_checkbox'" - _(last_response.body).must_include 'checked' + _(last_response.body).must_include "name='search_case_sensitive_checkbox'" + _(last_response.body).must_match(/]*\bid='conf-search-regex')(?=[^>]*\bchecked)[^>]*>/) + _(last_response.body).must_match(/]*\bid='conf-search-case-sensitive')(?=[^>]*\bchecked)[^>]*>/) end it 'keeps the regex checkbox unticked after a literal search' do @@ -111,7 +129,15 @@ def app search_regex_checkbox: 'off' _(last_response.ok?).must_equal true - _(last_response.body).wont_include 'checked' + _(last_response.body).wont_match(/]*\bid='conf-search-regex')(?=[^>]*\bchecked)[^>]*>/) + end + + it 'keeps the case-sensitive checkbox unticked after an insensitive search' do + post '/nodes/conf_search', search_in_conf_textbox: 'description', + search_case_sensitive_checkbox: 'off' + + _(last_response.ok?).must_equal true + _(last_response.body).wont_match(/]*\bid='conf-search-case-sensitive')(?=[^>]*\bchecked)[^>]*>/) end end From 37dc0359b029516e37c12b98be92a4c66f5361b9 Mon Sep 17 00:00:00 2001 From: Boden Garman Date: Wed, 29 Jul 2026 09:31:14 +1000 Subject: [PATCH 3/3] Merge nearby config search snippets --- CHANGELOG.md | 5 +++-- lib/oxidized/web/webapp.rb | 46 +++++++++++++++++++++++++++----------- spec/web/nodes_spec.rb | 35 ++++++++++++++++++++++++----- 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b873905..38140cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added -- Config search results show each matching line with two lines of context, - line numbers and the match highlighted (@bpbp-boop) +- Config search results show matching lines with two lines of context, line + numbers and highlighted matches. Overlapping or touching context windows are + merged into a single snippet (@bpbp-boop) - Search form on the results page, with an option to search for literal text instead of a regular expression (@bpbp-boop) diff --git a/lib/oxidized/web/webapp.rb b/lib/oxidized/web/webapp.rb index fb9e16f..c59f79c 100644 --- a/lib/oxidized/web/webapp.rb +++ b/lib/oxidized/web/webapp.rb @@ -290,23 +290,43 @@ def route_parse(param) [e.join('.'), json] end - # give one entry per line of config matching regexp, with the 1-based - # line number and a snippet of the line surrounded by up to - # CONF_SEARCH_CONTEXT_LINES lines of context on each side + # Give one entry per distinct region of the config matching +regexp+. + # Context windows that overlap or touch are merged so nearby matches do + # not return duplicate snippets. +line_number+ remains the first match + # for API compatibility; +line_numbers+ contains every match in the + # merged region. def config_search_matches(config, regexp) lines = config.lines.map(&:chomp) - matches = [] - lines.each_with_index do |line, index| - next unless line.match?(regexp) - - from = [index - CONF_SEARCH_CONTEXT_LINES, 0].max - to = [index + CONF_SEARCH_CONTEXT_LINES, lines.length - 1].min - snippet = (from..to).map do |i| - { number: i + 1, text: lines[i], match: i == index } + match_indexes = lines.each_index.select { |index| lines[index].match?(regexp) } + return [] if match_indexes.empty? + + regions = match_indexes.each_with_object([]) do |index, merged| + region = { + from: [index - CONF_SEARCH_CONTEXT_LINES, 0].max, + to: [index + CONF_SEARCH_CONTEXT_LINES, lines.length - 1].min, + matches: [index] + } + + if merged.any? && region[:from] <= merged.last[:to] + 1 + merged.last[:to] = [merged.last[:to], region[:to]].max + merged.last[:matches] << index + else + merged << region + end + end + + regions.map do |region| + matching_lines = region[:matches].to_h { |index| [index, true] } + snippet = (region[:from]..region[:to]).map do |index| + { number: index + 1, text: lines[index], match: matching_lines.has_key?(index) } end - matches.push({ line_number: index + 1, snippet: snippet }) + line_numbers = region[:matches].map { |index| index + 1 } + { + line_number: line_numbers.first, + line_numbers: line_numbers, + snippet: snippet + } end - matches end # HTML-escape line, wrapping every regexp match in diff --git a/spec/web/nodes_spec.rb b/spec/web/nodes_spec.rb index b1811cb..6c39614 100644 --- a/spec/web/nodes_spec.rb +++ b/spec/web/nodes_spec.rb @@ -41,6 +41,14 @@ def app ' description access port', 'system {', ' host-name sw4', + '}', + 'routing-options {', + ' autonomous-system 64512;', + ' router-id 192.0.2.1;', + '}', + 'policy-options {', + ' description primary policy', + ' description secondary policy', '}' ].join("\n") @nodes.stubs(:fetch).returns('no match here') @@ -56,7 +64,7 @@ def app _(result[0]['node']).must_equal 'sw4' end - it 'returns a snippet with two lines of context around each match' do + it 'merges overlapping context into one snippet' do post '/nodes/conf_search.json', search_in_conf_textbox: 'description' matches = JSON.parse(last_response.body)[0]['matches'] @@ -64,14 +72,27 @@ def app first = matches[0] _(first['line_number']).must_equal 2 - _(first['snippet'].map { |l| l['number'] }).must_equal [1, 2, 3, 4] + _(first['line_numbers']).must_equal [2, 5] + _(first['snippet'].map { |l| l['number'] }).must_equal [1, 2, 3, 4, 5, 6, 7] _(first['snippet'][1]['match']).must_equal true _(first['snippet'][1]['text']).must_equal ' description uplink to core' _(first['snippet'][0]['match']).must_equal false + _(first['snippet'][4]['match']).must_equal true second = matches[1] - _(second['line_number']).must_equal 5 - _(second['snippet'].map { |l| l['number'] }).must_equal [3, 4, 5, 6, 7] + _(second['line_number']).must_equal 14 + _(second['line_numbers']).must_equal [14, 15] + _(second['snippet'].map { |l| l['number'] }).must_equal [12, 13, 14, 15, 16] + _(second['snippet'].select { |l| l['match'] }.map { |l| l['number'] }).must_equal [14, 15] + end + + it 'merges context windows that touch without overlapping' do + post '/nodes/conf_search.json', search_in_conf_textbox: 'uplink|host-name' + + matches = JSON.parse(last_response.body)[0]['matches'] + _(matches.length).must_equal 1 + _(matches[0]['line_numbers']).must_equal [2, 7] + _(matches[0]['snippet'].map { |l| l['number'] }).must_equal (1..9).to_a end it 'highlights the matched text in the html view' do @@ -86,7 +107,9 @@ def app result = JSON.parse(last_response.body) _(result.length).must_equal 1 - _(result[0]['matches'].map { |m| m['line_number'] }).must_equal [1, 4] + _(result[0]['matches'].length).must_equal 1 + _(result[0]['matches'][0]['line_number']).must_equal 1 + _(result[0]['matches'][0]['line_numbers']).must_equal [1, 4] end it 'treats the search term as literal text when regex is unticked' do @@ -110,7 +133,7 @@ def app result = JSON.parse(last_response.body) _(result.length).must_equal 1 - _(result[0]['matches'].map { |m| m['line_number'] }).must_equal [2, 5] + _(result[0]['matches'].map { |m| m['line_numbers'] }).must_equal [[2, 5], [14, 15]] end it 'pre-fills the search form with the term and checkbox state' do