# frozen_string_literal: true # Estadísticas del sitio class StatsController < ApplicationController include Pundit include ActionView::Helpers::DateHelper before_action :authenticate_usuarie! before_action :authorize_stats breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path breadcrumb 'sites.index', :sites_path, match: :exact breadcrumb -> { site.title }, -> { site_posts_path(site, locale: locale) }, match: :exact EXTRA_OPTIONS = { builds: {}, space_used: { bytes: true }, build_time: {} }.freeze # XXX: Permitir a Chart.js inyectar su propio CSS content_security_policy only: :index do |policy| policy.style_src :self, :unsafe_inline policy.script_src :self, :unsafe_inline end # Parámetros por defecto # # @return [Hash] def default_url_options { interval: 'day', period_start: Date.today.beginning_of_year, period_end: Date.today } end def index breadcrumb I18n.t('stats.index.title'), '' params.with_defaults! default_url_options @chart_params = { interval: interval, period_start: params[:period_start], period_end: params[:period_end] } hostnames last_stat chart_options normalized_urls expires_in = Time.now.try(:"end_of_#{Stat.default_interval}") - Time.now @columns = {} Stat::COLUMNS.each do |column| @columns[column] = Rails.cache.fetch("stats/#{column}/#{site.id}", expires_in: expires_in) do rollup_scope.where(interval: interval, name: "host|#{column}") .where_dimensions(host: hostnames) .group("dimensions->>'#{column}'") .order('sum(value) desc') .sum(:value) .transform_values(&:to_i) .transform_values do |v| v * nodes end end end end # Genera un gráfico de visitas por dominio asociado a este sitio def host return unless stale? [last_stat, hostnames, interval, period] stats = rollup_scope.where_dimensions(host: hostnames).multi_series('host', interval: interval).tap do |series| series.each do |serie| serie[:name] = serie.dig(:dimensions, 'host') serie[:data].transform_values! do |value| value * nodes end end end render json: stats end def resources return unless stale? [last_stat, interval, resource, period] options = { interval: interval, dimensions: { site_id: site.id } } render json: rollup_scope.series(resource, **options) end def uris return unless stale? [last_stat, hostnames, interval, normalized_urls, period] options = { host: hostnames, uri: normalized_paths } # XXX: where_dimensions es más corto pero no aprovecha los índices # de Rollup stats = rollup_scope.where_dimensions(**options).multi_series('host|uri', interval: interval).tap do |series| series.each do |serie| serie[:name] = serie[:dimensions].slice('host', 'uri').values.join.sub('/index.html', '/') serie[:data].transform_values! do |value| value * nodes end end end render json: stats end private def rollup_scope Rollup.where(time: period) end def last_stat @last_stat ||= site.stats.last end def authorize_stats authorize SiteStat.new(site) end # TODO: Eliminar cuando mergeemos referer-origin def hostnames @hostnames ||= site.hostnames end # Normalizar las URLs # # @return [Array] def normalized_urls @normalized_urls ||= begin urls = params[:urls].is_a?(Array) ? params[:urls] : params[:urls]&.split("\n") urls = urls&.map(&:strip)&.select(&:present?)&.select do |uri| uri.start_with? 'https://' end urls ||= [site.url] urls.map do |u| # XXX: Eliminar al deployear # @see {https://0xacab.org/sutty/containers/nginx/-/merge_requests/1} next u unless u.end_with? '/' "#{u}index.html" end.uniq end end def normalized_paths @normalized_paths ||= normalized_urls.map do |u| "/#{u.split('/', 4).last}" end.map do |u| URI.decode_www_form_component u end end # Opciones por defecto para los gráficos. # # La invitación a volver dentro de X tiempo es para dar un estimado de # cuándo habrá información disponible, porque Rollup genera intervalos # completos (¿aunque dice que no?) # # La diferencia se calcula sumando el intervalo a la hora de última # toma de estadísticas y restando el tiempo que pasó desde ese # momento. def chart_options time = (last_stat&.created_at || Time.now) + 1.try(interval) please_return_at = { please_return_at: distance_of_time_in_words(Time.now, time) } @chart_options ||= { locale: I18n.locale, empty: I18n.t('stats.index.empty', **please_return_at), loading: I18n.t('stats.index.loading'), html: %(
%{loading}
) } end # Obtiene y valida los intervalos # # @return [Symbol] def interval @interval ||= begin i = params[:interval]&.to_sym Stat::INTERVALS.include?(i) ? i : Stat::INTERVALS.first end end # @return [Symbol] def resource @resource ||= begin r = params[:resource].to_sym Stat::RESOURCES.include?(r) ? r : Stat::RESOURCES.first end end # Obtiene la cantidad de nodos de Sutty, para poder calcular la # cantidad de visitas. # # Como repartimos las visitas por nodo rotando las IPs en el # nameserver y los resolvedores de DNS eligen un nameserver # aleatoriamente, la cantidad de visitas se reparte # equitativamente. # # XXX: Remover cuando podamos centralizar los AccessLog # # @return [Integer] def nodes @nodes ||= ENV.fetch('NODES', 1).to_i end def period @period ||= begin p = params.permit(:period_start, :period_end) p[:period_start]..p[:period_end] end end def site @site ||= find_site end end