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,215 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
module Visualization
|
|
3
|
+
# Activity heatmap visualization for D3.js
|
|
4
|
+
# Shows event activity patterns across time (hour of day vs day of week)
|
|
5
|
+
class ActivityHeatmap
|
|
6
|
+
DAYS_OF_WEEK = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
|
|
7
|
+
HOURS_OF_DAY = (0..23).to_a.freeze
|
|
8
|
+
|
|
9
|
+
def initialize(events)
|
|
10
|
+
@events = events.sort_by { |e| event_timestamp(e) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Generate heatmap data for D3.js
|
|
14
|
+
def to_d3_json
|
|
15
|
+
{
|
|
16
|
+
data: build_heatmap_data,
|
|
17
|
+
x_labels: hour_labels,
|
|
18
|
+
y_labels: DAYS_OF_WEEK,
|
|
19
|
+
metadata: heatmap_metadata
|
|
20
|
+
}.to_json
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Generate data hash (for API responses)
|
|
24
|
+
def to_data
|
|
25
|
+
{
|
|
26
|
+
data: build_heatmap_data,
|
|
27
|
+
x_labels: hour_labels,
|
|
28
|
+
y_labels: DAYS_OF_WEEK,
|
|
29
|
+
metadata: heatmap_metadata
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Generate hourly breakdown for a specific model
|
|
34
|
+
def hourly_breakdown(model_class = nil)
|
|
35
|
+
filtered = model_class ? @events.select { |e| event_model_class(e) == model_class } : @events
|
|
36
|
+
|
|
37
|
+
HOURS_OF_DAY.map do |hour|
|
|
38
|
+
count = filtered.count { |e| event_timestamp(e).hour == hour }
|
|
39
|
+
{
|
|
40
|
+
hour: hour,
|
|
41
|
+
label: format_hour(hour),
|
|
42
|
+
count: count,
|
|
43
|
+
percentage: filtered.empty? ? 0 : (count.to_f / filtered.size * 100).round(2)
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Generate daily breakdown
|
|
49
|
+
def daily_breakdown(model_class = nil)
|
|
50
|
+
filtered = model_class ? @events.select { |e| event_model_class(e) == model_class } : @events
|
|
51
|
+
|
|
52
|
+
DAYS_OF_WEEK.each_with_index.map do |day, i|
|
|
53
|
+
count = filtered.count { |e| event_timestamp(e).wday == i }
|
|
54
|
+
{
|
|
55
|
+
day: day,
|
|
56
|
+
day_index: i,
|
|
57
|
+
count: count,
|
|
58
|
+
percentage: filtered.empty? ? 0 : (count.to_f / filtered.size * 100).round(2)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Generate operation-based heatmap
|
|
64
|
+
def operation_heatmap
|
|
65
|
+
operations = @events.map { |e| event_operation(e) }.uniq
|
|
66
|
+
|
|
67
|
+
operations.map do |operation|
|
|
68
|
+
op_events = @events.select { |e| event_operation(e) == operation }
|
|
69
|
+
{
|
|
70
|
+
operation: operation.to_s,
|
|
71
|
+
hourly: HOURS_OF_DAY.map { |h| op_events.count { |e| event_timestamp(e).hour == h } },
|
|
72
|
+
total: op_events.count
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Generate model-based heatmap
|
|
78
|
+
def model_heatmap
|
|
79
|
+
model_classes = @events.map { |e| event_model_class(e) }.uniq
|
|
80
|
+
|
|
81
|
+
model_classes.map do |model_class|
|
|
82
|
+
model_events = @events.select { |e| event_model_class(e) == model_class }
|
|
83
|
+
{
|
|
84
|
+
model_class: model_class,
|
|
85
|
+
hourly: HOURS_OF_DAY.map { |h| model_events.count { |e| event_timestamp(e).hour == h } },
|
|
86
|
+
daily: DAYS_OF_WEEK.each_with_index.map { |_d, i| model_events.count { |e| event_timestamp(e).wday == i } },
|
|
87
|
+
total: model_events.count
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def build_heatmap_data
|
|
95
|
+
# Create a 7x24 matrix (days x hours)
|
|
96
|
+
matrix = Array.new(7) { Array.new(24, 0) }
|
|
97
|
+
|
|
98
|
+
@events.each do |event|
|
|
99
|
+
ts = event_timestamp(event)
|
|
100
|
+
day = ts.wday
|
|
101
|
+
hour = ts.hour
|
|
102
|
+
matrix[day][hour] += 1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Convert to D3.js-friendly format
|
|
106
|
+
data = []
|
|
107
|
+
matrix.each_with_index do |day_data, day_index|
|
|
108
|
+
day_data.each_with_index do |count, hour|
|
|
109
|
+
data << {
|
|
110
|
+
day: day_index,
|
|
111
|
+
day_name: DAYS_OF_WEEK[day_index],
|
|
112
|
+
hour: hour,
|
|
113
|
+
hour_label: format_hour(hour),
|
|
114
|
+
count: count,
|
|
115
|
+
intensity: calculate_intensity(count)
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
data
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def hour_labels
|
|
124
|
+
HOURS_OF_DAY.map { |h| format_hour(h) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def format_hour(hour)
|
|
128
|
+
if hour == 0
|
|
129
|
+
"12 AM"
|
|
130
|
+
elsif hour < 12
|
|
131
|
+
"#{hour} AM"
|
|
132
|
+
elsif hour == 12
|
|
133
|
+
"12 PM"
|
|
134
|
+
else
|
|
135
|
+
"#{hour - 12} PM"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def calculate_intensity(count)
|
|
140
|
+
return 0 if @events.empty?
|
|
141
|
+
|
|
142
|
+
max_count = @events.group_by { |e| ts = event_timestamp(e); [ts.wday, ts.hour] }
|
|
143
|
+
.values.map(&:count).max || 1
|
|
144
|
+
|
|
145
|
+
(count.to_f / max_count * 100).round(2)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def heatmap_metadata
|
|
149
|
+
{
|
|
150
|
+
total_events: @events.count,
|
|
151
|
+
time_range: {
|
|
152
|
+
start: @events.first ? event_timestamp(@events.first) : nil,
|
|
153
|
+
end: @events.last ? event_timestamp(@events.last) : nil
|
|
154
|
+
},
|
|
155
|
+
peak_hour: find_peak_hour,
|
|
156
|
+
peak_day: find_peak_day,
|
|
157
|
+
busiest_slot: find_busiest_slot,
|
|
158
|
+
operations_breakdown: @events.group_by { |e| event_operation(e) }.transform_values(&:count),
|
|
159
|
+
models_breakdown: @events.group_by { |e| event_model_class(e) }.transform_values(&:count)
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def find_peak_hour
|
|
164
|
+
return nil if @events.empty?
|
|
165
|
+
|
|
166
|
+
hourly_counts = @events.group_by { |e| event_timestamp(e).hour }
|
|
167
|
+
.transform_values(&:count)
|
|
168
|
+
peak = hourly_counts.max_by { |_h, c| c }
|
|
169
|
+
{ hour: peak[0], label: format_hour(peak[0]), count: peak[1] }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def find_peak_day
|
|
173
|
+
return nil if @events.empty?
|
|
174
|
+
|
|
175
|
+
daily_counts = @events.group_by { |e| event_timestamp(e).wday }
|
|
176
|
+
.transform_values(&:count)
|
|
177
|
+
peak = daily_counts.max_by { |_d, c| c }
|
|
178
|
+
{ day: peak[0], label: DAYS_OF_WEEK[peak[0]], count: peak[1] }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def find_busiest_slot
|
|
182
|
+
return nil if @events.empty?
|
|
183
|
+
|
|
184
|
+
slots = @events.group_by { |e| ts = event_timestamp(e); [ts.wday, ts.hour] }
|
|
185
|
+
.transform_values(&:count)
|
|
186
|
+
peak = slots.max_by { |_slot, c| c }
|
|
187
|
+
day, hour = peak[0]
|
|
188
|
+
{
|
|
189
|
+
day: day,
|
|
190
|
+
day_label: DAYS_OF_WEEK[day],
|
|
191
|
+
hour: hour,
|
|
192
|
+
hour_label: format_hour(hour),
|
|
193
|
+
count: peak[1]
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Helper methods to handle both Lyra::Event and RubyEventStore::Event
|
|
198
|
+
def event_model_class(event)
|
|
199
|
+
return event.model_class if event.respond_to?(:model_class)
|
|
200
|
+
event.data[:model_class] || event.data["model_class"]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def event_operation(event)
|
|
204
|
+
return event.operation if event.respond_to?(:operation)
|
|
205
|
+
op = event.data[:operation] || event.data["operation"]
|
|
206
|
+
op.is_a?(String) ? op.to_sym : op
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def event_timestamp(event)
|
|
210
|
+
return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
|
|
211
|
+
event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp] || Time.now
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
module Visualization
|
|
3
|
+
# Force-directed event graph visualization for D3.js
|
|
4
|
+
class EventGraph
|
|
5
|
+
def initialize(events)
|
|
6
|
+
@events = events.sort_by { |e| event_timestamp(e) }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Generate graph data for D3.js force-directed layout
|
|
10
|
+
def to_d3_json
|
|
11
|
+
{
|
|
12
|
+
nodes: build_nodes,
|
|
13
|
+
links: build_links,
|
|
14
|
+
metadata: graph_metadata
|
|
15
|
+
}.to_json
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Generate data hash (for API responses)
|
|
19
|
+
def to_data
|
|
20
|
+
{
|
|
21
|
+
nodes: build_nodes,
|
|
22
|
+
links: build_links,
|
|
23
|
+
metadata: graph_metadata
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Generate Mermaid flowchart
|
|
28
|
+
def to_mermaid
|
|
29
|
+
lines = ["flowchart TD"]
|
|
30
|
+
|
|
31
|
+
@events.each do |event|
|
|
32
|
+
node_id = sanitize_id(event_id(event))
|
|
33
|
+
operation = event_operation(event).to_s.upcase
|
|
34
|
+
model_info = "#{event_model_class(event)}##{event_model_id(event)}"
|
|
35
|
+
changed_fields = format_changed_fields(event)
|
|
36
|
+
|
|
37
|
+
label = if changed_fields.present?
|
|
38
|
+
"#{operation}\\n#{model_info}\\n#{changed_fields}"
|
|
39
|
+
else
|
|
40
|
+
"#{operation}\\n#{model_info}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
lines << " #{node_id}[\"#{label}\"]"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
lines << ""
|
|
47
|
+
|
|
48
|
+
build_links.each do |link|
|
|
49
|
+
source_id = sanitize_id(link[:source])
|
|
50
|
+
target_id = sanitize_id(link[:target])
|
|
51
|
+
link_style = link[:type] == 'correlation' ? '-->' : '-.->'
|
|
52
|
+
lines << " #{source_id} #{link_style} #{target_id}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
lines.join("\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def build_nodes
|
|
61
|
+
model_groups = @events.map { |e| event_model_class(e) }.uniq
|
|
62
|
+
color_scale = generate_color_scale(model_groups)
|
|
63
|
+
|
|
64
|
+
@events.map.with_index do |event, i|
|
|
65
|
+
has_pii = detect_pii?(event)
|
|
66
|
+
model_class = event_model_class(event)
|
|
67
|
+
model_id = event_model_id(event)
|
|
68
|
+
timestamp = event_timestamp(event)
|
|
69
|
+
metadata = event_metadata(event)
|
|
70
|
+
changes = event_changes(event)
|
|
71
|
+
changed_fields = extract_changed_field_names(changes)
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
id: event_id(event),
|
|
75
|
+
index: i,
|
|
76
|
+
label: "#{model_class}##{model_id}",
|
|
77
|
+
operation: event_operation(event).to_s,
|
|
78
|
+
model_class: model_class,
|
|
79
|
+
model_id: model_id,
|
|
80
|
+
timestamp: timestamp.to_i * 1000, # JavaScript timestamp
|
|
81
|
+
timestamp_formatted: timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
|
82
|
+
has_pii: has_pii,
|
|
83
|
+
group: model_groups.index(model_class),
|
|
84
|
+
color: color_scale[model_class],
|
|
85
|
+
size: calculate_node_size(event),
|
|
86
|
+
user_id: metadata[:user_id],
|
|
87
|
+
correlation_id: metadata[:correlation_id],
|
|
88
|
+
changed_fields: changed_fields,
|
|
89
|
+
changes: format_changes_for_display(changes)
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_links
|
|
95
|
+
links = []
|
|
96
|
+
events_by_id = @events.index_by { |e| event_id(e) }
|
|
97
|
+
|
|
98
|
+
# Link events by correlation ID (same workflow/transaction)
|
|
99
|
+
@events.group_by { |e| event_metadata(e)[:correlation_id] }.each do |corr_id, group|
|
|
100
|
+
next if corr_id.nil? || group.size < 2
|
|
101
|
+
|
|
102
|
+
sorted = group.sort_by { |e| event_timestamp(e) }
|
|
103
|
+
sorted.each_cons(2) do |source, target|
|
|
104
|
+
links << {
|
|
105
|
+
source: event_id(source),
|
|
106
|
+
target: event_id(target),
|
|
107
|
+
type: 'correlation',
|
|
108
|
+
strength: 1.0,
|
|
109
|
+
label: 'correlated'
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Link events on same record (entity lifecycle)
|
|
115
|
+
@events.group_by { |e| "#{event_model_class(e)}-#{event_model_id(e)}" }.each do |_key, group|
|
|
116
|
+
next if group.size < 2
|
|
117
|
+
|
|
118
|
+
sorted = group.sort_by { |e| event_timestamp(e) }
|
|
119
|
+
sorted.each_cons(2) do |source, target|
|
|
120
|
+
links << {
|
|
121
|
+
source: event_id(source),
|
|
122
|
+
target: event_id(target),
|
|
123
|
+
type: 'same_record',
|
|
124
|
+
strength: 0.5,
|
|
125
|
+
label: 'same entity'
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Link events by same user (user activity pattern)
|
|
131
|
+
@events.group_by { |e| event_metadata(e)[:user_id] }.each do |user_id, group|
|
|
132
|
+
next if user_id.nil? || group.size < 2
|
|
133
|
+
|
|
134
|
+
sorted = group.sort_by { |e| event_timestamp(e) }
|
|
135
|
+
sorted.each_cons(2) do |source, target|
|
|
136
|
+
# Only link if within 5 minutes of each other
|
|
137
|
+
if (event_timestamp(target) - event_timestamp(source)) < 300
|
|
138
|
+
links << {
|
|
139
|
+
source: event_id(source),
|
|
140
|
+
target: event_id(target),
|
|
141
|
+
type: 'same_user',
|
|
142
|
+
strength: 0.3,
|
|
143
|
+
label: 'same user'
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
links.uniq { |l| [l[:source], l[:target]] }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def graph_metadata
|
|
153
|
+
{
|
|
154
|
+
total_nodes: @events.count,
|
|
155
|
+
total_links: build_links.count,
|
|
156
|
+
model_classes: @events.map { |e| event_model_class(e) }.uniq,
|
|
157
|
+
time_range: {
|
|
158
|
+
start: @events.first ? event_timestamp(@events.first) : nil,
|
|
159
|
+
end: @events.last ? event_timestamp(@events.last) : nil
|
|
160
|
+
},
|
|
161
|
+
operations: @events.group_by { |e| event_operation(e) }.transform_values(&:count),
|
|
162
|
+
pii_count: @events.count { |e| detect_pii?(e) }
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def detect_pii?(event)
|
|
167
|
+
return false unless Lyra.privacy_features_available?
|
|
168
|
+
|
|
169
|
+
Lyra::Privacy::PIIDetector.detect(event_attributes(event)).any?
|
|
170
|
+
rescue
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def calculate_node_size(event)
|
|
175
|
+
base_size = 10
|
|
176
|
+
# Larger nodes for events with more changes
|
|
177
|
+
changes = event_changes(event)
|
|
178
|
+
change_bonus = (changes&.size || 0) * 2
|
|
179
|
+
# Larger nodes for PII-containing events
|
|
180
|
+
pii_bonus = detect_pii?(event) ? 5 : 0
|
|
181
|
+
|
|
182
|
+
[base_size + change_bonus + pii_bonus, 30].min
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def generate_color_scale(model_classes)
|
|
186
|
+
colors = %w[
|
|
187
|
+
#667eea #764ba2 #f59e0b #10b981 #ef4444
|
|
188
|
+
#8b5cf6 #06b6d4 #ec4899 #84cc16 #f97316
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
model_classes.each_with_index.to_h do |model_class, i|
|
|
192
|
+
[model_class, colors[i % colors.length]]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def sanitize_id(id)
|
|
197
|
+
id.to_s.gsub(/[^a-zA-Z0-9]/, '_')
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Helper methods to handle both Lyra::Event and RubyEventStore::Event
|
|
201
|
+
def event_id(event)
|
|
202
|
+
return event.event_id if event.respond_to?(:event_id)
|
|
203
|
+
event.data[:event_id] || event.data["event_id"]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def event_model_class(event)
|
|
207
|
+
return event.model_class if event.respond_to?(:model_class)
|
|
208
|
+
event.data[:model_class] || event.data["model_class"]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def event_model_id(event)
|
|
212
|
+
return event.model_id if event.respond_to?(:model_id)
|
|
213
|
+
event.data[:model_id] || event.data["model_id"]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def event_operation(event)
|
|
217
|
+
return event.operation if event.respond_to?(:operation)
|
|
218
|
+
op = event.data[:operation] || event.data["operation"]
|
|
219
|
+
op.is_a?(String) ? op.to_sym : op
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def event_timestamp(event)
|
|
223
|
+
return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
|
|
224
|
+
event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp] || Time.now
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def event_attributes(event)
|
|
228
|
+
return event.attributes if event.respond_to?(:attributes) && !event.attributes.is_a?(Hash)
|
|
229
|
+
event.data[:attributes] || event.data["attributes"] || {}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def event_changes(event)
|
|
233
|
+
return event.changes if event.respond_to?(:changes) && event.method(:changes).owner != ActiveRecord::AttributeMethods::Dirty
|
|
234
|
+
event.data[:changes] || event.data["changes"] || {}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def event_metadata(event)
|
|
238
|
+
return event.metadata if event.respond_to?(:metadata)
|
|
239
|
+
{}
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Extract just the field names that changed
|
|
243
|
+
def extract_changed_field_names(changes)
|
|
244
|
+
return [] if changes.blank?
|
|
245
|
+
|
|
246
|
+
changes.keys.map(&:to_s).reject { |k| k.end_with?("_at") }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Format changes for display in node details (with before/after values)
|
|
250
|
+
def format_changes_for_display(changes)
|
|
251
|
+
return [] if changes.blank?
|
|
252
|
+
|
|
253
|
+
changes.map do |field, values|
|
|
254
|
+
field_name = field.to_s
|
|
255
|
+
# Skip timestamp fields for cleaner display
|
|
256
|
+
next if field_name.end_with?("_at")
|
|
257
|
+
|
|
258
|
+
result = if values.is_a?(Array) && values.size == 2
|
|
259
|
+
{ field: field_name, from: truncate_value(values[0]), to: truncate_value(values[1]) }
|
|
260
|
+
else
|
|
261
|
+
{ field: field_name, value: truncate_value(values) }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Add PII type if field contains PII
|
|
265
|
+
pii_type = detect_field_pii_type(field_name)
|
|
266
|
+
result[:pii_type] = pii_type if pii_type
|
|
267
|
+
|
|
268
|
+
result
|
|
269
|
+
end.compact
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Detect PII type for a specific field
|
|
273
|
+
def detect_field_pii_type(field_name)
|
|
274
|
+
return nil unless Lyra.privacy_features_available?
|
|
275
|
+
|
|
276
|
+
# Check if field name matches known PII patterns
|
|
277
|
+
pii_info = Lyra::Privacy::PIIDetector.detect({ field_name.to_sym => "sample" })
|
|
278
|
+
return nil if pii_info.empty?
|
|
279
|
+
|
|
280
|
+
pii_info.values.first[:type]&.to_s
|
|
281
|
+
rescue
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Format changed fields for Mermaid label (compact)
|
|
286
|
+
def format_changed_fields(event)
|
|
287
|
+
changes = event_changes(event)
|
|
288
|
+
return nil if changes.blank?
|
|
289
|
+
|
|
290
|
+
fields = changes.keys.map(&:to_s).reject { |k| k.end_with?("_at") }
|
|
291
|
+
return nil if fields.empty?
|
|
292
|
+
|
|
293
|
+
# Limit to 3 fields to keep labels readable
|
|
294
|
+
if fields.size > 3
|
|
295
|
+
"📝 #{fields.first(3).join(', ')}..."
|
|
296
|
+
else
|
|
297
|
+
"📝 #{fields.join(', ')}"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Truncate long values for display
|
|
302
|
+
def truncate_value(value)
|
|
303
|
+
return nil if value.nil?
|
|
304
|
+
|
|
305
|
+
str = value.to_s
|
|
306
|
+
str.length > 30 ? "#{str[0..27]}..." : str
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|