catpm 0.6.5 → 0.7.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,32 @@
2
2
 
3
3
  module Catpm
4
4
  class CallTracer
5
+ MAX_CALL_DEPTH = 64
6
+
7
+ # Global thread-safe path classification cache — avoids repeated Fingerprint.app_frame? calls
8
+ @global_path_cache = {}
9
+ @global_path_mutex = Mutex.new
10
+
11
+ class << self
12
+ def app_frame_cached?(path)
13
+ cached = @global_path_cache[path]
14
+ return cached unless cached.nil?
15
+
16
+ result = Fingerprint.app_frame?(path)
17
+ @global_path_mutex.synchronize do
18
+ # Cap cache to prevent unbounded growth across process lifetime
19
+ @global_path_cache.clear if @global_path_cache.size > 2000
20
+ @global_path_cache[path] = result
21
+ end
22
+ result
23
+ end
24
+ end
25
+
5
26
  def initialize(request_segments:)
6
27
  @request_segments = request_segments
7
28
  @call_stack = []
8
- @path_cache = {}
9
29
  @started = false
30
+ @depth = 0
10
31
 
11
32
  @tracepoint = TracePoint.new(:call, :return) do |tp|
12
33
  case tp.event
@@ -36,14 +57,22 @@ module Catpm
36
57
  private
37
58
 
38
59
  def handle_call(tp)
60
+ @depth += 1
61
+
39
62
  path = tp.path
40
- app = app_frame?(path)
63
+ app = self.class.app_frame_cached?(path)
41
64
 
42
65
  unless app
43
66
  @call_stack.push(:skip)
44
67
  return
45
68
  end
46
69
 
70
+ # Prevent excessive nesting from blowing up memory
71
+ if @depth > MAX_CALL_DEPTH
72
+ @call_stack.push(:skip)
73
+ return
74
+ end
75
+
47
76
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
77
  detail = format_detail(tp.defined_class, tp.method_id)
49
78
  index = @request_segments.push_span(type: :code, detail: detail, started_at: started_at)
@@ -51,6 +80,7 @@ module Catpm
51
80
  end
52
81
 
53
82
  def handle_return
83
+ @depth -= 1 if @depth > 0
54
84
  entry = @call_stack.pop
55
85
  return if entry == :skip || entry.nil?
56
86
 
@@ -66,13 +96,6 @@ module Catpm
66
96
  @call_stack.clear
67
97
  end
68
98
 
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
99
  def format_detail(defined_class, method_id)
77
100
  if defined_class.singleton_class?
78
101
  owner = defined_class.attached_object