binocs 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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +528 -0
- data/Rakefile +7 -0
- data/app/assets/javascripts/binocs/application.js +105 -0
- data/app/assets/stylesheets/binocs/application.css +67 -0
- data/app/channels/binocs/application_cable/channel.rb +8 -0
- data/app/channels/binocs/application_cable/connection.rb +8 -0
- data/app/channels/binocs/requests_channel.rb +13 -0
- data/app/controllers/binocs/application_controller.rb +62 -0
- data/app/controllers/binocs/requests_controller.rb +69 -0
- data/app/helpers/binocs/application_helper.rb +61 -0
- data/app/models/binocs/application_record.rb +7 -0
- data/app/models/binocs/request.rb +198 -0
- data/app/views/binocs/requests/_empty_list.html.erb +9 -0
- data/app/views/binocs/requests/_request.html.erb +61 -0
- data/app/views/binocs/requests/index.html.erb +115 -0
- data/app/views/binocs/requests/show.html.erb +227 -0
- data/app/views/layouts/binocs/application.html.erb +109 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20240101000000_create_binocs_requests.rb +36 -0
- data/exe/binocs +86 -0
- data/lib/binocs/agent.rb +153 -0
- data/lib/binocs/agent_context.rb +165 -0
- data/lib/binocs/agent_manager.rb +302 -0
- data/lib/binocs/configuration.rb +65 -0
- data/lib/binocs/engine.rb +61 -0
- data/lib/binocs/log_subscriber.rb +56 -0
- data/lib/binocs/middleware/request_recorder.rb +264 -0
- data/lib/binocs/swagger/client.rb +100 -0
- data/lib/binocs/swagger/path_matcher.rb +118 -0
- data/lib/binocs/tui/agent_output.rb +163 -0
- data/lib/binocs/tui/agents_list.rb +195 -0
- data/lib/binocs/tui/app.rb +726 -0
- data/lib/binocs/tui/colors.rb +115 -0
- data/lib/binocs/tui/filter_menu.rb +162 -0
- data/lib/binocs/tui/help_screen.rb +93 -0
- data/lib/binocs/tui/request_detail.rb +899 -0
- data/lib/binocs/tui/request_list.rb +268 -0
- data/lib/binocs/tui/spirit_animal.rb +235 -0
- data/lib/binocs/tui/window.rb +98 -0
- data/lib/binocs/tui.rb +24 -0
- data/lib/binocs/version.rb +5 -0
- data/lib/binocs.rb +27 -0
- data/lib/generators/binocs/install/install_generator.rb +61 -0
- data/lib/generators/binocs/install/templates/create_binocs_requests.rb +36 -0
- data/lib/generators/binocs/install/templates/initializer.rb +25 -0
- data/lib/tasks/binocs_tasks.rake +38 -0
- metadata +149 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binocs
|
|
4
|
+
module TUI
|
|
5
|
+
class RequestList < Window
|
|
6
|
+
attr_accessor :requests, :selected_index, :scroll_offset
|
|
7
|
+
attr_reader :filters, :search_query
|
|
8
|
+
|
|
9
|
+
def initialize(height:, width:, top:, left:)
|
|
10
|
+
super
|
|
11
|
+
@requests = []
|
|
12
|
+
@selected_index = 0
|
|
13
|
+
@scroll_offset = 0
|
|
14
|
+
@filters = {}
|
|
15
|
+
@search_query = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def load_requests
|
|
19
|
+
scope = Binocs::Request.recent
|
|
20
|
+
|
|
21
|
+
# Apply filters
|
|
22
|
+
scope = scope.by_method(@filters[:method]) if @filters[:method].present?
|
|
23
|
+
scope = scope.by_status_range(@filters[:status]) if @filters[:status].present?
|
|
24
|
+
scope = scope.with_exception if @filters[:has_exception]
|
|
25
|
+
scope = scope.search(@search_query) if @search_query.present?
|
|
26
|
+
|
|
27
|
+
@requests = scope.limit(500).to_a
|
|
28
|
+
|
|
29
|
+
# Adjust selection if out of bounds
|
|
30
|
+
@selected_index = [@selected_index, @requests.length - 1].min
|
|
31
|
+
@selected_index = 0 if @selected_index < 0
|
|
32
|
+
|
|
33
|
+
adjust_scroll
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def set_filter(key, value)
|
|
37
|
+
if value.nil? || value == ''
|
|
38
|
+
@filters.delete(key)
|
|
39
|
+
else
|
|
40
|
+
@filters[key] = value
|
|
41
|
+
end
|
|
42
|
+
load_requests
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def set_search(query)
|
|
46
|
+
@search_query = query.present? ? query : nil
|
|
47
|
+
@selected_index = 0
|
|
48
|
+
@scroll_offset = 0
|
|
49
|
+
load_requests
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def clear_filters
|
|
53
|
+
@filters = {}
|
|
54
|
+
@search_query = nil
|
|
55
|
+
@selected_index = 0
|
|
56
|
+
@scroll_offset = 0
|
|
57
|
+
load_requests
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def selected_request
|
|
61
|
+
@requests[@selected_index]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def move_up
|
|
65
|
+
if @selected_index > 0
|
|
66
|
+
@selected_index -= 1
|
|
67
|
+
adjust_scroll
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def move_down
|
|
72
|
+
if @selected_index < @requests.length - 1
|
|
73
|
+
@selected_index += 1
|
|
74
|
+
adjust_scroll
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def go_to_top
|
|
79
|
+
@selected_index = 0
|
|
80
|
+
@scroll_offset = 0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def go_to_bottom
|
|
84
|
+
@selected_index = [@requests.length - 1, 0].max
|
|
85
|
+
adjust_scroll
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def page_up
|
|
89
|
+
visible_rows = content_height
|
|
90
|
+
@selected_index = [@selected_index - visible_rows, 0].max
|
|
91
|
+
adjust_scroll
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def page_down
|
|
95
|
+
visible_rows = content_height
|
|
96
|
+
@selected_index = [@selected_index + visible_rows, @requests.length - 1].min
|
|
97
|
+
adjust_scroll
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def draw
|
|
101
|
+
clear
|
|
102
|
+
draw_box("Requests (#{@requests.length})")
|
|
103
|
+
draw_header
|
|
104
|
+
draw_requests
|
|
105
|
+
draw_status_bar
|
|
106
|
+
refresh
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def content_height
|
|
112
|
+
@height - 5 # Box borders (2) + header (1) + status bar (2)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def content_width
|
|
116
|
+
@width - 2 # Box borders
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def adjust_scroll
|
|
120
|
+
visible_rows = content_height
|
|
121
|
+
|
|
122
|
+
# Scroll up if selected is above visible area
|
|
123
|
+
if @selected_index < @scroll_offset
|
|
124
|
+
@scroll_offset = @selected_index
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Scroll down if selected is below visible area
|
|
128
|
+
if @selected_index >= @scroll_offset + visible_rows
|
|
129
|
+
@scroll_offset = @selected_index - visible_rows + 1
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def draw_header
|
|
134
|
+
y = 1
|
|
135
|
+
x = 1
|
|
136
|
+
|
|
137
|
+
# Header - AI column + rest (leave room for time column ~10 chars)
|
|
138
|
+
path_width = [content_width - 65, 15].max
|
|
139
|
+
header = "AI METHOD STATUS PATH#{' ' * (path_width - 4)} CONTROLLER DURATION TIME"
|
|
140
|
+
write(y, x, header[0, content_width], Colors::HEADER, Curses::A_BOLD)
|
|
141
|
+
|
|
142
|
+
# Draw separator
|
|
143
|
+
write(2, 1, '─' * content_width, Colors::BORDER)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def draw_requests
|
|
147
|
+
visible_rows = content_height
|
|
148
|
+
start_y = 3
|
|
149
|
+
|
|
150
|
+
visible_rows.times do |i|
|
|
151
|
+
req_index = @scroll_offset + i
|
|
152
|
+
break if req_index >= @requests.length
|
|
153
|
+
|
|
154
|
+
request = @requests[req_index]
|
|
155
|
+
y = start_y + i
|
|
156
|
+
is_selected = req_index == @selected_index
|
|
157
|
+
|
|
158
|
+
draw_request_row(y, request, is_selected)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def draw_request_row(y, request, is_selected)
|
|
163
|
+
if is_selected
|
|
164
|
+
# Draw full-width selection background
|
|
165
|
+
write(y, 1, ' ' * content_width, Colors::SELECTED)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
x = 1
|
|
169
|
+
|
|
170
|
+
# Agent indicator
|
|
171
|
+
agents = Binocs::Agent.for_request(request.id)
|
|
172
|
+
if agents.any?
|
|
173
|
+
running = agents.any?(&:running?)
|
|
174
|
+
indicator = running ? '●' : '○'
|
|
175
|
+
color = running ? Colors::STATUS_SUCCESS : Colors::MUTED
|
|
176
|
+
write(y, x, indicator, is_selected ? Colors::SELECTED : color, Curses::A_BOLD)
|
|
177
|
+
end
|
|
178
|
+
x += 3
|
|
179
|
+
|
|
180
|
+
# Method (simple colored text for now)
|
|
181
|
+
method_str = request.method.to_s.upcase.ljust(7)
|
|
182
|
+
if is_selected
|
|
183
|
+
write(y, x, method_str, Colors::SELECTED, Curses::A_BOLD)
|
|
184
|
+
else
|
|
185
|
+
write(y, x, method_str, Colors.method_color(request.method), Curses::A_BOLD)
|
|
186
|
+
end
|
|
187
|
+
x += 8
|
|
188
|
+
|
|
189
|
+
# Status (simple colored text for now)
|
|
190
|
+
status_str = (request.status_code || '???').to_s.ljust(6)
|
|
191
|
+
if is_selected
|
|
192
|
+
write(y, x, status_str, Colors::SELECTED)
|
|
193
|
+
else
|
|
194
|
+
write(y, x, status_str, Colors.status_color(request.status_code))
|
|
195
|
+
end
|
|
196
|
+
x += 7
|
|
197
|
+
|
|
198
|
+
# Path (variable width - leave room for time column ~10 chars)
|
|
199
|
+
path_width = [content_width - 65, 15].max
|
|
200
|
+
path_text = truncate(request.path, path_width).ljust(path_width)
|
|
201
|
+
write(y, x, path_text, is_selected ? Colors::SELECTED : Colors::NORMAL)
|
|
202
|
+
x += path_width + 1
|
|
203
|
+
|
|
204
|
+
# Controller
|
|
205
|
+
controller_text = truncate(request.controller_action || '-', 25).ljust(25)
|
|
206
|
+
write(y, x, controller_text, is_selected ? Colors::SELECTED : Colors::MUTED, is_selected ? 0 : Curses::A_DIM)
|
|
207
|
+
x += 26
|
|
208
|
+
|
|
209
|
+
# Duration
|
|
210
|
+
duration_text = request.formatted_duration.ljust(8)
|
|
211
|
+
write(y, x, duration_text, is_selected ? Colors::SELECTED : Colors::NORMAL)
|
|
212
|
+
x += 9
|
|
213
|
+
|
|
214
|
+
# Time
|
|
215
|
+
time_text = time_ago(request.created_at)
|
|
216
|
+
write(y, x, time_text, is_selected ? Colors::SELECTED : Colors::MUTED, is_selected ? 0 : Curses::A_DIM)
|
|
217
|
+
|
|
218
|
+
# Exception indicator
|
|
219
|
+
if request.has_exception?
|
|
220
|
+
write(y, content_width - 2, '!', is_selected ? Colors::SELECTED : Colors::ERROR, Curses::A_BOLD)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def draw_status_bar
|
|
225
|
+
y = @height - 2
|
|
226
|
+
|
|
227
|
+
# Draw separator
|
|
228
|
+
write(y - 1, 1, '─' * content_width, Colors::BORDER)
|
|
229
|
+
|
|
230
|
+
# Left side: filter info
|
|
231
|
+
filter_parts = []
|
|
232
|
+
filter_parts << "method:#{@filters[:method]}" if @filters[:method]
|
|
233
|
+
filter_parts << "status:#{@filters[:status]}" if @filters[:status]
|
|
234
|
+
filter_parts << "errors" if @filters[:has_exception]
|
|
235
|
+
filter_parts << "search:\"#{@search_query}\"" if @search_query
|
|
236
|
+
|
|
237
|
+
if filter_parts.any?
|
|
238
|
+
filter_text = "Filters: #{filter_parts.join(', ')}"
|
|
239
|
+
write(y, 1, truncate(filter_text, content_width / 2), Colors::MUTED, Curses::A_DIM)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Right side: key hints
|
|
243
|
+
hints = "j/k:nav Enter:view /:search f:filter r:refresh ?:help q:quit"
|
|
244
|
+
write(y, content_width - hints.length, hints, Colors::KEY_HINT, Curses::A_DIM)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def truncate(str, max_length)
|
|
248
|
+
str = str.to_s
|
|
249
|
+
return str if str.length <= max_length
|
|
250
|
+
return str if max_length < 4
|
|
251
|
+
|
|
252
|
+
"#{str[0, max_length - 3]}..."
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def time_ago(time)
|
|
256
|
+
return '-' unless time
|
|
257
|
+
|
|
258
|
+
seconds = Time.current - time
|
|
259
|
+
case seconds
|
|
260
|
+
when 0..59 then "#{seconds.to_i}s ago"
|
|
261
|
+
when 60..3599 then "#{(seconds / 60).to_i}m ago"
|
|
262
|
+
when 3600..86399 then "#{(seconds / 3600).to_i}h ago"
|
|
263
|
+
else "#{(seconds / 86400).to_i}d ago"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binocs
|
|
4
|
+
module TUI
|
|
5
|
+
class SpiritAnimal < Window
|
|
6
|
+
ANIMALS = [
|
|
7
|
+
{
|
|
8
|
+
name: "Fox",
|
|
9
|
+
trait: "Clever & Quick",
|
|
10
|
+
art: <<~ART
|
|
11
|
+
/\\ /\\
|
|
12
|
+
//\\\\_//\\\\ ____
|
|
13
|
+
\\_ _/ / /
|
|
14
|
+
/ * * \\ /^^^]
|
|
15
|
+
\\_\\O/_/ [ ]
|
|
16
|
+
/ \\_ [ /
|
|
17
|
+
\\ \\_ / /
|
|
18
|
+
[ [ / \\/ _/
|
|
19
|
+
_[ [ \\ /_/
|
|
20
|
+
ART
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "Owl",
|
|
24
|
+
trait: "Wise & Watchful",
|
|
25
|
+
art: <<~ART
|
|
26
|
+
,_,
|
|
27
|
+
(O,O)
|
|
28
|
+
( )
|
|
29
|
+
-"-"-
|
|
30
|
+
^ ^
|
|
31
|
+
ART
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "Cat",
|
|
35
|
+
trait: "Independent & Curious",
|
|
36
|
+
art: <<~ART
|
|
37
|
+
/\\_/\\
|
|
38
|
+
( o.o )
|
|
39
|
+
> ^ <
|
|
40
|
+
/| |\\
|
|
41
|
+
(_| |_)
|
|
42
|
+
ART
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "Bear",
|
|
46
|
+
trait: "Strong & Patient",
|
|
47
|
+
art: <<~ART
|
|
48
|
+
ʕ•ᴥ•ʔ
|
|
49
|
+
/| |\\
|
|
50
|
+
_| |_
|
|
51
|
+
| | | |
|
|
52
|
+
|_| |_|
|
|
53
|
+
ART
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "Rabbit",
|
|
57
|
+
trait: "Fast & Alert",
|
|
58
|
+
art: <<~ART
|
|
59
|
+
(\\(\\
|
|
60
|
+
( -.-)
|
|
61
|
+
o_(")(")
|
|
62
|
+
ART
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "Wolf",
|
|
66
|
+
trait: "Loyal & Fierce",
|
|
67
|
+
art: <<~ART
|
|
68
|
+
/\\ /\\
|
|
69
|
+
/ \\ / \\
|
|
70
|
+
/ /\\ \\/ /\\ \\
|
|
71
|
+
\\/ \\ / \\/
|
|
72
|
+
\\ \\/ /
|
|
73
|
+
\\ ** /
|
|
74
|
+
\\ -- /
|
|
75
|
+
\\ /
|
|
76
|
+
\\/
|
|
77
|
+
ART
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "Dragon",
|
|
81
|
+
trait: "Powerful & Legendary",
|
|
82
|
+
art: <<~ART
|
|
83
|
+
____
|
|
84
|
+
/ __ \\
|
|
85
|
+
/\\_/\\ | | | |
|
|
86
|
+
( o.o ) | |__| |
|
|
87
|
+
> ^ < \\____/
|
|
88
|
+
/| |\\ __/ \\__
|
|
89
|
+
/ | | \\/ \\ / \\
|
|
90
|
+
\\__/\\__/
|
|
91
|
+
ART
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "Turtle",
|
|
95
|
+
trait: "Steady & Resilient",
|
|
96
|
+
art: <<~ART
|
|
97
|
+
_____
|
|
98
|
+
/ \\
|
|
99
|
+
| O O |
|
|
100
|
+
\\ ^ /
|
|
101
|
+
___/-----\\___
|
|
102
|
+
/ _______ \\
|
|
103
|
+
/___/ \\___\\
|
|
104
|
+
ART
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "Phoenix",
|
|
108
|
+
trait: "Reborn & Radiant",
|
|
109
|
+
art: <<~ART
|
|
110
|
+
,//
|
|
111
|
+
///
|
|
112
|
+
///
|
|
113
|
+
/// /\\
|
|
114
|
+
/// //\\\\
|
|
115
|
+
/// ///\\\\\\
|
|
116
|
+
/// /// \\\\\\\\
|
|
117
|
+
///________\\ \\\\\\
|
|
118
|
+
\\\\\\\\\\\\\\\\\\\\\\\\ //
|
|
119
|
+
\\\\\\\\\\\\\\\\\\\\\\\\//
|
|
120
|
+
\\\\\\ \\\\\\//
|
|
121
|
+
\\\\\\ \\//
|
|
122
|
+
\\\\\\ //
|
|
123
|
+
\\\\\\_//
|
|
124
|
+
ART
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "Penguin",
|
|
128
|
+
trait: "Social & Adaptable",
|
|
129
|
+
art: <<~ART
|
|
130
|
+
.--.
|
|
131
|
+
|o_o |
|
|
132
|
+
|:_/ |
|
|
133
|
+
// \\ \\
|
|
134
|
+
(| | )
|
|
135
|
+
/'\\_ _/`\\
|
|
136
|
+
\\___)=(___/
|
|
137
|
+
ART
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "Octopus",
|
|
141
|
+
trait: "Creative & Flexible",
|
|
142
|
+
art: <<~ART
|
|
143
|
+
___
|
|
144
|
+
.-' '-.
|
|
145
|
+
/ o o \\
|
|
146
|
+
| ^ |
|
|
147
|
+
\\ '---' /
|
|
148
|
+
/\\/\\/\\/\\/\\/\\/\\
|
|
149
|
+
| | | | | | | |
|
|
150
|
+
ART
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "Unicorn",
|
|
154
|
+
trait: "Magical & Unique",
|
|
155
|
+
art: <<~ART
|
|
156
|
+
\\
|
|
157
|
+
\\
|
|
158
|
+
\\\\
|
|
159
|
+
\\\\
|
|
160
|
+
>\\/ /\\
|
|
161
|
+
\\ / \\
|
|
162
|
+
\\/ \\
|
|
163
|
+
`\\
|
|
164
|
+
\\
|
|
165
|
+
ART
|
|
166
|
+
}
|
|
167
|
+
].freeze
|
|
168
|
+
|
|
169
|
+
attr_accessor :request, :animal
|
|
170
|
+
|
|
171
|
+
def initialize(height:, width:, top:, left:)
|
|
172
|
+
super
|
|
173
|
+
@request = nil
|
|
174
|
+
@animal = nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def set_request(request)
|
|
178
|
+
@request = request
|
|
179
|
+
@animal = pick_spirit_animal(request)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def draw
|
|
183
|
+
return unless @animal
|
|
184
|
+
|
|
185
|
+
clear
|
|
186
|
+
draw_box("✨ Spirit Animal ✨")
|
|
187
|
+
|
|
188
|
+
y = 2
|
|
189
|
+
|
|
190
|
+
# Animal name and trait
|
|
191
|
+
name_text = "The #{@animal[:name]}"
|
|
192
|
+
write(y, (@width - name_text.length) / 2, name_text, Colors::HEADER, Curses::A_BOLD)
|
|
193
|
+
y += 1
|
|
194
|
+
|
|
195
|
+
trait_text = "\"#{@animal[:trait]}\""
|
|
196
|
+
write(y, (@width - trait_text.length) / 2, trait_text, Colors::STATUS_SUCCESS)
|
|
197
|
+
y += 2
|
|
198
|
+
|
|
199
|
+
# Draw ASCII art centered
|
|
200
|
+
art_lines = @animal[:art].lines.map(&:chomp)
|
|
201
|
+
max_art_width = art_lines.map(&:length).max || 0
|
|
202
|
+
|
|
203
|
+
art_lines.each do |line|
|
|
204
|
+
x = [(@width - max_art_width) / 2, 2].max
|
|
205
|
+
write(y, x, line, Colors::NORMAL)
|
|
206
|
+
y += 1
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
y += 1
|
|
210
|
+
|
|
211
|
+
# Request info that determined the animal
|
|
212
|
+
write(y, 2, "Request:", Colors::MUTED, Curses::A_DIM)
|
|
213
|
+
y += 1
|
|
214
|
+
method_str = @request.respond_to?(:read_attribute) ? @request.read_attribute(:method) : @request.method
|
|
215
|
+
info = "#{method_str} #{@request.path[0, 30]}"
|
|
216
|
+
write(y, 2, info, Colors::MUTED, Curses::A_DIM)
|
|
217
|
+
|
|
218
|
+
# Footer
|
|
219
|
+
write(@height - 2, 2, "Press any key to close", Colors::KEY_HINT, Curses::A_DIM)
|
|
220
|
+
|
|
221
|
+
refresh
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def pick_spirit_animal(request)
|
|
227
|
+
# Create a hash from request attributes to deterministically pick an animal
|
|
228
|
+
seed_string = "#{request.id}#{request.path}#{request.method}#{request.status_code}"
|
|
229
|
+
hash = seed_string.bytes.reduce(0) { |acc, b| acc + b }
|
|
230
|
+
|
|
231
|
+
ANIMALS[hash % ANIMALS.length]
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binocs
|
|
4
|
+
module TUI
|
|
5
|
+
class Window
|
|
6
|
+
attr_reader :win, :height, :width, :top, :left
|
|
7
|
+
|
|
8
|
+
def initialize(height:, width:, top:, left:)
|
|
9
|
+
@height = height
|
|
10
|
+
@width = width
|
|
11
|
+
@top = top
|
|
12
|
+
@left = left
|
|
13
|
+
@win = Curses::Window.new(height, width, top, left)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def refresh
|
|
17
|
+
@win.refresh
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def noutrefresh
|
|
21
|
+
@win.noutrefresh
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear
|
|
25
|
+
@win.erase # Use erase instead of clear to reduce flicker
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def close
|
|
29
|
+
@win.close
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def resize(height, width, top, left)
|
|
33
|
+
@win.close
|
|
34
|
+
@height = height
|
|
35
|
+
@width = width
|
|
36
|
+
@top = top
|
|
37
|
+
@left = left
|
|
38
|
+
@win = Curses::Window.new(height, width, top, left)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def draw_box(title = nil)
|
|
42
|
+
@win.attron(Curses.color_pair(Colors::BORDER)) do
|
|
43
|
+
# Draw corners
|
|
44
|
+
@win.setpos(0, 0)
|
|
45
|
+
@win.addstr('┌')
|
|
46
|
+
@win.setpos(0, @width - 1)
|
|
47
|
+
@win.addstr('┐')
|
|
48
|
+
@win.setpos(@height - 1, 0)
|
|
49
|
+
@win.addstr('└')
|
|
50
|
+
@win.setpos(@height - 1, @width - 1)
|
|
51
|
+
@win.addstr('┘')
|
|
52
|
+
|
|
53
|
+
# Draw horizontal lines
|
|
54
|
+
(1...@width - 1).each do |x|
|
|
55
|
+
@win.setpos(0, x)
|
|
56
|
+
@win.addstr('─')
|
|
57
|
+
@win.setpos(@height - 1, x)
|
|
58
|
+
@win.addstr('─')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Draw vertical lines
|
|
62
|
+
(1...@height - 1).each do |y|
|
|
63
|
+
@win.setpos(y, 0)
|
|
64
|
+
@win.addstr('│')
|
|
65
|
+
@win.setpos(y, @width - 1)
|
|
66
|
+
@win.addstr('│')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Draw title if provided
|
|
71
|
+
if title
|
|
72
|
+
@win.attron(Curses.color_pair(Colors::TITLE) | Curses::A_BOLD) do
|
|
73
|
+
title_text = " #{title} "
|
|
74
|
+
@win.setpos(0, 2)
|
|
75
|
+
@win.addstr(title_text)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write(y, x, text, color_pair = Colors::NORMAL, attrs = 0)
|
|
81
|
+
return if y < 0 || y >= @height || x < 0
|
|
82
|
+
|
|
83
|
+
@win.attron(Curses.color_pair(color_pair) | attrs) do
|
|
84
|
+
@win.setpos(y, x)
|
|
85
|
+
# Truncate text if it would overflow
|
|
86
|
+
max_len = @width - x
|
|
87
|
+
truncated = text.to_s[0, max_len]
|
|
88
|
+
@win.addstr(truncated)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def write_centered(y, text, color_pair = Colors::NORMAL, attrs = 0)
|
|
93
|
+
x = [(@width - text.length) / 2, 0].max
|
|
94
|
+
write(y, x, text, color_pair, attrs)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/binocs/tui.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
require 'rbconfig'
|
|
5
|
+
require_relative 'swagger/client'
|
|
6
|
+
require_relative 'swagger/path_matcher'
|
|
7
|
+
require_relative 'agent'
|
|
8
|
+
require_relative 'agent_context'
|
|
9
|
+
require_relative 'agent_manager'
|
|
10
|
+
require_relative 'tui/colors'
|
|
11
|
+
require_relative 'tui/window'
|
|
12
|
+
require_relative 'tui/request_list'
|
|
13
|
+
require_relative 'tui/request_detail'
|
|
14
|
+
require_relative 'tui/help_screen'
|
|
15
|
+
require_relative 'tui/filter_menu'
|
|
16
|
+
require_relative 'tui/agents_list'
|
|
17
|
+
require_relative 'tui/agent_output'
|
|
18
|
+
require_relative 'tui/spirit_animal'
|
|
19
|
+
require_relative 'tui/app'
|
|
20
|
+
|
|
21
|
+
module Binocs
|
|
22
|
+
module TUI
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/binocs.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "binocs/version"
|
|
4
|
+
require "binocs/configuration"
|
|
5
|
+
require "binocs/engine"
|
|
6
|
+
|
|
7
|
+
module Binocs
|
|
8
|
+
class << self
|
|
9
|
+
attr_writer :configuration
|
|
10
|
+
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def configure
|
|
16
|
+
yield(configuration)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enabled?
|
|
20
|
+
configuration.enabled && !Rails.env.production?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset_configuration!
|
|
24
|
+
@configuration = Configuration.new
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|