catpm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +222 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/catpm/application.css +15 -0
  6. data/app/controllers/catpm/application_controller.rb +6 -0
  7. data/app/controllers/catpm/endpoints_controller.rb +63 -0
  8. data/app/controllers/catpm/errors_controller.rb +63 -0
  9. data/app/controllers/catpm/events_controller.rb +89 -0
  10. data/app/controllers/catpm/samples_controller.rb +13 -0
  11. data/app/controllers/catpm/status_controller.rb +79 -0
  12. data/app/controllers/catpm/system_controller.rb +17 -0
  13. data/app/helpers/catpm/application_helper.rb +264 -0
  14. data/app/jobs/catpm/application_job.rb +6 -0
  15. data/app/mailers/catpm/application_mailer.rb +8 -0
  16. data/app/models/catpm/application_record.rb +7 -0
  17. data/app/models/catpm/bucket.rb +45 -0
  18. data/app/models/catpm/error_record.rb +37 -0
  19. data/app/models/catpm/event_bucket.rb +12 -0
  20. data/app/models/catpm/event_sample.rb +22 -0
  21. data/app/models/catpm/sample.rb +26 -0
  22. data/app/views/catpm/endpoints/_sample_table.html.erb +36 -0
  23. data/app/views/catpm/endpoints/show.html.erb +124 -0
  24. data/app/views/catpm/errors/index.html.erb +66 -0
  25. data/app/views/catpm/errors/show.html.erb +107 -0
  26. data/app/views/catpm/events/index.html.erb +73 -0
  27. data/app/views/catpm/events/show.html.erb +86 -0
  28. data/app/views/catpm/samples/show.html.erb +113 -0
  29. data/app/views/catpm/shared/_page_nav.html.erb +6 -0
  30. data/app/views/catpm/shared/_segments_waterfall.html.erb +147 -0
  31. data/app/views/catpm/status/index.html.erb +124 -0
  32. data/app/views/catpm/system/index.html.erb +454 -0
  33. data/app/views/layouts/catpm/application.html.erb +381 -0
  34. data/config/routes.rb +19 -0
  35. data/db/migrate/20250601000001_create_catpm_tables.rb +104 -0
  36. data/lib/catpm/adapter/base.rb +85 -0
  37. data/lib/catpm/adapter/postgresql.rb +186 -0
  38. data/lib/catpm/adapter/sqlite.rb +159 -0
  39. data/lib/catpm/adapter.rb +28 -0
  40. data/lib/catpm/auto_instrument.rb +145 -0
  41. data/lib/catpm/buffer.rb +59 -0
  42. data/lib/catpm/circuit_breaker.rb +60 -0
  43. data/lib/catpm/collector.rb +320 -0
  44. data/lib/catpm/configuration.rb +103 -0
  45. data/lib/catpm/custom_event.rb +37 -0
  46. data/lib/catpm/engine.rb +39 -0
  47. data/lib/catpm/errors.rb +6 -0
  48. data/lib/catpm/event.rb +75 -0
  49. data/lib/catpm/fingerprint.rb +52 -0
  50. data/lib/catpm/flusher.rb +462 -0
  51. data/lib/catpm/lifecycle.rb +76 -0
  52. data/lib/catpm/middleware.rb +75 -0
  53. data/lib/catpm/middleware_probe.rb +28 -0
  54. data/lib/catpm/patches/httpclient.rb +44 -0
  55. data/lib/catpm/patches/net_http.rb +39 -0
  56. data/lib/catpm/request_segments.rb +101 -0
  57. data/lib/catpm/segment_subscribers.rb +242 -0
  58. data/lib/catpm/span_helpers.rb +51 -0
  59. data/lib/catpm/stack_sampler.rb +226 -0
  60. data/lib/catpm/subscribers.rb +47 -0
  61. data/lib/catpm/tdigest.rb +174 -0
  62. data/lib/catpm/trace.rb +165 -0
  63. data/lib/catpm/version.rb +5 -0
  64. data/lib/catpm.rb +66 -0
  65. data/lib/generators/catpm/install_generator.rb +36 -0
  66. data/lib/generators/catpm/templates/initializer.rb.tt +77 -0
  67. data/lib/tasks/catpm_seed.rake +79 -0
  68. data/lib/tasks/catpm_tasks.rake +6 -0
  69. metadata +123 -0
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ module ApplicationHelper
5
+ # Soft pastel palette — bars, breakdown, waterfall
6
+ SEGMENT_COLORS = {
7
+ 'sql' => '#b8e4c6', 'view' => '#e4d4f4', 'cache' => '#fdd8b5',
8
+ 'http' => '#f9c4c0', 'mailer' => '#e4d4f4', 'storage' => '#fdd8b5',
9
+ 'custom' => '#dde2e8', 'code' => '#c8daf0', 'gem' => '#f0e0f0', 'other' => '#e8e8e8', 'controller' => '#b6d9f7',
10
+ 'middleware' => '#f0dfa0', 'request' => '#b6d9f7'
11
+ }.freeze
12
+
13
+ SEGMENT_TEXT_COLORS = {
14
+ 'sql' => '#1a7f37', 'view' => '#6639a6', 'cache' => '#953800',
15
+ 'http' => '#a1110a', 'mailer' => '#6639a6', 'storage' => '#953800',
16
+ 'custom' => '#4b5563', 'code' => '#3b5998', 'gem' => '#7b3f9e', 'other' => '#9ca3af', 'controller' => '#0550ae',
17
+ 'middleware' => '#7c5c00', 'request' => '#0550ae'
18
+ }.freeze
19
+
20
+ BADGE_CLASSES = {
21
+ 'http' => 'badge-http', 'job' => 'badge-job', 'custom' => 'badge-custom',
22
+ 'sql' => 'badge-sql', 'view' => 'badge-view', 'cache' => 'badge-cache',
23
+ 'mailer' => 'badge-mailer', 'storage' => 'badge-storage',
24
+ 'error' => 'badge-error', 'slow' => 'badge-slow', 'random' => 'badge-random'
25
+ }.freeze
26
+
27
+ SAMPLE_TYPE_LABELS = {
28
+ 'random' => 'sample', 'slow' => 'slow', 'error' => 'error'
29
+ }.freeze
30
+
31
+ SEGMENT_LABELS = {
32
+ 'sql' => 'SQL Queries', 'view' => 'View Renders', 'cache' => 'Cache Ops',
33
+ 'http' => 'HTTP Calls', 'mailer' => 'Mailer', 'storage' => 'Storage',
34
+ 'custom' => 'Custom', 'code' => 'App Code', 'gem' => 'Gems', 'other' => 'Untracked',
35
+ 'controller' => 'Controller', 'middleware' => 'Middleware', 'request' => 'Request'
36
+ }.freeze
37
+
38
+ RANGES = {
39
+ '1h' => [1.hour, 60],
40
+ '6h' => [6.hours, 360],
41
+ '24h' => [24.hours, 1440],
42
+ '1w' => [1.week, (1.week / 60).to_i],
43
+ '2w' => [2.weeks, (2.weeks / 60).to_i],
44
+ '1m' => [30.days, (30.days / 60).to_i],
45
+ '1y' => [365.days, (365.days / 60).to_i]
46
+ }.freeze
47
+
48
+ RANGE_KEYS = RANGES.keys.freeze
49
+
50
+ def segment_colors
51
+ SEGMENT_COLORS
52
+ end
53
+
54
+ def segment_labels
55
+ SEGMENT_LABELS
56
+ end
57
+
58
+ def segment_text_colors
59
+ SEGMENT_TEXT_COLORS
60
+ end
61
+
62
+ def type_badge(type)
63
+ type_str = type.to_s
64
+ display = SAMPLE_TYPE_LABELS[type_str] || type_str
65
+ css = BADGE_CLASSES[type_str] || ''
66
+ %(<span class="badge #{css}">#{ERB::Util.html_escape(display)}</span>).html_safe
67
+ end
68
+
69
+ def format_duration(ms)
70
+ ms = ms.to_f
71
+ if ms >= 1000
72
+ '%.2fs' % (ms / 1000.0)
73
+ else
74
+ '%.1fms' % ms
75
+ end
76
+ end
77
+
78
+ def status_badge(status_val)
79
+ return '' unless status_val
80
+ s = status_val.to_i
81
+ css = s >= 500 ? 'badge-err' : s >= 400 ? 'badge-warn' : 'badge-ok'
82
+ %(<span class="badge #{css}">#{status_val}</span>).html_safe
83
+ end
84
+
85
+ def segment_count_summary(summary)
86
+ return '' if summary.blank?
87
+ parts = SEGMENT_COLORS.keys.filter_map do |type|
88
+ count = (summary["#{type}_count"] || summary[:"#{type}_count"] || 0).to_i
89
+ next if count == 0
90
+ "#{count} #{type}"
91
+ end
92
+ parts.join(', ')
93
+ end
94
+
95
+ def sparkline_svg(data_points, width: 120, height: 48, color: '#539bf5', fill: false, labels: nil, time_labels: nil)
96
+ return '' if data_points.blank?
97
+ points = data_points.map(&:to_f)
98
+ max_val = points.max
99
+ max_val = 1.0 if max_val == 0
100
+ step = width.to_f / [points.size - 1, 1].max
101
+
102
+ parsed = points.each_with_index.map do |val, i|
103
+ x = (i * step).round(1)
104
+ y = (height - (val / max_val * (height - 4)) - 2).round(1)
105
+ [x, y, val]
106
+ end
107
+
108
+ coords_str = parsed.map { |x, y, _| "#{x},#{y}" }
109
+
110
+ fill_el = ''
111
+ if fill
112
+ fill_coords = coords_str + ["#{width},#{height}", "0,#{height}"]
113
+ fill_el = %(<polygon points="#{fill_coords.join(" ")}" fill="#{color}" opacity="0.08"/>)
114
+ end
115
+
116
+ circles = parsed.map.with_index do |(x, y, val), i|
117
+ label = labels ? labels[i] : val.is_a?(Float) ? ('%.1f' % val) : val
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"/>)
120
+ end.join
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
123
+ end
124
+
125
+ def bar_chart_svg(data_points, width: 600, height: 200, color: 'var(--accent)', time_labels: nil)
126
+ return '' if data_points.blank?
127
+ points = data_points.map(&:to_i)
128
+ max_val = points.max
129
+ max_val = 1 if max_val == 0
130
+ bar_count = points.size
131
+ gap = 2
132
+ bar_width = ((width.to_f - (bar_count - 1) * gap) / bar_count).round(2)
133
+ bar_width = [bar_width, 1].max
134
+
135
+ bars = points.each_with_index.map do |val, i|
136
+ x = (i * (bar_width + gap)).round(2)
137
+ bar_h = (val.to_f / max_val * (height - 20)).round(2)
138
+ y = height - bar_h
139
+ time_attr = time_labels ? %( data-time="#{time_labels[i]}") : ''
140
+ rx = [bar_width / 4, 2].min.round(1)
141
+ # Visible bar
142
+ bar = %(<rect x="#{x}" y="#{y}" width="#{bar_width}" height="#{bar_h}" fill="#{color}" rx="#{rx}" ry="#{rx}" opacity="0.85"/>)
143
+ # Invisible hover target (full height)
144
+ hover = %(<rect x="#{x}" y="0" width="#{bar_width}" height="#{height}" fill="transparent" data-value="#{val}"#{time_attr} class="sparkline-dot"/>)
145
+ bar + hover
146
+ end.join
147
+
148
+ # Gridlines
149
+ gridlines = [0.25, 0.5, 0.75].map do |pct|
150
+ gy = (height - pct * (height - 20)).round(1)
151
+ %(<line x1="0" y1="#{gy}" x2="#{width}" y2="#{gy}" stroke="var(--border)" stroke-width="0.5" stroke-dasharray="4,3"/>)
152
+ end.join
153
+
154
+ svg = %(<svg width="100%" height="#{height}" viewBox="0 0 #{width} #{height}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" style="display:block">#{gridlines}#{bars}</svg>)
155
+ max_label = %(<span class="bar-chart-max">#{max_val}</span>)
156
+
157
+ %(<div class="bar-chart-wrap">#{svg}#{max_label}</div>).html_safe
158
+ end
159
+
160
+ def relative_time(time)
161
+ return '—' unless time
162
+ time = Time.parse(time) if time.is_a?(String)
163
+ diff = (Time.current - time).to_i
164
+ if diff < 60
165
+ 'just now'
166
+ elsif diff < 3600
167
+ "#{diff / 60}m ago"
168
+ elsif diff < 86_400
169
+ "#{diff / 3600}h ago"
170
+ elsif diff < 172_800
171
+ 'yesterday'
172
+ elsif diff < 604_800
173
+ "#{diff / 86_400}d ago"
174
+ else
175
+ time.strftime('%b %-d')
176
+ end
177
+ rescue ArgumentError, TypeError
178
+ '—'
179
+ end
180
+
181
+ def time_with_tooltip(time)
182
+ return '—' unless time
183
+ time = Time.parse(time) if time.is_a?(String)
184
+ full = time.strftime('%Y-%m-%d %H:%M:%S')
185
+ rel = relative_time(time)
186
+ %(<span title="#{full}" class="time-rel">#{rel}</span>).html_safe
187
+ rescue ArgumentError, TypeError
188
+ '—'
189
+ end
190
+
191
+ def sort_header(label, column, current_sort, current_dir, extra_params: {})
192
+ active = current_sort == column.to_s
193
+ new_dir = active && current_dir == 'asc' ? 'desc' : 'asc'
194
+ arrow = active ? (current_dir == 'asc' ? ' &#9650;' : ' &#9660;') : ''
195
+ params_hash = extra_params.merge(sort: column, dir: new_dir)
196
+ url = '?' + params_hash.map { |k, v| "#{k}=#{v}" }.join('&')
197
+ %(<a href="#{url}" class="sort-link#{active ? ' active' : ''}">#{label}#{arrow}</a>).html_safe
198
+ end
199
+
200
+ def section_description(text)
201
+ %(<p class="section-desc">#{text}</p>).html_safe
202
+ end
203
+
204
+ def status_dot(resolved)
205
+ color = resolved ? 'var(--green)' : 'var(--red)'
206
+ label = resolved ? 'Resolved' : 'Active'
207
+ %(<span class="status-dot"><span class="dot" style="background:#{color}"></span> #{label}</span>).html_safe
208
+ end
209
+
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
+ period, bucket_seconds = RANGES[key]
215
+ [key, period, bucket_seconds]
216
+ end
217
+
218
+ def range_label(range)
219
+ case range
220
+ when '6h' then 'Last 6 hours'
221
+ when '24h' then 'Last 24 hours'
222
+ when '1w' then 'Last week'
223
+ when '2w' then 'Last 2 weeks'
224
+ when '1m' then 'Last month'
225
+ when '1y' then 'Last year'
226
+ else 'Last hour'
227
+ end
228
+ end
229
+
230
+ def pagination_nav(current_page, total_count, per_page, extra_params: {})
231
+ total_pages = (total_count.to_f / per_page).ceil
232
+ return '' if total_pages <= 1
233
+
234
+ prev_params = extra_params.merge(page: current_page - 1)
235
+ next_params = extra_params.merge(page: current_page + 1)
236
+ prev_url = '?' + prev_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
237
+ next_url = '?' + next_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
238
+
239
+ html = '<div class="pagination">'
240
+ if current_page > 1
241
+ html << %(<a href="#{prev_url}" class="btn">← Previous</a>)
242
+ else
243
+ html << '<span class="btn" style="opacity:0.3;cursor:default">← Previous</span>'
244
+ end
245
+ html << %(<span class="pagination-info">Page #{current_page} of #{total_pages}</span>)
246
+ if current_page < total_pages
247
+ html << %(<a href="#{next_url}" class="btn">Next →</a>)
248
+ else
249
+ html << '<span class="btn" style="opacity:0.3;cursor:default">Next →</span>'
250
+ end
251
+ html << '</div>'
252
+ html.html_safe
253
+ end
254
+
255
+ def trend_indicator(error)
256
+ return '' unless error.last_occurred_at
257
+ if error.last_occurred_at > 1.hour.ago
258
+ %(<span title="Active in the last hour" style="color:var(--red)">↑</span>).html_safe
259
+ else
260
+ %(<span title="Quiet" style="color:var(--text-2)">—</span>).html_safe
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class ApplicationMailer < ActionMailer::Base
5
+ default from: 'from@example.com'
6
+ layout 'mailer'
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class Bucket < ApplicationRecord
5
+ self.table_name = 'catpm_buckets'
6
+
7
+ has_many :samples, class_name: 'Catpm::Sample', foreign_key: :bucket_id, dependent: :delete_all
8
+
9
+ validates :kind, :target, :bucket_start, presence: true
10
+
11
+ scope :by_kind, ->(kind) { where(kind: kind) }
12
+ scope :recent, ->(period = 1.hour) { where(bucket_start: period.ago..) }
13
+
14
+ def average_duration
15
+ return 0.0 if count.zero?
16
+ duration_sum / count
17
+ end
18
+
19
+ def failure_rate
20
+ return 0.0 if count.zero?
21
+ failure_count.to_f / count
22
+ end
23
+
24
+ def percentile(p)
25
+ digest = tdigest
26
+ return nil if digest.nil? || digest.empty?
27
+ digest.percentile(p)
28
+ end
29
+
30
+ def tdigest
31
+ return nil if p95_digest.blank?
32
+ TDigest.deserialize(p95_digest)
33
+ end
34
+
35
+ def parsed_metadata_sum
36
+ case metadata_sum
37
+ when Hash then metadata_sum
38
+ when String then JSON.parse(metadata_sum)
39
+ else {}
40
+ end
41
+ rescue JSON::ParserError
42
+ {}
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class ErrorRecord < ApplicationRecord
5
+ self.table_name = 'catpm_errors'
6
+
7
+ validates :fingerprint, :kind, :error_class, :first_occurred_at, :last_occurred_at, presence: true
8
+ validates :fingerprint, uniqueness: true
9
+
10
+ scope :by_kind, ->(kind) { where(kind: kind) }
11
+ scope :unresolved, -> { where(resolved_at: nil) }
12
+ scope :resolved, -> { where.not(resolved_at: nil) }
13
+ scope :recent, ->(period = 24.hours) { where(last_occurred_at: period.ago..) }
14
+
15
+ def resolved?
16
+ resolved_at.present?
17
+ end
18
+
19
+ def resolve!
20
+ update!(resolved_at: Time.current)
21
+ end
22
+
23
+ def unresolve!
24
+ update!(resolved_at: nil)
25
+ end
26
+
27
+ def parsed_contexts
28
+ case contexts
29
+ when Array then contexts
30
+ when String then JSON.parse(contexts)
31
+ else []
32
+ end
33
+ rescue JSON::ParserError
34
+ []
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class EventBucket < ApplicationRecord
5
+ self.table_name = 'catpm_event_buckets'
6
+
7
+ validates :name, :bucket_start, presence: true
8
+
9
+ scope :by_name, ->(name) { where(name: name) }
10
+ scope :recent, ->(period = 1.hour) { where(bucket_start: period.ago..) }
11
+ end
12
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class EventSample < ApplicationRecord
5
+ self.table_name = 'catpm_event_samples'
6
+
7
+ validates :name, :recorded_at, presence: true
8
+
9
+ scope :by_name, ->(name) { where(name: name) }
10
+ scope :recent, ->(period = 1.hour) { where(recorded_at: period.ago..) }
11
+
12
+ def parsed_payload
13
+ case payload
14
+ when Hash then payload
15
+ when String then JSON.parse(payload)
16
+ else {}
17
+ end
18
+ rescue JSON::ParserError
19
+ {}
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class Sample < ApplicationRecord
5
+ self.table_name = 'catpm_samples'
6
+
7
+ belongs_to :bucket, class_name: 'Catpm::Bucket'
8
+
9
+ validates :kind, :sample_type, :recorded_at, :duration, presence: true
10
+
11
+ scope :by_kind, ->(kind) { where(kind: kind) }
12
+ scope :slow, -> { where(sample_type: 'slow') }
13
+ scope :errors, -> { where(sample_type: 'error') }
14
+ scope :recent, ->(period = 1.hour) { where(recorded_at: period.ago..) }
15
+
16
+ def parsed_context
17
+ case context
18
+ when Hash then context
19
+ when String then JSON.parse(context)
20
+ else {}
21
+ end
22
+ rescue JSON::ParserError
23
+ {}
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,36 @@
1
+ <h2><%= title %> (<%= samples.size %>)</h2>
2
+ <% if samples.any? %>
3
+ <div class="table-scroll">
4
+ <table>
5
+ <thead>
6
+ <tr>
7
+ <th>Duration</th>
8
+ <th>Segments</th>
9
+ <th>Time</th>
10
+ </tr>
11
+ </thead>
12
+ <tbody>
13
+ <% samples.each do |s| %>
14
+ <% ctx = s.parsed_context; summary = ctx["segment_summary"] || ctx[:segment_summary] || {} %>
15
+ <tr class="linked">
16
+ <td><a href="<%= catpm.sample_path(s) %>" class="row-link mono"><%= format_duration(s.duration) %></a></td>
17
+ <td>
18
+ <% sc = (summary["sql_count"] || summary[:sql_count] || 0).to_i %>
19
+ <% vc = (summary["view_count"] || summary[:view_count] || 0).to_i %>
20
+ <% if sc > 0 || vc > 0 %>
21
+ <span class="mono text-muted"><%= sc %> sql, <%= vc %> views</span>
22
+ <% else %>
23
+ <span class="text-muted">—</span>
24
+ <% end %>
25
+ </td>
26
+ <td><%= time_with_tooltip(s.recorded_at) %></td>
27
+ </tr>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+ </div>
32
+ <% else %>
33
+ <div class="empty-state">
34
+ <div class="empty-hint"><%= empty_msg %></div>
35
+ </div>
36
+ <% end %>
@@ -0,0 +1,124 @@
1
+ <% content_for :title, @target %>
2
+ <% content_for :subtitle do %>
3
+ <%= type_badge(@kind) %>
4
+ <%= @target %> <%= @operation.presence %>
5
+ &middot; <%= @count %> requests
6
+ &middot; <%= @range == "all" ? "All time" : range_label(@range) %>
7
+ <% end %>
8
+
9
+ <%= render "catpm/shared/page_nav", active: "performance" %>
10
+
11
+ <div class="breadcrumbs">
12
+ <a href="<%= catpm.status_index_path %>">Performance</a>
13
+ <span class="sep">/</span>
14
+ <span><%= @target %></span>
15
+ </div>
16
+
17
+ <% ep_params = { kind: @kind, target: @target, operation: @operation } %>
18
+ <div class="time-range">
19
+ <% (Catpm::ApplicationHelper::RANGE_KEYS + ["all"]).each do |r| %>
20
+ <a href="<%= catpm.endpoint_path(ep_params.merge(range: r)) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
21
+ <% end %>
22
+ </div>
23
+
24
+ <div class="grid">
25
+ <div class="card">
26
+ <div class="label">Requests</div>
27
+ <div class="value"><%= @count %></div>
28
+ </div>
29
+ <div class="card">
30
+ <div class="label">Avg Duration</div>
31
+ <div class="value"><%= format_duration(@avg_duration) %></div>
32
+ </div>
33
+ <div class="card">
34
+ <div class="label">p95</div>
35
+ <div class="value"><%= @tdigest ? format_duration(@tdigest.percentile(0.95)) : "—" %></div>
36
+ </div>
37
+ <div class="card">
38
+ <div class="label">Max</div>
39
+ <div class="value"><%= format_duration(@duration_max) %></div>
40
+ </div>
41
+ <div class="card">
42
+ <div class="label">Min</div>
43
+ <div class="value"><%= format_duration(@duration_min) %></div>
44
+ </div>
45
+ <div class="card">
46
+ <div class="label">Failures</div>
47
+ <div class="value"><%= @failure_count %></div>
48
+ <div class="detail"><%= "%.1f" % (@failure_rate * 100) %>%</div>
49
+ </div>
50
+ </div>
51
+
52
+ <% if @tdigest && @tdigest.count > 0 %>
53
+ <h2>Percentile Distribution</h2>
54
+ <%= section_description("p95 means 95% of requests completed faster than this value. Higher percentiles reveal tail latency.") %>
55
+ <div class="grid">
56
+ <% [0.5, 0.75, 0.9, 0.95, 0.99].each do |p| %>
57
+ <div class="card">
58
+ <div class="label">p<%= (p * 100).to_i %></div>
59
+ <div class="value"><%= format_duration(@tdigest.percentile(p)) %></div>
60
+ </div>
61
+ <% end %>
62
+ </div>
63
+ <% end %>
64
+
65
+ <%
66
+ type_data = segment_colors.map { |type, color|
67
+ count = (@metadata["#{type}_count"] || @metadata[:"#{type}_count"] || 0).to_f
68
+ dur = (@metadata["#{type}_duration"] || @metadata[:"#{type}_duration"] || 0).to_f
69
+ avg_dur = @count > 0 ? dur / @count : 0
70
+ avg_count = @count > 0 ? count / @count : 0
71
+ text_color = segment_text_colors[type] || "#4b5563"
72
+ { type: type, label: segment_labels[type] || type.capitalize, bg: color, text: text_color, count: count, dur: dur, avg_dur: avg_dur, avg_count: avg_count }
73
+ }.select { |d| d[:count] > 0 }
74
+ %>
75
+
76
+ <% if type_data.any? %>
77
+ <h2>Average Time Breakdown</h2>
78
+
79
+ <%
80
+ avg_total = @avg_duration
81
+ typed_avg = type_data.sum { |d| d[:avg_dur] }
82
+ avg_other = [avg_total - typed_avg, 0].max
83
+ bar_data = type_data.map { |d| [d[:label], d[:avg_dur], d[:bg], d[:text]] }
84
+ bar_data << ["Other", avg_other, "#dde2e8", "#4b5563"] if avg_other > 0
85
+ %>
86
+
87
+ <div style="margin-bottom:16px">
88
+ <div style="display:flex; height:32px; border-radius:4px; overflow:hidden; border:1px solid var(--border)">
89
+ <% bar_data.each do |label, dur, bg, text| %>
90
+ <% if dur > 0 && avg_total > 0 %>
91
+ <div title="<%= label %> — <%= format_duration(dur) %> (<%= "%.0f" % (dur/avg_total*100) %>%)" style="flex:<%= dur %>; background:<%= bg %>; display:flex; align-items:center; justify-content:center; color:<%= text %>; font-size:11px; font-weight:500; overflow:hidden; white-space:nowrap; padding:0 4px;">
92
+ <% if dur / avg_total > 0.08 %><%= label %><% end %>
93
+ </div>
94
+ <% end %>
95
+ <% end %>
96
+ </div>
97
+ <div style="display:flex; gap:14px; flex-wrap:wrap; margin-top:6px; font-size:12px; color:var(--text-1)">
98
+ <% bar_data.each do |label, dur, bg, text| %>
99
+ <span><span style="display:inline-block;width:10px;height:10px;background:<%= bg %>;border-radius:2px;margin-right:4px;border:1px solid <%= text %>22"></span><%= label %> <%= format_duration(dur) %> (<%= "%.0f" % (avg_total > 0 ? dur/avg_total*100 : 0) %>%)</span>
100
+ <% end %>
101
+ </div>
102
+ </div>
103
+
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>
110
+ </div>
111
+ <% 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>
117
+ </div>
118
+ <% end %>
119
+ </div>
120
+ <% end %>
121
+
122
+ <%= render "catpm/endpoints/sample_table", title: "Error Requests", samples: @error_samples, empty_msg: "No errors for this endpoint." %>
123
+ <%= render "catpm/endpoints/sample_table", title: "Slowest Requests", samples: @slow_samples, empty_msg: "No slow requests captured yet." %>
124
+ <%= render "catpm/endpoints/sample_table", title: "Sample Requests", samples: @samples, empty_msg: "No samples captured yet." %>
@@ -0,0 +1,66 @@
1
+ <% content_for :title, "Errors" %>
2
+ <% content_for :subtitle, "#{@active_count} active, #{@resolved_count} resolved" %>
3
+
4
+ <%= render "catpm/shared/page_nav", active: "errors" %>
5
+
6
+ <div class="tabs">
7
+ <a href="<%= catpm.errors_path(tab: "active") %>" class="tab <%= 'active' if @tab == 'active' %>">Active (<%= @active_count %>)</a>
8
+ <a href="<%= catpm.errors_path(tab: "resolved") %>" class="tab <%= 'active' if @tab == 'resolved' %>">Resolved (<%= @resolved_count %>)</a>
9
+ </div>
10
+
11
+ <% if @errors.any? || @kind_filter.present? %>
12
+ <div class="filters">
13
+ <% @available_kinds.each do |k| %>
14
+ <a href="<%= catpm.errors_path(tab: @tab, kind: @kind_filter == k ? nil : k) %>" class="filter-pill<%= ' active' if @kind_filter == k %>"><%= k %></a>
15
+ <% end %>
16
+ <input type="text" class="search-input" id="error-search" placeholder="Search errors... (/)" oninput="filterByText('error-search','errors-table')">
17
+ <% if @tab == "active" && @active_count > 0 %>
18
+ <span style="display:inline; margin-left:auto"><%= button_to "Resolve all", catpm.resolve_all_errors_path, method: :post, class: "btn", data: { confirm: "Resolve all #{@active_count} active errors?" } %></span>
19
+ <% end %>
20
+ </div>
21
+ <% end %>
22
+
23
+ <% extra = { tab: @tab }; extra[:kind] = @kind_filter if @kind_filter %>
24
+
25
+ <% if @errors.any? %>
26
+ <div class="table-scroll">
27
+ <table id="errors-table">
28
+ <thead>
29
+ <tr>
30
+ <th>Kind</th>
31
+ <th><%= sort_header("Error Class", "error_class", @sort, @dir, extra_params: extra) %></th>
32
+ <th>Message</th>
33
+ <th><%= sort_header("Count", "occurrences_count", @sort, @dir, extra_params: extra) %></th>
34
+ <th>Trend</th>
35
+ <th><%= sort_header("Last Seen", "last_occurred_at", @sort, @dir, extra_params: extra) %></th>
36
+ <% if @tab == "resolved" %><th>Resolved</th><% end %>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ <% @errors.each do |e| %>
41
+ <tr class="linked">
42
+ <td><a href="<%= catpm.error_path(e) %>" class="row-link"><%= type_badge(e.kind) %></a></td>
43
+ <td class="mono"><%= e.error_class %></td>
44
+ <td style="color:var(--text-1)"><%= truncate(e.message, length: 60) %></td>
45
+ <td<%= " style=\"font-weight:600\"".html_safe if e.occurrences_count >= 10 %>><%= e.occurrences_count %></td>
46
+ <td><%= trend_indicator(e) %></td>
47
+ <td><%= time_with_tooltip(e.last_occurred_at) %></td>
48
+ <% if @tab == "resolved" %><td><%= time_with_tooltip(e.resolved_at) %></td><% end %>
49
+ </tr>
50
+ <% end %>
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+ <% page_extra = extra.merge(sort: @sort, dir: @dir) %>
55
+ <%= pagination_nav(@page, @total_count, Catpm::ErrorsController::PER_PAGE, extra_params: page_extra) %>
56
+ <% else %>
57
+ <div class="empty-state">
58
+ <% if @tab == "active" %>
59
+ <div class="empty-title">No active errors</div>
60
+ <div class="empty-hint">Your application is running smoothly. Errors will appear here when they occur.</div>
61
+ <% else %>
62
+ <div class="empty-title">No resolved errors</div>
63
+ <div class="empty-hint">When you resolve active errors, they'll move here for reference.</div>
64
+ <% end %>
65
+ </div>
66
+ <% end %>