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,1132 @@
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
+ height: 100%;
85
+ }
86
+
87
+ /* Styles for Search and Hover Buttons */
88
+ .search-input {
89
+ width: 100%;
90
+ background-color: var(--input-bg);
91
+ border: 1px solid var(--border);
92
+ color: var(--text-primary);
93
+ border-radius: 5px;
94
+ padding: 0.375rem 0.75rem;
95
+ font-size: 0.875rem;
96
+ margin-bottom: 0.75rem;
97
+ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
98
+ }
99
+ .search-input:focus {
100
+ outline: none;
101
+ border-color: #4f80e1; /* Use a consistent focus color */
102
+ box-shadow: 0 0 0 2px rgba(79, 128, 225, 0.5);
103
+ }
104
+ .history-btn {
105
+ visibility: hidden;
106
+ opacity: 0;
107
+ transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
108
+ background-color: #35353d;
109
+ color: #e0e0e0;
110
+ border: 1px solid #4a4a52;
111
+ padding: 2px 8px;
112
+ font-size: 11px;
113
+ font-weight: 500;
114
+ border-radius: 4px;
115
+ cursor: pointer;
116
+ }
117
+ [data-theme="light"] .history-btn {
118
+ background-color: #e5e7eb;
119
+ border-color: #d1d5db;
120
+ color: #111827;
121
+ }
122
+ tr:hover .history-btn {
123
+ visibility: visible;
124
+ opacity: 1;
125
+ }
126
+ .history-btn:hover {
127
+ background-color: #4f80e1;
128
+ border-color: #4f80e1;
129
+ color: white;
130
+ }
131
+
132
+ /* Styles for Custom Select */
133
+ .select-wrapper { position: relative; display: inline-block; }
134
+ .custom-select {
135
+ appearance: none;
136
+ -webkit-appearance: none;
137
+ background-color: var(--border);
138
+ color: var(--text-primary);
139
+ border-radius: 5px;
140
+ padding: 0.25rem 2rem 0.25rem 0.75rem;
141
+ font-size: 0.875rem;
142
+ border: none;
143
+ cursor: pointer;
144
+ transition: background-color 0.2s ease-in-out;
145
+ }
146
+ .select-wrapper::after {
147
+ content: '▾';
148
+ position: absolute;
149
+ right: 0.75rem;
150
+ top: 50%;
151
+ transform: translateY(-50%);
152
+ pointer-events: none;
153
+ color: var(--text-secondary);
154
+ }
155
+
156
+ /* Styles for Modal Chart Toggle */
157
+ .chart-toggle-group {
158
+ display: inline-flex;
159
+ background-color: var(--toggle-bg);
160
+ border: 1px solid var(--border);
161
+ border-radius: 5px;
162
+ padding: 2px;
163
+ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
164
+ }
165
+ .toggle-btn {
166
+ background-color: transparent;
167
+ color: var(--text-secondary);
168
+ border: none;
169
+ padding: 4px 12px;
170
+ font-size: 12px;
171
+ font-weight: 500;
172
+ border-radius: 4px;
173
+ cursor: pointer;
174
+ transition: all 0.2s ease-in-out;
175
+ }
176
+ .toggle-btn.active {
177
+ background-color: var(--toggle-active-bg);
178
+ color: var(--text-primary);
179
+ }
180
+
181
+ .log-entry { animation: fadeIn 0.5s ease-in-out; }
182
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
183
+ </style>
184
+ </head>
185
+ <body data-theme="dark">
186
+
187
+ <div class="container mx-auto p-4 md:p-6">
188
+ <header class="mb-6 flex justify-between items-center">
189
+ <div class="flex items-center gap-3 w-full max-w-sm">
190
+ <h1 class="text-xl font-bold shrink-0">Service:</h1>
191
+ <div class="select-wrapper flex-grow">
192
+ <select id="service-selector" class="custom-select w-full !text-base !font-medium !py-1 !px-3 !bg-[var(--widget-bg)] border border-[var(--border)] focus:outline-none focus:border-[#4f80e1] focus:ring-2 focus:ring-[#4f80e1]/50">
193
+ <option>Waiting for services...</option>
194
+ </select>
195
+ </div>
196
+ </div>
197
+ <div class="flex items-center gap-4">
198
+ <div id="status-indicator" class="flex items-center space-x-2 text-sm">
199
+ <div id="status-dot" class="w-2.5 h-2.5 rounded-full bg-yellow-500"></div>
200
+ <span id="status-text" class="text-[var(--text-secondary)] font-medium">Connecting...</span>
201
+ </div>
202
+ <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">
203
+ <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
204
+ <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" />
205
+ </svg>
206
+ </button>
207
+ <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)]">
208
+ <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>
209
+ <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>
210
+ <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>
211
+ </button>
212
+ </div>
213
+ </header>
214
+
215
+ <main class="grid grid-cols-1 lg:grid-cols-3 gap-6">
216
+ <div class="lg:col-span-1 space-y-6">
217
+ <div class="widget">
218
+ <div class="widget-header"><h2 class="widget-title">Key Metrics</h2></div>
219
+ <div class="space-y-5">
220
+ <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>
221
+ <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>
222
+ <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>
223
+ <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>
224
+ <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>
225
+ </div>
226
+ </div>
227
+ <div class="widget">
228
+ <div class="widget-header"><h2 class="widget-title">Real-time Event Log</h2></div>
229
+ <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>
230
+ </div>
231
+ </div>
232
+
233
+ <div class="lg:col-span-2 space-y-6">
234
+ <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>
235
+ <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>
236
+
237
+ <div class="widget" id="objects-table-widget">
238
+ <div class="widget-header"><h2 class="widget-title">Living Objects Snapshot</h2></div>
239
+ <div class="space-y-8">
240
+ <div>
241
+ <h3 class="text-sm font-semibold text-sky-400 mb-2">Platform Classes</h3>
242
+ <input type="text" id="native-class-search" class="search-input" placeholder="Filter platform classes...">
243
+ <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>
244
+ </div>
245
+ <div>
246
+ <h3 class="text-sm font-semibold text-amber-400 mb-2">Application Classes</h3>
247
+ <input type="text" id="tailored-class-search" class="search-input" placeholder="Filter application classes...">
248
+ <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>
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ <div class="widget" id="platform-objects-chart-widget">
254
+ <div class="widget-header">
255
+ <h2 id="platform-history-widget-title" class="widget-title">Living Platform Objects History (Top 15)</h2>
256
+ <div class="select-wrapper">
257
+ <select id="platform-history-count-select" class="custom-select">
258
+ <option value="5">Top 5</option>
259
+ <option value="10">Top 10</option>
260
+ <option value="15" selected>Top 15</option>
261
+ <option value="20">Top 20</option>
262
+ </select>
263
+ </div>
264
+ </div>
265
+ <div class="chart-container"><canvas id="platformObjectHistoryChart"></canvas></div>
266
+ <p id="platform-object-chart-placeholder" class="text-center py-8 text-[var(--text-secondary)]">Waiting for snapshot to initialize platform history chart...</p>
267
+ </div>
268
+
269
+ <div class="widget" id="app-objects-chart-widget">
270
+ <div class="widget-header">
271
+ <h2 id="app-history-widget-title" class="widget-title">Living Application Objects History (Top 15)</h2>
272
+ <div class="select-wrapper">
273
+ <select id="app-history-count-select" class="custom-select">
274
+ <option value="5">Top 5</option>
275
+ <option value="10">Top 10</option>
276
+ <option value="15" selected>Top 15</option>
277
+ <option value="20">Top 20</option>
278
+ </select>
279
+ </div>
280
+ </div>
281
+ <div class="chart-container"><canvas id="appObjectHistoryChart"></canvas></div>
282
+ <p id="app-object-chart-placeholder" class="text-center py-8 text-[var(--text-secondary)]">Waiting for snapshot to initialize application history chart...</p>
283
+ </div>
284
+
285
+ <div class="widget p-0" id="gc-stats-widget">
286
+ <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>
287
+ <div id="gc-stats-widget-content" class="accordion-content hidden px-5 pb-5">
288
+ <input type="text" id="gc-stats-search" class="search-input" placeholder="Filter metrics...">
289
+ <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>
290
+ </div>
291
+ </div>
292
+ <div class="widget p-0" id="object-summary-widget">
293
+ <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>
294
+ <div id="object-summary-widget-content" class="accordion-content hidden px-5 pb-5">
295
+ <input type="text" id="object-summary-search" class="search-input" placeholder="Filter object types...">
296
+ <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>
297
+ </div>
298
+ </div>
299
+
300
+ <div class="widget" id="spans-widget">
301
+ <div class="widget-header">
302
+ <h2 class="widget-title">Living Objects by Span</h2>
303
+ <div class="flex items-center gap-2 flex-wrap">
304
+ <input type="datetime-local" id="span-start-date-filter" class="custom-select !py-1 !px-2 text-sm !pr-2" title="Start date and time">
305
+ <span class="text-[var(--text-secondary)]">to</span>
306
+ <input type="datetime-local" id="span-end-date-filter" class="custom-select !py-1 !px-2 text-sm !pr-2" title="End date and time">
307
+ <button id="apply-span-filter-btn" class="bg-[#4f80e1] text-white px-3 py-1 rounded-md text-sm hover:bg-[#3a6ed1] transition-colors">Apply</button>
308
+ </div>
309
+ </div>
310
+ <div id="spans-container" class="space-y-1 text-xs">
311
+ <p class="text-center py-8 text-[var(--text-secondary)]">Waiting for snapshot to provide span data...</p>
312
+ </div>
313
+ </div>
314
+ </div>
315
+ </main>
316
+ </div>
317
+
318
+ <div id="class-history-modal" class="modal fixed inset-0 bg-black/70 hidden flex items-center justify-center p-4">
319
+ <div class="modal-content w-full max-w-4xl">
320
+ <div class="flex justify-between items-center p-4 border-b border-[var(--border)]">
321
+ <h2 id="modal-title" class="text-lg font-semibold flex-1 truncate">History</h2>
322
+ <div id="modal-chart-type-toggle" class="chart-toggle-group mx-4">
323
+ <button class="toggle-btn active" data-chart-type="line">Line</button>
324
+ <button class="toggle-btn" data-chart-type="bar">Bar</button>
325
+ </div>
326
+ <button id="modal-close-btn" class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-3xl leading-none">&times;</button>
327
+ </div>
328
+ <div class="p-5">
329
+ <div id="modal-content-area" class="h-96">
330
+ </div>
331
+ </div>
332
+ </div>
333
+ </div>
334
+
335
+ <script>
336
+ document.addEventListener('DOMContentLoaded', () => {
337
+ // --- Config ---
338
+ const VIBRANT_COLORS = { blue: '#4f80e1', green: '#2d9d78', purple: '#a37ac5', yellow: '#f0ba49', red: '#e05d5d', orange: '#f58e46', cyan: '#38bdf8', pink: '#f472b6' };
339
+ const CHART_COLOR_PALETTE = Object.values(VIBRANT_COLORS);
340
+ const darkThemeChartColors = { ticks: '#a0a0b0', grid: '#3c3c44', legend: '#e0e0e0', tooltipBody: '#e0e0e0', tooltipTitle: '#a0a0b0', tooltipBg: 'rgba(0,0,0,0.8)', tooltipBorder: '#3c3c44' };
341
+ const lightThemeChartColors = { ticks: '#6b7280', grid: '#e5e7eb', legend: '#111827', tooltipBody: '#111827', tooltipTitle: '#6b7280', tooltipBg: 'rgba(255,255,255,0.9)', tooltipBorder: '#e5e7eb' };
342
+ const MAX_DATA_POINTS = 45;
343
+ const MAX_LOG_ENTRIES = 100;
344
+
345
+ // --- DOM Elements ---
346
+ const dom = {
347
+ body: document.body,
348
+ container: document.querySelector('.container.mx-auto'),
349
+ themeToggle: document.getElementById('theme-toggle'),
350
+ serviceSelector: document.getElementById('service-selector'),
351
+ downloadPdfBtn: document.getElementById('download-pdf-btn'),
352
+ themeIcons: {
353
+ auto: document.getElementById('theme-icon-auto'),
354
+ sun: document.getElementById('theme-icon-sun'),
355
+ moon: document.getElementById('theme-icon-moon'),
356
+ },
357
+ statusText: document.getElementById('status-text'),
358
+ statusDot: document.getElementById('status-dot'),
359
+ eventLog: document.getElementById('event-log'),
360
+ spansContainer: document.getElementById('spans-container'),
361
+ modal: document.getElementById('class-history-modal'),
362
+ modalContentArea: document.getElementById('modal-content-area'),
363
+ modalTitle: document.getElementById('modal-title'),
364
+ modalCloseBtn: document.getElementById('modal-close-btn'),
365
+ modalChartTypeToggle: document.getElementById('modal-chart-type-toggle'),
366
+ platformHistorySelect: document.getElementById('platform-history-count-select'),
367
+ platformHistoryWidgetTitle: document.getElementById('platform-history-widget-title'),
368
+ appHistorySelect: document.getElementById('app-history-count-select'),
369
+ appHistoryWidgetTitle: document.getElementById('app-history-widget-title'),
370
+ nativeSearch: document.getElementById('native-class-search'),
371
+ tailoredSearch: document.getElementById('tailored-class-search'),
372
+ gcStatsSearch: document.getElementById('gc-stats-search'),
373
+ objectSummarySearch: document.getElementById('object-summary-search'),
374
+ gcStatsContent: document.getElementById('gc-stats-widget-content'),
375
+ objectSummaryContent: document.getElementById('object-summary-widget-content'),
376
+ objectsTableWidget: document.getElementById('objects-table-widget'),
377
+ spanStartDateFilter: document.getElementById('span-start-date-filter'),
378
+ spanEndDateFilter: document.getElementById('span-end-date-filter'),
379
+ applySpanFilterBtn: document.getElementById('apply-span-filter-btn'),
380
+ };
381
+
382
+ // --- State ---
383
+ let state = {
384
+ socket: null,
385
+ themeMode: 'auto', // auto, light, dark
386
+ modalChartType: 'line',
387
+ currentModalData: {},
388
+ activeService: null,
389
+ services: {},
390
+ };
391
+ const allChartInstances = {};
392
+
393
+ // --- Theme Management ---
394
+ function applyAndDisplayCurrentTheme() {
395
+ let themeToApply;
396
+ let autoTheme = '';
397
+
398
+ if (state.themeMode === 'auto') {
399
+ const currentHour = new Date().getHours();
400
+ themeToApply = (currentHour >= 19 || currentHour < 6) ? 'dark' : 'light';
401
+ autoTheme = ` (${themeToApply.charAt(0).toUpperCase() + themeToApply.slice(1)})`;
402
+ } else {
403
+ themeToApply = state.themeMode;
404
+ }
405
+
406
+ setThemeOnPage(themeToApply);
407
+ updateToggleButtonState(autoTheme);
408
+ }
409
+
410
+ function setThemeOnPage(theme) {
411
+ dom.body.setAttribute('data-theme', theme);
412
+ updateAllChartsTheme(theme);
413
+ }
414
+
415
+ function updateToggleButtonState(autoTheme = '') {
416
+ dom.themeToggle.title = `Theme: ${state.themeMode.charAt(0).toUpperCase() + state.themeMode.slice(1)}${autoTheme}`;
417
+ Object.values(dom.themeIcons).forEach(icon => icon.classList.add('hidden'));
418
+ dom.themeIcons[state.themeMode === 'auto' ? 'auto' : (state.themeMode === 'light' ? 'sun' : 'moon')].classList.remove('hidden');
419
+ }
420
+
421
+ function updateAllChartsTheme(theme) {
422
+ const themeColors = theme === 'dark' ? darkThemeChartColors : lightThemeChartColors;
423
+ for (const chartKey in allChartInstances) {
424
+ const chart = allChartInstances[chartKey];
425
+ if (chart) {
426
+ chart.options.scales.x.ticks.color = themeColors.ticks;
427
+ chart.options.scales.x.grid.color = themeColors.grid;
428
+ chart.options.scales.y.ticks.color = themeColors.ticks;
429
+ chart.options.scales.y.grid.color = themeColors.grid;
430
+ if(chart.options.plugins.legend) chart.options.plugins.legend.labels.color = themeColors.legend;
431
+ if(chart.options.plugins.tooltip) {
432
+ Object.assign(chart.options.plugins.tooltip, {
433
+ backgroundColor: themeColors.tooltipBg,
434
+ titleColor: themeColors.tooltipTitle,
435
+ bodyColor: themeColors.tooltipBody,
436
+ borderColor: themeColors.tooltipBorder,
437
+ });
438
+ }
439
+ chart.update('none');
440
+ }
441
+ }
442
+ }
443
+
444
+ // --- All Functions ---
445
+ const logic = {
446
+ // --- Helper functions for state management ---
447
+ createNewServiceState() {
448
+ return {
449
+ platformHistoryClassCount: 15,
450
+ appHistoryClassCount: 15,
451
+ snapshotHistory: {},
452
+ objectHistoryData: {},
453
+ gcStatsHistory: {},
454
+ gcPauseHistory: {},
455
+ latestLivingObjectsData: null,
456
+ latestGcStatsData: null,
457
+ latestObjectSummaryData: null,
458
+ latestSpansData: null,
459
+ selectedSpanStartDate: null,
460
+ selectedSpanEndDate: null,
461
+ lastGcPauseMs: 0.00,
462
+ nativeSearchTerm: '',
463
+ tailoredSearchTerm: '',
464
+ gcStatsSearchTerm: '',
465
+ objectSummarySearchTerm: '',
466
+ trackedPlatformClasses: null,
467
+ trackedAppClasses: null,
468
+ };
469
+ },
470
+ getActiveServiceState() {
471
+ if (!state.activeService || !state.services[state.activeService]) return null;
472
+ return state.services[state.activeService];
473
+ },
474
+
475
+ // --- Charting and UI Rendering ---
476
+ createChart(id, config) {
477
+ const container = document.getElementById(id).parentElement;
478
+ if(allChartInstances[id]) { allChartInstances[id].destroy(); }
479
+ const chart = new Chart(document.getElementById(id), config);
480
+ allChartInstances[id] = chart;
481
+ return chart;
482
+ },
483
+ getCommonChartOptions() {
484
+ const themeColors = dom.body.getAttribute('data-theme') === 'dark' ? darkThemeChartColors : lightThemeChartColors;
485
+ return {
486
+ responsive: true, maintainAspectRatio: false,
487
+ scales: {
488
+ x: { ticks: { color: themeColors.ticks, maxRotation: 0, autoSkipPadding: 20 }, grid: { color: themeColors.grid, borderDash: [4, 4], drawOnChartArea: false } },
489
+ y: { beginAtZero: true, ticks: { color: themeColors.ticks }, grid: { color: themeColors.grid, borderDash: [4, 4] } }
490
+ },
491
+ plugins: {
492
+ legend: { position: 'top', align: 'end', labels: { color: themeColors.legend, boxWidth: 12, padding: 20, usePointStyle: true, pointStyle: 'rectRounded' } },
493
+ tooltip: { backgroundColor: themeColors.tooltipBg, titleColor: themeColors.tooltipTitle, bodyColor: themeColors.tooltipBody, padding: 10, cornerRadius: 4, borderColor: themeColors.tooltipBorder, borderWidth: 1 }
494
+ },
495
+ animation: { duration: 200 }
496
+ };
497
+ },
498
+ initializePrimaryCharts() {
499
+ this.createChart('heapChart', { type: 'line', data: { labels: [], datasets: [ { label: 'Heap Live Slots', data: [], borderColor: VIBRANT_COLORS.blue, backgroundColor: 'rgba(79, 128, 225, 0.4)', fill: true, tension: 0.4, pointRadius: 0 }, { label: 'Heap Free Slots', data: [], borderColor: VIBRANT_COLORS.orange, backgroundColor: 'rgba(245, 142, 70, 0.4)', fill: true, tension: 0.4, pointRadius: 0 } ] }, options: this.getCommonChartOptions() });
500
+ 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() });
501
+ },
502
+
503
+ getTodayRangeStrings() {
504
+ const now = new Date();
505
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 1, 0);
506
+ const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
507
+
508
+ const toDateTimeLocalString = (date) => {
509
+ const year = date.getFullYear();
510
+ const month = (date.getMonth() + 1).toString().padStart(2, '0');
511
+ const day = date.getDate().toString().padStart(2, '0');
512
+ const hours = date.getHours().toString().padStart(2, '0');
513
+ const minutes = date.getMinutes().toString().padStart(2, '0');
514
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
515
+ };
516
+
517
+ return {
518
+ start: toDateTimeLocalString(startOfDay),
519
+ end: toDateTimeLocalString(endOfDay)
520
+ };
521
+ },
522
+
523
+ // --- Event Listener Setup ---
524
+ setupEventListeners() {
525
+ dom.themeToggle.addEventListener('click', () => {
526
+ if (state.themeMode === 'auto') state.themeMode = 'light';
527
+ else if (state.themeMode === 'light') state.themeMode = 'dark';
528
+ else state.themeMode = 'auto';
529
+ if (state.themeMode === 'auto') localStorage.removeItem('userThemePreference');
530
+ else localStorage.setItem('userThemePreference', state.themeMode);
531
+ applyAndDisplayCurrentTheme();
532
+ });
533
+
534
+ dom.serviceSelector.addEventListener('change', (e) => {
535
+ const newService = e.target.value;
536
+ if (newService && newService !== state.activeService) {
537
+ state.activeService = newService;
538
+ this.renderDashboardForService(newService);
539
+ }
540
+ });
541
+
542
+ dom.downloadPdfBtn.addEventListener('click', this.generatePdf);
543
+ document.querySelectorAll('.accordion-header').forEach(header => { header.addEventListener('click', () => { header.classList.toggle('open'); header.nextElementSibling.classList.toggle('hidden'); }); });
544
+ dom.modalCloseBtn.addEventListener('click', () => dom.modal.classList.add('hidden'));
545
+ dom.modal.addEventListener('click', (e) => { if (e.target === dom.modal) dom.modal.classList.add('hidden'); });
546
+
547
+ dom.applySpanFilterBtn.addEventListener('click', () => {
548
+ const activeServiceState = this.getActiveServiceState();
549
+ if (activeServiceState) {
550
+ activeServiceState.selectedSpanStartDate = dom.spanStartDateFilter.value;
551
+ activeServiceState.selectedSpanEndDate = dom.spanEndDateFilter.value;
552
+ this.renderSpans(activeServiceState);
553
+ }
554
+ });
555
+ dom.modalChartTypeToggle.addEventListener('click', (e) => {
556
+ const button = e.target.closest('button');
557
+ if (!button || button.classList.contains('active')) return;
558
+ state.modalChartType = button.dataset.chartType;
559
+ dom.modalChartTypeToggle.querySelector('.active').classList.remove('active');
560
+ button.classList.add('active');
561
+ this.renderModalChart();
562
+ });
563
+
564
+ dom.spansContainer.addEventListener('click', (e) => {
565
+ const row = e.target.closest('[data-span-name]');
566
+ if (row) {
567
+ logic.showSpanHistory(row.dataset.spanName, row.dataset.spanType);
568
+ }
569
+ });
570
+
571
+ const setupHistoryButtonListener = (container, historySource) => {
572
+ container.addEventListener('click', (e) => {
573
+ const btn = e.target.closest('.history-btn');
574
+ if (!btn) return;
575
+ const activeServiceState = this.getActiveServiceState();
576
+ if (activeServiceState) {
577
+ const key = btn.closest('tr').dataset.metricKey || btn.closest('tr').dataset.className;
578
+ const name = btn.closest('tr').dataset.metricKey ? this.formatKey(key) : key;
579
+ this.showHistory(key, name, activeServiceState[historySource]);
580
+ }
581
+ });
582
+ };
583
+
584
+ setupHistoryButtonListener(dom.objectsTableWidget, 'objectHistoryData');
585
+ setupHistoryButtonListener(dom.gcStatsContent, 'gcStatsHistory');
586
+ setupHistoryButtonListener(dom.objectSummaryContent, 'snapshotHistory');
587
+
588
+ const setupSearchListener = (inputElement, stateKey) => {
589
+ inputElement.addEventListener('input', (e) => {
590
+ const activeServiceState = this.getActiveServiceState();
591
+ if (activeServiceState) {
592
+ activeServiceState[stateKey] = e.target.value;
593
+ if(stateKey.includes('class')) this.updateObjectClassTable();
594
+ else if(stateKey.includes('gc')) this.populateKeyValueTable('gc-stats-table-body', activeServiceState.latestGcStatsData, activeServiceState.gcStatsSearchTerm, this.formatKey);
595
+ else if(stateKey.includes('summary')) this.populateKeyValueTable('object-summary-table-body', activeServiceState.latestObjectSummaryData, activeServiceState.objectSummarySearchTerm);
596
+ }
597
+ });
598
+ };
599
+ setupSearchListener(dom.nativeSearch, 'nativeSearchTerm');
600
+ setupSearchListener(dom.tailoredSearch, 'tailoredSearchTerm');
601
+ setupSearchListener(dom.gcStatsSearch, 'gcStatsSearchTerm');
602
+ setupSearchListener(dom.objectSummarySearch, 'objectSummarySearchTerm');
603
+
604
+ const setupHistoryCountListener = (selectElement, titleElement, stateKey, isPlatform) => {
605
+ selectElement.addEventListener('change', (e) => {
606
+ const activeServiceState = this.getActiveServiceState();
607
+ if (activeServiceState) {
608
+ const newCount = parseInt(e.target.value, 10);
609
+ activeServiceState[stateKey] = newCount;
610
+ titleElement.textContent = `Living ${isPlatform ? 'Platform' : 'Application'} Objects History (Top ${newCount})`;
611
+ if (activeServiceState.latestLivingObjectsData) { this.rebuildObjectHistoryChart(isPlatform); }
612
+ }
613
+ });
614
+ };
615
+ setupHistoryCountListener(dom.platformHistorySelect, dom.platformHistoryWidgetTitle, 'platformHistoryClassCount', true);
616
+ setupHistoryCountListener(dom.appHistorySelect, dom.appHistoryWidgetTitle, 'appHistoryClassCount', false);
617
+ },
618
+
619
+ connectWebSocket() {
620
+ const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/cable`;
621
+ state.socket = new WebSocket(wsUrl);
622
+ state.socket.onopen = () => { this.updateStatus('Connected', 'green'); state.socket.send(JSON.stringify({ command: 'subscribe', identifier: JSON.stringify({ channel: 'HeapPeriscopeUi::RuntimeStatsChannel' }) })); };
623
+ state.socket.onmessage = (event) => {
624
+ try {
625
+ const data = JSON.parse(event.data);
626
+ if (data.type === 'ping' || data.type === 'welcome' || !data.message) return;
627
+
628
+ const message = data.message;
629
+ const serviceName = message.service_name;
630
+ if (!serviceName) { console.warn("Received message without a service_name. Ignoring."); return; }
631
+
632
+ if (!state.services[serviceName]) {
633
+ state.services[serviceName] = this.createNewServiceState();
634
+ const option = new Option(serviceName, serviceName);
635
+ if (dom.serviceSelector.options[0]?.textContent === 'Waiting for services...') { dom.serviceSelector.innerHTML = ''; }
636
+ dom.serviceSelector.add(option);
637
+ }
638
+
639
+ if (!state.activeService) {
640
+ state.activeService = serviceName;
641
+ dom.serviceSelector.value = serviceName;
642
+ this.renderDashboardForService(serviceName);
643
+ }
644
+
645
+ switch (message.type) {
646
+ case 'snapshot':
647
+ this.handleSnapshot(serviceName, message);
648
+ if (serviceName === state.activeService) this.addLogEntry(`Snapshot from ${serviceName} (PID ${message.process_id})`, 'info');
649
+ break;
650
+ case 'gc_profiler_report':
651
+ this.handleGcReport(serviceName, message.payload);
652
+ if (serviceName === state.activeService) this.addLogEntry(`GC from ${serviceName}. Pause: ${message.payload.gc_duration_since_last_check_ms.toFixed(2)}ms`, 'gc');
653
+ break;
654
+ }
655
+ } catch (error) { console.error("Failed to parse websocket message:", error); }
656
+ };
657
+ state.socket.onclose = () => { this.updateStatus('Disconnected', 'red'); setTimeout(() => this.connectWebSocket(), 5000); };
658
+ state.socket.onerror = (error) => { console.error('WebSocket Error:', error); this.updateStatus('Error', 'red'); };
659
+ },
660
+ handleSnapshot(serviceName, message) {
661
+ const serviceState = state.services[serviceName];
662
+ const payload = message.payload;
663
+ const stats = payload.gc_stats;
664
+ if (!stats) return;
665
+
666
+ const timeLabel = new Date().toLocaleTimeString();
667
+
668
+ serviceState.latestGcStatsData = stats;
669
+ serviceState.latestObjectSummaryData = payload.object_space_summary;
670
+ serviceState.latestSpansData = payload.living_objects_by_spans;
671
+ serviceState.latestLivingObjectsData = payload.living_objects_by_class;
672
+
673
+ serviceState.gcStatsHistory[timeLabel] = stats;
674
+ if(payload.living_objects_by_class) serviceState.objectHistoryData[timeLabel] = payload.living_objects_by_class;
675
+
676
+ serviceState.snapshotHistory[timeLabel] = message;
677
+
678
+ const histories = [serviceState.gcStatsHistory, serviceState.objectHistoryData, serviceState.snapshotHistory];
679
+ histories.forEach(h => { if (Object.keys(h).length > MAX_DATA_POINTS) { delete h[Object.keys(h)[0]]; } });
680
+
681
+ if (serviceName !== state.activeService) return;
682
+
683
+ document.getElementById('metric-heap-live').textContent = stats.heap_live_slots.toLocaleString();
684
+ document.getElementById('metric-total-allocated').textContent = stats.total_allocated_objects.toLocaleString();
685
+ document.getElementById('metric-major-gc').textContent = stats.major_gc_count.toLocaleString();
686
+ document.getElementById('metric-minor-gc').textContent = stats.minor_gc_count.toLocaleString();
687
+
688
+ this.updateChart(allChartInstances.heapChart, timeLabel, [stats.heap_live_slots, stats.heap_free_slots]);
689
+ this.populateKeyValueTable('gc-stats-table-body', serviceState.latestGcStatsData, serviceState.gcStatsSearchTerm, this.formatKey);
690
+ this.populateKeyValueTable('object-summary-table-body', serviceState.latestObjectSummaryData, serviceState.objectSummarySearchTerm);
691
+ this.renderSpans(serviceState);
692
+
693
+ if (payload.living_objects_by_class) {
694
+ this.updateObjectClassTable();
695
+ if (Object.keys(serviceState.latestLivingObjectsData).length > 0) {
696
+ if (!allChartInstances.platformObjectHistoryChart) this.rebuildObjectHistoryChart(true); else this.updateObjectHistoryChart(true);
697
+ if (!allChartInstances.appObjectHistoryChart) this.rebuildObjectHistoryChart(false); else this.updateObjectHistoryChart(false);
698
+ }
699
+ }
700
+ },
701
+ handleGcReport(serviceName, payload) {
702
+ const serviceState = state.services[serviceName];
703
+ const duration = payload.gc_duration_since_last_check_ms;
704
+ const timeLabel = new Date().toLocaleTimeString();
705
+
706
+ serviceState.lastGcPauseMs = duration;
707
+ serviceState.gcPauseHistory[timeLabel] = duration;
708
+ if (Object.keys(serviceState.gcPauseHistory).length > MAX_DATA_POINTS) { delete serviceState.gcPauseHistory[Object.keys(serviceState.gcPauseHistory)[0]]; }
709
+
710
+ if (serviceName !== state.activeService) return;
711
+ document.getElementById('metric-last-gc-pause').textContent = duration.toFixed(2);
712
+ this.updateChart(allChartInstances.gcPauseChart, timeLabel, [duration]);
713
+ },
714
+
715
+ renderDashboardForService(serviceName) {
716
+ const serviceState = state.services[serviceName];
717
+ if (!serviceState) return;
718
+
719
+ Object.values(allChartInstances).forEach(chart => chart && chart.destroy());
720
+ Object.keys(allChartInstances).forEach(key => delete allChartInstances[key]);
721
+ this.initializePrimaryCharts();
722
+
723
+ const latestGcStats = serviceState.latestGcStatsData || {};
724
+ document.getElementById('metric-heap-live').textContent = (latestGcStats.heap_live_slots || 0).toLocaleString();
725
+ document.getElementById('metric-total-allocated').textContent = (latestGcStats.total_allocated_objects || 0).toLocaleString();
726
+ document.getElementById('metric-major-gc').textContent = (latestGcStats.major_gc_count || 0).toLocaleString();
727
+ document.getElementById('metric-minor-gc').textContent = (latestGcStats.minor_gc_count || 0).toLocaleString();
728
+ document.getElementById('metric-last-gc-pause').textContent = serviceState.lastGcPauseMs.toFixed(2);
729
+
730
+ allChartInstances.heapChart.data.labels = Object.keys(serviceState.gcStatsHistory);
731
+ allChartInstances.heapChart.data.datasets[0].data = Object.values(serviceState.gcStatsHistory).map(s => s.heap_live_slots || 0);
732
+ allChartInstances.heapChart.data.datasets[1].data = Object.values(serviceState.gcStatsHistory).map(s => s.heap_free_slots || 0);
733
+ allChartInstances.gcPauseChart.data.labels = Object.keys(serviceState.gcPauseHistory);
734
+ allChartInstances.gcPauseChart.data.datasets[0].data = Object.values(serviceState.gcPauseHistory);
735
+ allChartInstances.heapChart.update('none');
736
+ allChartInstances.gcPauseChart.update('none');
737
+
738
+ if (!serviceState.selectedSpanStartDate) {
739
+ const { start, end } = this.getTodayRangeStrings();
740
+ serviceState.selectedSpanStartDate = start;
741
+ serviceState.selectedSpanEndDate = end;
742
+ }
743
+ dom.spanStartDateFilter.value = serviceState.selectedSpanStartDate;
744
+ dom.spanEndDateFilter.value = serviceState.selectedSpanEndDate;
745
+
746
+ if(serviceState.latestLivingObjectsData) {
747
+ this.rebuildObjectHistoryChart(true);
748
+ this.rebuildObjectHistoryChart(false);
749
+ }
750
+
751
+ dom.nativeSearch.value = serviceState.nativeSearchTerm;
752
+ dom.tailoredSearch.value = serviceState.tailoredSearchTerm;
753
+ dom.gcStatsSearch.value = serviceState.gcStatsSearchTerm;
754
+ dom.objectSummarySearch.value = serviceState.objectSummarySearchTerm;
755
+ this.updateObjectClassTable();
756
+ this.populateKeyValueTable('gc-stats-table-body', serviceState.latestGcStatsData, serviceState.gcStatsSearchTerm, this.formatKey);
757
+ this.populateKeyValueTable('object-summary-table-body', serviceState.latestObjectSummaryData, serviceState.objectSummarySearchTerm);
758
+ this.renderSpans(serviceState);
759
+
760
+ dom.eventLog.innerHTML = `<p class="text-center py-8 text-[var(--text-secondary)]">Log for ${serviceName}. Waiting for new events...</p>`;
761
+ },
762
+
763
+ renderSpans(serviceState) {
764
+ if (!dom.spansContainer) return;
765
+
766
+ const startDateString = serviceState.selectedSpanStartDate;
767
+ const endDateString = serviceState.selectedSpanEndDate;
768
+
769
+ if (!startDateString || !endDateString || !serviceState.snapshotHistory) {
770
+ dom.spansContainer.innerHTML = `<p class="text-center py-8 text-[var(--text-secondary)]">No date range selected or no history available.</p>`;
771
+ return;
772
+ }
773
+
774
+ const startDate = new Date(startDateString);
775
+ const endDate = new Date(endDateString);
776
+
777
+ const snapshotsInRange = Object.values(serviceState.snapshotHistory).filter(snapshot => {
778
+ if (!snapshot || !snapshot.reported_at) return false;
779
+ const snapshotTime = new Date(snapshot.reported_at);
780
+ return snapshotTime >= startDate && snapshotTime <= endDate;
781
+ });
782
+
783
+ if (snapshotsInRange.length === 0) {
784
+ dom.spansContainer.innerHTML = `<p class="text-center py-8 text-[var(--text-secondary)]">No span data available for the selected time range.</p>`;
785
+ return;
786
+ }
787
+
788
+ const aggregatedSpansByType = {};
789
+
790
+ for (const snapshot of snapshotsInRange) {
791
+ const spansPayload = snapshot.payload?.living_objects_by_spans;
792
+ if (!spansPayload) continue;
793
+
794
+ for (const [spanType, spansArray] of Object.entries(spansPayload)) {
795
+ if (!aggregatedSpansByType[spanType]) {
796
+ aggregatedSpansByType[spanType] = new Map();
797
+ }
798
+ const spanNameCountMap = aggregatedSpansByType[spanType];
799
+ for (const span of spansArray) {
800
+ const totalObjectsInSpan = span.live_objects.reduce((sum, obj) => sum + obj.count, 0);
801
+ const currentTotal = spanNameCountMap.get(span.name) || 0;
802
+ spanNameCountMap.set(span.name, currentTotal + totalObjectsInSpan);
803
+ }
804
+ }
805
+ }
806
+
807
+ const fragment = document.createDocumentFragment();
808
+ let colorIndex = 0;
809
+
810
+ const infoHeader = document.createElement('p');
811
+ infoHeader.className = 'text-xs text-[var(--text-secondary)] mb-4';
812
+ const snapshotText = snapshotsInRange.length === 1 ? 'snapshot' : 'snapshots';
813
+ infoHeader.textContent = `Showing aggregated data from ${snapshotsInRange.length} ${snapshotText} in the selected range.`;
814
+ fragment.appendChild(infoHeader);
815
+
816
+ for (const [spanType, spanNameCountMap] of Object.entries(aggregatedSpansByType)) {
817
+ const groupTitle = document.createElement('h3');
818
+ groupTitle.className = 'text-sm font-semibold text-purple-400 mb-2 mt-4 capitalize';
819
+ groupTitle.textContent = `${spanType} Spans`;
820
+ fragment.appendChild(groupTitle);
821
+
822
+ let processedSpans = Array.from(spanNameCountMap, ([name, totalObjects]) => ({ name, totalObjects }));
823
+ if (processedSpans.length === 0) continue;
824
+
825
+ processedSpans.sort((a, b) => b.totalObjects - a.totalObjects);
826
+ const maxTotalCount = processedSpans[0].totalObjects > 0 ? processedSpans[0].totalObjects : 1;
827
+
828
+ processedSpans.forEach((span) => {
829
+ const totalObjects = span.totalObjects;
830
+ const widthPercentage = Math.max((totalObjects / maxTotalCount) * 100, 0.5);
831
+ const barWrapper = document.createElement('div');
832
+ barWrapper.className = 'w-full mb-1 cursor-pointer hover:opacity-80 transition-opacity';
833
+ barWrapper.dataset.spanName = span.name;
834
+ barWrapper.dataset.spanType = spanType;
835
+ const bar = document.createElement('div');
836
+ const bgColor = CHART_COLOR_PALETTE[colorIndex % CHART_COLOR_PALETTE.length];
837
+ bar.style.width = `${widthPercentage}%`;
838
+ bar.style.backgroundColor = bgColor;
839
+ bar.style.color = '#ffffff';
840
+ bar.className = 'h-6 flex items-center px-2 text-xs font-mono whitespace-nowrap overflow-hidden rounded-sm transition-all duration-300 ease-out';
841
+ bar.title = `${span.name} - ${totalObjects.toLocaleString()} objects`;
842
+ const barText = document.createElement('span');
843
+ barText.className = 'truncate';
844
+ barText.textContent = span.name;
845
+ bar.appendChild(barText);
846
+ barWrapper.appendChild(bar);
847
+ fragment.appendChild(barWrapper);
848
+ colorIndex++;
849
+ });
850
+ }
851
+
852
+ dom.spansContainer.innerHTML = '';
853
+ dom.spansContainer.appendChild(fragment);
854
+ },
855
+
856
+ updateObjectClassTable() {
857
+ const serviceState = this.getActiveServiceState();
858
+ const nativeBody = document.getElementById('native-object-table-body');
859
+ const tailoredBody = document.getElementById('tailored-object-table-body');
860
+ nativeBody.innerHTML = ''; tailoredBody.innerHTML = '';
861
+
862
+ if (!serviceState || !serviceState.latestLivingObjectsData) {
863
+ nativeBody.innerHTML = `<tr><td colspan="2" class="text-center py-4 text-[var(--text-secondary)]">No data for this service.</td></tr>`;
864
+ tailoredBody.innerHTML = `<tr><td colspan="2" class="text-center py-4 text-[var(--text-secondary)]">No data for this service.</td></tr>`;
865
+ return;
866
+ }
867
+
868
+ let nativeFound = false, tailoredFound = false;
869
+ Object.entries(serviceState.latestLivingObjectsData).sort(([,a], [,b]) => b.count - a.count).forEach(([name, details]) => {
870
+ 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>`;
871
+ const lowerName = name.toLowerCase();
872
+ if (details.is_platform_class) {
873
+ if (lowerName.includes(serviceState.nativeSearchTerm.toLowerCase())) { nativeBody.innerHTML += rowHtml; nativeFound = true; }
874
+ } else {
875
+ if (lowerName.includes(serviceState.tailoredSearchTerm.toLowerCase())) { tailoredBody.innerHTML += rowHtml; tailoredFound = true; }
876
+ }
877
+ });
878
+
879
+ if (!nativeFound) nativeBody.innerHTML = `<tr><td colspan="2" class="text-center py-4 text-[var(--text-secondary)]">${serviceState.nativeSearchTerm ? 'No matching classes.' : 'No platform objects.'}</td></tr>`;
880
+ if (!tailoredFound) tailoredBody.innerHTML = `<tr><td colspan="2" class="text-center py-4 text-[var(--text-secondary)]">${serviceState.tailoredSearchTerm ? 'No matching classes.' : 'No application objects.'}</td></tr>`;
881
+ },
882
+ rebuildObjectHistoryChart(isPlatform) {
883
+ const serviceState = this.getActiveServiceState();
884
+ if (!serviceState) return;
885
+
886
+ const chartId = isPlatform ? 'platformObjectHistoryChart' : 'appObjectHistoryChart';
887
+ const stateKey = isPlatform ? 'trackedPlatformClasses' : 'trackedAppClasses';
888
+ const topN = isPlatform ? serviceState.platformHistoryClassCount : serviceState.appHistoryClassCount;
889
+
890
+ document.getElementById(isPlatform ? 'platform-object-chart-placeholder' : 'app-object-chart-placeholder').style.display = 'none';
891
+
892
+ const sortedData = Object.entries(serviceState.latestLivingObjectsData || {}).filter(([, details]) => details.is_platform_class === isPlatform).sort(([, a], [, b]) => b.count - a.count);
893
+ serviceState[stateKey] = sortedData.slice(0, topN).map(([name]) => name);
894
+
895
+ if (serviceState[stateKey].length > 0) {
896
+ const datasets = serviceState[stateKey].map((className, index) => ({
897
+ label: className,
898
+ data: Object.values(serviceState.objectHistoryData).map(h => h[className]?.count ?? 0),
899
+ backgroundColor: CHART_COLOR_PALETTE[index % CHART_COLOR_PALETTE.length],
900
+ barPercentage: 0.9, categoryPercentage: 0.8
901
+ }));
902
+ const chartOptions = this.getCommonChartOptions();
903
+ chartOptions.scales.x.stacked = true;
904
+ chartOptions.scales.y.stacked = true;
905
+ this.createChart(chartId, { type: 'bar', data: { labels: Object.keys(serviceState.objectHistoryData), datasets }, options: chartOptions });
906
+ }
907
+ },
908
+ updateObjectHistoryChart(isPlatform) {
909
+ const serviceState = this.getActiveServiceState();
910
+ if (!serviceState) return;
911
+ const chartId = isPlatform ? 'platformObjectHistoryChart' : 'appObjectHistoryChart';
912
+ const stateKey = isPlatform ? 'trackedPlatformClasses' : 'trackedAppClasses';
913
+ const chart = allChartInstances[chartId];
914
+ if (!chart || !serviceState[stateKey]?.length) return;
915
+ chart.data.labels.push(new Date().toLocaleTimeString());
916
+ chart.data.datasets.forEach(dataset => {
917
+ const count = serviceState.latestLivingObjectsData[dataset.label]?.count ?? 0;
918
+ dataset.data.push(count);
919
+ if (dataset.data.length > MAX_DATA_POINTS) dataset.data.shift();
920
+ });
921
+ if (chart.data.labels.length > MAX_DATA_POINTS) chart.data.labels.shift();
922
+ chart.update('none');
923
+ },
924
+ showHistory(dataKey, formattedName, historyObject) {
925
+ dom.modalChartTypeToggle.classList.remove('hidden');
926
+ dom.modalTitle.textContent = `History for: ${formattedName}`;
927
+
928
+ let dataPoints;
929
+ if (historyObject === this.getActiveServiceState().snapshotHistory) {
930
+ dataPoints = Object.values(historyObject).map(snapshot => snapshot.payload.object_space_summary[dataKey] ?? 0);
931
+ } else {
932
+ dataPoints = Object.values(historyObject).map(snapshot => snapshot ? (snapshot[dataKey]?.count ?? snapshot[dataKey] ?? 0) : 0);
933
+ }
934
+
935
+ state.currentModalData = {
936
+ dataKey: dataKey, formattedName: formattedName, labels: Object.keys(historyObject), dataPoints: dataPoints
937
+ };
938
+ this.renderModalChart();
939
+ dom.modal.classList.remove('hidden');
940
+ },
941
+ showSpanHistory(spanName, spanType) {
942
+ const serviceState = this.getActiveServiceState();
943
+ if (!serviceState || !serviceState.snapshotHistory) return;
944
+
945
+ dom.modalTitle.textContent = `History for span: ${spanName}`;
946
+ dom.modalChartTypeToggle.classList.add('hidden');
947
+ dom.modalContentArea.innerHTML = '';
948
+
949
+ const tableContainer = document.createElement('div');
950
+ tableContainer.className = "overflow-y-auto h-full";
951
+
952
+ const table = document.createElement('table');
953
+ table.className = "w-full text-left text-xs";
954
+ table.innerHTML = `
955
+ <thead class="sticky top-0 bg-[var(--widget-bg)]">
956
+ <tr class="border-b border-[var(--border)]">
957
+ <th class="py-2 px-3 text-[var(--text-secondary)] font-medium">DATE</th>
958
+ <th class="py-2 px-3 text-[var(--text-secondary)] font-medium">RESOURCE</th>
959
+ <th class="py-2 px-3 text-[var(--text-secondary)] font-medium">SERVICE</th>
960
+ <th class="py-2 px-3 text-[var(--text-secondary)] font-medium text-right">TOTAL OBJECTS</th>
961
+ </tr>
962
+ </thead>
963
+ <tbody class="divide-y divide-[var(--border)]"></tbody>
964
+ `;
965
+ const tbody = table.querySelector('tbody');
966
+
967
+ const snapshots = Object.values(serviceState.snapshotHistory).reverse();
968
+ let foundEntries = false;
969
+ for (const snapshot of snapshots) {
970
+ const spansPayload = snapshot.payload?.living_objects_by_spans;
971
+ if (!spansPayload || !spansPayload[spanType]) continue;
972
+
973
+ for (const span of spansPayload[spanType]) {
974
+ if (span.name === spanName) {
975
+ foundEntries = true;
976
+ const totalObjects = span.live_objects.reduce((sum, obj) => sum + obj.count, 0);
977
+ const tr = tbody.insertRow();
978
+ tr.className = 'cursor-pointer hover:bg-[var(--toggle-active-bg)] transition-colors';
979
+ tr.dataset.snapshotReportedAt = snapshot.reported_at;
980
+ tr.dataset.spanName = spanName;
981
+ tr.dataset.spanType = spanType;
982
+ tr.innerHTML = `
983
+ <td class="py-2 px-3 font-mono whitespace-nowrap">${new Date(snapshot.reported_at).toLocaleString()}</td>
984
+ <td class="py-2 px-3 font-mono text-sky-400">${span.name}</td>
985
+ <td class="py-2 px-3 font-mono">${snapshot.service_name}</td>
986
+ <td class="py-2 px-3 font-mono text-right text-emerald-400">${totalObjects.toLocaleString()}</td>
987
+ `;
988
+ }
989
+ }
990
+ }
991
+
992
+ if (!foundEntries) {
993
+ tbody.innerHTML = `<tr><td colspan="4" class="text-center py-8 text-[var(--text-secondary)]">No history found for this span.</td></tr>`;
994
+ }
995
+
996
+ tbody.addEventListener('click', e => {
997
+ const row = e.target.closest('tr[data-snapshot-reported-at]');
998
+ if (!row) return;
999
+ const { snapshotReportedAt, spanName, spanType } = row.dataset;
1000
+ logic.showSpanObjectDetails(snapshotReportedAt, spanName, spanType);
1001
+ });
1002
+
1003
+ tableContainer.appendChild(table);
1004
+ dom.modalContentArea.appendChild(tableContainer);
1005
+ dom.modal.classList.remove('hidden');
1006
+ },
1007
+ showSpanObjectDetails(snapshotReportedAt, spanName, spanType) {
1008
+ const serviceState = this.getActiveServiceState();
1009
+ if (!serviceState) return;
1010
+
1011
+ const snapshot = Object.values(serviceState.snapshotHistory).find(s => s.reported_at === snapshotReportedAt);
1012
+ if (!snapshot) return;
1013
+
1014
+ const spansArray = snapshot.payload?.living_objects_by_spans?.[spanType] || [];
1015
+ const spanData = spansArray.find(s => s.name === spanName);
1016
+ if (!spanData || !spanData.live_objects) return;
1017
+
1018
+ dom.modalTitle.textContent = `Objects in ${spanName} at ${new Date(snapshotReportedAt).toLocaleTimeString()}`;
1019
+ dom.modalContentArea.innerHTML = '';
1020
+
1021
+ const backButton = document.createElement('button');
1022
+ backButton.textContent = '← Back to History';
1023
+ backButton.className = 'mb-4 text-sm text-[var(--text-link)] hover:underline';
1024
+ backButton.onclick = () => logic.showSpanHistory(spanName, spanType);
1025
+ dom.modalContentArea.appendChild(backButton);
1026
+
1027
+ const liveObjects = spanData.live_objects;
1028
+ liveObjects.sort((a, b) => b.count - a.count);
1029
+
1030
+ if (liveObjects.length === 0) {
1031
+ dom.modalContentArea.innerHTML += `<p class="text-center py-8 text-[var(--text-secondary)]">No live objects found for this span in this snapshot.</p>`;
1032
+ return;
1033
+ }
1034
+
1035
+ const container = document.createElement('div');
1036
+ container.className = 'space-y-1';
1037
+ const maxCount = liveObjects[0].count > 0 ? liveObjects[0].count : 1;
1038
+ let colorIndex = 0;
1039
+
1040
+ liveObjects.forEach(obj => {
1041
+ const widthPercentage = Math.max((obj.count / maxCount) * 100, 0.5);
1042
+ const bar = document.createElement('div');
1043
+ const bgColor = CHART_COLOR_PALETTE[colorIndex % CHART_COLOR_PALETTE.length];
1044
+ //bar.style.width = `${widthPercentage}%`;
1045
+ bar.style.width = 'fit-content'
1046
+ bar.style.backgroundColor = bgColor;
1047
+ bar.style.color = '#ffffff';
1048
+ bar.className = 'h-6 flex items-center justify-between px-2 text-xs font-mono whitespace-nowrap overflow-hidden rounded-sm';
1049
+ bar.title = `${obj.name} - ${obj.count.toLocaleString()} objects`;
1050
+
1051
+ const barText = document.createElement('span');
1052
+ barText.className = 'truncate';
1053
+ barText.textContent = obj.name;
1054
+
1055
+ const barCount = document.createElement('span');
1056
+ barCount.className = 'font-semibold pl-2';
1057
+ barCount.textContent = obj.count.toLocaleString();
1058
+
1059
+ bar.appendChild(barText);
1060
+ bar.appendChild(barCount);
1061
+ container.appendChild(bar);
1062
+ colorIndex++;
1063
+ });
1064
+ dom.modalContentArea.appendChild(container);
1065
+ },
1066
+ renderModalChart() {
1067
+ dom.modalContentArea.innerHTML = `<canvas id="modalChart"></canvas>`;
1068
+ const { formattedName, labels, dataPoints } = state.currentModalData;
1069
+ let datasetOptions;
1070
+ if (state.modalChartType === 'line') {
1071
+ datasetOptions = { borderColor: VIBRANT_COLORS.cyan, backgroundColor: 'rgba(56, 189, 248, 0.2)', fill: true, tension: 0.3, pointRadius: 1 };
1072
+ } else {
1073
+ datasetOptions = { backgroundColor: 'rgba(56, 189, 248, 0.6)', borderColor: 'rgba(56, 189, 248, 1)', borderWidth: 1 };
1074
+ }
1075
+ const chartOptions = this.getCommonChartOptions();
1076
+ chartOptions.scales.x.ticks.autoSkipPadding = 40;
1077
+ this.createChart('modalChart', { type: state.modalChartType, data: { labels, datasets: [{ label: `Value for ${formattedName}`, data: dataPoints, ...datasetOptions }] }, options: chartOptions });
1078
+ },
1079
+ populateKeyValueTable(bodyId, dataObject, searchTerm = '', keyFormatter = key => key) {
1080
+ const tableBody = document.getElementById(bodyId);
1081
+ if (!dataObject) { tableBody.innerHTML = `<tr><td colspan="2" class="text-center py-4 text-[var(--text-secondary)]">Waiting for data...</td></tr>`; return; }
1082
+ const lowerSearchTerm = searchTerm.toLowerCase();
1083
+ const filteredData = Object.entries(dataObject).filter(([key]) => keyFormatter(key).toLowerCase().includes(lowerSearchTerm));
1084
+ 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; }
1085
+ 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('');
1086
+ },
1087
+ formatKey: key => key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
1088
+ updateChart(chart, label, dataArray) {
1089
+ if (!chart) return;
1090
+ chart.data.labels.push(label);
1091
+ dataArray.forEach((value, index) => { chart.data.datasets[index].data.push(value); });
1092
+ if (chart.data.labels.length > MAX_DATA_POINTS) {
1093
+ chart.data.labels.shift();
1094
+ chart.data.datasets.forEach(dataset => dataset.data.shift());
1095
+ }
1096
+ chart.update('none');
1097
+ },
1098
+ updateStatus(text, color) {
1099
+ dom.statusText.textContent = text;
1100
+ dom.statusDot.className = 'w-2.5 h-2.5 rounded-full';
1101
+ if (color === 'green') dom.statusDot.classList.add('bg-green-500');
1102
+ else if (color === 'red') dom.statusDot.classList.add('bg-red-500');
1103
+ else dom.statusDot.classList.add('bg-yellow-500', 'animate-pulse');
1104
+ },
1105
+ addLogEntry(message, type) {
1106
+ if (dom.eventLog.querySelector('p.text-center')) dom.eventLog.innerHTML = '';
1107
+ const entry = document.createElement('p');
1108
+ const color = type === 'info' ? 'text-cyan-400' : 'text-fuchsia-400';
1109
+ entry.className = `log-entry font-mono text-xs ${color}`;
1110
+ entry.innerHTML = `<span class="text-[var(--text-secondary)] mr-2">${new Date().toLocaleTimeString('en-GB')}</span> ${message}`;
1111
+ dom.eventLog.prepend(entry);
1112
+ while (dom.eventLog.childNodes.length > MAX_LOG_ENTRIES) dom.eventLog.lastChild.remove();
1113
+ },
1114
+ async generatePdf() {
1115
+ alert("PDF generation logic needs to be adapted for multi-service state. This feature is currently disabled in this refactored version.");
1116
+ },
1117
+ };
1118
+
1119
+ function init() {
1120
+ logic.initializePrimaryCharts();
1121
+ logic.setupEventListeners();
1122
+ const savedPreference = localStorage.getItem('userThemePreference');
1123
+ state.themeMode = savedPreference || 'auto';
1124
+ applyAndDisplayCurrentTheme();
1125
+ logic.connectWebSocket();
1126
+ }
1127
+
1128
+ init();
1129
+ });
1130
+ </script>
1131
+ </body>
1132
+ </html>