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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +222 -0
- data/LICENSE +21 -0
- data/README.md +1165 -0
- data/Rakefile +728 -0
- data/app/controllers/lyra/application_controller.rb +23 -0
- data/app/controllers/lyra/dashboard_controller.rb +624 -0
- data/app/controllers/lyra/flow_controller.rb +224 -0
- data/app/controllers/lyra/privacy_controller.rb +182 -0
- data/app/views/lyra/dashboard/audit_trail.html.erb +324 -0
- data/app/views/lyra/dashboard/discrepancies.html.erb +125 -0
- data/app/views/lyra/dashboard/event_graph_view.html.erb +525 -0
- data/app/views/lyra/dashboard/heatmap_view.html.erb +155 -0
- data/app/views/lyra/dashboard/index.html.erb +119 -0
- data/app/views/lyra/dashboard/model_overview.html.erb +115 -0
- data/app/views/lyra/dashboard/projections.html.erb +302 -0
- data/app/views/lyra/dashboard/schema.html.erb +283 -0
- data/app/views/lyra/dashboard/schema_history.html.erb +78 -0
- data/app/views/lyra/dashboard/schema_version.html.erb +340 -0
- data/app/views/lyra/dashboard/verification.html.erb +370 -0
- data/app/views/lyra/flow/crud_mapping.html.erb +125 -0
- data/app/views/lyra/flow/timeline.html.erb +260 -0
- data/app/views/lyra/privacy/pii_detection.html.erb +148 -0
- data/app/views/lyra/privacy/policy.html.erb +188 -0
- data/app/workflows/es_async_mode_workflow.rb +80 -0
- data/app/workflows/es_sync_mode_workflow.rb +64 -0
- data/app/workflows/hijack_mode_workflow.rb +54 -0
- data/app/workflows/lifecycle_workflow.rb +43 -0
- data/app/workflows/monitor_mode_workflow.rb +39 -0
- data/config/privacy_policies.rb +273 -0
- data/config/routes.rb +48 -0
- data/lib/lyra/aggregate.rb +131 -0
- data/lib/lyra/associations/event_aware.rb +225 -0
- data/lib/lyra/command.rb +81 -0
- data/lib/lyra/command_handler.rb +155 -0
- data/lib/lyra/configuration.rb +124 -0
- data/lib/lyra/consistency/read_your_writes.rb +91 -0
- data/lib/lyra/correlation.rb +144 -0
- data/lib/lyra/dual_view.rb +231 -0
- data/lib/lyra/engine.rb +67 -0
- data/lib/lyra/event.rb +71 -0
- data/lib/lyra/event_analyzer.rb +135 -0
- data/lib/lyra/event_flow.rb +449 -0
- data/lib/lyra/event_mapper.rb +106 -0
- data/lib/lyra/event_store_adapter.rb +72 -0
- data/lib/lyra/id_generator.rb +137 -0
- data/lib/lyra/interceptors/association_interceptor.rb +169 -0
- data/lib/lyra/interceptors/crud_interceptor.rb +543 -0
- data/lib/lyra/privacy/gdpr_compliance.rb +161 -0
- data/lib/lyra/privacy/pii_detector.rb +85 -0
- data/lib/lyra/privacy/pii_masker.rb +66 -0
- data/lib/lyra/privacy/policy_integration.rb +253 -0
- data/lib/lyra/projection.rb +94 -0
- data/lib/lyra/projections/async_projection_job.rb +63 -0
- data/lib/lyra/projections/cached_projection.rb +322 -0
- data/lib/lyra/projections/cached_relation.rb +757 -0
- data/lib/lyra/projections/event_store_reader.rb +127 -0
- data/lib/lyra/projections/model_projection.rb +143 -0
- data/lib/lyra/schema/diff.rb +331 -0
- data/lib/lyra/schema/event_class_registrar.rb +63 -0
- data/lib/lyra/schema/generator.rb +190 -0
- data/lib/lyra/schema/reporter.rb +188 -0
- data/lib/lyra/schema/store.rb +156 -0
- data/lib/lyra/schema/validator.rb +100 -0
- data/lib/lyra/strict_data_access.rb +363 -0
- data/lib/lyra/verification/crud_lifecycle_workflow.rb +456 -0
- data/lib/lyra/verification/workflow_generator.rb +540 -0
- data/lib/lyra/version.rb +3 -0
- data/lib/lyra/visualization/activity_heatmap.rb +215 -0
- data/lib/lyra/visualization/event_graph.rb +310 -0
- data/lib/lyra/visualization/timeline.rb +398 -0
- data/lib/lyra.rb +150 -0
- data/lib/tasks/dist.rake +391 -0
- data/lib/tasks/gems.rake +185 -0
- data/lib/tasks/lyra_schema.rake +231 -0
- data/lib/tasks/lyra_workflows.rake +452 -0
- data/lib/tasks/public_release.rake +351 -0
- data/lib/tasks/stats.rake +175 -0
- data/lib/tasks/testbed.rake +479 -0
- data/lib/tasks/version.rake +159 -0
- metadata +221 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
# Base controller for Lyra engine
|
|
4
|
+
protect_from_forgery with: :exception, unless: -> { request.format.json? }
|
|
5
|
+
|
|
6
|
+
helper_method :pam_dsl_available?, :user_tracking_configured?, :petri_flow_available?
|
|
7
|
+
|
|
8
|
+
# Check if PAM DSL is available for privacy features
|
|
9
|
+
def pam_dsl_available?
|
|
10
|
+
PAM_DSL_AVAILABLE
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Check if PetriFlow is available for formal verification
|
|
14
|
+
def petri_flow_available?
|
|
15
|
+
PETRI_FLOW_AVAILABLE
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Check if user tracking is configured via Rails CurrentAttributes
|
|
19
|
+
def user_tracking_configured?
|
|
20
|
+
defined?(::Current) && ::Current.respond_to?(:user)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
class DashboardController < ApplicationController
|
|
3
|
+
helper_method :user_display_name
|
|
4
|
+
before_action :ensure_models_loaded
|
|
5
|
+
|
|
6
|
+
# GET /lyra/dashboard
|
|
7
|
+
def index
|
|
8
|
+
@monitored_models = Lyra.config.monitored_models
|
|
9
|
+
@mode = Lyra.config.mode
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# GET /lyra/dashboard/model/:model_class
|
|
13
|
+
def model_overview
|
|
14
|
+
@model_class = params[:model_class].constantize
|
|
15
|
+
@records_count = @model_class.count
|
|
16
|
+
@events_count = count_events_for_model(@model_class)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# GET /lyra/dashboard/compare/:model_class/:id
|
|
20
|
+
def compare
|
|
21
|
+
model_class = params[:model_class].constantize
|
|
22
|
+
model_id = params[:id]
|
|
23
|
+
|
|
24
|
+
@comparison = DualView.new(model_class, model_id).compare
|
|
25
|
+
@audit_trail = DualView.new(model_class, model_id).audit_trail
|
|
26
|
+
@analysis = StateAnalyzer.analyze(model_class, model_id)
|
|
27
|
+
|
|
28
|
+
render json: {
|
|
29
|
+
comparison: @comparison,
|
|
30
|
+
audit_trail: @audit_trail,
|
|
31
|
+
analysis: @analysis
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# GET /lyra/dashboard/discrepancies/:model_class
|
|
36
|
+
def discrepancies
|
|
37
|
+
@model_class = params[:model_class].constantize
|
|
38
|
+
@discrepancies = DualView.find_discrepancies(@model_class)
|
|
39
|
+
|
|
40
|
+
respond_to do |format|
|
|
41
|
+
format.html
|
|
42
|
+
format.json { render json: { discrepancies: @discrepancies } }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# GET /lyra/config/projections
|
|
47
|
+
def projections
|
|
48
|
+
@mode = Lyra.config.mode
|
|
49
|
+
@projection_mode = Lyra.config.projection_mode
|
|
50
|
+
@strict_projections = Lyra.config.strict_projections
|
|
51
|
+
@monitored_models = Lyra.config.monitored_models
|
|
52
|
+
@async_inline = Lyra.config.async_projections_inline
|
|
53
|
+
|
|
54
|
+
# Projection types available in Lyra
|
|
55
|
+
@projection_types = [
|
|
56
|
+
{
|
|
57
|
+
name: "StateProjection",
|
|
58
|
+
description: "Rebuilds current state by replaying events. Used for dual-view comparison between DB and event-sourced state.",
|
|
59
|
+
use_case: "Consistency checking, debugging, state recovery"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "AuditProjection",
|
|
63
|
+
description: "Generates chronological audit trail from events. Shows who changed what and when.",
|
|
64
|
+
use_case: "Compliance reporting, change history, forensics"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "ModelProjection",
|
|
68
|
+
description: "Synchronizes ActiveRecord model tables from events using raw SQL (bypasses callbacks).",
|
|
69
|
+
use_case: "Event sourcing mode - keeps read models in sync"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "AsyncProjectionJob",
|
|
73
|
+
description: "Background job for eventual consistency. Retries with exponential backoff on failures.",
|
|
74
|
+
use_case: "High-throughput systems, async projection mode",
|
|
75
|
+
queue: "lyra_projections",
|
|
76
|
+
retry_attempts: 5
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# Get model configurations
|
|
81
|
+
@model_configs = {}
|
|
82
|
+
@monitored_models.each do |model_class|
|
|
83
|
+
config = Lyra.config.model_config(model_class)
|
|
84
|
+
@model_configs[model_class.name] = {
|
|
85
|
+
event_prefix: config.event_prefix,
|
|
86
|
+
aggregate_class: config.aggregate_class,
|
|
87
|
+
command_handler: config.command_handler,
|
|
88
|
+
privacy_policy: config.privacy_policy
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get event counts per model with operation breakdown
|
|
93
|
+
@model_stats = {}
|
|
94
|
+
@monitored_models.each do |model_class|
|
|
95
|
+
events = collect_events_for_model(model_class)
|
|
96
|
+
record_count = model_class.count rescue 0
|
|
97
|
+
|
|
98
|
+
# Group by operation
|
|
99
|
+
by_operation = events.group_by { |e| e.data[:operation] || e.data["operation"] }
|
|
100
|
+
|
|
101
|
+
@model_stats[model_class.name] = {
|
|
102
|
+
events: events.count,
|
|
103
|
+
records: record_count,
|
|
104
|
+
avg_events_per_record: record_count > 0 ? (events.count.to_f / record_count).round(2) : 0,
|
|
105
|
+
created: by_operation[:created]&.count || by_operation["created"]&.count || 0,
|
|
106
|
+
updated: by_operation[:updated]&.count || by_operation["updated"]&.count || 0,
|
|
107
|
+
destroyed: by_operation[:destroyed]&.count || by_operation["destroyed"]&.count || 0,
|
|
108
|
+
last_event_at: events.max_by { |e| e.metadata[:timestamp] || e.timestamp rescue Time.at(0) }&.then { |e| e.metadata[:timestamp] || e.timestamp rescue nil }
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# GET /lyra/dashboard/audit_trail
|
|
114
|
+
def audit_trail
|
|
115
|
+
@monitored_models = Lyra.config.monitored_models
|
|
116
|
+
@selected_model = params[:model_class]
|
|
117
|
+
@selected_id = params[:record_id]
|
|
118
|
+
@search_query = params[:search]
|
|
119
|
+
|
|
120
|
+
# If a model is selected, load available records for the picker
|
|
121
|
+
if @selected_model.present?
|
|
122
|
+
@model_class = @selected_model.constantize
|
|
123
|
+
@available_records = fetch_available_records(@model_class, @search_query)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if @selected_model.present? && @selected_id.present?
|
|
127
|
+
@record = @model_class.find_by(id: @selected_id)
|
|
128
|
+
@audit_entries = AuditProjection.audit_trail(@model_class, @selected_id)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# GET /lyra/dashboard/audit_trail/:model_class/:id
|
|
133
|
+
def audit_trail_for_record
|
|
134
|
+
@model_class = params[:model_class].constantize
|
|
135
|
+
@model_id = params[:id]
|
|
136
|
+
@record = @model_class.find_by(id: @model_id)
|
|
137
|
+
@audit_entries = AuditProjection.audit_trail(@model_class, @model_id)
|
|
138
|
+
@monitored_models = Lyra.config.monitored_models
|
|
139
|
+
|
|
140
|
+
respond_to do |format|
|
|
141
|
+
format.html { render :audit_trail }
|
|
142
|
+
format.json { render json: { audit_trail: @audit_entries, record_id: @model_id, model: @model_class.name } }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# GET /lyra/dashboard/visualizations/timeline
|
|
147
|
+
def timeline
|
|
148
|
+
events = fetch_recent_events(limit: params[:limit]&.to_i || 100)
|
|
149
|
+
timeline = Visualization::Timeline.new(events)
|
|
150
|
+
|
|
151
|
+
render json: timeline.to_data
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# GET /lyra/dashboard/visualizations/event_graph
|
|
155
|
+
# Returns filtered events based on query params
|
|
156
|
+
def event_graph
|
|
157
|
+
events = fetch_filtered_events(
|
|
158
|
+
model_class: params[:model_class],
|
|
159
|
+
operation: params[:operation],
|
|
160
|
+
limit: params[:limit]&.to_i || 50
|
|
161
|
+
)
|
|
162
|
+
graph = Visualization::EventGraph.new(events)
|
|
163
|
+
|
|
164
|
+
render json: graph.to_data
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# GET /lyra/dashboard/visualizations/entity_graph/:model_class/:id
|
|
168
|
+
# Returns focused graph for a specific entity's lifecycle
|
|
169
|
+
def entity_graph
|
|
170
|
+
model_class = params[:model_class]
|
|
171
|
+
model_id = params[:id]
|
|
172
|
+
depth = params[:depth]&.to_i || 1 # How many related entities to include
|
|
173
|
+
|
|
174
|
+
events = fetch_entity_events(model_class, model_id, depth: depth)
|
|
175
|
+
graph = Visualization::EventGraph.new(events)
|
|
176
|
+
|
|
177
|
+
render json: graph.to_data.merge(
|
|
178
|
+
focused_entity: { model_class: model_class, model_id: model_id }
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# GET /lyra/dashboard/visualizations/event_list
|
|
183
|
+
# Returns list of entities with event counts for the picker UI
|
|
184
|
+
def event_list
|
|
185
|
+
model_filter = params[:model_class]
|
|
186
|
+
limit = params[:limit]&.to_i || 20
|
|
187
|
+
|
|
188
|
+
entities = []
|
|
189
|
+
models = model_filter.present? ? [model_filter.constantize] : Lyra.config.monitored_models
|
|
190
|
+
|
|
191
|
+
models.each do |model_class|
|
|
192
|
+
model_class.order(updated_at: :desc).limit(limit).each do |record|
|
|
193
|
+
stream_name = "#{model_class.name}$#{record.id}"
|
|
194
|
+
begin
|
|
195
|
+
event_count = Lyra.config.event_store.read.stream(stream_name).count
|
|
196
|
+
next if event_count == 0
|
|
197
|
+
|
|
198
|
+
# Get display name for the record
|
|
199
|
+
display_name = record_display_name(record)
|
|
200
|
+
|
|
201
|
+
entities << {
|
|
202
|
+
model_class: model_class.name,
|
|
203
|
+
model_id: record.id,
|
|
204
|
+
display_name: display_name,
|
|
205
|
+
event_count: event_count,
|
|
206
|
+
updated_at: record.updated_at
|
|
207
|
+
}
|
|
208
|
+
rescue
|
|
209
|
+
# Skip if stream doesn't exist
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Sort by event_count descending, then updated_at descending
|
|
215
|
+
entities.sort_by! { |e| [-e[:event_count], -e[:updated_at].to_i] }
|
|
216
|
+
entities = entities.first(limit)
|
|
217
|
+
|
|
218
|
+
render json: {
|
|
219
|
+
entities: entities,
|
|
220
|
+
model_classes: Lyra.config.monitored_models.map(&:name)
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# GET /lyra/dashboard/visualizations/heatmap
|
|
225
|
+
def heatmap
|
|
226
|
+
events = fetch_events_for_period(days: params[:days]&.to_i || 7)
|
|
227
|
+
heatmap = Visualization::ActivityHeatmap.new(events)
|
|
228
|
+
|
|
229
|
+
render json: heatmap.to_data
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# GET /lyra/visualizations/event_graph (HTML view)
|
|
233
|
+
def event_graph_view
|
|
234
|
+
@limit = params[:limit]&.to_i || 50
|
|
235
|
+
events = fetch_recent_events(limit: @limit)
|
|
236
|
+
graph = Visualization::EventGraph.new(events)
|
|
237
|
+
@graph_data = graph.to_data
|
|
238
|
+
@mermaid = graph.to_mermaid
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# GET /lyra/visualizations/heatmap (HTML view)
|
|
242
|
+
def heatmap_view
|
|
243
|
+
@days = params[:days]&.to_i || 7
|
|
244
|
+
events = fetch_events_for_period(days: @days)
|
|
245
|
+
heatmap = Visualization::ActivityHeatmap.new(events)
|
|
246
|
+
@heatmap_data = heatmap.to_data
|
|
247
|
+
@hourly = heatmap.hourly_breakdown
|
|
248
|
+
@daily = heatmap.daily_breakdown
|
|
249
|
+
@operation_heatmap = heatmap.operation_heatmap
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# GET /lyra/verification (HTML view)
|
|
253
|
+
def verification
|
|
254
|
+
@petri_flow_available = Lyra.petri_flow_available?
|
|
255
|
+
|
|
256
|
+
if @petri_flow_available
|
|
257
|
+
verifier = Lyra::Verification::CrudVerifier.new
|
|
258
|
+
@report = verifier.verify_all
|
|
259
|
+
@summary = @report[:summary]
|
|
260
|
+
@lifecycle = @report[:details][:lifecycle]
|
|
261
|
+
@modes = @report[:details][:modes]
|
|
262
|
+
@generated_workflows = @report[:details][:generated_workflows] || {}
|
|
263
|
+
else
|
|
264
|
+
@report = nil
|
|
265
|
+
@summary = nil
|
|
266
|
+
@generated_workflows = {}
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# GET /lyra/verification.json
|
|
271
|
+
def verification_data
|
|
272
|
+
if Lyra.petri_flow_available?
|
|
273
|
+
verifier = Lyra::Verification::CrudVerifier.new
|
|
274
|
+
render json: verifier.verify_all
|
|
275
|
+
else
|
|
276
|
+
render json: { error: "PetriFlow not available", available: false }, status: :service_unavailable
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# GET /lyra/dashboard/schema
|
|
281
|
+
def schema
|
|
282
|
+
@schema = Schema::Store.load_current || Schema::Generator.generate
|
|
283
|
+
@report = Schema::Reporter.new.generate
|
|
284
|
+
|
|
285
|
+
# Parse schema for view
|
|
286
|
+
@version = @schema[:version]
|
|
287
|
+
@fingerprint = @schema[:fingerprint]
|
|
288
|
+
@created_at = @schema[:created_at]
|
|
289
|
+
@lyra_version = @schema[:lyra_version]
|
|
290
|
+
@configuration = @schema[:configuration] || {}
|
|
291
|
+
@models = @schema[:models] || {}
|
|
292
|
+
@summary = @schema[:summary] || {}
|
|
293
|
+
|
|
294
|
+
# Load version history
|
|
295
|
+
@history = Schema::Store.history.reverse # Most recent first
|
|
296
|
+
|
|
297
|
+
# Check for uncommitted changes
|
|
298
|
+
if @version
|
|
299
|
+
current_schema = Schema::Generator.generate
|
|
300
|
+
@pending_changes = Schema::Diff.compare(@schema, current_schema)
|
|
301
|
+
@has_pending_changes = @pending_changes.any?
|
|
302
|
+
else
|
|
303
|
+
@pending_changes = []
|
|
304
|
+
@has_pending_changes = false
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
respond_to do |format|
|
|
308
|
+
format.html
|
|
309
|
+
format.json { render json: @schema }
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# GET /lyra/dashboard/schema/:version
|
|
314
|
+
def schema_version
|
|
315
|
+
version = params[:version].to_i
|
|
316
|
+
@schema = Schema::Store.load_version(version)
|
|
317
|
+
|
|
318
|
+
unless @schema
|
|
319
|
+
redirect_to schema_path, alert: "Schema version #{version} not found"
|
|
320
|
+
return
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Schema metadata
|
|
324
|
+
@version = @schema[:version]
|
|
325
|
+
@fingerprint = @schema[:fingerprint]
|
|
326
|
+
@created_at = @schema[:created_at]
|
|
327
|
+
@lyra_version = @schema[:lyra_version]
|
|
328
|
+
@rails_version = @schema[:rails_version]
|
|
329
|
+
@configuration = @schema[:configuration] || {}
|
|
330
|
+
@models = @schema[:models] || {}
|
|
331
|
+
@summary = @schema[:summary] || {}
|
|
332
|
+
|
|
333
|
+
# Load version history for navigation
|
|
334
|
+
@history = Schema::Store.history
|
|
335
|
+
@current_index = @history.find_index { |h| h[:version] == version }
|
|
336
|
+
@prev_version = @current_index && @current_index > 0 ? @history[@current_index - 1][:version] : nil
|
|
337
|
+
@next_version = @current_index && @current_index < @history.size - 1 ? @history[@current_index + 1][:version] : nil
|
|
338
|
+
|
|
339
|
+
# Compare with previous version if available
|
|
340
|
+
if @prev_version
|
|
341
|
+
prev_schema = Schema::Store.load_version(@prev_version)
|
|
342
|
+
@diff_from_previous = Schema::Diff.compare(prev_schema, @schema) if prev_schema
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
respond_to do |format|
|
|
346
|
+
format.html
|
|
347
|
+
format.json { render json: @schema }
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# GET /lyra/dashboard/schema/history
|
|
352
|
+
def schema_history
|
|
353
|
+
@history = Schema::Store.history.reverse # Most recent first
|
|
354
|
+
@current_version = Schema::Store.load_current&.dig(:version)
|
|
355
|
+
|
|
356
|
+
respond_to do |format|
|
|
357
|
+
format.html
|
|
358
|
+
format.json { render json: { history: @history, current_version: @current_version } }
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# GET /lyra/dashboard/visualizations/model_heatmap/:model_class
|
|
363
|
+
def model_heatmap
|
|
364
|
+
model_class = params[:model_class].constantize
|
|
365
|
+
events = fetch_events_for_model(model_class, days: params[:days]&.to_i || 7)
|
|
366
|
+
heatmap = Visualization::ActivityHeatmap.new(events)
|
|
367
|
+
|
|
368
|
+
render json: {
|
|
369
|
+
heatmap: heatmap.to_data,
|
|
370
|
+
model_specific: heatmap.model_heatmap,
|
|
371
|
+
operation_breakdown: heatmap.operation_heatmap
|
|
372
|
+
}
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
private
|
|
376
|
+
|
|
377
|
+
# Ensure models are loaded so Lyra.config.monitored_models is populated
|
|
378
|
+
# In development mode, Rails uses lazy loading so models with monitor_with_lyra
|
|
379
|
+
# may not be loaded yet when the dashboard is accessed
|
|
380
|
+
def ensure_models_loaded
|
|
381
|
+
return if Rails.application.config.eager_load
|
|
382
|
+
|
|
383
|
+
# Only eager load the models directory to avoid loading the entire app
|
|
384
|
+
models_path = Rails.root.join("app", "models")
|
|
385
|
+
if models_path.exist?
|
|
386
|
+
Dir[models_path.join("**", "*.rb")].each do |file|
|
|
387
|
+
require_dependency file
|
|
388
|
+
rescue StandardError
|
|
389
|
+
# Skip files that can't be loaded
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def count_events_for_model(model_class)
|
|
395
|
+
collect_events_for_model(model_class).count
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def collect_events_for_model(model_class)
|
|
399
|
+
# Collect all events for a model class
|
|
400
|
+
events = []
|
|
401
|
+
model_class.pluck(:id).each do |id|
|
|
402
|
+
stream_name = "#{model_class.name}$#{id}"
|
|
403
|
+
begin
|
|
404
|
+
events.concat(Lyra.config.event_store.read.stream(stream_name).to_a)
|
|
405
|
+
rescue
|
|
406
|
+
# Skip if stream doesn't exist
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
events
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def fetch_available_records(model_class, search_query = nil)
|
|
413
|
+
# Determine display fields for this model
|
|
414
|
+
display_fields = [:name, :title, :email, :firstname, :lastname, :description, :label].select do |field|
|
|
415
|
+
model_class.column_names.include?(field.to_s)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
scope = model_class.order(updated_at: :desc)
|
|
419
|
+
|
|
420
|
+
# Apply search if provided
|
|
421
|
+
if search_query.present? && display_fields.any?
|
|
422
|
+
conditions = display_fields.map { |f| "#{f} LIKE ?" }.join(' OR ')
|
|
423
|
+
search_term = "%#{search_query}%"
|
|
424
|
+
scope = scope.where(conditions, *([search_term] * display_fields.size))
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Limit results
|
|
428
|
+
records = scope.limit(50)
|
|
429
|
+
|
|
430
|
+
# Build display info for each record
|
|
431
|
+
records.map do |record|
|
|
432
|
+
display_name = display_fields.map { |f| record.send(f) }.compact.first
|
|
433
|
+
display_name ||= "Record ##{record.id}"
|
|
434
|
+
|
|
435
|
+
# Add more context if available
|
|
436
|
+
if model_class.column_names.include?('firstname') && model_class.column_names.include?('lastname')
|
|
437
|
+
full_name = [record.firstname, record.lastname].compact.join(' ')
|
|
438
|
+
display_name = full_name if full_name.present?
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
{
|
|
442
|
+
id: record.id,
|
|
443
|
+
display_name: display_name,
|
|
444
|
+
updated_at: record.updated_at,
|
|
445
|
+
extra_info: build_extra_info(record, display_fields)
|
|
446
|
+
}
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def build_extra_info(record, display_fields)
|
|
451
|
+
info = []
|
|
452
|
+
info << record.email if record.respond_to?(:email) && record.email.present? && !display_fields.include?(:email)
|
|
453
|
+
info << record.phone if record.respond_to?(:phone) && record.phone.present?
|
|
454
|
+
info << "Amount: #{record.amount}" if record.respond_to?(:amount) && record.amount.present?
|
|
455
|
+
info << record.status.to_s.titleize if record.respond_to?(:status) && record.status.present?
|
|
456
|
+
info.first(2).join(' | ')
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def fetch_recent_events(limit: 100)
|
|
460
|
+
events = []
|
|
461
|
+
Lyra.config.monitored_models.each do |model_class|
|
|
462
|
+
model_class.order(updated_at: :desc).limit(limit / Lyra.config.monitored_models.size).each do |record|
|
|
463
|
+
stream_name = "#{model_class.name}$#{record.id}"
|
|
464
|
+
begin
|
|
465
|
+
stream_events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
466
|
+
events.concat(stream_events)
|
|
467
|
+
rescue
|
|
468
|
+
# Skip if stream doesn't exist
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
events.sort_by { |e| event_timestamp(e) }.last(limit)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def fetch_filtered_events(model_class: nil, operation: nil, limit: 50)
|
|
476
|
+
events = []
|
|
477
|
+
models = model_class.present? ? [model_class.constantize] : Lyra.config.monitored_models
|
|
478
|
+
|
|
479
|
+
models.each do |klass|
|
|
480
|
+
klass.order(updated_at: :desc).limit(limit).each do |record|
|
|
481
|
+
stream_name = "#{klass.name}$#{record.id}"
|
|
482
|
+
begin
|
|
483
|
+
stream_events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
484
|
+
events.concat(stream_events)
|
|
485
|
+
rescue
|
|
486
|
+
# Skip if stream doesn't exist
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Filter by operation if specified
|
|
492
|
+
if operation.present?
|
|
493
|
+
events = events.select do |e|
|
|
494
|
+
op = e.data[:operation] || e.data["operation"]
|
|
495
|
+
op.to_s == operation.to_s
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
events.sort_by { |e| event_timestamp(e) }.last(limit)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def fetch_entity_events(model_class, model_id, depth: 1)
|
|
503
|
+
events = []
|
|
504
|
+
stream_name = "#{model_class}$#{model_id}"
|
|
505
|
+
|
|
506
|
+
begin
|
|
507
|
+
# Get all events for the primary entity
|
|
508
|
+
primary_events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
509
|
+
events.concat(primary_events)
|
|
510
|
+
|
|
511
|
+
# If depth > 0, also include related entities (via correlation_id)
|
|
512
|
+
if depth > 0 && primary_events.any?
|
|
513
|
+
correlation_ids = primary_events.map { |e| e.metadata[:correlation_id] }.compact.uniq
|
|
514
|
+
|
|
515
|
+
# Find events sharing correlation IDs
|
|
516
|
+
correlation_ids.each do |corr_id|
|
|
517
|
+
Lyra.config.monitored_models.each do |klass|
|
|
518
|
+
klass.order(updated_at: :desc).limit(20).each do |record|
|
|
519
|
+
other_stream = "#{klass.name}$#{record.id}"
|
|
520
|
+
next if other_stream == stream_name
|
|
521
|
+
|
|
522
|
+
begin
|
|
523
|
+
other_events = Lyra.config.event_store.read.stream(other_stream).to_a
|
|
524
|
+
related = other_events.select { |e| e.metadata[:correlation_id] == corr_id }
|
|
525
|
+
events.concat(related)
|
|
526
|
+
rescue
|
|
527
|
+
# Skip
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
rescue
|
|
534
|
+
# Stream doesn't exist
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
events.uniq { |e| e.event_id }.sort_by { |e| event_timestamp(e) }
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def record_display_name(record)
|
|
541
|
+
# Try common display name fields
|
|
542
|
+
[:name, :title, :email, :display_name].each do |field|
|
|
543
|
+
return record.send(field) if record.respond_to?(field) && record.send(field).present?
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Try firstname + lastname
|
|
547
|
+
if record.respond_to?(:firstname) && record.respond_to?(:lastname)
|
|
548
|
+
full_name = [record.firstname, record.lastname].compact.join(' ')
|
|
549
|
+
return full_name if full_name.present?
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Fallback
|
|
553
|
+
"#{record.class.name}##{record.id}"
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def fetch_events_for_period(days: 7)
|
|
557
|
+
cutoff = days.days.ago
|
|
558
|
+
events = []
|
|
559
|
+
|
|
560
|
+
Lyra.config.monitored_models.each do |model_class|
|
|
561
|
+
model_class.where("updated_at >= ?", cutoff).find_each do |record|
|
|
562
|
+
stream_name = "#{model_class.name}$#{record.id}"
|
|
563
|
+
begin
|
|
564
|
+
stream_events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
565
|
+
events.concat(stream_events.select { |e| event_timestamp(e) >= cutoff })
|
|
566
|
+
rescue
|
|
567
|
+
# Skip if stream doesn't exist
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
events.sort_by { |e| event_timestamp(e) }
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def fetch_events_for_model(model_class, days: 7)
|
|
576
|
+
cutoff = days.days.ago
|
|
577
|
+
events = []
|
|
578
|
+
|
|
579
|
+
model_class.where("updated_at >= ?", cutoff).find_each do |record|
|
|
580
|
+
stream_name = "#{model_class.name}$#{record.id}"
|
|
581
|
+
begin
|
|
582
|
+
stream_events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
583
|
+
events.concat(stream_events.select { |e| event_timestamp(e) >= cutoff })
|
|
584
|
+
rescue
|
|
585
|
+
# Skip if stream doesn't exist
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
events.sort_by { |e| event_timestamp(e) }
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def event_timestamp(event)
|
|
593
|
+
return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
|
|
594
|
+
event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp]
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Look up user display name from user_id
|
|
598
|
+
# Tries to find a User model with name/display_name method
|
|
599
|
+
# Falls back to "User #ID" if lookup fails
|
|
600
|
+
def user_display_name(user_id)
|
|
601
|
+
return nil unless user_id
|
|
602
|
+
|
|
603
|
+
begin
|
|
604
|
+
# Try to find User model in the host app
|
|
605
|
+
if defined?(::User) && ::User.respond_to?(:find_by)
|
|
606
|
+
user = ::User.find_by(id: user_id)
|
|
607
|
+
if user
|
|
608
|
+
# Prefer display_name, then name, then username
|
|
609
|
+
return user.display_name if user.respond_to?(:display_name)
|
|
610
|
+
return user.name if user.respond_to?(:name)
|
|
611
|
+
return user.username if user.respond_to?(:username)
|
|
612
|
+
return user.email if user.respond_to?(:email)
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
rescue => e
|
|
616
|
+
# Log but don't fail if user lookup has issues
|
|
617
|
+
Rails.logger.debug { "Lyra: Could not look up user ##{user_id}: #{e.message}" }
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Fallback to showing the ID
|
|
621
|
+
"User ##{user_id}"
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
end
|