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.
@@ -3,499 +3,96 @@
3
3
 
4
4
  <%= render "catpm/shared/page_nav", active: "system" %>
5
5
 
6
- <%# ─── Pipeline ─── %>
7
- <h2>Data Pipeline</h2>
8
- <%= section_description("How requests flow from your app to the catpm database.") %>
9
-
10
- <div class="pipeline" data-buffer="<%= @buffer_size %>" data-flushes="<%= @stats[:flushes] %>">
11
- <div class="pipeline-node">
12
- <div class="node-icon"><svg width="28" height="28" 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>
13
- <div class="node-label">Capture</div>
14
- <div class="node-value" style="font-size:14px">Middleware</div>
15
- <div class="node-detail">A Rack middleware wraps each request, measures total duration, and collects SQL, view, cache, and HTTP segments along the way.</div>
16
- </div>
17
- <div class="pipeline-arrow">
18
- <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>
6
+ <%# ─── Runtime Diagnostics ─── %>
7
+ <h2>Runtime</h2>
8
+
9
+ <div class="diag-grid">
10
+ <div class="diag-card">
11
+ <div class="diag-label">Buffer</div>
12
+ <div class="diag-value"><%= @buffer_size %> <span class="diag-unit">events</span></div>
13
+ <div class="diag-detail"><%= number_to_human_size(@buffer_bytes) %> / <%= number_to_human_size(@config.max_buffer_memory) %></div>
19
14
  </div>
20
- <div class="pipeline-node">
21
- <div class="node-icon"><svg width="28" height="28" 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>
22
- <div class="node-label">Buffer</div>
23
- <div class="node-value"><%= @buffer_size %> <span style="font-size:12px;font-weight:400;color:var(--text-2)">events</span></div>
24
- <div class="node-detail">Finished requests are added to a thread-safe in-memory queue. Currently using <%= number_to_human_size(@buffer_bytes) %> of <%= number_to_human_size(@config.max_buffer_memory) %> max.<br><% if @stats[:dropped_events] > 0 %><span style="color:var(--red)"><%= @stats[:dropped_events] %> events dropped (buffer was full)</span><% else %><span class="pulse" style="background:var(--green)"></span> no drops<% end %></div>
15
+ <div class="diag-card">
16
+ <div class="diag-label">Flushes</div>
17
+ <div class="diag-value"><%= @stats[:flushes] %></div>
18
+ <div class="diag-detail">every ~<%= @config.flush_interval %>s</div>
25
19
  </div>
26
- <div class="pipeline-arrow">
27
- <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>
20
+ <div class="diag-card">
21
+ <div class="diag-label">Drops</div>
22
+ <div class="diag-value<%= @stats[:dropped_events] > 0 ? " diag-value--warn" : "" %>"><%= @stats[:dropped_events] %></div>
23
+ <div class="diag-detail"><% if @stats[:dropped_events] > 0 %>buffer was full<% else %><span class="pulse" style="background:var(--green)"></span> none<% end %></div>
28
24
  </div>
29
- <div class="pipeline-node">
30
- <div class="node-icon"><svg width="28" height="28" 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>
31
- <div class="node-label">Flush</div>
32
- <div class="node-value"><%= @stats[:flushes] %> <span style="font-size:12px;font-weight:400;color:var(--text-2)">flushes</span></div>
33
- <div class="node-detail">Every ~<%= @config.flush_interval %>s a background thread drains the buffer, aggregates events into time buckets, and writes them to the database in bulk.<br><% if @stats[:circuit_opens] > 0 %><span style="color:var(--yellow)"><%= @stats[:circuit_opens] %> circuit opens (DB write failures)</span><% else %><span class="pulse" style="background:var(--green)"></span> circuit healthy<% end %></div>
25
+ <div class="diag-card">
26
+ <div class="diag-label">Circuit</div>
27
+ <div class="diag-value<%= @stats[:circuit_opens] > 0 ? " diag-value--warn" : "" %>"><% if @stats[:circuit_opens] > 0 %><%= @stats[:circuit_opens] %> opens<% else %>healthy<% end %></div>
28
+ <div class="diag-detail"><% if @stats[:circuit_opens] > 0 %>DB write failures<% else %><span class="pulse" style="background:var(--green)"></span> no failures<% end %></div>
34
29
  </div>
35
- <div class="pipeline-arrow">
36
- <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>
37
- </div>
38
- <div class="pipeline-node">
39
- <div class="node-icon"><svg width="28" height="28" 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>
40
- <div class="node-label">Database</div>
41
- <div class="node-value" style="font-size:14px">Storage</div>
42
- <div class="node-detail">Aggregated stats are stored as time buckets, with detailed samples and error fingerprints.<br><%= @oldest_bucket ? "Data since #{@oldest_bucket.strftime('%b %-d')}, retained #{@config.retention_period ? "#{(@config.retention_period / 1.day).to_i} days" : "forever"}." : "No data yet." %></div>
30
+ <div class="diag-card">
31
+ <div class="diag-label">Data</div>
32
+ <div class="diag-value"><%= @oldest_bucket ? @oldest_bucket.strftime('%b %-d') : "—" %></div>
33
+ <div class="diag-detail"><%= @oldest_bucket ? "retained #{@config.retention_period ? "#{(@config.retention_period / 1.day).to_i} days" : "forever"}" : "no data yet" %></div>
43
34
  </div>
44
35
  </div>
45
36
 
46
37
  <%# ─── Storage ─── %>
47
- <h2>Database Storage</h2>
48
- <%= section_description("Disk space used by catpm tables in your database.") %>
49
-
50
38
  <% total_bytes = @table_sizes.sum { |t| t[:total_bytes].to_i } %>
51
39
  <% has_bytes = @table_sizes.any? { |t| t[:total_bytes] } %>
40
+ <% total_rows = @table_sizes.sum { |t| t[:row_estimate].to_i } %>
41
+ <% max_rows = @table_sizes.map { |t| t[:row_estimate].to_i }.max %>
42
+ <% max_rows = 1 if max_rows == 0 %>
52
43
 
53
- <% if has_bytes %>
54
- <div class="storage-total">
55
- Total: <strong><%= number_to_human_size(total_bytes) %></strong>
56
- </div>
57
- <% end %>
44
+ <h2>Storage</h2>
58
45
 
59
- <div class="config-table">
60
- <div class="table-scroll">
61
- <table>
62
- <thead>
63
- <tr>
64
- <th>Table</th>
65
- <th style="text-align:right">Rows</th>
66
- <% if has_bytes %>
67
- <th style="text-align:right">Size</th>
68
- <% end %>
69
- </tr>
70
- </thead>
71
- <tbody>
72
- <% @table_sizes.each do |t| %>
73
- <tr>
74
- <td class="mono"><%= t[:name] %></td>
75
- <td class="mono" style="text-align:right"><%= number_with_delimiter(t[:row_estimate].to_i) %></td>
76
- <% if has_bytes %>
77
- <td class="mono" style="text-align:right"><%= number_to_human_size(t[:total_bytes].to_i) %></td>
78
- <% end %>
79
- </tr>
80
- <% end %>
46
+ <div class="storage-card">
47
+ <div class="storage-header">
81
48
  <% if has_bytes %>
82
- <tr style="font-weight:600;border-top:2px solid var(--border)">
83
- <td>Total</td>
84
- <td class="mono" style="text-align:right"><%= number_with_delimiter(@table_sizes.sum { |t| t[:row_estimate].to_i }) %></td>
85
- <td class="mono" style="text-align:right"><%= number_to_human_size(total_bytes) %></td>
86
- </tr>
87
- <% end %>
88
- </tbody>
89
- </table>
90
- </div>
91
- </div>
92
-
93
- <%# ─── Configuration ─── %>
94
- <h2>Configuration</h2>
95
- <%= section_description("Current catpm settings. Configure via initializer.") %>
96
- <div class="config-table">
97
- <div class="table-scroll">
98
- <table>
99
- <thead>
100
- <tr>
101
- <th>Setting</th>
102
- <th>Value</th>
103
- </tr>
104
- </thead>
105
- <tbody>
106
- <tr><td colspan="2" style="font-weight:600;color:var(--text-2);font-size:11px;text-transform:uppercase;letter-spacing:0.5px;padding-top:12px">Instrumentation</td></tr>
107
- <tr><td>Enabled</td><td class="mono"><%= @config.enabled %></td></tr>
108
- <tr><td>HTTP Instrumentation</td><td class="mono"><%= @config.instrument_http %></td></tr>
109
- <tr><td>Job Instrumentation</td><td class="mono"><%= @config.instrument_jobs %></td></tr>
110
- <tr><td>Segment Instrumentation</td><td class="mono"><%= @config.instrument_segments %></td></tr>
111
- <tr><td>Net::HTTP Instrumentation</td><td class="mono"><%= @config.instrument_net_http %></td></tr>
112
- <tr><td>Middleware Stack Instrumentation</td><td class="mono"><%= @config.instrument_middleware_stack %></td></tr>
113
- <tr><td>Max Segments / Request</td><td class="mono"><%= @config.max_segments_per_request %></td></tr>
114
- <tr><td>Segment Source Threshold</td><td class="mono"><%= @config.segment_source_threshold == 0.0 ? "0 (always capture)" : "#{@config.segment_source_threshold}ms" %></td></tr>
115
- <tr><td>Max SQL Length</td><td class="mono"><%= @config.max_sql_length %> chars</td></tr>
116
- <tr><td>Slow Threshold</td><td class="mono"><%= @config.slow_threshold %>ms</td></tr>
117
- <% if @config.slow_threshold_per_kind.any? %>
118
- <tr><td>Slow Threshold (per kind)</td><td class="mono"><%= @config.slow_threshold_per_kind.map { |k, v| "#{k}: #{v}ms" }.join(", ") %></td></tr>
49
+ <span class="storage-total"><%= number_to_human_size(total_bytes) %></span>
50
+ <span class="storage-meta"><%= number_with_delimiter(total_rows) %> rows across <%= @table_sizes.size %> tables</span>
51
+ <% else %>
52
+ <span class="storage-total"><%= number_with_delimiter(total_rows) %> rows</span>
53
+ <span class="storage-meta"><%= @table_sizes.size %> tables</span>
119
54
  <% end %>
120
- <tr><td>Ignored Targets</td><td class="mono"><%= @config.ignored_targets.any? ? @config.ignored_targets.map(&:to_s).join(", ") : "none" %></td></tr>
121
-
122
- <tr><td colspan="2" style="font-weight:600;color:var(--text-2);font-size:11px;text-transform:uppercase;letter-spacing:0.5px;padding-top:12px">Sampling</td></tr>
123
- <tr><td>Random Sample Rate</td><td class="mono">1 in <%= @config.random_sample_rate %></td></tr>
124
- <tr><td>Max Random Samples / Endpoint</td><td class="mono"><%= @config.max_random_samples_per_endpoint %></td></tr>
125
- <tr><td>Max Slow Samples / Endpoint</td><td class="mono"><%= @config.max_slow_samples_per_endpoint %></td></tr>
126
-
127
- <tr><td colspan="2" style="font-weight:600;color:var(--text-2);font-size:11px;text-transform:uppercase;letter-spacing:0.5px;padding-top:12px">Buffer & Flush</td></tr>
128
- <tr><td>Max Buffer Memory</td><td class="mono"><%= number_to_human_size(@config.max_buffer_memory) %></td></tr>
129
- <tr><td>Flush Interval</td><td class="mono"><%= @config.flush_interval %>s (&plusmn;<%= @config.flush_jitter %>s jitter)</td></tr>
130
- <tr><td>Persistence Batch Size</td><td class="mono"><%= @config.persistence_batch_size %></td></tr>
131
- <tr><td>Shutdown Timeout</td><td class="mono"><%= @config.shutdown_timeout %>s</td></tr>
132
-
133
- <tr><td colspan="2" style="font-weight:600;color:var(--text-2);font-size:11px;text-transform:uppercase;letter-spacing:0.5px;padding-top:12px">Retention & Downsampling</td></tr>
134
- <tr><td>Retention Period</td><td class="mono"><%= @config.retention_period ? "#{(@config.retention_period / 1.day).to_i} days" : "forever" %></td></tr>
135
- <tr><td>Cleanup Interval</td><td class="mono"><%= (@config.cleanup_interval / 1.minute).to_i %> min</td></tr>
136
- <tr><td>Bucket Sizes</td><td class="mono"><%= @config.bucket_sizes.map { |k, v| "#{k}: #{v < 1.hour ? "#{(v / 1.minute).to_i}min" : v < 1.day ? "#{(v / 1.hour).to_i}h" : "#{(v / 1.day).to_i}d"}" }.join(", ") %></td></tr>
137
-
138
- <tr><td colspan="2" style="font-weight:600;color:var(--text-2);font-size:11px;text-transform:uppercase;letter-spacing:0.5px;padding-top:12px">Error Tracking</td></tr>
139
- <tr><td>Max Error Contexts</td><td class="mono"><%= @config.max_error_contexts %></td></tr>
140
- <tr><td>Backtrace Lines</td><td class="mono"><%= @config.backtrace_lines %></td></tr>
141
-
142
- <tr><td colspan="2" style="font-weight:600;color:var(--text-2);font-size:11px;text-transform:uppercase;letter-spacing:0.5px;padding-top:12px">Resilience</td></tr>
143
- <tr><td>Circuit Breaker Threshold</td><td class="mono"><%= @config.circuit_breaker_failure_threshold %> failures</td></tr>
144
- <tr><td>Circuit Breaker Recovery</td><td class="mono"><%= @config.circuit_breaker_recovery_timeout %>s</td></tr>
145
- <% if ActiveRecord::Base.connection.adapter_name.downcase.include?("sqlite") %>
146
- <tr><td>SQLite Busy Timeout</td><td class="mono"><%= @config.sqlite_busy_timeout %>ms</td></tr>
55
+ </div>
56
+ <div class="storage-bars">
57
+ <% @table_sizes.sort_by { |t| -t[:row_estimate].to_i }.each do |t| %>
58
+ <% row_count = t[:row_estimate].to_i %>
59
+ <% pct = (row_count.to_f / max_rows * 100).round(1) %>
60
+ <div class="storage-row">
61
+ <span class="storage-name mono"><%= t[:name].sub('catpm_', '') %></span>
62
+ <div class="storage-bar-track">
63
+ <div class="storage-bar-fill" style="width:<%= [pct, 2].max %>%"></div>
64
+ </div>
65
+ <span class="storage-row-stat mono"><%= number_with_delimiter(row_count) %></span>
66
+ <% if has_bytes %>
67
+ <span class="storage-row-size mono"><%= number_to_human_size(t[:total_bytes].to_i) %></span>
68
+ <% end %>
69
+ </div>
147
70
  <% end %>
148
-
149
- <tr><td colspan="2" style="font-weight:600;color:var(--text-2);font-size:11px;text-transform:uppercase;letter-spacing:0.5px;padding-top:12px">PII Filtering</td></tr>
150
- <tr><td>Additional Filter Parameters</td><td class="mono"><%= @config.additional_filter_parameters.any? ? @config.additional_filter_parameters.map(&:to_s).join(", ") : "none (Rails defaults only)" %></td></tr>
151
- </tbody>
152
- </table>
153
- </div>
71
+ </div>
154
72
  </div>
155
73
 
156
- <script>
157
- (function() {
158
- var el = document.querySelector('.pipeline');
159
- if (!el) return;
160
-
161
- var pnodes = el.querySelectorAll('.pipeline-node');
162
- if (pnodes.length < 4) return;
163
- var r0 = pnodes[0].getBoundingClientRect();
164
- var r1 = pnodes[1].getBoundingClientRect();
165
- if (r1.top > r0.bottom - 10) return;
166
-
167
- // Find arrow elements between nodes for path routing
168
- var arrows = el.querySelectorAll('.pipeline-arrow');
169
-
170
- var canvas = document.createElement('canvas');
171
- canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:2';
172
- el.style.position = 'relative';
173
- el.style.overflow = 'hidden';
174
- el.appendChild(canvas);
175
-
176
- var ctx = canvas.getContext('2d');
177
- var dpr = window.devicePixelRatio || 1;
178
- var W, H;
179
- var FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
180
-
181
- function resize() {
182
- W = el.offsetWidth; H = el.offsetHeight;
183
- canvas.width = W * dpr; canvas.height = H * dpr;
184
- canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
185
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
186
- }
187
- resize();
188
- if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
189
-
190
- function zones() {
191
- var pr = el.getBoundingClientRect();
192
- var nodeZones = Array.prototype.map.call(pnodes, function(n) {
193
- var r = n.getBoundingClientRect();
194
- return {
195
- l: r.left - pr.left, r: r.right - pr.left,
196
- t: r.top - pr.top, b: r.bottom - pr.top,
197
- cx: (r.left + r.right) / 2 - pr.left,
198
- cy: (r.top + r.bottom) / 2 - pr.top,
199
- w: r.width, h: r.height
200
- };
201
- });
202
- // Arrow centers
203
- var arrowZones = Array.prototype.map.call(arrows, function(a) {
204
- var r = a.getBoundingClientRect();
205
- return {
206
- l: r.left - pr.left, r: r.right - pr.left,
207
- cx: (r.left + r.right) / 2 - pr.left,
208
- cy: (r.top + r.bottom) / 2 - pr.top
209
- };
210
- });
211
- return { nodes: nodeZones, arrows: arrowZones };
212
- }
213
-
214
- var C = { ok: '#1a7f37', error: '#cf222e', slow: '#9a6700', sample: '#8250df' };
215
- var POOL = ['ok','ok','ok','ok','ok','ok','ok','slow','slow','error','sample'];
216
- var SZ = 5;
217
- var MAX_BUF = 36;
218
- var FLUSH_MS = 5000;
219
-
220
- var METHODS = ['GET','GET','GET','GET','POST','POST','PUT','PATCH','DELETE'];
221
- var RPATHS = ['/users','/home','/orders','/api','/settings',
222
- '/reports','/data','/search','/login','/pay','/items','/stats'];
223
-
224
- function randLabel(type) {
225
- var m = METHODS[Math.floor(Math.random() * METHODS.length)];
226
- var p = RPATHS[Math.floor(Math.random() * RPATHS.length)];
227
- if (type === 'slow') return m + ' ' + p + ' ' + (200 + Math.floor(Math.random() * 600)) + 'ms';
228
- if (type === 'error') {
229
- var codes = [500, 502, 503, 422];
230
- return m + ' ' + p + ' ' + codes[Math.floor(Math.random() * codes.length)];
231
- }
232
- return m + ' ' + p;
233
- }
234
-
235
- function rrect(x, y, w, h, r) {
236
- ctx.beginPath();
237
- ctx.moveTo(x + r, y);
238
- ctx.lineTo(x + w - r, y);
239
- ctx.quadraticCurveTo(x + w, y, x + w, y + r);
240
- ctx.lineTo(x + w, y + h - r);
241
- ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
242
- ctx.lineTo(x + r, y + h);
243
- ctx.quadraticCurveTo(x, y + h, x, y + h - r);
244
- ctx.lineTo(x, y + r);
245
- ctx.quadraticCurveTo(x, y, x + r, y);
246
- ctx.closePath();
247
- }
248
-
249
- // Linear interpolation
250
- function lerp(a, b, t) { return a + (b - a) * t; }
251
-
252
- var particles = [];
253
- var batchAcc = 0;
254
- var flushAcc = 0;
255
- var prev = 0;
256
-
257
- function randType() { return POOL[Math.floor(Math.random() * POOL.length)]; }
258
-
259
- // Buffer slots: vertical columns, right-aligned, bottom-to-top, like a queue
260
- function bufRowsPerCol(bz) { return Math.max(1, Math.floor((bz.h - 20) / (SZ + 2))); }
261
- function bufSlot(idx, bz) {
262
- var perCol = bufRowsPerCol(bz);
263
- var col = Math.floor(idx / perCol);
264
- var row = idx % perCol;
265
- return {
266
- x: bz.r - 10 - col * (SZ + 2) - SZ / 2,
267
- y: bz.b - 10 - row * (SZ + 2) - SZ / 2
268
- };
269
- }
270
-
271
- var initBuf = Math.min(parseInt(el.dataset.buffer) || 0, MAX_BUF);
272
-
273
- function seedBuffer(z) {
274
- var buf = z.nodes[1];
275
- for (var i = 0; i < initBuf; i++) {
276
- var s = bufSlot(i, buf);
277
- particles.push({
278
- type: randType(), x: s.x, y: s.y,
279
- state: 1, opacity: 0.8, slotX: s.x, slotY: s.y
280
- });
281
- }
282
- initBuf = 0;
283
- }
284
-
285
- function tick(t) {
286
- if (!prev) { prev = t; requestAnimationFrame(tick); return; }
287
- var dt = Math.min(t - prev, 50);
288
- prev = t;
289
-
290
- var z = zones();
291
- if (z.nodes.length < 4) { requestAnimationFrame(tick); return; }
292
- var cap = z.nodes[0], buf = z.nodes[1], flu = z.nodes[2], db = z.nodes[3];
293
- var arrow0 = z.arrows[0] || { cx: (cap.r + buf.l) / 2, cy: cap.cy };
294
- var laneY = cap.t + cap.h * 0.42;
295
-
296
- if (initBuf > 0) seedBuffer(z);
297
-
298
- var bc = 0;
299
- for (var i = 0; i < particles.length; i++) if (particles[i].state === 1) bc++;
300
-
301
- // Check if previous batch is still lingering at Capture
302
- var captureOccupied = false;
303
- for (var i = 0; i < particles.length; i++) {
304
- if (particles[i].state === 0 && particles[i].phase <= 1) {
305
- captureOccupied = true; break;
306
- }
307
- }
308
-
309
- // --- Batch spawn: 1-5 requests arrive together, every 3-5s ---
310
- batchAcc += dt;
311
- if (batchAcc > 2500 + Math.random() * 1500 && !captureOccupied) {
312
- batchAcc = 0;
313
- var count = Math.min(1 + Math.floor(Math.random() * 5), MAX_BUF - bc);
314
- if (count > 0) {
315
- var spacing = 26;
316
- var yBase = laneY - (count - 1) * spacing / 2;
317
- for (var j = 0; j < count; j++) {
318
- var type = randType();
319
- var label = randLabel(type);
320
- ctx.font = '500 11px ' + FONT;
321
- var lw = ctx.measureText(label).width;
322
- var s = bufSlot(bc + j, buf);
323
- particles.push({
324
- type: type, label: label, labelW: lw,
325
- x: cap.l - 20,
326
- y: yBase + j * spacing,
327
- phase: 0, // 0=enter, 1=linger, 2=smooth glide to slot
328
- lingerTime: 300 + Math.random() * 400,
329
- lingerAcc: 0,
330
- state: 0, opacity: 0, age: 0, progress: 0,
331
- slotX: s.x, slotY: s.y
332
- });
333
- }
334
- }
335
- }
336
-
337
- // --- Flush: routes Buffer → arrow → Flush → arrow → Database ---
338
- flushAcc += dt;
339
- if (flushAcc > FLUSH_MS + Math.random() * 2000) {
340
- flushAcc = 0;
341
- var delay = 0;
342
- for (var i = 0; i < particles.length; i++) {
343
- var p = particles[i];
344
- if (p.state === 1) {
345
- p.state = 2;
346
- p.phase = 0;
347
- p.fx = flu.cx + (Math.random() - 0.5) * 24;
348
- p.fy = flu.t + 18 + Math.random() * (flu.h * 0.35);
349
- p.tx = db.cx + (Math.random() - 0.5) * 40;
350
- p.ty = db.t + 18 + Math.random() * (db.h * 0.4);
351
- p.delay = delay;
352
- p.waited = 0;
353
- p.pauseAcc = 0;
354
- delay += 20 + Math.random() * 15;
355
- }
356
- }
357
- }
358
-
359
- // --- Update ---
360
- for (var i = particles.length - 1; i >= 0; i--) {
361
- var p = particles[i];
362
-
363
- if (p.state === 0) {
364
- p.age = (p.age || 0) + dt;
365
-
366
- if (p.phase === 0) {
367
- // Entering: slide in from left to Capture center
368
- p.opacity = Math.min(0.9, p.opacity + dt * 0.005);
369
- var dx = cap.cx - p.x;
370
- p.x += dx * 0.008 * dt;
371
- if (Math.abs(dx) < 3) {
372
- p.phase = 1;
373
- p.x = cap.cx;
374
- p.lingerAcc = 0;
375
- }
376
- }
377
- else if (p.phase === 1) {
378
- // Lingering at Capture (shorter time)
379
- p.opacity = 0.9;
380
- p.lingerAcc += dt;
381
- if (p.lingerAcc > p.lingerTime) {
382
- p.phase = 2;
383
- p.progress = 0;
384
- // Save start position for path interpolation
385
- p.startX = p.x;
386
- p.startY = p.y;
387
- }
388
- }
389
- else if (p.phase === 2) {
390
- // Smooth glide directly from Capture → slot position
391
- p.progress = Math.min(1, p.progress + dt * 0.0012);
392
- var ease = p.progress * p.progress * (3 - 2 * p.progress); // smoothstep
393
- p.x = lerp(p.startX, p.slotX, ease);
394
- p.y = lerp(p.startY, p.slotY, ease);
395
- if (p.progress >= 1) {
396
- p.state = 1;
397
- p.x = p.slotX;
398
- p.y = p.slotY;
399
- p.opacity = 0.8;
400
- }
401
- }
402
- }
403
- else if (p.state === 1) {
404
- // In buffer — snap to grid, no jiggle
405
- p.x = p.slotX;
406
- p.y = p.slotY;
407
- }
408
- else if (p.state === 2) {
409
- // Flushing: Buffer → arrow → Flush (pause) → arrow → Database
410
- p.waited = (p.waited || 0) + dt;
411
- if (p.waited < p.delay) continue;
412
-
413
- if (p.phase === 0) {
414
- var dx = p.fx - p.x, dy = p.fy - p.y;
415
- if (Math.abs(dx) < 6 && Math.abs(dy) < 6) {
416
- p.phase = 1; p.pauseAcc = 0;
417
- } else {
418
- p.x += dx * 0.0035 * dt;
419
- p.y += dy * 0.0035 * dt;
420
- }
421
- }
422
- else if (p.phase === 1) {
423
- p.pauseAcc += dt;
424
- if (p.pauseAcc > 350 + Math.random() * 200) p.phase = 2;
425
- }
426
- else if (p.phase === 2) {
427
- var dx = p.tx - p.x, dy = p.ty - p.y;
428
- if (Math.abs(dx) < 4 && Math.abs(dy) < 4) {
429
- p.state = 3; p.ds = t;
430
- } else {
431
- p.x += dx * 0.0035 * dt;
432
- p.y += dy * 0.0035 * dt;
433
- }
434
- }
435
- }
436
- else if (p.state === 3) {
437
- var e = t - p.ds;
438
- p.opacity = 0.8 * Math.max(0, 1 - e / 800);
439
- if (p.opacity <= 0.01) { particles.splice(i, 1); continue; }
440
- }
441
- }
442
-
443
- // --- Draw ---
444
- ctx.clearRect(0, 0, W, H);
445
- for (var i = 0; i < particles.length; i++) {
446
- var p = particles[i];
447
- if (p.opacity <= 0.01) continue;
448
-
449
- // Scale: full-size at Capture, shrinks to 0 as it glides toward the buffer slot
450
- var scale = 0;
451
- if (p.state === 0) {
452
- if (p.phase <= 1) {
453
- scale = 1;
454
- } else {
455
- // Shrink based on progress along the glide (0→1)
456
- scale = Math.max(0, 1 - p.progress * 1.5); // fully small by ~67% of the path
457
- }
458
- }
459
-
460
- ctx.globalAlpha = p.opacity;
461
-
462
- if (scale > 0.12 && p.label) {
463
- // Large block with text
464
- var fs = Math.round(11 * scale);
465
- var bw = (p.labelW + 14) * scale;
466
- var bh = 22 * scale;
467
- var rad = 3 * scale;
468
-
469
- ctx.fillStyle = C[p.type];
470
- ctx.globalAlpha = p.opacity * 0.92;
471
- rrect(p.x - bw / 2, p.y - bh / 2, bw, bh, rad);
472
- ctx.fill();
473
-
474
- if (fs >= 7) {
475
- ctx.globalAlpha = p.opacity;
476
- ctx.fillStyle = '#fff';
477
- ctx.font = '500 ' + fs + 'px ' + FONT;
478
- ctx.textAlign = 'center';
479
- ctx.textBaseline = 'middle';
480
- ctx.fillText(p.label, p.x, p.y + 0.5);
481
- }
482
- } else {
483
- // Small cube
484
- ctx.fillStyle = C[p.type];
485
- if (p.state === 3) {
486
- var e = t - p.ds;
487
- var s = SZ * (1 + e * 0.002);
488
- ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s);
489
- } else {
490
- ctx.fillRect(p.x - SZ / 2, p.y - SZ / 2, SZ, SZ);
491
- }
492
- }
493
- }
494
- ctx.globalAlpha = 1;
495
-
496
- requestAnimationFrame(tick);
497
- }
498
-
499
- requestAnimationFrame(tick);
500
- })();
501
- </script>
74
+ <%# ─── Configuration (grouped cards) ─── %>
75
+ <h2>Configuration</h2>
76
+ <%= section_description("Current runtime settings. Set via initializer — each row shows the config parameter name.") %>
77
+
78
+ <% Catpm::ApplicationHelper::CONFIG_METADATA.group_by { |_, m| m[:group] }.each do |group_name, settings| %>
79
+ <% visible = settings.select { |_, m| config_condition_met?(m) } %>
80
+ <% next if visible.empty? %>
81
+ <div class="config-group">
82
+ <h3 class="config-group-title"><%= group_name %></h3>
83
+ <div class="config-group-body">
84
+ <% visible.each do |attr, meta| %>
85
+ <div class="config-item">
86
+ <div class="config-item-header">
87
+ <span class="config-item-label"><%= meta[:label] %></span>
88
+ <span class="config-item-value mono"><%= format_config_value(@config, attr, meta) %></span>
89
+ </div>
90
+ <div class="config-item-meta">
91
+ <code class="config-param">config.<%= attr %></code>
92
+ <span class="config-desc"><%= meta[:desc] %></span>
93
+ </div>
94
+ </div>
95
+ <% end %>
96
+ </div>
97
+ </div>
98
+ <% end %>