catpm 0.6.6 → 0.8.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.
@@ -0,0 +1,344 @@
1
+ <div class="pipeline-showcase">
2
+ <div class="pipeline">
3
+ <div class="pipeline-node">
4
+ <div class="node-icon"><svg width="36" height="36" viewBox="0 0 28 28" fill="none" stroke="var(--text-2)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="14" cy="14" r="9"/><path d="M14 9v5l3.5 3.5"/></svg></div>
5
+ <div class="node-label">Capture</div>
6
+ <div class="node-value">Middleware</div>
7
+ </div>
8
+ <div class="pipeline-arrow">
9
+ <svg width="24" height="16" viewBox="0 0 24 16"><path d="M0 8h20M16 3l5 5-5 5" stroke="var(--text-2)" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
10
+ </div>
11
+ <div class="pipeline-node">
12
+ <div class="node-icon"><svg width="36" height="36" viewBox="0 0 28 28" fill="none" stroke="var(--text-2)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="16" height="20" rx="2"/><line x1="10" y1="10" x2="18" y2="10"/><line x1="10" y1="14" x2="18" y2="14"/><line x1="10" y1="18" x2="15" y2="18"/></svg></div>
13
+ <div class="node-label">Buffer</div>
14
+ <div class="node-value">Memory Queue</div>
15
+ </div>
16
+ <div class="pipeline-arrow">
17
+ <svg width="24" height="16" viewBox="0 0 24 16"><path d="M0 8h20M16 3l5 5-5 5" stroke="var(--text-2)" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
18
+ </div>
19
+ <div class="pipeline-node">
20
+ <div class="node-icon"><svg width="36" height="36" viewBox="0 0 28 28" fill="none" stroke="var(--text-2)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4,22 10,16 15,19 24,8"/><polyline points="18,8 24,8 24,14"/></svg></div>
21
+ <div class="node-label">Flush</div>
22
+ <div class="node-value">Aggregator</div>
23
+ </div>
24
+ <div class="pipeline-arrow">
25
+ <svg width="24" height="16" viewBox="0 0 24 16"><path d="M0 8h20M16 3l5 5-5 5" stroke="var(--text-2)" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
26
+ </div>
27
+ <div class="pipeline-node">
28
+ <div class="node-icon"><svg width="36" height="36" viewBox="0 0 28 28" fill="none" stroke="var(--text-2)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="14" cy="8" rx="8" ry="4"/><path d="M6 8v12c0 2.2 3.58 4 8 4s8-1.8 8-4V8"/><path d="M6 14c0 2.2 3.58 4 8 4s8-1.8 8-4"/></svg></div>
29
+ <div class="node-label">Database</div>
30
+ <div class="node-value">Storage</div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+
35
+ <script>
36
+ (function() {
37
+ var el = document.querySelector('.pipeline');
38
+ if (!el) return;
39
+
40
+ var pnodes = el.querySelectorAll('.pipeline-node');
41
+ if (pnodes.length < 4) return;
42
+ var r0 = pnodes[0].getBoundingClientRect();
43
+ var r1 = pnodes[1].getBoundingClientRect();
44
+ if (r1.top > r0.bottom - 10) return;
45
+
46
+ var arrows = el.querySelectorAll('.pipeline-arrow');
47
+
48
+ var canvas = document.createElement('canvas');
49
+ canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:2';
50
+ el.style.position = 'relative';
51
+ el.style.overflow = 'hidden';
52
+ el.appendChild(canvas);
53
+
54
+ var ctx = canvas.getContext('2d');
55
+ var dpr = window.devicePixelRatio || 1;
56
+ var W, H;
57
+ var FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
58
+
59
+ function resize() {
60
+ W = el.offsetWidth; H = el.offsetHeight;
61
+ canvas.width = W * dpr; canvas.height = H * dpr;
62
+ canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
63
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
64
+ }
65
+ resize();
66
+ if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
67
+
68
+ function zones() {
69
+ var pr = el.getBoundingClientRect();
70
+ var nodeZones = Array.prototype.map.call(pnodes, function(n) {
71
+ var r = n.getBoundingClientRect();
72
+ return {
73
+ l: r.left - pr.left, r: r.right - pr.left,
74
+ t: r.top - pr.top, b: r.bottom - pr.top,
75
+ cx: (r.left + r.right) / 2 - pr.left,
76
+ cy: (r.top + r.bottom) / 2 - pr.top,
77
+ w: r.width, h: r.height
78
+ };
79
+ });
80
+ var arrowZones = Array.prototype.map.call(arrows, function(a) {
81
+ var r = a.getBoundingClientRect();
82
+ return {
83
+ l: r.left - pr.left, r: r.right - pr.left,
84
+ cx: (r.left + r.right) / 2 - pr.left,
85
+ cy: (r.top + r.bottom) / 2 - pr.top
86
+ };
87
+ });
88
+ return { nodes: nodeZones, arrows: arrowZones };
89
+ }
90
+
91
+ var C = { ok: '#1a7f37', error: '#cf222e', slow: '#9a6700', sample: '#8250df' };
92
+ var POOL = ['ok','ok','ok','ok','ok','ok','ok','slow','slow','error','sample'];
93
+ var SZ = 5;
94
+ var MAX_BUF = 36;
95
+ var FLUSH_MS = 3500;
96
+
97
+ var METHODS = ['GET','GET','GET','GET','POST','POST','PUT','PATCH','DELETE'];
98
+ var RPATHS = ['/users','/home','/orders','/api','/settings',
99
+ '/reports','/data','/search','/login','/pay','/items','/stats'];
100
+
101
+ function randLabel(type) {
102
+ var m = METHODS[Math.floor(Math.random() * METHODS.length)];
103
+ var p = RPATHS[Math.floor(Math.random() * RPATHS.length)];
104
+ if (type === 'slow') return m + ' ' + p + ' ' + (200 + Math.floor(Math.random() * 600)) + 'ms';
105
+ if (type === 'error') {
106
+ var codes = [500, 502, 503, 422];
107
+ return m + ' ' + p + ' ' + codes[Math.floor(Math.random() * codes.length)];
108
+ }
109
+ return m + ' ' + p;
110
+ }
111
+
112
+ function rrect(x, y, w, h, r) {
113
+ ctx.beginPath();
114
+ ctx.moveTo(x + r, y);
115
+ ctx.lineTo(x + w - r, y);
116
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
117
+ ctx.lineTo(x + w, y + h - r);
118
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
119
+ ctx.lineTo(x + r, y + h);
120
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
121
+ ctx.lineTo(x, y + r);
122
+ ctx.quadraticCurveTo(x, y, x + r, y);
123
+ ctx.closePath();
124
+ }
125
+
126
+ function lerp(a, b, t) { return a + (b - a) * t; }
127
+
128
+ var particles = [];
129
+ var batchAcc = 0;
130
+ var flushAcc = 0;
131
+ var prev = 0;
132
+
133
+ function randType() { return POOL[Math.floor(Math.random() * POOL.length)]; }
134
+
135
+ function bufRowsPerCol(bz) { return Math.max(1, Math.floor((bz.h - 20) / (SZ + 2))); }
136
+ function bufSlot(idx, bz) {
137
+ var perCol = bufRowsPerCol(bz);
138
+ var col = Math.floor(idx / perCol);
139
+ var row = idx % perCol;
140
+ return {
141
+ x: bz.r - 10 - col * (SZ + 2) - SZ / 2,
142
+ y: bz.b - 10 - row * (SZ + 2) - SZ / 2
143
+ };
144
+ }
145
+
146
+ function tick(t) {
147
+ if (!prev) { prev = t; requestAnimationFrame(tick); return; }
148
+ var dt = Math.min(t - prev, 50);
149
+ prev = t;
150
+
151
+ var z = zones();
152
+ if (z.nodes.length < 4) { requestAnimationFrame(tick); return; }
153
+ var cap = z.nodes[0], buf = z.nodes[1], flu = z.nodes[2], db = z.nodes[3];
154
+ var laneY = cap.t + cap.h * 0.42;
155
+
156
+ var bc = 0;
157
+ for (var i = 0; i < particles.length; i++) if (particles[i].state === 1) bc++;
158
+
159
+ var captureOccupied = false;
160
+ for (var i = 0; i < particles.length; i++) {
161
+ if (particles[i].state === 0 && particles[i].phase <= 1) {
162
+ captureOccupied = true; break;
163
+ }
164
+ }
165
+
166
+ batchAcc += dt;
167
+ if (batchAcc > 2500 + Math.random() * 1500 && !captureOccupied) {
168
+ batchAcc = 0;
169
+ var count = Math.min(1 + Math.floor(Math.random() * 5), MAX_BUF - bc);
170
+ if (count > 0) {
171
+ var spacing = 26;
172
+ var yBase = laneY - (count - 1) * spacing / 2;
173
+ for (var j = 0; j < count; j++) {
174
+ var type = randType();
175
+ var label = randLabel(type);
176
+ ctx.font = '500 11px ' + FONT;
177
+ var lw = ctx.measureText(label).width;
178
+ var s = bufSlot(bc + j, buf);
179
+ particles.push({
180
+ type: type, label: label, labelW: lw,
181
+ x: cap.l - 20,
182
+ y: yBase + j * spacing,
183
+ phase: 0,
184
+ lingerTime: 300 + Math.random() * 400,
185
+ lingerAcc: 0,
186
+ state: 0, opacity: 0, age: 0, progress: 0,
187
+ slotX: s.x, slotY: s.y
188
+ });
189
+ }
190
+ }
191
+ }
192
+
193
+ flushAcc += dt;
194
+ if (flushAcc > FLUSH_MS + Math.random() * 2000) {
195
+ flushAcc = 0;
196
+ var delay = 0;
197
+ for (var i = 0; i < particles.length; i++) {
198
+ var p = particles[i];
199
+ if (p.state === 1) {
200
+ p.state = 2;
201
+ p.phase = 0;
202
+ p.fx = flu.cx + (Math.random() - 0.5) * 24;
203
+ p.fy = flu.t + 18 + Math.random() * (flu.h * 0.35);
204
+ p.tx = db.cx + (Math.random() - 0.5) * 40;
205
+ p.ty = db.t + 18 + Math.random() * (db.h * 0.4);
206
+ p.delay = delay;
207
+ p.waited = 0;
208
+ p.pauseAcc = 0;
209
+ delay += 20 + Math.random() * 15;
210
+ }
211
+ }
212
+ }
213
+
214
+ for (var i = particles.length - 1; i >= 0; i--) {
215
+ var p = particles[i];
216
+
217
+ if (p.state === 0) {
218
+ p.age = (p.age || 0) + dt;
219
+
220
+ if (p.phase === 0) {
221
+ p.opacity = Math.min(0.9, p.opacity + dt * 0.005);
222
+ var dx = cap.cx - p.x;
223
+ p.x += dx * 0.008 * dt;
224
+ if (Math.abs(dx) < 3) {
225
+ p.phase = 1;
226
+ p.x = cap.cx;
227
+ p.lingerAcc = 0;
228
+ }
229
+ }
230
+ else if (p.phase === 1) {
231
+ p.opacity = 0.9;
232
+ p.lingerAcc += dt;
233
+ if (p.lingerAcc > p.lingerTime) {
234
+ p.phase = 2;
235
+ p.progress = 0;
236
+ p.startX = p.x;
237
+ p.startY = p.y;
238
+ }
239
+ }
240
+ else if (p.phase === 2) {
241
+ p.progress = Math.min(1, p.progress + dt * 0.0012);
242
+ var ease = p.progress * p.progress * (3 - 2 * p.progress);
243
+ p.x = lerp(p.startX, p.slotX, ease);
244
+ p.y = lerp(p.startY, p.slotY, ease);
245
+ if (p.progress >= 1) {
246
+ p.state = 1;
247
+ p.x = p.slotX;
248
+ p.y = p.slotY;
249
+ p.opacity = 0.8;
250
+ }
251
+ }
252
+ }
253
+ else if (p.state === 1) {
254
+ p.x = p.slotX;
255
+ p.y = p.slotY;
256
+ }
257
+ else if (p.state === 2) {
258
+ p.waited = (p.waited || 0) + dt;
259
+ if (p.waited < p.delay) continue;
260
+
261
+ if (p.phase === 0) {
262
+ var dx = p.fx - p.x, dy = p.fy - p.y;
263
+ if (Math.abs(dx) < 6 && Math.abs(dy) < 6) {
264
+ p.phase = 1; p.pauseAcc = 0;
265
+ } else {
266
+ p.x += dx * 0.0035 * dt;
267
+ p.y += dy * 0.0035 * dt;
268
+ }
269
+ }
270
+ else if (p.phase === 1) {
271
+ p.pauseAcc += dt;
272
+ if (p.pauseAcc > 350 + Math.random() * 200) p.phase = 2;
273
+ }
274
+ else if (p.phase === 2) {
275
+ var dx = p.tx - p.x, dy = p.ty - p.y;
276
+ if (Math.abs(dx) < 4 && Math.abs(dy) < 4) {
277
+ p.state = 3; p.ds = t;
278
+ } else {
279
+ p.x += dx * 0.0035 * dt;
280
+ p.y += dy * 0.0035 * dt;
281
+ }
282
+ }
283
+ }
284
+ else if (p.state === 3) {
285
+ var e = t - p.ds;
286
+ p.opacity = 0.8 * Math.max(0, 1 - e / 800);
287
+ if (p.opacity <= 0.01) { particles.splice(i, 1); continue; }
288
+ }
289
+ }
290
+
291
+ ctx.clearRect(0, 0, W, H);
292
+ for (var i = 0; i < particles.length; i++) {
293
+ var p = particles[i];
294
+ if (p.opacity <= 0.01) continue;
295
+
296
+ var scale = 0;
297
+ if (p.state === 0) {
298
+ if (p.phase <= 1) {
299
+ scale = 1;
300
+ } else {
301
+ scale = Math.max(0, 1 - p.progress * 1.5);
302
+ }
303
+ }
304
+
305
+ ctx.globalAlpha = p.opacity;
306
+
307
+ if (scale > 0.12 && p.label) {
308
+ var fs = Math.round(11 * scale);
309
+ var bw = (p.labelW + 14) * scale;
310
+ var bh = 22 * scale;
311
+ var rad = 3 * scale;
312
+
313
+ ctx.fillStyle = C[p.type];
314
+ ctx.globalAlpha = p.opacity * 0.92;
315
+ rrect(p.x - bw / 2, p.y - bh / 2, bw, bh, rad);
316
+ ctx.fill();
317
+
318
+ if (fs >= 7) {
319
+ ctx.globalAlpha = p.opacity;
320
+ ctx.fillStyle = '#fff';
321
+ ctx.font = '500 ' + fs + 'px ' + FONT;
322
+ ctx.textAlign = 'center';
323
+ ctx.textBaseline = 'middle';
324
+ ctx.fillText(p.label, p.x, p.y + 0.5);
325
+ }
326
+ } else {
327
+ ctx.fillStyle = C[p.type];
328
+ if (p.state === 3) {
329
+ var e = t - p.ds;
330
+ var s = SZ * (1 + e * 0.002);
331
+ ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s);
332
+ } else {
333
+ ctx.fillRect(p.x - SZ / 2, p.y - SZ / 2, SZ, SZ);
334
+ }
335
+ }
336
+ }
337
+ ctx.globalAlpha = 1;
338
+
339
+ requestAnimationFrame(tick);
340
+ }
341
+
342
+ requestAnimationFrame(tick);
343
+ })();
344
+ </script>
@@ -290,6 +290,41 @@
290
290
 
291
291
  .footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--border); color: var(--text-2); font-size: 12px; text-align: center; }
292
292
 
293
+ /* ─── Diagnostics Grid ─── */
294
+ .diag-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 24px; }
295
+ .diag-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
296
+ .diag-label { font-size: 11px; color: var(--text-1); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; }
297
+ .diag-value { font-size: 22px; font-weight: 600; color: var(--text-0); margin-top: 2px; line-height: 1.2; }
298
+ .diag-value--warn { color: var(--yellow); }
299
+ .diag-unit { font-size: 12px; font-weight: 400; color: var(--text-2); }
300
+ .diag-detail { font-size: 12px; color: var(--text-2); margin-top: 4px; }
301
+
302
+ /* ─── Storage Card ─── */
303
+ .storage-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 24px; }
304
+ .storage-header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 14px; }
305
+ .storage-total { font-size: 18px; font-weight: 600; color: var(--text-0); }
306
+ .storage-meta { font-size: 13px; color: var(--text-2); }
307
+ .storage-bars { display: flex; flex-direction: column; gap: 6px; }
308
+ .storage-row { display: grid; grid-template-columns: 120px 1fr auto auto; gap: 10px; align-items: center; font-size: 12px; }
309
+ .storage-name { color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
310
+ .storage-bar-track { height: 6px; background: var(--bg-2); border-radius: 3px; overflow: hidden; }
311
+ .storage-bar-fill { height: 100%; background: var(--accent); border-radius: 3px; opacity: 0.6; transition: width 0.3s; }
312
+ .storage-row-stat { color: var(--text-1); text-align: right; min-width: 50px; }
313
+ .storage-row-size { color: var(--text-2); text-align: right; min-width: 60px; }
314
+
315
+ /* ─── Config Groups (System page) ─── */
316
+ .config-group { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 12px; overflow: hidden; }
317
+ .config-group-title { font-size: 11px; font-weight: 600; color: var(--text-1); text-transform: uppercase; letter-spacing: 0.5px; padding: 10px 16px; margin: 0; background: var(--bg-1); border-bottom: 1px solid var(--border); }
318
+ .config-group-body { padding: 0; }
319
+ .config-item { padding: 10px 16px; border-bottom: 1px solid var(--bg-2); }
320
+ .config-item:last-child { border-bottom: none; }
321
+ .config-item-header { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
322
+ .config-item-label { font-weight: 500; font-size: 13px; color: var(--text-0); }
323
+ .config-item-value { font-size: 13px; color: var(--text-0); text-align: right; flex-shrink: 0; }
324
+ .config-item-meta { margin-top: 3px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
325
+ .config-param { font-family: var(--font-mono); font-size: 11px; color: var(--accent); background: var(--bg-1); padding: 1px 5px; border-radius: 3px; white-space: nowrap; }
326
+ .config-desc { font-size: 12px; color: var(--text-2); line-height: 1.4; }
327
+
293
328
  /* ─── Table Scroll ─── */
294
329
  .table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
295
330
 
@@ -306,6 +341,11 @@
306
341
  .pipeline-node { min-width: auto; }
307
342
  .pipeline-arrow { justify-content: center; padding: 4px 0; }
308
343
  .pipeline-arrow svg { transform: rotate(90deg); }
344
+ .config-item-header { flex-direction: column; gap: 2px; }
345
+ .config-item-value { text-align: left; }
346
+ .diag-grid { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
347
+ .storage-row { grid-template-columns: 90px 1fr auto auto; gap: 6px; }
348
+ .storage-header { flex-direction: column; gap: 2px; }
309
349
  }
310
350
  </style>
311
351
  </head>
@@ -0,0 +1,79 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>catpm — Pipeline</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <style>
7
+ :root {
8
+ --bg-0: #ffffff;
9
+ --bg-1: #f6f8fa;
10
+ --bg-2: #eaeef2;
11
+ --border: #d0d7de;
12
+ --text-0: #1f2328;
13
+ --text-1: #656d76;
14
+ --text-2: #8b949e;
15
+ --accent: #0969da;
16
+ --red: #cf222e;
17
+ --green: #1a7f37;
18
+ --yellow: #9a6700;
19
+ --purple: #8250df;
20
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
21
+ --font-mono: "SF Mono", "Fira Code", Consolas, "Liberation Mono", Menlo, monospace;
22
+ --radius: 6px;
23
+ }
24
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
25
+ body {
26
+ font-family: var(--font-sans);
27
+ background: var(--bg-0);
28
+ color: var(--text-0);
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ min-height: 100vh;
33
+ margin: 0;
34
+ padding: 48px;
35
+ }
36
+ .pipeline-showcase { max-width: 1060px; width: 100%; }
37
+ .pipeline-showcase .pipeline {
38
+ display: flex;
39
+ align-items: stretch;
40
+ gap: 0;
41
+ }
42
+ .pipeline-node {
43
+ background: var(--bg-1);
44
+ border: 1px solid var(--border);
45
+ border-radius: 8px;
46
+ padding: 28px 24px 24px;
47
+ text-align: center;
48
+ min-width: 200px;
49
+ flex: 1;
50
+ }
51
+ .pipeline-node .node-icon { margin-bottom: 10px; line-height: 1; opacity: 0.5; }
52
+ .pipeline-node .node-icon svg { width: 36px; height: 36px; }
53
+ .pipeline-node .node-label {
54
+ font-size: 12px;
55
+ color: var(--text-1);
56
+ text-transform: uppercase;
57
+ letter-spacing: 1px;
58
+ font-weight: 600;
59
+ }
60
+ .pipeline-node .node-value {
61
+ font-size: 15px;
62
+ font-weight: 500;
63
+ color: var(--text-2);
64
+ margin-top: 6px;
65
+ }
66
+ .pipeline-arrow {
67
+ color: var(--text-2);
68
+ padding: 0 8px;
69
+ display: flex;
70
+ align-items: center;
71
+ }
72
+ @keyframes flow-arrow { 0% { opacity: 0.3; } 50% { opacity: 1; } 100% { opacity: 0.3; } }
73
+ .pipeline-arrow svg { animation: flow-arrow 1.5s ease-in-out infinite; }
74
+ </style>
75
+ </head>
76
+ <body>
77
+ <%= yield %>
78
+ </body>
79
+ </html>
data/config/routes.rb CHANGED
@@ -4,6 +4,7 @@ Catpm::Engine.routes.draw do
4
4
  root 'status#index'
5
5
  resources :status, only: [:index]
6
6
  resources :system, only: [:index]
7
+ get 'pipeline', to: 'system#pipeline', as: :pipeline
7
8
  get 'endpoint', to: 'endpoints#show', as: :endpoint
8
9
  delete 'endpoint', to: 'endpoints#destroy'
9
10
  patch 'endpoint/pin', to: 'endpoints#toggle_pin', as: :endpoint_pin
data/lib/catpm/buffer.rb CHANGED
@@ -10,14 +10,29 @@ module Catpm
10
10
  @current_bytes = 0
11
11
  @max_bytes = max_bytes
12
12
  @dropped_count = 0
13
+ @flush_callback = nil
14
+ end
15
+
16
+ # Register a callback invoked when buffer reaches configured capacity.
17
+ # Used by Flusher to wake up immediately for an emergency flush.
18
+ def on_flush_needed(&block)
19
+ @flush_callback = block
13
20
  end
14
21
 
15
22
  # Called from request threads. Returns :accepted or :dropped.
16
23
  # Never blocks — monitoring must not slow down the application.
24
+ #
25
+ # When buffer reaches max_bytes, signals the flusher for immediate drain
26
+ # and continues accepting events. Only drops as a last resort at 3x capacity
27
+ # (flusher stuck or DB down).
17
28
  def push(event)
29
+ signal_flush = false
30
+
18
31
  @monitor.synchronize do
19
32
  bytes = event.estimated_bytes
20
- if @current_bytes + bytes > @max_bytes
33
+
34
+ # Hard safety cap: 3x configured limit prevents OOM if flusher is stuck
35
+ if @current_bytes + bytes > @max_bytes * 3
21
36
  @dropped_count += 1
22
37
  Catpm.stats[:dropped_events] += 1
23
38
  return :dropped
@@ -25,8 +40,13 @@ module Catpm
25
40
 
26
41
  @events << event
27
42
  @current_bytes += bytes
28
- :accepted
43
+
44
+ signal_flush = @current_bytes >= @max_bytes
29
45
  end
46
+
47
+ # Signal outside monitor to avoid holding the lock during callback
48
+ @flush_callback&.call if signal_flush
49
+ :accepted
30
50
  end
31
51
 
32
52
  # Called from flusher thread. Atomically swaps out the entire buffer.
@@ -2,11 +2,13 @@
2
2
 
3
3
  module Catpm
4
4
  class CallTracer
5
+ MAX_CALL_DEPTH = 64
6
+
5
7
  def initialize(request_segments:)
6
8
  @request_segments = request_segments
7
9
  @call_stack = []
8
- @path_cache = {}
9
10
  @started = false
11
+ @depth = 0
10
12
 
11
13
  @tracepoint = TracePoint.new(:call, :return) do |tp|
12
14
  case tp.event
@@ -36,14 +38,22 @@ module Catpm
36
38
  private
37
39
 
38
40
  def handle_call(tp)
41
+ @depth += 1
42
+
39
43
  path = tp.path
40
- app = app_frame?(path)
44
+ app = Fingerprint.app_frame?(path)
41
45
 
42
46
  unless app
43
47
  @call_stack.push(:skip)
44
48
  return
45
49
  end
46
50
 
51
+ # Prevent excessive nesting from blowing up memory
52
+ if @depth > MAX_CALL_DEPTH
53
+ @call_stack.push(:skip)
54
+ return
55
+ end
56
+
47
57
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
58
  detail = format_detail(tp.defined_class, tp.method_id)
49
59
  index = @request_segments.push_span(type: :code, detail: detail, started_at: started_at)
@@ -51,6 +61,7 @@ module Catpm
51
61
  end
52
62
 
53
63
  def handle_return
64
+ @depth -= 1 if @depth > 0
54
65
  entry = @call_stack.pop
55
66
  return if entry == :skip || entry.nil?
56
67
 
@@ -66,19 +77,15 @@ module Catpm
66
77
  @call_stack.clear
67
78
  end
68
79
 
69
- def app_frame?(path)
70
- cached = @path_cache[path]
71
- return cached unless cached.nil?
72
-
73
- @path_cache[path] = Fingerprint.app_frame?(path)
74
- end
75
-
76
80
  def format_detail(defined_class, method_id)
77
- if defined_class.singleton_class?
81
+ name = defined_class.name
82
+ if name
83
+ "#{name}##{method_id}"
84
+ elsif defined_class.singleton_class?
78
85
  owner = defined_class.attached_object
79
86
  "#{owner.name || owner.inspect}.#{method_id}"
80
87
  else
81
- "#{defined_class.name || defined_class.inspect}##{method_id}"
88
+ "#{defined_class.inspect}##{method_id}"
82
89
  end
83
90
  end
84
91
  end
@@ -90,6 +90,19 @@ module Catpm
90
90
  end
91
91
  end
92
92
 
93
+ # Inject call tree segments from sampler (replaces TracePoint-based CallTracer)
94
+ if Catpm.config.instrument_call_tree && req_segments
95
+ tree_segs = req_segments.call_tree_segments
96
+ if tree_segs.any?
97
+ base_idx = segments.size
98
+ tree_segs.each do |seg|
99
+ tree_parent = seg.delete(:_tree_parent)
100
+ seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
101
+ segments << seg
102
+ end
103
+ end
104
+ end
105
+
93
106
  # Fill untracked controller time with sampler data or synthetic segment
94
107
  ctrl_idx = segments.index { |s| s[:type] == 'controller' }
95
108
  if ctrl_idx
@@ -246,6 +259,19 @@ module Catpm
246
259
  end
247
260
  segments.unshift(root_segment)
248
261
 
262
+ # Inject call tree segments from sampler
263
+ if Catpm.config.instrument_call_tree && req_segments
264
+ tree_segs = req_segments.call_tree_segments
265
+ if tree_segs.any?
266
+ base_idx = segments.size
267
+ tree_segs.each do |seg|
268
+ tree_parent = seg.delete(:_tree_parent)
269
+ seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
270
+ segments << seg
271
+ end
272
+ end
273
+ end
274
+
249
275
  # Fill untracked controller time with sampler data or synthetic segment
250
276
  ctrl_idx = segments.index { |s| s[:type] == 'controller' }
251
277
  if ctrl_idx
@@ -339,14 +365,18 @@ module Catpm
339
365
  # ActiveSupport controller notification finishes.
340
366
  # Mutates segments in place: removes the wrapper and re-indexes parent references.
341
367
  def collapse_code_wrappers(segments)
368
+ # Pre-build set of parent indices that have a controller child — O(n)
369
+ parents_with_controller = {}
370
+ segments.each do |seg|
371
+ parents_with_controller[seg[:parent_index]] = true if seg[:type] == 'controller' && seg[:parent_index]
372
+ end
373
+
342
374
  # Identify code spans to collapse: near-zero duration wrapping a controller child
343
375
  collapse = {}
344
376
  segments.each_with_index do |seg, i|
345
377
  next unless seg[:type] == 'code'
346
378
  next unless (seg[:duration] || 0).to_f < 1.0
347
-
348
- has_controller_child = segments.any? { |s| s[:parent_index] == i && s[:type] == 'controller' }
349
- next unless has_controller_child
379
+ next unless parents_with_controller[i]
350
380
 
351
381
  collapse[i] = seg[:parent_index]
352
382
  end
@@ -71,7 +71,7 @@ module Catpm
71
71
  @instrument_stack_sampler = false
72
72
  @instrument_middleware_stack = false
73
73
  @max_segments_per_request = 50
74
- @segment_source_threshold = 0.0 # ms — capture caller_locations for all segments (set higher to reduce overhead)
74
+ @segment_source_threshold = 5.0 # ms — capture caller_locations only for segments >= 5ms (set to 0.0 to capture all)
75
75
  @max_sql_length = 200
76
76
  @slow_threshold = 500 # milliseconds
77
77
  @slow_threshold_per_kind = {}
@@ -98,7 +98,7 @@ module Catpm
98
98
  @circuit_breaker_recovery_timeout = 60 # seconds
99
99
  @sqlite_busy_timeout = 5_000 # milliseconds
100
100
  @persistence_batch_size = 100
101
- @backtrace_lines = nil
101
+ @backtrace_lines = 20 # frames per error backtrace (nil = unlimited)
102
102
  @shutdown_timeout = 5 # seconds
103
103
  @events_enabled = false
104
104
  @events_max_samples_per_name = 20