diff --git a/Gemfile b/Gemfile index aacc982..e3665cb 100644 --- a/Gemfile +++ b/Gemfile @@ -42,6 +42,8 @@ gem 'whenever', require: false gem 'octicons' +gem 'kaminari' +gem 'has_scope' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', '>= 1.1.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 61f2f33..5e091d7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -131,6 +131,9 @@ GEM guard-minitest (2.4.6) guard-compat (~> 1.2) minitest (>= 3.0) + has_scope (0.7.2) + actionpack (>= 4.1) + activesupport (>= 4.1) i18n (1.0.1) concurrent-ruby (~> 1.0) io-like (0.3.0) @@ -138,6 +141,18 @@ GEM activesupport (>= 4.2.0) multi_json (>= 1.2) json (2.1.0) + kaminari (1.1.1) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.1.1) + kaminari-activerecord (= 1.1.1) + kaminari-core (= 1.1.1) + kaminari-actionview (1.1.1) + actionview + kaminari-core (= 1.1.1) + kaminari-activerecord (1.1.1) + activerecord + kaminari-core (= 1.1.1) + kaminari-core (1.1.1) launchy (2.4.3) addressable (~> 2.3) letter_opener (1.6.0) @@ -338,7 +353,9 @@ DEPENDENCIES factory_bot_rails guard guard-minitest + has_scope jbuilder (~> 2.5) + kaminari launchy letter_opener_web listen (>= 3.0.5, < 3.2) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5127212..86a4a95 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -20,4 +20,14 @@ class ApplicationController < ActionController::Base def set_locale I18n.locale = current_user.try(:locale) || I18n.default_locale end + + def not_found + fail ActionController::RoutingError, "Not Found" + rescue StandardError + render_404 + end + + def render_404 + render file: "#{Rails.root}/public/404", status: :not_found + end end diff --git a/app/controllers/checks_controller.rb b/app/controllers/checks_controller.rb index b2566a6..d252411 100644 --- a/app/controllers/checks_controller.rb +++ b/app/controllers/checks_controller.rb @@ -4,14 +4,24 @@ class ChecksController < ApplicationController after_action :verify_authorized, except: :index after_action :verify_policy_scoped, only: :index + has_scope :kind + has_scope :by_domain + has_scope :recurrent_failures, type: :boolean + def index - @checks = policy_scope(Check).order(:domain_expires_at) + @checks = apply_scopes(policy_scope(Check)).order(Hash[*current_sort]).page(params[:page]) end def new @check = Check.new - build_empty_notification authorize @check + + if params[:kind].present? + return not_found unless Check.kinds.key?(params[:kind]) + @check.kind = params[:kind] + end + + build_empty_notification end def create @@ -20,10 +30,10 @@ class ChecksController < ApplicationController authorize @check if @check.save - flash[:notice] = "Your check has been saved." + flash[:notice] = t(".saved") redirect_to checks_path else - flash.now[:alert] = "An error occured." + flash.now[:alert] = t(".invalid") render :new end end @@ -73,4 +83,22 @@ class ChecksController < ApplicationController def build_empty_notification @check.notifications.build end + + def current_sort + @current_sort ||= clean_sort || Check.default_sort + end + helper_method :current_sort + + def clean_sort + return unless params[:sort].present? + field, _, direction = params[:sort].rpartition("_").map(&:to_sym) + + valid_fields = [:domain, :domain_expires_at] + valid_directions = [:asc, :desc] + + return unless valid_fields.include?(field) + return unless valid_directions.include?(direction) + + [field, direction] + end end diff --git a/app/frontend/packs/application.js b/app/frontend/packs/application.js index 30be22b..d2d55db 100644 --- a/app/frontend/packs/application.js +++ b/app/frontend/packs/application.js @@ -8,10 +8,18 @@ // layout file, like app/views/layouts/application.html.erb import Rails from 'rails-ujs'; +import Turbolinks from 'turbolinks'; import 'bootstrap/js/dist/collapse'; import 'bootstrap/js/dist/dropdown'; +import 'bootstrap/js/dist/button'; +import 'bootstrap/js/dist/tooltip'; import '../scss'; Rails.start() +Turbolinks.start() + +document.addEventListener("turbolinks:load", () => { + $('[data-toggle="tooltip"]').tooltip(); +}); diff --git a/app/frontend/scss/_variables.scss b/app/frontend/scss/_variables.scss new file mode 100644 index 0000000..822dfbf --- /dev/null +++ b/app/frontend/scss/_variables.scss @@ -0,0 +1,10 @@ +$input-placeholder-color: #adb5bd; +$enable-rounded: false; +$theme-colors: ( + "primary": #259EDB, + "secondary": #565554, + "success": #42935C, + "warning": #F6AE2D, + "danger": #E94F37, + "info": #2E86AB, +); diff --git a/app/frontend/scss/components/checks.scss b/app/frontend/scss/components/checks.scss index 26f3f5d..f0e20e6 100644 --- a/app/frontend/scss/components/checks.scss +++ b/app/frontend/scss/components/checks.scss @@ -1,4 +1,4 @@ -.table-checks { +.checks-table { .action a { color: black; } diff --git a/app/frontend/scss/index.scss b/app/frontend/scss/index.scss index 2577ff8..4b15d76 100644 --- a/app/frontend/scss/index.scss +++ b/app/frontend/scss/index.scss @@ -1,3 +1,4 @@ +@import '_variables'; @import '~bootstrap/scss/bootstrap'; @import 'layout'; @import 'icons'; diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 53fa2d6..3d6fea9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,4 +1,8 @@ module ApplicationHelper + def format_date(time, format: :long) + l(time.utc.to_date, format: format) + end + def format_utc(time, format: :default) l(time.utc, format: format) end diff --git a/app/helpers/checks_helper.rb b/app/helpers/checks_helper.rb index ae82a4c..f779644 100644 --- a/app/helpers/checks_helper.rb +++ b/app/helpers/checks_helper.rb @@ -1,14 +1,78 @@ -module ChecksHelper - def check_kind_label(check) - check.kind.upcase - end +# frozen_string_literal: true +module ChecksHelper def check_row_class(check) expiry_date = check.domain_expires_at return unless expiry_date.present? - return "table-danger" if expiry_date <= 2.weeks.from_now - return "table-warning" if expiry_date <= 30.days.from_now + return "table-danger" if expiry_date <= 3.days.from_now + return "table-warning" if expiry_date < 1.month.from_now + end + + def checks_sort_links(field) + %i[asc desc].map { |direction| + checks_sort_link(field, direction) + }.join + end + + def checks_sort_link(field, direction) + classes = "btn btn-light btn-sm mx-1 mx-1 px-1 py-0" + sort = [field, direction] + + icon = direction == :asc ? "chevron-up" : "chevron-down" + html = Octicons::Octicon.new(icon).to_svg.html_safe + + sort_path = checks_path(current_criterias.merge(sort: sort.join("_"))) + link_to_unless sort == current_sort, html, sort_path, class: classes do + content_tag(:span, html, class: classes + " active") + end + end + + def check_in_error(check) + content_tag( + :span, + Octicons::Octicon.new("alert", class: "ml-1").to_svg.html_safe, + class: "in-error text-danger", + data: { + toggle: "tooltip", + placement: "bottom", + title: check_last_success_title(check), + }, + ) + end + + def current_criterias + current_scopes.merge(sort: params[:sort]) + end + + def scoped_with?(scope) + name, value = scope.first + scope_value = current_scopes[name] + scope_value = scope_value.to_sym if scope_value.respond_to?(:to_sym) + + scope_value == value + end + + def check_button_criterias(scope) + if scoped_with?(scope) + current_criterias.except(scope.keys.first) + else + current_criterias.merge(scope) + end + end + + def check_button_scope_class(scope = nil) + "btn btn-sm " + if scope && scoped_with?(scope) + "btn-info active" + else + "btn-outline-info" + end + end + + def check_last_success_title(check) + return t(".never_succeeded") if check.last_success_at.nil? + + t(".days_from_last_success", count: check.days_from_last_success) end end diff --git a/app/models/check.rb b/app/models/check.rb index 115119b..de2d32d 100644 --- a/app/models/check.rb +++ b/app/models/check.rb @@ -27,6 +27,8 @@ # class Check < ApplicationRecord + ERROR_DELAY_DAYS = 3 + belongs_to :user has_many :logs, class_name: "CheckLog" has_many :notifications, validate: true, dependent: :destroy @@ -58,6 +60,32 @@ class Check < ApplicationRecord OR (last_success_at <= DATE_SUB(last_run_at, INTERVAL 5 MINUTE))") } + scope :kind, ->(kind) { where(kind: kind) } + scope :by_domain, ->(domain) { where("domain LIKE ?", "%#{domain}%") } + scope :recurrent_failures, -> { + interval = "INTERVAL #{ERROR_DELAY_DAYS} DAY" + where("last_run_at IS NOT NULL AND created_at <= DATE_SUB(NOW(), #{interval})") + .where("last_success_at IS NULL OR last_success_at <= DATE_SUB(last_run_at, #{interval})") + } + + def self.default_sort + [:domain_expires_at, :asc] + end + + def in_error? + return false if created_at > ERROR_DELAY_DAYS.days.ago + return false if last_run_at.nil? + return true if last_success_at.nil? + + last_success_at < ERROR_DELAY_DAYS.days.ago + end + + def days_from_last_success + return unless last_success_at.present? + + (Date.today - last_success_at.to_date).to_i + end + private def domain_created_at_past diff --git a/app/views/checks/_filters.html.erb b/app/views/checks/_filters.html.erb new file mode 100644 index 0000000..80e6b17 --- /dev/null +++ b/app/views/checks/_filters.html.erb @@ -0,0 +1,40 @@ +
+ +
+
+ <% Check.kinds.keys.map(&:to_sym).each do |kind_name| %> + <%= link_to t(".kind_#{kind_name}"), + checks_path(check_button_criterias(kind: kind_name)), + class: check_button_scope_class(kind: kind_name) %> + <% end %> +
+ + <%= link_to t(".with_error"), + checks_path(check_button_criterias(recurrent_failures: true)), + class: check_button_scope_class(recurrent_failures: true) %> +
+ +
+ <%= form_tag(checks_path, method: :get) do %> +
+
+
+ <%= search_field_tag :by_domain, current_scopes[:by_domain], class: "form-control form-control-sm", placeholder: ".com, example.org, …" %> +
+ <%= button_tag Octicons::Octicon.new("search").to_svg.html_safe, class: "btn btn-sm btn-outline-secondary" %> +
+
+ + <%- current_criterias.except(:by_domain).compact.each_pair do |name, value| %> + <%= hidden_field_tag name, value%> + <% end %> +
+ +
+ <%= link_to Octicons::Octicon.new("x").to_svg.html_safe, checks_path, class: "btn btn-danger btn-sm btn-outline-danger" %> +
+
+ + <% end %> +
+
diff --git a/app/views/checks/_form.html.erb b/app/views/checks/_form.html.erb index e4dd82f..959d5b0 100644 --- a/app/views/checks/_form.html.erb +++ b/app/views/checks/_form.html.erb @@ -1,8 +1,11 @@ <%= simple_form_for(check) do |f| %> - <%= f.input :domain, autofocus: true, input_html: { autocapitalize: :none, autocorrect: :off } %> + <%= f.input :domain, + autofocus: true, + input_html: { autocapitalize: :none, autocorrect: :off }, + label: t(".#{check.kind || "generic" }.domain") %> <% if check.new_record? %> - <%= f.input :kind, as: :radio_buttons, collection: Check.kinds.keys if check.new_record? %> + <%= f.input :kind, as: check.kind.present? ? :hidden : :radio_buttons, collection: Check.kinds.keys %> <% end %> <%= f.input :comment %> diff --git a/app/views/checks/_table.html.erb b/app/views/checks/_table.html.erb index d177256..fcda912 100644 --- a/app/views/checks/_table.html.erb +++ b/app/views/checks/_table.html.erb @@ -1,26 +1,41 @@ -
- +
+
- - - + + + <% checks.each do |check| %> - -
DomainExpiry dateEdit + <%= t(".th.domain") %> + + <%== checks_sort_links(:domain) %> + + + <%= t(".th.expiry_date") %> + <%= t(".th.expiry_date_short") %> + + <%== checks_sort_links(:domain_expires_at) %> + + <%= t(".th.edit") %>
- <%= check_kind_label(check) %> + + <%= t(".kind_labels.#{check.kind}") %> + <%= check_in_error(check) if check.in_error? %> <%= check.domain %> - <%= format_utc(check.domain_expires_at) if check.domain_expires_at.present? %> + <% if check.domain_expires_at.present? %> + <%= content_tag :span, format_date(check.domain_expires_at), class: "d-none d-md-inline" %> + <%= content_tag :span, format_date(check.domain_expires_at, format: :short), class: "d-inline d-md-none" %> + <% end %> + <%= link_to edit_check_path(check) do %> <%== Octicons::Octicon.new("pencil").to_svg %> <% end %> @@ -30,3 +45,5 @@
+ +<%= paginate @checks %> diff --git a/app/views/checks/index.html.erb b/app/views/checks/index.html.erb index d425008..ae8d626 100644 --- a/app/views/checks/index.html.erb +++ b/app/views/checks/index.html.erb @@ -1,13 +1,20 @@ -
+
-
- <% if @checks.empty? %> +
+ <% if @checks.empty? && current_scopes.blank? %>
- <%= t(".no_check_yet_html", new_domain_path: new_check_path, new_ssl_path: new_check_path) %> + <%= t(".no_check_yet_html", new_domain_path: new_check_path(kind: :domain), new_ssl_path: new_check_path(kind: :ssl)) %>
<% else %> -

List of your checks

- <%= render "table", checks: @checks %> +

<%= t(".title") %>

+ + <%= render "filters" %> + + <% if @checks.any? %> + <%= render "table", checks: @checks %> + <% else %> +
<%= t(".no_matching_check") %>
+ <% end %> <% end %>
diff --git a/app/views/checks/new.html.erb b/app/views/checks/new.html.erb index 97b34b8..3a662a7 100644 --- a/app/views/checks/new.html.erb +++ b/app/views/checks/new.html.erb @@ -1,7 +1,7 @@
-

Create a new check

+

<%= t(".#{@check.kind}.title") %>

<%= render "form", check: @check %>
diff --git a/app/views/kaminari/_first_page.html.erb b/app/views/kaminari/_first_page.html.erb new file mode 100644 index 0000000..be84d7b --- /dev/null +++ b/app/views/kaminari/_first_page.html.erb @@ -0,0 +1,11 @@ +<%# Link to the "First" page + - available local variables + url: url to the first page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • + <%= link_to t('views.pagination.first').html_safe, url, remote: remote, class: "page-link" %> +
  • diff --git a/app/views/kaminari/_gap.html.erb b/app/views/kaminari/_gap.html.erb new file mode 100644 index 0000000..cd730b7 --- /dev/null +++ b/app/views/kaminari/_gap.html.erb @@ -0,0 +1,10 @@ +<%# Non-link tag that stands for skipped pages... + - available local variables + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • + <%= t('views.pagination.truncate').html_safe %> +
  • diff --git a/app/views/kaminari/_last_page.html.erb b/app/views/kaminari/_last_page.html.erb new file mode 100644 index 0000000..4ef40e0 --- /dev/null +++ b/app/views/kaminari/_last_page.html.erb @@ -0,0 +1,11 @@ +<%# Link to the "Last" page + - available local variables + url: url to the last page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • + <%= link_to t('views.pagination.last').html_safe, url, remote: remote, class: "page-link" %> +
  • diff --git a/app/views/kaminari/_next_page.html.erb b/app/views/kaminari/_next_page.html.erb new file mode 100644 index 0000000..c967dd8 --- /dev/null +++ b/app/views/kaminari/_next_page.html.erb @@ -0,0 +1,11 @@ +<%# Link to the "Next" page + - available local variables + url: url to the next page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • + <%= link_to t('views.pagination.next').html_safe, url, rel: 'next', remote: remote, class: "page-link" %> +
  • diff --git a/app/views/kaminari/_page.html.erb b/app/views/kaminari/_page.html.erb new file mode 100644 index 0000000..efcbe03 --- /dev/null +++ b/app/views/kaminari/_page.html.erb @@ -0,0 +1,15 @@ +<%# Link showing page number + - available local variables + page: a page object for "this" page + url: url to this page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • + <%= link_to page, url, remote: remote, rel: page.rel, class: "page-link" %> + <% if page.current? %> + (current) + <% end %> +
  • diff --git a/app/views/kaminari/_paginator.html.erb b/app/views/kaminari/_paginator.html.erb new file mode 100644 index 0000000..7a26612 --- /dev/null +++ b/app/views/kaminari/_paginator.html.erb @@ -0,0 +1,27 @@ +<%# The container tag + - available local variables + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote + paginator: the paginator that renders the pagination tags inside +-%> +<%= paginator.render do -%> + +<% end -%> diff --git a/app/views/kaminari/_prev_page.html.erb b/app/views/kaminari/_prev_page.html.erb new file mode 100644 index 0000000..d272a5a --- /dev/null +++ b/app/views/kaminari/_prev_page.html.erb @@ -0,0 +1,11 @@ +<%# Link to the "Previous" page + - available local variables + url: url to the previous page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • + <%= link_to t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote, class: "page-link" %> +
  • diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 02f3c53..fccb7eb 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -8,10 +8,13 @@ diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf..af698fc 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -11,6 +11,6 @@ # end # These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym 'RESTful' -# end +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym 'SSL' +end diff --git a/config/initializers/kaminari_config.rb b/config/initializers/kaminari_config.rb new file mode 100644 index 0000000..aed7d31 --- /dev/null +++ b/config/initializers/kaminari_config.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +Kaminari.configure do |config| + config.default_per_page = 20 + config.max_per_page = 200 + # config.window = 4 + # config.outer_window = 0 + # config.left = 0 + # config.right = 0 + # config.page_method_name = :page + # config.param_name = :page + # config.params_on_first_page = false +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 9a34ad9..40adaf0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -12,6 +12,11 @@ en: check: past: "can't be in the future" + date: + formats: + short: "%-d, %b %Y" + long: "%A, %B %-d, %Y" + devise: registrations: new: @@ -53,6 +58,9 @@ en: en: English fr: French navbar: + my_checks: "My checks" + new_domain_check: "New domain check" + new_ssl_check: "New SSL check" sign_up: "Sign up" sign_in: "Sign in" sign_out: "Sign out" @@ -60,11 +68,51 @@ en: checks: index: + title: List of your checks + no_matching_check: "No checks match your filters." no_check_yet_html: | You have not set up a check yet. Please add a domain or a ssl ! + + new: + title: New check + domain: + title: New domain check + ssl: + title: New SSL check + + create: + saved: "Your check has been saved." + invalid: "Please check the form." + + filters: + kind_domain: Domain + kind_ssl: SSL + with_error: With error + form: + generic: + domain: Domain + domain: + domain: Domain name + ssl: + domain: Hostname notifications_hint: | Receive notifications to warn you when our system detects that the expiration date is coming. The time is set in number of days. + + table: + th: + domain: Name + expiry_date: "Expiration date" + expiry_date_short: "Exp." + edit: Edit + kind_labels: + domain: Domain + ssl: SSL + never_succeeded: "Chexpire has never been able to perform a check." + days_from_last_success: + zero: "Last check successful: today" + one: "Last check successful: yesterday" + other: "Last check successful %{count} days ago" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index ed5926f..799126a 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -2,8 +2,15 @@ fr: activerecord: attributes: check: + domain: "Domaine" + comment: "Commentaire" + vendor: "Fournisseur" + kind: Type domain_created_at: "Date de création" domain_updated_at: "Date de modification" + notification: + interval: Délai + recipient: Destinataire user: tos_accepted: "Conditions d'utilisation" notifications_enabled: "Notifications activées" @@ -11,6 +18,26 @@ fr: models: check: past: "ne peut être dans le futur" + helpers: + submit: + check: + create: "Créer" + update: "Valider" + page_entries_info: + one_page: + display_entries: + zero: "Pas de %{entry_name} trouvé." + one: "Affiche 1 %{entry_name}" + other: "Affiche les %{count} %{entry_name}" + more_pages: + display_entries: "Affiche %{entry_name} %{first} - %{last} de %{total} au total" + views: + pagination: + first: "« Début" + last: "Fin »" + previous: "‹ Préc" + next: "Suiv ›" + truncate: "…" time: am: am @@ -20,6 +47,11 @@ fr: short: "%d %b %H:%M" pm: pm + date: + formats: + short: "%d/%m/%Y" + long: "%A %d %B %Y" + devise: registrations: new: @@ -61,6 +93,9 @@ fr: en: Anglais fr: Français navbar: + my_checks: "Mes vérifications" + new_domain_check: "Nouveau nom de domaine" + new_ssl_check: "Nouveau certificat SSL" sign_up: "Enregistrement" sign_in: "Connexion" sign_out: "Déconnexion" @@ -68,11 +103,51 @@ fr: checks: index: + title: "Liste de vos vérifications" + no_matching_check: "Aucune vérification ne correspond à vos critères." no_check_yet_html: | Vous n'avez pas encore créé de vérification. Vous pouvez en ajouter pour un domaine ou un SSL ! + + new: + title: Nouvelle vérification + domain: + title: Nouvelle vérification d'un nom de domaine + ssl: + title: Nouvelle vérification d'un certificat SSL + + create: + saved: La vérification est enregistrée. + invalid: Veuillez vérifier le formulaire. + + filters: + kind_domain: Domaine + kind_ssl: SSL + with_error: En erreur + form: + generic: + domain: Domaine + domain: + domain: Nom de domaine + ssl: + domain: Nom d'hôte notifications_hint: | Recevez des notifications pour vous avertir lorsque notre système détecte - que la date d'expiration approche. Le délais est indiqué ennombre de jours. + que la date d'expiration approche. Le délai est indiqué ennombre de jours. + + table: + th: + domain: Nom + expiry_date: "Date d'expiration" + expiry_date_short: "Exp." + edit: Modifier + kind_labels: + domain: Domaine + ssl: SSL + never_succeeded: "Chexpire n'a jamais pu effectuer de vérification." + days_from_last_success: + zero: "Dernière vérification réussie : aujourd'hui" + one: "Dernière vérification réussie : hier" + other: "Dernière vérification réussie il y a %{count} jours" diff --git a/db/seeds.rb b/db/seeds.rb index 2bf199b..996d657 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -7,9 +7,20 @@ user1 = User.create!( email: "colin@example.org", password: "password", tos_accepted: true, - confirmed_at: Time.now + confirmed_at: Time.now, + locale: :fr, ) +user2 = User.create!( + email: "colin+en@example.org", + password: "password", + tos_accepted: true, + confirmed_at: Time.now, + locale: :en, +) + +users = [user1, user2] + check_chexpire_org = Check.create!( user: user1, kind: :domain, @@ -24,14 +35,14 @@ check_chexpire_org = Check.create!( check_chexpire_org_error = Check.create!( user: user1, kind: :domain, - domain: "chexpire.org", + domain: "chexpire-error.org", domain_expires_at: 1.week.from_now, domain_updated_at: 6.months.ago, domain_created_at: Time.new(2016, 8, 4, 12, 15, 1), comment: "The date are fake, this is a seed !", vendor: "Some random registrar", last_run_at: 20.minutes.ago, - last_success_at: 4.days.ago, + created_at: 3.weeks.ago, ) ssl_check_chexpire_org = Check.create!( @@ -48,7 +59,7 @@ ssl_check_chexpire_org = Check.create!( ssl_check_chexpire_org_error = Check.create!( user: user1, kind: :ssl, - domain: "chexpire.org", + domain: "chexpire-error.org", domain_expires_at: 1.week.from_now, domain_updated_at: 6.months.ago, domain_created_at: Time.new(2016, 8, 4, 12, 15, 1), @@ -59,6 +70,33 @@ ssl_check_chexpire_org_error = Check.create!( ) +def check_factory(users) + ext = %w[com net org fr].sample + word = (0...rand(4..12)).map { (97 + rand(26)).chr }.join + + Check.new( + user: users.sample, + kind: Check.kinds.keys.sample, + domain: "#{word}.#{ext}", + domain_expires_at: rand(8..300).days.from_now, + domain_updated_at: rand(1..300).days.ago, + domain_created_at: rand(301..3000).days.ago, + ) +end + +100.times do |i| + check_factory(users).save! +end + +# checks with error +10.times do |i| + check_factory(users).update_attributes( + created_at: rand(1..300).days.ago, + last_run_at: 4.hours.ago, + last_success_at: rand(10...100).days.ago, + ) +end + Notification.create!( check: check_chexpire_org, interval: 15, diff --git a/package.json b/package.json index 93b6085..e00de19 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "exports-loader": "^0.7.0", "jquery": "^3.3.1", "popper.js": "^1.14.3", - "rails-ujs": "^5.2.0" + "rails-ujs": "^5.2.0", + "turbolinks": "^5.1.1" }, "devDependencies": { "webpack-dev-server": "2.11.2" diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 89fe8b5..e62a52a 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -4,7 +4,7 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase driven_by :headless_chrome def teardown - Capybara.reset_sessions! Warden.test_reset! + super end end diff --git a/test/controllers/.rubocop.yml b/test/controllers/.rubocop.yml new file mode 100644 index 0000000..9df3ff0 --- /dev/null +++ b/test/controllers/.rubocop.yml @@ -0,0 +1,7 @@ +inherit_from: ../../.rubocop.yml + +Metrics/ClassLength: + Enabled: false + +Metrics/BlockLength: + Enabled: false diff --git a/test/controllers/checks_controller_test.rb b/test/controllers/checks_controller_test.rb index 77435b7..a430258 100644 --- a/test/controllers/checks_controller_test.rb +++ b/test/controllers/checks_controller_test.rb @@ -1,7 +1,150 @@ require "test_helper" class ChecksControllerTest < ActionDispatch::IntegrationTest - # test "the truth" do - # assert true - # end + setup do + @user = create(:user) + login_as(@user) + end + + test "no logged users are redirected to signin form" do + logout + get new_check_path + assert_redirected_to new_user_session_path + end + + test "new without kind does not trigger an error" do + get new_check_path + assert_response :success + end + + test "new with kind domain does not trigger an error" do + get new_check_path(kind: :domain) + assert_response :success + end + + test "new with kind ssl does not trigger an error" do + get new_check_path(kind: :ssl) + assert_response :success + end + + test "new with an invalid kind returns an error" do + get new_check_path(kind: :invalid) + assert_response :not_found + end + + test "checks are ordered by default by expiry date sort" do + c1 = create(:check, user: @user, domain_expires_at: 20.days.from_now) + c2 = create(:check, user: @user, domain_expires_at: 10.days.from_now) + c3 = create(:check, user: @user, domain_expires_at: 1.day.from_now) + + get checks_path + assert_equal [c3, c2, c1], current_checks + end + + test "checks are ordered by expiry date asc" do + c1 = create(:check, user: @user, domain_expires_at: 20.days.from_now) + c2 = create(:check, user: @user, domain_expires_at: 10.days.from_now) + c3 = create(:check, user: @user, domain_expires_at: 1.day.from_now) + + get checks_path(sort: :domain_expires_at_asc) + assert_equal [c3, c2, c1], current_checks + end + + test "checks are ordered by reverse expiring date" do + c1 = create(:check, user: @user, domain_expires_at: 1.day.from_now) + c2 = create(:check, user: @user, domain_expires_at: 10.days.from_now) + c3 = create(:check, user: @user, domain_expires_at: 20.days.from_now) + + get checks_path(sort: :domain_expires_at_desc) + assert_equal [c3, c2, c1], current_checks + end + + test "checks are ordered by domain name asc" do + c1 = create(:check, user: @user, domain: "a") + c2 = create(:check, user: @user, domain: "b") + c3 = create(:check, user: @user, domain: "c") + + get checks_path(sort: :domain_asc) + assert_equal [c1, c2, c3], current_checks + end + + test "checks are ordered by domain name desc" do + c1 = create(:check, user: @user, domain: "a") + c2 = create(:check, user: @user, domain: "b") + c3 = create(:check, user: @user, domain: "c") + + get checks_path(sort: :domain_desc) + assert_equal [c3, c2, c1], current_checks + end + + test "invalid sort fallback to default sort" do + c1 = create(:check, user: @user, domain_expires_at: 20.days.from_now) + c2 = create(:check, user: @user, domain_expires_at: 10.days.from_now) + c3 = create(:check, user: @user, domain_expires_at: 1.day.from_now) + + get checks_path(sort: :invalid_sort_asc) + assert_equal [c3, c2, c1], current_checks + end + + test "checks are filtered by domain kind" do + c1 = create(:check, :domain, user: @user) + c2 = create(:check, :domain, user: @user) + create(:check, :ssl, user: @user) + + get checks_path(kind: :domain) + assert_equal [c1, c2], current_checks + end + + test "checks are filtered by ssl kind" do + create(:check, :domain, user: @user) + create(:check, :domain, user: @user) + c3 = create(:check, :ssl, user: @user) + + get checks_path(kind: :ssl) + assert_equal [c3], current_checks + end + + test "checks are filtered by domain name" do + c1 = create(:check, user: @user, domain: "abc") + c2 = create(:check, user: @user, domain: "bcde") + create(:check, user: @user, domain: "hijk") + + get checks_path(by_domain: "bc") + assert_equal [c1, c2], current_checks + + get checks_path(by_domain: "klm") + assert_empty current_checks + end + + test "checks in error are filtered" do + c1 = create(:check, :last_runs_failed, created_at: 1.week.ago, user: @user) + create(:check, user: @user) + + get checks_path(recurrent_failures: true) + assert_equal [c1], current_checks + end + + test "checks are paginated" do + create_list(:check, 40, user: @user) + + get checks_path + assert_equal 1, current_checks.current_page + first_page = current_checks + + get checks_path(page: 2) + assert_equal 2, current_checks.current_page + assert_not_equal first_page, current_checks + end + + test "checks are scoped to current user" do + c1 = create(:check, user: @user) + create(:check) + + get checks_path + assert_equal [c1], current_checks + end + + def current_checks + @controller.instance_variable_get("@checks") + end end diff --git a/test/models/check_test.rb b/test/models/check_test.rb index 1dde49b..9606150 100644 --- a/test/models/check_test.rb +++ b/test/models/check_test.rb @@ -49,4 +49,56 @@ class CheckTest < ActiveSupport::TestCase assert notification.pending? assert_nil notification.sent_at end + + test "in_error? for recently added" do + check = build(:check, created_at: 1.day.ago) + refute check.in_error? + + check = build(:check, created_at: 1.day.ago, last_run_at: 3.minutes.ago) + refute check.in_error? + + check = build(:check, created_at: 1.day.ago, last_success_at: 1.hour.ago) + refute check.in_error? + end + + test "in_error? for never success check, with at least 1 run" do + check = build(:check, created_at: 3.weeks.ago, last_run_at: 1.day.ago) + assert check.in_error? + + check = build(:check, created_at: 3.weeks.ago, last_run_at: 4.days.ago) + assert check.in_error? + end + + test "in_error? ignore check without run" do + check = build(:check, created_at: 3.weeks.ago) + refute check.in_error? + end + + test "in_error? for last success a few days ago" do + check = build(:check, created_at: 3.weeks.ago, + last_success_at: 10.days.ago, last_run_at: 1.day.ago) + assert check.in_error? + + check = build(:check, created_at: 3.weeks.ago, + last_success_at: 1.days.ago, last_run_at: 1.day.ago) + refute check.in_error? + end + + test "days_from_last_success without any success" do + check = build(:check) + assert_nil check.days_from_last_success + + check = build(:check, last_run_at: 1.day.ago) + assert_nil check.days_from_last_success + end + + test "days_from_last_success" do + check = build(:check, last_success_at: 10.days.ago - 1.hour) + assert_equal 10, check.days_from_last_success + end + + test "days_from_last_success with a time" do + check = build(:check, last_success_at: (10.1 * 24).hours.ago) + assert_equal 10, check.days_from_last_success + end end diff --git a/test/system/.rubocop.yml b/test/system/.rubocop.yml new file mode 100644 index 0000000..229dd53 --- /dev/null +++ b/test/system/.rubocop.yml @@ -0,0 +1,4 @@ +inherit_from: ../../.rubocop.yml + +Metrics/ClassLength: + Enabled: false diff --git a/test/system/checks_test.rb b/test/system/checks_test.rb index 1fefa51..994346c 100644 --- a/test/system/checks_test.rb +++ b/test/system/checks_test.rb @@ -4,16 +4,263 @@ class ChecksTest < ApplicationSystemTestCase setup do @user = create(:user) login_as(@user) - - @check = create(:check, :with_notifications, user: @user) end - test "create a check and a notification" do + test "create a check and a notification without kind" do visit new_check_path + choose "domain" + + fill_and_valid_new_check + end + + test "create a predefined domain check" do + visit new_check_path(kind: :domain) + + refute page.has_css? "domain[kind]" + + fill_and_valid_new_check + end + + test "create a predefined ssl check" do + visit new_check_path(kind: :ssl) + + refute page.has_css? "domain[kind]" + + fill_and_valid_new_check + end + + test "remove a notification" do + check = create(:check, :with_notifications, domain: "dom-with-notif.net", user: @user) + visit edit_check_path(check) + notification = check.notifications.first + + selector = "[data-notification-id=\"#{notification.id}\"]" + + assert_difference "Notification.where(check_id: #{check.id}).count", -1 do + within selector do + find(".btn-danger").click + end + + page.has_no_content?(selector) + end + end + + test "update a check" do + check = create(:check, :with_notifications, domain: "dom-with-notif.net", user: @user) + visit edit_check_path(check) + + fill_in "check[comment]", with: "My comment" + + click_button "Update Check" + + assert_equal checks_path, page.current_path + + assert page.has_css?(".alert-success") + check.reload + assert_equal "My comment", check.comment + end + + test "add a notification" do + check = create(:check, :with_notifications, domain: "dom-with-notif.net", user: @user) + visit edit_check_path(check) + + recipient = "recipient2@example.org" + fill_in("check[notifications_attributes][2][recipient]", with: recipient) + fill_in("check[notifications_attributes][2][interval]", with: 55) + + assert_difference "Notification.where(check_id: #{check.id}).count", +1 do + click_button "Update Check" + + assert_equal checks_path, page.current_path + end + + assert page.has_css?(".alert-success") + + notification = Notification.last + assert_equal recipient, notification.recipient + assert_equal 55, notification.interval + assert notification.email? + assert notification.pending? + end + + test "list my checks" do + create(:check, :domain, domain: "dom.com", domain_expires_at: Time.new(2018, 7, 5, 12), user: @user) # rubocop:disable Metrics/LineLength + create(:check, :ssl, domain: "ssldom.com", user: @user) + create(:check, :ssl, domain: "ssldom2.com", user: @user) + + visit checks_path + + within ".checks-table" do + assert page.has_content?("SSL", count: 2) + assert page.has_content?("Domain", count: 1) + end + + within ".check-row:first-of-type" do + assert page.has_content?("Domain") + assert page.has_content?("dom.com") + assert page.has_content?("Thursday, July 5, 2018") + end + end + + test "list filterable by domain and ssl" do + create_list(:check, 2, :domain, domain: "mydom.fr", user: @user) + create_list(:check, 1, :ssl, domain: "ssl.com", user: @user) + + visit checks_path + + assert page.has_css?(".check-row", count: 3) + + within ".checks-filters" do + click_on "Domain" + assert find_link("Domain").matches_css? ".active" + assert find_link("SSL").not_matches_css? ".active" + end + + within ".checks-table" do + assert page.has_css?(".check-row", count: 2) + assert page.has_content?("Domain", count: 2) + end + + within ".checks-filters" do + click_on "SSL" + assert find_link("SSL").matches_css? ".active" + assert find_link("Domain").not_matches_css? ".active" + end + + within ".checks-table" do + assert page.has_css?(".check-row", count: 1) + assert page.has_content?("SSL", count: 1) + assert page.has_content?("ssl.com") + end + end + + test "list filterable by check in error" do + create(:check, user: @user) + create(:check, :last_runs_failed, created_at: 1.week.ago, user: @user) + + visit checks_path + + within ".checks-table" do + assert page.has_css?(".check-row", count: 2) + assert page.has_css?(".in-error", count: 1) + end + + within ".checks-filters" do + click_on(I18n.t("checks.filters.with_error")) + end + + within ".checks-table" do + assert page.has_css?(".check-row", count: 1) + assert page.has_css?(".in-error", count: 1) + end + end + + test "list filterable by name string" do + create(:check, user: @user) + create(:check, domain: "chexpire.org", user: @user) + create(:check, domain: "chexpire.net", user: @user) + + visit checks_path + + within ".checks-filters" do + fill_in("by_domain", with: "chex") + click_button + end + + within ".checks-table" do + assert page.has_css?(".check-row", count: 2) + assert page.has_content?("chexpire.", count: 2) + end + end + + test "list is paginated" do + create(:check, user: @user) + + visit checks_path + assert page.has_no_css?("ul.pagination") + + create_list(:check, 50, user: @user) + + visit checks_path + assert page.has_css?("ul.pagination") + end + + test "list is sortable by name" do + visit checks_path + + create(:check, domain: "a.org", user: @user) + create(:check, domain: "b.org", user: @user) + + visit checks_path + + within ".checks-table thead th:nth-of-type(2)" do + find(".sort-links:first-child").click + end + + within ".check-row:first-of-type" do + page.has_content? "a.org" + end + + within ".checks-table thead th:nth-of-type(2)" do + find(".sort-links:last-child").click + end + + within ".check-row:first-of-type" do + page.has_content? "b.org" + end + end + + test "list is sorted by expiration date by default" do + visit checks_path + + create(:check, domain_expires_at: Time.new(2018, 7, 6, 12), user: @user) + create(:check, domain_expires_at: Time.new(2018, 7, 5, 12), user: @user) + + visit checks_path + + within ".check-row:first-of-type" do + page.has_content? "Thursday, July 5, 2018" + end + end + + test "list is sortable by expiration date" do + visit checks_path + + create(:check, domain_expires_at: Time.new(2018, 7, 5, 12), user: @user) + create(:check, domain_expires_at: Time.new(2018, 7, 6, 12), user: @user) + + visit checks_path + + within ".check-row:first-of-type" do + page.has_content? "Thursday, July 5, 2018" + end + + # only a desc link because of default sort + within ".checks-table thead th:nth-of-type(3)" do + find(".sort-links a").click + end + + within ".check-row:first-of-type" do + page.has_content? "Friday, July 6, 2018" + end + + within ".checks-table thead th:nth-of-type(3)" do + find(".sort-links a").click + end + + within ".check-row:first-of-type" do + page.has_content? "Thursday, July 5, 2018" + end + end + + private + + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength + def fill_and_valid_new_check domain = "domain-test.fr" fill_in("check[domain]", with: domain) - choose "domain" recipient = "recipient@example.org" fill_in("check[notifications_attributes][0][recipient]", with: recipient) @@ -32,55 +279,6 @@ class ChecksTest < ApplicationSystemTestCase assert notification.email? assert notification.pending? end - - test "remove a notification" do - visit edit_check_path(@check) - notification = @check.notifications.first - - selector = "[data-notification-id=\"#{notification.id}\"]" - - assert_difference "Notification.where(check_id: #{@check.id}).count", -1 do - within selector do - find(".btn-danger").click - end - - page.has_no_content?(selector) - end - end - - test "update a check" do - visit edit_check_path(@check) - - fill_in "check[comment]", with: "My comment" - - click_button "Update Check" - - assert_equal checks_path, page.current_path - - assert page.has_css?(".alert-success") - @check.reload - assert_equal "My comment", @check.comment - end - - test "add a notification" do - visit edit_check_path(@check) - - recipient = "recipient2@example.org" - fill_in("check[notifications_attributes][2][recipient]", with: recipient) - fill_in("check[notifications_attributes][2][interval]", with: 55) - - assert_difference "Notification.where(check_id: #{@check.id}).count", +1 do - click_button "Update Check" - - assert_equal checks_path, page.current_path - end - - assert page.has_css?(".alert-success") - - notification = Notification.last - assert_equal recipient, notification.recipient - assert_equal 55, notification.interval - assert notification.email? - assert notification.pending? - end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength end diff --git a/test/test_helper.rb b/test/test_helper.rb index 91ae325..afc4077 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -42,6 +42,7 @@ Capybara.register_driver :headless_chrome do |app| end Capybara.save_path = Rails.root.join("tmp/capybara") Capybara.javascript_driver = :headless_chrome +Capybara.default_driver = :headless_chrome # Disable Open4 real system calls require "open4" diff --git a/yarn.lock b/yarn.lock index 83f3e0f..010038d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5598,6 +5598,10 @@ tunnel-agent@~0.4.1: version "0.4.3" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb" +turbolinks@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/turbolinks/-/turbolinks-5.1.1.tgz#3d418a2d8172edbde5e787bf74cb7bef151ae43f" + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"