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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +259 -0
- data/lib/blast_radius/active_record_extension.rb +62 -0
- data/lib/blast_radius/analyzer.rb +60 -0
- data/lib/blast_radius/configuration.rb +31 -0
- data/lib/blast_radius/dependency_tree.rb +87 -0
- data/lib/blast_radius/formatters/base.rb +28 -0
- data/lib/blast_radius/formatters/dot_formatter.rb +61 -0
- data/lib/blast_radius/formatters/html_formatter.rb +144 -0
- data/lib/blast_radius/formatters/json_formatter.rb +36 -0
- data/lib/blast_radius/formatters/mermaid_formatter.rb +49 -0
- data/lib/blast_radius/formatters/text_formatter.rb +43 -0
- data/lib/blast_radius/html/graph.js +852 -0
- data/lib/blast_radius/html/styles.css +475 -0
- data/lib/blast_radius/html/template.html.erb +112 -0
- data/lib/blast_radius/impact_calculator.rb +129 -0
- data/lib/blast_radius/node.rb +65 -0
- data/lib/blast_radius/railtie.rb +21 -0
- data/lib/blast_radius/version.rb +5 -0
- data/lib/blast_radius.rb +47 -0
- data/lib/tasks/blast_radius.rake +102 -0
- metadata +164 -0
|
@@ -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
|