orfeas_lyra 0.6.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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +222 -0
  3. data/LICENSE +21 -0
  4. data/README.md +1165 -0
  5. data/Rakefile +728 -0
  6. data/app/controllers/lyra/application_controller.rb +23 -0
  7. data/app/controllers/lyra/dashboard_controller.rb +624 -0
  8. data/app/controllers/lyra/flow_controller.rb +224 -0
  9. data/app/controllers/lyra/privacy_controller.rb +182 -0
  10. data/app/views/lyra/dashboard/audit_trail.html.erb +324 -0
  11. data/app/views/lyra/dashboard/discrepancies.html.erb +125 -0
  12. data/app/views/lyra/dashboard/event_graph_view.html.erb +525 -0
  13. data/app/views/lyra/dashboard/heatmap_view.html.erb +155 -0
  14. data/app/views/lyra/dashboard/index.html.erb +119 -0
  15. data/app/views/lyra/dashboard/model_overview.html.erb +115 -0
  16. data/app/views/lyra/dashboard/projections.html.erb +302 -0
  17. data/app/views/lyra/dashboard/schema.html.erb +283 -0
  18. data/app/views/lyra/dashboard/schema_history.html.erb +78 -0
  19. data/app/views/lyra/dashboard/schema_version.html.erb +340 -0
  20. data/app/views/lyra/dashboard/verification.html.erb +370 -0
  21. data/app/views/lyra/flow/crud_mapping.html.erb +125 -0
  22. data/app/views/lyra/flow/timeline.html.erb +260 -0
  23. data/app/views/lyra/privacy/pii_detection.html.erb +148 -0
  24. data/app/views/lyra/privacy/policy.html.erb +188 -0
  25. data/app/workflows/es_async_mode_workflow.rb +80 -0
  26. data/app/workflows/es_sync_mode_workflow.rb +64 -0
  27. data/app/workflows/hijack_mode_workflow.rb +54 -0
  28. data/app/workflows/lifecycle_workflow.rb +43 -0
  29. data/app/workflows/monitor_mode_workflow.rb +39 -0
  30. data/config/privacy_policies.rb +273 -0
  31. data/config/routes.rb +48 -0
  32. data/lib/lyra/aggregate.rb +131 -0
  33. data/lib/lyra/associations/event_aware.rb +225 -0
  34. data/lib/lyra/command.rb +81 -0
  35. data/lib/lyra/command_handler.rb +155 -0
  36. data/lib/lyra/configuration.rb +124 -0
  37. data/lib/lyra/consistency/read_your_writes.rb +91 -0
  38. data/lib/lyra/correlation.rb +144 -0
  39. data/lib/lyra/dual_view.rb +231 -0
  40. data/lib/lyra/engine.rb +67 -0
  41. data/lib/lyra/event.rb +71 -0
  42. data/lib/lyra/event_analyzer.rb +135 -0
  43. data/lib/lyra/event_flow.rb +449 -0
  44. data/lib/lyra/event_mapper.rb +106 -0
  45. data/lib/lyra/event_store_adapter.rb +72 -0
  46. data/lib/lyra/id_generator.rb +137 -0
  47. data/lib/lyra/interceptors/association_interceptor.rb +169 -0
  48. data/lib/lyra/interceptors/crud_interceptor.rb +543 -0
  49. data/lib/lyra/privacy/gdpr_compliance.rb +161 -0
  50. data/lib/lyra/privacy/pii_detector.rb +85 -0
  51. data/lib/lyra/privacy/pii_masker.rb +66 -0
  52. data/lib/lyra/privacy/policy_integration.rb +253 -0
  53. data/lib/lyra/projection.rb +94 -0
  54. data/lib/lyra/projections/async_projection_job.rb +63 -0
  55. data/lib/lyra/projections/cached_projection.rb +322 -0
  56. data/lib/lyra/projections/cached_relation.rb +757 -0
  57. data/lib/lyra/projections/event_store_reader.rb +127 -0
  58. data/lib/lyra/projections/model_projection.rb +143 -0
  59. data/lib/lyra/schema/diff.rb +331 -0
  60. data/lib/lyra/schema/event_class_registrar.rb +63 -0
  61. data/lib/lyra/schema/generator.rb +190 -0
  62. data/lib/lyra/schema/reporter.rb +188 -0
  63. data/lib/lyra/schema/store.rb +156 -0
  64. data/lib/lyra/schema/validator.rb +100 -0
  65. data/lib/lyra/strict_data_access.rb +363 -0
  66. data/lib/lyra/verification/crud_lifecycle_workflow.rb +456 -0
  67. data/lib/lyra/verification/workflow_generator.rb +540 -0
  68. data/lib/lyra/version.rb +3 -0
  69. data/lib/lyra/visualization/activity_heatmap.rb +215 -0
  70. data/lib/lyra/visualization/event_graph.rb +310 -0
  71. data/lib/lyra/visualization/timeline.rb +398 -0
  72. data/lib/lyra.rb +150 -0
  73. data/lib/tasks/dist.rake +391 -0
  74. data/lib/tasks/gems.rake +185 -0
  75. data/lib/tasks/lyra_schema.rake +231 -0
  76. data/lib/tasks/lyra_workflows.rake +452 -0
  77. data/lib/tasks/public_release.rake +351 -0
  78. data/lib/tasks/stats.rake +175 -0
  79. data/lib/tasks/testbed.rake +479 -0
  80. data/lib/tasks/version.rake +159 -0
  81. metadata +221 -0
@@ -0,0 +1,525 @@
1
+ <style>
2
+ .lyra-visualization { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1400px; margin: 0 auto; padding: 20px; }
3
+ .lyra-visualization h1 { color: #1a1a2e; border-bottom: 2px solid #4a4e69; padding-bottom: 10px; }
4
+ .lyra-visualization h2 { color: #4a4e69; margin-top: 30px; }
5
+ .lyra-visualization .breadcrumb { margin-bottom: 20px; font-size: 14px; }
6
+ .lyra-visualization .breadcrumb a { color: #4a4e69; text-decoration: none; }
7
+ .lyra-visualization .breadcrumb a:hover { text-decoration: underline; }
8
+ .lyra-visualization .controls { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap; }
9
+ .lyra-visualization .controls label { font-weight: 500; color: #495057; }
10
+ .lyra-visualization .controls select { padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; min-width: 150px; }
11
+ .lyra-visualization .controls button { padding: 8px 16px; background: #4a4e69; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
12
+ .lyra-visualization .controls button:hover { background: #22223b; }
13
+
14
+ .graph-layout { display: grid; grid-template-columns: 300px 1fr; gap: 20px; min-height: 500px; }
15
+ .entity-picker { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; display: flex; flex-direction: column; }
16
+ .entity-picker-header { padding: 15px; font-weight: 600; color: #1a1a2e; border-bottom: 1px solid #dee2e6; background: #f8f9fa; border-radius: 8px 8px 0 0; }
17
+ .entity-list { flex: 1; overflow-y: auto; max-height: 450px; }
18
+ .entity-item { padding: 12px 15px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background 0.15s; }
19
+ .entity-item:hover { background: #f8f9fa; }
20
+ .entity-item.selected { background: #e8f0fe; border-left: 3px solid #4a4e69; }
21
+ .entity-name { font-weight: 500; color: #1a1a2e; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
22
+ .entity-meta { display: flex; gap: 10px; font-size: 12px; }
23
+ .entity-type { color: #4a4e69; font-weight: 500; }
24
+ .entity-events { color: #6c757d; }
25
+ .empty-message { padding: 30px; text-align: center; color: #6c757d; }
26
+ .loading-message { padding: 30px; text-align: center; color: #6c757d; }
27
+
28
+ .graph-panel { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; }
29
+ .graph-panel-header { font-weight: 600; color: #1a1a2e; margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center; }
30
+ .graph-content { flex: 1; min-height: 400px; }
31
+ .mermaid-container { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; overflow-x: auto; min-height: 300px; }
32
+ .placeholder-message { display: flex; align-items: center; justify-content: center; height: 300px; color: #6c757d; font-size: 14px; }
33
+
34
+ .legend { display: flex; gap: 20px; margin-top: 15px; padding: 10px; background: #f8f9fa; border-radius: 6px; font-size: 13px; }
35
+ .legend-item { display: flex; align-items: center; gap: 6px; }
36
+ .legend-line { width: 24px; height: 2px; }
37
+ .legend-line.same-record { background: #10b981; }
38
+ .legend-line.correlation { background: #667eea; }
39
+ .legend-line.same-user { background: #9ca3af; border-style: dashed; }
40
+
41
+ .stats-row { display: flex; gap: 20px; margin-bottom: 15px; }
42
+ .stat-item { font-size: 13px; color: #6c757d; }
43
+ .stat-item strong { color: #1a1a2e; }
44
+
45
+ /* Event detail popup */
46
+ .event-popup-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
47
+ .event-popup { background: #fff; border-radius: 12px; padding: 0; max-width: 500px; width: 90%; max-height: 80vh; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
48
+ .event-popup-header { padding: 16px 20px; background: #4a4e69; color: #fff; display: flex; justify-content: space-between; align-items: center; }
49
+ .event-popup-title { font-weight: 600; font-size: 16px; }
50
+ .event-popup-close { background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; line-height: 1; padding: 0 4px; }
51
+ .event-popup-close:hover { opacity: 0.8; }
52
+ .event-popup-body { padding: 20px; overflow-y: auto; max-height: calc(80vh - 60px); }
53
+ .event-popup-meta { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
54
+ .event-popup-badge { padding: 4px 10px; border-radius: 4px; font-size: 12px; font-weight: 500; }
55
+ .event-popup-badge.created { background: #d1fae5; color: #065f46; }
56
+ .event-popup-badge.updated { background: #dbeafe; color: #1e40af; }
57
+ .event-popup-badge.destroyed { background: #fee2e2; color: #991b1b; }
58
+ .event-popup-time { color: #6c757d; font-size: 13px; }
59
+ .event-popup-section { margin-bottom: 16px; }
60
+ .event-popup-section-title { font-weight: 600; color: #1a1a2e; margin-bottom: 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; }
61
+ .changes-table { width: 100%; border-collapse: collapse; font-size: 13px; }
62
+ .changes-table th { text-align: left; padding: 8px 12px; background: #f8f9fa; border-bottom: 1px solid #dee2e6; color: #495057; font-weight: 500; }
63
+ .changes-table td { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: top; word-break: break-word; max-width: 200px; }
64
+ .changes-table .field-name { font-weight: 500; color: #1a1a2e; }
65
+ .changes-table .value-from { color: #dc2626; text-decoration: line-through; opacity: 0.7; }
66
+ .changes-table .value-to { color: #059669; }
67
+ .changes-table .value-null { color: #9ca3af; font-style: italic; }
68
+ .no-changes { color: #6c757d; font-style: italic; }
69
+ .click-hint { text-align: center; padding: 8px; background: #fef3c7; color: #92400e; font-size: 12px; border-radius: 4px; margin-top: 10px; }
70
+
71
+ /* PII field tags */
72
+ .pii-tag { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 500; margin-left: 6px; background: #fef3c7; color: #92400e; text-transform: uppercase; }
73
+ .pii-tag.email { background: #dbeafe; color: #1e40af; }
74
+ .pii-tag.name { background: #dcfce7; color: #166534; }
75
+ .pii-tag.phone { background: #f3e8ff; color: #7c3aed; }
76
+ .pii-tag.address { background: #ffedd5; color: #c2410c; }
77
+ .pii-tag.ssn, .pii-tag.financial { background: #fee2e2; color: #991b1b; }
78
+
79
+ /* Correlation ID tooltip */
80
+ .correlation-info { position: relative; display: inline-flex; align-items: center; gap: 6px; }
81
+ .correlation-help { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #e5e7eb; color: #6b7280; font-size: 11px; cursor: help; font-weight: 600; }
82
+ .correlation-help:hover { background: #d1d5db; }
83
+ .correlation-tooltip { display: none; position: absolute; left: 0; top: 100%; margin-top: 8px; background: #1f2937; color: #fff; padding: 10px 14px; border-radius: 6px; font-size: 12px; width: 280px; z-index: 1001; line-height: 1.5; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
84
+ .correlation-tooltip::before { content: ''; position: absolute; top: -6px; left: 20px; border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid #1f2937; }
85
+ .correlation-help:hover + .correlation-tooltip, .correlation-tooltip:hover { display: block; }
86
+
87
+ @media (max-width: 900px) {
88
+ .graph-layout { grid-template-columns: 1fr; }
89
+ .entity-list { max-height: 200px; }
90
+ }
91
+ </style>
92
+
93
+ <div class="lyra-visualization">
94
+ <div class="breadcrumb">
95
+ <%= link_to "Dashboard", lyra.dashboard_path %> &rsaquo; Event Graph
96
+ </div>
97
+
98
+ <h1>Event Graph Visualization</h1>
99
+ <p style="color: #6c757d; margin-bottom: 20px;">
100
+ Select an entity to view its event lifecycle. The graph shows how events are connected through entity lifecycle, correlations, and user activity.
101
+ </p>
102
+
103
+ <div class="controls">
104
+ <label for="model-filter">Filter by model:</label>
105
+ <select id="model-filter">
106
+ <option value="">All Models</option>
107
+ <% Lyra.config.monitored_models.each do |model| %>
108
+ <option value="<%= model.name %>"><%= model.name %></option>
109
+ <% end %>
110
+ </select>
111
+ <button type="button" id="refresh-btn">↻ Refresh</button>
112
+ </div>
113
+
114
+ <div class="graph-layout">
115
+ <div class="entity-picker">
116
+ <div class="entity-picker-header">Entities with Events</div>
117
+ <div class="entity-list" id="entity-list">
118
+ <div class="loading-message">Loading entities...</div>
119
+ </div>
120
+ </div>
121
+
122
+ <div class="graph-panel">
123
+ <div class="graph-panel-header">
124
+ <span id="graph-title">Select an entity to view its event graph</span>
125
+ <span id="graph-stats" class="stat-item"></span>
126
+ </div>
127
+ <div class="graph-content">
128
+ <div class="mermaid-container" id="mermaid-container">
129
+ <div class="placeholder-message">← Click an entity from the list to view its lifecycle graph</div>
130
+ </div>
131
+ </div>
132
+ <div class="legend">
133
+ <div class="legend-item">
134
+ <div class="legend-line same-record"></div>
135
+ <span>Same Entity (lifecycle)</span>
136
+ </div>
137
+ <div class="legend-item">
138
+ <div class="legend-line correlation"></div>
139
+ <span>Correlated Events</span>
140
+ </div>
141
+ <div class="legend-item">
142
+ <div class="legend-line same-user" style="border-top: 2px dashed #9ca3af;"></div>
143
+ <span>Same User</span>
144
+ </div>
145
+ </div>
146
+ <div class="click-hint">💡 Click on any event node to see full change details</div>
147
+ </div>
148
+ </div>
149
+ </div>
150
+
151
+ <!-- Event Detail Popup -->
152
+ <div id="event-popup-overlay" class="event-popup-overlay" style="display: none;">
153
+ <div class="event-popup">
154
+ <div class="event-popup-header">
155
+ <span class="event-popup-title" id="popup-title">Event Details</span>
156
+ <button class="event-popup-close" id="popup-close">&times;</button>
157
+ </div>
158
+ <div class="event-popup-body" id="popup-body">
159
+ </div>
160
+ </div>
161
+ </div>
162
+
163
+ <script type="module">
164
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
165
+ mermaid.initialize({
166
+ startOnLoad: false,
167
+ theme: 'neutral',
168
+ flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis' }
169
+ });
170
+
171
+ let selectedEntity = null;
172
+ let currentNodes = {}; // Store node data by sanitized ID
173
+
174
+ // Format changed fields for Mermaid label
175
+ function formatChangedFields(fields) {
176
+ if (!fields || fields.length === 0) return null;
177
+ // Limit to 3 fields to keep labels readable
178
+ if (fields.length > 3) {
179
+ return '📝 ' + fields.slice(0, 3).join(', ') + '...';
180
+ }
181
+ return '📝 ' + fields.join(', ');
182
+ }
183
+
184
+ // Show popup with event details (using safe DOM methods)
185
+ function showEventPopup(nodeId) {
186
+ const node = currentNodes[nodeId];
187
+ if (!node) return;
188
+
189
+ const overlay = document.getElementById('event-popup-overlay');
190
+ const title = document.getElementById('popup-title');
191
+ const body = document.getElementById('popup-body');
192
+
193
+ title.textContent = node.operation.toUpperCase() + ' - ' + node.model_class + '#' + node.model_id;
194
+
195
+ // Clear previous content
196
+ body.replaceChildren();
197
+
198
+ // Meta section
199
+ const metaDiv = document.createElement('div');
200
+ metaDiv.className = 'event-popup-meta';
201
+
202
+ const badge = document.createElement('span');
203
+ badge.className = 'event-popup-badge ' + node.operation;
204
+ badge.textContent = node.operation.toUpperCase();
205
+ metaDiv.appendChild(badge);
206
+
207
+ // Show PII badge only if there are changes in PII fields
208
+ const hasPiiChanges = node.changes && node.changes.some(function(c) { return c.pii_type; });
209
+ if (hasPiiChanges) {
210
+ const piiBadge = document.createElement('span');
211
+ piiBadge.className = 'event-popup-badge';
212
+ piiBadge.style.cssText = 'background: #fef3c7; color: #92400e;';
213
+ piiBadge.textContent = '⚠️ PII';
214
+ metaDiv.appendChild(piiBadge);
215
+ }
216
+
217
+ const timeSpan = document.createElement('span');
218
+ timeSpan.className = 'event-popup-time';
219
+ timeSpan.textContent = '🕐 ' + node.timestamp_formatted;
220
+ metaDiv.appendChild(timeSpan);
221
+
222
+ if (node.correlation_id) {
223
+ const corrContainer = document.createElement('span');
224
+ corrContainer.className = 'correlation-info';
225
+
226
+ const corrSpan = document.createElement('span');
227
+ corrSpan.className = 'event-popup-time';
228
+ corrSpan.textContent = '🔗 ' + node.correlation_id;
229
+ corrSpan.style.wordBreak = 'break-all';
230
+ corrContainer.appendChild(corrSpan);
231
+
232
+ const helpIcon = document.createElement('span');
233
+ helpIcon.className = 'correlation-help';
234
+ helpIcon.textContent = '?';
235
+ corrContainer.appendChild(helpIcon);
236
+
237
+ const tooltip = document.createElement('div');
238
+ tooltip.className = 'correlation-tooltip';
239
+ tooltip.textContent = 'Correlation ID groups related events that occur in the same transaction or workflow. Events with the same correlation ID were triggered together (e.g., updating a parent and child record in one request).';
240
+ corrContainer.appendChild(tooltip);
241
+
242
+ metaDiv.appendChild(corrContainer);
243
+ }
244
+ body.appendChild(metaDiv);
245
+
246
+ // Event ID section
247
+ const eventIdDiv = document.createElement('div');
248
+ eventIdDiv.className = 'event-popup-section';
249
+ const eventIdLabel = document.createElement('div');
250
+ eventIdLabel.className = 'event-popup-section-title';
251
+ eventIdLabel.textContent = 'Event ID';
252
+ eventIdDiv.appendChild(eventIdLabel);
253
+ const eventIdValue = document.createElement('code');
254
+ eventIdValue.style.cssText = 'font-size: 12px; background: #f1f5f9; padding: 4px 8px; border-radius: 4px; word-break: break-all; display: block;';
255
+ eventIdValue.textContent = node.id;
256
+ eventIdDiv.appendChild(eventIdValue);
257
+ body.appendChild(eventIdDiv);
258
+
259
+ // Changes section
260
+ const sectionDiv = document.createElement('div');
261
+ sectionDiv.className = 'event-popup-section';
262
+
263
+ const sectionTitle = document.createElement('div');
264
+ sectionTitle.className = 'event-popup-section-title';
265
+ sectionTitle.textContent = 'Changed Fields';
266
+ sectionDiv.appendChild(sectionTitle);
267
+
268
+ if (node.changes && node.changes.length > 0) {
269
+ const table = document.createElement('table');
270
+ table.className = 'changes-table';
271
+
272
+ const thead = document.createElement('thead');
273
+ const headerRow = document.createElement('tr');
274
+ ['Field', 'From', 'To'].forEach(function(text) {
275
+ const th = document.createElement('th');
276
+ th.textContent = text;
277
+ headerRow.appendChild(th);
278
+ });
279
+ thead.appendChild(headerRow);
280
+ table.appendChild(thead);
281
+
282
+ const tbody = document.createElement('tbody');
283
+ node.changes.forEach(function(change) {
284
+ const tr = document.createElement('tr');
285
+
286
+ const fieldTd = document.createElement('td');
287
+ fieldTd.className = 'field-name';
288
+
289
+ const fieldText = document.createTextNode(change.field);
290
+ fieldTd.appendChild(fieldText);
291
+
292
+ // Add PII tag if this field contains PII
293
+ if (change.pii_type) {
294
+ const piiTag = document.createElement('span');
295
+ piiTag.className = 'pii-tag ' + change.pii_type;
296
+ piiTag.textContent = change.pii_type;
297
+ fieldTd.appendChild(piiTag);
298
+ }
299
+
300
+ tr.appendChild(fieldTd);
301
+
302
+ if (change.from !== undefined) {
303
+ const fromTd = document.createElement('td');
304
+ fromTd.className = change.from === null ? 'value-null' : 'value-from';
305
+ fromTd.textContent = formatValue(change.from);
306
+ tr.appendChild(fromTd);
307
+
308
+ const toTd = document.createElement('td');
309
+ toTd.className = change.to === null ? 'value-null' : 'value-to';
310
+ toTd.textContent = formatValue(change.to);
311
+ tr.appendChild(toTd);
312
+ } else {
313
+ const valueTd = document.createElement('td');
314
+ valueTd.colSpan = 2;
315
+ valueTd.className = 'value-to';
316
+ valueTd.textContent = formatValue(change.value);
317
+ tr.appendChild(valueTd);
318
+ }
319
+ tbody.appendChild(tr);
320
+ });
321
+ table.appendChild(tbody);
322
+ sectionDiv.appendChild(table);
323
+ } else {
324
+ const noChanges = document.createElement('p');
325
+ noChanges.className = 'no-changes';
326
+ if (node.operation === 'created') {
327
+ noChanges.textContent = 'New record created with initial values';
328
+ } else if (node.operation === 'destroyed') {
329
+ noChanges.textContent = 'Record deleted';
330
+ } else {
331
+ noChanges.textContent = 'No field changes recorded';
332
+ }
333
+ sectionDiv.appendChild(noChanges);
334
+ }
335
+ body.appendChild(sectionDiv);
336
+
337
+ overlay.style.display = 'flex';
338
+ }
339
+
340
+ function hideEventPopup() {
341
+ document.getElementById('event-popup-overlay').style.display = 'none';
342
+ }
343
+
344
+ function formatValue(value) {
345
+ if (value === null || value === undefined) return 'null';
346
+ if (value === '') return 'empty';
347
+ return String(value);
348
+ }
349
+
350
+ // Setup popup close handlers
351
+ document.getElementById('popup-close').addEventListener('click', hideEventPopup);
352
+ document.getElementById('event-popup-overlay').addEventListener('click', function(e) {
353
+ if (e.target === this) hideEventPopup();
354
+ });
355
+ document.addEventListener('keydown', function(e) {
356
+ if (e.key === 'Escape') hideEventPopup();
357
+ });
358
+
359
+ function loadEntityList() {
360
+ const listEl = document.getElementById('entity-list');
361
+ const modelFilter = document.getElementById('model-filter');
362
+
363
+ const loadingDiv = document.createElement('div');
364
+ loadingDiv.className = 'loading-message';
365
+ loadingDiv.textContent = 'Loading entities...';
366
+ listEl.replaceChildren(loadingDiv);
367
+
368
+ let url = '/lyra/visualizations/event_list.json';
369
+ if (modelFilter && modelFilter.value) {
370
+ url += '?model_class=' + encodeURIComponent(modelFilter.value);
371
+ }
372
+
373
+ fetch(url)
374
+ .then(r => r.json())
375
+ .then(data => {
376
+ listEl.replaceChildren();
377
+
378
+ if (!data.entities || data.entities.length === 0) {
379
+ const emptyDiv = document.createElement('div');
380
+ emptyDiv.className = 'empty-message';
381
+ emptyDiv.textContent = 'No entities with events found';
382
+ listEl.appendChild(emptyDiv);
383
+ return;
384
+ }
385
+
386
+ data.entities.forEach(entity => {
387
+ const item = document.createElement('div');
388
+ item.className = 'entity-item';
389
+ if (selectedEntity && selectedEntity.model_class === entity.model_class && selectedEntity.model_id === entity.model_id) {
390
+ item.classList.add('selected');
391
+ }
392
+
393
+ const nameDiv = document.createElement('div');
394
+ nameDiv.className = 'entity-name';
395
+ nameDiv.textContent = entity.display_name;
396
+ nameDiv.title = entity.display_name;
397
+
398
+ const metaDiv = document.createElement('div');
399
+ metaDiv.className = 'entity-meta';
400
+
401
+ const typeSpan = document.createElement('span');
402
+ typeSpan.className = 'entity-type';
403
+ typeSpan.textContent = entity.model_class;
404
+
405
+ const eventsSpan = document.createElement('span');
406
+ eventsSpan.className = 'entity-events';
407
+ eventsSpan.textContent = entity.event_count + ' events';
408
+
409
+ metaDiv.appendChild(typeSpan);
410
+ metaDiv.appendChild(eventsSpan);
411
+ item.appendChild(nameDiv);
412
+ item.appendChild(metaDiv);
413
+
414
+ item.addEventListener('click', () => {
415
+ selectedEntity = entity;
416
+ document.querySelectorAll('.entity-item').forEach(el => el.classList.remove('selected'));
417
+ item.classList.add('selected');
418
+ loadEntityGraph(entity.model_class, entity.model_id, entity.display_name);
419
+ });
420
+
421
+ listEl.appendChild(item);
422
+ });
423
+ })
424
+ .catch(err => {
425
+ const errorDiv = document.createElement('div');
426
+ errorDiv.className = 'empty-message';
427
+ errorDiv.textContent = 'Error loading entities';
428
+ listEl.replaceChildren(errorDiv);
429
+ console.error('Entity list error:', err);
430
+ });
431
+ }
432
+
433
+ function loadEntityGraph(modelClass, modelId, displayName) {
434
+ const container = document.getElementById('mermaid-container');
435
+ const titleEl = document.getElementById('graph-title');
436
+ const statsEl = document.getElementById('graph-stats');
437
+
438
+ const loadingDiv = document.createElement('div');
439
+ loadingDiv.className = 'placeholder-message';
440
+ loadingDiv.textContent = 'Loading graph...';
441
+ container.replaceChildren(loadingDiv);
442
+
443
+ titleEl.textContent = displayName + ' (' + modelClass + '#' + modelId + ')';
444
+
445
+ fetch('/lyra/visualizations/entity_graph/' + encodeURIComponent(modelClass) + '/' + modelId + '.json')
446
+ .then(r => r.json())
447
+ .then(data => {
448
+ if (!data.nodes || data.nodes.length === 0) {
449
+ const emptyDiv = document.createElement('div');
450
+ emptyDiv.className = 'placeholder-message';
451
+ emptyDiv.textContent = 'No events found for this entity';
452
+ container.replaceChildren(emptyDiv);
453
+ statsEl.textContent = '';
454
+ return;
455
+ }
456
+
457
+ statsEl.textContent = data.nodes.length + ' events, ' + data.links.length + ' connections';
458
+
459
+ // Store nodes for popup lookup
460
+ currentNodes = {};
461
+ data.nodes.forEach(node => {
462
+ const nodeId = node.id.replace(/[^a-zA-Z0-9]/g, '_');
463
+ currentNodes[nodeId] = node;
464
+ });
465
+
466
+ // Build Mermaid diagram
467
+ let mermaidCode = 'flowchart TD\n';
468
+ data.nodes.forEach(node => {
469
+ const nodeId = node.id.replace(/[^a-zA-Z0-9]/g, '_');
470
+ const time = node.timestamp_formatted.split(' ')[1];
471
+ const changedFields = formatChangedFields(node.changed_fields);
472
+ let label = node.operation.toUpperCase() + '\\n' + time;
473
+ if (changedFields) {
474
+ label += '\\n' + changedFields;
475
+ }
476
+ mermaidCode += ' ' + nodeId + '["' + label + '"]\n';
477
+ });
478
+ mermaidCode += '\n';
479
+ data.links.forEach(link => {
480
+ const sourceId = link.source.replace(/[^a-zA-Z0-9]/g, '_');
481
+ const targetId = link.target.replace(/[^a-zA-Z0-9]/g, '_');
482
+ const arrow = link.type === 'same_record' ? '-->' : '-.->';
483
+ mermaidCode += ' ' + sourceId + ' ' + arrow + ' ' + targetId + '\n';
484
+ });
485
+
486
+ const pre = document.createElement('pre');
487
+ pre.className = 'mermaid';
488
+ pre.textContent = mermaidCode;
489
+ container.replaceChildren(pre);
490
+
491
+ mermaid.run({ nodes: [pre] }).then(function() {
492
+ // After Mermaid renders, add click handlers to nodes
493
+ setTimeout(function() {
494
+ const svg = container.querySelector('svg');
495
+ if (!svg) return;
496
+
497
+ // Find all node elements and add click handlers
498
+ svg.querySelectorAll('.node').forEach(function(nodeEl) {
499
+ const nodeId = nodeEl.id.replace('flowchart-', '').replace(/-\d+$/, '');
500
+ if (currentNodes[nodeId]) {
501
+ nodeEl.style.cursor = 'pointer';
502
+ nodeEl.addEventListener('click', function(e) {
503
+ e.preventDefault();
504
+ e.stopPropagation();
505
+ showEventPopup(nodeId);
506
+ });
507
+ }
508
+ });
509
+ }, 100);
510
+ });
511
+ })
512
+ .catch(err => {
513
+ const errorDiv = document.createElement('div');
514
+ errorDiv.className = 'placeholder-message';
515
+ errorDiv.textContent = 'Error loading graph';
516
+ container.replaceChildren(errorDiv);
517
+ console.error('Entity graph error:', err);
518
+ });
519
+ }
520
+
521
+ document.getElementById('model-filter').addEventListener('change', loadEntityList);
522
+ document.getElementById('refresh-btn').addEventListener('click', loadEntityList);
523
+
524
+ loadEntityList();
525
+ </script>