binocs 0.1.0 → 0.2.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 +4 -4
- data/README.md +17 -4
- data/app/controllers/binocs/requests_controller.rb +190 -1
- data/app/helpers/binocs/application_helper.rb +43 -11
- data/app/models/binocs/request.rb +31 -0
- data/app/views/binocs/requests/_empty_list.html.erb +3 -3
- data/app/views/binocs/requests/_log_entry.html.erb +104 -0
- data/app/views/binocs/requests/_request.html.erb +5 -5
- data/app/views/binocs/requests/analytics.html.erb +239 -0
- data/app/views/binocs/requests/heatmap.html.erb +179 -0
- data/app/views/binocs/requests/index.html.erb +42 -37
- data/app/views/binocs/requests/lifecycle.html.erb +886 -0
- data/app/views/binocs/requests/raw.html.erb +184 -0
- data/app/views/binocs/requests/sequence.html.erb +449 -0
- data/app/views/binocs/requests/show.html.erb +81 -74
- data/app/views/layouts/binocs/application.html.erb +36 -19
- data/config/routes.rb +7 -0
- data/db/migrate/20260314000000_add_client_identifier_to_binocs_requests.rb +8 -0
- data/lib/binocs/engine.rb +9 -0
- data/lib/binocs/log_subscriber.rb +164 -0
- data/lib/binocs/middleware/request_recorder.rb +35 -3
- data/lib/binocs/tui/app.rb +181 -2
- data/lib/binocs/tui/endpoints.rb +327 -0
- data/lib/binocs/tui/help_screen.rb +16 -0
- data/lib/binocs/tui/sequence_diagram.rb +266 -0
- data/lib/binocs/tui/window.rb +20 -0
- data/lib/binocs/tui.rb +2 -0
- data/lib/binocs/version.rb +1 -1
- metadata +21 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4195cc40aca616da7fff7aaa442e94847efeab48e360fa2b5b47c7e66d9a0b2
|
|
4
|
+
data.tar.gz: 8214123335d3746b294d857275738a790b6ed66bf44c5b7a5046daefd1907b69
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8c5bb23f9b228e6734132ab2775ab4593bf287a0035ce2ef0f62f13ee99e1ca627422f227079c0e9172e7bceb4b07def83d08dbab6e20fe2a4a0d34d3a370610
|
|
7
|
+
data.tar.gz: 88a11c55945e97c2a96dd6e377390227ade60c484394c93d1f6c4c1127be6f4315abd3341bb60fc8808d59d29e7a08caf5ff9e91235109860260ee93bc452ca4
|
data/README.md
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
# Binocs
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/binocs)
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="docs/images/binocs-icon-512.png" alt="Binocs icon" width="128" />
|
|
9
|
+
</p>
|
|
10
|
+
|
|
3
11
|
A Laravel Telescope-inspired request monitoring dashboard for Rails applications. Binocs provides real-time visibility into HTTP requests through both a web interface and a terminal UI with vim-style navigation, making debugging and development easier whether you prefer the browser or the command line.
|
|
4
12
|
|
|
13
|
+
*Binocs is short for binoculars — the trusty tool train engineers and conductors used to look down the tracks and inspect what's coming. Now you can do the same for your Rails requests.*
|
|
14
|
+
|
|
5
15
|
## Features
|
|
6
16
|
|
|
7
17
|
- **Real-time Request Monitoring**: Watch requests stream in as they happen via ActionCable/Turbo Streams
|
|
@@ -51,10 +61,7 @@ A Laravel Telescope-inspired request monitoring dashboard for Rails applications
|
|
|
51
61
|
|
|
52
62
|
```ruby
|
|
53
63
|
# Gemfile
|
|
54
|
-
gem 'binocs'
|
|
55
|
-
|
|
56
|
-
# Or from GitHub (once published)
|
|
57
|
-
# gem 'binocs', github: 'zincan/binocs'
|
|
64
|
+
gem 'binocs'
|
|
58
65
|
```
|
|
59
66
|
|
|
60
67
|
### 2. Install the gem
|
|
@@ -70,6 +77,7 @@ bin/rails generate binocs:install
|
|
|
70
77
|
```
|
|
71
78
|
|
|
72
79
|
This will:
|
|
80
|
+
|
|
73
81
|
- Copy the migration file
|
|
74
82
|
- Create an initializer at `config/initializers/binocs.rb`
|
|
75
83
|
- Add the route to mount the engine
|
|
@@ -267,6 +275,7 @@ Binocs uses Rails' ActionCable and Turbo Streams to provide real-time updates to
|
|
|
267
275
|
3. **Database Storage**: The request data is saved to the `binocs_requests` table
|
|
268
276
|
|
|
269
277
|
4. **Real-time Broadcast**: After saving, Binocs broadcasts the new request via Turbo Streams:
|
|
278
|
+
|
|
270
279
|
```ruby
|
|
271
280
|
Turbo::StreamsChannel.broadcast_prepend_to(
|
|
272
281
|
"binocs_requests",
|
|
@@ -299,6 +308,7 @@ If your application uses Devise (or similar) with ActionCable authentication, th
|
|
|
299
308
|
### Disabling Real-time Updates
|
|
300
309
|
|
|
301
310
|
The dashboard includes a "Live/Paused" toggle. When paused:
|
|
311
|
+
|
|
302
312
|
- The Turbo Stream subscription is temporarily disabled
|
|
303
313
|
- No new requests appear automatically
|
|
304
314
|
- Click "Refresh" to manually load new requests
|
|
@@ -306,17 +316,20 @@ The dashboard includes a "Live/Paused" toggle. When paused:
|
|
|
306
316
|
### Troubleshooting ActionCable
|
|
307
317
|
|
|
308
318
|
**WebSocket not connecting:**
|
|
319
|
+
|
|
309
320
|
- Check that ActionCable is configured in `config/cable.yml`
|
|
310
321
|
- Verify the `/cable` path is accessible
|
|
311
322
|
- Check browser console for WebSocket errors
|
|
312
323
|
- If using Devise, ensure you're authenticated
|
|
313
324
|
|
|
314
325
|
**Requests not appearing in real-time:**
|
|
326
|
+
|
|
315
327
|
- Verify `Turbo::StreamsChannel` is available (requires `turbo-rails` gem)
|
|
316
328
|
- Check Rails logs for `[Binocs] Broadcasting new request` messages
|
|
317
329
|
- Ensure the request path isn't in `config.ignored_paths`
|
|
318
330
|
|
|
319
331
|
**High latency or missed updates:**
|
|
332
|
+
|
|
320
333
|
- Consider using Redis adapter for ActionCable in production-like environments
|
|
321
334
|
- The async adapter (default for development) works fine for local debugging
|
|
322
335
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Binocs
|
|
4
4
|
class RequestsController < ApplicationController
|
|
5
|
-
before_action :set_request, only: [:show, :destroy]
|
|
5
|
+
before_action :set_request, only: [:show, :destroy, :lifecycle, :raw]
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
8
|
@requests = Request.recent
|
|
@@ -39,6 +39,138 @@ module Binocs
|
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
def sequence
|
|
43
|
+
@client_identifiers = Request.client_identifiers
|
|
44
|
+
@selected_client = params[:client].presence || @client_identifiers.first
|
|
45
|
+
@requests = Request.by_client(@selected_client).for_sequence.limit(200)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def heatmap
|
|
49
|
+
@endpoints = Request
|
|
50
|
+
.group(:method, :path)
|
|
51
|
+
.select(
|
|
52
|
+
"method",
|
|
53
|
+
"path",
|
|
54
|
+
"COUNT(*) as hit_count",
|
|
55
|
+
"AVG(duration_ms) as avg_duration",
|
|
56
|
+
"MAX(duration_ms) as max_duration",
|
|
57
|
+
"SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END) as error_count",
|
|
58
|
+
"SUM(CASE WHEN status_code >= 400 AND status_code < 500 THEN 1 ELSE 0 END) as client_error_count"
|
|
59
|
+
)
|
|
60
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
61
|
+
|
|
62
|
+
# Try to match endpoints to swagger spec paths for grouping
|
|
63
|
+
@swagger_spec = Binocs::Swagger::Client.fetch_spec rescue nil
|
|
64
|
+
@endpoint_groups = build_endpoint_groups(@endpoints, @swagger_spec)
|
|
65
|
+
|
|
66
|
+
@total_requests = Request.count
|
|
67
|
+
@max_hits = @endpoints.map(&:hit_count).max || 1
|
|
68
|
+
@max_avg_duration = @endpoints.map { |e| e.avg_duration.to_f }.max || 1
|
|
69
|
+
@view_mode = params[:view] || "frequency"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def analytics
|
|
73
|
+
@total_requests = Request.count
|
|
74
|
+
@today_requests = Request.today.count
|
|
75
|
+
@avg_duration = Request.average_duration
|
|
76
|
+
@error_rate = Request.error_rate
|
|
77
|
+
|
|
78
|
+
# Hourly traffic for the last 24 hours
|
|
79
|
+
@hourly_traffic = Request
|
|
80
|
+
.where("created_at >= ?", 24.hours.ago)
|
|
81
|
+
.group_by_hour
|
|
82
|
+
.count
|
|
83
|
+
|
|
84
|
+
# Status code distribution
|
|
85
|
+
@status_distribution = Request.status_breakdown
|
|
86
|
+
|
|
87
|
+
# Method distribution
|
|
88
|
+
@method_distribution = Request.methods_breakdown
|
|
89
|
+
|
|
90
|
+
# Top endpoints by volume
|
|
91
|
+
@top_endpoints = Request
|
|
92
|
+
.group(:method, :path)
|
|
93
|
+
.select("method, path, COUNT(*) as hit_count, AVG(duration_ms) as avg_duration")
|
|
94
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
95
|
+
.limit(15)
|
|
96
|
+
|
|
97
|
+
# Slowest endpoints (avg)
|
|
98
|
+
@slowest_endpoints = Request
|
|
99
|
+
.group(:method, :path)
|
|
100
|
+
.select("method, path, COUNT(*) as hit_count, AVG(duration_ms) as avg_duration, MAX(duration_ms) as max_duration")
|
|
101
|
+
.having("COUNT(*) >= 2")
|
|
102
|
+
.order(Arel.sql("AVG(duration_ms) DESC"))
|
|
103
|
+
.limit(10)
|
|
104
|
+
|
|
105
|
+
# Error hotspots
|
|
106
|
+
@error_endpoints = Request
|
|
107
|
+
.where("status_code >= 400")
|
|
108
|
+
.group(:method, :path, :status_code)
|
|
109
|
+
.select("method, path, status_code, COUNT(*) as error_count")
|
|
110
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
111
|
+
.limit(10)
|
|
112
|
+
|
|
113
|
+
# Response time distribution (buckets)
|
|
114
|
+
@duration_buckets = build_duration_buckets
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def lifecycle
|
|
118
|
+
logs = Array(@request.logs)
|
|
119
|
+
|
|
120
|
+
# Extract the controller log entry (the summary line from ActiveSupport::Notifications)
|
|
121
|
+
controller_log = logs.find { |l| l["type"] == "controller" }
|
|
122
|
+
|
|
123
|
+
# Timing breakdown
|
|
124
|
+
total_duration = @request.duration_ms.to_f
|
|
125
|
+
controller_duration = controller_log&.dig("duration").to_f
|
|
126
|
+
view_runtime = controller_log&.dig("view_runtime").to_f
|
|
127
|
+
db_runtime = controller_log&.dig("db_runtime").to_f
|
|
128
|
+
|
|
129
|
+
middleware_time = [total_duration - controller_duration, 0].max
|
|
130
|
+
other_time = [controller_duration - view_runtime - db_runtime, 0].max
|
|
131
|
+
|
|
132
|
+
@lifecycle = {
|
|
133
|
+
total_duration: total_duration,
|
|
134
|
+
controller_duration: controller_duration,
|
|
135
|
+
middleware_time: middleware_time,
|
|
136
|
+
view_runtime: view_runtime,
|
|
137
|
+
db_runtime: db_runtime,
|
|
138
|
+
other_time: other_time
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# SQL queries from logs
|
|
142
|
+
@sql_queries = logs.select { |l| l["type"] == "sql" }
|
|
143
|
+
|
|
144
|
+
# Render entries from logs
|
|
145
|
+
@render_entries = logs.select { |l| l["type"] == "render" }
|
|
146
|
+
|
|
147
|
+
# Halted filter (if any before_action halted the chain)
|
|
148
|
+
@halted_filter = logs.find { |l| l["type"] == "halted" }
|
|
149
|
+
|
|
150
|
+
# Redirect info
|
|
151
|
+
@redirect = logs.find { |l| l["type"] == "redirect" }
|
|
152
|
+
|
|
153
|
+
# Exception info (from logs or request model)
|
|
154
|
+
@exception_log = logs.find { |l| l["type"] == "exception" }
|
|
155
|
+
|
|
156
|
+
# Generic log entries
|
|
157
|
+
@log_entries = logs.select { |l| l["type"] == "log" }
|
|
158
|
+
|
|
159
|
+
# Middleware stack from the host Rails app
|
|
160
|
+
@middleware_stack = begin
|
|
161
|
+
Rails.application.middleware.map do |middleware|
|
|
162
|
+
name = middleware.klass.is_a?(String) ? middleware.klass : middleware.klass.name
|
|
163
|
+
name
|
|
164
|
+
end.compact
|
|
165
|
+
rescue
|
|
166
|
+
[]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def raw
|
|
171
|
+
@section = params[:section].presence || "full"
|
|
172
|
+
end
|
|
173
|
+
|
|
42
174
|
def clear
|
|
43
175
|
Request.delete_all
|
|
44
176
|
|
|
@@ -56,6 +188,63 @@ module Binocs
|
|
|
56
188
|
redirect_to requests_path, alert: "Request not found."
|
|
57
189
|
end
|
|
58
190
|
|
|
191
|
+
def build_endpoint_groups(endpoints, spec)
|
|
192
|
+
groups = {}
|
|
193
|
+
|
|
194
|
+
endpoints.each do |ep|
|
|
195
|
+
tag = find_swagger_tag(ep.method, ep.path, spec) || derive_tag_from_path(ep.path)
|
|
196
|
+
groups[tag] ||= []
|
|
197
|
+
groups[tag] << ep
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Sort groups by total hits descending
|
|
201
|
+
groups.sort_by { |_tag, eps| -eps.sum(&:hit_count) }.to_h
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def find_swagger_tag(method, path, spec)
|
|
205
|
+
return nil unless spec && spec["paths"]
|
|
206
|
+
|
|
207
|
+
spec["paths"].each do |spec_path, path_item|
|
|
208
|
+
next unless path_item.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
pattern = spec_path.gsub(/\{[^}]+\}/, "[^/]+")
|
|
211
|
+
next unless path.match?(/\A#{pattern}\z/)
|
|
212
|
+
|
|
213
|
+
operation = path_item[method.downcase]
|
|
214
|
+
next unless operation
|
|
215
|
+
|
|
216
|
+
return operation["tags"]&.first
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def derive_tag_from_path(path)
|
|
223
|
+
# /v1/companies/123/invitations -> "Companies"
|
|
224
|
+
# /api/users -> "Users"
|
|
225
|
+
segments = path.split("/").reject(&:blank?)
|
|
226
|
+
# Skip version prefixes
|
|
227
|
+
segments.shift if segments.first&.match?(/\Av\d+\z/)
|
|
228
|
+
segments.shift if segments.first&.match?(/\Aapi\z/i)
|
|
229
|
+
# Take first meaningful segment
|
|
230
|
+
segment = segments.first || "Other"
|
|
231
|
+
segment.titleize.pluralize
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def build_duration_buckets
|
|
235
|
+
buckets = {
|
|
236
|
+
"< 10ms" => Request.where("duration_ms < 10").count,
|
|
237
|
+
"10-50ms" => Request.where("duration_ms >= 10 AND duration_ms < 50").count,
|
|
238
|
+
"50-100ms" => Request.where("duration_ms >= 50 AND duration_ms < 100").count,
|
|
239
|
+
"100-250ms" => Request.where("duration_ms >= 100 AND duration_ms < 250").count,
|
|
240
|
+
"250-500ms" => Request.where("duration_ms >= 250 AND duration_ms < 500").count,
|
|
241
|
+
"500ms-1s" => Request.where("duration_ms >= 500 AND duration_ms < 1000").count,
|
|
242
|
+
"1-3s" => Request.where("duration_ms >= 1000 AND duration_ms < 3000").count,
|
|
243
|
+
"> 3s" => Request.where("duration_ms >= 3000").count
|
|
244
|
+
}
|
|
245
|
+
buckets.reject { |_, v| v.zero? }
|
|
246
|
+
end
|
|
247
|
+
|
|
59
248
|
def apply_filters(scope)
|
|
60
249
|
scope = scope.by_method(params[:method]) if params[:method].present?
|
|
61
250
|
scope = scope.by_status_range(params[:status]) if params[:status].present?
|
|
@@ -5,32 +5,32 @@ module Binocs
|
|
|
5
5
|
def method_badge_class(method)
|
|
6
6
|
case method.to_s.upcase
|
|
7
7
|
when "GET"
|
|
8
|
-
"bg-
|
|
8
|
+
"bg-emerald-500/15 text-emerald-400 ring-1 ring-emerald-500/20"
|
|
9
9
|
when "POST"
|
|
10
|
-
"bg-
|
|
10
|
+
"bg-cyan-500/15 text-cyan-400 ring-1 ring-cyan-500/20"
|
|
11
11
|
when "PUT", "PATCH"
|
|
12
|
-
"bg-
|
|
12
|
+
"bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20"
|
|
13
13
|
when "DELETE"
|
|
14
|
-
"bg-
|
|
14
|
+
"bg-rose-500/15 text-rose-400 ring-1 ring-rose-500/20"
|
|
15
15
|
else
|
|
16
|
-
"bg-
|
|
16
|
+
"bg-zinc-800 text-zinc-400 ring-1 ring-zinc-700"
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def status_badge_class(status)
|
|
21
|
-
return "bg-
|
|
21
|
+
return "bg-zinc-800 text-zinc-400 ring-1 ring-zinc-700" if status.nil?
|
|
22
22
|
|
|
23
23
|
case status
|
|
24
24
|
when 200..299
|
|
25
|
-
"bg-
|
|
25
|
+
"bg-emerald-500/15 text-emerald-400 ring-1 ring-emerald-500/20"
|
|
26
26
|
when 300..399
|
|
27
|
-
"bg-
|
|
27
|
+
"bg-cyan-500/15 text-cyan-400 ring-1 ring-cyan-500/20"
|
|
28
28
|
when 400..499
|
|
29
|
-
"bg-
|
|
29
|
+
"bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20"
|
|
30
30
|
when 500..599
|
|
31
|
-
"bg-
|
|
31
|
+
"bg-rose-500/15 text-rose-400 ring-1 ring-rose-500/20"
|
|
32
32
|
else
|
|
33
|
-
"bg-
|
|
33
|
+
"bg-zinc-800 text-zinc-400 ring-1 ring-zinc-700"
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -47,6 +47,18 @@ module Binocs
|
|
|
47
47
|
value.to_s
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
def client_label(identifier)
|
|
51
|
+
return "Unknown" if identifier.blank?
|
|
52
|
+
|
|
53
|
+
prefix, value = identifier.split(":", 2)
|
|
54
|
+
case prefix
|
|
55
|
+
when "session" then "Session #{value.to_s[0, 8]}"
|
|
56
|
+
when "auth" then "Auth #{value.to_s[0, 8]}"
|
|
57
|
+
when "ip" then "IP #{value}"
|
|
58
|
+
else identifier
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
50
62
|
def format_body(body)
|
|
51
63
|
return body if body.nil?
|
|
52
64
|
|
|
@@ -57,5 +69,25 @@ module Binocs
|
|
|
57
69
|
body
|
|
58
70
|
end
|
|
59
71
|
end
|
|
72
|
+
|
|
73
|
+
def relative_time(time)
|
|
74
|
+
return "N/A" if time.nil?
|
|
75
|
+
|
|
76
|
+
seconds = (Time.current - time).to_i
|
|
77
|
+
return "just now" if seconds < 5
|
|
78
|
+
|
|
79
|
+
minutes = seconds / 60
|
|
80
|
+
hours = minutes / 60
|
|
81
|
+
|
|
82
|
+
if hours >= 3
|
|
83
|
+
time.strftime("%b %d, %H:%M:%S")
|
|
84
|
+
elsif hours >= 1
|
|
85
|
+
"#{hours} #{hours == 1 ? 'hour' : 'hours'} ago"
|
|
86
|
+
elsif minutes >= 1
|
|
87
|
+
"#{minutes} #{minutes == 1 ? 'minute' : 'minutes'} ago"
|
|
88
|
+
else
|
|
89
|
+
"#{seconds} #{seconds == 1 ? 'second' : 'seconds'} ago"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
60
92
|
end
|
|
61
93
|
end
|
|
@@ -32,9 +32,21 @@ module Binocs
|
|
|
32
32
|
scope :without_exception, -> { where(exception: nil) }
|
|
33
33
|
scope :slow, ->(threshold_ms = 1000) { where("duration_ms > ?", threshold_ms) }
|
|
34
34
|
scope :by_ip, ->(ip) { where(ip_address: ip) if ip.present? }
|
|
35
|
+
scope :by_client, ->(identifier) { where(client_identifier: identifier) if identifier.present? }
|
|
36
|
+
scope :for_sequence, -> { order(created_at: :asc) }
|
|
35
37
|
scope :recent, -> { order(created_at: :desc) }
|
|
36
38
|
scope :today, -> { where("created_at >= ?", Time.current.beginning_of_day) }
|
|
37
39
|
scope :last_hour, -> { where("created_at >= ?", 1.hour.ago) }
|
|
40
|
+
scope :group_by_hour, -> {
|
|
41
|
+
adapter = connection.adapter_name.downcase
|
|
42
|
+
if adapter.include?("sqlite")
|
|
43
|
+
group("strftime('%Y-%m-%d %H:00', created_at)")
|
|
44
|
+
elsif adapter.include?("postgres")
|
|
45
|
+
group("to_char(created_at, 'YYYY-MM-DD HH24:00')")
|
|
46
|
+
else
|
|
47
|
+
group("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')")
|
|
48
|
+
end
|
|
49
|
+
}
|
|
38
50
|
scope :search, ->(query) {
|
|
39
51
|
return all if query.blank?
|
|
40
52
|
|
|
@@ -172,7 +184,26 @@ module Binocs
|
|
|
172
184
|
controller_name.demodulize.sub(/Controller$/, "").titleize
|
|
173
185
|
end
|
|
174
186
|
|
|
187
|
+
def client_label
|
|
188
|
+
return "Unknown" if client_identifier.blank?
|
|
189
|
+
|
|
190
|
+
prefix, value = client_identifier.split(":", 2)
|
|
191
|
+
case prefix
|
|
192
|
+
when "session" then "Session #{value.to_s[0, 8]}"
|
|
193
|
+
when "auth" then "Auth #{value.to_s[0, 8]}"
|
|
194
|
+
when "ip" then "IP #{value}"
|
|
195
|
+
else client_identifier
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
175
199
|
# Class methods for statistics
|
|
200
|
+
def self.client_identifiers
|
|
201
|
+
where.not(client_identifier: nil)
|
|
202
|
+
.group(:client_identifier)
|
|
203
|
+
.order(Arel.sql("MAX(created_at) DESC"))
|
|
204
|
+
.pluck(:client_identifier)
|
|
205
|
+
end
|
|
206
|
+
|
|
176
207
|
def self.average_duration
|
|
177
208
|
average(:duration_ms)&.round(2)
|
|
178
209
|
end
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<div class="px-4 py-12 text-center">
|
|
2
|
-
<svg class="mx-auto h-12 w-12 text-
|
|
2
|
+
<svg class="mx-auto h-12 w-12 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
3
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
4
|
</svg>
|
|
5
|
-
<h3 class="mt-2 text-sm font-medium text-
|
|
6
|
-
<p class="mt-1 text-sm text-
|
|
5
|
+
<h3 class="mt-2 text-sm font-medium text-zinc-200">No requests</h3>
|
|
6
|
+
<p class="mt-1 text-sm text-zinc-500">
|
|
7
7
|
Make some requests to your application and they'll appear here in real-time.
|
|
8
8
|
</p>
|
|
9
9
|
</div>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<% border_class = case log["type"]
|
|
2
|
+
when "exception" then "border-l-4 border-l-rose-500"
|
|
3
|
+
when "log" then log["level"] == "error" || log["level"] == "fatal" ? "border-l-4 border-l-rose-500" : "border-l-4 border-l-amber-500"
|
|
4
|
+
when "sql" then log["error"] ? "border-l-4 border-l-rose-500" : ""
|
|
5
|
+
when "render" then log["error"] ? "border-l-4 border-l-rose-500" : ""
|
|
6
|
+
else ""
|
|
7
|
+
end %>
|
|
8
|
+
|
|
9
|
+
<div class="bg-zinc-950 rounded p-4 <%= border_class %>">
|
|
10
|
+
<div class="flex items-center justify-between mb-2">
|
|
11
|
+
<div class="flex items-center space-x-2">
|
|
12
|
+
<% type_color = case log["type"]
|
|
13
|
+
when "exception" then "text-rose-400 bg-rose-500/10"
|
|
14
|
+
when "log" then log["level"] == "error" || log["level"] == "fatal" ? "text-rose-400 bg-rose-500/10" : "text-amber-400 bg-amber-500/10"
|
|
15
|
+
when "sql" then log["error"] ? "text-rose-400 bg-rose-500/10" : "text-cyan-400 bg-cyan-500/10"
|
|
16
|
+
when "render" then "text-purple-400 bg-purple-500/10"
|
|
17
|
+
when "controller" then "text-yellow-400 bg-yellow-500/10"
|
|
18
|
+
when "redirect" then "text-cyan-400 bg-cyan-500/10"
|
|
19
|
+
when "halted" then "text-amber-400 bg-amber-500/10"
|
|
20
|
+
else "text-zinc-400 bg-zinc-800"
|
|
21
|
+
end %>
|
|
22
|
+
<span class="text-xs font-medium uppercase rounded px-1.5 py-0.5 <%= type_color %>"><%= log["type"] %></span>
|
|
23
|
+
<% if log["level"] %>
|
|
24
|
+
<span class="text-xs font-bold uppercase <%= log["level"] == 'error' || log["level"] == 'fatal' ? 'text-rose-300' : 'text-amber-300' %>"><%= log["level"] %></span>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% if log["duration"] %>
|
|
27
|
+
<span class="text-xs text-zinc-600"><%= log["duration"] %>ms</span>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
30
|
+
<span class="text-xs text-zinc-600"><%= log["timestamp"] %></span>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<% case log["type"] %>
|
|
34
|
+
<% when "controller" %>
|
|
35
|
+
<div class="text-sm text-zinc-200">
|
|
36
|
+
<span class="text-zinc-500">Controller:</span> <%= log["controller"] %>#<%= log["action"] %>
|
|
37
|
+
<% if log["view_runtime"] %>
|
|
38
|
+
<span class="ml-4 text-zinc-500">View:</span> <%= log["view_runtime"] %>ms
|
|
39
|
+
<% end %>
|
|
40
|
+
<% if log["db_runtime"] %>
|
|
41
|
+
<span class="ml-4 text-zinc-500">DB:</span> <%= log["db_runtime"] %>ms
|
|
42
|
+
<% end %>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<% when "redirect" %>
|
|
46
|
+
<div class="text-sm text-zinc-200">
|
|
47
|
+
<span class="text-zinc-500">Redirect to:</span> <%= log["location"] %>
|
|
48
|
+
<span class="ml-4 text-zinc-500">Status:</span> <%= log["status"] %>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<% when "sql" %>
|
|
52
|
+
<div>
|
|
53
|
+
<div class="flex items-center space-x-2 mb-1">
|
|
54
|
+
<span class="text-xs text-zinc-500"><%= log["name"] %></span>
|
|
55
|
+
</div>
|
|
56
|
+
<pre class="text-sm font-mono overflow-x-auto whitespace-pre-wrap <%= log["error"] ? 'text-rose-300' : 'text-emerald-300' %>"><%= log["sql"] %></pre>
|
|
57
|
+
<% if log["error"] %>
|
|
58
|
+
<div class="mt-2 text-sm text-rose-300">
|
|
59
|
+
<span class="font-semibold"><%= log["exception_class"] %>:</span> <%= log["exception_message"] %>
|
|
60
|
+
</div>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<% when "render" %>
|
|
65
|
+
<div class="text-sm text-zinc-200">
|
|
66
|
+
<span class="text-zinc-500"><%= log["render_type"]&.capitalize %>:</span>
|
|
67
|
+
<span class="font-mono text-purple-300 ml-1"><%= log["identifier"] %></span>
|
|
68
|
+
<% if log["error"] %>
|
|
69
|
+
<div class="mt-1 text-rose-300">
|
|
70
|
+
<span class="font-semibold"><%= log["exception_class"] %>:</span> <%= log["exception_message"] %>
|
|
71
|
+
</div>
|
|
72
|
+
<% end %>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<% when "exception" %>
|
|
76
|
+
<div class="text-sm">
|
|
77
|
+
<div class="text-rose-300 font-mono font-semibold"><%= log["class"] %></div>
|
|
78
|
+
<div class="text-rose-200 mt-1"><%= log["message"] %></div>
|
|
79
|
+
<% if log["backtrace"].present? %>
|
|
80
|
+
<pre class="mt-2 bg-zinc-900 rounded p-3 text-xs text-rose-200 overflow-x-auto max-h-48"><%= Array(log["backtrace"]).join("\n") %></pre>
|
|
81
|
+
<% end %>
|
|
82
|
+
<% if log["cause"].present? %>
|
|
83
|
+
<div class="mt-3 pl-3 border-l-2 border-rose-500/30">
|
|
84
|
+
<div class="text-xs text-rose-400 uppercase font-semibold mb-1">Caused by</div>
|
|
85
|
+
<div class="text-rose-300 font-mono text-xs"><%= log["cause"]["class"] %>: <%= log["cause"]["message"] %></div>
|
|
86
|
+
<% if log["cause"]["backtrace"].present? %>
|
|
87
|
+
<pre class="mt-1 text-xs text-rose-200/70 overflow-x-auto max-h-24"><%= Array(log["cause"]["backtrace"]).join("\n") %></pre>
|
|
88
|
+
<% end %>
|
|
89
|
+
</div>
|
|
90
|
+
<% end %>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<% when "log" %>
|
|
94
|
+
<pre class="text-sm font-mono whitespace-pre-wrap <%= log["level"] == 'error' || log["level"] == 'fatal' ? 'text-rose-300' : 'text-amber-300' %>"><%= log["message"] %></pre>
|
|
95
|
+
|
|
96
|
+
<% when "halted" %>
|
|
97
|
+
<div class="text-sm text-amber-300">
|
|
98
|
+
Filter chain halted by <span class="font-mono font-semibold"><%= log["filter"] %></span>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<% else %>
|
|
102
|
+
<pre class="text-sm text-zinc-200"><%= format_value(log) %></pre>
|
|
103
|
+
<% end %>
|
|
104
|
+
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<%= turbo_frame_tag dom_id(request) do %>
|
|
2
|
-
<%= link_to binocs.request_path(request.uuid), class: "block hover:bg-
|
|
2
|
+
<%= link_to binocs.request_path(request.uuid), class: "block hover:bg-yellow-500/5 transition-colors duration-150", data: { turbo_frame: "_top" } do %>
|
|
3
3
|
<div class="px-4 py-3 pr-6">
|
|
4
4
|
<div class="flex items-center justify-between">
|
|
5
5
|
<div class="flex items-center space-x-4 min-w-0">
|
|
@@ -14,20 +14,20 @@
|
|
|
14
14
|
</span>
|
|
15
15
|
|
|
16
16
|
<!-- Path -->
|
|
17
|
-
<span class="text-sm text-
|
|
17
|
+
<span class="text-sm text-zinc-200 truncate" title="<%= request.path %>">
|
|
18
18
|
<%= request.short_path %>
|
|
19
19
|
</span>
|
|
20
20
|
|
|
21
21
|
<!-- Controller#Action -->
|
|
22
22
|
<% if request.controller_action %>
|
|
23
|
-
<span class="text-xs text-
|
|
23
|
+
<span class="text-xs text-zinc-500">
|
|
24
24
|
<%= request.controller_action %>
|
|
25
25
|
</span>
|
|
26
26
|
<% end %>
|
|
27
27
|
|
|
28
28
|
<!-- Exception Indicator -->
|
|
29
29
|
<% if request.has_exception? %>
|
|
30
|
-
<span class="inline-flex items-center rounded-full bg-
|
|
30
|
+
<span class="inline-flex items-center rounded-full bg-rose-500/10 px-2 py-1 text-xs font-medium text-rose-400 ring-1 ring-rose-500/20">
|
|
31
31
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
32
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
33
|
</svg>
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
<% end %>
|
|
37
37
|
</div>
|
|
38
38
|
|
|
39
|
-
<div class="flex items-center space-x-4 text-sm text-
|
|
39
|
+
<div class="flex items-center space-x-4 text-sm text-zinc-500 flex-shrink-0 pl-4">
|
|
40
40
|
<!-- Duration -->
|
|
41
41
|
<span title="Duration" class="whitespace-nowrap">
|
|
42
42
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|