sumologic-query 1.3.1 → 1.3.3
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 +4 -4
- data/CHANGELOG.md +152 -2
- data/README.md +1 -0
- data/lib/sumologic/cli/commands/base_command.rb +51 -0
- data/lib/sumologic/cli/commands/list_collectors_command.rb +22 -0
- data/lib/sumologic/cli/commands/list_sources_command.rb +53 -0
- data/lib/sumologic/cli/commands/search_command.rb +88 -0
- data/lib/sumologic/cli.rb +12 -149
- data/lib/sumologic/http/client.rb +27 -50
- data/lib/sumologic/http/connection_pool.rb +14 -0
- data/lib/sumologic/http/cookie_jar.rb +52 -0
- data/lib/sumologic/http/debug_logger.rb +45 -0
- data/lib/sumologic/http/request_builder.rb +63 -0
- data/lib/sumologic/http/response_handler.rb +42 -0
- data/lib/sumologic/interactive/fzf_viewer/config.rb +38 -0
- data/lib/sumologic/interactive/fzf_viewer/formatter.rb +50 -0
- data/lib/sumologic/interactive/fzf_viewer/fzf_config.rb +74 -0
- data/lib/sumologic/interactive/fzf_viewer/header_builder.rb +42 -0
- data/lib/sumologic/interactive/fzf_viewer/searchable_builder.rb +40 -0
- data/lib/sumologic/interactive/fzf_viewer.rb +39 -114
- data/lib/sumologic/version.rb +1 -1
- metadata +15 -2
|
@@ -1,30 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'net/http'
|
|
4
|
-
require 'json'
|
|
5
|
-
require 'uri'
|
|
6
3
|
require_relative 'connection_pool'
|
|
4
|
+
require_relative 'debug_logger'
|
|
5
|
+
require_relative 'cookie_jar'
|
|
6
|
+
require_relative 'request_builder'
|
|
7
|
+
require_relative 'response_handler'
|
|
7
8
|
|
|
8
9
|
module Sumologic
|
|
9
10
|
module Http
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# Orchestrates HTTP communication with Sumo Logic API
|
|
12
|
+
# Delegates to specialized components for request building,
|
|
13
|
+
# response handling, connection pooling, and cookie management
|
|
13
14
|
class Client
|
|
14
15
|
def initialize(base_url:, authenticator:)
|
|
15
|
-
@
|
|
16
|
-
@
|
|
16
|
+
@cookie_jar = CookieJar.new
|
|
17
|
+
@request_builder = RequestBuilder.new(
|
|
18
|
+
base_url: base_url,
|
|
19
|
+
authenticator: authenticator,
|
|
20
|
+
cookie_jar: @cookie_jar
|
|
21
|
+
)
|
|
22
|
+
@response_handler = ResponseHandler.new
|
|
17
23
|
@connection_pool = ConnectionPool.new(base_url: base_url, max_connections: 10)
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
# Execute HTTP request with error handling
|
|
21
27
|
# Uses connection pool for thread-safe parallel execution
|
|
22
28
|
def request(method:, path:, body: nil, query_params: nil)
|
|
23
|
-
uri = build_uri(path, query_params)
|
|
24
|
-
request = build_request(method, uri, body)
|
|
29
|
+
uri = @request_builder.build_uri(path, query_params)
|
|
30
|
+
request = @request_builder.build_request(method, uri, body)
|
|
31
|
+
|
|
32
|
+
DebugLogger.log_request(method, uri, body)
|
|
25
33
|
|
|
26
34
|
response = execute_request(uri, request)
|
|
27
|
-
|
|
35
|
+
|
|
36
|
+
DebugLogger.log_response(response)
|
|
37
|
+
|
|
38
|
+
@response_handler.handle(response)
|
|
28
39
|
rescue Errno::ECONNRESET, Errno::EPIPE, EOFError, Net::HTTPBadResponse => e
|
|
29
40
|
# Connection error - raise for retry at higher level
|
|
30
41
|
raise Error, "Connection error: #{e.message}"
|
|
@@ -37,49 +48,15 @@ module Sumologic
|
|
|
37
48
|
|
|
38
49
|
private
|
|
39
50
|
|
|
40
|
-
def build_uri(path, query_params)
|
|
41
|
-
uri = URI("#{@base_url}#{path}")
|
|
42
|
-
uri.query = URI.encode_www_form(query_params) if query_params
|
|
43
|
-
uri
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def build_request(method, uri, body)
|
|
47
|
-
request_class = case method
|
|
48
|
-
when :get then Net::HTTP::Get
|
|
49
|
-
when :post then Net::HTTP::Post
|
|
50
|
-
when :delete then Net::HTTP::Delete
|
|
51
|
-
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
request = request_class.new(uri)
|
|
55
|
-
request['Authorization'] = @authenticator.auth_header
|
|
56
|
-
request['Accept'] = 'application/json'
|
|
57
|
-
|
|
58
|
-
if body
|
|
59
|
-
request['Content-Type'] = 'application/json'
|
|
60
|
-
request.body = body.to_json
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
request
|
|
64
|
-
end
|
|
65
|
-
|
|
66
51
|
def execute_request(uri, request)
|
|
67
|
-
@connection_pool.with_connection(uri) do |http|
|
|
52
|
+
response = @connection_pool.with_connection(uri) do |http|
|
|
68
53
|
http.request(request)
|
|
69
54
|
end
|
|
70
|
-
end
|
|
71
55
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
when 401, 403
|
|
77
|
-
raise AuthenticationError, "Authentication failed: #{response.body}"
|
|
78
|
-
when 429
|
|
79
|
-
raise Error, "Rate limit exceeded: #{response.body}"
|
|
80
|
-
else
|
|
81
|
-
raise Error, "HTTP #{response.code}: #{response.body}"
|
|
82
|
-
end
|
|
56
|
+
# Store cookies from response for subsequent requests
|
|
57
|
+
@cookie_jar.store_from_response(response)
|
|
58
|
+
|
|
59
|
+
response
|
|
83
60
|
end
|
|
84
61
|
end
|
|
85
62
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
3
5
|
module Sumologic
|
|
4
6
|
module Http
|
|
5
7
|
# Thread-safe connection pool for HTTP clients
|
|
@@ -84,10 +86,22 @@ module Sumologic
|
|
|
84
86
|
http.read_timeout = READ_TIMEOUT
|
|
85
87
|
http.open_timeout = OPEN_TIMEOUT
|
|
86
88
|
http.keep_alive_timeout = 30
|
|
89
|
+
|
|
90
|
+
# SSL configuration
|
|
91
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
92
|
+
http.cert_store = ssl_cert_store
|
|
93
|
+
|
|
87
94
|
http.start
|
|
88
95
|
http
|
|
89
96
|
end
|
|
90
97
|
|
|
98
|
+
def ssl_cert_store
|
|
99
|
+
# Use system's default certificate store
|
|
100
|
+
store = OpenSSL::X509::Store.new
|
|
101
|
+
store.set_default_paths
|
|
102
|
+
store
|
|
103
|
+
end
|
|
104
|
+
|
|
91
105
|
def create_temporary_connection(uri)
|
|
92
106
|
# Fallback: create a temporary connection if pool is exhausted
|
|
93
107
|
create_connection(uri)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sumologic
|
|
4
|
+
module Http
|
|
5
|
+
# Simple cookie jar for storing and managing HTTP cookies
|
|
6
|
+
# Handles Set-Cookie response headers and Cookie request headers
|
|
7
|
+
class CookieJar
|
|
8
|
+
def initialize
|
|
9
|
+
@cookies = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Store cookies from response Set-Cookie headers
|
|
13
|
+
def store_from_response(response)
|
|
14
|
+
return unless response['Set-Cookie']
|
|
15
|
+
|
|
16
|
+
Array(response['Set-Cookie']).each do |cookie_header|
|
|
17
|
+
parse_and_store(cookie_header)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Format cookies for Cookie request header
|
|
22
|
+
# Returns nil if no cookies stored
|
|
23
|
+
def to_header
|
|
24
|
+
return nil if @cookies.empty?
|
|
25
|
+
|
|
26
|
+
@cookies.map { |name, value| "#{name}=#{value}" }.join('; ')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if any cookies are stored
|
|
30
|
+
def any?
|
|
31
|
+
@cookies.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Clear all stored cookies
|
|
35
|
+
def clear
|
|
36
|
+
@cookies.clear
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def parse_and_store(cookie_header)
|
|
42
|
+
# Parse cookie name=value (ignore path, domain, expires, etc.)
|
|
43
|
+
# Example: "session_id=abc123; Path=/; HttpOnly"
|
|
44
|
+
return unless cookie_header =~ /^([^=]+)=([^;]+)/
|
|
45
|
+
|
|
46
|
+
name = Regexp.last_match(1).strip
|
|
47
|
+
value = Regexp.last_match(2).strip
|
|
48
|
+
@cookies[name] = value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Sumologic
|
|
6
|
+
module Http
|
|
7
|
+
# Handles debug logging for HTTP requests and responses
|
|
8
|
+
# Only logs when $DEBUG is enabled
|
|
9
|
+
module DebugLogger
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def log_request(method, uri, body)
|
|
13
|
+
return unless $DEBUG
|
|
14
|
+
|
|
15
|
+
warn "\n[DEBUG] API Request:"
|
|
16
|
+
warn " Method: #{method.to_s.upcase}"
|
|
17
|
+
warn " URL: #{uri}"
|
|
18
|
+
log_request_body(body) if body
|
|
19
|
+
warn ''
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def log_response(response)
|
|
23
|
+
return unless $DEBUG
|
|
24
|
+
|
|
25
|
+
warn '[DEBUG] API Response:'
|
|
26
|
+
warn " Status: #{response.code} #{response.message}"
|
|
27
|
+
log_response_body(response.body)
|
|
28
|
+
warn ''
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def log_request_body(body)
|
|
32
|
+
warn " Body: #{JSON.pretty_generate(body)}"
|
|
33
|
+
rescue JSON::GeneratorError
|
|
34
|
+
warn " Body: #{body.inspect}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def log_response_body(body)
|
|
38
|
+
truncated = body.length > 500
|
|
39
|
+
display_body = truncated ? "#{body[0..500]}..." : body
|
|
40
|
+
warn " Body: #{display_body}"
|
|
41
|
+
warn " (truncated, full length: #{body.length} characters)" if truncated
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Sumologic
|
|
8
|
+
module Http
|
|
9
|
+
# Builds HTTP requests with proper headers, authentication, and cookies
|
|
10
|
+
class RequestBuilder
|
|
11
|
+
def initialize(base_url:, authenticator:, cookie_jar:)
|
|
12
|
+
@base_url = base_url
|
|
13
|
+
@authenticator = authenticator
|
|
14
|
+
@cookie_jar = cookie_jar
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Build complete URI from path and query parameters
|
|
18
|
+
def build_uri(path, query_params = nil)
|
|
19
|
+
uri = URI("#{@base_url}#{path}")
|
|
20
|
+
uri.query = URI.encode_www_form(query_params) if query_params
|
|
21
|
+
uri
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Build HTTP request with all necessary headers
|
|
25
|
+
def build_request(method, uri, body = nil)
|
|
26
|
+
request = create_request_object(method, uri)
|
|
27
|
+
add_headers(request)
|
|
28
|
+
add_body(request, body) if body
|
|
29
|
+
request
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def create_request_object(method, uri)
|
|
35
|
+
request_class = request_class_for(method)
|
|
36
|
+
request_class.new(uri)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def request_class_for(method)
|
|
40
|
+
case method
|
|
41
|
+
when :get then Net::HTTP::Get
|
|
42
|
+
when :post then Net::HTTP::Post
|
|
43
|
+
when :delete then Net::HTTP::Delete
|
|
44
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def add_headers(request)
|
|
49
|
+
request['Authorization'] = @authenticator.auth_header
|
|
50
|
+
request['Accept'] = 'application/json'
|
|
51
|
+
|
|
52
|
+
# Add cookies if available
|
|
53
|
+
cookie_header = @cookie_jar.to_header
|
|
54
|
+
request['Cookie'] = cookie_header if cookie_header
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add_body(request, body)
|
|
58
|
+
request['Content-Type'] = 'application/json'
|
|
59
|
+
request.body = body.to_json
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Sumologic
|
|
6
|
+
module Http
|
|
7
|
+
# Handles HTTP response parsing and error handling
|
|
8
|
+
class ResponseHandler
|
|
9
|
+
# Parse response and handle errors
|
|
10
|
+
def handle(response)
|
|
11
|
+
case response.code.to_i
|
|
12
|
+
when 200..299
|
|
13
|
+
parse_success(response)
|
|
14
|
+
when 401, 403
|
|
15
|
+
handle_authentication_error(response)
|
|
16
|
+
when 429
|
|
17
|
+
handle_rate_limit_error(response)
|
|
18
|
+
else
|
|
19
|
+
handle_generic_error(response)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def parse_success(response)
|
|
26
|
+
JSON.parse(response.body)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def handle_authentication_error(response)
|
|
30
|
+
raise AuthenticationError, "Authentication failed: #{response.body}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handle_rate_limit_error(response)
|
|
34
|
+
raise Error, "Rate limit exceeded: #{response.body}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_generic_error(response)
|
|
38
|
+
raise Error, "HTTP #{response.code}: #{response.body}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sumologic
|
|
4
|
+
module Interactive
|
|
5
|
+
class FzfViewer
|
|
6
|
+
module Config
|
|
7
|
+
# Display configuration
|
|
8
|
+
TIME_WIDTH = 8
|
|
9
|
+
LEVEL_WIDTH = 7
|
|
10
|
+
SOURCE_WIDTH = 25
|
|
11
|
+
MESSAGE_PREVIEW_LENGTH = 80
|
|
12
|
+
SEARCHABLE_PADDING = 5
|
|
13
|
+
|
|
14
|
+
# Searchable field names
|
|
15
|
+
SEARCHABLE_FIELDS = %w[
|
|
16
|
+
_source
|
|
17
|
+
_sourcecategory
|
|
18
|
+
_sourcename
|
|
19
|
+
_collector
|
|
20
|
+
_sourcehost
|
|
21
|
+
region
|
|
22
|
+
_group
|
|
23
|
+
_tier
|
|
24
|
+
_view
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# ANSI color codes
|
|
28
|
+
COLORS = {
|
|
29
|
+
red: "\e[31m",
|
|
30
|
+
yellow: "\e[33m",
|
|
31
|
+
cyan: "\e[36m",
|
|
32
|
+
gray: "\e[90m",
|
|
33
|
+
reset: "\e[0m"
|
|
34
|
+
}.freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sumologic
|
|
4
|
+
module Interactive
|
|
5
|
+
class FzfViewer
|
|
6
|
+
module Formatter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def format_time(timestamp_ms)
|
|
10
|
+
return 'N/A' unless timestamp_ms
|
|
11
|
+
|
|
12
|
+
Time.at(timestamp_ms.to_i / 1000).strftime('%H:%M:%S')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format_level(level)
|
|
16
|
+
level_str = level.to_s.upcase.ljust(Config::LEVEL_WIDTH)
|
|
17
|
+
colorize_level(level_str)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def colorize_level(level_str)
|
|
21
|
+
case level_str.strip
|
|
22
|
+
when 'ERROR', 'FATAL', 'CRITICAL'
|
|
23
|
+
"#{Config::COLORS[:red]}#{level_str}#{Config::COLORS[:reset]}"
|
|
24
|
+
when 'WARN', 'WARNING'
|
|
25
|
+
"#{Config::COLORS[:yellow]}#{level_str}#{Config::COLORS[:reset]}"
|
|
26
|
+
when 'INFO'
|
|
27
|
+
"#{Config::COLORS[:cyan]}#{level_str}#{Config::COLORS[:reset]}"
|
|
28
|
+
when 'DEBUG', 'TRACE'
|
|
29
|
+
"#{Config::COLORS[:gray]}#{level_str}#{Config::COLORS[:reset]}"
|
|
30
|
+
else
|
|
31
|
+
level_str
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def sanitize(text)
|
|
36
|
+
text.to_s.gsub(/[\n\r\t]/, ' ').squeeze(' ')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def truncate(text, length)
|
|
40
|
+
text = text.to_s
|
|
41
|
+
text.length > length ? "#{text[0...(length - 3)]}..." : text
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pad(text, width)
|
|
45
|
+
text.ljust(width)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Sumologic
|
|
6
|
+
module Interactive
|
|
7
|
+
class FzfViewer
|
|
8
|
+
module FzfConfig
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def build_fzf_args(input_path, preview_path, header_text)
|
|
12
|
+
[
|
|
13
|
+
'fzf',
|
|
14
|
+
*search_options,
|
|
15
|
+
*display_options(preview_path, header_text),
|
|
16
|
+
*keybinding_options(input_path, preview_path)
|
|
17
|
+
]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def search_options
|
|
21
|
+
[
|
|
22
|
+
'--ansi',
|
|
23
|
+
'--multi',
|
|
24
|
+
'--exact', # Exact substring matching
|
|
25
|
+
'-i', # Case-insensitive
|
|
26
|
+
'--no-hscroll' # Prevent horizontal scrolling
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_options(preview_path, header_text)
|
|
31
|
+
[
|
|
32
|
+
"--header=#{header_text}",
|
|
33
|
+
"--preview=#{build_preview_command(preview_path)}",
|
|
34
|
+
'--preview-window=right:60%:wrap:follow',
|
|
35
|
+
'--height=100%'
|
|
36
|
+
]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def keybinding_options(input_path, preview_path)
|
|
40
|
+
[
|
|
41
|
+
'--bind=enter:toggle',
|
|
42
|
+
"--bind=tab:execute(#{build_view_command(preview_path)})",
|
|
43
|
+
'--bind=ctrl-a:select-all',
|
|
44
|
+
'--bind=ctrl-d:deselect-all',
|
|
45
|
+
'--bind=ctrl-s:execute-silent(echo {+} > sumo-selected.txt)+abort',
|
|
46
|
+
'--bind=ctrl-y:execute-silent(echo {+} | pbcopy || ' \
|
|
47
|
+
'echo {+} | xclip -selection clipboard 2>/dev/null)+abort',
|
|
48
|
+
'--bind=ctrl-e:execute-silent(echo {+} > sumo-export.jsonl)+abort',
|
|
49
|
+
'--bind=ctrl-/:toggle-preview',
|
|
50
|
+
"--bind=ctrl-r:reload(cat #{input_path})",
|
|
51
|
+
'--bind=ctrl-t:toggle-search',
|
|
52
|
+
'--bind=ctrl-q:abort'
|
|
53
|
+
]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_view_command(preview_path)
|
|
57
|
+
# FZF {n} is 0-indexed, sed is 1-indexed
|
|
58
|
+
'LINE=$(({n} + 1)); ' \
|
|
59
|
+
"sed -n \"$LINE\"p #{Shellwords.escape(preview_path)} | jq -C . | less -R"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_preview_command(preview_path)
|
|
63
|
+
escaped_path = Shellwords.escape(preview_path)
|
|
64
|
+
|
|
65
|
+
calc = "LINE=$(({n} + 1)); TOTAL=$(wc -l < #{escaped_path}); "
|
|
66
|
+
display = 'echo "Message $LINE of $TOTAL"; echo ""; '
|
|
67
|
+
extract = "sed -n \"$LINE\"p #{escaped_path}"
|
|
68
|
+
|
|
69
|
+
"#{calc}#{display}#{extract} | jq -C . || #{extract}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sumologic
|
|
4
|
+
module Interactive
|
|
5
|
+
class FzfViewer
|
|
6
|
+
module HeaderBuilder
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def build_header_text(results, messages)
|
|
10
|
+
[
|
|
11
|
+
build_column_headers,
|
|
12
|
+
build_info_line(results, messages),
|
|
13
|
+
build_search_tips,
|
|
14
|
+
build_keybindings_help
|
|
15
|
+
].join("\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build_column_headers
|
|
19
|
+
"#{Formatter.pad('TIME', Config::TIME_WIDTH)} " \
|
|
20
|
+
"#{Formatter.pad('LEVEL', Config::LEVEL_WIDTH)} " \
|
|
21
|
+
"#{Formatter.pad('SOURCE', Config::SOURCE_WIDTH)} MESSAGE"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_info_line(results, messages)
|
|
25
|
+
query = results['query'] || 'N/A'
|
|
26
|
+
count = messages.size
|
|
27
|
+
sources = messages.map { |m| m['map']['_source'] }.compact.uniq.size
|
|
28
|
+
|
|
29
|
+
"#{count} msgs | #{sources} sources | Query: #{Formatter.truncate(query, 40)}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_search_tips
|
|
33
|
+
'💡 Simple text search (case-insensitive) - searches all JSON fields and log content'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_keybindings_help
|
|
37
|
+
'Enter=select Tab=view Ctrl-T=toggle-search Ctrl-S=save Ctrl-Y=copy Ctrl-E=export Ctrl-Q=quit'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sumologic
|
|
4
|
+
module Interactive
|
|
5
|
+
class FzfViewer
|
|
6
|
+
module SearchableBuilder
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def build_searchable_content(map)
|
|
10
|
+
parts = []
|
|
11
|
+
|
|
12
|
+
add_primary_content(parts, map)
|
|
13
|
+
add_standard_fields(parts, map)
|
|
14
|
+
add_custom_fields(parts, map)
|
|
15
|
+
|
|
16
|
+
parts.compact.join(' ')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add_primary_content(parts, map)
|
|
20
|
+
parts << Formatter.sanitize(map['_raw'] || map['message'] || '')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add_standard_fields(parts, map)
|
|
24
|
+
Config::SEARCHABLE_FIELDS.each do |field|
|
|
25
|
+
parts << map[field] if map[field]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_custom_fields(parts, map)
|
|
30
|
+
map.each do |key, value|
|
|
31
|
+
next if key.start_with?('_')
|
|
32
|
+
next if value.nil? || value.to_s.empty?
|
|
33
|
+
|
|
34
|
+
parts << "#{key}:#{value}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|