blast_radius 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ /* BlastRadius - Heatmap ER Diagram Styles */
2
+
3
+ :root {
4
+ /* Light mode colors */
5
+ --bg-primary: #ffffff;
6
+ --bg-secondary: #f9fafb;
7
+ --bg-sidebar: #f3f4f6;
8
+ --text-primary: #111827;
9
+ --text-secondary: #6b7280;
10
+ --border-color: #e5e7eb;
11
+
12
+ /* Heatmap colors */
13
+ --color-selected: #b91c1c;
14
+ --color-depth-1: #dc2626;
15
+ --color-depth-2: #ea580c;
16
+ --color-depth-3: #ca8a04;
17
+ --color-depth-4: #fde047;
18
+ --color-unaffected: #d1d5db;
19
+ --color-nullify: #2563eb;
20
+ --color-restrict: #4b5563;
21
+
22
+ /* Edge colors */
23
+ --edge-normal: #9ca3af;
24
+ --edge-destroy: #dc2626;
25
+ --edge-delete-all: #ea580c;
26
+ --edge-destroy-async: #9333ea;
27
+ }
28
+
29
+ @media (prefers-color-scheme: dark) {
30
+ :root {
31
+ --bg-primary: #111827;
32
+ --bg-secondary: #1f2937;
33
+ --bg-sidebar: #0f172a;
34
+ --text-primary: #f9fafb;
35
+ --text-secondary: #9ca3af;
36
+ --border-color: #374151;
37
+ --color-unaffected: #6b7280;
38
+ --edge-normal: #6b7280;
39
+ }
40
+
41
+ .node rect {
42
+ fill: #475569;
43
+ stroke: #334155;
44
+ }
45
+
46
+ .node.selected rect {
47
+ fill: #60a5fa;
48
+ stroke: #3b82f6;
49
+ filter: drop-shadow(0 0 12px rgba(96, 165, 250, 0.7));
50
+ }
51
+
52
+ .node.unaffected rect {
53
+ fill: #64748b;
54
+ stroke: #475569;
55
+ }
56
+ }
57
+
58
+ * {
59
+ margin: 0;
60
+ padding: 0;
61
+ box-sizing: border-box;
62
+ }
63
+
64
+ body {
65
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
66
+ background-color: var(--bg-primary);
67
+ color: var(--text-primary);
68
+ overflow: hidden;
69
+ }
70
+
71
+ .container {
72
+ display: flex;
73
+ height: 100vh;
74
+ }
75
+
76
+ /* Sidebar */
77
+ .sidebar {
78
+ width: 320px;
79
+ background-color: var(--bg-sidebar);
80
+ border-right: 1px solid var(--border-color);
81
+ overflow-y: auto;
82
+ padding: 20px;
83
+ }
84
+
85
+ .sidebar h2 {
86
+ font-size: 1.25rem;
87
+ font-weight: 600;
88
+ margin-bottom: 16px;
89
+ }
90
+
91
+ .sidebar-section {
92
+ margin-bottom: 24px;
93
+ padding-bottom: 24px;
94
+ border-bottom: 1px solid var(--border-color);
95
+ }
96
+
97
+ .sidebar-section:last-child {
98
+ border-bottom: none;
99
+ }
100
+
101
+ .stat-item {
102
+ display: flex;
103
+ justify-content: space-between;
104
+ padding: 8px 0;
105
+ font-size: 0.875rem;
106
+ }
107
+
108
+ .stat-label {
109
+ color: var(--text-secondary);
110
+ }
111
+
112
+ .stat-value {
113
+ font-weight: 600;
114
+ }
115
+
116
+ .dependent-badge {
117
+ display: inline-block;
118
+ padding: 2px 8px;
119
+ border-radius: 4px;
120
+ font-size: 0.75rem;
121
+ font-weight: 600;
122
+ margin-right: 4px;
123
+ color: white;
124
+ }
125
+
126
+ .badge-destroy {
127
+ background-color: var(--color-depth-1);
128
+ }
129
+
130
+ .badge-delete-all {
131
+ background-color: var(--color-depth-2);
132
+ }
133
+
134
+ .badge-destroy-async {
135
+ background-color: #a855f7;
136
+ }
137
+
138
+ .badge-nullify {
139
+ background-color: var(--color-nullify);
140
+ }
141
+
142
+ .badge-restrict {
143
+ background-color: var(--color-restrict);
144
+ }
145
+
146
+ .impact-list {
147
+ list-style: none;
148
+ }
149
+
150
+ .impact-list li {
151
+ padding: 6px 12px;
152
+ margin: 4px 0;
153
+ background-color: var(--bg-secondary);
154
+ border-radius: 4px;
155
+ font-size: 0.875rem;
156
+ cursor: pointer;
157
+ transition: background-color 0.2s;
158
+ }
159
+
160
+ .impact-list li:hover {
161
+ background-color: var(--border-color);
162
+ }
163
+
164
+ /* Main graph area */
165
+ .graph-container {
166
+ flex: 1;
167
+ position: relative;
168
+ overflow: hidden;
169
+ }
170
+
171
+ .graph-header {
172
+ position: absolute;
173
+ top: 0;
174
+ left: 0;
175
+ right: 0;
176
+ background-color: var(--bg-secondary);
177
+ border-bottom: 1px solid var(--border-color);
178
+ padding: 12px 20px;
179
+ display: flex;
180
+ justify-content: space-between;
181
+ align-items: center;
182
+ z-index: 10;
183
+ }
184
+
185
+ .graph-title {
186
+ font-size: 1.125rem;
187
+ font-weight: 600;
188
+ }
189
+
190
+ .graph-controls {
191
+ display: flex;
192
+ gap: 8px;
193
+ }
194
+
195
+ .btn {
196
+ padding: 6px 12px;
197
+ border: 1px solid var(--border-color);
198
+ background-color: var(--bg-primary);
199
+ color: var(--text-primary);
200
+ border-radius: 4px;
201
+ cursor: pointer;
202
+ font-size: 0.875rem;
203
+ transition: background-color 0.2s;
204
+ }
205
+
206
+ .btn:hover {
207
+ background-color: var(--bg-secondary);
208
+ }
209
+
210
+ .btn-primary {
211
+ background-color: #3b82f6;
212
+ color: white;
213
+ border-color: #3b82f6;
214
+ }
215
+
216
+ .btn-primary:hover {
217
+ background-color: #2563eb;
218
+ }
219
+
220
+ .btn.active {
221
+ background-color: #3b82f6;
222
+ color: white;
223
+ border-color: #3b82f6;
224
+ }
225
+
226
+ .btn-group {
227
+ display: flex;
228
+ gap: 0;
229
+ }
230
+
231
+ .btn-group .btn {
232
+ border-radius: 0;
233
+ }
234
+
235
+ .btn-group .btn:first-child {
236
+ border-top-left-radius: 4px;
237
+ border-bottom-left-radius: 4px;
238
+ }
239
+
240
+ .btn-group .btn:last-child {
241
+ border-top-right-radius: 4px;
242
+ border-bottom-right-radius: 4px;
243
+ }
244
+
245
+ .dropdown {
246
+ position: relative;
247
+ display: inline-block;
248
+ }
249
+
250
+ .dropdown-menu {
251
+ display: none;
252
+ position: absolute;
253
+ top: 100%;
254
+ right: 0;
255
+ margin-top: 4px;
256
+ background-color: var(--bg-primary);
257
+ border: 1px solid var(--border-color);
258
+ border-radius: 4px;
259
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
260
+ z-index: 1000;
261
+ min-width: 150px;
262
+ }
263
+
264
+ .dropdown-menu.show {
265
+ display: block;
266
+ }
267
+
268
+ .dropdown-item {
269
+ display: block;
270
+ width: 100%;
271
+ padding: 8px 12px;
272
+ border: none;
273
+ background: none;
274
+ color: var(--text-primary);
275
+ text-align: left;
276
+ cursor: pointer;
277
+ font-size: 0.875rem;
278
+ transition: background-color 0.2s;
279
+ }
280
+
281
+ .dropdown-item:hover {
282
+ background-color: var(--bg-secondary);
283
+ }
284
+
285
+ .dropdown-item:first-child {
286
+ border-top-left-radius: 4px;
287
+ border-top-right-radius: 4px;
288
+ }
289
+
290
+ .dropdown-item:last-child {
291
+ border-bottom-left-radius: 4px;
292
+ border-bottom-right-radius: 4px;
293
+ }
294
+
295
+ #graph-svg {
296
+ width: 100%;
297
+ height: 100%;
298
+ margin-top: 53px;
299
+ }
300
+
301
+ /* Graph elements */
302
+ .node {
303
+ cursor: pointer;
304
+ transition: all 0.3s;
305
+ }
306
+
307
+ .node rect {
308
+ fill: #64748b;
309
+ stroke: #475569;
310
+ stroke-width: 2.5;
311
+ rx: 6;
312
+ }
313
+
314
+ .node.selected rect {
315
+ fill: #3b82f6;
316
+ stroke: #1d4ed8;
317
+ stroke-width: 5;
318
+ filter: drop-shadow(0 0 8px rgba(59, 130, 246, 0.6));
319
+ }
320
+
321
+ .node.depth-1 rect {
322
+ fill: var(--color-depth-1);
323
+ stroke: var(--color-depth-1);
324
+ }
325
+
326
+ .node.depth-2 rect {
327
+ fill: var(--color-depth-2);
328
+ stroke: var(--color-depth-2);
329
+ }
330
+
331
+ .node.depth-3 rect {
332
+ fill: var(--color-depth-3);
333
+ stroke: var(--color-depth-3);
334
+ }
335
+
336
+ .node.depth-4 rect {
337
+ fill: var(--color-depth-4);
338
+ stroke: #ca8a04;
339
+ }
340
+
341
+ .node.depth-4 text {
342
+ fill: #78350f;
343
+ }
344
+
345
+ .node.unaffected rect {
346
+ fill: #94a3b8;
347
+ stroke: #64748b;
348
+ }
349
+
350
+ .node text {
351
+ fill: white;
352
+ font-size: 16px;
353
+ font-weight: 600;
354
+ pointer-events: none;
355
+ user-select: none;
356
+ }
357
+
358
+ .node.depth-1 text,
359
+ .node.depth-2 text,
360
+ .node.depth-3 text,
361
+ .node.selected text {
362
+ fill: white;
363
+ }
364
+
365
+ .edge {
366
+ fill: none;
367
+ stroke: var(--edge-normal);
368
+ stroke-width: 2.5;
369
+ transition: all 0.3s;
370
+ }
371
+
372
+ .edge.active {
373
+ stroke: var(--color-depth-2);
374
+ stroke-width: 3.5;
375
+ }
376
+
377
+ .edge.destroy {
378
+ stroke-dasharray: none;
379
+ }
380
+
381
+ .edge.delete-all {
382
+ stroke-dasharray: 5,5;
383
+ }
384
+
385
+ .edge.destroy-async {
386
+ stroke-dasharray: 10,5;
387
+ stroke: var(--edge-destroy-async);
388
+ }
389
+
390
+ .edge.nullify {
391
+ stroke: var(--color-nullify);
392
+ stroke-width: 1;
393
+ }
394
+
395
+ .edge.restrict {
396
+ stroke: var(--color-restrict);
397
+ stroke-width: 1;
398
+ }
399
+
400
+ .edge-label {
401
+ font-size: 11px;
402
+ fill: var(--text-secondary);
403
+ pointer-events: none;
404
+ user-select: none;
405
+ }
406
+
407
+ /* Tooltip */
408
+ .tooltip {
409
+ position: absolute;
410
+ background-color: var(--bg-secondary);
411
+ border: 1px solid var(--border-color);
412
+ padding: 8px 12px;
413
+ border-radius: 4px;
414
+ font-size: 0.875rem;
415
+ pointer-events: none;
416
+ opacity: 0;
417
+ transition: opacity 0.2s;
418
+ z-index: 100;
419
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
420
+ }
421
+
422
+ .tooltip.visible {
423
+ opacity: 1;
424
+ }
425
+
426
+ /* Legend */
427
+ .legend {
428
+ position: absolute;
429
+ bottom: 20px;
430
+ right: 20px;
431
+ background-color: var(--bg-secondary);
432
+ border: 1px solid var(--border-color);
433
+ padding: 12px;
434
+ border-radius: 6px;
435
+ font-size: 0.875rem;
436
+ }
437
+
438
+ .legend-item {
439
+ display: flex;
440
+ align-items: center;
441
+ margin: 4px 0;
442
+ }
443
+
444
+ .legend-color {
445
+ width: 20px;
446
+ height: 3px;
447
+ margin-right: 8px;
448
+ }
449
+
450
+ .legend-color.destroy {
451
+ background-color: var(--color-depth-1);
452
+ }
453
+
454
+ .legend-color.delete-all {
455
+ background-color: var(--color-depth-2);
456
+ opacity: 0.6;
457
+ }
458
+
459
+ .legend-color.destroy-async {
460
+ background-color: var(--edge-destroy-async);
461
+ }
462
+
463
+ .legend-color.nullify {
464
+ background-color: var(--color-nullify);
465
+ }
466
+
467
+ /* Loading */
468
+ .loading {
469
+ position: absolute;
470
+ top: 50%;
471
+ left: 50%;
472
+ transform: translate(-50%, -50%);
473
+ font-size: 1.25rem;
474
+ color: var(--text-secondary);
475
+ }
@@ -0,0 +1,112 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= title %> - BlastRadius</title>
7
+ <style>
8
+ <%= css %>
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <div class="container">
13
+ <!-- Sidebar -->
14
+ <div class="sidebar">
15
+ <div class="sidebar-section" id="summary-section">
16
+ <h2>📊 Summary</h2>
17
+ <div class="stat-item">
18
+ <span class="stat-label">Total Models:</span>
19
+ <span class="stat-value"><%= total_models %></span>
20
+ </div>
21
+ <div class="stat-item">
22
+ <span class="stat-label">Total Associations:</span>
23
+ <span class="stat-value"><%= total_associations %></span>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="sidebar-section">
28
+ <h3 style="font-size: 1rem; margin-bottom: 12px;">Dependent Types</h3>
29
+ <% dependent_counts.each do |type, count| %>
30
+ <div class="stat-item">
31
+ <span class="dependent-badge badge-<%= type.to_s.gsub('_', '-') %>"><%= type %></span>
32
+ <span class="stat-value"><%= count %></span>
33
+ </div>
34
+ <% end %>
35
+ </div>
36
+
37
+ <div class="sidebar-section" id="selected-info">
38
+ <h2>ℹ️ Info</h2>
39
+ <p style="color: var(--text-secondary); font-size: 0.875rem;">
40
+ Click on a model to see its cascade deletion impact.
41
+ </p>
42
+ </div>
43
+
44
+ <div class="sidebar-section">
45
+ <h3 style="font-size: 1rem; margin-bottom: 12px;">Affected Models</h3>
46
+ <ul class="impact-list" id="impact-list">
47
+ <!-- Populated by JavaScript -->
48
+ </ul>
49
+ </div>
50
+ </div>
51
+
52
+ <!-- Main Graph Area -->
53
+ <div class="graph-container">
54
+ <div class="graph-header">
55
+ <div class="graph-title"><%= root_model || 'All Models' %></div>
56
+ <div class="graph-controls">
57
+ <div class="btn-group">
58
+ <button class="btn layout-btn active" data-layout="force" title="Force Layout">⚡ Force</button>
59
+ <button class="btn layout-btn" data-layout="tree" title="Tree Layout">🌳 Tree</button>
60
+ </div>
61
+ <button class="btn" id="reset-btn" title="Reset Selection (Esc)">↺ Reset</button>
62
+ <button class="btn" id="fit-btn" title="Fit to Screen (F)">⛶ Fit</button>
63
+ <div class="dropdown">
64
+ <button class="btn btn-primary" id="export-btn">⬇ Export</button>
65
+ <div class="dropdown-menu" id="export-menu">
66
+ <button class="dropdown-item" id="export-svg">Export as SVG</button>
67
+ <button class="dropdown-item" id="export-png">Export as PNG</button>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <svg id="graph-svg">
74
+ <defs>
75
+ <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
76
+ <polygon points="0 0, 10 3, 0 6" fill="var(--edge-normal)" />
77
+ </marker>
78
+ </defs>
79
+ </svg>
80
+
81
+ <div class="legend">
82
+ <div style="font-weight: 600; margin-bottom: 8px;">Legend</div>
83
+ <div class="legend-item">
84
+ <div class="legend-color destroy"></div>
85
+ <span>destroy</span>
86
+ </div>
87
+ <div class="legend-item">
88
+ <div class="legend-color delete-all" style="opacity: 0.6;"></div>
89
+ <span>delete_all</span>
90
+ </div>
91
+ <div class="legend-item">
92
+ <div class="legend-color destroy-async"></div>
93
+ <span>destroy_async</span>
94
+ </div>
95
+ <div class="legend-item">
96
+ <div class="legend-color nullify"></div>
97
+ <span>nullify</span>
98
+ </div>
99
+ </div>
100
+
101
+ <div class="tooltip" id="tooltip"></div>
102
+ </div>
103
+ </div>
104
+
105
+ <script>
106
+ // Graph data
107
+ const graphData = <%= graph_data.to_json %>;
108
+
109
+ <%= javascript %>
110
+ </script>
111
+ </body>
112
+ </html>
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlastRadius
4
+ class ImpactCalculator
5
+ def initialize(configuration = nil)
6
+ @configuration = configuration || BlastRadius.configuration
7
+ end
8
+
9
+ # Calculate the impact of deleting a specific record
10
+ #
11
+ # @param record [ActiveRecord::Base] record to analyze
12
+ # @param options [Hash] options
13
+ # - :max_depth [Integer] maximum depth (default: from configuration)
14
+ # - :include_nullify [Boolean] include nullify associations (default: from configuration)
15
+ # - :include_restrict [Boolean] include restrict associations (default: from configuration)
16
+ # @return [Hash] hash of model names to record counts
17
+ def calculate(record, options = {})
18
+ tree_builder = DependencyTree.new(@configuration)
19
+ tree = tree_builder.build(record.class, options)
20
+
21
+ impact = Hash.new(0)
22
+ # Start with root node's children
23
+ tree.children.each do |child|
24
+ calculate_node_impact(child, record, impact)
25
+ end
26
+ impact
27
+ end
28
+
29
+ # Perform a dry run to see what would be deleted
30
+ #
31
+ # @param record [ActiveRecord::Base] record to analyze
32
+ # @param options [Hash] options (same as #calculate)
33
+ # @return [Hash] detailed impact information with counts and sample IDs
34
+ def dry_run(record, options = {})
35
+ tree_builder = DependencyTree.new(@configuration)
36
+ tree = tree_builder.build(record.class, options)
37
+
38
+ impact = {}
39
+ # Start with root node's children
40
+ tree.children.each do |child|
41
+ calculate_detailed_impact(child, record, impact)
42
+ end
43
+ impact
44
+ end
45
+
46
+ private
47
+
48
+ def calculate_node_impact(node, record, impact)
49
+ # Get associated records
50
+ associated_records = get_associated_records(record, node.association_name)
51
+ return if associated_records.nil?
52
+ return if associated_records.respond_to?(:empty?) && associated_records.empty?
53
+
54
+ # Count records based on dependent type
55
+ count = count_records(associated_records, node.dependent_type)
56
+ impact[node.model_name] += count if count.positive?
57
+
58
+ # Recursively calculate impact for children
59
+ return unless %i[destroy destroy_async].include?(node.dependent_type)
60
+
61
+ Array(associated_records).each do |assoc_record|
62
+ node.children.each do |child|
63
+ calculate_node_impact(child, assoc_record, impact)
64
+ end
65
+ end
66
+ end
67
+
68
+ def calculate_detailed_impact(node, record, impact)
69
+ # Get associated records
70
+ associated_records = get_associated_records(record, node.association_name)
71
+ return if associated_records.nil?
72
+ return if associated_records.respond_to?(:empty?) && associated_records.empty?
73
+
74
+ # Store detailed information
75
+ records_array = Array(associated_records)
76
+ count = count_records(associated_records, node.dependent_type)
77
+
78
+ if count.positive?
79
+ impact[node.model_name] ||= {
80
+ count: 0,
81
+ dependent_type: node.dependent_type,
82
+ sample_ids: []
83
+ }
84
+ impact[node.model_name][:count] += count
85
+ impact[node.model_name][:sample_ids] += records_array.map(&:id)
86
+ impact[node.model_name][:sample_ids].uniq!
87
+ impact[node.model_name][:sample_ids] = impact[node.model_name][:sample_ids].first(5)
88
+ end
89
+
90
+ # Recursively calculate impact for children
91
+ return unless %i[destroy destroy_async].include?(node.dependent_type)
92
+
93
+ records_array.each do |assoc_record|
94
+ node.children.each do |child|
95
+ calculate_detailed_impact(child, assoc_record, impact)
96
+ end
97
+ end
98
+ end
99
+
100
+ def get_associated_records(record, association_name)
101
+ return nil unless record.respond_to?(association_name)
102
+
103
+ begin
104
+ record.public_send(association_name)
105
+ rescue ActiveRecord::RecordNotFound, NoMethodError
106
+ nil
107
+ end
108
+ end
109
+
110
+ def count_records(records, dependent_type)
111
+ case dependent_type
112
+ when :destroy, :delete_all, :destroy_async
113
+ if records.respond_to?(:count)
114
+ records.count
115
+ else
116
+ records.nil? ? 0 : 1
117
+ end
118
+ when :nullify
119
+ # Nullify doesn't delete records, just sets foreign key to null
120
+ 0
121
+ when :restrict_with_exception, :restrict_with_error
122
+ # Restrict prevents deletion if records exist
123
+ 0
124
+ else
125
+ 0
126
+ end
127
+ end
128
+ end
129
+ end