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,67 @@
1
+ /*
2
+ * Binocs - Request Monitor Styles
3
+ * Base styles - Tailwind is loaded via CDN in layout
4
+ */
5
+
6
+ /* Additional custom styles */
7
+ .binocs-container {
8
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9
+ }
10
+
11
+ /* Scrollbar styling for dark theme */
12
+ ::-webkit-scrollbar {
13
+ width: 8px;
14
+ height: 8px;
15
+ }
16
+
17
+ ::-webkit-scrollbar-track {
18
+ background: #1e293b;
19
+ }
20
+
21
+ ::-webkit-scrollbar-thumb {
22
+ background: #475569;
23
+ border-radius: 4px;
24
+ }
25
+
26
+ ::-webkit-scrollbar-thumb:hover {
27
+ background: #64748b;
28
+ }
29
+
30
+ /* Code/Pre styling */
31
+ pre {
32
+ white-space: pre-wrap;
33
+ word-wrap: break-word;
34
+ }
35
+
36
+ /* Animation for new requests */
37
+ @keyframes highlight-new {
38
+ 0% {
39
+ background-color: rgba(99, 102, 241, 0.2);
40
+ }
41
+ 100% {
42
+ background-color: transparent;
43
+ }
44
+ }
45
+
46
+ .turbo-stream-prepend {
47
+ animation: highlight-new 2s ease-out;
48
+ }
49
+
50
+ /* Form input styling */
51
+ input[type="text"],
52
+ select {
53
+ background-color: #334155;
54
+ border-color: #475569;
55
+ color: #f8fafc;
56
+ }
57
+
58
+ input[type="text"]:focus,
59
+ select:focus {
60
+ border-color: #6366f1;
61
+ outline: none;
62
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
63
+ }
64
+
65
+ input[type="text"]::placeholder {
66
+ color: #94a3b8;
67
+ }
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ module ApplicationCable
5
+ class Channel < ActionCable::Channel::Base
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ module ApplicationCable
5
+ class Connection < ActionCable::Connection::Base
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ class RequestsChannel < ApplicationCable::Channel
5
+ def subscribed
6
+ stream_from "binocs_requests"
7
+ end
8
+
9
+ def unsubscribed
10
+ # Cleanup when channel is unsubscribed
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+ layout "binocs/application"
7
+
8
+ before_action :verify_access
9
+ before_action :authenticate_binocs_user
10
+
11
+ private
12
+
13
+ def verify_access
14
+ if Rails.env.production?
15
+ render plain: "Binocs is not available in production.", status: :forbidden
16
+ return
17
+ end
18
+
19
+ return unless Binocs.configuration.basic_auth_enabled?
20
+
21
+ authenticate_or_request_with_http_basic("Binocs") do |username, password|
22
+ ActiveSupport::SecurityUtils.secure_compare(username, Binocs.configuration.basic_auth_username) &
23
+ ActiveSupport::SecurityUtils.secure_compare(password, Binocs.configuration.basic_auth_password)
24
+ end
25
+ end
26
+
27
+ def authenticate_binocs_user
28
+ auth_method = Binocs.configuration.authentication_method
29
+ return unless auth_method
30
+
31
+ # Store the current URL for redirect after login (Devise integration)
32
+ store_location_for_binocs
33
+
34
+ case auth_method
35
+ when Symbol
36
+ # Call the method by name (e.g., :authenticate_user!)
37
+ send(auth_method)
38
+ when Proc
39
+ # Call the proc with the controller instance
40
+ instance_exec(&auth_method)
41
+ when String
42
+ # Call the method by name as string
43
+ send(auth_method.to_sym)
44
+ end
45
+ end
46
+
47
+ def store_location_for_binocs
48
+ # Determine the Devise scope from the authentication method
49
+ auth_method = Binocs.configuration.authentication_method.to_s
50
+ match = auth_method.match(/authenticate_(\w+)!/)
51
+ scope = match ? match[1] : 'user'
52
+
53
+ # Check if user is already signed in (don't overwrite return URL)
54
+ signed_in_method = "#{scope}_signed_in?"
55
+ return if respond_to?(signed_in_method, true) && send(signed_in_method)
56
+
57
+ # Store directly in session using Devise's expected key format
58
+ # Devise looks for session["#{scope}_return_to"] after sign in
59
+ session["#{scope}_return_to"] = request.fullpath
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ class RequestsController < ApplicationController
5
+ before_action :set_request, only: [:show, :destroy]
6
+
7
+ def index
8
+ @requests = Request.recent
9
+ @requests = apply_filters(@requests)
10
+ @requests = @requests.page(params[:page]).per(50) if @requests.respond_to?(:page)
11
+ @requests = @requests.limit(50) unless @requests.respond_to?(:page)
12
+
13
+ @stats = {
14
+ total: Request.count,
15
+ today: Request.today.count,
16
+ avg_duration: Request.average_duration,
17
+ error_rate: Request.error_rate
18
+ }
19
+
20
+ respond_to do |format|
21
+ format.html
22
+ format.turbo_stream
23
+ end
24
+ end
25
+
26
+ def show
27
+ respond_to do |format|
28
+ format.html
29
+ format.turbo_stream
30
+ end
31
+ end
32
+
33
+ def destroy
34
+ @request.destroy
35
+
36
+ respond_to do |format|
37
+ format.html { redirect_to requests_path, notice: "Request deleted." }
38
+ format.turbo_stream { render turbo_stream: turbo_stream.remove(@request) }
39
+ end
40
+ end
41
+
42
+ def clear
43
+ Request.delete_all
44
+
45
+ respond_to do |format|
46
+ format.html { redirect_to requests_path, notice: "All requests cleared." }
47
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("requests-list", partial: "binocs/requests/empty_list") }
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def set_request
54
+ @request = Request.find_by!(uuid: params[:id])
55
+ rescue ActiveRecord::RecordNotFound
56
+ redirect_to requests_path, alert: "Request not found."
57
+ end
58
+
59
+ def apply_filters(scope)
60
+ scope = scope.by_method(params[:method]) if params[:method].present?
61
+ scope = scope.by_status_range(params[:status]) if params[:status].present?
62
+ scope = scope.search(params[:search]) if params[:search].present?
63
+ scope = scope.by_controller(params[:controller_name]) if params[:controller_name].present?
64
+ scope = scope.with_exception if params[:has_exception] == "1"
65
+ scope = scope.slow(params[:slow_threshold].to_i) if params[:slow_threshold].present?
66
+ scope
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ module ApplicationHelper
5
+ def method_badge_class(method)
6
+ case method.to_s.upcase
7
+ when "GET"
8
+ "bg-green-900/50 text-green-300"
9
+ when "POST"
10
+ "bg-blue-900/50 text-blue-300"
11
+ when "PUT", "PATCH"
12
+ "bg-yellow-900/50 text-yellow-300"
13
+ when "DELETE"
14
+ "bg-red-900/50 text-red-300"
15
+ else
16
+ "bg-slate-700 text-slate-300"
17
+ end
18
+ end
19
+
20
+ def status_badge_class(status)
21
+ return "bg-slate-700 text-slate-300" if status.nil?
22
+
23
+ case status
24
+ when 200..299
25
+ "bg-green-900/50 text-green-300"
26
+ when 300..399
27
+ "bg-blue-900/50 text-blue-300"
28
+ when 400..499
29
+ "bg-yellow-900/50 text-yellow-300"
30
+ when 500..599
31
+ "bg-red-900/50 text-red-300"
32
+ else
33
+ "bg-slate-700 text-slate-300"
34
+ end
35
+ end
36
+
37
+ def format_value(value)
38
+ case value
39
+ when Hash, Array
40
+ JSON.pretty_generate(value)
41
+ when nil
42
+ "null"
43
+ else
44
+ value.to_s
45
+ end
46
+ rescue
47
+ value.to_s
48
+ end
49
+
50
+ def format_body(body)
51
+ return body if body.nil?
52
+
53
+ begin
54
+ parsed = JSON.parse(body)
55
+ JSON.pretty_generate(parsed)
56
+ rescue JSON::ParserError
57
+ body
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ class Request < ApplicationRecord
5
+ self.table_name = "binocs_requests"
6
+
7
+ serialize :params, coder: JSON
8
+ serialize :request_headers, coder: JSON
9
+ serialize :response_headers, coder: JSON
10
+ serialize :logs, coder: JSON
11
+ serialize :exception, coder: JSON
12
+
13
+ validates :uuid, presence: true, uniqueness: true
14
+ validates :method, presence: true
15
+ validates :path, presence: true
16
+
17
+ # Scopes for filtering
18
+ scope :by_method, ->(method) { where(method: method.upcase) if method.present? }
19
+ scope :by_status, ->(status) { where(status_code: status) if status.present? }
20
+ scope :by_status_range, ->(range) {
21
+ case range
22
+ when "2xx" then where(status_code: 200..299)
23
+ when "3xx" then where(status_code: 300..399)
24
+ when "4xx" then where(status_code: 400..499)
25
+ when "5xx" then where(status_code: 500..599)
26
+ end
27
+ }
28
+ scope :by_path, ->(path) { where("path LIKE ?", "%#{path}%") if path.present? }
29
+ scope :by_controller, ->(controller) { where(controller_name: controller) if controller.present? }
30
+ scope :by_action, ->(action) { where(action_name: action) if action.present? }
31
+ scope :with_exception, -> { where.not(exception: nil) }
32
+ scope :without_exception, -> { where(exception: nil) }
33
+ scope :slow, ->(threshold_ms = 1000) { where("duration_ms > ?", threshold_ms) }
34
+ scope :by_ip, ->(ip) { where(ip_address: ip) if ip.present? }
35
+ scope :recent, -> { order(created_at: :desc) }
36
+ scope :today, -> { where("created_at >= ?", Time.current.beginning_of_day) }
37
+ scope :last_hour, -> { where("created_at >= ?", 1.hour.ago) }
38
+ scope :search, ->(query) {
39
+ return all if query.blank?
40
+
41
+ where("path LIKE :q OR controller_name LIKE :q OR action_name LIKE :q", q: "%#{query}%")
42
+ }
43
+
44
+ # Instance methods
45
+
46
+ # Alias for 'method' to avoid conflict with Object#method
47
+ def http_method
48
+ read_attribute(:method)
49
+ end
50
+
51
+ def success?
52
+ status_code.present? && status_code >= 200 && status_code < 300
53
+ end
54
+
55
+ def redirect?
56
+ status_code.present? && status_code >= 300 && status_code < 400
57
+ end
58
+
59
+ def client_error?
60
+ status_code.present? && status_code >= 400 && status_code < 500
61
+ end
62
+
63
+ def server_error?
64
+ status_code.present? && status_code >= 500
65
+ end
66
+
67
+ def has_exception?
68
+ exception.present?
69
+ end
70
+
71
+ def status_class
72
+ return "error" if server_error? || has_exception?
73
+ return "warning" if client_error?
74
+ return "redirect" if redirect?
75
+
76
+ "success"
77
+ end
78
+
79
+ def method_class
80
+ case method.upcase
81
+ when "GET" then "method-get"
82
+ when "POST" then "method-post"
83
+ when "PUT", "PATCH" then "method-put"
84
+ when "DELETE" then "method-delete"
85
+ else "method-other"
86
+ end
87
+ end
88
+
89
+ def formatted_duration
90
+ return "N/A" unless duration_ms
91
+
92
+ if duration_ms < 1
93
+ "< 1ms"
94
+ elsif duration_ms < 1000
95
+ "#{duration_ms.round(1)}ms"
96
+ else
97
+ "#{(duration_ms / 1000).round(2)}s"
98
+ end
99
+ end
100
+
101
+ def formatted_memory_delta
102
+ return "N/A" unless memory_delta
103
+
104
+ if memory_delta.abs < 1024
105
+ "#{memory_delta} B"
106
+ elsif memory_delta.abs < 1024 * 1024
107
+ "#{(memory_delta / 1024.0).round(2)} KB"
108
+ else
109
+ "#{(memory_delta / (1024.0 * 1024)).round(2)} MB"
110
+ end
111
+ end
112
+
113
+ def short_path
114
+ return path if path.length <= 50
115
+
116
+ "#{path[0, 47]}..."
117
+ end
118
+
119
+ def full_url
120
+ # Construct URL from host header if available
121
+ host = request_headers&.dig('Host') || request_headers&.dig('host') || 'localhost'
122
+ scheme = request_headers&.dig('X-Forwarded-Proto') || 'http'
123
+ "#{scheme}://#{host}#{path}"
124
+ end
125
+
126
+ def controller_action
127
+ return nil unless controller_name && action_name
128
+
129
+ "#{controller_name}##{action_name}"
130
+ end
131
+
132
+ def swagger_url
133
+ # Convert path to Swagger UI deep link format: #/{tagName}/{operationId}
134
+ swagger_path, tag = find_swagger_path_and_tag
135
+
136
+ # Generate operationId: method + path with non-alphanumeric chars replaced
137
+ # /v1/companies/{company_uuid}/invitations -> v1_companies__company_uuid__invitations
138
+ operation_id = "#{method.downcase}#{swagger_path.gsub(/[^a-zA-Z0-9]/, '_')}"
139
+
140
+ "/api-docs/index.html#/#{ERB::Util.url_encode(tag)}/#{operation_id}"
141
+ end
142
+
143
+ def find_swagger_path_and_tag
144
+ spec_path = Rails.root.join("swagger/v1/swagger.yaml")
145
+ return [path, default_tag] unless File.exist?(spec_path)
146
+
147
+ spec = YAML.load_file(spec_path)
148
+
149
+ # Find matching swagger path by converting UUIDs to param placeholders
150
+ spec["paths"]&.each do |swagger_path, methods|
151
+ # Create regex from swagger path: /v1/channels/{channel_uuid} -> /v1/channels/[^/]+
152
+ pattern = swagger_path.gsub(/\{[^}]+\}/, "[^/]+")
153
+ regex = /\A#{pattern}\z/
154
+
155
+ if path.match?(regex) && methods[method.downcase]
156
+ tag = methods.dig(method.downcase, "tags")&.first || default_tag
157
+ return [swagger_path, tag]
158
+ end
159
+ end
160
+
161
+ [path, default_tag]
162
+ rescue => e
163
+ Rails.logger.debug "[Binocs] Failed to find swagger path: #{e.message}"
164
+ [path, default_tag]
165
+ end
166
+
167
+ def default_tag
168
+ # Fallback: derive tag from controller name
169
+ # V1::CompaniesController -> "Companies"
170
+ return "default" unless controller_name
171
+
172
+ controller_name.demodulize.sub(/Controller$/, "").titleize
173
+ end
174
+
175
+ # Class methods for statistics
176
+ def self.average_duration
177
+ average(:duration_ms)&.round(2)
178
+ end
179
+
180
+ def self.error_rate
181
+ return 0 if count.zero?
182
+
183
+ ((with_exception.count + by_status_range("5xx").count).to_f / count * 100).round(2)
184
+ end
185
+
186
+ def self.methods_breakdown
187
+ group(:method).count
188
+ end
189
+
190
+ def self.status_breakdown
191
+ group(:status_code).count
192
+ end
193
+
194
+ def self.controllers_list
195
+ distinct.pluck(:controller_name).compact.sort
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,9 @@
1
+ <div class="px-4 py-12 text-center">
2
+ <svg class="mx-auto h-12 w-12 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
3
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
4
+ </svg>
5
+ <h3 class="mt-2 text-sm font-medium text-white">No requests</h3>
6
+ <p class="mt-1 text-sm text-slate-400">
7
+ Make some requests to your application and they'll appear here in real-time.
8
+ </p>
9
+ </div>
@@ -0,0 +1,61 @@
1
+ <%= turbo_frame_tag dom_id(request) do %>
2
+ <%= link_to binocs.request_path(request.uuid), class: "block hover:bg-slate-700/50 transition-colors duration-150", data: { turbo_frame: "_top" } do %>
3
+ <div class="px-4 py-3 pr-6">
4
+ <div class="flex items-center justify-between">
5
+ <div class="flex items-center space-x-4 min-w-0">
6
+ <!-- Method Badge -->
7
+ <span class="inline-flex items-center rounded px-2 py-1 text-xs font-medium <%= method_badge_class(request.method) %>">
8
+ <%= request.method %>
9
+ </span>
10
+
11
+ <!-- Status Badge -->
12
+ <span class="inline-flex items-center rounded px-2 py-1 text-xs font-medium <%= status_badge_class(request.status_code) %>">
13
+ <%= request.status_code %>
14
+ </span>
15
+
16
+ <!-- Path -->
17
+ <span class="text-sm text-white truncate" title="<%= request.path %>">
18
+ <%= request.short_path %>
19
+ </span>
20
+
21
+ <!-- Controller#Action -->
22
+ <% if request.controller_action %>
23
+ <span class="text-xs text-slate-400">
24
+ <%= request.controller_action %>
25
+ </span>
26
+ <% end %>
27
+
28
+ <!-- Exception Indicator -->
29
+ <% if request.has_exception? %>
30
+ <span class="inline-flex items-center rounded-full bg-red-900/50 px-2 py-1 text-xs font-medium text-red-300">
31
+ <svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
32
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
33
+ </svg>
34
+ Exception
35
+ </span>
36
+ <% end %>
37
+ </div>
38
+
39
+ <div class="flex items-center space-x-4 text-sm text-slate-400 flex-shrink-0 pl-4">
40
+ <!-- Duration -->
41
+ <span title="Duration" class="whitespace-nowrap">
42
+ <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
43
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
44
+ </svg>
45
+ <%= request.formatted_duration %>
46
+ </span>
47
+
48
+ <!-- IP Address -->
49
+ <span title="IP Address" class="hidden lg:inline whitespace-nowrap">
50
+ <%= request.ip_address %>
51
+ </span>
52
+
53
+ <!-- Timestamp -->
54
+ <span title="<%= request.created_at %>" class="whitespace-nowrap">
55
+ <%= time_ago_in_words(request.created_at) %> ago
56
+ </span>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ <% end %>
61
+ <% end %>