catpm 0.2.0 → 0.4.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: 4ee3b0b066416fbb74d3bdecd0397443272118d67a07b339e0d61a7c2f408351
4
- data.tar.gz: 5b355e9a18cde63cd975c49300f8f56efa4d8a079e855a3747de518965ff1611
3
+ metadata.gz: 30a794032ccb0fcb32152d5ae6c7285758260c6a80c5241c217924a887040885
4
+ data.tar.gz: 7691f6480aa62e4efd41d657fda9e1501a2765584a3d926a5d9ac3a8b6ac0c98
5
5
  SHA512:
6
- metadata.gz: '0811e5ecba637add9c81496c180eb65fd4d8864761bb4c4d5c40b0be40ad9f3ca7e5b6e9ce120316ad2ed17f144e68b8953cf3d2ce2721f2b942a9e43aebece4'
7
- data.tar.gz: 43434262c2f712032f5cf7e2205ccf2db0638d48c672e89dd2787b90258c1cd861353d21b62b504b53daa576501614e8c3aa1b6105ed6fc8e4655056120478d5
6
+ metadata.gz: c902a837f12312f1f856d232b056eb3669c8648f5907e5088ced55972888d64682749f937a994d79b040688d1f59d929dccd11a1d28ffdcfe4e25f2458c02f49
7
+ data.tar.gz: 73dd36a0cb7ea32b405388bb62f54e9f327f30155b3b30794101feb056abe70b7b747cd5dda3dc67e6669e4289392964b37ae76385e6cb680c96e5a5bb550d55
@@ -2,5 +2,13 @@
2
2
 
3
3
  module Catpm
4
4
  class ApplicationController < ActionController::Base
5
+ private
6
+
7
+ def remembered_range
8
+ if params[:range].present?
9
+ cookies[:catpm_range] = { value: params[:range], expires: 1.year.from_now }
10
+ end
11
+ params[:range] || cookies[:catpm_range]
12
+ end
5
13
  end
6
14
  end
@@ -8,7 +8,7 @@ module Catpm
8
8
  @operation = params[:operation].presence || ''
9
9
 
10
10
  # Time range filter
11
- @range, period, _bucket_seconds = helpers.parse_range(params[:range], extra_valid: ['all'])
11
+ @range, period, bucket_seconds = helpers.parse_range(remembered_range)
12
12
 
13
13
  scope = Catpm::Bucket
14
14
  .where(kind: @kind, target: @target, operation: @operation)
@@ -23,11 +23,15 @@ module Catpm
23
23
  'MAX(duration_max)',
24
24
  'MIN(duration_min)',
25
25
  'SUM(failure_count)',
26
- 'SUM(success_count)'
26
+ 'SUM(success_count)',
27
+ 'MIN(bucket_start)',
28
+ 'MAX(bucket_start)'
27
29
  )
28
30
 
29
31
  @count, @duration_sum, @duration_max, @duration_min, @failure_count, @success_count =
30
- @aggregate.map { |v| v || 0 }
32
+ @aggregate[0..5].map { |v| v || 0 }
33
+ @first_event_at = @aggregate[6]
34
+ @last_event_at = @aggregate[7]
31
35
 
32
36
  @avg_duration = @count > 0 ? @duration_sum / @count : 0.0
33
37
  @failure_rate = @count > 0 ? @failure_count.to_f / @count : 0.0
@@ -45,19 +49,52 @@ module Catpm
45
49
  end
46
50
  end
47
51
 
52
+ # Chart data — request volume, errors, avg duration
53
+ chart_buckets = scope.order(bucket_start: :asc).to_a
54
+ bucket_seconds = helpers.compute_bucket_seconds(chart_buckets) if @range == 'all'
55
+
56
+ if bucket_seconds
57
+ slots_count = {}
58
+ slots_errors = {}
59
+ slots_dur_sum = {}
60
+ slots_dur_count = {}
61
+ chart_buckets.each do |b|
62
+ slot_key = (b.bucket_start.to_i / bucket_seconds) * bucket_seconds
63
+ slots_count[slot_key] = (slots_count[slot_key] || 0) + b.count
64
+ slots_errors[slot_key] = (slots_errors[slot_key] || 0) + b.failure_count
65
+ slots_dur_sum[slot_key] = (slots_dur_sum[slot_key] || 0) + b.duration_sum
66
+ slots_dur_count[slot_key] = (slots_dur_count[slot_key] || 0) + b.count
67
+ end
68
+
69
+ now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
70
+ @chart_requests = 60.times.map { |i| slots_count[now_slot - (59 - i) * bucket_seconds] || 0 }
71
+ @chart_errors = 60.times.map { |i| slots_errors[now_slot - (59 - i) * bucket_seconds] || 0 }
72
+ @chart_durations = 60.times.map do |i|
73
+ key = now_slot - (59 - i) * bucket_seconds
74
+ c = slots_dur_count[key] || 0
75
+ c > 0 ? (slots_dur_sum[key] / c).round(1) : 0
76
+ end
77
+ @chart_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
78
+ end
79
+
48
80
  endpoint_samples = Catpm::Sample
49
81
  .joins(:bucket)
50
82
  .where(catpm_buckets: { kind: @kind, target: @target, operation: @operation })
51
83
 
52
- if @range != 'all'
53
- endpoint_samples = endpoint_samples.where('catpm_samples.recorded_at >= ?', period.ago)
54
- end
55
-
56
84
  @slow_samples = endpoint_samples.where(sample_type: 'slow').order(duration: :desc).limit(10)
57
85
  @samples = endpoint_samples.where(sample_type: 'random').order(recorded_at: :desc).limit(10)
58
86
  @error_samples = endpoint_samples.where(sample_type: 'error').order(recorded_at: :desc).limit(10)
59
87
 
60
88
  @active_error_count = Catpm::ErrorRecord.unresolved.count
61
89
  end
90
+
91
+ def destroy
92
+ kind = params[:kind]
93
+ target = params[:target]
94
+ operation = params[:operation].presence || ''
95
+
96
+ Catpm::Bucket.where(kind: kind, target: target, operation: operation).destroy_all
97
+ redirect_to catpm.status_index_path, notice: 'Endpoint deleted'
98
+ end
62
99
  end
63
100
  end
@@ -36,7 +36,7 @@ module Catpm
36
36
  @contexts = @error.parsed_contexts
37
37
  @active_error_count = Catpm::ErrorRecord.unresolved.count
38
38
 
39
- @range, period, bucket_seconds = helpers.parse_range(params[:range] || '24h')
39
+ @range, period, bucket_seconds = helpers.parse_range(remembered_range)
40
40
 
41
41
  # Samples table: 20 most recent linked by fingerprint
42
42
  @samples = Catpm::Sample.where(error_fingerprint: @error.fingerprint)
@@ -66,12 +66,36 @@ module Catpm
66
66
  end
67
67
 
68
68
  slots = {}
69
- cutoff = period.ago.to_i
70
- (ob[resolution] || {}).each do |ts_str, count|
71
- ts = ts_str.to_i
72
- next if ts < cutoff
73
- slot_key = (ts / bucket_seconds) * bucket_seconds
74
- slots[slot_key] = (slots[slot_key] || 0) + count
69
+ if @range == 'all'
70
+ # Collect all timestamps to compute dynamic bucket_seconds
71
+ all_timestamps = []
72
+ (ob[resolution] || {}).each do |ts_str, count|
73
+ ts = ts_str.to_i
74
+ all_timestamps << ts
75
+ slots[ts] = (slots[ts] || 0) + count
76
+ end
77
+ if all_timestamps.any?
78
+ span = all_timestamps.max - all_timestamps.min
79
+ span = 3600 if span < 3600
80
+ bucket_seconds = (span / 60.0).ceil
81
+ else
82
+ bucket_seconds = 60
83
+ end
84
+ # Re-bucket with computed bucket_seconds
85
+ rebucketed = {}
86
+ slots.each do |ts, count|
87
+ slot_key = (ts / bucket_seconds) * bucket_seconds
88
+ rebucketed[slot_key] = (rebucketed[slot_key] || 0) + count
89
+ end
90
+ slots = rebucketed
91
+ else
92
+ cutoff = period.ago.to_i
93
+ (ob[resolution] || {}).each do |ts_str, count|
94
+ ts = ts_str.to_i
95
+ next if ts < cutoff
96
+ slot_key = (ts / bucket_seconds) * bucket_seconds
97
+ slots[slot_key] = (slots[slot_key] || 0) + count
98
+ end
75
99
  end
76
100
 
77
101
  now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
@@ -5,13 +5,25 @@ module Catpm
5
5
  PER_PAGE = 25
6
6
 
7
7
  def index
8
- @range, period, bucket_seconds = helpers.parse_range(params[:range])
8
+ @range, period, bucket_seconds = helpers.parse_range(remembered_range)
9
9
 
10
- recent_buckets = Catpm::EventBucket.recent(period).to_a
10
+ recent_buckets = if @range == 'all'
11
+ Catpm::EventBucket.all.to_a
12
+ else
13
+ Catpm::EventBucket.recent(period).to_a
14
+ end
15
+
16
+ bucket_seconds = helpers.compute_bucket_seconds(recent_buckets) if @range == 'all'
11
17
 
12
18
  # Hero metrics
13
19
  @total_events = recent_buckets.sum(&:count)
14
- period_minutes = period.to_f / 60
20
+ effective_period = if @range == 'all'
21
+ earliest = recent_buckets.min_by(&:bucket_start)&.bucket_start
22
+ earliest ? [Time.current - earliest, 60].max : 3600
23
+ else
24
+ period
25
+ end
26
+ period_minutes = effective_period.to_f / 60
15
27
  @events_per_min = (period_minutes > 0 ? @total_events / period_minutes : 0).round(1)
16
28
 
17
29
  # Group by name for table
@@ -59,13 +71,25 @@ module Catpm
59
71
 
60
72
  def show
61
73
  @name = params[:name]
62
- @range, period, bucket_seconds = helpers.parse_range(params[:range])
74
+ @range, period, bucket_seconds = helpers.parse_range(remembered_range)
63
75
 
64
- recent_buckets = Catpm::EventBucket.by_name(@name).recent(period).to_a
76
+ recent_buckets = if @range == 'all'
77
+ Catpm::EventBucket.by_name(@name).all.to_a
78
+ else
79
+ Catpm::EventBucket.by_name(@name).recent(period).to_a
80
+ end
81
+
82
+ bucket_seconds = helpers.compute_bucket_seconds(recent_buckets) if @range == 'all'
65
83
 
66
84
  # Hero metrics
67
85
  @total_count = recent_buckets.sum(&:count)
68
- period_minutes = period.to_f / 60
86
+ effective_period = if @range == 'all'
87
+ earliest = recent_buckets.min_by(&:bucket_start)&.bucket_start
88
+ earliest ? [Time.current - earliest, 60].max : 3600
89
+ else
90
+ period
91
+ end
92
+ period_minutes = effective_period.to_f / 60
69
93
  @events_per_min = (period_minutes > 0 ? @total_count / period_minutes : 0).round(1)
70
94
  @last_seen = recent_buckets.map(&:bucket_start).max
71
95
 
@@ -6,9 +6,15 @@ module Catpm
6
6
 
7
7
  def index
8
8
  # Time range (parsed first — everything below uses this)
9
- @range, period, bucket_seconds = helpers.parse_range(params[:range])
9
+ @range, period, bucket_seconds = helpers.parse_range(remembered_range)
10
10
 
11
- recent_buckets = Catpm::Bucket.recent(period).to_a
11
+ recent_buckets = if @range == 'all'
12
+ Catpm::Bucket.all.to_a
13
+ else
14
+ Catpm::Bucket.recent(period).to_a
15
+ end
16
+
17
+ bucket_seconds = helpers.compute_bucket_seconds(recent_buckets) if @range == 'all'
12
18
 
13
19
  # Sparkline data
14
20
  slots = {}
@@ -31,7 +37,13 @@ module Catpm
31
37
 
32
38
  recent_count = recent_buckets.sum(&:count)
33
39
  recent_failures = recent_buckets.sum(&:failure_count)
34
- period_minutes = period.to_f / 60
40
+ earliest_bucket = recent_buckets.min_by(&:bucket_start)&.bucket_start
41
+ effective_period = if @range == 'all'
42
+ earliest_bucket ? [Time.current - earliest_bucket, 60].max : 3600
43
+ else
44
+ earliest_bucket ? [[period, Time.current - earliest_bucket].min, 60].max : period
45
+ end
46
+ period_minutes = effective_period.to_f / 60
35
47
  @recent_avg_duration = recent_count > 0 ? (recent_buckets.sum(&:duration_sum) / recent_count).round(1) : 0.0
36
48
  @error_rate = recent_count > 0 ? (recent_failures.to_f / recent_count * 100).round(1) : 0.0
37
49
  @requests_per_min = (recent_count / period_minutes).round(1)
@@ -7,9 +7,6 @@ module Catpm
7
7
  @buffer_size = Catpm.buffer&.size || 0
8
8
  @buffer_bytes = Catpm.buffer&.current_bytes || 0
9
9
  @config = Catpm.config
10
- @bucket_count = Catpm::Bucket.count
11
- @sample_count = Catpm::Sample.count
12
- @error_count = Catpm::ErrorRecord.count
13
10
  @oldest_bucket = Catpm::Bucket.minimum(:bucket_start)
14
11
  @active_error_count = Catpm::ErrorRecord.unresolved.count
15
12
  end
@@ -116,10 +116,14 @@ module Catpm
116
116
  circles = parsed.map.with_index do |(x, y, val), i|
117
117
  label = labels ? labels[i] : val.is_a?(Float) ? ('%.1f' % val) : val
118
118
  time_attr = time_labels ? %( data-time="#{time_labels[i]}") : ''
119
- %(<circle cx="#{x}" cy="#{y}" r="6" fill="transparent" data-value="#{label}"#{time_attr} class="sparkline-dot"/>)
119
+ %(<circle cx="#{x}" cy="#{y}" r="0" data-value="#{label}"#{time_attr} class="sparkline-dot"/>)
120
120
  end.join
121
121
 
122
- %(<svg width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}" xmlns="http://www.w3.org/2000/svg" style="display:block;position:relative">#{fill_el}<polyline points="#{coords_str.join(" ")}" fill="none" stroke="#{color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>#{circles}</svg>).html_safe
122
+ capture = %(<rect width="#{width}" height="#{height}" fill="transparent"/>)
123
+ highlight = %(<circle cx="0" cy="0" r="3" fill="#{color}" class="sparkline-highlight" style="display:none"/>)
124
+ vline = %(<line x1="0" y1="0" x2="0" y2="#{height}" stroke="#{color}" stroke-width="0.5" opacity="0.4" class="sparkline-vline" style="display:none"/>)
125
+
126
+ %(<svg class="sparkline-chart" width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}" xmlns="http://www.w3.org/2000/svg" style="display:block">#{capture}#{fill_el}<polyline points="#{coords_str.join(" ")}" fill="none" stroke="#{color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>#{circles}#{vline}#{highlight}</svg>).html_safe
123
127
  end
124
128
 
125
129
  def bar_chart_svg(data_points, width: 600, height: 200, color: 'var(--accent)', time_labels: nil)
@@ -207,16 +211,16 @@ module Catpm
207
211
  %(<span class="status-dot"><span class="dot" style="background:#{color}"></span> #{label}</span>).html_safe
208
212
  end
209
213
 
210
- def parse_range(range_str, extra_valid: [])
211
- valid = RANGE_KEYS + extra_valid
212
- key = valid.include?(range_str) ? range_str : '1h'
213
- return [key, nil, nil] if extra_valid.include?(key) && !RANGES.key?(key)
214
+ def parse_range(range_str)
215
+ key = (RANGE_KEYS + ['all']).include?(range_str) ? range_str : 'all'
216
+ return [key, nil, nil] if key == 'all'
214
217
  period, bucket_seconds = RANGES[key]
215
218
  [key, period, bucket_seconds]
216
219
  end
217
220
 
218
221
  def range_label(range)
219
222
  case range
223
+ when 'all' then 'All time'
220
224
  when '6h' then 'Last 6 hours'
221
225
  when '24h' then 'Last 24 hours'
222
226
  when '1w' then 'Last week'
@@ -227,6 +231,14 @@ module Catpm
227
231
  end
228
232
  end
229
233
 
234
+ def compute_bucket_seconds(buckets)
235
+ return 60 if buckets.empty?
236
+ times = buckets.map { |b| b.bucket_start.to_i }
237
+ span = times.max - times.min
238
+ span = 3600 if span < 3600
239
+ (span / 60.0).ceil
240
+ end
241
+
230
242
  def pagination_nav(current_page, total_count, per_page, extra_params: {})
231
243
  total_pages = (total_count.to_f / per_page).ceil
232
244
  return '' if total_pages <= 1
@@ -236,7 +248,7 @@ module Catpm
236
248
  prev_url = '?' + prev_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
237
249
  next_url = '?' + next_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
238
250
 
239
- html = '<div class="pagination">'
251
+ html = +'<div class="pagination">'
240
252
  if current_page > 1
241
253
  html << %(<a href="#{prev_url}" class="btn">← Previous</a>)
242
254
  else
@@ -8,15 +8,20 @@
8
8
 
9
9
  <%= render "catpm/shared/page_nav", active: "performance" %>
10
10
 
11
- <div class="breadcrumbs">
12
- <a href="<%= catpm.status_index_path %>">Performance</a>
13
- <span class="sep">/</span>
14
- <span><%= @target %></span>
11
+ <div class="breadcrumbs" style="display:flex; align-items:center; justify-content:space-between">
12
+ <div>
13
+ <a href="<%= catpm.status_index_path %>">Performance</a>
14
+ <span class="sep">/</span>
15
+ <span><%= @target %></span>
16
+ </div>
17
+ <%= button_to "Delete Endpoint", catpm.endpoint_path(kind: @kind, target: @target, operation: @operation),
18
+ method: :delete, class: "btn btn-danger",
19
+ data: { confirm: "Delete this endpoint and all its data? This cannot be undone." } %>
15
20
  </div>
16
21
 
17
22
  <% ep_params = { kind: @kind, target: @target, operation: @operation } %>
18
23
  <div class="time-range">
19
- <% (Catpm::ApplicationHelper::RANGE_KEYS + ["all"]).each do |r| %>
24
+ <% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
20
25
  <a href="<%= catpm.endpoint_path(ep_params.merge(range: r)) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
21
26
  <% end %>
22
27
  </div>
@@ -27,12 +32,12 @@
27
32
  <div class="value"><%= @count %></div>
28
33
  </div>
29
34
  <div class="card">
30
- <div class="label">Avg Duration</div>
31
- <div class="value"><%= format_duration(@avg_duration) %></div>
35
+ <div class="label">First Event</div>
36
+ <div class="value"><%= @first_event_at ? time_with_tooltip(@first_event_at) : "—" %></div>
32
37
  </div>
33
38
  <div class="card">
34
- <div class="label">p95</div>
35
- <div class="value"><%= @tdigest ? format_duration(@tdigest.percentile(0.95)) : "—" %></div>
39
+ <div class="label">Last Event</div>
40
+ <div class="value"><%= @last_event_at ? time_with_tooltip(@last_event_at) : "—" %></div>
36
41
  </div>
37
42
  <div class="card">
38
43
  <div class="label">Max</div>
@@ -101,19 +106,36 @@
101
106
  </div>
102
107
  </div>
103
108
 
104
- <div class="grid">
105
- <% type_data.each do |d| %>
106
- <div class="card">
107
- <div class="label">Avg <%= d[:label] %></div>
108
- <div class="value"><%= "%.0f" % d[:avg_count] %></div>
109
- <div class="detail"><%= format_duration(d[:avg_dur]) %> avg</div>
109
+ <% end %>
110
+
111
+ <% has_requests = @chart_requests&.any? { |v| v > 0 } %>
112
+ <% has_errors = @chart_errors&.any? { |v| v > 0 } %>
113
+ <% has_durations = @chart_durations&.any? { |v| v > 0 } %>
114
+ <% if has_requests || has_errors || has_durations %>
115
+ <div class="charts-row">
116
+ <% if has_requests %>
117
+ <div class="chart-card">
118
+ <div class="label">Request Volume</div>
119
+ <div class="value"><%= @chart_requests.sum %></div>
120
+ <div class="detail"><%= range_label(@range) %></div>
121
+ <div class="sparkline"><%= sparkline_svg(@chart_requests, width: 500, height: 64, color: "var(--accent)", fill: true, time_labels: @chart_times) %></div>
110
122
  </div>
111
123
  <% end %>
112
- <% if avg_other > 0 %>
113
- <div class="card">
114
- <div class="label">Avg Other</div>
115
- <div class="value"><%= format_duration(avg_other) %></div>
116
- <div class="detail">App code, middleware, etc.</div>
124
+ <% if has_errors %>
125
+ <div class="chart-card">
126
+ <div class="label">Error Volume</div>
127
+ <div class="value"><%= @chart_errors.sum %></div>
128
+ <div class="detail"><%= range_label(@range) %></div>
129
+ <div class="sparkline"><%= sparkline_svg(@chart_errors, width: 500, height: 64, color: "var(--red)", fill: true, time_labels: @chart_times) %></div>
130
+ </div>
131
+ <% end %>
132
+ <% if has_durations %>
133
+ <div class="chart-card">
134
+ <div class="label">Avg Duration</div>
135
+ <% nonzero = @chart_durations.select { |v| v > 0 } %>
136
+ <div class="value"><%= nonzero.any? ? format_duration(nonzero.sum / nonzero.size) : "—" %></div>
137
+ <div class="detail"><%= range_label(@range) %></div>
138
+ <div class="sparkline"><%= sparkline_svg(@chart_durations, width: 500, height: 64, color: "var(--accent)", fill: true, labels: @chart_durations.map { |d| format_duration(d) }, time_labels: @chart_times) %></div>
117
139
  </div>
118
140
  <% end %>
119
141
  </div>
@@ -50,16 +50,21 @@
50
50
 
51
51
  <%# ─── Error Frequency Chart ─── %>
52
52
  <div class="time-range">
53
- <% Catpm::ApplicationHelper::RANGE_KEYS.each do |r| %>
54
- <a href="<%= catpm.error_path(@error, range: r) %>" class="<%= 'active' if @range == r %>"><%= r %></a>
53
+ <% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
54
+ <a href="<%= catpm.error_path(@error, range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
55
55
  <% end %>
56
56
  </div>
57
57
 
58
- <h2>Error Frequency</h2>
59
- <%= section_description("Occurrences per time slot over the selected range.") %>
60
- <div style="border:1px solid var(--border); border-radius:var(--radius); padding:16px; margin-bottom:24px; position:relative">
61
- <%= bar_chart_svg(@chart_data, width: 600, height: 180, color: "var(--red, #e5534b)", time_labels: @chart_times) %>
62
- </div>
58
+ <% if @chart_data&.any? { |v| v > 0 } %>
59
+ <div class="charts-row">
60
+ <div class="chart-card">
61
+ <div class="label">Error Frequency</div>
62
+ <div class="value"><%= @chart_data.sum %></div>
63
+ <div class="detail">occurrences &middot; <%= range_label(@range) %></div>
64
+ <div class="sparkline"><%= sparkline_svg(@chart_data, width: 500, height: 64, color: "var(--red)", fill: true, time_labels: @chart_times) %></div>
65
+ </div>
66
+ </div>
67
+ <% end %>
63
68
 
64
69
  <%# ─── Backtrace ─── %>
65
70
  <% first_bt = @contexts.first && (@contexts.first["backtrace"] || @contexts.first[:backtrace] || []) %>
@@ -5,8 +5,8 @@
5
5
 
6
6
  <%# ─── Time Range ─── %>
7
7
  <div class="time-range">
8
- <% Catpm::ApplicationHelper::RANGE_KEYS.each do |r| %>
9
- <a href="<%= catpm.events_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r %></a>
8
+ <% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
9
+ <a href="<%= catpm.events_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
10
10
  <% end %>
11
11
  <span style="margin-left:auto; font-size:12px; color:var(--text-2)">
12
12
  Updated <%= Time.current.strftime("%H:%M:%S") %>
@@ -11,8 +11,8 @@
11
11
 
12
12
  <%# ─── Time Range ─── %>
13
13
  <div class="time-range">
14
- <% Catpm::ApplicationHelper::RANGE_KEYS.each do |r| %>
15
- <a href="<%= catpm.event_path(name: @name, range: r) %>" class="<%= 'active' if @range == r %>"><%= r %></a>
14
+ <% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
15
+ <a href="<%= catpm.event_path(name: @name, range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
16
16
  <% end %>
17
17
  </div>
18
18
 
@@ -34,12 +34,16 @@
34
34
  </div>
35
35
  </div>
36
36
 
37
- <%# ─── Bar Chart ─── %>
38
- <h2>Event Volume</h2>
39
- <%= section_description("Events per time slot over the selected range.") %>
40
- <div style="border:1px solid var(--border); border-radius:var(--radius); padding:16px; margin-bottom:24px; position:relative">
41
- <%= bar_chart_svg(@chart_data, width: 600, height: 180, color: "var(--accent)", time_labels: @chart_times) %>
42
- </div>
37
+ <% if @chart_data&.any? { |v| v > 0 } %>
38
+ <div class="charts-row">
39
+ <div class="chart-card">
40
+ <div class="label">Event Volume</div>
41
+ <div class="value"><%= @chart_data.sum %></div>
42
+ <div class="detail"><%= range_label(@range) %></div>
43
+ <div class="sparkline"><%= sparkline_svg(@chart_data, width: 500, height: 64, color: "var(--accent)", fill: true, time_labels: @chart_times) %></div>
44
+ </div>
45
+ </div>
46
+ <% end %>
43
47
 
44
48
  <%# ─── Recent Samples ─── %>
45
49
  <h2>Recent Samples</h2>
@@ -34,48 +34,34 @@
34
34
  <% end %>
35
35
  </div>
36
36
 
37
- <%# ─── Request Context + Full JSON side by side ─── %>
37
+ <%# ─── Request Context ─── %>
38
38
  <%
39
- ctx_display = @context.except("segments", :segments, "segment_summary", :segment_summary, "segments_capped", :segments_capped, "backtrace", :backtrace)
39
+ ctx_display = @context.except("segments", :segments, "segment_summary", :segment_summary, "segments_capped", :segments_capped, "backtrace", :backtrace, "method", :method, "path", :path, "status", :status)
40
40
  ctx_flat = ctx_display.select { |_, v| !v.is_a?(Hash) && !v.is_a?(Array) }
41
41
  ctx_nested = ctx_display.select { |_, v| v.is_a?(Hash) || v.is_a?(Array) }
42
42
  %>
43
43
 
44
- <div class="sample-layout">
45
- <% if ctx_display.any? %>
46
- <div class="sample-sidebar">
47
- <h2 style="margin-top:0">Request Context</h2>
48
- <% if ctx_flat.any? %>
49
- <div class="context-grid" style="margin-bottom:12px">
50
- <% ctx_flat.each do |k, v| %>
51
- <div class="ctx-key"><%= k %></div>
52
- <div class="ctx-val"><%= v.to_s.truncate(200) %></div>
53
- <% end %>
54
- </div>
55
- <% end %>
56
- <% if ctx_nested.any? %>
57
- <% ctx_nested.each do |k, v| %>
58
- <details class="collapsible" open>
59
- <summary><%= k %></summary>
60
- <div class="details-body">
61
- <pre class="mono" style="color:var(--text-1); white-space:pre-wrap; font-size:12px"><%= JSON.pretty_generate(v) rescue v.inspect %></pre>
62
- </div>
63
- </details>
64
- <% end %>
44
+ <% if ctx_display.any? %>
45
+ <h2>Request Context</h2>
46
+ <% if ctx_flat.any? %>
47
+ <div class="context-grid" style="margin-bottom:12px">
48
+ <% ctx_flat.each do |k, v| %>
49
+ <div class="ctx-key"><%= k %></div>
50
+ <div class="ctx-val"><%= v.to_s.truncate(200) %></div>
65
51
  <% end %>
66
52
  </div>
67
53
  <% end %>
68
-
69
- <div class="sample-main">
70
- <%# ─── Full JSON ─── %>
71
- <details class="collapsible" open>
72
- <summary>Full JSON</summary>
73
- <div class="details-body">
74
- <pre class="mono" style="color:var(--text-1); white-space:pre-wrap; font-size:12px; word-break:break-all"><%= JSON.pretty_generate(@context.except("segments", :segments)) rescue @context.inspect %></pre>
75
- </div>
76
- </details>
77
- </div>
78
- </div>
54
+ <% if ctx_nested.any? %>
55
+ <% ctx_nested.each do |k, v| %>
56
+ <details class="collapsible" open>
57
+ <summary><%= k %></summary>
58
+ <div class="details-body">
59
+ <pre class="mono" style="color:var(--text-1); white-space:pre-wrap; font-size:12px"><%= JSON.pretty_generate(v) rescue v.inspect %></pre>
60
+ </div>
61
+ </details>
62
+ <% end %>
63
+ <% end %>
64
+ <% end %>
79
65
 
80
66
  <%# ─── Time Breakdown (full width, above waterfall) ─── %>
81
67
  <% if @summary.any? %>
@@ -1,6 +1,8 @@
1
1
  <div class="page-nav">
2
2
  <a href="<%= catpm.status_index_path %>"<%= ' class="active"'.html_safe if active == "performance" %>>Performance</a>
3
- <a href="<%= catpm.events_path %>"<%= ' class="active"'.html_safe if active == "events" %>>Events</a>
3
+ <% if Catpm.config.events_enabled || Catpm::EventBucket.exists? %>
4
+ <a href="<%= catpm.events_path %>"<%= ' class="active"'.html_safe if active == "events" %>>Events</a>
5
+ <% end %>
4
6
  <a href="<%= catpm.errors_path %>"<%= ' class="active"'.html_safe if active == "errors" %>>Errors<% if @active_error_count.to_i > 0 %><span class="nav-count alert"><%= @active_error_count %></span><% end %></a>
5
7
  <a href="<%= catpm.system_index_path %>"<%= ' class="active"'.html_safe if active == "system" %>>System</a>
6
8
  </div>
@@ -5,8 +5,8 @@
5
5
 
6
6
  <%# ─── Time Range ─── %>
7
7
  <div class="time-range">
8
- <% Catpm::ApplicationHelper::RANGE_KEYS.each do |r| %>
9
- <a href="<%= catpm.status_index_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r %></a>
8
+ <% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
9
+ <a href="<%= catpm.status_index_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
10
10
  <% end %>
11
11
  <span style="margin-left:auto; font-size:12px; color:var(--text-2)">
12
12
  Updated <%= Time.current.strftime("%H:%M:%S") %> ·
@@ -38,8 +38,8 @@
38
38
  <div class="pipeline-node">
39
39
  <div class="node-icon"><svg width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="var(--text-2)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="14" cy="8" rx="8" ry="4"/><path d="M6 8v12c0 2.2 3.58 4 8 4s8-1.8 8-4V8"/><path d="M6 14c0 2.2 3.58 4 8 4s8-1.8 8-4"/></svg></div>
40
40
  <div class="node-label">Database</div>
41
- <div class="node-value"><%= @bucket_count %> <span style="font-size:12px;font-weight:400;color:var(--text-2)">buckets</span></div>
42
- <div class="node-detail">Aggregated stats are stored as time buckets, plus <%= @sample_count %> detailed samples and <%= @error_count %> error fingerprints.<br><%= @oldest_bucket ? "Data since #{@oldest_bucket.strftime('%b %-d')}, retained #{@config.retention_period ? "#{(@config.retention_period / 1.day).to_i} days" : "forever"}." : "No data yet." %></div>
41
+ <div class="node-value" style="font-size:14px">Storage</div>
42
+ <div class="node-detail">Aggregated stats are stored as time buckets, with detailed samples and error fingerprints.<br><%= @oldest_bucket ? "Data since #{@oldest_bucket.strftime('%b %-d')}, retained #{@config.retention_period ? "#{(@config.retention_period / 1.day).to_i} days" : "forever"}." : "No data yet." %></div>
43
43
  </div>
44
44
  </div>
45
45