mirror of
https://github.com/discourse/discourse.git
synced 2025-06-24 00:35:05 +08:00
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:
@ -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>
|
||||
}
|
@ -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",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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}} />
|
@ -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)
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
128
plugins/automation/app/models/discourse_automation/stat.rb
Normal file
128
plugins/automation/app/models/discourse_automation/stat.rb
Normal 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
|
||||
#
|
@ -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
|
||||
#
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
254
plugins/automation/spec/models/stat_spec.rb
Normal file
254
plugins/automation/spec/models/stat_spec.rb
Normal 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
|
@ -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)
|
||||
|
||||
|
Reference in New Issue
Block a user