FEATURE: Add date range selector to staff action log (#33959)

This commit implements a date-time range selector to staff action log in
admin page, making it easier to filter and analyze the data.
Additionally, it allows to apply the date-time picker to the exported
file as well, reducing the exported file size.
This commit is contained in:
Linca
2025-08-06 15:27:28 +08:00
committed by GitHub
parent 15b8b3b073
commit 4e304e7130
13 changed files with 346 additions and 101 deletions
@@ -13,10 +13,14 @@ export default class AdminLogsStaffActionLogsController extends Controller {
@service modal;
@service store;
queryParams = ["filters"];
queryParams = ["filters", "startDate", "endDate"];
model = null;
filters = null;
userHistoryActions = null;
/** @type {moment.Moment | null} */
startDate = null;
/** @type {moment.Moment | null} */
endDate = null;
@discourseComputed("filters.action_name")
actionFilter(name) {
@@ -29,25 +33,31 @@ export default class AdminLogsStaffActionLogsController extends Controller {
}
_refresh() {
this.store.findAll("staff-action-log", this.filters).then((result) => {
this.set("model", result);
this.store
.findAll("staff-action-log", {
...this.filters,
start_date: this.startDate?.toISOString(),
end_date: this.endDate?.toISOString(),
})
.then((result) => {
this.set("model", result);
if (!this.userHistoryActions) {
this.set(
"userHistoryActions",
result.extras.user_history_actions
.map((historyAction) => ({
id: historyAction.id,
action_id: historyAction.action_id,
name: i18n(
"admin.logs.staff_actions.actions." + historyAction.id
),
name_raw: historyAction.id,
}))
.sort((a, b) => a.name.localeCompare(b.name))
);
}
});
if (!this.userHistoryActions) {
this.set(
"userHistoryActions",
result.extras.user_history_actions
.map((historyAction) => ({
id: historyAction.id,
action_id: historyAction.action_id,
name: i18n(
"admin.logs.staff_actions.actions." + historyAction.id
),
name_raw: historyAction.id,
}))
.sort((a, b) => a.name.localeCompare(b.name))
);
}
});
}
scheduleRefresh() {
@@ -145,7 +155,10 @@ export default class AdminLogsStaffActionLogsController extends Controller {
@action
exportStaffActionLogs() {
exportEntity("staff_action").then(outputExportResult);
exportEntity("staff_action", {
start_date: this.startDate?.toISOString(),
end_date: this.endDate?.toISOString(),
}).then(outputExportResult);
}
@action
@@ -168,4 +181,16 @@ export default class AdminLogsStaffActionLogsController extends Controller {
model: { staffActionLog: model },
});
}
/**
* @arg {moment.Moment} from
* @arg {moment.Moment} to
*/
@action
onChangeDateRange({ from, to }) {
this.set("startDate", from);
this.set("endDate", to);
this.set("model", EmberObject.create({ loadingMore: true }));
this.scheduleRefresh();
}
}
@@ -7,6 +7,8 @@ export default class AdminLogsStaffActionLogsRoute extends DiscourseRoute {
queryParams = {
filters: { refreshModel: true },
startDate: { refreshModel: true },
endDate: { refreshModel: true },
};
beforeModel(transition) {
@@ -21,6 +23,9 @@ export default class AdminLogsStaffActionLogsRoute extends DiscourseRoute {
if (urlKey === "filters" && value) {
return EmberObject.create(JSON.parse(decodeURIComponent(value)));
}
if (urlKey === "startDate" || urlKey === "endDate") {
return value ? moment(value) : null;
}
return super.deserializeQueryParam(value, urlKey, defaultValueType);
}
@@ -33,6 +38,9 @@ export default class AdminLogsStaffActionLogsRoute extends DiscourseRoute {
return null;
}
}
if (urlKey === "startDate" || urlKey === "endDate") {
return value ? value.toISOString() : null;
}
return super.serializeQueryParam(value, urlKey, defaultValueType);
}
@@ -5,6 +5,7 @@ import { htmlSafe } from "@ember/template";
import RouteTemplate from "ember-route-template";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import DateTimeInputRange from "discourse/components/date-time-input-range";
import LoadMore from "discourse/components/load-more";
import ageWithTooltip from "discourse/helpers/age-with-tooltip";
import avatar from "discourse/helpers/avatar";
@@ -16,85 +17,99 @@ import ComboBox from "select-kit/components/combo-box";
export default RouteTemplate(
<template>
<div class="staff-action-logs-controls">
{{#if @controller.filtersExists}}
<div class="staff-action-logs-filters">
<a
href
{{on "click" @controller.clearAllFilters}}
class="clear-filters filter btn"
>
<span class="label">{{i18n
"admin.logs.staff_actions.clear_filters"
}}</span>
</a>
{{#if @controller.actionFilter}}
<div class="staff-action-logs-controls__left">
{{#if @controller.filtersExists}}
<div class="staff-action-logs-filters">
<a
href
{{on "click" (fn @controller.clearFilter "actionFilter")}}
class="filter btn"
>
<span class="label">{{i18n "admin.logs.action"}}</span>:
{{@controller.actionFilter}}
{{icon "circle-xmark"}}
</a>
{{/if}}
{{#if @controller.filters.acting_user}}
<a
href
{{on "click" (fn @controller.clearFilter "acting_user")}}
class="filter btn"
{{on "click" @controller.clearAllFilters}}
class="clear-filters filter btn"
>
<span class="label">{{i18n
"admin.logs.staff_actions.staff_user"
}}</span>:
{{@controller.filters.acting_user}}
{{icon "circle-xmark"}}
"admin.logs.staff_actions.clear_filters"
}}</span>
</a>
{{/if}}
{{#if @controller.filters.target_user}}
<a
href
{{on "click" (fn @controller.clearFilter "target_user")}}
class="filter btn"
>
<span class="label">{{i18n
"admin.logs.staff_actions.target_user"
}}</span>:
{{@controller.filters.target_user}}
{{icon "circle-xmark"}}
</a>
{{/if}}
{{#if @controller.filters.subject}}
<a
href
{{on "click" (fn @controller.clearFilter "subject")}}
class="filter btn"
>
<span class="label">{{i18n
"admin.logs.staff_actions.subject"
}}</span>:
{{@controller.filters.subject}}
{{icon "circle-xmark"}}
</a>
{{/if}}
</div>
{{else}}
{{i18n "admin.logs.staff_actions.filter"}}
<ComboBox
@content={{@controller.userHistoryActions}}
@value={{@controller.filterActionId}}
@onChange={{@controller.filterActionIdChanged}}
@options={{hash none="admin.logs.staff_actions.all"}}
@id="staff-action-logs-action-filter"
/>
{{/if}}
{{#if @controller.actionFilter}}
<a
href
{{on "click" (fn @controller.clearFilter "actionFilter")}}
class="filter btn"
>
<span class="label">{{i18n "admin.logs.action"}}</span>:
{{@controller.actionFilter}}
{{icon "circle-xmark"}}
</a>
{{/if}}
{{#if @controller.filters.acting_user}}
<a
href
{{on "click" (fn @controller.clearFilter "acting_user")}}
class="filter btn"
>
<span class="label">{{i18n
"admin.logs.staff_actions.staff_user"
}}</span>:
{{@controller.filters.acting_user}}
{{icon "circle-xmark"}}
</a>
{{/if}}
{{#if @controller.filters.target_user}}
<a
href
{{on "click" (fn @controller.clearFilter "target_user")}}
class="filter btn"
>
<span class="label">{{i18n
"admin.logs.staff_actions.target_user"
}}</span>:
{{@controller.filters.target_user}}
{{icon "circle-xmark"}}
</a>
{{/if}}
{{#if @controller.filters.subject}}
<a
href
{{on "click" (fn @controller.clearFilter "subject")}}
class="filter btn"
>
<span class="label">{{i18n
"admin.logs.staff_actions.subject"
}}</span>:
{{@controller.filters.subject}}
{{icon "circle-xmark"}}
</a>
{{/if}}
</div>
{{else}}
{{i18n "admin.logs.staff_actions.filter"}}
<ComboBox
@content={{@controller.userHistoryActions}}
@value={{@controller.filterActionId}}
@onChange={{@controller.filterActionIdChanged}}
@options={{hash none="admin.logs.staff_actions.all"}}
@id="staff-action-logs-action-filter"
/>
{{/if}}
<DButton
@action={{@controller.exportStaffActionLogs}}
@label="admin.export_csv.button_text"
@icon="download"
class="btn-default"
/>
<div class="date-filter-container">
<DateTimeInputRange
@from={{@controller.startDate}}
@to={{@controller.endDate}}
@onChange={{@controller.onChangeDateRange}}
@showFromTime={{false}}
@showToTime={{false}}
/>
</div>
</div>
<div class="staff-action-logs-controls__right">
<DButton
@action={{@controller.exportStaffActionLogs}}
@label="admin.export_csv.button_text"
@icon="download"
class="btn-default export-staff-action-logs"
/>
</div>
</div>
<div class="clearfix"></div>
+37 -5
View File
@@ -138,15 +138,47 @@
.staff-action-logs-controls {
display: flex;
align-items: center;
align-items: baseline;
gap: 0.5em;
justify-content: space-between;
margin: 0 0 1em 0;
.select-kit {
margin: 0 0.5em;
// for too small screen, we want it to also wrap
@media (width <= 400px) {
flex-wrap: wrap;
}
button {
margin-left: auto;
.staff-action-logs-controls__left {
flex: 1 1 auto;
justify-content: flex-start;
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
// we want to put it in one line
.date-filter-container .d-date-time-input-range {
display: flex;
gap: 0.5em;
.from {
margin-bottom: 0;
}
}
}
#staff-action-logs-action-filter {
// unset the global width of .admin-container .select-kit, so we can shrink it by flex
width: unset;
flex: 1 0 120px;
max-width: 400px;
}
.action-filter-container {
flex: 0 1 380px;
display: flex;
gap: 0.5em;
align-items: center;
}
a.filter {
@@ -4,12 +4,13 @@ class Admin::StaffActionLogsController < Admin::StaffController
INDEX_LIMIT = 200
def index
filters = params.slice(*UserHistory.staff_filters + %i[page limit])
filters = params.slice(*UserHistory.staff_filters + %i[page limit], :start_date, :end_date)
page = (params[:page] || 0).to_i
page_size = fetch_limit_from_params(default: INDEX_LIMIT, max: INDEX_LIMIT)
staff_action_logs = UserHistory.staff_action_records(current_user, filters)
count = staff_action_logs.count
staff_action_logs = staff_action_logs.offset(page * page_size).limit(page_size).to_a
+9 -2
View File
@@ -148,11 +148,18 @@ module Jobs
end
def staff_action_export
start_date = @extra[:start_date]&.to_time if @extra
end_date = @extra[:end_date]&.to_time if @extra
query = UserHistory
query = query.where("created_at >= ?", start_date) if start_date.present?
query = query.where("created_at <= ?", end_date) if end_date.present?
staff_action_data =
if @current_user.admin?
UserHistory.only_staff_actions
query.only_staff_actions
else
UserHistory.where(admin_only: false).only_staff_actions
query.where(admin_only: false).only_staff_actions
end
staff_action_data.find_each(order: :desc) do |staff_action|
+4
View File
@@ -337,6 +337,10 @@ class UserHistory < ActiveRecord::Base
.order("id DESC")
.includes(:acting_user, :target_user)
query = query.where(admin_only: false) unless viewer && viewer.admin?
query = query.where("created_at >= ?", opts[:start_date].to_time) if opts[:start_date]
query = query.where("created_at <= ?", opts[:end_date].to_time) if opts[:end_date]
query
end
+1
View File
@@ -7324,6 +7324,7 @@ en:
modal_title: "Details"
no_previous: "There is no previous value."
deleted: "No new value. The record was deleted."
filter_date_time: "Filter date and time"
actions:
permanently_delete_post_revisions: "permanently delete post revisions"
delete_user: "delete user"
+34
View File
@@ -72,6 +72,40 @@ RSpec.describe Jobs::ExportCsvFile do
admin.uploads.each(&:destroy!)
end
end
context "when exporting staff action" do
before do
freeze_time
(1..10).each do |i|
Fabricate(
:user_history,
action: UserHistory.actions[:suspend_user],
created_at: i.days.ago,
)
end
end
it "exports staff action logs with date filters" do
expect do
Jobs::ExportCsvFile.new.execute(
user_id: admin.id,
entity: "staff_action",
args: {
# Fine-tuning for 1 minute to ensure we capture the correct range
start_date: (5.days.ago - 1.minutes).iso8601,
end_date: (2.days.ago + 1.minutes).iso8601,
},
)
end.to change { Upload.count }.by(1)
Zip::File.open(Discourse.store.path_for(Upload.last)) do |zip_file|
zip_file.each do |entry|
content = zip_file.read(entry)
expect(CSV.parse(content).size).to eq(5) # [header, 2, 3, 4, 5]
end
end
end
end
end
describe ".report_export" do
+17
View File
@@ -77,6 +77,23 @@ RSpec.describe UserHistory do
expect(records.size).to eq(1)
expect(records.first).to eq(custom_history)
end
it "filters by start and/or end date" do
freeze_time
10.times do |i|
Fabricate(
:user_history,
action: UserHistory.actions[:suspend_user],
created_at: i.days.ago,
)
end
records =
described_class.staff_action_records(admin, start_date: 7.days.ago, end_date: 2.days.ago)
expect(records.size).to eq(7 - 2 + 1)
end
end
end
end
@@ -28,6 +28,57 @@ RSpec.describe Admin::StaffActionLogsController do
)
end
describe "filter logs by date" do
before do
freeze_time
topic = Fabricate(:topic)
StaffActionLogger.new(Discourse.system_user).log_topic_delete_recover(
topic,
"delete_topic",
)
freeze_time 3.days.from_now
StaffActionLogger.new(Discourse.system_user).log_grant_admin(user)
freeze_time 2.days.from_now
StaffActionLogger.new(Discourse.system_user).log_user_suspend(user, "reason")
end
it "filter logs by start_date" do
get "/admin/logs/staff_action_logs.json", params: { start_date: 3.days.ago.iso8601 }
json = response.parsed_body
expect(response.status).to eq(200)
expect(json["staff_action_logs"].length).to eq(2)
expect(json["staff_action_logs"][0]["action_name"]).to eq("suspend_user")
expect(json["staff_action_logs"][1]["action_name"]).to eq("grant_admin")
end
it "filter logs by end_date" do
get "/admin/logs/staff_action_logs.json", params: { end_date: 1.days.ago.iso8601 }
json = response.parsed_body
expect(response.status).to eq(200)
expect(json["staff_action_logs"].length).to eq(2)
expect(json["staff_action_logs"][0]["action_name"]).to eq("grant_admin")
expect(json["staff_action_logs"][1]["action_name"]).to eq("delete_topic")
end
it "filter logs by start_date and end_date" do
get "/admin/logs/staff_action_logs.json",
params: {
start_date: 3.days.ago.iso8601,
end_date: 1.days.ago.iso8601,
}
json = response.parsed_body
expect(response.status).to eq(200)
expect(json["staff_action_logs"].length).to eq(1)
expect(json["staff_action_logs"][0]["action_name"]).to eq("grant_admin")
end
end
describe "when limit params is invalid" do
include_examples "invalid limit params",
"/admin/logs/staff_action_logs.json",
@@ -80,4 +80,42 @@ describe "Admin staff action logs", type: :system do
find("#{staff_action_logs_page.log_row_selector(history_1)} .col.value.details a").click
expect(PageObjects::Modals::Base.new).to have_content(history_1.details)
end
describe "date time filting" do
let!(:histories) do
(1..10).map do |i|
Fabricate(:user_history, action: UserHistory.actions[:suspend_user], created_at: i.days.ago)
end
end
it "can see filtered logs" do
visit "/admin/logs/staff_action_logs"
staff_action_logs_page.fill_date_filter_from(7.days.ago)
staff_action_logs_page.fill_date_filter_to(2.days.ago)
[1, 2, 8, 9, 10].each do |i|
expect(staff_action_logs_page).to have_no_log_row(histories[i - 1])
end
[3, 4, 5, 6, 7].each { |i| expect(staff_action_logs_page).to have_log_row(histories[i - 1]) }
end
it "can export filtered logs" do
visit "/admin/logs/staff_action_logs"
staff_action_logs_page.fill_date_filter_from(7.days.ago)
staff_action_logs_page.fill_date_filter_to(2.days.ago)
expect do
staff_action_logs_page.click_export_button
expect(page).to have_text(I18n.t("admin_js.admin.export_csv.success"))
end.to change { Jobs::ExportCsvFile.jobs.size }.by(1)
job = Jobs::ExportCsvFile.jobs.last
args = job["args"].first["args"]
expect(args).to include("start_date")
expect(args).to include("end_date")
end
end
end
@@ -33,6 +33,18 @@ module PageObjects
def clear_filter
find(".clear-filters").click
end
def click_export_button
find(".export-staff-action-logs").click
end
def fill_date_filter_from(date)
find(".d-date-time-input.from .d-date-input input").set(date)
end
def fill_date_filter_to(date)
find(".d-date-time-input.to .d-date-input input").set(date)
end
end
end
end