├── demo ├── log │ └── .keep ├── lib │ └── assets │ │ └── .keep ├── public │ ├── favicon.ico │ ├── icon.png │ ├── icon.svg │ ├── 406-unsupported-browser.html │ ├── 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 │ │ └── examples_controller.rb │ ├── views │ │ ├── layouts │ │ │ ├── mailer.text.erb │ │ │ ├── mailer.html.erb │ │ │ └── application.html.erb │ │ └── examples │ │ │ ├── create.html.erb │ │ │ ├── index.html.erb │ │ │ └── new.html.erb │ ├── helpers │ │ └── application_helper.rb │ ├── channels │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── mailers │ │ └── application_mailer.rb │ └── jobs │ │ └── application_job.rb ├── config │ ├── environments │ │ ├── demo.rb │ │ ├── development.rb │ │ ├── test.rb │ │ └── production.rb │ ├── environment.rb │ ├── cable.yml │ ├── boot.rb │ ├── routes.rb │ ├── initializers │ │ ├── filter_parameter_logging.rb │ │ ├── permissions_policy.rb │ │ ├── assets.rb │ │ ├── inflections.rb │ │ └── content_security_policy.rb │ ├── locales │ │ └── en.yml │ ├── application.rb │ ├── database.yml │ ├── storage.yml │ └── puma.rb ├── bin │ ├── rake │ ├── rails │ └── setup ├── config.ru ├── Rakefile └── db │ └── schema.rb ├── spec ├── system │ └── .keep ├── tmp │ └── .keep ├── app │ ├── controllers │ │ ├── .keep │ │ └── events_controller_spec.rb │ └── models │ │ └── spectator_sport │ │ └── event_spec.rb ├── support │ ├── rspec_not_change.rb │ ├── sql_helper.rb │ ├── output_helper.rb │ ├── logger.rb │ ├── uuid.rb │ ├── selenium.rb │ ├── system.rb │ ├── shell_out.rb │ ├── pre_documentation_formatter.rb │ └── example_app_helper.rb ├── lib │ └── spectator_sport_spec.rb ├── generators │ └── spectator_sport │ │ └── install_generator_spec.rb ├── rails_helper.rb └── spec_helper.rb ├── .ruby-version ├── app ├── models │ ├── concerns │ │ └── .keep │ └── spectator_sport │ │ ├── application_record.rb │ │ ├── session.rb │ │ ├── session_window.rb │ │ └── event.rb ├── controllers │ ├── concerns │ │ └── .keep │ └── spectator_sport │ │ ├── application_controller.rb │ │ ├── dashboard │ │ ├── dashboards_controller.rb │ │ ├── application_controller.rb │ │ ├── session_windows_controller.rb │ │ └── frontends_controller.rb │ │ └── events_controller.rb ├── helpers │ └── spectator_sport │ │ ├── application_helper.rb │ │ ├── dashboard │ │ ├── application_helper.rb │ │ └── icons_helper.rb │ │ └── script_helper.rb ├── jobs │ └── spectator_sport │ │ └── application_job.rb ├── mailers │ └── spectator_sport │ │ └── application_mailer.rb ├── views │ ├── spectator_sport │ │ ├── dashboard │ │ │ ├── session_windows │ │ │ │ ├── _more_events_frame.erb │ │ │ │ ├── events.erb │ │ │ │ └── show.html.erb │ │ │ └── dashboards │ │ │ │ └── index.html.erb │ │ └── shared │ │ │ └── _navbar.erb │ └── layouts │ │ └── spectator_sport │ │ └── dashboard │ │ └── application.html.erb └── frontend │ └── spectator_sport │ └── dashboard │ ├── application.js │ ├── modules │ ├── theme_controller.js │ └── player_controller.js │ ├── style.css │ ├── vendor │ ├── rrweb-player │ │ └── rrweb-player.min.css │ └── es_module_shims.js │ └── icons.svg ├── Procfile ├── lib ├── spectator_sport │ ├── version.rb │ └── engine.rb ├── tasks │ └── spectator_sport_tasks.rake └── spectator_sport.rb ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── bin ├── rspec ├── rubocop └── rails ├── config ├── routes.rb └── dashboard_routes.rb ├── .gitignore ├── .rubocop.yml ├── Rakefile ├── Gemfile ├── db └── migrate │ └── 20240923140845_create_spectator_sport_events.rb ├── spectator_sport.gemspec ├── LICENSE.txt ├── README.md └── Gemfile.lock /demo/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/system/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.5 2 | -------------------------------------------------------------------------------- /demo/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/app/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/app/models/spectator_sport/event_spec.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /demo/config/environments/demo.rb: -------------------------------------------------------------------------------- 1 | require_relative "production" 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: bin/rails app:db:prepare 2 | web: bin/rails server 3 | -------------------------------------------------------------------------------- /demo/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /lib/spectator_sport/version.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /demo/app/views/examples/create.html.erb: -------------------------------------------------------------------------------- 1 | <%= @resource.name %> 2 | <%= @resource.message %> 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [bensheldon] 4 | -------------------------------------------------------------------------------- /demo/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensheldon/spectator_sport/HEAD/demo/public/icon.png -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'bundler/setup' 3 | load Gem.bin_path('rspec-core', 'rspec') 4 | -------------------------------------------------------------------------------- /demo/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /demo/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | SpectatorSport::Engine.routes.draw do 2 | resources :events, only: [ :index, :create ] 3 | end 4 | -------------------------------------------------------------------------------- /demo/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /demo/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /app/helpers/spectator_sport/application_helper.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | module ApplicationHelper 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/jobs/spectator_sport/application_job.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | class ApplicationJob < ActiveJob::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/rspec_not_change.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define_negated_matcher :not_change, :change 4 | -------------------------------------------------------------------------------- /demo/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/lib/spectator_sport_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | describe SpectatorSport do 6 | end 7 | -------------------------------------------------------------------------------- /lib/tasks/spectator_sport_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :spectator_sport do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /demo/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /demo/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /demo/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /app/controllers/spectator_sport/application_controller.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | class ApplicationController < ActionController::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/spectator_sport.rb: -------------------------------------------------------------------------------- 1 | require "spectator_sport/version" 2 | require "spectator_sport/engine" 3 | 4 | module SpectatorSport 5 | # Your code goes here... 6 | end 7 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /app/models/spectator_sport/application_record.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | class ApplicationRecord < ActiveRecord::Base 3 | self.abstract_class = true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /demo/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /demo/db/*.sqlite3 7 | /demo/db/*.sqlite3-* 8 | /demo/log/*.log 9 | /demo/storage/ 10 | /demo/tmp/ 11 | -------------------------------------------------------------------------------- /app/models/spectator_sport/session.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | class Session < ApplicationRecord 3 | has_many :session_windows 4 | has_many :events 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/models/spectator_sport/session_window.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | class SessionWindow < ApplicationRecord 3 | belongs_to :session 4 | has_many :events 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /app/helpers/spectator_sport/dashboard/application_helper.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | module Dashboard 3 | module ApplicationHelper 4 | include Dashboard::IconsHelper 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/mailers/spectator_sport/application_mailer.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | class ApplicationMailer < ActionMailer::Base 3 | default from: "from@example.com" 4 | layout "mailer" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/views/spectator_sport/dashboard/session_windows/_more_events_frame.erb: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/helpers/spectator_sport/script_helper.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | module ScriptHelper 3 | def spectator_sport_script_tags 4 | tag.script defer: true, src: spectator_sport.events_path(format: :js) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/sql_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SqlHelper 4 | def normalize_sql(sql) 5 | sql.gsub(/\s/, ' ').gsub(/([()])/, ' \1 ').squish 6 | end 7 | end 8 | 9 | RSpec.configure { |c| c.include SqlHelper } 10 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /demo/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: spectator_sport_production 11 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Omakase Ruby styling for Rails 2 | inherit_gem: { rubocop-rails-omakase: rubocop.yml } 3 | 4 | # Overwrite or add rules to create your own house style 5 | # 6 | # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` 7 | # Layout/SpaceInsideArrayLiteralBrackets: 8 | # Enabled: false 9 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /demo/app/views/examples/index.html.erb: -------------------------------------------------------------------------------- 1 |

This is an example page to demonstrate Spectator Sport

2 |

Your browser activity is being recorded.

3 | 4 | <%= link_to "Pretend you are doing stuff", { action: :new } %> 5 |
6 | <%= link_to "Try encountering server error", { action: :error } %> 7 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /demo/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/controllers/spectator_sport/dashboard/dashboards_controller.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | module Dashboard 3 | class DashboardsController < ApplicationController 4 | def index 5 | @session_windows = SessionWindow.order(:created_at).limit(50).reverse_order 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/controllers/spectator_sport/dashboard/application_controller.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | module Dashboard 3 | class ApplicationController < ActionController::Base 4 | end 5 | end 6 | end 7 | 8 | ActiveSupport.run_load_hooks(:spectator_sport_dashboard_application_controller, SpectatorSport::Dashboard::ApplicationController) 9 | -------------------------------------------------------------------------------- /demo/app/views/examples/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with model: @resource, url: { action: :create }, action: :post do |f| %> 2 | <%= f.label :name %> 3 | <%= f.text_field :name %> 4 | 5 |

6 | <%= f.label :message %> 7 | <%= f.text_area :message %> 8 | 9 |

10 | 11 | <%= f.submit "Submit" %> 12 | <% end %> 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | APP_RAKEFILE = File.expand_path("demo/Rakefile", __dir__) 4 | load "rails/tasks/engine.rake" 5 | 6 | load "rails/tasks/statistics.rake" 7 | 8 | require "bundler/gem_tasks" 9 | 10 | # assets:precompile does not exist. call app:assets:precompile is used instead 11 | task "assets:precompile" => "app:assets:precompile" 12 | -------------------------------------------------------------------------------- /spec/support/output_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OutputHelper 4 | def quiet(&block) 5 | if ENV['LOUD'].present? 6 | yield 7 | else 8 | expect(&block).to output(/.*/).to_stderr_from_any_process.and output(/.*/).to_stdout_from_any_process 9 | end 10 | end 11 | end 12 | RSpec.configure { |c| c.include OutputHelper } 13 | -------------------------------------------------------------------------------- /demo/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount SpectatorSport::Engine => "/spectator_sport" 3 | mount SpectatorSport::Dashboard::Engine => "/spectator_sport_dashboard" 4 | 5 | root to: "examples#index" 6 | 7 | resources :examples, only: [ :index, :show, :new, :create ] do 8 | collection do 9 | get :error 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/models/spectator_sport/event.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | class Event < ApplicationRecord 3 | PAGE_LIMIT = 1_000 4 | 5 | belongs_to :session 6 | belongs_to :session_window 7 | 8 | scope :page_after, ->(event) { (event ? where("(created_at, id) > (?, ?)", event.created_at, event.id) : self).order(created_at: :asc, id: :asc).limit(PAGE_LIMIT) } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/frontend/spectator_sport/dashboard/application.js: -------------------------------------------------------------------------------- 1 | /*jshint esversion: 6, strict: false */ 2 | 3 | import "turbo"; 4 | import { Application } from "stimulus"; 5 | 6 | window.Stimulus = Application.start(); 7 | 8 | 9 | import ThemeController from "theme_controller"; 10 | import PlayerController from "player_controller"; 11 | Stimulus.register("theme", ThemeController); 12 | Stimulus.register("player", PlayerController); 13 | -------------------------------------------------------------------------------- /spec/support/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.around do |example| 5 | Rails.logger.debug { "\n\n---- START EXAMPLE: #{example.full_description} (#{example.location})" } 6 | Thread.current.name = "RSpec: #{example.description}" 7 | example.run 8 | Rails.logger.debug { "---- END EXAMPLE: #{example.full_description} (#{example.location})\n\n" } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/uuid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :be_a_uuid do 4 | match do |actual| 5 | regexp = /\A\h{8}-\h{4}-(\h{4})-\h{4}-\h{12}\z/ 6 | actual.is_a?(String) && actual.match?(regexp) 7 | end 8 | 9 | description { "a UUID" } 10 | failure_message { "expected #{description}" } 11 | failure_message_when_negated { "did not expect #{description}" } 12 | end 13 | 14 | RSpec::Matchers.alias_matcher :a_uuid, :be_a_uuid 15 | -------------------------------------------------------------------------------- /demo/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /app/views/spectator_sport/dashboard/session_windows/events.erb: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | <% if @events.size >= SpectatorSport::Event::PAGE_LIMIT %> 8 | 9 | 12 | 13 | <% end %> 14 | -------------------------------------------------------------------------------- /config/dashboard_routes.rb: -------------------------------------------------------------------------------- 1 | SpectatorSport::Dashboard::Engine.routes.draw do 2 | root to: "dashboards#index" 3 | resources :session_windows, only: [ :show, :destroy ] do 4 | member do 5 | get :events 6 | end 7 | end 8 | 9 | scope :frontend, controller: :frontends, defaults: { version: SpectatorSport::VERSION.tr(".", "-") } do 10 | get "modules/:version/:id", action: :module, as: :frontend_module, constraints: { format: "js" } 11 | get "static/:version/:id", action: :static, as: :frontend_static 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/helpers/spectator_sport/dashboard/icons_helper.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | module Dashboard 3 | module IconsHelper 4 | def render_icon(name, class: nil, **options) 5 | tag.svg(viewBox: "0 0 16 16", class: "svg-icon #{binding.local_variable_get(:class)}", **options) do 6 | tag.use(fill: "currentColor", href: "#{icons_path}##{name}") 7 | end 8 | end 9 | 10 | def icons_path 11 | @_icons_path ||= frontend_static_path(:icons, format: :svg, locale: nil) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /demo/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide HTTP permissions policy. For further 4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 5 | 6 | # Rails.application.config.permissions_policy do |policy| 7 | # policy.camera :none 8 | # policy.gyroscope :none 9 | # policy.microphone :none 10 | # policy.usb :none 11 | # policy.fullscreen :self 12 | # policy.payment :self, "https://secure.example.com" 13 | # end 14 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /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/spectator_sport/engine", __dir__) 7 | APP_PATH = File.expand_path("../demo/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 | -------------------------------------------------------------------------------- /app/views/spectator_sport/dashboard/session_windows/show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | <%= tag.div id: "player", 5 | data: { 6 | id: "player", 7 | controller: "player", 8 | player_events_value: @events.map(&:event_data).to_json, 9 | } do %> 10 |
11 | 12 | <% if @events.size >= SpectatorSport::Event::PAGE_LIMIT %> 13 | <%= render "more_events_frame", events: @events %> 14 | <% end %> 15 | <% end %> 16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /lib/spectator_sport/engine.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | class Engine < ::Rails::Engine 3 | isolate_namespace SpectatorSport 4 | 5 | initializer "local_helper.action_controller" do 6 | ActiveSupport.on_load :action_controller do 7 | # TODO: this should probably be done manually by the client, maybe? 8 | helper SpectatorSport::ScriptHelper 9 | end 10 | end 11 | end 12 | 13 | module Dashboard 14 | class Engine < ::Rails::Engine 15 | isolate_namespace SpectatorSport::Dashboard 16 | paths.add "config/routes.rb", with: "config/dashboard_routes.rb" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "bundler" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | bundler-lint: 9 | patterns: 10 | - brakeman 11 | - erb_lint 12 | - rubocop 13 | - rubocop-* 14 | bundler-dependencies: 15 | patterns: 16 | - "*" 17 | exclude-patterns: 18 | - puma 19 | - brakeman 20 | - erb_lint 21 | - rubocop 22 | - rubocop-* 23 | - package-ecosystem: github-actions 24 | directory: "/" 25 | schedule: 26 | interval: monthly 27 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | if File.exist?(".ruby-version") 4 | ruby file: ".ruby-version" 5 | end 6 | 7 | # Specify your gem's dependencies in spectator_sport.gemspec. 8 | gemspec 9 | 10 | gem "puma" 11 | 12 | gem "sqlite3" 13 | gem "pg" 14 | 15 | gem "sprockets-rails" 16 | 17 | # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] 18 | gem "rubocop-rails-omakase", require: false 19 | 20 | # Start debugger with binding.b [https://github.com/ruby/debug] 21 | # gem "debug", ">= 1.0.0" 22 | 23 | group :test do 24 | gem "capybara" 25 | gem "rspec-rails" 26 | gem "selenium-webdriver" 27 | gem "warning" 28 | end 29 | -------------------------------------------------------------------------------- /spec/generators/spectator_sport/install_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | # require 'generators/spectator_sport/install_generator' 5 | 6 | describe "install generator", type: :generator do 7 | around do |example| 8 | quiet { setup_example_app } 9 | example.run 10 | teardown_example_app 11 | end 12 | 13 | it 'creates a migration for spectator_sport_events table', skip: true do 14 | quiet do 15 | run_in_example_app 'rails g spectator_sport:install:migrations' 16 | end 17 | 18 | expect(Dir.glob("#{example_app_path}/db/migrate/[0-9]*_create_spectator_sport_events.rb")).not_to be_empty 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /demo/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for(:title) || "SpectatorSport Demo" %> 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= yield :head %> 10 | 11 | 12 | 13 | 14 | <%= stylesheet_link_tag "application" %> 15 | 16 | <%= spectator_sport_script_tags %> 17 | 18 | 19 | 20 | <%= yield %> 21 | 22 |


23 |
24 |

Replay it on the <%= link_to "Spectator Sport Dashboard", spectator_sport_dashboard_path %>

25 | 26 | 27 | -------------------------------------------------------------------------------- /db/migrate/20240923140845_create_spectator_sport_events.rb: -------------------------------------------------------------------------------- 1 | class CreateSpectatorSportEvents < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :spectator_sport_sessions do |t| 4 | t.timestamps 5 | t.string :secure_id, null: false 6 | 7 | t.index [ :secure_id, :created_at ] 8 | end 9 | 10 | create_table :spectator_sport_session_windows do |t| 11 | t.timestamps 12 | t.references :session, null: false 13 | t.string :secure_id, null: false 14 | end 15 | 16 | create_table :spectator_sport_events do |t| 17 | t.timestamps 18 | t.references :session, null: false 19 | t.references :session_window, null: true 20 | t.json :event_data, null: false # TODO: jsonb for postgres ??? 21 | 22 | t.index [ :session_id, :created_at ] 23 | t.index [ :session_window_id, :created_at ] 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /demo/app/controllers/examples_controller.rb: -------------------------------------------------------------------------------- 1 | class ExamplesController < ApplicationController 2 | ExampleError = Class.new(StandardError) 3 | 4 | def index 5 | end 6 | 7 | def new 8 | @resource = FakeModel.new 9 | end 10 | 11 | def create 12 | resource_params = params.require(:resource).permit(:name, :message) 13 | @resource = FakeModel.new(resource_params) 14 | end 15 | 16 | class FakeModel 17 | include ActiveModel::Model 18 | attr_accessor :name, :message 19 | 20 | def self.model_name 21 | ActiveModel::Name.new(self, nil, "Resource") 22 | end 23 | end 24 | 25 | # This enpoints serves for testing behavior when error page is encountered 26 | def error 27 | request.env["action_dispatch.show_detailed_exceptions"] = false 28 | raise ExampleError 29 | end 30 | 31 | private 32 | 33 | def show_detailed_exceptions? 34 | action_name == "error" ? false : super 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/app/controllers/events_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | describe SpectatorSport::EventsController, type: :controller do 6 | render_views 7 | 8 | before do 9 | @routes = SpectatorSport::Engine.routes 10 | end 11 | 12 | describe 'POST #create' do 13 | it 'stores events from rrweb' do 14 | payload = { 15 | "sessionId": "fb1x1aji7p5nljgnydya9kid7vncrzw48ir7d70h", 16 | "windowId": "873h0zhhw66i9f1t36rh5myu8pzwopt676v9s83z", 17 | "events": [ 18 | { "type": 4, "data": { "href": "http://127.0.0.1:3000/", "width": 1512, "height": 770 }, "timestamp": 1727655888530 } 19 | ] 20 | } 21 | 22 | post :create, params: payload 23 | 24 | expect(SpectatorSport::Event.last).to have_attributes( 25 | session: SpectatorSport::Session.find_by(secure_id: payload[:sessionId]) 26 | ) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spectator_sport.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/spectator_sport/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "spectator_sport" 5 | spec.version = SpectatorSport::VERSION 6 | spec.summary = "Summary of SpectatorSport." 7 | spec.description = "Description of SpectatorSport." 8 | 9 | spec.license = "MIT" 10 | spec.authors = [ "Ben Sheldon" ] 11 | spec.email = [ "bensheldon@gmail.com" ] 12 | spec.homepage = "https://github.com/bensheldon/spectator_sport" 13 | spec.metadata = { 14 | "homepage_uri" => spec.homepage, 15 | "source_code_uri" => "https://github.com/bensheldon/spectator_sport", 16 | "changelog_uri" => "https://github.com/bensheldon/spectator_sport/releases" 17 | } 18 | 19 | spec.files = Dir[ 20 | "app/**/*", 21 | "config/**/*", 22 | "lib/**/*", 23 | "README.md", 24 | "LICENSE.txt", 25 | ] 26 | 27 | spec.add_dependency "rails", ">= 7.2.1" 28 | end 29 | -------------------------------------------------------------------------------- /spec/support/selenium.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "action_dispatch/system_testing/browser" 4 | 5 | # Monkeypatch to quiet deprecation notice: 6 | # https://github.com/rails/rails/blob/55c4adeb36eff229972eecbb53723c1b80393091/actionpack/lib/action_dispatch/system_testing/browser.rb#L74 7 | module ActionDispatch 8 | module SystemTesting 9 | class Browser # :nodoc: 10 | silence_redefinition_of_method :resolve_driver_path 11 | def resolve_driver_path(namespace) 12 | # The path method has been deprecated in 4.20.0 13 | namespace::Service.driver_path = if Gem::Version.new(::Selenium::WebDriver::VERSION) >= Gem::Version.new("4.20.0") 14 | ::Selenium::WebDriver::DriverFinder.new(options, namespace::Service.new).driver_path 15 | else 16 | ::Selenium::WebDriver::DriverFinder.path(options, namespace::Service) 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/controllers/spectator_sport/dashboard/session_windows_controller.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | module Dashboard 3 | class SessionWindowsController < ApplicationController 4 | def show 5 | @session_window = SessionWindow.find(params[:id]) 6 | @events = @session_window.events.page_after(nil) 7 | end 8 | 9 | def events 10 | response.content_type = "text/vnd.turbo-stream.html" 11 | return head(:ok) if params[:after_event_id].blank? 12 | 13 | session_window = SessionWindow.find(params[:id]) 14 | previous_event = session_window.events.find_by(id: params[:after_event_id]) 15 | @events = session_window.events.page_after(previous_event) 16 | 17 | render layout: false 18 | end 19 | 20 | def destroy 21 | @session_window = SessionWindow.find(params[:id]) 22 | @session_window.events.delete_all 23 | @session_window.delete 24 | 25 | redirect_to root_path 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /demo/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # 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 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2024 Ben Sheldon 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 | -------------------------------------------------------------------------------- /demo/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 | 9 | module Demo 10 | class Application < Rails::Application 11 | config.load_defaults Rails::VERSION::STRING.to_f 12 | 13 | # For compatibility with applications that use this config 14 | config.action_controller.include_all_helpers = false 15 | 16 | # Please, add to the `ignore` list any other `lib` subdirectories that do 17 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 18 | # Common ones are `templates`, `generators`, or `middleware`, for example. 19 | config.autoload_lib(ignore: %w[assets tasks]) 20 | 21 | # Configuration for the application, engines, and railties goes here. 22 | # 23 | # These settings can be overridden in specific environments using the files 24 | # in config/environments, which are processed later. 25 | # 26 | # config.time_zone = "Central Time (US & Canada)" 27 | # config.eager_load_paths << Rails.root.join("extras") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /demo/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, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /demo/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | APP_NAME = "demo" 6 | 7 | def system!(*args) 8 | system(*args, exception: true) 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 | 34 | # puts "\n== Configuring puma-dev ==" 35 | # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}" 36 | # system "curl -Is https://#{APP_NAME}.test/up | head -n 1" 37 | end 38 | -------------------------------------------------------------------------------- /demo/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: storage/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: storage/test.sqlite3 22 | 23 | 24 | # SQLite3 write its data on the local filesystem, as such it requires 25 | # persistent disks. If you are deploying to a managed service, you should 26 | # make sure it provides disk persistence, as many don't. 27 | # 28 | # Similarly, if you deploy your application as a Docker container, you must 29 | # ensure the database is located in a persisted volume. 30 | production: 31 | <<: *default 32 | # database: path/to/persistent/storage/production.sqlite3 33 | 34 | demo: 35 | adapter: postgresql 36 | database: "spectator_sport_demo" 37 | encoding: unicode 38 | pool: 50 39 | url: <%= ENV['DATABASE_URL'] %> 40 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /app/controllers/spectator_sport/events_controller.rb: -------------------------------------------------------------------------------- 1 | module SpectatorSport 2 | class EventsController < ApplicationController 3 | skip_before_action :verify_authenticity_token 4 | 5 | def index 6 | end 7 | 8 | def create 9 | data = if params.key?(:sessionId) && params.key?(:windowId) && params.key?(:events) 10 | params.slice(:sessionId, :windowId, :events).stringify_keys 11 | else 12 | # beacon sends JSON in the request body 13 | JSON.parse(request.body.read).slice("sessionId", "windowId", "events") 14 | end 15 | 16 | session_secure_id = data["sessionId"] 17 | window_secure_id = data["windowId"] 18 | events = data["events"] 19 | 20 | session = Session.find_or_create_by(secure_id: session_secure_id) 21 | window = SessionWindow.find_or_create_by(secure_id: window_secure_id, session: session) 22 | 23 | records_data = events.map do |event| 24 | { session_id: session.id, session_window_id: window.id, event_data: event, created_at: Time.at(event["timestamp"].to_f / 1000.0) } 25 | end.to_a 26 | Event.insert_all(records_data) 27 | 28 | last_event = records_data.max_by { |data| data[:created_at] } 29 | window.update(updated_at: last_event[:created_at]) if last_event 30 | 31 | render json: { message: "ok" } 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/frontend/spectator_sport/dashboard/modules/theme_controller.js: -------------------------------------------------------------------------------- 1 | // hello_controller.js 2 | import { Controller } from "stimulus" 3 | export default class extends Controller { 4 | static targets = [ "dropdown", "button" ] 5 | 6 | connect() { 7 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 8 | const theme = localStorage.getItem('spectator_sport-theme'); 9 | if (!["light", "dark"].includes(theme)) { 10 | this.setTheme(this.autoTheme()); 11 | } 12 | }); 13 | 14 | this.setTheme(this.getStoredTheme() || 'light'); 15 | } 16 | 17 | change(event) { 18 | const theme = event.params.value; 19 | localStorage.setItem('spectator_sport-theme', theme); 20 | this.setTheme(theme); 21 | } 22 | 23 | setTheme(theme) { 24 | document.documentElement.setAttribute('data-bs-theme', theme === 'auto' ? this.autoTheme() : theme); 25 | 26 | this.buttonTargets.forEach((button) => { 27 | button.classList.remove('active'); 28 | if (button.dataset.themeValueParam === theme) { 29 | button.classList.add('active'); 30 | } 31 | }); 32 | 33 | const svg = this.buttonTargets.filter(b => b.matches(".active"))[0]?.querySelector('svg'); 34 | this.dropdownTarget.querySelector('svg').outerHTML = svg.outerHTML; 35 | } 36 | 37 | getStoredTheme() { 38 | return localStorage.getItem('spectator_sport-theme'); 39 | } 40 | 41 | autoTheme() { 42 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Ruby 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | bundler-cache: true 19 | 20 | - name: Lint code for consistent style 21 | run: bin/rubocop -f github 22 | 23 | test: 24 | runs-on: ubuntu-latest 25 | 26 | # services: 27 | # redis: 28 | # image: redis 29 | # ports: 30 | # - 6379:6379 31 | # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 32 | steps: 33 | - name: Install packages 34 | run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libjemalloc2 libvips sqlite3 35 | 36 | - name: Checkout code 37 | uses: actions/checkout@v4 38 | 39 | - name: Set up Ruby 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | bundler-cache: true 43 | 44 | - name: Run tests 45 | env: 46 | RAILS_ENV: test 47 | run: bin/rails db:test:prepare && bin/rspec 48 | 49 | - name: Keep screenshots from failed system tests 50 | uses: actions/upload-artifact@v4 51 | if: failure() 52 | with: 53 | name: screenshots 54 | path: ${{ github.workspace }}/tmp/screenshots 55 | if-no-files-found: ignore 56 | 57 | -------------------------------------------------------------------------------- /app/frontend/spectator_sport/dashboard/modules/player_controller.js: -------------------------------------------------------------------------------- 1 | // hello_controller.js 2 | import { Controller } from "stimulus" 3 | import { Player } from 'rrweb-player'; 4 | 5 | export default class extends Controller { 6 | static values = { 7 | events: { type: Array, default: [] }, 8 | } 9 | 10 | static targets = [ "player", "events" ] 11 | 12 | connect() { 13 | this.player = new Player({ 14 | target: this.playerTarget, 15 | props: { 16 | events: this.eventsValue, 17 | liveMode: true, 18 | } 19 | }); 20 | 21 | if (window.location.hash) { 22 | const seconds = parseInt(window.location.hash.substring(1)); 23 | this.player.getReplayer().play(seconds * 1000); 24 | } 25 | 26 | this.player.addEventListener('ui-update-current-time', (event) => { 27 | const seconds = parseInt(event.payload / 1000); 28 | window.location.hash = seconds; 29 | }); 30 | 31 | const playerElement = this.playerTarget.getElementsByClassName("rr-player")[0]; 32 | playerElement.style.width = "100%"; 33 | playerElement.style.height = null; 34 | playerElement.style.float = "none" 35 | playerElement.style["border-radius"] = "inherit"; 36 | playerElement.style["box-shadow"] = "none"; 37 | } 38 | 39 | eventsTargetConnected(element) { 40 | if (!this.player) return; 41 | 42 | const events = JSON.parse(element.dataset.events); 43 | events.forEach(event => { 44 | this.player.addEvent(event); 45 | }); 46 | 47 | element.remove(); 48 | } 49 | 50 | disconnect() { 51 | this.player?.$destroy(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /spec/support/system.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "selenium-webdriver" 4 | 5 | Capybara.default_max_wait_time = 2 6 | Capybara.server = :puma, { Silent: true } 7 | Capybara.disable_animation = true # injects CSP-incompatible CSS and JS 8 | 9 | module SystemTestHelpers 10 | [ 11 | :accept_alert, 12 | :dismiss_alert, 13 | :accept_confirm, 14 | :dismiss_confirm, 15 | :accept_prompt, 16 | :dismiss_prompt, 17 | :accept_modal, 18 | :dismiss_modal 19 | ].each do |driver_method| 20 | define_method(driver_method) do |text = nil, **options, &blk| 21 | super(text, **options, &blk) 22 | rescue Capybara::NotSupportedByDriverError 23 | blk.call 24 | end 25 | end 26 | end 27 | 28 | RSpec.configure do |config| 29 | config.include ActionView::RecordIdentifier, type: :system 30 | config.include SystemTestHelpers, type: :system 31 | 32 | config.before(:each, type: :system) do |example| 33 | ActiveRecord::Base.connection.disable_query_cache! 34 | 35 | if ENV['SHOW_BROWSER'] 36 | example.metadata[:js] = true 37 | driven_by :selenium, using: :chrome, screen_size: [ 1024, 800 ] 38 | else 39 | driven_by :rack_test 40 | end 41 | end 42 | 43 | config.before(:each, :js, type: :system) do 44 | # Chrome's no-sandbox option is required for running in Docker 45 | driven_by :selenium, using: (ENV['SHOW_BROWSER'] ? :chrome : :headless_chrome), screen_size: [ 1024, 800 ] do |driver_options| 46 | driver_options.add_argument("--disable-dev-shm-usage") 47 | driver_options.add_argument("--no-sandbox") 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/views/layouts/spectator_sport/dashboard/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Spectator sport 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= yield :head %> 9 | 10 | <%# Do not use asset tag helpers to avoid paths being overriden by config.asset_host %> 11 | <%= tag.link rel: "stylesheet", href: frontend_static_path(:bootstrap, format: :css, locale: nil), nonce: content_security_policy_nonce %> 12 | <%= tag.link rel: "stylesheet", href: frontend_static_path(:"rrweb-player", format: :css, locale: nil), nonce: content_security_policy_nonce %> 13 | <%= tag.link rel: "stylesheet", href: frontend_static_path(:style, format: :css, locale: nil), nonce: content_security_policy_nonce %> 14 | <%= tag.script "", src: frontend_static_path(:bootstrap, format: :js, locale: nil), nonce: content_security_policy_nonce %> 15 | <%= tag.script "", src: frontend_static_path(:es_module_shims, format: :js, locale: nil), async: true, nonce: content_security_policy_nonce %> 16 | <% importmaps = SpectatorSport::Dashboard::FrontendsController.js_modules.keys.index_with { |module_name| frontend_module_path(module_name, format: :js, locale: nil) } %> 17 | <%= tag.script({ imports: importmaps }.to_json.html_safe, type: "importmap", nonce: content_security_policy_nonce) %> 18 | <%= tag.script "", type: "module", nonce: content_security_policy_nonce do %> import "application"; <% end %> 19 | 20 | 21 |
22 | <%= render "spectator_sport/shared/navbar" %> 23 | <%= yield %> 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /demo/config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | 5 | # Puma starts a configurable number of processes (workers) and each process 6 | # serves each request in a thread from an internal thread pool. 7 | # 8 | # The ideal number of threads per worker depends both on how much time the 9 | # application spends waiting for IO operations and on how much you wish to 10 | # to prioritize throughput over latency. 11 | # 12 | # As a rule of thumb, increasing the number of threads will increase how much 13 | # traffic a given process can handle (throughput), but due to CRuby's 14 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 15 | # response time (latency) of the application. 16 | # 17 | # The default is set to 3 threads as it's deemed a decent compromise between 18 | # throughput and latency for the average Rails application. 19 | # 20 | # Any libraries that use a connection pool or another resource pool should 21 | # be configured to provide at least as many connections as the number of 22 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 23 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 24 | threads threads_count, threads_count 25 | 26 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 27 | port ENV.fetch("PORT", 3000) 28 | 29 | # Allow puma to be restarted by `bin/rails restart` command. 30 | plugin :tmp_restart 31 | 32 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 33 | # In other environments, only set the PID file if requested. 34 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 35 | -------------------------------------------------------------------------------- /demo/public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Your browser is not supported (406) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

Your browser is not supported.

62 |

Please upgrade your browser to continue.

63 |
64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /demo/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 |

We're sorry, but something went wrong.

64 |
65 |

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

66 |
67 | 68 | 69 | -------------------------------------------------------------------------------- /demo/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 |

The change you wanted was rejected.

64 |

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

65 |
66 |

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

67 |
68 | 69 | 70 | -------------------------------------------------------------------------------- /app/frontend/spectator_sport/dashboard/style.css: -------------------------------------------------------------------------------- 1 | .tooltip { 2 | position: absolute; 3 | z-index: 1; 4 | padding: 5px; 5 | background: rgba(0, 0, 0, 0.3); 6 | opacity: 1; 7 | border-radius: 3px; 8 | text-align: center; 9 | pointer-events: none; 10 | color: white; 11 | transition: opacity .1s ease-out; 12 | } 13 | 14 | .tooltip.tooltip-hidden { 15 | opacity: 0; 16 | } 17 | 18 | .ct-label.ct-horizontal { 19 | white-space: nowrap; 20 | } 21 | 22 | .chart-wrapper { 23 | position: relative; 24 | height: 200px; 25 | } 26 | 27 | .legend-item-color-box { 28 | display: inline-block; 29 | flex-shrink: 0; 30 | height: 20px; 31 | width: 20px; 32 | border-style: solid; 33 | margin-right: 3px; 34 | } 35 | 36 | #chart-legend-container { 37 | height: 200px; 38 | } 39 | 40 | /* Break out of a container */ 41 | .break-out { 42 | width:100vw; 43 | position:relative; 44 | left:calc(-1 * (100vw - 100%)/2); 45 | } 46 | 47 | .toast-container { 48 | z-index: 1; 49 | } 50 | 51 | .btn-outline-secondary { 52 | border-color: #ced4da; /* $gray-400 */ 53 | } 54 | 55 | .min-w-auto { 56 | min-width: auto; 57 | } 58 | 59 | .w-fit-content { 60 | width: fit-content 61 | } 62 | 63 | .svg-icon { 64 | height: 1rem; 65 | width: 1rem; 66 | } 67 | 68 | /*Style table within card*/ 69 | .card > table:first-child > thead > tr:first-child > th:first-child { 70 | border-top-left-radius: var(--bs-card-inner-border-radius); 71 | } 72 | .card > table:first-child > thead > tr:first-child > th:last-child { 73 | border-top-right-radius: var(--bs-card-inner-border-radius); 74 | } 75 | .card > table:last-child > tbody > tr:last-child > td:first-child { 76 | border-bottom-left-radius: var(--bs-card-inner-border-radius); 77 | } 78 | .card > table:last-child > tbody > tr:last-child > td:last-child { 79 | border-bottom-right-radius: var(--bs-card-inner-border-radius); 80 | } 81 | .card > table { 82 | margin-bottom: var(--bs-card-border-width, 1px); 83 | } 84 | .card > table > tbody > tr:last-child > td { 85 | border-bottom: none; 86 | box-shadow: none; 87 | } 88 | -------------------------------------------------------------------------------- /demo/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 |
62 |
63 |

The page you were looking for doesn't exist.

64 |

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

65 |
66 |

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

67 |
68 | 69 | 70 | -------------------------------------------------------------------------------- /app/views/spectator_sport/dashboard/dashboards/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if @session_windows.to_a.any? %> 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | <% @session_windows.each do |session_window| %> 17 | 18 | 19 | 20 | 36 | 37 | <% end %> 38 | 39 |
List of screen recordings
RecordingSession IDUpdatedCreatedActions
<%= link_to "Session (Window ##{session_window.id})", session_window_path(session_window.id) %><%= session_window.session_id %><%= tag.time(time_ago_in_words(session_window.updated_at), datetime: session_window.updated_at, title: session_window.updated_at) %> 21 | <%= tag.time(time_ago_in_words(session_window.created_at), datetime: session_window.created_at, title: session_window.created_at) %> 22 | 23 | 27 | 35 |
40 |
41 | <% else %> 42 | No recordings found. 43 | <% end %> 44 |
45 | -------------------------------------------------------------------------------- /spec/support/shell_out.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'open3' 4 | 5 | class ShellOut 6 | WaitTimeout = Class.new(StandardError) 7 | KILL_TIMEOUT = 5 8 | PROCESS_EXIT = "[PROCESS EXIT]" 9 | 10 | def self.command(command, env: {}, &block) 11 | new.command(command, env: env, &block) 12 | end 13 | 14 | attr_reader :output 15 | 16 | def initialize 17 | @output = Concurrent::Array.new 18 | end 19 | 20 | def command(command, env: {}) 21 | all_env = ENV.to_h.merge(env) 22 | Open3.popen3(all_env, command, chdir: Rails.root) do |stdin, stdout, stderr, wait_thr| 23 | pid = wait_thr.pid 24 | stdin.close 25 | 26 | stdout_future = Concurrent::Promises.future(stdout, @output) do |fstdout, foutput| 27 | loop do 28 | line = fstdout.gets 29 | break unless line 30 | 31 | Rails.logger.debug { "STDOUT: #{line}" } 32 | foutput << line 33 | end 34 | end 35 | stderr_future = Concurrent::Promises.future(stderr, @output) do |fstderr, foutput| 36 | loop do 37 | line = fstderr.gets 38 | break unless line 39 | 40 | Rails.logger.debug { "STDERR: #{line}" } 41 | foutput << line 42 | end 43 | end 44 | 45 | begin 46 | yield(self) 47 | ensure 48 | begin 49 | Rails.logger.debug { "Sending TERM to #{pid}" } 50 | Process.kill('TERM', pid) 51 | 52 | Concurrent::Promises.future(pid, @output) do |fpid| 53 | sleep 5 54 | Process.kill('KILL', fpid) 55 | rescue Errno::ECHILD, Errno::ESRCH 56 | nil 57 | else 58 | Rails.logger.debug { "TERM unsuccessful, sent KILL to #{pid}" } 59 | end 60 | 61 | Process.wait(pid) 62 | rescue Errno::ECHILD, Errno::ESRCH 63 | @output << PROCESS_EXIT 64 | end 65 | end 66 | status = wait_thr.value 67 | stdout_future.value 68 | stderr_future.value 69 | 70 | Rails.logger.debug { "Command finished: #{status}" } 71 | @output 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /demo/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.2].define(version: 2024_09_23_140845) do 14 | create_table "spectator_sport_events", force: :cascade do |t| 15 | t.datetime "created_at", null: false 16 | t.datetime "updated_at", null: false 17 | t.integer "session_id", null: false 18 | t.integer "session_window_id" 19 | t.json "event_data", null: false 20 | t.index [ "session_id", "created_at" ], name: "index_spectator_sport_events_on_session_id_and_created_at" 21 | t.index [ "session_id" ], name: "index_spectator_sport_events_on_session_id" 22 | t.index [ "session_window_id", "created_at" ], name: "idx_on_session_window_id_created_at_f1aab0a880" 23 | t.index [ "session_window_id" ], name: "index_spectator_sport_events_on_session_window_id" 24 | end 25 | 26 | create_table "spectator_sport_session_windows", force: :cascade do |t| 27 | t.datetime "created_at", null: false 28 | t.datetime "updated_at", null: false 29 | t.integer "session_id", null: false 30 | t.string "secure_id", null: false 31 | t.index [ "session_id" ], name: "index_spectator_sport_session_windows_on_session_id" 32 | end 33 | 34 | create_table "spectator_sport_sessions", force: :cascade do |t| 35 | t.datetime "created_at", null: false 36 | t.datetime "updated_at", null: false 37 | t.string "secure_id", null: false 38 | t.index [ "secure_id", "created_at" ], name: "index_spectator_sport_sessions_on_secure_id_and_created_at" 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/controllers/spectator_sport/dashboard/frontends_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SpectatorSport 4 | module Dashboard 5 | class FrontendsController < ActionController::Base # rubocop:disable Rails/ApplicationController 6 | protect_from_forgery with: :exception 7 | skip_after_action :verify_same_origin_request, raise: false 8 | 9 | def self.asset_path(path) 10 | Engine.root.join("app/frontend/spectator_sport/dashboard", path) 11 | end 12 | 13 | STATIC_ASSETS = { 14 | css: { 15 | bootstrap: asset_path("vendor/bootstrap/bootstrap.min.css"), 16 | "rrweb-player": asset_path("vendor/rrweb-player/rrweb-player.min.css"), 17 | style: asset_path("style.css") 18 | }, 19 | js: { 20 | bootstrap: asset_path("vendor/bootstrap/bootstrap.bundle.min.js"), 21 | es_module_shims: asset_path("vendor/es_module_shims.js") 22 | }, 23 | svg: { 24 | icons: asset_path("icons.svg") 25 | } 26 | }.freeze 27 | 28 | MODULE_OVERRIDES = { 29 | application: asset_path("application.js"), 30 | "rrweb-player": asset_path("vendor/rrweb-player/rrweb-player.min.js"), 31 | stimulus: asset_path("vendor/stimulus.js"), 32 | turbo: asset_path("vendor/turbo.js") 33 | }.freeze 34 | 35 | def self.js_modules 36 | @_js_modules ||= asset_path("modules").children.select(&:file?).each_with_object({}) do |file, modules| 37 | key = File.basename(file.basename.to_s, ".js").to_sym 38 | modules[key] = file 39 | end.merge(MODULE_OVERRIDES) 40 | end 41 | 42 | before_action do 43 | expires_in 1.year, public: true 44 | end 45 | 46 | def static 47 | file_path = STATIC_ASSETS.dig(params[:format]&.to_sym, params[:id]&.to_sym) || raise(ActionController::RoutingError, "Not Found") 48 | send_file file_path, disposition: "inline" 49 | end 50 | 51 | def module 52 | raise(ActionController::RoutingError, "Not Found") if params[:format] != "js" 53 | 54 | file_path = self.class.js_modules[params[:id]&.to_sym] || raise(ActionController::RoutingError, "Not Found") 55 | send_file file_path, disposition: "inline" 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/support/pre_documentation_formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Support.require_rspec_core "formatters/base_text_formatter" 4 | RSpec::Support.require_rspec_core "formatters/console_codes" 5 | 6 | class PreDocumentationFormatter < RSpec::Core::Formatters::BaseTextFormatter 7 | RSpec::Core::Formatters.register self, :example_started, :example_group_started, :example_group_finished, 8 | :example_passed, :example_pending, :example_failed 9 | 10 | def initialize(output) 11 | super 12 | @group_level = 0 13 | @failed_examples = [] 14 | end 15 | 16 | def example_group_started(notification) 17 | output.puts if @group_level == 0 18 | output.puts "#{current_indentation}#{notification.group.description.strip}" 19 | 20 | @group_level += 1 21 | end 22 | 23 | def example_group_finished(_notification) 24 | @group_level -= 1 if @group_level > 0 25 | end 26 | 27 | def example_passed(passed) 28 | output.puts passed_output(passed.example) 29 | end 30 | 31 | def example_pending(pending) 32 | output.puts pending_output(pending.example, 33 | pending.example.execution_result.pending_message) 34 | end 35 | 36 | def example_failed(failure) 37 | @failed_examples << failure.example 38 | output.puts failure_output(failure.example) 39 | output.puts failure.fully_formatted(@failed_examples.size) 40 | end 41 | 42 | def example_started(notification) 43 | output.puts "#{current_indentation}RUNNING: #{notification.example.description}" 44 | end 45 | 46 | private 47 | 48 | def passed_output(example) 49 | RSpec::Core::Formatters::ConsoleCodes.wrap("#{current_indentation}#{example.description.strip}", :success) 50 | end 51 | 52 | def pending_output(example, message) 53 | RSpec::Core::Formatters::ConsoleCodes.wrap("#{current_indentation}#{example.description.strip} (PENDING: #{message})", :pending) 54 | end 55 | 56 | def failure_output(example) 57 | RSpec::Core::Formatters::ConsoleCodes.wrap("#{current_indentation}#{example.description.strip} (FAILED - #{next_failure_index})", :failure) 58 | end 59 | 60 | def next_failure_index 61 | @next_failure_index ||= 0 62 | @next_failure_index += 1 63 | end 64 | 65 | def current_indentation 66 | ' ' * @group_level 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /app/views/spectator_sport/shared/_navbar.erb: -------------------------------------------------------------------------------- 1 | 64 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is copied to spec/ when you run 'rails generate rspec:install' 4 | require 'spec_helper' 5 | ENV['RAILS_ENV'] ||= 'test' 6 | 7 | require File.expand_path('../demo/config/environment', __dir__) 8 | 9 | # Prevent database truncation if the environment is production 10 | abort("The Rails environment is running in production mode!") if Rails.env.production? 11 | require 'rspec/rails' 12 | # Add additional requires below this line. Rails is not loaded until this point! 13 | 14 | # Requires supporting ruby files with custom matchers and macros, etc, in 15 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 16 | # run as spec files by default. This means that files in spec/support that end 17 | # in _spec.rb will both be required and run as specs, causing the specs to be 18 | # run twice. It is recommended that you do not name files matching this glob to 19 | # end with _spec.rb. You can configure this pattern with the --pattern 20 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 21 | # 22 | # The following line is provided for convenience purposes. It has the downside 23 | # of increasing the boot-up time by auto-requiring all files in the support 24 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 25 | # require only the support files necessary. 26 | # 27 | Dir[File.join(File.dirname(__FILE__), 'support', '**', '*.rb')].sort.each { |f| require f } 28 | 29 | RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = 10_000 30 | 31 | # Checks for pending migrations and applies them before tests are run. 32 | # If you are not using ActiveRecord, you can remove these lines. 33 | begin 34 | ActiveRecord::Migration.maintain_test_schema! 35 | rescue ActiveRecord::PendingMigrationError => e 36 | puts e.to_s.strip 37 | exit 1 38 | end 39 | RSpec.configure do |config| 40 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 41 | if config.respond_to? :fixture_paths 42 | config.fixture_paths = Rails.root.join("spec/fixtures") 43 | else 44 | config.fixture_path = Rails.root.join("spec/fixtures") 45 | end 46 | 47 | # RSpec Rails can automatically mix in different behaviours to your tests 48 | # based on their file location, for example enabling you to call `get` and 49 | # `post` in specs under `spec/controllers`. 50 | # 51 | # You can disable this behaviour by removing the line below, and instead 52 | # explicitly tag your specs with their type, e.g.: 53 | # 54 | # RSpec.describe UsersController, :type => :controller do 55 | # # ... 56 | # end 57 | # 58 | # The different available types are documented in the features, such as in 59 | # https://relishapp.com/rspec/rspec-rails/docs 60 | config.infer_spec_type_from_file_location! 61 | 62 | # Filter lines from Rails gems in backtraces. 63 | config.filter_rails_from_backtrace! 64 | # arbitrary gems may also be filtered via: 65 | # config.filter_gems_from_backtrace("gem name") 66 | end 67 | -------------------------------------------------------------------------------- /demo/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.enable_reloading = true 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 = { "Cache-Control" => "public, max-age=#{2.days.to_i}" } 28 | else 29 | config.action_controller.perform_caching = false 30 | 31 | config.cache_store = :null_store 32 | end 33 | 34 | # Store uploaded files on the local file system (see config/storage.yml for options). 35 | config.active_storage.service = :local 36 | 37 | # Don't care if the mailer can't send. 38 | config.action_mailer.raise_delivery_errors = false 39 | 40 | # Disable caching for Action Mailer templates even if Action Controller 41 | # caching is enabled. 42 | config.action_mailer.perform_caching = false 43 | 44 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 45 | 46 | # Print deprecation notices to the Rails logger. 47 | config.active_support.deprecation = :log 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 | # Raise an error on page load if there are pending migrations. 56 | config.active_record.migration_error = :page_load 57 | 58 | # Highlight code that triggered database queries in logs. 59 | config.active_record.verbose_query_logs = true 60 | 61 | # Highlight code that enqueued background job in logs. 62 | config.active_job.verbose_enqueue_logs = true 63 | 64 | # Suppress logger output for asset requests. 65 | config.assets.quiet = true 66 | 67 | # Raises error for missing translations. 68 | # config.i18n.raise_on_missing_translations = true 69 | 70 | # Annotate rendered view with file names. 71 | config.action_view.annotate_rendered_view_with_filenames = true 72 | 73 | # Uncomment if you wish to allow Action Cable access from any origin. 74 | # config.action_cable.disable_request_forgery_protection = true 75 | 76 | # Raise error when a before_action's only/except options reference missing actions. 77 | config.action_controller.raise_on_missing_callback_actions = true 78 | end 79 | -------------------------------------------------------------------------------- /demo/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 | # While tests run files are not watched, reloading is not necessary. 12 | config.enable_reloading = false 13 | 14 | # Eager loading loads your entire application. When running a single test locally, 15 | # this is usually not necessary, and can slow down your test suite. However, it's 16 | # recommended that you enable it in continuous integration systems to ensure eager 17 | # loading is working properly before deploying your code. 18 | config.eager_load = ENV["CI"].present? 19 | 20 | # Configure public file server for tests with Cache-Control for performance. 21 | config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" } 22 | 23 | # Show full error reports and disable caching. 24 | config.consider_all_requests_local = true 25 | config.action_controller.perform_caching = false 26 | config.cache_store = :null_store 27 | 28 | # Render exception templates for rescuable exceptions and raise for other exceptions. 29 | config.action_dispatch.show_exceptions = :rescuable 30 | 31 | # Disable request forgery protection in test environment. 32 | config.action_controller.allow_forgery_protection = false 33 | 34 | # Store uploaded files on the local file system in a temporary directory. 35 | config.active_storage.service = :test 36 | 37 | # Disable caching for Action Mailer templates even if Action Controller 38 | # caching is enabled. 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 | # Unlike controllers, the mailer instance doesn't have any context about the 47 | # incoming request so you'll need to provide the :host parameter yourself. 48 | config.action_mailer.default_url_options = { host: "www.example.com" } 49 | 50 | # Print deprecation notices to the stderr. 51 | config.active_support.deprecation = :stderr 52 | 53 | # Raise exceptions for disallowed deprecations. 54 | config.active_support.disallowed_deprecation = :raise 55 | 56 | # Tell Active Support which deprecation messages to disallow. 57 | config.active_support.disallowed_deprecation_warnings = [] 58 | 59 | # Raises error for missing translations. 60 | # config.i18n.raise_on_missing_translations = true 61 | 62 | # Annotate rendered view with file names. 63 | # config.action_view.annotate_rendered_view_with_filenames = true 64 | 65 | # Raise error when a before_action's only/except options reference missing actions. 66 | config.action_controller.raise_on_missing_callback_actions = true 67 | end 68 | -------------------------------------------------------------------------------- /spec/support/example_app_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | 5 | module ExampleAppHelper 6 | def setup_example_app 7 | FileUtils.rm_rf(example_app_path) 8 | 9 | # Rails will not install within a directory containing `bin/rails` 10 | Rails.root.join("../bin/rails").rename(Rails.root.join("../bin/_rails")) if Rails.root.join("../bin/rails").exist? 11 | 12 | root_path = example_app_path.join('..') 13 | FileUtils.cd(root_path) do 14 | system("rails new #{app_name} -d postgresql --no-assets --skip-action-text --skip-action-mailer --skip-action-mailbox --skip-action-cable --skip-git --skip-sprockets --skip-listen --skip-javascript --skip-turbolinks --skip-system-test --skip-test-unit --skip-bootsnap --skip-spring --skip-active-storage") 15 | end 16 | 17 | FileUtils.rm_rf("#{example_app_path}/config/initializers/assets.rb") 18 | FileUtils.cp(::Rails.root.join('config/database.yml'), "#{example_app_path}/config/database.yml") 19 | 20 | File.open("#{example_app_path}/Gemfile", 'a') do |f| 21 | f.puts 'gem "spectator_sport", path: "#{File.dirname(__FILE__)}/../../../"' # rubocop:disable Lint/InterpolationCheck 22 | end 23 | end 24 | 25 | def teardown_example_app 26 | Rails.root.join("../bin/_rails").rename(Rails.root.join("../bin/rails")) 27 | FileUtils.rm_rf(example_app_path) 28 | end 29 | 30 | def run_in_example_app(*args) 31 | FileUtils.cd(example_app_path) do 32 | system(*args) || raise("Command #{args} failed") 33 | end 34 | end 35 | 36 | def run_in_demo_app(*args) 37 | FileUtils.cd(Rails.root) do 38 | system(*args) || raise("Command #{args} failed") 39 | end 40 | end 41 | 42 | def within_example_app 43 | # Will be running database migrations from the newly created Example App 44 | # but doing so in the existing database. This resets the database so that 45 | # newly created migrations can be run, then resets it back. 46 | # 47 | # Ideally this would happen in a different database, but that seemed like 48 | # a lot of work to do in Github Actions. 49 | models = [ 50 | SpectatorSport::Event, 51 | SpectatorSport::Session, 52 | SpectatorSport::SessionWindow 53 | ] 54 | quiet do 55 | models.each do |model| 56 | table_name = model.table_name 57 | ActiveRecord::Migration.drop_table(table_name) if ActiveRecord::Base.connection.table_exists?(table_name) 58 | end 59 | ActiveRecord::Base.connection.execute("TRUNCATE schema_migrations") 60 | 61 | setup_example_app 62 | run_in_demo_app("bin/rails db:environment:set RAILS_ENV=test") 63 | models.each(&:reset_column_information) 64 | end 65 | 66 | yield 67 | ensure 68 | quiet do 69 | teardown_example_app 70 | 71 | tables.each do |table_name| 72 | ActiveRecord::Migration.drop_table(table_name) if ActiveRecord::Base.connection.table_exists?(table_name) 73 | end 74 | ActiveRecord::Base.connection.execute("TRUNCATE schema_migrations") 75 | 76 | run_in_demo_app("bin/rails db:schema:load db:environment:set RAILS_ENV=test") 77 | models.each(&:reset_column_information) 78 | end 79 | end 80 | 81 | def example_app_path 82 | Rails.root.join('../tmp', app_name) 83 | end 84 | 85 | def app_name 86 | 'example_app' 87 | end 88 | end 89 | 90 | RSpec.configure { |c| c.include ExampleAppHelper, type: :generator } 91 | -------------------------------------------------------------------------------- /app/frontend/spectator_sport/dashboard/vendor/rrweb-player/rrweb-player.min.css: -------------------------------------------------------------------------------- 1 | // https://cdn.jsdelivr.net/npm/rrweb-player@2.0.0-alpha.18/dist/style.min.css 2 | .switch.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{height:1em;display:flex;align-items:center}.switch.disabled.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{opacity:.5}.label.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{margin:0 8px}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7.svelte-a6h7w7{position:absolute;opacity:0}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7{width:2em;height:1em;position:relative;cursor:pointer;display:block}.switch.disabled.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7{cursor:not-allowed}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7:before{content:"";position:absolute;width:2em;height:1em;left:.1em;transition:background .1s ease;background:#4950f680;border-radius:50px}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7:after{content:"";position:absolute;width:1em;height:1em;border-radius:50px;left:0;transition:all .2s ease;box-shadow:0 2px 5px #0000004d;background:#fcfff4;animation:switch-off .2s ease-out;z-index:2}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7:checked+label.svelte-a6h7w7:before{background:#4950f6}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7:checked+label.svelte-a6h7w7:after{animation:switch-on .2s ease-out;left:1.1em}.rr-controller.svelte-189zk2r.svelte-189zk2r{width:100%;height:80px;background:#fff;display:flex;flex-direction:column;justify-content:space-around;align-items:center;border-radius:0 0 5px 5px}.rr-timeline.svelte-189zk2r.svelte-189zk2r{width:80%;display:flex;align-items:center}.rr-timeline__time.svelte-189zk2r.svelte-189zk2r{display:inline-block;width:100px;text-align:center;color:#11103e}.rr-progress.svelte-189zk2r.svelte-189zk2r{flex:1;height:12px;background:#eee;position:relative;border-radius:3px;cursor:pointer;box-sizing:border-box;border-top:solid 4px #fff;border-bottom:solid 4px #fff}.rr-progress.disabled.svelte-189zk2r.svelte-189zk2r{cursor:not-allowed}.rr-progress__step.svelte-189zk2r.svelte-189zk2r{height:100%;position:absolute;left:0;top:0;background:#e0e1fe}.rr-progress__handler.svelte-189zk2r.svelte-189zk2r{width:20px;height:20px;border-radius:10px;position:absolute;top:2px;transform:translate(-50%,-50%);background:#4950f6}.rr-controller__btns.svelte-189zk2r.svelte-189zk2r{display:flex;align-items:center;justify-content:center;font-size:13px}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r{width:32px;height:32px;display:flex;padding:0;align-items:center;justify-content:center;background:none;border:none;border-radius:50%;cursor:pointer}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r:active{background:#e0e1fe}.rr-controller__btns.svelte-189zk2r button.active.svelte-189zk2r{color:#fff;background:#4950f6}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r:disabled{cursor:not-allowed}.replayer-wrapper{position:relative}.replayer-mouse{position:absolute;width:20px;height:20px;transition:left .05s linear,top .05s linear;background-size:contain;background-position:center center;background-repeat:no-repeat;background-image:url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg==);border-color:transparent}.replayer-mouse:after{content:"";display:inline-block;width:20px;height:20px;background:#4950f6;border-radius:100%;transform:translate(-50%,-50%);opacity:.3}.replayer-mouse.active:after{animation:click .2s ease-in-out 1}.replayer-mouse.touch-device{background-image:none;width:70px;height:70px;border-width:4px;border-style:solid;border-radius:100%;margin-left:-37px;margin-top:-37px;border-color:#4950f600;transition:left 0s linear,top 0s linear,border-color .2s ease-in-out}.replayer-mouse.touch-device.touch-active{border-color:#4950f6;transition:left .25s linear,top .25s linear,border-color .2s ease-in-out}.replayer-mouse.touch-device:after{opacity:0}.replayer-mouse.touch-device.active:after{animation:touch-click .2s ease-in-out 1}.replayer-mouse-tail{position:absolute;pointer-events:none}@keyframes click{0%{opacity:.3;width:20px;height:20px}50%{opacity:.5;width:10px;height:10px}}@keyframes touch-click{0%{opacity:0;width:20px;height:20px}50%{opacity:.5;width:10px;height:10px}}.rr-player{position:relative;background:#fff;float:left;border-radius:5px;box-shadow:0 24px 48px #11103e1f}.rr-player__frame{overflow:hidden}.replayer-wrapper{float:left;clear:both;transform-origin:top left;left:50%;top:50%}.replayer-wrapper>iframe{border:none} 3 | -------------------------------------------------------------------------------- /demo/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.enable_reloading = false 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 ENV["RAILS_MASTER_KEY"], config/master.key, or an environment 20 | # key such as config/credentials/production.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 `public/`, relying on NGINX/Apache to do so instead. 24 | # config.public_file_server.enabled = false 25 | 26 | # Compress CSS using a preprocessor. 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fall back to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 33 | # config.asset_host = "http://assets.example.com" 34 | 35 | # Specifies the header that your server uses for sending files. 36 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 37 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 38 | 39 | # Store uploaded files on the local file system (see config/storage.yml for options). 40 | config.active_storage.service = :local 41 | 42 | # Mount Action Cable outside main process or domain. 43 | # config.action_cable.mount_path = nil 44 | # config.action_cable.url = "wss://example.com/cable" 45 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 46 | 47 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 48 | # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. 49 | # config.assume_ssl = true 50 | 51 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 52 | config.force_ssl = true 53 | 54 | # Skip http-to-https redirect for the default health check endpoint. 55 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 56 | 57 | # Log to STDOUT by default 58 | config.logger = ActiveSupport::Logger.new(STDOUT) 59 | .tap { |logger| logger.formatter = ::Logger::Formatter.new } 60 | .then { |logger| ActiveSupport::TaggedLogging.new(logger) } 61 | 62 | # Prepend all log lines with the following tags. 63 | config.log_tags = [ :request_id ] 64 | 65 | # "info" includes generic and useful information about system operation, but avoids logging too much 66 | # information to avoid inadvertent exposure of personally identifiable information (PII). If you 67 | # want to log everything, set the level to "debug". 68 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 69 | 70 | # Use a different cache store in production. 71 | # config.cache_store = :mem_cache_store 72 | 73 | # Use a real queuing backend for Active Job (and separate queues per environment). 74 | # config.active_job.queue_adapter = :resque 75 | # config.active_job.queue_name_prefix = "spectator_sport_production" 76 | 77 | # Disable caching for Action Mailer templates even if Action Controller 78 | # caching is enabled. 79 | config.action_mailer.perform_caching = false 80 | 81 | # Ignore bad email addresses and do not raise email delivery errors. 82 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 83 | # config.action_mailer.raise_delivery_errors = false 84 | 85 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 86 | # the I18n.default_locale when a translation cannot be found). 87 | config.i18n.fallbacks = true 88 | 89 | # Don't log any deprecations. 90 | config.active_support.report_deprecations = false 91 | 92 | # Do not dump schema after migrations. 93 | config.active_record.dump_schema_after_migration = false 94 | 95 | # Enable DNS rebinding protection and other `Host` header attacks. 96 | # config.hosts = [ 97 | # "example.com", # Allow requests from example.com 98 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 99 | # ] 100 | # Skip DNS rebinding protection for the default health check endpoint. 101 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 102 | end 103 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "warning" 4 | Warning.ignore([ :not_reached, :unused_var ], /.*lib\/mail\/parser.*/) 5 | 6 | 7 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 8 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 9 | # The generated `.rspec` file contains `--require spec_helper` which will cause 10 | # this file to always be loaded, without a need to explicitly require it in any 11 | # files. 12 | # 13 | # Given that it is always loaded, you are encouraged to keep this file as 14 | # light-weight as possible. Requiring heavyweight dependencies from this file 15 | # will add to the boot time of your test suite on EVERY test run, even for an 16 | # individual file that may not need all of that loaded. Instead, consider making 17 | # a separate helper file that requires the additional dependencies and performs 18 | # the additional setup, and require it from the spec files that actually need 19 | # it. 20 | # 21 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 22 | RSpec.configure do |config| 23 | # rspec-expectations config goes here. You can use an alternate 24 | # assertion/expectation library such as wrong or the stdlib/minitest 25 | # assertions if you prefer. 26 | config.expect_with :rspec do |expectations| 27 | # This option will default to `true` in RSpec 4. It makes the `description` 28 | # and `failure_message` of custom matchers include text for helper methods 29 | # defined using `chain`, e.g.: 30 | # be_bigger_than(2).and_smaller_than(4).description 31 | # # => "be bigger than 2 and smaller than 4" 32 | # ...rather than: 33 | # # => "be bigger than 2" 34 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 35 | end 36 | 37 | # rspec-mocks config goes here. You can use an alternate test double 38 | # library (such as bogus or mocha) by changing the `mock_with` option here. 39 | config.mock_with :rspec do |mocks| 40 | # Prevents you from mocking or stubbing a method that does not exist on 41 | # a real object. This is generally recommended, and will default to 42 | # `true` in RSpec 4. 43 | mocks.verify_partial_doubles = true 44 | end 45 | 46 | config.warnings = true 47 | 48 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 49 | # have no way to turn it off -- the option exists only for backwards 50 | # compatibility in RSpec 3). It causes shared context metadata to be 51 | # inherited by the metadata hash of host groups and examples, rather than 52 | # triggering implicit auto-inclusion in groups with matching metadata. 53 | config.shared_context_metadata_behavior = :apply_to_host_groups 54 | 55 | # Allows RSpec to persist some state between runs in order to support 56 | # the `--only-failures` and `--next-failure` CLI options. We recommend 57 | # you configure your source control system to ignore this file. 58 | config.example_status_persistence_file_path = "tmp/rspec_examples.txt" 59 | 60 | # The settings below are suggested to provide a good initial experience 61 | # with RSpec, but feel free to customize to your heart's content. 62 | # # This allows you to limit a spec run to individual examples or groups 63 | # # you care about by tagging them with `:focus` metadata. When nothing 64 | # # is tagged with `:focus`, all examples get run. RSpec also provides 65 | # # aliases for `it`, `describe`, and `context` that include `:focus` 66 | # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 67 | # config.filter_run_when_matching :focus 68 | # 69 | # # Limits the available syntax to the non-monkey patched syntax that is 70 | # # recommended. For more details, see: 71 | # # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 72 | # # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 73 | # # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 74 | # config.disable_monkey_patching! 75 | # 76 | # # Many RSpec users commonly either run the entire suite or an individual 77 | # # file, and it's useful to allow more verbose output when running an 78 | # # individual spec file. 79 | # if config.files_to_run.one? 80 | # # Use the documentation formatter for detailed output, 81 | # # unless a formatter has already been configured 82 | # # (e.g. via a command-line flag). 83 | # config.default_formatter = "doc" 84 | # end 85 | # 86 | # # Print the 10 slowest examples and example groups at the 87 | # # end of the spec run, to help surface which specs are running 88 | # # particularly slow. 89 | # config.profile_examples = 10 90 | # 91 | # # Run specs in random order to surface order dependencies. If you find an 92 | # # order dependency and want to debug it, you can fix the order by providing 93 | # # the seed, which is printed after each run. 94 | # # --seed 1234 95 | # config.order = :random 96 | # 97 | # # Seed global randomization in this process using the `--seed` CLI option. 98 | # # Setting this allows you to use `--seed` to deterministically reproduce 99 | # # test failures related to randomization by passing the same `--seed` value 100 | # # as the one that triggered the failure. 101 | # Kernel.srand config.seed 102 | end 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spectator Sport 2 | 3 | Record and replay browser sessions in a self-hosted Rails Engine. 4 | 5 | Spectator Sport uses the [`rrweb` library](https://www.rrweb.io/) to create recordings of your website's DOM as your users interact with it. These recordings are stored in your database for replay by developers and administrators to analyze user behavior, reproduce bugs, and make building for the web more fun. 6 | 7 | 🚧 🚧 _This gem is very early in its development lifecycle and will undergo significant changes on its journey to v1.0. I would love your feedback and help in co-developing it, just fyi that it's going to be so much better than it is right now._ 8 | 9 | 🚧 🚧 **Future Roadmap:** 10 | 11 | - ✅ Proof of concept and technical demo 12 | - ✅ Running in production on Ben Sheldon's personal business websites 13 | - ✅ Publish [manifesto of principles and intent](https://github.com/bensheldon/spectator_sport/discussions/6) 14 | - ◻️ Reliable and efficient event stream transport 15 | - ✅ Player dashboard design using Bootstrap and Turbo ([#20](https://github.com/bensheldon/spectator_sport/pull/20)) 16 | - ◻️ Automatic cleanup of old recordings to minimize database space 17 | - ◻️ Identity methods for linking application users to recordings 18 | - ◻️ Privacy controls with masked recording by default 19 | - ◻️ Automated installation process with Rails generators 20 | - ◻️ Fully documented installation process 21 | - 🏁 Release v1.0 🎉 22 | - ◻️ Live streaming replay of recordings 23 | - ◻️ Searching / filtering of recordings, including navigation and 404s/500s, button clicks, rage clicks, dead clicks, etc. 24 | - ◻️ Custom events 25 | - 💖 Your feedback and ideas. Please open an Issue or Discussion or even a PR modifying this Roadmap. I'd love to chat! 26 | 27 | ## Installation 28 | 29 | The Spectator Sport gem is conceptually two parts packaged together in this single gem and mounted in your application: 30 | 31 | 1. The Recorder, including javascript that runs in the client browser and produces a stream of events, an API endpoint to receive those events, and database migrations and models to store the events as a cohesive recording. 32 | 2. The Player Dashboard, an administrative dashboard to view and replay stored recordings 33 | 34 | To install Spectator Sport in your Rails application: 35 | 36 | 1. Add `spectator_sport` to your application's Gemfile and install the gem: 37 | ```bash 38 | bundle add spectator_sport 39 | ``` 40 | 2. Install Spectator Sport in your application. _🚧 This will change on the path to v1._ Explore the `/demo` app as live example: 41 | - Create database migrations with `bin/rails g spectator_sport:install:migrations`. Apply migrations with `bin/rails db:prepare` 42 | - Mount the recorder API in your application's routes with `mount SpectatorSport::Engine, at: "/spectator_sport, as: :spectator_sport"` 43 | - Add the `spectator_sport_script_tags` helper to the bottom of the `` of `layout/application.rb`. Example: 44 | ```erb 45 | <%# app/views/layouts/application.html.erb %> 46 | <%# ... %> 47 | <%= spectator_sport_script_tags %> 48 | 49 | ``` 50 | 51 | - Add a ` 56 | 57 | ``` 58 | 3. To view recordings, you will want to mount the Player Dashboard in your application and set up authorization to limit access. See the section on [Dashboard authorization](#dashboard-authorization) for instructions. 59 | 60 | ## Dashboard authorization 61 | 62 | It is advisable to manually install and set up authorization for the **Player Dashboard** and refrain from making it public. 63 | 64 | If you are using Devise, the process of authorizing admins might resemble the following: 65 | 66 | ```ruby 67 | # config/routes.rb 68 | authenticate :user, ->(user) { user.admin? } do 69 | mount SpectatorSport::Dashboard::Engine, at: 'spectator_sport_dashboard', as: :spectator_sport_dashboard 70 | end 71 | ``` 72 | 73 | Or set up Basic Auth: 74 | 75 | ```ruby 76 | # config/initializers/spectator_sport.rb 77 | SpectatorSport::Dashboard::Engine.middleware.use(Rack::Auth::Basic) do |username, password| 78 | ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.spectator_sport_username, username) & 79 | ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.spectator_sport_password, password) 80 | end 81 | ``` 82 | 83 | If you are using an authentication method similar to the one used in ONCE products, you can utilize an auth constraint in your routes. 84 | ```ruby 85 | # config/routes.rb 86 | class AuthRouteConstraint 87 | def matches?(request) 88 | return false unless request.session[:user_id] 89 | user = User.find(request.session[:user_id]) 90 | 91 | if user && user.admin? 92 | cookies = ActionDispatch::Cookies::CookieJar.build(request, request.cookies) 93 | token = cookies.signed[:session_token] 94 | 95 | return user.sessions.find_by(token: token) 96 | end 97 | end 98 | end 99 | 100 | Rails.application.routes.draw do 101 | # ... 102 | namespace :admin, constraints: AuthRouteConstraint.new do 103 | mount SpectatorSport::Dashboard::Engine, at: 'spectator_sport_dashboard', as: :spectator_sport_dashboard 104 | end 105 | end 106 | ``` 107 | 108 | Or extend the `SpectatorSport::Dashboard::ApplicationController` with your own authorization logic: 109 | 110 | ```ruby 111 | # config/initializers/spectator_sport.rb 112 | ActiveSupport.on_load(:spectator_sport_dashboard_application_controller) do 113 | # context here is SpectatorSport::Dashboard::ApplicationController 114 | 115 | before_action do 116 | raise ActionController::RoutingError.new('Not Found') unless current_user&.admin? 117 | end 118 | 119 | def current_user 120 | # load current user from session, cookies, etc. 121 | end 122 | end 123 | ``` 124 | 125 | ## Contributing 126 | 127 | 💖 Please don't be shy about opening an issue or half-baked PR. Your ideas and suggestions are more important to discuss than a polished/complete code change. 128 | 129 | This repository is intended to be simple and easy to run locally with a fully-featured demo application for immediately seeing the results of your proposed changes: 130 | 131 | ```bash 132 | # 1. Clone this repository via git 133 | # 2. Set it up locally 134 | bundle install 135 | # 3. Create database 136 | bin/rails db:setup 137 | # 4. Run the demo Rails application: 138 | bin/rails s 139 | # 5. Load the demo application in your browser 140 | open http://localhost:3000 141 | # 6. Make changes, see the result, commit and make a PR! 142 | ``` 143 | 144 | ## License 145 | 146 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 147 | -------------------------------------------------------------------------------- /app/frontend/spectator_sport/dashboard/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | spectator_sport (0.1.0) 5 | rails (>= 7.2.1) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (8.0.2) 11 | actionpack (= 8.0.2) 12 | activesupport (= 8.0.2) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | zeitwerk (~> 2.6) 16 | actionmailbox (8.0.2) 17 | actionpack (= 8.0.2) 18 | activejob (= 8.0.2) 19 | activerecord (= 8.0.2) 20 | activestorage (= 8.0.2) 21 | activesupport (= 8.0.2) 22 | mail (>= 2.8.0) 23 | actionmailer (8.0.2) 24 | actionpack (= 8.0.2) 25 | actionview (= 8.0.2) 26 | activejob (= 8.0.2) 27 | activesupport (= 8.0.2) 28 | mail (>= 2.8.0) 29 | rails-dom-testing (~> 2.2) 30 | actionpack (8.0.2) 31 | actionview (= 8.0.2) 32 | activesupport (= 8.0.2) 33 | nokogiri (>= 1.8.5) 34 | rack (>= 2.2.4) 35 | rack-session (>= 1.0.1) 36 | rack-test (>= 0.6.3) 37 | rails-dom-testing (~> 2.2) 38 | rails-html-sanitizer (~> 1.6) 39 | useragent (~> 0.16) 40 | actiontext (8.0.2) 41 | actionpack (= 8.0.2) 42 | activerecord (= 8.0.2) 43 | activestorage (= 8.0.2) 44 | activesupport (= 8.0.2) 45 | globalid (>= 0.6.0) 46 | nokogiri (>= 1.8.5) 47 | actionview (8.0.2) 48 | activesupport (= 8.0.2) 49 | builder (~> 3.1) 50 | erubi (~> 1.11) 51 | rails-dom-testing (~> 2.2) 52 | rails-html-sanitizer (~> 1.6) 53 | activejob (8.0.2) 54 | activesupport (= 8.0.2) 55 | globalid (>= 0.3.6) 56 | activemodel (8.0.2) 57 | activesupport (= 8.0.2) 58 | activerecord (8.0.2) 59 | activemodel (= 8.0.2) 60 | activesupport (= 8.0.2) 61 | timeout (>= 0.4.0) 62 | activestorage (8.0.2) 63 | actionpack (= 8.0.2) 64 | activejob (= 8.0.2) 65 | activerecord (= 8.0.2) 66 | activesupport (= 8.0.2) 67 | marcel (~> 1.0) 68 | activesupport (8.0.2) 69 | base64 70 | benchmark (>= 0.3) 71 | bigdecimal 72 | concurrent-ruby (~> 1.0, >= 1.3.1) 73 | connection_pool (>= 2.2.5) 74 | drb 75 | i18n (>= 1.6, < 2) 76 | logger (>= 1.4.2) 77 | minitest (>= 5.1) 78 | securerandom (>= 0.3) 79 | tzinfo (~> 2.0, >= 2.0.5) 80 | uri (>= 0.13.1) 81 | addressable (2.8.7) 82 | public_suffix (>= 2.0.2, < 7.0) 83 | ast (2.4.3) 84 | base64 (0.3.0) 85 | benchmark (0.4.1) 86 | bigdecimal (3.2.2) 87 | builder (3.3.0) 88 | capybara (3.40.0) 89 | addressable 90 | matrix 91 | mini_mime (>= 0.1.3) 92 | nokogiri (~> 1.11) 93 | rack (>= 1.6.0) 94 | rack-test (>= 0.6.3) 95 | regexp_parser (>= 1.5, < 3.0) 96 | xpath (~> 3.2) 97 | concurrent-ruby (1.3.5) 98 | connection_pool (2.5.3) 99 | crass (1.0.6) 100 | date (3.4.1) 101 | diff-lcs (1.6.2) 102 | drb (2.2.3) 103 | erb (5.0.2) 104 | erubi (1.13.1) 105 | globalid (1.2.1) 106 | activesupport (>= 6.1) 107 | i18n (1.14.7) 108 | concurrent-ruby (~> 1.0) 109 | io-console (0.8.1) 110 | irb (1.15.2) 111 | pp (>= 0.6.0) 112 | rdoc (>= 4.0.0) 113 | reline (>= 0.4.2) 114 | json (2.13.2) 115 | language_server-protocol (3.17.0.5) 116 | lint_roller (1.1.0) 117 | logger (1.7.0) 118 | loofah (2.24.1) 119 | crass (~> 1.0.2) 120 | nokogiri (>= 1.12.0) 121 | mail (2.8.1) 122 | mini_mime (>= 0.1.1) 123 | net-imap 124 | net-pop 125 | net-smtp 126 | marcel (1.0.4) 127 | matrix (0.4.3) 128 | mini_mime (1.1.5) 129 | mini_portile2 (2.8.9) 130 | minitest (5.25.5) 131 | net-imap (0.5.9) 132 | date 133 | net-protocol 134 | net-pop (0.1.2) 135 | net-protocol 136 | net-protocol (0.2.2) 137 | timeout 138 | net-smtp (0.5.1) 139 | net-protocol 140 | nio4r (2.7.4) 141 | nokogiri (1.18.9) 142 | mini_portile2 (~> 2.8.2) 143 | racc (~> 1.4) 144 | nokogiri (1.18.9-aarch64-linux-gnu) 145 | racc (~> 1.4) 146 | nokogiri (1.18.9-aarch64-linux-musl) 147 | racc (~> 1.4) 148 | nokogiri (1.18.9-arm-linux-gnu) 149 | racc (~> 1.4) 150 | nokogiri (1.18.9-arm-linux-musl) 151 | racc (~> 1.4) 152 | nokogiri (1.18.9-arm64-darwin) 153 | racc (~> 1.4) 154 | nokogiri (1.18.9-x86_64-darwin) 155 | racc (~> 1.4) 156 | nokogiri (1.18.9-x86_64-linux-gnu) 157 | racc (~> 1.4) 158 | nokogiri (1.18.9-x86_64-linux-musl) 159 | racc (~> 1.4) 160 | parallel (1.27.0) 161 | parser (3.3.9.0) 162 | ast (~> 2.4.1) 163 | racc 164 | pg (1.6.0) 165 | pg (1.6.0-aarch64-linux) 166 | pg (1.6.0-arm64-darwin) 167 | pg (1.6.0-x86_64-darwin) 168 | pg (1.6.0-x86_64-linux) 169 | pp (0.6.2) 170 | prettyprint 171 | prettyprint (0.2.0) 172 | prism (1.4.0) 173 | psych (5.2.6) 174 | date 175 | stringio 176 | public_suffix (6.0.2) 177 | puma (6.6.0) 178 | nio4r (~> 2.0) 179 | racc (1.8.1) 180 | rack (3.1.16) 181 | rack-session (2.1.1) 182 | base64 (>= 0.1.0) 183 | rack (>= 3.0.0) 184 | rack-test (2.2.0) 185 | rack (>= 1.3) 186 | rackup (2.2.1) 187 | rack (>= 3) 188 | rails (8.0.2) 189 | actioncable (= 8.0.2) 190 | actionmailbox (= 8.0.2) 191 | actionmailer (= 8.0.2) 192 | actionpack (= 8.0.2) 193 | actiontext (= 8.0.2) 194 | actionview (= 8.0.2) 195 | activejob (= 8.0.2) 196 | activemodel (= 8.0.2) 197 | activerecord (= 8.0.2) 198 | activestorage (= 8.0.2) 199 | activesupport (= 8.0.2) 200 | bundler (>= 1.15.0) 201 | railties (= 8.0.2) 202 | rails-dom-testing (2.3.0) 203 | activesupport (>= 5.0.0) 204 | minitest 205 | nokogiri (>= 1.6) 206 | rails-html-sanitizer (1.6.2) 207 | loofah (~> 2.21) 208 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 209 | railties (8.0.2) 210 | actionpack (= 8.0.2) 211 | activesupport (= 8.0.2) 212 | irb (~> 1.13) 213 | rackup (>= 1.0.0) 214 | rake (>= 12.2) 215 | thor (~> 1.0, >= 1.2.2) 216 | zeitwerk (~> 2.6) 217 | rainbow (3.1.1) 218 | rake (13.3.0) 219 | rdoc (6.14.2) 220 | erb 221 | psych (>= 4.0.0) 222 | regexp_parser (2.10.0) 223 | reline (0.6.2) 224 | io-console (~> 0.5) 225 | rexml (3.4.1) 226 | rspec-core (3.13.5) 227 | rspec-support (~> 3.13.0) 228 | rspec-expectations (3.13.5) 229 | diff-lcs (>= 1.2.0, < 2.0) 230 | rspec-support (~> 3.13.0) 231 | rspec-mocks (3.13.5) 232 | diff-lcs (>= 1.2.0, < 2.0) 233 | rspec-support (~> 3.13.0) 234 | rspec-rails (8.0.1) 235 | actionpack (>= 7.2) 236 | activesupport (>= 7.2) 237 | railties (>= 7.2) 238 | rspec-core (~> 3.13) 239 | rspec-expectations (~> 3.13) 240 | rspec-mocks (~> 3.13) 241 | rspec-support (~> 3.13) 242 | rspec-support (3.13.4) 243 | rubocop (1.79.0) 244 | json (~> 2.3) 245 | language_server-protocol (~> 3.17.0.2) 246 | lint_roller (~> 1.1.0) 247 | parallel (~> 1.10) 248 | parser (>= 3.3.0.2) 249 | rainbow (>= 2.2.2, < 4.0) 250 | regexp_parser (>= 2.9.3, < 3.0) 251 | rubocop-ast (>= 1.46.0, < 2.0) 252 | ruby-progressbar (~> 1.7) 253 | tsort (>= 0.2.0) 254 | unicode-display_width (>= 2.4.0, < 4.0) 255 | rubocop-ast (1.46.0) 256 | parser (>= 3.3.7.2) 257 | prism (~> 1.4) 258 | rubocop-performance (1.25.0) 259 | lint_roller (~> 1.1) 260 | rubocop (>= 1.75.0, < 2.0) 261 | rubocop-ast (>= 1.38.0, < 2.0) 262 | rubocop-rails (2.32.0) 263 | activesupport (>= 4.2.0) 264 | lint_roller (~> 1.1) 265 | rack (>= 1.1) 266 | rubocop (>= 1.75.0, < 2.0) 267 | rubocop-ast (>= 1.44.0, < 2.0) 268 | rubocop-rails-omakase (1.1.0) 269 | rubocop (>= 1.72) 270 | rubocop-performance (>= 1.24) 271 | rubocop-rails (>= 2.30) 272 | ruby-progressbar (1.13.0) 273 | rubyzip (2.4.1) 274 | securerandom (0.4.1) 275 | selenium-webdriver (4.34.0) 276 | base64 (~> 0.2) 277 | logger (~> 1.4) 278 | rexml (~> 3.2, >= 3.2.5) 279 | rubyzip (>= 1.2.2, < 3.0) 280 | websocket (~> 1.0) 281 | sprockets (4.2.2) 282 | concurrent-ruby (~> 1.0) 283 | logger 284 | rack (>= 2.2.4, < 4) 285 | sprockets-rails (3.5.2) 286 | actionpack (>= 6.1) 287 | activesupport (>= 6.1) 288 | sprockets (>= 3.0.0) 289 | sqlite3 (2.7.3-aarch64-linux-gnu) 290 | sqlite3 (2.7.3-aarch64-linux-musl) 291 | sqlite3 (2.7.3-arm-linux-gnu) 292 | sqlite3 (2.7.3-arm-linux-musl) 293 | sqlite3 (2.7.3-arm64-darwin) 294 | sqlite3 (2.7.3-x86-linux-gnu) 295 | sqlite3 (2.7.3-x86-linux-musl) 296 | sqlite3 (2.7.3-x86_64-darwin) 297 | sqlite3 (2.7.3-x86_64-linux-gnu) 298 | sqlite3 (2.7.3-x86_64-linux-musl) 299 | stringio (3.1.7) 300 | thor (1.4.0) 301 | timeout (0.4.3) 302 | tsort (0.2.0) 303 | tzinfo (2.0.6) 304 | concurrent-ruby (~> 1.0) 305 | unicode-display_width (3.1.4) 306 | unicode-emoji (~> 4.0, >= 4.0.4) 307 | unicode-emoji (4.0.4) 308 | uri (1.0.3) 309 | useragent (0.16.11) 310 | warning (1.5.0) 311 | websocket (1.2.11) 312 | websocket-driver (0.8.0) 313 | base64 314 | websocket-extensions (>= 0.1.0) 315 | websocket-extensions (0.1.5) 316 | xpath (3.2.0) 317 | nokogiri (~> 1.8) 318 | zeitwerk (2.7.3) 319 | 320 | PLATFORMS 321 | aarch64-linux 322 | aarch64-linux-gnu 323 | aarch64-linux-musl 324 | arm-linux 325 | arm-linux-gnu 326 | arm-linux-musl 327 | arm64-darwin 328 | x86-linux 329 | x86-linux-gnu 330 | x86-linux-musl 331 | x86_64-darwin 332 | x86_64-linux 333 | x86_64-linux-gnu 334 | x86_64-linux-musl 335 | 336 | DEPENDENCIES 337 | capybara 338 | pg 339 | puma 340 | rspec-rails 341 | rubocop-rails-omakase 342 | selenium-webdriver 343 | spectator_sport! 344 | sprockets-rails 345 | sqlite3 346 | warning 347 | 348 | RUBY VERSION 349 | ruby 3.4.5p51 350 | 351 | BUNDLED WITH 352 | 2.6.9 353 | -------------------------------------------------------------------------------- /app/frontend/spectator_sport/dashboard/vendor/es_module_shims.js: -------------------------------------------------------------------------------- 1 | (function(){const e="undefined"!==typeof window;const t="undefined"!==typeof document;const noop=()=>{};const r=t?document.querySelector("script[type=esms-options]"):void 0;const s=r?JSON.parse(r.innerHTML):{};Object.assign(s,self.esmsInitOptions||{});let n=!t||!!s.shimMode;const a=globalHook(n&&s.onimport);const i=globalHook(n&&s.resolve);let c=s.fetch?globalHook(s.fetch):fetch;const f=s.meta?globalHook(n&&s.meta):noop;const te=s.mapOverrides;let se=s.nonce;if(!se&&t){const e=document.querySelector("script[nonce]");e&&(se=e.nonce||e.getAttribute("nonce"))}const ne=globalHook(s.onerror||noop);const oe=s.onpolyfill?globalHook(s.onpolyfill):()=>{console.log("%c^^ Module TypeError above is polyfilled and can be ignored ^^","font-weight:900;color:#391")};const{revokeBlobURLs:ce,noLoadEventRetriggers:le,enforceIntegrity:fe}=s;function globalHook(e){return"string"===typeof e?self[e]:e}const ue=Array.isArray(s.polyfillEnable)?s.polyfillEnable:[];const de=ue.includes("css-modules");const pe=ue.includes("json-modules");const be=!navigator.userAgentData&&!!navigator.userAgent.match(/Edge\/\d+\.\d+/);const he=t?document.baseURI:`${location.protocol}//${location.host}${location.pathname.includes("/")?location.pathname.slice(0,location.pathname.lastIndexOf("/")+1):location.pathname}`;const createBlob=(e,t="text/javascript")=>URL.createObjectURL(new Blob([e],{type:t}));let{skip:me}=s;if(Array.isArray(me)){const e=me.map((e=>new URL(e,he).href));me=t=>e.some((e=>"/"===e[e.length-1]&&t.startsWith(e)||t===e))}else if("string"===typeof me){const e=new RegExp(me);me=t=>e.test(t)}const eoop=e=>setTimeout((()=>{throw e}));const throwError=t=>{(self.reportError||e&&window.safari&&console.error||eoop)(t),void ne(t)};function fromParent(e){return e?` imported from ${e}`:""}let ke=false;function setImportMapSrcOrLazy(){ke=true}if(!n)if(document.querySelectorAll("script[type=module-shim],script[type=importmap-shim],link[rel=modulepreload-shim]").length)n=true;else{let e=false;for(const t of document.querySelectorAll("script[type=module],script[type=importmap]"))if(e){if("importmap"===t.type&&e){ke=true;break}}else"module"!==t.type||t.ep||(e=true)}const we=/\\/g;function isURL(e){if(-1===e.indexOf(":"))return false;try{new URL(e);return true}catch(e){return false}}function resolveUrl(e,t){return resolveIfNotPlainOrUrl(e,t)||(isURL(e)?e:resolveIfNotPlainOrUrl("./"+e,t))}function resolveIfNotPlainOrUrl(e,t){const r=t.indexOf("#"),s=t.indexOf("?");r+s>-2&&(t=t.slice(0,-1===r?s:-1===s||s>r?r:s));-1!==e.indexOf("\\")&&(e=e.replace(we,"/"));if("/"===e[0]&&"/"===e[1])return t.slice(0,t.indexOf(":")+1)+e;if("."===e[0]&&("/"===e[1]||"."===e[1]&&("/"===e[2]||2===e.length&&(e+="/"))||1===e.length&&(e+="/"))||"/"===e[0]){const r=t.slice(0,t.indexOf(":")+1);let s;if("/"===t[r.length+1])if("file:"!==r){s=t.slice(r.length+2);s=s.slice(s.indexOf("/")+1)}else s=t.slice(8);else s=t.slice(r.length+("/"===t[r.length]));if("/"===e[0])return t.slice(0,t.length-s.length-1)+e;const n=s.slice(0,s.lastIndexOf("/")+1)+e;const a=[];let i=-1;for(let e=0;e "${e[a]}" does not resolve`)}}let ge=!t&&(0,eval)("u=>import(u)");let ve;const ye=t&&new Promise((e=>{const t=Object.assign(document.createElement("script"),{src:createBlob("self._d=u=>import(u)"),ep:true});t.setAttribute("nonce",se);t.addEventListener("load",(()=>{if(!(ve=!!(ge=self._d))){let e;window.addEventListener("error",(t=>e=t));ge=(t,r)=>new Promise(((s,n)=>{const a=Object.assign(document.createElement("script"),{type:"module",src:createBlob(`import*as m from'${t}';self._esmsi=m`)});e=void 0;a.ep=true;se&&a.setAttribute("nonce",se);a.addEventListener("error",cb);a.addEventListener("load",cb);function cb(i){document.head.removeChild(a);if(self._esmsi){s(self._esmsi,he);self._esmsi=void 0}else{n(!(i instanceof Event)&&i||e&&e.error||new Error(`Error loading ${r&&r.errUrl||t} (${a.src}).`));e=void 0}}document.head.appendChild(a)}))}document.head.removeChild(t);delete self._d;e()}));document.head.appendChild(t)}));let $e=false;let Se=false;let Le=!(!t||!HTMLScriptElement.supports)&&HTMLScriptElement.supports("importmap");let Oe=Le;const Ce="import.meta";const Ae='import"x"assert{type:"css"}';const xe='import"x"assert{type:"json"}';const Pe=Promise.resolve(ye).then((()=>{if(ve&&(!Le||de||pe))return t?new Promise((e=>{const t=document.createElement("iframe");t.style.display="none";t.setAttribute("nonce",se);function cb({data:[r,s,n,a]}){Le=r;Oe=s;Se=n;$e=a;e();document.head.removeChild(t);window.removeEventListener("message",cb,false)}window.addEventListener("message",cb,false);const r=`