heap_periscope_ui 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 (34) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/heap_periscope_ui_manifest.js +3 -0
  6. data/app/assets/stylesheets/heap_periscope_ui/application.css +15 -0
  7. data/app/assets/stylesheets/heap_periscope_ui/visualizer.css +11 -0
  8. data/app/channels/heap_periscope_ui/application_cable/channel.rb +7 -0
  9. data/app/channels/heap_periscope_ui/application_cable/connection.rb +12 -0
  10. data/app/channels/heap_periscope_ui/runtime_stats_channel.rb +14 -0
  11. data/app/controllers/heap_periscope_ui/application_controller.rb +4 -0
  12. data/app/controllers/heap_periscope_ui/dashboard_controller.rb +11 -0
  13. data/app/helpers/heap_periscope_ui/application_helper.rb +4 -0
  14. data/app/jobs/heap_periscope_ui/application_job.rb +4 -0
  15. data/app/mailers/heap_periscope_ui/application_mailer.rb +6 -0
  16. data/app/models/heap_periscope_ui/application_record.rb +5 -0
  17. data/app/models/heap_periscope_ui/object_count.rb +13 -0
  18. data/app/models/heap_periscope_ui/profiler_report.rb +15 -0
  19. data/app/views/heap_periscope_ui/dashboard/index.html +1132 -0
  20. data/app/views/heap_periscope_ui/dashboard/index.html.bak +862 -0
  21. data/app/views/layouts/heap_periscope_ui/application.html.erb +14 -0
  22. data/config/initializers/heap_periscope_ui_start_udp_listener.rb +15 -0
  23. data/config/routes.rb +8 -0
  24. data/db/migrate/20250617144101_create_heap_periscope_ui_profiler_reports.rb +17 -0
  25. data/db/migrate/20250617144853_create_heap_periscope_ui_object_counts.rb +14 -0
  26. data/lib/generators/heap_periscope_ui/install/install_generator.rb +26 -0
  27. data/lib/generators/heap_periscope_ui/install/templates/README_SETUP +17 -0
  28. data/lib/generators/heap_periscope_ui/install/templates/initializer.rb +4 -0
  29. data/lib/heap_periscope_ui/engine.rb +13 -0
  30. data/lib/heap_periscope_ui/udp_listener.rb +131 -0
  31. data/lib/heap_periscope_ui/version.rb +3 -0
  32. data/lib/heap_periscope_ui.rb +16 -0
  33. data/lib/tasks/heap_periscope_ui_tasks.rake +4 -0
  34. metadata +100 -0
@@ -0,0 +1,862 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Rails Runtime Visualizer (Datadog Theme)</title>
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
10
+
11
+ <script src="https://cdn.tailwindcss.com"></script>
12
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
13
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
14
+ <style>
15
+ /* START: THEME-AWARE CSS VARIABLES */
16
+ :root, [data-theme="dark"] {
17
+ --bg: #1a1a1f;
18
+ --widget-bg: #2a2a33;
19
+ --border: #3c3c44;
20
+ --text-primary: #e0e0e0;
21
+ --text-secondary: #a0a0b0;
22
+ --text-link: #6ea8ff;
23
+ --input-bg: #1a1a1f;
24
+ --toggle-bg: #1a1a1f;
25
+ --toggle-active-bg: #35353d;
26
+ }
27
+
28
+ [data-theme="light"] {
29
+ --bg: #f3f4f6;
30
+ --widget-bg: #ffffff;
31
+ --border: #e5e7eb;
32
+ --text-primary: #111827;
33
+ --text-secondary: #6b7280;
34
+ --text-link: #2563eb;
35
+ --input-bg: #f3f4f6;
36
+ --toggle-bg: #e5e7eb;
37
+ --toggle-active-bg: #d1d5db;
38
+ }
39
+ /* END: THEME-AWARE CSS VARIABLES */
40
+
41
+ body {
42
+ font-family: 'Inter', sans-serif;
43
+ background-color: var(--bg);
44
+ color: var(--text-primary);
45
+ transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
46
+ }
47
+
48
+ .widget {
49
+ background-color: var(--widget-bg);
50
+ border: 1px solid var(--border);
51
+ border-radius: 6px;
52
+ padding: 1.25rem;
53
+ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
54
+ }
55
+
56
+ .widget-header {
57
+ display: flex;
58
+ justify-content: space-between;
59
+ align-items: center;
60
+ margin-bottom: 1rem;
61
+ padding-bottom: 1rem;
62
+ border-bottom: 1px solid var(--border);
63
+ transition: border-color 0.2s ease-in-out;
64
+ }
65
+
66
+ .widget-title {
67
+ font-size: 1rem;
68
+ font-weight: 500;
69
+ color: var(--text-secondary);
70
+ }
71
+
72
+ .chart-container { position: relative; height: 300px; width: 100%; }
73
+
74
+ .accordion-header { cursor: pointer; }
75
+ .accordion-chevron { transition: transform 0.2s ease-in-out; color: var(--text-secondary); }
76
+ .accordion-header.open .accordion-chevron { transform: rotate(180deg); }
77
+
78
+ .modal { backdrop-filter: blur(3px); }
79
+ .modal-content {
80
+ background-color: var(--widget-bg);
81
+ border: 1px solid var(--border);
82
+ border-radius: 8px;
83
+ box-shadow: 0 10px 30px rgba(0,0,0,0.5);
84
+ }
85
+
86
+ /* Styles for Search and Hover Buttons */
87
+ .search-input {
88
+ width: 100%;
89
+ background-color: var(--input-bg);
90
+ border: 1px solid var(--border);
91
+ color: var(--text-primary);
92
+ border-radius: 5px;
93
+ padding: 0.375rem 0.75rem;
94
+ font-size: 0.875rem;
95
+ margin-bottom: 0.75rem;
96
+ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
97
+ }
98
+ .search-input:focus {
99
+ outline: none;
100
+ border-color: #4f80e1; /* Use a consistent focus color */
101
+ box-shadow: 0 0 0 2px rgba(79, 128, 225, 0.5);
102
+ }
103
+ .history-btn {
104
+ visibility: hidden;
105
+ opacity: 0;
106
+ transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
107
+ background-color: #35353d;
108
+ color: #e0e0e0;
109
+ border: 1px solid #4a4a52;
110
+ padding: 2px 8px;
111
+ font-size: 11px;
112
+ font-weight: 500;
113
+ border-radius: 4px;
114
+ cursor: pointer;
115
+ }
116
+ [data-theme="light"] .history-btn {
117
+ background-color: #e5e7eb;
118
+ border-color: #d1d5db;
119
+ color: #111827;
120
+ }
121
+ tr:hover .history-btn {
122
+ visibility: visible;
123
+ opacity: 1;
124
+ }
125
+ .history-btn:hover {
126
+ background-color: #4f80e1;
127
+ border-color: #4f80e1;
128
+ color: white;
129
+ }
130
+
131
+ /* Styles for Custom Select */
132
+ .select-wrapper { position: relative; display: inline-block; }
133
+ .custom-select {
134
+ appearance: none;
135
+ -webkit-appearance: none;
136
+ background-color: var(--border);
137
+ color: var(--text-primary);
138
+ border-radius: 5px;
139
+ padding: 0.25rem 2rem 0.25rem 0.75rem;
140
+ font-size: 0.875rem;
141
+ border: none;
142
+ cursor: pointer;
143
+ transition: background-color 0.2s ease-in-out;
144
+ }
145
+ .select-wrapper::after {
146
+ content: '▾';
147
+ position: absolute;
148
+ right: 0.75rem;
149
+ top: 50%;
150
+ transform: translateY(-50%);
151
+ pointer-events: none;
152
+ color: var(--text-secondary);
153
+ }
154
+
155
+ /* Styles for Modal Chart Toggle */
156
+ .chart-toggle-group {
157
+ display: inline-flex;
158
+ background-color: var(--toggle-bg);
159
+ border: 1px solid var(--border);
160
+ border-radius: 5px;
161
+ padding: 2px;
162
+ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
163
+ }
164
+ .toggle-btn {
165
+ background-color: transparent;
166
+ color: var(--text-secondary);
167
+ border: none;
168
+ padding: 4px 12px;
169
+ font-size: 12px;
170
+ font-weight: 500;
171
+ border-radius: 4px;
172
+ cursor: pointer;
173
+ transition: all 0.2s ease-in-out;
174
+ }
175
+ .toggle-btn.active {
176
+ background-color: var(--toggle-active-bg);
177
+ color: var(--text-primary);
178
+ }
179
+
180
+ .log-entry { animation: fadeIn 0.5s ease-in-out; }
181
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
182
+ </style>
183
+ </head>
184
+ <body data-theme="dark">
185
+
186
+ <div class="container mx-auto p-4 md:p-6">
187
+ <header class="mb-6 flex justify-between items-center">
188
+ <h1 class="text-xl font-bold">Service: <span class="font-normal text-[var(--text-secondary)]">Rails Runtime</span></h1>
189
+ <div class="flex items-center gap-4">
190
+ <div id="status-indicator" class="flex items-center space-x-2 text-sm">
191
+ <div id="status-dot" class="w-2.5 h-2.5 rounded-full bg-yellow-500"></div>
192
+ <span id="status-text" class="text-[var(--text-secondary)] font-medium">Connecting...</span>
193
+ </div>
194
+ <button id="download-pdf-btn" title="Download as PDF" class="w-8 h-8 rounded-full flex items-center justify-center bg-[var(--widget-bg)] border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors">
195
+ <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
196
+ <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
197
+ </svg>
198
+ </button>
199
+ <button id="theme-toggle" title="Toggle Theme" class="w-8 h-8 rounded-full flex items-center justify-center bg-[var(--widget-bg)] border border-[var(--border)] text-[var(--text-secondary)]">
200
+ <svg id="theme-icon-auto" class="w-5 h-5 hidden" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
201
+ <svg id="theme-icon-sun" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
202
+ <svg id="theme-icon-moon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
203
+ </button>
204
+ </div>
205
+ </header>
206
+
207
+ <main class="grid grid-cols-1 lg:grid-cols-3 gap-6">
208
+ <div class="lg:col-span-1 space-y-6">
209
+ <div class="widget">
210
+ <div class="widget-header"><h2 class="widget-title">Key Metrics</h2></div>
211
+ <div class="space-y-5">
212
+ <div class="flex justify-between items-baseline"><span class="text-sm text-[var(--text-secondary)]">Heap Live Slots</span><span id="metric-heap-live" class="text-xl font-semibold text-[#4f80e1]">0</span></div>
213
+ <div class="flex justify-between items-baseline"><span class="text-sm text-[var(--text-secondary)]">Total Allocated Objects</span><span id="metric-total-allocated" class="text-xl font-semibold text-[#2d9d78]">0</span></div>
214
+ <div class="flex justify-between items-baseline"><span class="text-sm text-[var(--text-secondary)]">Major GCs</span><span id="metric-major-gc" class="text-xl font-semibold text-[#a37ac5]">0</span></div>
215
+ <div class="flex justify-between items-baseline"><span class="text-sm text-[var(--text-secondary)]">Minor GCs</span><span id="metric-minor-gc" class="text-xl font-semibold text-[#f0ba49]">0</span></div>
216
+ <div class="flex justify-between items-baseline"><span class="text-sm text-[var(--text-secondary)]">Last GC Pause (ms)</span><span id="metric-last-gc-pause" class="text-xl font-semibold text-[#e05d5d]">0.00</span></div>
217
+ </div>
218
+ </div>
219
+ <div class="widget">
220
+ <div class="widget-header"><h2 class="widget-title">Real-time Event Log</h2></div>
221
+ <div id="event-log" class="space-y-2 h-96 overflow-y-auto pr-2"><p class="text-center py-8 text-[var(--text-secondary)]">Waiting for events...</p></div>
222
+ </div>
223
+ </div>
224
+
225
+ <div class="lg:col-span-2 space-y-6">
226
+ <div class="widget"><div class="widget-header"><h2 class="widget-title">GC Heap Usage Over Time</h2></div><div class="chart-container"><canvas id="heapChart"></canvas></div></div>
227
+ <div class="widget"><div class="widget-header"><h2 class="widget-title">GC Pause Durations (ms)</h2></div><div class="chart-container"><canvas id="gcPauseChart"></canvas></div></div>
228
+
229
+ <div class="widget" id="objects-table-widget">
230
+ <div class="widget-header"><h2 class="widget-title">Living Objects Snapshot</h2></div>
231
+ <div class="space-y-8">
232
+ <div>
233
+ <h3 class="text-sm font-semibold text-sky-400 mb-2">Platform Classes</h3>
234
+ <input type="text" id="native-class-search" class="search-input" placeholder="Filter platform classes...">
235
+ <div class="overflow-x-auto max-h-60"><table class="w-full text-left text-xs"><thead class="sticky top-0 bg-[var(--widget-bg)]"><tr><th class="py-2 px-3 text-[var(--text-secondary)] font-medium">Class Name</th><th class="py-2 px-3 text-[var(--text-secondary)] font-medium text-right">Count</th></tr></thead><tbody id="native-object-table-body" class="divide-y divide-[var(--border)]"></tbody></table></div>
236
+ </div>
237
+ <div>
238
+ <h3 class="text-sm font-semibold text-amber-400 mb-2">Application Classes</h3>
239
+ <input type="text" id="tailored-class-search" class="search-input" placeholder="Filter application classes...">
240
+ <div class="overflow-x-auto max-h-60"><table class="w-full text-left text-xs"><thead class="sticky top-0 bg-[var(--widget-bg)]"><tr><th class="py-2 px-3 text-[var(--text-secondary)] font-medium">Class Name</th><th class="py-2 px-3 text-[var(--text-secondary)] font-medium text-right">Count</th></tr></thead><tbody id="tailored-object-table-body" class="divide-y divide-[var(--border)]"></tbody></table></div>
241
+ </div>
242
+ </div>
243
+ </div>
244
+
245
+ <div class="widget" id="platform-objects-chart-widget">
246
+ <div class="widget-header">
247
+ <h2 id="platform-history-widget-title" class="widget-title">Living Platform Objects History (Top 15)</h2>
248
+ <div class="select-wrapper">
249
+ <select id="platform-history-count-select" class="custom-select">
250
+ <option value="5">Top 5</option>
251
+ <option value="10">Top 10</option>
252
+ <option value="15" selected>Top 15</option>
253
+ <option value="20">Top 20</option>
254
+ </select>
255
+ </div>
256
+ </div>
257
+ <div class="chart-container"><canvas id="platformObjectHistoryChart"></canvas></div>
258
+ <p id="platform-object-chart-placeholder" class="text-center py-8 text-[var(--text-secondary)]">Waiting for snapshot to initialize platform history chart...</p>
259
+ </div>
260
+
261
+ <div class="widget" id="app-objects-chart-widget">
262
+ <div class="widget-header">
263
+ <h2 id="app-history-widget-title" class="widget-title">Living Application Objects History (Top 15)</h2>
264
+ <div class="select-wrapper">
265
+ <select id="app-history-count-select" class="custom-select">
266
+ <option value="5">Top 5</option>
267
+ <option value="10">Top 10</option>
268
+ <option value="15" selected>Top 15</option>
269
+ <option value="20">Top 20</option>
270
+ </select>
271
+ </div>
272
+ </div>
273
+ <div class="chart-container"><canvas id="appObjectHistoryChart"></canvas></div>
274
+ <p id="app-object-chart-placeholder" class="text-center py-8 text-[var(--text-secondary)]">Waiting for snapshot to initialize application history chart...</p>
275
+ </div>
276
+
277
+ <div class="widget p-0" id="gc-stats-widget">
278
+ <div class="accordion-header flex justify-between items-center p-5"><h2 class="widget-title">Full GC Statistics (gc_stat)</h2><svg class="accordion-chevron w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg></div>
279
+ <div id="gc-stats-widget-content" class="accordion-content hidden px-5 pb-5">
280
+ <input type="text" id="gc-stats-search" class="search-input" placeholder="Filter metrics...">
281
+ <div class="overflow-x-auto max-h-96"><table class="w-full text-left text-xs"><thead class="border-b border-[var(--border)]"><tr><th class="py-2 px-3 text-[var(--text-secondary)] font-medium">Metric</th><th class="py-2 px-3 text-[var(--text-secondary)] font-medium text-right">Value</th></tr></thead><tbody id="gc-stats-table-body" class="divide-y divide-[var(--border)]"></tbody></table></div>
282
+ </div>
283
+ </div>
284
+ <div class="widget p-0" id="object-summary-widget">
285
+ <div class="accordion-header flex justify-between items-center p-5"><h2 class="widget-title">ObjectSpace Summary</h2><svg class="accordion-chevron w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg></div>
286
+ <div id="object-summary-widget-content" class="accordion-content hidden px-5 pb-5">
287
+ <input type="text" id="object-summary-search" class="search-input" placeholder="Filter object types...">
288
+ <div class="overflow-x-auto max-h-96"><table class="w-full text-left text-xs"><thead class="border-b border-[var(--border)]"><tr><th class="py-2 px-3 text-[var(--text-secondary)] font-medium">Object Type</th><th class="py-2 px-3 text-[var(--text-secondary)] font-medium text-right">Count</th></tr></thead><tbody id="object-summary-table-body" class="divide-y divide-[var(--border)]"></tbody></table></div>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ </main>
293
+ </div>
294
+
295
+ <div id="class-history-modal" class="modal fixed inset-0 bg-black/70 hidden flex items-center justify-center p-4">
296
+ <div class="modal-content w-full max-w-4xl">
297
+ <div class="flex justify-between items-center p-4 border-b border-[var(--border)]">
298
+ <h2 id="modal-title" class="text-lg font-semibold flex-1 truncate">History</h2>
299
+ <div id="modal-chart-type-toggle" class="chart-toggle-group mx-4">
300
+ <button class="toggle-btn active" data-chart-type="line">Line</button>
301
+ <button class="toggle-btn" data-chart-type="bar">Bar</button>
302
+ </div>
303
+ <button id="modal-close-btn" class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-3xl leading-none">&times;</button>
304
+ </div>
305
+ <div class="p-5">
306
+ <div class="chart-container h-96">
307
+ <canvas id="modalChart"></canvas>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ </div>
312
+
313
+ <script>
314
+ document.addEventListener('DOMContentLoaded', () => {
315
+ // --- Config ---
316
+ const VIBRANT_COLORS = { blue: '#4f80e1', green: '#2d9d78', purple: '#a37ac5', yellow: '#f0ba49', red: '#e05d5d', orange: '#f58e46', cyan: '#38bdf8', pink: '#f472b6' };
317
+ const CHART_COLOR_PALETTE = Object.values(VIBRANT_COLORS);
318
+ const darkThemeChartColors = { ticks: '#a0a0b0', grid: '#3c3c44', legend: '#e0e0e0', tooltipBody: '#e0e0e0', tooltipTitle: '#a0a0b0', tooltipBg: 'rgba(0,0,0,0.8)', tooltipBorder: '#3c3c44' };
319
+ const lightThemeChartColors = { ticks: '#6b7280', grid: '#e5e7eb', legend: '#111827', tooltipBody: '#111827', tooltipTitle: '#6b7280', tooltipBg: 'rgba(255,255,255,0.9)', tooltipBorder: '#e5e7eb' };
320
+ const MAX_DATA_POINTS = 45;
321
+ const MAX_LOG_ENTRIES = 100;
322
+
323
+ // --- DOM Elements ---
324
+ const dom = {
325
+ body: document.body,
326
+ container: document.querySelector('.container.mx-auto'),
327
+ themeToggle: document.getElementById('theme-toggle'),
328
+ downloadPdfBtn: document.getElementById('download-pdf-btn'),
329
+ themeIcons: {
330
+ auto: document.getElementById('theme-icon-auto'),
331
+ sun: document.getElementById('theme-icon-sun'),
332
+ moon: document.getElementById('theme-icon-moon'),
333
+ },
334
+ statusText: document.getElementById('status-text'),
335
+ statusDot: document.getElementById('status-dot'),
336
+ eventLog: document.getElementById('event-log'),
337
+ modal: document.getElementById('class-history-modal'),
338
+ modalTitle: document.getElementById('modal-title'),
339
+ modalCloseBtn: document.getElementById('modal-close-btn'),
340
+ modalChartTypeToggle: document.getElementById('modal-chart-type-toggle'),
341
+ platformHistorySelect: document.getElementById('platform-history-count-select'),
342
+ platformHistoryWidgetTitle: document.getElementById('platform-history-widget-title'),
343
+ appHistorySelect: document.getElementById('app-history-count-select'),
344
+ appHistoryWidgetTitle: document.getElementById('app-history-widget-title'),
345
+ nativeSearch: document.getElementById('native-class-search'),
346
+ tailoredSearch: document.getElementById('tailored-class-search'),
347
+ gcStatsSearch: document.getElementById('gc-stats-search'),
348
+ objectSummarySearch: document.getElementById('object-summary-search'),
349
+ gcStatsContent: document.getElementById('gc-stats-widget-content'),
350
+ objectSummaryContent: document.getElementById('object-summary-widget-content'),
351
+ objectsTableWidget: document.getElementById('objects-table-widget'),
352
+ };
353
+
354
+ // --- State ---
355
+ let state = {
356
+ socket: null,
357
+ themeMode: 'auto', // auto, light, dark
358
+ modalChartType: 'line',
359
+ currentModalData: {},
360
+ trackedPlatformClasses: null,
361
+ trackedAppClasses: null,
362
+ platformHistoryClassCount: 15,
363
+ appHistoryClassCount: 15,
364
+ objectHistoryData: {},
365
+ gcStatsHistory: {},
366
+ objectSpaceHistory: {},
367
+ latestLivingObjectsData: null,
368
+ latestGcStatsData: null,
369
+ latestObjectSummaryData: null,
370
+ nativeSearchTerm: '',
371
+ tailoredSearchTerm: '',
372
+ gcStatsSearchTerm: '',
373
+ objectSummarySearchTerm: '',
374
+ };
375
+ const allChartInstances = {};
376
+
377
+ // --- Theme Management ---
378
+ function applyAndDisplayCurrentTheme() {
379
+ let themeToApply;
380
+ let autoTheme = '';
381
+
382
+ if (state.themeMode === 'auto') {
383
+ const currentHour = new Date().getHours();
384
+ themeToApply = (currentHour >= 19 || currentHour < 6) ? 'dark' : 'light';
385
+ autoTheme = ` (${themeToApply.charAt(0).toUpperCase() + themeToApply.slice(1)})`;
386
+ } else {
387
+ themeToApply = state.themeMode;
388
+ }
389
+
390
+ setThemeOnPage(themeToApply);
391
+ updateToggleButtonState(autoTheme);
392
+ }
393
+
394
+ function setThemeOnPage(theme) {
395
+ dom.body.setAttribute('data-theme', theme);
396
+ updateAllChartsTheme(theme);
397
+ }
398
+
399
+ function updateToggleButtonState(autoTheme = '') {
400
+ dom.themeToggle.title = `Theme: ${state.themeMode.charAt(0).toUpperCase() + state.themeMode.slice(1)}${autoTheme}`;
401
+ Object.values(dom.themeIcons).forEach(icon => icon.classList.add('hidden'));
402
+ dom.themeIcons[state.themeMode === 'auto' ? 'auto' : (state.themeMode === 'light' ? 'sun' : 'moon')].classList.remove('hidden');
403
+ }
404
+
405
+ function updateAllChartsTheme(theme) {
406
+ const themeColors = theme === 'dark' ? darkThemeChartColors : lightThemeChartColors;
407
+ for (const chartKey in allChartInstances) {
408
+ const chart = allChartInstances[chartKey];
409
+ if (chart) {
410
+ chart.options.scales.x.ticks.color = themeColors.ticks;
411
+ chart.options.scales.x.grid.color = themeColors.grid;
412
+ chart.options.scales.y.ticks.color = themeColors.ticks;
413
+ chart.options.scales.y.grid.color = themeColors.grid;
414
+ if(chart.options.plugins.legend) chart.options.plugins.legend.labels.color = themeColors.legend;
415
+ if(chart.options.plugins.tooltip) {
416
+ Object.assign(chart.options.plugins.tooltip, {
417
+ backgroundColor: themeColors.tooltipBg,
418
+ titleColor: themeColors.tooltipTitle,
419
+ bodyColor: themeColors.tooltipBody,
420
+ borderColor: themeColors.tooltipBorder,
421
+ });
422
+ }
423
+ chart.update('none');
424
+ }
425
+ }
426
+ }
427
+
428
+ // --- Chart Initialization & Core Logic... (Full functions below) ---
429
+
430
+ // --- All Functions ---
431
+ const logic = {
432
+ createChart(id, config) {
433
+ const chart = new Chart(document.getElementById(id).getContext('2d'), config);
434
+ allChartInstances[id] = chart;
435
+ return chart;
436
+ },
437
+ getCommonChartOptions() {
438
+ const themeColors = dom.body.getAttribute('data-theme') === 'dark' ? darkThemeChartColors : lightThemeChartColors;
439
+ return {
440
+ responsive: true, maintainAspectRatio: false,
441
+ scales: {
442
+ x: { ticks: { color: themeColors.ticks, maxRotation: 0, autoSkipPadding: 20 }, grid: { color: themeColors.grid, borderDash: [4, 4], drawOnChartArea: false } },
443
+ y: { beginAtZero: true, ticks: { color: themeColors.ticks }, grid: { color: themeColors.grid, borderDash: [4, 4] } }
444
+ },
445
+ plugins: {
446
+ legend: { position: 'top', align: 'end', labels: { color: themeColors.legend, boxWidth: 12, padding: 20, usePointStyle: true, pointStyle: 'rectRounded' } },
447
+ tooltip: { backgroundColor: themeColors.tooltipBg, titleColor: themeColors.tooltipTitle, bodyColor: themeColors.tooltipBody, padding: 10, cornerRadius: 4, borderColor: themeColors.tooltipBorder, borderWidth: 1 }
448
+ },
449
+ animation: { duration: 200 }
450
+ };
451
+ },
452
+ initializePrimaryCharts() {
453
+ const heapChartCtx = document.getElementById('heapChart').getContext('2d');
454
+ const gradientLive = heapChartCtx.createLinearGradient(0, 0, 0, 300);
455
+ gradientLive.addColorStop(0, 'rgba(79, 128, 225, 0.4)');
456
+ gradientLive.addColorStop(1, 'rgba(79, 128, 225, 0)');
457
+ const gradientFree = heapChartCtx.createLinearGradient(0, 0, 0, 300);
458
+ gradientFree.addColorStop(0, 'rgba(245, 142, 70, 0.4)');
459
+ gradientFree.addColorStop(1, 'rgba(245, 142, 70, 0)');
460
+ this.createChart('heapChart', { type: 'line', data: { labels: [], datasets: [ { label: 'Heap Live Slots', data: [], borderColor: VIBRANT_COLORS.blue, backgroundColor: gradientLive, fill: true, tension: 0.4, pointRadius: 0 }, { label: 'Heap Free Slots', data: [], borderColor: VIBRANT_COLORS.orange, backgroundColor: gradientFree, fill: true, tension: 0.4, pointRadius: 0 } ] }, options: this.getCommonChartOptions() });
461
+ this.createChart('gcPauseChart', { type: 'bar', data: { labels: [], datasets: [{ label: 'GC Pause (ms)', data: [], backgroundColor: 'rgba(224, 93, 93, 0.6)', borderColor: VIBRANT_COLORS.red, borderWidth: 1, barThickness: 12 }] }, options: this.getCommonChartOptions() });
462
+ },
463
+ setupEventListeners() {
464
+ dom.themeToggle.addEventListener('click', () => {
465
+ if (state.themeMode === 'auto') state.themeMode = 'light';
466
+ else if (state.themeMode === 'light') state.themeMode = 'dark';
467
+ else state.themeMode = 'auto';
468
+
469
+ if (state.themeMode === 'auto') localStorage.removeItem('userThemePreference');
470
+ else localStorage.setItem('userThemePreference', state.themeMode);
471
+
472
+ applyAndDisplayCurrentTheme();
473
+ });
474
+
475
+ dom.downloadPdfBtn.addEventListener('click', this.generatePdf);
476
+
477
+ document.querySelectorAll('.accordion-header').forEach(header => { header.addEventListener('click', () => { header.classList.toggle('open'); header.nextElementSibling.classList.toggle('hidden'); }); });
478
+ dom.modalCloseBtn.addEventListener('click', () => dom.modal.classList.add('hidden'));
479
+ dom.modal.addEventListener('click', (e) => { if (e.target === dom.modal) dom.modal.classList.add('hidden'); });
480
+
481
+ dom.modalChartTypeToggle.addEventListener('click', (e) => {
482
+ const button = e.target.closest('button');
483
+ if (!button || button.classList.contains('active')) return;
484
+ state.modalChartType = button.dataset.chartType;
485
+ dom.modalChartTypeToggle.querySelector('.active').classList.remove('active');
486
+ button.classList.add('active');
487
+ this.renderModalChart();
488
+ });
489
+
490
+ dom.objectsTableWidget.addEventListener('click', (e) => {
491
+ const btn = e.target.closest('.history-btn');
492
+ if (btn) this.showHistory(btn.closest('tr').dataset.className, btn.closest('tr').dataset.className, state.objectHistoryData);
493
+ });
494
+ dom.gcStatsContent.addEventListener('click', (e) => {
495
+ const btn = e.target.closest('.history-btn');
496
+ if (btn) this.showHistory(btn.closest('tr').dataset.metricKey, this.formatKey(btn.closest('tr').dataset.metricKey), state.gcStatsHistory);
497
+ });
498
+ dom.objectSummaryContent.addEventListener('click', (e) => {
499
+ const btn = e.target.closest('.history-btn');
500
+ if (btn) this.showHistory(btn.closest('tr').dataset.metricKey, btn.closest('tr').dataset.metricKey, state.objectSpaceHistory);
501
+ });
502
+
503
+ dom.nativeSearch.addEventListener('input', (e) => { state.nativeSearchTerm = e.target.value; this.updateObjectClassTable(state.latestLivingObjectsData); });
504
+ dom.tailoredSearch.addEventListener('input', (e) => { state.tailoredSearchTerm = e.target.value; this.updateObjectClassTable(state.latestLivingObjectsData); });
505
+ dom.gcStatsSearch.addEventListener('input', (e) => { state.gcStatsSearchTerm = e.target.value; this.populateKeyValueTable('gc-stats-table-body', state.latestGcStatsData, state.gcStatsSearchTerm, this.formatKey); });
506
+ dom.objectSummarySearch.addEventListener('input', (e) => { state.objectSummarySearchTerm = e.target.value; this.populateKeyValueTable('object-summary-table-body', state.latestObjectSummaryData, state.objectSummarySearchTerm); });
507
+
508
+ dom.platformHistorySelect.addEventListener('change', (e) => {
509
+ state.platformHistoryClassCount = parseInt(e.target.value, 10);
510
+ dom.platformHistoryWidgetTitle.textContent = `Living Platform Objects History (Top ${state.platformHistoryClassCount})`;
511
+ if (state.latestLivingObjectsData) this.rebuildObjectHistoryChart(state.latestLivingObjectsData, true);
512
+ });
513
+ dom.appHistorySelect.addEventListener('change', (e) => {
514
+ state.appHistoryClassCount = parseInt(e.target.value, 10);
515
+ dom.appHistoryWidgetTitle.textContent = `Living Application Objects History (Top ${state.appHistoryClassCount})`;
516
+ if (state.latestLivingObjectsData) this.rebuildObjectHistoryChart(state.latestLivingObjectsData, false);
517
+ });
518
+ },
519
+ async generatePdf() {
520
+ const { jsPDF } = jspdf;
521
+ const canvasRenderer = html2canvas;
522
+
523
+ const downloadBtn = dom.downloadPdfBtn;
524
+ const originalIcon = downloadBtn.innerHTML;
525
+ downloadBtn.innerHTML = `<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>`;
526
+ downloadBtn.disabled = true;
527
+
528
+ try {
529
+ const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' });
530
+ const pageHeight = pdf.internal.pageSize.height;
531
+ const pageWidth = pdf.internal.pageSize.width;
532
+ const margin = 15;
533
+ let y = 0;
534
+
535
+ const addPageNumbers = () => {
536
+ const pageCount = pdf.internal.getNumberOfPages();
537
+ pdf.setFont("helvetica", "normal").setFontSize(10);
538
+ for (let i = 1; i <= pageCount; i++) {
539
+ pdf.setPage(i);
540
+ pdf.text(`Page ${i} of ${pageCount}`, pageWidth - margin, pageHeight - 10, { align: 'right' });
541
+ }
542
+ };
543
+
544
+ // --- 1. Title & Header ---
545
+ pdf.setFont("helvetica", "bold").setFontSize(22);
546
+ pdf.text("Rails Runtime Analysis Report", pageWidth / 2, margin + 5, { align: 'center' });
547
+
548
+ pdf.setFont("helvetica", "normal").setFontSize(12);
549
+ const reportDate = new Date();
550
+ pdf.text(`Generated: ${reportDate.toLocaleDateString()} ${reportDate.toLocaleTimeString()}`, pageWidth / 2, margin + 15, { align: 'center' });
551
+
552
+ // --- 2. Key Metrics ---
553
+ const metrics = {
554
+ "Heap Live Slots": document.getElementById('metric-heap-live').textContent,
555
+ "Total Allocated Objects": document.getElementById('metric-total-allocated').textContent,
556
+ "Major GCs": document.getElementById('metric-major-gc').textContent,
557
+ "Minor GCs": document.getElementById('metric-minor-gc').textContent,
558
+ "Last GC Pause (ms)": document.getElementById('metric-last-gc-pause').textContent
559
+ };
560
+ const metricsBody = Object.entries(metrics).map(([key, value]) => [key, value]);
561
+
562
+ pdf.autoTable({
563
+ startY: margin + 25,
564
+ head: [['Metric', 'Value']],
565
+ body: metricsBody,
566
+ headStyles: { fillColor: [41, 128, 185], textColor: 255 },
567
+ theme: 'striped'
568
+ });
569
+ y = pdf.previousAutoTable.finalY + 10;
570
+
571
+ // --- 3. Charts ---
572
+ const chartIds = ['heapChart', 'gcPauseChart', 'platformObjectHistoryChart', 'appObjectHistoryChart'];
573
+ for (const id of chartIds) {
574
+ const chartEl = document.getElementById(id);
575
+ const widgetEl = chartEl ? chartEl.closest('.widget') : null;
576
+ if (widgetEl && widgetEl.style.display !== 'none' && canvas.style.display !== 'none') {
577
+ const chartTitle = widgetEl.querySelector('.widget-title').textContent;
578
+
579
+ const canvas = await canvasRenderer(widgetEl.querySelector('.chart-container'), {
580
+ scale: 2,
581
+ backgroundColor: dom.body.getAttribute('data-theme') === 'dark' ? '#2a2a33' : '#ffffff'
582
+ });
583
+
584
+ const imgData = canvas.toDataURL('image/png', 0.95);
585
+ const imgWidth = pageWidth - (margin * 2);
586
+ const imgHeight = (canvas.height * imgWidth) / canvas.width;
587
+
588
+ if (y + imgHeight + 15 > pageHeight) {
589
+ pdf.addPage();
590
+ y = margin;
591
+ }
592
+
593
+ pdf.setFont("helvetica", "bold").setFontSize(14);
594
+ pdf.text(chartTitle, margin, y);
595
+ y += 6;
596
+
597
+ pdf.addImage(imgData, 'PNG', margin, y, imgWidth, imgHeight);
598
+ y += imgHeight + 10;
599
+ }
600
+ }
601
+
602
+ // --- 4. Data Tables ---
603
+ const headStyles = { fillColor: [41, 128, 185], textColor: 255 };
604
+
605
+ if (state.latestLivingObjectsData) {
606
+ const appObjects = Object.entries(state.latestLivingObjectsData)
607
+ .filter(([, details]) => !details.is_platform_class)
608
+ .map(([name, details]) => [name, details.count.toLocaleString()]);
609
+
610
+ if (appObjects.length > 0) {
611
+ pdf.autoTable({ startY: y, head: [['Application Class', 'Count']], body: appObjects, headStyles: headStyles, margin: { left: margin, right: margin }, theme: 'grid' });
612
+ y = pdf.previousAutoTable.finalY + 10;
613
+ }
614
+
615
+ const platformObjects = Object.entries(state.latestLivingObjectsData)
616
+ .filter(([, details]) => details.is_platform_class)
617
+ .sort(([, a], [, b]) => b.count - a.count)
618
+ .slice(0, 25)
619
+ .map(([name, details]) => [name, details.count.toLocaleString()]);
620
+
621
+ if (platformObjects.length > 0) {
622
+ pdf.autoTable({ startY: y, head: [['Platform Class (Top 25)', 'Count']], body: platformObjects, headStyles: headStyles, margin: { left: margin, right: margin }, theme: 'grid' });
623
+ y = pdf.previousAutoTable.finalY + 10;
624
+ }
625
+ }
626
+
627
+ if (state.latestGcStatsData) {
628
+ const gcStats = Object.entries(state.latestGcStatsData)
629
+ .map(([key, value]) => [logic.formatKey(key), value.toLocaleString()]);
630
+ pdf.autoTable({ startY: y, head: [['Full GC Statistics', 'Value']], body: gcStats, headStyles: headStyles, margin: { left: margin, right: margin }, theme: 'grid' });
631
+ }
632
+
633
+ // --- 5. Page Numbers ---
634
+ addPageNumbers();
635
+
636
+ // --- 6. Save ---
637
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
638
+ pdf.save(`rails-runtime-report-${timestamp}.pdf`);
639
+
640
+ } catch (error) {
641
+ console.error("Error generating PDF:", error);
642
+ alert("Sorry, there was an error generating the PDF report.");
643
+ } finally {
644
+ downloadBtn.innerHTML = originalIcon;
645
+ downloadBtn.disabled = false;
646
+ }
647
+ },
648
+ connectWebSocket() {
649
+ const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/cable`;
650
+ state.socket = new WebSocket(wsUrl);
651
+ state.socket.onopen = () => { this.updateStatus('Connected', 'green'); state.socket.send(JSON.stringify({ command: 'subscribe', identifier: JSON.stringify({ channel: 'HeapPeriscopeUi::RuntimeStatsChannel' }) })); };
652
+ state.socket.onmessage = (event) => {
653
+ try {
654
+ const data = JSON.parse(event.data);
655
+ if (data.type === 'ping' || data.type === 'welcome' || !data.message) return;
656
+ const message = data.message;
657
+ switch (message.type) {
658
+ case 'snapshot': this.handleSnapshot(message.payload); this.addLogEntry(`Received snapshot from PID ${message.process_id}`, 'info'); break;
659
+ case 'gc_profiler_report': this.handleGcReport(message.payload); this.addLogEntry(`GC cycle. Pause: ${message.payload.gc_duration_since_last_check_ms.toFixed(2)}ms`, 'gc'); break;
660
+ }
661
+ } catch (error) { console.error("Failed to parse websocket message:", error); }
662
+ };
663
+ state.socket.onclose = () => { this.updateStatus('Disconnected', 'red'); setTimeout(() => this.connectWebSocket(), 5000); };
664
+ state.socket.onerror = (error) => { console.error('WebSocket Error:', error); this.updateStatus('Error', 'red'); };
665
+ },
666
+ handleSnapshot(payload) {
667
+ const stats = payload.gc_stats;
668
+ if (!stats) return;
669
+ document.getElementById('metric-heap-live').textContent = stats.heap_live_slots.toLocaleString();
670
+ document.getElementById('metric-total-allocated').textContent = stats.total_allocated_objects.toLocaleString();
671
+ document.getElementById('metric-major-gc').textContent = stats.major_gc_count.toLocaleString();
672
+ document.getElementById('metric-minor-gc').textContent = stats.minor_gc_count.toLocaleString();
673
+ this.updateChart(allChartInstances.heapChart, new Date().toLocaleTimeString(), [stats.heap_live_slots, stats.heap_free_slots]);
674
+ const timeLabel = new Date().toLocaleTimeString();
675
+ const histories = { objectHistoryData: state.objectHistoryData, gcStatsHistory: state.gcStatsHistory, objectSpaceHistory: state.objectSpaceHistory };
676
+ for(const key in histories) {
677
+ if(Object.keys(histories[key]).length > MAX_DATA_POINTS) {
678
+ delete histories[key][Object.keys(histories[key])[0]];
679
+ }
680
+ }
681
+ state.gcStatsHistory[timeLabel] = payload.gc_stats;
682
+ state.objectSpaceHistory[timeLabel] = payload.object_space_summary;
683
+ state.latestGcStatsData = payload.gc_stats;
684
+ state.latestObjectSummaryData = payload.object_space_summary;
685
+ this.populateKeyValueTable('gc-stats-table-body', state.latestGcStatsData, state.gcStatsSearchTerm, this.formatKey);
686
+ this.populateKeyValueTable('object-summary-table-body', state.latestObjectSummaryData, state.objectSummarySearchTerm);
687
+ if (payload.living_objects_by_class) {
688
+ state.objectHistoryData[timeLabel] = payload.living_objects_by_class;
689
+ state.latestLivingObjectsData = payload.living_objects_by_class;
690
+ this.updateObjectClassTable(state.latestLivingObjectsData);
691
+ if (Object.keys(state.latestLivingObjectsData).length > 0) {
692
+ // Platform Chart
693
+ if (!allChartInstances.platformObjectHistoryChart) this.rebuildObjectHistoryChart(state.latestLivingObjectsData, true);
694
+ else this.updateObjectHistoryChart(state.latestLivingObjectsData, true);
695
+
696
+ // Application Chart
697
+ if (!allChartInstances.appObjectHistoryChart) this.rebuildObjectHistoryChart(state.latestLivingObjectsData, false);
698
+ else this.updateObjectHistoryChart(state.latestLivingObjectsData, false);
699
+ }
700
+ }
701
+ },
702
+ handleGcReport(payload) {
703
+ const duration = payload.gc_duration_since_last_check_ms;
704
+ document.getElementById('metric-last-gc-pause').textContent = duration.toFixed(2);
705
+ this.updateChart(allChartInstances.gcPauseChart, new Date().toLocaleTimeString(), [duration]);
706
+ },
707
+ showHistory(dataKey, formattedName, historyObject) {
708
+ dom.modalTitle.textContent = `History for: ${formattedName}`;
709
+ state.currentModalData = {
710
+ dataKey: dataKey,
711
+ formattedName: formattedName,
712
+ labels: Object.keys(historyObject),
713
+ dataPoints: Object.values(historyObject).map(snapshot => snapshot ? (snapshot[dataKey]?.count ?? snapshot[dataKey] ?? 0) : 0)
714
+ };
715
+ this.renderModalChart();
716
+ dom.modal.classList.remove('hidden');
717
+ },
718
+ renderModalChart() {
719
+ if (allChartInstances.modalChart) allChartInstances.modalChart.destroy();
720
+ const { formattedName, labels, dataPoints } = state.currentModalData;
721
+ let datasetOptions;
722
+ if (state.modalChartType === 'line') {
723
+ datasetOptions = { borderColor: VIBRANT_COLORS.cyan, backgroundColor: 'rgba(56, 189, 248, 0.2)', fill: true, tension: 0.3, pointRadius: 1 };
724
+ } else {
725
+ datasetOptions = { backgroundColor: 'rgba(56, 189, 248, 0.6)', borderColor: 'rgba(56, 189, 248, 1)', borderWidth: 1 };
726
+ }
727
+ const chartOptions = this.getCommonChartOptions();
728
+ chartOptions.scales.x.ticks.autoSkipPadding = 40;
729
+ this.createChart('modalChart', { type: state.modalChartType, data: { labels, datasets: [{ label: `Value of ${formattedName}`, data: dataPoints, ...datasetOptions }] }, options: chartOptions });
730
+ },
731
+ rebuildObjectHistoryChart(data, isPlatform) {
732
+ const chartId = isPlatform ? 'platformObjectHistoryChart' : 'appObjectHistoryChart';
733
+ const placeholderId = isPlatform ? 'platform-object-chart-placeholder' : 'app-object-chart-placeholder';
734
+ const stateKey = isPlatform ? 'trackedPlatformClasses' : 'trackedAppClasses';
735
+ const topN = isPlatform ? state.platformHistoryClassCount : state.appHistoryClassCount;
736
+
737
+ const placeholder = document.getElementById(placeholderId);
738
+ const canvas = document.getElementById(chartId);
739
+
740
+ if (allChartInstances[chartId]) allChartInstances[chartId].destroy();
741
+
742
+ const sortedData = Object.entries(data)
743
+ .filter(([, details]) => details.is_platform_class === isPlatform)
744
+ .sort(([, a], [, b]) => b.count - a.count);
745
+
746
+ state[stateKey] = sortedData.slice(0, topN).map(([name]) => name);
747
+
748
+ if (state[stateKey].length > 0) {
749
+ placeholder.style.display = 'none';
750
+ canvas.style.display = 'block';
751
+
752
+ const datasets = state[stateKey].map((className, index) => ({
753
+ label: className,
754
+ data: Object.values(state.objectHistoryData).map(h => h[className]?.count ?? 0),
755
+ backgroundColor: CHART_COLOR_PALETTE[index % CHART_COLOR_PALETTE.length],
756
+ barPercentage: 0.9,
757
+ categoryPercentage: 0.8
758
+ }));
759
+
760
+ const chartOptions = this.getCommonChartOptions();
761
+ chartOptions.scales.x.stacked = true;
762
+ chartOptions.scales.y.stacked = true;
763
+
764
+ this.createChart(chartId, { type: 'bar', data: { labels: Object.keys(state.objectHistoryData), datasets }, options: chartOptions });
765
+ } else {
766
+ placeholder.style.display = 'block';
767
+ canvas.style.display = 'none';
768
+ delete allChartInstances[chartId];
769
+ state[stateKey] = null;
770
+ }
771
+ },
772
+ updateObjectHistoryChart(data, isPlatform) {
773
+ const chartId = isPlatform ? 'platformObjectHistoryChart' : 'appObjectHistoryChart';
774
+ const stateKey = isPlatform ? 'trackedPlatformClasses' : 'trackedAppClasses';
775
+ const chart = allChartInstances[chartId];
776
+
777
+ if (!chart || !state[stateKey]?.length) return;
778
+
779
+ const newTimeLabel = new Date().toLocaleTimeString();
780
+ if (chart.data.labels.length === 0 || chart.data.labels[chart.data.labels.length - 1] !== newTimeLabel) {
781
+ chart.data.labels.push(newTimeLabel);
782
+ }
783
+
784
+ chart.data.datasets.forEach(dataset => {
785
+ const count = data[dataset.label]?.count ?? 0;
786
+ if (chart.data.labels.length > dataset.data.length) dataset.data.push(count);
787
+ else dataset.data[dataset.data.length - 1] = count;
788
+ if (dataset.data.length > MAX_DATA_POINTS) dataset.data.shift();
789
+ });
790
+ if (chart.data.labels.length > MAX_DATA_POINTS) chart.data.labels.shift();
791
+ chart.update('none');
792
+ },
793
+ updateObjectClassTable(objectData) {
794
+ const nativeBody = document.getElementById('native-object-table-body');
795
+ const tailoredBody = document.getElementById('tailored-object-table-body');
796
+ nativeBody.innerHTML = ''; tailoredBody.innerHTML = '';
797
+ let nativeFound = false, tailoredFound = false;
798
+ if (objectData) {
799
+ Object.entries(objectData).sort(([,a], [,b]) => b.count - a.count).forEach(([name, details]) => {
800
+ const rowHtml = `<tr data-class-name="${name}"><td class="py-2 px-3 font-mono text-sky-400">${name}</td><td class="py-2 px-3 font-mono text-right flex items-center justify-end gap-4"><span>${details.count.toLocaleString()}</span><button class="history-btn">History</button></td></tr>`;
801
+ const lowerName = name.toLowerCase();
802
+ if (details.is_platform_class) {
803
+ if (lowerName.includes(state.nativeSearchTerm.toLowerCase())) { nativeBody.innerHTML += rowHtml; nativeFound = true; }
804
+ } else {
805
+ if (lowerName.includes(state.tailoredSearchTerm.toLowerCase())) { tailoredBody.innerHTML += rowHtml; tailoredFound = true; }
806
+ }
807
+ });
808
+ }
809
+ if (!nativeFound) nativeBody.innerHTML = `<tr><td colspan="2" class="text-center py-4 text-[var(--text-secondary)]">${state.nativeSearchTerm ? 'No matching classes.' : 'No platform objects.'}</td></tr>`;
810
+ if (!tailoredFound) tailoredBody.innerHTML = `<tr><td colspan="2" class="text-center py-4 text-[var(--text-secondary)]">${state.tailoredSearchTerm ? 'No matching classes.' : 'No application objects.'}</td></tr>`;
811
+ },
812
+ populateKeyValueTable(bodyId, dataObject, searchTerm = '', keyFormatter = key => key) {
813
+ const tableBody = document.getElementById(bodyId);
814
+ if (!dataObject) { tableBody.innerHTML = `<tr><td colspan="2" class="text-center py-4 text-[var(--text-secondary)]">Waiting for data...</td></tr>`; return; }
815
+ const lowerSearchTerm = searchTerm.toLowerCase();
816
+ const filteredData = Object.entries(dataObject).filter(([key]) => keyFormatter(key).toLowerCase().includes(lowerSearchTerm));
817
+ if (filteredData.length === 0) { tableBody.innerHTML = `<tr><td colspan="2" class="text-center py-4 text-[var(--text-secondary)]">${searchTerm ? 'No matching items found.' : 'No data.'}</td></tr>`; return; }
818
+ tableBody.innerHTML = filteredData.map(([key, value]) => `<tr data-metric-key="${key}"><td class="py-1.5 px-3 font-mono text-[var(--text-secondary)]">${keyFormatter(key)}</td><td class="py-1.5 px-3 font-mono text-right flex items-center justify-end gap-4"><span class="text-emerald-400">${typeof value === 'number' ? value.toLocaleString() : value}</span><button class="history-btn">History</button></td></tr>`).join('');
819
+ },
820
+ formatKey: key => key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
821
+ updateChart(chart, label, dataArray) {
822
+ if (!chart) return;
823
+ chart.data.labels.push(label);
824
+ dataArray.forEach((value, index) => { chart.data.datasets[index].data.push(value); });
825
+ if (chart.data.labels.length > MAX_DATA_POINTS) {
826
+ chart.data.labels.shift();
827
+ chart.data.datasets.forEach(dataset => dataset.data.shift());
828
+ }
829
+ chart.update('none');
830
+ },
831
+ updateStatus(text, color) {
832
+ dom.statusText.textContent = text;
833
+ dom.statusDot.className = 'w-2.5 h-2.5 rounded-full';
834
+ if (color === 'green') dom.statusDot.classList.add('bg-green-500');
835
+ else if (color === 'red') dom.statusDot.classList.add('bg-red-500');
836
+ else dom.statusDot.classList.add('bg-yellow-500', 'animate-pulse');
837
+ },
838
+ addLogEntry(message, type) {
839
+ if (dom.eventLog.querySelector('p.text-center')) dom.eventLog.innerHTML = '';
840
+ const entry = document.createElement('p');
841
+ const color = type === 'info' ? 'text-cyan-400' : 'text-red-400';
842
+ entry.className = `log-entry font-mono text-xs ${color}`;
843
+ entry.innerHTML = `<span class="text-[var(--text-secondary)] mr-2">${new Date().toLocaleTimeString('en-GB')}</span> ${message}`;
844
+ dom.eventLog.prepend(entry);
845
+ while (dom.eventLog.childNodes.length > MAX_LOG_ENTRIES) dom.eventLog.lastChild.remove();
846
+ },
847
+ };
848
+
849
+ function init() {
850
+ logic.initializePrimaryCharts();
851
+ logic.setupEventListeners();
852
+ const savedPreference = localStorage.getItem('userThemePreference');
853
+ state.themeMode = savedPreference || 'auto';
854
+ applyAndDisplayCurrentTheme();
855
+ logic.connectWebSocket();
856
+ }
857
+
858
+ init();
859
+ });
860
+ </script>
861
+ </body>
862
+ </html>