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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/app/controllers/catpm/application_controller.rb +15 -0
- data/app/controllers/catpm/system_controller.rb +4 -0
- data/app/helpers/catpm/application_helper.rb +142 -0
- data/app/views/catpm/system/index.html.erb +76 -479
- data/app/views/catpm/system/pipeline.html.erb +344 -0
- data/app/views/layouts/catpm/application.html.erb +40 -0
- data/app/views/layouts/catpm/pipeline.html.erb +79 -0
- data/config/routes.rb +1 -0
- data/lib/catpm/buffer.rb +22 -2
- data/lib/catpm/call_tracer.rb +32 -9
- data/lib/catpm/flusher.rb +44 -63
- data/lib/catpm/request_segments.rb +6 -1
- data/lib/catpm/version.rb +1 -1
- metadata +3 -1
|
@@ -3,499 +3,96 @@
|
|
|
3
3
|
|
|
4
4
|
<%= render "catpm/shared/page_nav", active: "system" %>
|
|
5
5
|
|
|
6
|
-
<%# ───
|
|
7
|
-
<h2>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
<div class="
|
|
11
|
-
|
|
12
|
-
<div class="
|
|
13
|
-
<div class="
|
|
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="
|
|
21
|
-
<div class="
|
|
22
|
-
<div class="
|
|
23
|
-
<div class="
|
|
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="
|
|
27
|
-
<
|
|
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="
|
|
30
|
-
<div class="
|
|
31
|
-
<div class="
|
|
32
|
-
<div class="
|
|
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="
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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="
|
|
60
|
-
<div class="
|
|
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
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 %>
|