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,324 @@
1
+ <style>
2
+ .lyra-audit { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
3
+ .lyra-audit h1 { color: #1a1a2e; border-bottom: 2px solid #4a4e69; padding-bottom: 10px; }
4
+ .lyra-audit h2 { color: #4a4e69; margin-top: 30px; }
5
+ .lyra-audit .back-link { display: inline-block; margin-bottom: 20px; color: #4a4e69; text-decoration: none; }
6
+ .lyra-audit .back-link:hover { text-decoration: underline; }
7
+ .lyra-audit .section-desc { color: #6c757d; font-size: 13px; margin-bottom: 15px; }
8
+ .lyra-audit .lookup-form { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
9
+ .lyra-audit .lookup-form h3 { margin: 0 0 15px 0; font-size: 16px; color: #1a1a2e; }
10
+ .lyra-audit .form-row { display: flex; gap: 15px; align-items: flex-end; flex-wrap: wrap; }
11
+ .lyra-audit .form-group { flex: 1; min-width: 200px; }
12
+ .lyra-audit .form-group label { display: block; font-size: 13px; color: #495057; margin-bottom: 5px; font-weight: 600; }
13
+ .lyra-audit .form-group select, .lyra-audit .form-group input { width: 100%; padding: 8px 12px; border: 1px solid #ced4da; border-radius: 4px; font-size: 14px; }
14
+ .lyra-audit .form-group select:focus, .lyra-audit .form-group input:focus { outline: none; border-color: #4a4e69; box-shadow: 0 0 0 2px rgba(74, 78, 105, 0.1); }
15
+ .lyra-audit .btn { padding: 8px 20px; background: #4a4e69; color: #fff; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; }
16
+ .lyra-audit .btn:hover { background: #22223b; }
17
+ .lyra-audit .btn-secondary { background: #e9ecef; color: #495057; }
18
+ .lyra-audit .btn-secondary:hover { background: #dee2e6; }
19
+ .lyra-audit .record-picker { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; margin-bottom: 30px; }
20
+ .lyra-audit .record-picker h3 { margin: 0 0 15px 0; font-size: 16px; color: #1a1a2e; display: flex; justify-content: space-between; align-items: center; }
21
+ .lyra-audit .record-picker .record-count { font-size: 12px; color: #6c757d; font-weight: normal; }
22
+ .lyra-audit .search-bar { display: flex; gap: 10px; margin-bottom: 15px; }
23
+ .lyra-audit .search-bar input { flex: 1; padding: 8px 12px; border: 1px solid #ced4da; border-radius: 4px; font-size: 14px; }
24
+ .lyra-audit .record-list { max-height: 300px; overflow-y: auto; border: 1px solid #e9ecef; border-radius: 6px; }
25
+ .lyra-audit .record-item { padding: 12px 15px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; cursor: pointer; transition: background 0.15s; }
26
+ .lyra-audit .record-item:last-child { border-bottom: none; }
27
+ .lyra-audit .record-item:hover { background: #f8f9fa; }
28
+ .lyra-audit .record-item.selected { background: #e7f1ff; border-left: 3px solid #007bff; }
29
+ .lyra-audit .record-item .record-main { display: flex; align-items: center; gap: 10px; }
30
+ .lyra-audit .record-item .record-id { background: #e9ecef; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; color: #495057; }
31
+ .lyra-audit .record-item .record-name { font-weight: 500; color: #1a1a2e; }
32
+ .lyra-audit .record-item .record-extra { font-size: 12px; color: #6c757d; }
33
+ .lyra-audit .record-item .record-meta { text-align: right; }
34
+ .lyra-audit .record-item .record-updated { font-size: 11px; color: #6c757d; }
35
+ .lyra-audit .no-records { padding: 30px; text-align: center; color: #6c757d; }
36
+ .lyra-audit .record-info { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 15px 20px; margin-bottom: 20px; }
37
+ .lyra-audit .record-info h3 { margin: 0 0 10px 0; font-size: 16px; color: #1a1a2e; }
38
+ .lyra-audit .record-info .meta { font-size: 13px; color: #6c757d; }
39
+ .lyra-audit .record-deleted { background: #f8d7da; border-color: #f5c6cb; }
40
+ .lyra-audit .record-deleted h3 { color: #721c24; }
41
+ .lyra-audit .timeline { position: relative; padding-left: 30px; }
42
+ .lyra-audit .timeline::before { content: ''; position: absolute; left: 10px; top: 0; bottom: 0; width: 2px; background: #dee2e6; }
43
+ .lyra-audit .timeline-entry { position: relative; margin-bottom: 20px; }
44
+ .lyra-audit .timeline-entry::before { content: ''; position: absolute; left: -24px; top: 5px; width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff; }
45
+ .lyra-audit .timeline-entry.created::before { background: #28a745; }
46
+ .lyra-audit .timeline-entry.updated::before { background: #007bff; }
47
+ .lyra-audit .timeline-entry.destroyed::before { background: #dc3545; }
48
+ .lyra-audit .entry-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; }
49
+ .lyra-audit .entry-header { background: #f8f9fa; padding: 12px 15px; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; }
50
+ .lyra-audit .entry-header .operation { font-weight: 600; text-transform: uppercase; font-size: 12px; }
51
+ .lyra-audit .entry-header .operation.created { color: #28a745; }
52
+ .lyra-audit .entry-header .operation.updated { color: #007bff; }
53
+ .lyra-audit .entry-header .operation.destroyed { color: #dc3545; }
54
+ .lyra-audit .entry-header .timestamp { font-size: 12px; color: #6c757d; }
55
+ .lyra-audit .entry-header .user { font-size: 12px; color: #495057; margin-left: 15px; }
56
+ .lyra-audit .entry-body { padding: 15px; }
57
+ .lyra-audit .changes-table { width: 100%; border-collapse: collapse; font-size: 13px; }
58
+ .lyra-audit .changes-table th { text-align: left; padding: 8px 12px; background: #f8f9fa; border-bottom: 1px solid #dee2e6; font-weight: 600; color: #495057; }
59
+ .lyra-audit .changes-table td { padding: 8px 12px; border-bottom: 1px solid #eee; vertical-align: top; }
60
+ .lyra-audit .changes-table tr:last-child td { border-bottom: none; }
61
+ .lyra-audit .old-value { color: #dc3545; text-decoration: line-through; }
62
+ .lyra-audit .new-value { color: #28a745; }
63
+ .lyra-audit .attr-value { font-family: monospace; font-size: 12px; background: #f8f9fa; padding: 2px 6px; border-radius: 3px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; display: inline-block; }
64
+ .lyra-audit .empty-state { text-align: center; padding: 60px 20px; color: #6c757d; background: #f8f9fa; border: 1px dashed #dee2e6; border-radius: 8px; }
65
+ .lyra-audit .empty-state h3 { margin: 0 0 10px 0; color: #495057; }
66
+ .lyra-audit .summary-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; margin: 20px 0; }
67
+ .lyra-audit .summary-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 15px; text-align: center; }
68
+ .lyra-audit .summary-card .value { font-size: 28px; font-weight: 700; color: #1a1a2e; }
69
+ .lyra-audit .summary-card .label { color: #6c757d; font-size: 12px; margin-top: 5px; }
70
+ .lyra-audit .summary-card.created .value { color: #28a745; }
71
+ .lyra-audit .summary-card.updated .value { color: #007bff; }
72
+ .lyra-audit .summary-card.destroyed .value { color: #dc3545; }
73
+ .lyra-audit .expandable { cursor: pointer; }
74
+ .lyra-audit .expandable:hover { background: #e9ecef; }
75
+ .lyra-audit .expand-toggle { color: #4a4e69; font-size: 11px; margin-left: 5px; }
76
+ </style>
77
+
78
+ <div class="lyra-audit">
79
+ <%= link_to "&larr; Back to Dashboard".html_safe, dashboard_path, class: "back-link" %>
80
+
81
+ <h1>Audit Trail</h1>
82
+ <p class="section-desc">
83
+ View the complete history of changes for any record. The audit trail shows every create, update,
84
+ and delete operation with timestamps, user IDs, and field-level changes.
85
+ </p>
86
+
87
+ <div class="lookup-form">
88
+ <h3>Step 1: Select Model</h3>
89
+ <%= form_tag(audit_trail_path, method: :get, id: "model-form") do %>
90
+ <div class="form-row">
91
+ <div class="form-group">
92
+ <label for="model_class">Model</label>
93
+ <select name="model_class" id="model_class" onchange="this.form.submit()">
94
+ <option value="">Select a model...</option>
95
+ <% @monitored_models.each do |model| %>
96
+ <option value="<%= model.name %>" <%= 'selected' if @selected_model == model.name %>><%= model.name %></option>
97
+ <% end %>
98
+ </select>
99
+ </div>
100
+ </div>
101
+ <% end %>
102
+ </div>
103
+
104
+ <% if @selected_model.present? && @available_records.present? %>
105
+ <div class="record-picker">
106
+ <h3>
107
+ Step 2: Select Record
108
+ <span class="record-count">Showing <%= @available_records.size %> records</span>
109
+ </h3>
110
+
111
+ <%= form_tag(audit_trail_path, method: :get, id: "search-form") do %>
112
+ <input type="hidden" name="model_class" value="<%= @selected_model %>">
113
+ <div class="search-bar">
114
+ <input type="text" name="search" placeholder="Search by name, email, or other fields..." value="<%= @search_query %>" id="record-search">
115
+ <button type="submit" class="btn btn-secondary">Search</button>
116
+ <% if @search_query.present? %>
117
+ <%= link_to "Clear", audit_trail_path(model_class: @selected_model), class: "btn btn-secondary" %>
118
+ <% end %>
119
+ </div>
120
+ <% end %>
121
+
122
+ <div class="record-list">
123
+ <% @available_records.each do |record| %>
124
+ <%= link_to audit_trail_path(model_class: @selected_model, record_id: record[:id]), class: "record-item #{'selected' if @selected_id == record[:id].to_s}", style: "text-decoration: none;" do %>
125
+ <div class="record-main">
126
+ <span class="record-id">#<%= record[:id] %></span>
127
+ <div>
128
+ <span class="record-name"><%= record[:display_name] %></span>
129
+ <% if record[:extra_info].present? %>
130
+ <div class="record-extra"><%= record[:extra_info] %></div>
131
+ <% end %>
132
+ </div>
133
+ </div>
134
+ <div class="record-meta">
135
+ <span class="record-updated">Updated <%= record[:updated_at]&.strftime("%b %d, %Y") %></span>
136
+ </div>
137
+ <% end %>
138
+ <% end %>
139
+ <% if @available_records.empty? %>
140
+ <div class="no-records">
141
+ <% if @search_query.present? %>
142
+ No records found matching "<%= @search_query %>"
143
+ <% else %>
144
+ No records found for <%= @selected_model %>
145
+ <% end %>
146
+ </div>
147
+ <% end %>
148
+ </div>
149
+ </div>
150
+ <% elsif @selected_model.present? && @available_records&.empty? %>
151
+ <div class="record-picker">
152
+ <h3>Step 2: Select Record</h3>
153
+ <div class="no-records">
154
+ No records found for <%= @selected_model %>. You can still enter a record ID manually below.
155
+ </div>
156
+ <%= form_tag(audit_trail_path, method: :get) do %>
157
+ <input type="hidden" name="model_class" value="<%= @selected_model %>">
158
+ <div class="form-row" style="margin-top: 15px;">
159
+ <div class="form-group">
160
+ <label for="record_id">Enter Record ID Manually</label>
161
+ <input type="text" name="record_id" id="record_id" placeholder="Enter record ID" value="<%= @selected_id %>">
162
+ </div>
163
+ <button type="submit" class="btn">View Audit Trail</button>
164
+ </div>
165
+ <% end %>
166
+ </div>
167
+ <% end %>
168
+
169
+ <% if @audit_entries.present? %>
170
+ <h2 style="margin-top: 0;">Audit Trail for <%= @model_class.name %> #<%= @selected_id || @model_id %></h2>
171
+
172
+ <% if @record %>
173
+ <div class="record-info">
174
+ <h3>Current Record State</h3>
175
+ <p class="meta">
176
+ Created: <%= @record.created_at&.strftime("%Y-%m-%d %H:%M:%S") %> |
177
+ Updated: <%= @record.updated_at&.strftime("%Y-%m-%d %H:%M:%S") %>
178
+ </p>
179
+ </div>
180
+ <% else %>
181
+ <div class="record-info record-deleted">
182
+ <h3>Record Not Found (Deleted)</h3>
183
+ <p class="meta">
184
+ This record no longer exists in the database, but its audit history is preserved in the event store.
185
+ </p>
186
+ </div>
187
+ <% end %>
188
+
189
+ <%
190
+ created_count = @audit_entries.count { |e| e[:operation].to_sym == :created }
191
+ updated_count = @audit_entries.count { |e| e[:operation].to_sym == :updated }
192
+ destroyed_count = @audit_entries.count { |e| e[:operation].to_sym == :destroyed }
193
+ %>
194
+ <div class="summary-cards">
195
+ <div class="summary-card">
196
+ <div class="value"><%= @audit_entries.count %></div>
197
+ <div class="label">Total Events</div>
198
+ </div>
199
+ <div class="summary-card created">
200
+ <div class="value"><%= created_count %></div>
201
+ <div class="label">Created</div>
202
+ </div>
203
+ <div class="summary-card updated">
204
+ <div class="value"><%= updated_count %></div>
205
+ <div class="label">Updated</div>
206
+ </div>
207
+ <div class="summary-card destroyed">
208
+ <div class="value"><%= destroyed_count %></div>
209
+ <div class="label">Destroyed</div>
210
+ </div>
211
+ </div>
212
+
213
+ <div class="timeline">
214
+ <% @audit_entries.reverse_each do |entry| %>
215
+ <% operation = entry[:operation].to_s %>
216
+ <div class="timeline-entry <%= operation %>">
217
+ <div class="entry-card">
218
+ <div class="entry-header">
219
+ <div>
220
+ <span class="operation <%= operation %>"><%= operation %></span>
221
+ <% if entry[:user_id] %>
222
+ <span class="user">by <%= user_display_name(entry[:user_id]) %></span>
223
+ <% end %>
224
+ </div>
225
+ <span class="timestamp"><%= entry[:timestamp].is_a?(String) ? entry[:timestamp] : entry[:timestamp]&.strftime("%Y-%m-%d %H:%M:%S.%L") %></span>
226
+ </div>
227
+ <div class="entry-body">
228
+ <% if operation == 'created' && entry[:attributes].present? %>
229
+ <table class="changes-table">
230
+ <thead>
231
+ <tr>
232
+ <th>Field</th>
233
+ <th>Initial Value</th>
234
+ </tr>
235
+ </thead>
236
+ <tbody>
237
+ <% entry[:attributes].each do |field, value| %>
238
+ <tr>
239
+ <td><strong><%= field %></strong></td>
240
+ <td><span class="attr-value new-value"><%= value.to_s.truncate(100) %></span></td>
241
+ </tr>
242
+ <% end %>
243
+ </tbody>
244
+ </table>
245
+ <% elsif operation == 'updated' && entry[:changes].present? %>
246
+ <table class="changes-table">
247
+ <thead>
248
+ <tr>
249
+ <th>Field</th>
250
+ <th>Old Value</th>
251
+ <th>New Value</th>
252
+ </tr>
253
+ </thead>
254
+ <tbody>
255
+ <% entry[:changes].each do |field, change| %>
256
+ <% old_val, new_val = change.is_a?(Array) ? change : [nil, change] %>
257
+ <tr>
258
+ <td><strong><%= field %></strong></td>
259
+ <td><span class="attr-value old-value"><%= old_val.to_s.truncate(100) %></span></td>
260
+ <td><span class="attr-value new-value"><%= new_val.to_s.truncate(100) %></span></td>
261
+ </tr>
262
+ <% end %>
263
+ </tbody>
264
+ </table>
265
+ <% elsif operation == 'destroyed' %>
266
+ <p style="color: #6c757d; margin: 0; font-size: 13px;">Record was permanently deleted.</p>
267
+ <% if entry[:attributes].present? %>
268
+ <details style="margin-top: 10px;">
269
+ <summary style="cursor: pointer; color: #4a4e69; font-size: 13px;">View final state before deletion</summary>
270
+ <table class="changes-table" style="margin-top: 10px;">
271
+ <thead>
272
+ <tr>
273
+ <th>Field</th>
274
+ <th>Final Value</th>
275
+ </tr>
276
+ </thead>
277
+ <tbody>
278
+ <% entry[:attributes].each do |field, value| %>
279
+ <tr>
280
+ <td><strong><%= field %></strong></td>
281
+ <td><span class="attr-value"><%= value.to_s.truncate(100) %></span></td>
282
+ </tr>
283
+ <% end %>
284
+ </tbody>
285
+ </table>
286
+ </details>
287
+ <% end %>
288
+ <% else %>
289
+ <p style="color: #6c757d; margin: 0; font-size: 13px;">No detailed changes recorded.</p>
290
+ <% end %>
291
+ </div>
292
+ </div>
293
+ </div>
294
+ <% end %>
295
+ </div>
296
+
297
+ <% elsif @selected_model.present? && @selected_id.present? %>
298
+ <div class="empty-state">
299
+ <h3>No Audit Trail Found</h3>
300
+ <p>No events were found for <%= @selected_model %> #<%= @selected_id %>.</p>
301
+ <p style="font-size: 13px; margin-top: 10px;">
302
+ This could mean the record was created before Lyra was enabled,
303
+ or the record ID doesn't exist.
304
+ </p>
305
+ </div>
306
+
307
+ <% elsif !@selected_model.present? %>
308
+ <div class="empty-state">
309
+ <h3>Select a Model to Begin</h3>
310
+ <p>Choose a model from the dropdown above to browse available records.</p>
311
+ <p style="font-size: 13px; margin-top: 10px;">
312
+ The audit trail shows every change made to a record, including who made the change and when.
313
+ </p>
314
+ </div>
315
+ <% elsif @selected_model.present? && !@selected_id.present? %>
316
+ <div class="empty-state">
317
+ <h3>Select a Record</h3>
318
+ <p>Click on a record above to view its complete audit trail.</p>
319
+ <p style="font-size: 13px; margin-top: 10px;">
320
+ You can search by name, email, or other identifying fields.
321
+ </p>
322
+ </div>
323
+ <% end %>
324
+ </div>
@@ -0,0 +1,125 @@
1
+ <style>
2
+ .lyra-discrepancies { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
3
+ .lyra-discrepancies h1 { color: #1a1a2e; border-bottom: 2px solid #4a4e69; padding-bottom: 10px; }
4
+ .lyra-discrepancies h2 { color: #4a4e69; margin-top: 30px; }
5
+ .lyra-discrepancies .back-link { display: inline-block; margin-bottom: 20px; color: #4a4e69; text-decoration: none; }
6
+ .lyra-discrepancies .back-link:hover { text-decoration: underline; }
7
+ .lyra-discrepancies .summary-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }
8
+ .lyra-discrepancies .summary-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
9
+ .lyra-discrepancies .summary-card .value { font-size: 36px; font-weight: 700; color: #1a1a2e; }
10
+ .lyra-discrepancies .summary-card .label { color: #6c757d; font-size: 14px; margin-top: 5px; }
11
+ .lyra-discrepancies .summary-card.warning { border-color: #ffc107; background: #fff9e6; }
12
+ .lyra-discrepancies .summary-card.success { border-color: #28a745; background: #e8f5e9; }
13
+ .lyra-discrepancies .discrepancy-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; margin-bottom: 15px; overflow: hidden; }
14
+ .lyra-discrepancies .discrepancy-header { background: #fff3cd; padding: 15px 20px; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; }
15
+ .lyra-discrepancies .discrepancy-header.match { background: #d4edda; }
16
+ .lyra-discrepancies .discrepancy-header h3 { margin: 0; color: #1a1a2e; font-size: 16px; }
17
+ .lyra-discrepancies .discrepancy-header .status { padding: 4px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; }
18
+ .lyra-discrepancies .discrepancy-header .status.mismatch { background: #f8d7da; color: #721c24; }
19
+ .lyra-discrepancies .discrepancy-header .status.match { background: #d4edda; color: #155724; }
20
+ .lyra-discrepancies .discrepancy-body { padding: 20px; }
21
+ .lyra-discrepancies .comparison-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
22
+ .lyra-discrepancies .comparison-col h4 { margin: 0 0 10px 0; font-size: 14px; color: #495057; }
23
+ .lyra-discrepancies .comparison-col .source-label { font-size: 12px; color: #6c757d; margin-bottom: 5px; }
24
+ .lyra-discrepancies .field-list { list-style: none; padding: 0; margin: 0; }
25
+ .lyra-discrepancies .field-list li { padding: 8px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
26
+ .lyra-discrepancies .field-list li:last-child { border-bottom: none; }
27
+ .lyra-discrepancies .field-name { font-weight: 500; color: #1a1a2e; }
28
+ .lyra-discrepancies .field-value { color: #495057; font-family: monospace; font-size: 13px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
29
+ .lyra-discrepancies .field-mismatch { background: #fff3cd; padding: 2px 6px; border-radius: 3px; }
30
+ .lyra-discrepancies .empty-state { text-align: center; padding: 60px 40px; color: #6c757d; background: #d4edda; border: 1px solid #28a745; border-radius: 8px; }
31
+ .lyra-discrepancies .empty-state h3 { color: #155724; margin-bottom: 10px; }
32
+ </style>
33
+
34
+ <div class="lyra-discrepancies">
35
+ <%= link_to "&larr; Back to Dashboard".html_safe, dashboard_path, class: "back-link" %>
36
+
37
+ <h1>Discrepancies: <%= @model_class.name %></h1>
38
+ <p style="color: #6c757d; margin-bottom: 20px;">
39
+ Compares the current database state with the reconstructed state from events.
40
+ Discrepancies indicate differences between DB records and event-sourced projections.
41
+ </p>
42
+
43
+ <h2 style="margin-top: 0;">Summary</h2>
44
+ <p style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
45
+ Overview of data consistency between the database and event store.
46
+ </p>
47
+ <%
48
+ total_records = @discrepancies.count
49
+ mismatches = @discrepancies.count { |d| d[:status] == :mismatch || d[:differences]&.any? }
50
+ matches = total_records - mismatches
51
+ %>
52
+ <div class="summary-cards">
53
+ <div class="summary-card">
54
+ <div class="value"><%= total_records %></div>
55
+ <div class="label">Total Records Checked</div>
56
+ </div>
57
+ <div class="summary-card success">
58
+ <div class="value"><%= matches %></div>
59
+ <div class="label">Matching</div>
60
+ </div>
61
+ <div class="summary-card <%= mismatches > 0 ? 'warning' : '' %>">
62
+ <div class="value"><%= mismatches %></div>
63
+ <div class="label">Discrepancies</div>
64
+ </div>
65
+ </div>
66
+
67
+ <h2>Discrepancy Details</h2>
68
+ <p style="color: #6c757d; font-size: 13px; margin-bottom: 15px;">
69
+ Per-record comparison showing differences between database values and event-reconstructed state.
70
+ </p>
71
+
72
+ <% if @discrepancies.empty? || mismatches == 0 %>
73
+ <div class="empty-state">
74
+ <h3>All Records Match</h3>
75
+ <p>No discrepancies found between the database and event store.</p>
76
+ <p>The current state matches the event-sourced projection for all <%= @model_class.name %> records.</p>
77
+ </div>
78
+ <% else %>
79
+ <% @discrepancies.each do |discrepancy| %>
80
+ <% has_differences = discrepancy[:status] == :mismatch || discrepancy[:differences]&.any? %>
81
+ <div class="discrepancy-card">
82
+ <div class="discrepancy-header <%= has_differences ? '' : 'match' %>">
83
+ <h3><%= @model_class.name %> #<%= discrepancy[:id] || discrepancy[:record_id] %></h3>
84
+ <span class="status <%= has_differences ? 'mismatch' : 'match' %>">
85
+ <%= has_differences ? 'Mismatch' : 'Match' %>
86
+ </span>
87
+ </div>
88
+ <% if has_differences %>
89
+ <div class="discrepancy-body">
90
+ <div class="comparison-grid">
91
+ <div class="comparison-col">
92
+ <p class="source-label">Database State</p>
93
+ <h4>Current Values</h4>
94
+ <ul class="field-list">
95
+ <% (discrepancy[:differences] || discrepancy[:db_state] || {}).each do |field, values| %>
96
+ <li>
97
+ <span class="field-name"><%= field %></span>
98
+ <span class="field-value field-mismatch">
99
+ <%= values.is_a?(Array) ? values[0] : values %>
100
+ </span>
101
+ </li>
102
+ <% end %>
103
+ </ul>
104
+ </div>
105
+ <div class="comparison-col">
106
+ <p class="source-label">Event-Sourced State</p>
107
+ <h4>Projected Values</h4>
108
+ <ul class="field-list">
109
+ <% (discrepancy[:differences] || discrepancy[:event_state] || {}).each do |field, values| %>
110
+ <li>
111
+ <span class="field-name"><%= field %></span>
112
+ <span class="field-value field-mismatch">
113
+ <%= values.is_a?(Array) ? values[1] : values %>
114
+ </span>
115
+ </li>
116
+ <% end %>
117
+ </ul>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ <% end %>
122
+ </div>
123
+ <% end %>
124
+ <% end %>
125
+ </div>