diff --git a/Gemfile b/Gemfile
index 981ce7fd..c37d3f62 100644
--- a/Gemfile
+++ b/Gemfile
@@ -31,6 +31,7 @@ gem 'jbuilder', '~> 2.5'
# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.7'
gem 'blazer'
+gem 'chartkick'
gem 'commonmarker'
gem 'devise'
gem 'devise-i18n'
@@ -61,6 +62,7 @@ gem 'rails-i18n'
gem 'rails_warden'
gem 'redis', require: %w[redis redis/connection/hiredis]
gem 'redis-rails'
+gem 'rollups', git: 'https://github.com/ankane/rollup.git', branch: 'master'
gem 'rubyzip'
gem 'rugged'
gem 'concurrent-ruby-ext'
diff --git a/Gemfile.lock b/Gemfile.lock
index e08bcb05..f516af42 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -6,6 +6,15 @@ GIT
rails (>= 3.0)
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
remote: https://github.com/fauno/email_address
revision: 536b51f7071b68a55140c0c1726b4cd401d1c04d
@@ -205,6 +214,8 @@ GEM
ffi (~> 1.0)
globalid (0.4.2)
activesupport (>= 4.2.0)
+ groupdate (5.2.2)
+ activesupport (>= 5)
hairtrigger (0.2.24)
activerecord (>= 5.0, < 7)
ruby2ruby (~> 2.4)
@@ -649,6 +660,7 @@ DEPENDENCIES
bootstrap (~> 4)
brakeman
capybara (~> 2.13)
+ chartkick
commonmarker
concurrent-ruby-ext
database_cleaner
@@ -707,6 +719,7 @@ DEPENDENCIES
recursero-jekyll-theme
redis
redis-rails
+ rollups!
rubocop-rails
rubyzip
rugged
diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb
index 07baaf1a..44073c1f 100644
--- a/app/controllers/stats_controller.rb
+++ b/app/controllers/stats_controller.rb
@@ -3,16 +3,166 @@
# Estadísticas del sitio
class StatsController < ApplicationController
include Pundit
+ include ActionView::Helpers::DateHelper
+
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
+ @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
authorize SiteStat.new(@site)
+ end
- # Solo queremos el promedio de tiempo de compilación, no de
- # instalación de dependencias.
- stats = @site.build_stats.jekyll
- @build_avg = stats.average(:seconds).to_f.round(2)
- @build_max = stats.maximum(:seconds).to_f.round(2)
+ # TODO: Eliminar cuando mergeemos referer-origin
+ def hostnames
+ @hostnames ||= [@site.hostname, @site.alternative_hostnames].flatten
+ end
+
+ # 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: %(
%{loading}
)
+ }
+ 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
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index 6aa3a2e1..492ca736 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -18,6 +18,7 @@ import 'etc'
import Rails from '@rails/ujs'
import Turbolinks from 'turbolinks'
import * as ActiveStorage from '@rails/activestorage'
+import 'chartkick/chart.js'
Rails.start()
Turbolinks.start()
diff --git a/app/jobs/periodic_job.rb b/app/jobs/periodic_job.rb
new file mode 100644
index 00000000..8d9453a3
--- /dev/null
+++ b/app/jobs/periodic_job.rb
@@ -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
diff --git a/app/jobs/stat_collection_job.rb b/app/jobs/stat_collection_job.rb
new file mode 100644
index 00000000..2aa8d702
--- /dev/null
+++ b/app/jobs/stat_collection_job.rb
@@ -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
diff --git a/app/jobs/uri_collection_job.rb b/app/jobs/uri_collection_job.rb
new file mode 100644
index 00000000..9ec333cd
--- /dev/null
+++ b/app/jobs/uri_collection_job.rb
@@ -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
diff --git a/app/models/access_log.rb b/app/models/access_log.rb
index 85cd4c36..3a066b33 100644
--- a/app/models/access_log.rb
+++ b/app/models/access_log.rb
@@ -1,4 +1,12 @@
# frozen_string_literal: true
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
diff --git a/app/models/site.rb b/app/models/site.rb
index ddfe2bc9..df92264a 100644
--- a/app/models/site.rb
+++ b/app/models/site.rb
@@ -37,6 +37,7 @@ class Site < ApplicationRecord
belongs_to :design
belongs_to :licencia
+ has_many :stats
has_many :log_entries, dependent: :destroy
has_many :deploys, dependent: :destroy
has_many :build_stats, through: :deploys
diff --git a/app/models/stat.rb b/app/models/stat.rb
new file mode 100644
index 00000000..5f72ccd0
--- /dev/null
+++ b/app/models/stat.rb
@@ -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
diff --git a/app/policies/site_stat_policy.rb b/app/policies/site_stat_policy.rb
index a797034c..cb62b507 100644
--- a/app/policies/site_stat_policy.rb
+++ b/app/policies/site_stat_policy.rb
@@ -12,4 +12,16 @@ class SiteStatPolicy
def index?
site_stat.site.usuarie? usuarie
end
+
+ def host?
+ index?
+ end
+
+ def resources?
+ index?
+ end
+
+ def uris?
+ index?
+ end
end
diff --git a/app/views/stats/index.haml b/app/views/stats/index.haml
index f49cdd15..bfcf33ef 100644
--- a/app/views/stats/index.haml
+++ b/app/views/stats/index.haml
@@ -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
.col
%h1= t('.title')
%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
- %tbody
- %tr
- %td= t('.build.average')
- %td= distance_of_time_in_words_if_more_than_a_minute @build_avg
- %tr
- %td= t('.build.maximum')
- %td= distance_of_time_in_words_if_more_than_a_minute @build_max
+ .mb-5
+ - Stat::INTERVALS.each do |interval|
+ = link_to t(".#{interval}"), site_stats_path(interval: interval, urls: params[:urls]), class: "btn #{'btn-primary active' if @interval == interval}"
+
+ .mb-5
+ %h2= t('.host.title', count: @hostnames.size)
+ %p.lead= t('.host.description')
+ = 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])
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index 35b309ea..0e18b987 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -11,6 +11,8 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.singular 'licencias', 'licencia'
inflect.plural 'rol', 'roles'
inflect.singular 'roles', 'rol'
+ inflect.plural 'rollup', 'rollups'
+ inflect.singular 'rollups', 'rollup'
end
ActiveSupport::Inflector.inflections(:es) do |inflect|
@@ -24,4 +26,6 @@ ActiveSupport::Inflector.inflections(:es) do |inflect|
inflect.singular 'roles', 'rol'
inflect.plural 'licencia', 'licencias'
inflect.singular 'licencias', 'licencia'
+ inflect.plural 'rollup', 'rollups'
+ inflect.singular 'rollups', 'rollup'
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 43ab0d0a..b814796d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -252,9 +252,38 @@ en:
help: |
These statistics show information about how your site is generated and
how many resources it uses.
- build:
- average: 'Average building time'
- maximum: 'Maximum building time'
+ last_update: 'Updated every hour. Last update on '
+ empty: 'There is no enough information yet. We invite you to come back in %{please_return_at}!'
+ 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:
donations:
url: 'https://donaciones.sutty.nl/en/'
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 880b9e7c..a6fbd407 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -257,9 +257,38 @@ es:
help: |
Las estadísticas visibilizan información sobre cómo se genera y
cuántos recursos utiliza tu sitio.
- build:
- average: 'Tiempo promedio de generación'
- maximum: 'Tiempo máximo de generación'
+ last_update: 'Actualizadas cada hora. Última actualización hace '
+ empty: 'Todavía no hay información suficiente. Te invitamos a volver en %{please_return_at} :)'
+ 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:
donations:
url: 'https://donaciones.sutty.nl/'
diff --git a/config/routes.rb b/config/routes.rb
index 5e172cda..2aa0056f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -76,5 +76,8 @@ Rails.application.routes.draw do
post 'reorder_posts', to: 'sites#reorder_posts'
resources :stats, only: [:index]
+ get :'stats/host', to: 'stats#host'
+ get :'stats/uris', to: 'stats#uris'
+ get :'stats/resources', to: 'stats#resources'
end
end
diff --git a/db/migrate/20210807003928_create_rollups.rb b/db/migrate/20210807003928_create_rollups.rb
new file mode 100644
index 00000000..932513a4
--- /dev/null
+++ b/db/migrate/20210807003928_create_rollups.rb
@@ -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
diff --git a/db/migrate/20210807004941_add_create_at_to_access_logs.rb b/db/migrate/20210807004941_add_create_at_to_access_logs.rb
new file mode 100644
index 00000000..0e106061
--- /dev/null
+++ b/db/migrate/20210807004941_add_create_at_to_access_logs.rb
@@ -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
diff --git a/db/migrate/20211008201239_create_stats.rb b/db/migrate/20211008201239_create_stats.rb
new file mode 100644
index 00000000..e1aff8f6
--- /dev/null
+++ b/db/migrate/20211008201239_create_stats.rb
@@ -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
diff --git a/package.json b/package.json
index 0a2458a6..d520c8f5 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,8 @@
"@rails/ujs": "^6.1.3-1",
"@rails/webpacker": "5.2.1",
"babel-loader": "^8.2.2",
+ "chart.js": "^3.5.1",
+ "chartkick": "^4.0.5",
"circular-dependency-plugin": "^5.2.2",
"commonmark": "^0.29.0",
"fork-awesome": "^1.1.7",
diff --git a/yarn.lock b/yarn.lock
index 11ff78cb..68b7fd23 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2119,6 +2119,25 @@ chalk@^4.1.0:
ansi-styles "^4.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:
version "2.1.8"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"
@@ -2744,6 +2763,11 @@ dashdash@^1.12.0:
dependencies:
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:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"