├── test ├── helpers │ └── .keep ├── mailers │ └── .keep ├── models │ ├── .keep │ └── eyeloupe │ │ ├── out_request_test.rb │ │ ├── in_request_test.rb │ │ ├── job_test.rb │ │ └── exception_test.rb ├── controllers │ ├── .keep │ └── eyeloupe │ │ ├── jobs_controller_test.rb │ │ ├── exceptions_controller_test.rb │ │ ├── out_requests_controller_test.rb │ │ └── in_requests_controller_test.rb ├── dummy │ ├── log │ │ └── .keep │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_record.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── views │ │ │ └── layouts │ │ │ │ ├── mailer.text.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── application.html.erb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ └── jobs │ │ │ └── application_job.rb │ ├── config │ │ ├── routes.rb │ │ ├── initializers │ │ │ ├── eyeloupe.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── permissions_policy.rb │ │ │ ├── assets.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── environment.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── storage.yml │ │ ├── puma.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ ├── bin │ │ ├── rake │ │ ├── rails │ │ └── setup │ ├── config.ru │ ├── Rakefile │ └── db │ │ └── schema.rb ├── integration │ ├── .keep │ └── navigation_test.rb ├── fixtures │ ├── files │ │ └── .keep │ └── eyeloupe │ │ ├── out_requests.yml │ │ ├── in_requests.yml │ │ ├── jobs.yml │ │ └── exceptions.yml ├── eyeloupe_test.rb ├── lib │ ├── request_middleware_test.rb │ └── processors │ │ ├── out_request_processor_test.rb │ │ ├── job_processor_test.rb │ │ ├── exception_processor_test.rb │ │ └── in_request_processor_test.rb └── test_helper.rb ├── app ├── models │ ├── concerns │ │ └── .keep │ └── eyeloupe │ │ ├── in_request.rb │ │ ├── out_request.rb │ │ ├── job.rb │ │ ├── exception.rb │ │ └── application_record.rb ├── assets │ ├── images │ │ └── eyeloupe │ │ │ ├── .keep │ │ │ └── logo.png │ ├── config │ │ └── eyeloupe_manifest.js │ ├── javascripts │ │ └── eyeloupe │ │ │ ├── application.js │ │ │ └── controllers │ │ │ ├── application.js │ │ │ ├── eyeloupe │ │ │ ├── nav_controller.js │ │ │ ├── search_controller.js │ │ │ ├── pause_controller.js │ │ │ ├── ai_assistant_controller.js │ │ │ └── refresh_controller.js │ │ │ └── index.js │ ├── stylesheets │ │ ├── eyeloupe │ │ │ └── application.css │ │ └── application.tailwind.css │ └── builds │ │ └── eyeloupe.css ├── controllers │ ├── concerns │ │ ├── .keep │ │ └── eyeloupe │ │ │ └── searchable.rb │ └── eyeloupe │ │ ├── data_controller.rb │ │ ├── application_controller.rb │ │ ├── configs_controller.rb │ │ ├── in_requests_controller.rb │ │ ├── jobs_controller.rb │ │ ├── out_requests_controller.rb │ │ ├── exceptions_controller.rb │ │ └── ai_assistant_responses_controller.rb ├── helpers │ └── eyeloupe │ │ ├── jobs_helper.rb │ │ ├── application_helper.rb │ │ └── request_helper.rb ├── jobs │ └── eyeloupe │ │ └── application_job.rb ├── mailers │ └── eyeloupe │ │ └── application_mailer.rb └── views │ ├── eyeloupe │ ├── exceptions │ │ ├── index.html.erb │ │ ├── _frame.html.erb │ │ └── show.html.erb │ ├── shared │ │ ├── _verb.html.erb │ │ ├── _status_code.html.erb │ │ └── _job_status.html.erb │ ├── jobs │ │ ├── index.html.erb │ │ ├── _frame.html.erb │ │ └── show.html.erb │ ├── in_requests │ │ ├── index.html.erb │ │ ├── _frame.html.erb │ │ └── show.html.erb │ └── out_requests │ │ ├── index.html.erb │ │ ├── _frame.html.erb │ │ └── show.html.erb │ └── layouts │ └── eyeloupe │ └── application.html.erb ├── doc └── img │ ├── screen.png │ └── ai-assistant.gif ├── lib ├── eyeloupe │ ├── version.rb │ ├── concerns │ │ └── rescuable.rb │ ├── configuration.rb │ ├── processors │ │ ├── out_request.rb │ │ ├── exception.rb │ │ ├── job.rb │ │ └── in_request.rb │ ├── request_middleware.rb │ └── engine.rb ├── tasks │ └── eyeloupe_tasks.rake └── eyeloupe.rb ├── Rakefile ├── .gitignore ├── Gemfile ├── config ├── importmap.rb ├── routes.rb └── tailwind.config.js ├── db └── migrate │ ├── 20230525125352_create_eyeloupe_out_requests.rb │ ├── 20230827161224_create_eyeloupe_jobs.rb │ ├── 20230518175305_create_eyeloupe_in_requests.rb │ └── 20230604190442_create_eyeloupe_exceptions.rb ├── CHANGELOG.md ├── bin └── rails ├── MIT-LICENSE ├── eyeloupe.gemspec ├── .github └── workflows │ └── ruby.yml ├── Gemfile.lock └── README.md /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/eyeloupe/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end -------------------------------------------------------------------------------- /doc/img/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alxlion/eyeloupe/HEAD/doc/img/screen.png -------------------------------------------------------------------------------- /app/helpers/eyeloupe/jobs_helper.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | module JobsHelper 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /doc/img/ai-assistant.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alxlion/eyeloupe/HEAD/doc/img/ai-assistant.gif -------------------------------------------------------------------------------- /lib/eyeloupe/version.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | # @return [String] 3 | VERSION = "0.4.0" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount Eyeloupe::Engine => "/eyeloupe" 3 | end 4 | -------------------------------------------------------------------------------- /app/assets/images/eyeloupe/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alxlion/eyeloupe/HEAD/app/assets/images/eyeloupe/logo.png -------------------------------------------------------------------------------- /app/jobs/eyeloupe/application_job.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | class ApplicationJob < ActiveJob::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /app/assets/config/eyeloupe_manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../builds/ .css 2 | //= link_tree ../images 3 | //= link_tree ../javascripts .js -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /app/helpers/eyeloupe/application_helper.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | module ApplicationHelper 3 | include Pagy::Frontend 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link eyeloupe_manifest.js 4 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/eyeloupe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Eyeloupe.configure do |config| 4 | config.excluded_paths = %w[assets] 5 | end -------------------------------------------------------------------------------- /test/models/eyeloupe/out_request_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Eyeloupe 4 | class OutRequestTest < ActiveSupport::TestCase 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/models/eyeloupe/in_request_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Eyeloupe 4 | class InRequestTest < ActiveSupport::TestCase 5 | 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/models/eyeloupe/in_request.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | class InRequest < ApplicationRecord 3 | has_one :exception, class_name: "Eyeloupe::Exception" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/eyeloupe/out_request.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | class OutRequest < ApplicationRecord 3 | has_one :exception, class_name: "Eyeloupe::Exception" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/assets/javascripts/eyeloupe/application.js: -------------------------------------------------------------------------------- 1 | import "@hotwired/turbo-rails" 2 | import "eyeloupe/controllers" 3 | 4 | import { Turbo } from "@hotwired/turbo-rails" 5 | window.Turbo = Turbo -------------------------------------------------------------------------------- /app/mailers/eyeloupe/application_mailer.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | class ApplicationMailer < ActionMailer::Base 3 | default from: "from@example.com" 4 | layout "mailer" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/integration/navigation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NavigationTest < ActionDispatch::IntegrationTest 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 4 | load "rails/tasks/engine.rake" 5 | 6 | load "rails/tasks/statistics.rake" 7 | 8 | require "bundler/gem_tasks" -------------------------------------------------------------------------------- /app/models/eyeloupe/job.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | class Job < ApplicationRecord 3 | validates :job_id, uniqueness: true 4 | 5 | enum status: [:enqueued, :running, :completed, :failed, :discarded] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/models/eyeloupe/job_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Eyeloupe 4 | class JobTest < ActiveSupport::TestCase 5 | # test "the truth" do 6 | # assert true 7 | # end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/models/eyeloupe/exception_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Eyeloupe 4 | class ExceptionTest < ActiveSupport::TestCase 5 | # test "the truth" do 6 | # assert true 7 | # end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/models/eyeloupe/exception.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | class Exception < ApplicationRecord 3 | has_one :in_request, class_name: "Eyeloupe::InRequest" 4 | has_one :out_request, class_name: "Eyeloupe::OutRequest" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /log/*.log 3 | /pkg/ 4 | /tmp/ 5 | /test/dummy/db/*.sqlite3 6 | /test/dummy/db/*.sqlite3-* 7 | /test/dummy/log/*.log 8 | /test/dummy/storage/ 9 | /test/dummy/tmp/ 10 | /.gem_rbs_collection/ 11 | .DS_Store 12 | .idea/ 13 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | gem 'rubocop', group: 'development', require: false 5 | gem "sqlite3" 6 | gem "sprockets-rails" 7 | gem "pagy" 8 | gem "turbo-rails" 9 | 10 | gemspec -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /app/assets/javascripts/eyeloupe/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/models/eyeloupe/application_record.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | class ApplicationRecord < ActiveRecord::Base 3 | self.abstract_class = true 4 | 5 | if Eyeloupe.configuration.database 6 | connects_to database: { writing: Eyeloupe.configuration.database.to_sym, reading: Eyeloupe.configuration.database.to_sym } 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/eyeloupe/concerns/rescuable.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | module Concerns 3 | module Rescuable 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | rescue_from(StandardError) do |exception| 8 | Eyeloupe::Processors::Exception.instance.process(nil, exception) 9 | end 10 | end 11 | 12 | end 13 | end 14 | end -------------------------------------------------------------------------------- /app/assets/javascripts/eyeloupe/controllers/eyeloupe/nav_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | static targets = ["content"] 5 | 6 | close() { 7 | this.contentTarget.classList.add("hidden") 8 | } 9 | 10 | open() { 11 | this.contentTarget.classList.remove("hidden") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag "application" %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/assets/javascripts/eyeloupe/controllers/eyeloupe/search_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | static targets = ["frame"] 5 | 6 | submit(e) { 7 | e.preventDefault() 8 | let q = e.target.elements["q"].value 9 | this.frameTarget.src = this.frameTarget.src + "&q=" + q 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/eyeloupe_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class EyeloupeTest < ActiveSupport::TestCase 4 | test "it has a version number" do 5 | assert Eyeloupe::VERSION 6 | end 7 | 8 | test "it has configuration" do 9 | assert Eyeloupe.configuration 10 | assert Eyeloupe.configuration.excluded_paths == %w[assets] 11 | assert Eyeloupe.configuration.capture 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config/importmap.rb: -------------------------------------------------------------------------------- 1 | pin_all_from File.expand_path("../app/assets/javascripts", __dir__) 2 | pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true 3 | pin "@hotwired/stimulus", to: "https://ga.jspm.io/npm:@hotwired/stimulus@3.0.1/dist/stimulus.js" 4 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true 5 | pin "showdown", to: "https://ga.jspm.io/npm:showdown@2.1.0/dist/showdown.js" -------------------------------------------------------------------------------- /app/controllers/eyeloupe/data_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Eyeloupe 4 | 5 | class DataController < ApplicationController 6 | 7 | # Delete all data in the database 8 | # DELETE /data 9 | def destroy 10 | Exception.destroy_all 11 | InRequest.destroy_all 12 | OutRequest.destroy_all 13 | redirect_to root_path, status: 303 14 | end 15 | end 16 | 17 | end -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Eyeloupe::Engine.routes.draw do 2 | 3 | root to: "application#root" 4 | 5 | resources :in_requests, only: [:index, :show] 6 | resources :out_requests, only: [:index, :show] 7 | resources :exceptions, only: [:index, :show] 8 | resources :jobs, only: [:index, :show] 9 | resources :ai_assistant_responses, only: [:show] 10 | 11 | resource :data, only: [:destroy] 12 | 13 | resource :configs, only: [:update] 14 | 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/eyeloupe/out_requests.yml: -------------------------------------------------------------------------------- 1 | one: 2 | verb: GET 3 | hostname: hostname 4 | path: /uri 5 | status: 200 6 | format: application/json 7 | duration: 2 8 | payload: 9 | req_headers: 10 | res_headers: 11 | response: 12 | 13 | two: 14 | verb: POST 15 | hostname: hostname2 16 | path: /uri2 17 | status: 202 18 | format: application/json 19 | duration: 1 20 | payload: 21 | req_headers: 22 | res_headers: 23 | response: 24 | -------------------------------------------------------------------------------- /lib/tasks/eyeloupe_tasks.rake: -------------------------------------------------------------------------------- 1 | desc "Compiling TailwindCSS files" 2 | task :tailwind_watch do 3 | require "tailwindcss-rails" 4 | system "#{Tailwindcss::Engine.root.join("exe/tailwindcss")} \ 5 | -i #{Eyeloupe::Engine.root.join("app/assets/stylesheets/application.tailwind.css")} \ 6 | -o #{Eyeloupe::Engine.root.join("app/assets/builds/eyeloupe.css")} \ 7 | -c #{Eyeloupe::Engine.root.join("config/tailwind.config.js")} \ 8 | --minify -w" 9 | end -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /app/controllers/eyeloupe/application_controller.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | class ApplicationController < ActionController::Base 3 | include Pagy::Backend 4 | 5 | before_action :set_config 6 | 7 | def root 8 | redirect_to in_requests_path 9 | end 10 | 11 | protected 12 | 13 | def set_config 14 | @eyeloupe_capture = cookies[:eyeloupe_capture].present? ? cookies[:eyeloupe_capture] == "true" : Eyeloupe.configuration.capture 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/eyeloupe/configs_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Eyeloupe 4 | class ConfigsController < ApplicationController 5 | 6 | before_action :set_config, only: [:update] 7 | 8 | def update 9 | Eyeloupe.configuration.capture = @value == "true" 10 | cookies[:eyeloupe_capture] = @value 11 | redirect_to root_path, status: 303 12 | end 13 | 14 | protected 15 | 16 | def set_config 17 | @value = params[:value] 18 | end 19 | end 20 | end -------------------------------------------------------------------------------- /test/controllers/eyeloupe/jobs_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Eyeloupe 4 | class JobsControllerTest < ActionDispatch::IntegrationTest 5 | include Engine.routes.url_helpers 6 | 7 | setup do 8 | @job = eyeloupe_jobs(:one) 9 | end 10 | 11 | test "should get index" do 12 | get jobs_url 13 | assert_response :success 14 | end 15 | 16 | test "should get show" do 17 | get jobs_url(@job) 18 | assert_response :success 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /db/migrate/20230525125352_create_eyeloupe_out_requests.rb: -------------------------------------------------------------------------------- 1 | class CreateEyeloupeOutRequests < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :eyeloupe_out_requests do |t| 4 | t.string :verb 5 | t.string :hostname 6 | t.string :path 7 | t.string :format 8 | t.integer :status 9 | t.integer :duration 10 | t.text :payload 11 | t.text :req_headers 12 | t.text :res_headers 13 | t.text :response 14 | 15 | t.timestamps 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/controllers/eyeloupe/exceptions_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Eyeloupe 4 | class ExceptionsControllerTest < ActionDispatch::IntegrationTest 5 | include Engine.routes.url_helpers 6 | 7 | setup do 8 | @exception = eyeloupe_exceptions(:one) 9 | end 10 | 11 | test "should get index" do 12 | get exceptions_url 13 | assert_response :success 14 | end 15 | 16 | test "should show exception" do 17 | get exception_url(@exception) 18 | assert_response :success 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/controllers/eyeloupe/in_requests_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Eyeloupe 4 | class InRequestsController < ApplicationController 5 | include Searchable 6 | 7 | before_action :set_in_request, only: [:show] 8 | 9 | def index 10 | @pagy, @requests = pagy(@query, items: 50) 11 | 12 | render partial: 'frame' if params[:frame].present? 13 | end 14 | 15 | def show 16 | end 17 | 18 | protected 19 | 20 | def set_in_request 21 | @request = InRequest.find(params[:id]) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/controllers/eyeloupe/jobs_controller.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | class JobsController < ApplicationController 3 | include Searchable 4 | 5 | before_action :set_job, only: %i[ show ] 6 | 7 | def index 8 | @pagy, @jobs = pagy(@query, items: 50) 9 | 10 | render partial: 'frame' if params[:frame].present? 11 | end 12 | 13 | def show 14 | end 15 | 16 | private 17 | # Use callbacks to share common setup or constraints between actions. 18 | def set_job 19 | @job = Job.find(params[:id]) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /test/controllers/eyeloupe/out_requests_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Eyeloupe 4 | class OutRequestsControllerTest < ActionDispatch::IntegrationTest 5 | include Engine.routes.url_helpers 6 | 7 | setup do 8 | @out_request = eyeloupe_out_requests(:one) 9 | end 10 | 11 | test "should get index" do 12 | get out_requests_url 13 | assert_response :success 14 | end 15 | 16 | test "should show out_request" do 17 | get out_request_url(@out_request) 18 | assert_response :success 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.4.0 2 | 3 | - Add support for ActiveJob 4 | - Fix pretty formatting of payloads 5 | 6 | ## 0.3.1 7 | 8 | - Add optional database config (thanks to @kiskoza). 9 | 10 | ## 0.3.0 11 | 12 | - Add exceptions: Framework + ActiveJob + Sidekiq Worker exceptions. 13 | - Add OpenAI support for AI assistant in exceptions. 14 | - Fix exceptions when using importmap binary by adding net/http override in railties initializer. 15 | 16 | ## 0.2.0 17 | 18 | - Fix missing require for `pagy` gem. 19 | 20 | ## 0.1.0 21 | 22 | - Initial release including incoming and outgoing requests. 23 | -------------------------------------------------------------------------------- /db/migrate/20230827161224_create_eyeloupe_jobs.rb: -------------------------------------------------------------------------------- 1 | class CreateEyeloupeJobs < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :eyeloupe_jobs do |t| 4 | t.string :classname 5 | t.string :job_id 6 | t.string :queue_name 7 | t.string :adapter 8 | t.integer :status, default: 0 9 | t.datetime :scheduled_at 10 | t.datetime :executed_at 11 | t.datetime :completed_at 12 | t.integer :retry, default: 0 13 | t.string :args 14 | t.timestamps 15 | 16 | t.index :job_id, unique: true 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/controllers/eyeloupe/in_requests_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module Eyeloupe 6 | class InRequestsControllerTest < ActionDispatch::IntegrationTest 7 | include Engine.routes.url_helpers 8 | 9 | setup do 10 | @in_request = eyeloupe_in_requests(:one) 11 | end 12 | 13 | test "should get index" do 14 | get in_requests_url 15 | assert_response :success 16 | end 17 | 18 | test "should get show" do 19 | get in_request_url(@in_request) 20 | assert_response :success 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/eyeloupe/engine", __dir__) 7 | APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails/all" 14 | require "rails/engine/commands" 15 | -------------------------------------------------------------------------------- /config/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | 3 | module.exports = { 4 | content: [ 5 | './public/*.html', 6 | './app/helpers/**/*.rb', 7 | './app/assets/javascripts/**/*.js', 8 | './app/views/**/*.{erb,haml,html,slim}' 9 | ], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ['Fira Sans', 'sans-serif', ...defaultTheme.fontFamily.sans], 14 | }, 15 | }, 16 | }, 17 | plugins: [ 18 | require('@tailwindcss/forms'), 19 | require('@tailwindcss/aspect-ratio'), 20 | require('@tailwindcss/typography'), 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /app/views/eyeloupe/exceptions/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Exceptions

5 |

All exceptions that have been raised in the application.

6 |
7 |
8 |
9 |
10 |
11 | <%= turbo_frame_tag "frame", src: exceptions_path(frame: true), data: {"eyeloupe--refresh-target": "frame"} do %><% end %> 12 |
13 |
14 | -------------------------------------------------------------------------------- /db/migrate/20230518175305_create_eyeloupe_in_requests.rb: -------------------------------------------------------------------------------- 1 | class CreateEyeloupeInRequests < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :eyeloupe_in_requests do |t| 4 | t.string :verb 5 | t.string :hostname 6 | t.string :controller 7 | t.string :path 8 | t.string :format 9 | t.integer :status 10 | t.integer :duration 11 | t.integer :db_duration 12 | t.integer :view_duration 13 | t.string :ip 14 | t.text :payload 15 | t.text :headers 16 | t.text :session 17 | t.text :response 18 | 19 | t.timestamps 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/fixtures/eyeloupe/in_requests.yml: -------------------------------------------------------------------------------- 1 | one: 2 | verb: POST 3 | hostname: localhost 4 | controller: TestController#index 5 | path: /one 6 | status: 200 7 | duration: 20 8 | ip: 127.0.0.1 9 | db_duration: 1 10 | view_duration: 2 11 | format: application/json 12 | payload: 13 | headers: 14 | session: 15 | response: 16 | 17 | two: 18 | verb: GET 19 | hostname: localhost 20 | controller: TestController#index 21 | path: /two 22 | status: 201 23 | duration: 457 24 | ip: 127.0.0.1 25 | db_duration: 1 26 | view_duration: 2 27 | format: text/html 28 | payload: 29 | headers: 30 | session: 31 | response: 32 | -------------------------------------------------------------------------------- /test/fixtures/eyeloupe/jobs.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | classname: MyString 5 | job_id: MyString 6 | queue_name: MyString 7 | adapter: MyString 8 | status: 0 9 | retry: 0 10 | executed_at: 2023-08-27 18:12:24 11 | completed_at: 2023-08-27 18:12:24 12 | scheduled_at: 2023-08-27 18:12:24 13 | 14 | two: 15 | classname: MyString 16 | job_id: MyString2 17 | queue_name: MyString 18 | adapter: MyString 19 | status: 0 20 | retry: 0 21 | executed_at: 2023-08-27 18:12:24 22 | completed_at: 2023-08-27 18:12:24 23 | scheduled_at: 2023-08-27 18:12:24 24 | -------------------------------------------------------------------------------- /app/assets/javascripts/eyeloupe/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap under controllers/* 2 | 3 | import { application } from "eyeloupe/controllers/application" 4 | 5 | // Eager load all controllers defined in the import map under controllers/**/*_controller 6 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 7 | eagerLoadControllersFrom("eyeloupe/controllers", application) 8 | 9 | // Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) 10 | // import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" 11 | // lazyLoadControllersFrom("controllers", application) 12 | -------------------------------------------------------------------------------- /lib/eyeloupe/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'singleton' 3 | 4 | module Eyeloupe 5 | class Configuration 6 | include Singleton 7 | 8 | # @return [Symbol|Nil] 9 | attr_accessor :database 10 | 11 | # @return [Array] 12 | attr_accessor :excluded_paths 13 | 14 | # @return [Boolean] 15 | attr_accessor :capture 16 | 17 | # @return [String] 18 | attr_accessor :openai_access_key 19 | 20 | # @return [String] 21 | attr_accessor :openai_model 22 | 23 | def initialize 24 | @excluded_paths = %w[] 25 | @capture = true 26 | @openai_model = "gpt-3.5-turbo" 27 | end 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /lib/eyeloupe.rb: -------------------------------------------------------------------------------- 1 | require "eyeloupe/version" 2 | require "eyeloupe/engine" 3 | 4 | require 'eyeloupe/request_middleware' 5 | require 'eyeloupe/configuration' 6 | require 'eyeloupe/processors/in_request' 7 | require 'eyeloupe/processors/out_request' 8 | require 'eyeloupe/processors/exception' 9 | require 'eyeloupe/processors/job' 10 | require 'eyeloupe/concerns/rescuable' 11 | 12 | require 'pagy' 13 | require "openai" 14 | module Eyeloupe 15 | 16 | # @return [Eyeloupe::Configuration] 17 | def self.configuration 18 | Configuration.instance 19 | end 20 | 21 | # @yieldparam [Eyeloupe::Configuration] configuration 22 | def self.configure 23 | yield(configuration) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/controllers/eyeloupe/out_requests_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Eyeloupe 4 | class OutRequestsController < ApplicationController 5 | include Searchable 6 | 7 | before_action :set_out_request, only: %i[ show ] 8 | 9 | # GET /out_requests 10 | def index 11 | @pagy, @requests = pagy(@query, items: 50) 12 | 13 | render partial: 'frame' if params[:frame].present? 14 | end 15 | 16 | # GET /out_requests/1 17 | def show 18 | end 19 | 20 | private 21 | # Use callbacks to share common setup or constraints between actions. 22 | def set_out_request 23 | @request = OutRequest.find(params[:id]) 24 | end 25 | 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/controllers/concerns/eyeloupe/searchable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Eyeloupe 4 | module Searchable 5 | extend ActiveSupport::Concern 6 | 7 | path_models = %w[InRequest OutRequest] 8 | name_models = %w[Job] 9 | 10 | included do 11 | before_action :set_query, only: [:index] 12 | end 13 | 14 | protected 15 | 16 | def set_query 17 | model = ("Eyeloupe::" + controller_name.classify).constantize 18 | where = model.attribute_names.include?("path") ? 'path' : 'classname' 19 | @query = params[:q].present? ? model.where("#{where} LIKE ?", "%#{params[:q].strip}%").order(created_at: :desc) 20 | : model.all.order(created_at: :desc) 21 | end 22 | end 23 | 24 | end -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /db/migrate/20230604190442_create_eyeloupe_exceptions.rb: -------------------------------------------------------------------------------- 1 | class CreateEyeloupeExceptions < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :eyeloupe_exceptions do |t| 4 | t.string :hostname 5 | t.string :kind 6 | t.string :location 7 | t.string :file 8 | t.integer :line 9 | t.text :stacktrace 10 | t.string :message 11 | t.integer :count, default: 1 12 | t.text :full_message 13 | t.references :in_request, null: true, foreign_key: { to_table: :eyeloupe_in_requests } 14 | t.references :out_request, null: true, foreign_key: { to_table: :eyeloupe_out_requests } 15 | 16 | t.timestamps 17 | end 18 | 19 | add_index :eyeloupe_exceptions, [:kind, :file, :line] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/lib/request_middleware_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class RequestMiddlewareTest < ActiveSupport::TestCase 4 | 5 | def setup 6 | @middleware = Eyeloupe::RequestMiddleware.new(nil) 7 | @env = { 8 | "REQUEST_METHOD" => "GET", 9 | "PATH_INFO" => "/", 10 | "HTTP_HOST" => "localhost", 11 | "HTTP_USER_AGENT" => "Rails Testing", 12 | "rack.input" => StringIO.new 13 | } 14 | end 15 | 16 | test "should not skip request" do 17 | @request = ActionDispatch::Request.new(@env) 18 | assert_not @middleware.send(:skip_request?, @request) 19 | end 20 | 21 | test "should skip request" do 22 | @env["PATH_INFO"] = "/assets" 23 | @request = ActionDispatch::Request.new(@env) 24 | assert @middleware.send(:skip_request?, @request) 25 | end 26 | 27 | end -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require_relative "../test/dummy/config/environment" 5 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] 6 | ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__) 7 | require "rails/test_help" 8 | 9 | include Eyeloupe::RequestHelper 10 | 11 | # Load fixtures from the engine 12 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 13 | ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) 14 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 15 | ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_path + "/files" 16 | ActiveSupport::TestCase.fixtures :all 17 | end 18 | -------------------------------------------------------------------------------- /app/assets/stylesheets/eyeloupe/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/controllers/eyeloupe/exceptions_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Eyeloupe 4 | class ExceptionsController < ApplicationController 5 | 6 | before_action :set_exception, only: %i[ show ] 7 | 8 | # GET /out_requests 9 | def index 10 | @pagy, @exceptions = pagy(Exception.all.order(created_at: :desc), items: 50) 11 | 12 | render partial: 'frame' if params[:frame].present? 13 | end 14 | 15 | # GET /out_requests/1 16 | def show 17 | start = @exception.line - 5 18 | start = 0 if start < 0 19 | @line_numbers = [*start..@exception.line+6] 20 | end 21 | 22 | private 23 | # Use callbacks to share common setup or constraints between actions. 24 | def set_exception 25 | @exception = Exception.find(params[:id]) 26 | end 27 | 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | require "eyeloupe" 9 | 10 | module Dummy 11 | class Application < Rails::Application 12 | config.load_defaults Rails::VERSION::STRING.to_f 13 | 14 | # For compatibility with applications that use this config 15 | config.action_controller.include_all_helpers = false 16 | 17 | # Configuration for the application, engines, and railties goes here. 18 | # 19 | # These settings can be overridden in specific environments using the files 20 | # in config/environments, which are processed later. 21 | # 22 | # config.time_zone = "Central Time (US & Canada)" 23 | # config.eager_load_paths << Rails.root.join("extras") 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/fixtures/eyeloupe/exceptions.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | hostname: example.com 5 | kind: KindException 6 | location: '["test"]' 7 | stacktrace: '[ "a", "b" ]' 8 | message: "message" 9 | full_message: "full_message" 10 | in_request_id: one 11 | count: 1 12 | file: "file.rb" 13 | line: 1 14 | 15 | two: 16 | hostname: example2.com 17 | kind: Kind2Exception 18 | location: '[]' 19 | stacktrace: '[ "a", "b", "c" ]' 20 | message: "message" 21 | full_message: "full_message" 22 | in_request_id: two 23 | count: 4 24 | file: "file.rb" 25 | line: 123 26 | 27 | three: 28 | hostname: example3.com 29 | kind: Kind3Exception 30 | location: '[]' 31 | stacktrace: '[ "a", "b", "c" ]' 32 | message: "message3" 33 | full_message: "full_message" 34 | out_request_id: two 35 | count: 4 36 | file: "file.rb" 37 | line: 4 -------------------------------------------------------------------------------- /app/views/eyeloupe/shared/_verb.html.erb: -------------------------------------------------------------------------------- 1 | <% if verb.downcase == "post" || verb.downcase == "patch" || verb.downcase == "put" %> 2 | 3 | <%= verb %> 4 | 5 | <% elsif verb.downcase == "get" || verb.downcase == "options" %> 6 | 7 | <%= verb %> 8 | 9 | <% elsif verb.downcase == "delete" %> 10 | 11 | <%= verb %> 12 | 13 | <% else %> 14 | 15 | <%= verb %> 16 | 17 | <% end %> -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /app/assets/javascripts/eyeloupe/controllers/eyeloupe/pause_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | 5 | SETTING_NAME = "eyeloupe-pause" 6 | static targets = ["btn"] 7 | static values = { enabled: Boolean } 8 | 9 | connect() { 10 | this._setActiveClass() 11 | this._setupRefresh() 12 | } 13 | 14 | toggle() { 15 | localStorage.setItem(this.SETTING_NAME, !this.enabled) 16 | this._setActiveClass() 17 | } 18 | 19 | _setActiveClass() { 20 | if (this.enabledValue) { 21 | this.btnTarget.classList.add("bg-red-500", "text-white", "hover:bg-red-600") 22 | this.btnTarget.classList.remove("bg-gray-200", "text-gray-500", "hover:bg-gray-300") 23 | } else { 24 | this.btnTarget.classList.remove("bg-red-500", "text-white", "hover:bg-red-600") 25 | this.btnTarget.classList.add("bg-gray-200", "text-gray-500", "hover:bg-gray-300") 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /app/assets/javascripts/eyeloupe/controllers/eyeloupe/ai_assistant_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | import showdown from "showdown" 3 | export default class extends Controller { 4 | static targets = ["result", "loader", "btn"] 5 | static values = { url: String } 6 | 7 | assist() { 8 | this.btnTarget.classList.add("hidden") 9 | this.resultTarget.innerHTML = "" 10 | this.loaderTarget.classList.remove("hidden") 11 | 12 | fetch(this.urlValue) 13 | .then(response => response.json()) 14 | .then(json => { 15 | let result = json.choices[0].message.content 16 | let converter = new showdown.Converter() 17 | this.resultTarget.innerHTML = converter.makeHtml(result) 18 | 19 | }) 20 | .catch(error => { 21 | this.resultTarget.innerHTML = error 22 | }) 23 | .finally(() => { 24 | this.loaderTarget.classList.add("hidden") 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/helpers/eyeloupe/request_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Eyeloupe 4 | module RequestHelper 5 | # @param [Eyeloupe::InRequest, Eyeloupe::OutRequest] request The request object 6 | # @return [String] The formatted response 7 | def format_response(request) 8 | type = request.format.to_s != '*/*' ? request.format.to_s : request&.headers 9 | format(type, request.response) 10 | end 11 | 12 | # @param [Eyeloupe::InRequest, Eyeloupe::OutRequest] request The request object 13 | # @return [String] The formatted payload 14 | def format_payload(request) 15 | type = request.format.to_s != '*/*' ? request.format.to_s : request&.headers 16 | format(type, request.payload) 17 | end 18 | 19 | private 20 | 21 | def format(format, str) 22 | case format 23 | when /json/ 24 | JSON.pretty_generate(JSON.parse(str || '{}')) 25 | when /xml/ 26 | Nokogiri::XML(str || '<>').to_xml(indent: 2) 27 | else 28 | str 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/views/eyeloupe/shared/_status_code.html.erb: -------------------------------------------------------------------------------- 1 | <% if code.to_s[0] == "2" %> 2 | 3 | <%= code %> 4 | 5 | <% elsif code.to_s[0] == "3" %> 6 | 7 | <%= code %> 8 | 9 | <% elsif code.to_s[0] == "4" %> 10 | 11 | <%= code %> 12 | 13 | <% elsif code.to_s[0] == "5" %> 14 | 15 | <%= code %> 16 | 17 | <% else %> 18 | 19 | <%= code %> 20 | 21 | <% end %> -------------------------------------------------------------------------------- /app/views/eyeloupe/jobs/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Jobs

5 |

All jobs running in your application

6 |
7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | <%= turbo_frame_tag "frame", src: jobs_path(frame: true), data: {"eyeloupe--refresh-target": "frame", "eyeloupe--search-target": "frame"} do %><% end %> 17 |
18 |
19 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Alex 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /app/views/eyeloupe/in_requests/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Requests

5 |

All incoming request to your application

6 |
7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | <%= turbo_frame_tag "frame", src: in_requests_path(frame: true), data: {"eyeloupe--refresh-target": "frame", "eyeloupe--search-target": "frame"} do %><% end %> 17 |
18 |
19 | -------------------------------------------------------------------------------- /app/views/eyeloupe/out_requests/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

HTTP Client

5 |

All outbound HTTP requests made by your application

6 |
7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | <%= turbo_frame_tag "frame", src: out_requests_path(frame: true), data: {"eyeloupe--refresh-target": "frame", "eyeloupe--search-target": "frame"} do %><% end %> 17 |
18 |
19 | -------------------------------------------------------------------------------- /app/views/eyeloupe/shared/_job_status.html.erb: -------------------------------------------------------------------------------- 1 | <% if job.enqueued? %> 2 | 3 | <%= job.status.capitalize %> 4 | 5 | <% elsif job.running? %> 6 | 7 | <%= job.status.capitalize %> 8 | 9 | <% elsif job.completed? %> 10 | 11 | <%= job.status.capitalize %> 12 | 13 | <% elsif job.failed? %> 14 | 15 | <%= job.status.capitalize %> 16 | 17 | <% else %> 18 | 19 | <%= job.status.capitalize %> 20 | 21 | <% end %> -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /app/controllers/eyeloupe/ai_assistant_responses_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Eyeloupe 4 | class AiAssistantResponsesController < ApplicationController 5 | 6 | before_action :set_exception, only: %i[ show ] 7 | 8 | def show 9 | client = OpenAI::Client.new 10 | 11 | code = File.read(@exception.file) 12 | 13 | @response = client.chat( 14 | parameters: { 15 | model: Eyeloupe::configuration.openai_model, 16 | messages: [{"role": "system", "content": "You are a Ruby on Rails software developer, you develop software programs and applications using programming languages like Ruby and Ruby on Rails and development tools."}, 17 | {"role": "user", "content": "I have a problem with my Ruby on Rails application. I am getting an error message that says: #{@exception.kind} #{@exception.message}. Here is my code, the error is in line #{@exception.line}: #{code}. Answer as concise as possible. Show me resulting code. The response should in Markdown format."}], 18 | temperature: 0.7 19 | }) 20 | 21 | render json: @response 22 | end 23 | 24 | private 25 | 26 | def set_exception 27 | @exception = Exception.find(params[:id]) 28 | end 29 | end 30 | end -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /test/lib/processors/out_request_processor_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class OutRequestProcessorTest < ActiveSupport::TestCase 6 | def setup 7 | @out_request_processor = Eyeloupe::Processors::OutRequest.instance 8 | @request = Net::HTTP::Get.new('/') 9 | end 10 | 11 | test "should initialize out request processor" do 12 | assert_not_nil @out_request_processor 13 | end 14 | 15 | test "should init" do 16 | @out_request_processor.init(@request, "") 17 | assert_not_nil @out_request_processor.started_at 18 | assert_not_nil @out_request_processor.request 19 | assert_not_nil @out_request_processor.body 20 | end 21 | 22 | test "should process" do 23 | @out_request_processor.init(@request, "") 24 | http = Net::HTTP.new('example.com', nil) 25 | response = http.request(@request) 26 | res = @out_request_processor.process(response, nil) 27 | assert_not_nil res 28 | end 29 | 30 | test "should get headers with request" do 31 | headers = @out_request_processor.send(:get_headers, @request) 32 | assert_not_nil headers 33 | assert headers.is_a?(Hash) 34 | end 35 | 36 | test "should get headers with response" do 37 | http = Net::HTTP.new('example.com', nil) 38 | response = http.request(@request) 39 | headers = @out_request_processor.send(:get_headers, response) 40 | assert_not_nil headers 41 | assert headers.is_a?(Hash) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /eyeloupe.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/eyeloupe/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "eyeloupe" 5 | spec.version = Eyeloupe::VERSION 6 | spec.authors = ["Alexandre Lion"] 7 | spec.email = ["dev@alexandrelion.com"] 8 | spec.homepage = "https://github.com/alxlion/eyeloupe" 9 | spec.summary = "The elegant Rails debug assistant" 10 | spec.description = "Eyeloupe is debug assistant for Rails. It provides a simple and elegant way to debug your Rails application." 11 | spec.license = "MIT" 12 | 13 | spec.metadata["homepage_uri"] = spec.homepage 14 | spec.metadata["source_code_uri"] = "https://github.com/alxlion/eyeloupe" 15 | spec.metadata["changelog_uri"] = "https://github.com/alxlion/eyeloupe/blob/master/CHANGELOG.md" 16 | 17 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 18 | Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "CHANGELOG.md"] 19 | end 20 | 21 | spec.required_ruby_version = ">= 2.7" 22 | 23 | spec.add_dependency "sprockets-rails", "~> 3.4" 24 | spec.add_dependency "rails", "~> 7.0" 25 | spec.add_dependency "importmap-rails", "~> 1.1" 26 | spec.add_dependency "pagy", "~> 6.0" 27 | spec.add_dependency "ruby-openai", "~> 4.1.0" 28 | spec.add_dependency "nokogiri", "~> 1.15.4" 29 | 30 | spec.add_development_dependency "sqlite3", "~> 1.3.6" 31 | spec.add_development_dependency "tailwindcss-rails", "~> 2.0" 32 | spec.add_development_dependency "turbo-rails", "~> 1.1" 33 | 34 | end 35 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ "main" ] 13 | pull_request: 14 | branches: [ "main" ] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | tests: 21 | name: Tests 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | ruby-version: [ '2.7', '3.0' ] 26 | services: 27 | postgres: 28 | image: postgres:12.7 29 | env: 30 | POSTGRES_USER: myapp 31 | POSTGRES_DB: myapp_test 32 | POSTGRES_PASSWORD: "" 33 | ports: [ "5432:5432" ] 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v2 38 | 39 | - name: Setup Ruby and install gems 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: ${{ matrix.ruby-version }} 43 | bundler-cache: true 44 | 45 | - name: Setup test database 46 | env: 47 | RAILS_ENV: test 48 | PGHOST: localhost 49 | PGUSER: myapp 50 | run: | 51 | bin/rails db:setup 52 | 53 | - name: Run tests 54 | run: bin/rails test 55 | -------------------------------------------------------------------------------- /app/assets/javascripts/eyeloupe/controllers/eyeloupe/refresh_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | 5 | SETTING_NAME = "eyeloupe-refresh" 6 | static targets = ["btn", "frame"] 7 | 8 | connect() { 9 | this._setActiveClass() 10 | this._setupRefresh() 11 | } 12 | 13 | toggle() { 14 | localStorage.setItem(this.SETTING_NAME, !this.enabled) 15 | this._setActiveClass() 16 | this._setupRefresh() 17 | } 18 | 19 | _setupRefresh() { 20 | if (this.enabled) { 21 | this._fetch() 22 | this.interval = setInterval(this._fetch.bind(this), 3000) 23 | } else { 24 | if (this.interval) { 25 | clearTimeout(this.interval) 26 | } 27 | } 28 | } 29 | 30 | _setActiveClass() { 31 | if (this.enabled) { 32 | this.btnTargets.forEach((btn) => { 33 | btn.classList.add("bg-red-500", "text-white", "hover:bg-red-600") 34 | btn.classList.remove("bg-gray-200", "text-gray-500", "hover:bg-gray-300") 35 | }) 36 | 37 | } else { 38 | this.btnTargets.forEach((btn) => { 39 | btn.classList.remove("bg-red-500", "text-white", "hover:bg-red-600") 40 | btn.classList.add("bg-gray-200", "text-gray-500", "hover:bg-gray-300") 41 | }) 42 | } 43 | } 44 | 45 | _fetch() { 46 | if (this.hasFrameTarget) { 47 | this.frameTarget.reload() 48 | } 49 | } 50 | 51 | get enabled() { 52 | return localStorage.getItem(this.SETTING_NAME) === "true" 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | } 7 | 8 | .pagination { 9 | @apply relative z-0 inline-flex rounded-md shadow-sm -space-x-px; 10 | } 11 | 12 | .pagination .prev a { 13 | @apply relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50; 14 | } 15 | 16 | .pagination .next a { 17 | @apply relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50; 18 | } 19 | 20 | .pagination .current { 21 | @apply border-red-500 text-red-500 border-t-2 pt-4 px-4 inline-flex items-center text-sm font-medium; 22 | } 23 | 24 | .pagination a { 25 | @apply bg-white border-gray-300 text-gray-500 hover:bg-gray-50 relative inline-flex items-center px-4 py-2 border text-sm font-medium; 26 | } 27 | 28 | .pagination .disabled { 29 | @apply bg-white border-gray-300 text-gray-500 hover:bg-gray-50 relative inline-flex items-center px-4 py-2 border text-sm font-medium opacity-40; 30 | } 31 | 32 | .pagination .gap { 33 | @apply relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700; 34 | } 35 | 36 | .pagination .active { 37 | @apply z-10 bg-red-50 border-red-500 text-red-500 relative inline-flex items-center px-4 py-2 border text-sm font-medium; 38 | } 39 | 40 | .pagination .previous_page, .pagination .next_page { 41 | @apply hidden; 42 | } 43 | 44 | #result pre { 45 | @apply bg-black text-white p-3 rounded-md my-2; 46 | } 47 | 48 | #result p code { 49 | @apply bg-gray-200 text-gray-600 px-2 py-0.5 text-sm rounded-md; 50 | } -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /test/lib/processors/job_processor_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | include ActiveJob::TestHelper 5 | 6 | class JobProcessorTest < ActiveSupport::TestCase 7 | def setup 8 | @job_processor = Eyeloupe::Processors::Job.instance 9 | 10 | activejob = ActiveJob::Base.new 11 | activejob.job_id = SecureRandom.uuid 12 | @event = ActiveSupport::Notifications::Event.new("test.event", Time.now, Time.now, SecureRandom.uuid, {job: activejob}) 13 | end 14 | 15 | test "should initialize job processor" do 16 | assert_not_nil @job_processor 17 | assert_equal [], @job_processor.subs 18 | end 19 | 20 | test "should process" do 21 | job = @job_processor.process(@event) 22 | assert_not_nil job 23 | assert job.persisted? 24 | end 25 | 26 | test "should run" do 27 | job = @job_processor.process(@event) 28 | @job_processor.run(@event) 29 | job.reload 30 | assert_equal "running", job.status 31 | end 32 | 33 | test "should complete without fail" do 34 | job = @job_processor.process(@event) 35 | @job_processor.run(@event) 36 | @job_processor.complete(@event) 37 | job.reload 38 | assert_equal "completed", job.status 39 | assert_equal 0, job.retry 40 | end 41 | 42 | test "should complete with fail" do 43 | job = @job_processor.process(@event) 44 | @job_processor.run(@event) 45 | job.update(status: :failed) 46 | @job_processor.complete(@event) 47 | job.reload 48 | assert_equal "failed", job.status 49 | assert_equal 1, job.retry 50 | end 51 | 52 | test "should be failed" do 53 | job = @job_processor.process(@event) 54 | @job_processor.failed(@event) 55 | job.reload 56 | assert_equal "failed", job.status 57 | end 58 | 59 | test "should be discarded" do 60 | job = @job_processor.process(@event) 61 | @job_processor.discard(@event) 62 | job.reload 63 | assert_equal "discarded", job.status 64 | end 65 | 66 | end 67 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /lib/eyeloupe/processors/out_request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Eyeloupe 4 | module Processors 5 | class OutRequest 6 | include Singleton 7 | 8 | # @return [Net::HTTPRequest, nil] 9 | attr_accessor :request 10 | 11 | # @return [String] 12 | attr_accessor :body 13 | 14 | # @return [Time, nil] 15 | attr_accessor :started_at 16 | 17 | def initialize 18 | @request = nil 19 | @body = "" 20 | @started_at = nil 21 | end 22 | 23 | # @param [Net::HTTPRequest] request The request object 24 | # @param [String] body The request body 25 | def init(request, body) 26 | @request = request 27 | @body = body 28 | @started_at = Time.now 29 | end 30 | 31 | # @param [Net::HTTPResponse] response The response object 32 | # @param [Eyeloupe::Exception, nil] ex The exception object persisted in db 33 | # @return [Net::HTTPResponse] The response object 34 | def process(response, ex) 35 | req = Eyeloupe::OutRequest.create( 36 | verb: @request.method, 37 | hostname: @request['host'], 38 | path: @request.path, 39 | status: response.code, 40 | format: response.content_type, 41 | duration: (Time.now - @started_at) * 1000, 42 | payload: @request.body, 43 | req_headers: (get_headers(@request) || {}).to_json, 44 | res_headers: (get_headers(response) || {}).to_json, 45 | response: response.body, 46 | ) 47 | 48 | ex.update(out_request_id: req.id) if ex.present? && ex.out_request_id.blank? 49 | 50 | response 51 | end 52 | 53 | protected 54 | 55 | # @param [Net::HTTPRequest, Net::HTTPResponse] el The request or response object to get headers from 56 | def get_headers(el) 57 | headers = {} 58 | el.each_header do |key, value| 59 | headers[key] = value 60 | end 61 | headers 62 | end 63 | 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /app/views/eyeloupe/exceptions/_frame.html.erb: -------------------------------------------------------------------------------- 1 | <%= turbo_frame_tag "frame" do %> 2 |
3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | <% @exceptions.each do |ex| %> 18 | 19 | 23 | 24 | 25 | 28 | 29 | <% end %> 30 | 31 |
KindCountOccured 12 | Details 13 |
20 | <%= ex.kind %> 21 |

<%= ex.message.truncate(100) %>

22 |
<%= ex.count %><%= distance_of_time_in_words(ex.updated_at, DateTime.now) %> 26 | <%= link_to "Details", exception_path(ex), class: "text-gray-600 hover:text-gray-900", data: {"turbo_frame": "_top"} %> 27 |
32 |
33 | 38 |
39 | <% end %> -------------------------------------------------------------------------------- /lib/eyeloupe/request_middleware.rb: -------------------------------------------------------------------------------- 1 | module Eyeloupe 2 | 3 | class RequestMiddleware 4 | 5 | # @return [Eyeloupe::Processors::InRequest] 6 | attr_accessor :inrequest_processor 7 | 8 | # @return [Eyeloupe::Processors::Exception] 9 | attr_accessor :exception_processor 10 | 11 | def initialize(app) 12 | @app = app 13 | @inrequest_processor = Processors::InRequest.instance 14 | @exception_processor = Processors::Exception.instance 15 | end 16 | 17 | # @param [Hash] env Rack environment 18 | def call(env) 19 | 20 | request = ActionDispatch::Request.new(env) 21 | ex = nil 22 | 23 | if enabled?(request) && !skip_request?(request) 24 | @inrequest_processor.start_timer 25 | 26 | begin 27 | status, headers, response = @app.call(env) 28 | 29 | framework_exception = env['action_dispatch.exception'] 30 | if framework_exception 31 | ex = @exception_processor.process(env, framework_exception) 32 | end 33 | 34 | [status, headers, response] 35 | rescue Exception => e 36 | exception = ActionDispatch::ExceptionWrapper.new(env, e) 37 | status = exception.status_code 38 | headers = {} 39 | response = e.message 40 | ex = @exception_processor.process(env, e) 41 | raise 42 | ensure 43 | @inrequest_processor.init(request, env, status, headers, response, ex).process 44 | end 45 | else 46 | @app.call(env) 47 | end 48 | 49 | end 50 | 51 | protected 52 | 53 | # Check if capture is enabled, if so we are looking to the capture cookie, if no cookie present capture is enabled by default 54 | # 55 | # @param [ActionDispatch::Request] request 56 | # @return [Boolean] 57 | def enabled?(request) 58 | if Eyeloupe.configuration.capture 59 | if request.cookies['eyeloupe_capture'].present? 60 | request.cookies['eyeloupe_capture'] == "true" 61 | else 62 | true 63 | end 64 | else 65 | false 66 | end 67 | end 68 | 69 | # Check if the request path is in the excluded paths 70 | # 71 | # @param [ActionDispatch::Request] request 72 | # @return [Boolean] 73 | def skip_request?(request) 74 | excluded_paths = %w[mini-profiler eyeloupe active_storage] + Eyeloupe.configuration.excluded_paths 75 | 76 | excluded_paths.each do |item| 77 | return true if request.path =~ /#{item}/ 78 | end 79 | 80 | false 81 | end 82 | 83 | end 84 | end -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | end 61 | -------------------------------------------------------------------------------- /lib/eyeloupe/processors/exception.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | module Eyeloupe 3 | module Processors 4 | class Exception 5 | include Singleton 6 | 7 | # @param [Hash, nil] env Rack environment 8 | # @param [Exception] exception The exception object 9 | # @return [Eyeloupe::Exception] The exception model 10 | def process(env, exception) 11 | if env && env['action_dispatch.backtrace_cleaner'].present? 12 | backtrace = env['action_dispatch.backtrace_cleaner'].filter(exception.backtrace) 13 | backtrace = exception.backtrace if backtrace.blank? 14 | else 15 | backtrace = exception.backtrace 16 | end 17 | 18 | file = backtrace ? backtrace[0].split(":")[0] : "" 19 | line = backtrace ? backtrace[0].split(":")[1].to_i : 0 20 | 21 | create_or_update_exception(exception.class.name || "", file, line, backtrace, exception.message, exception.full_message) 22 | end 23 | 24 | protected 25 | 26 | # @param [Array] trace The backtrace 27 | # @return [Array] The source code lines 28 | def read_file(trace) 29 | file = trace.size > 0 ? trace[0].split(":")[0] : "" 30 | line = trace.size > 0 ? trace[0].split(":")[1].to_i : 0 31 | 32 | if File.exist?(file) 33 | lines = File.readlines(file) 34 | start = line - 5 35 | start = 0 if start < 0 36 | lines[start..line+5] || [] 37 | else 38 | [] 39 | end 40 | end 41 | 42 | # @param [String] kind The exception class name 43 | # @param [String] file The file path 44 | # @param [Integer] line The line number 45 | # @param [Array] backtrace The backtrace 46 | # @param [String] message The exception message 47 | # @param [String] full_message The full exception message 48 | # @return [Eyeloupe::Exception] The exception model 49 | def create_or_update_exception(kind, file, line, backtrace, message, full_message) 50 | obj = Eyeloupe::Exception.find_by(kind: kind, file: file, line: line) 51 | 52 | if obj 53 | obj.update(count: obj.count + 1, updated_at: Time.now) 54 | else 55 | obj = Eyeloupe::Exception.create( 56 | hostname: Socket.gethostname, 57 | kind: kind, 58 | message: message, 59 | full_message: full_message, 60 | location: read_file(backtrace || []).to_json, 61 | file: file, 62 | line: line, 63 | stacktrace: (backtrace || []).to_json, 64 | ) 65 | end 66 | 67 | obj 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /app/views/eyeloupe/in_requests/_frame.html.erb: -------------------------------------------------------------------------------- 1 | <%= turbo_frame_tag "frame" do %> 2 | 3 |
4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | <% @requests.each do |request| %> 20 | 21 | 24 | 25 | 28 | 29 | 30 | 33 | 34 | <% end %> 35 | 36 |
VerbPathStatusDurationOccurred 14 | Details 15 |
22 | <%= render "eyeloupe/shared/verb", verb: request.verb %> 23 | <%= request.path.truncate(100) %> 26 | <%= render "eyeloupe/shared/status_code", code: request.status %> 27 | <%= request.duration %> ms<%= distance_of_time_in_words(request.created_at, DateTime.now) %> 31 | <%= link_to "Details", in_request_path(request), class: "text-gray-600 hover:text-gray-900", data: {"turbo_frame": "_top"} %> 32 |
37 |
38 | 43 |
44 | <% end %> -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | config.action_mailer.raise_delivery_errors = false 41 | 42 | config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Suppress logger output for asset requests. 60 | config.assets.quiet = true 61 | 62 | # Raises error for missing translations. 63 | # config.i18n.raise_on_missing_translations = true 64 | 65 | # Annotate rendered view with file names. 66 | # config.action_view.annotate_rendered_view_with_filenames = true 67 | 68 | # Uncomment if you wish to allow Action Cable access from any origin. 69 | # config.action_cable.disable_request_forgery_protection = true 70 | end 71 | -------------------------------------------------------------------------------- /test/lib/processors/exception_processor_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ExceptionProcessorTest < ActiveSupport::TestCase 6 | def setup 7 | @exception_processor = Eyeloupe::Processors::Exception.instance 8 | @env = { 9 | "REQUEST_METHOD" => "GET", 10 | "PATH_INFO" => "/", 11 | "HTTP_HOST" => "localhost", 12 | "HTTP_USER_AGENT" => "Rails Testing", 13 | "rack.input" => StringIO.new 14 | } 15 | @exception = NameError.new("Test Exception") 16 | @request = ActionDispatch::Request.new(@env) 17 | @sample_file_name = 'file.rb' 18 | File.write(@sample_file_name, "puts (\"hello world\")\ncontinue") 19 | 20 | @full_file_name = 'full.rb' 21 | File.write(@full_file_name, "puts (\"hello world1\")\nputs (\"hello world2\")\nputs (\"hello world3\")\nputs (\"hello world4\")\nputs (\"hello world5\")\nputs (\"hello world6\")\nputs (\"hello world7\")\nputs (\"hello world8\")\nputs (\"hello world9\")\nputs (\"hello world10\")\nputs (\"hello world11\")\nputs (\"hello world12\")\nputs (\"hello world13\")\nputs (\"hello world14\")\nputs (\"hello world15\")\n") 22 | end 23 | 24 | def teardown 25 | File.delete(@sample_file_name) 26 | File.delete(@full_file_name) 27 | end 28 | 29 | test "should initialize in request processor" do 30 | assert_not_nil @exception_processor 31 | end 32 | 33 | test "should read file and return empty array" do 34 | assert_equal [], @exception_processor.send(:read_file, ["unknown.rb:1"]) 35 | end 36 | 37 | test "should read file and return source code" do 38 | assert_equal ["puts (\"hello world\")\n", "continue"], @exception_processor.send(:read_file, ["file.rb:1"]) 39 | end 40 | 41 | test "should read file and return scoped source code" do 42 | assert_equal ["puts (\"hello world6\")\n", "puts (\"hello world7\")\n", "puts (\"hello world8\")\n", "puts (\"hello world9\")\n", "puts (\"hello world10\")\n", "puts (\"hello world11\")\n", "puts (\"hello world12\")\n", "puts (\"hello world13\")\n", "puts (\"hello world14\")\n", "puts (\"hello world15\")\n"], @exception_processor.send(:read_file, ["full.rb:10"]) 43 | end 44 | 45 | test "should update count if already exist" do 46 | ex = @exception_processor.send(:create_or_update_exception, "Exception", @sample_file_name, 1, ["file.rb:1"], "message", "full message") 47 | assert_not_nil ex 48 | assert_equal 1, ex.count 49 | 50 | ex = @exception_processor.send(:create_or_update_exception, "Exception", @sample_file_name, 1, ["file.rb:1"], "message", "full message") 51 | 52 | assert_equal 2, ex.count 53 | end 54 | 55 | test "should return exception" do 56 | assert_not_nil @exception_processor.process(@env, @exception) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/views/eyeloupe/jobs/_frame.html.erb: -------------------------------------------------------------------------------- 1 | <%= turbo_frame_tag "frame" do %> 2 |
3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | <% @jobs.each do |job| %> 21 | 22 | 25 | 26 | 27 | 30 | 31 | 32 | 35 | 36 | <% end %> 37 | 38 |
NameQueueAdapterStatusRetryEnqueued at 15 | Details 16 |
23 | <%= job.classname %> 24 | <%= job.queue_name %><%= job.adapter %> 28 | <%= render "eyeloupe/shared/job_status", job: job %> 29 | <%= job.retry %><%= distance_of_time_in_words(job.created_at, DateTime.now) %> 33 | <%= link_to "Details", job_path(job), class: "text-gray-600 hover:text-gray-900", data: {"turbo_frame": "_top"} %> 34 |
39 |
40 | 45 |
46 | <% end %> -------------------------------------------------------------------------------- /app/views/eyeloupe/out_requests/_frame.html.erb: -------------------------------------------------------------------------------- 1 | <%= turbo_frame_tag "frame" do %> 2 |
3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | <% @requests.each do |request| %> 21 | 22 | 25 | 26 | 27 | 30 | 31 | 32 | 35 | 36 | <% end %> 37 | 38 |
VerbHostPathStatusDurationOccurred 15 | Details 16 |
23 | <%= render "eyeloupe/shared/verb", verb: request.verb %> 24 | <%= request.hostname %><%= request.path.truncate(100) %> 28 | <%= render "eyeloupe/shared/status_code", code: request.status %> 29 | <%= request.duration %> ms<%= distance_of_time_in_words(request.created_at, DateTime.now) %> 33 | <%= link_to "Details", out_request_path(request), class: "text-gray-600 hover:text-gray-900", data: {"turbo_frame": "_top"} %> 34 |
39 |
40 | 45 |
46 | <% end %> -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.0].define(version: 2023_08_27_161224) do 14 | create_table "eyeloupe_exceptions", force: :cascade do |t| 15 | t.string "hostname" 16 | t.string "kind" 17 | t.string "location" 18 | t.string "file" 19 | t.integer "line" 20 | t.text "stacktrace" 21 | t.string "message" 22 | t.integer "count", default: 1 23 | t.text "full_message" 24 | t.integer "in_request_id" 25 | t.integer "out_request_id" 26 | t.datetime "created_at", null: false 27 | t.datetime "updated_at", null: false 28 | t.index ["in_request_id"], name: "index_eyeloupe_exceptions_on_in_request_id" 29 | t.index ["kind", "file", "line"], name: "index_eyeloupe_exceptions_on_kind_and_file_and_line" 30 | t.index ["out_request_id"], name: "index_eyeloupe_exceptions_on_out_request_id" 31 | end 32 | 33 | create_table "eyeloupe_in_requests", force: :cascade do |t| 34 | t.string "verb" 35 | t.string "hostname" 36 | t.string "controller" 37 | t.string "path" 38 | t.string "format" 39 | t.integer "status" 40 | t.integer "duration" 41 | t.integer "db_duration" 42 | t.integer "view_duration" 43 | t.string "ip" 44 | t.text "payload" 45 | t.text "headers" 46 | t.text "session" 47 | t.text "response" 48 | t.datetime "created_at", null: false 49 | t.datetime "updated_at", null: false 50 | end 51 | 52 | create_table "eyeloupe_jobs", force: :cascade do |t| 53 | t.string "classname" 54 | t.string "job_id" 55 | t.string "queue_name" 56 | t.string "adapter" 57 | t.integer "status", default: 0 58 | t.datetime "scheduled_at" 59 | t.datetime "executed_at" 60 | t.datetime "completed_at" 61 | t.integer "retry", default: 0 62 | t.string "args" 63 | t.datetime "created_at", null: false 64 | t.datetime "updated_at", null: false 65 | t.index ["job_id"], name: "index_eyeloupe_jobs_on_job_id", unique: true 66 | end 67 | 68 | create_table "eyeloupe_out_requests", force: :cascade do |t| 69 | t.string "verb" 70 | t.string "hostname" 71 | t.string "path" 72 | t.string "format" 73 | t.integer "status" 74 | t.integer "duration" 75 | t.text "payload" 76 | t.text "req_headers" 77 | t.text "res_headers" 78 | t.text "response" 79 | t.datetime "created_at", null: false 80 | t.datetime "updated_at", null: false 81 | end 82 | 83 | add_foreign_key "eyeloupe_exceptions", "eyeloupe_in_requests", column: "in_request_id" 84 | add_foreign_key "eyeloupe_exceptions", "eyeloupe_out_requests", column: "out_request_id" 85 | end 86 | -------------------------------------------------------------------------------- /app/views/eyeloupe/jobs/show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Job details

4 |
5 |
6 |
7 |
8 |
Enqueued at
9 |
10 | <%= @job.created_at.to_formatted_s(:long) %> (<%= distance_of_time_in_words(@job.created_at, DateTime.now) %>) 11 |
12 |
13 |
14 |
Duration
15 |
16 | <%= (@job.completed_at - @job.created_at).round %> seconds 17 |
18 |
19 |
20 |
Name
21 |
<%= @job.classname %>
22 |
23 |
24 |
Adapter
25 |
26 | <%= @job.adapter %> 27 |
28 |
29 |
30 |
Queue
31 |
<%= @job.queue_name %>
32 |
33 |
34 |
Job ID
35 |
36 | <%= @job.job_id %> 37 |
38 |
39 |
40 |
Retry
41 |
42 | <%= @job.retry %> 43 |
44 |
45 |
46 |
Status
47 |
48 | <%= render "eyeloupe/shared/job_status", job: @job %> 49 |
50 |
51 |
52 |
Arguments
53 |
54 | <% if @job.args.present? %> 55 |
<%= JSON.pretty_generate(JSON.parse(@job.args || "{}")) %>
56 | <% else %> 57 |

No args

58 | <% end %> 59 |
60 |
61 |
62 |
63 |
-------------------------------------------------------------------------------- /test/lib/processors/in_request_processor_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class InRequestProcessorTest < ActiveSupport::TestCase 6 | def setup 7 | @out_request_processor = Eyeloupe::Processors::InRequest.instance 8 | @env = { 9 | "REQUEST_METHOD" => "GET", 10 | "PATH_INFO" => "/", 11 | "HTTP_HOST" => "localhost", 12 | "HTTP_USER_AGENT" => "Rails Testing", 13 | "rack.input" => StringIO.new 14 | } 15 | @request = ActionDispatch::Request.new(@env) 16 | end 17 | 18 | test "should initialize in request processor" do 19 | assert_not_nil @out_request_processor 20 | end 21 | 22 | test "should start timer and subscribe" do 23 | @out_request_processor.start_timer 24 | assert_not_nil @out_request_processor.started_at 25 | assert @out_request_processor.started_at.is_a?(Time) 26 | assert @out_request_processor.subs.size == 1 27 | assert @out_request_processor.subs[0].is_a?(ActiveSupport::Notifications::Fanout::Subscribers::Timed) 28 | end 29 | 30 | test "should unsubscribe after process" do 31 | @out_request_processor.start_timer 32 | @out_request_processor.init(@request, @env, 200, {}, "", nil) 33 | assert_not_nil @out_request_processor.process 34 | assert @out_request_processor.subs.size == 0 35 | end 36 | 37 | test "should have correct total duration without timings" do 38 | @out_request_processor.start_timer 39 | @out_request_processor.init(@request, @env, 200, {}, "", nil) 40 | assert_not_equal 0, @out_request_processor.send(:get_total_duration) 41 | end 42 | 43 | test "should have correct total duration with timings" do 44 | @out_request_processor.start_timer 45 | @out_request_processor.init(@request, @env, 200, {}, "", nil) 46 | @out_request_processor.timings = { 47 | controller_time: 1, 48 | } 49 | assert_equal 1, @out_request_processor.send(:get_total_duration) 50 | end 51 | 52 | test "should get correct response" do 53 | @out_request_processor.start_timer 54 | @env['HTTP_ACCEPT'] = "text/html" 55 | @request = ActionDispatch::Request.new(@env) 56 | 57 | @out_request_processor.init(@request, @env, 200, {}, "", nil) 58 | 59 | assert_equal "HTML content", @out_request_processor.send(:get_response) 60 | end 61 | 62 | test "should get correct response for body response" do 63 | @out_request_processor.start_timer 64 | @request.format = nil 65 | response = ActionDispatch::Response.new(200, {}, "test") 66 | @out_request_processor.init(@request, @env, 200, {}, response, nil) 67 | assert_equal "test", @out_request_processor.send(:get_response) 68 | end 69 | 70 | test "should get correct response for non body response" do 71 | @out_request_processor.start_timer 72 | @request.format = nil 73 | @out_request_processor.init(@request, @env, 200, {}, "test2", nil) 74 | assert_equal "test2", @out_request_processor.send(:get_response) 75 | end 76 | 77 | test "should get correct controller with no controller class" do 78 | @out_request_processor.start_timer 79 | @out_request_processor.init(@request, @env, 200, {}, "", nil) 80 | assert_nil @out_request_processor.send(:get_controller) 81 | end 82 | 83 | test "should get correct controller with controller class" do 84 | @out_request_processor.start_timer 85 | @request.path_parameters = { controller: "eyeloupe/in_requests", action: "index" } 86 | @out_request_processor.init(@request, @env, 200, {}, "", nil) 87 | assert_equal "Eyeloupe::InRequestsController#index", @out_request_processor.send(:get_controller) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/eyeloupe/engine.rb: -------------------------------------------------------------------------------- 1 | require 'importmap-rails' 2 | 3 | module Eyeloupe 4 | class Engine < ::Rails::Engine 5 | isolate_namespace Eyeloupe 6 | 7 | initializer "eyeloupe.assets" do |app| 8 | app.config.assets.precompile += %w[ eyeloupe_manifest ] 9 | end 10 | 11 | initializer 'eyeloupe.add_middleware' do |app| 12 | app.config.middleware.insert(0, Eyeloupe::RequestMiddleware) 13 | end 14 | 15 | initializer 'eyeloupe.active_job' do 16 | ActiveSupport.on_load(:active_job) do 17 | include Eyeloupe::Concerns::Rescuable 18 | 19 | ActiveSupport::Notifications.subscribe("enqueue_at.active_job") do |*args| 20 | Eyeloupe::Processors::Job.instance.process(ActiveSupport::Notifications::Event.new(*args)) 21 | end 22 | 23 | ActiveSupport::Notifications.subscribe("enqueue.active_job") do |*args| 24 | Eyeloupe::Processors::Job.instance.process(ActiveSupport::Notifications::Event.new(*args)) 25 | end 26 | 27 | ActiveSupport::Notifications.subscribe("perform_start.active_job") do |*args| 28 | Eyeloupe::Processors::Job.instance.run(ActiveSupport::Notifications::Event.new(*args)) 29 | end 30 | 31 | ActiveSupport::Notifications.subscribe("perform.active_job") do |*args| 32 | Eyeloupe::Processors::Job.instance.complete(ActiveSupport::Notifications::Event.new(*args)) 33 | end 34 | 35 | ActiveSupport::Notifications.subscribe("retry_stopped.active_job") do |*args| 36 | Eyeloupe::Processors::Job.instance.failed(ActiveSupport::Notifications::Event.new(*args)) 37 | end 38 | 39 | ActiveSupport::Notifications.subscribe("discard.active_job") do |*args| 40 | Eyeloupe::Processors::Job.instance.discard(ActiveSupport::Notifications::Event.new(*args)) 41 | end 42 | end 43 | end 44 | 45 | initializer "eyeloupe.configure_openai" do 46 | OpenAI.configure do |config| 47 | config.access_token = Eyeloupe::configuration.openai_access_key 48 | end 49 | end 50 | 51 | initializer "eyeloupe.configure_sidekiq" do 52 | if defined?(Sidekiq) 53 | Sidekiq.configure_server do |config| 54 | config.error_handlers << proc {|ex,ctx_hash| Eyeloupe::Processors::Exception.instance.process(nil, ex) } 55 | end 56 | end 57 | end 58 | 59 | initializer 'eyeloupe.override_net_http_request' do 60 | require 'net/http' 61 | Net::HTTP.class_eval do 62 | alias original_request request 63 | def request(req, body = nil, &block) 64 | res, ex = nil 65 | exception_processor = Eyeloupe::Processors::Exception.instance 66 | out_request_processor = Eyeloupe::Processors::OutRequest.instance 67 | 68 | if Eyeloupe.configuration.capture 69 | begin 70 | out_request_processor.init(req, body) 71 | res = original_request(req, body, &block) 72 | rescue => e 73 | ex = exception_processor.process(nil, e) 74 | ensure 75 | out_request_processor.process(res, ex) 76 | end 77 | else 78 | res = original_request(req, body, &block) 79 | end 80 | res 81 | end 82 | end 83 | end 84 | 85 | initializer "eyeloupe.importmap", :before => "importmap" do |app| 86 | app.config.importmap.paths << root.join("config/importmap.rb") 87 | # https://github.com/rails/importmap-rails#sweeping-the-cache-in-development-and-test 88 | app.config.importmap.cache_sweepers << root.join("app/assets/javascripts") 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/eyeloupe/processors/job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Eyeloupe 3 | module Processors 4 | class Job 5 | include Singleton 6 | 7 | # @return [Array] 8 | attr_accessor :subs 9 | 10 | def initialize 11 | @subs = [] 12 | end 13 | 14 | # @param [ActiveSupport::Notifications::Event] event The event object 15 | def process(event) 16 | job = event.payload[:job] 17 | 18 | Eyeloupe::Job.create( 19 | classname: job.class.name, 20 | job_id: job.job_id, 21 | queue_name: queue_name(event), 22 | adapter: adapter_name(event), 23 | scheduled_at: scheduled_at(event), 24 | status: :enqueued, 25 | args: (args_info(job) || {}).to_json 26 | ) 27 | end 28 | 29 | # @param [ActiveSupport::Notifications::Event] event The event object 30 | def run(event) 31 | job = event.payload[:job] 32 | 33 | Eyeloupe::Job.where(job_id: job.job_id).update(status: :running, executed_at: Time.now.utc) 34 | end 35 | 36 | # @param [ActiveSupport::Notifications::Event] event The event object 37 | def complete(event) 38 | job = event.payload[:job] 39 | 40 | existing = Eyeloupe::Job.where(job_id: job.job_id).first 41 | 42 | if existing&.failed? 43 | Eyeloupe::Job.where(job_id: job.job_id).update(completed_at: Time.now.utc, retry: existing.retry + 1) 44 | else 45 | Eyeloupe::Job.where(job_id: job.job_id).update( 46 | status: :completed, 47 | completed_at: Time.now.utc, 48 | retry: (job.executions.zero? ? 1 : job.executions) - 1 49 | ) 50 | end 51 | end 52 | 53 | # @param [ActiveSupport::Notifications::Event] event The event object 54 | def failed(event) 55 | job = event.payload[:job] 56 | 57 | Eyeloupe::Job.where(job_id: job.job_id).update(status: :failed) 58 | end 59 | 60 | # @param [ActiveSupport::Notifications::Event] event The event object 61 | def discard(event) 62 | job = event.payload[:job] 63 | 64 | Eyeloupe::Job.where(job_id: job.job_id).update(status: :discarded) 65 | end 66 | 67 | # @param [ActiveJob::Base] job The job object 68 | # @return [Array, nil] 69 | def args_info(job) 70 | if job.class.log_arguments? && job.arguments.any? 71 | job.arguments 72 | end 73 | end 74 | 75 | private 76 | 77 | # @param [ActiveSupport::Notifications::Event] event The event object 78 | # @return [String] The name of the queue 79 | def queue_name(event) 80 | event.payload[:job].queue_name 81 | end 82 | 83 | # @param [ActiveSupport::Notifications::Event] event The event object 84 | # @return [String] The name of the adapter 85 | def adapter_name(event) 86 | event.payload[:adapter].class.name.demodulize.remove("Adapter") 87 | end 88 | 89 | # @param [ActiveSupport::Notifications::Event] event The event object 90 | # @return [Time, nil] The time the job was scheduled at 91 | def scheduled_at(event) 92 | return unless event.payload[:job].scheduled_at 93 | Time.at(event.payload[:job].scheduled_at).utc 94 | end 95 | 96 | # @param [String] event The event to subscribe to 97 | # @param [Proc] block The block to execute when the event is triggered 98 | # @yield [ActiveSupport::Notifications::Event] The event object 99 | def subscribe(event, &block) 100 | @subs << ActiveSupport::Notifications.subscribe(event) do |*args| 101 | block.call(ActiveSupport::Notifications::Event.new(*args)) 102 | end 103 | end 104 | 105 | def unsubscribe 106 | @subs.each do |sub| 107 | ActiveSupport::Notifications.unsubscribe(sub) 108 | end 109 | @subs = [] 110 | end 111 | end 112 | end 113 | end -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | config.active_storage.service = :local 42 | 43 | # Mount Action Cable outside main process or domain. 44 | # config.action_cable.mount_path = nil 45 | # config.action_cable.url = "wss://example.com/cable" 46 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | # config.force_ssl = true 50 | 51 | # Include generic and useful information about system operation, but avoid logging too much 52 | # information to avoid inadvertent exposure of personally identifiable information (PII). 53 | config.log_level = :info 54 | 55 | # Prepend all log lines with the following tags. 56 | config.log_tags = [ :request_id ] 57 | 58 | # Use a different cache store in production. 59 | # config.cache_store = :mem_cache_store 60 | 61 | # Use a real queuing backend for Active Job (and separate queues per environment). 62 | # config.active_job.queue_adapter = :resque 63 | # config.active_job.queue_name_prefix = "dummy_production" 64 | 65 | config.action_mailer.perform_caching = false 66 | 67 | # Ignore bad email addresses and do not raise email delivery errors. 68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 69 | # config.action_mailer.raise_delivery_errors = false 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Don't log any deprecations. 76 | config.active_support.report_deprecations = false 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | 81 | # Use a different logger for distributed setups. 82 | # require "syslog/logger" 83 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 84 | 85 | if ENV["RAILS_LOG_TO_STDOUT"].present? 86 | logger = ActiveSupport::Logger.new(STDOUT) 87 | logger.formatter = config.log_formatter 88 | config.logger = ActiveSupport::TaggedLogging.new(logger) 89 | end 90 | 91 | # Do not dump schema after migrations. 92 | config.active_record.dump_schema_after_migration = false 93 | end 94 | -------------------------------------------------------------------------------- /app/views/eyeloupe/out_requests/show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

HTTP Client Request Details

4 |
5 |
6 |
7 |
8 |
Time
9 |
10 | <%= @request.created_at.to_formatted_s(:long) %> (<%= distance_of_time_in_words(@request.created_at, DateTime.now) %>) 11 |
12 |
13 |
14 |
Hostname
15 |
<%= @request.hostname %>
16 |
17 |
18 |
Method
19 |
20 | <%= render "eyeloupe/shared/verb", verb: @request.verb %> 21 |
22 |
23 |
24 |
Path
25 |
<%= @request.path %>
26 |
27 |
28 |
Status
29 |
30 | <%= render "eyeloupe/shared/status_code", code: @request.status %> 31 |
32 |
33 |
34 |
Duration
35 |
36 | <%= @request.duration %> ms 37 |
38 |
39 |
40 |
Payload
41 |
42 | <% if @request.payload.present? %> 43 |
<%= format_payload @request %>
44 | <% else %> 45 |

No payload

46 | <% end %> 47 |
48 |
49 |
50 |
Request headers
51 |
52 |
<%= JSON.pretty_generate(JSON.parse(@request.req_headers || "{}")) %>
53 |
54 |
55 |
56 |
Response headers
57 |
58 |
<%= JSON.pretty_generate(JSON.parse(@request.res_headers || "{}")) %>
59 |
60 |
61 |
62 |
Response
63 |
64 |
<%= format_response @request %>
65 |
66 |
67 |
68 |
69 |
70 | 71 | <% if @request.exception.present? %> 72 |
73 |

Exceptions

74 | 75 | <%= link_to exception_path(@request.exception), class: "text-gray-600 hover:text-gray-900 hover:bg-gray-100 block px-4 sm:px-6 lg:px-8", data: {"turbo_frame": "_top"} do %> 76 |
77 |
78 | <%= @request.exception.kind %> 79 |

<%= @request.exception.message %>

80 |
81 |
82 | <% end %> 83 | 84 |
85 | <% end %> -------------------------------------------------------------------------------- /lib/eyeloupe/processors/in_request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'singleton' 3 | 4 | module Eyeloupe 5 | module Processors 6 | class InRequest 7 | 8 | include Singleton 9 | 10 | # @return [ActionDispatch::Request] 11 | attr_accessor :request 12 | 13 | # @return [Hash, nil] 14 | attr_accessor :env 15 | 16 | # @return [Integer, nil] 17 | attr_accessor :status 18 | 19 | # @return [Hash, nil] 20 | attr_accessor :headers 21 | 22 | # @return [String, nil, Rack::BodyProxy, ActionDispatch::Response] 23 | attr_accessor :response 24 | 25 | # @return [Hash] 26 | attr_accessor :timings 27 | 28 | # @return [Time, nil] 29 | attr_accessor :started_at 30 | 31 | # @return [Array] 32 | attr_accessor :subs 33 | 34 | # @return [Eyeloupe::Exception, nil] 35 | attr_accessor :ex 36 | 37 | def initialize 38 | @env = {} 39 | @request = ActionDispatch::Request.new(@env) 40 | @status = nil 41 | @headers = nil 42 | @response = nil 43 | @timings = {} 44 | @started_at = nil 45 | @subs = [] 46 | @ex = nil 47 | end 48 | 49 | # @param [ActionDispatch::Request] request The request object 50 | # @param [Hash, nil] env Rack environment 51 | # @param [Integer, nil] status HTTP status code 52 | # @param [Hash, nil] headers HTTP headers 53 | # @param [String, nil] response HTTP response 54 | # @param [Eyeloupe::Exception, nil] ex The exception object persisted in db 55 | # @return [Eyeloupe::Processors::InRequest] 56 | def init(request, env, status, headers, response, ex) 57 | unsubscribe 58 | 59 | @request = request 60 | @env = env 61 | @status = status 62 | @headers = headers 63 | @response = response 64 | @ex = ex 65 | 66 | self 67 | end 68 | 69 | def start_timer 70 | @started_at = Time.now 71 | 72 | subscribe('process_action.action_controller') do |event| 73 | @timings[:controller_time] = event.duration 74 | @timings[:db_time] = event.payload[:db_runtime] 75 | @timings[:view_time] = event.payload[:view_runtime] 76 | end 77 | end 78 | 79 | # @return [Eyeloupe::InRequest] 80 | def process 81 | 82 | req = Eyeloupe::InRequest.create( 83 | verb: @request.request_method, 84 | hostname: @request.host, 85 | path: @env["REQUEST_URI"], 86 | controller: get_controller, 87 | status: @status, 88 | format: @request.format, 89 | duration: get_total_duration, 90 | db_duration: @timings[:db_time].present? ? @timings[:db_time].round : nil, 91 | view_duration: @timings[:view_time].present? ? @timings[:view_time].round : nil, 92 | ip: @request.ip, 93 | payload: @env['rack.input'].read, 94 | headers: @headers&.to_json, 95 | session: (@request.session || {}).to_json, 96 | response: get_response, 97 | ) 98 | 99 | @ex.update(in_request_id: req.id) if @ex.present? && @ex.in_request_id.blank? 100 | 101 | req 102 | end 103 | 104 | protected 105 | 106 | # @return [Float] 107 | def get_total_duration 108 | if @timings[:controller_time].present? 109 | @timings[:controller_time].round 110 | elsif @started_at.present? 111 | (Time.now - @started_at) * 1000 112 | else 113 | 0.0 114 | end 115 | end 116 | 117 | # @return [String, nil] 118 | def get_response 119 | if @request.format.to_s =~ /html/ || @headers&.to_json =~ /html/ 120 | "HTML content" 121 | elsif @response.is_a?(ActionDispatch::Response) 122 | @response.body 123 | elsif @response.is_a?(Rack::BodyProxy) && @response.respond_to?(:first) 124 | @response.first 125 | else 126 | @response 127 | end 128 | end 129 | 130 | # @return [String, nil] 131 | def get_controller 132 | if @request.controller_class.to_s =~ /PASS_NOT_FOUND/ 133 | nil 134 | else 135 | "#{@request.controller_class.to_s}##{@request.path_parameters[:action]}" 136 | end 137 | end 138 | 139 | private 140 | 141 | # @param [String] event The event to subscribe to 142 | # @param [Proc] block The block to execute when the event is triggered 143 | # @yield [ActiveSupport::Notifications::Event] The event object 144 | def subscribe(event, &block) 145 | @subs << ActiveSupport::Notifications.subscribe(event) do |*args| 146 | block.call(ActiveSupport::Notifications::Event.new(*args)) 147 | end 148 | end 149 | 150 | def unsubscribe 151 | @subs.each do |sub| 152 | ActiveSupport::Notifications.unsubscribe(sub) 153 | end 154 | @subs = [] 155 | end 156 | 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /app/views/eyeloupe/in_requests/show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Request details

4 |
5 |
6 |
7 |
8 |
Time
9 |
10 | <%= @request.created_at.to_formatted_s(:long) %> (<%= distance_of_time_in_words(@request.created_at, DateTime.now) %>) 11 |
12 |
13 |
14 |
Hostname
15 |
<%= @request.hostname %>
16 |
17 |
18 |
Method
19 |
20 | <%= render "eyeloupe/shared/verb", verb: @request.verb %> 21 |
22 |
23 | <% if @request.controller.present? %> 24 |
25 |
Controller
26 |
<%= @request.controller %>
27 |
28 | <% end %> 29 |
30 |
Path
31 |
<%= @request.path %>
32 |
33 |
34 |
Status
35 |
36 | <%= render "eyeloupe/shared/status_code", code: @request.status %> 37 |
38 |
39 |
40 |
Duration
41 |
42 | <%= @request.duration %> ms 43 | <% if @request.db_duration.present? && @request.view_duration.present? %> 44 | (ActiveRecord: <%= @request.db_duration %> ms, Views: <%= @request.view_duration %> ms) 45 | <% end %> 46 |
47 |
48 |
49 |
IP Address
50 |
<%= @request.ip %>
51 |
52 |
53 |
Payload
54 |
55 | <% if @request.payload.present? %> 56 |
<%= format_payload @request %>
57 | <% else %> 58 |

No payload

59 | <% end %> 60 |
61 |
62 |
63 |
Headers
64 |
65 |
<%= JSON.pretty_generate(JSON.parse(@request.headers || "{}")) %>
66 |
67 |
68 |
69 |
Session
70 |
71 |
<%= JSON.pretty_generate(JSON.parse(@request.session || "{}")) %>
72 |
73 |
74 |
75 |
Response
76 |
77 |
<%= format_response @request %>
78 |
79 |
80 |
81 |
82 |
83 | 84 | <% if @request.exception.present? %> 85 |
86 |

Exceptions

87 | 88 | <%= link_to exception_path(@request.exception), class: "text-gray-600 hover:text-gray-900 hover:bg-gray-100 block px-4 sm:px-6 lg:px-8", data: {"turbo_frame": "_top"} do %> 89 |
90 |
91 | <%= @request.exception.kind %> 92 |

<%= @request.exception.message %>

93 |
94 |
95 | <% end %> 96 | 97 |
98 | <% end %> -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | eyeloupe (0.4.0) 5 | importmap-rails (~> 1.1) 6 | nokogiri (~> 1.15.4) 7 | pagy (~> 6.0) 8 | rails (~> 7.0) 9 | ruby-openai (~> 4.1.0) 10 | sprockets-rails (~> 3.4) 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | actioncable (7.0.4.2) 16 | actionpack (= 7.0.4.2) 17 | activesupport (= 7.0.4.2) 18 | nio4r (~> 2.0) 19 | websocket-driver (>= 0.6.1) 20 | actionmailbox (7.0.4.2) 21 | actionpack (= 7.0.4.2) 22 | activejob (= 7.0.4.2) 23 | activerecord (= 7.0.4.2) 24 | activestorage (= 7.0.4.2) 25 | activesupport (= 7.0.4.2) 26 | mail (>= 2.7.1) 27 | net-imap 28 | net-pop 29 | net-smtp 30 | actionmailer (7.0.4.2) 31 | actionpack (= 7.0.4.2) 32 | actionview (= 7.0.4.2) 33 | activejob (= 7.0.4.2) 34 | activesupport (= 7.0.4.2) 35 | mail (~> 2.5, >= 2.5.4) 36 | net-imap 37 | net-pop 38 | net-smtp 39 | rails-dom-testing (~> 2.0) 40 | actionpack (7.0.4.2) 41 | actionview (= 7.0.4.2) 42 | activesupport (= 7.0.4.2) 43 | rack (~> 2.0, >= 2.2.0) 44 | rack-test (>= 0.6.3) 45 | rails-dom-testing (~> 2.0) 46 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 47 | actiontext (7.0.4.2) 48 | actionpack (= 7.0.4.2) 49 | activerecord (= 7.0.4.2) 50 | activestorage (= 7.0.4.2) 51 | activesupport (= 7.0.4.2) 52 | globalid (>= 0.6.0) 53 | nokogiri (>= 1.8.5) 54 | actionview (7.0.4.2) 55 | activesupport (= 7.0.4.2) 56 | builder (~> 3.1) 57 | erubi (~> 1.4) 58 | rails-dom-testing (~> 2.0) 59 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 60 | activejob (7.0.4.2) 61 | activesupport (= 7.0.4.2) 62 | globalid (>= 0.3.6) 63 | activemodel (7.0.4.2) 64 | activesupport (= 7.0.4.2) 65 | activerecord (7.0.4.2) 66 | activemodel (= 7.0.4.2) 67 | activesupport (= 7.0.4.2) 68 | activestorage (7.0.4.2) 69 | actionpack (= 7.0.4.2) 70 | activejob (= 7.0.4.2) 71 | activerecord (= 7.0.4.2) 72 | activesupport (= 7.0.4.2) 73 | marcel (~> 1.0) 74 | mini_mime (>= 1.1.0) 75 | activesupport (7.0.4.2) 76 | concurrent-ruby (~> 1.0, >= 1.0.2) 77 | i18n (>= 1.6, < 2) 78 | minitest (>= 5.1) 79 | tzinfo (~> 2.0) 80 | ast (2.4.2) 81 | builder (3.2.4) 82 | concurrent-ruby (1.2.2) 83 | crass (1.0.6) 84 | date (3.3.3) 85 | erubi (1.12.0) 86 | faraday (2.7.6) 87 | faraday-net_http (>= 2.0, < 3.1) 88 | ruby2_keywords (>= 0.0.4) 89 | faraday-multipart (1.0.4) 90 | multipart-post (~> 2) 91 | faraday-net_http (3.0.2) 92 | globalid (1.1.0) 93 | activesupport (>= 5.0) 94 | i18n (1.13.0) 95 | concurrent-ruby (~> 1.0) 96 | importmap-rails (1.1.6) 97 | actionpack (>= 6.0.0) 98 | railties (>= 6.0.0) 99 | json (2.6.2) 100 | loofah (2.19.1) 101 | crass (~> 1.0.2) 102 | nokogiri (>= 1.5.9) 103 | mail (2.8.1) 104 | mini_mime (>= 0.1.1) 105 | net-imap 106 | net-pop 107 | net-smtp 108 | marcel (1.0.2) 109 | method_source (1.0.0) 110 | mini_mime (1.1.2) 111 | minitest (5.18.0) 112 | multipart-post (2.3.0) 113 | net-imap (0.3.4) 114 | date 115 | net-protocol 116 | net-pop (0.1.2) 117 | net-protocol 118 | net-protocol (0.2.1) 119 | timeout 120 | net-smtp (0.3.3) 121 | net-protocol 122 | nio4r (2.5.9) 123 | nokogiri (1.15.4-arm64-darwin) 124 | racc (~> 1.4) 125 | nokogiri (1.15.4-x86_64-linux) 126 | racc (~> 1.4) 127 | pagy (6.0.4) 128 | parallel (1.22.1) 129 | parser (3.1.2.0) 130 | ast (~> 2.4.1) 131 | racc (1.6.2) 132 | rack (2.2.7) 133 | rack-test (2.1.0) 134 | rack (>= 1.3) 135 | rails (7.0.4.2) 136 | actioncable (= 7.0.4.2) 137 | actionmailbox (= 7.0.4.2) 138 | actionmailer (= 7.0.4.2) 139 | actionpack (= 7.0.4.2) 140 | actiontext (= 7.0.4.2) 141 | actionview (= 7.0.4.2) 142 | activejob (= 7.0.4.2) 143 | activemodel (= 7.0.4.2) 144 | activerecord (= 7.0.4.2) 145 | activestorage (= 7.0.4.2) 146 | activesupport (= 7.0.4.2) 147 | bundler (>= 1.15.0) 148 | railties (= 7.0.4.2) 149 | rails-dom-testing (2.0.3) 150 | activesupport (>= 4.2.0) 151 | nokogiri (>= 1.6) 152 | rails-html-sanitizer (1.5.0) 153 | loofah (~> 2.19, >= 2.19.1) 154 | railties (7.0.4.2) 155 | actionpack (= 7.0.4.2) 156 | activesupport (= 7.0.4.2) 157 | method_source 158 | rake (>= 12.2) 159 | thor (~> 1.0) 160 | zeitwerk (~> 2.5) 161 | rainbow (3.1.1) 162 | rake (13.0.6) 163 | regexp_parser (2.8.0) 164 | rexml (3.2.5) 165 | rubocop (1.31.2) 166 | json (~> 2.3) 167 | parallel (~> 1.10) 168 | parser (>= 3.1.0.0) 169 | rainbow (>= 2.2.2, < 4.0) 170 | regexp_parser (>= 1.8, < 3.0) 171 | rexml (>= 3.2.5, < 4.0) 172 | rubocop-ast (>= 1.18.0, < 2.0) 173 | ruby-progressbar (~> 1.7) 174 | unicode-display_width (>= 1.4.0, < 3.0) 175 | rubocop-ast (1.19.1) 176 | parser (>= 3.1.1.0) 177 | ruby-openai (4.1.0) 178 | faraday (>= 1) 179 | faraday-multipart (>= 1) 180 | ruby-progressbar (1.11.0) 181 | ruby2_keywords (0.0.5) 182 | sprockets (4.2.0) 183 | concurrent-ruby (~> 1.0) 184 | rack (>= 2.2.4, < 4) 185 | sprockets-rails (3.4.2) 186 | actionpack (>= 5.2) 187 | activesupport (>= 5.2) 188 | sprockets (>= 3.0.0) 189 | sqlite3 (1.5.4-arm64-darwin) 190 | sqlite3 (1.5.4-x86_64-linux) 191 | tailwindcss-rails (2.0.10-arm64-darwin) 192 | railties (>= 6.0.0) 193 | tailwindcss-rails (2.0.10-x86_64-linux) 194 | railties (>= 6.0.0) 195 | thor (1.2.1) 196 | timeout (0.3.2) 197 | turbo-rails (1.1.1) 198 | actionpack (>= 6.0.0) 199 | activejob (>= 6.0.0) 200 | railties (>= 6.0.0) 201 | tzinfo (2.0.6) 202 | concurrent-ruby (~> 1.0) 203 | unicode-display_width (2.2.0) 204 | websocket-driver (0.7.5) 205 | websocket-extensions (>= 0.1.0) 206 | websocket-extensions (0.1.5) 207 | zeitwerk (2.6.7) 208 | 209 | PLATFORMS 210 | arm64-darwin-22 211 | arm64-darwin-23 212 | x86_64-linux 213 | 214 | DEPENDENCIES 215 | eyeloupe! 216 | pagy 217 | rubocop 218 | sprockets-rails 219 | sqlite3 220 | tailwindcss-rails (~> 2.0) 221 | turbo-rails 222 | 223 | BUNDLED WITH 224 | 2.4.3 225 | -------------------------------------------------------------------------------- /app/views/eyeloupe/exceptions/show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Exception Details

4 |
5 |
6 |
7 |
8 |
Time
9 |
10 | <%= @exception.created_at.to_formatted_s(:long) %> (<%= distance_of_time_in_words(@exception.updated_at, DateTime.now) %>) 11 |
12 |
13 |
14 |
Hostname
15 |
<%= @exception.hostname %>
16 |
17 |
18 |
Kind
19 |
20 | <%= @exception.kind %> 21 |
22 |
23 |
24 |
Count
25 |
26 | <%= @exception.count %> 27 |
28 |
29 | <% if @exception.in_request_id.present? %> 30 |
31 |
Request
32 |
33 | <%= link_to "View request", in_request_path(@exception.in_request_id), class: "underline" %> 34 |
35 |
36 | <% end %> 37 | <% if @exception.out_request_id.present? %> 38 |
39 |
Request
40 |
41 | <%= link_to "View request", out_request_path(@exception.out_request_id), class: "underline" %> 42 |
43 |
44 | <% end %> 45 |
46 |
Message
47 |
48 |
<%= @exception.message %>
49 |
50 |
51 |
52 |
Backtrace
53 |
54 |
<%= JSON.parse(@exception.stacktrace).join("\n") %>
55 |
56 |
57 |
58 |
Code preview
59 |
60 |

<%= @exception.file %>

61 | <% JSON.parse(@exception.location).each_with_index do |line, i| %> 62 |
"> 63 | <%= @line_numbers[i] + 1 %> 64 |
<%= line %>
65 |
66 | <% end %> 67 |
68 |
69 | <% if Eyeloupe::configuration.openai_access_key.present? %> 70 |
71 |
AI Assistant
72 |
73 | 85 | 86 | 118 | 119 |

120 |
121 |
122 | <% end %> 123 |
124 |
125 | 126 |
127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Project no longer under active development - Looking for new contributors 2 | 3 | [![Gem Version](https://badge.fury.io/rb/eyeloupe.svg)](https://badge.fury.io/rb/eyeloupe) 4 | 5 | [![Contributors][contributors-shield]][contributors-url] 6 | [![Forks][forks-shield]][forks-url] 7 | [![Stargazers][stars-shield]][stars-url] 8 | [![Issues][issues-shield]][issues-url] 9 | [![MIT License][license-shield]][license-url] 10 | 11 |
12 |
13 | 14 | Logo 15 | 16 | 17 |

Eyeloupe

18 | 19 |

20 | The elegant Rails debug assistant. AI powered. 21 |
22 | Report Bug 23 | · 24 | Request Feature 25 |

26 |
27 | 28 | [![Eyeloupe screenshot][eyeloupe-screen]](https://github.com/alxlion/eyeloupe) 29 | 30 | Eyeloupe is the elegant Rails debug assistant. It helps you to debug your Rails application by providing a simple and elegant interface to view your incoming/outgoing requests and exceptions, powered by AI. 31 | 32 | ## Installation 33 | Add this line to your application's Gemfile: 34 | 35 | ```ruby 36 | gem "eyeloupe" 37 | ``` 38 | 39 | And then execute: 40 | ```bash 41 | $ bundle 42 | ``` 43 | 44 | Install Eyeloupe migrations into your project: 45 | ```bash 46 | $ rails eyeloupe:install:migrations 47 | ``` 48 | 49 | And run the migrations: 50 | ```bash 51 | $ rails db:migrate 52 | ``` 53 | 54 | To access Eyeloupe dashboard you need to add the following route to your `config/routes.rb` file: 55 | ```ruby 56 | mount Eyeloupe::Engine => "/eyeloupe" 57 | ``` 58 | 59 | ## Configuration 60 | 61 | This is an example of the configuration you can add to your `initializers/eyeloupe.rb` file: 62 | 63 | ```ruby 64 | Eyeloupe.configure do |config| 65 | config.excluded_paths = %w[assets favicon.ico service-worker.js manifest.json] 66 | config.capture = Rails.env.development? 67 | config.openai_access_key = "your-openai-access-key" 68 | config.openai_model = "gpt-4" 69 | config.database = 'eyeloupe' 70 | end 71 | ``` 72 | 73 | - `excluded_paths` is an array of paths you want to exclude from Eyeloupe capture. Eyeloupe adds these excluded paths to the default ones: ` %w[mini-profiler eyeloupe active_storage]` 74 | - `capture` is a boolean to enable/disable Eyeloupe capture. By default, it's set to `true`. 75 | - `openai_access_key` is the access key to use the OpenAI API. You can get one [here](https://platform.openai.com/). 76 | - `openai_model` is the model to use for the OpenAI API. You can find the list of available models [here](https://platform.openai.com/docs/models). 77 | - `database` is an optional database config Eyeloupe will use ([Database](#database)). 78 | 79 | 80 | ### Database 81 | 82 | By default, Eyeloupe uses the same database as your application. If you want to use a different database to keep your production environment clean, you can add a new database config in your `config/database.yml` file: 83 | 84 | ```yml 85 | development: 86 | primary: 87 | <<: *default 88 | database: db/development.sqlite3 89 | eyeloupe: 90 | <<: *default 91 | database: db/eyeloupe.sqlite3 92 | migrations_paths: <%= Gem.loaded_specs['eyeloupe'].full_gem_path + '/db/migrate' %> 93 | schema_dump: false 94 | ``` 95 | 96 | Using this you can skip the `eyeloupe:install:migrations` step, but do not forget to run `rails db:migrate RAILS_ENV=eyeloupe` to setup the database. 97 | 98 | ### Exception handling 99 | 100 | To be able to handle exceptions, be sure to disable the default Rails exception handling in your environment config file (e.g. `config/environments/development.rb`): 101 | 102 | ```ruby 103 | config.consider_all_requests_local = false 104 | ``` 105 | 106 | ## Usage 107 | 108 | Eyeloupe is exclusively developed for the Rails framework. 109 | 110 | You can use it in your development environment to debug your application but it's not recommended to use it in production. 111 | 112 | ### Auto-refresh 113 | 114 | By activating auto-fresh, every _3 seconds_ the page will be refreshed to show you the latest data. 115 | 116 | ### Delete all data 117 | 118 | You can delete all the data stored by Eyeloupe by clicking on the trash button. 119 | 120 | ### AI Assistant 121 | 122 | When you define an OpenAI access key in the configuration, you could see a new section in the exception details page. This section is powered by the OpenAI API and it's able to give you a solution to solve your exception. 123 | It sends the entire content of the file containing the exception to have the best answer to your problem. 124 | 125 | ![Eyeloupe ai_assistant](/doc/img/ai-assistant.gif) 126 | 127 | ## Upgrade 128 | 129 | When your upgrade Eyeloupe to the latest version, be sure to run the following commands: 130 | 131 | ```bash 132 | $ rails eyeloupe:install:migrations 133 | $ rails db:migrate 134 | ``` 135 | 136 | ## Q/A 137 | 138 | ### Why the request time is not the same on rack-mini-profiler ? 139 | 140 | Eyeloupe is not a performance-oriented tool, the request time is the same you can view in the Rails log. If you want more details about your load time, you can use rack-mini-profiler along with Eyeloupe. 141 | 142 | ### Is this the Laravel Telescope for Rails ? 143 | 144 | Yes, Eyeloupe is inspired by Laravel Telescope. A lot of people coming from Laravel are missing Telescope or looking for something similar, so Eyeloupe is here to fill this gap. 145 | 146 | ## Contributing 147 | Contributions are what makes the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 148 | 149 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". 150 | Don't forget to give the project a star! Thanks again! 151 | 152 | 1. Fork the Project 153 | 2. Create your Feature Branch (`git checkout -b feature/amazing_feature`) 154 | 3. Commit your Changes (`git commit -m 'Add some amazing feature'`) 155 | 4. Push to the Branch (`git push origin feature/amazing_feature`) 156 | 5. Open a Pull Request 157 | 158 | ## License 159 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 160 | 161 | ## Contact 162 | 163 | [![](https://img.shields.io/badge/@alxlion__-000000?style=for-the-badge&logo=x&logoColor=white)](https://x.com/alxlion_) 164 | 165 | Project Link: [https://github.com/alxlion/eyeloupe](https://github.com/alxlion/eyeloupe) 166 | 167 | 168 | 169 | 170 | 171 | [contributors-shield]: https://img.shields.io/github/contributors/alxlion/eyeloupe.svg?style=for-the-badge 172 | [contributors-url]: https://github.com/alxlion/eyeloupe/graphs/contributors 173 | [forks-shield]: https://img.shields.io/github/forks/alxlion/eyeloupe.svg?style=for-the-badge 174 | [forks-url]: https://github.com/alxlion/eyeloupe/network/members 175 | [stars-shield]: https://img.shields.io/github/stars/alxlion/eyeloupe.svg?style=for-the-badge 176 | [stars-url]: https://github.com/alxlion/eyeloupe/stargazers 177 | [issues-shield]: https://img.shields.io/github/issues/alxlion/eyeloupe.svg?style=for-the-badge 178 | [issues-url]: https://github.com/alxlion/eyeloupe/issues 179 | [license-shield]: https://img.shields.io/github/license/alxlion/eyeloupe.svg?style=for-the-badge 180 | [license-url]: https://github.com/alxlion/eyeloupe/blob/master/MIT-LICENSE.txt 181 | [eyeloupe-screen]: /doc/img/screen.png 182 | [gem-version]: https://badge.fury.io/rb/eyeloupe.svg 183 | [gem-url]: https://rubygems.org/gems/eyeloupe 184 | -------------------------------------------------------------------------------- /app/views/layouts/eyeloupe/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Eyeloupe 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | <%= javascript_importmap_tags "eyeloupe/application" %> 8 | 9 | 10 | 11 | 12 | 13 | <%= stylesheet_link_tag "eyeloupe", media: "all" %> 14 | 15 | 16 | 17 |
18 |
19 | 20 | 105 | 106 | 107 |
108 | 177 |
178 | 179 | 205 | 206 |
207 |
208 | 214 | <%= link_to root_url, class: "flex gap-x-1" do %> 215 | <%= image_tag "eyeloupe/logo.png", class: "h-7 w-auto" %> 216 |

Eyeloupe

217 | <% end %> 218 |
219 |
220 | <%= link_to configs_path(value: "#{!@eyeloupe_capture}"), title: "Pause data collection", data: { "turbo_method": "put" }, class: "#{@eyeloupe_capture ? "text-gray-500 hover:bg-gray-300 bg-gray-200" : "bg-red-500 text-white hover:bg-red-600"} rounded-md p-1 transition duration-500 inline-block hidden" do %> 221 | 222 | 223 | 224 | 225 | 226 | <% end %> 227 | 234 | <%= link_to data_path, title: "Delete all Eyeloupe data", data: { "turbo_method": "delete", "turbo_confirm": "Are you sure to delete all Eyeloupe data?" }, class: "rounded-md text-gray-500 hover:bg-gray-300 bg-gray-200 p-1 transition duration-500 inline-block" do %> 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | <% end %> 244 |
245 |
246 | 247 |
248 | <%= yield %> 249 |
250 | 251 | 254 | 255 |
256 |
257 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /app/assets/builds/eyeloupe.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v3.1.3 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-family:Fira Sans,sans-serif,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input:-ms-input-placeholder,textarea:-ms-input-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],select,textarea{--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,select:focus,textarea:focus{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);outline:2px solid #0000;outline-offset:2px}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input:-ms-input-placeholder,textarea:-ms-input-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}select{color-adjust:exact;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact}[multiple]{color-adjust:unset;background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset}[type=checkbox],[type=radio]{color-adjust:exact;--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;vertical-align:middle;width:1rem}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);outline:2px solid #0000;outline-offset:2px}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E")}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=checkbox]:indeterminate,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:#0000}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:#0000}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px auto -webkit-focus-ring-color}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::-webkit-backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.sr-only{clip:rect(0,0,0,0);border-width:0;height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:-webkit-sticky;position:sticky}.inset-0{bottom:0;left:0;right:0;top:0}.left-full{left:100%}.top-0{top:0}.z-50{z-index:50}.z-40{z-index:40}.col-span-1{grid-column:span 1/span 1}.col-span-11{grid-column:span 11/span 11}.-m-2\.5{margin:-.625rem}.-m-2{margin:-.5rem}.-mx-4{margin-left:-1rem;margin-right:-1rem}.-my-2{margin-bottom:-.5rem;margin-top:-.5rem}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mt-4{margin-top:1rem}.mt-2{margin-top:.5rem}.mt-8{margin-top:2rem}.mt-6{margin-top:1.5rem}.mt-1{margin-top:.25rem}.mr-16{margin-right:4rem}.ml-2{margin-left:.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.h-6{height:1.5rem}.h-16{height:4rem}.h-7{height:1.75rem}.h-12{height:3rem}.h-5{height:1.25rem}.w-full{width:100%}.w-16{width:4rem}.w-6{width:1.5rem}.w-auto{width:auto}.w-5{width:1.25rem}.min-w-full{min-width:100%}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.flex-col{flex-direction:column}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-x-1{-moz-column-gap:.25rem;column-gap:.25rem}.gap-y-5{row-gap:1.25rem}.gap-y-7{row-gap:1.75rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-gray-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(209 213 219/var(--tw-divide-opacity))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-t{border-top-width:1px}.border-b{border-bottom-width:1px}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity))}.bg-gray-900\/80{background-color:#111827cc}.bg-opacity-30{--tw-bg-opacity:0.3}.p-2{padding:.5rem}.\!p-0{padding:0!important}.p-2\.5{padding:.625rem}.p-1{padding:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.px-4{padding-left:1rem;padding-right:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-0{padding-left:0;padding-right:0}.py-1{padding-bottom:.25rem;padding-top:.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-10{padding-bottom:2.5rem;padding-top:2.5rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.pl-4{padding-left:1rem}.pr-3{padding-right:.75rem}.pl-3{padding-left:.75rem}.pr-4{padding-right:1rem}.pt-2{padding-top:.5rem}.pb-4{padding-bottom:1rem}.pb-3{padding-bottom:.75rem}.pt-5{padding-top:1.25rem}.pb-2{padding-bottom:.5rem}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-sm{font-size:.875rem;line-height:1.25rem}.text-base{font-size:1rem;line-height:1.5rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-2xl{font-size:1.5rem;line-height:2rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-6{line-height:1.5rem}.leading-7{line-height:1.75rem}.tracking-wide{letter-spacing:.025em}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-purple-700{--tw-text-opacity:1;color:rgb(126 34 206/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.underline{-webkit-text-decoration-line:underline;text-decoration-line:underline}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-inset{--tw-ring-inset:inset}.ring-green-600\/20{--tw-ring-color:#16a34a33}.ring-blue-700\/10{--tw-ring-color:#1d4ed81a}.ring-yellow-600\/20{--tw-ring-color:#ca8a0433}.ring-red-600\/10{--tw-ring-color:#dc26261a}.ring-gray-500\/10{--tw-ring-color:#6b72801a}.ring-gray-500{--tw-ring-opacity:1;--tw-ring-color:rgb(107 114 128/var(--tw-ring-opacity))}.ring-purple-700\/10{--tw-ring-color:#7e22ce1a}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-500{transition-duration:.5s}.pagination{display:inline-flex;position:relative;z-index:0}.pagination>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(-1px*(1 - var(--tw-space-x-reverse)));margin-right:calc(-1px*var(--tw-space-x-reverse))}.pagination{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);border-radius:.375rem;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.pagination .prev a{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1;align-items:center;background-color:rgb(255 255 255/var(--tw-bg-opacity));border-bottom-left-radius:.375rem;border-color:rgb(209 213 219/var(--tw-border-opacity));border-top-left-radius:.375rem;border-width:1px;color:rgb(107 114 128/var(--tw-text-opacity));display:inline-flex;font-size:.875rem;font-weight:500;line-height:1.25rem;padding:.5rem;position:relative}.pagination .prev a:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.pagination .next a{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1;align-items:center;background-color:rgb(255 255 255/var(--tw-bg-opacity));border-bottom-right-radius:.375rem;border-color:rgb(209 213 219/var(--tw-border-opacity));border-top-right-radius:.375rem;border-width:1px;color:rgb(107 114 128/var(--tw-text-opacity));display:inline-flex;font-size:.875rem;font-weight:500;line-height:1.25rem;padding:.5rem;position:relative}.pagination .next a:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.pagination .current{--tw-text-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity));border-top-width:2px;color:rgb(239 68 68/var(--tw-text-opacity));padding-left:1rem;padding-right:1rem;padding-top:1rem}.pagination .current,.pagination a{--tw-border-opacity:1;align-items:center;display:inline-flex;font-size:.875rem;font-weight:500;line-height:1.25rem}.pagination a{--tw-bg-opacity:1;--tw-text-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));border-color:rgb(209 213 219/var(--tw-border-opacity));border-width:1px;color:rgb(107 114 128/var(--tw-text-opacity));padding:.5rem 1rem;position:relative}.pagination a:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.pagination .disabled{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1;align-items:center;background-color:rgb(255 255 255/var(--tw-bg-opacity));border-color:rgb(209 213 219/var(--tw-border-opacity));border-width:1px;color:rgb(107 114 128/var(--tw-text-opacity));display:inline-flex;font-size:.875rem;font-weight:500;line-height:1.25rem;opacity:.4;padding:.5rem 1rem;position:relative}.pagination .disabled:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.pagination .gap{background-color:rgb(255 255 255/var(--tw-bg-opacity));border-color:rgb(209 213 219/var(--tw-border-opacity));color:rgb(55 65 81/var(--tw-text-opacity))}.pagination .active,.pagination .gap{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1;align-items:center;border-width:1px;display:inline-flex;font-size:.875rem;font-weight:500;line-height:1.25rem;padding:.5rem 1rem;position:relative}.pagination .active{background-color:rgb(254 242 242/var(--tw-bg-opacity));border-color:rgb(239 68 68/var(--tw-border-opacity));color:rgb(239 68 68/var(--tw-text-opacity));z-index:10}.pagination .next_page,.pagination .previous_page{display:none}#result pre{background-color:rgb(0 0 0/var(--tw-bg-opacity));color:rgb(255 255 255/var(--tw-text-opacity));margin-bottom:.5rem;margin-top:.5rem;padding:.75rem}#result p code,#result pre{--tw-bg-opacity:1;--tw-text-opacity:1;border-radius:.375rem}#result p code{background-color:rgb(229 231 235/var(--tw-bg-opacity));color:rgb(75 85 99/var(--tw-text-opacity));font-size:.875rem;line-height:1.25rem;padding:.125rem .5rem}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.hover\:bg-red-400:hover{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-red-500:focus{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity))}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-red-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity))}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:-mx-6{margin-left:-1.5rem;margin-right:-1.5rem}.sm\:mt-0{margin-top:0}.sm\:flex{display:flex}.sm\:grid{display:grid}.sm\:flex-auto{flex:1 1 auto}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:items-center{align-items:center}.sm\:gap-4{gap:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:px-0{padding-right:0}.sm\:pl-0,.sm\:px-0{padding-left:0}.sm\:pr-0{padding-right:0}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:1024px){.lg\:fixed{position:fixed}.lg\:inset-y-0{bottom:0;top:0}.lg\:z-50{z-index:50}.lg\:-mx-8{margin-left:-2rem;margin-right:-2rem}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-72{width:18rem}.lg\:flex-col{flex-direction:column}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:pl-72{padding-left:18rem}} --------------------------------------------------------------------------------