diff --git a/plugins/automation/admin/assets/javascripts/admin/components/automation-list.gjs b/plugins/automation/admin/assets/javascripts/admin/components/automation-list.gjs new file mode 100644 index 00000000000..b42ec03ab02 --- /dev/null +++ b/plugins/automation/admin/assets/javascripts/admin/components/automation-list.gjs @@ -0,0 +1,233 @@ +import Component from "@glimmer/component"; +import { fn } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { LinkTo } from "@ember/routing"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import DPageSubheader from "discourse/components/d-page-subheader"; +import DToggleSwitch from "discourse/components/d-toggle-switch"; +import avatar from "discourse/helpers/avatar"; +import formatDate from "discourse/helpers/format-date"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { escapeExpression } from "discourse/lib/utilities"; +import { i18n } from "discourse-i18n"; +import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list"; + +// number of runs required to show the runs count for the period +const RUN_THRESHOLD = 10; + +export default class AutomationList extends Component { + @service dialog; + @service router; + + @action + destroyAutomation(automation) { + this.dialog.deleteConfirm({ + message: i18n("discourse_automation.destroy_automation.confirm", { + name: escapeExpression(automation.name), + }), + didConfirm: () => { + return automation + .destroyRecord() + .then(() => this.send("triggerRefresh")) + .catch(popupAjaxError); + }, + }); + } + + @action + async toggleEnabled(automation) { + automation.set("enabled", !automation.enabled); + try { + await automation.save({ enabled: automation.enabled }); + } catch (e) { + popupAjaxError(e); + automation.set("enabled", !automation.enabled); + } + } + + statsText(stats) { + if (!stats || !stats.last_month || stats.last_month.total_runs === 0) { + return "-"; + } + + if (stats.last_day?.total_runs > RUN_THRESHOLD) { + return i18n("discourse_automation.models.automation.runs_today", { + count: stats.last_day.total_runs, + }); + } + + if (stats.last_week?.total_runs > RUN_THRESHOLD) { + return i18n("discourse_automation.models.automation.runs_this_week", { + count: stats.last_day.total_runs, + }); + } + + return i18n("discourse_automation.models.automation.runs_this_month", { + count: stats.last_day.total_runs, + }); + } + + +} diff --git a/plugins/automation/admin/assets/javascripts/admin/helpers/format-enabled-automation.js b/plugins/automation/admin/assets/javascripts/admin/helpers/format-enabled-automation.js deleted file mode 100644 index 6d55994833b..00000000000 --- a/plugins/automation/admin/assets/javascripts/admin/helpers/format-enabled-automation.js +++ /dev/null @@ -1,20 +0,0 @@ -import { htmlSafe } from "@ember/template"; -import { iconHTML } from "discourse/lib/icon-library"; - -export default function formatEnabledAutomation(enabled, trigger) { - if (enabled && trigger.id) { - return htmlSafe( - iconHTML("check", { - class: "enabled-automation", - title: "discourse_automation.models.automation.enabled.label", - }) - ); - } else { - return htmlSafe( - iconHTML("xmark", { - class: "disabled-automation", - title: "discourse_automation.models.automation.disabled.label", - }) - ); - } -} diff --git a/plugins/automation/admin/assets/javascripts/discourse/templates/admin-plugins/show/automation/index.hbs b/plugins/automation/admin/assets/javascripts/discourse/templates/admin-plugins/show/automation/index.hbs index 5bb1e7fa297..29533471818 100644 --- a/plugins/automation/admin/assets/javascripts/discourse/templates/admin-plugins/show/automation/index.hbs +++ b/plugins/automation/admin/assets/javascripts/discourse/templates/admin-plugins/show/automation/index.hbs @@ -1,131 +1 @@ -
- - <:actions as |actions|> - - - - - {{#if this.model.length}} - - - - - - - - - - - - - {{#each this.model as |automation|}} - - {{#if automation.script.not_found}} - - {{else if automation.trigger.not_found}} - - {{else}} - - - - - - {{/if}} - - - - {{/each}} - -
{{i18n "discourse_automation.models.automation.name.label"}}{{i18n - "discourse_automation.models.automation.trigger.label" - }}{{i18n - "discourse_automation.models.automation.script.label" - }}{{i18n - "discourse_automation.models.automation.last_updated_by.label" - }}
-
- {{i18n "discourse_automation.models.automation.status.label"}} -
- {{i18n - "discourse_automation.scriptables.not_found" - script=automation.script.id - automation=automation.name - }} -
-
- {{i18n "discourse_automation.models.automation.status.label"}} -
- {{i18n - "discourse_automation.triggerables.not_found" - trigger=automation.trigger.id - automation=automation.name - }} -
-
- {{i18n "discourse_automation.models.automation.status.label"}} -
- {{format-enabled-automation - automation.enabled - automation.trigger - }} -
- {{if - automation.name - automation.name - (i18n "discourse_automation.unnamed_automation") - }} - -
- {{i18n - "discourse_automation.models.automation.trigger.label" - }} -
- {{if automation.trigger.id automation.trigger.name "-"}} -
-
- {{i18n "discourse_automation.models.automation.script.label"}} -
- {{automation.script.name}} - (v{{automation.script.version}}) -
-
- {{i18n - "discourse_automation.models.automation.last_updated_by.label" - }} -
-
- - {{avatar automation.last_updated_by imageSize="small"}} - - {{format-date automation.updated_at leaveAgo="true"}} -
-
- - - -
- {{else}} - - {{/if}} -
\ No newline at end of file + \ No newline at end of file diff --git a/plugins/automation/app/controllers/discourse_automation/admin_automations_controller.rb b/plugins/automation/app/controllers/discourse_automation/admin_automations_controller.rb index 85a3e440d0a..908fd467aa6 100644 --- a/plugins/automation/app/controllers/discourse_automation/admin_automations_controller.rb +++ b/plugins/automation/app/controllers/discourse_automation/admin_automations_controller.rb @@ -11,6 +11,9 @@ module DiscourseAutomation automations, each_serializer: DiscourseAutomation::AutomationSerializer, root: "automations", + scope: { + stats: DiscourseAutomation::Stat.fetch_period_summaries, + }, ).as_json render_json_dump(serializer) end @@ -50,31 +53,35 @@ module DiscourseAutomation last_updated_by_id: current_user.id, ) - if automation.trigger != params[:automation][:trigger] + if attributes.key?(:trigger) && automation.trigger != params[:automation][:trigger] params[:automation][:fields] = [] attributes[:enabled] = false automation.fields.destroy_all end - if automation.script != params[:automation][:script] - attributes[:trigger] = nil - params[:automation][:fields] = [] - attributes[:enabled] = false - automation.fields.destroy_all - automation.tap { |r| r.assign_attributes(attributes) }.save!(validate: false) - else - Array(params[:automation][:fields]) - .reject(&:empty?) - .each do |field| - automation.upsert_field!( - field[:name], - field[:component], - field[:metadata], - target: field[:target], - ) - end + if attributes.key?(:script) + if automation.script != params[:automation][:script] + attributes[:trigger] = nil + params[:automation][:fields] = [] + attributes[:enabled] = false + automation.fields.destroy_all + automation.tap { |r| r.assign_attributes(attributes) }.save!(validate: false) + else + Array(params[:automation][:fields]) + .reject(&:empty?) + .each do |field| + automation.upsert_field!( + field[:name], + field[:component], + field[:metadata], + target: field[:target], + ) + end - automation.tap { |r| r.assign_attributes(attributes) }.save! + automation.update!(attributes) + end + else + automation.update!(attributes) end render_serialized_automation(automation) diff --git a/plugins/automation/app/models/discourse_automation/automation.rb b/plugins/automation/app/models/discourse_automation/automation.rb index 9d671e069de..8df2b6b96d2 100644 --- a/plugins/automation/app/models/discourse_automation/automation.rb +++ b/plugins/automation/app/models/discourse_automation/automation.rb @@ -17,6 +17,8 @@ module DiscourseAutomation dependent: :delete_all, foreign_key: "automation_id" + has_many :stats, class_name: "DiscourseAutomation::Stat", dependent: :delete_all + validates :script, presence: true validate :validate_trigger_fields @@ -137,8 +139,10 @@ module DiscourseAutomation if scriptable.background && !running_in_background trigger_in_background!(context) else - triggerable&.on_call&.call(self, serialized_fields) - scriptable.script.call(context, serialized_fields, self) + Stat.log(id) do + triggerable&.on_call&.call(self, serialized_fields) + scriptable.script.call(context, serialized_fields, self) + end end ensure DiscourseAutomation.set_active_automation(nil) @@ -187,3 +191,17 @@ module DiscourseAutomation end end end + +# == Schema Information +# +# Table name: discourse_automation_automations +# +# id :bigint not null, primary key +# name :string +# script :string not null +# enabled :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# last_updated_by_id :integer not null +# trigger :string +# diff --git a/plugins/automation/app/models/discourse_automation/field.rb b/plugins/automation/app/models/discourse_automation/field.rb index 6e6689f4ea6..601773b6cc7 100644 --- a/plugins/automation/app/models/discourse_automation/field.rb +++ b/plugins/automation/app/models/discourse_automation/field.rb @@ -250,3 +250,17 @@ module DiscourseAutomation end end end + +# == Schema Information +# +# Table name: discourse_automation_fields +# +# id :bigint not null, primary key +# automation_id :bigint not null +# metadata :jsonb not null +# component :string not null +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# target :string +# diff --git a/plugins/automation/app/models/discourse_automation/pending_automation.rb b/plugins/automation/app/models/discourse_automation/pending_automation.rb index f6c4e0e6e65..de7f85c6a1f 100644 --- a/plugins/automation/app/models/discourse_automation/pending_automation.rb +++ b/plugins/automation/app/models/discourse_automation/pending_automation.rb @@ -7,3 +7,14 @@ module DiscourseAutomation belongs_to :automation, class_name: "DiscourseAutomation::Automation" end end + +# == Schema Information +# +# Table name: discourse_automation_pending_automations +# +# id :bigint not null, primary key +# automation_id :bigint not null +# execute_at :datetime not null +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/plugins/automation/app/models/discourse_automation/pending_pm.rb b/plugins/automation/app/models/discourse_automation/pending_pm.rb index b696ee09c27..aa87d302a1e 100644 --- a/plugins/automation/app/models/discourse_automation/pending_pm.rb +++ b/plugins/automation/app/models/discourse_automation/pending_pm.rb @@ -7,3 +7,19 @@ module DiscourseAutomation belongs_to :automation, class_name: "DiscourseAutomation::Automation" end end + +# == Schema Information +# +# Table name: discourse_automation_pending_pms +# +# id :bigint not null, primary key +# target_usernames :string is an Array +# sender :string +# title :string +# raw :string +# automation_id :bigint not null +# execute_at :datetime not null +# created_at :datetime not null +# updated_at :datetime not null +# prefers_encrypt :boolean default(FALSE), not null +# diff --git a/plugins/automation/app/models/discourse_automation/stat.rb b/plugins/automation/app/models/discourse_automation/stat.rb new file mode 100644 index 00000000000..9b85ba8c2b7 --- /dev/null +++ b/plugins/automation/app/models/discourse_automation/stat.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true +# +module DiscourseAutomation + class Stat < ActiveRecord::Base + self.table_name = "discourse_automation_stats" + + def self.log(automation_id, run_time = nil) + if block_given? && run_time.nil? + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = yield + run_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + result + end + ensure + update_stats(automation_id, run_time) + end + + def self.fetch_period_summaries + today = Date.current + + # Define our time periods + periods = { + last_day: { + start_date: today - 1.day, + end_date: today, + }, + last_week: { + start_date: today - 1.week, + end_date: today, + }, + last_month: { + start_date: today - 1.month, + end_date: today, + }, + } + + result = {} + + periods.each do |period_name, date_range| + builder = DB.build <<~SQL + SELECT + automation_id, + SUM(total_runs) AS total_runs, + SUM(total_time) AS total_time, + CASE WHEN SUM(total_runs) > 0 + THEN SUM(total_time) / SUM(total_runs) + ELSE 0 + END AS average_run_time, + MIN(min_run_time) AS min_run_time, + MAX(max_run_time) AS max_run_time + FROM discourse_automation_stats + WHERE date >= :start_date AND date <= :end_date + GROUP BY automation_id + SQL + + stats = builder.query(start_date: date_range[:start_date], end_date: date_range[:end_date]) + + last_run_stats = DB.query_array <<~SQL + SELECT + automation_id, + MAX(last_run_at) AS last_run_at + FROM discourse_automation_stats + GROUP BY automation_id + SQL + last_run_stats = Hash[*last_run_stats.flatten] + + stats.each do |stat| + automation_id = stat.automation_id + result[automation_id] ||= {} + result[automation_id][:last_run_at] = last_run_stats[automation_id] + result[automation_id][period_name] = { + total_runs: stat.total_runs, + total_time: stat.total_time, + average_run_time: stat.average_run_time, + min_run_time: stat.min_run_time, + max_run_time: stat.max_run_time, + } + end + end + + result + end + + def self.update_stats(automation_id, run_time) + today = Date.current + current_time = Time.now + + builder = DB.build <<~SQL + INSERT INTO discourse_automation_stats + (automation_id, date, last_run_at, total_time, average_run_time, min_run_time, max_run_time, total_runs) + VALUES (:automation_id, :date, :current_time, :run_time, :run_time, :run_time, :run_time, 1) + ON CONFLICT (automation_id, date) DO UPDATE SET + last_run_at = :current_time, + total_time = discourse_automation_stats.total_time + :run_time, + total_runs = discourse_automation_stats.total_runs + 1, + average_run_time = (discourse_automation_stats.total_time + :run_time) / (discourse_automation_stats.total_runs + 1), + min_run_time = LEAST(discourse_automation_stats.min_run_time, :run_time), + max_run_time = GREATEST(discourse_automation_stats.max_run_time, :run_time) + SQL + + builder.exec( + automation_id: automation_id, + date: today, + current_time: current_time, + run_time: run_time, + ) + end + end +end + +# == Schema Information +# +# Table name: discourse_automation_stats +# +# id :bigint not null, primary key +# automation_id :bigint not null +# date :date not null +# last_run_at :datetime not null +# total_time :float not null +# average_run_time :float not null +# min_run_time :float not null +# max_run_time :float not null +# total_runs :integer not null +# +# Indexes +# +# index_discourse_automation_stats_on_automation_id_and_date (automation_id,date) UNIQUE +# diff --git a/plugins/automation/app/models/discourse_automation/user_global_notice.rb b/plugins/automation/app/models/discourse_automation/user_global_notice.rb index d41d4643592..5f3ffb921db 100644 --- a/plugins/automation/app/models/discourse_automation/user_global_notice.rb +++ b/plugins/automation/app/models/discourse_automation/user_global_notice.rb @@ -7,3 +7,20 @@ module DiscourseAutomation belongs_to :user end end + +# == Schema Information +# +# Table name: discourse_automation_user_global_notices +# +# id :bigint not null, primary key +# user_id :integer not null +# notice :text not null +# identifier :string not null +# level :string default("info") +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# idx_discourse_automation_user_global_notices (user_id,identifier) UNIQUE +# diff --git a/plugins/automation/app/serializers/discourse_automation/automation_serializer.rb b/plugins/automation/app/serializers/discourse_automation/automation_serializer.rb index 395fedb8115..ad42aad8a48 100644 --- a/plugins/automation/app/serializers/discourse_automation/automation_serializer.rb +++ b/plugins/automation/app/serializers/discourse_automation/automation_serializer.rb @@ -2,15 +2,16 @@ module DiscourseAutomation class AutomationSerializer < ApplicationSerializer - attributes :id - attributes :name - attributes :enabled - attributes :script - attributes :trigger - attributes :updated_at - attributes :last_updated_by - attributes :next_pending_automation_at - attributes :placeholders + attribute :id + attribute :name + attribute :enabled + attribute :script + attribute :trigger + attribute :updated_at + attribute :last_updated_by + attribute :next_pending_automation_at + attribute :placeholders + attribute :stats def last_updated_by BasicUserSerializer.new( @@ -84,6 +85,29 @@ module DiscourseAutomation } end + def include_stats? + scope&.dig(:stats).present? + end + + EMPTY_STATS = { + total_runs: 0, + total_time: 0, + average_run_time: 0, + min_run_time: 0, + max_run_time: 0, + } + + def stats + automation_stats = scope&.dig(:stats, object.id) || {} + + { + last_day: automation_stats[:last_day] || EMPTY_STATS, + last_week: automation_stats[:last_week] || EMPTY_STATS, + last_month: automation_stats[:last_month] || EMPTY_STATS, + last_run_at: automation_stats[:last_run_at], + } + end + private def filter_fields_with_priority(arr, trigger) diff --git a/plugins/automation/assets/stylesheets/common/discourse-automation.scss b/plugins/automation/assets/stylesheets/common/discourse-automation.scss index 061389783e1..2c82c0aa3fd 100644 --- a/plugins/automation/assets/stylesheets/common/discourse-automation.scss +++ b/plugins/automation/assets/stylesheets/common/discourse-automation.scss @@ -1,7 +1,11 @@ .admin-plugins.automation { .automations { .relative-date { - font-size: $font-down-1; + font-size: var(--font-down-1); + } + + &__stats { + font-size: var(--font-down-1); } td[role="button"] { @@ -26,31 +30,25 @@ } } - .d-admin-row__overview { + .d-admin-row__name { @include breakpoint("tablet") { order: 1; } } - .d-admin-row__detail.automations__status { + .d-admin-row__detail.automations__runs { @include breakpoint("tablet") { order: 2; // move below the name to avoid empty spacing } } - .d-admin-row__detail.automations__script { + .d-admin-row__detail.automations__updated-by { @include breakpoint("tablet") { order: 3; } } - .d-admin-row__detail.automations__version { - @include breakpoint("tablet") { - order: 4; - } - } - - .d-admin-row__detail.automations__updated-by { + .d-admin-row__detail.automations__enabled { @include breakpoint("tablet") { order: 5; } diff --git a/plugins/automation/config/locales/client.en.yml b/plugins/automation/config/locales/client.en.yml index 018e274ba14..d8238bffd70 100644 --- a/plugins/automation/config/locales/client.en.yml +++ b/plugins/automation/config/locales/client.en.yml @@ -426,6 +426,11 @@ en: name: label: Trigger automation: + runs_today: "%{count} today" + runs_this_week: "%{count} this week" + runs_this_month: "%{count} this month" + runs: + label: Runs name: label: Name trigger: @@ -444,5 +449,7 @@ en: label: Placeholders last_updated_at: label: Last update + last_run: + label: Last run last_updated_by: label: Updated by diff --git a/plugins/automation/db/migrate/20250319232839_add_automation_stats.rb b/plugins/automation/db/migrate/20250319232839_add_automation_stats.rb new file mode 100644 index 00000000000..9b140ed6db8 --- /dev/null +++ b/plugins/automation/db/migrate/20250319232839_add_automation_stats.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +class AddAutomationStats < ActiveRecord::Migration[7.2] + def change + create_table :discourse_automation_stats do |t| + t.bigint :automation_id, null: false + t.date :date, null: false + t.datetime :last_run_at, null: false + t.float :total_time, null: false + t.float :average_run_time, null: false + t.float :min_run_time, null: false + t.float :max_run_time, null: false + t.integer :total_runs, null: false + end + + add_index :discourse_automation_stats, %i[automation_id date], unique: true + end +end diff --git a/plugins/automation/spec/models/stat_spec.rb b/plugins/automation/spec/models/stat_spec.rb new file mode 100644 index 00000000000..4cda513c725 --- /dev/null +++ b/plugins/automation/spec/models/stat_spec.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DiscourseAutomation::Stat do + let(:automation_id) { 42 } + let(:another_automation_id) { 43 } + let(:run_time) { 0.5 } + + describe ".fetch_period_summaries" do + let(:today) { Date.new(2023, 1, 10) } + + before do + freeze_time today + + # Last day: 2 runs + DiscourseAutomation::Stat.create!( + automation_id: automation_id, + date: today - 1.day, + last_run_at: today - 1.day + 10.hours, + total_time: 3.0, + average_run_time: 1.5, + min_run_time: 1.0, + max_run_time: 2.0, + total_runs: 2, + ) + + # Same day, different automation + DiscourseAutomation::Stat.create!( + automation_id: another_automation_id, + date: today - 1.day, + last_run_at: today - 1.day + 12.hours, + total_time: 1.0, + average_run_time: 1.0, + min_run_time: 1.0, + max_run_time: 1.0, + total_runs: 1, + ) + + # Last week: 3 runs (including the day above) + DiscourseAutomation::Stat.create!( + automation_id: automation_id, + date: today - 5.days, + last_run_at: today - 5.days + 14.hours, + total_time: 2.5, + average_run_time: 1.25, + min_run_time: 0.5, + max_run_time: 2.0, + total_runs: 2, + ) + + # Last month: 5 runs (including the week above) + DiscourseAutomation::Stat.create!( + automation_id: automation_id, + date: today - 20.days, + last_run_at: today - 20.days + 8.hours, + total_time: 4.0, + average_run_time: 2.0, + min_run_time: 1.5, + max_run_time: 2.5, + total_runs: 2, + ) + end + + it "returns correctly structured data for multiple periods" do + summaries = DiscourseAutomation::Stat.fetch_period_summaries + expect(summaries.keys).to contain_exactly(automation_id, another_automation_id) + + auto_summary = summaries[automation_id] + expect(auto_summary.keys).to contain_exactly(:last_run_at, :last_day, :last_week, :last_month) + expect(auto_summary[:last_run_at].to_date).to eq((today - 1.day).to_date) + + %i[last_day last_week last_month].each do |period| + expect(auto_summary[period].keys).to contain_exactly( + :total_runs, + :total_time, + :average_run_time, + :min_run_time, + :max_run_time, + ) + end + end + + it "correctly aggregates stats for different time periods" do + auto_summary = DiscourseAutomation::Stat.fetch_period_summaries[automation_id] + + expect(auto_summary[:last_day]).to include( + total_runs: 2, + total_time: 3.0, + min_run_time: 1.0, + max_run_time: 2.0, + average_run_time: 1.5, + ) + + expect(auto_summary[:last_week]).to include( + total_runs: 4, + total_time: 5.5, + min_run_time: 0.5, + max_run_time: 2.0, + ) + + expect(auto_summary[:last_month]).to include( + total_runs: 6, + total_time: 9.5, + min_run_time: 0.5, + max_run_time: 2.5, + ) + end + + it "handles multiple automations correctly" do + summaries = DiscourseAutomation::Stat.fetch_period_summaries + + # Check another_automation_id data + other_summary = summaries[another_automation_id] + expect(other_summary[:last_day][:total_runs]).to eq(1) + expect(other_summary[:last_day][:total_time]).to eq(1.0) + expect(other_summary[:last_run_at].to_date).to eq((today - 1.day).to_date) + end + + it "returns empty hash when no stats exist" do + DiscourseAutomation::Stat.delete_all + expect(DiscourseAutomation::Stat.fetch_period_summaries).to eq({}) + end + + it "correctly handles automations with no stats in specific periods" do + new_automation_id = 44 + + DiscourseAutomation::Stat.create!( + automation_id: new_automation_id, + date: 2.days.from_now, + last_run_at: 2.days.from_now, + total_time: 1.0, + average_run_time: 1.0, + min_run_time: 1.0, + max_run_time: 1.0, + total_runs: 1, + ) + + summaries = DiscourseAutomation::Stat.fetch_period_summaries + + expect(summaries.keys).not_to include(new_automation_id) + end + end + + describe ".log" do + context "with block form" do + it "measures the execution time and records it" do + # Mock Process.clock_gettime to return controlled values + allow(Process).to receive(:clock_gettime).and_return(10, 10.75) + + result = DiscourseAutomation::Stat.log(automation_id) { "test result" } + + expect(result).to eq("test result") + + stat = DiscourseAutomation::Stat.find_by(automation_id: automation_id) + expect(stat.total_time).to eq(0.75) + expect(stat.average_run_time).to eq(0.75) + expect(stat.min_run_time).to eq(0.75) + expect(stat.max_run_time).to eq(0.75) + expect(stat.total_runs).to eq(1) + end + end + + context "with direct call form" do + it "logs the provided run time" do + DiscourseAutomation::Stat.log(automation_id, run_time) + + stat = DiscourseAutomation::Stat.find_by(automation_id: automation_id) + expect(stat.total_time).to eq(run_time) + expect(stat.average_run_time).to eq(run_time) + expect(stat.min_run_time).to eq(run_time) + expect(stat.max_run_time).to eq(run_time) + expect(stat.total_runs).to eq(1) + end + end + + context "when updating existing stats" do + before do + freeze_time DateTime.parse("2023-01-01 12:00:00") + DiscourseAutomation::Stat.log(automation_id, 0.5) + end + + it "updates stats correctly for the same automation on the same day" do + freeze_time DateTime.parse("2023-01-01 14:00:00") + DiscourseAutomation::Stat.log(automation_id, 1.5) + + stat = DiscourseAutomation::Stat.find_by(automation_id: automation_id) + expect(stat.total_time).to eq(2.0) # 0.5 + 1.5 + expect(stat.average_run_time).to eq(1.0) # (0.5 + 1.5) / 2 + expect(stat.min_run_time).to eq(0.5) + expect(stat.max_run_time).to eq(1.5) + expect(stat.total_runs).to eq(2) + expect(stat.last_run_at.to_s).to include("2023-01-01 14:00:00") + end + + it "creates a new record for the same automation on a different day" do + freeze_time DateTime.parse("2023-01-02 12:00:00") + DiscourseAutomation::Stat.log(automation_id, 2.0) + + # There should be two records now + expect(DiscourseAutomation::Stat.where(automation_id: automation_id).count).to eq(2) + + # Check first day's stats + day1_stat = + DiscourseAutomation::Stat.find_by(automation_id: automation_id, date: "2023-01-01") + expect(day1_stat.total_time).to eq(0.5) + expect(day1_stat.total_runs).to eq(1) + + # Check second day's stats + day2_stat = + DiscourseAutomation::Stat.find_by(automation_id: automation_id, date: "2023-01-02") + expect(day2_stat.total_time).to eq(2.0) + expect(day2_stat.total_runs).to eq(1) + end + + it "handles multiple automations on the same day" do + freeze_time DateTime.parse("2023-01-01 13:00:00") + DiscourseAutomation::Stat.log(another_automation_id, 0.7) + + # Original automation should be unchanged + first_stat = DiscourseAutomation::Stat.find_by(automation_id: automation_id) + expect(first_stat.total_runs).to eq(1) + expect(first_stat.total_time).to eq(0.5) + + # New automation should have its own stats + second_stat = DiscourseAutomation::Stat.find_by(automation_id: another_automation_id) + expect(second_stat.total_runs).to eq(1) + expect(second_stat.total_time).to eq(0.7) + end + end + + context "with extreme values" do + it "correctly tracks min/max values" do + freeze_time DateTime.parse("2023-01-01 12:00:00") + + # First run + DiscourseAutomation::Stat.log(automation_id, 5.0) + + # Second run with lower time + DiscourseAutomation::Stat.log(automation_id, 2.0) + + # Third run with higher time + DiscourseAutomation::Stat.log(automation_id, 10.0) + + stat = DiscourseAutomation::Stat.find_by(automation_id: automation_id) + expect(stat.min_run_time).to eq(2.0) + expect(stat.max_run_time).to eq(10.0) + expect(stat.total_time).to eq(17.0) + expect(stat.average_run_time).to eq(17.0 / 3) + expect(stat.total_runs).to eq(3) + end + end + end +end diff --git a/plugins/automation/spec/requests/admin_discourse_automation_automations_spec.rb b/plugins/automation/spec/requests/admin_discourse_automation_automations_spec.rb index 9e33cef4f65..ec3fe5fd205 100644 --- a/plugins/automation/spec/requests/admin_discourse_automation_automations_spec.rb +++ b/plugins/automation/spec/requests/admin_discourse_automation_automations_spec.rb @@ -143,6 +143,28 @@ describe DiscourseAutomation::AdminAutomationsController do end end + context "when only changing enabled state" do + it "updates only the enabled state" do + original_trigger = automation.trigger + original_script = automation.script + expect(automation.enabled).to eq(true) + + put "/admin/plugins/automation/automations/#{automation.id}.json", + params: { + automation: { + enabled: false, + }, + } + + expect(response.status).to eq(200) + expect(response.parsed_body["automation"]["enabled"]).to eq(false) + automation.reload + expect(automation.enabled).to eq(false) + expect(automation.trigger).to eq(original_trigger) + expect(automation.script).to eq(original_script) + end + end + context "when changing trigger and script of an enabled automation" do it "forces the automation to be disabled" do expect(automation.enabled).to eq(true) @@ -225,6 +247,104 @@ describe DiscourseAutomation::AdminAutomationsController do end end + describe "#index" do + fab!(:automation1) { Fabricate(:automation, name: "First Automation") } + fab!(:automation2) { Fabricate(:automation, name: "Second Automation") } + + context "when logged in as an admin" do + before { sign_in(Fabricate(:admin)) } + + it "returns a list of automations" do + get "/admin/plugins/automation/automations.json" + + expect(response.status).to eq(200) + + parsed_response = response.parsed_body + expect(parsed_response["automations"].length).to eq(3) # includes the default automation + + automation_names = parsed_response["automations"].map { |a| a["name"] } + expect(automation_names).to include(automation1.name) + expect(automation_names).to include(automation2.name) + end + + it "doesn't include stats by default" do + get "/admin/plugins/automation/automations.json" + + expect(response.status).to eq(200) + + parsed_response = response.parsed_body + automation_response = parsed_response["automations"].find { |a| a["id"] == automation1.id } + + expect(automation_response.key?("stats")).to eq(false) + end + + context "with stats" do + before do + # Create some stats for automation1 + freeze_time DateTime.parse("2023-01-01") + + # Create stats for today (within last day) + DiscourseAutomation::Stat.log(automation1.id, 0.5) + DiscourseAutomation::Stat.log(automation1.id, 1.5) + + # Create stats for 3 days ago (within last week) + freeze_time DateTime.parse("2022-12-29") + DiscourseAutomation::Stat.log(automation1.id, 2.0) + + # Create stats for 15 days ago (within last month) + freeze_time DateTime.parse("2022-12-17") + DiscourseAutomation::Stat.log(automation2.id, 3.0) + + # Return to present + freeze_time DateTime.parse("2023-01-01") + end + + it "includes stats in the response" do + get "/admin/plugins/automation/automations.json" + + expect(response.status).to eq(200) + + parsed_response = response.parsed_body + automation1_response = + parsed_response["automations"].find { |a| a["id"] == automation1.id } + automation2_response = + parsed_response["automations"].find { |a| a["id"] == automation2.id } + + # Verify stats exist + expect(automation1_response["stats"]).to be_present + expect(automation2_response["stats"]).to be_present + + # Verify periods + expect(automation1_response["stats"]["last_day"]).to be_present + expect(automation1_response["stats"]["last_week"]).to be_present + expect(automation1_response["stats"]["last_month"]).to be_present + + # Verify specific values for automation1 + expect(automation1_response["stats"]["last_day"]["total_runs"]).to eq(2) + expect(automation1_response["stats"]["last_day"]["total_time"]).to eq(2.0) + expect(automation1_response["stats"]["last_day"]["average_run_time"]).to eq(1.0) + expect(automation1_response["stats"]["last_day"]["min_run_time"]).to eq(0.5) + expect(automation1_response["stats"]["last_day"]["max_run_time"]).to eq(1.5) + + expect(automation1_response["stats"]["last_week"]["total_runs"]).to eq(3) + + # Verify specific values for automation2 + expect(automation2_response["stats"]["last_month"]["total_runs"]).to eq(1) + expect(automation2_response["stats"]["last_month"]["total_time"]).to eq(3.0) + end + end + end + + context "when logged in as a regular user" do + before { sign_in(Fabricate(:user)) } + + it "raises a 404" do + get "/admin/plugins/automation/automations.json" + expect(response.status).to eq(404) + end + end + end + describe "#destroy" do fab!(:automation)