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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +528 -0
  4. data/Rakefile +7 -0
  5. data/app/assets/javascripts/binocs/application.js +105 -0
  6. data/app/assets/stylesheets/binocs/application.css +67 -0
  7. data/app/channels/binocs/application_cable/channel.rb +8 -0
  8. data/app/channels/binocs/application_cable/connection.rb +8 -0
  9. data/app/channels/binocs/requests_channel.rb +13 -0
  10. data/app/controllers/binocs/application_controller.rb +62 -0
  11. data/app/controllers/binocs/requests_controller.rb +69 -0
  12. data/app/helpers/binocs/application_helper.rb +61 -0
  13. data/app/models/binocs/application_record.rb +7 -0
  14. data/app/models/binocs/request.rb +198 -0
  15. data/app/views/binocs/requests/_empty_list.html.erb +9 -0
  16. data/app/views/binocs/requests/_request.html.erb +61 -0
  17. data/app/views/binocs/requests/index.html.erb +115 -0
  18. data/app/views/binocs/requests/show.html.erb +227 -0
  19. data/app/views/layouts/binocs/application.html.erb +109 -0
  20. data/config/importmap.rb +6 -0
  21. data/config/routes.rb +11 -0
  22. data/db/migrate/20240101000000_create_binocs_requests.rb +36 -0
  23. data/exe/binocs +86 -0
  24. data/lib/binocs/agent.rb +153 -0
  25. data/lib/binocs/agent_context.rb +165 -0
  26. data/lib/binocs/agent_manager.rb +302 -0
  27. data/lib/binocs/configuration.rb +65 -0
  28. data/lib/binocs/engine.rb +61 -0
  29. data/lib/binocs/log_subscriber.rb +56 -0
  30. data/lib/binocs/middleware/request_recorder.rb +264 -0
  31. data/lib/binocs/swagger/client.rb +100 -0
  32. data/lib/binocs/swagger/path_matcher.rb +118 -0
  33. data/lib/binocs/tui/agent_output.rb +163 -0
  34. data/lib/binocs/tui/agents_list.rb +195 -0
  35. data/lib/binocs/tui/app.rb +726 -0
  36. data/lib/binocs/tui/colors.rb +115 -0
  37. data/lib/binocs/tui/filter_menu.rb +162 -0
  38. data/lib/binocs/tui/help_screen.rb +93 -0
  39. data/lib/binocs/tui/request_detail.rb +899 -0
  40. data/lib/binocs/tui/request_list.rb +268 -0
  41. data/lib/binocs/tui/spirit_animal.rb +235 -0
  42. data/lib/binocs/tui/window.rb +98 -0
  43. data/lib/binocs/tui.rb +24 -0
  44. data/lib/binocs/version.rb +5 -0
  45. data/lib/binocs.rb +27 -0
  46. data/lib/generators/binocs/install/install_generator.rb +61 -0
  47. data/lib/generators/binocs/install/templates/create_binocs_requests.rb +36 -0
  48. data/lib/generators/binocs/install/templates/initializer.rb +25 -0
  49. data/lib/tasks/binocs_tasks.rake +38 -0
  50. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ VERSION = '0.1.0'
5
+ 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