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,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,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,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 %>
|