catpm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +222 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/catpm/application.css +15 -0
  6. data/app/controllers/catpm/application_controller.rb +6 -0
  7. data/app/controllers/catpm/endpoints_controller.rb +63 -0
  8. data/app/controllers/catpm/errors_controller.rb +63 -0
  9. data/app/controllers/catpm/events_controller.rb +89 -0
  10. data/app/controllers/catpm/samples_controller.rb +13 -0
  11. data/app/controllers/catpm/status_controller.rb +79 -0
  12. data/app/controllers/catpm/system_controller.rb +17 -0
  13. data/app/helpers/catpm/application_helper.rb +264 -0
  14. data/app/jobs/catpm/application_job.rb +6 -0
  15. data/app/mailers/catpm/application_mailer.rb +8 -0
  16. data/app/models/catpm/application_record.rb +7 -0
  17. data/app/models/catpm/bucket.rb +45 -0
  18. data/app/models/catpm/error_record.rb +37 -0
  19. data/app/models/catpm/event_bucket.rb +12 -0
  20. data/app/models/catpm/event_sample.rb +22 -0
  21. data/app/models/catpm/sample.rb +26 -0
  22. data/app/views/catpm/endpoints/_sample_table.html.erb +36 -0
  23. data/app/views/catpm/endpoints/show.html.erb +124 -0
  24. data/app/views/catpm/errors/index.html.erb +66 -0
  25. data/app/views/catpm/errors/show.html.erb +107 -0
  26. data/app/views/catpm/events/index.html.erb +73 -0
  27. data/app/views/catpm/events/show.html.erb +86 -0
  28. data/app/views/catpm/samples/show.html.erb +113 -0
  29. data/app/views/catpm/shared/_page_nav.html.erb +6 -0
  30. data/app/views/catpm/shared/_segments_waterfall.html.erb +147 -0
  31. data/app/views/catpm/status/index.html.erb +124 -0
  32. data/app/views/catpm/system/index.html.erb +454 -0
  33. data/app/views/layouts/catpm/application.html.erb +381 -0
  34. data/config/routes.rb +19 -0
  35. data/db/migrate/20250601000001_create_catpm_tables.rb +104 -0
  36. data/lib/catpm/adapter/base.rb +85 -0
  37. data/lib/catpm/adapter/postgresql.rb +186 -0
  38. data/lib/catpm/adapter/sqlite.rb +159 -0
  39. data/lib/catpm/adapter.rb +28 -0
  40. data/lib/catpm/auto_instrument.rb +145 -0
  41. data/lib/catpm/buffer.rb +59 -0
  42. data/lib/catpm/circuit_breaker.rb +60 -0
  43. data/lib/catpm/collector.rb +320 -0
  44. data/lib/catpm/configuration.rb +103 -0
  45. data/lib/catpm/custom_event.rb +37 -0
  46. data/lib/catpm/engine.rb +39 -0
  47. data/lib/catpm/errors.rb +6 -0
  48. data/lib/catpm/event.rb +75 -0
  49. data/lib/catpm/fingerprint.rb +52 -0
  50. data/lib/catpm/flusher.rb +462 -0
  51. data/lib/catpm/lifecycle.rb +76 -0
  52. data/lib/catpm/middleware.rb +75 -0
  53. data/lib/catpm/middleware_probe.rb +28 -0
  54. data/lib/catpm/patches/httpclient.rb +44 -0
  55. data/lib/catpm/patches/net_http.rb +39 -0
  56. data/lib/catpm/request_segments.rb +101 -0
  57. data/lib/catpm/segment_subscribers.rb +242 -0
  58. data/lib/catpm/span_helpers.rb +51 -0
  59. data/lib/catpm/stack_sampler.rb +226 -0
  60. data/lib/catpm/subscribers.rb +47 -0
  61. data/lib/catpm/tdigest.rb +174 -0
  62. data/lib/catpm/trace.rb +165 -0
  63. data/lib/catpm/version.rb +5 -0
  64. data/lib/catpm.rb +66 -0
  65. data/lib/generators/catpm/install_generator.rb +36 -0
  66. data/lib/generators/catpm/templates/initializer.rb.tt +77 -0
  67. data/lib/tasks/catpm_seed.rake +79 -0
  68. data/lib/tasks/catpm_tasks.rake +6 -0
  69. metadata +123 -0
@@ -0,0 +1,454 @@
1
+ <% content_for :title, "System" %>
2
+ <% content_for :subtitle, "Diagnostics & Configuration" %>
3
+
4
+ <%= render "catpm/shared/page_nav", active: "system" %>
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>
19
+ </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>
25
+ </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>
28
+ </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>
34
+ </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"><%= @bucket_count %> <span style="font-size:12px;font-weight:400;color:var(--text-2)">buckets</span></div>
42
+ <div class="node-detail">Aggregated stats are stored as time buckets, plus <%= @sample_count %> detailed samples and <%= @error_count %> error fingerprints.<br><%= @oldest_bucket ? "Data since #{@oldest_bucket.strftime('%b %-d')}, retained #{(@config.retention_period / 1.day).to_i} days." : "No data yet." %></div>
43
+ </div>
44
+ </div>
45
+
46
+ <%# ─── Configuration ─── %>
47
+ <h2>Configuration</h2>
48
+ <%= section_description("Current catpm settings. Configure via initializer.") %>
49
+ <div class="config-table">
50
+ <div class="table-scroll">
51
+ <table>
52
+ <thead>
53
+ <tr>
54
+ <th>Setting</th>
55
+ <th>Value</th>
56
+ </tr>
57
+ </thead>
58
+ <tbody>
59
+ <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>
60
+ <tr><td>Enabled</td><td class="mono"><%= @config.enabled %></td></tr>
61
+ <tr><td>HTTP Instrumentation</td><td class="mono"><%= @config.instrument_http %></td></tr>
62
+ <tr><td>Job Instrumentation</td><td class="mono"><%= @config.instrument_jobs %></td></tr>
63
+ <tr><td>Segment Instrumentation</td><td class="mono"><%= @config.instrument_segments %></td></tr>
64
+ <tr><td>Net::HTTP Instrumentation</td><td class="mono"><%= @config.instrument_net_http %></td></tr>
65
+ <tr><td>Middleware Stack Instrumentation</td><td class="mono"><%= @config.instrument_middleware_stack %></td></tr>
66
+ <tr><td>Max Segments / Request</td><td class="mono"><%= @config.max_segments_per_request %></td></tr>
67
+ <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>
68
+ <tr><td>Max SQL Length</td><td class="mono"><%= @config.max_sql_length %> chars</td></tr>
69
+ <tr><td>Slow Threshold</td><td class="mono"><%= @config.slow_threshold %>ms</td></tr>
70
+ <% if @config.slow_threshold_per_kind.any? %>
71
+ <tr><td>Slow Threshold (per kind)</td><td class="mono"><%= @config.slow_threshold_per_kind.map { |k, v| "#{k}: #{v}ms" }.join(", ") %></td></tr>
72
+ <% end %>
73
+ <tr><td>Ignored Targets</td><td class="mono"><%= @config.ignored_targets.any? ? @config.ignored_targets.map(&:to_s).join(", ") : "none" %></td></tr>
74
+
75
+ <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>
76
+ <tr><td>Random Sample Rate</td><td class="mono">1 in <%= @config.random_sample_rate %></td></tr>
77
+ <tr><td>Max Random Samples / Endpoint</td><td class="mono"><%= @config.max_random_samples_per_endpoint %></td></tr>
78
+ <tr><td>Max Slow Samples / Endpoint</td><td class="mono"><%= @config.max_slow_samples_per_endpoint %></td></tr>
79
+
80
+ <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>
81
+ <tr><td>Max Buffer Memory</td><td class="mono"><%= number_to_human_size(@config.max_buffer_memory) %></td></tr>
82
+ <tr><td>Flush Interval</td><td class="mono"><%= @config.flush_interval %>s (&plusmn;<%= @config.flush_jitter %>s jitter)</td></tr>
83
+ <tr><td>Persistence Batch Size</td><td class="mono"><%= @config.persistence_batch_size %></td></tr>
84
+ <tr><td>Shutdown Timeout</td><td class="mono"><%= @config.shutdown_timeout %>s</td></tr>
85
+
86
+ <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>
87
+ <tr><td>Retention Period</td><td class="mono"><%= (@config.retention_period / 1.day).to_i %> days</td></tr>
88
+ <tr><td>Cleanup Interval</td><td class="mono"><%= (@config.cleanup_interval / 1.minute).to_i %> min</td></tr>
89
+ <tr><td>Bucket Sizes</td><td class="mono">recent: <%= (@config.bucket_sizes[:recent] / 1.minute).to_i %>min, medium: <%= (@config.bucket_sizes[:medium] / 1.minute).to_i %>min, old: <%= (@config.bucket_sizes[:old] / 1.hour).to_i %>h</td></tr>
90
+
91
+ <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>
92
+ <tr><td>Max Error Contexts</td><td class="mono"><%= @config.max_error_contexts %></td></tr>
93
+ <tr><td>Backtrace Lines</td><td class="mono"><%= @config.backtrace_lines %></td></tr>
94
+
95
+ <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>
96
+ <tr><td>Circuit Breaker Threshold</td><td class="mono"><%= @config.circuit_breaker_failure_threshold %> failures</td></tr>
97
+ <tr><td>Circuit Breaker Recovery</td><td class="mono"><%= @config.circuit_breaker_recovery_timeout %>s</td></tr>
98
+ <% if ActiveRecord::Base.connection.adapter_name.downcase.include?("sqlite") %>
99
+ <tr><td>SQLite Busy Timeout</td><td class="mono"><%= @config.sqlite_busy_timeout %>ms</td></tr>
100
+ <% end %>
101
+
102
+ <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>
103
+ <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>
104
+ </tbody>
105
+ </table>
106
+ </div>
107
+ </div>
108
+
109
+ <script>
110
+ (function() {
111
+ var el = document.querySelector('.pipeline');
112
+ if (!el) return;
113
+
114
+ var pnodes = el.querySelectorAll('.pipeline-node');
115
+ if (pnodes.length < 4) return;
116
+ var r0 = pnodes[0].getBoundingClientRect();
117
+ var r1 = pnodes[1].getBoundingClientRect();
118
+ if (r1.top > r0.bottom - 10) return;
119
+
120
+ // Find arrow elements between nodes for path routing
121
+ var arrows = el.querySelectorAll('.pipeline-arrow');
122
+
123
+ var canvas = document.createElement('canvas');
124
+ canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:2';
125
+ el.style.position = 'relative';
126
+ el.style.overflow = 'hidden';
127
+ el.appendChild(canvas);
128
+
129
+ var ctx = canvas.getContext('2d');
130
+ var dpr = window.devicePixelRatio || 1;
131
+ var W, H;
132
+ var FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
133
+
134
+ function resize() {
135
+ W = el.offsetWidth; H = el.offsetHeight;
136
+ canvas.width = W * dpr; canvas.height = H * dpr;
137
+ canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
138
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
139
+ }
140
+ resize();
141
+ if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
142
+
143
+ function zones() {
144
+ var pr = el.getBoundingClientRect();
145
+ var nodeZones = Array.prototype.map.call(pnodes, function(n) {
146
+ var r = n.getBoundingClientRect();
147
+ return {
148
+ l: r.left - pr.left, r: r.right - pr.left,
149
+ t: r.top - pr.top, b: r.bottom - pr.top,
150
+ cx: (r.left + r.right) / 2 - pr.left,
151
+ cy: (r.top + r.bottom) / 2 - pr.top,
152
+ w: r.width, h: r.height
153
+ };
154
+ });
155
+ // Arrow centers
156
+ var arrowZones = Array.prototype.map.call(arrows, function(a) {
157
+ var r = a.getBoundingClientRect();
158
+ return {
159
+ l: r.left - pr.left, r: r.right - pr.left,
160
+ cx: (r.left + r.right) / 2 - pr.left,
161
+ cy: (r.top + r.bottom) / 2 - pr.top
162
+ };
163
+ });
164
+ return { nodes: nodeZones, arrows: arrowZones };
165
+ }
166
+
167
+ var C = { ok: '#1a7f37', error: '#cf222e', slow: '#9a6700', sample: '#8250df' };
168
+ var POOL = ['ok','ok','ok','ok','ok','ok','ok','slow','slow','error','sample'];
169
+ var SZ = 5;
170
+ var MAX_BUF = 36;
171
+ var FLUSH_MS = 5000;
172
+
173
+ var METHODS = ['GET','GET','GET','GET','POST','POST','PUT','PATCH','DELETE'];
174
+ var RPATHS = ['/users','/home','/orders','/api','/settings',
175
+ '/reports','/data','/search','/login','/pay','/items','/stats'];
176
+
177
+ function randLabel(type) {
178
+ var m = METHODS[Math.floor(Math.random() * METHODS.length)];
179
+ var p = RPATHS[Math.floor(Math.random() * RPATHS.length)];
180
+ if (type === 'slow') return m + ' ' + p + ' ' + (200 + Math.floor(Math.random() * 600)) + 'ms';
181
+ if (type === 'error') {
182
+ var codes = [500, 502, 503, 422];
183
+ return m + ' ' + p + ' ' + codes[Math.floor(Math.random() * codes.length)];
184
+ }
185
+ return m + ' ' + p;
186
+ }
187
+
188
+ function rrect(x, y, w, h, r) {
189
+ ctx.beginPath();
190
+ ctx.moveTo(x + r, y);
191
+ ctx.lineTo(x + w - r, y);
192
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
193
+ ctx.lineTo(x + w, y + h - r);
194
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
195
+ ctx.lineTo(x + r, y + h);
196
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
197
+ ctx.lineTo(x, y + r);
198
+ ctx.quadraticCurveTo(x, y, x + r, y);
199
+ ctx.closePath();
200
+ }
201
+
202
+ // Linear interpolation
203
+ function lerp(a, b, t) { return a + (b - a) * t; }
204
+
205
+ var particles = [];
206
+ var batchAcc = 0;
207
+ var flushAcc = 0;
208
+ var prev = 0;
209
+
210
+ function randType() { return POOL[Math.floor(Math.random() * POOL.length)]; }
211
+
212
+ // Buffer slots: vertical columns, right-aligned, bottom-to-top, like a queue
213
+ function bufRowsPerCol(bz) { return Math.max(1, Math.floor((bz.h - 20) / (SZ + 2))); }
214
+ function bufSlot(idx, bz) {
215
+ var perCol = bufRowsPerCol(bz);
216
+ var col = Math.floor(idx / perCol);
217
+ var row = idx % perCol;
218
+ return {
219
+ x: bz.r - 10 - col * (SZ + 2) - SZ / 2,
220
+ y: bz.b - 10 - row * (SZ + 2) - SZ / 2
221
+ };
222
+ }
223
+
224
+ var initBuf = Math.min(parseInt(el.dataset.buffer) || 0, MAX_BUF);
225
+
226
+ function seedBuffer(z) {
227
+ var buf = z.nodes[1];
228
+ for (var i = 0; i < initBuf; i++) {
229
+ var s = bufSlot(i, buf);
230
+ particles.push({
231
+ type: randType(), x: s.x, y: s.y,
232
+ state: 1, opacity: 0.8, slotX: s.x, slotY: s.y
233
+ });
234
+ }
235
+ initBuf = 0;
236
+ }
237
+
238
+ function tick(t) {
239
+ if (!prev) { prev = t; requestAnimationFrame(tick); return; }
240
+ var dt = Math.min(t - prev, 50);
241
+ prev = t;
242
+
243
+ var z = zones();
244
+ if (z.nodes.length < 4) { requestAnimationFrame(tick); return; }
245
+ var cap = z.nodes[0], buf = z.nodes[1], flu = z.nodes[2], db = z.nodes[3];
246
+ var arrow0 = z.arrows[0] || { cx: (cap.r + buf.l) / 2, cy: cap.cy };
247
+ var laneY = cap.t + cap.h * 0.42;
248
+
249
+ if (initBuf > 0) seedBuffer(z);
250
+
251
+ var bc = 0;
252
+ for (var i = 0; i < particles.length; i++) if (particles[i].state === 1) bc++;
253
+
254
+ // Check if previous batch is still lingering at Capture
255
+ var captureOccupied = false;
256
+ for (var i = 0; i < particles.length; i++) {
257
+ if (particles[i].state === 0 && particles[i].phase <= 1) {
258
+ captureOccupied = true; break;
259
+ }
260
+ }
261
+
262
+ // --- Batch spawn: 1-5 requests arrive together, every 3-5s ---
263
+ batchAcc += dt;
264
+ if (batchAcc > 2500 + Math.random() * 1500 && !captureOccupied) {
265
+ batchAcc = 0;
266
+ var count = Math.min(1 + Math.floor(Math.random() * 5), MAX_BUF - bc);
267
+ if (count > 0) {
268
+ var spacing = 26;
269
+ var yBase = laneY - (count - 1) * spacing / 2;
270
+ for (var j = 0; j < count; j++) {
271
+ var type = randType();
272
+ var label = randLabel(type);
273
+ ctx.font = '500 11px ' + FONT;
274
+ var lw = ctx.measureText(label).width;
275
+ var s = bufSlot(bc + j, buf);
276
+ particles.push({
277
+ type: type, label: label, labelW: lw,
278
+ x: cap.l - 20,
279
+ y: yBase + j * spacing,
280
+ phase: 0, // 0=enter, 1=linger, 2=smooth glide to slot
281
+ lingerTime: 300 + Math.random() * 400,
282
+ lingerAcc: 0,
283
+ state: 0, opacity: 0, age: 0, progress: 0,
284
+ slotX: s.x, slotY: s.y
285
+ });
286
+ }
287
+ }
288
+ }
289
+
290
+ // --- Flush: routes Buffer → arrow → Flush → arrow → Database ---
291
+ flushAcc += dt;
292
+ if (flushAcc > FLUSH_MS + Math.random() * 2000) {
293
+ flushAcc = 0;
294
+ var delay = 0;
295
+ for (var i = 0; i < particles.length; i++) {
296
+ var p = particles[i];
297
+ if (p.state === 1) {
298
+ p.state = 2;
299
+ p.phase = 0;
300
+ p.fx = flu.cx + (Math.random() - 0.5) * 24;
301
+ p.fy = flu.t + 18 + Math.random() * (flu.h * 0.35);
302
+ p.tx = db.cx + (Math.random() - 0.5) * 40;
303
+ p.ty = db.t + 18 + Math.random() * (db.h * 0.4);
304
+ p.delay = delay;
305
+ p.waited = 0;
306
+ p.pauseAcc = 0;
307
+ delay += 20 + Math.random() * 15;
308
+ }
309
+ }
310
+ }
311
+
312
+ // --- Update ---
313
+ for (var i = particles.length - 1; i >= 0; i--) {
314
+ var p = particles[i];
315
+
316
+ if (p.state === 0) {
317
+ p.age = (p.age || 0) + dt;
318
+
319
+ if (p.phase === 0) {
320
+ // Entering: slide in from left to Capture center
321
+ p.opacity = Math.min(0.9, p.opacity + dt * 0.005);
322
+ var dx = cap.cx - p.x;
323
+ p.x += dx * 0.008 * dt;
324
+ if (Math.abs(dx) < 3) {
325
+ p.phase = 1;
326
+ p.x = cap.cx;
327
+ p.lingerAcc = 0;
328
+ }
329
+ }
330
+ else if (p.phase === 1) {
331
+ // Lingering at Capture (shorter time)
332
+ p.opacity = 0.9;
333
+ p.lingerAcc += dt;
334
+ if (p.lingerAcc > p.lingerTime) {
335
+ p.phase = 2;
336
+ p.progress = 0;
337
+ // Save start position for path interpolation
338
+ p.startX = p.x;
339
+ p.startY = p.y;
340
+ }
341
+ }
342
+ else if (p.phase === 2) {
343
+ // Smooth glide directly from Capture → slot position
344
+ p.progress = Math.min(1, p.progress + dt * 0.0012);
345
+ var ease = p.progress * p.progress * (3 - 2 * p.progress); // smoothstep
346
+ p.x = lerp(p.startX, p.slotX, ease);
347
+ p.y = lerp(p.startY, p.slotY, ease);
348
+ if (p.progress >= 1) {
349
+ p.state = 1;
350
+ p.x = p.slotX;
351
+ p.y = p.slotY;
352
+ p.opacity = 0.8;
353
+ }
354
+ }
355
+ }
356
+ else if (p.state === 1) {
357
+ // In buffer — snap to grid, no jiggle
358
+ p.x = p.slotX;
359
+ p.y = p.slotY;
360
+ }
361
+ else if (p.state === 2) {
362
+ // Flushing: Buffer → arrow → Flush (pause) → arrow → Database
363
+ p.waited = (p.waited || 0) + dt;
364
+ if (p.waited < p.delay) continue;
365
+
366
+ if (p.phase === 0) {
367
+ var dx = p.fx - p.x, dy = p.fy - p.y;
368
+ if (Math.abs(dx) < 6 && Math.abs(dy) < 6) {
369
+ p.phase = 1; p.pauseAcc = 0;
370
+ } else {
371
+ p.x += dx * 0.0035 * dt;
372
+ p.y += dy * 0.0035 * dt;
373
+ }
374
+ }
375
+ else if (p.phase === 1) {
376
+ p.pauseAcc += dt;
377
+ if (p.pauseAcc > 350 + Math.random() * 200) p.phase = 2;
378
+ }
379
+ else if (p.phase === 2) {
380
+ var dx = p.tx - p.x, dy = p.ty - p.y;
381
+ if (Math.abs(dx) < 4 && Math.abs(dy) < 4) {
382
+ p.state = 3; p.ds = t;
383
+ } else {
384
+ p.x += dx * 0.0035 * dt;
385
+ p.y += dy * 0.0035 * dt;
386
+ }
387
+ }
388
+ }
389
+ else if (p.state === 3) {
390
+ var e = t - p.ds;
391
+ p.opacity = 0.8 * Math.max(0, 1 - e / 800);
392
+ if (p.opacity <= 0.01) { particles.splice(i, 1); continue; }
393
+ }
394
+ }
395
+
396
+ // --- Draw ---
397
+ ctx.clearRect(0, 0, W, H);
398
+ for (var i = 0; i < particles.length; i++) {
399
+ var p = particles[i];
400
+ if (p.opacity <= 0.01) continue;
401
+
402
+ // Scale: full-size at Capture, shrinks to 0 as it glides toward the buffer slot
403
+ var scale = 0;
404
+ if (p.state === 0) {
405
+ if (p.phase <= 1) {
406
+ scale = 1;
407
+ } else {
408
+ // Shrink based on progress along the glide (0→1)
409
+ scale = Math.max(0, 1 - p.progress * 1.5); // fully small by ~67% of the path
410
+ }
411
+ }
412
+
413
+ ctx.globalAlpha = p.opacity;
414
+
415
+ if (scale > 0.12 && p.label) {
416
+ // Large block with text
417
+ var fs = Math.round(11 * scale);
418
+ var bw = (p.labelW + 14) * scale;
419
+ var bh = 22 * scale;
420
+ var rad = 3 * scale;
421
+
422
+ ctx.fillStyle = C[p.type];
423
+ ctx.globalAlpha = p.opacity * 0.92;
424
+ rrect(p.x - bw / 2, p.y - bh / 2, bw, bh, rad);
425
+ ctx.fill();
426
+
427
+ if (fs >= 7) {
428
+ ctx.globalAlpha = p.opacity;
429
+ ctx.fillStyle = '#fff';
430
+ ctx.font = '500 ' + fs + 'px ' + FONT;
431
+ ctx.textAlign = 'center';
432
+ ctx.textBaseline = 'middle';
433
+ ctx.fillText(p.label, p.x, p.y + 0.5);
434
+ }
435
+ } else {
436
+ // Small cube
437
+ ctx.fillStyle = C[p.type];
438
+ if (p.state === 3) {
439
+ var e = t - p.ds;
440
+ var s = SZ * (1 + e * 0.002);
441
+ ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s);
442
+ } else {
443
+ ctx.fillRect(p.x - SZ / 2, p.y - SZ / 2, SZ, SZ);
444
+ }
445
+ }
446
+ }
447
+ ctx.globalAlpha = 1;
448
+
449
+ requestAnimationFrame(tick);
450
+ }
451
+
452
+ requestAnimationFrame(tick);
453
+ })();
454
+ </script>