rails_trace_viewer 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ee7089d7fb74980ab3da97dff49ad0db37fca991d11c26deea295884f53d507e
4
+ data.tar.gz: c28776d4e3cd56a26cdd9cd9a30fe9b36079149e1614582c8a00f779109f0dfe
5
+ SHA512:
6
+ metadata.gz: 119bdb43c3f579398839a527253273a740aa7039f05b7b9918aefd3abda410fb51d44154d04349d4fa3a33860e84a66381506cb08cb1efdfe1e72388204db843
7
+ data.tar.gz: 3cf763350d1b8df9bb775609fb5be65d84c8014f2c161b23b765dc32faa4876636f37fe20b9bf94361789236763d7c3c41253d7d34cbfda11dea9f866a4d82a1
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-11-02
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Aditya-JOSHย 
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,224 @@
1
+ # Rails Trace Viewer
2
+
3
+ An educational and debugging tool for Ruby on Rails to visualize the request lifecycle in real-time.
4
+
5
+ Rails Trace Viewer provides a beautiful, interactive Call Stack Tree that visualizes how your Rails application processes requests. It traces the flow from the Controller through Models, Views, SQL Queries, and even across process boundaries into Sidekiq Background Jobs.
6
+
7
+ ---
8
+
9
+ ## ๐ŸŽฏ Purpose
10
+
11
+ This gem is designed for beginners and advanced developers alike to:
12
+
13
+ - Visualize the **"Magic"**: See exactly what happens when you hit a route.
14
+ - **Debug Distributed Traces**: Watch a request enqueue a Sidekiq job and follow that execution into the worker process in a single connected tree.
15
+ - **Spot Performance Issues**: Identify N+1 queries, slow renders, or redundant method calls.
16
+
17
+ ---
18
+
19
+ ## โœจ Key Features
20
+
21
+ - ๐Ÿ” **Real-time Visualization**: Traces appear instantly via WebSockets.
22
+ - ๐Ÿงฉ **Distributed Tracing**: Seamlessly links Controller actions to Sidekiq Jobs (enqueue & perform).
23
+ - ๐Ÿ“Š **Deep Inspection**: Click any node to see arguments, SQL binds, file paths, and line numbers.
24
+ - ๐ŸŽจ **Beautiful UI**: Interactive infinite canvas with panning, zooming, and auto-centering.
25
+ - ๐Ÿ›‘ **Zero Production Impact**: Designed to run safely in development mode.
26
+
27
+ ---
28
+
29
+ ## ๐Ÿ“ฆ Installation
30
+
31
+ Add this line to your application's Gemfile:
32
+
33
+ ```ruby
34
+ gem 'rails_trace_viewer', group: :development
35
+ ```
36
+
37
+ Then execute:
38
+
39
+ ```bash
40
+ bundle install
41
+ ```
42
+
43
+ ---
44
+
45
+ ## ๐Ÿ”ง Configuration (Crucial)
46
+
47
+ To enable real-time tracing, you must ensure ActionCable is correctly configured and the engine is mounted.
48
+
49
+ ---
50
+
51
+ ### 1. Setup ActionCable Connection
52
+
53
+ Create or update `app/channels/application_cable/connection.rb`:
54
+
55
+ ```ruby
56
+ module ApplicationCable
57
+ class Connection < ActionCable::Connection::Base
58
+ identified_by :current_user
59
+
60
+ def connect
61
+ self.current_user = find_verified_user
62
+ end
63
+
64
+ private
65
+
66
+ def find_verified_user
67
+ if current_user = env['warden'].user
68
+ current_user
69
+ else
70
+ reject_unauthorized_connection
71
+ end
72
+ end
73
+ end
74
+ end
75
+ ```
76
+
77
+ ---
78
+
79
+ ### 2. Mount Routes
80
+
81
+ Update `config/routes.rb`:
82
+
83
+ ```ruby
84
+ Rails.application.routes.draw do
85
+ # Mount Trace Viewer (Development Only)
86
+ if Rails.env.development?
87
+ mount RailsTraceViewer::Engine => '/rails_trace_viewer'
88
+ end
89
+
90
+ # Mount ActionCable
91
+ mount ActionCable.server => '/cable'
92
+
93
+ # Optional: Mount Sidekiq Web
94
+ mount Sidekiq::Web => '/sidekiq'
95
+ end
96
+ ```
97
+
98
+ ---
99
+
100
+ ### 3. Configure Action Cable (Redis)
101
+
102
+ Update `config/cable.yml`:
103
+
104
+ ```yaml
105
+ development:
106
+ adapter: redis
107
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
108
+ ```
109
+
110
+ ---
111
+
112
+ ### 4. Environment Configuration
113
+
114
+ In `config/environments/development.rb`:
115
+
116
+ ```ruby
117
+ Rails.application.configure do
118
+ config.log_level = :debug
119
+
120
+ # Suppress ActionCable broadcast logs
121
+ config.action_cable.logger = Logger.new(STDOUT)
122
+ config.action_cable.logger.level = Logger::WARN
123
+ end
124
+ ```
125
+
126
+ ---
127
+
128
+ ### 5. Configure Sidekiq
129
+
130
+ Add to `config/initializers/sidekiq.rb`:
131
+
132
+ ```ruby
133
+ Sidekiq.configure_server do |config|
134
+ config.redis = { url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } }
135
+ end
136
+
137
+ Sidekiq.configure_client do |config|
138
+ config.redis = { url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } }
139
+ end
140
+ ```
141
+
142
+ ---
143
+
144
+ ## ๐Ÿš€ Usage
145
+
146
+ To see the full power of Rails Trace Viewer:
147
+
148
+ ### 1. Start the Web Server
149
+
150
+ ```bash
151
+ rails s
152
+ ```
153
+
154
+ ### 2. Start Sidekiq (required for job tracing)
155
+
156
+ ```bash
157
+ bundle exec sidekiq
158
+ ```
159
+
160
+ ### 3. Open the Viewer
161
+
162
+ Visit:
163
+
164
+ ```
165
+ http://localhost:3000/rails_trace_viewer
166
+ ```
167
+
168
+ ### 4. Trigger a Trace
169
+
170
+ Perform any action in your app (load a page, submit a form, etc.).
171
+
172
+ If the action enqueues a Sidekiq job, wait for the worker to pick it up.
173
+ You'll see the trace tree expand in real-time.
174
+
175
+ ---
176
+
177
+ ## ๐ŸŽ“ How to Read the Trace
178
+
179
+ The viewer uses specific colors to represent different parts of the call stack:
180
+
181
+ - ๐ŸŸฆ **Request** โ€” Incoming HTTP request
182
+ - ๐ŸŸฆ **Controller** โ€” Controller action
183
+ - โฌœ **Method** โ€” Ruby method call (models, services, helpers)
184
+ - ๐ŸŸจ **SQL** โ€” Database query
185
+ - ๐ŸŸฉ **View** โ€” Rails View or Partial rendering
186
+ - ๐ŸŸช **Job Enqueue** โ€” When a background job is scheduled
187
+ - ๐ŸŸช **Job Perform** โ€” When Sidekiq executes the job
188
+
189
+ ๐Ÿ’ก **Tip:** Click any node to open the details panel showing:
190
+ - File path
191
+ - Line number
192
+ - Method arguments
193
+ - SQL binds
194
+ - And more
195
+
196
+ ---
197
+
198
+ ## ๐Ÿ› ๏ธ Troubleshooting
199
+
200
+ ### **"I see the Enqueue node, but the trace stops there."**
201
+ - Ensure **Sidekiq is running**.
202
+ - Ensure `config/cable.yml` uses **Redis**, not the async adapter.
203
+
204
+ ### **"I see duplicate nodes."**
205
+ - Restart the Rails server.
206
+ This can happen if reloader attaches subscribers twice.
207
+
208
+ ### **"The graph feels jittery."**
209
+ - Normal during heavy trace activity.
210
+ - The UI buffers updates every **100ms** to improve smoothness.
211
+
212
+ ---
213
+
214
+ ## ๐Ÿค Contributing
215
+
216
+ Bug reports and pull requests are welcome at:
217
+
218
+ https://github.com/Aditya-JOSH/rails_trace_viewer
219
+
220
+ ---
221
+
222
+ ## ๐Ÿ“ License
223
+
224
+ This gem is available as open source under the terms of the **MIT License**.
@@ -0,0 +1,7 @@
1
+ module RailsTraceViewer
2
+ class TraceChannel < ApplicationCable::Channel
3
+ def subscribed
4
+ stream_from "rails_trace_viewer"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ module RailsTraceViewer
2
+ class TracesController < ActionController::Base
3
+ def show
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,410 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Rails Trace Viewer</title>
5
+ <script src="https://cdn.tailwindcss.com"></script>
6
+ <script src="https://d3js.org/d3.v7.min.js"></script>
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/@rails/actioncable@7.1.3/app/assets/javascripts/actioncable.js"></script>
9
+
10
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
11
+
12
+ <style>
13
+ body { font-family: 'Inter', sans-serif; background: #f8fafc; overflow: hidden; }
14
+
15
+ /* Graph Container - Infinite Canvas Style */
16
+ .trace-container {
17
+ height: calc(100vh - 64px);
18
+ width: 100%;
19
+ overflow: hidden;
20
+ cursor: grab;
21
+ background: #f8fafc;
22
+ }
23
+ .trace-container:active { cursor: grabbing; }
24
+
25
+ /* Nodes */
26
+ .node rect { rx: 6; ry: 6; stroke-width: 1px; transition: all 0.2s; filter: drop-shadow(0 2px 4px rgb(0 0 0 / 0.05)); }
27
+ .node:hover rect { stroke-width: 2px; stroke: #6366f1; cursor: pointer; transform: translateY(-1px); }
28
+ .node text { font-family: 'Inter', sans-serif; pointer-events: none; }
29
+ .node .label-title { font-weight: 600; font-size: 12px; }
30
+ .node .label-type { font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.6; }
31
+
32
+ .link { fill: none; stroke: #cbd5e1; stroke-width: 1.5px; marker-end: url(#arrow); transition: stroke 0.2s; }
33
+
34
+ /* Node Colors */
35
+ .node-type-request rect, .node-type-controller_action rect { fill: #eff6ff; stroke: #bfdbfe; }
36
+ .node-type-request text { fill: #1e40af; }
37
+
38
+ .node-type-method rect { fill: #ffffff; stroke: #cbd5e1; }
39
+ .node-type-method text { fill: #334155; }
40
+
41
+ .node-type-sql rect { fill: #fffbeb; stroke: #fde68a; }
42
+ .node-type-sql text { fill: #92400e; }
43
+
44
+ .node-type-job_enqueue rect { fill: #faf5ff; stroke: #d8b4fe; }
45
+ .node-type-job_enqueue text { fill: #6b21a8; }
46
+
47
+ .node-type-job_perform rect { fill: #fdf2f8; stroke: #f9a8d4; }
48
+ .node-type-job_perform text { fill: #9d174d; }
49
+
50
+ .node-type-view rect { fill: #f0fdf4; stroke: #86efac; }
51
+ .node-type-view text { fill: #166534; }
52
+
53
+ .node-type-route rect { fill: #ecfeff; stroke: #a5f3fc; }
54
+ .node-type-route text { fill: #0e7490; }
55
+
56
+ /* Drawer */
57
+ .drawer {
58
+ position: fixed; top: 64px; right: -500px; width: 450px; height: calc(100vh - 64px);
59
+ background: white; border-left: 1px solid #e2e8f0; box-shadow: -4px 0 25px rgba(0,0,0,0.05);
60
+ transition: right 0.3s cubic-bezier(0.16, 1, 0.3, 1); z-index: 50; display: flex; flex-direction: column;
61
+ }
62
+ .drawer.open { right: 0; }
63
+
64
+ .code-block {
65
+ background: #f8fafc; padding: 10px; border-radius: 6px; font-family: 'JetBrains Mono', monospace;
66
+ font-size: 11px; color: #334155; overflow-x: auto; white-space: pre-wrap; border: 1px solid #e2e8f0; margin-top: 4px;
67
+ }
68
+ .kv-row { margin-bottom: 12px; }
69
+ .kv-label { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-bottom: 2px; }
70
+ .kv-value { font-size: 13px; color: #1e293b; word-break: break-all; line-height: 1.4; }
71
+
72
+ /* Help/Learn Styles */
73
+ .help-section { margin-bottom: 24px; }
74
+ .help-title { font-weight: 600; color: #1e293b; display: flex; items-center; gap: 8px; margin-bottom: 8px; }
75
+ .help-text { font-size: 13px; color: #475569; line-height: 1.5; }
76
+ .help-list { list-style: disc; padding-left: 20px; margin-top: 4px; }
77
+ .help-list li { margin-bottom: 4px; }
78
+ .kbd { background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 4px; padding: 2px 4px; font-family: 'JetBrains Mono', monospace; font-size: 11px; }
79
+ </style>
80
+ </head>
81
+
82
+ <body>
83
+
84
+ <div class="h-16 bg-white border-b border-slate-200 flex justify-between items-center px-6 shadow-sm z-50 relative">
85
+ <div class="flex items-center gap-3">
86
+ <div class="bg-indigo-600 text-white p-1.5 rounded-lg">
87
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
88
+ </div>
89
+ <h1 class="text-lg font-bold text-slate-800">Rails Trace Viewer</h1>
90
+ </div>
91
+ <div class="flex gap-3">
92
+ <button id="center-btn" class="px-3 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-300 rounded-md hover:bg-slate-50 flex items-center gap-2 transition-all active:scale-95">
93
+ <span>๐ŸŽฏ</span> Latest Trace
94
+ </button>
95
+ <button id="clear-btn" class="px-3 py-2 text-sm font-medium text-red-600 bg-white border border-slate-300 rounded-md hover:bg-red-50 flex items-center gap-2 transition-all active:scale-95">
96
+ <span>๐Ÿ—‘</span> Clear
97
+ </button>
98
+ <button id="learn-btn" class="px-3 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 flex items-center gap-2 transition-all active:scale-95">
99
+ <span>๐Ÿ“–</span> Learn
100
+ </button>
101
+ </div>
102
+ </div>
103
+
104
+ <div class="flex h-full">
105
+ <div id="trace-visualization" class="trace-container"></div>
106
+ <div id="detail-drawer" class="drawer">
107
+ <div class="p-5 border-b border-slate-100 flex justify-between items-center bg-slate-50">
108
+ <h2 class="font-bold text-slate-800 text-lg" id="drawer-title">Details</h2>
109
+ <button id="close-drawer" class="text-slate-400 hover:text-slate-700">โœ•</button>
110
+ </div>
111
+ <div class="p-6 overflow-y-auto flex-1" id="drawer-content"></div>
112
+ </div>
113
+ </div>
114
+
115
+ <script>
116
+ document.addEventListener("DOMContentLoaded", () => {
117
+ const container = document.getElementById("trace-visualization");
118
+ const drawer = document.getElementById("detail-drawer");
119
+ let graphs = {};
120
+ let nodeBuffer = {};
121
+ let verticalOffset = 60;
122
+ const DAG_SPACING = 200;
123
+
124
+ // Node Dimensions
125
+ const NODE_WIDTH = 250;
126
+ const NODE_HEIGHT = 46;
127
+
128
+ const svg = d3.select("#trace-visualization").append("svg").attr("width", "100%").attr("height", "100%");
129
+ const g = svg.append("g");
130
+
131
+ svg.append("defs").append("marker")
132
+ .attr("id", "arrow").attr("viewBox", "0 -5 10 10").attr("refX", 9).attr("refY", 0)
133
+ .attr("markerWidth", 5).attr("markerHeight", 5).attr("orient", "auto")
134
+ .append("path").attr("d", "M0,-5L10,0L0,5").attr("fill", "#94a3b8");
135
+
136
+ const zoom = d3.zoom().scaleExtent([0.05, 3]).on("zoom", (e) => g.attr("transform", e.transform));
137
+ svg.call(zoom);
138
+
139
+ // --- ActionCable Management ---
140
+ let cable = null;
141
+ let subscription = null;
142
+
143
+ function setupSubscription() {
144
+ if (cable) cable.disconnect();
145
+ cable = ActionCable.createConsumer();
146
+
147
+ subscription = cable.subscriptions.create({ channel: "RailsTraceViewer::TraceChannel" }, {
148
+ connected() { console.log("โœ… TraceViewer: Connected"); },
149
+ disconnected() { console.log("โŒ TraceViewer: Disconnected"); },
150
+ received(data) {
151
+ if (!data || !data.node) return;
152
+ if ((data.node.name || "").includes("RailsTraceViewer")) return;
153
+ addNodeToGraph(data.trace_id, data.node);
154
+
155
+ if (this.redrawTimer) clearTimeout(this.redrawTimer);
156
+ this.redrawTimer = setTimeout(redrawAllGraphs, 100);
157
+ }
158
+ });
159
+ }
160
+
161
+ setupSubscription();
162
+
163
+ // --- Buffer Logic ---
164
+ setInterval(() => {
165
+ const now = Date.now();
166
+ let changed = false;
167
+ Object.keys(nodeBuffer).forEach(pid => {
168
+ const list = nodeBuffer[pid];
169
+ const kept = [];
170
+ list.forEach(item => {
171
+ if (now - item.receivedAt > 3000) {
172
+ addNodeToGraph(item.traceId, { ...item.node, parent_id: null });
173
+ changed = true;
174
+ } else kept.push(item);
175
+ });
176
+ if (kept.length === 0) delete nodeBuffer[pid];
177
+ else nodeBuffer[pid] = kept;
178
+ });
179
+ if (changed) redrawAllGraphs();
180
+ }, 1000);
181
+
182
+ function addNodeToGraph(trace_id, node) {
183
+ if (!graphs[trace_id]) graphs[trace_id] = { nodes: {}, edges: [], height: 0 };
184
+ const G = graphs[trace_id];
185
+ if (G.nodes[node.id]) return;
186
+
187
+ if (node.parent_id && !G.nodes[node.parent_id]) {
188
+ nodeBuffer[node.parent_id] ||= [];
189
+ nodeBuffer[node.parent_id].push({ node, traceId: trace_id, receivedAt: Date.now() });
190
+ return;
191
+ }
192
+
193
+ G.nodes[node.id] = { ...node, label: node.name };
194
+ if (node.parent_id && !G.edges.some(e => e.source === node.parent_id && e.target === node.id)) {
195
+ G.edges.push({ source: node.parent_id, target: node.id });
196
+ }
197
+
198
+ if (nodeBuffer[node.id]) {
199
+ const children = nodeBuffer[node.id];
200
+ delete nodeBuffer[node.id];
201
+ children.forEach(c => addNodeToGraph(trace_id, c.node));
202
+ }
203
+ }
204
+
205
+ // --- Rendering ---
206
+ function redrawAllGraphs() {
207
+ g.selectAll("*").remove();
208
+ verticalOffset = 60;
209
+
210
+ Object.keys(graphs).forEach(tid => {
211
+ renderSingleDAG(graphs[tid], verticalOffset);
212
+ verticalOffset += graphs[tid].height + DAG_SPACING;
213
+ });
214
+ }
215
+
216
+ function renderSingleDAG(G, yOffset) {
217
+ const dag = new dagre.graphlib.Graph();
218
+ dag.setGraph({ rankdir: "LR", nodesep: 25, ranksep: 60 });
219
+ dag.setDefaultEdgeLabel(() => ({}));
220
+
221
+ Object.values(G.nodes).forEach(n => {
222
+ let lbl = n.name || "Unknown";
223
+ if (lbl.length > 38) lbl = lbl.substring(0, 35) + "...";
224
+ dag.setNode(n.id, { label: lbl, width: NODE_WIDTH, height: NODE_HEIGHT, type: n.type, fullData: n });
225
+ });
226
+
227
+ G.edges.forEach(e => dag.setEdge(e.source, e.target));
228
+ dagre.layout(dag);
229
+
230
+ if (dag.graph().height) G.height = dag.graph().height;
231
+
232
+ let minX = Infinity, maxX = -Infinity;
233
+ let minY = Infinity, maxY = -Infinity;
234
+
235
+ Object.values(G.nodes).forEach(n => {
236
+ const nodeInfo = dag.node(n.id);
237
+ const nx1 = nodeInfo.x - (nodeInfo.width / 2);
238
+ const nx2 = nodeInfo.x + (nodeInfo.width / 2);
239
+ const ny1 = nodeInfo.y - (nodeInfo.height / 2);
240
+ const ny2 = nodeInfo.y + (nodeInfo.height / 2);
241
+
242
+ if (nx1 < minX) minX = nx1;
243
+ if (nx2 > maxX) maxX = nx2;
244
+ if (ny1 < minY) minY = ny1;
245
+ if (ny2 > maxY) maxY = ny2;
246
+ });
247
+
248
+ if (minX === Infinity) { minX = 0; maxX = 0; minY = 0; maxY = 0; }
249
+
250
+ G.bounds = {
251
+ minX: minX,
252
+ maxX: maxX,
253
+ minY: minY + yOffset,
254
+ maxY: maxY + yOffset,
255
+ width: maxX - minX,
256
+ height: maxY - minY,
257
+ centerX: minX + (maxX - minX) / 2,
258
+ centerY: (minY + yOffset) + (maxY - minY) / 2
259
+ };
260
+
261
+ g.selectAll(`.link-${G.id}`).data(dag.edges()).enter().append("path")
262
+ .attr("class", "link").attr("d", e => {
263
+ const pts = dag.edge(e).points;
264
+ return d3.line().x(d => d.x).y(d => d.y + yOffset).curve(d3.curveBasis)(pts);
265
+ });
266
+
267
+ const nodeGroup = g.selectAll(`.node-${G.id}`).data(dag.nodes()).enter().append("g")
268
+ .attr("class", d => `node node-type-${dag.node(d).type}`)
269
+ .attr("transform", d => `translate(${dag.node(d).x}, ${dag.node(d).y + yOffset})`)
270
+ .on("click", (e, d) => { e.stopPropagation(); showDrawer(dag.node(d).fullData); });
271
+
272
+ nodeGroup.append("rect")
273
+ .attr("width", NODE_WIDTH).attr("height", NODE_HEIGHT).attr("x", -NODE_WIDTH / 2).attr("y", -NODE_HEIGHT / 2);
274
+
275
+ nodeGroup.append("text")
276
+ .attr("x", 0).attr("y", 0).attr("dy", "0.35em")
277
+ .text(d => dag.node(d).label).classed("label-title", true).style("text-anchor", "middle");
278
+
279
+ nodeGroup.append("text")
280
+ .attr("x", (NODE_WIDTH / 2) - 10).attr("y", (NODE_HEIGHT / 2) - 6)
281
+ .text(d => dag.node(d).type.replace(/_/g, " ")).classed("label-type", true).style("text-anchor", "end");
282
+ }
283
+
284
+ // --- Drawer & Learn Logic ---
285
+
286
+ function openDrawerWithContent(titleText, htmlContent) {
287
+ const title = document.getElementById("drawer-title");
288
+ const content = document.getElementById("drawer-content");
289
+
290
+ title.innerText = titleText;
291
+ content.innerHTML = htmlContent;
292
+ drawer.classList.add("open");
293
+ }
294
+
295
+ function showDrawer(node) {
296
+ const ignore = ["id", "parent_id", "children", "type", "name", "label", "width", "height", "x", "y"];
297
+ let html = `<div class="kv-row"><div class="kv-label">Name</div><div class="kv-value font-medium">${node.name}</div></div>`;
298
+
299
+ Object.entries(node).forEach(([key, val]) => {
300
+ if (ignore.includes(key) || val === null || val === "") return;
301
+ const isObj = typeof val === 'object';
302
+ const display = isObj ? JSON.stringify(val, null, 2) : val;
303
+ html += `<div class="kv-row"><div class="kv-label">${key.replace(/_/g, " ")}</div>${isObj ? `<div class="code-block">${display}</div>` : `<div class="kv-value">${display}</div>`}</div>`;
304
+ });
305
+
306
+ openDrawerWithContent((node.type || "Node").toUpperCase().replace(/_/g, " "), html);
307
+ }
308
+
309
+ function showLearn() {
310
+ const content = `
311
+ <div class="help-section">
312
+ <div class="help-title">
313
+ <span>๐Ÿ•น๏ธ</span> Navigation
314
+ </div>
315
+ <div class="help-text">
316
+ <ul class="help-list">
317
+ <li><strong>Pan:</strong> Click and drag anywhere on the background.</li>
318
+ <li><strong>Zoom:</strong> Use your mouse wheel or trackpad pinch.</li>
319
+ <li><strong>Details:</strong> Click any node to see arguments, SQL binds, and file paths.</li>
320
+ <li><strong>Center:</strong> Click <span class="kbd">Latest Trace</span> to snap the camera to the newest activity.</li>
321
+ </ul>
322
+ </div>
323
+ </div>
324
+
325
+ <div class="help-section">
326
+ <div class="help-title">
327
+ <span>โš™๏ธ</span> Development Workflow
328
+ </div>
329
+ <div class="help-text">
330
+ <p class="mb-2">To see full traces including Background Jobs, ensure you are running:</p>
331
+ <div class="code-block">bundle exec sidekiq</div>
332
+ <p class="mt-2">Sidekiq runs in a separate process. If you change code in your app, <strong>Sidekiq does NOT reload automatically</strong>.</p>
333
+ <p class="mt-2 font-semibold text-indigo-700">๐Ÿ’ก If you change code, restart the Sidekiq process and restart rails server using </p>
334
+ <div class="code-block">bundle exec sidekiq
335
+ rails s</div>
336
+ <p class="mt-2 font-semibold text-indigo-700"> Please refresh this viewer page after making changes to ensure the latest code is reflected.</p>
337
+ </div>
338
+ </div>
339
+
340
+ <div class="help-section">
341
+ <div class="help-title">
342
+ <span>๐ŸŽจ</span> Legend
343
+ </div>
344
+ <div class="help-text grid grid-cols-2 gap-2 mt-2">
345
+ <div class="flex items-center gap-2"><span class="w-3 h-3 bg-cyan-100 border border-cyan-300 rounded"></span> Route</div>
346
+ <div class="flex items-center gap-2"><span class="w-3 h-3 bg-blue-100 border border-blue-300 rounded"></span> Request</div>
347
+ <div class="flex items-center gap-2"><span class="w-3 h-3 bg-purple-100 border border-purple-300 rounded"></span> Job Enqueue</div>
348
+ <div class="flex items-center gap-2"><span class="w-3 h-3 bg-pink-100 border border-pink-300 rounded"></span> Job Perform</div>
349
+ <div class="flex items-center gap-2"><span class="w-3 h-3 bg-amber-100 border border-amber-300 rounded"></span> SQL Query</div>
350
+ </div>
351
+ </div>
352
+
353
+ <div class="mt-12 pt-6 border-t border-slate-200">
354
+ <div class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3">Created By</div>
355
+ <a href="https://www.linkedin.com/in/aditya-kolekar-5a54611b5/" target="_blank" class="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl shadow-sm hover:shadow-md hover:border-blue-200 transition-all group text-decoration-none">
356
+ <div class="bg-[#0077b5] text-white p-2.5 rounded-full shrink-0">
357
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
358
+ </div>
359
+ <div>
360
+ <div class="font-bold text-slate-800 text-sm group-hover:text-[#0077b5] transition-colors">Aditya Kolekar</div>
361
+ <div class="text-xs text-slate-500 mt-0.5">Connect on LinkedIn</div>
362
+ </div>
363
+ </a>
364
+ </div>
365
+ `;
366
+ openDrawerWithContent("How to Use", content);
367
+ }
368
+
369
+ // --- UI Controls ---
370
+
371
+ document.getElementById("clear-btn").onclick = () => {
372
+ graphs = {};
373
+ redrawAllGraphs();
374
+ drawer.classList.remove("open");
375
+ console.log("๐Ÿงน Clearing traces & reconnecting...");
376
+ setupSubscription();
377
+ };
378
+
379
+ document.getElementById("center-btn").onclick = () => {
380
+ const ids = Object.keys(graphs);
381
+ if (ids.length === 0) return;
382
+ const G = graphs[ids[ids.length - 1]];
383
+ if (!G || !G.bounds || G.bounds.width === 0) return;
384
+
385
+ const padding = 80;
386
+ const viewW = container.clientWidth;
387
+ const viewH = container.clientHeight;
388
+ const scaleX = (viewW - padding) / G.bounds.width;
389
+ const scaleY = (viewH - padding) / G.bounds.height;
390
+ let scale = Math.min(scaleX, scaleY);
391
+ scale = Math.min(scale, 1);
392
+
393
+ const tX = (viewW / 2) - (G.bounds.centerX * scale);
394
+ const tY = (viewH / 2) - (G.bounds.centerY * scale);
395
+
396
+ svg.transition().duration(800).call(zoom.transform, d3.zoomIdentity.translate(tX, tY).scale(scale));
397
+ };
398
+
399
+ document.getElementById("learn-btn").onclick = showLearn;
400
+ document.getElementById("close-drawer").onclick = () => drawer.classList.remove("open");
401
+ document.querySelector("svg").onclick = () => drawer.classList.remove("open");
402
+
403
+ window.addEventListener("resize", () => {
404
+ svg.attr("width", container.clientWidth).attr("height", container.clientHeight);
405
+ redrawAllGraphs();
406
+ });
407
+ });
408
+ </script>
409
+ </body>
410
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ RailsTraceViewer::Engine.routes.draw do
2
+ root to: "traces#show"
3
+ end