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,381 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>catpm<%= " — #{yield :title}" if content_for?(:title) %></title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= yield :head_extra if content_for?(:head_extra) %>
7
+ <style>
8
+ /* ─── Design Tokens — Light Theme ─── */
9
+ :root {
10
+ --bg-0: #ffffff;
11
+ --bg-1: #f6f8fa;
12
+ --bg-2: #eaeef2;
13
+ --border: #d0d7de;
14
+ --text-0: #1f2328;
15
+ --text-1: #656d76;
16
+ --text-2: #8b949e;
17
+ --accent: #0969da;
18
+ --red: #cf222e;
19
+ --green: #1a7f37;
20
+ --yellow: #9a6700;
21
+ --purple: #8250df;
22
+ --orange: #bc4c00;
23
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
24
+ --font-mono: "SF Mono", "Fira Code", Consolas, "Liberation Mono", Menlo, monospace;
25
+ --radius: 6px;
26
+ }
27
+
28
+ /* ─── Reset & Base ─── */
29
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
30
+ body { font-family: var(--font-sans); background: var(--bg-0); color: var(--text-0); padding: 24px 28px; line-height: 1.6; font-size: 14px; max-width: 1200px; margin: 0 auto; }
31
+ a { color: var(--accent); text-decoration: none; }
32
+ a:hover { text-decoration: underline; }
33
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 2px; }
34
+ h1 a { color: var(--text-0); }
35
+ h1 a:hover { color: var(--accent); text-decoration: none; }
36
+ h2 { font-size: 15px; font-weight: 600; color: var(--text-0); margin: 28px 0 8px; }
37
+ h3 { font-size: 13px; font-weight: 600; color: var(--text-1); margin: 16px 0 8px; }
38
+
39
+ /* ─── Header ─── */
40
+ .header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 4px; }
41
+ .header-sub { color: var(--text-1); font-size: 13px; margin-bottom: 16px; }
42
+
43
+ /* ─── Page Nav (dashboard only) ─── */
44
+ .page-nav { display: flex; gap: 2px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
45
+ .page-nav a { display: inline-block; padding: 8px 14px; font-size: 13px; font-weight: 500; color: var(--text-1); border-bottom: 2px solid transparent; margin-bottom: -1px; }
46
+ .page-nav a:hover { color: var(--text-0); text-decoration: none; }
47
+ .page-nav a.active { color: var(--text-0); border-bottom-color: var(--accent); }
48
+ .nav-count { display: inline-block; background: var(--bg-2); color: var(--text-1); font-size: 11px; font-weight: 600; padding: 0 5px; border-radius: 8px; margin-left: 4px; line-height: 16px; }
49
+ .nav-count.alert { background: #ffebe9; color: var(--red); }
50
+
51
+ /* ─── Flash Messages ─── */
52
+ .flash { padding: 10px 14px; border-radius: var(--radius); font-size: 13px; margin-bottom: 16px; }
53
+ .flash-notice { background: #dafbe1; color: var(--green); border: 1px solid #aceebb; }
54
+ .flash-alert { background: #ffebe9; color: var(--red); border: 1px solid #ffcecb; }
55
+
56
+ /* ─── Breadcrumbs ─── */
57
+ .breadcrumbs { font-size: 13px; color: var(--text-2); margin-bottom: 16px; }
58
+ .breadcrumbs a { color: var(--text-1); }
59
+ .breadcrumbs a:hover { color: var(--accent); }
60
+ .breadcrumbs .sep { margin: 0 6px; }
61
+
62
+ /* ─── Tabs (underline) ─── */
63
+ .tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
64
+ .tab { display: inline-block; padding: 8px 16px; color: var(--text-1); font-size: 14px; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; }
65
+ .tab:hover { color: var(--text-0); text-decoration: none; }
66
+ .tab.active { color: var(--text-0); border-bottom-color: var(--accent); }
67
+
68
+ /* ─── Time Range ─── */
69
+ .time-range { display: flex; gap: 2px; margin-bottom: 20px; align-items: center; }
70
+ .time-range a { padding: 4px 10px; font-size: 12px; color: var(--text-1); border-radius: 4px; }
71
+ .time-range a:hover { background: var(--bg-2); color: var(--text-0); text-decoration: none; }
72
+ .time-range a.active { background: var(--bg-2); color: var(--text-0); font-weight: 500; }
73
+
74
+ /* ─── Grid & Cards ─── */
75
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 20px; }
76
+ .grid-hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 24px; }
77
+ .card { background: var(--bg-0); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
78
+ .card .label { color: var(--text-1); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; }
79
+ .card .value { color: var(--text-0); font-size: 26px; font-weight: 600; margin-top: 2px; line-height: 1.2; }
80
+ .card .detail { color: var(--text-2); font-size: 12px; margin-top: 4px; }
81
+ .card .sparkline { margin-top: 8px; }
82
+
83
+ /* ─── Error Cards (dashboard) ─── */
84
+ .error-card { display: block; background: #fffbfa; border: 1px solid #ffcecb; border-left: 3px solid var(--red); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 6px; color: inherit; }
85
+ .error-card:hover { background: #ffebe9; text-decoration: none; }
86
+ .error-card .error-card-title { font-size: 14px; font-weight: 600; color: var(--text-0); }
87
+ .error-card .error-card-message { font-size: 13px; color: var(--text-1); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
88
+ .error-card .error-card-meta { font-size: 12px; color: var(--text-2); margin-top: 8px; display: flex; gap: 12px; align-items: center; }
89
+
90
+ /* ─── Tables ─── */
91
+ table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
92
+ thead th { color: var(--text-1); text-align: left; padding: 6px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; border-bottom: 1px solid var(--border); }
93
+ tbody td { padding: 8px 12px; font-size: 14px; border-bottom: 1px solid var(--bg-2); }
94
+ tbody tr:last-child td { border-bottom: none; }
95
+ tr.linked { cursor: pointer; }
96
+ tr.linked:hover td { background: var(--bg-1); }
97
+ tr.linked { position: relative; }
98
+ tr.linked a.row-link { color: inherit; text-decoration: none; }
99
+ tr.linked a.row-link::after { content: ""; position: absolute; inset: 0; }
100
+ tr.expandable { cursor: pointer; }
101
+ tr.expandable:hover td { background: var(--bg-1); }
102
+ tr.expandable .chevron { display: inline-block; width: 12px; color: var(--text-2); font-size: 10px; transition: transform 0.15s; }
103
+ tr.expandable.open .chevron { transform: rotate(90deg); }
104
+
105
+ /* ─── Sort Links ─── */
106
+ th .sort-link { color: var(--text-1); text-decoration: none; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; font-weight: 500; }
107
+ th .sort-link:hover { color: var(--text-0); text-decoration: none; }
108
+ th .sort-link.active { color: var(--accent); }
109
+
110
+ /* ─── Filters ─── */
111
+ .filters { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
112
+ .filter-pill { display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; color: var(--text-1); background: transparent; border: 1px solid var(--border); }
113
+ .filter-pill:hover { background: var(--bg-2); color: var(--text-0); text-decoration: none; }
114
+ .filter-pill.active { background: var(--bg-2); color: var(--text-0); border-color: var(--text-1); }
115
+ .search-input { background: var(--bg-0); border: 1px solid var(--border); border-radius: 4px; padding: 5px 10px; color: var(--text-0); font-size: 13px; outline: none; min-width: 180px; }
116
+ .search-input:focus { border-color: var(--accent); }
117
+ .search-input::placeholder { color: var(--text-2); }
118
+
119
+ /* ─── Badges — pastel type colors ─── */
120
+ .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 500; background: var(--bg-2); color: var(--text-1); }
121
+ .badge-http { background: #ddf4ff; color: #0969da; }
122
+ .badge-job { background: #fff8c5; color: #9a6700; }
123
+ .badge-custom { background: #eaeef2; color: #656d76; }
124
+ .badge-sql { background: #dafbe1; color: #1a7f37; }
125
+ .badge-view { background: #fbefff; color: #8250df; }
126
+ .badge-cache { background: #fff1e5; color: #bc4c00; }
127
+ .badge-mailer { background: #fbefff; color: #8250df; }
128
+ .badge-storage { background: #fff1e5; color: #bc4c00; }
129
+ .badge-ok { background: #dafbe1; color: var(--green); }
130
+ .badge-warn { background: #fff8c5; color: var(--yellow); }
131
+ .badge-err { background: #ffebe9; color: var(--red); }
132
+ .badge-error { background: #ffebe9; color: var(--red); }
133
+ .badge-slow { background: #fff8c5; color: var(--yellow); }
134
+ .badge-random { background: var(--bg-2); color: var(--text-1); }
135
+ .badge-event { background: #e8f0fe; color: #1967d2; }
136
+
137
+ /* ─── Status Dot ─── */
138
+ .status-dot { font-size: 13px; color: var(--text-1); display: inline-flex; align-items: center; gap: 6px; }
139
+ .status-dot .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
140
+
141
+ /* ─── Typography ─── */
142
+ .mono { font-family: var(--font-mono); font-size: 13px; }
143
+ .text-muted { color: var(--text-2); }
144
+ .section-desc { color: var(--text-2); font-size: 13px; margin: -4px 0 12px; font-weight: 400; }
145
+ .time-rel { cursor: default; }
146
+
147
+ /* ─── Request Info Bar ─── */
148
+ .request-bar { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 16px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 14px; }
149
+ .request-bar .mono { font-size: 14px; }
150
+ .request-bar .sep { color: var(--text-2); }
151
+
152
+ /* ─── Context Section ─── */
153
+ .context-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 16px; font-size: 13px; }
154
+ .context-grid .ctx-key { color: var(--text-1); font-weight: 500; white-space: nowrap; }
155
+ .context-grid .ctx-val { color: var(--text-0); font-family: var(--font-mono); font-size: 12px; word-break: break-all; overflow-wrap: anywhere; }
156
+ .sample-layout { display: flex; gap: 20px; align-items: flex-start; }
157
+ .sample-main { flex: 1; min-width: 0; }
158
+ .sample-sidebar { flex: 0 0 320px; }
159
+ @media (max-width: 900px) { .sample-layout { flex-direction: column; } .sample-sidebar { flex: auto; width: 100%; } }
160
+
161
+ /* ─── Config Table ─── */
162
+ .config-table { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
163
+ .config-table table { margin: 0; }
164
+ .config-table td { font-size: 13px; }
165
+ .config-table td:first-child { font-weight: 500; color: var(--text-0); }
166
+ .config-table tbody tr:nth-child(even) td { background: var(--bg-0); }
167
+
168
+ /* ─── Logo ─── */
169
+ .logo-cat { display: inline-block; font-family: var(--font-mono); font-size: 10px; line-height: 1.2; color: var(--text-2); white-space: pre; vertical-align: middle; margin-right: 8px; transition: color 0.15s; }
170
+ h1 a:hover .logo-cat { color: var(--accent); }
171
+
172
+ /* ─── Empty State ─── */
173
+ .empty-state { text-align: center; padding: 32px 24px; color: var(--text-2); }
174
+ .empty-state .empty-title { font-size: 14px; color: var(--text-1); margin-bottom: 4px; }
175
+ .empty-state .empty-hint { font-size: 13px; }
176
+
177
+ /* ─── Pagination ─── */
178
+ .pagination { display: flex; align-items: center; gap: 12px; margin: 16px 0; }
179
+ .pagination-info { font-size: 13px; color: var(--text-2); }
180
+
181
+ /* ─── Code / Backtrace ─── */
182
+ .source { color: var(--green); font-size: 12px; font-family: var(--font-mono); }
183
+ .sql-text { color: var(--accent); font-size: 12px; font-family: var(--font-mono); white-space: pre-wrap; word-break: break-all; }
184
+ .sql-wrap { display: flex; align-items: flex-start; gap: 2px; }
185
+ .sql-wrap .sql-text { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; }
186
+ .sql-wrap.open .sql-text { display: block; -webkit-line-clamp: unset; overflow: visible; white-space: pre-wrap; }
187
+ .sql-toggle { flex-shrink: 0; color: var(--text-2); font-size: 11px; cursor: pointer; padding: 0 4px; border-radius: 3px; user-select: none; line-height: 1.6; }
188
+ .sql-toggle:hover { color: var(--text-0); background: var(--bg-2); }
189
+ .backtrace-app { color: var(--text-0); font-weight: 500; }
190
+ .backtrace-lib { color: var(--text-2); }
191
+ .kv { display: inline-block; background: var(--bg-1); border: 1px solid var(--border); border-radius: 3px; padding: 2px 8px; margin: 2px; font-size: 13px; }
192
+ .kv .k { color: var(--text-1); }
193
+ .kv .v { color: var(--text-0); }
194
+
195
+ /* ─── Buttons ─── */
196
+ .btn { display: inline-block; padding: 5px 12px; border-radius: var(--radius); font-size: 13px; font-weight: 500; border: 1px solid var(--border); cursor: pointer; background: var(--bg-0); color: var(--text-1); }
197
+ .btn:hover { background: var(--bg-1); color: var(--text-0); text-decoration: none; }
198
+ .btn-primary { background: var(--bg-2); color: var(--text-0); border-color: var(--border); }
199
+ .btn-primary:hover { background: var(--border); }
200
+ .btn-danger { background: var(--bg-0); color: var(--text-2); border-color: var(--border); }
201
+ .btn-danger:hover { background: #ffebe9; color: var(--red); border-color: #ffcecb; }
202
+ .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
+ .copy-btn:hover { color: var(--text-0); background: var(--bg-2); }
204
+
205
+ /* ─── Collapsible ─── */
206
+ details.collapsible { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 12px; }
207
+ details.collapsible summary { padding: 10px 14px 10px 28px; cursor: pointer; color: var(--text-1); font-size: 13px; list-style: none; user-select: none; position: relative; }
208
+ details.collapsible summary::-webkit-details-marker { display: none; }
209
+ details.collapsible summary::marker { display: none; content: ""; }
210
+ details.collapsible summary::before { content: "\25B8"; color: var(--text-2); position: absolute; left: 12px; top: 10px; }
211
+ details.collapsible[open] summary::before { content: "\25BE"; }
212
+ details.collapsible .details-body { padding: 0 14px 14px; }
213
+ details.collapsible-compact { border: none; margin: 0; }
214
+ details.collapsible-compact summary { padding: 4px 4px 4px 16px; font-size: 13px; font-family: var(--font-mono); cursor: pointer; color: var(--text-1); list-style: none; user-select: none; position: relative; }
215
+ details.collapsible-compact summary::-webkit-details-marker { display: none; }
216
+ details.collapsible-compact summary::marker { display: none; content: ""; }
217
+ details.collapsible-compact summary::before { content: "\25B8"; color: var(--text-2); position: absolute; left: 2px; top: 5px; font-size: 10px; }
218
+ details.collapsible-compact[open] summary::before { content: "\25BE"; }
219
+ details.collapsible-compact .details-body { padding: 4px 4px 4px 16px; }
220
+
221
+ /* ─── Waterfall ─── */
222
+ .bar-container { position: relative; height: 18px; background: var(--bg-2); border-radius: 3px; overflow: hidden; margin: 3px 0; }
223
+ .bar-fill { height: 100%; border-radius: 3px; display: flex; align-items: center; padding-left: 6px; font-size: 10px; color: var(--text-0); min-width: fit-content; }
224
+ .tree-indent { display: inline-block; color: var(--border); font-family: monospace; white-space: pre; user-select: none; }
225
+ .seg-toggle { display: inline-block; width: 16px; cursor: pointer; color: var(--text-2); font-size: 12px; user-select: none; text-align: center; vertical-align: middle; }
226
+ .seg-toggle:hover { color: var(--text-0); }
227
+ .seg-leaf { display: inline-block; width: 16px; }
228
+
229
+ /* ─── Bar Chart ─── */
230
+ .bar-chart-wrap { position: relative; }
231
+ .bar-chart-max { position: absolute; top: 2px; left: 4px; font-size: 11px; color: var(--text-2); font-family: var(--font-sans); pointer-events: none; line-height: 1; }
232
+
233
+ /* ─── Sparkline Interaction ─── */
234
+ .sparkline-tip { position: absolute; background: #ffffff; color: var(--text-0); font-size: 11px; font-family: var(--font-sans); padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.12); pointer-events: none; white-space: nowrap; z-index: 10; display: none; line-height: 1.3; }
235
+
236
+ /* ─── Error Hero ─── */
237
+ .error-hero { border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px; }
238
+ .error-hero-class { font-size: 18px; font-weight: 700; color: var(--text-0); margin-bottom: 6px; font-family: var(--font-mono); }
239
+ .error-hero-message { font-size: 14px; color: var(--text-0); word-break: break-word; margin-bottom: 12px; line-height: 1.5; background: var(--bg-1); border-radius: 4px; padding: 8px 12px; }
240
+ .error-hero-meta { display: flex; gap: 16px; align-items: center; font-size: 13px; color: var(--text-1); }
241
+ .error-hero-meta .badge { font-size: 12px; padding: 2px 8px; }
242
+ .error-hero-meta .occurrence-count { font-weight: 600; font-size: 14px; color: var(--text-0); }
243
+
244
+ /* ─── Pipeline Diagram ─── */
245
+ .pipeline { display: flex; align-items: stretch; gap: 0; margin: 20px 0; flex-wrap: wrap; }
246
+ .pipeline-node { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 20px; text-align: center; min-width: 160px; flex: 1; }
247
+ .pipeline-node .node-label { font-size: 11px; color: var(--text-1); text-transform: uppercase; letter-spacing: 0.5px; }
248
+ .pipeline-node .node-value { font-size: 22px; font-weight: 600; color: var(--text-0); margin-top: 4px; }
249
+ .pipeline-node .node-detail { font-size: 12px; color: var(--text-2); margin-top: 4px; line-height: 1.4; }
250
+ .pipeline-node .node-icon { margin-bottom: 6px; line-height: 1; opacity: 0.5; }
251
+ .pipeline-arrow { color: var(--text-2); font-size: 22px; padding: 0 6px; display: flex; align-items: center; }
252
+ @keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
253
+ .pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; animation: pulse-dot 2s ease-in-out infinite; }
254
+ @keyframes flow-arrow { 0% { opacity: 0.3; } 50% { opacity: 1; } 100% { opacity: 0.3; } }
255
+ .pipeline-arrow svg { animation: flow-arrow 1.5s ease-in-out infinite; }
256
+
257
+ .footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--border); color: var(--text-2); font-size: 12px; text-align: center; }
258
+
259
+ /* ─── Table Scroll ─── */
260
+ .table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
261
+
262
+ /* ─── Responsive ─── */
263
+ @media (max-width: 768px) {
264
+ body { padding: 16px 12px; }
265
+ .grid-hero { grid-template-columns: 1fr; }
266
+ .grid { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
267
+ .card .value { font-size: 20px; }
268
+ .filters { gap: 4px; }
269
+ .header { flex-wrap: wrap; }
270
+ .table-scroll { margin: 0 -12px; padding: 0 12px; }
271
+ .pipeline { flex-direction: column; align-items: stretch; }
272
+ .pipeline-node { min-width: auto; }
273
+ .pipeline-arrow { justify-content: center; padding: 4px 0; }
274
+ .pipeline-arrow svg { transform: rotate(90deg); }
275
+ }
276
+ </style>
277
+ </head>
278
+ <body>
279
+ <div class="header">
280
+ <h1><a href="<%= catpm.status_index_path %>"><span class="logo-cat"> &#x2571;&#x005C;_&#x2571;&#x005C;
281
+ ( &#xB7;.&#xB7; )
282
+ &#x2283;&#x2234;&#x2282;</span>catpm</a></h1>
283
+ </div>
284
+ <div class="header-sub"><%= yield :subtitle if content_for?(:subtitle) %></div>
285
+
286
+ <% if flash[:notice] %>
287
+ <div class="flash flash-notice"><%= flash[:notice] %></div>
288
+ <% end %>
289
+ <% if flash[:alert] %>
290
+ <div class="flash flash-alert"><%= flash[:alert] %></div>
291
+ <% end %>
292
+
293
+ <%= yield %>
294
+
295
+ <div class="footer">catpm v<%= Catpm::VERSION %></div>
296
+
297
+ <script>
298
+ function toggleSegment(btn) {
299
+ var segId = btn.getAttribute('data-seg');
300
+ var isCollapsed = btn.textContent.trim() === '\u25B8';
301
+ btn.textContent = isCollapsed ? '\u25BE' : '\u25B8';
302
+ var table = btn.closest('table');
303
+ var rows = table.querySelectorAll('tbody tr');
304
+ var segDepth = null, found = false;
305
+ var skipBelow = -1;
306
+ for (var i = 0; i < rows.length; i++) {
307
+ var row = rows[i];
308
+ if (row.getAttribute('data-seg') === segId) { found = true; segDepth = parseInt(row.getAttribute('data-depth')); continue; }
309
+ if (!found) continue;
310
+ var d = parseInt(row.getAttribute('data-depth'));
311
+ if (d <= segDepth) break;
312
+ if (isCollapsed) {
313
+ // Expanding: show direct children, skip nested collapsed subtrees
314
+ if (skipBelow >= 0 && d > skipBelow) continue;
315
+ skipBelow = -1;
316
+ row.removeAttribute('data-collapsed');
317
+ var t = row.querySelector('.seg-toggle');
318
+ if (t && t.textContent.trim() === '\u25B8') skipBelow = d;
319
+ } else {
320
+ // Collapsing: hide all descendants, preserve their toggles
321
+ row.setAttribute('data-collapsed', '');
322
+ }
323
+ }
324
+ }
325
+
326
+ function toggleOccurrence(row) {
327
+ var idx = row.getAttribute('data-occurrence');
328
+ var detail = document.getElementById('detail-' + idx);
329
+ if (!detail) return;
330
+ var open = detail.style.display !== 'none';
331
+ detail.style.display = open ? 'none' : 'table-row';
332
+ row.classList.toggle('open', !open);
333
+ }
334
+
335
+ function copyText(el) {
336
+ var text = el.parentElement.querySelector('pre').textContent;
337
+ navigator.clipboard.writeText(text).then(function() { el.textContent = 'Copied!'; setTimeout(function() { el.textContent = 'Copy'; }, 1500); });
338
+ }
339
+
340
+ function filterByText(inputId, tableId) {
341
+ var q = document.getElementById(inputId).value.toLowerCase();
342
+ document.getElementById(tableId).querySelectorAll('tbody tr').forEach(function(row) {
343
+ row.style.display = row.textContent.toLowerCase().indexOf(q) !== -1 ? '' : 'none';
344
+ });
345
+ }
346
+
347
+ /* Sparkline hover */
348
+ var sparkTip = null;
349
+ var sparkTarget = null;
350
+ document.addEventListener('mouseover', function(e) {
351
+ if (!e.target.classList.contains('sparkline-dot')) return;
352
+ var dot = e.target;
353
+ sparkTarget = dot;
354
+ if (!sparkTip) { sparkTip = document.createElement('div'); sparkTip.className = 'sparkline-tip'; document.body.appendChild(sparkTip); }
355
+ var val = dot.dataset.value || '';
356
+ var time = dot.dataset.time || '';
357
+ sparkTip.innerHTML = '<strong>' + val + '</strong>' + (time ? '<br>' + time : '');
358
+ sparkTip.style.display = 'block';
359
+ });
360
+ document.addEventListener('mousemove', function(e) {
361
+ if (!sparkTip || sparkTip.style.display === 'none') return;
362
+ sparkTip.style.left = (e.pageX) + 'px';
363
+ sparkTip.style.top = (e.pageY + 16) + 'px';
364
+ });
365
+ document.addEventListener('mouseout', function(e) {
366
+ if (!e.target.classList.contains('sparkline-dot')) return;
367
+ sparkTarget = null;
368
+ if (sparkTip) sparkTip.style.display = 'none';
369
+ });
370
+
371
+ document.addEventListener('keydown', function(e) {
372
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
373
+ if (e.key === '/') {
374
+ e.preventDefault();
375
+ var search = document.querySelector('.search-input');
376
+ if (search) search.focus();
377
+ }
378
+ });
379
+ </script>
380
+ </body>
381
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ Catpm::Engine.routes.draw do
4
+ root 'status#index'
5
+ resources :status, only: [:index]
6
+ resources :system, only: [:index]
7
+ get 'endpoint', to: 'endpoints#show', as: :endpoint
8
+ resources :samples, only: [:show]
9
+ resources :events, only: [:index, :show], param: :name
10
+ resources :errors, only: [:index, :show, :destroy] do
11
+ collection do
12
+ post :resolve_all
13
+ end
14
+ member do
15
+ patch :resolve
16
+ patch :unresolve
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateCatpmTables < ActiveRecord::Migration[8.0]
4
+ def up
5
+ create_table :catpm_buckets do |t|
6
+ t.string :kind, null: false
7
+ t.string :target, null: false
8
+ t.string :operation, null: false, default: ''
9
+ t.datetime :bucket_start, null: false
10
+
11
+ t.integer :count, null: false, default: 0
12
+ t.integer :success_count, null: false, default: 0
13
+ t.integer :failure_count, null: false, default: 0
14
+
15
+ t.float :duration_sum, null: false, default: 0.0
16
+ t.float :duration_max, null: false, default: 0.0
17
+ t.float :duration_min, null: false, default: 0.0
18
+
19
+ t.json :metadata_sum
20
+ t.binary :p95_digest
21
+ end
22
+
23
+ add_index :catpm_buckets, [:kind, :target, :operation, :bucket_start],
24
+ unique: true, name: 'idx_catpm_buckets_unique'
25
+ add_index :catpm_buckets, :bucket_start, name: 'idx_catpm_buckets_time'
26
+ add_index :catpm_buckets, [:kind, :bucket_start], name: 'idx_catpm_buckets_kind_time'
27
+
28
+ create_table :catpm_samples do |t|
29
+ t.references :bucket, null: false, foreign_key: { to_table: :catpm_buckets }
30
+ t.string :kind, null: false
31
+ t.string :sample_type, null: false
32
+ t.datetime :recorded_at, null: false
33
+ t.float :duration, null: false
34
+ t.json :context
35
+ end
36
+
37
+ add_index :catpm_samples, :recorded_at, name: 'idx_catpm_samples_time'
38
+ add_index :catpm_samples, [:kind, :recorded_at], name: 'idx_catpm_samples_kind_time'
39
+
40
+ create_table :catpm_errors do |t|
41
+ t.string :fingerprint, null: false, limit: 64
42
+ t.string :kind, null: false
43
+ t.string :error_class, null: false
44
+ t.text :message
45
+ t.integer :occurrences_count, null: false, default: 0
46
+ t.datetime :first_occurred_at, null: false
47
+ t.datetime :last_occurred_at, null: false
48
+ t.json :contexts
49
+ t.datetime :resolved_at
50
+ end
51
+
52
+ add_index :catpm_errors, :fingerprint, unique: true, name: 'idx_catpm_errors_fingerprint'
53
+ add_index :catpm_errors, [:kind, :last_occurred_at], name: 'idx_catpm_errors_kind_time'
54
+
55
+ create_table :catpm_event_buckets do |t|
56
+ t.string :name, null: false
57
+ t.datetime :bucket_start, null: false
58
+ t.integer :count, null: false, default: 0
59
+ end
60
+
61
+ add_index :catpm_event_buckets, [:name, :bucket_start],
62
+ unique: true, name: 'idx_catpm_event_buckets_unique'
63
+ add_index :catpm_event_buckets, :bucket_start, name: 'idx_catpm_event_buckets_time'
64
+
65
+ create_table :catpm_event_samples do |t|
66
+ t.string :name, null: false
67
+ t.json :payload
68
+ t.datetime :recorded_at, null: false
69
+ end
70
+
71
+ add_index :catpm_event_samples, [:name, :recorded_at], name: 'idx_catpm_event_samples_name_time'
72
+ add_index :catpm_event_samples, :recorded_at, name: 'idx_catpm_event_samples_time'
73
+
74
+ if postgresql?
75
+ execute <<~SQL
76
+ CREATE OR REPLACE FUNCTION catpm_merge_jsonb_sums(a jsonb, b jsonb)
77
+ RETURNS jsonb AS $$
78
+ SELECT COALESCE(a, '{}'::jsonb) || (
79
+ SELECT jsonb_object_agg(key, COALESCE((a ->> key)::numeric, 0) + value::numeric)
80
+ FROM jsonb_each_text(COALESCE(b, '{}'::jsonb))
81
+ );
82
+ $$ LANGUAGE sql IMMUTABLE;
83
+ SQL
84
+ end
85
+ end
86
+
87
+ def down
88
+ if postgresql?
89
+ execute 'DROP FUNCTION IF EXISTS catpm_merge_jsonb_sums(jsonb, jsonb);'
90
+ end
91
+
92
+ drop_table :catpm_event_samples, if_exists: true
93
+ drop_table :catpm_event_buckets, if_exists: true
94
+ drop_table :catpm_errors, if_exists: true
95
+ drop_table :catpm_samples, if_exists: true
96
+ drop_table :catpm_buckets, if_exists: true
97
+ end
98
+
99
+ private
100
+
101
+ def postgresql?
102
+ ActiveRecord::Base.connection.adapter_name =~ /PostgreSQL/i
103
+ end
104
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ module Adapter
5
+ module Base
6
+ def persist_buckets(aggregated_buckets)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def persist_errors(error_records)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def persist_event_buckets(event_buckets)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def persist_event_samples(event_samples)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def persist_samples(samples, bucket_map)
23
+ ActiveRecord::Base.connection_pool.with_connection do
24
+ samples.each_slice(Catpm.config.persistence_batch_size) do |batch|
25
+ records = batch.filter_map do |sample_data|
26
+ bucket = bucket_map[sample_data[:bucket_key]]
27
+ next unless bucket
28
+
29
+ {
30
+ bucket_id: bucket.id,
31
+ kind: sample_data[:kind],
32
+ sample_type: sample_data[:sample_type],
33
+ recorded_at: sample_data[:recorded_at],
34
+ duration: sample_data[:duration],
35
+ context: sample_data[:context]
36
+ }
37
+ end
38
+
39
+ Catpm::Sample.insert_all(records) if records.any?
40
+ end
41
+ end
42
+ end
43
+
44
+ def modulo_bucket_sql(interval)
45
+ raise NotImplementedError
46
+ end
47
+
48
+ def merge_metadata_sum(existing, incoming)
49
+ existing = parse_json(existing)
50
+ incoming = parse_json(incoming)
51
+
52
+ incoming.each do |key, value|
53
+ existing[key] = (existing[key] || 0).to_f + value.to_f
54
+ end
55
+
56
+ existing
57
+ end
58
+
59
+ def merge_digest(existing_blob, new_blob)
60
+ existing = existing_blob ? TDigest.deserialize(existing_blob) : TDigest.new
61
+ incoming = new_blob ? TDigest.deserialize(new_blob) : TDigest.new
62
+ existing.merge(incoming)
63
+ existing.empty? ? nil : existing.serialize
64
+ end
65
+
66
+ def merge_contexts(existing_contexts, new_contexts)
67
+ combined = (existing_contexts + new_contexts)
68
+ combined.last(Catpm.config.max_error_contexts)
69
+ end
70
+
71
+ private
72
+
73
+ def parse_json(value)
74
+ case value
75
+ when Hash then value.transform_keys(&:to_s)
76
+ when String then JSON.parse(value)
77
+ when NilClass then {}
78
+ else {}
79
+ end
80
+ rescue JSON::ParserError
81
+ {}
82
+ end
83
+ end
84
+ end
85
+ end