diff --git a/CHANGELOG.md b/CHANGELOG.md index 251168b..38140cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- 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) ### 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..bd34570 100644 --- a/lib/oxidized/web/views/conf_search.haml +++ b/lib/oxidized/web/views/conf_search.haml @@ -2,37 +2,71 @@ .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 + .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 - %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..c59f79c 100644 --- a/lib/oxidized/web/webapp.rb +++ b/lib/oxidized/web/webapp.rb @@ -65,15 +65,39 @@ 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' + # 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 = [] - 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) + options = @case_sensitive_search ? 0 : Regexp::IGNORECASE + @to_research = Regexp.new pattern, options + 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 +249,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 +290,80 @@ def route_parse(param) [e.join('.'), json] end + # 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) + 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 + line_numbers = region[:matches].map { |index| index + 1 } + { + line_number: line_numbers.first, + line_numbers: line_numbers, + snippet: snippet + } + end + 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..6c39614 100644 --- a/spec/web/nodes_spec.rb +++ b/spec/web/nodes_spec.rb @@ -31,6 +31,139 @@ 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', + '}', + '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') + @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 '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'] + _(matches.length).must_equal 2 + + first = matches[0] + _(first['line_number']).must_equal 2 + _(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 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 + 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'].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 + 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 '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_numbers'] }).must_equal [[2, 5], [14, 15]] + 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 "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 + post '/nodes/conf_search', search_in_conf_textbox: 'description', + search_regex_checkbox: 'off' + + _(last_response.ok?).must_equal true + _(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 + describe '/nodes/:filter/*' do it 'shows all nodes of a group' do get '/nodes/group/group1.json' @@ -62,3 +195,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