FEATURE: Add automation statistics tracking to Automation (#31921)

introduces comprehensive statistics tracking for the Discourse
Automation plugin, allowing users to monitor the performance and
execution patterns of their automations:

- Add `discourse_automation_stats` table to track execution metrics
including run counts, execution times, and performance data
- Create a new `Stat` model to handle tracking and retrieving automation
statistics
- Update the admin UI to display automation stats (runs today/this
week/month and last run time)
- Modernize the automation list interface using Glimmer components
- Replace the older enable/disable icon with a toggle switch for better
UX
- Add schema annotations to existing models for better code
documentation
- Include extensive test coverage for the new statistics functionality

This helps administrators understand how their automations are
performing and identify potential bottlenecks or optimization
opportunities.

---------

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
Co-authored-by: Ted Johansson <ted@discourse.org>
This commit is contained in:
Sam
2025-03-21 12:53:26 +11:00
committed by GitHub
parent dd0a6bd188
commit 8c8bc94ed8
16 changed files with 906 additions and 192 deletions

View File

@ -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,
});
}
<template>
<section class="discourse-automations-table">
<DPageSubheader @titleLabel={{i18n "discourse_automation.table_title"}}>
<:actions as |actions|>
<actions.Primary
@label="discourse_automation.create"
@route="adminPlugins.show.automation.new"
@icon="plus"
class="discourse-automation__create-btn"
/>
</:actions>
</DPageSubheader>
{{#if @model.length}}
<table class="d-admin-table automations">
<thead>
<tr>
<th>{{i18n
"discourse_automation.models.automation.name.label"
}}</th>
<th>{{i18n
"discourse_automation.models.automation.last_updated_by.label"
}}</th>
<th>{{i18n
"discourse_automation.models.automation.runs.label"
}}</th>
<th>{{i18n
"discourse_automation.models.automation.last_run.label"
}}</th>
<th>{{i18n
"discourse_automation.models.automation.enabled.label"
}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each @model as |automation|}}
<tr class="d-admin-row__content">
{{#if automation.script.not_found}}
<td
colspan="5"
class="d-admin-row__detail alert alert-danger"
>
<div class="d-admin-row__mobile-label">
{{i18n
"discourse_automation.models.automation.status.label"
}}
</div>
{{i18n
"discourse_automation.scriptables.not_found"
script=automation.script.id
automation=automation.name
}}
</td>
{{else if automation.trigger.not_found}}
<td
colspan="5"
class="d-admin-row__detail alert alert-danger"
>
<div class="d-admin-row__mobile-label">
{{i18n
"discourse_automation.models.automation.status.label"
}}
</div>
{{i18n
"discourse_automation.triggerables.not_found"
trigger=automation.trigger.id
automation=automation.name
}}
</td>
{{else}}
<td class="d-admin-row__overview automations__name">
{{if
automation.name
automation.name
(i18n "discourse_automation.unnamed_automation")
}}
</td>
<td class="d-admin-row__detail automations__updated-by">
<div class="d-admin-row__mobile-label">
{{i18n
"discourse_automation.models.automation.last_updated_by.label"
}}
</div>
<div class="automations__user-timestamp">
<a
href={{automation.last_updated_by.userPath}}
data-user-card={{automation.last_updated_by.username}}
>
{{avatar automation.last_updated_by imageSize="small"}}
</a>
{{formatDate automation.updated_at leaveAgo="true"}}
</div>
</td>
<td class="d-admin-row__detail automations__runs">
<div class="d-admin-row__mobile-label">
{{i18n
"discourse_automation.models.automation.runs.label"
}}
</div>
<span class="automations__stats">
{{this.statsText automation.stats}}
</span>
</td>
<td class="d-admin-row__detail automations__last-run">
<div class="d-admin-row__mobile-label">
{{i18n
"discourse_automation.models.automation.last_run.label"
}}
</div>
{{#if automation.stats.last_run_at}}
{{formatDate
automation.stats.last_run_at
leaveAgo="true"
}}
{{else}}
-
{{/if}}
</td>
<td class="d-admin-row__detail automations__enabled">
<div class="d-admin-row__mobile-label">
{{i18n
"discourse_automation.models.automation.enabled.label"
}}
</div>
<DToggleSwitch
@state={{automation.enabled}}
{{on "click" (fn this.toggleEnabled automation)}}
/>
</td>
{{/if}}
<td class="d-admin-row__controls automations__controls">
<LinkTo
@route="adminPlugins.show.automation.edit"
@model={{automation.id}}
class="btn btn-default btn-text btn-small"
>
{{i18n "discourse_automation.edit"}}
</LinkTo>
<DButton
@icon="trash-can"
@action={{this.destroyAutomation automation}}
class="btn-small btn-danger"
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="discourse_automation.create"
@ctaRoute="adminPlugins.show.automation.new"
@ctaClass="discourse-automation__create-btn"
@emptyLabel="discourse_automation.no_automation_yet"
/>
{{/if}}
</section>
</template>
}

View File

@ -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",
})
);
}
}

View File

@ -1,131 +1 @@
<section class="discourse-automations-table">
<DPageSubheader @titleLabel={{i18n "discourse_automation.table_title"}}>
<:actions as |actions|>
<actions.Primary
@label="discourse_automation.create"
@route="adminPlugins.show.automation.new"
@icon="plus"
class="discourse-automation__create-btn"
/>
</:actions>
</DPageSubheader>
{{#if this.model.length}}
<table class="d-admin-table automations">
<thead>
<tr>
<th></th>
<th>{{i18n "discourse_automation.models.automation.name.label"}}</th>
<th>{{i18n
"discourse_automation.models.automation.trigger.label"
}}</th>
<th>{{i18n
"discourse_automation.models.automation.script.label"
}}</th>
<th>{{i18n
"discourse_automation.models.automation.last_updated_by.label"
}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each this.model as |automation|}}
<tr class="d-admin-row__content">
{{#if automation.script.not_found}}
<td colspan="5" class="d-admin-row__detail alert alert-danger">
<div class="d-admin-row__mobile-label">
{{i18n "discourse_automation.models.automation.status.label"}}
</div>
{{i18n
"discourse_automation.scriptables.not_found"
script=automation.script.id
automation=automation.name
}}
</td>
{{else if automation.trigger.not_found}}
<td colspan="5" class="d-admin-row__detail alert alert-danger">
<div class="d-admin-row__mobile-label">
{{i18n "discourse_automation.models.automation.status.label"}}
</div>
{{i18n
"discourse_automation.triggerables.not_found"
trigger=automation.trigger.id
automation=automation.name
}}
</td>
{{else}}
<td class="d-admin-row__detail automations__status">
<div class="d-admin-row__mobile-label">
{{i18n "discourse_automation.models.automation.status.label"}}
</div>
{{format-enabled-automation
automation.enabled
automation.trigger
}}
</td>
<td class="d-admin-row__overview automations__name">
{{if
automation.name
automation.name
(i18n "discourse_automation.unnamed_automation")
}}
</td>
<td class="d-admin-row__detail automations__script">
<div class="d-admin-row__mobile-label">
{{i18n
"discourse_automation.models.automation.trigger.label"
}}
</div>
{{if automation.trigger.id automation.trigger.name "-"}}
</td>
<td class="d-admin-row__detail automations__version">
<div class="d-admin-row__mobile-label">
{{i18n "discourse_automation.models.automation.script.label"}}
</div>
{{automation.script.name}}
(v{{automation.script.version}})
</td>
<td class="d-admin-row__detail automations__updated-by">
<div class="d-admin-row__mobile-label">
{{i18n
"discourse_automation.models.automation.last_updated_by.label"
}}
</div>
<div class="automations__user-timestamp">
<a
href={{automation.last_updated_by.userPath}}
data-user-card={{automation.last_updated_by.username}}
>
{{avatar automation.last_updated_by imageSize="small"}}
</a>
{{format-date automation.updated_at leaveAgo="true"}}
</div>
</td>
{{/if}}
<td class="d-admin-row__controls automations__controls">
<DButton
@label="discourse_automation.edit"
class="btn-small"
@action={{action "editAutomation" automation}}
/>
<DButton
@icon="trash-can"
@action={{action "destroyAutomation" automation}}
class="btn-small btn-danger"
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="discourse_automation.create"
@ctaRoute="adminPlugins.show.automation.new"
@ctaClass="discourse-automation__create-btn"
@emptyLabel="discourse_automation.no_automation_yet"
/>
{{/if}}
</section>
<AutomationList @model={{this.model}} />

View File

@ -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)

View File

@ -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
#

View File

@ -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
#

View File

@ -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
#

View File

@ -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
#

View File

@ -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
#

View File

@ -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
#

View File

@ -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)

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)