diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 41a5cce64..65e02ded1 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -12,6 +12,16 @@ class RulesController < ApplicationController @direction = "asc" unless [ "asc", "desc" ].include?(@direction) @rules = Current.family.rules.order(@sort_by => @direction) + + # Fetch recent rule runs with pagination + recent_runs_scope = RuleRun + .joins(:rule) + .where(rules: { family_id: Current.family.id }) + .recent + .includes(:rule) + + @pagy, @recent_runs = pagy(recent_runs_scope, limit: params[:per_page] || 20, page_param: :runs_page) + render layout: "settings" end diff --git a/app/jobs/auto_categorize_job.rb b/app/jobs/auto_categorize_job.rb index c31917a83..f168351bd 100644 --- a/app/jobs/auto_categorize_job.rb +++ b/app/jobs/auto_categorize_job.rb @@ -1,7 +1,13 @@ class AutoCategorizeJob < ApplicationJob queue_as :medium_priority - def perform(family, transaction_ids: []) - family.auto_categorize_transactions(transaction_ids) + def perform(family, transaction_ids: [], rule_run_id: nil) + modified_count = family.auto_categorize_transactions(transaction_ids) + + # If this job was part of a rule run, report back the modified count + if rule_run_id.present? + rule_run = RuleRun.find_by(id: rule_run_id) + rule_run&.complete_job!(modified_count: modified_count) + end end end diff --git a/app/jobs/auto_detect_merchants_job.rb b/app/jobs/auto_detect_merchants_job.rb index eb3713a4e..cdb4ecb7c 100644 --- a/app/jobs/auto_detect_merchants_job.rb +++ b/app/jobs/auto_detect_merchants_job.rb @@ -1,7 +1,13 @@ class AutoDetectMerchantsJob < ApplicationJob queue_as :medium_priority - def perform(family, transaction_ids: []) - family.auto_detect_transaction_merchants(transaction_ids) + def perform(family, transaction_ids: [], rule_run_id: nil) + modified_count = family.auto_detect_transaction_merchants(transaction_ids) + + # If this job was part of a rule run, report back the modified count + if rule_run_id.present? + rule_run = RuleRun.find_by(id: rule_run_id) + rule_run&.complete_job!(modified_count: modified_count) + end end end diff --git a/app/jobs/rule_job.rb b/app/jobs/rule_job.rb index fe446d0ec..1dbcccbd0 100644 --- a/app/jobs/rule_job.rb +++ b/app/jobs/rule_job.rb @@ -1,7 +1,93 @@ class RuleJob < ApplicationJob queue_as :medium_priority - def perform(rule, ignore_attribute_locks: false) - rule.apply(ignore_attribute_locks: ignore_attribute_locks) + def perform(rule, ignore_attribute_locks: false, execution_type: "manual") + executed_at = Time.current + transactions_queued = 0 + transactions_processed = 0 + transactions_modified = 0 + pending_jobs_count = 0 + status = "unknown" + error_message = nil + rule_run = nil + + begin + # Count matching transactions before processing (queued count) + transactions_queued = rule.affected_resource_count + + # Create the RuleRun record first with pending status + # We'll update it after we know if there are async jobs + # Store the rule name at execution time so it persists even if the rule name changes later + rule_run = RuleRun.create!( + rule: rule, + rule_name: rule.name, + execution_type: execution_type, + status: "pending", # Start as pending, will be updated + transactions_queued: transactions_queued, + transactions_processed: 0, + transactions_modified: 0, + pending_jobs_count: 0, + executed_at: executed_at + ) + + # Apply the rule and get the result + result = rule.apply(ignore_attribute_locks: ignore_attribute_locks, rule_run: rule_run) + + if result.is_a?(Hash) && result[:async] + # Async actions were executed + transactions_processed = result[:modified_count] || 0 + pending_jobs_count = result[:jobs_count] || 0 + status = "pending" + elsif result.is_a?(Integer) + # Only synchronous actions were executed + transactions_processed = result + transactions_modified = result + status = "success" + else + # Unexpected result type - log and default to 0 + Rails.logger.warn("RuleJob: Unexpected result type from rule.apply: #{result.class} for rule #{rule.id}") + transactions_processed = 0 + transactions_modified = 0 + status = "unknown" + end + + # Update the rule run with final counts + rule_run.update!( + status: status, + transactions_processed: transactions_processed, + transactions_modified: transactions_modified, + pending_jobs_count: pending_jobs_count + ) + rescue => e + status = "failed" + error_message = "#{e.class}: #{e.message}" + Rails.logger.error("RuleJob failed for rule #{rule.id}: #{error_message}") + + # Update the rule run as failed if it was created + if rule_run + rule_run.update(status: "failed", error_message: error_message) + else + # Create a failed rule run if we hadn't created one yet + # Store the rule name at execution time so it persists even if the rule name changes later + begin + RuleRun.create!( + rule: rule, + rule_name: rule.name, + execution_type: execution_type, + status: "failed", + transactions_queued: transactions_queued, + transactions_processed: 0, + transactions_modified: 0, + pending_jobs_count: 0, + executed_at: executed_at, + error_message: error_message + ) + rescue => e + Rails.logger.error("RuleJob: Failed to create RuleRun for rule #{rule.id}: #{create_error.message}") + end + end + + raise # Re-raise to mark job as failed in Sidekiq + end end end diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index be813066d..f305a4faa 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -31,11 +31,36 @@ module Enrichable # - Are not locked # - Are not ignored # - Have changed value from the last saved value + # Returns true if any attributes were actually changed, false otherwise def enrich_attributes(attrs, source:, metadata: {}) + # Track current values before modification for virtual attributes (like tag_ids) + current_values = {} enrichable_attrs = Array(attrs).reject do |attr_key, attr_value| - locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key) || self[attr_key.to_s] == attr_value + if locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key) + true + else + # For virtual attributes (like tag_ids), use the getter method + # For regular attributes, use self[attr_key] + current_value = if respond_to?(attr_key.to_sym) + send(attr_key.to_sym) + else + self[attr_key.to_s] + end + + # Normalize arrays for comparison (sort them) + if current_value.is_a?(Array) && attr_value.is_a?(Array) + current_values[attr_key] = current_value + current_value.sort == attr_value.sort + else + current_values[attr_key] = current_value + current_value == attr_value + end + end end + return false if enrichable_attrs.empty? + + was_modified = false ActiveRecord::Base.transaction do enrichable_attrs.each do |attr, value| self.send("#{attr}=", value) @@ -47,7 +72,34 @@ module Enrichable end save + + # For virtual attributes (like tag_ids), previous_changes won't track them + # So we need to check if the value actually changed by comparing before/after + if previous_changes.any? + was_modified = true + else + # Check if any virtual attributes changed by comparing current value with what we set + enrichable_attrs.each do |attr, new_value| + # Get the current value after save (for virtual attributes, this reflects the change) + current_value = if respond_to?(attr.to_sym) + send(attr.to_sym) + else + self[attr.to_s] + end + + old_value = current_values[attr] + if old_value.is_a?(Array) && new_value.is_a?(Array) && current_value.is_a?(Array) + was_modified = true if old_value.sort != current_value.sort + elsif old_value != current_value + was_modified = true + end + break if was_modified + end + end end + + # Return whether any attributes were actually saved + was_modified end def locked?(attr) diff --git a/app/models/family.rb b/app/models/family.rb index deee144ee..1df25797f 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -45,16 +45,16 @@ class Family < ApplicationRecord Merchant.where(id: merchant_ids) end - def auto_categorize_transactions_later(transactions) - AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id)) + def auto_categorize_transactions_later(transactions, rule_run_id: nil) + AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id) end def auto_categorize_transactions(transaction_ids) AutoCategorizer.new(self, transaction_ids: transaction_ids).auto_categorize end - def auto_detect_transaction_merchants_later(transactions) - AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id)) + def auto_detect_transaction_merchants_later(transactions, rule_run_id: nil) + AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id) end def auto_detect_transaction_merchants(transaction_ids) diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 9a57df28b..1efb76c58 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -11,7 +11,7 @@ class Family::AutoCategorizer if scope.none? Rails.logger.info("No transactions to auto-categorize for family #{family.id}") - return + return 0 else Rails.logger.info("Auto-categorizing #{scope.count} transactions for family #{family.id}") end @@ -20,7 +20,7 @@ class Family::AutoCategorizer if categories_input.empty? Rails.logger.error("Cannot auto-categorize transactions for family #{family.id}: no categories available") - return + return 0 end result = llm_provider.auto_categorize( @@ -31,23 +31,28 @@ class Family::AutoCategorizer unless result.success? Rails.logger.error("Failed to auto-categorize transactions for family #{family.id}: #{result.error.message}") - return + return 0 end + modified_count = 0 scope.each do |transaction| auto_categorization = result.data.find { |c| c.transaction_id == transaction.id } category_id = categories_input.find { |c| c[:name] == auto_categorization&.category_name }&.dig(:id) if category_id.present? - transaction.enrich_attribute( + was_modified = transaction.enrich_attribute( :category_id, category_id, source: "ai" ) transaction.lock_attr!(:category_id) + # enrich_attribute returns true if the transaction was actually modified + modified_count += 1 if was_modified end end + + modified_count end private diff --git a/app/models/family/auto_merchant_detector.rb b/app/models/family/auto_merchant_detector.rb index be1cbcebb..2877d5c38 100644 --- a/app/models/family/auto_merchant_detector.rb +++ b/app/models/family/auto_merchant_detector.rb @@ -11,7 +11,7 @@ class Family::AutoMerchantDetector if scope.none? Rails.logger.info("No transactions to auto-detect merchants for family #{family.id}") - return + return 0 else Rails.logger.info("Auto-detecting merchants for #{scope.count} transactions for family #{family.id}") end @@ -24,9 +24,10 @@ class Family::AutoMerchantDetector unless result.success? Rails.logger.error("Failed to auto-detect merchants for family #{family.id}: #{result.error.message}") - return + return 0 end + modified_count = 0 scope.each do |transaction| auto_detection = result.data.find { |c| c.transaction_id == transaction.id } @@ -45,15 +46,19 @@ class Family::AutoMerchantDetector merchant_id = merchant_id || ai_provider_merchant&.id if merchant_id.present? - transaction.enrich_attribute( + was_modified = transaction.enrich_attribute( :merchant_id, merchant_id, source: "ai" ) # We lock the attribute so that this Rule doesn't try to run again transaction.lock_attr!(:merchant_id) + # enrich_attribute returns true if the transaction was actually modified + modified_count += 1 if was_modified end end + + modified_count end private diff --git a/app/models/rule.rb b/app/models/rule.rb index 9632cdff2..4ffb8caa2 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -4,6 +4,7 @@ class Rule < ApplicationRecord belongs_to :family has_many :conditions, dependent: :destroy has_many :actions, dependent: :destroy + has_many :rule_runs, dependent: :destroy accepts_nested_attributes_for :conditions, allow_destroy: true accepts_nested_attributes_for :actions, allow_destroy: true @@ -39,9 +40,30 @@ class Rule < ApplicationRecord matching_resources_scope.count end - def apply(ignore_attribute_locks: false) + def apply(ignore_attribute_locks: false, rule_run: nil) + total_modified = 0 + total_async_jobs = 0 + has_async = false + actions.each do |action| - action.apply(matching_resources_scope, ignore_attribute_locks: ignore_attribute_locks) + result = action.apply(matching_resources_scope, ignore_attribute_locks: ignore_attribute_locks, rule_run: rule_run) + + if result.is_a?(Hash) && result[:async] + has_async = true + total_async_jobs += result[:jobs_count] || 0 + total_modified += result[:modified_count] || 0 + elsif result.is_a?(Integer) + total_modified += result + else + # Log unexpected result type but don't fail + Rails.logger.warn("Rule#apply: Unexpected result type from action #{action.id}: #{result.class} (value: #{result.inspect})") + end + end + + if has_async + { modified_count: total_modified, async: true, jobs_count: total_async_jobs } + else + total_modified end end diff --git a/app/models/rule/action.rb b/app/models/rule/action.rb index 5945503ef..c415c59eb 100644 --- a/app/models/rule/action.rb +++ b/app/models/rule/action.rb @@ -3,8 +3,8 @@ class Rule::Action < ApplicationRecord validates :action_type, presence: true - def apply(resource_scope, ignore_attribute_locks: false) - executor.execute(resource_scope, value: value, ignore_attribute_locks: ignore_attribute_locks) + def apply(resource_scope, ignore_attribute_locks: false, rule_run: nil) + executor.execute(resource_scope, value: value, ignore_attribute_locks: ignore_attribute_locks, rule_run: rule_run) || 0 end def options diff --git a/app/models/rule/action_executor.rb b/app/models/rule/action_executor.rb index 4a9a1185d..37f1d2541 100644 --- a/app/models/rule/action_executor.rb +++ b/app/models/rule/action_executor.rb @@ -21,7 +21,7 @@ class Rule::ActionExecutor nil end - def execute(scope, value: nil, ignore_attribute_locks: false) + def execute(scope, value: nil, ignore_attribute_locks: false, rule_run: nil) raise NotImplementedError, "Action executor #{self.class.name} must implement #execute" end @@ -34,6 +34,35 @@ class Rule::ActionExecutor } end + protected + # Helper method to track modified count during enrichment + # The block should return true if the resource was modified, false otherwise + # If the block doesn't return a value, we'll check previous_changes as a fallback + def count_modified_resources(scope) + modified_count = 0 + scope.each do |resource| + # Yield the resource and capture the return value if the block returns one + block_result = yield resource + + # If the block explicitly returned a boolean, use that + if block_result == true || block_result == false + was_modified = block_result + else + # Otherwise, check previous_changes as fallback + was_modified = resource.previous_changes.any? + + # For Transaction resources, check the entry if the transaction itself wasn't modified + if !was_modified && resource.respond_to?(:entry) + entry = resource.entry + was_modified = entry&.previous_changes&.any? || false + end + end + + modified_count += 1 if was_modified + end + modified_count + end + private attr_reader :rule diff --git a/app/models/rule/action_executor/auto_categorize.rb b/app/models/rule/action_executor/auto_categorize.rb index 3ea40b70c..a3701089d 100644 --- a/app/models/rule/action_executor/auto_categorize.rb +++ b/app/models/rule/action_executor/auto_categorize.rb @@ -29,17 +29,31 @@ class Rule::ActionExecutor::AutoCategorize < Rule::ActionExecutor end end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) enrichable_transactions = transaction_scope.enrichable(:category_id) if enrichable_transactions.empty? Rails.logger.info("No transactions to auto-categorize for #{rule.id}") - return + return 0 end - enrichable_transactions.in_batches(of: 20).each_with_index do |transactions, idx| + batch_size = 20 + jobs_count = 0 + + enrichable_transactions.in_batches(of: batch_size).each_with_index do |transactions, idx| Rails.logger.info("Scheduling auto-categorization for batch #{idx + 1} of #{enrichable_transactions.count}") - rule.family.auto_categorize_transactions_later(transactions) + rule.family.auto_categorize_transactions_later(transactions, rule_run_id: rule_run&.id) + jobs_count += 1 end + + # Return metadata about async jobs + # Note: modified_count is set to queued_count here because we don't know + # the actual modified count until the async jobs complete + # The actual modified count will be reported back via rule_run.complete_job! + { + async: true, + modified_count: enrichable_transactions.count, + jobs_count: jobs_count + } end end diff --git a/app/models/rule/action_executor/auto_detect_merchants.rb b/app/models/rule/action_executor/auto_detect_merchants.rb index 87c046825..15151b31c 100644 --- a/app/models/rule/action_executor/auto_detect_merchants.rb +++ b/app/models/rule/action_executor/auto_detect_merchants.rb @@ -7,17 +7,31 @@ class Rule::ActionExecutor::AutoDetectMerchants < Rule::ActionExecutor end end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) enrichable_transactions = transaction_scope.enrichable(:merchant_id) if enrichable_transactions.empty? Rails.logger.info("No transactions to auto-detect merchants for #{rule.id}") - return + return 0 end - enrichable_transactions.in_batches(of: 20).each_with_index do |transactions, idx| + batch_size = 20 + jobs_count = 0 + + enrichable_transactions.in_batches(of: batch_size).each_with_index do |transactions, idx| Rails.logger.info("Scheduling auto-merchant-enrichment for batch #{idx + 1} of #{enrichable_transactions.count}") - rule.family.auto_detect_transaction_merchants_later(transactions) + rule.family.auto_detect_transaction_merchants_later(transactions, rule_run_id: rule_run&.id) + jobs_count += 1 end + + # Return metadata about async jobs + # Note: modified_count is set to queued_count here because we don't know + # the actual modified count until the async jobs complete + # The actual modified count will be reported back via rule_run.complete_job! + { + async: true, + modified_count: enrichable_transactions.count, + jobs_count: jobs_count + } end end diff --git a/app/models/rule/action_executor/set_transaction_category.rb b/app/models/rule/action_executor/set_transaction_category.rb index 2da95bbac..e14bf33a7 100644 --- a/app/models/rule/action_executor/set_transaction_category.rb +++ b/app/models/rule/action_executor/set_transaction_category.rb @@ -7,7 +7,7 @@ class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor family.categories.alphabetically.pluck(:name, :id) end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) category = family.categories.find_by_id(value) scope = transaction_scope @@ -16,7 +16,8 @@ class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor scope = scope.enrichable(:category_id) end - scope.each do |txn| + count_modified_resources(scope) do |txn| + # enrich_attribute returns true if the transaction was actually modified, false otherwise txn.enrich_attribute( :category_id, category.id, diff --git a/app/models/rule/action_executor/set_transaction_merchant.rb b/app/models/rule/action_executor/set_transaction_merchant.rb index f9693ddac..0b0d43ad3 100644 --- a/app/models/rule/action_executor/set_transaction_merchant.rb +++ b/app/models/rule/action_executor/set_transaction_merchant.rb @@ -7,16 +7,17 @@ class Rule::ActionExecutor::SetTransactionMerchant < Rule::ActionExecutor family.merchants.alphabetically.pluck(:name, :id) end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) merchant = family.merchants.find_by_id(value) - return unless merchant + return 0 unless merchant scope = transaction_scope unless ignore_attribute_locks scope = scope.enrichable(:merchant_id) end - scope.each do |txn| + count_modified_resources(scope) do |txn| + # enrich_attribute returns true if the transaction was actually modified, false otherwise txn.enrich_attribute( :merchant_id, merchant.id, diff --git a/app/models/rule/action_executor/set_transaction_name.rb b/app/models/rule/action_executor/set_transaction_name.rb index 1dd89fa30..4d4c84d8a 100644 --- a/app/models/rule/action_executor/set_transaction_name.rb +++ b/app/models/rule/action_executor/set_transaction_name.rb @@ -7,15 +7,20 @@ class Rule::ActionExecutor::SetTransactionName < Rule::ActionExecutor nil end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) - return if value.blank? + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) + return 0 if value.blank? - scope = transaction_scope + scope = transaction_scope.with_entry unless ignore_attribute_locks - scope = scope.enrichable(:name) + # Filter by entry's locked_attributes, not transaction's + # Since name is on Entry, not Transaction, we need to check entries.locked_attributes + scope = scope.where.not( + Arel.sql("entries.locked_attributes ? 'name'") + ) end - scope.each do |txn| + count_modified_resources(scope) do |txn| + # enrich_attribute returns true if the entry was actually modified, false otherwise txn.entry.enrich_attribute( :name, value, diff --git a/app/models/rule/action_executor/set_transaction_tags.rb b/app/models/rule/action_executor/set_transaction_tags.rb index f317f9602..c1dbc30a2 100644 --- a/app/models/rule/action_executor/set_transaction_tags.rb +++ b/app/models/rule/action_executor/set_transaction_tags.rb @@ -7,7 +7,7 @@ class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor family.tags.alphabetically.pluck(:name, :id) end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) tag = family.tags.find_by_id(value) scope = transaction_scope @@ -16,7 +16,7 @@ class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor scope = scope.enrichable(:tag_ids) end - rows = scope.each do |txn| + count_modified_resources(scope) do |txn| txn.enrich_attribute( :tag_ids, [ tag.id ], diff --git a/app/models/rule_run.rb b/app/models/rule_run.rb new file mode 100644 index 000000000..8f0ac759f --- /dev/null +++ b/app/models/rule_run.rb @@ -0,0 +1,42 @@ +class RuleRun < ApplicationRecord + belongs_to :rule + + validates :execution_type, inclusion: { in: %w[manual scheduled] } + validates :status, inclusion: { in: %w[pending success failed] } + validates :executed_at, presence: true + validates :transactions_queued, numericality: { greater_than_or_equal_to: 0 } + validates :transactions_processed, numericality: { greater_than_or_equal_to: 0 } + validates :transactions_modified, numericality: { greater_than_or_equal_to: 0 } + validates :pending_jobs_count, numericality: { greater_than_or_equal_to: 0 } + + scope :recent, -> { order(executed_at: :desc) } + scope :for_rule, ->(rule) { where(rule: rule) } + scope :successful, -> { where(status: "success") } + scope :failed, -> { where(status: "failed") } + scope :pending, -> { where(status: "pending") } + + def pending? + status == "pending" + end + + def success? + status == "success" + end + + def failed? + status == "failed" + end + + # Thread-safe method to complete a job and update the run + def complete_job!(modified_count: 0) + with_lock do + increment!(:transactions_modified, modified_count) + decrement!(:pending_jobs_count) + + # If all jobs are done, mark as success + if pending_jobs_count <= 0 + update!(status: "success") + end + end + end +end diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 49062f691..23613b959 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -81,3 +81,89 @@ <% end %> + + +<% if @recent_runs.any? %> +
<%= t("rules.recent_runs.description") %>
+| + <%= t("rules.recent_runs.columns.date_time") %> + | ++ <%= t("rules.recent_runs.columns.execution_type") %> + | ++ <%= t("rules.recent_runs.columns.status") %> + | ++ <%= t("rules.recent_runs.columns.rule_name") %> + | +
+
+
+ <%= t("rules.recent_runs.columns.transactions_counts.queued") %>
+ <%= t("rules.recent_runs.columns.transactions_counts.processed") %>
+ <%= t("rules.recent_runs.columns.transactions_counts.modified") %>
+ |
+
|---|---|---|---|---|
| + <%= run.executed_at.strftime("%b %d, %Y %I:%M %p") %> + | ++ + <%= t("rules.recent_runs.execution_types.#{run.execution_type}") %> + + | +
+
+ <% if run.pending? %>
+
+ <%= t("rules.recent_runs.statuses.#{run.status}") %>
+
+ <% elsif run.success? %>
+
+ <%= t("rules.recent_runs.statuses.#{run.status}") %>
+
+ <% else %>
+
+ <%= t("rules.recent_runs.statuses.#{run.status}") %>
+
+ <% end %>
+ <% if run.failed? && run.error_message.present? %>
+
+
+ <%= icon("info", size: "sm", class: "text-red-500") %>
+
+ <% end %>
+ |
+ + <%= run.rule_name.presence || run.rule&.name.presence || t("rules.recent_runs.unnamed_rule") %> + | ++ <%= "#{number_with_delimiter(run.transactions_queued)} / #{number_with_delimiter(run.transactions_processed)} / #{number_with_delimiter(run.transactions_modified)}" %> + | +