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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +222 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/catpm/application.css +15 -0
- data/app/controllers/catpm/application_controller.rb +6 -0
- data/app/controllers/catpm/endpoints_controller.rb +63 -0
- data/app/controllers/catpm/errors_controller.rb +63 -0
- data/app/controllers/catpm/events_controller.rb +89 -0
- data/app/controllers/catpm/samples_controller.rb +13 -0
- data/app/controllers/catpm/status_controller.rb +79 -0
- data/app/controllers/catpm/system_controller.rb +17 -0
- data/app/helpers/catpm/application_helper.rb +264 -0
- data/app/jobs/catpm/application_job.rb +6 -0
- data/app/mailers/catpm/application_mailer.rb +8 -0
- data/app/models/catpm/application_record.rb +7 -0
- data/app/models/catpm/bucket.rb +45 -0
- data/app/models/catpm/error_record.rb +37 -0
- data/app/models/catpm/event_bucket.rb +12 -0
- data/app/models/catpm/event_sample.rb +22 -0
- data/app/models/catpm/sample.rb +26 -0
- data/app/views/catpm/endpoints/_sample_table.html.erb +36 -0
- data/app/views/catpm/endpoints/show.html.erb +124 -0
- data/app/views/catpm/errors/index.html.erb +66 -0
- data/app/views/catpm/errors/show.html.erb +107 -0
- data/app/views/catpm/events/index.html.erb +73 -0
- data/app/views/catpm/events/show.html.erb +86 -0
- data/app/views/catpm/samples/show.html.erb +113 -0
- data/app/views/catpm/shared/_page_nav.html.erb +6 -0
- data/app/views/catpm/shared/_segments_waterfall.html.erb +147 -0
- data/app/views/catpm/status/index.html.erb +124 -0
- data/app/views/catpm/system/index.html.erb +454 -0
- data/app/views/layouts/catpm/application.html.erb +381 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20250601000001_create_catpm_tables.rb +104 -0
- data/lib/catpm/adapter/base.rb +85 -0
- data/lib/catpm/adapter/postgresql.rb +186 -0
- data/lib/catpm/adapter/sqlite.rb +159 -0
- data/lib/catpm/adapter.rb +28 -0
- data/lib/catpm/auto_instrument.rb +145 -0
- data/lib/catpm/buffer.rb +59 -0
- data/lib/catpm/circuit_breaker.rb +60 -0
- data/lib/catpm/collector.rb +320 -0
- data/lib/catpm/configuration.rb +103 -0
- data/lib/catpm/custom_event.rb +37 -0
- data/lib/catpm/engine.rb +39 -0
- data/lib/catpm/errors.rb +6 -0
- data/lib/catpm/event.rb +75 -0
- data/lib/catpm/fingerprint.rb +52 -0
- data/lib/catpm/flusher.rb +462 -0
- data/lib/catpm/lifecycle.rb +76 -0
- data/lib/catpm/middleware.rb +75 -0
- data/lib/catpm/middleware_probe.rb +28 -0
- data/lib/catpm/patches/httpclient.rb +44 -0
- data/lib/catpm/patches/net_http.rb +39 -0
- data/lib/catpm/request_segments.rb +101 -0
- data/lib/catpm/segment_subscribers.rb +242 -0
- data/lib/catpm/span_helpers.rb +51 -0
- data/lib/catpm/stack_sampler.rb +226 -0
- data/lib/catpm/subscribers.rb +47 -0
- data/lib/catpm/tdigest.rb +174 -0
- data/lib/catpm/trace.rb +165 -0
- data/lib/catpm/version.rb +5 -0
- data/lib/catpm.rb +66 -0
- data/lib/generators/catpm/install_generator.rb +36 -0
- data/lib/generators/catpm/templates/initializer.rb.tt +77 -0
- data/lib/tasks/catpm_seed.rake +79 -0
- data/lib/tasks/catpm_tasks.rake +6 -0
- 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 (±<%= @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>
|