catpm 0.5.0 → 0.6.1

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: 527b9950df4630b2c22f992c2e3e604eac9365c7faa0ea2a892cac5ed47d69af
4
- data.tar.gz: d39f675cb7bea8ab51762f78d2319d29e2f88769b37594a45bb0571cdcdd2d32
3
+ metadata.gz: 77d3e9a9e80a64c61ba1cbc9f400f9b53cb774c2eee0cf1fc807952bf1b81c39
4
+ data.tar.gz: 3364e29476e9a6f77494f86c0fb00932c70a84eb90067df86e8455a00608930b
5
5
  SHA512:
6
- metadata.gz: d47303a0a85e9f9773e37ef6ca2f0faf7627207ea1050ae456cf0bdff285d60f6ae043285216e5dbdcf61668ba6e4bffb91848bfd884f3e0c7548ea79a97ebf9
7
- data.tar.gz: d4b68684377d6f350645dbfff66cd7faac777884c5dc0aae3b7ef5c2c612576bfc27c27ee55c7c3f3136e1897b3b2d48bc76e49b8aae5f6f9b0eb8240db7c543
6
+ metadata.gz: 52c5cb2542feabdb2a493390e1fd0c42bef5e5ea8c8d89bb71115f774b790e88b8c8f253402e47835da8190a4113d3ed7302398f59a2ce2841df2576018a20cb
7
+ data.tar.gz: be688f7c4ce207e04d09fb1ac30b390bc7f2ea8b050120bc46929a952a632d17e63ffc110950907ee37e60aa62f8f7400a3d982acfc568dd2392d01fe9841c91
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  gem build catpm.gemspec
2
- gem push catpm-0.1.2.gem
2
+ gem push catpm-0.5.0.gem
3
3
 
4
4
  # Catpm
5
5
 
Binary file
@@ -85,6 +85,7 @@ module Catpm
85
85
  @samples = endpoint_samples.where(sample_type: 'random').order(recorded_at: :desc).limit(10)
86
86
  @error_samples = endpoint_samples.where(sample_type: 'error').order(recorded_at: :desc).limit(10)
87
87
 
88
+ @pref = Catpm::EndpointPref.find_by(kind: @kind, target: @target, operation: @operation)
88
89
  @active_error_count = Catpm::ErrorRecord.unresolved.count
89
90
  end
90
91
 
@@ -94,7 +95,61 @@ module Catpm
94
95
  operation = params[:operation].presence || ''
95
96
 
96
97
  Catpm::Bucket.where(kind: kind, target: target, operation: operation).destroy_all
97
- redirect_to catpm.status_index_path, notice: 'Endpoint deleted'
98
+ Catpm::EndpointPref.find_by(kind: kind, target: target, operation: operation)&.destroy
99
+ if request.xhr?
100
+ render json: { deleted: true }
101
+ else
102
+ redirect_to catpm.status_index_path, notice: 'Endpoint deleted'
103
+ end
104
+ end
105
+
106
+ def toggle_pin
107
+ pref = Catpm::EndpointPref.lookup(params[:kind], params[:target], params[:operation])
108
+ pref.pinned = !pref.pinned
109
+ pref.save!
110
+ if request.xhr?
111
+ render json: { pinned: pref.pinned }
112
+ else
113
+ redirect_back fallback_location: catpm.endpoint_path(kind: params[:kind], target: params[:target], operation: params[:operation])
114
+ end
115
+ end
116
+
117
+ def toggle_ignore
118
+ pref = Catpm::EndpointPref.lookup(params[:kind], params[:target], params[:operation])
119
+ pref.ignored = !pref.ignored
120
+ pref.save!
121
+ if request.xhr?
122
+ render json: { ignored: pref.ignored }
123
+ else
124
+ redirect_back fallback_location: catpm.status_index_path
125
+ end
126
+ end
127
+
128
+ def ignored
129
+ @range, period, _bucket_seconds = helpers.parse_range(remembered_range)
130
+ ignored_prefs = Catpm::EndpointPref.ignored
131
+
132
+ scope = @range == 'all' ? Catpm::Bucket.all : Catpm::Bucket.recent(period)
133
+ grouped = scope.group_by { |b| [b.kind, b.target, b.operation] }
134
+
135
+ ignored_keys = ignored_prefs.map { |p| [p.kind, p.target, p.operation] }.to_set
136
+
137
+ @ignored_endpoints = ignored_prefs.map do |pref|
138
+ key = [pref.kind, pref.target, pref.operation]
139
+ bs = grouped[key]
140
+ total_count = bs ? bs.sum(&:count) : 0
141
+ {
142
+ kind: pref.kind,
143
+ target: pref.target,
144
+ operation: pref.operation,
145
+ total_count: total_count,
146
+ avg_duration: total_count > 0 ? bs.sum(&:duration_sum) / total_count : 0.0,
147
+ last_seen: bs&.map(&:bucket_start)&.max
148
+ }
149
+ end
150
+
151
+ @active_endpoint_count = grouped.keys.count { |k| !ignored_keys.include?(k) }
152
+ @active_error_count = Catpm::ErrorRecord.unresolved.count
98
153
  end
99
154
  end
100
155
  end
@@ -28,7 +28,7 @@ module Catpm
28
28
 
29
29
  @total_count = scope.count
30
30
  @page = [params[:page].to_i, 1].max
31
- @errors = scope.order(@sort => @dir).offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
31
+ @errors = scope.order(pinned: :desc, @sort => @dir).offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
32
32
  end
33
33
 
34
34
  def show
@@ -106,19 +106,41 @@ module Catpm
106
106
  def resolve
107
107
  error = Catpm::ErrorRecord.find(params[:id])
108
108
  error.resolve!
109
- redirect_to catpm.error_path(error), notice: 'Marked as resolved'
109
+ if request.xhr?
110
+ render json: { resolved: true }
111
+ else
112
+ redirect_to catpm.error_path(error), notice: 'Marked as resolved'
113
+ end
110
114
  end
111
115
 
112
116
  def unresolve
113
117
  error = Catpm::ErrorRecord.find(params[:id])
114
118
  error.unresolve!
115
- redirect_to catpm.error_path(error), notice: 'Reopened'
119
+ if request.xhr?
120
+ render json: { resolved: false }
121
+ else
122
+ redirect_to catpm.error_path(error), notice: 'Reopened'
123
+ end
124
+ end
125
+
126
+ def toggle_pin
127
+ error = Catpm::ErrorRecord.find(params[:id])
128
+ error.update!(pinned: !error.pinned)
129
+ if request.xhr?
130
+ render json: { pinned: error.pinned }
131
+ else
132
+ redirect_back fallback_location: catpm.error_path(error)
133
+ end
116
134
  end
117
135
 
118
136
  def destroy
119
137
  error = Catpm::ErrorRecord.find(params[:id])
120
138
  error.destroy!
121
- redirect_to catpm.errors_path, notice: 'Error deleted'
139
+ if request.xhr?
140
+ render json: { deleted: true }
141
+ else
142
+ redirect_to catpm.errors_path, notice: 'Error deleted'
143
+ end
122
144
  end
123
145
 
124
146
  def resolve_all
@@ -30,40 +30,49 @@ module Catpm
30
30
  grouped = recent_buckets.group_by(&:name)
31
31
  @unique_names = grouped.keys.size
32
32
 
33
+ # Load event preferences
34
+ prefs = Catpm::EventPref.where('pinned = ? OR ignored = ?', true, true).index_by(&:name)
35
+
36
+ now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
37
+ @sparkline_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
38
+
33
39
  events_list = grouped.map do |name, bs|
34
40
  total_count = bs.sum(&:count)
35
41
 
36
- # Sparkline data for this name
37
42
  slots = {}
38
43
  bs.each do |b|
39
44
  slot_key = (b.bucket_start.to_i / bucket_seconds) * bucket_seconds
40
45
  slots[slot_key] = (slots[slot_key] || 0) + b.count
41
46
  end
42
- now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
43
47
  sparkline = 60.times.map { |i| slots[now_slot - (59 - i) * bucket_seconds] || 0 }
44
48
 
49
+ pref = prefs[name]
45
50
  {
46
51
  name: name,
47
52
  total_count: total_count,
48
53
  sparkline: sparkline,
49
- last_seen: bs.map(&:bucket_start).max
54
+ last_seen: bs.map(&:bucket_start).max,
55
+ pinned: pref&.pinned || false,
56
+ ignored: pref&.ignored || false
50
57
  }
51
58
  end
52
59
 
53
- # Sort
60
+ # Separate ignored events
61
+ @ignored_events = events_list.select { |e| e[:ignored] }
62
+ events_list = events_list.reject { |e| e[:ignored] }
63
+
64
+ # Sort (pinned always on top)
54
65
  @sort = %w[name total_count last_seen].include?(params[:sort]) ? params[:sort] : 'total_count'
55
66
  @dir = params[:dir] == 'asc' ? 'asc' : 'desc'
56
- events_list = events_list.sort_by { |e| e[@sort.to_sym] || '' }
57
- events_list = events_list.reverse if @dir == 'desc'
67
+ sorted = events_list.sort_by { |e| e[@sort.to_sym] || '' }
68
+ sorted = sorted.reverse if @dir == 'desc'
69
+ pinned, unpinned = sorted.partition { |e| e[:pinned] }
70
+ events_list = pinned + unpinned
58
71
 
59
72
  @total_event_names = events_list.size
60
73
 
61
- # Sparkline times for tooltips
62
- now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
63
- @sparkline_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
64
-
65
74
  # Pagination
66
- @page = [ params[:page].to_i, 1 ].max
75
+ @page = [params[:page].to_i, 1].max
67
76
  @events = events_list.drop((@page - 1) * PER_PAGE).first(PER_PAGE)
68
77
 
69
78
  @active_error_count = Catpm::ErrorRecord.unresolved.count
@@ -107,6 +116,74 @@ module Catpm
107
116
  # Recent samples
108
117
  @samples = Catpm::EventSample.by_name(@name).order(recorded_at: :desc).limit(Catpm.config.events_max_samples_per_name)
109
118
 
119
+ @pref = Catpm::EventPref.find_by(name: @name)
120
+ @active_error_count = Catpm::ErrorRecord.unresolved.count
121
+ end
122
+
123
+ def destroy
124
+ name = params[:name]
125
+ Catpm::EventBucket.where(name: name).destroy_all
126
+ Catpm::EventSample.where(name: name).destroy_all
127
+ Catpm::EventPref.find_by(name: name)&.destroy
128
+ if request.xhr?
129
+ render json: { deleted: true }
130
+ else
131
+ redirect_to catpm.events_path, notice: 'Event deleted'
132
+ end
133
+ end
134
+
135
+ def toggle_pin
136
+ pref = Catpm::EventPref.lookup(params[:name])
137
+ pref.pinned = !pref.pinned
138
+ pref.save!
139
+ if request.xhr?
140
+ render json: { pinned: pref.pinned }
141
+ else
142
+ redirect_back fallback_location: catpm.event_path(name: params[:name])
143
+ end
144
+ end
145
+
146
+ def toggle_ignore
147
+ pref = Catpm::EventPref.lookup(params[:name])
148
+ pref.ignored = !pref.ignored
149
+ pref.save!
150
+ if request.xhr?
151
+ render json: { ignored: pref.ignored }
152
+ else
153
+ redirect_back fallback_location: catpm.events_path
154
+ end
155
+ end
156
+
157
+ def destroy_sample
158
+ sample = Catpm::EventSample.find(params[:sample_id])
159
+ sample.destroy
160
+ if request.xhr?
161
+ render json: { deleted: true }
162
+ else
163
+ redirect_back fallback_location: catpm.events_path, notice: 'Sample deleted'
164
+ end
165
+ end
166
+
167
+ def ignored
168
+ @range, period, _bucket_seconds = helpers.parse_range(remembered_range)
169
+ ignored_prefs = Catpm::EventPref.ignored
170
+
171
+ scope = @range == 'all' ? Catpm::EventBucket.all : Catpm::EventBucket.recent(period)
172
+ grouped = scope.group_by(&:name)
173
+
174
+ ignored_keys = ignored_prefs.map(&:name).to_set
175
+
176
+ @ignored_events = ignored_prefs.map do |pref|
177
+ bs = grouped[pref.name]
178
+ total_count = bs ? bs.sum(&:count) : 0
179
+ {
180
+ name: pref.name,
181
+ total_count: total_count,
182
+ last_seen: bs&.map(&:bucket_start)&.max
183
+ }
184
+ end
185
+
186
+ @active_event_count = grouped.keys.count { |k| !ignored_keys.include?(k) }
110
187
  @active_error_count = Catpm::ErrorRecord.unresolved.count
111
188
  end
112
189
  end
@@ -12,5 +12,18 @@ module Catpm
12
12
  Catpm::ErrorRecord.find_by(fingerprint: @sample.error_fingerprint)
13
13
  end
14
14
  end
15
+
16
+ def destroy
17
+ sample = Catpm::Sample.find(params[:id])
18
+ bucket = sample.bucket
19
+ sample.destroy
20
+ if request.xhr?
21
+ render json: { deleted: true }
22
+ elsif bucket
23
+ redirect_to catpm.endpoint_path(kind: bucket.kind, target: bucket.target, operation: bucket.operation), notice: 'Sample deleted'
24
+ else
25
+ redirect_to catpm.status_index_path, notice: 'Sample deleted'
26
+ end
27
+ end
15
28
  end
16
29
  end
@@ -52,9 +52,13 @@ module Catpm
52
52
  # Endpoints — aggregated from the SAME time range as hero metrics
53
53
  grouped = recent_buckets.group_by { |b| [b.kind, b.target, b.operation] }
54
54
 
55
+ # Load endpoint preferences (pinned/ignored)
56
+ prefs = Catpm::EndpointPref.where('pinned = ? OR ignored = ?', true, true).index_by { |p| [p.kind, p.target, p.operation] }
57
+
55
58
  endpoints = grouped.map do |key, bs|
56
59
  kind, target, operation = key
57
60
  total_count = bs.sum(&:count)
61
+ pref = prefs[key]
58
62
  {
59
63
  kind: kind,
60
64
  target: target,
@@ -63,20 +67,28 @@ module Catpm
63
67
  avg_duration: total_count > 0 ? bs.sum(&:duration_sum) / total_count : 0.0,
64
68
  max_duration: bs.map(&:duration_max).max,
65
69
  total_failures: bs.sum(&:failure_count),
66
- last_seen: bs.map(&:bucket_start).max
70
+ last_seen: bs.map(&:bucket_start).max,
71
+ pinned: pref&.pinned || false,
72
+ ignored: pref&.ignored || false
67
73
  }
68
74
  end
69
75
 
76
+ # Separate ignored endpoints
77
+ @ignored_endpoints = endpoints.select { |e| e[:ignored] }
78
+ endpoints = endpoints.reject { |e| e[:ignored] }
79
+
70
80
  # Kind filter (URL-based)
71
81
  @available_kinds = endpoints.map { |e| e[:kind] }.uniq.sort
72
82
  @kind_filter = params[:kind] if params[:kind].present? && @available_kinds.include?(params[:kind])
73
83
  endpoints = endpoints.select { |e| e[:kind] == @kind_filter } if @kind_filter
74
84
 
75
- # Server-side sort
85
+ # Server-side sort (pinned always on top)
76
86
  @sort = %w[target total_count avg_duration max_duration total_failures last_seen].include?(params[:sort]) ? params[:sort] : 'last_seen'
77
87
  @dir = params[:dir] == 'asc' ? 'asc' : 'desc'
78
- endpoints = endpoints.sort_by { |e| e[@sort.to_sym] || '' }
79
- endpoints = endpoints.reverse if @dir == 'desc'
88
+ sorted = endpoints.sort_by { |e| e[@sort.to_sym] || '' }
89
+ sorted = sorted.reverse if @dir == 'desc'
90
+ pinned, unpinned = sorted.partition { |e| e[:pinned] }
91
+ endpoints = pinned + unpinned
80
92
 
81
93
  @total_endpoint_count = endpoints.size
82
94
 
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class EndpointPref < ActiveRecord::Base
5
+ self.table_name = 'catpm_endpoint_prefs'
6
+
7
+ scope :pinned, -> { where(pinned: true) }
8
+ scope :ignored, -> { where(ignored: true) }
9
+
10
+ def self.lookup(kind, target, operation)
11
+ find_or_initialize_by(kind: kind, target: target, operation: operation.presence || '')
12
+ end
13
+ end
14
+ end
@@ -10,6 +10,7 @@ module Catpm
10
10
  scope :by_kind, ->(kind) { where(kind: kind) }
11
11
  scope :unresolved, -> { where(resolved_at: nil) }
12
12
  scope :resolved, -> { where.not(resolved_at: nil) }
13
+ scope :pinned, -> { where(pinned: true) }
13
14
  scope :recent, ->(period = 24.hours) { where(last_occurred_at: period.ago..) }
14
15
 
15
16
  def resolved?
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class EventPref < ActiveRecord::Base
5
+ self.table_name = 'catpm_event_prefs'
6
+
7
+ scope :pinned, -> { where(pinned: true) }
8
+ scope :ignored, -> { where(ignored: true) }
9
+
10
+ def self.lookup(name)
11
+ find_or_initialize_by(name: name)
12
+ end
13
+ end
14
+ end
@@ -7,6 +7,7 @@
7
7
  <th>Duration</th>
8
8
  <th>Segments</th>
9
9
  <th>Time</th>
10
+ <th></th>
10
11
  </tr>
11
12
  </thead>
12
13
  <tbody>
@@ -24,6 +25,12 @@
24
25
  <% end %>
25
26
  </td>
26
27
  <td><%= time_with_tooltip(s.recorded_at) %></td>
28
+ <td style="position:relative; z-index:2; width:28px; padding:4px 2px; text-align:center">
29
+ <button class="action-menu-btn">&#x22EE;</button>
30
+ <div class="action-menu">
31
+ <button class="menu-danger" data-action="delete" data-url="<%= catpm.sample_path(s) %>">Delete</button>
32
+ </div>
33
+ </td>
27
34
  </tr>
28
35
  <% end %>
29
36
  </tbody>
@@ -0,0 +1,57 @@
1
+ <% content_for :title, "Ignored Endpoints" %>
2
+ <% content_for :subtitle, "Endpoints hidden from the main dashboard" %>
3
+
4
+ <%= render "catpm/shared/page_nav", active: "performance" %>
5
+
6
+ <div class="tabs">
7
+ <a href="<%= catpm.status_index_path %>" class="tab">Active (<%= @active_endpoint_count %>)</a>
8
+ <a href="<%= catpm.ignored_endpoints_path %>" class="tab active">Ignored (<%= @ignored_endpoints.size %>)</a>
9
+ </div>
10
+
11
+ <%# ─── Time Range ─── %>
12
+ <div class="time-range">
13
+ <% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
14
+ <a href="<%= catpm.ignored_endpoints_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
15
+ <% end %>
16
+ </div>
17
+
18
+ <% if @ignored_endpoints.any? %>
19
+ <div class="table-scroll">
20
+ <table>
21
+ <thead>
22
+ <tr>
23
+ <th>Kind</th>
24
+ <th>Target</th>
25
+ <th>Count</th>
26
+ <th>Avg</th>
27
+ <th>Last Seen</th>
28
+ <th></th>
29
+ </tr>
30
+ </thead>
31
+ <tbody>
32
+ <% @ignored_endpoints.each do |ep| %>
33
+ <% ep_p = { kind: ep[:kind], target: ep[:target], operation: ep[:operation] } %>
34
+ <tr class="linked">
35
+ <td><a href="<%= catpm.endpoint_path(ep_p) %>" class="row-link"><%= type_badge(ep[:kind]) %></a></td>
36
+ <td class="mono"><%= ep[:target] %><%= " #{ep[:operation]}" if ep[:operation].present? %></td>
37
+ <td><%= ep[:total_count] %></td>
38
+ <td class="mono"><%= format_duration(ep[:avg_duration]) %></td>
39
+ <td><%= ep[:last_seen] ? time_with_tooltip(ep[:last_seen]) : "—" %></td>
40
+ <td style="position:relative; z-index:2; width:28px; padding:4px 2px; text-align:center">
41
+ <button class="action-menu-btn">&#x22EE;</button>
42
+ <div class="action-menu">
43
+ <button data-action="unignore" data-url="<%= catpm.endpoint_ignore_path(ep_p) %>">Unignore</button>
44
+ <button class="menu-danger" data-action="delete" data-url="<%= catpm.endpoint_path(ep_p) %>">Delete</button>
45
+ </div>
46
+ </td>
47
+ </tr>
48
+ <% end %>
49
+ </tbody>
50
+ </table>
51
+ </div>
52
+ <% else %>
53
+ <div class="empty-state">
54
+ <div class="empty-title">No ignored endpoints</div>
55
+ <div class="empty-hint">Endpoints you ignore will appear here.</div>
56
+ </div>
57
+ <% end %>
@@ -14,9 +14,13 @@
14
14
  <span class="sep">/</span>
15
15
  <span><%= @target %></span>
16
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." } %>
17
+ <div style="display:flex; gap:6px; align-items:center">
18
+ <%= button_to @pref&.ignored ? "Unignore" : "Ignore", catpm.endpoint_ignore_path(kind: @kind, target: @target, operation: @operation),
19
+ method: :patch, class: "btn" %>
20
+ <%= button_to "Delete Endpoint", catpm.endpoint_path(kind: @kind, target: @target, operation: @operation),
21
+ method: :delete, class: "btn btn-danger",
22
+ data: { confirm: "Delete this endpoint and all its data? This cannot be undone." } %>
23
+ </div>
20
24
  </div>
21
25
 
22
26
  <% ep_params = { kind: @kind, target: @target, operation: @operation } %>
@@ -27,6 +27,7 @@
27
27
  <table id="errors-table">
28
28
  <thead>
29
29
  <tr>
30
+ <th></th>
30
31
  <th>Kind</th>
31
32
  <th><%= sort_header("Error Class", "error_class", @sort, @dir, extra_params: extra) %></th>
32
33
  <th>Message</th>
@@ -34,11 +35,13 @@
34
35
  <th>Trend</th>
35
36
  <th><%= sort_header("Last Seen", "last_occurred_at", @sort, @dir, extra_params: extra) %></th>
36
37
  <% if @tab == "resolved" %><th>Resolved</th><% end %>
38
+ <th></th>
37
39
  </tr>
38
40
  </thead>
39
41
  <tbody>
40
42
  <% @errors.each do |e| %>
41
43
  <tr class="linked">
44
+ <td style="position:relative; z-index:1; width:28px; padding:4px 2px; text-align:center"><button class="star-btn<%= ' pinned' if e.pinned %>" data-pin-url="<%= catpm.toggle_pin_error_path(e) %>"><%= e.pinned ? "&#x2605;".html_safe : "&#x2606;".html_safe %></button></td>
42
45
  <td><a href="<%= catpm.error_path(e) %>" class="row-link"><%= type_badge(e.kind) %></a></td>
43
46
  <td class="mono"><%= e.error_class %></td>
44
47
  <td style="color:var(--text-1)"><%= truncate(e.message, length: 60) %></td>
@@ -46,6 +49,17 @@
46
49
  <td><%= trend_indicator(e) %></td>
47
50
  <td><%= time_with_tooltip(e.last_occurred_at) %></td>
48
51
  <% if @tab == "resolved" %><td><%= time_with_tooltip(e.resolved_at) %></td><% end %>
52
+ <td style="position:relative; z-index:2; width:28px; padding:4px 2px; text-align:center">
53
+ <button class="action-menu-btn">&#x22EE;</button>
54
+ <div class="action-menu">
55
+ <% if e.resolved? %>
56
+ <button data-action="unresolve" data-url="<%= catpm.unresolve_error_path(e) %>">Reopen</button>
57
+ <% else %>
58
+ <button data-action="resolve" data-url="<%= catpm.resolve_error_path(e) %>">Resolve</button>
59
+ <% end %>
60
+ <button class="menu-danger" data-action="delete" data-url="<%= catpm.error_path(e) %>">Delete</button>
61
+ </div>
62
+ </td>
49
63
  </tr>
50
64
  <% end %>
51
65
  </tbody>
@@ -106,17 +106,24 @@
106
106
  <th>Status</th>
107
107
  <th>Target</th>
108
108
  <th>Segments</th>
109
+ <th></th>
109
110
  </tr>
110
111
  </thead>
111
112
  <tbody>
112
113
  <% @samples.each do |sample| %>
113
114
  <% ctx = sample.parsed_context %>
114
- <tr class="clickable-row" onclick="window.location='<%= catpm.sample_path(sample) %>';" style="cursor:pointer">
115
- <td><%= time_with_tooltip(sample.recorded_at) %></td>
115
+ <tr class="linked">
116
+ <td><a href="<%= catpm.sample_path(sample) %>" class="row-link"><%= time_with_tooltip(sample.recorded_at) %></a></td>
116
117
  <td class="mono"><%= format_duration(sample.duration) %></td>
117
118
  <td><%= status_badge(ctx["status"] || ctx[:status]) %></td>
118
119
  <td class="mono"><%= sample.bucket&.target || "—" %></td>
119
120
  <td class="mono text-muted"><%= segment_count_summary(ctx["segment_summary"] || ctx[:segment_summary]).presence || "—" %></td>
121
+ <td style="position:relative; z-index:2; width:28px; padding:4px 2px; text-align:center">
122
+ <button class="action-menu-btn">&#x22EE;</button>
123
+ <div class="action-menu">
124
+ <button class="menu-danger" data-action="delete" data-url="<%= catpm.sample_path(sample) %>">Delete</button>
125
+ </div>
126
+ </td>
120
127
  </tr>
121
128
  <% end %>
122
129
  </tbody>
@@ -0,0 +1,52 @@
1
+ <% content_for :title, "Ignored Events" %>
2
+ <% content_for :subtitle, "Events hidden from the main dashboard" %>
3
+
4
+ <%= render "catpm/shared/page_nav", active: "events" %>
5
+
6
+ <div class="tabs">
7
+ <a href="<%= catpm.events_path %>" class="tab">Active (<%= @active_event_count %>)</a>
8
+ <a href="<%= catpm.ignored_events_path %>" class="tab active">Ignored (<%= @ignored_events.size %>)</a>
9
+ </div>
10
+
11
+ <%# ─── Time Range ─── %>
12
+ <div class="time-range">
13
+ <% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
14
+ <a href="<%= catpm.ignored_events_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
15
+ <% end %>
16
+ </div>
17
+
18
+ <% if @ignored_events.any? %>
19
+ <div class="table-scroll">
20
+ <table>
21
+ <thead>
22
+ <tr>
23
+ <th>Name</th>
24
+ <th>Count</th>
25
+ <th>Last Seen</th>
26
+ <th></th>
27
+ </tr>
28
+ </thead>
29
+ <tbody>
30
+ <% @ignored_events.each do |ev| %>
31
+ <tr class="linked">
32
+ <td><a href="<%= catpm.event_path(name: ev[:name]) %>" class="row-link"><span class="badge badge-event"><%= ev[:name] %></span></a></td>
33
+ <td><%= ev[:total_count] %></td>
34
+ <td><%= ev[:last_seen] ? time_with_tooltip(ev[:last_seen]) : "—" %></td>
35
+ <td style="position:relative; z-index:2; width:28px; padding:4px 2px; text-align:center">
36
+ <button class="action-menu-btn">&#x22EE;</button>
37
+ <div class="action-menu">
38
+ <button data-action="unignore" data-url="<%= catpm.event_ignore_path(name: ev[:name]) %>">Unignore</button>
39
+ <button class="menu-danger" data-action="delete" data-url="<%= catpm.event_path(name: ev[:name]) %>">Delete</button>
40
+ </div>
41
+ </td>
42
+ </tr>
43
+ <% end %>
44
+ </tbody>
45
+ </table>
46
+ </div>
47
+ <% else %>
48
+ <div class="empty-state">
49
+ <div class="empty-title">No ignored events</div>
50
+ <div class="empty-hint">Events you ignore will appear here.</div>
51
+ </div>
52
+ <% end %>
@@ -3,6 +3,11 @@
3
3
 
4
4
  <%= render "catpm/shared/page_nav", active: "events" %>
5
5
 
6
+ <div class="tabs">
7
+ <a href="<%= catpm.events_path(range: @range) %>" class="tab active">Active (<%= @total_event_names %>)</a>
8
+ <a href="<%= catpm.ignored_events_path %>" class="tab">Ignored (<%= @ignored_events.size %>)</a>
9
+ </div>
10
+
6
11
  <%# ─── Time Range ─── %>
7
12
  <div class="time-range">
8
13
  <% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
@@ -34,22 +39,24 @@
34
39
 
35
40
  <%# ─── Events Table ─── %>
36
41
  <h2>Events <% if @total_event_names > @events.size %><span class="text-muted" style="font-weight:400; font-size:13px">(showing <%= @events.size %> of <%= @total_event_names %>)</span><% end %></h2>
37
- <%= section_description("Events tracked in the selected time range.") %>
38
42
 
39
43
  <% if @events.any? %>
40
44
  <div class="table-scroll">
41
45
  <table>
42
46
  <thead>
43
47
  <tr>
48
+ <th></th>
44
49
  <th><%= sort_header("Name", "name", @sort, @dir, extra_params: { range: @range }) %></th>
45
50
  <th><%= sort_header("Count", "total_count", @sort, @dir, extra_params: { range: @range }) %></th>
46
51
  <th>Trend</th>
47
52
  <th><%= sort_header("Last Seen", "last_seen", @sort, @dir, extra_params: { range: @range }) %></th>
53
+ <th></th>
48
54
  </tr>
49
55
  </thead>
50
56
  <tbody>
51
57
  <% @events.each do |ev| %>
52
58
  <tr class="linked">
59
+ <td style="position:relative; z-index:1; width:28px; padding:4px 2px; text-align:center"><button class="star-btn<%= ' pinned' if ev[:pinned] %>" data-pin-url="<%= catpm.event_pin_path(name: ev[:name]) %>"><%= ev[:pinned] ? "&#x2605;".html_safe : "&#x2606;".html_safe %></button></td>
53
60
  <td>
54
61
  <a href="<%= catpm.event_path(name: ev[:name], range: @range) %>" class="row-link">
55
62
  <span class="badge badge-event"><%= ev[:name] %></span>
@@ -58,6 +65,13 @@
58
65
  <td><%= ev[:total_count] %></td>
59
66
  <td><%= sparkline_svg(ev[:sparkline], width: 120, height: 32, color: "var(--accent)", time_labels: @sparkline_times) %></td>
60
67
  <td><%= time_with_tooltip(ev[:last_seen]) %></td>
68
+ <td style="position:relative; z-index:2; width:28px; padding:4px 2px; text-align:center">
69
+ <button class="action-menu-btn">&#x22EE;</button>
70
+ <div class="action-menu">
71
+ <button data-action="ignore" data-url="<%= catpm.event_ignore_path(name: ev[:name]) %>">Ignore</button>
72
+ <button class="menu-danger" data-action="delete" data-url="<%= catpm.event_path(name: ev[:name]) %>">Delete</button>
73
+ </div>
74
+ </td>
61
75
  </tr>
62
76
  <% end %>
63
77
  </tbody>
@@ -3,10 +3,19 @@
3
3
 
4
4
  <%= render "catpm/shared/page_nav", active: "events" %>
5
5
 
6
- <div class="breadcrumbs">
7
- <a href="<%= catpm.events_path(range: @range) %>">Events</a>
8
- <span class="sep">/</span>
9
- <span class="badge badge-event"><%= @name %></span>
6
+ <div class="breadcrumbs" style="display:flex; align-items:center; justify-content:space-between">
7
+ <div>
8
+ <a href="<%= catpm.events_path(range: @range) %>">Events</a>
9
+ <span class="sep">/</span>
10
+ <span class="badge badge-event"><%= @name %></span>
11
+ </div>
12
+ <div style="display:flex; gap:6px; align-items:center">
13
+ <%= button_to @pref&.ignored ? "Unignore" : "Ignore", catpm.event_ignore_path(name: @name),
14
+ method: :patch, class: "btn" %>
15
+ <%= button_to "Delete Event", catpm.event_path(name: @name),
16
+ method: :delete, class: "btn btn-danger",
17
+ data: { confirm: "Delete this event and all its data? This cannot be undone." } %>
18
+ </div>
10
19
  </div>
11
20
 
12
21
  <%# ─── Time Range ─── %>
@@ -54,11 +63,13 @@
54
63
  <colgroup>
55
64
  <col style="width:120px">
56
65
  <col>
66
+ <col style="width:36px">
57
67
  </colgroup>
58
68
  <thead>
59
69
  <tr>
60
70
  <th>Recorded At</th>
61
71
  <th>Payload</th>
72
+ <th></th>
62
73
  </tr>
63
74
  </thead>
64
75
  <tbody>
@@ -78,6 +89,12 @@
78
89
  <span class="text-muted">—</span>
79
90
  <% end %>
80
91
  </td>
92
+ <td style="position:relative; z-index:2; width:28px; padding:4px 2px; text-align:center; vertical-align:top">
93
+ <button class="action-menu-btn">&#x22EE;</button>
94
+ <div class="action-menu">
95
+ <button class="menu-danger" data-action="delete" data-url="<%= catpm.destroy_sample_events_path(sample_id: sample.id) %>">Delete</button>
96
+ </div>
97
+ </td>
81
98
  </tr>
82
99
  <% end %>
83
100
  </tbody>
@@ -6,14 +6,19 @@
6
6
  &middot; <%= format_duration(@sample.duration) %>
7
7
  <% end %>
8
8
 
9
- <div class="breadcrumbs">
10
- <a href="<%= catpm.status_index_path %>">Overview</a>
11
- <% if @bucket %>
9
+ <div class="breadcrumbs" style="display:flex; align-items:center; justify-content:space-between">
10
+ <div>
11
+ <a href="<%= catpm.status_index_path %>">Overview</a>
12
+ <% if @bucket %>
13
+ <span class="sep">/</span>
14
+ <a href="<%= catpm.endpoint_path(kind: @bucket.kind, target: @bucket.target, operation: @bucket.operation) %>"><%= @bucket.target %></a>
15
+ <% end %>
12
16
  <span class="sep">/</span>
13
- <a href="<%= catpm.endpoint_path(kind: @bucket.kind, target: @bucket.target, operation: @bucket.operation) %>"><%= @bucket.target %></a>
14
- <% end %>
15
- <span class="sep">/</span>
16
- <span>Sample #<%= @sample.id %></span>
17
+ <span>Sample #<%= @sample.id %></span>
18
+ </div>
19
+ <%= button_to "Delete Sample", catpm.sample_path(@sample),
20
+ method: :delete, class: "btn btn-danger",
21
+ data: { confirm: "Delete this sample? This cannot be undone." } %>
17
22
  </div>
18
23
 
19
24
  <%# ─── Request Info Bar ─── %>
@@ -3,6 +3,11 @@
3
3
 
4
4
  <%= render "catpm/shared/page_nav", active: "performance" %>
5
5
 
6
+ <div class="tabs">
7
+ <a href="<%= catpm.status_index_path(range: @range) %>" class="tab active">Active (<%= @total_endpoint_count %>)</a>
8
+ <a href="<%= catpm.ignored_endpoints_path %>" class="tab">Ignored (<%= @ignored_endpoints.size %>)</a>
9
+ </div>
10
+
6
11
  <%# ─── Time Range ─── %>
7
12
  <div class="time-range">
8
13
  <% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
@@ -70,7 +75,6 @@
70
75
 
71
76
  <%# ─── Endpoints ─── %>
72
77
  <h2>Endpoints <% if @total_endpoint_count > @endpoint_count %><span class="text-muted" style="font-weight:400; font-size:13px">(showing <%= @endpoint_count %> of <%= @total_endpoint_count %>)</span><% end %></h2>
73
- <%= section_description("Endpoints active in the selected time range.") %>
74
78
  <% ep_extra = { range: @range }; ep_extra[:kind] = @kind_filter if @kind_filter %>
75
79
  <% if @endpoints.any? || @kind_filter.present? %>
76
80
  <div class="filters">
@@ -84,6 +88,7 @@
84
88
  <table id="endpoints-table">
85
89
  <thead>
86
90
  <tr>
91
+ <th></th>
87
92
  <th>Kind</th>
88
93
  <th><%= sort_header("Target", "target", @sort, @dir, extra_params: ep_extra) %></th>
89
94
  <th><%= sort_header("Count", "total_count", @sort, @dir, extra_params: ep_extra) %></th>
@@ -91,18 +96,28 @@
91
96
  <th><%= sort_header("Max", "max_duration", @sort, @dir, extra_params: ep_extra) %></th>
92
97
  <th><%= sort_header("Fail", "total_failures", @sort, @dir, extra_params: ep_extra) %></th>
93
98
  <th><%= sort_header("Last Seen", "last_seen", @sort, @dir, extra_params: ep_extra) %></th>
99
+ <th></th>
94
100
  </tr>
95
101
  </thead>
96
102
  <tbody>
97
103
  <% @endpoints.each do |ep| %>
104
+ <% ep_p = { kind: ep[:kind], target: ep[:target], operation: ep[:operation] } %>
98
105
  <tr class="linked">
99
- <td><a href="<%= catpm.endpoint_path(kind: ep[:kind], target: ep[:target], operation: ep[:operation]) %>" class="row-link"><%= type_badge(ep[:kind]) %></a></td>
106
+ <td style="position:relative; z-index:1; width:28px; padding:4px 2px; text-align:center"><button class="star-btn<%= ' pinned' if ep[:pinned] %>" data-pin-url="<%= catpm.endpoint_pin_path(ep_p) %>"><%= ep[:pinned] ? "&#x2605;".html_safe : "&#x2606;".html_safe %></button></td>
107
+ <td><a href="<%= catpm.endpoint_path(ep_p) %>" class="row-link"><%= type_badge(ep[:kind]) %></a></td>
100
108
  <td class="mono"><%= ep[:target] %><%= " #{ep[:operation]}" if ep[:operation].present? %></td>
101
109
  <td><%= ep[:total_count] %></td>
102
110
  <td class="mono"><%= format_duration(ep[:avg_duration]) %></td>
103
111
  <td class="mono"><%= format_duration(ep[:max_duration]) %></td>
104
112
  <td><%= ep[:total_failures] %></td>
105
113
  <td><%= time_with_tooltip(ep[:last_seen]) %></td>
114
+ <td style="position:relative; z-index:2; width:28px; padding:4px 2px; text-align:center">
115
+ <button class="action-menu-btn">&#x22EE;</button>
116
+ <div class="action-menu">
117
+ <button data-action="ignore" data-url="<%= catpm.endpoint_ignore_path(ep_p) %>">Ignore</button>
118
+ <button class="menu-danger" data-action="delete" data-url="<%= catpm.endpoint_path(ep_p) %>">Delete</button>
119
+ </div>
120
+ </td>
106
121
  </tr>
107
122
  <% end %>
108
123
  </tbody>
@@ -122,3 +137,4 @@
122
137
  </div>
123
138
  <% end %>
124
139
 
140
+
@@ -3,6 +3,11 @@
3
3
  <head>
4
4
  <title>catpm<%= " — #{yield :title}" if content_for?(:title) %></title>
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <link rel="icon" type="image/png" sizes="32x32" href="<%= image_path('catpm/favicon-32.png') %>">
7
+ <link rel="icon" type="image/png" sizes="512x512" href="<%= image_path('catpm/favicon-512.png') %>">
8
+ <link rel="icon" type="image/x-icon" href="<%= image_path('catpm/favicon.ico') %>">
9
+ <link rel="apple-touch-icon" href="<%= image_path('catpm/favicon-512.png') %>">
10
+ <%= csrf_meta_tags %>
6
11
  <%= yield :head_extra if content_for?(:head_extra) %>
7
12
  <style>
8
13
  /* ─── Design Tokens — Light Theme ─── */
@@ -199,6 +204,22 @@
199
204
  .btn-primary:hover { background: var(--border); }
200
205
  .btn-danger { background: var(--bg-0); color: var(--text-2); border-color: var(--border); }
201
206
  .btn-danger:hover { background: #ffebe9; color: var(--red); border-color: #ffcecb; }
207
+
208
+ /* ─── Star Button ─── */
209
+ .star-btn { background: none; border: none; cursor: pointer; font-size: 16px; color: var(--text-2); padding: 0; line-height: 1; transition: color 0.15s; }
210
+ .star-btn:hover { color: #e3b341; }
211
+ .star-btn.pinned { color: #e3b341; }
212
+
213
+ /* ─── Action Menu (3-dot) ─── */
214
+ .action-menu-btn { background: none; border: none; cursor: pointer; font-size: 18px; color: var(--text-2); padding: 2px 6px; line-height: 1; border-radius: 4px; transition: background 0.1s, color 0.1s; }
215
+ .action-menu-btn:hover, .action-menu-btn.active { color: var(--text-0); background: var(--bg-2); }
216
+ .action-menu { display: none; }
217
+ .action-menu-overlay { position: fixed; background: var(--bg-0); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15), 0 1px 4px rgba(0,0,0,0.08); z-index: 9999; min-width: 160px; padding: 4px 0; }
218
+ .action-menu-overlay button { display: block; width: 100%; text-align: left; padding: 8px 14px; border: none; background: none; cursor: pointer; font-size: 13px; color: var(--text-0); transition: background 0.1s; }
219
+ .action-menu-overlay button:hover { background: var(--bg-1); }
220
+ .action-menu-overlay .menu-danger { color: var(--red); }
221
+ .action-menu-overlay .menu-danger:hover { background: #ffebe9; }
222
+
202
223
  .copy-btn { background: var(--bg-1); border: 1px solid var(--border); color: var(--text-2); padding: 3px 8px; border-radius: 3px; font-size: 11px; cursor: pointer; }
203
224
  .copy-btn:hover { color: var(--text-0); background: var(--bg-2); }
204
225
 
@@ -422,6 +443,123 @@
422
443
  if (search) search.focus();
423
444
  }
424
445
  });
446
+
447
+ /* ─── AJAX Helpers ─── */
448
+ function csrfToken() {
449
+ var meta = document.querySelector('meta[name="csrf-token"]');
450
+ return meta ? meta.getAttribute('content') : '';
451
+ }
452
+
453
+ function apiRequest(url, method) {
454
+ return fetch(url, {
455
+ method: method,
456
+ headers: { 'X-CSRF-Token': csrfToken(), 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
457
+ credentials: 'same-origin'
458
+ }).then(function(r) { return r.json(); });
459
+ }
460
+
461
+ function fadeOutRow(row) {
462
+ row.style.transition = 'opacity 0.3s';
463
+ row.style.opacity = '0';
464
+ setTimeout(function() { row.remove(); }, 300);
465
+ }
466
+
467
+ var _actionMenu = null;
468
+ function closeActionMenu() {
469
+ if (_actionMenu) {
470
+ _actionMenu.overlay.remove();
471
+ _actionMenu.btn.classList.remove('active');
472
+ _actionMenu = null;
473
+ }
474
+ }
475
+
476
+ document.addEventListener('click', function(e) {
477
+ /* Star (pin) toggle */
478
+ var starBtn = e.target.closest('.star-btn');
479
+ if (starBtn) {
480
+ e.stopPropagation();
481
+ e.preventDefault();
482
+ apiRequest(starBtn.dataset.pinUrl, 'PATCH').then(function(data) {
483
+ starBtn.classList.toggle('pinned', data.pinned);
484
+ starBtn.innerHTML = data.pinned ? '\u2605' : '\u2606';
485
+ });
486
+ return;
487
+ }
488
+
489
+ /* Pin text button (endpoint page) */
490
+ var pinTextBtn = e.target.closest('[data-pin-text]');
491
+ if (pinTextBtn) {
492
+ e.preventDefault();
493
+ apiRequest(pinTextBtn.dataset.pinUrl, 'PATCH').then(function(data) {
494
+ pinTextBtn.textContent = data.pinned ? 'Unpin' : 'Pin';
495
+ pinTextBtn.classList.toggle('btn-primary', data.pinned);
496
+ });
497
+ return;
498
+ }
499
+
500
+ /* 3-dot menu toggle — portal overlay on body */
501
+ var menuBtn = e.target.closest('.action-menu-btn');
502
+ if (menuBtn) {
503
+ e.stopPropagation();
504
+ closeActionMenu();
505
+ var source = menuBtn.nextElementSibling;
506
+ if (!source) return;
507
+ var row = menuBtn.closest('tr');
508
+ var rect = menuBtn.getBoundingClientRect();
509
+
510
+ var overlay = document.createElement('div');
511
+ overlay.className = 'action-menu-overlay';
512
+ source.querySelectorAll('button').forEach(function(src) {
513
+ var item = document.createElement('button');
514
+ item.textContent = src.textContent;
515
+ if (src.classList.contains('menu-danger')) item.className = 'menu-danger';
516
+ item.dataset.action = src.dataset.action;
517
+ item.dataset.url = src.dataset.url;
518
+ overlay.appendChild(item);
519
+ });
520
+
521
+ document.body.appendChild(overlay);
522
+ menuBtn.classList.add('active');
523
+ _actionMenu = { overlay: overlay, row: row, btn: menuBtn };
524
+
525
+ overlay.style.right = (window.innerWidth - rect.right) + 'px';
526
+ overlay.style.top = (rect.bottom + 4) + 'px';
527
+ requestAnimationFrame(function() {
528
+ var mr = overlay.getBoundingClientRect();
529
+ if (mr.bottom > window.innerHeight - 8) {
530
+ overlay.style.top = (rect.top - mr.height - 4) + 'px';
531
+ }
532
+ });
533
+ return;
534
+ }
535
+
536
+ /* Menu overlay action clicks */
537
+ var overlayBtn = e.target.closest('.action-menu-overlay button');
538
+ if (overlayBtn) {
539
+ e.stopPropagation();
540
+ var action = overlayBtn.dataset.action;
541
+ var url = overlayBtn.dataset.url;
542
+ var row = _actionMenu ? _actionMenu.row : null;
543
+ closeActionMenu();
544
+
545
+ if (action === 'ignore') {
546
+ apiRequest(url, 'PATCH').then(function(data) { if (data.ignored && row) fadeOutRow(row); });
547
+ } else if (action === 'delete') {
548
+ if (!confirm('Delete this and all its data?')) return;
549
+ apiRequest(url, 'DELETE').then(function(data) { if (data.deleted && row) fadeOutRow(row); });
550
+ } else if (action === 'unignore') {
551
+ apiRequest(url, 'PATCH').then(function(data) { if (!data.ignored && row) fadeOutRow(row); });
552
+ } else if (action === 'resolve') {
553
+ apiRequest(url, 'PATCH').then(function(data) { if (data.resolved && row) fadeOutRow(row); });
554
+ } else if (action === 'unresolve') {
555
+ apiRequest(url, 'PATCH').then(function(data) { if (!data.resolved && row) fadeOutRow(row); });
556
+ }
557
+ return;
558
+ }
559
+
560
+ /* Close menu on outside click */
561
+ closeActionMenu();
562
+ });
425
563
  </script>
426
564
  </body>
427
565
  </html>
data/config/routes.rb CHANGED
@@ -6,8 +6,18 @@ Catpm::Engine.routes.draw do
6
6
  resources :system, only: [:index]
7
7
  get 'endpoint', to: 'endpoints#show', as: :endpoint
8
8
  delete 'endpoint', to: 'endpoints#destroy'
9
- resources :samples, only: [:show]
10
- resources :events, only: [:index, :show], param: :name
9
+ patch 'endpoint/pin', to: 'endpoints#toggle_pin', as: :endpoint_pin
10
+ patch 'endpoint/ignore', to: 'endpoints#toggle_ignore', as: :endpoint_ignore
11
+ get 'endpoints/ignored', to: 'endpoints#ignored', as: :ignored_endpoints
12
+ resources :samples, only: [:show, :destroy]
13
+ resources :events, only: [:index, :show, :destroy], param: :name do
14
+ collection do
15
+ delete 'samples/:sample_id', to: 'events#destroy_sample', as: :destroy_sample
16
+ end
17
+ end
18
+ patch 'events/:name/pin', to: 'events#toggle_pin', as: :event_pin
19
+ patch 'events/:name/ignore', to: 'events#toggle_ignore', as: :event_ignore
20
+ get 'events_ignored', to: 'events#ignored', as: :ignored_events
11
21
  resources :errors, only: [:index, :show, :destroy] do
12
22
  collection do
13
23
  post :resolve_all
@@ -15,6 +25,7 @@ Catpm::Engine.routes.draw do
15
25
  member do
16
26
  patch :resolve
17
27
  patch :unresolve
28
+ patch :toggle_pin
18
29
  end
19
30
  end
20
31
  end
@@ -50,6 +50,7 @@ class CreateCatpmTables < ActiveRecord::Migration[8.0]
50
50
  t.json :contexts
51
51
  t.json :occurrence_buckets
52
52
  t.datetime :resolved_at
53
+ t.boolean :pinned, null: false, default: false
53
54
  end
54
55
 
55
56
  add_index :catpm_errors, :fingerprint, unique: true, name: 'idx_catpm_errors_fingerprint'
@@ -74,6 +75,26 @@ class CreateCatpmTables < ActiveRecord::Migration[8.0]
74
75
  add_index :catpm_event_samples, [:name, :recorded_at], name: 'idx_catpm_event_samples_name_time'
75
76
  add_index :catpm_event_samples, :recorded_at, name: 'idx_catpm_event_samples_time'
76
77
 
78
+ create_table :catpm_endpoint_prefs do |t|
79
+ t.string :kind, null: false
80
+ t.string :target, null: false
81
+ t.string :operation, null: false, default: ''
82
+ t.boolean :pinned, null: false, default: false
83
+ t.boolean :ignored, null: false, default: false
84
+ end
85
+
86
+ add_index :catpm_endpoint_prefs, [:kind, :target, :operation],
87
+ unique: true, name: 'idx_catpm_endpoint_prefs_unique'
88
+
89
+ create_table :catpm_event_prefs do |t|
90
+ t.string :name, null: false
91
+ t.boolean :pinned, null: false, default: false
92
+ t.boolean :ignored, null: false, default: false
93
+ end
94
+
95
+ add_index :catpm_event_prefs, :name,
96
+ unique: true, name: 'idx_catpm_event_prefs_unique'
97
+
77
98
  if postgresql?
78
99
  execute <<~SQL
79
100
  CREATE OR REPLACE FUNCTION catpm_merge_jsonb_sums(a jsonb, b jsonb)
@@ -92,6 +113,8 @@ class CreateCatpmTables < ActiveRecord::Migration[8.0]
92
113
  execute 'DROP FUNCTION IF EXISTS catpm_merge_jsonb_sums(jsonb, jsonb);'
93
114
  end
94
115
 
116
+ drop_table :catpm_event_prefs, if_exists: true
117
+ drop_table :catpm_endpoint_prefs, if_exists: true
95
118
  drop_table :catpm_event_samples, if_exists: true
96
119
  drop_table :catpm_event_buckets, if_exists: true
97
120
  drop_table :catpm_errors, if_exists: true
@@ -53,6 +53,7 @@ module Catpm
53
53
  duration: elapsed_ms(env),
54
54
  started_at: Time.current,
55
55
  status: 500,
56
+ sample_type: 'error',
56
57
  error_class: exception.class.name,
57
58
  error_message: exception.message,
58
59
  backtrace: exception.backtrace,
data/lib/catpm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Catpm
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.1'
5
5
  end
@@ -19,5 +19,40 @@ namespace :catpm do
19
19
  else
20
20
  puts '[catpm] catpm_errors.occurrence_buckets already exists, skipping'
21
21
  end
22
+
23
+ unless connection.table_exists?(:catpm_endpoint_prefs)
24
+ connection.create_table :catpm_endpoint_prefs do |t|
25
+ t.string :kind, null: false
26
+ t.string :target, null: false
27
+ t.string :operation, null: false, default: ''
28
+ t.boolean :pinned, null: false, default: false
29
+ t.boolean :ignored, null: false, default: false
30
+ end
31
+ connection.add_index :catpm_endpoint_prefs, [:kind, :target, :operation],
32
+ unique: true, name: 'idx_catpm_endpoint_prefs_unique'
33
+ puts '[catpm] Created catpm_endpoint_prefs table'
34
+ else
35
+ puts '[catpm] catpm_endpoint_prefs table already exists, skipping'
36
+ end
37
+
38
+ unless connection.column_exists?(:catpm_errors, :pinned)
39
+ connection.add_column :catpm_errors, :pinned, :boolean, null: false, default: false
40
+ puts '[catpm] Added pinned column to catpm_errors'
41
+ else
42
+ puts '[catpm] catpm_errors.pinned already exists, skipping'
43
+ end
44
+
45
+ unless connection.table_exists?(:catpm_event_prefs)
46
+ connection.create_table :catpm_event_prefs do |t|
47
+ t.string :name, null: false
48
+ t.boolean :pinned, null: false, default: false
49
+ t.boolean :ignored, null: false, default: false
50
+ end
51
+ connection.add_index :catpm_event_prefs, :name,
52
+ unique: true, name: 'idx_catpm_event_prefs_unique'
53
+ puts '[catpm] Created catpm_event_prefs table'
54
+ else
55
+ puts '[catpm] catpm_event_prefs table already exists, skipping'
56
+ end
22
57
  end
23
58
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: catpm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -33,6 +33,9 @@ files:
33
33
  - MIT-LICENSE
34
34
  - README.md
35
35
  - Rakefile
36
+ - app/assets/images/catpm/favicon-32.png
37
+ - app/assets/images/catpm/favicon-512.png
38
+ - app/assets/images/catpm/favicon.ico
36
39
  - app/assets/stylesheets/catpm/application.css
37
40
  - app/controllers/catpm/application_controller.rb
38
41
  - app/controllers/catpm/endpoints_controller.rb
@@ -46,14 +49,18 @@ files:
46
49
  - app/mailers/catpm/application_mailer.rb
47
50
  - app/models/catpm/application_record.rb
48
51
  - app/models/catpm/bucket.rb
52
+ - app/models/catpm/endpoint_pref.rb
49
53
  - app/models/catpm/error_record.rb
50
54
  - app/models/catpm/event_bucket.rb
55
+ - app/models/catpm/event_pref.rb
51
56
  - app/models/catpm/event_sample.rb
52
57
  - app/models/catpm/sample.rb
53
58
  - app/views/catpm/endpoints/_sample_table.html.erb
59
+ - app/views/catpm/endpoints/ignored.html.erb
54
60
  - app/views/catpm/endpoints/show.html.erb
55
61
  - app/views/catpm/errors/index.html.erb
56
62
  - app/views/catpm/errors/show.html.erb
63
+ - app/views/catpm/events/ignored.html.erb
57
64
  - app/views/catpm/events/index.html.erb
58
65
  - app/views/catpm/events/show.html.erb
59
66
  - app/views/catpm/samples/show.html.erb