From bf90cad9a090c2dad2db9923b765e55171f32040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 7 Dec 2025 16:30:02 +0100 Subject: [PATCH] Add Recent Runs visibility for rule executions (#376) * Add Recent Runs visibility for rule executions Adds a comprehensive tracking system for rule execution history with the following features: - Creates RuleRun model to track execution metadata: * Date/time of execution * Execution type (manual/scheduled) * Success/failure status * Rule reference * Transaction counts (processed and modified) * Error messages for failed runs - Updates RuleJob to automatically record execution results: * Captures transaction processing statistics * Handles success/failure states * Stores error details for debugging - Adds "Recent Runs" section to rules index page: * Paginated display (20 runs per page) * Columnar layout similar to LLM usage page * Visual status indicators (success/failed badges) * Error tooltips for failed runs * Responsive design with design system tokens - Includes i18n translations for all user-facing strings This provides users with visibility into rule execution history, making it easier to debug issues and monitor rule performance. * Update schema.rb with rule_runs table definition * Linter noise * Separate transaction counts into Queued, Processed, and Modified Previously, the code eagerly reported transactions as "processed" when they were only queued for processing. This commit separates the counts into three distinct metrics: - Transactions Queued: Count of transactions matching the rule's filter conditions before any processing begins - Transactions Processed: Count of transactions that were actually processed and modified by the rule actions - Transactions Modified: Count of transactions that had their values changed (currently same as Processed, but allows for future differentiation) Changes: - Add transactions_queued column to rule_runs table - Update RuleJob to track all three counts separately - Update action executors to return count of modified transactions - Update Rule#apply to aggregate modification counts from actions - Add transactions_queued label to locales - Update Recent Runs view to display new column - Add validation for transactions_queued in RuleRun model The tracking now correctly reports: 1. How many transactions matched the filter (queued) 2. How many were actually modified (processed/modified) 3. Distinguishes between matching and modifying transactions * Add Pending status to track async rule execution progress Introduced a new "pending" status for rule runs to properly track async AI operations. The system now: - Tracks pending async jobs with a counter that decrements as jobs complete - Updates transactions_modified incrementally as each job finishes - Only counts transactions that were actually modified (not just queued) - Displays pending status with yellow badge in the UI - Automatically transitions from pending to success when all jobs complete This provides better visibility into long-running AI categorization and merchant detection operations, showing real-time progress as Sidekiq processes the batches. * Fix migration version to 7.2 as per project standards * Consolidate rule_runs migrations into single migration file Merged three separate migrations (create, add_transactions_queued, add_pending_jobs_count) into a single CreateRuleRuns migration. This provides better clarity and maintains a clean migration history. Changes: - Updated CreateRuleRuns migration to include all columns upfront - Removed redundant add_column migrations - Updated schema version to 2025_11_24_000000 * Linter and test fixes * Space optimization * LLM l10n is better than no l10n * Fix implementation for tags/AI rules * Fix tests * Use batch_size * Consider jobs "unknown" status sometimes * Rabbit suggestion * Rescue block for RuleRun.create! --------- Co-authored-by: Claude --- app/controllers/rules_controller.rb | 10 +++ app/jobs/auto_categorize_job.rb | 10 ++- app/jobs/auto_detect_merchants_job.rb | 10 ++- app/jobs/rule_job.rb | 90 ++++++++++++++++++- app/models/concerns/enrichable.rb | 54 ++++++++++- app/models/family.rb | 8 +- app/models/family/auto_categorizer.rb | 13 ++- app/models/family/auto_merchant_detector.rb | 11 ++- app/models/rule.rb | 26 +++++- app/models/rule/action.rb | 4 +- app/models/rule/action_executor.rb | 31 ++++++- .../rule/action_executor/auto_categorize.rb | 22 ++++- .../action_executor/auto_detect_merchants.rb | 22 ++++- .../set_transaction_category.rb | 5 +- .../set_transaction_merchant.rb | 7 +- .../action_executor/set_transaction_name.rb | 15 ++-- .../action_executor/set_transaction_tags.rb | 4 +- app/models/rule_run.rb | 42 +++++++++ app/views/rules/index.html.erb | 86 ++++++++++++++++++ config/locales/views/rules/ca.yml | 24 +++++ config/locales/views/rules/de.yml | 24 +++++ config/locales/views/rules/en.yml | 23 +++++ config/locales/views/rules/es.yml | 24 +++++ config/locales/views/rules/nb.yml | 24 +++++ config/locales/views/rules/ro.yml | 24 +++++ config/locales/views/rules/tr.yml | 24 +++++ db/migrate/20251124000000_create_rule_runs.rb | 21 +++++ db/schema.rb | 21 ++++- test/models/rule/action_test.rb | 2 +- 29 files changed, 636 insertions(+), 45 deletions(-) create mode 100644 app/models/rule_run.rb create mode 100644 config/locales/views/rules/ca.yml create mode 100644 config/locales/views/rules/de.yml create mode 100644 config/locales/views/rules/en.yml create mode 100644 config/locales/views/rules/es.yml create mode 100644 config/locales/views/rules/nb.yml create mode 100644 config/locales/views/rules/ro.yml create mode 100644 config/locales/views/rules/tr.yml create mode 100644 db/migrate/20251124000000_create_rule_runs.rb 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.title") %>

+

<%= t("rules.recent_runs.description") %>

+
+ +
+ + + + + + + + + + + + <% @recent_runs.each do |run| %> + + + + + + + + <% end %> + +
+ <%= 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)}" %> +
+
+ + <% if @pagy.pages > 1 %> +
+ <%= render "shared/pagination", pagy: @pagy %> +
+ <% end %> +
+<% end %> diff --git a/config/locales/views/rules/ca.yml b/config/locales/views/rules/ca.yml new file mode 100644 index 000000000..33843b8f4 --- /dev/null +++ b/config/locales/views/rules/ca.yml @@ -0,0 +1,24 @@ +--- +ca: + rules: + recent_runs: + title: Execucions Recents + description: Visualitza l'historial d'execució de les teves regles incloent l'estat d'èxit/fallada i els recomptes de transaccions. + unnamed_rule: Regla Sense Nom + columns: + date_time: Data/Hora + execution_type: Tipus + status: Estat + rule_name: Nom de la Regla + transactions_counts: + queued: En Cua + processed: Processades + modified: Modificades + execution_types: + manual: Manual + scheduled: Programada + statuses: + pending: Pendent + success: Èxit + failed: Fallada + diff --git a/config/locales/views/rules/de.yml b/config/locales/views/rules/de.yml new file mode 100644 index 000000000..d86ceebd5 --- /dev/null +++ b/config/locales/views/rules/de.yml @@ -0,0 +1,24 @@ +--- +de: + rules: + recent_runs: + title: Letzte Ausführungen + description: Zeige die Ausführungsgeschichte deiner Regeln einschließlich Erfolgs-/Fehlerstatus und Transaktionsanzahlen. + unnamed_rule: Unbenannte Regel + columns: + date_time: Datum/Uhrzeit + execution_type: Typ + status: Status + rule_name: Regelname + transactions_counts: + queued: In Warteschlange + processed: Verarbeitet + modified: Geändert + execution_types: + manual: Manuell + scheduled: Geplant + statuses: + pending: Ausstehend + success: Erfolgreich + failed: Fehlgeschlagen + diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml new file mode 100644 index 000000000..7a1837e45 --- /dev/null +++ b/config/locales/views/rules/en.yml @@ -0,0 +1,23 @@ +--- +en: + rules: + recent_runs: + title: Recent Runs + description: View the execution history of your rules including success/failure status and transaction counts. + unnamed_rule: Unnamed Rule + columns: + date_time: Date/Time + execution_type: Type + status: Status + rule_name: Rule Name + transactions_counts: + queued: Queued + processed: Processed + modified: Modified + execution_types: + manual: Manual + scheduled: Scheduled + statuses: + pending: Pending + success: Success + failed: Failed diff --git a/config/locales/views/rules/es.yml b/config/locales/views/rules/es.yml new file mode 100644 index 000000000..701038479 --- /dev/null +++ b/config/locales/views/rules/es.yml @@ -0,0 +1,24 @@ +--- +es: + rules: + recent_runs: + title: Ejecuciones Recientes + description: Ver el historial de ejecución de tus reglas incluyendo el estado de éxito/fallo y los conteos de transacciones. + unnamed_rule: Regla Sin Nombre + columns: + date_time: Fecha/Hora + execution_type: Tipo + status: Estado + rule_name: Nombre de Regla + transactions_counts: + queued: En Cola + processed: Procesadas + modified: Modificadas + execution_types: + manual: Manual + scheduled: Programada + statuses: + pending: Pendiente + success: Éxito + failed: Fallido + diff --git a/config/locales/views/rules/nb.yml b/config/locales/views/rules/nb.yml new file mode 100644 index 000000000..a1b7aeb06 --- /dev/null +++ b/config/locales/views/rules/nb.yml @@ -0,0 +1,24 @@ +--- +nb: + rules: + recent_runs: + title: Siste Kjøringer + description: Se kjøringsloggen for reglene dine inkludert suksess/feil-status og transaksjonsantall. + unnamed_rule: Navnløs Regel + columns: + date_time: Dato/Tid + execution_type: Type + status: Status + rule_name: Regelnavn + transactions_counts: + queued: I Kø + processed: Behandlet + modified: Endret + execution_types: + manual: Manuell + scheduled: Planlagt + statuses: + pending: Ventende + success: Vellykket + failed: Mislyktes + diff --git a/config/locales/views/rules/ro.yml b/config/locales/views/rules/ro.yml new file mode 100644 index 000000000..d4e7df3a5 --- /dev/null +++ b/config/locales/views/rules/ro.yml @@ -0,0 +1,24 @@ +--- +ro: + rules: + recent_runs: + title: Rulări Recente + description: Vezi istoricul de execuție al regulilor tale incluzând statusul de succes/eșec și numărul de tranzacții. + unnamed_rule: Regulă Fără Nume + columns: + date_time: Dată/Ora + execution_type: Tip + status: Status + rule_name: Nume Regulă + transactions_counts: + queued: În Așteptare + processed: Procesate + modified: Modificate + execution_types: + manual: Manual + scheduled: Programată + statuses: + pending: În Așteptare + success: Succes + failed: Eșuat + diff --git a/config/locales/views/rules/tr.yml b/config/locales/views/rules/tr.yml new file mode 100644 index 000000000..8318415b1 --- /dev/null +++ b/config/locales/views/rules/tr.yml @@ -0,0 +1,24 @@ +--- +tr: + rules: + recent_runs: + title: Son Çalıştırmalar + description: Başarı/başarısızlık durumu ve işlem sayıları dahil olmak üzere kurallarınızın yürütme geçmişini görüntüleyin. + unnamed_rule: İsimsiz Kural + columns: + date_time: Tarih/Saat + execution_type: Tür + status: Durum + rule_name: Kural Adı + transactions_counts: + queued: Kuyruğa Alındı + processed: İşlendi + modified: Değiştirildi + execution_types: + manual: Manuel + scheduled: Zamanlanmış + statuses: + pending: Beklemede + success: Başarılı + failed: Başarısız + diff --git a/db/migrate/20251124000000_create_rule_runs.rb b/db/migrate/20251124000000_create_rule_runs.rb new file mode 100644 index 000000000..40ed831fa --- /dev/null +++ b/db/migrate/20251124000000_create_rule_runs.rb @@ -0,0 +1,21 @@ +class CreateRuleRuns < ActiveRecord::Migration[7.2] + def change + create_table :rule_runs, id: :uuid do |t| + t.references :rule, null: false, foreign_key: true, type: :uuid + t.string :rule_name + t.string :execution_type, null: false + t.string :status, null: false + t.integer :transactions_queued, null: false, default: 0 + t.integer :transactions_processed, null: false, default: 0 + t.integer :transactions_modified, null: false, default: 0 + t.integer :pending_jobs_count, null: false, default: 0 + t.datetime :executed_at, null: false + t.text :error_message + + t.timestamps + end + + add_index :rule_runs, :executed_at + add_index :rule_runs, [ :rule_id, :executed_at ] + end +end diff --git a/db/schema.rb b/db/schema.rb index bd35087c4..bec5fc441 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_26_094446) do +ActiveRecord::Schema[7.2].define(version: 2025_12_06_131244) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -800,6 +800,24 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_094446) do t.index ["family_id"], name: "index_rules_on_family_id" end + create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "rule_id", null: false + t.string "rule_name" + t.string "execution_type", null: false + t.string "status", null: false + t.integer "transactions_queued", default: 0, null: false + t.integer "transactions_processed", default: 0, null: false + t.integer "transactions_modified", default: 0, null: false + t.integer "pending_jobs_count", default: 0, null: false + t.datetime "executed_at", null: false + t.text "error_message" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["executed_at"], name: "index_rule_runs_on_executed_at" + t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" + t.index ["rule_id"], name: "index_rule_runs_on_rule_id" + end + create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "ticker", null: false t.string "name" @@ -1106,6 +1124,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_094446) do add_foreign_key "rule_actions", "rules" add_foreign_key "rule_conditions", "rule_conditions", column: "parent_id" add_foreign_key "rule_conditions", "rules" + add_foreign_key "rule_runs", "rules" add_foreign_key "rules", "families" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" diff --git a/test/models/rule/action_test.rb b/test/models/rule/action_test.rb index f71bb2cbe..db3933ea0 100644 --- a/test/models/rule/action_test.rb +++ b/test/models/rule/action_test.rb @@ -84,7 +84,7 @@ class Rule::ActionTest < ActiveSupport::TestCase new_name = "Renamed Transaction" # Does not modify transactions that are locked (user edited them) - @txn1.lock_attr!(:name) + @txn1.entry.lock_attr!(:name) action = Rule::Action.new( rule: @transaction_rule,