Merge branch 'rollups' into 'rails'
Estadísticas Closes #3228 See merge request sutty/sutty!61
This commit is contained in:
commit
f0613aef25
21 changed files with 610 additions and 23 deletions
2
Gemfile
2
Gemfile
|
@ -31,6 +31,7 @@ gem 'jbuilder', '~> 2.5'
|
||||||
# Use ActiveModel has_secure_password
|
# Use ActiveModel has_secure_password
|
||||||
gem 'bcrypt', '~> 3.1.7'
|
gem 'bcrypt', '~> 3.1.7'
|
||||||
gem 'blazer'
|
gem 'blazer'
|
||||||
|
gem 'chartkick'
|
||||||
gem 'commonmarker'
|
gem 'commonmarker'
|
||||||
gem 'devise'
|
gem 'devise'
|
||||||
gem 'devise-i18n'
|
gem 'devise-i18n'
|
||||||
|
@ -61,6 +62,7 @@ gem 'rails-i18n'
|
||||||
gem 'rails_warden'
|
gem 'rails_warden'
|
||||||
gem 'redis', require: %w[redis redis/connection/hiredis]
|
gem 'redis', require: %w[redis redis/connection/hiredis]
|
||||||
gem 'redis-rails'
|
gem 'redis-rails'
|
||||||
|
gem 'rollups', git: 'https://github.com/ankane/rollup.git', branch: 'master'
|
||||||
gem 'rubyzip'
|
gem 'rubyzip'
|
||||||
gem 'rugged'
|
gem 'rugged'
|
||||||
gem 'concurrent-ruby-ext'
|
gem 'concurrent-ruby-ext'
|
||||||
|
|
13
Gemfile.lock
13
Gemfile.lock
|
@ -6,6 +6,15 @@ GIT
|
||||||
rails (>= 3.0)
|
rails (>= 3.0)
|
||||||
rake (>= 0.8.7)
|
rake (>= 0.8.7)
|
||||||
|
|
||||||
|
GIT
|
||||||
|
remote: https://github.com/ankane/rollup.git
|
||||||
|
revision: 94ca777d54180c23e96ac4b4285cc9b405ccbd1a
|
||||||
|
branch: master
|
||||||
|
specs:
|
||||||
|
rollups (0.1.2)
|
||||||
|
activesupport (>= 5.1)
|
||||||
|
groupdate (>= 5.2)
|
||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/fauno/email_address
|
remote: https://github.com/fauno/email_address
|
||||||
revision: 536b51f7071b68a55140c0c1726b4cd401d1c04d
|
revision: 536b51f7071b68a55140c0c1726b4cd401d1c04d
|
||||||
|
@ -205,6 +214,8 @@ GEM
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
globalid (0.4.2)
|
globalid (0.4.2)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
|
groupdate (5.2.2)
|
||||||
|
activesupport (>= 5)
|
||||||
hairtrigger (0.2.24)
|
hairtrigger (0.2.24)
|
||||||
activerecord (>= 5.0, < 7)
|
activerecord (>= 5.0, < 7)
|
||||||
ruby2ruby (~> 2.4)
|
ruby2ruby (~> 2.4)
|
||||||
|
@ -649,6 +660,7 @@ DEPENDENCIES
|
||||||
bootstrap (~> 4)
|
bootstrap (~> 4)
|
||||||
brakeman
|
brakeman
|
||||||
capybara (~> 2.13)
|
capybara (~> 2.13)
|
||||||
|
chartkick
|
||||||
commonmarker
|
commonmarker
|
||||||
concurrent-ruby-ext
|
concurrent-ruby-ext
|
||||||
database_cleaner
|
database_cleaner
|
||||||
|
@ -707,6 +719,7 @@ DEPENDENCIES
|
||||||
recursero-jekyll-theme
|
recursero-jekyll-theme
|
||||||
redis
|
redis
|
||||||
redis-rails
|
redis-rails
|
||||||
|
rollups!
|
||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubyzip
|
rubyzip
|
||||||
rugged
|
rugged
|
||||||
|
|
|
@ -3,16 +3,166 @@
|
||||||
# Estadísticas del sitio
|
# Estadísticas del sitio
|
||||||
class StatsController < ApplicationController
|
class StatsController < ApplicationController
|
||||||
include Pundit
|
include Pundit
|
||||||
|
include ActionView::Helpers::DateHelper
|
||||||
|
|
||||||
before_action :authenticate_usuarie!
|
before_action :authenticate_usuarie!
|
||||||
|
before_action :authorize_stats
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@chart_params = { interval: interval }
|
||||||
|
hostnames
|
||||||
|
last_stat
|
||||||
|
chart_options
|
||||||
|
normalized_urls
|
||||||
|
end
|
||||||
|
|
||||||
|
# Genera un gráfico de visitas por dominio asociado a este sitio
|
||||||
|
def host
|
||||||
|
return unless stale? [last_stat, hostnames, interval]
|
||||||
|
|
||||||
|
stats = Rollup.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]
|
||||||
|
|
||||||
|
options = {
|
||||||
|
interval: interval,
|
||||||
|
dimensions: {
|
||||||
|
deploy_id: @site.deploys.where(type: 'DeployLocal').pluck(:id).first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render json: Rollup.series(resource, **options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def uris
|
||||||
|
return unless stale? [last_stat, hostnames, interval, normalized_urls]
|
||||||
|
|
||||||
|
options = { host: hostnames, uri: normalized_paths }
|
||||||
|
stats = Rollup.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 last_stat
|
||||||
|
@last_stat ||= Stat.last
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_stats
|
||||||
@site = find_site
|
@site = find_site
|
||||||
authorize SiteStat.new(@site)
|
authorize SiteStat.new(@site)
|
||||||
|
end
|
||||||
|
|
||||||
# Solo queremos el promedio de tiempo de compilación, no de
|
# TODO: Eliminar cuando mergeemos referer-origin
|
||||||
# instalación de dependencias.
|
def hostnames
|
||||||
stats = @site.build_stats.jekyll
|
@hostnames ||= [@site.hostname, @site.alternative_hostnames].flatten
|
||||||
@build_avg = stats.average(:seconds).to_f.round(2)
|
end
|
||||||
@build_max = stats.maximum(:seconds).to_f.round(2)
|
|
||||||
|
# Normalizar las URLs
|
||||||
|
#
|
||||||
|
# @return [Array]
|
||||||
|
def normalized_urls
|
||||||
|
@normalized_urls ||= params.permit(:urls).try(:[],
|
||||||
|
:urls)&.split("\n")&.map(&:strip)&.select(&:present?)&.select do |uri|
|
||||||
|
uri.start_with? 'https://'
|
||||||
|
end&.map do |u|
|
||||||
|
# XXX: Eliminar
|
||||||
|
# @see {https://0xacab.org/sutty/containers/nginx/-/merge_requests/1}
|
||||||
|
next u unless u.end_with? '/'
|
||||||
|
|
||||||
|
"#{u}index.html"
|
||||||
|
end&.uniq || [@site.url, @site.urls].flatten.uniq
|
||||||
|
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: %(<div id="%{id}" class="d-flex align-items-center justify-content-center" style="height: %{height}; width: %{width};">%{loading}</div>)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Obtiene y valida los intervalos
|
||||||
|
#
|
||||||
|
# @return [Symbol]
|
||||||
|
def interval
|
||||||
|
@interval ||= begin
|
||||||
|
i = params[:interval]&.to_sym
|
||||||
|
Stat::INTERVALS.include?(i) ? i : :day
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource
|
||||||
|
@resource ||= begin
|
||||||
|
r = params[:resource].to_sym
|
||||||
|
Stat::RESOURCES.include?(r) ? r : :builds
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -18,6 +18,7 @@ import 'etc'
|
||||||
import Rails from '@rails/ujs'
|
import Rails from '@rails/ujs'
|
||||||
import Turbolinks from 'turbolinks'
|
import Turbolinks from 'turbolinks'
|
||||||
import * as ActiveStorage from '@rails/activestorage'
|
import * as ActiveStorage from '@rails/activestorage'
|
||||||
|
import 'chartkick/chart.js'
|
||||||
|
|
||||||
Rails.start()
|
Rails.start()
|
||||||
Turbolinks.start()
|
Turbolinks.start()
|
||||||
|
|
55
app/jobs/periodic_job.rb
Normal file
55
app/jobs/periodic_job.rb
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Una tarea que se corre periódicamente
|
||||||
|
class PeriodicJob < ApplicationJob
|
||||||
|
class RunAgainException < StandardError; end
|
||||||
|
|
||||||
|
STARTING_INTERVAL = Stat::INTERVALS.first
|
||||||
|
|
||||||
|
# Tener el sitio a mano
|
||||||
|
attr_reader :site
|
||||||
|
|
||||||
|
# Descartar y notificar si pasó algo más.
|
||||||
|
#
|
||||||
|
# XXX: En realidad deberíamos seguir reintentando?
|
||||||
|
discard_on(StandardError) do |_, error|
|
||||||
|
ExceptionNotifier.notify_exception(error)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Correr indefinidamente una vez por hora.
|
||||||
|
#
|
||||||
|
# XXX: El orden importa, si el descarte viene después, nunca se va a
|
||||||
|
# reintentar.
|
||||||
|
retry_on(PeriodicJob::RunAgainException, wait: 1.try(STARTING_INTERVAL), attempts: Float::INFINITY, jitter: 0)
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Las clases que implementen esta tienen que usar este método al
|
||||||
|
# terminar.
|
||||||
|
def run_again!
|
||||||
|
raise PeriodicJob::RunAgainException, 'Reintentando'
|
||||||
|
end
|
||||||
|
|
||||||
|
# El intervalo de inicio
|
||||||
|
#
|
||||||
|
# @return [Symbol]
|
||||||
|
def starting_interval
|
||||||
|
STARTING_INTERVAL
|
||||||
|
end
|
||||||
|
|
||||||
|
# La última recolección de estadísticas o empezar desde el principio
|
||||||
|
# de los tiempos.
|
||||||
|
#
|
||||||
|
# @return [Stat]
|
||||||
|
def last_stat
|
||||||
|
@last_stat ||= site.stats.where(name: stat_name).last ||
|
||||||
|
site.stats.build(created_at: Time.new(1970, 1, 1))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Devuelve el comienzo del intervalo
|
||||||
|
#
|
||||||
|
# @return [Time]
|
||||||
|
def beginning_of_interval
|
||||||
|
@beginning_of_interval ||= last_stat.created_at.try(:"beginning_of_#{starting_interval}")
|
||||||
|
end
|
||||||
|
end
|
67
app/jobs/stat_collection_job.rb
Normal file
67
app/jobs/stat_collection_job.rb
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Genera resúmenes de información para poder mostrar estadísticas y se
|
||||||
|
# corre regularmente a sí misma.
|
||||||
|
class StatCollectionJob < ApplicationJob
|
||||||
|
STAT_NAME = 'stat_collection_job'
|
||||||
|
|
||||||
|
def perform(site_id:, once: true)
|
||||||
|
@site = Site.find site_id
|
||||||
|
|
||||||
|
scope.rollup('builds', **options)
|
||||||
|
|
||||||
|
scope.rollup('space_used', **options) do |rollup|
|
||||||
|
rollup.average(:bytes)
|
||||||
|
end
|
||||||
|
|
||||||
|
scope.rollup('build_time', **options) do |rollup|
|
||||||
|
rollup.average(:seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
# XXX: Es correcto promediar promedios?
|
||||||
|
Stat::INTERVALS.reduce do |previous, current|
|
||||||
|
rollup(name: 'builds', interval_previous: previous, interval: current)
|
||||||
|
rollup(name: 'space_used', interval_previous: previous, interval: current, operation: :average)
|
||||||
|
rollup(name: 'build_time', interval_previous: previous, interval: current, operation: :average)
|
||||||
|
|
||||||
|
current
|
||||||
|
end
|
||||||
|
|
||||||
|
# Registrar que se hicieron todas las recolecciones
|
||||||
|
site.stats.create! name: STAT_NAME
|
||||||
|
|
||||||
|
run_again! unless once
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Genera un rollup recursivo en base al período anterior y aplica una
|
||||||
|
# operación.
|
||||||
|
#
|
||||||
|
# @return [NilClass]
|
||||||
|
def rollup(name:, interval_previous:, interval:, operation: :sum)
|
||||||
|
Rollup.where(name: name, interval: interval_previous)
|
||||||
|
.where_dimensions(site_id: site.id)
|
||||||
|
.group("dimensions->'site_id'")
|
||||||
|
.rollup(name, interval: interval, update: true) do |rollup|
|
||||||
|
rollup.try(:operation, :value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Los registros a procesar
|
||||||
|
#
|
||||||
|
# @return [ActiveRecord::Relation]
|
||||||
|
def scope
|
||||||
|
@scope ||= site.build_stats
|
||||||
|
.jekyll
|
||||||
|
.where('created_at => ?', beginning_of_interval)
|
||||||
|
.group(:site_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Las opciones por defecto
|
||||||
|
#
|
||||||
|
# @return [Hash]
|
||||||
|
def options
|
||||||
|
@options ||= { interval: starting_interval, update: true }
|
||||||
|
end
|
||||||
|
end
|
106
app/jobs/uri_collection_job.rb
Normal file
106
app/jobs/uri_collection_job.rb
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Procesar una lista de URIs para una lista de dominios. Esto nos
|
||||||
|
# permite procesar estadísticas a demanda.
|
||||||
|
#
|
||||||
|
# Hay varias cosas acá que van a convertirse en métodos propios, como la
|
||||||
|
# detección de URIs de un sitio (aunque la versión actual detecta todas
|
||||||
|
# las páginas y no solo las de posts como tenemos planeado, hay que
|
||||||
|
# resolver eso).
|
||||||
|
#
|
||||||
|
# Los hostnames de un sitio van a poder obtenerse a partir de
|
||||||
|
# Site#hostnames con la garantía de que son únicos.
|
||||||
|
class UriCollectionJob < PeriodicJob
|
||||||
|
# Ignoramos imágenes porque suelen ser demasiadas y no aportan a las
|
||||||
|
# estadísticas.
|
||||||
|
IMAGES = %w[.png .jpg .jpeg .gif .webp].freeze
|
||||||
|
STAT_NAME = 'uri_collection_job'
|
||||||
|
|
||||||
|
def perform(site_id:, once: true)
|
||||||
|
@site = Site.find site_id
|
||||||
|
|
||||||
|
hostnames.each do |hostname|
|
||||||
|
uris.each do |uri|
|
||||||
|
return if File.exist? Rails.root.join('tmp', 'uri_collection_job_stop')
|
||||||
|
|
||||||
|
AccessLog.where(host: hostname, uri: uri)
|
||||||
|
.where('created_at >= ?', beginning_of_interval)
|
||||||
|
.completed_requests
|
||||||
|
.non_robots
|
||||||
|
.group(:host, :uri)
|
||||||
|
.rollup('host|uri', interval: starting_interval, update: true)
|
||||||
|
|
||||||
|
# Reducir las estadísticas calculadas aplicando un rollup sobre el
|
||||||
|
# intervalo más amplio.
|
||||||
|
Stat::INTERVALS.reduce do |previous, current|
|
||||||
|
Rollup.where(name: 'host|uri', interval: previous)
|
||||||
|
.where_dimensions(host: hostname, uri: uri)
|
||||||
|
.group("dimensions->'host'", "dimensions->'uri'")
|
||||||
|
.rollup('host|uri', interval: current, update: true) do |rollup|
|
||||||
|
rollup.sum(:value)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Devolver el intervalo actual
|
||||||
|
current
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Recordar la última vez que se corrió la tarea
|
||||||
|
site.stats.create! name: STAT_NAME
|
||||||
|
|
||||||
|
run_again! unless once
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def stat_name
|
||||||
|
STAT_NAME
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [String]
|
||||||
|
#
|
||||||
|
# TODO: Cambiar al mergear origin-referer
|
||||||
|
def destination
|
||||||
|
@destination ||= site.deploys.find_by(type: 'DeployLocal').destination
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO: Cambiar al mergear origin-referer
|
||||||
|
#
|
||||||
|
# @return [Array]
|
||||||
|
def hostnames
|
||||||
|
@hostnames ||= site.deploys.map do |deploy|
|
||||||
|
case deploy
|
||||||
|
when DeployLocal
|
||||||
|
site.hostname
|
||||||
|
when DeployWww
|
||||||
|
deploy.fqdn
|
||||||
|
when DeployAlternativeDomain
|
||||||
|
deploy.hostname.dup.tap do |h|
|
||||||
|
h.replace(h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}")
|
||||||
|
end
|
||||||
|
when DeployHiddenService
|
||||||
|
deploy.onion
|
||||||
|
end
|
||||||
|
end.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
# Recolecta todas las URIs menos imágenes
|
||||||
|
#
|
||||||
|
# @return [Array]
|
||||||
|
def uris
|
||||||
|
@uris ||= Dir.chdir destination do
|
||||||
|
(Dir.glob('**/*.html') + Dir.glob('public/**/*').reject do |p|
|
||||||
|
File.directory? p
|
||||||
|
end.reject do |p|
|
||||||
|
p = p.downcase
|
||||||
|
|
||||||
|
IMAGES.any? do |i|
|
||||||
|
p.end_with? i
|
||||||
|
end
|
||||||
|
end).map do |uri|
|
||||||
|
"/#{uri}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,4 +1,12 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class AccessLog < ApplicationRecord
|
class AccessLog < ApplicationRecord
|
||||||
|
# Las peticiones completas son las que terminaron bien y se
|
||||||
|
# respondieron con 200 OK o 304 Not Modified
|
||||||
|
#
|
||||||
|
# @see {https://en.wikipedia.org/wiki/List_of_HTTP_status_codes}
|
||||||
|
scope :completed_requests, -> { where(request_method: 'GET', request_completion: 'OK', status: [200, 304]) }
|
||||||
|
scope :non_robots, -> { where(crawler: false) }
|
||||||
|
scope :robots, -> { where(crawler: true) }
|
||||||
|
scope :pages, -> { where(sent_http_content_type: ['text/html', 'text/html; charset=utf-8', 'text/html; charset=UTF-8']) }
|
||||||
end
|
end
|
||||||
|
|
|
@ -37,6 +37,7 @@ class Site < ApplicationRecord
|
||||||
belongs_to :design
|
belongs_to :design
|
||||||
belongs_to :licencia
|
belongs_to :licencia
|
||||||
|
|
||||||
|
has_many :stats
|
||||||
has_many :log_entries, dependent: :destroy
|
has_many :log_entries, dependent: :destroy
|
||||||
has_many :deploys, dependent: :destroy
|
has_many :deploys, dependent: :destroy
|
||||||
has_many :build_stats, through: :deploys
|
has_many :build_stats, through: :deploys
|
||||||
|
|
10
app/models/stat.rb
Normal file
10
app/models/stat.rb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Registran cuándo fue la última recolección de datos.
|
||||||
|
class Stat < ApplicationRecord
|
||||||
|
# XXX: Los intervalos van en orden de mayor especificidad a menor
|
||||||
|
INTERVALS = %i[day month year].freeze
|
||||||
|
RESOURCES = %i[builds space_used build_time].freeze
|
||||||
|
|
||||||
|
belongs_to :site
|
||||||
|
end
|
|
@ -12,4 +12,16 @@ class SiteStatPolicy
|
||||||
def index?
|
def index?
|
||||||
site_stat.site.usuarie? usuarie
|
site_stat.site.usuarie? usuarie
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def host?
|
||||||
|
index?
|
||||||
|
end
|
||||||
|
|
||||||
|
def resources?
|
||||||
|
index?
|
||||||
|
end
|
||||||
|
|
||||||
|
def uris?
|
||||||
|
index?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,17 +1,43 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
|
||||||
link_to(@site.name, site_path(@site)), t('.title')]
|
|
||||||
|
|
||||||
.row
|
.row
|
||||||
.col
|
.col
|
||||||
%h1= t('.title')
|
%h1= t('.title')
|
||||||
%p.lead= t('.help')
|
%p.lead= t('.help')
|
||||||
|
- if @last_stat
|
||||||
|
%p
|
||||||
|
%small
|
||||||
|
= t('.last_update')
|
||||||
|
%time{ datetime: @last_stat.created_at }
|
||||||
|
#{time_ago_in_words @last_stat.created_at}.
|
||||||
|
|
||||||
%table.table.table-condensed
|
.mb-5
|
||||||
%tbody
|
- Stat::INTERVALS.each do |interval|
|
||||||
%tr
|
= link_to t(".#{interval}"), site_stats_path(interval: interval, urls: params[:urls]), class: "btn #{'btn-primary active' if @interval == interval}"
|
||||||
%td= t('.build.average')
|
|
||||||
%td= distance_of_time_in_words_if_more_than_a_minute @build_avg
|
.mb-5
|
||||||
%tr
|
%h2= t('.host.title', count: @hostnames.size)
|
||||||
%td= t('.build.maximum')
|
%p.lead= t('.host.description')
|
||||||
%td= distance_of_time_in_words_if_more_than_a_minute @build_max
|
= line_chart site_stats_host_path(@chart_params), **@chart_options
|
||||||
|
|
||||||
|
.mb-5
|
||||||
|
%h2= t('.urls.title')
|
||||||
|
%p.lead= t('.urls.description')
|
||||||
|
%form
|
||||||
|
%input{ type: 'hidden', name: 'interval', value: @interval }
|
||||||
|
.form-group
|
||||||
|
%label{ for: 'urls' }= t('.urls.label')
|
||||||
|
%textarea#urls.form-control{ name: 'urls', autocomplete: 'on', required: true, rows: @normalized_urls.size, aria_describedby: 'help-urls' }= @normalized_urls.join("\n")
|
||||||
|
%small#help-urls.feedback.form-text.text-muted= t('.urls.help')
|
||||||
|
.form-group
|
||||||
|
%button.btn{ type: 'submit' }= t('.urls.submit')
|
||||||
|
- if @normalized_urls.present?
|
||||||
|
= line_chart site_stats_uris_path(urls: params[:urls], **@chart_params), **@chart_options
|
||||||
|
|
||||||
|
.mb-5
|
||||||
|
%h2= t('.resources.title')
|
||||||
|
%p.lead= t('.resources.description')
|
||||||
|
|
||||||
|
- Stat::RESOURCES.each do |resource|
|
||||||
|
.mb-5
|
||||||
|
%h3= t(".resources.#{resource}.title")
|
||||||
|
%p.lead= t(".resources.#{resource}.description")
|
||||||
|
= line_chart site_stats_resources_path(resource: resource, **@chart_params), **@chart_options.merge(StatsController::EXTRA_OPTIONS[resource])
|
||||||
|
|
|
@ -11,6 +11,8 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||||
inflect.singular 'licencias', 'licencia'
|
inflect.singular 'licencias', 'licencia'
|
||||||
inflect.plural 'rol', 'roles'
|
inflect.plural 'rol', 'roles'
|
||||||
inflect.singular 'roles', 'rol'
|
inflect.singular 'roles', 'rol'
|
||||||
|
inflect.plural 'rollup', 'rollups'
|
||||||
|
inflect.singular 'rollups', 'rollup'
|
||||||
end
|
end
|
||||||
|
|
||||||
ActiveSupport::Inflector.inflections(:es) do |inflect|
|
ActiveSupport::Inflector.inflections(:es) do |inflect|
|
||||||
|
@ -24,4 +26,6 @@ ActiveSupport::Inflector.inflections(:es) do |inflect|
|
||||||
inflect.singular 'roles', 'rol'
|
inflect.singular 'roles', 'rol'
|
||||||
inflect.plural 'licencia', 'licencias'
|
inflect.plural 'licencia', 'licencias'
|
||||||
inflect.singular 'licencias', 'licencia'
|
inflect.singular 'licencias', 'licencia'
|
||||||
|
inflect.plural 'rollup', 'rollups'
|
||||||
|
inflect.singular 'rollups', 'rollup'
|
||||||
end
|
end
|
||||||
|
|
|
@ -252,9 +252,38 @@ en:
|
||||||
help: |
|
help: |
|
||||||
These statistics show information about how your site is generated and
|
These statistics show information about how your site is generated and
|
||||||
how many resources it uses.
|
how many resources it uses.
|
||||||
build:
|
last_update: 'Updated every hour. Last update on '
|
||||||
average: 'Average building time'
|
empty: 'There is no enough information yet. We invite you to come back in %{please_return_at}!'
|
||||||
maximum: 'Maximum building time'
|
loading: 'Loading...'
|
||||||
|
hour: 'Hourly'
|
||||||
|
day: 'Daily'
|
||||||
|
week: 'Weekly'
|
||||||
|
month: 'Monthly'
|
||||||
|
year: 'Yearly'
|
||||||
|
host:
|
||||||
|
title:
|
||||||
|
zero: 'Site visits'
|
||||||
|
one: 'Site visits'
|
||||||
|
other: 'Visits by domain name'
|
||||||
|
description: 'Counts visited pages on your site, grouped by domain names in use.'
|
||||||
|
urls:
|
||||||
|
title: 'Visits by URL'
|
||||||
|
description: 'Counts visits or downloads on any URL.'
|
||||||
|
label: 'URLs ("links")'
|
||||||
|
help: 'Copy and paste a single URL per line'
|
||||||
|
submit: 'Update graph'
|
||||||
|
resources:
|
||||||
|
title: 'Resource usage'
|
||||||
|
description: "In this section you can find statistics on your site's use of Sutty's shared resources"
|
||||||
|
builds:
|
||||||
|
title: 'Site publication'
|
||||||
|
description: 'Times you published your site.'
|
||||||
|
space_used:
|
||||||
|
title: 'Server disk usage'
|
||||||
|
description: 'Average storage space used by your site.'
|
||||||
|
build_time:
|
||||||
|
title: 'Publication time'
|
||||||
|
description: 'Average time your site takes to build.'
|
||||||
sites:
|
sites:
|
||||||
donations:
|
donations:
|
||||||
url: 'https://donaciones.sutty.nl/en/'
|
url: 'https://donaciones.sutty.nl/en/'
|
||||||
|
|
|
@ -257,9 +257,38 @@ es:
|
||||||
help: |
|
help: |
|
||||||
Las estadísticas visibilizan información sobre cómo se genera y
|
Las estadísticas visibilizan información sobre cómo se genera y
|
||||||
cuántos recursos utiliza tu sitio.
|
cuántos recursos utiliza tu sitio.
|
||||||
build:
|
last_update: 'Actualizadas cada hora. Última actualización hace '
|
||||||
average: 'Tiempo promedio de generación'
|
empty: 'Todavía no hay información suficiente. Te invitamos a volver en %{please_return_at} :)'
|
||||||
maximum: 'Tiempo máximo de generación'
|
loading: 'Cargando...'
|
||||||
|
hour: 'Por hora'
|
||||||
|
day: 'Diarias'
|
||||||
|
week: 'Semanales'
|
||||||
|
month: 'Mensuales'
|
||||||
|
year: 'Anuales'
|
||||||
|
host:
|
||||||
|
title:
|
||||||
|
zero: 'Visitas del sitio'
|
||||||
|
one: 'Visitas del sitio'
|
||||||
|
other: 'Visitas agrupadas por nombre de dominio del sitio'
|
||||||
|
description: 'Cuenta la cantidad de páginas visitadas en tu sitio.'
|
||||||
|
urls:
|
||||||
|
title: 'Visitas por dirección'
|
||||||
|
description: 'Cantidad de visitas o descargas por dirección.'
|
||||||
|
label: 'Direcciones web (URL, "links", vínculos)'
|
||||||
|
help: 'Copia y pega una dirección por línea.'
|
||||||
|
submit: 'Actualizar gráfico'
|
||||||
|
resources:
|
||||||
|
title: 'Uso de recursos'
|
||||||
|
description: 'En esta sección podrás acceder a estadísticas del uso de recursos compartidos con otros sitios alojados en Sutty.'
|
||||||
|
builds:
|
||||||
|
title: 'Publicaciones del sitio'
|
||||||
|
description: 'Cantidad de veces que publicaste tu sitio.'
|
||||||
|
space_used:
|
||||||
|
title: 'Espacio utilizado en el servidor'
|
||||||
|
description: 'Espacio en disco que ocupa en promedio tu sitio.'
|
||||||
|
build_time:
|
||||||
|
title: 'Tiempo de publicación'
|
||||||
|
description: 'Tiempo promedio que toma en publicarse tu sitio.'
|
||||||
sites:
|
sites:
|
||||||
donations:
|
donations:
|
||||||
url: 'https://donaciones.sutty.nl/'
|
url: 'https://donaciones.sutty.nl/'
|
||||||
|
|
|
@ -76,5 +76,8 @@ Rails.application.routes.draw do
|
||||||
post 'reorder_posts', to: 'sites#reorder_posts'
|
post 'reorder_posts', to: 'sites#reorder_posts'
|
||||||
|
|
||||||
resources :stats, only: [:index]
|
resources :stats, only: [:index]
|
||||||
|
get :'stats/host', to: 'stats#host'
|
||||||
|
get :'stats/uris', to: 'stats#uris'
|
||||||
|
get :'stats/resources', to: 'stats#resources'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
15
db/migrate/20210807003928_create_rollups.rb
Normal file
15
db/migrate/20210807003928_create_rollups.rb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Crear la tabla de Rollups
|
||||||
|
class CreateRollups < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
create_table :rollups do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.string :interval, null: false
|
||||||
|
t.datetime :time, null: false
|
||||||
|
t.jsonb :dimensions, null: false, default: {}
|
||||||
|
t.float :value
|
||||||
|
end
|
||||||
|
add_index :rollups, %i[name interval time dimensions], unique: true
|
||||||
|
end
|
||||||
|
end
|
18
db/migrate/20210807004941_add_create_at_to_access_logs.rb
Normal file
18
db/migrate/20210807004941_add_create_at_to_access_logs.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Cambia los msec a datetime para poder agregar por tiempos
|
||||||
|
class AddCreateAtToAccessLogs < ActiveRecord::Migration[6.1]
|
||||||
|
def up
|
||||||
|
add_column :access_logs, :created_at, :datetime, precision: 6
|
||||||
|
|
||||||
|
create_trigger(compatibility: 1).on(:access_logs).before(:insert) do
|
||||||
|
'new.created_at := to_timestamp(new.msec)'
|
||||||
|
end
|
||||||
|
|
||||||
|
ActiveRecord::Base.connection.execute('update access_logs set created_at = to_timestamp(msec);')
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :access_logs, :created_at
|
||||||
|
end
|
||||||
|
end
|
12
db/migrate/20211008201239_create_stats.rb
Normal file
12
db/migrate/20211008201239_create_stats.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Una tabla que lleva el recuento de recolección de estadísticas, solo
|
||||||
|
# es necesario para saber cuándo se hicieron, si se hicieron y usar como
|
||||||
|
# caché.
|
||||||
|
class CreateStats < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
create_table :stats do |t|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -13,6 +13,8 @@
|
||||||
"@rails/ujs": "^6.1.3-1",
|
"@rails/ujs": "^6.1.3-1",
|
||||||
"@rails/webpacker": "5.2.1",
|
"@rails/webpacker": "5.2.1",
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.2",
|
||||||
|
"chart.js": "^3.5.1",
|
||||||
|
"chartkick": "^4.0.5",
|
||||||
"circular-dependency-plugin": "^5.2.2",
|
"circular-dependency-plugin": "^5.2.2",
|
||||||
"commonmark": "^0.29.0",
|
"commonmark": "^0.29.0",
|
||||||
"fork-awesome": "^1.1.7",
|
"fork-awesome": "^1.1.7",
|
||||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -2119,6 +2119,25 @@ chalk@^4.1.0:
|
||||||
ansi-styles "^4.1.0"
|
ansi-styles "^4.1.0"
|
||||||
supports-color "^7.1.0"
|
supports-color "^7.1.0"
|
||||||
|
|
||||||
|
chart.js@>=3.0.2, chart.js@^3.5.1:
|
||||||
|
version "3.5.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.5.1.tgz#73e24d23a4134a70ccdb5e79a917f156b6f3644a"
|
||||||
|
integrity sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ==
|
||||||
|
|
||||||
|
chartjs-adapter-date-fns@>=2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b"
|
||||||
|
integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==
|
||||||
|
|
||||||
|
chartkick@^4.0.5:
|
||||||
|
version "4.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/chartkick/-/chartkick-4.0.5.tgz#310a60c931e8ceedc39adee2ef8e9d1e474cb0e6"
|
||||||
|
integrity sha512-xKak4Fsgfvp1hj/LykRKkniDMaZASx2A4TdVc/sfsiNFFNf1m+D7PGwP1vgj1UsbsCjOCSfGWWyJpOYxkUCBug==
|
||||||
|
optionalDependencies:
|
||||||
|
chart.js ">=3.0.2"
|
||||||
|
chartjs-adapter-date-fns ">=2.0.0"
|
||||||
|
date-fns ">=2.0.0"
|
||||||
|
|
||||||
chokidar@^2.1.8:
|
chokidar@^2.1.8:
|
||||||
version "2.1.8"
|
version "2.1.8"
|
||||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"
|
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"
|
||||||
|
@ -2744,6 +2763,11 @@ dashdash@^1.12.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
assert-plus "^1.0.0"
|
assert-plus "^1.0.0"
|
||||||
|
|
||||||
|
date-fns@>=2.0.0:
|
||||||
|
version "2.24.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.24.0.tgz#7d86dc0d93c87b76b63d213b4413337cfd1c105d"
|
||||||
|
integrity sha512-6ujwvwgPID6zbI0o7UbURi2vlLDR9uP26+tW6Lg+Ji3w7dd0i3DOcjcClLjLPranT60SSEFBwdSyYwn/ZkPIuw==
|
||||||
|
|
||||||
debug@2.6.9, debug@^2.2.0, debug@^2.3.3:
|
debug@2.6.9, debug@^2.2.0, debug@^2.3.3:
|
||||||
version "2.6.9"
|
version "2.6.9"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||||
|
|
Loading…
Reference in a new issue