diff --git a/app/components/time_series_component.rb b/app/components/time_series_component.rb new file mode 100644 index 000000000..651ad6129 --- /dev/null +++ b/app/components/time_series_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class TimeSeriesComponent < ApplicationComponent + delegate :current_page_path, :search_params, to: :helpers + + attr_reader :results, :columns, :mode + + def initialize(results, columns, mode: :table) + @results = results + @columns = columns + @mode = mode.to_sym + end +end diff --git a/app/components/time_series_component/time_series_component.html.erb b/app/components/time_series_component/time_series_component.html.erb new file mode 100644 index 000000000..e0c10f399 --- /dev/null +++ b/app/components/time_series_component/time_series_component.html.erb @@ -0,0 +1,43 @@ +
+ <% if mode == :table %> + <%= link_to "Chart", current_page_path(search: { **search_params, mode: "chart" }) %> + <% else %> + <%= link_to "Table", current_page_path(search: { **search_params, mode: "table" }) %> + <% end %> +
+ +<% if mode == :table %> + + + + + <% columns.each do |column| %> + <%= tag.th(column.to_s.capitalize, class: ("col-expand" if column == columns.last)) %> + <% end %> + + + + <% results.each do |row| %> + + + + <% columns.each do |column| %> + + <% end %> + + <% end %> + +
Date
<%= row["date"].to_date %><%= row[column.to_s] %>
+<% elsif mode == :chart %> +
+ + +<% end %> diff --git a/app/components/time_series_component/time_series_component.js b/app/components/time_series_component/time_series_component.js new file mode 120000 index 000000000..fd5249567 --- /dev/null +++ b/app/components/time_series_component/time_series_component.js @@ -0,0 +1 @@ +../../javascript/src/javascripts/time_series_component.js \ No newline at end of file diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb new file mode 100644 index 000000000..230249ff0 --- /dev/null +++ b/app/controllers/reports_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class ReportsController < ApplicationController + respond_to :html, :json, :xml + + def index + end + + def show + @report = params[:id] + @mode = params.dig(:search, :mode) || "chart" + + case @report + when "posts" + @model = Post + @title = "Posts Report" + @columns = { posts: "COUNT(*)", uploaders: "COUNT(distinct uploader_id)" } + when "post_votes" + @model = PostVote + @title = "Post Votes Report" + @columns = { votes: "COUNT(*)", posts: "COUNT(distinct post_id)", voters: "COUNT(distinct user_id)" } + when "pools" + @model = Pool + @title = "Pools Report" + @columns = { series_pools: "COUNT(*) FILTER (WHERE category = 'series')", collection_pools: "COUNT(*) FILTER (WHERE category = 'collection')" } + when "comments" + @model = Comment + @title = "Comments Report" + @columns = { comments: "COUNT(*)", commenters: "COUNT(distinct creator_id)" } + when "comment_votes" + @model = CommentVote + @title = "Comment Votes Report" + @columns = { votes: "COUNT(*)", comments: "COUNT(distinct comment_id)", voters: "COUNT(distinct user_id)" } + when "forum_posts" + @model = ForumPost + @title = "Forum Posts Report" + @columns = { forum_posts: "COUNT(*)", posters: "COUNT(distinct creator_id)" } + when "bulk_update_requests" + @model = BulkUpdateRequest + @title = "Bulk Update Requests Report" + @columns = { requests: "COUNT(*)", requestors: "COUNT(distinct user_id)" } + when "tag_aliases" + @model = TagAlias + @title = "Tag Aliases Report" + @columns = { aliases: "COUNT(*)" } + when "tag_implications" + @model = TagImplication + @title = "Tag Implications Report" + @columns = { aliases: "COUNT(*)" } + when "artist_versions" + @model = ArtistVersion + @title = "Artist Edits Report" + @columns = { artist_edits: "COUNT(*)", artists: "COUNT(distinct artist_id)", editors: "COUNT(distinct updater_id)" } + when "note_versions" + @model = NoteVersion + @title = "Note Edits Report" + @columns = { note_edits: "COUNT(*)", posts: "COUNT(distinct post_id)", editors: "COUNT(distinct updater_id)" } + when "wiki_page_versions" + @model = WikiPageVersion + @title = "Wiki Edits Report" + @columns = { wiki_edits: "COUNT(*)", editors: "COUNT(distinct updater_id)" } + when "users" + @model = User + @title = "New Users Report" + @columns = { users: "COUNT(*)" } + when "bans" + @model = Ban + @title = "Bans Report" + @columns = { bans: "COUNT(*)", banners: "COUNT(DISTINCT banner_id)" } + else + raise ActiveRecord::RecordNotFound + end + + @period = params.dig(:search, :period)&.downcase || "day" + @from = params.dig(:search, :from) || 1.month.ago + @to = params.dig(:search, :to) || Time.zone.now + + @results = @model.search(params[:search], CurrentUser.user).timeseries(period: @period, from: @from, to: @to, columns: @columns) + respond_with(@results) + end +end diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 3a7878da7..a1abfc62e 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -55,6 +55,7 @@ import PreviewSizeMenuComponent from "../src/javascripts/preview_size_menu_compo import RelatedTag from "../src/javascripts/related_tag.js"; import Shortcuts from "../src/javascripts/shortcuts.js"; import TagCounter from "../src/javascripts/tag_counter.js"; +import TimeSeriesComponent from "../src/javascripts/time_series_component.js"; import Upload from "../src/javascripts/uploads.js"; import UserTooltip from "../src/javascripts/user_tooltips.js"; import Utility from "../src/javascripts/utility.js"; @@ -82,6 +83,7 @@ Danbooru.PreviewSizeMenuComponent = PreviewSizeMenuComponent; Danbooru.RelatedTag = RelatedTag; Danbooru.Shortcuts = Shortcuts; Danbooru.TagCounter = TagCounter; +Danbooru.TimeSeriesComponent = TimeSeriesComponent; Danbooru.Upload = Upload; Danbooru.UserTooltip = UserTooltip; Danbooru.Utility = Utility; diff --git a/app/javascript/src/javascripts/current_user.js b/app/javascript/src/javascripts/current_user.js index e38ae191f..a97fa9b7e 100644 --- a/app/javascript/src/javascripts/current_user.js +++ b/app/javascript/src/javascripts/current_user.js @@ -11,4 +11,10 @@ CurrentUser.update = function(settings) { }); }; +CurrentUser.darkMode = function() { + let theme = CurrentUser.data("theme"); + + return theme === "dark" || (theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches); +}; + export default CurrentUser; diff --git a/app/javascript/src/javascripts/time_series_component.js b/app/javascript/src/javascripts/time_series_component.js new file mode 100644 index 000000000..ffaa9b1a8 --- /dev/null +++ b/app/javascript/src/javascripts/time_series_component.js @@ -0,0 +1,72 @@ +import * as echarts from "echarts"; +import startCase from "lodash/startCase"; +import CurrentUser from "./current_user.js"; + +export default class TimeSeriesComponent { + constructor({ container = null, data = [], columns = [], theme = null } = {}) { + this.container = container; + this.data = data; + this.columns = columns; + this.theme = CurrentUser.darkMode() ? "dark" : null; + + this.options = { + dataset: { + dimensions: ["date", ...columns], + source: data, + }, + tooltip: { + trigger: "axis", + axisPointer: { + type: "cross", + label: { + backgroundColor: '#6a7985' + } + } + }, + toolbox: { + feature: { + dataView: {}, + dataZoom: { + yAxisIndex: "none" + }, + magicType: { + type: ["line", "bar"], + }, + restore: {}, + saveAsImage: {} + } + }, + dataZoom: [ + { type: "inside" }, + { type: "slider" } + ], + grid: { + left: "1%", + right: "1%", + containLabel: true + }, + legend: { + data: columns.map(startCase), + }, + xAxis: { type: "time" }, + yAxis: columns.map(name => ({ type: "value" })), + series: columns.map(name => ({ + name: startCase(name), + type: "line", + areaStyle: {}, + emphasis: { + focus: "series" + }, + encode: { + x: "date", + y: name + } + })) + }; + + this.chart = echarts.init(container, this.theme); + this.chart.setOption(this.options); + + $(window).on("resize.danbooru", () => this.chart.resize()); + } +} diff --git a/app/logical/concerns/aggregatable.rb b/app/logical/concerns/aggregatable.rb new file mode 100644 index 000000000..26b3aa9e2 --- /dev/null +++ b/app/logical/concerns/aggregatable.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Aggregatable + extend ActiveSupport::Concern + + def timeseries(period: "day", date_column: :created_at, from: first[date_column], to: Time.now.utc, columns: { count: "COUNT(*)" }) + raise ArgumentError, "invalid period: #{period}" if !period.in?(%w[second minute hour day week month quarter year]) + + from = from.to_date + to = to.to_date + + # SELECT + # date_trunc('day', posts.created_at) AS date + # COUNT(*) AS count + # FROM posts + # WHERE posts.created_at BETWEEN from AND to + # GROUP BY date + subquery = select(date_trunc(period, date_column).as("date")).where(date_column => (from..to)).group("date").reorder(nil) + columns.each do |name, sql| + # SELECT COUNT(*) AS count + subquery = subquery.select(Arel.sql(sql).as(name.to_s).to_sql) + end + + # SELECT date_trunc('day', dates) AS date FROM generate_series('2022-01-01', '2022-02-15', '1 day'::interval) AS dates + dates = "SELECT #{date_trunc(period, Arel.sql("dates")).to_sql} AS date FROM #{generate_timeseries(from, to, period).to_sql} AS dates" + + # SELECT + # date_trunc('day', dates.date) AS date, + # COALESCE(subquery.count, 0) AS count + # FROM ( + # SELECT date_trunc('day', dates) AS date + # FROM generate_series(from, to, '1 day'::interval) AS dates + # ) AS dates + # LEFT OUTER JOIN ( + # SELECT + # date_trunc('day', posts.created_at) AS date, + # COUNT(*) AS count + # FROM posts + # WHERE posts.created_at BETWEEN from AND to + # GROUP BY date + # ) AS subquery + # ORDER BY date DESC + query = + unscoped. + select(date_trunc(period, Arel.sql("dates.date")).as("date")). + from("(#{dates}) AS dates"). + joins("LEFT OUTER JOIN (#{subquery.to_sql}) AS subquery ON subquery.date = dates.date"). + order("date DESC") + + columns.each do |name, sql| + # SELECT COALESCE(subquery.count, 0) AS count + query = query.select(coalesce(Arel.sql("subquery.#{connection.quote_column_name(name)}"), 0).as(name.to_s)) + end + + query.select_all + end + + def group_by_period(period = "day", column = :created_at) + select(date_trunc(period, column).as("date")).group("date").order(Arel.sql("date DESC")) + end + + def select_all + connection.select_all(all) + end + + def date_trunc(field, column) + sql_function(:date_trunc, field, column) + end + + def coalesce(column, value) + sql_function(:coalesce, column, value) + end + + def generate_series(from, to, interval) + sql_function(:generate_series, from, to, interval) + end + + def generate_timeseries(from, to, interval) + generate_series(from, to, Arel.sql("#{connection.quote("1 #{interval}")}::interval")) + end +end diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index 33ff16b1a..c1a13a061 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -744,16 +744,15 @@ module Searchable end def sql_value(value) - if Arel.arel_node?(value) + case value + in _ if Arel.arel_node?(value) value - elsif value.is_a?(String) - Arel::Nodes.build_quoted(value) - elsif value.is_a?(Symbol) + in Symbol arel_table[value] - elsif value.is_a?(Array) + in Array sql_array(value) else - raise ArgumentError + Arel::Nodes.build_quoted(value) end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index e1520bbd7..baa3cad66 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -10,6 +10,7 @@ class ApplicationRecord < ActiveRecord::Base include HasDtextLinks extend HasBitFlags extend Searchable + extend Aggregatable concerning :PaginationMethods do class_methods do diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb new file mode 100644 index 000000000..216b465dc --- /dev/null +++ b/app/views/reports/index.html.erb @@ -0,0 +1,24 @@ +<% page_title "Reports" %> + +
+
+

Reports

+ + +
+
diff --git a/app/views/reports/show.html.erb b/app/views/reports/show.html.erb new file mode 100644 index 000000000..c219e4d4c --- /dev/null +++ b/app/views/reports/show.html.erb @@ -0,0 +1,22 @@ +<% page_title @title %> + +
+
+

<%= @title %>

+ <%= link_to "« Back", reports_path, class: "text-xs" %> + + <%= search_form_for(current_page_path) do |f| %> + <% if @report == "posts" %> + <%= f.input :tags, label: "Tags", input_html: { value: params[:search][:tags], data: { autocomplete: "tag-query" } } %> + <% end %> + + <%= f.input :from, as: :date, html5: true, input_html: { value: params[:search][:from] || 1.month.ago.to_date } %> + <%= f.input :to, as: :date, html5: true, input_html: { value: params[:search][:to] || Time.zone.now.to_date } %> + <%= f.input :period, collection: %w[Day Week Month Year], selected: params[:search][:period] %> + <%= f.input :mode, as: :hidden, input_html: { value: params[:search][:mode] } %> + <%= f.submit "Search" %> + <% end %> + + <%= render TimeSeriesComponent.new(@results, @columns.keys, mode: @mode) %> +
+
diff --git a/app/views/static/site_map.html.erb b/app/views/static/site_map.html.erb index cd2407b29..b180c1495 100644 --- a/app/views/static/site_map.html.erb +++ b/app/views/static/site_map.html.erb @@ -73,6 +73,7 @@