diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..fd4bfd8 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, they will be +# requested for review when someone opens a pull request. +* @brunvez @t-romani @martinjaimem diff --git a/.reek.yml b/.reek.yml index 92dbd90..ad817d4 100644 --- a/.reek.yml +++ b/.reek.yml @@ -32,6 +32,7 @@ detectors: exclude: - ExceptionHunter::Middleware::RequestHunter#catch_prey - ExceptionHunter::Middleware::SidekiqHunter#track_exception + - ExceptionHunter::DashboardPresenter#calculate_tabs_counts InstanceVariableAssumption: enabled: false @@ -84,6 +85,9 @@ detectors: - initialize max_statements: 12 + TooManyConstants: + enabled: false + UncommunicativeMethodName: enabled: true exclude: [] diff --git a/Gemfile.lock b/Gemfile.lock index f843e90..06d2458 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,13 @@ GIT remote: https://github.com/rspec/rspec-core - revision: 464b8488e80a90c5c1a76b476c1b814f79fc8efb + revision: 9f55fafe62ea367a4801ebed2ca19cf6dfdceb39 specs: rspec-core (3.10.0.pre) rspec-support (= 3.10.0.pre) GIT remote: https://github.com/rspec/rspec-expectations - revision: 7b7fecf35e3a93d74893020f55faa62901fdf6d0 + revision: a7205975dcda3f53466ba5952f7dec8ac452e18c specs: rspec-expectations (3.10.0.pre) diff-lcs (>= 1.2.0, < 2.0) @@ -15,7 +15,7 @@ GIT GIT remote: https://github.com/rspec/rspec-mocks - revision: a0005a15cc092c7cde75a8429f2da6704235488e + revision: 1728885f65b25a9676c5ce54133ef8a1283e2e0d specs: rspec-mocks (3.10.0.pre) diff-lcs (>= 1.2.0, < 2.0) @@ -23,7 +23,7 @@ GIT GIT remote: https://github.com/rspec/rspec-support - revision: 0e322b49c412947c306d394fdaa506d7d830f1cd + revision: 6553911974ee93855008f8ff41c26cea6652d74d specs: rspec-support (3.10.0.pre) @@ -36,70 +36,70 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (6.0.2.2) - actionpack (= 6.0.2.2) + actioncable (6.0.3.1) + actionpack (= 6.0.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.2.2) - actionpack (= 6.0.2.2) - activejob (= 6.0.2.2) - activerecord (= 6.0.2.2) - activestorage (= 6.0.2.2) - activesupport (= 6.0.2.2) + actionmailbox (6.0.3.1) + actionpack (= 6.0.3.1) + activejob (= 6.0.3.1) + activerecord (= 6.0.3.1) + activestorage (= 6.0.3.1) + activesupport (= 6.0.3.1) mail (>= 2.7.1) - actionmailer (6.0.2.2) - actionpack (= 6.0.2.2) - actionview (= 6.0.2.2) - activejob (= 6.0.2.2) + actionmailer (6.0.3.1) + actionpack (= 6.0.3.1) + actionview (= 6.0.3.1) + activejob (= 6.0.3.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.2.2) - actionview (= 6.0.2.2) - activesupport (= 6.0.2.2) + actionpack (6.0.3.1) + actionview (= 6.0.3.1) + activesupport (= 6.0.3.1) rack (~> 2.0, >= 2.0.8) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.2.2) - actionpack (= 6.0.2.2) - activerecord (= 6.0.2.2) - activestorage (= 6.0.2.2) - activesupport (= 6.0.2.2) + actiontext (6.0.3.1) + actionpack (= 6.0.3.1) + activerecord (= 6.0.3.1) + activestorage (= 6.0.3.1) + activesupport (= 6.0.3.1) nokogiri (>= 1.8.5) - actionview (6.0.2.2) - activesupport (= 6.0.2.2) + actionview (6.0.3.1) + activesupport (= 6.0.3.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.0.2.2) - activesupport (= 6.0.2.2) + activejob (6.0.3.1) + activesupport (= 6.0.3.1) globalid (>= 0.3.6) - activemodel (6.0.2.2) - activesupport (= 6.0.2.2) - activerecord (6.0.2.2) - activemodel (= 6.0.2.2) - activesupport (= 6.0.2.2) - activestorage (6.0.2.2) - actionpack (= 6.0.2.2) - activejob (= 6.0.2.2) - activerecord (= 6.0.2.2) + activemodel (6.0.3.1) + activesupport (= 6.0.3.1) + activerecord (6.0.3.1) + activemodel (= 6.0.3.1) + activesupport (= 6.0.3.1) + activestorage (6.0.3.1) + actionpack (= 6.0.3.1) + activejob (= 6.0.3.1) + activerecord (= 6.0.3.1) marcel (~> 0.3.1) - activesupport (6.0.2.2) + activesupport (6.0.3.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) - zeitwerk (~> 2.2) + zeitwerk (~> 2.2, >= 2.2.2) ast (2.4.0) axiom-types (0.1.1) descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) bcrypt (3.1.13) - brakeman (4.8.0) + brakeman (4.8.2) builder (3.2.4) - byebug (11.1.1) + byebug (11.1.3) code_analyzer (0.5.1) sexp_processor codeclimate-engine-rb (0.4.1) @@ -122,10 +122,10 @@ GEM equalizer (0.0.11) erubi (1.9.0) erubis (2.7.0) - factory_bot (5.1.2) + factory_bot (5.2.0) activesupport (>= 4.2.0) - factory_bot_rails (5.1.1) - factory_bot (~> 5.1.0) + factory_bot_rails (5.2.0) + factory_bot (~> 5.2.0) railties (>= 4.2.0) globalid (0.4.2) activesupport (>= 4.2.0) @@ -135,7 +135,7 @@ GEM jaro_winkler (1.5.4) json (2.3.0) kwalify (0.7.2) - loofah (2.4.0) + loofah (2.5.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -143,17 +143,17 @@ GEM marcel (0.3.3) mimemagic (~> 0.3.2) method_source (1.0.0) - mimemagic (0.3.4) + mimemagic (0.3.5) mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.14.0) + minitest (5.14.1) nio4r (2.5.2) nokogiri (1.10.9) mini_portile2 (~> 2.4.0) orm_adapter (0.5.0) pagy (3.8.1) parallel (1.19.1) - parser (2.7.0.5) + parser (2.7.1.3) ast (~> 2.4.0) pg (1.2.3) psych (3.1.0) @@ -162,20 +162,20 @@ GEM rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.0.2.2) - actioncable (= 6.0.2.2) - actionmailbox (= 6.0.2.2) - actionmailer (= 6.0.2.2) - actionpack (= 6.0.2.2) - actiontext (= 6.0.2.2) - actionview (= 6.0.2.2) - activejob (= 6.0.2.2) - activemodel (= 6.0.2.2) - activerecord (= 6.0.2.2) - activestorage (= 6.0.2.2) - activesupport (= 6.0.2.2) + rails (6.0.3.1) + actioncable (= 6.0.3.1) + actionmailbox (= 6.0.3.1) + actionmailer (= 6.0.3.1) + actionpack (= 6.0.3.1) + actiontext (= 6.0.3.1) + actionview (= 6.0.3.1) + activejob (= 6.0.3.1) + activemodel (= 6.0.3.1) + activerecord (= 6.0.3.1) + activestorage (= 6.0.3.1) + activesupport (= 6.0.3.1) bundler (>= 1.3.0) - railties (= 6.0.2.2) + railties (= 6.0.3.1) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.4) actionpack (>= 5.0.1.x) @@ -194,9 +194,9 @@ GEM json require_all (~> 3.0) ruby-progressbar - railties (6.0.2.2) - actionpack (= 6.0.2.2) - activesupport (= 6.0.2.2) + railties (6.0.3.1) + actionpack (= 6.0.3.1) + activesupport (= 6.0.3.1) method_source rake (>= 0.8.7) thor (>= 0.20.3, < 2.0) @@ -214,7 +214,7 @@ GEM actionpack (>= 5.0) railties (>= 5.0) rexml (3.2.4) - rspec-rails (4.0.0) + rspec-rails (4.0.1) actionpack (>= 4.2) activesupport (>= 4.2) railties (>= 4.2) @@ -252,7 +252,7 @@ GEM sprockets (>= 3.0.0) thor (1.0.1) thread_safe (0.3.6) - tzinfo (1.2.6) + tzinfo (1.2.7) thread_safe (~> 0.1) unicode-display_width (1.6.1) virtus (1.0.5) @@ -262,9 +262,9 @@ GEM equalizer (~> 0.0, >= 0.0.9) warden (1.2.8) rack (>= 2.0.6) - websocket-driver (0.7.1) + websocket-driver (0.7.2) websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.4) + websocket-extensions (0.1.5) zeitwerk (2.3.0) PLATFORMS diff --git a/README.md b/README.md index 022ec0b..4ec6e0f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,27 @@ # ExceptionHunter -Short description and motivation. -## Usage -How to use my plugin. +![Index screenshot](doc/screenshot.png) + +Exception Hunter is a Rails engine meant to track errors in your Rails project. It works +by using your Postgres database to save errors with their corresponding metadata (like backtrace +or environment data at the time of failure). + +To do so we hook to various points of your application where we can rescue from errors, track and +then re-raise those errors so they are handled normally. As such, the gem does not conflict with any +other service so you can have your favorite error tracking service running in parallel with Exception Hunter +while you decide which you like best. + +## Motivation + +Error tracking is one of the most important tools a developer can have in their toolset. As such +we think it'd be nice to provide a way for everyone to have it in their project, be it a personal +project, and MVP or something else. ## Installation Add Exception Hunter to your application's Gemfile: ```ruby -gem 'exception_hunter', '~> 0.2.0' +gem 'exception_hunter', '~> 0.3.0' ``` You may also need to add [Devise](https://github.com/heartcombo/devise) to your Gemfile @@ -32,8 +45,18 @@ you can run the command with the `--skip-users` flag. Additionally it should add the 'ExceptionHunter.routes(self)' line to your routes, which means you can go to `/exception_hunter/errors` in your browser and start enjoying some good old fashioned exception tracking! -## Contributing -Contribution directions go here. +## Stale data + +You can get rid of stale errors by running the rake task to purge them: + +```bash +$ rake exception_hunter:purge_errors +``` + +We recommend you run this task once in a while to de-clutter your DB, using a recurring tasks once +a week would be ideal. You can also purge errors by running `ExceptionHunter::ErrorReaper.purge`. + +The time it takes for an error to go stale defaults to 45 days but it's configurable via the initializer. ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/app/assets/stylesheets/exception_hunter/base.css b/app/assets/stylesheets/exception_hunter/base.css index 5cf2429..3d33657 100644 --- a/app/assets/stylesheets/exception_hunter/base.css +++ b/app/assets/stylesheets/exception_hunter/base.css @@ -1,19 +1,73 @@ :root { - --main-color: #C8193C; - --secondary-color: #F4F5F6; - --link-color: #158FEF; - --border-color: #D1D1D1; - --file-name-color: #16AF90 + --background-grey: #E5E5E5; + --highlight-color: #CF4031; + --highlight-good-color: #2CBB85; + --highlight-inactive-color: #808183; + --inactive-grey: #D1D1D3; + --focused-grey: #808183; + --highlighted-link-blue: #0036F7; + --header-grey: #F8F8F8; + --border-grey: #F1F2F5; + --file-name-color: #2CBB85; + --tag-color: #EAE639; +} + +body { + font-family: 'Inter', sans-serif; + background-color: var(--background-grey); +} + +.container { + padding: 0; +} + +.row { + margin: 0; + width: 100%; +} + +.row .column { + padding: 0; + margin-bottom: 0; } .wrapper { - margin: 5.5rem auto auto; + margin: 6.5rem auto auto; +} + +.text--underline { + text-decoration: underline; } a { - color: var(--link-color); + color: inherit; } a:hover, a:focus, a:active { - color: var(--main-color); + color: inherit; +} + +form { + margin-bottom: 0; +} + +.flash.flash--notice { + background-color: #FFF; + margin-bottom: 2rem; + padding: 0.7rem 2rem; + border-radius: 5px; + line-height: 3.8rem; + font-weight: 400; +} + +.button.button-dismiss { + margin-bottom: 0; + padding: 0; + color: var(--focused-grey); + width: 100%; + text-align: right; +} + +.button.button-dismiss:hover, .button.button-dismiss:focus, .button.button-dismiss:active { + color: var(--inactive-grey); } diff --git a/app/assets/stylesheets/exception_hunter/errors.css b/app/assets/stylesheets/exception_hunter/errors.css index 96f06ed..0d66de6 100644 --- a/app/assets/stylesheets/exception_hunter/errors.css +++ b/app/assets/stylesheets/exception_hunter/errors.css @@ -2,65 +2,203 @@ Place all the styles related to the matching controller here. They will automatically be included in application.css. */ +.errors__container { + background-color: #FFF; + padding: 32px; + border-radius: 0 5px 5px 5px; +} + +.errors-tabs { + display: flex; +} + +ul.errors-tabs { + list-style-type: none; + margin-bottom: 0; +} + +.errors-tab { + border-radius: 5px 5px 0 0; + margin-right: 1rem; + background-color: var(--inactive-grey); +} + +li.errors-tab { + margin-bottom: 0; +} -.row.statistics-row { - padding-top: 1rem; +.errors-tab--active, li.errors-tab a[aria-selected="true"] .errors-tab__content { + background-color: #FFF; + color: var(--highlight-color); } -.statistics__cell { - color: var(--main-color); - background-color: var(--secondary-color); - border: 1px solid var(--border-color); - height: 5rem; - border-radius: 10px; +.errors-tab__resolved.errors-tab--active { + color: var(--highlight-good-color); +} + +.errors-tab__content { + padding: 1rem; + display: flex; +} + +.errors-tab__badge { + background: var(--highlight-inactive-color); + border-radius: 5px; + color: #FFF; + padding: 4px 8px; +} + +.errors-tab--active .errors-tab__badge { + background-color: var(--highlight-color); +} + +.errors-tab__resolved.errors-tab--active .errors-tab__badge { + background-color: var(--highlight-good-color); +} + +.errors-tab__description { + margin-left: 1.3rem; display: flex; - align-items: center; justify-content: center; - font-size: 2.2rem; + align-items: center; +} + +.button.purge-button { + background-color: var(--highlight-color); + border: none; +} + +.button.purge-button:hover, .button.purge-button:focus { + background: var(--focused-grey); +} + +.errors-date-group { + background-color: var(--header-grey); + padding: 0.5rem 1rem; +} + +.errors-date-group ~ .errors-date-group { + border-bottom: 1px solid var(--border-grey); } .row.error-row { - padding-top: 1rem; - padding-bottom: 1rem; - border-bottom: 1px solid; + padding: 1rem; } -.row.error-row-no-border { - padding-top: 1rem; - padding-bottom: 1rem; +.error-row:not(.error-row--header) { + border: 1px solid var(--border-grey); +} + +.error-message, .error-message:hover, .error-message:focus, .error-message:active { + color: var(--highlighted-link-blue); } .error-row.error-row--header { - font-weight: bold; + background-color: var(--header-grey); + font-weight: 500; + color: #000; +} + +.error-cell__message { + overflow-x: hidden; +} + +.error-cell__tags { + font-weight: 500; + font-size: 12px; + color: #000; + padding-bottom: 3px; } -.error-cell.error-cell--highlight { - color: var(--main-color); +.error-cell__tags .error-tag { + background-color: var(--tag-color); + border-radius: 5px; + padding: 1px 5px 1px 5px; } .error-title { - font-size: 20px; - border-bottom: 1px solid var(--border-color); + color: var(--highlighted-link-blue); + padding-left: 2rem; +} + +.error-occurrence__header { + background: #FFF; + padding: 2rem 1rem; + margin-bottom: 2rem; + border-radius: 5px; +} + +.button_to .button.button-outlined.resolve-button { + color: var(--highlight-good-color); + border-color: var(--highlight-good-color); + font-size: 10px; + padding: 0 1rem; + height: 3rem; + line-height: 3rem; +} + +.button_to .button.resolve-button { + color: #FFF; + background-color: var(--highlight-good-color); + border-color: var(--highlight-good-color); + font-size: 10px; + padding: 0 1rem; + height: 3rem; + line-height: 3rem; + margin-bottom: 0; +} + +.button.resolve-button:hover, .button.resolve-button:focus { + color: var(--focused-grey); + border-color: var(--focused-grey); } .error-occurred_at { - color: var(--main-color); + color: var(--highlight-color); font-size: 14px; margin-top: 0.5em; margin-bottom: 2em; } +.error-occurrences__nav { + display: flex; + justify-content: flex-end; + height: 100%; + align-items: center; +} + +.button.button-outline.error-occurrences__nav-link { + margin-bottom: 0; + padding: 0 1rem; + height: 2.5rem; + line-height: 2.5rem; + margin-right: 0.5rem; + border-color: #000; + color: #000; +} + +.button.button-outline.error-occurrences__nav-link[disabled="disabled"], +.button.button-outline.error-occurrences__nav-link[disabled="disabled"]:focus, +.button.button-outline.error-occurrences__nav-link[disabled="disabled"]:hover { + border-color: var(--highlight-inactive-color); + color: var(--highlight-inactive-color); +} + +.error-occurrences__nav-current { + margin: 0 2rem; +} + .tab-content { padding: 1em 0.5em; } .data-title { font-weight: bold; - color: var(--main-color); + color: var(--highlight-color); } .tracked-data { - border-left-color: var(--main-color); + border-left-color: var(--highlight-color); } .backtrace { @@ -80,7 +218,7 @@ .backtrace-line__line-number { margin-right: 5px; - color: var(--main-color); + color: var(--highlight-color); } .backtrace-line__file-name { diff --git a/app/assets/stylesheets/exception_hunter/navigation.css b/app/assets/stylesheets/exception_hunter/navigation.css index 92f9acf..fdb8834 100644 --- a/app/assets/stylesheets/exception_hunter/navigation.css +++ b/app/assets/stylesheets/exception_hunter/navigation.css @@ -1,8 +1,7 @@ .nav { - background: var(--secondary-color); - border-bottom: .1rem solid var(--border-color); + background: #000; display: block; - height: 5.2rem; + height: 4.2rem; left: 0; max-width: 100%; position: fixed; @@ -16,6 +15,22 @@ height: 100%; } -.nav__logo img { - height: 100%; +.nav__title { + line-height: 4.2rem; + font-style: normal; + font-weight: 600; + font-size: 16px; + color: #FFF; +} + +.footer { + text-align: center; + padding-top: 4rem; + padding-bottom: 1rem; +} + +.logout { + line-height: 4.2rem; + color: #FFF; + text-align: right; } diff --git a/app/assets/stylesheets/exception_hunter/sessions.css b/app/assets/stylesheets/exception_hunter/sessions.css new file mode 100644 index 0000000..71055a0 --- /dev/null +++ b/app/assets/stylesheets/exception_hunter/sessions.css @@ -0,0 +1,71 @@ +.login_form_container { + margin: 0rem auto auto; + display: flex; + max-width: 75%; + margin-top: 20rem; + width: 910px; + height: 356px; + font-family: 'Inter', sans-serif; + background-color: white; +} + +.login_left_container { + width: 455px; + text-align: center; + width: 38rem; + background-color: black; + +} + +.left_column { + max-width: 50%; + padding-top: 15%; + padding-left: 10%; + line-height: 130%; + color: white; + text-align: left; + font-size: 30px; + font-weight: 600; +} + +.login_right_container { + width: 350px; + margin: 3rem 3.5rem 1rem; + font-size: 24px; + font-weight: 400; + text-align: left; + line-height: 29px; + line-height: 100%; + color: black; +} + +.login_row { + margin: 2rem 0rem 0rem; + width: 100%; + align-items: center; +} + +.login_button{ + margin: 3rem 3rem 0rem; + align-items: right; +} + +.button-log-in { + background-color: #2CBB85!important; + border-color: #2CBB85!important; +} + +.field{ + border: 0.1rem solid black; + border-radius: .4rem; +} + +input[type='password'], +input[type='email'], +textarea:focus, +select:focus { + border-color: black!important; + outline: 0; + font-size: 1.2rem!important; + color: black!important; +} diff --git a/app/controllers/concerns/exception_hunter/authorization.rb b/app/controllers/concerns/exception_hunter/authorization.rb new file mode 100644 index 0000000..7cded05 --- /dev/null +++ b/app/controllers/concerns/exception_hunter/authorization.rb @@ -0,0 +1,23 @@ +module ExceptionHunter + module Authorization + extend ActiveSupport::Concern + + included do + before_action :authenticate_admin_user_class + end + + def authenticate_admin_user_class + return unless ExceptionHunter::Config.auth_enabled? && !send("current_#{underscored_admin_user_class}") + + redirect_to '/exception_hunter/login' + end + + def redirect_to_login + render 'exception_hunter/devise/sessions/new' + end + + def underscored_admin_user_class + ExceptionHunter::Config.admin_user_class.underscore + end + end +end diff --git a/app/controllers/exception_hunter/application_controller.rb b/app/controllers/exception_hunter/application_controller.rb index cd8fd59..5899aed 100644 --- a/app/controllers/exception_hunter/application_controller.rb +++ b/app/controllers/exception_hunter/application_controller.rb @@ -1,5 +1,7 @@ module ExceptionHunter class ApplicationController < ActionController::Base + include ExceptionHunter::Authorization + protect_from_forgery with: :exception end end diff --git a/app/controllers/exception_hunter/errors_controller.rb b/app/controllers/exception_hunter/errors_controller.rb index 9641a48..036f3a3 100644 --- a/app/controllers/exception_hunter/errors_controller.rb +++ b/app/controllers/exception_hunter/errors_controller.rb @@ -5,14 +5,20 @@ class ErrorsController < ApplicationController include Pagy::Backend def index - @errors = ErrorGroup.all.order(created_at: :desc) - @errors_count = Error.count - @month_errors = Error.in_current_month.count + @dashboard = DashboardPresenter.new(current_tab) + shown_errors = errors_for_tab(@dashboard).order(updated_at: :desc).distinct + @errors = ErrorGroupPresenter.wrap_collection(shown_errors) end def show @pagy, errors = pagy(most_recent_errors, items: 1) - @error = ErrorPresenter.new(errors.first) + @error = ErrorPresenter.new(errors.first!) + end + + def destroy + ErrorReaper.purge + + redirect_back fallback_location: errors_path, notice: 'Errors purged successfully' end private @@ -20,5 +26,22 @@ def show def most_recent_errors Error.most_recent(params[:id]) end + + def current_tab + params[:tab] + end + + def errors_for_tab(dashboard) + case dashboard.current_tab + when DashboardPresenter::LAST_7_DAYS_TAB + ErrorGroup.with_errors_in_last_7_days.active + when DashboardPresenter::CURRENT_MONTH_TAB + ErrorGroup.with_errors_in_current_month.active + when DashboardPresenter::TOTAL_ERRORS_TAB + ErrorGroup.active + when DashboardPresenter::RESOLVED_ERRORS_TAB + ErrorGroup.resolved + end + end end end diff --git a/app/controllers/exception_hunter/resolved_errors_controller.rb b/app/controllers/exception_hunter/resolved_errors_controller.rb new file mode 100644 index 0000000..fbe9661 --- /dev/null +++ b/app/controllers/exception_hunter/resolved_errors_controller.rb @@ -0,0 +1,11 @@ +require_dependency 'exception_hunter/application_controller' + +module ExceptionHunter + class ResolvedErrorsController < ApplicationController + def create + ErrorGroup.find(params[:error_group][:id]).resolved! + + redirect_to errors_path, notice: 'Error resolved successfully' + end + end +end diff --git a/app/helpers/exception_hunter/sessions_helper.rb b/app/helpers/exception_hunter/sessions_helper.rb new file mode 100644 index 0000000..f82e72c --- /dev/null +++ b/app/helpers/exception_hunter/sessions_helper.rb @@ -0,0 +1,16 @@ +module ExceptionHunter + module SessionsHelper + def current_admin_user? + underscored_admin_user_class && + current_admin_class_name(underscored_admin_user_class) + end + + def underscored_admin_user_class + ExceptionHunter::Config.admin_user_class.try(:underscore) + end + + def current_admin_class_name(class_name) + send("current_#{class_name.underscore}") + end + end +end diff --git a/app/models/exception_hunter/application_record.rb b/app/models/exception_hunter/application_record.rb index b95c9c2..62ec2a2 100644 --- a/app/models/exception_hunter/application_record.rb +++ b/app/models/exception_hunter/application_record.rb @@ -1,5 +1,13 @@ module ExceptionHunter class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + + class << self + delegate :[], to: :arel_table + + def sql_similarity(attr, value) + Arel::Nodes::NamedFunction.new('similarity', [attr, Arel::Nodes.build_quoted(value)]) + end + end end end diff --git a/app/models/exception_hunter/error.rb b/app/models/exception_hunter/error.rb index cd05402..369b5df 100644 --- a/app/models/exception_hunter/error.rb +++ b/app/models/exception_hunter/error.rb @@ -3,24 +3,37 @@ class Error < ApplicationRecord validates :class_name, presence: true validates :occurred_at, presence: true - belongs_to :error_group + belongs_to :error_group, touch: true before_validation :set_occurred_at, on: :create + after_create :unresolve_error_group, unless: -> { error_group.active? } scope :most_recent, lambda { |error_group_id| where(error_group_id: error_group_id).order(occurred_at: :desc) } - - def self.in_current_month - current_month = Date.today.beginning_of_month..Date.today.end_of_month - - where(occurred_at: current_month) - end + scope :with_occurrences_before, lambda { |max_occurrence_date| + where(Error[:occurred_at].lteq(max_occurrence_date)) + } + scope :in_period, ->(period) { where(occurred_at: period) } + scope :in_last_7_days, -> { in_period(7.days.ago.beginning_of_day..Time.now) } + scope :in_current_month, lambda { + in_period(Date.current.beginning_of_month.beginning_of_day..Date.current.end_of_month.end_of_day) + } + scope :from_active_error_groups, lambda { + joins(:error_group).where(error_group: ErrorGroup.active) + } + scope :from_resolved_error_groups, lambda { + joins(:error_group).where(error_group: ErrorGroup.resolved) + } private def set_occurred_at self.occurred_at ||= Time.now end + + def unresolve_error_group + error_group.active! + end end end diff --git a/app/models/exception_hunter/error_group.rb b/app/models/exception_hunter/error_group.rb index 9d501d3..447ff94 100644 --- a/app/models/exception_hunter/error_group.rb +++ b/app/models/exception_hunter/error_group.rb @@ -4,12 +4,27 @@ class ErrorGroup < ApplicationRecord validates :error_class_name, presence: true - has_many :grouped_errors, class_name: 'ExceptionHunter::Error' + has_many :grouped_errors, class_name: 'ExceptionHunter::Error', dependent: :destroy + + enum status: { active: 0, resolved: 1 } scope :most_similar, lambda { |message| - quoted_message = ActiveRecord::Base.connection.quote_string(message) - where("similarity(exception_hunter_error_groups.message, :message) >= #{SIMILARITY_THRESHOLD}", message: message) - .order(Arel.sql("similarity(exception_hunter_error_groups.message, '#{quoted_message}') DESC")) + message_similarity = sql_similarity(ErrorGroup[:message], message) + where(message_similarity.gteq(SIMILARITY_THRESHOLD)) + .order(message_similarity.desc) + } + + scope :without_errors, lambda { + is_associated_error = Error[:error_group_id].eq(ErrorGroup[:id]) + where.not(Error.where(is_associated_error).arel.exists) + } + scope :with_errors_in_last_7_days, lambda { + joins(:grouped_errors) + .where(Error.in_last_7_days.where(Error[:error_group_id].eq(ErrorGroup[:id])).arel.exists) + } + scope :with_errors_in_current_month, lambda { + joins(:grouped_errors) + .where(Error.in_current_month.where(Error[:error_group_id].eq(ErrorGroup[:id])).arel.exists) } def self.find_matching_group(error) @@ -18,6 +33,10 @@ def self.find_matching_group(error) .first end + def first_occurrence + @first_occurrence ||= grouped_errors.minimum(:occurred_at) + end + def last_occurrence @last_occurrence ||= grouped_errors.maximum(:occurred_at) end diff --git a/app/presenters/exception_hunter/dashboard_presenter.rb b/app/presenters/exception_hunter/dashboard_presenter.rb new file mode 100644 index 0000000..099fbd9 --- /dev/null +++ b/app/presenters/exception_hunter/dashboard_presenter.rb @@ -0,0 +1,54 @@ +module ExceptionHunter + class DashboardPresenter + LAST_7_DAYS_TAB = 'last_7_days'.freeze + CURRENT_MONTH_TAB = 'current_month'.freeze + TOTAL_ERRORS_TAB = 'total_errors'.freeze + RESOLVED_ERRORS_TAB = 'resolved'.freeze + TABS = [LAST_7_DAYS_TAB, CURRENT_MONTH_TAB, TOTAL_ERRORS_TAB, RESOLVED_ERRORS_TAB].freeze + DEFAULT_TAB = LAST_7_DAYS_TAB + + attr_reader :current_tab + + def initialize(current_tab) + assign_tab(current_tab) + calculate_tabs_counts + end + + def tab_active?(tab) + tab == current_tab + end + + def partial_for_tab + case current_tab + when LAST_7_DAYS_TAB + 'exception_hunter/errors/last_7_days_errors_table' + when CURRENT_MONTH_TAB, TOTAL_ERRORS_TAB, RESOLVED_ERRORS_TAB + 'exception_hunter/errors/errors_table' + end + end + + def errors_count(tab) + @tabs_counts[tab] + end + + private + + def assign_tab(tab) + @current_tab = if TABS.include?(tab) + tab + else + DEFAULT_TAB + end + end + + def calculate_tabs_counts + active_errors = Error.from_active_error_groups + @tabs_counts = { + LAST_7_DAYS_TAB => active_errors.in_last_7_days.count, + CURRENT_MONTH_TAB => active_errors.in_current_month.count, + TOTAL_ERRORS_TAB => active_errors.count, + RESOLVED_ERRORS_TAB => Error.from_resolved_error_groups.count + } + end + end +end diff --git a/app/presenters/exception_hunter/error_group_presenter.rb b/app/presenters/exception_hunter/error_group_presenter.rb new file mode 100644 index 0000000..dfe5a31 --- /dev/null +++ b/app/presenters/exception_hunter/error_group_presenter.rb @@ -0,0 +1,25 @@ +module ExceptionHunter + class ErrorGroupPresenter + delegate_missing_to :error_group + + def initialize(error_group) + @error_group = error_group + end + + def self.wrap_collection(collection) + collection.map { |error_group| new(error_group) } + end + + def self.format_occurrence_day(day) + day.to_date.strftime('%A, %B %d') + end + + def show_for_day?(day) + last_occurrence.in_time_zone.to_date == day.to_date + end + + private + + attr_reader :error_group + end +end diff --git a/app/presenters/exception_hunter/error_presenter.rb b/app/presenters/exception_hunter/error_presenter.rb index a642329..0f90d91 100644 --- a/app/presenters/exception_hunter/error_presenter.rb +++ b/app/presenters/exception_hunter/error_presenter.rb @@ -1,6 +1,7 @@ module ExceptionHunter class ErrorPresenter delegate_missing_to :error + delegate :tags, to: :error_group BacktraceLine = Struct.new(:path, :file_name, :line_number, :method_call) diff --git a/app/views/exception_hunter/devise/sessions/new.html.erb b/app/views/exception_hunter/devise/sessions/new.html.erb new file mode 100644 index 0000000..fe4dbfa --- /dev/null +++ b/app/views/exception_hunter/devise/sessions/new.html.erb @@ -0,0 +1,24 @@ +
@@ -13,7 +13,7 @@ <% end %> <% unless error.tracked_params.nil? %> -+Tracked Params@@ -23,11 +23,11 @@ <% end %> <% if error.custom_data.nil? %> -+No custom data included.<% else %> -+Custom Datadiff --git a/app/views/exception_hunter/errors/_errors_table.erb b/app/views/exception_hunter/errors/_errors_table.erb new file mode 100644 index 0000000..2bfe4c6 --- /dev/null +++ b/app/views/exception_hunter/errors/_errors_table.erb @@ -0,0 +1 @@ +<%= render partial: 'exception_hunter/errors/error_row', collection: errors, as: :error %> diff --git a/app/views/exception_hunter/errors/_last_7_days_errors_table.erb b/app/views/exception_hunter/errors/_last_7_days_errors_table.erb new file mode 100644 index 0000000..222e3f2 --- /dev/null +++ b/app/views/exception_hunter/errors/_last_7_days_errors_table.erb @@ -0,0 +1,12 @@ +<% today_errors = errors.select { |error| error.show_for_day?(Date.current) } %> +<%= render partial: 'exception_hunter/errors/error_row', collection: today_errors, as: :error %> + +<% yesterday_errors = errors.select { |error| error.show_for_day?(Date.yesterday) } %> +Yesterday+<%= render partial: 'exception_hunter/errors/error_row', collection: yesterday_errors, as: :error %> + +<% (2..6).each do |i| %> + <% errors_on_day = errors.select { |error| error.show_for_day?(i.days.ago) } %> +<%= ExceptionHunter::ErrorGroupPresenter.format_occurrence_day(i.days.ago) %>+ <%= render partial: 'exception_hunter/errors/error_row', collection: errors_on_day, as: :error %> +<% end %> diff --git a/app/views/exception_hunter/errors/index.html.erb b/app/views/exception_hunter/errors/index.html.erb index abab2ab..569b081 100644 --- a/app/views/exception_hunter/errors/index.html.erb +++ b/app/views/exception_hunter/errors/index.html.erb @@ -1,36 +1,77 @@ ----- <%= number_with_delimiter(@errors_count) %> Total errors ---- <%= number_with_delimiter(@month_errors) %> Errors this month ++-+-++ <%= link_to errors_path(tab: @dashboard.class::LAST_7_DAYS_TAB) do %> ++ +++ <% end %> ++ <%= @dashboard.errors_count(@dashboard.class::LAST_7_DAYS_TAB) %> +++ Errors in the last 7 days +++ <%= link_to errors_path(tab: @dashboard.class::CURRENT_MONTH_TAB) do %> ++ +++ <% end %> ++ <%= @dashboard.errors_count(@dashboard.class::CURRENT_MONTH_TAB) %> +++ Errors this month +++ <%= link_to errors_path(tab: @dashboard.class::TOTAL_ERRORS_TAB) do %> ++ +++ <% end %> ++ <%= @dashboard.errors_count(@dashboard.class::TOTAL_ERRORS_TAB) %> +++ Total errors +++ <%= link_to errors_path(tab: @dashboard.class::RESOLVED_ERRORS_TAB) do %> +++ <% end %> ++ <%= @dashboard.errors_count(@dashboard.class::RESOLVED_ERRORS_TAB) || '-' %> +++ Resolved ++--<% @errors.each do |error| %> -Message-Last Occurrence-Occurrences++ <%= button_to 'Purge', purge_errors_path, + class: %w[button purge-button], + method: :delete, + data: { confirm: 'This will delete all stale errors, do you want to continue?' } %> +-- <%= link_to error.message, error_path(error.id) %> --- <% if error.last_occurrence.present? %> - <%= time_ago_in_words(error.last_occurrence) %> ago - <% else %> - Never - <% end %> --- <%= error.total_occurrences %> -++diff --git a/app/views/exception_hunter/errors/pagy/_pagy_nav.html.erb b/app/views/exception_hunter/errors/pagy/_pagy_nav.html.erb index 3010909..081d036 100644 --- a/app/views/exception_hunter/errors/pagy/_pagy_nav.html.erb +++ b/app/views/exception_hunter/errors/pagy/_pagy_nav.html.erb @@ -1,17 +1,17 @@ -<% link = pagy_link_proc(pagy) %> -+-<% end %> + + <%= render partial: @dashboard.partial_for_tab, locals: { errors: @errors } %> +Tags+Message+First Occurrence+Last Occurrence+Total+