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"> <AutomationList @model={{this.model}} />
<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>

View File

@ -11,6 +11,9 @@ module DiscourseAutomation
automations, automations,
each_serializer: DiscourseAutomation::AutomationSerializer, each_serializer: DiscourseAutomation::AutomationSerializer,
root: "automations", root: "automations",
scope: {
stats: DiscourseAutomation::Stat.fetch_period_summaries,
},
).as_json ).as_json
render_json_dump(serializer) render_json_dump(serializer)
end end
@ -50,31 +53,35 @@ module DiscourseAutomation
last_updated_by_id: current_user.id, 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] = [] params[:automation][:fields] = []
attributes[:enabled] = false attributes[:enabled] = false
automation.fields.destroy_all automation.fields.destroy_all
end end
if automation.script != params[:automation][:script] if attributes.key?(:script)
attributes[:trigger] = nil if automation.script != params[:automation][:script]
params[:automation][:fields] = [] attributes[:trigger] = nil
attributes[:enabled] = false params[:automation][:fields] = []
automation.fields.destroy_all attributes[:enabled] = false
automation.tap { |r| r.assign_attributes(attributes) }.save!(validate: false) automation.fields.destroy_all
else automation.tap { |r| r.assign_attributes(attributes) }.save!(validate: false)
Array(params[:automation][:fields]) else
.reject(&:empty?) Array(params[:automation][:fields])
.each do |field| .reject(&:empty?)
automation.upsert_field!( .each do |field|
field[:name], automation.upsert_field!(
field[:component], field[:name],
field[:metadata], field[:component],
target: field[:target], field[:metadata],
) target: field[:target],
end )
end
automation.tap { |r| r.assign_attributes(attributes) }.save! automation.update!(attributes)
end
else
automation.update!(attributes)
end end
render_serialized_automation(automation) render_serialized_automation(automation)

View File

@ -17,6 +17,8 @@ module DiscourseAutomation
dependent: :delete_all, dependent: :delete_all,
foreign_key: "automation_id" foreign_key: "automation_id"
has_many :stats, class_name: "DiscourseAutomation::Stat", dependent: :delete_all
validates :script, presence: true validates :script, presence: true
validate :validate_trigger_fields validate :validate_trigger_fields
@ -137,8 +139,10 @@ module DiscourseAutomation
if scriptable.background && !running_in_background if scriptable.background && !running_in_background
trigger_in_background!(context) trigger_in_background!(context)
else else
triggerable&.on_call&.call(self, serialized_fields) Stat.log(id) do
scriptable.script.call(context, serialized_fields, self) triggerable&.on_call&.call(self, serialized_fields)
scriptable.script.call(context, serialized_fields, self)
end
end end
ensure ensure
DiscourseAutomation.set_active_automation(nil) DiscourseAutomation.set_active_automation(nil)
@ -187,3 +191,17 @@ module DiscourseAutomation
end end
end 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 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" belongs_to :automation, class_name: "DiscourseAutomation::Automation"
end end
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" belongs_to :automation, class_name: "DiscourseAutomation::Automation"
end end
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 belongs_to :user
end end
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 module DiscourseAutomation
class AutomationSerializer < ApplicationSerializer class AutomationSerializer < ApplicationSerializer
attributes :id attribute :id
attributes :name attribute :name
attributes :enabled attribute :enabled
attributes :script attribute :script
attributes :trigger attribute :trigger
attributes :updated_at attribute :updated_at
attributes :last_updated_by attribute :last_updated_by
attributes :next_pending_automation_at attribute :next_pending_automation_at
attributes :placeholders attribute :placeholders
attribute :stats
def last_updated_by def last_updated_by
BasicUserSerializer.new( BasicUserSerializer.new(
@ -84,6 +85,29 @@ module DiscourseAutomation
} }
end 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 private
def filter_fields_with_priority(arr, trigger) def filter_fields_with_priority(arr, trigger)

View File

@ -1,7 +1,11 @@
.admin-plugins.automation { .admin-plugins.automation {
.automations { .automations {
.relative-date { .relative-date {
font-size: $font-down-1; font-size: var(--font-down-1);
}
&__stats {
font-size: var(--font-down-1);
} }
td[role="button"] { td[role="button"] {
@ -26,31 +30,25 @@
} }
} }
.d-admin-row__overview { .d-admin-row__name {
@include breakpoint("tablet") { @include breakpoint("tablet") {
order: 1; order: 1;
} }
} }
.d-admin-row__detail.automations__status { .d-admin-row__detail.automations__runs {
@include breakpoint("tablet") { @include breakpoint("tablet") {
order: 2; // move below the name to avoid empty spacing 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") { @include breakpoint("tablet") {
order: 3; order: 3;
} }
} }
.d-admin-row__detail.automations__version { .d-admin-row__detail.automations__enabled {
@include breakpoint("tablet") {
order: 4;
}
}
.d-admin-row__detail.automations__updated-by {
@include breakpoint("tablet") { @include breakpoint("tablet") {
order: 5; order: 5;
} }

View File

@ -426,6 +426,11 @@ en:
name: name:
label: Trigger label: Trigger
automation: automation:
runs_today: "%{count} today"
runs_this_week: "%{count} this week"
runs_this_month: "%{count} this month"
runs:
label: Runs
name: name:
label: Name label: Name
trigger: trigger:
@ -444,5 +449,7 @@ en:
label: Placeholders label: Placeholders
last_updated_at: last_updated_at:
label: Last update label: Last update
last_run:
label: Last run
last_updated_by: last_updated_by:
label: 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
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 context "when changing trigger and script of an enabled automation" do
it "forces the automation to be disabled" do it "forces the automation to be disabled" do
expect(automation.enabled).to eq(true) expect(automation.enabled).to eq(true)
@ -225,6 +247,104 @@ describe DiscourseAutomation::AdminAutomationsController do
end end
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 describe "#destroy" do
fab!(:automation) fab!(:automation)