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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +222 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/catpm/application.css +15 -0
- data/app/controllers/catpm/application_controller.rb +6 -0
- data/app/controllers/catpm/endpoints_controller.rb +63 -0
- data/app/controllers/catpm/errors_controller.rb +63 -0
- data/app/controllers/catpm/events_controller.rb +89 -0
- data/app/controllers/catpm/samples_controller.rb +13 -0
- data/app/controllers/catpm/status_controller.rb +79 -0
- data/app/controllers/catpm/system_controller.rb +17 -0
- data/app/helpers/catpm/application_helper.rb +264 -0
- data/app/jobs/catpm/application_job.rb +6 -0
- data/app/mailers/catpm/application_mailer.rb +8 -0
- data/app/models/catpm/application_record.rb +7 -0
- data/app/models/catpm/bucket.rb +45 -0
- data/app/models/catpm/error_record.rb +37 -0
- data/app/models/catpm/event_bucket.rb +12 -0
- data/app/models/catpm/event_sample.rb +22 -0
- data/app/models/catpm/sample.rb +26 -0
- data/app/views/catpm/endpoints/_sample_table.html.erb +36 -0
- data/app/views/catpm/endpoints/show.html.erb +124 -0
- data/app/views/catpm/errors/index.html.erb +66 -0
- data/app/views/catpm/errors/show.html.erb +107 -0
- data/app/views/catpm/events/index.html.erb +73 -0
- data/app/views/catpm/events/show.html.erb +86 -0
- data/app/views/catpm/samples/show.html.erb +113 -0
- data/app/views/catpm/shared/_page_nav.html.erb +6 -0
- data/app/views/catpm/shared/_segments_waterfall.html.erb +147 -0
- data/app/views/catpm/status/index.html.erb +124 -0
- data/app/views/catpm/system/index.html.erb +454 -0
- data/app/views/layouts/catpm/application.html.erb +381 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20250601000001_create_catpm_tables.rb +104 -0
- data/lib/catpm/adapter/base.rb +85 -0
- data/lib/catpm/adapter/postgresql.rb +186 -0
- data/lib/catpm/adapter/sqlite.rb +159 -0
- data/lib/catpm/adapter.rb +28 -0
- data/lib/catpm/auto_instrument.rb +145 -0
- data/lib/catpm/buffer.rb +59 -0
- data/lib/catpm/circuit_breaker.rb +60 -0
- data/lib/catpm/collector.rb +320 -0
- data/lib/catpm/configuration.rb +103 -0
- data/lib/catpm/custom_event.rb +37 -0
- data/lib/catpm/engine.rb +39 -0
- data/lib/catpm/errors.rb +6 -0
- data/lib/catpm/event.rb +75 -0
- data/lib/catpm/fingerprint.rb +52 -0
- data/lib/catpm/flusher.rb +462 -0
- data/lib/catpm/lifecycle.rb +76 -0
- data/lib/catpm/middleware.rb +75 -0
- data/lib/catpm/middleware_probe.rb +28 -0
- data/lib/catpm/patches/httpclient.rb +44 -0
- data/lib/catpm/patches/net_http.rb +39 -0
- data/lib/catpm/request_segments.rb +101 -0
- data/lib/catpm/segment_subscribers.rb +242 -0
- data/lib/catpm/span_helpers.rb +51 -0
- data/lib/catpm/stack_sampler.rb +226 -0
- data/lib/catpm/subscribers.rb +47 -0
- data/lib/catpm/tdigest.rb +174 -0
- data/lib/catpm/trace.rb +165 -0
- data/lib/catpm/version.rb +5 -0
- data/lib/catpm.rb +66 -0
- data/lib/generators/catpm/install_generator.rb +36 -0
- data/lib/generators/catpm/templates/initializer.rb.tt +77 -0
- data/lib/tasks/catpm_seed.rake +79 -0
- data/lib/tasks/catpm_tasks.rake +6 -0
- metadata +123 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<% content_for :title, @error.error_class %>
|
|
2
|
+
<% content_for :subtitle do %>
|
|
3
|
+
<%= type_badge(@error.kind) %>
|
|
4
|
+
<%= @error.error_class %>
|
|
5
|
+
· <%= @error.occurrences_count %> occurrences
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
<%= render "catpm/shared/page_nav", active: "errors" %>
|
|
9
|
+
|
|
10
|
+
<div class="breadcrumbs">
|
|
11
|
+
<a href="<%= catpm.errors_path(tab: @error.resolved? ? "resolved" : "active") %>">Errors</a>
|
|
12
|
+
<span class="sep">/</span>
|
|
13
|
+
<span><%= @error.error_class %></span>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<%# ─── Hero ─── %>
|
|
17
|
+
<div class="error-hero">
|
|
18
|
+
<div style="display:flex; justify-content:space-between; align-items:flex-start; flex-wrap:wrap; gap:12px">
|
|
19
|
+
<div style="flex:1; min-width:0">
|
|
20
|
+
<div class="error-hero-class"><%= @error.error_class %></div>
|
|
21
|
+
<div class="error-hero-message"><%= @error.message %></div>
|
|
22
|
+
<div class="error-hero-meta">
|
|
23
|
+
<%= type_badge(@error.kind) %>
|
|
24
|
+
<%= status_dot(@error.resolved?) %>
|
|
25
|
+
<span class="occurrence-count"><%= @error.occurrences_count %> occurrences</span>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<div style="display:flex; gap:6px; flex-shrink:0">
|
|
29
|
+
<% if @error.resolved? %>
|
|
30
|
+
<%= button_to "Reopen", catpm.unresolve_error_path(@error), method: :patch, class: "btn" %>
|
|
31
|
+
<% else %>
|
|
32
|
+
<%= button_to "Resolve", catpm.resolve_error_path(@error), method: :patch, class: "btn btn-primary" %>
|
|
33
|
+
<% end %>
|
|
34
|
+
<%= button_to "Delete", catpm.error_path(@error), method: :delete, class: "btn btn-danger", data: { confirm: "Delete this error and all its occurrences?" } %>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<%# ─── Timeline ─── %>
|
|
40
|
+
<div class="grid">
|
|
41
|
+
<div class="card">
|
|
42
|
+
<div class="label">First Seen</div>
|
|
43
|
+
<div class="value" style="font-size:16px"><%= time_with_tooltip(@error.first_occurred_at) %></div>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="card">
|
|
46
|
+
<div class="label">Last Seen</div>
|
|
47
|
+
<div class="value" style="font-size:16px"><%= time_with_tooltip(@error.last_occurred_at) %></div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<%# ─── Backtrace ─── %>
|
|
52
|
+
<% first_bt = @contexts.first && (@contexts.first["backtrace"] || @contexts.first[:backtrace] || []) %>
|
|
53
|
+
<% if first_bt.any? %>
|
|
54
|
+
<h2>Backtrace</h2>
|
|
55
|
+
<%= section_description("All occurrences share the same fingerprint and backtrace.") %>
|
|
56
|
+
<div style="border:1px solid var(--border); border-radius:var(--radius); padding:14px; margin-bottom:12px; position:relative">
|
|
57
|
+
<button class="copy-btn" style="position:absolute; top:8px; right:8px" onclick="copyText(this)">Copy</button>
|
|
58
|
+
<pre class="mono" style="font-size:12px; white-space:pre-wrap; margin:0; line-height:1.8"><% first_bt.first(20).each do |line| %><span class="<%= line.match?(%r{/(gems|ruby|vendor|bundle)/}) ? 'backtrace-lib' : 'backtrace-app' %>"><%= line %></span>
|
|
59
|
+
<% end %></pre>
|
|
60
|
+
</div>
|
|
61
|
+
<% end %>
|
|
62
|
+
|
|
63
|
+
<%# ─── Fingerprint ─── %>
|
|
64
|
+
<div style="margin-bottom:20px; font-size:12px; color:var(--text-2)">
|
|
65
|
+
<span style="font-weight:500; color:var(--text-1)">Fingerprint:</span>
|
|
66
|
+
<span class="mono" style="word-break:break-all"><%= @error.fingerprint %></span>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<%# ─── Occurrences ─── %>
|
|
70
|
+
<% if @contexts.any? %>
|
|
71
|
+
<h2>Last <%= @contexts.size %> Captured Occurrences</h2>
|
|
72
|
+
<div class="table-scroll">
|
|
73
|
+
<table>
|
|
74
|
+
<thead>
|
|
75
|
+
<tr>
|
|
76
|
+
<th>#</th>
|
|
77
|
+
<th>Time</th>
|
|
78
|
+
<th>Duration</th>
|
|
79
|
+
<th>Status</th>
|
|
80
|
+
<th>Target</th>
|
|
81
|
+
<th>Segments</th>
|
|
82
|
+
</tr>
|
|
83
|
+
</thead>
|
|
84
|
+
<tbody>
|
|
85
|
+
<% @contexts.each_with_index do |ctx, i| %>
|
|
86
|
+
<% segments = ctx["segments"] || ctx[:segments] || [] %>
|
|
87
|
+
<% has_detail = segments.any? %>
|
|
88
|
+
<tr id="occurrence-<%= i + 1 %>" class="<%= 'expandable' if has_detail %>" data-occurrence="<%= i %>" <% if has_detail %>onclick="toggleOccurrence(this)"<% end %>>
|
|
89
|
+
<td class="mono text-muted"><% if has_detail %><span class="chevron">▸</span> <% end %>#<%= i + 1 %></td>
|
|
90
|
+
<td><%= time_with_tooltip(ctx["occurred_at"] || ctx[:occurred_at]) %></td>
|
|
91
|
+
<td class="mono"><%= (ctx["duration"] || ctx[:duration]) ? format_duration((ctx["duration"] || ctx[:duration]).to_f) : "—" %></td>
|
|
92
|
+
<td><%= status_badge(ctx["status"] || ctx[:status]) %></td>
|
|
93
|
+
<td class="mono"><%= ctx["target"] || ctx[:target] || "—" %></td>
|
|
94
|
+
<td class="mono text-muted"><%= segment_count_summary(ctx["segment_summary"] || ctx[:segment_summary]).presence || "—" %></td>
|
|
95
|
+
</tr>
|
|
96
|
+
<% if has_detail %>
|
|
97
|
+
<tr id="detail-<%= i %>" style="display:none">
|
|
98
|
+
<td colspan="6" style="padding:14px; background:var(--bg-1)">
|
|
99
|
+
<%= render "catpm/shared/segments_waterfall", segments: segments, total_duration: (ctx["duration"] || ctx[:duration] || 1), segments_capped: ctx["segments_capped"] || ctx[:segments_capped], table_id: "segments-table-#{i}" %>
|
|
100
|
+
</td>
|
|
101
|
+
</tr>
|
|
102
|
+
<% end %>
|
|
103
|
+
<% end %>
|
|
104
|
+
</tbody>
|
|
105
|
+
</table>
|
|
106
|
+
</div>
|
|
107
|
+
<% end %>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<% content_for :title, "Events" %>
|
|
2
|
+
<% content_for :subtitle, "Custom Event Tracking" %>
|
|
3
|
+
|
|
4
|
+
<%= render "catpm/shared/page_nav", active: "events" %>
|
|
5
|
+
|
|
6
|
+
<%# ─── Time Range ─── %>
|
|
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>
|
|
10
|
+
<% end %>
|
|
11
|
+
<span style="margin-left:auto; font-size:12px; color:var(--text-2)">
|
|
12
|
+
Updated <%= Time.current.strftime("%H:%M:%S") %>
|
|
13
|
+
</span>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<%# ─── Hero Metrics ─── %>
|
|
17
|
+
<div class="grid-hero">
|
|
18
|
+
<div class="card">
|
|
19
|
+
<div class="label">Total Events</div>
|
|
20
|
+
<div class="value"><%= @total_events %></div>
|
|
21
|
+
<div class="detail"><%= range_label(@range) %></div>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="card">
|
|
24
|
+
<div class="label">Unique Names</div>
|
|
25
|
+
<div class="value"><%= @unique_names %></div>
|
|
26
|
+
<div class="detail">distinct event types</div>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="card">
|
|
29
|
+
<div class="label">Events / min</div>
|
|
30
|
+
<div class="value"><%= @events_per_min %></div>
|
|
31
|
+
<div class="detail"><%= range_label(@range) %></div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<%# ─── Events Table ─── %>
|
|
36
|
+
<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
|
+
|
|
39
|
+
<% if @events.any? %>
|
|
40
|
+
<div class="table-scroll">
|
|
41
|
+
<table>
|
|
42
|
+
<thead>
|
|
43
|
+
<tr>
|
|
44
|
+
<th><%= sort_header("Name", "name", @sort, @dir, extra_params: { range: @range }) %></th>
|
|
45
|
+
<th><%= sort_header("Count", "total_count", @sort, @dir, extra_params: { range: @range }) %></th>
|
|
46
|
+
<th>Trend</th>
|
|
47
|
+
<th><%= sort_header("Last Seen", "last_seen", @sort, @dir, extra_params: { range: @range }) %></th>
|
|
48
|
+
</tr>
|
|
49
|
+
</thead>
|
|
50
|
+
<tbody>
|
|
51
|
+
<% @events.each do |ev| %>
|
|
52
|
+
<tr class="linked">
|
|
53
|
+
<td>
|
|
54
|
+
<a href="<%= catpm.event_path(name: ev[:name], range: @range) %>" class="row-link">
|
|
55
|
+
<span class="badge badge-event"><%= ev[:name] %></span>
|
|
56
|
+
</a>
|
|
57
|
+
</td>
|
|
58
|
+
<td><%= ev[:total_count] %></td>
|
|
59
|
+
<td><%= sparkline_svg(ev[:sparkline], width: 120, height: 32, color: "var(--accent)", time_labels: @sparkline_times) %></td>
|
|
60
|
+
<td><%= time_with_tooltip(ev[:last_seen]) %></td>
|
|
61
|
+
</tr>
|
|
62
|
+
<% end %>
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
<% ep_extra = { range: @range, sort: @sort, dir: @dir } %>
|
|
67
|
+
<%= pagination_nav(@page, @total_event_names, Catpm::EventsController::PER_PAGE, extra_params: ep_extra) %>
|
|
68
|
+
<% else %>
|
|
69
|
+
<div class="empty-state">
|
|
70
|
+
<div class="empty-title">No events tracked yet</div>
|
|
71
|
+
<div class="empty-hint">Use <code>Catpm.event("name")</code> to start tracking custom events.</div>
|
|
72
|
+
</div>
|
|
73
|
+
<% end %>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<% content_for :title, @name %>
|
|
2
|
+
<% content_for :subtitle, "Event Detail" %>
|
|
3
|
+
|
|
4
|
+
<%= render "catpm/shared/page_nav", active: "events" %>
|
|
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>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<%# ─── Time Range ─── %>
|
|
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>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<%# ─── Hero Metrics ─── %>
|
|
20
|
+
<div class="grid-hero">
|
|
21
|
+
<div class="card">
|
|
22
|
+
<div class="label">Total Count</div>
|
|
23
|
+
<div class="value"><%= @total_count %></div>
|
|
24
|
+
<div class="detail"><%= range_label(@range) %></div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="card">
|
|
27
|
+
<div class="label">Events / min</div>
|
|
28
|
+
<div class="value"><%= @events_per_min %></div>
|
|
29
|
+
<div class="detail"><%= range_label(@range) %></div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="card">
|
|
32
|
+
<div class="label">Last Seen</div>
|
|
33
|
+
<div class="value" style="font-size:18px"><%= @last_seen ? time_with_tooltip(@last_seen) : "—" %></div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
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>
|
|
43
|
+
|
|
44
|
+
<%# ─── Recent Samples ─── %>
|
|
45
|
+
<h2>Recent Samples</h2>
|
|
46
|
+
<%= section_description("Most recent event payloads captured.") %>
|
|
47
|
+
|
|
48
|
+
<% if @samples.any? %>
|
|
49
|
+
<table style="table-layout:fixed">
|
|
50
|
+
<colgroup>
|
|
51
|
+
<col style="width:120px">
|
|
52
|
+
<col>
|
|
53
|
+
</colgroup>
|
|
54
|
+
<thead>
|
|
55
|
+
<tr>
|
|
56
|
+
<th>Recorded At</th>
|
|
57
|
+
<th>Payload</th>
|
|
58
|
+
</tr>
|
|
59
|
+
</thead>
|
|
60
|
+
<tbody>
|
|
61
|
+
<% @samples.each do |sample| %>
|
|
62
|
+
<tr>
|
|
63
|
+
<td style="white-space:nowrap; vertical-align:top"><%= time_with_tooltip(sample.recorded_at) %></td>
|
|
64
|
+
<td style="vertical-align:top">
|
|
65
|
+
<% payload = sample.parsed_payload %>
|
|
66
|
+
<% if payload.any? %>
|
|
67
|
+
<details class="collapsible-compact">
|
|
68
|
+
<summary><%= payload.keys.first(3).join(", ") %><%= "..." if payload.keys.size > 3 %></summary>
|
|
69
|
+
<div class="details-body">
|
|
70
|
+
<pre style="font-size:12px; white-space:pre-wrap; word-break:break-all; margin:0"><%= JSON.pretty_generate(payload) %></pre>
|
|
71
|
+
</div>
|
|
72
|
+
</details>
|
|
73
|
+
<% else %>
|
|
74
|
+
<span class="text-muted">—</span>
|
|
75
|
+
<% end %>
|
|
76
|
+
</td>
|
|
77
|
+
</tr>
|
|
78
|
+
<% end %>
|
|
79
|
+
</tbody>
|
|
80
|
+
</table>
|
|
81
|
+
<% else %>
|
|
82
|
+
<div class="empty-state">
|
|
83
|
+
<div class="empty-title">No samples recorded</div>
|
|
84
|
+
<div class="empty-hint">Events without payloads don't generate samples.</div>
|
|
85
|
+
</div>
|
|
86
|
+
<% end %>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<% content_for :title, "Sample ##{@sample.id}" %>
|
|
2
|
+
<% content_for :subtitle do %>
|
|
3
|
+
<%= type_badge(@sample.sample_type) %>
|
|
4
|
+
<%= type_badge(@sample.kind) %>
|
|
5
|
+
<%= @bucket&.target %>
|
|
6
|
+
· <%= format_duration(@sample.duration) %>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<div class="breadcrumbs">
|
|
10
|
+
<a href="<%= catpm.status_index_path %>">Overview</a>
|
|
11
|
+
<% if @bucket %>
|
|
12
|
+
<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
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<%# ─── Request Info Bar ─── %>
|
|
20
|
+
<% method_val = @context["method"] || @context[:method] %>
|
|
21
|
+
<% path_val = @context["path"] || @context[:path] %>
|
|
22
|
+
<% status_val = @context["status"] || @context[:status] %>
|
|
23
|
+
<div class="request-bar">
|
|
24
|
+
<% if method_val %><strong class="mono"><%= method_val %></strong><% end %>
|
|
25
|
+
<% if path_val %><span class="mono"><%= path_val %></span><% end %>
|
|
26
|
+
<% if status_val %><%= status_badge(status_val) %><% end %>
|
|
27
|
+
<span class="sep">·</span>
|
|
28
|
+
<span class="mono"><%= format_duration(@sample.duration) %></span>
|
|
29
|
+
<span class="sep">·</span>
|
|
30
|
+
<span class="text-muted"><%= time_with_tooltip(@sample.recorded_at) %></span>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<%# ─── Request Context + Full JSON side by side ─── %>
|
|
34
|
+
<%
|
|
35
|
+
ctx_display = @context.except("segments", :segments, "segment_summary", :segment_summary, "segments_capped", :segments_capped, "backtrace", :backtrace)
|
|
36
|
+
ctx_flat = ctx_display.select { |_, v| !v.is_a?(Hash) && !v.is_a?(Array) }
|
|
37
|
+
ctx_nested = ctx_display.select { |_, v| v.is_a?(Hash) || v.is_a?(Array) }
|
|
38
|
+
%>
|
|
39
|
+
|
|
40
|
+
<div class="sample-layout">
|
|
41
|
+
<% if ctx_display.any? %>
|
|
42
|
+
<div class="sample-sidebar">
|
|
43
|
+
<h2 style="margin-top:0">Request Context</h2>
|
|
44
|
+
<% if ctx_flat.any? %>
|
|
45
|
+
<div class="context-grid" style="margin-bottom:12px">
|
|
46
|
+
<% ctx_flat.each do |k, v| %>
|
|
47
|
+
<div class="ctx-key"><%= k %></div>
|
|
48
|
+
<div class="ctx-val"><%= v.to_s.truncate(200) %></div>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
52
|
+
<% if ctx_nested.any? %>
|
|
53
|
+
<% ctx_nested.each do |k, v| %>
|
|
54
|
+
<details class="collapsible" open>
|
|
55
|
+
<summary><%= k %></summary>
|
|
56
|
+
<div class="details-body">
|
|
57
|
+
<pre class="mono" style="color:var(--text-1); white-space:pre-wrap; font-size:12px"><%= JSON.pretty_generate(v) rescue v.inspect %></pre>
|
|
58
|
+
</div>
|
|
59
|
+
</details>
|
|
60
|
+
<% end %>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
<% end %>
|
|
64
|
+
|
|
65
|
+
<div class="sample-main">
|
|
66
|
+
<%# ─── Full JSON ─── %>
|
|
67
|
+
<details class="collapsible" open>
|
|
68
|
+
<summary>Full JSON</summary>
|
|
69
|
+
<div class="details-body">
|
|
70
|
+
<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>
|
|
71
|
+
</div>
|
|
72
|
+
</details>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<%# ─── Time Breakdown (full width, above waterfall) ─── %>
|
|
77
|
+
<% if @summary.any? %>
|
|
78
|
+
<%
|
|
79
|
+
breakdown = segment_colors.filter_map { |type, color|
|
|
80
|
+
dur = (@summary["#{type}_duration"] || @summary[:"#{type}_duration"] || 0).to_f
|
|
81
|
+
count = (@summary["#{type}_count"] || @summary[:"#{type}_count"] || 0).to_i
|
|
82
|
+
next if dur <= 0
|
|
83
|
+
next if (type == "code" || type == "other") && count <= 1 && dur < 1.0
|
|
84
|
+
text_color = segment_text_colors[type] || "#4b5563"
|
|
85
|
+
[segment_labels[type] || type.capitalize, dur, color, count, text_color]
|
|
86
|
+
}
|
|
87
|
+
typed_total = breakdown.sum { |_, dur, _, _, _| dur }
|
|
88
|
+
other_dur = [@sample.duration - typed_total, 0].max
|
|
89
|
+
breakdown << ["Other", other_dur, "#dde2e8", nil, "#4b5563"] if other_dur > 0.5
|
|
90
|
+
total = @sample.duration
|
|
91
|
+
%>
|
|
92
|
+
|
|
93
|
+
<h2>Time Breakdown</h2>
|
|
94
|
+
<div style="display:flex; height:32px; border-radius:4px; overflow:hidden; margin-bottom:4px; border:1px solid var(--border)">
|
|
95
|
+
<% breakdown.each do |label, dur, color, _, text| %>
|
|
96
|
+
<% if dur > 0 && total > 0 %>
|
|
97
|
+
<div title="<%= label %> — <%= format_duration(dur) %> (<%= "%.0f" % (dur/total*100) %>%)" style="flex:<%= dur %>; background:<%= color %>; display:flex; align-items:center; justify-content:center; color:<%= text %>; font-size:11px; font-weight:500; overflow:hidden; white-space:nowrap; padding:0 4px;">
|
|
98
|
+
<% if dur / total > 0.08 %><%= label %><% end %>
|
|
99
|
+
</div>
|
|
100
|
+
<% end %>
|
|
101
|
+
<% end %>
|
|
102
|
+
</div>
|
|
103
|
+
<div style="display:flex; gap:14px; flex-wrap:wrap; margin-bottom:16px; font-size:12px; color:var(--text-1)">
|
|
104
|
+
<% breakdown.each do |label, dur, color, count, text| %>
|
|
105
|
+
<span><span style="display:inline-block;width:10px;height:10px;background:<%= color %>;border-radius:2px;margin-right:4px;border:1px solid <%= text %>22"></span><%= label %> <%= format_duration(dur) %> (<%= "%.0f" % (total > 0 ? dur/total*100 : 0) %>%)<%= " · #{count}" if count && count > 0 %></span>
|
|
106
|
+
<% end %>
|
|
107
|
+
</div>
|
|
108
|
+
<% end %>
|
|
109
|
+
|
|
110
|
+
<%# ─── Segments Waterfall (full width, no title) ─── %>
|
|
111
|
+
<% if @segments.any? %>
|
|
112
|
+
<%= render "catpm/shared/segments_waterfall", segments: @segments, total_duration: @sample.duration, segments_capped: @context["segments_capped"] || @context[:segments_capped], table_id: "segments-table" %>
|
|
113
|
+
<% end %>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<div class="page-nav">
|
|
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>
|
|
4
|
+
<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
|
+
<a href="<%= catpm.system_index_path %>"<%= ' class="active"'.html_safe if active == "system" %>>System</a>
|
|
6
|
+
</div>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<%# Locals: segments, total_duration, segments_capped, table_id %>
|
|
2
|
+
|
|
3
|
+
<% if segments.any? %>
|
|
4
|
+
<% total_dur = total_duration.to_f %>
|
|
5
|
+
<% total_dur = 1.0 if total_dur == 0 %>
|
|
6
|
+
|
|
7
|
+
<%
|
|
8
|
+
# Build tree: compute children lists and depth per segment
|
|
9
|
+
children = Hash.new { |h, k| h[k] = [] }
|
|
10
|
+
roots = []
|
|
11
|
+
segments.each_with_index do |seg, i|
|
|
12
|
+
pi = seg["parent_index"] || seg[:parent_index]
|
|
13
|
+
if pi.nil?
|
|
14
|
+
roots << i
|
|
15
|
+
else
|
|
16
|
+
children[pi.to_i] << i
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
depth_map = {}
|
|
21
|
+
ordered = []
|
|
22
|
+
build_order = ->(indices, depth) {
|
|
23
|
+
indices.each do |i|
|
|
24
|
+
depth_map[i] = depth
|
|
25
|
+
ordered << i
|
|
26
|
+
build_order.call(children.fetch(i, []), depth + 1)
|
|
27
|
+
end
|
|
28
|
+
}
|
|
29
|
+
build_order.call(roots, 0)
|
|
30
|
+
|
|
31
|
+
# Include any segments not reachable from roots (orphans from overflow)
|
|
32
|
+
segments.each_index { |i| unless depth_map.key?(i); depth_map[i] = 0; ordered << i; end }
|
|
33
|
+
|
|
34
|
+
# For tree lines: determine if a segment is the last child of its parent
|
|
35
|
+
last_child = {}
|
|
36
|
+
([nil] + segments.each_index.to_a).each do |pi|
|
|
37
|
+
kids = pi.nil? ? roots : children.fetch(pi, [])
|
|
38
|
+
last_child[kids.last] = true if kids.any?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Build tree prefix string for indentation
|
|
42
|
+
tree_prefix = ->(depth, index, current_pos) {
|
|
43
|
+
next "" if depth == 0
|
|
44
|
+
parts = []
|
|
45
|
+
(1...depth).each do |d|
|
|
46
|
+
has_more = false
|
|
47
|
+
((current_pos + 1)...ordered.size).each do |j|
|
|
48
|
+
jd = depth_map[ordered[j]]
|
|
49
|
+
break if jd < d
|
|
50
|
+
if jd == d
|
|
51
|
+
has_more = true
|
|
52
|
+
break
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
parts << (has_more ? "│ " : " ")
|
|
56
|
+
end
|
|
57
|
+
parts << (last_child[index] ? "└─ " : "├─ ")
|
|
58
|
+
parts.join
|
|
59
|
+
}
|
|
60
|
+
%>
|
|
61
|
+
|
|
62
|
+
<%
|
|
63
|
+
# Determine which segments are parents (have children)
|
|
64
|
+
has_children = Set.new
|
|
65
|
+
children.each { |pi, _kids| has_children << pi }
|
|
66
|
+
|
|
67
|
+
# Segments marked collapsed (sampler children — hidden by default)
|
|
68
|
+
collapsed_set = Set.new
|
|
69
|
+
starts_collapsed = Set.new
|
|
70
|
+
segments.each_with_index do |s, i|
|
|
71
|
+
if s["collapsed"] || s[:collapsed]
|
|
72
|
+
collapsed_set << i
|
|
73
|
+
pi = s["parent_index"] || s[:parent_index]
|
|
74
|
+
starts_collapsed << pi.to_i if pi
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
%>
|
|
78
|
+
|
|
79
|
+
<style>tr[data-collapsed] { display: none; }</style>
|
|
80
|
+
<div style="background:var(--bg-1); border:1px solid var(--border); border-radius:8px; overflow:hidden">
|
|
81
|
+
<table style="margin:0" id="<%= table_id %>">
|
|
82
|
+
<thead>
|
|
83
|
+
<tr>
|
|
84
|
+
<th style="width:55px">Type</th>
|
|
85
|
+
<th style="width:70px">Duration</th>
|
|
86
|
+
<th>Detail</th>
|
|
87
|
+
<th style="width:35%">Timeline</th>
|
|
88
|
+
</tr>
|
|
89
|
+
</thead>
|
|
90
|
+
<tbody>
|
|
91
|
+
<% ordered.each_with_index do |seg_idx, pos| %>
|
|
92
|
+
<% seg = segments[seg_idx] %>
|
|
93
|
+
<% type = (seg["type"] || seg[:type]).to_s %>
|
|
94
|
+
<% dur = (seg["duration"] || seg[:duration] || 0).to_f %>
|
|
95
|
+
<% offset = (seg["offset"] || seg[:offset] || 0).to_f %>
|
|
96
|
+
<% detail = (seg["detail"] || seg[:detail]).to_s %>
|
|
97
|
+
<% source = seg["source"] || seg[:source] %>
|
|
98
|
+
<% bar_color = segment_colors[type] || "#484f58" %>
|
|
99
|
+
<% left_pct = (offset / total_dur * 100).clamp(0, 99) %>
|
|
100
|
+
<% width_pct = (dur / total_dur * 100).clamp(0.5, 100 - left_pct) %>
|
|
101
|
+
<% depth = depth_map[seg_idx] %>
|
|
102
|
+
<% prefix = tree_prefix.call(depth, seg_idx, pos) %>
|
|
103
|
+
<% is_parent = has_children.include?(seg_idx) %>
|
|
104
|
+
<% pi = seg["parent_index"] || seg[:parent_index] %>
|
|
105
|
+
<% is_collapsed = collapsed_set.include?(seg_idx) %>
|
|
106
|
+
<% parent_starts_collapsed = starts_collapsed.include?(seg_idx) %>
|
|
107
|
+
|
|
108
|
+
<tr data-seg="<%= seg_idx %>" data-depth="<%= depth %>"<%= " data-parent=\"#{pi}\"" if pi %><%= " data-has-children" if is_parent %><%= " data-collapsed" if is_collapsed %>>
|
|
109
|
+
<td><%= type_badge(type) %></td>
|
|
110
|
+
<td class="mono" style="text-align:right"><%= "%.2f" % dur %>ms</td>
|
|
111
|
+
<td>
|
|
112
|
+
<div style="padding-left:<%= depth * 20 %>px">
|
|
113
|
+
<% if is_parent %>
|
|
114
|
+
<span class="seg-toggle" data-seg="<%= seg_idx %>" onclick="toggleSegment(this)" title="Collapse/Expand"><%= parent_starts_collapsed ? "▸" : "▾" %></span>
|
|
115
|
+
<% end %>
|
|
116
|
+
<% if depth > 0 %><span class="tree-indent"><%= prefix %></span><% end %>
|
|
117
|
+
<% if type == "sql" && detail.length > 60 %>
|
|
118
|
+
<div class="sql-wrap" onclick="event.stopPropagation()">
|
|
119
|
+
<span class="sql-toggle" onclick="var w=this.parentElement;w.classList.toggle('open');this.textContent=w.classList.contains('open')?'\u25BE':'\u25B8'">▸</span>
|
|
120
|
+
<span class="sql-text"><%= detail %></span>
|
|
121
|
+
</div>
|
|
122
|
+
<% elsif type == "sql" %>
|
|
123
|
+
<span class="sql-text"><%= detail %></span>
|
|
124
|
+
<% else %>
|
|
125
|
+
<span class="mono"><%= detail %></span>
|
|
126
|
+
<% end %>
|
|
127
|
+
<% if source %>
|
|
128
|
+
<div class="source"><%= source %></div>
|
|
129
|
+
<% end %>
|
|
130
|
+
</div>
|
|
131
|
+
</td>
|
|
132
|
+
<td>
|
|
133
|
+
<div class="bar-container">
|
|
134
|
+
<div class="bar-fill" style="margin-left:<%= left_pct %>%; width:<%= width_pct %>%; background:<%= bar_color %>"></div>
|
|
135
|
+
</div>
|
|
136
|
+
</td>
|
|
137
|
+
</tr>
|
|
138
|
+
<% end %>
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
</div>
|
|
142
|
+
<% else %>
|
|
143
|
+
<div class="empty-state">
|
|
144
|
+
<div class="empty-title">No segments captured</div>
|
|
145
|
+
<div class="empty-hint">Segments appear when SQL queries, view renders, or HTTP calls are instrumented.</div>
|
|
146
|
+
</div>
|
|
147
|
+
<% end %>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<% content_for :title, "Overview" %>
|
|
2
|
+
<% content_for :subtitle, "Application Performance Monitor" %>
|
|
3
|
+
|
|
4
|
+
<%= render "catpm/shared/page_nav", active: "performance" %>
|
|
5
|
+
|
|
6
|
+
<%# ─── Time Range ─── %>
|
|
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>
|
|
10
|
+
<% end %>
|
|
11
|
+
<span style="margin-left:auto; font-size:12px; color:var(--text-2)">
|
|
12
|
+
Updated <%= Time.current.strftime("%H:%M:%S") %> ·
|
|
13
|
+
<span id="refresh-status">Refreshing in <span id="refresh-countdown">30</span>s</span>
|
|
14
|
+
<a href="#" id="refresh-toggle" onclick="toggleAutoRefresh(event)" style="margin-left:4px; color:var(--text-2)">Pause</a>
|
|
15
|
+
</span>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<script>
|
|
19
|
+
(function() {
|
|
20
|
+
var seconds = 30, paused = false, timer;
|
|
21
|
+
var countdownEl = document.getElementById('refresh-countdown');
|
|
22
|
+
var statusEl = document.getElementById('refresh-status');
|
|
23
|
+
var toggleEl = document.getElementById('refresh-toggle');
|
|
24
|
+
function tick() {
|
|
25
|
+
if (paused) return;
|
|
26
|
+
seconds--;
|
|
27
|
+
if (seconds <= 0) { location.reload(); return; }
|
|
28
|
+
countdownEl.textContent = seconds;
|
|
29
|
+
timer = setTimeout(tick, 1000);
|
|
30
|
+
}
|
|
31
|
+
timer = setTimeout(tick, 1000);
|
|
32
|
+
window.toggleAutoRefresh = function(e) {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
paused = !paused;
|
|
35
|
+
if (paused) {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
statusEl.textContent = 'Auto-refresh paused';
|
|
38
|
+
toggleEl.textContent = 'Resume';
|
|
39
|
+
} else {
|
|
40
|
+
statusEl.innerHTML = 'Refreshing in <span id="refresh-countdown">' + seconds + '</span>s';
|
|
41
|
+
countdownEl = document.getElementById('refresh-countdown');
|
|
42
|
+
toggleEl.textContent = 'Pause';
|
|
43
|
+
timer = setTimeout(tick, 1000);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<%# ─── Hero Metrics ─── %>
|
|
50
|
+
<div class="grid-hero">
|
|
51
|
+
<div class="card">
|
|
52
|
+
<div class="label">Requests / min</div>
|
|
53
|
+
<div class="value"><%= @requests_per_min %></div>
|
|
54
|
+
<div class="detail"><%= @recent_count %> total · <%= range_label(@range) %></div>
|
|
55
|
+
<div class="sparkline"><%= sparkline_svg(@sparkline_requests, width: 200, height: 48, color: "var(--accent)", fill: true, time_labels: @sparkline_times) %></div>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="card">
|
|
58
|
+
<div class="label">Error Rate</div>
|
|
59
|
+
<div class="value"><%= @error_rate %>%</div>
|
|
60
|
+
<div class="detail"><%= @sparkline_errors.sum %> errors · <%= range_label(@range) %></div>
|
|
61
|
+
<div class="sparkline"><%= sparkline_svg(@sparkline_errors, width: 200, height: 48, color: "var(--red)", fill: true, time_labels: @sparkline_times) %></div>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="card">
|
|
64
|
+
<div class="label">Avg Response Time</div>
|
|
65
|
+
<div class="value"><%= format_duration(@recent_avg_duration) %></div>
|
|
66
|
+
<div class="detail"><%= @total_endpoint_count %> endpoints tracked</div>
|
|
67
|
+
<div class="sparkline"><%= sparkline_svg(@sparkline_durations, width: 200, height: 48, color: "var(--accent)", fill: true, labels: @sparkline_durations.map { |d| format_duration(d) }, time_labels: @sparkline_times) %></div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<%# ─── Endpoints ─── %>
|
|
72
|
+
<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
|
+
<% ep_extra = { range: @range }; ep_extra[:kind] = @kind_filter if @kind_filter %>
|
|
75
|
+
<% if @endpoints.any? || @kind_filter.present? %>
|
|
76
|
+
<div class="filters">
|
|
77
|
+
<% @available_kinds.each do |k| %>
|
|
78
|
+
<a href="<%= catpm.status_index_path(range: @range, kind: @kind_filter == k ? nil : k) %>" class="filter-pill<%= ' active' if @kind_filter == k %>"><%= k %></a>
|
|
79
|
+
<% end %>
|
|
80
|
+
</div>
|
|
81
|
+
<% end %>
|
|
82
|
+
<% if @endpoints.any? %>
|
|
83
|
+
<div class="table-scroll">
|
|
84
|
+
<table id="endpoints-table">
|
|
85
|
+
<thead>
|
|
86
|
+
<tr>
|
|
87
|
+
<th>Kind</th>
|
|
88
|
+
<th><%= sort_header("Target", "target", @sort, @dir, extra_params: ep_extra) %></th>
|
|
89
|
+
<th><%= sort_header("Count", "total_count", @sort, @dir, extra_params: ep_extra) %></th>
|
|
90
|
+
<th><%= sort_header("Avg", "avg_duration", @sort, @dir, extra_params: ep_extra) %></th>
|
|
91
|
+
<th><%= sort_header("Max", "max_duration", @sort, @dir, extra_params: ep_extra) %></th>
|
|
92
|
+
<th><%= sort_header("Fail", "total_failures", @sort, @dir, extra_params: ep_extra) %></th>
|
|
93
|
+
<th><%= sort_header("Last Seen", "last_seen", @sort, @dir, extra_params: ep_extra) %></th>
|
|
94
|
+
</tr>
|
|
95
|
+
</thead>
|
|
96
|
+
<tbody>
|
|
97
|
+
<% @endpoints.each do |ep| %>
|
|
98
|
+
<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>
|
|
100
|
+
<td class="mono"><%= ep[:target] %><%= " #{ep[:operation]}" if ep[:operation].present? %></td>
|
|
101
|
+
<td><%= ep[:total_count] %></td>
|
|
102
|
+
<td class="mono"><%= format_duration(ep[:avg_duration]) %></td>
|
|
103
|
+
<td class="mono"><%= format_duration(ep[:max_duration]) %></td>
|
|
104
|
+
<td><%= ep[:total_failures] %></td>
|
|
105
|
+
<td><%= time_with_tooltip(ep[:last_seen]) %></td>
|
|
106
|
+
</tr>
|
|
107
|
+
<% end %>
|
|
108
|
+
</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
</div>
|
|
111
|
+
<% ep_page_extra = { range: @range, sort: @sort, dir: @dir }; ep_page_extra[:kind] = @kind_filter if @kind_filter %>
|
|
112
|
+
<%= pagination_nav(@page, @total_endpoint_count, Catpm::StatusController::PER_PAGE, extra_params: ep_page_extra) %>
|
|
113
|
+
<% else %>
|
|
114
|
+
<div class="empty-state">
|
|
115
|
+
<% if @kind_filter %>
|
|
116
|
+
<div class="empty-title">No matching endpoints</div>
|
|
117
|
+
<div class="empty-hint">No <strong><%= @kind_filter %></strong> endpoints found in this time range.</div>
|
|
118
|
+
<% else %>
|
|
119
|
+
<div class="empty-title">No endpoints tracked yet</div>
|
|
120
|
+
<div class="empty-hint">Make some requests to your application and they'll appear here after the next flush.</div>
|
|
121
|
+
<% end %>
|
|
122
|
+
</div>
|
|
123
|
+
<% end %>
|
|
124
|
+
|