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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 951ede246b19df8711837e0024c936962a8e56c60351a1fa1c47202ff6923ebb
4
- data.tar.gz: b21a7315ffd445e61326d0bc7332431cde04f7d44510fdfda24f0938351717a2
3
+ metadata.gz: e4195cc40aca616da7fff7aaa442e94847efeab48e360fa2b5b47c7e66d9a0b2
4
+ data.tar.gz: 8214123335d3746b294d857275738a790b6ed66bf44c5b7a5046daefd1907b69
5
5
  SHA512:
6
- metadata.gz: 10ec890ba59758423105071b00baa05183d9a22e51b92106831b9b8c4f40d11d8bdc1c391934bc99a1f426c50e012523ce74c4442602c93b32ba3bd3d51389fd
7
- data.tar.gz: 50c57ebe70c4997edf303bb7bbdda1fd29d2a03474317c4f93fa9281aceaa97b9ee05c23b45ccc9f7ad32c7c1b72a74b542220a22396de92796e34822ca09c7f
6
+ metadata.gz: 8c5bb23f9b228e6734132ab2775ab4593bf287a0035ce2ef0f62f13ee99e1ca627422f227079c0e9172e7bceb4b07def83d08dbab6e20fe2a4a0d34d3a370610
7
+ data.tar.gz: 88a11c55945e97c2a96dd6e377390227ade60c484394c93d1f6c4c1127be6f4315abd3341bb60fc8808d59d29e7a08caf5ff9e91235109860260ee93bc452ca4
data/README.md CHANGED
@@ -1,7 +1,17 @@
1
1
  # Binocs
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/binocs.svg)](https://rubygems.org/gems/binocs)
4
+
5
+ ![A picture of a train conductor with binoculars](https://cdn.zincan.com/952b429896eb12e5e36a68a43a9cdc16.jpg)
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', path: 'path/to/binocs' # For local development
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-green-900/50 text-green-300"
8
+ "bg-emerald-500/15 text-emerald-400 ring-1 ring-emerald-500/20"
9
9
  when "POST"
10
- "bg-blue-900/50 text-blue-300"
10
+ "bg-cyan-500/15 text-cyan-400 ring-1 ring-cyan-500/20"
11
11
  when "PUT", "PATCH"
12
- "bg-yellow-900/50 text-yellow-300"
12
+ "bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20"
13
13
  when "DELETE"
14
- "bg-red-900/50 text-red-300"
14
+ "bg-rose-500/15 text-rose-400 ring-1 ring-rose-500/20"
15
15
  else
16
- "bg-slate-700 text-slate-300"
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-slate-700 text-slate-300" if status.nil?
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-green-900/50 text-green-300"
25
+ "bg-emerald-500/15 text-emerald-400 ring-1 ring-emerald-500/20"
26
26
  when 300..399
27
- "bg-blue-900/50 text-blue-300"
27
+ "bg-cyan-500/15 text-cyan-400 ring-1 ring-cyan-500/20"
28
28
  when 400..499
29
- "bg-yellow-900/50 text-yellow-300"
29
+ "bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20"
30
30
  when 500..599
31
- "bg-red-900/50 text-red-300"
31
+ "bg-rose-500/15 text-rose-400 ring-1 ring-rose-500/20"
32
32
  else
33
- "bg-slate-700 text-slate-300"
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-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
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-white">No requests</h3>
6
- <p class="mt-1 text-sm text-slate-400">
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-slate-700/50 transition-colors duration-150", data: { turbo_frame: "_top" } do %>
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-white truncate" title="<%= request.path %>">
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-slate-400">
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-red-900/50 px-2 py-1 text-xs font-medium text-red-300">
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-slate-400 flex-shrink-0 pl-4">
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">