data_migration_for_rails 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/README.md +196 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/manifest.js +2 -0
  6. data/app/assets/stylesheets/application.css +15 -0
  7. data/app/channels/application_cable/channel.rb +6 -0
  8. data/app/channels/application_cable/connection.rb +6 -0
  9. data/app/controllers/concerns/data_migration/pundit_authorization.rb +12 -0
  10. data/app/controllers/data_migration/application_controller.rb +63 -0
  11. data/app/controllers/data_migration/exports_controller.rb +68 -0
  12. data/app/controllers/data_migration/imports_controller.rb +78 -0
  13. data/app/controllers/data_migration/migration_executions_controller.rb +75 -0
  14. data/app/controllers/data_migration/migration_plans_controller.rb +103 -0
  15. data/app/controllers/data_migration/migration_steps_controller.rb +164 -0
  16. data/app/controllers/data_migration/users_controller.rb +71 -0
  17. data/app/controllers/users/sessions_controller.rb +30 -0
  18. data/app/helpers/data_migration/application_helper.rb +24 -0
  19. data/app/jobs/application_job.rb +9 -0
  20. data/app/jobs/export_job.rb +27 -0
  21. data/app/jobs/import_job.rb +28 -0
  22. data/app/mailers/application_mailer.rb +6 -0
  23. data/app/models/application_record.rb +5 -0
  24. data/app/models/data_migration_user.rb +43 -0
  25. data/app/models/migration_execution.rb +93 -0
  26. data/app/models/migration_plan.rb +23 -0
  27. data/app/models/migration_record.rb +60 -0
  28. data/app/models/migration_step.rb +150 -0
  29. data/app/policies/application_policy.rb +53 -0
  30. data/app/policies/data_migration/user_policy.rb +27 -0
  31. data/app/policies/data_migration_user_policy.rb +37 -0
  32. data/app/policies/migration_execution_policy.rb +33 -0
  33. data/app/policies/migration_plan_policy.rb +41 -0
  34. data/app/policies/migration_step_policy.rb +29 -0
  35. data/app/services/data_migration/model_registry.rb +95 -0
  36. data/app/services/exports/generator_service.rb +444 -0
  37. data/app/services/imports/processor_service.rb +457 -0
  38. data/app/services/migration_plans/export_config_service.rb +41 -0
  39. data/app/services/migration_plans/import_config_service.rb +158 -0
  40. data/app/views/data_migration/devise/registrations/edit.html.erb +41 -0
  41. data/app/views/data_migration/devise/sessions/new.html.erb +35 -0
  42. data/app/views/data_migration/devise/shared/_error_messages.html.erb +13 -0
  43. data/app/views/data_migration/devise/shared/_links.html.erb +21 -0
  44. data/app/views/data_migration/exports/new.html.erb +85 -0
  45. data/app/views/data_migration/imports/new.html.erb +70 -0
  46. data/app/views/data_migration/migration_executions/index.html.erb +78 -0
  47. data/app/views/data_migration/migration_executions/show.html.erb +338 -0
  48. data/app/views/data_migration/migration_plans/_form.html.erb +28 -0
  49. data/app/views/data_migration/migration_plans/edit.html.erb +12 -0
  50. data/app/views/data_migration/migration_plans/index.html.erb +118 -0
  51. data/app/views/data_migration/migration_plans/new.html.erb +9 -0
  52. data/app/views/data_migration/migration_plans/show.html.erb +105 -0
  53. data/app/views/data_migration/migration_steps/_form.html.erb +473 -0
  54. data/app/views/data_migration/migration_steps/edit.html.erb +12 -0
  55. data/app/views/data_migration/migration_steps/new.html.erb +9 -0
  56. data/app/views/data_migration/users/_form.html.erb +49 -0
  57. data/app/views/data_migration/users/edit.html.erb +2 -0
  58. data/app/views/data_migration/users/index.html.erb +41 -0
  59. data/app/views/data_migration/users/new.html.erb +2 -0
  60. data/app/views/data_migration/users/show.html.erb +133 -0
  61. data/app/views/layouts/_navbar.html.erb +38 -0
  62. data/app/views/layouts/data_migration.html.erb +37 -0
  63. data/app/views/layouts/mailer.html.erb +13 -0
  64. data/app/views/layouts/mailer.text.erb +1 -0
  65. data/app/views/users/registrations/edit.html.erb +41 -0
  66. data/app/views/users/sessions/new.html.erb +35 -0
  67. data/app/views/users/shared/_error_messages.html.erb +13 -0
  68. data/app/views/users/shared/_links.html.erb +21 -0
  69. data/config/initializers/assets.rb +14 -0
  70. data/config/initializers/content_security_policy.rb +27 -0
  71. data/config/initializers/devise.rb +313 -0
  72. data/config/initializers/filter_parameter_logging.rb +10 -0
  73. data/config/initializers/inflections.rb +18 -0
  74. data/config/initializers/permissions_policy.rb +15 -0
  75. data/config/initializers/warden.rb +14 -0
  76. data/config/locales/devise.en.yml +65 -0
  77. data/config/locales/en.yml +31 -0
  78. data/config/routes.rb +62 -0
  79. data/db/migrate/20251102121659_create_migration_plans.rb +13 -0
  80. data/db/migrate/20251102122012_create_migration_steps.rb +24 -0
  81. data/db/migrate/20251105215702_create_migration_executions.rb +23 -0
  82. data/db/migrate/20251105215853_create_migration_records.rb +16 -0
  83. data/db/migrate/20251115154000_remove_unused_attributes.rb +17 -0
  84. data/db/migrate/20251116120000_add_filter_params_to_migration_executions.rb +7 -0
  85. data/db/migrate/20251118140000_create_data_migration_users.rb +27 -0
  86. data/db/migrate/20251118200641_add_user_foreign_keys.rb +15 -0
  87. data/db/migrate/20251124140000_add_attachment_export_mode_to_migration_steps.rb +9 -0
  88. data/db/schema.rb +102 -0
  89. data/db/seeds.rb +19 -0
  90. data/lib/data_migration/engine.rb +28 -0
  91. data/lib/data_migration/version.rb +5 -0
  92. data/lib/data_migration.rb +8 -0
  93. data/lib/tasks/data_migration_tasks.rake +40 -0
  94. metadata +279 -0
@@ -0,0 +1,35 @@
1
+ <div class="row justify-content-center mt-5">
2
+ <div class="col-md-5">
3
+ <div class="card shadow">
4
+ <div class="card-body p-5">
5
+ <div class="text-center mb-4">
6
+ <h2 class="card-title">Data Migration Tool</h2>
7
+ <p class="text-muted">Sign in to your account</p>
8
+ </div>
9
+
10
+ <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
11
+ <div class="mb-3">
12
+ <%= f.label :email, class: "form-label" %>
13
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "form-control", placeholder: "Enter your email" %>
14
+ </div>
15
+
16
+ <div class="mb-3">
17
+ <%= f.label :password, class: "form-label" %>
18
+ <%= f.password_field :password, autocomplete: "current-password", class: "form-control", placeholder: "Enter your password" %>
19
+ </div>
20
+
21
+ <% if devise_mapping.rememberable? %>
22
+ <div class="mb-3 form-check">
23
+ <%= f.check_box :remember_me, class: "form-check-input" %>
24
+ <%= f.label :remember_me, class: "form-check-label" %>
25
+ </div>
26
+ <% end %>
27
+
28
+ <div class="d-grid">
29
+ <%= f.submit "Sign in", class: "btn btn-primary btn-lg" %>
30
+ </div>
31
+ <% end %>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
@@ -0,0 +1,13 @@
1
+ <% if resource.errors.any? %>
2
+ <div class="alert alert-danger alert-dismissible fade show" role="alert">
3
+ <h6 class="alert-heading">
4
+ <%= pluralize(resource.errors.count, "error") %> prohibited this from being saved:
5
+ </h6>
6
+ <ul class="mb-0">
7
+ <% resource.errors.full_messages.each do |message| %>
8
+ <li><%= message %></li>
9
+ <% end %>
10
+ </ul>
11
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
12
+ </div>
13
+ <% end %>
@@ -0,0 +1,21 @@
1
+ <%- if controller_name != 'sessions' %>
2
+ <%= link_to "Log in", new_session_path(resource_name) %><br />
3
+ <% end %>
4
+
5
+ <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
6
+ <%= link_to "Forgot your password?", new_password_path(resource_name) %><br />
7
+ <% end %>
8
+
9
+ <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
10
+ <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %><br />
11
+ <% end %>
12
+
13
+ <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
14
+ <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %><br />
15
+ <% end %>
16
+
17
+ <%- if devise_mapping.omniauthable? %>
18
+ <%- resource_class.omniauth_providers.each do |provider| %>
19
+ <%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %><br />
20
+ <% end %>
21
+ <% end %>
@@ -0,0 +1,85 @@
1
+ <div class="container-fluid py-4">
2
+ <div class="row justify-content-center">
3
+ <div class="col-lg-8">
4
+ <div class="d-flex justify-content-between align-items-center mb-4">
5
+ <h2>Export: <%= @migration_plan.name %></h2>
6
+ <%= link_to "Cancel", "/data_migration/migration_plans/#{@migration_plan.id}", class: "btn btn-secondary" %>
7
+ </div>
8
+
9
+ <% if @filter_params.any? %>
10
+ <div class="card">
11
+ <div class="card-header">
12
+ <h5 class="mb-0">📝 Filter Parameters</h5>
13
+ </div>
14
+ <div class="card-body">
15
+ <p class="text-muted mb-4">
16
+ This migration plan uses placeholders in filter queries. Please provide values for the following parameters:
17
+ </p>
18
+
19
+ <%= form_with url: "/data_migration/migration_plans/#{@migration_plan.id}/export", method: :post, local: true do |form| %>
20
+ <% @filter_params.each do |param_name, _| %>
21
+ <div class="mb-3">
22
+ <%= label_tag "filter_params[#{param_name}]", param_name.humanize, class: "form-label" %>
23
+ <%= text_field_tag "filter_params[#{param_name}]", nil,
24
+ class: "form-control",
25
+ placeholder: "Enter value for {{#{param_name}}}",
26
+ required: true %>
27
+ <div class="form-text">
28
+ This value will replace <code>{{#{param_name}}}</code> in filter queries.
29
+ </div>
30
+ </div>
31
+ <% end %>
32
+
33
+ <div class="alert alert-info mt-4">
34
+ <strong>💡 Examples:</strong>
35
+ <ul class="mb-0">
36
+ <li>For date: <code>2024-01-01</code></li>
37
+ <li>For number: <code>100</code></li>
38
+ <li>For string: <code>active</code></li>
39
+ </ul>
40
+ </div>
41
+
42
+ <div class="d-grid gap-2 mt-4">
43
+ <%= form.submit "Start Export", class: "btn btn-primary btn-lg" %>
44
+ </div>
45
+ <% end %>
46
+ </div>
47
+ </div>
48
+ <% else %>
49
+ <div class="card">
50
+ <div class="card-body text-center py-5">
51
+ <h5>No parameters required</h5>
52
+ <p class="text-muted">This migration plan doesn't use any filter parameters.</p>
53
+
54
+ <%= form_with url: "/data_migration/migration_plans/#{@migration_plan.id}/export", method: :post, local: true do |form| %>
55
+ <div class="d-grid gap-2 col-md-6 mx-auto">
56
+ <%= form.submit "Start Export", class: "btn btn-primary btn-lg" %>
57
+ </div>
58
+ <% end %>
59
+ </div>
60
+ </div>
61
+ <% end %>
62
+
63
+ <div class="mt-4">
64
+ <h6>Migration Steps:</h6>
65
+ <ul class="list-group">
66
+ <% @migration_plan.migration_steps.order(:sequence).each do |step| %>
67
+ <li class="list-group-item">
68
+ <div class="d-flex justify-content-between align-items-start">
69
+ <div>
70
+ <span class="badge bg-primary me-2"><%= step.sequence %></span>
71
+ <strong><%= step.source_model_name %></strong>
72
+ </div>
73
+ </div>
74
+ <% if step.filter_query.present? %>
75
+ <small class="text-muted d-block mt-2">
76
+ <strong>Filter:</strong> <code><%= step.filter_query %></code>
77
+ </small>
78
+ <% end %>
79
+ </li>
80
+ <% end %>
81
+ </ul>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </div>
@@ -0,0 +1,70 @@
1
+ <div class="row justify-content-center">
2
+ <div class="col-md-8">
3
+ <h1 class="mb-4">Import Data</h1>
4
+
5
+ <div class="card">
6
+ <div class="card-body">
7
+ <h5 class="card-title"><%= @migration_plan.name %></h5>
8
+ <p class="card-text text-muted"><%= @migration_plan.description %></p>
9
+
10
+ <hr>
11
+
12
+ <%= form_with url: migration_plan_import_path(@migration_plan), method: :post, multipart: true, local: true do |form| %>
13
+ <div class="mb-3">
14
+ <%= form.label :archive_file, "Upload Export Archive", class: "form-label" %>
15
+ <%= form.file_field :archive_file, class: "form-control", accept: ".tar.gz,.tgz", required: true %>
16
+ <div class="form-text">
17
+ Select the .tar.gz file exported from the source environment.
18
+ </div>
19
+ </div>
20
+
21
+ <% if @filter_params.any? %>
22
+ <hr>
23
+ <h6>📝 Filter Parameters <small class="text-muted">(Optional)</small></h6>
24
+ <p class="text-muted small mb-3">
25
+ Update filter parameters if needed. Leave blank to use values from the last export.
26
+ </p>
27
+
28
+ <% @filter_params.each do |param_name, _| %>
29
+ <% default_value = @last_export&.filter_params&.dig(param_name) %>
30
+ <div class="mb-3">
31
+ <%= label_tag "filter_params[#{param_name}]", param_name.humanize, class: "form-label" %>
32
+ <%= text_field_tag "filter_params[#{param_name}]", default_value,
33
+ class: "form-control",
34
+ placeholder: default_value.present? ? "Default: #{default_value}" : "Enter value for {{#{param_name}}}" %>
35
+ <% if default_value.present? %>
36
+ <div class="form-text">
37
+ Last export used: <code><%= default_value %></code>
38
+ </div>
39
+ <% else %>
40
+ <div class="form-text">
41
+ This will replace <code>{{#{param_name}}}</code> in filter queries.
42
+ </div>
43
+ <% end %>
44
+ </div>
45
+ <% end %>
46
+ <hr>
47
+ <% end %>
48
+
49
+ <div class="alert alert-warning">
50
+ <h6>⚠️ Important Notes:</h6>
51
+ <ul class="mb-0">
52
+ <li>This will import data according to the configured migration steps.</li>
53
+ <li>Existing records will be updated based on unique identifiers.</li>
54
+ <li>The import runs in the background. You'll be redirected to monitor progress.</li>
55
+ <li>Make sure you have a database backup before proceeding.</li>
56
+ </ul>
57
+ </div>
58
+
59
+ <div class="d-grid gap-2">
60
+ <%= form.submit "Start Import", class: "btn btn-primary btn-lg", data: { confirm: "Are you sure you want to import this data?" } %>
61
+ </div>
62
+ <% end %>
63
+
64
+ <div class="mt-3">
65
+ <%= link_to "Cancel", @migration_plan, class: "btn btn-secondary" %>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
@@ -0,0 +1,78 @@
1
+ <h1 class="mb-4">Execution History</h1>
2
+
3
+ <% if @executions.any? %>
4
+ <div class="table-responsive">
5
+ <table class="table table-hover">
6
+ <thead class="table-light">
7
+ <tr>
8
+ <th>Type</th>
9
+ <th>Migration Plan</th>
10
+ <th>Status</th>
11
+ <th>Started</th>
12
+ <th>Duration</th>
13
+ <th>User</th>
14
+ <th>Stats</th>
15
+ <th>Actions</th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @executions.each do |execution| %>
20
+ <tr>
21
+ <td>
22
+ <%= execution_type_icon(execution.execution_type) %>
23
+ <%= execution.execution_type.titleize %>
24
+ </td>
25
+ <td>
26
+ <%= link_to execution.migration_plan.name, execution.migration_plan, class: "text-decoration-none" %>
27
+ </td>
28
+ <td>
29
+ <span class="badge bg-<%= execution_status_color(execution.status) %>">
30
+ <%= execution.status.titleize %>
31
+ </span>
32
+ </td>
33
+ <td>
34
+ <%= execution.started_at&.strftime("%Y-%m-%d %H:%M") || 'Pending' %>
35
+ </td>
36
+ <td>
37
+ <% if execution.duration %>
38
+ <%= distance_of_time_in_words(execution.duration) %>
39
+ <% else %>
40
+ <span class="text-muted">-</span>
41
+ <% end %>
42
+ </td>
43
+ <td>
44
+ <small><%= execution.user.email %></small>
45
+ </td>
46
+ <td>
47
+ <% if execution.stats.present? %>
48
+ <small>
49
+ <% if execution.export? %>
50
+ <%= execution.stats['processed_records'] || 0 %> records
51
+ <% else %>
52
+ <span class="text-success"><%= execution.stats['created'] || 0 %></span> /
53
+ <span class="text-warning"><%= execution.stats['updated'] || 0 %></span> /
54
+ <span class="text-danger"><%= execution.stats['failed'] || 0 %></span>
55
+ <% end %>
56
+ </small>
57
+ <% end %>
58
+ </td>
59
+ <td>
60
+ <div class="btn-group btn-group-sm">
61
+ <%= link_to "View", execution, class: "btn btn-outline-primary" %>
62
+ <% if execution.completed? && execution.export? && execution.file_path.present? %>
63
+ <%= link_to "Download", download_migration_execution_path(execution), class: "btn btn-outline-success" %>
64
+ <% end %>
65
+ </div>
66
+ </td>
67
+ </tr>
68
+ <% end %>
69
+ </tbody>
70
+ </table>
71
+ </div>
72
+ <% else %>
73
+ <div class="alert alert-info">
74
+ <h4>No Executions Yet</h4>
75
+ <p>Start an export or import from a migration plan to see execution history here.</p>
76
+ <%= link_to "View Migration Plans", migration_plans_path, class: "btn btn-primary" %>
77
+ </div>
78
+ <% end %>
@@ -0,0 +1,338 @@
1
+ <div class="mb-4">
2
+ <div class="d-flex justify-content-between align-items-center">
3
+ <div>
4
+ <h1>
5
+ <%= execution_type_icon(@execution.execution_type) %>
6
+ <%= @execution.display_name %>
7
+ </h1>
8
+ <p class="text-muted mb-0">
9
+ Started <%= time_ago_in_words(@execution.created_at) %> ago
10
+ by <%= @execution.user.email %>
11
+ </p>
12
+ </div>
13
+ <div>
14
+ <span class="badge bg-<%= execution_status_color(@execution.status) %> fs-5">
15
+ <%= @execution.status.titleize %>
16
+ </span>
17
+ </div>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="row">
22
+ <div class="col-md-8">
23
+ <!-- Progress Card -->
24
+ <div class="card mb-4" data-controller="execution-monitor" data-execution-monitor-id-value="<%= @execution.id %>">
25
+ <div class="card-header">
26
+ <h5 class="mb-0">Progress</h5>
27
+ </div>
28
+ <div class="card-body">
29
+ <% if @execution.running? || @execution.pending? %>
30
+ <div class="progress mb-3" style="height: 30px;">
31
+ <div class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
32
+ role="progressbar"
33
+ style="width: <%= @execution.progress_percentage %>%"
34
+ data-execution-monitor-target="progressBar">
35
+ <%= @execution.progress_percentage %>%
36
+ </div>
37
+ </div>
38
+ <p class="text-center text-muted" data-execution-monitor-target="statusMessage">
39
+ <% if @execution.pending? %>
40
+ Waiting to start...
41
+ <% else %>
42
+ Processing...
43
+ <% end %>
44
+ </p>
45
+ <% elsif @execution.completed? %>
46
+ <div class="alert alert-success">
47
+ <h5>✅ <%= @execution.execution_type.titleize %> Completed Successfully</h5>
48
+ <p class="mb-0">
49
+ Duration: <%= distance_of_time_in_words(@execution.duration) %>
50
+ </p>
51
+ </div>
52
+ <% elsif @execution.failed? %>
53
+ <div class="alert alert-danger">
54
+ <h5>❌ <%= @execution.execution_type.titleize %> Failed</h5>
55
+ <% if @execution.error_log.present? %>
56
+ <details>
57
+ <summary>View Error Details</summary>
58
+ <pre class="mt-2"><%= @execution.error_log %></pre>
59
+ </details>
60
+ <% end %>
61
+ </div>
62
+ <% end %>
63
+
64
+ <!-- Statistics -->
65
+ <% if @execution.stats.present? %>
66
+ <div class="row text-center mt-4">
67
+ <% if @execution.export? %>
68
+ <div class="col-md-4">
69
+ <div class="card">
70
+ <div class="card-body">
71
+ <h3 class="text-primary"><%= @execution.stats['processed_records'] || 0 %></h3>
72
+ <p class="text-muted mb-0">Records Exported</p>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ <div class="col-md-4">
77
+ <div class="card">
78
+ <div class="card-body">
79
+ <h3 class="text-info"><%= @execution.stats['completed_steps'] || 0 %></h3>
80
+ <p class="text-muted mb-0">Steps Completed</p>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ <div class="col-md-4">
85
+ <div class="card">
86
+ <div class="card-body">
87
+ <h3 class="text-success"><%= @execution.stats['processed_attachments'] || 0 %></h3>
88
+ <p class="text-muted mb-0">📎 Attachments Exported</p>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ <% else %>
93
+ <div class="col-md-3">
94
+ <div class="card">
95
+ <div class="card-body">
96
+ <h3 class="text-success"><%= @execution.stats['created'] || 0 %></h3>
97
+ <p class="text-muted mb-0">Created</p>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ <div class="col-md-3">
102
+ <div class="card">
103
+ <div class="card-body">
104
+ <h3 class="text-warning"><%= @execution.stats['updated'] || 0 %></h3>
105
+ <p class="text-muted mb-0">Updated</p>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ <div class="col-md-3">
110
+ <div class="card">
111
+ <div class="card-body">
112
+ <h3 class="text-secondary"><%= @execution.stats['skipped'] || 0 %></h3>
113
+ <p class="text-muted mb-0">Skipped</p>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ <div class="col-md-3">
118
+ <div class="card">
119
+ <div class="card-body">
120
+ <h3 class="text-danger"><%= @execution.stats['failed'] || 0 %></h3>
121
+ <p class="text-muted mb-0">Failed</p>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ <% end %>
126
+ </div>
127
+
128
+ <!-- Attachments Row -->
129
+ <% if (@execution.stats['processed_attachments'] || 0) > 0 %>
130
+ <div class="row text-center mt-3">
131
+ <div class="col-md-12">
132
+ <div class="card bg-light">
133
+ <div class="card-body">
134
+ <h4 class="text-success mb-0">
135
+ 📎 <%= @execution.stats['processed_attachments'] || 0 %> Attachments <%= @execution.export? ? 'Exported' : 'Imported' %>
136
+ </h4>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ <% end %>
142
+ <% end %>
143
+
144
+ <!-- Download Export -->
145
+ <% if @execution.completed? && @execution.export? && @execution.file_path.present? %>
146
+ <div class="d-grid gap-2 mt-4">
147
+ <%= link_to "📥 Download Export Archive", download_migration_execution_path(@execution), class: "btn btn-success btn-lg" %>
148
+ </div>
149
+ <% end %>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- Migration Records -->
154
+ <% if @execution.import? && @execution.migration_records.any? %>
155
+ <div class="card">
156
+ <div class="card-header d-flex justify-content-between align-items-center">
157
+ <h5 class="mb-0">Processed Records</h5>
158
+ <span class="badge bg-secondary"><%= @execution.migration_records.count %> total</span>
159
+ </div>
160
+
161
+ <!-- Action Summary -->
162
+ <% if @action_counts.any? %>
163
+ <div class="card-body border-bottom">
164
+ <div class="row text-center">
165
+ <% if @action_counts['created'].to_i > 0 %>
166
+ <div class="col">
167
+ <span class="badge bg-success fs-6"><%= @action_counts['created'] %> Created</span>
168
+ </div>
169
+ <% end %>
170
+ <% if @action_counts['updated'].to_i > 0 %>
171
+ <div class="col">
172
+ <span class="badge bg-warning fs-6"><%= @action_counts['updated'] %> Updated</span>
173
+ </div>
174
+ <% end %>
175
+ <% if @action_counts['skipped'].to_i > 0 %>
176
+ <div class="col">
177
+ <span class="badge bg-secondary fs-6"><%= @action_counts['skipped'] %> Skipped</span>
178
+ </div>
179
+ <% end %>
180
+ <% if @action_counts['failed'].to_i > 0 %>
181
+ <div class="col">
182
+ <span class="badge bg-danger fs-6"><%= @action_counts['failed'] %> Failed</span>
183
+ </div>
184
+ <% end %>
185
+ </div>
186
+ </div>
187
+ <% end %>
188
+
189
+ <!-- Filters -->
190
+ <div class="card-body border-bottom bg-light">
191
+ <%= form_with url: migration_execution_path(@execution), method: :get, local: true, class: "row g-3" do |f| %>
192
+ <div class="col-md-4">
193
+ <%= f.label :model, "Filter by Model", class: "form-label small" %>
194
+ <%= f.select :model,
195
+ options_for_select([['All Models', '']] + @model_names.map { |m| [m, m] }, params[:model]),
196
+ {},
197
+ { class: "form-select form-select-sm", onchange: "this.form.submit()" } %>
198
+ </div>
199
+ <div class="col-md-4">
200
+ <%= f.label :filter_action, "Filter by Action", class: "form-label small" %>
201
+ <%= f.select :filter_action,
202
+ options_for_select([['All Actions', ''], ['Created', 'created'], ['Updated', 'updated'], ['Skipped', 'skipped'], ['Failed', 'failed']], params[:filter_action]),
203
+ {},
204
+ { class: "form-select form-select-sm", onchange: "this.form.submit()" } %>
205
+ </div>
206
+ <div class="col-md-4">
207
+ <%= f.label :limit, "Records to Show", class: "form-label small" %>
208
+ <%= f.select :limit,
209
+ options_for_select([['100', 100], ['500', 500], ['1000', 1000], ['5000', 5000]], @limit),
210
+ {},
211
+ { class: "form-select form-select-sm", onchange: "this.form.submit()" } %>
212
+ </div>
213
+ <% end %>
214
+ </div>
215
+
216
+ <div class="card-body">
217
+ <div class="table-responsive">
218
+ <table class="table table-sm table-hover">
219
+ <thead>
220
+ <tr>
221
+ <th>Model</th>
222
+ <th>Record ID</th>
223
+ <th>Action</th>
224
+ <th>Changes/Error</th>
225
+ </tr>
226
+ </thead>
227
+ <tbody>
228
+ <% if @migration_records.any? %>
229
+ <% @migration_records.each do |record| %>
230
+ <tr class="<%= 'table-danger' if record.failed? %>">
231
+ <td><%= record.migrated_model_name %></td>
232
+ <td><small class="font-monospace"><%= record.record_identifier %></small></td>
233
+ <td>
234
+ <span class="badge bg-<%= case record.action
235
+ when 'created' then 'success'
236
+ when 'updated' then 'warning'
237
+ when 'skipped' then 'secondary'
238
+ when 'failed' then 'danger'
239
+ else 'info'
240
+ end %>">
241
+ <%= record.action.titleize %>
242
+ </span>
243
+ </td>
244
+ <td>
245
+ <% if record.error_message.present? %>
246
+ <small class="text-danger"><%= truncate(record.error_message, length: 80) %></small>
247
+ <% elsif record.record_changes.present? && record.record_changes.any? %>
248
+ <details>
249
+ <summary class="text-muted small" style="cursor: pointer;">View changes</summary>
250
+ <pre class="mb-0 mt-1 small"><%= JSON.pretty_generate(record.record_changes) %></pre>
251
+ </details>
252
+ <% end %>
253
+ </td>
254
+ </tr>
255
+ <% end %>
256
+ <% else %>
257
+ <tr>
258
+ <td colspan="4" class="text-center text-muted py-4">
259
+ No records match the current filters. Try adjusting the filters above.
260
+ </td>
261
+ </tr>
262
+ <% end %>
263
+ </tbody>
264
+ </table>
265
+ </div>
266
+
267
+ <% if @migration_records.count == @limit %>
268
+ <div class="alert alert-info mt-3">
269
+ Showing first <%= @limit %> records. Use the filter above to show more or filter by model/action.
270
+ </div>
271
+ <% end %>
272
+ </div>
273
+ </div>
274
+ <% end %>
275
+ </div>
276
+
277
+ <div class="col-md-4">
278
+ <!-- Info Card -->
279
+ <div class="card mb-4">
280
+ <div class="card-header">
281
+ <h6 class="mb-0">Execution Info</h6>
282
+ </div>
283
+ <div class="card-body">
284
+ <dl class="row mb-0">
285
+ <dt class="col-sm-5">Type:</dt>
286
+ <dd class="col-sm-7"><%= @execution.execution_type.titleize %></dd>
287
+
288
+ <dt class="col-sm-5">Started:</dt>
289
+ <dd class="col-sm-7">
290
+ <% if @execution.started_at %>
291
+ <%= @execution.started_at.strftime("%Y-%m-%d %H:%M:%S") %>
292
+ <% else %>
293
+ <em>Not started</em>
294
+ <% end %>
295
+ </dd>
296
+
297
+ <dt class="col-sm-5">Completed:</dt>
298
+ <dd class="col-sm-7">
299
+ <% if @execution.completed_at %>
300
+ <%= @execution.completed_at.strftime("%Y-%m-%d %H:%M:%S") %>
301
+ <% else %>
302
+ <em>In progress</em>
303
+ <% end %>
304
+ </dd>
305
+
306
+ <% if @execution.duration %>
307
+ <dt class="col-sm-5">Duration:</dt>
308
+ <dd class="col-sm-7"><%= distance_of_time_in_words(@execution.duration) %></dd>
309
+ <% end %>
310
+
311
+ <dt class="col-sm-5">User:</dt>
312
+ <dd class="col-sm-7"><%= @execution.user.email %></dd>
313
+
314
+ <% if @execution.filter_params.present? && @execution.filter_params.any? %>
315
+ <dt class="col-sm-12 mt-3 mb-2 text-primary">Filter Parameters:</dt>
316
+ <% @execution.filter_params.each do |key, value| %>
317
+ <dt class="col-sm-5 small">{{<%= key %>}}:</dt>
318
+ <dd class="col-sm-7 small"><code><%= value %></code></dd>
319
+ <% end %>
320
+ <% end %>
321
+ </dl>
322
+ </div>
323
+ </div>
324
+
325
+ <!-- Actions -->
326
+ <div class="card">
327
+ <div class="card-body">
328
+ <%= link_to "← Back to Plan", @execution.migration_plan, class: "btn btn-secondary w-100 mb-2" %>
329
+ <%= link_to "View All Executions", migration_executions_path, class: "btn btn-outline-secondary w-100" %>
330
+ </div>
331
+ </div>
332
+ </div>
333
+ </div>
334
+
335
+ <!-- Auto-reload for running executions -->
336
+ <% if @execution.running? || @execution.pending? %>
337
+ <meta http-equiv="refresh" content="5">
338
+ <% end %>