5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-05-18 08:20:49 +00:00

Merge branch 'rails' into actualizacion-de-cuidados

This commit is contained in:
f 2021-11-24 21:02:11 -03:00
commit da5f7e959e
28 changed files with 646 additions and 42 deletions

View file

@ -1,3 +1,4 @@
RAILS_GROUPS=assets
DELEGATE=athshe.sutty.nl DELEGATE=athshe.sutty.nl
HAINISH=../haini.sh/haini.sh HAINISH=../haini.sh/haini.sh
DATABASE= DATABASE=

View file

@ -2,7 +2,7 @@
# el mismo repositorio de trabajo. Cuando tengamos CI/CD algunas cosas # el mismo repositorio de trabajo. Cuando tengamos CI/CD algunas cosas
# como el tarball van a tener que cambiar porque ya vamos a haber hecho # como el tarball van a tener que cambiar porque ya vamos a haber hecho
# un clone/pull limpio. # un clone/pull limpio.
FROM alpine:3.13.5 AS build FROM alpine:3.13.6 AS build
MAINTAINER "f <f@sutty.nl>" MAINTAINER "f <f@sutty.nl>"
ARG RAILS_MASTER_KEY ARG RAILS_MASTER_KEY
@ -14,7 +14,7 @@ ENV SECRET_KEY_BASE solo_es_necesaria_para_correr_rake
ENV RAILS_ENV production ENV RAILS_ENV production
ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-bundler ruby-json ruby-bigdecimal ruby-rake RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake
RUN apk add --no-cache postgresql-libs git yarn brotli libssh2 python3 RUN apk add --no-cache postgresql-libs git yarn brotli libssh2 python3
RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'` RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'`
@ -29,7 +29,7 @@ RUN cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/rubygems-platform-musl.patch
RUN addgroup -g 82 -S www-data RUN addgroup -g 82 -S www-data
RUN adduser -s /bin/sh -G www-data -h /home/app -D app RUN adduser -s /bin/sh -G www-data -h /home/app -D app
RUN install -dm750 -o app -g www-data /home/app/sutty RUN install -dm750 -o app -g www-data /home/app/sutty
RUN gem install --no-document bundler RUN gem install --no-document bundler:2.1.4
# Empezamos con la usuaria app # Empezamos con la usuaria app
USER app USER app
@ -39,7 +39,8 @@ WORKDIR /home/app/sutty
# Copiamos solo el Gemfile para poder instalar las gemas necesarias # Copiamos solo el Gemfile para poder instalar las gemas necesarias
COPY --chown=app:www-data ./Gemfile . COPY --chown=app:www-data ./Gemfile .
COPY --chown=app:www-data ./Gemfile.lock . COPY --chown=app:www-data ./Gemfile.lock .
RUN bundle config set no-cache 'true' RUN bundle config set no-cache true
RUN bundle config set specific_platform true
RUN bundle install --path=./vendor --without='test development' RUN bundle install --path=./vendor --without='test development'
# Vaciar la caché # Vaciar la caché
RUN rm vendor/ruby/2.7.0/cache/*.gem RUN rm vendor/ruby/2.7.0/cache/*.gem
@ -60,10 +61,6 @@ RUN mv ../sutty/.bundle ./.bundle
# Instalar secretos # Instalar secretos
COPY --chown=app:root ./config/credentials.yml.enc ./config/ COPY --chown=app:root ./config/credentials.yml.enc ./config/
# Eliminar la necesidad de un runtime JS en producción, porque los
# assets ya están pre-compilados.
RUN sed -re "/(sassc|uglifier|bootstrap|coffee-rails)/d" -i Gemfile
RUN bundle clean
RUN rm -rf ./node_modules ./tmp/cache ./.git ./test ./doc RUN rm -rf ./node_modules ./tmp/cache ./.git ./test ./doc
# Eliminar archivos innecesarios # Eliminar archivos innecesarios
USER root USER root
@ -71,7 +68,7 @@ RUN apk add --no-cache findutils
RUN find /home/app/checkout/vendor/ruby/2.7.0 -maxdepth 3 -type d -name test -o -name spec -o -name rubocop | xargs -r rm -rf RUN find /home/app/checkout/vendor/ruby/2.7.0 -maxdepth 3 -type d -name test -o -name spec -o -name rubocop | xargs -r rm -rf
# Contenedor final # Contenedor final
FROM sutty/monit:latest FROM registry.nulo.in/sutty/monit:3.13.6
ENV RAILS_ENV production ENV RAILS_ENV production
# Pandoc # Pandoc
@ -79,7 +76,7 @@ RUN echo 'http://dl-cdn.alpinelinux.org/alpine/edge/testing' >> /etc/apk/reposit
# Instalar las dependencias, separamos la librería de base de datos para # Instalar las dependencias, separamos la librería de base de datos para
# poder reutilizar este primer paso desde otros contenedores # poder reutilizar este primer paso desde otros contenedores
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-bundler ruby-json ruby-bigdecimal ruby-rake ruby-irb RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake ruby-irb ruby-io-console ruby-etc
RUN apk add --no-cache postgresql-libs libssh2 file rsync git jpegoptim vips RUN apk add --no-cache postgresql-libs libssh2 file rsync git jpegoptim vips
RUN apk add --no-cache ffmpeg imagemagick pandoc tectonic oxipng jemalloc RUN apk add --no-cache ffmpeg imagemagick pandoc tectonic oxipng jemalloc
RUN apk add --no-cache git-lfs openssh-client patch RUN apk add --no-cache git-lfs openssh-client patch
@ -97,7 +94,7 @@ RUN apk add --no-cache patch && cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/ru
# principal # principal
RUN apk add --no-cache yarn RUN apk add --no-cache yarn
# Instalar foreman para poder correr los servicios # Instalar foreman para poder correr los servicios
RUN gem install --no-document --no-user-install bundler foreman RUN gem install --no-document --no-user-install bundler:2.1.4 foreman
# Agregar el grupo del servidor web y la usuaria # Agregar el grupo del servidor web y la usuaria
RUN addgroup -g 82 -S www-data RUN addgroup -g 82 -S www-data

19
Gemfile
View file

@ -11,13 +11,16 @@ gem 'dotenv-rails', require: 'dotenv/rails-now'
gem 'rails', '~> 6' gem 'rails', '~> 6'
# Use Puma as the app server # Use Puma as the app server
gem 'puma' gem 'puma'
# See https://github.com/rails/execjs#readme for more supported runtimes
# gem 'therubyracer', platforms: :ruby # Solo incluir las gemas cuando estemos en desarrollo o compilando los
# Use SCSS for stylesheets # assets. No es necesario instalarlas en producción.
gem 'sassc-rails' #
# Use Uglifier as compressor for JavaScript assets # XXX: Supuestamente Rails ya soporta RAILS_GROUPS, pero Bundler no.
gem 'uglifier', '>= 1.3.0' if ENV['RAILS_GROUPS']&.split(',')&.include? 'assets'
gem 'bootstrap', '~> 4' gem 'sassc-rails'
gem 'uglifier', '>= 1.3.0'
gem 'bootstrap', '~> 4'
end
gem 'nokogiri', '~>1.11.0' gem 'nokogiri', '~>1.11.0'
@ -30,6 +33,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'
@ -60,6 +64,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'

View file

@ -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.5.2) globalid (0.5.2)
activesupport (>= 5.0) activesupport (>= 5.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)
@ -650,6 +661,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
@ -709,6 +721,7 @@ DEPENDENCIES
recursero-jekyll-theme recursero-jekyll-theme
redis redis
redis-rails redis-rails
rollups!
rubocop-rails rubocop-rails
rubyzip rubyzip
rugged rugged

View file

@ -48,7 +48,7 @@ help: always ## Ayuda
@echo -e "\nArgumentos:\n" @echo -e "\nArgumentos:\n"
@grep -E "^[a-z\-]+ \?=.*##" Makefile | sed -re "s/(.*) \?=.*##(.*)/\1;\2/" | column -s ";" -t | sed -re "s/^([^ ]+) /\x1B[38;5;197m\1\x1B[0m/" @grep -E "^[a-z\-]+ \?=.*##" Makefile | sed -re "s/(.*) \?=.*##(.*)/\1;\2/" | column -s ";" -t | sed -re "s/^([^ ]+) /\x1B[38;5;197m\1\x1B[0m/"
assets: node_modules public/packs/manifest.json.br ## Compilar los assets assets: public/packs/manifest.json.br ## Compilar los assets
test: always ## Ejecutar los tests test: always ## Ejecutar los tests
$(MAKE) rake args="test RAILS_ENV=test $(args)" $(MAKE) rake args="test RAILS_ENV=test $(args)"

View file

@ -2,6 +2,13 @@
box-sizing: border-box; box-sizing: border-box;
*, *::before, *::after { box-sizing: inherit; } *, *::before, *::after { box-sizing: inherit; }
// Arreglo temporal para que las cosas sean legibles en modo oscuro
--foreground: black;
--background: white;
--color: #f206f9;
background: var(--background);
color: var(--foreground);
h1, h2, h3, h4, h5, h6, p, li { h1, h2, h3, h4, h5, h6, p, li {
min-height: 1.5rem; min-height: 1.5rem;
} }

View file

@ -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

View file

@ -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
View 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

View 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

View 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

View file

@ -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

View file

@ -2,6 +2,12 @@
# Este metadato permite generar rutas manuales. # Este metadato permite generar rutas manuales.
class MetadataPermalink < MetadataString class MetadataPermalink < MetadataString
# El valor por defecto una vez creado es la URL que le asigne Jekyll,
# de forma que nunca cambia aunque se cambie el título.
def default_value
document.url.sub(%r{\A/}, '') unless post.new?
end
# Los permalinks nunca pueden ser privados # Los permalinks nunca pueden ser privados
def private? def private?
false false

View file

@ -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
View 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

View file

@ -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

View file

@ -122,6 +122,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
# la búsqueda. # la búsqueda.
def change_licencias def change_licencias
site.locales.each do |locale| site.locales.each do |locale|
next unless I18n.available_locales.include? locale
Mobility.with_locale(locale) do Mobility.with_locale(locale) do
permalink = "#{I18n.t('activerecord.models.licencia').downcase}/" permalink = "#{I18n.t('activerecord.models.licencia').downcase}/"
post = site.posts(lang: locale).find_by(permalink: permalink) post = site.posts(lang: locale).find_by(permalink: permalink)

View file

@ -67,6 +67,7 @@
%h2= t('posts.empty') %h2= t('posts.empty')
- else - else
= form_tag site_posts_reorder_path, method: :post do = form_tag site_posts_reorder_path, method: :post do
%input{ type: 'hidden', name: 'post[lang]', value: @locale }
%table.table{ data: { controller: 'reorder' } } %table.table{ data: { controller: 'reorder' } }
%caption.sr-only= t('posts.caption') %caption.sr-only= t('posts.caption')
%thead %thead

View file

@ -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])

View file

@ -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

View file

@ -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/'

View file

@ -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/'

View file

@ -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

View 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

View 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

View 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

View file

@ -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",

View file

@ -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"