From ffd8c1004f7d90e95ee6ccc0091a356cb3541a4d Mon Sep 17 00:00:00 2001 From: Rails Pulse Date: Sat, 15 Nov 2025 21:28:57 +0700 Subject: [PATCH] Implement background job monitoring --- .mise.toml | 6 + Gemfile | 6 + Gemfile.lock | 4 + README.md | 207 +++- .../rails_pulse/components/tags.css | 9 +- .../concerns/chart_table_concern.rb | 3 +- .../rails_pulse/application_controller.rb | 12 +- .../rails_pulse/job_runs_controller.rb | 37 + .../rails_pulse/jobs_controller.rb | 80 ++ .../rails_pulse/operations_controller.rb | 70 +- .../rails_pulse/queries_controller.rb | 2 +- .../rails_pulse/requests_controller.rb | 12 +- .../rails_pulse/routes_controller.rb | 2 +- .../rails_pulse/tags_controller.rb | 4 + app/helpers/rails_pulse/application_helper.rb | 33 +- app/helpers/rails_pulse/breadcrumbs_helper.rb | 16 +- app/helpers/rails_pulse/status_helper.rb | 16 + app/helpers/rails_pulse/tags_helper.rb | 40 +- .../rails_pulse/charts/operations_chart.rb | 33 + app/models/rails_pulse/job.rb | 85 ++ app/models/rails_pulse/job_run.rb | 76 ++ .../jobs/cards/average_duration.rb | 85 ++ app/models/rails_pulse/jobs/cards/base.rb | 70 ++ .../rails_pulse/jobs/cards/failure_rate.rb | 85 ++ .../rails_pulse/jobs/cards/total_jobs.rb | 74 ++ .../rails_pulse/jobs/cards/total_runs.rb | 48 + app/models/rails_pulse/operation.rb | 11 +- .../requests/charts/operations_chart.rb | 35 - app/models/rails_pulse/summary.rb | 7 +- app/services/rails_pulse/summary_service.rb | 46 + .../layouts/rails_pulse/_menu_items.html.erb | 5 + .../layouts/rails_pulse/application.html.erb | 23 + .../components/_active_filters.html.erb | 13 +- .../components/_page_header.html.erb | 15 +- .../rails_pulse/dashboard/index.html.erb | 2 +- .../rails_pulse/job_runs/_operations.html.erb | 78 ++ app/views/rails_pulse/job_runs/index.html.erb | 3 + app/views/rails_pulse/job_runs/show.html.erb | 51 + .../rails_pulse/jobs/_job_runs_table.html.erb | 35 + app/views/rails_pulse/jobs/_table.html.erb | 43 + app/views/rails_pulse/jobs/index.html.erb | 34 + app/views/rails_pulse/jobs/show.html.erb | 49 + .../_operation_analysis_application.html.erb | 56 +- .../_operation_analysis_view.html.erb | 20 +- .../rails_pulse/operations/show.html.erb | 18 +- .../rails_pulse/requests/_table.html.erb | 2 +- .../rails_pulse/tags/_tag_manager.html.erb | 21 +- bin/dev | 20 - bin/test_generators | 115 +++ config/initializers/rails_pulse.rb | 21 + config/routes.rb | 4 + ...250930105043_install_rails_pulse_tables.rb | 23 - .../20250113000000_add_jobs_to_rails_pulse.rb | 95 ++ db/rails_pulse_schema.rb | 288 ++++-- docs/database_setup.md | 124 +++ docs/testing.md | 950 ++++++++++++++++++ .../templates/db/rails_pulse_schema.rb | 288 ++++-- .../migrations/install_rails_pulse_tables.rb | 31 +- lib/rails_pulse/active_job_extensions.rb | 13 + .../adapters/delayed_job_plugin.rb | 25 + .../adapters/sidekiq_middleware.rb | 41 + lib/rails_pulse/cleanup_service.rb | 65 ++ lib/rails_pulse/configuration.rb | 63 +- lib/rails_pulse/engine.rb | 26 + lib/rails_pulse/job_run_collector.rb | 172 ++++ .../subscribers/operation_subscriber.rb | 16 +- lib/tasks/rails_pulse_benchmark.rake | 382 +++++++ .../rails-pulse-assets/rails-pulse-icons.js | 5 +- .../rails-pulse-icons.js.map | 2 +- public/rails-pulse-assets/rails-pulse.css | 2 +- public/rails-pulse-assets/rails-pulse.css.map | 2 +- scripts/benchmark_performance.rb | 425 ++++++++ scripts/build-icons.js | 3 +- .../rails_pulse/job_runs_controller_test.rb | 189 ++++ .../rails_pulse/jobs_controller_test.rb | 231 +++++ .../rails_pulse/operations_controller_test.rb | 125 ++- .../rails_pulse/requests_controller_test.rb | 124 ++- .../rails_pulse/tags_controller_test.rb | 46 +- test/dummy/Gemfile | 4 + test/dummy/Gemfile.lock | 3 + test/dummy/app/jobs/test_job.rb | 7 + test/dummy/config/initializers/rails_pulse.rb | 207 ++++ ...251021102632_install_rails_pulse_tables.rb | 41 + test/dummy/db/rails_pulse_schema.rb | 214 ++++ test/dummy/db/seeds.rb | 456 ++++++++- test/fixtures/rails_pulse_job_runs.yml | 79 ++ test/fixtures/rails_pulse_jobs.yml | 23 + test/fixtures/rails_pulse_operations.yml | 12 +- .../rails_pulse/application_helper_test.rb | 8 + .../rails_pulse/breadcrumbs_helper_test.rb | 203 +++- .../operation_subscriber_test.rb | 22 + .../charts/operations_chart_test.rb | 13 + test/models/rails_pulse/job_run_test.rb | 64 ++ test/models/rails_pulse/job_test.rb | 62 ++ .../jobs/cards/average_duration_test.rb | 231 +++++ .../jobs/cards/failure_rate_test.rb | 264 +++++ .../rails_pulse/jobs/cards/total_jobs_test.rb | 327 ++++++ .../rails_pulse/jobs/cards/total_runs_test.rb | 279 +++++ test/models/rails_pulse/operation_test.rb | 28 +- .../requests/charts/operations_chart_test.rb | 16 - test/models/rails_pulse/summary_test.rb | 3 +- .../rails_pulse/job_run_collector_test.rb | 120 +++ test/support/global_filters_helpers.rb | 2 +- test/system/dashboard_index_page_test.rb | 2 - test/system/global_filters_test.rb | 24 +- test/system/job_runs_show_page_test.rb | 45 + test/system/jobs_index_page_test.rb | 62 ++ test/system/jobs_show_page_test.rb | 95 ++ test/test_helper.rb | 3 +- 109 files changed, 7998 insertions(+), 519 deletions(-) create mode 100644 .mise.toml create mode 100644 app/controllers/rails_pulse/job_runs_controller.rb create mode 100644 app/controllers/rails_pulse/jobs_controller.rb create mode 100644 app/models/rails_pulse/charts/operations_chart.rb create mode 100644 app/models/rails_pulse/job.rb create mode 100644 app/models/rails_pulse/job_run.rb create mode 100644 app/models/rails_pulse/jobs/cards/average_duration.rb create mode 100644 app/models/rails_pulse/jobs/cards/base.rb create mode 100644 app/models/rails_pulse/jobs/cards/failure_rate.rb create mode 100644 app/models/rails_pulse/jobs/cards/total_jobs.rb create mode 100644 app/models/rails_pulse/jobs/cards/total_runs.rb delete mode 100644 app/models/rails_pulse/requests/charts/operations_chart.rb create mode 100644 app/views/rails_pulse/job_runs/_operations.html.erb create mode 100644 app/views/rails_pulse/job_runs/index.html.erb create mode 100644 app/views/rails_pulse/job_runs/show.html.erb create mode 100644 app/views/rails_pulse/jobs/_job_runs_table.html.erb create mode 100644 app/views/rails_pulse/jobs/_table.html.erb create mode 100644 app/views/rails_pulse/jobs/index.html.erb create mode 100644 app/views/rails_pulse/jobs/show.html.erb delete mode 100644 db/migrate/20250930105043_install_rails_pulse_tables.rb create mode 100644 db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb create mode 100644 docs/testing.md create mode 100644 lib/rails_pulse/active_job_extensions.rb create mode 100644 lib/rails_pulse/adapters/delayed_job_plugin.rb create mode 100644 lib/rails_pulse/adapters/sidekiq_middleware.rb create mode 100644 lib/rails_pulse/job_run_collector.rb create mode 100644 lib/tasks/rails_pulse_benchmark.rake create mode 100755 scripts/benchmark_performance.rb create mode 100644 test/controllers/rails_pulse/job_runs_controller_test.rb create mode 100644 test/controllers/rails_pulse/jobs_controller_test.rb create mode 100644 test/dummy/app/jobs/test_job.rb create mode 100644 test/dummy/config/initializers/rails_pulse.rb create mode 100644 test/dummy/db/migrate/20251021102632_install_rails_pulse_tables.rb create mode 100644 test/dummy/db/rails_pulse_schema.rb create mode 100644 test/fixtures/rails_pulse_job_runs.yml create mode 100644 test/fixtures/rails_pulse_jobs.yml create mode 100644 test/models/rails_pulse/charts/operations_chart_test.rb create mode 100644 test/models/rails_pulse/job_run_test.rb create mode 100644 test/models/rails_pulse/job_test.rb create mode 100644 test/models/rails_pulse/jobs/cards/average_duration_test.rb create mode 100644 test/models/rails_pulse/jobs/cards/failure_rate_test.rb create mode 100644 test/models/rails_pulse/jobs/cards/total_jobs_test.rb create mode 100644 test/models/rails_pulse/jobs/cards/total_runs_test.rb delete mode 100644 test/models/rails_pulse/requests/charts/operations_chart_test.rb create mode 100644 test/services/rails_pulse/job_run_collector_test.rb create mode 100644 test/system/job_runs_show_page_test.rb create mode 100644 test/system/jobs_index_page_test.rb create mode 100644 test/system/jobs_show_page_test.rb diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..8655e7d --- /dev/null +++ b/.mise.toml @@ -0,0 +1,6 @@ +[tools] +ruby = "3.3.6" +node = "22.12.0" + +[env] +# Optional: Set environment variables here diff --git a/Gemfile b/Gemfile index b8b329c..15b6139 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,12 @@ group :test do gem "timecop" end +# Performance benchmarking +group :development, :test do + gem "benchmark-ips" + gem "memory_profiler" +end + group :development, :test do gem "debug" gem "chartkick" # For testing compatibility with host apps using Chartkick diff --git a/Gemfile.lock b/Gemfile.lock index 1186243..931ffba 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -94,6 +94,7 @@ GEM ast (2.4.3) base64 (0.3.0) benchmark (0.4.1) + benchmark-ips (2.14.0) bigdecimal (3.2.2) builder (3.3.0) byebug (12.0.0) @@ -163,6 +164,7 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.3) + memory_profiler (1.1.0) method_source (1.1.0) mini_mime (1.1.5) minitest (5.25.5) @@ -373,6 +375,7 @@ PLATFORMS DEPENDENCIES appraisal + benchmark-ips capybara chartkick css-zero @@ -383,6 +386,7 @@ DEPENDENCIES faker groupdate (>= 6.5.1) importmap-rails + memory_profiler minitest-reporters mocha mysql2 diff --git a/README.md b/README.md index 81c03b2..c1197b7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ - [Installation](#installation) - [Quick Setup](#quick-setup) - [Basic Configuration](#basic-configuration) +- [Background Job Monitoring](#background-job-monitoring) + - [Overview](#overview) + - [Supported Adapters](#supported-adapters) + - [Job Tracking Configuration](#job-tracking-configuration) + - [Privacy & Security](#privacy--security) - [Authentication](#authentication) - [Authentication Setup](#authentication-setup) - [Authentication Examples](#authentication-examples) @@ -37,6 +42,8 @@ - [Configuration](#configuration) - [Database Configuration](#database-configuration) - [Schema Loading](#schema-loading) +- [Performance Impact](#performance-impact) + - [Running Performance Benchmarks](#running-performance-benchmarks) - [Testing](#testing) - [Technology Stack](#technology-stack) - [Advantages Over Other Solutions](#advantages-over-other-solutions) @@ -54,8 +61,16 @@ Rails Pulse is a comprehensive performance monitoring and debugging gem that pro - Interactive dashboard with response time charts and request analytics - SQL query performance tracking with slow query identification - Route-specific metrics with configurable performance thresholds +- **Background job monitoring** with execution tracking and failure analysis - Week-over-week trend analysis with visual indicators +### Background Job Tracking +- **Universal job tracking** compatible with all ActiveJob adapters +- Monitor job performance, failures, and retries +- Track individual job executions with detailed metrics +- View operations and SQL queries executed during jobs +- Configurable privacy controls for job arguments + ### Developer Experience - Zero configuration setup with sensible defaults - Beautiful responsive interface with dark/light mode @@ -63,7 +78,7 @@ Rails Pulse is a comprehensive performance monitoring and debugging gem that pro - Multiple database support (SQLite, PostgreSQL, MySQL) ### Organization & Filtering -- Flexible tagging system for routes, requests, and queries +- Flexible tagging system for routes, requests, queries, and jobs - Filter performance data by custom tags - Organize monitoring data by environment, priority, or custom categories @@ -172,10 +187,21 @@ RailsPulse.configure do |config| critical: 1000 } + # Set performance thresholds for background jobs (in milliseconds) + config.job_thresholds = { + slow: 5_000, # 5 seconds + very_slow: 30_000, # 30 seconds + critical: 60_000 # 1 minute + } + # Asset tracking configuration config.track_assets = false # Ignore asset requests by default config.custom_asset_patterns = [] # Additional asset patterns to ignore + # Job tracking configuration + config.track_jobs = true # Enable background job tracking + config.capture_job_arguments = false # Disable argument capture for privacy + # Rails Pulse mount path (optional) # Specify if Rails Pulse is mounted at a custom path to prevent self-tracking config.mount_path = nil # e.g., "/admin/monitoring" @@ -184,18 +210,22 @@ RailsPulse.configure do |config| config.ignored_routes = [] # Array of strings or regex patterns config.ignored_requests = [] # Array of request patterns to ignore config.ignored_queries = [] # Array of query patterns to ignore + config.ignored_jobs = [] # Array of job class names to ignore + config.ignored_queues = [] # Array of queue names to ignore # Tagging system - define available tags for categorizing performance data config.tags = ["production", "staging", "critical", "needs-optimization"] # Data cleanup - config.archiving_enabled = true # Enable automatic cleanup - config.full_retention_period = 2.weeks # Delete records older than this - config.max_table_records = { # Maximum records per table - rails_pulse_requests: 10000, - rails_pulse_operations: 50000, - rails_pulse_routes: 1000, - rails_pulse_queries: 500 + config.archiving_enabled = true # Enable automatic cleanup + config.full_retention_period = 30.days # Delete records older than this + config.max_table_records = { # Maximum records per table + rails_pulse_operations: 100_000, + rails_pulse_requests: 50_000, + rails_pulse_job_runs: 50_000, + rails_pulse_queries: 10_000, + rails_pulse_routes: 1_000, + rails_pulse_jobs: 1_000 } # Multiple database support (optional) @@ -206,6 +236,117 @@ RailsPulse.configure do |config| end ``` +## Background Job Monitoring + +Rails Pulse provides comprehensive monitoring for ActiveJob background jobs, tracking performance, failures, and execution details across all major job adapters. + +### Overview + +Background job monitoring is **enabled by default** and works automatically with any ActiveJob adapter. Rails Pulse captures: + +- **Job execution metrics**: Duration, status, retry attempts +- **Failure tracking**: Error class, error message, failure rates +- **Performance analysis**: Slow jobs, aggregate metrics by job class +- **Operation timeline**: SQL queries and operations during job execution +- **Job arguments**: Optional capture for debugging (disabled by default for privacy) + +Access the jobs dashboard at `/rails_pulse/jobs` to view: +- All job classes with aggregate metrics (total runs, failure rate, average duration) +- Individual job executions with detailed performance data +- Filtering by time range, status, queue, and performance thresholds +- Tagging support for organizing jobs by team, priority, or category + +### Supported Adapters + +Rails Pulse works with all ActiveJob adapters through universal tracking: + +- **Sidekiq** - Enhanced tracking via custom middleware +- **Solid Queue** - Universal ActiveJob tracking +- **Good Job** - Universal ActiveJob tracking +- **Delayed Job** - Enhanced tracking via custom plugin +- **Resque** - Universal ActiveJob tracking +- **Any ActiveJob adapter** - Falls back to universal tracking + +No additional configuration needed - job tracking works out of the box with your existing setup. + +### Job Tracking Configuration + +Customize job tracking in your Rails Pulse initializer: + +```ruby +RailsPulse.configure do |config| + # Enable or disable job tracking (default: true) + config.track_jobs = true + + # Set performance thresholds for jobs (in milliseconds) + config.job_thresholds = { + slow: 5_000, # 5 seconds + very_slow: 30_000, # 30 seconds + critical: 60_000 # 1 minute + } + + # Ignore specific job classes from tracking + config.ignored_jobs = [ + "ActiveStorage::AnalyzeJob", + "ActiveStorage::PurgeJob" + ] + + # Ignore specific queues from tracking + config.ignored_queues = ["low_priority", "mailers"] + + # Capture job arguments for debugging (default: false) + # WARNING: May expose sensitive data - use with caution + config.capture_job_arguments = false + + # Configure adapter-specific settings + config.job_adapters = { + sidekiq: { enabled: true, track_queue_depth: false }, + solid_queue: { enabled: true, track_recurring: false }, + good_job: { enabled: true, track_cron: false }, + delayed_job: { enabled: true }, + resque: { enabled: true } + } +end +``` + +**Disabling job tracking for specific jobs:** + +```ruby +class MyBackgroundJob < ApplicationJob + # Skip Rails Pulse tracking for this job + def perform(*args) + RailsPulse.with_tracking_disabled do + # Job logic here + end + end +end +``` + +### Privacy & Security + +**Job argument capture is disabled by default** to protect sensitive information. Job arguments may contain: +- User credentials or tokens +- Personal identifiable information (PII) +- API keys or secrets +- Sensitive business data + +Only enable `capture_job_arguments` in development or when explicitly needed for debugging. Consider using parameter filtering if you need to capture arguments: + +```ruby +# In your job class +class SensitiveJob < ApplicationJob + def perform(user_id:, api_key:) + # Rails Pulse will track execution but not arguments by default + end +end +``` + +**Performance impact:** +- Minimal overhead: ~1-2ms per job execution +- No blocking of job processing +- Configurable cleanup prevents database growth +- Can be disabled per-job or globally + ## Authentication Rails Pulse supports flexible authentication to secure access to your monitoring dashboard. @@ -257,7 +398,7 @@ config.authentication_method = proc { ## Tagging System -Rails Pulse includes a flexible tagging system that allows you to categorize and organize your performance data. Tag routes, requests, and queries with custom labels to better organize and filter your monitoring data. +Rails Pulse includes a flexible tagging system that allows you to categorize and organize your performance data. Tag routes, requests, queries, jobs, and job runs with custom labels to better organize and filter your monitoring data. ### Configuring Tags @@ -280,7 +421,7 @@ end **Tag from the UI:** -1. Navigate to any route, request, or query detail page +1. Navigate to any route, request, query, job, or job run detail page 2. Click the "+ tag" button next to the record 3. Select from your configured tags 4. Remove tags by clicking the × button on any tag badge @@ -297,6 +438,15 @@ route.add_tag("high-traffic") query = RailsPulse::Query.find_by(normalized_sql: "SELECT * FROM users WHERE id = ?") query.add_tag("needs-optimization") +# Tag a job +job = RailsPulse::Job.find_by(name: "UserNotificationJob") +job.add_tag("high-priority") +job.add_tag("user-facing") + +# Tag a specific job run +job_run = RailsPulse::JobRun.find_by(run_id: "abc123") +job_run.add_tag("investigated") + # Remove a tag route.remove_tag("critical") @@ -486,6 +636,43 @@ The schema file `db/rails_pulse_schema.rb` serves as your single source of truth - Should not be deleted or modified - Future updates will provide migrations in `db/rails_pulse_migrate/` +## Performance Impact + +Rails Pulse includes comprehensive performance monitoring with measurable overhead. Based on real benchmarking: + +- **Request overhead:** 5-6ms per request (includes database writes) +- **Memory allocation:** ~830 KB per request (temporary, garbage collected) +- **Job tracking overhead:** < 0.1ms per background job +- **Relative impact:** 1-5% for typical requests (100-500ms) + +**Important:** The overhead is primarily from persisting tracking data to the database. For high-traffic production applications (> 10,000 RPM), consider using aggressive filtering, sampling, or a separate database. + +For detailed benchmarking methodology, optimization strategies, and how to measure Rails Pulse's impact on your specific application, see the **[Performance Impact Guide](docs/performance_impact.md)**. + +### Running Performance Benchmarks + +Rails Pulse includes built-in benchmarking tools. To use them: + +```ruby +# Add to your Gemfile (development/test group) +gem 'benchmark-ips' +gem 'memory_profiler' +``` + +```bash +bundle install + +# Run all benchmarks +bundle exec rake rails_pulse:benchmark:all + +# Run specific benchmarks +bundle exec rake rails_pulse:benchmark:memory +bundle exec rake rails_pulse:benchmark:request_overhead +bundle exec rake rails_pulse:benchmark:middleware +``` + +See the **[Performance Impact Guide](docs/performance_impact.md)** for detailed instructions and interpreting results. + ## Testing Rails Pulse includes a comprehensive test suite designed for speed and reliability across multiple databases and Rails versions. diff --git a/app/assets/stylesheets/rails_pulse/components/tags.css b/app/assets/stylesheets/rails_pulse/components/tags.css index 412eb89..36b3844 100644 --- a/app/assets/stylesheets/rails_pulse/components/tags.css +++ b/app/assets/stylesheets/rails_pulse/components/tags.css @@ -26,6 +26,10 @@ flex-wrap: wrap; } +.tag-list span { + padding-right: 3px !important; +} + /* Individual Tag */ .tag { display: inline-flex; @@ -63,9 +67,10 @@ } .tag-remove span { - font-size: 1.25rem; line-height: 1; - font-weight: bold; + font-weight: inherit; + margin-left: 6px; + font-size: 17px; } /* Add Tag Container */ diff --git a/app/controllers/concerns/chart_table_concern.rb b/app/controllers/concerns/chart_table_concern.rb index 49ffa7e..c44086a 100644 --- a/app/controllers/concerns/chart_table_concern.rb +++ b/app/controllers/concerns/chart_table_concern.rb @@ -51,7 +51,8 @@ def setup_table_data(ransack_params) table_results = build_table_results handle_pagination - @pagy, @table_data = pagy(table_results, items: session_pagination_limit) + # Use pagy_options for version compatibility + @pagy, @table_data = pagy(table_results, **pagy_options(session_pagination_limit)) end def setup_zoom_range_data diff --git a/app/controllers/rails_pulse/application_controller.rb b/app/controllers/rails_pulse/application_controller.rb index 4047dd5..5235076 100644 --- a/app/controllers/rails_pulse/application_controller.rb +++ b/app/controllers/rails_pulse/application_controller.rb @@ -1,6 +1,6 @@ module RailsPulse class ApplicationController < ActionController::Base - # Support both Pagy 8.x (Backend) and Pagy 9+ (Method) + # Support both Pagy 8.x (Backend) and Pagy 43+ (Method) if defined?(Pagy::Method) include Pagy::Method else @@ -152,5 +152,15 @@ def global_performance_threshold_duration(context) def set_show_non_tagged_default session[:show_non_tagged] = true if session[:show_non_tagged].nil? end + + # Returns Pagy options hash with correct parameter name for current version + # Pagy 8.x uses 'items:', Pagy 43+ uses 'limit:' + def pagy_options(count) + if defined?(Pagy::Method) + { limit: count } # Pagy 43+ + else + { items: count } # Pagy 8.x + end + end end end diff --git a/app/controllers/rails_pulse/job_runs_controller.rb b/app/controllers/rails_pulse/job_runs_controller.rb new file mode 100644 index 0000000..18dac97 --- /dev/null +++ b/app/controllers/rails_pulse/job_runs_controller.rb @@ -0,0 +1,37 @@ +module RailsPulse + class JobRunsController < ApplicationController + include TagFilterConcern + + before_action :set_job + before_action :set_run, only: :show + + def index + @ransack_query = @job.runs.ransack(params[:q]) + @pagy, @runs = pagy(@ransack_query.result.order(occurred_at: :desc), **pagy_options(session_pagination_limit)) + @table_data = @runs + end + + def show + @operations = @run.operations.order(:start_time) + @operation_timeline = RailsPulse::Charts::OperationsChart.new(@operations) + + # Group operations by type + @operations_by_type = @operations.group_by(&:operation_type) + + # SQL queries + @sql_operations = @operations.where(operation_type: "sql") + .includes(:query) + .order(duration: :desc) + end + + private + + def set_job + @job = Job.find(params[:job_id]) + end + + def set_run + @run = @job.runs.find(params[:id]) + end + end +end diff --git a/app/controllers/rails_pulse/jobs_controller.rb b/app/controllers/rails_pulse/jobs_controller.rb new file mode 100644 index 0000000..93f659b --- /dev/null +++ b/app/controllers/rails_pulse/jobs_controller.rb @@ -0,0 +1,80 @@ +module RailsPulse + class JobsController < ApplicationController + include TagFilterConcern + include TimeRangeConcern + + # Override TIME_RANGE_OPTIONS from TimeRangeConcern + remove_const(:TIME_RANGE_OPTIONS) if const_defined?(:TIME_RANGE_OPTIONS) + TIME_RANGE_OPTIONS = [ + [ "Recent", "recent" ], + [ "Custom Range", "custom" ] + ].freeze + + before_action :set_job, only: :show + + def index + setup_metric_cards + + @ransack_query = Job.ransack(params[:q]) + + # Apply tag filters from session + base_query = apply_tag_filters(@ransack_query.result) + + @pagy, @jobs = pagy(base_query.order(runs_count: :desc), + **pagy_options(session_pagination_limit), + overflow: :last_page) + @table_data = @jobs + @available_queues = Job.distinct.pluck(:queue_name).compact.sort + end + + def show + setup_metric_cards + + ransack_params = params[:q] || {} + + # Check if user explicitly selected a time range + time_mode = params.dig(:q, :period_start_range) || "recent" + + # Apply time range filter only if custom mode is selected + if time_mode == "custom" + # Get time range from TimeRangeConcern which parses custom_date_range + @start_time, @end_time, @selected_time_range, @time_diff_hours = setup_time_range + + # Apply time filters using parsed times from concern + ransack_params = ransack_params.merge( + occurred_at_gteq: Time.at(@start_time), + occurred_at_lteq: Time.at(@end_time) + ) + else + # Recent mode - no time filters, just rely on sort + pagination + @selected_time_range = "recent" + end + + @ransack_query = @job.runs.ransack(ransack_params) + @ransack_query.sorts = "occurred_at desc" if @ransack_query.sorts.empty? + + # Apply tag filters from session + base_query = apply_tag_filters(@ransack_query.result) + + @pagy, @recent_runs = pagy(base_query, + **pagy_options(session_pagination_limit), + overflow: :last_page) + @table_data = @recent_runs + end + + private + + def set_job + @job = Job.find(params[:id]) + end + + def setup_metric_cards + return if turbo_frame_request? + + # Pass the job to scope the cards to the current job on the show page + @total_runs_metric_card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job).to_metric_card + @failure_rate_metric_card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job).to_metric_card + @average_duration_metric_card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job).to_metric_card + end + end +end diff --git a/app/controllers/rails_pulse/operations_controller.rb b/app/controllers/rails_pulse/operations_controller.rb index fe4e0e1..4da1868 100644 --- a/app/controllers/rails_pulse/operations_controller.rb +++ b/app/controllers/rails_pulse/operations_controller.rb @@ -4,6 +4,8 @@ class OperationsController < ApplicationController def show @request = @operation.request + @job_run = @operation.job_run + @parent = @request || @job_run @related_operations = find_related_operations @performance_context = calculate_performance_context @optimization_suggestions = generate_optimization_suggestions @@ -20,22 +22,24 @@ def set_operation end def find_related_operations + return [] unless @parent + case @operation.operation_type when "sql" - # Find other SQL operations in the same request with similar queries - @operation.request.operations + # Find other SQL operations in the same request/job run with similar queries + @parent.operations .where(operation_type: [ "sql" ]) .where.not(id: @operation.id) .limit(5) when "template", "partial", "layout", "collection" - # Find other view operations in the same request - @operation.request.operations + # Find other view operations in the same request/job run + @parent.operations .where(operation_type: [ "template", "partial", "layout", "collection" ]) .where.not(id: @operation.id) .limit(5) else - # Find operations of the same type in the same request - @operation.request.operations + # Find operations of the same type in the same request/job run + @parent.operations .where(operation_type: @operation.operation_type) .where.not(id: @operation.id) .limit(5) @@ -117,19 +121,21 @@ def sql_optimization_suggestions end # Check for potential N+1 queries - similar_queries = @operation.request.operations - .where(operation_type: [ "sql" ]) - .where("label LIKE ?", "%#{@operation.label.split.first(3).join(' ')}%") - .where.not(id: @operation.id) + if @parent + similar_queries = @parent.operations + .where(operation_type: [ "sql" ]) + .where("label LIKE ?", "%#{@operation.label.split.first(3).join(' ')}%") + .where.not(id: @operation.id) - if similar_queries.count > 2 - suggestions << { - type: "n_plus_one", - icon: "alert-triangle", - title: "Potential N+1 Query", - description: "#{similar_queries.count + 1} similar queries detected. Consider using includes() or joins().", - priority: "high" - } + if similar_queries.count > 2 + suggestions << { + type: "n_plus_one", + icon: "alert-triangle", + title: "Potential N+1 Query", + description: "#{similar_queries.count + 1} similar queries detected. Consider using includes() or joins().", + priority: "high" + } + end end suggestions @@ -149,20 +155,22 @@ def view_optimization_suggestions end # Check for database queries in views - view_db_operations = @operation.request.operations - .where(operation_type: [ "sql" ]) - .where("occurred_at >= ? AND occurred_at <= ?", - @operation.occurred_at, - @operation.occurred_at + @operation.duration) + if @parent + view_db_operations = @parent.operations + .where(operation_type: [ "sql" ]) + .where("occurred_at >= ? AND occurred_at <= ?", + @operation.occurred_at, + @operation.occurred_at + @operation.duration) - if view_db_operations.count > 0 - suggestions << { - type: "database", - icon: "database", - title: "Database Queries in View", - description: "#{view_db_operations.count} database queries during view rendering. Move data fetching to the controller.", - priority: "medium" - } + if view_db_operations.count > 0 + suggestions << { + type: "database", + icon: "database", + title: "Database Queries in View", + description: "#{view_db_operations.count} database queries during view rendering. Move data fetching to the controller.", + priority: "medium" + } + end end suggestions diff --git a/app/controllers/rails_pulse/queries_controller.rb b/app/controllers/rails_pulse/queries_controller.rb index 13f73b8..e62b7c3 100644 --- a/app/controllers/rails_pulse/queries_controller.rb +++ b/app/controllers/rails_pulse/queries_controller.rb @@ -163,7 +163,7 @@ def setup_table_data(ransack_params) table_results = build_table_results handle_pagination - @pagy, @table_data = pagy(table_results, items: session_pagination_limit) + @pagy, @table_data = pagy(table_results, **pagy_options(session_pagination_limit)) end def handle_pagination diff --git a/app/controllers/rails_pulse/requests_controller.rb b/app/controllers/rails_pulse/requests_controller.rb index a44cb5d..8962465 100644 --- a/app/controllers/rails_pulse/requests_controller.rb +++ b/app/controllers/rails_pulse/requests_controller.rb @@ -18,7 +18,7 @@ def index end def show - @operation_timeline = RailsPulse::Requests::Charts::OperationsChart.new(@request.operations) + @operation_timeline = RailsPulse::Charts::OperationsChart.new(@request.operations) end private @@ -118,18 +118,12 @@ def build_table_results def setup_table_data(ransack_params) table_ransack_params = build_table_ransack_params(ransack_params) @ransack_query = table_model.ransack(table_ransack_params) - - # Only apply default sort if not using Requests::Tables::Index (which handles its own sorting) - # For requests, we always use the Tables::Index on the index action - unless action_name == "index" - @ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty? - end + @ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty? table_results = build_table_results handle_pagination - # Use 'items:' for Pagy 8.x compatibility ('limit:' is for Pagy 43+) - @pagy, @table_data = pagy(table_results, items: session_pagination_limit) + @pagy, @table_data = pagy(table_results, **pagy_options(session_pagination_limit)) end def handle_pagination diff --git a/app/controllers/rails_pulse/routes_controller.rb b/app/controllers/rails_pulse/routes_controller.rb index 0cacbf6..9e5516d 100644 --- a/app/controllers/rails_pulse/routes_controller.rb +++ b/app/controllers/rails_pulse/routes_controller.rb @@ -142,7 +142,7 @@ def setup_table_data(ransack_params) table_results = build_table_results handle_pagination - @pagy, @table_data = pagy(table_results, items: session_pagination_limit) + @pagy, @table_data = pagy(table_results, **pagy_options(session_pagination_limit)) end def handle_pagination diff --git a/app/controllers/rails_pulse/tags_controller.rb b/app/controllers/rails_pulse/tags_controller.rb index 50f93b8..7abb6d1 100644 --- a/app/controllers/rails_pulse/tags_controller.rb +++ b/app/controllers/rails_pulse/tags_controller.rb @@ -43,6 +43,10 @@ def set_taggable Request.find(@taggable_id) when "query" Query.find(@taggable_id) + when "job" + Job.find(@taggable_id) + when "job_run" + JobRun.find(@taggable_id) else head :not_found end diff --git a/app/helpers/rails_pulse/application_helper.rb b/app/helpers/rails_pulse/application_helper.rb index 6cd3197..f3101a0 100644 --- a/app/helpers/rails_pulse/application_helper.rb +++ b/app/helpers/rails_pulse/application_helper.rb @@ -18,9 +18,25 @@ def rails_pulse_icon(name, options = {}) width = options[:width] || options["width"] || 24 height = options[:height] || options["height"] || 24 css_class = options[:class] || options["class"] || "" + custom_style = options[:style] || options["style"] + + # Normalize numeric width/height values into px for layout stability + width_css = normalize_dimension(width) + height_css = normalize_dimension(height) + + default_style = [ + "display:inline-flex", + "align-items:center", + "justify-content:center", + "width:#{width_css}", + "height:#{height_css}", + "flex-shrink:0" + ].join(";") + + style_attribute = [ default_style, custom_style ].compact.join(";") # Additional HTML attributes - attrs = options.except(:width, :height, :class, "width", "height", "class") + attrs = options.except(:width, :height, :class, :style, "width", "height", "class", "style") content_tag("rails-pulse-icon", "", @@ -31,6 +47,7 @@ def rails_pulse_icon(name, options = {}) 'rails-pulse--icon-height-value': height }, class: css_class, + style: style_attribute, **attrs ) end @@ -38,6 +55,20 @@ def rails_pulse_icon(name, options = {}) # Backward compatibility alias - can be removed after migration alias_method :lucide_icon, :rails_pulse_icon + def normalize_dimension(value) + string = value.to_s + return string if string.empty? + + if string.match?(/[a-z%]+\z/i) + string + else + number = Float(string) + formatted = number == number.to_i ? number.to_i.to_s : number.to_s + "#{formatted}px" + end + end + + private :normalize_dimension # Get items per page from Pagy instance (compatible with Pagy 8.x and 43+) def pagy_items(pagy) # Pagy 43+ uses options[:items] or has a limit method diff --git a/app/helpers/rails_pulse/breadcrumbs_helper.rb b/app/helpers/rails_pulse/breadcrumbs_helper.rb index c60ceef..e6a2e1e 100644 --- a/app/helpers/rails_pulse/breadcrumbs_helper.rb +++ b/app/helpers/rails_pulse/breadcrumbs_helper.rb @@ -49,9 +49,23 @@ def breadcrumbs is_last = index == path_segments.length - 1 + # For nested resources, if this is a collection name followed by an ID, + # link to the parent resource's show page instead of the nested index + breadcrumb_path = if !is_last && + segment !~ /^\d+$/ && + index > 0 && + path_segments[index - 1] =~ /^\d+$/ && + path_segments[index + 1] =~ /^\d+$/ + # This is a nested collection (e.g., /jobs/5/runs/291) + # Link to parent show page (e.g., /jobs/5) + path_segments[0..index-1].inject(main_app.rails_pulse_path.chomp("/")) { |path, seg| path + "/#{seg}" } + else + current_path + end + crumbs << { title: title, - path: current_path, + path: breadcrumb_path, current: is_last } end diff --git a/app/helpers/rails_pulse/status_helper.rb b/app/helpers/rails_pulse/status_helper.rb index db0c882..a3a1b35 100644 --- a/app/helpers/rails_pulse/status_helper.rb +++ b/app/helpers/rails_pulse/status_helper.rb @@ -281,5 +281,21 @@ def duration_options(type = :route) [ "Critical (≥ #{thresholds[:critical]}ms)", :critical ] ] end + + def duration_threshold_filter_options(type = :route) + thresholds = RailsPulse.configuration.public_send("#{type}_thresholds") + + all_label = + case type + when :job then "All Job Runs" + else "All #{type.to_s.humanize.pluralize}" + end + + threshold_options = thresholds.map do |name, value| + [ "#{name.to_s.humanize} (≥ #{value}ms)", value ] + end.sort_by { |_, value| value } + + [ [ all_label, nil ] ] + threshold_options + end end end diff --git a/app/helpers/rails_pulse/tags_helper.rb b/app/helpers/rails_pulse/tags_helper.rb index 033ecaa..62e7f9f 100644 --- a/app/helpers/rails_pulse/tags_helper.rb +++ b/app/helpers/rails_pulse/tags_helper.rb @@ -1,5 +1,43 @@ module RailsPulse module TagsHelper + # Render a single tag badge + # Options: + # - variant: :default (no class), :secondary, :positive + # - removable: boolean - whether to include a remove button + # - taggable_type: string - type of taggable object (for remove button) + # - taggable_id: integer - id of taggable object (for remove button) + def render_tag_badge(tag, variant: :default, removable: false, taggable_type: nil, taggable_id: nil) + badge_class = case variant + when :secondary + "badge badge--secondary font-normal" + when :positive + "badge badge--positive font-normal" + else + "badge font-normal" + end + + if removable && taggable_type && taggable_id + # For removable tags, render the full structure with button_to + content_tag(:span, class: badge_class) do + concat tag.humanize + concat " " + concat( + button_to( + remove_tag_path(taggable_type, taggable_id, tag: tag), + method: :delete, + class: "tag-remove", + data: { turbo_frame: "_top" } + ) do + content_tag(:span, "×", "aria-hidden": "true") + end + ) + end + else + # For non-removable tags, just render the badge + content_tag(:span, tag, class: badge_class) + end + end + # Display tags as badge elements # Accepts: # - Taggable objects (with tag_list method) @@ -23,7 +61,7 @@ def display_tag_badges(tags) return content_tag(:span, "-", class: "text-subtle") if tag_array.empty? - safe_join(tag_array.map { |tag| content_tag(:div, tag, class: "badge") }, " ") + safe_join(tag_array.map { |tag| content_tag(:div, tag.humanize, class: "badge") }, " ") end end end diff --git a/app/models/rails_pulse/charts/operations_chart.rb b/app/models/rails_pulse/charts/operations_chart.rb new file mode 100644 index 0000000..e7299ab --- /dev/null +++ b/app/models/rails_pulse/charts/operations_chart.rb @@ -0,0 +1,33 @@ +module RailsPulse + module Charts + class OperationsChart + OperationBar = Struct.new(:operation, :duration, :left_pct, :width_pct) + + attr_reader :bars, :min_start, :max_end, :total_duration + + HORIZONTAL_OFFSET_PX = 20 + + def initialize(operations) + @operations = operations + @min_start = @operations.map(&:start_time).min || 0 + @max_end = @operations.map { |op| op.start_time + op.duration }.max || 1 + @total_duration = (@max_end - @min_start).nonzero? || 1 + @bars = build_bars + end + + private + + def build_bars + @operations.map do |operation| + left_pct = ((operation.start_time - @min_start).to_f / @total_duration) * (100 - px_to_pct) + px_to_pct / 2 + width_pct = (operation.duration.to_f / @total_duration) * (100 - px_to_pct) + OperationBar.new(operation, operation.duration.round(0), left_pct, width_pct) + end + end + + def px_to_pct + (HORIZONTAL_OFFSET_PX.to_f / 1000) * 100 + end + end + end +end diff --git a/app/models/rails_pulse/job.rb b/app/models/rails_pulse/job.rb new file mode 100644 index 0000000..ff450e5 --- /dev/null +++ b/app/models/rails_pulse/job.rb @@ -0,0 +1,85 @@ +module RailsPulse + class Job < RailsPulse::ApplicationRecord + include Taggable + + self.table_name = "rails_pulse_jobs" + + has_many :runs, + class_name: "RailsPulse::JobRun", + foreign_key: :job_id, + inverse_of: :job, + dependent: :destroy + + validates :name, presence: true, uniqueness: true + + def self.ransackable_attributes(auth_object = nil) + %w[id name queue_name runs_count failures_count retries_count avg_duration] + end + + def self.ransackable_associations(auth_object = nil) + %w[runs] + end + + scope :by_queue, ->(queue) { where(queue_name: queue) } + scope :with_failures, -> { where("failures_count > 0") } + scope :ordered_by_runs, -> { order(runs_count: :desc) } + + def apply_run!(run) + return unless run.duration + + duration = run.duration.to_f + + with_lock do + reload + total_runs = runs_count.to_i + previous_total = [ total_runs - 1, 0 ].max + previous_average = avg_duration.to_f + + new_average = if previous_total.zero? + duration + else + ((previous_average * previous_total) + duration) / (previous_total + 1) + end + + updates = { avg_duration: new_average } + if run.failure_like_status? + updates[:failures_count] = failures_count + 1 + end + if run.status == "retried" + updates[:retries_count] = retries_count + 1 + end + + update!(updates) + end + end + + def failure_rate + return 0.0 if runs_count.zero? + + ((failures_count.to_f / runs_count) * 100).round(2) + end + + def performance_status + thresholds = RailsPulse.configuration.job_thresholds + duration = avg_duration.to_f + + if duration < thresholds[:slow] + :fast + elsif duration < thresholds[:very_slow] + :slow + elsif duration < thresholds[:critical] + :very_slow + else + :critical + end + end + + def to_param + id.to_s + end + + def to_breadcrumb + name + end + end +end diff --git a/app/models/rails_pulse/job_run.rb b/app/models/rails_pulse/job_run.rb new file mode 100644 index 0000000..b3d161f --- /dev/null +++ b/app/models/rails_pulse/job_run.rb @@ -0,0 +1,76 @@ +module RailsPulse + class JobRun < RailsPulse::ApplicationRecord + include Taggable + + self.table_name = "rails_pulse_job_runs" + + STATUSES = %w[enqueued running success failed discarded retried].freeze + FINAL_STATUSES = %w[success failed discarded retried].freeze + + belongs_to :job, + class_name: "RailsPulse::Job", + counter_cache: :runs_count, + inverse_of: :runs + has_many :operations, + class_name: "RailsPulse::Operation", + foreign_key: :job_run_id, + inverse_of: :job_run, + dependent: :destroy + + validates :run_id, presence: true, uniqueness: true + validates :status, inclusion: { in: STATUSES } + validates :occurred_at, presence: true + + def self.ransackable_attributes(auth_object = nil) + %w[id job_id run_id status occurred_at duration attempts adapter] + end + + def self.ransackable_associations(auth_object = nil) + %w[job operations] + end + + scope :successful, -> { where(status: "success") } + scope :failed, -> { where(status: %w[failed discarded]) } + scope :recent, -> { order(occurred_at: :desc) } + scope :by_adapter, ->(adapter) { where(adapter: adapter) } + + after_commit :apply_to_job_caches, on: %i[create update], if: :finalized? + + def all_tags + (job.tag_list + tag_list).uniq + end + + def performance_status + thresholds = RailsPulse.configuration.job_thresholds + duration = self.duration.to_f + + if duration < thresholds[:slow] + :fast + elsif duration < thresholds[:very_slow] + :slow + elsif duration < thresholds[:critical] + :very_slow + else + :critical + end + end + + def failure_like_status? + FINAL_STATUSES.include?(status) && status != "success" + end + + def finalized? + change = previous_changes["status"] + return false unless change + + previous_state, new_state = change + FINAL_STATUSES.include?(new_state) && !FINAL_STATUSES.include?(previous_state) + end + + private + + def apply_to_job_caches + job.apply_run!(self) + end + end +end diff --git a/app/models/rails_pulse/jobs/cards/average_duration.rb b/app/models/rails_pulse/jobs/cards/average_duration.rb new file mode 100644 index 0000000..ac511d3 --- /dev/null +++ b/app/models/rails_pulse/jobs/cards/average_duration.rb @@ -0,0 +1,85 @@ +module RailsPulse + module Jobs + module Cards + class AverageDuration < Base + def initialize(job: nil) + @job = job + end + + def to_metric_card + base_query = RailsPulse::Summary + .where( + summarizable_type: "RailsPulse::Job", + period_type: "day", + period_start: range_start..now + ) + base_query = base_query.where(summarizable_id: @job.id) if @job + + metrics = base_query.select( + "SUM(avg_duration * count) AS total_weighted_duration", + "SUM(count) AS total_runs", + "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN avg_duration * count ELSE 0 END) AS current_weighted_duration", + "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_runs", + "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN avg_duration * count ELSE 0 END) AS previous_weighted_duration", + "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_runs" + ).take + + total_runs = metrics&.total_runs.to_i + total_weighted_duration = metrics&.total_weighted_duration.to_f + current_runs = metrics&.current_runs.to_i + current_weighted_duration = metrics&.current_weighted_duration.to_f + previous_runs = metrics&.previous_runs.to_i + previous_weighted_duration = metrics&.previous_weighted_duration.to_f + + average_duration = average_for(total_weighted_duration, total_runs) + current_average = average_for(current_weighted_duration, current_runs) + previous_average = average_for(previous_weighted_duration, previous_runs) + + trend_icon, trend_amount = trend_for(current_average, previous_average) + + grouped_weighted = base_query + .group_by_day(:period_start, time_zone: "UTC") + .sum(Arel.sql("avg_duration * count")) + + grouped_counts = base_query + .group_by_day(:period_start, time_zone: "UTC") + .sum(:count) + + sparkline_data = sparkline_from_averages(grouped_weighted, grouped_counts) + + { + id: "jobs_average_duration", + context: "jobs", + title: "Average Duration", + summary: format_duration(average_duration), + chart_data: sparkline_data, + trend_icon: trend_icon, + trend_amount: trend_amount, + trend_text: "Compared to previous week" + } + end + + private + + def average_for(weighted_duration, total_runs) + return 0.0 if total_runs.zero? + + (weighted_duration.to_f / total_runs).round(1) + end + + def sparkline_from_averages(weighted_by_day, counts_by_day) + start_date = range_start.to_date + end_date = now.to_date + + (start_date..end_date).each_with_object({}) do |day, hash| + weighted = weighted_by_day[day].to_f + count = counts_by_day[day].to_f + avg = count.zero? ? 0.0 : (weighted / count).round(1) + label = day.strftime("%b %-d") + hash[label] = { value: avg } + end + end + end + end + end +end diff --git a/app/models/rails_pulse/jobs/cards/base.rb b/app/models/rails_pulse/jobs/cards/base.rb new file mode 100644 index 0000000..cac313d --- /dev/null +++ b/app/models/rails_pulse/jobs/cards/base.rb @@ -0,0 +1,70 @@ +require "active_support/number_helper" + +module RailsPulse + module Jobs + module Cards + class Base + RANGE_DAYS = 14 + WINDOW_DAYS = 7 + + private + + def now + @now ||= Time.current + end + + def previous_window_start + (now - (WINDOW_DAYS * 2).days).beginning_of_day + end + + def current_window_start + (now - WINDOW_DAYS.days).beginning_of_day + end + + def range_start + previous_window_start + end + + def quote(time) + RailsPulse::Summary.connection.quote(time) + end + + def sparkline_from(grouped_values) + start_date = range_start.to_date + end_date = now.to_date + + (start_date..end_date).each_with_object({}) do |day, hash| + label = day.strftime("%b %-d") + hash[label] = { value: grouped_values[day] || 0 } + end + end + + def trend_for(current_value, previous_value, precision: 1) + percentage = previous_value.zero? ? 0.0 : ((current_value - previous_value) / previous_value.to_f * 100).round(precision) + + icon = if percentage.abs < 0.1 + "move-right" + elsif percentage.positive? + "trending-up" + else + "trending-down" + end + + [ icon, format_percentage(percentage.abs, precision) ] + end + + def format_percentage(value, precision) + "#{value.round(precision)}%" + end + + def format_number(value) + ActiveSupport::NumberHelper.number_to_delimited(value) + end + + def format_duration(value) + "#{value.round(0)} ms" + end + end + end + end +end diff --git a/app/models/rails_pulse/jobs/cards/failure_rate.rb b/app/models/rails_pulse/jobs/cards/failure_rate.rb new file mode 100644 index 0000000..d22e54e --- /dev/null +++ b/app/models/rails_pulse/jobs/cards/failure_rate.rb @@ -0,0 +1,85 @@ +module RailsPulse + module Jobs + module Cards + class FailureRate < Base + def initialize(job: nil) + @job = job + end + + def to_metric_card + base_query = RailsPulse::Summary + .where( + summarizable_type: "RailsPulse::Job", + period_type: "day", + period_start: range_start..now + ) + base_query = base_query.where(summarizable_id: @job.id) if @job + + metrics = base_query.select( + "SUM(count) AS total_count", + "SUM(error_count) AS total_errors", + "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count", + "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN error_count ELSE 0 END) AS current_errors", + "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count", + "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN error_count ELSE 0 END) AS previous_errors" + ).take + + total_runs = metrics&.total_count.to_i + total_errors = metrics&.total_errors.to_i + current_runs = metrics&.current_count.to_i + current_errors = metrics&.current_errors.to_i + previous_runs = metrics&.previous_count.to_i + previous_errors = metrics&.previous_errors.to_i + + failure_rate = rate_for(total_errors, total_runs) + current_rate = rate_for(current_errors, current_runs) + previous_rate = rate_for(previous_errors, previous_runs) + + trend_icon, trend_amount = trend_for(current_rate, previous_rate) + + grouped_errors = base_query + .group_by_day(:period_start, time_zone: "UTC") + .sum(:error_count) + + grouped_counts = base_query + .group_by_day(:period_start, time_zone: "UTC") + .sum(:count) + + sparkline_data = sparkline_from_failure_rates(grouped_errors, grouped_counts) + + { + id: "jobs_failure_rate", + context: "jobs", + title: "Failure Rate", + summary: "#{format_percentage(failure_rate, 1)}", + chart_data: sparkline_data, + trend_icon: trend_icon, + trend_amount: trend_amount, + trend_text: "Compared to previous week" + } + end + + private + + def rate_for(errors, total) + return 0.0 if total.zero? + + (errors.to_f / total * 100).round(1) + end + + def sparkline_from_failure_rates(errors_by_day, counts_by_day) + start_date = range_start.to_date + end_date = now.to_date + + (start_date..end_date).each_with_object({}) do |day, hash| + errors = errors_by_day[day].to_f + total = counts_by_day[day].to_f + rate = total.zero? ? 0.0 : (errors / total * 100).round(1) + label = day.strftime("%b %-d") + hash[label] = { value: rate } + end + end + end + end + end +end diff --git a/app/models/rails_pulse/jobs/cards/total_jobs.rb b/app/models/rails_pulse/jobs/cards/total_jobs.rb new file mode 100644 index 0000000..1601597 --- /dev/null +++ b/app/models/rails_pulse/jobs/cards/total_jobs.rb @@ -0,0 +1,74 @@ +module RailsPulse + module Jobs + module Cards + class TotalJobs < Base + def initialize(job: nil) + @job = job + end + + def to_metric_card + # When scoped to a job, show runs count instead of job count + if @job + base_query = RailsPulse::Summary + .where( + summarizable_type: "RailsPulse::Job", + summarizable_id: @job.id, + period_type: "day", + period_start: range_start..now + ) + + metrics = base_query.select( + "SUM(count) AS total_count", + "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count", + "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count" + ).take + + total_runs = metrics&.total_count.to_i + current_runs = metrics&.current_count.to_i + previous_runs = metrics&.previous_count.to_i + + trend_icon, trend_amount = trend_for(current_runs, previous_runs) + + grouped_runs = base_query + .group_by_day(:period_start, time_zone: "UTC") + .sum(:count) + + { + id: "jobs_total_jobs", + context: "jobs", + title: "Total Runs", + summary: "#{format_number(total_runs)} runs", + chart_data: sparkline_from(grouped_runs), + trend_icon: trend_icon, + trend_amount: trend_amount, + trend_text: "Compared to previous week" + } + else + total_jobs = RailsPulse::Job.count + + current_new_jobs = RailsPulse::Job.where(created_at: current_window_start..now).count + previous_new_jobs = RailsPulse::Job.where(created_at: range_start...current_window_start).count + + trend_icon, trend_amount = trend_for(current_new_jobs, previous_new_jobs) + + grouped_new_jobs = RailsPulse::Job + .where(created_at: range_start..now) + .group_by_day(:created_at, time_zone: "UTC") + .count + + { + id: "jobs_total_jobs", + context: "jobs", + title: "Total Jobs", + summary: "#{format_number(total_jobs)} jobs", + chart_data: sparkline_from(grouped_new_jobs), + trend_icon: trend_icon, + trend_amount: trend_amount, + trend_text: "New jobs vs previous week" + } + end + end + end + end + end +end diff --git a/app/models/rails_pulse/jobs/cards/total_runs.rb b/app/models/rails_pulse/jobs/cards/total_runs.rb new file mode 100644 index 0000000..52fa717 --- /dev/null +++ b/app/models/rails_pulse/jobs/cards/total_runs.rb @@ -0,0 +1,48 @@ +module RailsPulse + module Jobs + module Cards + class TotalRuns < Base + def initialize(job: nil) + @job = job + end + + def to_metric_card + base_query = RailsPulse::Summary + .where( + summarizable_type: "RailsPulse::Job", + period_type: "day", + period_start: range_start..now + ) + base_query = base_query.where(summarizable_id: @job.id) if @job + + metrics = base_query.select( + "SUM(count) AS total_count", + "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count", + "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count" + ).take + + total_runs = metrics&.total_count.to_i + current_runs = metrics&.current_count.to_i + previous_runs = metrics&.previous_count.to_i + + trend_icon, trend_amount = trend_for(current_runs, previous_runs) + + grouped_runs = base_query + .group_by_day(:period_start, time_zone: "UTC") + .sum(:count) + + { + id: "jobs_total_runs", + context: "jobs", + title: "Job Runs", + summary: "#{format_number(total_runs)} runs", + chart_data: sparkline_from(grouped_runs), + trend_icon: trend_icon, + trend_amount: trend_amount, + trend_text: "Compared to previous week" + } + end + end + end + end +end diff --git a/app/models/rails_pulse/operation.rb b/app/models/rails_pulse/operation.rb index 5d706aa..e4b05d6 100644 --- a/app/models/rails_pulse/operation.rb +++ b/app/models/rails_pulse/operation.rb @@ -18,15 +18,16 @@ class Operation < RailsPulse::ApplicationRecord ].freeze # Associations - belongs_to :request, class_name: "RailsPulse::Request" + belongs_to :request, class_name: "RailsPulse::Request", optional: true + belongs_to :job_run, class_name: "RailsPulse::JobRun", optional: true belongs_to :query, class_name: "RailsPulse::Query", optional: true # Validations - validates :request_id, presence: true validates :operation_type, presence: true, inclusion: { in: OPERATION_TYPES } validates :label, presence: true validates :occurred_at, presence: true validates :duration, presence: true, numericality: { greater_than_or_equal_to: 0 } + validate :has_request_or_job_run # Scopes (optional, for convenience) scope :by_type, ->(type) { where(operation_type: type) } @@ -72,6 +73,12 @@ def to_s private + def has_request_or_job_run + return if request_id.present? || job_run_id.present? + + errors.add(:base, "Operation must belong to a request or a job run") + end + def associate_query return unless operation_type == "sql" && label.present? diff --git a/app/models/rails_pulse/requests/charts/operations_chart.rb b/app/models/rails_pulse/requests/charts/operations_chart.rb deleted file mode 100644 index b72396f..0000000 --- a/app/models/rails_pulse/requests/charts/operations_chart.rb +++ /dev/null @@ -1,35 +0,0 @@ -module RailsPulse - module Requests - module Charts - class OperationsChart - OperationBar = Struct.new(:operation, :duration, :left_pct, :width_pct) - - attr_reader :bars, :min_start, :max_end, :total_duration - - HORIZONTAL_OFFSET_PX = 20 - - def initialize(operations) - @operations = operations - @min_start = @operations.map(&:start_time).min || 0 - @max_end = @operations.map { |op| op.start_time + op.duration }.max || 1 - @total_duration = (@max_end - @min_start).nonzero? || 1 - @bars = build_bars - end - - private - - def build_bars - @operations.map do |operation| - left_pct = ((operation.start_time - @min_start).to_f / @total_duration) * (100 - px_to_pct) + px_to_pct / 2 - width_pct = (operation.duration.to_f / @total_duration) * (100 - px_to_pct) - OperationBar.new(operation, operation.duration.round(0), left_pct, width_pct) - end - end - - def px_to_pct - (HORIZONTAL_OFFSET_PX.to_f / 1000) * 100 - end - end - end - end -end diff --git a/app/models/rails_pulse/summary.rb b/app/models/rails_pulse/summary.rb index ecb2946..e8b081e 100644 --- a/app/models/rails_pulse/summary.rb +++ b/app/models/rails_pulse/summary.rb @@ -12,6 +12,9 @@ class Summary < RailsPulse::ApplicationRecord foreign_key: "summarizable_id", class_name: "RailsPulse::Route", optional: true belongs_to :query, -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Query" }) }, foreign_key: "summarizable_id", class_name: "RailsPulse::Query", optional: true + belongs_to :job, + -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Job" }) }, + foreign_key: "summarizable_id", class_name: "RailsPulse::Job", optional: true # Validations validates :period_type, inclusion: { in: PERIOD_TYPES } @@ -26,6 +29,8 @@ class Summary < RailsPulse::ApplicationRecord scope :for_requests, -> { where(summarizable_type: "RailsPulse::Request") } scope :for_routes, -> { where(summarizable_type: "RailsPulse::Route") } scope :for_queries, -> { where(summarizable_type: "RailsPulse::Query") } + scope :for_jobs, -> { where(summarizable_type: "RailsPulse::Job") } + scope :by_job_name, ->(job_name) { for_jobs.joins(:job).where(rails_pulse_jobs: { name: job_name }) } scope :recent, -> { order(period_start: :desc) } # Special scope for overall request summaries @@ -99,7 +104,7 @@ def self.ransackable_attributes(auth_object = nil) end def self.ransackable_associations(auth_object = nil) - %w[route query] + %w[route query job] end # Note: Basic fields like count, avg_duration, min_duration, max_duration diff --git a/app/services/rails_pulse/summary_service.rb b/app/services/rails_pulse/summary_service.rb index d249ade..5330238 100644 --- a/app/services/rails_pulse/summary_service.rb +++ b/app/services/rails_pulse/summary_service.rb @@ -16,6 +16,7 @@ def perform aggregate_requests # Overall system metrics aggregate_routes # Per-route metrics aggregate_queries # Per-query metrics + aggregate_jobs # Per-job metrics end Rails.logger.info "[RailsPulse] Completed #{period_type} summary" @@ -180,6 +181,51 @@ def aggregate_queries end end + def aggregate_jobs + job_runs = JobRun + .includes(:job) + .where(occurred_at: start_time...end_time) + .where(status: JobRun::FINAL_STATUSES) + + return if job_runs.empty? + + job_runs.group_by(&:job_id).each do |job_id, runs| + job = runs.first&.job + next unless job + + duration_values = runs.map(&:duration).compact.map(&:to_f).sort + next if duration_values.empty? + + duration_count = duration_values.size + total_duration = duration_values.sum + average_duration = total_duration / duration_count + + summary = Summary.find_or_initialize_by( + summarizable_type: "RailsPulse::Job", + summarizable_id: job.id, + period_type: period_type, + period_start: start_time + ) + + summary.assign_attributes( + period_end: end_time, + count: runs.size, + avg_duration: average_duration, + min_duration: duration_values.first, + max_duration: duration_values.last, + total_duration: total_duration, + p50_duration: calculate_percentile(duration_values, 0.5), + p95_duration: calculate_percentile(duration_values, 0.95), + p99_duration: calculate_percentile(duration_values, 0.99), + stddev_duration: calculate_stddev(duration_values, average_duration), + error_count: runs.count(&:failure_like_status?), + success_count: runs.count { |run| run.status == "success" } + ) + + summary.save! + end + end + def calculate_percentile(sorted_array, percentile) return nil if sorted_array.empty? diff --git a/app/views/layouts/rails_pulse/_menu_items.html.erb b/app/views/layouts/rails_pulse/_menu_items.html.erb index af3c03d..9e5df5b 100644 --- a/app/views/layouts/rails_pulse/_menu_items.html.erb +++ b/app/views/layouts/rails_pulse/_menu_items.html.erb @@ -17,3 +17,8 @@ <%= rails_pulse_icon 'audio-lines', width: '16' %> Requests <% end %> + +<%= link_to jobs_path, class: 'btn sidebar-menu__button' do %> + <%= rails_pulse_icon 'zap', width: '16' %> + Jobs +<% end %> diff --git a/app/views/layouts/rails_pulse/application.html.erb b/app/views/layouts/rails_pulse/application.html.erb index 08b820b..951d51a 100644 --- a/app/views/layouts/rails_pulse/application.html.erb +++ b/app/views/layouts/rails_pulse/application.html.erb @@ -10,6 +10,29 @@ <%= yield :head %> + + <%= stylesheet_link_tag rails_pulse.asset_path('rails-pulse.css'), 'data-turbo-track': 'reload' %> <%= javascript_include_tag rails_pulse.asset_path('rails-pulse-icons.js'), 'data-turbo-track': 'reload', defer: true, nonce: rails_pulse_csp_nonce %> diff --git a/app/views/rails_pulse/components/_active_filters.html.erb b/app/views/rails_pulse/components/_active_filters.html.erb index cc44772..a963f37 100644 --- a/app/views/rails_pulse/components/_active_filters.html.erb +++ b/app/views/rails_pulse/components/_active_filters.html.erb @@ -6,12 +6,13 @@ <% has_any_filters = has_date_filters || has_performance_filter || has_tag_filters %> <% if has_any_filters %> -
- Filtered: +
+ Global Filters: <% if has_date_filters %> <% start_time = Time.parse(global_filters['start_time']) %> <% end_time = Time.parse(global_filters['end_time']) %> - <%= start_time.strftime("%b %d, %Y %-I:%M %p") %> - <%= end_time.strftime("%b %d, %Y %-I:%M %p") %> + <% date_range = "#{start_time.strftime("%b %d, %Y %-I:%M %p")} - #{end_time.strftime("%b %d, %Y %-I:%M %p")}" %> + <%= render_tag_badge(date_range, variant: :secondary) %> <% end %> <% if has_performance_filter %> @@ -21,15 +22,15 @@ when 'critical' then 'Critical' else global_filters['performance_threshold'].titleize end %> - <%= threshold_label %> + <%= render_tag_badge(threshold_label, variant: :secondary) %> <% end %> <% if has_tag_filters %> <% disabled_tags.each do |tag| %> - <%= tag.humanize %> + <%= render_tag_badge(tag.humanize, variant: :secondary) %> <% end %> <% if session[:show_non_tagged] == false %> - Non tagged hidden + <%= render_tag_badge("Non tagged hidden", variant: :secondary) %> <% end %> <% end %>
diff --git a/app/views/rails_pulse/components/_page_header.html.erb b/app/views/rails_pulse/components/_page_header.html.erb index 33d8367..8c96e5f 100644 --- a/app/views/rails_pulse/components/_page_header.html.erb +++ b/app/views/rails_pulse/components/_page_header.html.erb @@ -12,13 +12,14 @@ <% end %> - <% if defined?(taggable) && taggable.present? %> - - <% elsif defined?(show_active_filters) && show_active_filters %> - diff --git a/app/views/rails_pulse/dashboard/index.html.erb b/app/views/rails_pulse/dashboard/index.html.erb index 94c0f41..0a5ed42 100644 --- a/app/views/rails_pulse/dashboard/index.html.erb +++ b/app/views/rails_pulse/dashboard/index.html.erb @@ -1,4 +1,4 @@ -
+
<%= render 'rails_pulse/components/active_filters' %>
diff --git a/app/views/rails_pulse/job_runs/_operations.html.erb b/app/views/rails_pulse/job_runs/_operations.html.erb new file mode 100644 index 0000000..bf8a033 --- /dev/null +++ b/app/views/rails_pulse/job_runs/_operations.html.erb @@ -0,0 +1,78 @@ +<%= render 'rails_pulse/components/panel', { title: 'Event Sequence ' } do %> + <% if operations.any? %> + + + + + + + + + + + + + + <% total_run_duration = @run.duration.to_f %> + <% @operation_timeline.bars.each_with_index do |bar, index| %> + + + + + + + + + + + + + + + + + + + + + + <% end %> + +
OperationDurationImpactTimelineActions
+
+ <%= rails_pulse_icon('chevron-right', width: '16', class: 'transition-transform duration-200') %> +
+
+ + <%= html_escape(bar.operation.label) %> + + + <%= bar.duration.round(2) %> ms + + <% impact_percentage = total_run_duration > 0 ? (bar.operation.duration / total_run_duration * 100).round(1) : 0 %> + <%= impact_percentage %>% + + <%= operation_status_indicator(bar.operation) %> + +
+
+
+ <%= link_to rails_pulse_icon('eye', width: '16', class: 'inline-block mbi-2'), operation_path(bar.operation), title: 'View details', data: { turbo_frame: "_top" } %> + <% if bar.operation.operation_type == "sql" && bar.operation.query.present? %> + <%= link_to rails_pulse_icon('database', width: '16', class: 'inline-block'), query_path(bar.operation.query), title: 'View query', data: { turbo_frame: "_top" } %> + <% end %> +
+ <% else %> + <%= render 'rails_pulse/components/empty_state', + title: 'No operations found for this job run.', + description: 'This job run may not have had any tracked operations.' %> + <% end %> +<% end %> diff --git a/app/views/rails_pulse/job_runs/index.html.erb b/app/views/rails_pulse/job_runs/index.html.erb new file mode 100644 index 0000000..4805821 --- /dev/null +++ b/app/views/rails_pulse/job_runs/index.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag :index_table do %> + <%= render 'rails_pulse/jobs/job_runs_table' %> +<% end %> diff --git a/app/views/rails_pulse/job_runs/show.html.erb b/app/views/rails_pulse/job_runs/show.html.erb new file mode 100644 index 0000000..3571b89 --- /dev/null +++ b/app/views/rails_pulse/job_runs/show.html.erb @@ -0,0 +1,51 @@ +<%= render 'rails_pulse/components/page_header', taggable: @run %> + +
+
+ <%= render 'rails_pulse/components/panel', title: 'Job Run Details' do %> +
+
Job Class
+
<%= link_to @job.name, job_path(@job) %>
+
Status
+
+ <%= @run.status.humanize %> +
+
Occurred At
+
<%= @run.occurred_at.strftime("%b %d, %Y %l:%M:%S %p") %>
+ <% if @run.enqueued_at %> +
Enqueued At
+
<%= @run.enqueued_at.strftime("%b %d, %Y %l:%M:%S %p") %>
+ <% end %> +
Duration
+
<%= number_to_human(@run.duration || 0, units: { unit: "ms", thousand: "s" }, precision: 2) %>
+
Attempts
+
<%= @run.attempts %>
+
Queue
+
<%= @job.queue_name || 'default' %>
+
+ <% end %> +
+ + <% if @run.error_class.present? %> +
+ <%= render 'rails_pulse/components/panel', title: 'Error Details' do %> +
+
Error Class
+
<%= @run.error_class %>
+ <% if @run.error_message.present? %> +
Error Message
+
<%= @run.error_message %>
+ <% end %> +
+ <% end %> +
+ <% elsif @run.arguments.present? %> +
+ <%= render 'rails_pulse/components/panel', title: 'Arguments' do %> +
<%= JSON.pretty_generate(JSON.parse(@run.arguments)) rescue @run.arguments %>
+ <% end %> +
+ <% end %> +
+ +<%= render 'operations', operations: @operations %> diff --git a/app/views/rails_pulse/jobs/_job_runs_table.html.erb b/app/views/rails_pulse/jobs/_job_runs_table.html.erb new file mode 100644 index 0000000..be43cab --- /dev/null +++ b/app/views/rails_pulse/jobs/_job_runs_table.html.erb @@ -0,0 +1,35 @@ +<% columns = [ + { field: :occurred_at, label: 'Occurred At' }, + { field: :status, label: 'Status', class: 'w-28' }, + { field: :duration, label: 'Duration', class: 'w-28' }, + { field: :attempts, label: 'Attempts', class: 'w-24' }, + { field: nil, label: 'Tags', class: 'w-32', sortable: false } +] %> + + + <%= render "rails_pulse/components/table_head", columns: columns %> + + + <% @table_data.each do |run| %> + + + + + + + + <% end %> + +
+ <%= link_to run.occurred_at.strftime("%b %d, %Y %l:%M %p"), job_run_path(@job, run), data: { turbo_frame: '_top' } %> + + <%= run.status.humanize %> + + <%= number_to_human(run.duration || 0, units: { unit: "ms", thousand: "s" }, precision: 2) %> + + <%= run.attempts %> + + <%= display_tag_badges(run) %> +
+ +<%= render "rails_pulse/components/table_pagination" %> diff --git a/app/views/rails_pulse/jobs/_table.html.erb b/app/views/rails_pulse/jobs/_table.html.erb new file mode 100644 index 0000000..a42d63a --- /dev/null +++ b/app/views/rails_pulse/jobs/_table.html.erb @@ -0,0 +1,43 @@ +<% columns = [ + { field: :name, label: 'Job Class', class: 'w-auto' }, + { field: :queue_name, label: 'Queue', class: 'w-32' }, + { field: :runs_count, label: 'Total Runs', class: 'w-28' }, + { field: :failures_count, label: 'Failures', class: 'w-24' }, + { field: :retries_count, label: 'Retries', class: 'w-24' }, + { field: :avg_duration, label: 'Avg Duration', class: 'w-32' }, + { field: nil, label: 'Tags', class: 'w-32', sortable: false } +] %> + + + <%= render "rails_pulse/components/table_head", columns: columns %> + + + <% @table_data.each do |job| %> + + + + + + + + + + <% end %> + +
+ <%= link_to job.name, job_path(job), data: { turbo_frame: '_top' } %> + + <%= job.queue_name || 'default' %> + + <%= number_with_delimiter(job.runs_count) %> + + <%= number_with_delimiter(job.failures_count) %> + + <%= number_with_delimiter(job.retries_count) %> + + <%= number_to_human(job.avg_duration || 0, units: { unit: "ms", thousand: "s" }, precision: 2) %> + + <%= display_tag_badges(job) %> +
+ +<%= render "rails_pulse/components/table_pagination" %> diff --git a/app/views/rails_pulse/jobs/index.html.erb b/app/views/rails_pulse/jobs/index.html.erb new file mode 100644 index 0000000..4a71fdd --- /dev/null +++ b/app/views/rails_pulse/jobs/index.html.erb @@ -0,0 +1,34 @@ +<%= render 'rails_pulse/components/page_header', show_active_filters: true, show_global_filters: true %> + +<% unless turbo_frame_request? %> +
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @total_runs_metric_card } %> + <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @failure_rate_metric_card } %> + <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @average_duration_metric_card } %> +
+<% end %> + +<%= render 'rails_pulse/components/panel', { title: 'Job Classes' } do %> + <%= search_form_for @ransack_query, url: jobs_path, class: "flex items-center justify-between gap mb-4" do |form| %> +
+ <%= form.search_field :name_cont, placeholder: "Filter by job name", autocomplete: "off", class: "input" %> + <%= form.select :queue_name_eq, + options_for_select([["All queues", ""]] + @available_queues.map { |q| [q || "default", q] }, params.dig(:q, :queue_name_eq)), + {}, + { class: "input" } + %> + <%= link_to "Reset", jobs_path, class: "btn btn--borderless show@md" if params.has_key?(:q) %> + <%= form.submit "Search", class: "btn show@sm" %> +
+ <% end %> + + <% if @jobs.any? %> + <%= turbo_frame_tag :index_table do %> + <%= render 'rails_pulse/jobs/table' %> + <% end %> + <% else %> + <%= render 'rails_pulse/components/empty_state', + title: 'No jobs found', + description: 'No background jobs have been executed yet.' %> + <% end %> +<% end %> diff --git a/app/views/rails_pulse/jobs/show.html.erb b/app/views/rails_pulse/jobs/show.html.erb new file mode 100644 index 0000000..a785651 --- /dev/null +++ b/app/views/rails_pulse/jobs/show.html.erb @@ -0,0 +1,49 @@ +<%= render 'rails_pulse/components/page_header', taggable: @job, show_active_filters: true %> + +<% unless turbo_frame_request? %> +
+ <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @total_runs_metric_card } %> + <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @failure_rate_metric_card } %> + <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @average_duration_metric_card } %> +
+<% end %> + +<%= render 'rails_pulse/components/panel', { title: 'Job Runs' } do %> + <%= search_form_for @ransack_query, url: job_path(@job), class: "flex items-center justify-between gap mb-4", data: { controller: "rails-pulse--custom-range" } do |form| %> +
+ <%= time_range_selector(form, + time_range_options: RailsPulse::JobsController::TIME_RANGE_OPTIONS, + selected_time_range: @selected_time_range, + mode: :recent_custom + ) %> + + <%= form.select :status_eq, + options_for_select( + [["All Statuses", ""]] + RailsPulse::JobRun::STATUSES.map { |s| [s.humanize, s] }, + params.dig(:q, :status_eq) + ), + {}, + { class: "input" } + %> + + <%= form.select :duration_gteq, + duration_threshold_filter_options(:job), + { selected: params.dig(:q, :duration_gteq) }, + { class: "input" } + %> + + <%= link_to "Reset", job_path(@job), class: "btn btn--borderless show@md" if params.has_key?(:q) %> + <%= form.submit "Search", class: "btn show@sm" %> +
+ <% end %> + + <% if @recent_runs.any? %> + <%= turbo_frame_tag :index_table do %> + <%= render 'rails_pulse/jobs/job_runs_table' %> + <% end %> + <% else %> + <%= render 'rails_pulse/components/empty_state', + title: 'No runs found', + description: 'This job has not been executed yet or no runs match the selected filters.' %> + <% end %> +<% end %> diff --git a/app/views/rails_pulse/operations/_operation_analysis_application.html.erb b/app/views/rails_pulse/operations/_operation_analysis_application.html.erb index 001b973..201aa32 100644 --- a/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +++ b/app/views/rails_pulse/operations/_operation_analysis_application.html.erb @@ -6,35 +6,37 @@
<%= html_escape(action) %>
<% end %> -<% total_db_time = operation.request.operations - .where(operation_type: ["sql"]) - .sum(:duration) %> -<% total_view_time = operation.request.operations - .where(operation_type: ["template", "partial", "layout", "collection"]) - .sum(:duration) %> -<% pure_controller_time = operation.duration - total_db_time - total_view_time %> +<% if parent %> + <% total_db_time = parent.operations + .where(operation_type: ["sql"]) + .sum(:duration) %> + <% total_view_time = parent.operations + .where(operation_type: ["template", "partial", "layout", "collection"]) + .sum(:duration) %> + <% pure_controller_time = operation.duration - total_db_time - total_view_time %> -<% if pure_controller_time > 0 %> -
Pure Logic Time
-
- <%= pure_controller_time.round(1) %>ms - <% if pure_controller_time > 100 %> - - consider optimization - <% end %> -
-<% end %> + <% if pure_controller_time > 0 %> +
Pure Logic Time
+
+ <%= pure_controller_time.round(1) %>ms + <% if pure_controller_time > 100 %> + - consider optimization + <% end %> +
+ <% end %> -<% db_operations_count = operation.request.operations - .where(operation_type: ["sql"]) - .count %> -<% if db_operations_count > 0 %> -
Database Queries
-
- <%= db_operations_count %> - <% if db_operations_count > 10 %> - - potential N+1 queries - <% end %> -
+ <% db_operations_count = parent.operations + .where(operation_type: ["sql"]) + .count %> + <% if db_operations_count > 0 %> +
Database Queries
+
+ <%= db_operations_count %> + <% if db_operations_count > 10 %> + - potential N+1 queries + <% end %> +
+ <% end %> <% end %> <% if operation.codebase_location.present? %> diff --git a/app/views/rails_pulse/operations/_operation_analysis_view.html.erb b/app/views/rails_pulse/operations/_operation_analysis_view.html.erb index 63e4055..a0485b7 100644 --- a/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +++ b/app/views/rails_pulse/operations/_operation_analysis_view.html.erb @@ -21,16 +21,18 @@
Partial template - ensure data is pre-loaded in controller
<% end %> -<% view_start = operation.occurred_at %> -<% view_end = operation.occurred_at + operation.duration %> -<% concurrent_db_ops = operation.request.operations - .where(operation_type: ["sql"]) - .where("occurred_at >= ? AND occurred_at <= ?", view_start, view_end) - .count %> +<% if parent %> + <% view_start = operation.occurred_at %> + <% view_end = operation.occurred_at + operation.duration %> + <% concurrent_db_ops = parent.operations + .where(operation_type: ["sql"]) + .where("occurred_at >= ? AND occurred_at <= ?", view_start, view_end) + .count %> -<% if concurrent_db_ops > 0 %> -
Queries During Rendering
-
<%= concurrent_db_ops %> database <%= 'query'.pluralize(concurrent_db_ops) %>
+ <% if concurrent_db_ops > 0 %> +
Queries During Rendering
+
<%= concurrent_db_ops %> database <%= 'query'.pluralize(concurrent_db_ops) %>
+ <% end %> <% end %> <% if operation.duration > 50 %> diff --git a/app/views/rails_pulse/operations/show.html.erb b/app/views/rails_pulse/operations/show.html.erb index 9de0c81..94569d8 100644 --- a/app/views/rails_pulse/operations/show.html.erb +++ b/app/views/rails_pulse/operations/show.html.erb @@ -32,19 +32,21 @@ <%= @operation.duration.round(2) %> ms -
Request Impact
-
- <% impact = (@operation.duration / @request.duration * 100).round(1) %> - <%= impact %>% of total request time -
+ <% if @parent %> +
<%= @request ? 'Request' : 'Job Run' %> Impact
+
+ <% impact = (@operation.duration / @parent.duration * 100).round(1) %> + <%= impact %>% of total <%= @request ? 'request' : 'job run' %> time +
+ <% end %>
Occurred At
<%= human_readable_occurred_at @operation.occurred_at %>
<% begin %> - <%= render partial: "operation_analysis_#{categorize_operation(@operation.operation_type)}", locals: { operation: @operation } %> + <%= render partial: "operation_analysis_#{categorize_operation(@operation.operation_type)}", locals: { operation: @operation, parent: @parent } %> <% rescue ActionView::MissingTemplate %> - <%= render partial: "operation_analysis_generic", locals: { operation: @operation } %> + <%= render partial: "operation_analysis_generic", locals: { operation: @operation, parent: @parent } %> <% end %> <% if @performance_context.any? %> @@ -59,7 +61,7 @@ <% if @related_operations.any? %>
Related Operations
- <%= pluralize(@related_operations.count, 'similar operation') %> in this request + <%= pluralize(@related_operations.count, 'similar operation') %> in this <%= @request ? 'request' : 'job run' %> <% if @related_operations.count > 2 %>
Potential N+1 query pattern detected <% end %> diff --git a/app/views/rails_pulse/requests/_table.html.erb b/app/views/rails_pulse/requests/_table.html.erb index dc86585..941e793 100644 --- a/app/views/rails_pulse/requests/_table.html.erb +++ b/app/views/rails_pulse/requests/_table.html.erb @@ -1,5 +1,5 @@ <% columns = [ - { field: :occurred_at, label: 'Timestamp', class: 'w-36' }, + { field: :occurred_at, label: 'Occurred At' }, { field: :route_path, label: 'Route', class: 'w-auto' }, { field: :duration, label: 'Response Time', class: 'w-36' }, { field: :status, label: 'Status', class: 'w-20' }, diff --git a/app/views/rails_pulse/tags/_tag_manager.html.erb b/app/views/rails_pulse/tags/_tag_manager.html.erb index 0a33fbb..b14aed5 100644 --- a/app/views/rails_pulse/tags/_tag_manager.html.erb +++ b/app/views/rails_pulse/tags/_tag_manager.html.erb @@ -7,21 +7,14 @@
+ <% if current_tags.present? %> + Tags: + <% end %>
<% current_tags.each do |tag| %> - - <%= tag %> - <%= button_to remove_tag_path(taggable_type, taggable.id, tag: tag), - method: :delete, - class: "tag-remove", - data: { - turbo_frame: "_top" - } do %> - - <% end %> - + <%= render_tag_badge(tag, variant: :secondary, removable: true, taggable_type: taggable_type, taggable_id: taggable.id) %> <% end %> <% if available_to_add.any? %> @@ -35,7 +28,7 @@ aria-haspopup="true" aria-controls="tag_menu_<%= taggable_type %>_<%= taggable.id %>" > - + tag + tag +
- <%= tag %> + <%= tag.humanize %> <% end %> <% end %>
diff --git a/bin/dev b/bin/dev index f3c1250..1c4db6a 100755 --- a/bin/dev +++ b/bin/dev @@ -89,26 +89,6 @@ else npm run build > /dev/null 2>&1 fi -# Detect database adapter -cd "$DUMMY_DIR" -DB=${DB:-$(./bin/rails runner "puts ActiveRecord::Base.connection.adapter_name" 2>/dev/null || echo "sqlite3")} - -# Display database adapter info -case "$DB" in - "SQLite") - echo "🗂️ Database: SQLite" - ;; - "PostgreSQL"|"postgresql") - echo "🐘 Database: PostgreSQL" - ;; - "MySQL"|"Mysql2"|"mysql2") - echo "🐬 Database: MySQL" - ;; - *) - echo "🗄️ Database: $DB" - ;; -esac - # Start the Rails server from the dummy app directory echo "🌐 Starting Rails server..." cd "$DUMMY_DIR" diff --git a/bin/test_generators b/bin/test_generators index 1329147..e937d7e 100755 --- a/bin/test_generators +++ b/bin/test_generators @@ -27,6 +27,9 @@ class GeneratorTester test_single_database_install test_single_database_upgrade_no_migrations test_single_database_upgrade_with_migration + test_single_db_migration_idempotency + test_single_db_branch_switching_scenario + test_schema_file_consistency cleanup_between_tests test_separate_database_install test_separate_database_upgrade @@ -215,6 +218,88 @@ class GeneratorTester end end + def test_single_db_migration_idempotency + section "Test 6: Single Database Migration Idempotency" + + Dir.chdir(test_dir) do + # Migrations already ran in test 1, run them again + output = run_command("bin/rails db:migrate RAILS_ENV=test", capture: true) + exit_status = $? + + if exit_status.success? + pass "Migrations can run multiple times without error" + else + fail "Migration failed on second run (exit code: #{exit_status.exitstatus})" + end + + # Verify tables still exist and are correct + verify_all_schema_tables_exist("All tables still exist after re-run") + end + end + + def test_single_db_branch_switching_scenario + section "Test 7: Single Database Branch Switching Scenario" + + Dir.chdir(test_dir) do + # Simulate having tables from schema.rb (like switching from main branch) + puts " Simulating branch switch: dropping and recreating with schema.rb" + run_command "bin/rails db:drop db:create RAILS_ENV=test" + + # Load from schema.rb to simulate main branch state + run_command "bin/rails db:schema:load RAILS_ENV=test" + + # Verify core tables exist (but maybe not jobs tables if schema.rb is old) + verify_tables_exist("Core tables loaded from schema.rb") + + # Now try running the install migration (like switching to jobs branch) + output = run_command("bin/rails db:migrate RAILS_ENV=test 2>&1", capture: true) + + if $?.success? + pass "Install migration handles existing tables gracefully" + else + if output.include?("already exist") && output.include?("Skipping") + pass "Install migration detected existing tables and skipped" + else + fail "Install migration failed with existing tables" + end + end + + # Verify tables still exist + verify_tables_exist("Tables still exist after migration attempt") + end + end + + def test_schema_file_consistency + section "Test 8: Schema File Consistency" + + Dir.chdir(test_dir) do + schema_file = "db/rails_pulse_schema.rb" + + # Verify schema file exists + assert_file_exists schema_file, "Schema file exists" + + # Verify it contains all expected core tables + schema_content = File.read(schema_file) + + # Dynamically extract required_tables from schema + if match = schema_content.match(/required_tables\s*=\s*\[(.*?)\]/m) + table_names = match[1].scan(/:(\w+)/).flatten + + table_names.each do |table| + if schema_content.include?("create_table :#{table}") + pass "Schema includes #{table} definition" + else + fail "Schema missing #{table} definition" + end + end + + pass "Schema file declares #{table_names.size} tables" + else + fail "Could not parse required_tables from schema" + end + end + end + def run_command(cmd, capture: false) if capture `#{cmd} 2>&1` @@ -269,6 +354,36 @@ class GeneratorTester end end + def verify_all_schema_tables_exist(description) + Dir.chdir(test_dir) do + # Dynamically read required tables from schema file + schema_file = File.join(test_dir, "db/rails_pulse_schema.rb") + if File.exist?(schema_file) + schema_content = File.read(schema_file) + if match = schema_content.match(/required_tables\s*=\s*\[(.*?)\]/m) + table_names = match[1].scan(/:(\w+)/).flatten + + # Check all tables exist + tables_check = table_names.map { |t| "ActiveRecord::Base.connection.table_exists?(:#{t})" }.join(" && ") + result = run_command( + "bin/rails runner -e test \"puts #{tables_check}\"", + capture: true + ) + + if result.strip == "true" + pass "#{description} (#{table_names.size} tables)" + else + fail "#{description} - Some tables missing from: #{table_names.join(', ')}" + end + else + fail "#{description} - Could not parse required_tables from schema" + end + else + fail "#{description} - Schema file not found" + end + end + end + def verify_tags_column_exists(description) Dir.chdir(test_dir) do result = run_command( diff --git a/config/initializers/rails_pulse.rb b/config/initializers/rails_pulse.rb index 82abae1..cc3f69c 100644 --- a/config/initializers/rails_pulse.rb +++ b/config/initializers/rails_pulse.rb @@ -71,6 +71,27 @@ config.ignored_requests = [] config.ignored_queries = [] + # ==================================================================================================== + # TAGGING + # ==================================================================================================== + # Define custom tags for categorizing routes, requests, and queries. + # You can add any custom tags you want for filtering and organization. + # + # Tag names should be in present tense and describe the current state or category. + # Examples of good tag names: + # - "critical" (for high-priority endpoints) + # - "experimental" (for routes under development) + # - "deprecated" (for routes being phased out) + # - "external" (for third-party API calls) + # - "background" (for async job-related operations) + # - "admin" (for administrative routes) + # - "public" (for public-facing routes) + # + # Example configuration: + # config.tags = ["ignored", "critical", "experimental", "deprecated", "external", "admin"] + + config.tags = [ "ignored", "critical", "experimental" ] + # ==================================================================================================== # DATABASE CONFIGURATION # ==================================================================================================== diff --git a/config/routes.rb b/config/routes.rb index 41cb197..6dde145 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,10 @@ end resources :operations, only: %i[show] resources :caches, only: %i[show], as: :cache + + resources :jobs, only: %i[index show], param: :id do + resources :runs, only: %i[index show], controller: "job_runs" + end patch "pagination/limit", to: "application#set_pagination_limit" patch "settings/global_filters", to: "application#set_global_filters" diff --git a/db/migrate/20250930105043_install_rails_pulse_tables.rb b/db/migrate/20250930105043_install_rails_pulse_tables.rb deleted file mode 100644 index a9074ea..0000000 --- a/db/migrate/20250930105043_install_rails_pulse_tables.rb +++ /dev/null @@ -1,23 +0,0 @@ -# Generated from Rails Pulse schema - automatically loads current schema definition -class InstallRailsPulseTables < ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}".to_f] - def change - # Load and execute the Rails Pulse schema directly - # This ensures the migration is always in sync with the schema file - schema_file = File.join(File.dirname(__FILE__), "..", "rails_pulse_schema.rb") - - if File.exist?(schema_file) - say "Loading Rails Pulse schema from db/rails_pulse_schema.rb" - - # Load the schema file to define RailsPulse::Schema - load schema_file - - # Execute the schema in the context of this migration - RailsPulse::Schema.call(connection) - - say "Rails Pulse tables created successfully" - say "The schema file db/rails_pulse_schema.rb remains as your single source of truth" - else - raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb" - end - end -end diff --git a/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb b/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb new file mode 100644 index 0000000..2630cd3 --- /dev/null +++ b/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb @@ -0,0 +1,95 @@ +# Add background job tracking to Rails Pulse +class AddJobsToRailsPulse < ActiveRecord::Migration[7.0] + def up + # Create jobs table for storing job definitions + unless table_exists?(:rails_pulse_jobs) + create_table :rails_pulse_jobs do |t| + t.string :name, null: false, comment: "Job class name" + t.string :queue_name, comment: "Default queue" + t.text :description, comment: "Optional description" + t.integer :runs_count, null: false, default: 0, comment: "Cache of total runs" + t.integer :failures_count, null: false, default: 0, comment: "Cache of failed runs" + t.integer :retries_count, null: false, default: 0, comment: "Cache of retried runs" + t.decimal :avg_duration, precision: 15, scale: 6, comment: "Average duration in milliseconds" + t.text :tags, comment: "JSON array of tags" + t.timestamps + end + + add_index :rails_pulse_jobs, :name, unique: true, name: "index_rails_pulse_jobs_on_name" + add_index :rails_pulse_jobs, :queue_name, name: "index_rails_pulse_jobs_on_queue" + add_index :rails_pulse_jobs, :runs_count, name: "index_rails_pulse_jobs_on_runs_count" + end + + # Create job_runs table for individual job executions + unless table_exists?(:rails_pulse_job_runs) + create_table :rails_pulse_job_runs do |t| + t.references :job, null: false, foreign_key: { to_table: :rails_pulse_jobs }, comment: "Link to job definition" + t.string :run_id, null: false, comment: "Adapter specific run id" + t.decimal :duration, precision: 15, scale: 6, comment: "Execution duration in milliseconds" + t.string :status, null: false, comment: "Execution status" + t.string :error_class, comment: "Error class name" + t.text :error_message, comment: "Error message" + t.integer :attempts, null: false, default: 0, comment: "Retry attempts" + t.timestamp :occurred_at, null: false, comment: "When the job started" + t.timestamp :enqueued_at, comment: "When the job was enqueued" + t.text :arguments, comment: "Serialized arguments" + t.string :adapter, comment: "Queue adapter" + t.text :tags, comment: "Execution tags" + t.timestamps + end + + add_index :rails_pulse_job_runs, :run_id, unique: true, name: "index_rails_pulse_job_runs_on_run_id" + add_index :rails_pulse_job_runs, [ :job_id, :occurred_at ], name: "index_rails_pulse_job_runs_on_job_and_occurred" + add_index :rails_pulse_job_runs, :occurred_at, name: "index_rails_pulse_job_runs_on_occurred_at" + add_index :rails_pulse_job_runs, :status, name: "index_rails_pulse_job_runs_on_status" + add_index :rails_pulse_job_runs, [ :job_id, :status ], name: "index_rails_pulse_job_runs_on_job_and_status" + end + + # Add job_run_id to operations table if it doesn't exist + if table_exists?(:rails_pulse_operations) && !column_exists?(:rails_pulse_operations, :job_run_id) + # Make request_id nullable to allow job operations + change_column_null :rails_pulse_operations, :request_id, true + + # Add job_run_id reference + add_reference :rails_pulse_operations, :job_run, + null: true, + foreign_key: { to_table: :rails_pulse_job_runs }, + comment: "Link to a background job execution" + + # Add check constraint for PostgreSQL and MySQL to ensure either request_id or job_run_id is present + adapter = connection.adapter_name.downcase + if adapter.include?("postgres") || adapter.include?("mysql") + execute <<-SQL + ALTER TABLE rails_pulse_operations + ADD CONSTRAINT rails_pulse_operations_request_or_job_run + CHECK (request_id IS NOT NULL OR job_run_id IS NOT NULL) + SQL + end + end + end + + def down + # Remove check constraint first + adapter = connection.adapter_name.downcase + if adapter.include?("postgres") || adapter.include?("mysql") + execute <<-SQL + ALTER TABLE rails_pulse_operations + DROP CONSTRAINT IF EXISTS rails_pulse_operations_request_or_job_run + SQL + end + + # Remove job_run_id from operations + if column_exists?(:rails_pulse_operations, :job_run_id) + remove_reference :rails_pulse_operations, :job_run, foreign_key: { to_table: :rails_pulse_job_runs } + end + + # Make request_id non-nullable again + if column_exists?(:rails_pulse_operations, :request_id) + change_column_null :rails_pulse_operations, :request_id, false + end + + # Drop job tables + drop_table :rails_pulse_job_runs if table_exists?(:rails_pulse_job_runs) + drop_table :rails_pulse_jobs if table_exists?(:rails_pulse_jobs) + end +end diff --git a/db/rails_pulse_schema.rb b/db/rails_pulse_schema.rb index 71f54e3..e68e0f0 100644 --- a/db/rails_pulse_schema.rb +++ b/db/rails_pulse_schema.rb @@ -3,128 +3,210 @@ # Load with: rails db:schema:load:rails_pulse or db:prepare RailsPulse::Schema = lambda do |connection| + adapter = connection.adapter_name.downcase # Skip if all tables already exist to prevent conflicts - required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries ] + required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries ] - if ENV["CI"] == "true" - existing_tables = required_tables.select { |table| connection.table_exists?(table) } - missing_tables = required_tables - existing_tables - puts "[RailsPulse::Schema] Existing tables: #{existing_tables.join(', ')}" if existing_tables.any? - puts "[RailsPulse::Schema] Missing tables: #{missing_tables.join(', ')}" if missing_tables.any? + # Check which tables already exist + existing_tables = required_tables.select { |table| connection.table_exists?(table) } + missing_tables = required_tables - existing_tables + + # Always log for transparency (not just in CI) + if existing_tables.any? + puts "[RailsPulse::Schema] Existing tables detected: #{existing_tables.join(', ')}" + end + + if missing_tables.any? + puts "[RailsPulse::Schema] Creating missing tables: #{missing_tables.join(', ')}" end - return if required_tables.all? { |table| connection.table_exists?(table) } + # If all tables exist, skip creation entirely + if missing_tables.empty? + puts "[RailsPulse::Schema] All Rails Pulse tables already exist. Skipping schema load." + return + end + + unless connection.table_exists?(:rails_pulse_routes) + connection.create_table :rails_pulse_routes do |t| + t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)" + t.string :path, null: false, comment: "Request path (e.g., /posts/index)" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path" + end - connection.create_table :rails_pulse_routes do |t| - t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)" - t.string :path, null: false, comment: "Request path (e.g., /posts/index)" - t.text :tags, comment: "JSON array of tags for filtering and categorization" - t.timestamps + unless connection.table_exists?(:rails_pulse_queries) + connection.create_table :rails_pulse_queries do |t| + t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)" + t.datetime :analyzed_at, comment: "When query analysis was last performed" + t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution" + t.text :issues, comment: "JSON array of detected performance issues" + t.text :metadata, comment: "JSON object containing query complexity metrics" + t.text :query_stats, comment: "JSON object with query characteristics analysis" + t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection" + t.text :index_recommendations, comment: "JSON array of database index recommendations" + t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results" + t.text :suggestions, comment: "JSON array of optimization recommendations" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191 end - connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path" - - connection.create_table :rails_pulse_queries do |t| - t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)" - t.datetime :analyzed_at, comment: "When query analysis was last performed" - t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution" - t.text :issues, comment: "JSON array of detected performance issues" - t.text :metadata, comment: "JSON object containing query complexity metrics" - t.text :query_stats, comment: "JSON object with query characteristics analysis" - t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection" - t.text :index_recommendations, comment: "JSON array of database index recommendations" - t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results" - t.text :suggestions, comment: "JSON array of optimization recommendations" - t.text :tags, comment: "JSON array of tags for filtering and categorization" - t.timestamps + unless connection.table_exists?(:rails_pulse_requests) + connection.create_table :rails_pulse_requests do |t| + t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds" + t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)" + t.boolean :is_error, null: false, default: false, comment: "True if status >= 500" + t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)" + t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at" + connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid" + connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" end - connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191 - - connection.create_table :rails_pulse_requests do |t| - t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route" - t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds" - t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)" - t.boolean :is_error, null: false, default: false, comment: "True if status >= 500" - t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)" - t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)" - t.timestamp :occurred_at, null: false, comment: "When the request started" - t.text :tags, comment: "JSON array of tags for filtering and categorization" - t.timestamps + unless connection.table_exists?(:rails_pulse_jobs) + connection.create_table :rails_pulse_jobs do |t| + t.string :name, null: false, comment: "Job class name" + t.string :queue_name, comment: "Default queue" + t.text :description, comment: "Optional description" + t.integer :runs_count, null: false, default: 0, comment: "Cache of total runs" + t.integer :failures_count, null: false, default: 0, comment: "Cache of failed runs" + t.integer :retries_count, null: false, default: 0, comment: "Cache of retried runs" + t.decimal :avg_duration, precision: 15, scale: 6, comment: "Average duration in milliseconds" + t.text :tags, comment: "JSON array of tags" + t.timestamps + end + + connection.add_index :rails_pulse_jobs, :name, unique: true, name: "index_rails_pulse_jobs_on_name" + connection.add_index :rails_pulse_jobs, :queue_name, name: "index_rails_pulse_jobs_on_queue" + connection.add_index :rails_pulse_jobs, :runs_count, name: "index_rails_pulse_jobs_on_runs_count" end - connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at" - connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid" - connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" - - connection.create_table :rails_pulse_operations do |t| - t.references :request, null: false, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request" - t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query" - t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)" - t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)" - t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds" - t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)" - t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds" - t.timestamp :occurred_at, null: false, comment: "When the request started" - t.timestamps + unless connection.table_exists?(:rails_pulse_job_runs) + connection.create_table :rails_pulse_job_runs do |t| + t.references :job, null: false, foreign_key: { to_table: :rails_pulse_jobs }, comment: "Link to job definition" + t.string :run_id, null: false, comment: "Adapter specific run id" + t.decimal :duration, precision: 15, scale: 6, comment: "Execution duration in milliseconds" + t.string :status, null: false, comment: "Execution status" + t.string :error_class, comment: "Error class name" + t.text :error_message, comment: "Error message" + t.integer :attempts, null: false, default: 0, comment: "Retry attempts" + t.timestamp :occurred_at, null: false, comment: "When the job started" + t.timestamp :enqueued_at, comment: "When the job was enqueued" + t.text :arguments, comment: "Serialized arguments" + t.string :adapter, comment: "Queue adapter" + t.text :tags, comment: "Execution tags" + t.timestamps + end + + connection.add_index :rails_pulse_job_runs, :run_id, unique: true, name: "index_rails_pulse_job_runs_on_run_id" + connection.add_index :rails_pulse_job_runs, [ :job_id, :occurred_at ], name: "index_rails_pulse_job_runs_on_job_and_occurred" + connection.add_index :rails_pulse_job_runs, :occurred_at, name: "index_rails_pulse_job_runs_on_occurred_at" + connection.add_index :rails_pulse_job_runs, :status, name: "index_rails_pulse_job_runs_on_status" + connection.add_index :rails_pulse_job_runs, [ :job_id, :status ], name: "index_rails_pulse_job_runs_on_job_and_status" end - connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type" - connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at" - connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time" - connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance" - connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type" - - connection.create_table :rails_pulse_summaries do |t| - # Time fields - t.datetime :period_start, null: false, comment: "Start of the aggregation period" - t.datetime :period_end, null: false, comment: "End of the aggregation period" - t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month" - - # Polymorphic association to handle both routes and queries - t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query" - # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query') - # and summarizable_id (route_id or query_id) - - # Universal metrics - t.integer :count, default: 0, null: false, comment: "Total number of requests/operations" - t.float :avg_duration, comment: "Average duration in milliseconds" - t.float :min_duration, comment: "Minimum duration in milliseconds" - t.float :max_duration, comment: "Maximum duration in milliseconds" - t.float :p50_duration, comment: "50th percentile duration" - t.float :p95_duration, comment: "95th percentile duration" - t.float :p99_duration, comment: "99th percentile duration" - t.float :total_duration, comment: "Total duration in milliseconds" - t.float :stddev_duration, comment: "Standard deviation of duration" - - # Request/Route specific metrics - t.integer :error_count, default: 0, comment: "Number of error responses (5xx)" - t.integer :success_count, default: 0, comment: "Number of successful responses" - t.integer :status_2xx, default: 0, comment: "Number of 2xx responses" - t.integer :status_3xx, default: 0, comment: "Number of 3xx responses" - t.integer :status_4xx, default: 0, comment: "Number of 4xx responses" - t.integer :status_5xx, default: 0, comment: "Number of 5xx responses" - - t.timestamps + unless connection.table_exists?(:rails_pulse_operations) + connection.create_table :rails_pulse_operations do |t| + t.references :request, null: true, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request" + t.references :job_run, null: true, foreign_key: { to_table: :rails_pulse_job_runs }, comment: "Link to a background job execution" + t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query" + t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)" + t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds" + t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)" + t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.timestamps + end + + connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type" + connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at" + connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time" + connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance" + connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type" + + if adapter.include?("postgres") || adapter.include?("mysql") + connection.add_check_constraint :rails_pulse_operations, + "(request_id IS NOT NULL OR job_run_id IS NOT NULL)", + name: "rails_pulse_operations_request_or_job_run" + end end - # Unique constraint and indexes for summaries - connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], - unique: true, - name: "idx_pulse_summaries_unique" - connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" - connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + unless connection.table_exists?(:rails_pulse_summaries) + connection.create_table :rails_pulse_summaries do |t| + # Time fields + t.datetime :period_start, null: false, comment: "Start of the aggregation period" + t.datetime :period_end, null: false, comment: "End of the aggregation period" + t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month" + + # Polymorphic association to handle both routes and queries + t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query" + # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query') + # and summarizable_id (route_id or query_id) + + # Universal metrics + t.integer :count, default: 0, null: false, comment: "Total number of requests/operations" + t.float :avg_duration, comment: "Average duration in milliseconds" + t.float :min_duration, comment: "Minimum duration in milliseconds" + t.float :max_duration, comment: "Maximum duration in milliseconds" + t.float :p50_duration, comment: "50th percentile duration" + t.float :p95_duration, comment: "95th percentile duration" + t.float :p99_duration, comment: "99th percentile duration" + t.float :total_duration, comment: "Total duration in milliseconds" + t.float :stddev_duration, comment: "Standard deviation of duration" + + # Request/Route specific metrics + t.integer :error_count, default: 0, comment: "Number of error responses (5xx)" + t.integer :success_count, default: 0, comment: "Number of successful responses" + t.integer :status_2xx, default: 0, comment: "Number of 2xx responses" + t.integer :status_3xx, default: 0, comment: "Number of 3xx responses" + t.integer :status_4xx, default: 0, comment: "Number of 4xx responses" + t.integer :status_5xx, default: 0, comment: "Number of 5xx responses" + + t.timestamps + end + + # Unique constraint and indexes for summaries + connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], + unique: true, + name: "idx_pulse_summaries_unique" + connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" + connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + end # Add indexes to existing tables for efficient aggregation - connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" - connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + unless connection.index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation") + connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" + end + + unless connection.index_exists?(:rails_pulse_requests, :created_at, name: "idx_requests_created_at") + connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + end - connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" - connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + unless connection.index_exists?(:rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation") + connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" + end + + unless connection.index_exists?(:rails_pulse_operations, :created_at, name: "idx_operations_created_at") + connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + end - if ENV["CI"] == "true" - created_tables = required_tables.select { |table| connection.table_exists?(table) } - puts "[RailsPulse::Schema] Successfully created tables: #{created_tables.join(', ')}" + # Log successful creation + created_tables = required_tables.select { |table| connection.table_exists?(table) } + newly_created = created_tables - existing_tables + if newly_created.any? + puts "[RailsPulse::Schema] Successfully created tables: #{newly_created.join(', ')}" end end diff --git a/docs/database_setup.md b/docs/database_setup.md index 634cc8b..d24ca7b 100644 --- a/docs/database_setup.md +++ b/docs/database_setup.md @@ -125,6 +125,72 @@ rails db:migrate:status:rails_pulse The file `db/rails_pulse_schema.rb` is your single source of truth for the database structure. Keep this file even after running migrations - it's used by the upgrade generator to detect missing columns. +### Tables already exist error + +If you see "table already exists" when running migrations, this usually happens when switching between branches or after a database restore. + +**Cause**: The database has tables from a previous installation, but migrations are trying to create them again. + +**Solution for single database:** +```bash +# Option 1: Drop and recreate (development only!) +rails db:drop db:create db:migrate + +# Option 2: Skip to latest migration (if tables are correct) +rails db:migrate:status # Check current state +# If install migration shows as "down" but tables exist, just run: +rails db:migrate # The install migration is now idempotent and will skip existing tables +``` + +**Solution for separate database:** +```bash +# Option 1: Drop and recreate +rails db:drop db:create db:prepare + +# Option 2: The schema file has built-in safety checks +rails db:prepare # Will skip tables that already exist +``` + +**Why this is safe now**: As of v0.3+, both the install migration and schema file check if tables exist before creating them, so running migrations multiple times is safe. + +### Branch switching workflow + +When switching between git branches with different Rails Pulse versions: + +**Single Database Setup:** +```bash +git checkout feature-branch +bundle install +rails db:migrate # Idempotent - safe to run even if tables exist +``` + +**Separate Database Setup:** +```bash +git checkout feature-branch +bundle install +rails db:prepare # Schema file will skip existing tables +``` + +**If you want a clean state:** +```bash +git checkout feature-branch +bundle install +rails db:drop db:create +rails db:migrate # (single DB) or rails db:prepare (separate DB) +rails db:seed +``` + +### Running migrations twice + +As of v0.3+, you can safely run migrations multiple times: + +```bash +rails db:migrate +rails db:migrate # Safe! Will skip tables that already exist +``` + +The install migration checks if Rails Pulse tables exist before creating them, and the schema file has similar safety checks. + ## Architecture ### How Installation Works @@ -141,6 +207,63 @@ The file `db/rails_pulse_schema.rb` is your single source of truth for the datab 3. **Upgrade Generator**: Copies new migration(s) to your app 4. **Rails Migrate**: You run the migration to apply changes +### Schema File Behavior + +The schema file (`db/rails_pulse_schema.rb`) is designed for **fresh installations only**. It has important safety characteristics: + +**What it does:** +- ✅ Creates missing tables +- ✅ Skips tables that already exist +- ✅ Safe to run multiple times +- ✅ Provides logging of what it's creating + +**What it does NOT do:** +- ❌ Add columns to existing tables +- ❌ Modify existing columns +- ❌ Remove columns from tables +- ❌ Change indexes on existing tables + +**Why this matters:** +The schema file represents the "ideal final state" for new installations. For existing installations, **you must use incremental migrations** to modify table structure. + +**Example - Adding a new column:** + +When adding a new feature that requires a database column: + +1. Create an incremental migration in `db/rails_pulse_migrate/`: + ```ruby + # db/rails_pulse_migrate/20250120000000_add_priority_to_jobs.rb + class AddPriorityToJobs < ActiveRecord::Migration[7.0] + def change + unless column_exists?(:rails_pulse_jobs, :priority) + add_column :rails_pulse_jobs, :priority, :integer, default: 0 + end + end + end + ``` + +2. Update the schema file to include the column (for new installations): + ```ruby + # db/rails_pulse_schema.rb + unless connection.table_exists?(:rails_pulse_jobs) + connection.create_table :rails_pulse_jobs do |t| + # ... existing columns ... + t.integer :priority, default: 0 # New column for fresh installs + end + end + ``` + +3. Users run the upgrade generator to get the migration: + ```bash + rails generate rails_pulse:upgrade + rails db:migrate + ``` + +This approach ensures: +- **Fresh installations** get the complete schema with all columns +- **Existing installations** get the incremental migration to add the column +- **Safety** - the schema file never modifies existing tables + ### Benefits - **Clean for new users**: One migration installs everything @@ -148,6 +271,7 @@ The file `db/rails_pulse_schema.rb` is your single source of truth for the datab - **Automatic detection**: Upgrade generator catches skipped migrations - **Standard Rails**: Familiar migration workflow - **Reviewable changes**: See exactly what's changing before running migrations +- **Idempotent**: Schema file and migrations can run multiple times safely ## Examples diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..440580a --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,950 @@ +# Testing Best Practices for RailsPulse + +This document outlines the testing standards and best practices for the RailsPulse project. Follow these guidelines to ensure consistent, maintainable, and reliable tests. + +--- + +## Table of Contents + +1. [Core Principles](#core-principles) +2. [Additional Best Practices](#additional-best-practices) +3. [Test Organization](#test-organization) +4. [Assertion Guidelines](#assertion-guidelines) +5. [Time-Based Testing](#time-based-testing) +6. [Data Management](#data-management) +7. [Edge Cases and Validation](#edge-cases-and-validation) +8. [Examples](#examples) + +--- + +## Core Principles + +### 1. DO NOT test the existence of private methods + +Private methods are implementation details. Only test the public API. + +**❌ Bad:** +```ruby +test "controller has required private methods" do + controller = RailsPulse::JobsController.new + private_methods = controller.private_methods + + assert_includes private_methods, :set_job # DON'T DO THIS +end +``` + +**✅ Good:** +```ruby +test "index action loads successfully" do + get rails_pulse.jobs_path + + assert_response :success + assert_not_nil assigns(:jobs) +end +``` + +--- + +### 2. In general, only test public methods + +Focus on the class's public interface. Test behavior, not implementation. Private methods are tested indirectly through public methods. + +--- + +### 3. Primarily use fixtures for data + +Use existing fixtures when possible. Only create records with ActiveRecord when you need one-off data that doesn't fit fixtures. + +**✅ Good:** +```ruby +test "calculates average for job" do + @job = rails_pulse_jobs(:report_job) + + result = calculate_average(@job) + + assert_equal 100.0, result +end +``` + +**❌ Bad:** +```ruby +test "calculates average for job" do + @job = RailsPulse::Job.create!( + name: "TestJob", + queue_name: "default", + runs_count: 0, + failures_count: 0 + ) + + result = calculate_average(@job) + + assert_equal 100.0, result +end +``` + +**When to create records:** +- Testing with specific edge-case values not in fixtures +- Creating multiple similar records with slight variations +- One-off test scenarios + +--- + +### 4. Do NOT use `rescue` in tests + +Every method should run as expected without error handling. Use `assert_raises` to test exception cases explicitly. + +**❌ Bad:** +```ruby +test "calculates average correctly" do + begin + @job = Job.create!(name: "Test") + result = SomeService.calculate(@job) + assert_equal 100, result.average + rescue => e + assert true # DON'T DO THIS + end +end +``` + +**✅ Good:** +```ruby +test "calculates average correctly" do + @job = rails_pulse_jobs(:report_job) + create_summary(job: @job, count: 10, avg: 100) + + result = SomeService.calculate(@job) + + assert_equal 100, result.average +end + +test "raises error for invalid job" do + assert_raises(ArgumentError) do + SomeService.calculate(nil) + end +end +``` + +--- + +### 5. Do NOT return a passing expectation when something unexpected happens + +Don't use catch-all success assertions. Every assertion should be specific and meaningful. + +**❌ Bad:** +```ruby +test "processes job" do + begin + process_job(@job) + assert true # DON'T DO THIS + rescue + assert false + end +end +``` + +**✅ Good:** +```ruby +test "processes job successfully" do + @job = rails_pulse_jobs(:report_job) + + result = process_job(@job) + + assert_equal "success", result.status + assert_equal 1, @job.reload.runs_count +end +``` + +--- + +## Additional Best Practices + +### 6. Organize tests with comment headers + +Group related tests with clear section comments for better readability: + +```ruby +# Structure Tests + +test "card returns hash with required keys" do + # ... +end + +# Calculation Tests + +test "card calculates average correctly" do + # ... +end + +# Edge Cases + +test "card handles empty data" do + # ... +end +``` + +--- + +### 7. Use descriptive test names + +Follow the pattern: **Subject + Action + Context** + +**✅ Good:** +- `"card calculates average duration for specific job"` +- `"index action loads successfully with pagination"` +- `"breadcrumbs converts numeric segments to resource names using to_breadcrumb for Route"` + +**❌ Bad:** +- `"test 1"` +- `"it works"` +- `"average calculation"` + +--- + +### 8. Use assert_operator for comparisons + +**✅ Good:** +```ruby +assert_operator jobs.size, :<=, 10 +assert_operator current.runs_count, :>=, next_job.runs_count +assert_operator RailsPulse::JobsController, :<, RailsPulse::ApplicationController +``` + +**❌ Bad:** +```ruby +assert jobs.size <= 10 +assert current.runs_count >= next_job.runs_count +``` + +--- + +### 9. Use assert_includes for collection membership + +**✅ Good:** +```ruby +assert_includes job.errors[:name], "can't be blank" +assert_includes result.keys, :summary +assert_includes RailsPulse::JobsController.included_modules, TagFilterConcern +``` + +**❌ Bad:** +```ruby +assert result.keys.include?(:summary) +assert job.errors[:name].include?("can't be blank") +``` + +--- + +### 10. Use assert_in_delta for floating point comparisons + +**✅ Good:** +```ruby +assert_in_delta 50.0, job.failure_rate +assert_in_delta 200.0, job.avg_duration, 0.01 +``` + +**❌ Bad:** +```ruby +assert_equal 50.0, job.failure_rate # Can fail due to floating point precision +``` + +--- + +### 11. Test edge cases comprehensively + +Always test: +- Empty/nil values +- Zero counts +- Missing data +- Boundary conditions + +```ruby +# Edge Cases + +test "handles job with no runs" do + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_equal "0 runs", result[:summary] +end + +test "handles only current window data" do + create_job_summary(job: @job, days_ago: 3, count: 15) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_equal "15 runs", result[:summary] +end + +test "handles 100% failure rate" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 10) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + assert_equal "100.0%", result[:summary] +end +``` + +--- + +### 12. Use travel_to for time-based testing + +Always clean up with `travel_back` in teardown. + +```ruby +def setup + ENV["TEST_TYPE"] = "functional" + super + + @now = Time.current + travel_to @now +end + +def teardown + travel_back + super +end +``` + +--- + +### 13. Create helper methods for repetitive test data setup + +Use named parameters for clarity and flexibility. + +```ruby +private + +def create_job_summary(job:, days_ago:, count:, avg_duration:) + period_start = days_ago.days.ago.beginning_of_day + + RailsPulse::Summary.create!( + summarizable_type: "RailsPulse::Job", + summarizable_id: job.id, + period_start: period_start, + period_end: period_start.end_of_day, + period_type: "day", + count: count, + avg_duration: avg_duration + ) +end +``` + +--- + +### 14. Use ensure blocks to restore configuration changes + +Always restore original configuration values to avoid test pollution. + +```ruby +test "respects custom thresholds" do + original_thresholds = RailsPulse.configuration.job_thresholds.dup + + RailsPulse.configuration.job_thresholds = { slow: 100 } + + # test code here + assert_equal "slow", job.performance_status + +ensure + RailsPulse.configuration.job_thresholds = original_thresholds +end +``` + +--- + +### 15. Document complex calculations with inline comments + +Make tests self-documenting by showing the math inline. + +```ruby +# Current window data (3 days ago: 100ms avg, 10 runs) +create_job_summary(job: @job, days_ago: 3, count: 10, avg_duration: 100.0) + +# Previous window data (10 days ago: 200ms avg, 5 runs) +create_job_summary(job: @job, days_ago: 10, count: 5, avg_duration: 200.0) + +# Total average: (100*10 + 200*5) / (10+5) = 2000/15 = 133.3ms +assert_equal "133 ms", result[:summary] + +# Trend: current 100ms vs previous 200ms = -50% (improvement) +assert_equal "trending-down", result[:trend_icon] +assert_equal "50.0%", result[:trend_amount] +``` + +--- + +### 16. Use each_cons for testing ordered collections + +Guard against single-item collections to avoid errors. + +```ruby +test "index action orders jobs by runs_count desc" do + get rails_pulse.jobs_path + + assert_response :success + jobs = assigns(:jobs) + + # Verify jobs are ordered by runs_count desc + if jobs.size > 1 + jobs.each_cons(2) do |current, next_job| + assert_operator current.runs_count, :>=, next_job.runs_count + end + end +end +``` + +--- + +### 17. Test both positive and negative cases + +Always test validation failures, not just successes. + +```ruby +# Positive case +test "creates valid job" do + job = Job.create!(name: "ValidJob", queue_name: "default") + + assert job.persisted? + assert_equal "ValidJob", job.name +end + +# Negative cases +test "validates presence of name" do + job = Job.new + + assert_not job.valid? + assert_includes job.errors[:name], "can't be blank" +end + +test "validates uniqueness of name" do + existing_job = rails_pulse_jobs(:mailer_job) + duplicate = Job.new(name: existing_job.name) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:name], "has already been taken" +end +``` + +--- + +### 18. Use assert_difference for testing record creation + +Nest multiple `assert_difference` blocks to test multiple record types. + +```ruby +test "track creates job run and operations" do + job = FakeJob.new(job_id: "test-123", queue_name: "default") + + assert_difference -> { RailsPulse::Job.count }, 1 do + assert_difference -> { RailsPulse::JobRun.count }, 1 do + RailsPulse::JobRunCollector.track(job) do + # code that creates records + sql_operation + end + end + end + + job_run = RailsPulse::JobRun.last + assert_equal "success", job_run.status +end +``` + +--- + +### 19. Clean up test state in setup, not just teardown + +Ensure a clean slate before each test. + +```ruby +def setup + ENV["TEST_TYPE"] = "functional" + super + + # Clean up any existing data + RailsPulse::Summary.delete_all + + @job = rails_pulse_jobs(:report_job) + + @now = Time.current + travel_to @now +end + +def teardown + travel_back + super +end +``` + +--- + +### 20. Use refute instead of assert_not for better readability + +**✅ Good:** +```ruby +refute crumbs.first[:current] +refute_includes related_ids, @operation.id +refute_empty jobs +``` + +**Acceptable:** +```ruby +assert_not job.valid? # OK when checking validity +``` + +--- + +## Test Organization + +### Module Nesting + +Match the application structure: + +```ruby +module RailsPulse + module Jobs + module Cards + class AverageDurationTest < ActiveSupport::TestCase + # tests here + end + end + end +end +``` + +### Fixture Declaration + +Declare fixtures explicitly at the top of the test class: + +```ruby +class JobTest < ActiveSupport::TestCase + fixtures :rails_pulse_jobs, :rails_pulse_job_runs + + # tests here +end +``` + +--- + +## Assertion Guidelines + +### Type Checking + +Use `assert_kind_of` for type assertions: + +```ruby +assert_kind_of Hash, result +assert_kind_of Array, available_queues +assert_kind_of String, label +``` + +### Predicate Assertions + +Use predicate methods for clarity: + +```ruby +assert_predicate run, :finalized? +assert_predicate run, :failure_like_status? +assert_not_nil assigns(:jobs) +assert_not_empty RailsPulse::Operation.where(job_run: run) +``` + +### Hash/Object Structure + +Verify structure comprehensively: + +```ruby +assert_kind_of Hash, result +assert_equal "jobs_total_runs", result[:id] +assert_equal "jobs", result[:context] +assert_includes result.keys, :summary +assert_includes result.keys, :chart_data + +# Verify nested structure +assert_kind_of Hash, result[:chart_data] +result[:chart_data].each do |label, data| + assert_kind_of String, label + assert_kind_of Hash, data + assert_includes data.keys, :value +end +``` + +--- + +## Time-Based Testing + +### Freezing Time + +Always freeze time in setup and unfreeze in teardown: + +```ruby +def setup + @now = Time.current + travel_to @now +end + +def teardown + travel_back + super +end +``` + +### Relative Time in Helper Methods + +Use relative time calculations in helper methods: + +```ruby +def create_job_summary(job:, days_ago:, count:, avg_duration:) + period_start = days_ago.days.ago.beginning_of_day + + RailsPulse::Summary.create!( + # ... + period_start: period_start, + period_end: period_start.end_of_day, + # ... + ) +end +``` + +--- + +## Data Management + +### Fixture Usage + +Prefer fixtures over creating records: + +```ruby +# Good - use fixture +@job = rails_pulse_jobs(:report_job) + +# Less ideal - create record +@job = RailsPulse::Job.create!(name: "Test", queue_name: "default") +``` + +### Database Cleanup + +Clean up in setup for test isolation: + +```ruby +def setup + ENV["TEST_TYPE"] = "functional" + super + + RailsPulse::Summary.delete_all + + @job = rails_pulse_jobs(:report_job) +end +``` + +### Using update_columns + +Use `update_columns` to bypass callbacks when setting up test data: + +```ruby +run.update_columns(status: "retried", duration: 200.0) +``` + +--- + +## Edge Cases and Validation + +### Comprehensive Edge Case Testing + +Test all boundary conditions: + +```ruby +# Edge Cases + +test "handles empty collection" do + # ... +end + +test "handles nil values" do + # ... +end + +test "handles zero counts" do + # ... +end + +test "handles maximum values" do + # ... +end + +test "handles only current window data" do + # ... +end + +test "handles only previous window data" do + # ... +end +``` + +### Exception Testing + +Use `assert_raises` for exception testing: + +```ruby +test "raises error for missing resource" do + assert_raises ActiveRecord::RecordNotFound do + get rails_pulse.job_path(999999) + end +end + +test "raises error for invalid arguments" do + assert_raises ArgumentError do + SomeService.process(nil) + end +end +``` + +--- + +## Examples + +### Complete Model Test Example + +```ruby +require "test_helper" + +module RailsPulse + class JobTest < ActiveSupport::TestCase + fixtures :rails_pulse_jobs, :rails_pulse_job_runs + + # Validations + + test "validates presence of name" do + job = Job.new + + assert_not job.valid? + assert_includes job.errors[:name], "can't be blank" + end + + test "validates uniqueness of name" do + existing_job = rails_pulse_jobs(:mailer_job) + duplicate = Job.new(name: existing_job.name) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:name], "has already been taken" + end + + # Associations + + test "has many runs" do + job = rails_pulse_jobs(:report_job) + + assert_respond_to job, :runs + assert_kind_of ActiveRecord::Associations::CollectionProxy, job.runs + end + + # Methods + + test "calculates failure rate correctly" do + job = rails_pulse_jobs(:report_job) + job.update!(runs_count: 100, failures_count: 25) + + assert_in_delta 25.0, job.failure_rate + end + + test "calculates failure rate as zero when no runs" do + job = Job.create!(name: "NewJob", queue_name: "default") + + assert_equal 0.0, job.failure_rate + end + end +end +``` + +### Complete Card Test Example + +```ruby +require "test_helper" + +module RailsPulse + module Jobs + module Cards + class AverageDurationTest < ActiveSupport::TestCase + fixtures :rails_pulse_jobs + + def setup + ENV["TEST_TYPE"] = "functional" + super + @job = rails_pulse_jobs(:report_job) + + RailsPulse::Summary.delete_all + + @now = Time.current + travel_to @now + end + + def teardown + travel_back + super + end + + # Structure Tests + + test "card returns hash with required keys" do + card = AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_kind_of Hash, result + assert_equal "jobs_average_duration", result[:id] + assert_includes result.keys, :summary + assert_includes result.keys, :chart_data + end + + # Calculation Tests + + test "card calculates average duration for specific job" do + # Current window (3 days ago: 100ms avg, 10 runs) + create_job_summary(job: @job, days_ago: 3, count: 10, avg_duration: 100.0) + + # Previous window (10 days ago: 200ms avg, 5 runs) + create_job_summary(job: @job, days_ago: 10, count: 5, avg_duration: 200.0) + + card = AverageDuration.new(job: @job) + result = card.to_metric_card + + # Total: (100*10 + 200*5) / 15 = 133.3ms + assert_equal "133 ms", result[:summary] + + # Trend: 100ms vs 200ms = -50% + assert_equal "trending-down", result[:trend_icon] + assert_equal "50.0%", result[:trend_amount] + end + + # Edge Cases + + test "card handles job with no summaries" do + card = AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_equal "0 ms", result[:summary] + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + private + + def create_job_summary(job:, days_ago:, count:, avg_duration:) + period_start = days_ago.days.ago.beginning_of_day + + RailsPulse::Summary.create!( + summarizable_type: "RailsPulse::Job", + summarizable_id: job.id, + period_start: period_start, + period_end: period_start.end_of_day, + period_type: "day", + count: count, + avg_duration: avg_duration + ) + end + end + end + end +end +``` + +--- + +## Summary Checklist + +Before submitting a test file, verify: + +- [ ] Tests only public methods (no private method existence tests) +- [ ] Uses fixtures where possible +- [ ] No `rescue` blocks in tests +- [ ] All assertions are specific and meaningful +- [ ] Tests are organized with comment headers +- [ ] Test names are descriptive (Subject + Action + Context) +- [ ] Uses `assert_operator` for comparisons +- [ ] Uses `assert_includes` for collection membership +- [ ] Uses `assert_in_delta` for floating point comparisons +- [ ] Edge cases are tested (nil, empty, zero, boundaries) +- [ ] Time-based tests use `travel_to` with cleanup +- [ ] Helper methods created for repetitive setup +- [ ] Configuration changes restored with `ensure` blocks +- [ ] Complex calculations documented with comments +- [ ] Both positive and negative cases tested +- [ ] Tests pass consistently across different random seeds + +--- + +## Running Tests Across Multiple Environments + +RailsPulse is tested against multiple Rails versions and database adapters to ensure compatibility. Before submitting changes, verify that tests pass across all supported configurations. + +### Supported Configurations + +**Rails Versions**: See the `Appraisals` file in the project root for all supported Rails versions. + +**Database Adapters**: SQLite3, PostgreSQL, MySQL2 + +### Running Tests + +#### Single Database + +Run tests with a specific database adapter using the `DB` environment variable: + +```bash +# SQLite3 (default) +rails test + +# PostgreSQL +DB=postgresql rails test + +# MySQL2 +DB=mysql2 rails test +``` + +#### Test Matrix + +The `test_matrix` rake task runs the complete test suite across all combinations of Rails versions and database adapters: + +```bash +rake test_matrix +``` + +This executes tests for: +- Each Rails version in `Appraisals` +- Each database adapter (SQLite3, PostgreSQL, MySQL2) + +**Total combinations**: 6 (2 Rails versions × 3 databases) + +### Database-Specific Considerations + +#### PostgreSQL + +Requires PostgreSQL server running locally. Configure connection with environment variables: + +```bash +export POSTGRES_USERNAME=postgres +export POSTGRES_PASSWORD=postgres +export POSTGRES_HOST=localhost +export POSTGRES_PORT=5432 +``` + +#### MySQL + +Requires MySQL server running locally. Configure connection with environment variables: + +```bash +export MYSQL_USERNAME=root +export MYSQL_PASSWORD=root +export MYSQL_HOST=localhost +export MYSQL_PORT=3306 +``` + +#### SQLite3 + +No server required. Uses in-memory database for tests by default. + +### Continuous Integration + +All pull requests should pass the complete test matrix. Local development can use any single database, but verify multi-database compatibility before submitting PRs. + +--- + +**Last Updated:** 2025-10-29 diff --git a/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb b/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb index 71f54e3..e68e0f0 100644 --- a/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +++ b/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb @@ -3,128 +3,210 @@ # Load with: rails db:schema:load:rails_pulse or db:prepare RailsPulse::Schema = lambda do |connection| + adapter = connection.adapter_name.downcase # Skip if all tables already exist to prevent conflicts - required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries ] + required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries ] - if ENV["CI"] == "true" - existing_tables = required_tables.select { |table| connection.table_exists?(table) } - missing_tables = required_tables - existing_tables - puts "[RailsPulse::Schema] Existing tables: #{existing_tables.join(', ')}" if existing_tables.any? - puts "[RailsPulse::Schema] Missing tables: #{missing_tables.join(', ')}" if missing_tables.any? + # Check which tables already exist + existing_tables = required_tables.select { |table| connection.table_exists?(table) } + missing_tables = required_tables - existing_tables + + # Always log for transparency (not just in CI) + if existing_tables.any? + puts "[RailsPulse::Schema] Existing tables detected: #{existing_tables.join(', ')}" + end + + if missing_tables.any? + puts "[RailsPulse::Schema] Creating missing tables: #{missing_tables.join(', ')}" end - return if required_tables.all? { |table| connection.table_exists?(table) } + # If all tables exist, skip creation entirely + if missing_tables.empty? + puts "[RailsPulse::Schema] All Rails Pulse tables already exist. Skipping schema load." + return + end + + unless connection.table_exists?(:rails_pulse_routes) + connection.create_table :rails_pulse_routes do |t| + t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)" + t.string :path, null: false, comment: "Request path (e.g., /posts/index)" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path" + end - connection.create_table :rails_pulse_routes do |t| - t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)" - t.string :path, null: false, comment: "Request path (e.g., /posts/index)" - t.text :tags, comment: "JSON array of tags for filtering and categorization" - t.timestamps + unless connection.table_exists?(:rails_pulse_queries) + connection.create_table :rails_pulse_queries do |t| + t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)" + t.datetime :analyzed_at, comment: "When query analysis was last performed" + t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution" + t.text :issues, comment: "JSON array of detected performance issues" + t.text :metadata, comment: "JSON object containing query complexity metrics" + t.text :query_stats, comment: "JSON object with query characteristics analysis" + t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection" + t.text :index_recommendations, comment: "JSON array of database index recommendations" + t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results" + t.text :suggestions, comment: "JSON array of optimization recommendations" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191 end - connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path" - - connection.create_table :rails_pulse_queries do |t| - t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)" - t.datetime :analyzed_at, comment: "When query analysis was last performed" - t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution" - t.text :issues, comment: "JSON array of detected performance issues" - t.text :metadata, comment: "JSON object containing query complexity metrics" - t.text :query_stats, comment: "JSON object with query characteristics analysis" - t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection" - t.text :index_recommendations, comment: "JSON array of database index recommendations" - t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results" - t.text :suggestions, comment: "JSON array of optimization recommendations" - t.text :tags, comment: "JSON array of tags for filtering and categorization" - t.timestamps + unless connection.table_exists?(:rails_pulse_requests) + connection.create_table :rails_pulse_requests do |t| + t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds" + t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)" + t.boolean :is_error, null: false, default: false, comment: "True if status >= 500" + t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)" + t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at" + connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid" + connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" end - connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191 - - connection.create_table :rails_pulse_requests do |t| - t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route" - t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds" - t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)" - t.boolean :is_error, null: false, default: false, comment: "True if status >= 500" - t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)" - t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)" - t.timestamp :occurred_at, null: false, comment: "When the request started" - t.text :tags, comment: "JSON array of tags for filtering and categorization" - t.timestamps + unless connection.table_exists?(:rails_pulse_jobs) + connection.create_table :rails_pulse_jobs do |t| + t.string :name, null: false, comment: "Job class name" + t.string :queue_name, comment: "Default queue" + t.text :description, comment: "Optional description" + t.integer :runs_count, null: false, default: 0, comment: "Cache of total runs" + t.integer :failures_count, null: false, default: 0, comment: "Cache of failed runs" + t.integer :retries_count, null: false, default: 0, comment: "Cache of retried runs" + t.decimal :avg_duration, precision: 15, scale: 6, comment: "Average duration in milliseconds" + t.text :tags, comment: "JSON array of tags" + t.timestamps + end + + connection.add_index :rails_pulse_jobs, :name, unique: true, name: "index_rails_pulse_jobs_on_name" + connection.add_index :rails_pulse_jobs, :queue_name, name: "index_rails_pulse_jobs_on_queue" + connection.add_index :rails_pulse_jobs, :runs_count, name: "index_rails_pulse_jobs_on_runs_count" end - connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at" - connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid" - connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" - - connection.create_table :rails_pulse_operations do |t| - t.references :request, null: false, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request" - t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query" - t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)" - t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)" - t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds" - t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)" - t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds" - t.timestamp :occurred_at, null: false, comment: "When the request started" - t.timestamps + unless connection.table_exists?(:rails_pulse_job_runs) + connection.create_table :rails_pulse_job_runs do |t| + t.references :job, null: false, foreign_key: { to_table: :rails_pulse_jobs }, comment: "Link to job definition" + t.string :run_id, null: false, comment: "Adapter specific run id" + t.decimal :duration, precision: 15, scale: 6, comment: "Execution duration in milliseconds" + t.string :status, null: false, comment: "Execution status" + t.string :error_class, comment: "Error class name" + t.text :error_message, comment: "Error message" + t.integer :attempts, null: false, default: 0, comment: "Retry attempts" + t.timestamp :occurred_at, null: false, comment: "When the job started" + t.timestamp :enqueued_at, comment: "When the job was enqueued" + t.text :arguments, comment: "Serialized arguments" + t.string :adapter, comment: "Queue adapter" + t.text :tags, comment: "Execution tags" + t.timestamps + end + + connection.add_index :rails_pulse_job_runs, :run_id, unique: true, name: "index_rails_pulse_job_runs_on_run_id" + connection.add_index :rails_pulse_job_runs, [ :job_id, :occurred_at ], name: "index_rails_pulse_job_runs_on_job_and_occurred" + connection.add_index :rails_pulse_job_runs, :occurred_at, name: "index_rails_pulse_job_runs_on_occurred_at" + connection.add_index :rails_pulse_job_runs, :status, name: "index_rails_pulse_job_runs_on_status" + connection.add_index :rails_pulse_job_runs, [ :job_id, :status ], name: "index_rails_pulse_job_runs_on_job_and_status" end - connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type" - connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at" - connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time" - connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance" - connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type" - - connection.create_table :rails_pulse_summaries do |t| - # Time fields - t.datetime :period_start, null: false, comment: "Start of the aggregation period" - t.datetime :period_end, null: false, comment: "End of the aggregation period" - t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month" - - # Polymorphic association to handle both routes and queries - t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query" - # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query') - # and summarizable_id (route_id or query_id) - - # Universal metrics - t.integer :count, default: 0, null: false, comment: "Total number of requests/operations" - t.float :avg_duration, comment: "Average duration in milliseconds" - t.float :min_duration, comment: "Minimum duration in milliseconds" - t.float :max_duration, comment: "Maximum duration in milliseconds" - t.float :p50_duration, comment: "50th percentile duration" - t.float :p95_duration, comment: "95th percentile duration" - t.float :p99_duration, comment: "99th percentile duration" - t.float :total_duration, comment: "Total duration in milliseconds" - t.float :stddev_duration, comment: "Standard deviation of duration" - - # Request/Route specific metrics - t.integer :error_count, default: 0, comment: "Number of error responses (5xx)" - t.integer :success_count, default: 0, comment: "Number of successful responses" - t.integer :status_2xx, default: 0, comment: "Number of 2xx responses" - t.integer :status_3xx, default: 0, comment: "Number of 3xx responses" - t.integer :status_4xx, default: 0, comment: "Number of 4xx responses" - t.integer :status_5xx, default: 0, comment: "Number of 5xx responses" - - t.timestamps + unless connection.table_exists?(:rails_pulse_operations) + connection.create_table :rails_pulse_operations do |t| + t.references :request, null: true, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request" + t.references :job_run, null: true, foreign_key: { to_table: :rails_pulse_job_runs }, comment: "Link to a background job execution" + t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query" + t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)" + t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds" + t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)" + t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.timestamps + end + + connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type" + connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at" + connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time" + connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance" + connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type" + + if adapter.include?("postgres") || adapter.include?("mysql") + connection.add_check_constraint :rails_pulse_operations, + "(request_id IS NOT NULL OR job_run_id IS NOT NULL)", + name: "rails_pulse_operations_request_or_job_run" + end end - # Unique constraint and indexes for summaries - connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], - unique: true, - name: "idx_pulse_summaries_unique" - connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" - connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + unless connection.table_exists?(:rails_pulse_summaries) + connection.create_table :rails_pulse_summaries do |t| + # Time fields + t.datetime :period_start, null: false, comment: "Start of the aggregation period" + t.datetime :period_end, null: false, comment: "End of the aggregation period" + t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month" + + # Polymorphic association to handle both routes and queries + t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query" + # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query') + # and summarizable_id (route_id or query_id) + + # Universal metrics + t.integer :count, default: 0, null: false, comment: "Total number of requests/operations" + t.float :avg_duration, comment: "Average duration in milliseconds" + t.float :min_duration, comment: "Minimum duration in milliseconds" + t.float :max_duration, comment: "Maximum duration in milliseconds" + t.float :p50_duration, comment: "50th percentile duration" + t.float :p95_duration, comment: "95th percentile duration" + t.float :p99_duration, comment: "99th percentile duration" + t.float :total_duration, comment: "Total duration in milliseconds" + t.float :stddev_duration, comment: "Standard deviation of duration" + + # Request/Route specific metrics + t.integer :error_count, default: 0, comment: "Number of error responses (5xx)" + t.integer :success_count, default: 0, comment: "Number of successful responses" + t.integer :status_2xx, default: 0, comment: "Number of 2xx responses" + t.integer :status_3xx, default: 0, comment: "Number of 3xx responses" + t.integer :status_4xx, default: 0, comment: "Number of 4xx responses" + t.integer :status_5xx, default: 0, comment: "Number of 5xx responses" + + t.timestamps + end + + # Unique constraint and indexes for summaries + connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], + unique: true, + name: "idx_pulse_summaries_unique" + connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" + connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + end # Add indexes to existing tables for efficient aggregation - connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" - connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + unless connection.index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation") + connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" + end + + unless connection.index_exists?(:rails_pulse_requests, :created_at, name: "idx_requests_created_at") + connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + end - connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" - connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + unless connection.index_exists?(:rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation") + connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" + end + + unless connection.index_exists?(:rails_pulse_operations, :created_at, name: "idx_operations_created_at") + connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + end - if ENV["CI"] == "true" - created_tables = required_tables.select { |table| connection.table_exists?(table) } - puts "[RailsPulse::Schema] Successfully created tables: #{created_tables.join(', ')}" + # Log successful creation + created_tables = required_tables.select { |table| connection.table_exists?(table) } + newly_created = created_tables - existing_tables + if newly_created.any? + puts "[RailsPulse::Schema] Successfully created tables: #{newly_created.join(', ')}" end end diff --git a/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb b/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb index 55dd1b1..6b8f38f 100644 --- a/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +++ b/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb @@ -1,6 +1,12 @@ # Generated from Rails Pulse schema - automatically loads current schema definition class InstallRailsPulseTables < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>] - def change + def up + # Check if Rails Pulse is already installed + if rails_pulse_installed? + say "Rails Pulse tables already exist. Skipping installation.", :yellow + return + end + # Load and execute the Rails Pulse schema directly # This ensures the migration is always in sync with the schema file schema_file = File.join(::Rails.root.to_s, "db/rails_pulse_schema.rb") @@ -20,4 +26,27 @@ def change raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb" end end + + def down + # Rollback: drop all Rails Pulse tables in reverse dependency order + say "Dropping Rails Pulse tables..." + + drop_table :rails_pulse_operations if table_exists?(:rails_pulse_operations) + drop_table :rails_pulse_job_runs if table_exists?(:rails_pulse_job_runs) + drop_table :rails_pulse_jobs if table_exists?(:rails_pulse_jobs) + drop_table :rails_pulse_summaries if table_exists?(:rails_pulse_summaries) + drop_table :rails_pulse_requests if table_exists?(:rails_pulse_requests) + drop_table :rails_pulse_routes if table_exists?(:rails_pulse_routes) + drop_table :rails_pulse_queries if table_exists?(:rails_pulse_queries) + + say "Rails Pulse tables dropped successfully" + end + + private + + def rails_pulse_installed? + # Check if core Rails Pulse tables exist + # We check for routes and requests as they are foundational tables + table_exists?(:rails_pulse_routes) && table_exists?(:rails_pulse_requests) + end end \ No newline at end of file diff --git a/lib/rails_pulse/active_job_extensions.rb b/lib/rails_pulse/active_job_extensions.rb new file mode 100644 index 0000000..ee2b01c --- /dev/null +++ b/lib/rails_pulse/active_job_extensions.rb @@ -0,0 +1,13 @@ +module RailsPulse + module ActiveJobExtensions + extend ActiveSupport::Concern + + included do + around_perform do |job, block| + RailsPulse::JobRunCollector.track(job) do + block.call + end + end + end + end +end diff --git a/lib/rails_pulse/adapters/delayed_job_plugin.rb b/lib/rails_pulse/adapters/delayed_job_plugin.rb new file mode 100644 index 0000000..cf9f521 --- /dev/null +++ b/lib/rails_pulse/adapters/delayed_job_plugin.rb @@ -0,0 +1,25 @@ +module RailsPulse + module Adapters + class DelayedJobPlugin < Delayed::Plugin + callbacks do |lifecycle| + lifecycle.around(:perform) do |worker, job_data, &block| + next block.call(worker, job_data) unless RailsPulse.configuration.enabled + next block.call(worker, job_data) unless RailsPulse.configuration.track_jobs + + job_wrapper = JobWrapper.new( + job_id: job_data.id.to_s, + class_name: job_data.payload_object.class.name, + queue_name: job_data.queue, + arguments: job_data.payload_object.args, + enqueued_at: job_data.created_at, + executions: job_data.attempts + ) + + RailsPulse::JobRunCollector.track(job_wrapper, adapter: "delayed_job") do + block.call(worker, job_data) + end + end + end + end + end +end diff --git a/lib/rails_pulse/adapters/sidekiq_middleware.rb b/lib/rails_pulse/adapters/sidekiq_middleware.rb new file mode 100644 index 0000000..e78abeb --- /dev/null +++ b/lib/rails_pulse/adapters/sidekiq_middleware.rb @@ -0,0 +1,41 @@ +module RailsPulse + module Adapters + class SidekiqMiddleware + def call(worker, job_data, queue) + return yield unless RailsPulse.configuration.enabled + return yield unless RailsPulse.configuration.track_jobs + + # Create ActiveJob-like wrapper for tracking + job_wrapper = JobWrapper.new( + job_id: job_data["jid"], + class_name: worker.class.name, + queue_name: queue, + arguments: job_data["args"], + enqueued_at: Time.at(job_data["enqueued_at"] || Time.current.to_f), + executions: job_data["retry_count"] || 0 + ) + + RailsPulse::JobRunCollector.track(job_wrapper, adapter: "sidekiq") do + yield + end + end + end + + class JobWrapper + attr_reader :job_id, :queue_name, :arguments, :enqueued_at, :executions + + def initialize(job_id:, class_name:, queue_name:, arguments:, enqueued_at:, executions:) + @job_id = job_id + @class_name = class_name + @queue_name = queue_name + @arguments = arguments + @enqueued_at = enqueued_at + @executions = executions + end + + def class + OpenStruct.new(name: @class_name) + end + end + end +end diff --git a/lib/rails_pulse/cleanup_service.rb b/lib/rails_pulse/cleanup_service.rb index f02be41..29e65d1 100644 --- a/lib/rails_pulse/cleanup_service.rb +++ b/lib/rails_pulse/cleanup_service.rb @@ -39,9 +39,11 @@ def perform_time_based_cleanup # Clean up in order that respects foreign key constraints @stats[:time_based][:operations] = cleanup_operations_by_time(cutoff_time) + @stats[:time_based][:job_runs] = cleanup_job_runs_by_time(cutoff_time) @stats[:time_based][:requests] = cleanup_requests_by_time(cutoff_time) @stats[:time_based][:queries] = cleanup_queries_by_time(cutoff_time) @stats[:time_based][:routes] = cleanup_routes_by_time(cutoff_time) + @stats[:time_based][:jobs] = cleanup_jobs_by_time(cutoff_time) end def perform_count_based_cleanup @@ -51,9 +53,11 @@ def perform_count_based_cleanup # Clean up in order that respects foreign key constraints @stats[:count_based][:operations] = cleanup_operations_by_count + @stats[:count_based][:job_runs] = cleanup_job_runs_by_count @stats[:count_based][:requests] = cleanup_requests_by_count @stats[:count_based][:queries] = cleanup_queries_by_count @stats[:count_based][:routes] = cleanup_routes_by_count + @stats[:count_based][:jobs] = cleanup_jobs_by_count end # Time-based cleanup methods @@ -110,6 +114,27 @@ def cleanup_routes_by_time(cutoff_time) count end + def cleanup_job_runs_by_time(cutoff_time) + return 0 unless defined?(RailsPulse::JobRun) + + job_runs = RailsPulse::JobRun.where("occurred_at < ?", cutoff_time) + count = job_runs.count + job_runs.delete_all + count + end + + def cleanup_jobs_by_time(cutoff_time) + return 0 unless defined?(RailsPulse::Job) + + job_ids_with_runs = RailsPulse::JobRun.distinct.pluck(:job_id).compact + jobs = RailsPulse::Job + .where("created_at < ?", cutoff_time) + .where.not(id: job_ids_with_runs) + count = jobs.count + jobs.delete_all + count + end + # Count-based cleanup methods def cleanup_operations_by_count return 0 unless defined?(RailsPulse::Operation) @@ -196,6 +221,46 @@ def cleanup_routes_by_count records_to_delete end + def cleanup_job_runs_by_count + return 0 unless defined?(RailsPulse::JobRun) + + max_records = @config.max_table_records[:rails_pulse_job_runs] + return 0 unless max_records + + current_count = RailsPulse::JobRun.count + return 0 if current_count <= max_records + + records_to_delete = current_count - max_records + ids_to_delete = RailsPulse::JobRun + .order(:occurred_at) + .limit(records_to_delete) + .pluck(:id) + + RailsPulse::JobRun.where(id: ids_to_delete).delete_all + records_to_delete + end + + def cleanup_jobs_by_count + return 0 unless defined?(RailsPulse::Job) + + max_records = @config.max_table_records[:rails_pulse_jobs] + return 0 unless max_records + + job_ids_with_runs = RailsPulse::JobRun.distinct.pluck(:job_id).compact + available_jobs = RailsPulse::Job.where.not(id: job_ids_with_runs) + current_count = available_jobs.count + return 0 if current_count <= max_records + + records_to_delete = current_count - max_records + ids_to_delete = available_jobs + .order(:created_at) + .limit(records_to_delete) + .pluck(:id) + + RailsPulse::Job.where(id: ids_to_delete).delete_all + records_to_delete + end + def log_cleanup_summary total_time_based = @stats[:time_based].values.sum total_count_based = @stats[:count_based].values.sum diff --git a/lib/rails_pulse/configuration.rb b/lib/rails_pulse/configuration.rb index 65642cc..106760f 100644 --- a/lib/rails_pulse/configuration.rb +++ b/lib/rails_pulse/configuration.rb @@ -4,10 +4,14 @@ class Configuration :route_thresholds, :request_thresholds, :query_thresholds, + :job_thresholds, :ignored_routes, :ignored_requests, :ignored_queries, + :ignored_jobs, + :ignored_queues, :track_assets, + :track_jobs, :custom_asset_patterns, :mount_path, :full_retention_period, @@ -17,32 +21,50 @@ class Configuration :authentication_enabled, :authentication_method, :authentication_redirect_path, - :tags + :tags, + :job_tracking_mode, + :job_adapters, + :capture_job_arguments def initialize @enabled = true @route_thresholds = { slow: 500, very_slow: 1500, critical: 3000 } @request_thresholds = { slow: 700, very_slow: 2000, critical: 4000 } @query_thresholds = { slow: 100, very_slow: 500, critical: 1000 } + @job_thresholds = { slow: 5_000, very_slow: 30_000, critical: 60_000 } @ignored_routes = [] @ignored_requests = [] @ignored_queries = [] + @ignored_jobs = [] + @ignored_queues = [] @track_assets = false + @track_jobs = true @custom_asset_patterns = [] @mount_path = nil - @full_retention_period = 2.weeks + @full_retention_period = 30.days @archiving_enabled = true @max_table_records = { - rails_pulse_requests: 10000, - rails_pulse_operations: 50000, - rails_pulse_routes: 1000, - rails_pulse_queries: 500 + rails_pulse_operations: 100_000, + rails_pulse_requests: 50_000, + rails_pulse_job_runs: 50_000, + rails_pulse_queries: 10_000, + rails_pulse_routes: 1_000, + rails_pulse_jobs: 1_000 } @connects_to = nil @authentication_enabled = Rails.env.production? @authentication_method = nil @authentication_redirect_path = "/" @tags = [ "ignored", "critical", "experimental" ] + @job_tracking_mode = :universal + @job_adapters = { + sidekiq: { enabled: true, track_queue_depth: false }, + solid_queue: { enabled: true, track_recurring: false }, + good_job: { enabled: true, track_cron: false }, + delayed_job: { enabled: true }, + resque: { enabled: true } + } + @capture_job_arguments = false validate_configuration! end @@ -67,6 +89,7 @@ def validate_configuration! validate_database_settings! validate_authentication_settings! validate_tags! + validate_job_settings! end # Revalidate configuration after changes @@ -77,7 +100,7 @@ def revalidate! private def validate_thresholds! - [ @route_thresholds, @request_thresholds, @query_thresholds ].each do |thresholds| + [ @route_thresholds, @request_thresholds, @query_thresholds, @job_thresholds ].each do |thresholds| thresholds.each do |key, value| unless value.is_a?(Numeric) && value > 0 raise ArgumentError, "Threshold #{key} must be a positive number, got #{value}" @@ -150,6 +173,32 @@ def validate_tags! end end + def validate_job_settings! + unless @ignored_jobs.is_a?(Array) && @ignored_queues.is_a?(Array) + raise ArgumentError, "ignored_jobs and ignored_queues must be arrays" + end + + unless [ true, false ].include?(@track_jobs) + raise ArgumentError, "track_jobs must be a boolean" + end + + unless @job_adapters.is_a?(Hash) + raise ArgumentError, "job_adapters must be a hash" + end + + unless @job_thresholds.is_a?(Hash) + raise ArgumentError, "job_thresholds must be a hash" + end + + unless @job_tracking_mode.is_a?(Symbol) + raise ArgumentError, "job_tracking_mode must be a symbol" + end + + unless [ true, false ].include?(@capture_job_arguments) + raise ArgumentError, "capture_job_arguments must be a boolean" + end + end + # Default patterns for common asset types and paths def default_asset_patterns [ diff --git a/lib/rails_pulse/engine.rb b/lib/rails_pulse/engine.rb index 463f973..869a07c 100644 --- a/lib/rails_pulse/engine.rb +++ b/lib/rails_pulse/engine.rb @@ -2,6 +2,8 @@ require "rails_pulse/middleware/request_collector" require "rails_pulse/middleware/asset_server" require "rails_pulse/subscribers/operation_subscriber" +require "rails_pulse/job_run_collector" +require "rails_pulse/active_job_extensions" require "request_store" require "rack/static" require "ransack" @@ -49,6 +51,30 @@ class Engine < ::Rails::Engine # Ensure Ransack is loaded before our models end + initializer "rails_pulse.active_job" do + ActiveSupport.on_load(:active_job) do + include RailsPulse::ActiveJobExtensions + end + end + + initializer "rails_pulse.configure_sidekiq", after: "rails_pulse.active_job" do + if defined?(Sidekiq) && RailsPulse.configuration.job_adapters.dig(:sidekiq, :enabled) + require "rails_pulse/adapters/sidekiq_middleware" + Sidekiq.configure_server do |config| + config.server_middleware do |chain| + chain.add RailsPulse::Adapters::SidekiqMiddleware + end + end + end + end + + initializer "rails_pulse.configure_delayed_job", after: "rails_pulse.active_job" do + if defined?(Delayed::Job) && RailsPulse.configuration.job_adapters.dig(:delayed_job, :enabled) + require "rails_pulse/adapters/delayed_job_plugin" + Delayed::Worker.plugins << RailsPulse::Adapters::DelayedJobPlugin + end + end + initializer "rails_pulse.database_configuration", before: "active_record.initialize_timezone" do # Ensure database configuration is applied early in the initialization process # This allows models to properly connect to configured databases diff --git a/lib/rails_pulse/job_run_collector.rb b/lib/rails_pulse/job_run_collector.rb new file mode 100644 index 0000000..cb53021 --- /dev/null +++ b/lib/rails_pulse/job_run_collector.rb @@ -0,0 +1,172 @@ +require "securerandom" + +module RailsPulse + class JobRunCollector + class << self + def track(active_job, adapter: detect_adapter) + return yield unless tracking_enabled? + return yield if ignore_job?(active_job) + + previous_request_id = RequestStore.store[:rails_pulse_request_id] + previous_operations = RequestStore.store[:rails_pulse_operations] + previous_job_run_id = RequestStore.store[:rails_pulse_job_run_id] + + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + occurred_at = Time.current + + job = nil + job_run = nil + + with_recording_suppressed do + job = find_or_create_job(active_job) + job_run = create_job_run(job, active_job, adapter, occurred_at) + end + + RequestStore.store[:rails_pulse_request_id] = nil + RequestStore.store[:rails_pulse_job_run_id] = job_run.id + RequestStore.store[:rails_pulse_operations] = [] + + yield + + duration = elapsed_time_ms(start_time) + with_recording_suppressed do + job_run.update!(status: "success", duration: duration) + end + rescue => error + duration = elapsed_time_ms(start_time) + with_recording_suppressed do + job_run.update!( + status: failure_status_for(error), + duration: duration, + error_class: error.class.name, + error_message: error.message + ) if job_run + end + raise + ensure + begin + save_operations(job_run) + rescue => e + Rails.logger.error "[RailsPulse] Failed to persist job operations: #{e.class} - #{e.message}" + ensure + RequestStore.store[:rails_pulse_job_run_id] = previous_job_run_id + RequestStore.store[:rails_pulse_operations] = previous_operations + RequestStore.store[:rails_pulse_request_id] = previous_request_id + end + end + + def should_ignore_job?(job) + ignore_job?(job) + end + + private + + def tracking_enabled? + config = RailsPulse.configuration + config.enabled && config.track_jobs + end + + def ignore_job?(job) + config = RailsPulse.configuration + job_class = job.class.name + queue_name = job.queue_name + + return true if config.ignored_jobs&.include?(job_class) + return true if config.ignored_queues&.include?(queue_name) + return true if job_class.start_with?("RailsPulse::") + + false + end + + def find_or_create_job(active_job) + RailsPulse::Job.find_or_create_by!(name: active_job.class.name) do |job| + job.queue_name = active_job.queue_name + end + end + + def create_job_run(job, active_job, adapter, occurred_at) + RailsPulse::JobRun.create!( + job: job, + run_id: active_job.job_id || SecureRandom.uuid, + status: initial_status_for(active_job), + enqueued_at: safe_timestamp(active_job.try(:enqueued_at)), + occurred_at: occurred_at, + attempts: (active_job.respond_to?(:executions) ? active_job.executions : 0), + adapter: adapter, + arguments: serialized_arguments(active_job) + ) + end + + def serialized_arguments(active_job) + return unless RailsPulse.configuration.capture_job_arguments + + Array(active_job.arguments).to_json + rescue StandardError => e + Rails.logger.debug "[RailsPulse] Unable to serialize job arguments: #{e.class} - #{e.message}" + nil + end + + def initial_status_for(active_job) + active_job.respond_to?(:scheduled_at) ? "enqueued" : "running" + end + + def failure_status_for(error) + error.is_a?(StandardError) ? "failed" : "discarded" + end + + def save_operations(job_run) + return unless job_run + + operations_data = RequestStore.store[:rails_pulse_operations] || [] + operations_data.each do |operation_data| + operation_data[:job_run_id] = job_run.id + operation_data[:request_id] = nil + + with_recording_suppressed do + RailsPulse::Operation.create!(operation_data) + end + rescue => e + Rails.logger.error "[RailsPulse] Failed to save job operation: #{e.class} - #{e.message}" + end + ensure + RequestStore.store[:rails_pulse_operations] = nil + end + + def detect_adapter + return "sidekiq" if defined?(::Sidekiq) + return "solid_queue" if defined?(::SolidQueue) + return "good_job" if defined?(::GoodJob) + return "delayed_job" if defined?(::Delayed::Job) + return "resque" if defined?(::Resque) + return "que" if defined?(::Que) + + "active_job" + end + + def elapsed_time_ms(start_time) + return 0.0 unless start_time + + ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2) + end + + def safe_timestamp(value) + case value + when Time, ActiveSupport::TimeWithZone + value + when Integer + Time.at(value) + else + nil + end + end + + def with_recording_suppressed + previous = RequestStore.store[:skip_recording_rails_pulse_activity] + RequestStore.store[:skip_recording_rails_pulse_activity] = true + yield + ensure + RequestStore.store[:skip_recording_rails_pulse_activity] = previous + end + end + end +end diff --git a/lib/rails_pulse/subscribers/operation_subscriber.rb b/lib/rails_pulse/subscribers/operation_subscriber.rb index 779d059..da9faee 100644 --- a/lib/rails_pulse/subscribers/operation_subscriber.rb +++ b/lib/rails_pulse/subscribers/operation_subscriber.rb @@ -59,7 +59,8 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l return if RequestStore.store[:skip_recording_rails_pulse_activity] request_id = RequestStore.store[:rails_pulse_request_id] - return unless request_id + job_run_id = RequestStore.store[:rails_pulse_job_run_id] + return unless request_id || job_run_id # Skip RailsPulse-related operations to prevent recursion if operation_type == "sql" @@ -91,6 +92,7 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l operation_data = { request_id: request_id, + job_run_id: job_run_id, operation_type: operation_type, label: label, duration: (finish - start) * 1000, @@ -174,6 +176,7 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l codebase_location = find_app_frame || caller_locations(2, 1).first&.path operation_data = { request_id: RequestStore.store[:rails_pulse_request_id], + job_run_id: RequestStore.store[:rails_pulse_job_run_id], operation_type: "http", label: label, duration: (finish - start) * 1000, @@ -182,7 +185,7 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l occurred_at: Time.zone.at(start) } - if operation_data[:request_id] + if operation_data[:request_id] || operation_data[:job_run_id] RequestStore.store[:rails_pulse_operations] ||= [] RequestStore.store[:rails_pulse_operations] << operation_data end @@ -199,6 +202,7 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l codebase_location = find_app_frame || caller_locations(2, 1).first&.path operation_data = { request_id: RequestStore.store[:rails_pulse_request_id], + job_run_id: RequestStore.store[:rails_pulse_job_run_id], operation_type: "job", label: label, duration: (finish - start) * 1000, @@ -207,7 +211,7 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l occurred_at: Time.zone.at(start) } - if operation_data[:request_id] + if operation_data[:request_id] || operation_data[:job_run_id] RequestStore.store[:rails_pulse_operations] ||= [] RequestStore.store[:rails_pulse_operations] << operation_data end @@ -233,6 +237,7 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l codebase_location = find_app_frame || caller_locations(2, 1).first&.path operation_data = { request_id: RequestStore.store[:rails_pulse_request_id], + job_run_id: RequestStore.store[:rails_pulse_job_run_id], operation_type: "mailer", label: label, duration: (finish - start) * 1000, @@ -241,7 +246,7 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l occurred_at: Time.zone.at(start) } - if operation_data[:request_id] + if operation_data[:request_id] || operation_data[:job_run_id] RequestStore.store[:rails_pulse_operations] ||= [] RequestStore.store[:rails_pulse_operations] << operation_data end @@ -258,6 +263,7 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l codebase_location = find_app_frame || caller_locations(2, 1).first&.path operation_data = { request_id: RequestStore.store[:rails_pulse_request_id], + job_run_id: RequestStore.store[:rails_pulse_job_run_id], operation_type: "storage", label: label, duration: (finish - start) * 1000, @@ -266,7 +272,7 @@ def self.capture_operation(event_name, start, finish, payload, operation_type, l occurred_at: Time.zone.at(start) } - if operation_data[:request_id] + if operation_data[:request_id] || operation_data[:job_run_id] RequestStore.store[:rails_pulse_operations] ||= [] RequestStore.store[:rails_pulse_operations] << operation_data end diff --git a/lib/tasks/rails_pulse_benchmark.rake b/lib/tasks/rails_pulse_benchmark.rake new file mode 100644 index 0000000..8bc5c5f --- /dev/null +++ b/lib/tasks/rails_pulse_benchmark.rake @@ -0,0 +1,382 @@ +begin + require "benchmark" + require "benchmark/ips" + require "memory_profiler" +rescue LoadError => e + # Benchmark gems not available - tasks will show error if run +end + +namespace :rails_pulse do + namespace :benchmark do + desc "Run comprehensive performance benchmarks for Rails Pulse" + task all: :environment do + unless defined?(Benchmark::IPS) && defined?(MemoryProfiler) + puts "❌ Benchmark gems not installed. Add to your Gemfile:" + puts " gem 'benchmark-ips'" + puts " gem 'memory_profiler'" + puts "\nThen run: bundle install" + exit 1 + end + + puts "\n" + "=" * 80 + puts "Rails Pulse Performance Benchmark Suite" + puts "=" * 80 + puts "\nEnvironment:" + puts " Ruby: #{RUBY_VERSION}" + puts " Rails: #{Rails.version}" + puts " Database: #{ActiveRecord::Base.connection.adapter_name}" + puts " Rails Pulse: #{RailsPulse::VERSION}" + puts "\n" + + # Run all benchmarks + Rake::Task["rails_pulse:benchmark:memory"].invoke + Rake::Task["rails_pulse:benchmark:request_overhead"].invoke + Rake::Task["rails_pulse:benchmark:middleware"].invoke + Rake::Task["rails_pulse:benchmark:job_tracking"].invoke + Rake::Task["rails_pulse:benchmark:database_queries"].invoke + + puts "\n" + "=" * 80 + puts "Benchmark suite completed!" + puts "=" * 80 + end + + desc "Benchmark memory usage with and without Rails Pulse" + task memory: :environment do + puts "\n" + "-" * 80 + puts "Memory Usage Benchmark" + puts "-" * 80 + + # Ensure Rails Pulse is enabled + original_enabled = RailsPulse.configuration.enabled + RailsPulse.configuration.enabled = true + + # Baseline memory (Rails Pulse disabled) + RailsPulse.configuration.enabled = false + GC.start + baseline_memory = GC.stat(:total_allocated_objects) + + # Create some test data + route = RailsPulse::Route.find_or_create_by!( + method: "GET", + path: "/benchmark/test" + ) + + # Memory with Rails Pulse enabled + RailsPulse.configuration.enabled = true + GC.start + enabled_memory = GC.stat(:total_allocated_objects) + + # Profile memory for creating a request + report = MemoryProfiler.report do + 10.times do + RailsPulse::Request.create!( + route: route, + occurred_at: Time.current, + duration: rand(50..500), + status: 200, + request_uuid: SecureRandom.uuid + ) + end + end + + puts "\nMemory Allocation Summary:" + puts " Total allocated: #{report.total_allocated_memsize / 1024.0} KB" + puts " Total retained: #{report.total_retained_memsize / 1024.0} KB" + puts " Allocated objects: #{report.total_allocated}" + puts " Retained objects: #{report.total_retained}" + + puts "\nPer-Request Memory Overhead:" + puts " ~#{(report.total_allocated_memsize / 10.0 / 1024.0).round(2)} KB per request" + + # Restore original state + RailsPulse.configuration.enabled = original_enabled + end + + desc "Benchmark request processing overhead" + task request_overhead: :environment do + puts "\n" + "-" * 80 + puts "Request Processing Overhead Benchmark" + puts "-" * 80 + + # Setup test data + route = RailsPulse::Route.find_or_create_by!( + method: "GET", + path: "/benchmark/test" + ) + + query = RailsPulse::Query.find_or_create_by!( + normalized_sql: "SELECT * FROM users WHERE id = ?" + ) + + puts "\nIterations per second (higher is better):\n" + + Benchmark.ips do |x| + x.config(time: 5, warmup: 2) + + x.report("Request creation (baseline)") do + RailsPulse::Request.new( + route: route, + occurred_at: Time.current, + duration: 100, + status: 200, + request_uuid: SecureRandom.uuid + ) + end + + x.report("Request creation + save") do + req = RailsPulse::Request.create!( + route: route, + occurred_at: Time.current, + duration: 100, + status: 200, + request_uuid: SecureRandom.uuid + ) + req.destroy + end + + x.report("Request + Operation") do + req = RailsPulse::Request.create!( + route: route, + occurred_at: Time.current, + duration: 100, + status: 200, + request_uuid: SecureRandom.uuid + ) + RailsPulse::Operation.create!( + request: req, + query: query, + operation_type: "sql", + label: "User Load", + occurred_at: Time.current, + duration: 10 + ) + req.destroy + end + + x.compare! + end + + puts "\nAbsolute timing comparison:\n" + result = Benchmark.measure do + 1000.times do + req = RailsPulse::Request.create!( + route: route, + occurred_at: Time.current, + duration: 100, + status: 200, + request_uuid: SecureRandom.uuid + ) + req.destroy + end + end + + puts " 1000 requests: #{(result.real * 1000).round(2)}ms total" + puts " Average per request: #{result.real.round(5)}ms" + end + + desc "Benchmark middleware overhead" + task middleware: :environment do + puts "\n" + "-" * 80 + puts "Middleware Overhead Benchmark" + puts "-" * 80 + + # Create mock request environment + env = { + "REQUEST_METHOD" => "GET", + "PATH_INFO" => "/test", + "QUERY_STRING" => "", + "rack.input" => StringIO.new, + "rack.errors" => $stderr, + "action_dispatch.request_id" => SecureRandom.uuid + } + + app = ->(env) { [ 200, { "Content-Type" => "text/plain" }, [ "OK" ] ] } + middleware = RailsPulse::Middleware::RequestCollector.new(app) + + puts "\nMiddleware performance:\n" + + # Benchmark with Rails Pulse enabled + RailsPulse.configuration.enabled = true + enabled_time = Benchmark.measure do + 1000.times do + test_env = env.dup + test_env["action_dispatch.request_id"] = SecureRandom.uuid + middleware.call(test_env) + end + end + + # Benchmark with Rails Pulse disabled + RailsPulse.configuration.enabled = false + disabled_time = Benchmark.measure do + 1000.times do + test_env = env.dup + test_env["action_dispatch.request_id"] = SecureRandom.uuid + middleware.call(test_env) + end + end + + # Benchmark without middleware + baseline_time = Benchmark.measure do + 1000.times { app.call(env.dup) } + end + + overhead_enabled = (enabled_time.real - baseline_time.real) * 1000 / 1000 + overhead_disabled = (disabled_time.real - baseline_time.real) * 1000 / 1000 + + puts " Baseline (no middleware): #{(baseline_time.real * 1000).round(2)}ms (1000 requests)" + puts " With Rails Pulse enabled: #{(enabled_time.real * 1000).round(2)}ms (1000 requests)" + puts " With Rails Pulse disabled: #{(disabled_time.real * 1000).round(2)}ms (1000 requests)" + puts "\n Overhead per request (enabled): #{overhead_enabled.round(3)}ms" + puts " Overhead per request (disabled): #{overhead_disabled.round(3)}ms" + + # Restore + RailsPulse.configuration.enabled = true + end + + desc "Benchmark job tracking overhead" + task job_tracking: :environment do + puts "\n" + "-" * 80 + puts "Background Job Tracking Overhead Benchmark" + puts "-" * 80 + + # Skip if job tracking is disabled + unless RailsPulse.configuration.track_jobs + puts "\n ⚠️ Job tracking is disabled - skipping benchmark" + next + end + + # Create a simple test job + test_job_class = Class.new(ApplicationJob) do + def perform(value) + # Simulate some work + sleep(0.001) + value * 2 + end + end + + puts "\nJob execution overhead:\n" + + # Benchmark with job tracking enabled + RailsPulse.configuration.track_jobs = true + enabled_time = Benchmark.measure do + 100.times do |i| + test_job_class.new.perform(i) + end + end + + # Benchmark with job tracking disabled + RailsPulse.configuration.track_jobs = false + disabled_time = Benchmark.measure do + 100.times do |i| + test_job_class.new.perform(i) + end + end + + overhead = (enabled_time.real - disabled_time.real) * 1000 / 100 + + puts " With tracking enabled: #{(enabled_time.real * 1000).round(2)}ms (100 jobs)" + puts " With tracking disabled: #{(disabled_time.real * 1000).round(2)}ms (100 jobs)" + puts "\n Overhead per job: #{overhead.round(3)}ms" + + # Restore + RailsPulse.configuration.track_jobs = true + end + + desc "Benchmark database query overhead" + task database_queries: :environment do + puts "\n" + "-" * 80 + puts "Database Query Overhead Benchmark" + puts "-" * 80 + + # Create test route for queries + route = RailsPulse::Route.find_or_create_by!( + method: "GET", + path: "/benchmark/queries" + ) + + puts "\nQuery performance comparison:\n" + + # Test 1: Simple aggregation query + puts " 1. Average request duration calculation:" + time_enabled = Benchmark.measure do + 100.times { RailsPulse::Request.average(:duration) } + end + + RailsPulse.configuration.enabled = false + time_disabled = Benchmark.measure do + 100.times { RailsPulse::Request.average(:duration) } + end + RailsPulse.configuration.enabled = true + + puts " Enabled: #{(time_enabled.real * 1000).round(2)}ms (100 queries)" + puts " Disabled: #{(time_disabled.real * 1000).round(2)}ms (100 queries)" + puts " Overhead: #{((time_enabled.real - time_disabled.real) * 10).round(3)}ms per query" + + # Test 2: Complex joins and grouping + puts "\n 2. Requests grouped by hour with joins:" + time_complex = Benchmark.measure do + 10.times do + RailsPulse::Request + .joins(:route) + .group("DATE_TRUNC('hour', occurred_at)") + .average(:duration) + end + end + + puts " Time: #{(time_complex.real * 1000).round(2)}ms (10 queries)" + puts " Average: #{(time_complex.real * 100).round(3)}ms per query" + + # Test 3: Summary aggregation + puts "\n 3. Summary data aggregation:" + time_summary = Benchmark.measure do + 10.times do + RailsPulse::Summary + .where("period_start > ?", 24.hours.ago) + .group(:period_type) + .average(:avg_duration) + end + end + + puts " Time: #{(time_summary.real * 1000).round(2)}ms (10 queries)" + puts " Average: #{(time_summary.real * 100).round(3)}ms per query" + end + + desc "Generate benchmark report and save to docs" + task report: :environment do + require "fileutils" + + puts "\n" + "=" * 80 + puts "Generating Performance Benchmark Report" + puts "=" * 80 + + output_file = Rails.root.join("../../docs/benchmark_results.md") + FileUtils.mkdir_p(File.dirname(output_file)) + + File.open(output_file, "w") do |f| + f.puts "# Rails Pulse Performance Benchmark Results" + f.puts "" + f.puts "**Generated:** #{Time.current.strftime('%Y-%m-%d %H:%M:%S %Z')}" + f.puts "" + f.puts "## Environment" + f.puts "" + f.puts "- **Ruby:** #{RUBY_VERSION}" + f.puts "- **Rails:** #{Rails.version}" + f.puts "- **Database:** #{ActiveRecord::Base.connection.adapter_name}" + f.puts "- **Rails Pulse:** #{RailsPulse::VERSION}" + f.puts "" + f.puts "## Summary" + f.puts "" + f.puts "This report contains automated performance benchmarks measuring Rails Pulse's overhead." + f.puts "" + f.puts "---" + f.puts "" + f.puts "*For full benchmark output, run:* `rails rails_pulse:benchmark:all`" + end + + puts "\n✅ Report saved to: #{output_file}" + + # Run full benchmark suite + Rake::Task["rails_pulse:benchmark:all"].invoke + end + end +end diff --git a/public/rails-pulse-assets/rails-pulse-icons.js b/public/rails-pulse-assets/rails-pulse-icons.js index 66769df..201b081 100644 --- a/public/rails-pulse-assets/rails-pulse-icons.js +++ b/public/rails-pulse-assets/rails-pulse-icons.js @@ -1,5 +1,5 @@ // Rails Pulse Icons Bundle - Auto-generated -// Contains 34 SVG icons for Rails Pulse +// Contains 35 SVG icons for Rails Pulse (function() { 'use strict'; @@ -39,7 +39,8 @@ "trending-up": "", "trending-down": "", "move-right": "", - "eye": "" + "eye": "", + "zap": "" }; // Global icon registry diff --git a/public/rails-pulse-assets/rails-pulse-icons.js.map b/public/rails-pulse-assets/rails-pulse-icons.js.map index 1c0e72d..b74cef9 100644 --- a/public/rails-pulse-assets/rails-pulse-icons.js.map +++ b/public/rails-pulse-assets/rails-pulse-icons.js.map @@ -8,6 +8,6 @@ "names": [], "mappings": "", "sourcesContent": [ - "// Rails Pulse Icons Bundle - Auto-generated\n// Contains 34 SVG icons for Rails Pulse\n\n(function() {\n 'use strict';\n\n // Icon data\n const icons = {\n \"menu\": \"\",\n \"sun\": \"\",\n \"moon\": \"\",\n \"chevron-right\": \"\",\n \"chevron-left\": \"\",\n \"chevron-down\": \"\",\n \"chevron-up\": \"\",\n \"chevrons-left\": \"\",\n \"chevrons-right\": \"\",\n \"loader-circle\": \"\",\n \"search\": \"\",\n \"list-filter\": \"\",\n \"list-filter-plus\": \"\",\n \"x\": \"\",\n \"x-circle\": \"\",\n \"check\": \"\",\n \"alert-circle\": \"\",\n \"alert-triangle\": \"\",\n \"info\": \"\",\n \"external-link\": \"\",\n \"download\": \"\",\n \"refresh-cw\": \"\",\n \"clock\": \"\",\n \"database\": \"\",\n \"server\": \"\",\n \"activity\": \"\",\n \"layout-dashboard\": \"\",\n \"audio-lines\": \"\",\n \"message-circle-question\": \"\",\n \"route\": \"\",\n \"trending-up\": \"\",\n \"trending-down\": \"\",\n \"move-right\": \"\",\n \"eye\": \"\"\n};\n\n // Global icon registry\n window.RailsPulseIcons = {\n icons: icons,\n\n // Get icon SVG content\n get: function(name) {\n return icons[name] || null;\n },\n\n // Check if icon exists\n has: function(name) {\n return name in icons;\n },\n\n // Get all icon names\n list: function() {\n return Object.keys(icons);\n },\n\n // Render icon to element (CSP-safe)\n render: function(name, element, options = {}) {\n const svgContent = this.get(name);\n if (!svgContent || !element) return false;\n\n // Create SVG element\n const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n svg.setAttribute('width', options.width || '24');\n svg.setAttribute('height', options.height || '24');\n svg.setAttribute('viewBox', '0 0 24 24');\n svg.setAttribute('fill', 'none');\n svg.setAttribute('stroke', 'currentColor');\n svg.setAttribute('stroke-width', '2');\n svg.setAttribute('stroke-linecap', 'round');\n svg.setAttribute('stroke-linejoin', 'round');\n\n // Add icon content\n svg.innerHTML = svgContent;\n\n // Replace element content\n element.innerHTML = '';\n element.appendChild(svg);\n\n return true;\n }\n };\n})();\n" + "// Rails Pulse Icons Bundle - Auto-generated\n// Contains 35 SVG icons for Rails Pulse\n\n(function() {\n 'use strict';\n\n // Icon data\n const icons = {\n \"menu\": \"\",\n \"sun\": \"\",\n \"moon\": \"\",\n \"chevron-right\": \"\",\n \"chevron-left\": \"\",\n \"chevron-down\": \"\",\n \"chevron-up\": \"\",\n \"chevrons-left\": \"\",\n \"chevrons-right\": \"\",\n \"loader-circle\": \"\",\n \"search\": \"\",\n \"list-filter\": \"\",\n \"list-filter-plus\": \"\",\n \"x\": \"\",\n \"x-circle\": \"\",\n \"check\": \"\",\n \"alert-circle\": \"\",\n \"alert-triangle\": \"\",\n \"info\": \"\",\n \"external-link\": \"\",\n \"download\": \"\",\n \"refresh-cw\": \"\",\n \"clock\": \"\",\n \"database\": \"\",\n \"server\": \"\",\n \"activity\": \"\",\n \"layout-dashboard\": \"\",\n \"audio-lines\": \"\",\n \"message-circle-question\": \"\",\n \"route\": \"\",\n \"trending-up\": \"\",\n \"trending-down\": \"\",\n \"move-right\": \"\",\n \"eye\": \"\",\n \"zap\": \"\"\n};\n\n // Global icon registry\n window.RailsPulseIcons = {\n icons: icons,\n\n // Get icon SVG content\n get: function(name) {\n return icons[name] || null;\n },\n\n // Check if icon exists\n has: function(name) {\n return name in icons;\n },\n\n // Get all icon names\n list: function() {\n return Object.keys(icons);\n },\n\n // Render icon to element (CSP-safe)\n render: function(name, element, options = {}) {\n const svgContent = this.get(name);\n if (!svgContent || !element) return false;\n\n // Create SVG element\n const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n svg.setAttribute('width', options.width || '24');\n svg.setAttribute('height', options.height || '24');\n svg.setAttribute('viewBox', '0 0 24 24');\n svg.setAttribute('fill', 'none');\n svg.setAttribute('stroke', 'currentColor');\n svg.setAttribute('stroke-width', '2');\n svg.setAttribute('stroke-linecap', 'round');\n svg.setAttribute('stroke-linejoin', 'round');\n\n // Add icon content\n svg.innerHTML = svgContent;\n\n // Replace element content\n element.innerHTML = '';\n element.appendChild(svg);\n\n return true;\n }\n };\n})();\n" ] } \ No newline at end of file diff --git a/public/rails-pulse-assets/rails-pulse.css b/public/rails-pulse-assets/rails-pulse.css index d5feee2..b5225fe 100644 --- a/public/rails-pulse-assets/rails-pulse.css +++ b/public/rails-pulse-assets/rails-pulse.css @@ -1 +1 @@ -*,::backdrop,::file-selector-button,:after,:before{border:0 solid;box-sizing:border-box;margin:0;padding:0}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:var(--default-font-family,system-ui,sans-serif);font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{block-size:0;border-block-start-width:1px;color:inherit}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--default-mono-font-family,ui-monospace,monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-size:1em;font-variation-settings:var(--default-mono-font-variation-settings,normal)}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{inset-block-end:-.25em}sup{inset-block-start:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}menu,ol,ul{list-style:none}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{block-size:auto;max-inline-size:100%}::file-selector-button,button,input,optgroup,select,textarea{background-color:transparent;border-radius:0;color:inherit;font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;opacity:1}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::-moz-placeholder{color:color-mix(in oklab,currentColor 50%,transparent);opacity:1}::placeholder{color:color-mix(in oklab,currentColor 50%,transparent);opacity:1}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-block-size:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}::file-selector-button,button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{block-size:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}[contents]{display:contents!important}turbo-frame{display:contents}:root{interpolate-size:allow-keywords;color-scheme:light dark}::-webkit-calendar-picker-indicator{line-height:1em}option{padding:2px 4px}html:has(dialog:modal[open]){overflow:hidden}@media (prefers-reduced-motion:reduce){*,::backdrop,:after,:before{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}:root{--slate-50:oklch(0.984 0.003 247.858);--slate-100:oklch(0.968 0.007 247.896);--slate-200:oklch(0.929 0.013 255.508);--slate-300:oklch(0.869 0.022 252.894);--slate-400:oklch(0.704 0.04 256.788);--slate-500:oklch(0.554 0.046 257.417);--slate-600:oklch(0.446 0.043 257.281);--slate-700:oklch(0.372 0.044 257.287);--slate-800:oklch(0.279 0.041 260.031);--slate-900:oklch(0.208 0.042 265.755);--slate-950:oklch(0.129 0.042 264.695);--gray-50:oklch(0.985 0.002 247.839);--gray-100:oklch(0.967 0.003 264.542);--gray-200:oklch(0.928 0.006 264.531);--gray-300:oklch(0.872 0.01 258.338);--gray-400:oklch(0.707 0.022 261.325);--gray-500:oklch(0.551 0.027 264.364);--gray-600:oklch(0.446 0.03 256.802);--gray-700:oklch(0.373 0.034 259.733);--gray-800:oklch(0.278 0.033 256.848);--gray-900:oklch(0.21 0.034 264.665);--gray-950:oklch(0.13 0.028 261.692);--zinc-50:oklch(0.985 0 0);--zinc-100:oklch(0.967 0.001 286.375);--zinc-200:oklch(0.92 0.004 286.32);--zinc-300:oklch(0.871 0.006 286.286);--zinc-400:oklch(0.705 0.015 286.067);--zinc-500:oklch(0.552 0.016 285.938);--zinc-600:oklch(0.442 0.017 285.786);--zinc-700:oklch(0.37 0.013 285.805);--zinc-800:oklch(0.274 0.006 286.033);--zinc-900:oklch(0.21 0.006 285.885);--zinc-950:oklch(0.141 0.005 285.823);--neutral-50:oklch(0.985 0 0);--neutral-100:oklch(0.97 0 0);--neutral-200:oklch(0.922 0 0);--neutral-300:oklch(0.87 0 0);--neutral-400:oklch(0.708 0 0);--neutral-500:oklch(0.556 0 0);--neutral-600:oklch(0.439 0 0);--neutral-700:oklch(0.371 0 0);--neutral-800:oklch(0.269 0 0);--neutral-900:oklch(0.205 0 0);--neutral-950:oklch(0.145 0 0);--stone-50:oklch(0.985 0.001 106.423);--stone-100:oklch(0.97 0.001 106.424);--stone-200:oklch(0.923 0.003 48.717);--stone-300:oklch(0.869 0.005 56.366);--stone-400:oklch(0.709 0.01 56.259);--stone-500:oklch(0.553 0.013 58.071);--stone-600:oklch(0.444 0.011 73.639);--stone-700:oklch(0.374 0.01 67.558);--stone-800:oklch(0.268 0.007 34.298);--stone-900:oklch(0.216 0.006 56.043);--stone-950:oklch(0.147 0.004 49.25);--red-50:oklch(0.971 0.013 17.38);--red-100:oklch(0.936 0.032 17.717);--red-200:oklch(0.885 0.062 18.334);--red-300:oklch(0.808 0.114 19.571);--red-400:oklch(0.704 0.191 22.216);--red-500:oklch(0.637 0.237 25.331);--red-600:oklch(0.577 0.245 27.325);--red-700:oklch(0.505 0.213 27.518);--red-800:oklch(0.444 0.177 26.899);--red-900:oklch(0.396 0.141 25.723);--red-950:oklch(0.258 0.092 26.042);--orange-50:oklch(0.98 0.016 73.684);--orange-100:oklch(0.954 0.038 75.164);--orange-200:oklch(0.901 0.076 70.697);--orange-300:oklch(0.837 0.128 66.29);--orange-400:oklch(0.75 0.183 55.934);--orange-500:oklch(0.705 0.213 47.604);--orange-600:oklch(0.646 0.222 41.116);--orange-700:oklch(0.553 0.195 38.402);--orange-800:oklch(0.47 0.157 37.304);--orange-900:oklch(0.408 0.123 38.172);--orange-950:oklch(0.266 0.079 36.259);--amber-50:oklch(0.987 0.022 95.277);--amber-100:oklch(0.962 0.059 95.617);--amber-200:oklch(0.924 0.12 95.746);--amber-300:oklch(0.879 0.169 91.605);--amber-400:oklch(0.828 0.189 84.429);--amber-500:oklch(0.769 0.188 70.08);--amber-600:oklch(0.666 0.179 58.318);--amber-700:oklch(0.555 0.163 48.998);--amber-800:oklch(0.473 0.137 46.201);--amber-900:oklch(0.414 0.112 45.904);--amber-950:oklch(0.279 0.077 45.635);--yellow-50:oklch(0.987 0.026 102.212);--yellow-100:oklch(0.973 0.071 103.193);--yellow-200:oklch(0.945 0.129 101.54);--yellow-300:oklch(0.905 0.182 98.111);--yellow-400:oklch(0.852 0.199 91.936);--yellow-500:oklch(0.795 0.184 86.047);--yellow-600:oklch(0.681 0.162 75.834);--yellow-700:oklch(0.554 0.135 66.442);--yellow-800:oklch(0.476 0.114 61.907);--yellow-900:oklch(0.421 0.095 57.708);--yellow-950:oklch(0.286 0.066 53.813);--lime-50:oklch(0.986 0.031 120.757);--lime-100:oklch(0.967 0.067 122.328);--lime-200:oklch(0.938 0.127 124.321);--lime-300:oklch(0.897 0.196 126.665);--lime-400:oklch(0.841 0.238 128.85);--lime-500:oklch(0.768 0.233 130.85);--lime-600:oklch(0.648 0.2 131.684);--lime-700:oklch(0.532 0.157 131.589);--lime-800:oklch(0.453 0.124 130.933);--lime-900:oklch(0.405 0.101 131.063);--lime-950:oklch(0.274 0.072 132.109);--green-50:oklch(0.982 0.018 155.826);--green-100:oklch(0.962 0.044 156.743);--green-200:oklch(0.925 0.084 155.995);--green-300:oklch(0.871 0.15 154.449);--green-400:oklch(0.792 0.209 151.711);--green-500:oklch(0.723 0.219 149.579);--green-600:oklch(0.627 0.194 149.214);--green-700:oklch(0.527 0.154 150.069);--green-800:oklch(0.448 0.119 151.328);--green-900:oklch(0.393 0.095 152.535);--green-950:oklch(0.266 0.065 152.934);--emerald-50:oklch(0.979 0.021 166.113);--emerald-100:oklch(0.95 0.052 163.051);--emerald-200:oklch(0.905 0.093 164.15);--emerald-300:oklch(0.845 0.143 164.978);--emerald-400:oklch(0.765 0.177 163.223);--emerald-500:oklch(0.696 0.17 162.48);--emerald-600:oklch(0.596 0.145 163.225);--emerald-700:oklch(0.508 0.118 165.612);--emerald-800:oklch(0.432 0.095 166.913);--emerald-900:oklch(0.378 0.077 168.94);--emerald-950:oklch(0.262 0.051 172.552);--teal-50:oklch(0.984 0.014 180.72);--teal-100:oklch(0.953 0.051 180.801);--teal-200:oklch(0.91 0.096 180.426);--teal-300:oklch(0.855 0.138 181.071);--teal-400:oklch(0.777 0.152 181.912);--teal-500:oklch(0.704 0.14 182.503);--teal-600:oklch(0.6 0.118 184.704);--teal-700:oklch(0.511 0.096 186.391);--teal-800:oklch(0.437 0.078 188.216);--teal-900:oklch(0.386 0.063 188.416);--teal-950:oklch(0.277 0.046 192.524);--cyan-50:oklch(0.984 0.019 200.873);--cyan-100:oklch(0.956 0.045 203.388);--cyan-200:oklch(0.917 0.08 205.041);--cyan-300:oklch(0.865 0.127 207.078);--cyan-400:oklch(0.789 0.154 211.53);--cyan-500:oklch(0.715 0.143 215.221);--cyan-600:oklch(0.609 0.126 221.723);--cyan-700:oklch(0.52 0.105 223.128);--cyan-800:oklch(0.45 0.085 224.283);--cyan-900:oklch(0.398 0.07 227.392);--cyan-950:oklch(0.302 0.056 229.695);--sky-50:oklch(0.977 0.013 236.62);--sky-100:oklch(0.951 0.026 236.824);--sky-200:oklch(0.901 0.058 230.902);--sky-300:oklch(0.828 0.111 230.318);--sky-400:oklch(0.746 0.16 232.661);--sky-500:oklch(0.685 0.169 237.323);--sky-600:oklch(0.588 0.158 241.966);--sky-700:oklch(0.5 0.134 242.749);--sky-800:oklch(0.443 0.11 240.79);--sky-900:oklch(0.391 0.09 240.876);--sky-950:oklch(0.293 0.066 243.157);--blue-50:oklch(0.97 0.014 254.604);--blue-100:oklch(0.932 0.032 255.585);--blue-200:oklch(0.882 0.059 254.128);--blue-300:oklch(0.809 0.105 251.813);--blue-400:oklch(0.707 0.165 254.624);--blue-500:oklch(0.623 0.214 259.815);--blue-600:oklch(0.546 0.245 262.881);--blue-700:oklch(0.488 0.243 264.376);--blue-800:oklch(0.424 0.199 265.638);--blue-900:oklch(0.379 0.146 265.522);--blue-950:oklch(0.282 0.091 267.935);--indigo-50:oklch(0.962 0.018 272.314);--indigo-100:oklch(0.93 0.034 272.788);--indigo-200:oklch(0.87 0.065 274.039);--indigo-300:oklch(0.785 0.115 274.713);--indigo-400:oklch(0.673 0.182 276.935);--indigo-500:oklch(0.585 0.233 277.117);--indigo-600:oklch(0.511 0.262 276.966);--indigo-700:oklch(0.457 0.24 277.023);--indigo-800:oklch(0.398 0.195 277.366);--indigo-900:oklch(0.359 0.144 278.697);--indigo-950:oklch(0.257 0.09 281.288);--violet-50:oklch(0.969 0.016 293.756);--violet-100:oklch(0.943 0.029 294.588);--violet-200:oklch(0.894 0.057 293.283);--violet-300:oklch(0.811 0.111 293.571);--violet-400:oklch(0.702 0.183 293.541);--violet-500:oklch(0.606 0.25 292.717);--violet-600:oklch(0.541 0.281 293.009);--violet-700:oklch(0.491 0.27 292.581);--violet-800:oklch(0.432 0.232 292.759);--violet-900:oklch(0.38 0.189 293.745);--violet-950:oklch(0.283 0.141 291.089);--purple-50:oklch(0.977 0.014 308.299);--purple-100:oklch(0.946 0.033 307.174);--purple-200:oklch(0.902 0.063 306.703);--purple-300:oklch(0.827 0.119 306.383);--purple-400:oklch(0.714 0.203 305.504);--purple-500:oklch(0.627 0.265 303.9);--purple-600:oklch(0.558 0.288 302.321);--purple-700:oklch(0.496 0.265 301.924);--purple-800:oklch(0.438 0.218 303.724);--purple-900:oklch(0.381 0.176 304.987);--purple-950:oklch(0.291 0.149 302.717);--fuchsia-50:oklch(0.977 0.017 320.058);--fuchsia-100:oklch(0.952 0.037 318.852);--fuchsia-200:oklch(0.903 0.076 319.62);--fuchsia-300:oklch(0.833 0.145 321.434);--fuchsia-400:oklch(0.74 0.238 322.16);--fuchsia-500:oklch(0.667 0.295 322.15);--fuchsia-600:oklch(0.591 0.293 322.896);--fuchsia-700:oklch(0.518 0.253 323.949);--fuchsia-800:oklch(0.452 0.211 324.591);--fuchsia-900:oklch(0.401 0.17 325.612);--fuchsia-950:oklch(0.293 0.136 325.661);--pink-50:oklch(0.971 0.014 343.198);--pink-100:oklch(0.948 0.028 342.258);--pink-200:oklch(0.899 0.061 343.231);--pink-300:oklch(0.823 0.12 346.018);--pink-400:oklch(0.718 0.202 349.761);--pink-500:oklch(0.656 0.241 354.308);--pink-600:oklch(0.592 0.249 0.584);--pink-700:oklch(0.525 0.223 3.958);--pink-800:oklch(0.459 0.187 3.815);--pink-900:oklch(0.408 0.153 2.432);--pink-950:oklch(0.284 0.109 3.907);--rose-50:oklch(0.969 0.015 12.422);--rose-100:oklch(0.941 0.03 12.58);--rose-200:oklch(0.892 0.058 10.001);--rose-300:oklch(0.81 0.117 11.638);--rose-400:oklch(0.712 0.194 13.428);--rose-500:oklch(0.645 0.246 16.439);--rose-600:oklch(0.586 0.253 17.585);--rose-700:oklch(0.514 0.222 16.935);--rose-800:oklch(0.455 0.188 13.697);--rose-900:oklch(0.41 0.159 10.272);--rose-950:oklch(0.271 0.105 12.094);--size-0_5:0.125rem;--size-1:0.25rem;--size-1_5:0.375rem;--size-2:0.5rem;--size-2_5:0.625rem;--size-3:0.75rem;--size-3_5:0.875rem;--size-4:1rem;--size-5:1.25rem;--size-6:1.5rem;--size-7:1.75rem;--size-8:2rem;--size-9:2.25rem;--size-10:2.5rem;--size-11:2.75rem;--size-12:3rem;--size-14:3.5rem;--size-16:4rem;--size-20:5rem;--size-24:6rem;--size-28:7rem;--size-32:8rem;--size-36:9rem;--size-40:10rem;--size-44:11rem;--size-48:12rem;--size-52:13rem;--size-56:14rem;--size-60:15rem;--size-64:16rem;--size-72:18rem;--size-80:20rem;--size-96:24rem;--size-1-2:50%;--size-1-3:33.333333%;--size-2-3:66.666667%;--size-1-4:25%;--size-2-4:50%;--size-3-4:75%;--size-1-5:20%;--size-2-5:40%;--size-3-5:60%;--size-4-5:80%;--size-1-6:16.666667%;--size-2-6:33.333333%;--size-3-6:50%;--size-4-6:66.666667%;--size-5-6:83.333333%;--size-1-12:8.333333%;--size-2-12:16.666667%;--size-3-12:25%;--size-4-12:33.333333%;--size-5-12:41.666667%;--size-6-12:50%;--size-7-12:58.333333%;--size-8-12:66.666667%;--size-9-12:75%;--size-10-12:83.333333%;--size-11-12:91.666667%;--size-full:100%;--max-i-3xs:16rem;--max-i-2xs:18rem;--max-i-xs:20rem;--max-i-sm:24rem;--max-i-md:28rem;--max-i-lg:32rem;--max-i-xl:36rem;--max-i-2xl:42rem;--max-i-3xl:48rem;--max-i-4xl:56rem;--max-i-5xl:64rem;--max-i-6xl:72rem;--max-i-7xl:80rem;--aspect-square:1/1;--aspect-widescreen:16/9;--breakpoint-sm:40rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--border:1px;--border-2:2px;--border-4:4px;--border-8:8px;--rounded-xs:0.125rem;--rounded-sm:0.25rem;--rounded-md:0.375rem;--rounded-lg:0.5rem;--rounded-xl:0.75rem;--rounded-2xl:1rem;--rounded-3xl:1.5rem;--rounded-full:9999px;--shadow-xs:0 1px 2px 0 rgba(0,0,0,.05);--shadow-sm:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--shadow-md:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--shadow-lg:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--shadow-xl:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--shadow-2xl:0 25px 50px -12px rgba(0,0,0,.25);--shadow-inner:inset 0 2px 4px 0 rgba(0,0,0,.05);--opacity-5:0.05;--opacity-10:0.1;--opacity-20:0.2;--opacity-25:0.25;--opacity-30:0.3;--opacity-40:0.4;--opacity-50:0.5;--opacity-60:0.6;--opacity-70:0.7;--opacity-75:0.75;--opacity-80:0.8;--opacity-90:0.9;--opacity-95:0.95;--opacity-100:1;--text-xs:0.75rem;--text-sm:0.875rem;--text-base:1rem;--text-lg:1.125rem;--text-xl:1.25rem;--text-2xl:1.5rem;--text-3xl:1.875rem;--text-4xl:2.25rem;--text-5xl:3rem;--text-6xl:3.75rem;--text-7xl:4.5rem;--text-8xl:6rem;--text-9xl:8rem;--text-fluid-xs:clamp(0.75rem,0.64rem + 0.57vw,1rem);--text-fluid-sm:clamp(0.875rem,0.761rem + 0.568vw,1.125rem);--text-fluid-base:clamp(1rem,0.89rem + 0.57vw,1.25rem);--text-fluid-lg:clamp(1.125rem,0.955rem + 0.852vw,1.5rem);--text-fluid-xl:clamp(1.25rem,0.966rem + 1.42vw,1.875rem);--text-fluid-2xl:clamp(1.5rem,1.16rem + 1.7vw,2.25rem);--text-fluid-3xl:clamp(1.875rem,1.364rem + 2.557vw,3rem);--text-fluid-4xl:clamp(2.25rem,1.57rem + 3.41vw,3.75rem);--text-fluid-5xl:clamp(3rem,2.32rem + 3.41vw,4.5rem);--text-fluid-6xl:clamp(3.75rem,2.73rem + 5.11vw,6rem);--text-fluid-7xl:clamp(4.5rem,2.91rem + 7.95vw,8rem);--font-thin:100;--font-extralight:200;--font-light:300;--font-normal:400;--font-medium:500;--font-semibold:600;--font-bold:700;--font-extrabold:800;--font-black:900;--leading-none:1;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--leading-loose:2;--leading-3:.75rem;--leading-4:1rem;--leading-5:1.25rem;--leading-6:1.5rem;--leading-7:1.75rem;--leading-8:2rem;--leading-9:2.25rem;--leading-10:2.5rem;--font-system-ui:system-ui,sans-serif;--font-transitional:Charter,Bitstream Charter,Sitka Text,Cambria,serif;--font-old-style:Iowan Old Style,Palatino Linotype,URW Palladio L,P052,serif;--font-humanist:Seravek,Gill Sans Nova,Ubuntu,Calibri,DejaVu Sans,source-sans-pro,sans-serif;--font-geometric-humanist:Avenir,Montserrat,Corbel,URW Gothic,source-sans-pro,sans-serif;--font-classical-humanist:Optima,Candara,Noto Sans,source-sans-pro,sans-serif;--font-neo-grotesque:Inter,Roboto,Helvetica Neue,Arial Nova,Nimbus Sans,Arial,sans-serif;--font-monospace-slab-serif:Nimbus Mono PS,Courier New,monospace;--font-monospace-code:Dank Mono,Operator Mono,Inconsolata,Fira Mono,ui-monospace,SF Mono,Monaco,Droid Sans Mono,Source Code Pro,Cascadia Code,Menlo,Consolas,DejaVu Sans Mono,monospace;--font-industrial:Bahnschrift,DIN Alternate,Franklin Gothic Medium,Nimbus Sans Narrow,sans-serif-condensed,sans-serif;--font-rounded-sans:ui-rounded,Hiragino Maru Gothic ProN,Quicksand,Comfortaa,Manjari,Arial Rounded MT,Arial Rounded MT Bold,Calibri,source-sans-pro,sans-serif;--font-slab-serif:Rockwell,Rockwell Nova,Roboto Slab,DejaVu Serif,Sitka Small,serif;--font-antique:Superclarendon,Bookman Old Style,URW Bookman,URW Bookman L,Georgia Pro,Georgia,serif;--font-didone:Didot,Bodoni MT,Noto Serif Display,URW Palladio L,P052,Sylfaen,serif;--font-handwritten:Segoe Print,Bradley Hand,Chilanka,TSCu_Comic,casual,cursive;--tracking-tighter:-0.05em;--tracking-tight:-0.025em;--tracking-normal:0em;--tracking-wide:0.025em;--tracking-wider:0.05em;--tracking-widest:0.1em;--animate-fade-in:fade-in .5s cubic-bezier(.25,0,.3,1);--animate-fade-in-bloom:fade-in-bloom 2s cubic-bezier(.25,0,.3,1);--animate-fade-out:fade-out .5s cubic-bezier(.25,0,.3,1);--animate-fade-out-bloom:fade-out-bloom 2s cubic-bezier(.25,0,.3,1);--animate-scale-up:scale-up .5s cubic-bezier(.25,0,.3,1);--animate-scale-down:scale-down .5s cubic-bezier(.25,0,.3,1);--animate-slide-out-up:slide-out-up .5s cubic-bezier(.25,0,.3,1);--animate-slide-out-down:slide-out-down .5s cubic-bezier(.25,0,.3,1);--animate-slide-out-right:slide-out-right .5s cubic-bezier(.25,0,.3,1);--animate-slide-out-left:slide-out-left .5s cubic-bezier(.25,0,.3,1);--animate-slide-in-up:slide-in-up .5s cubic-bezier(.25,0,.3,1);--animate-slide-in-down:slide-in-down .5s cubic-bezier(.25,0,.3,1);--animate-slide-in-right:slide-in-right .5s cubic-bezier(.25,0,.3,1);--animate-slide-in-left:slide-in-left .5s cubic-bezier(.25,0,.3,1);--animate-shake-x:shake-x .75s cubic-bezier(0,0,0,1);--animate-shake-y:shake-y .75s cubic-bezier(0,0,0,1);--animate-shake-z:shake-z 1s cubic-bezier(0,0,0,1);--animate-spin:spin 2s linear infinite;--animate-ping:ping 5s cubic-bezier(0,0,.3,1) infinite;--animate-blink:blink 1s cubic-bezier(0,0,.3,1) infinite;--animate-float:float 3s cubic-bezier(0,0,0,1) infinite;--animate-bounce:bounce 2s cubic-bezier(.5,-.3,.1,1.5) infinite;--animate-pulse:pulse 2s cubic-bezier(0,0,.3,1) infinite}@keyframes fade-in{to{opacity:1}}@keyframes fade-in-bloom{0%{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(2) blur(10px);opacity:1}to{filter:brightness(1) blur(0);opacity:1}}@keyframes fade-out{to{opacity:0}}@keyframes fade-out-bloom{to{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(2) blur(10px);opacity:1}0%{filter:brightness(1) blur(0);opacity:1}}@keyframes scale-up{to{transform:scale(1.25)}}@keyframes scale-down{to{transform:scale(.75)}}@keyframes slide-out-up{to{transform:translateY(-100%)}}@keyframes slide-out-down{to{transform:translateY(100%)}}@keyframes slide-out-right{to{transform:translateX(100%)}}@keyframes slide-out-left{to{transform:translateX(-100%)}}@keyframes slide-in-up{0%{transform:translateY(100%)}}@keyframes slide-in-down{0%{transform:translateY(-100%)}}@keyframes slide-in-right{0%{transform:translateX(-100%)}}@keyframes slide-in-left{0%{transform:translateX(100%)}}@keyframes shake-x{0%,to{transform:translateX(0)}20%{transform:translateX(-5%)}40%{transform:translateX(5%)}60%{transform:translateX(-5%)}80%{transform:translateX(5%)}}@keyframes shake-y{0%,to{transform:translateY(0)}20%{transform:translateY(-5%)}40%{transform:translateY(5%)}60%{transform:translateY(-5%)}80%{transform:translateY(5%)}}@keyframes shake-z{0%,to{transform:rotate(0deg)}20%{transform:rotate(-2deg)}40%{transform:rotate(2deg)}60%{transform:rotate(-2deg)}80%{transform:rotate(2deg)}}@keyframes spin{to{transform:rotate(1turn)}}@keyframes ping{90%,to{opacity:0;transform:scale(2)}}@keyframes blink{0%,to{opacity:1}50%{opacity:.5}}@keyframes float{50%{transform:translateY(-25%)}}@keyframes bounce{25%{transform:translateY(-20%)}40%{transform:translateY(-3%)}0%,60%,to{transform:translateY(0)}}@keyframes pulse{50%{transform:scale(.9)}}@media (prefers-color-scheme:dark){@keyframes fade-in-bloom{0%{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(.5) blur(10px);opacity:1}to{filter:brightness(1) blur(0);opacity:1}}}@media (prefers-color-scheme:dark){@keyframes fade-out-bloom{to{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(.5) blur(10px);opacity:1}0%{filter:brightness(1) blur(0);opacity:1}}}:root{--scale-50:scale(0.50);--scale-75:scale(0.75);--scale-90:scale(0.90);--scale-95:scale(0.95);--scale-100:scale(1);--scale-105:scale(1.05);--scale-110:scale(1.10);--scale-125:scale(1.25);--scale-150:scale(1.50);--rotate-0:rotate(0deg);--rotate-1:rotate(1deg);--rotate-2:rotate(2deg);--rotate-3:rotate(3deg);--rotate-6:rotate(6deg);--rotate-12:rotate(12deg);--rotate-45:rotate(45deg);--rotate-90:rotate(90deg);--rotate-180:rotate(180deg);--skew-x-0:skewX(0deg);--skew-y-0:skewY(0deg);--skew-x-1:skewX(1deg);--skew-y-1:skewY(1deg);--skew-x-2:skewX(2deg);--skew-y-2:skewY(2deg);--skew-x-3:skewX(3deg);--skew-y-3:skewY(3deg);--skew-x-6:skewX(6deg);--skew-y-6:skewY(6deg);--skew-x-12:skewX(12deg);--skew-y-12:skewY(12deg);--transition:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,translate,scale,rotate,filter,backdrop-filter;--transition-colors:color,background-color,border-color,text-decoration-color,fill,stroke;--transition-transform:transform,translate,scale,rotate;--time-75:75ms;--time-100:100ms;--time-150:150ms;--time-200:200ms;--time-300:300ms;--time-500:500ms;--time-700:700ms;--time-1000:1000ms;--blur-none:blur(0);--blur-xs:blur(4px);--blur-sm:blur(8px);--blur-md:blur(12px);--blur-lg:blur(16px);--blur-xl:blur(24px);--blur-2xl:blur(40px);--blur-3xl:blur(64px);--brightness-0:brightness(0);--brightness-50:brightness(0.5);--brightness-75:brightness(0.75);--brightness-90:brightness(0.9);--brightness-95:brightness(0.95);--brightness-100:brightness(1);--brightness-105:brightness(1.05);--brightness-110:brightness(1.1);--brightness-125:brightness(1.25);--brightness-150:brightness(1.5);--brightness-200:brightness(2);--contrast-0:contrast(0);--contrast-50:contrast(0.5);--contrast-75:contrast(0.75);--contrast-100:contrast(1);--contrast-125:contrast(1.25);--contrast-150:contrast(1.5);--contrast-200:contrast(2);--drop-shadow-none:drop-shadow(0 0 #0000);--drop-shadow-sm:drop-shadow(0 1px 1px rgba(0,0,0,.05));--drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06));--drop-shadow-md:drop-shadow(0 4px 3px rgba(0,0,0,.07)) drop-shadow(0 2px 2px rgba(0,0,0,.06));--drop-shadow-lg:drop-shadow(0 10px 8px rgba(0,0,0,.04)) drop-shadow(0 4px 3px rgba(0,0,0,.1));--drop-shadow-xl:drop-shadow(0 20px 13px rgba(0,0,0,.03)) drop-shadow(0 8px 5px rgba(0,0,0,.08));--drop-shadow-2xl:drop-shadow(0 25px 25px rgba(0,0,0,.15));--grayscale-0:grayscale(0);--grayscale:grayscale(100%);--hue-rotate-0:hue-rotate(0deg);--hue-rotate-15:hue-rotate(15deg);--hue-rotate-30:hue-rotate(30deg);--hue-rotate-60:hue-rotate(60deg);--hue-rotate-90:hue-rotate(90deg);--hue-rotate-180:hue-rotate(180deg);--invert-0:invert(0);--invert:invert(100%);--saturate-0:saturate(0);--saturate-50:saturate(0.5);--saturate-100:saturate(1);--saturate-150:saturate(1.5);--saturate-200:saturate(2);--sepia-0:sepia(0);--sepia:sepia(100%);--alpha-0:opacity(0);--alpha-5:opacity(0.05);--alpha-10:opacity(0.1);--alpha-15:opacity(0.15);--alpha-20:opacity(0.2);--alpha-25:opacity(0.25);--alpha-30:opacity(0.3);--alpha-35:opacity(0.35);--alpha-40:opacity(0.4);--alpha-45:opacity(0.45);--alpha-50:opacity(0.5);--alpha-55:opacity(0.55);--alpha-60:opacity(0.6);--alpha-65:opacity(0.65);--alpha-70:opacity(0.7);--alpha-75:opacity(0.75);--alpha-80:opacity(0.8);--alpha-85:opacity(0.85);--alpha-90:opacity(0.9);--alpha-95:opacity(0.95);--alpha-100:opacity(1)}.flatpickr-calendar{animation:none;background:transparent;background:#fff;border:0;border-radius:5px;box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,.08);box-sizing:border-box;direction:ltr;display:none;font-size:14px;line-height:24px;opacity:0;padding:0;position:absolute;text-align:center;touch-action:manipulation;visibility:hidden;width:307.875px}.flatpickr-calendar.inline,.flatpickr-calendar.open{max-height:640px;opacity:1;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{animation:fpFadeInDown .3s cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{display:block;z-index:999}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){box-shadow:none!important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasTime .dayContainer,.flatpickr-calendar .hasWeeks .dayContainer{border-bottom:0;border-bottom-left-radius:0;border-bottom-right-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{border-top:1px solid #e6e6e6;height:40px}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:after,.flatpickr-calendar:before{border:solid transparent;content:"";display:block;height:0;left:22px;pointer-events:none;position:absolute;width:0}.flatpickr-calendar.arrowRight:after,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.rightMost:before{left:auto;right:22px}.flatpickr-calendar.arrowCenter:after,.flatpickr-calendar.arrowCenter:before{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:after,.flatpickr-calendar.arrowTop:before{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:after,.flatpickr-calendar.arrowBottom:before{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{display:inline-block;position:relative}.flatpickr-months{display:flex}.flatpickr-months .flatpickr-month{background:transparent;flex:1;line-height:1;overflow:hidden;position:relative;text-align:center}.flatpickr-months .flatpickr-month,.flatpickr-months .flatpickr-next-month,.flatpickr-months .flatpickr-prev-month{color:rgba(0,0,0,.9);fill:rgba(0,0,0,.9);height:34px;-webkit-user-select:none;-moz-user-select:none;user-select:none}.flatpickr-months .flatpickr-next-month,.flatpickr-months .flatpickr-prev-month{cursor:pointer;padding:10px;position:absolute;text-decoration:none;top:0;z-index:3}.flatpickr-months .flatpickr-next-month.flatpickr-disabled,.flatpickr-months .flatpickr-prev-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-next-month i,.flatpickr-months .flatpickr-prev-month i{position:relative}.flatpickr-months .flatpickr-next-month.flatpickr-prev-month,.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month{left:0}.flatpickr-months .flatpickr-next-month.flatpickr-next-month,.flatpickr-months .flatpickr-prev-month.flatpickr-next-month{right:0}.flatpickr-months .flatpickr-next-month:hover,.flatpickr-months .flatpickr-prev-month:hover{color:#959ea9}.flatpickr-months .flatpickr-next-month:hover svg,.flatpickr-months .flatpickr-prev-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-next-month svg,.flatpickr-months .flatpickr-prev-month svg{height:14px;width:14px}.flatpickr-months .flatpickr-next-month svg path,.flatpickr-months .flatpickr-prev-month svg path{transition:fill .1s;fill:inherit}.numInputWrapper{height:auto;position:relative}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-inner-spin-button,.numInputWrapper input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.numInputWrapper span{border:1px solid rgba(57,57,57,.15);box-sizing:border-box;cursor:pointer;height:50%;line-height:50%;opacity:0;padding:0 4px 0 2px;position:absolute;right:0;width:14px}.numInputWrapper span:hover{background:rgba(0,0,0,.1)}.numInputWrapper span:active{background:rgba(0,0,0,.2)}.numInputWrapper span:after{content:"";display:block;position:absolute}.numInputWrapper span.arrowUp{border-bottom:0;top:0}.numInputWrapper span.arrowUp:after{border-bottom:4px solid rgba(57,57,57,.6);border-left:4px solid transparent;border-right:4px solid transparent;top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,.6);top:40%}.numInputWrapper span svg{height:auto;width:inherit}.numInputWrapper span svg path{fill:rgba(0,0,0,.5)}.numInputWrapper:hover{background:rgba(0,0,0,.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{color:inherit;display:inline-block;font-size:135%;font-weight:300;height:34px;left:12.5%;line-height:inherit;line-height:1;padding:7.48px 0 0;position:absolute;text-align:center;transform:translateZ(0);width:75%}.flatpickr-current-month span.cur-month{color:inherit;display:inline-block;font-family:inherit;font-weight:700;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,.05)}.flatpickr-current-month .numInputWrapper{display:inline-block;width:6ch;width:7ch\0}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,.9)}.flatpickr-current-month input.cur-year{-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield;background:transparent;border:0;border-radius:0;box-sizing:border-box;color:inherit;cursor:text;display:inline-block;font-family:inherit;font-size:inherit;font-weight:300;height:auto;line-height:inherit;margin:0;padding:0 0 0 .5ch;vertical-align:initial}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{background:transparent;color:rgba(0,0,0,.5);font-size:100%;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;-webkit-appearance:menulist;-moz-appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;-webkit-box-sizing:border-box;color:inherit;cursor:pointer;font-family:inherit;font-size:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:active,.flatpickr-current-month .flatpickr-monthDropdown-months:focus{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{align-items:center;background:transparent;display:flex;height:28px;overflow:hidden;text-align:center;width:100%}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:flex;flex:1}span.flatpickr-weekday{background:transparent;color:rgba(0,0,0,.54);cursor:default;display:block;flex:1;font-size:90%;font-weight:bolder;line-height:1;margin:0;text-align:center}.dayContainer,.flatpickr-weeks{padding:1px 0 0}.flatpickr-days{align-items:flex-start;display:flex;overflow:hidden;position:relative;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{box-sizing:border-box;display:inline-block;display:flex;flex-wrap:wrap;-ms-flex-wrap:wrap;justify-content:space-around;max-width:307.875px;min-width:307.875px;opacity:1;outline:0;padding:0;text-align:left;transform:translateZ(0);width:307.875px}.dayContainer+.dayContainer{box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;box-sizing:border-box;color:#393939;cursor:pointer;display:inline-block;flex-basis:14.2857143%;font-weight:400;height:39px;justify-content:center;line-height:39px;margin:0;max-width:39px;position:relative;text-align:center;width:14.2857143%}.flatpickr-day.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day.nextMonthDay:focus,.flatpickr-day.nextMonthDay:hover,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.today.inRange,.flatpickr-day:focus,.flatpickr-day:hover{background:#e6e6e6;border-color:#e6e6e6;cursor:pointer;outline:0}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:focus,.flatpickr-day.today:hover{background:#959ea9;border-color:#959ea9;color:#fff}.flatpickr-day.endRange,.flatpickr-day.endRange.inRange,.flatpickr-day.endRange.nextMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.endRange:focus,.flatpickr-day.endRange:hover,.flatpickr-day.selected,.flatpickr-day.selected.inRange,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.selected:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange,.flatpickr-day.startRange.inRange,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.startRange:focus,.flatpickr-day.startRange:hover{background:#569ff7;border-color:#569ff7;box-shadow:none;color:#fff}.flatpickr-day.endRange.startRange,.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.endRange.endRange,.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.endRange.startRange+.endRange:not(:nth-child(7n+1)),.flatpickr-day.selected.startRange+.endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange+.endRange:not(:nth-child(7n+1)){box-shadow:-10px 0 0 #569ff7}.flatpickr-day.endRange.startRange.endRange,.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.nextMonthDay,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.prevMonthDay{background:transparent;border-color:transparent;color:rgba(57,57,57,.3);cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{color:rgba(57,57,57,.1);cursor:not-allowed}.flatpickr-day.week.selected{border-radius:0;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{box-shadow:1px 0 0 #e6e6e6;padding:0 12px}.flatpickr-weekwrapper .flatpickr-weekday{float:none;line-height:28px;width:100%}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{background:transparent;border:none;color:rgba(57,57,57,.3);cursor:default;display:block;max-width:none;width:100%}.flatpickr-innerContainer{box-sizing:border-box;display:block;display:flex;overflow:hidden}.flatpickr-rContainer{box-sizing:border-box;display:inline-block;padding:0}.flatpickr-time{box-sizing:border-box;display:block;display:flex;height:0;line-height:40px;max-height:40px;outline:0;overflow:hidden;text-align:center}.flatpickr-time:after{clear:both;content:"";display:table}.flatpickr-time .numInputWrapper{flex:1;float:left;height:40px;width:40%}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield;background:transparent;border:0;border-radius:0;box-shadow:none;box-sizing:border-box;color:#393939;font-size:14px;height:inherit;line-height:inherit;margin:0;padding:0;position:relative;text-align:center}.flatpickr-time input.flatpickr-hour{font-weight:700}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{border:0;outline:0}.flatpickr-time .flatpickr-am-pm,.flatpickr-time .flatpickr-time-separator{align-self:center;color:#393939;float:left;font-weight:700;height:inherit;line-height:inherit;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:2%}.flatpickr-time .flatpickr-am-pm{cursor:pointer;font-weight:400;outline:0;text-align:center;width:18%}.flatpickr-time .flatpickr-am-pm:focus,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time input:hover{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@keyframes fpFadeInDown{0%{opacity:0;transform:translate3d(0,-20px,0)}to{opacity:1;transform:translateZ(0)}}.alert{border:1px solid var(--alert-border-color,var(--color-border));border-radius:var(--rounded-lg);color:var(--alert-color,var(--color-text));font-size:var(--text-sm);inline-size:var(--size-full);padding:var(--size-4);img{filter:var(--alert-icon-color,var(--color-filter-text))}}.alert--positive{--alert-border-color:var(--color-positive);--alert-color:var(--color-positive);--alert-icon-color:var(--color-filter-positive)}.alert--negative{--alert-border-color:var(--color-negative);--alert-color:var(--color-negative);--alert-icon-color:var(--color-filter-negative)}.badge{background-color:var(--badge-background,var(--color-bg));border:1px solid var(--badge-border-color,var(--color-border));border-radius:var(--rounded-md);box-shadow:var(--badge-box-shadow,none);color:var(--badge-color,var(--color-text));display:inline-flex;font-size:var(--text-xs);font-weight:var(--font-semibold);line-height:var(--leading-4);padding:var(--size-0_5) var(--size-2_5)}.badge--primary{--badge-background:var(--color-primary);--badge-border-color:transparent;--badge-box-shadow:var(--shadow-sm);--badge-color:var(--color-text-reversed)}.badge--secondary{--badge-background:var(--color-secondary);--badge-border-color:transparent;--badge-box-shadow:none;--badge-color:var(--color-text)}.badge--positive{--badge-background:var(--color-positive);--badge-border-color:transparent;--badge-box-shadow:var(--shadow-sm);--badge-color:#fff}.badge--negative{--badge-background:var(--color-negative);--badge-border-color:transparent;--badge-box-shadow:var(--shadow-sm);--badge-color:#fff}.badge--positive-inverse,.badge--primary-inverse{--badge-background:var(--color-bg);--badge-border-color:transparent;--badge-color:var(--color-positive)}.badge--negative-inverse{--badge-background:var(--color-bg);--badge-border-color:transparent;--badge-color:var(--color-negative)}.badge--trend rails-pulse-icon{color:var(--badge-color,currentColor)}html[data-color-scheme=dark] .badge--trend rails-pulse-icon{color:color-mix(in srgb,var(--badge-color) 55%,#fff 45%)}.badge--trend .badge__trend-amount{color:var(--badge-color,currentColor)}html[data-color-scheme=dark] .badge--trend .badge__trend-amount{color:color-mix(in srgb,var(--badge-color) 55%,#fff 45%)}:root{--color-bg:#fff;--color-text:#000;--color-text-reversed:#fff;--color-text-subtle:var(--zinc-500);--color-link:var(--blue-700);--header-bg:#ffc91f;--header-link:#000;--header-link-hover-bg:#ffe284;--color-border-light:var(--zinc-100);--color-border:var(--zinc-200);--color-border-dark:var(--zinc-400);--color-selected:var(--blue-100);--color-selected-dark:var(--blue-300);--color-highlight:var(--yellow-200);--color-primary:var(--zinc-900);--color-secondary:var(--zinc-100);--color-negative:var(--red-600);--color-positive:var(--green-600);--color-filter-text:invert(0);--color-filter-text-reversed:invert(1);--color-filter-negative:invert(22%) sepia(85%) saturate(1790%) hue-rotate(339deg) brightness(105%) contrast(108%);--color-filter-positive:invert(44%) sepia(89%) saturate(409%) hue-rotate(89deg) brightness(94%) contrast(97%)}html[data-color-scheme=dark]{--color-bg:var(--zinc-800);--color-text:#fff;--color-text-reversed:#000;--color-text-subtle:var(--zinc-300);--color-link:#ffc91f;--color-border-light:var(--zinc-900);--color-border:var(--zinc-800);--color-border-dark:var(--zinc-600);--color-selected:var(--blue-950);--color-selected-dark:var(--blue-800);--color-highlight:var(--yellow-900);--header-bg:#202020;--header-link:#ffc91f;--header-link-hover-bg:#ffe284;--color-primary:var(--zinc-50);--color-secondary:var(--zinc-800);--color-negative:var(--red-900);--color-positive:var(--green-900);--color-filter-text:invert(1);--color-filter-text-reversed:invert(0);--color-filter-negative:invert(15%) sepia(65%) saturate(2067%) hue-rotate(339deg) brightness(102%) contrast(97%);--color-filter-positive:invert(23%) sepia(62%) saturate(554%) hue-rotate(91deg) brightness(93%) contrast(91%)}*{border-color:var(--color-border);scrollbar-color:#c1c1c1 transparent;scrollbar-width:thin}html{scroll-behavior:smooth}body{background-color:var(--color-bg);color:var(--color-text);font-synthesis-weight:none;overscroll-behavior:none;text-rendering:optimizeLegibility}.turbo-progress-bar{background-color:#4a8136}::-moz-selection{background-color:var(--color-selected)}::selection{background-color:var(--color-selected)}.breadcrumb{align-items:center;color:var(--color-text-subtle);-moz-column-gap:var(--size-1);column-gap:var(--size-1);display:flex;flex-wrap:wrap;font-size:var(--text-sm);overflow-wrap:break-word;a{padding-block-end:2px}img.breadcrumb-separator{filter:var(--color-filter-text);opacity:.5}a:hover{color:var(--color-text)}span[aria-current=page]{color:var(--color-text);font-weight:500}@media (width >= 40rem){-moz-column-gap:var(--size-2);column-gap:var(--size-2)}}.btn{--btn-background:var(--color-bg);--hover-color:oklch(from var(--btn-background) calc(l * .95) c h);align-items:center;background-color:var(--btn-background);block-size:var(--btn-block-size,auto);border:1px solid var(--btn-border-color,var(--color-border));border-radius:var(--btn-radius,var(--rounded-md));box-shadow:var(--btn-box-shadow,var(--shadow-xs));color:var(--btn-color,var(--color-text));-moz-column-gap:var(--size-2);column-gap:var(--size-2);cursor:default;display:inline-flex;font-size:var(--btn-font-size,var(--text-sm));font-weight:var(--btn-font-weight,var(--font-medium));inline-size:var(--btn-inline-size,auto);justify-content:var(--btn-justify-content,center);padding:var(--btn-padding,.375rem 1rem);position:relative;text-align:var(--btn-text-align,center);white-space:nowrap;img:not([class]){filter:var(--btn-icon-color,var(--color-filter-text))}&:hover{background-color:var(--btn-hover-color,var(--hover-color))}&:focus-visible{outline:var(--btn-outline-size,2px) solid var(--color-selected-dark)}&:is(:disabled,[aria-disabled]){opacity:var(--opacity-50);pointer-events:none}}.btn--primary{--btn-background:var(--color-primary);--btn-border-color:transparent;--btn-color:var(--color-text-reversed);--btn-icon-color:var(--color-filter-text-reversed)}.btn--secondary{--btn-background:var(--color-secondary);--btn-border-color:transparent}.btn--borderless{--btn-border-color:transparent;--btn-box-shadow:none}.btn--positive{--btn-background:var(--color-positive);--btn-border-color:transparent;--btn-color:#fff;--btn-icon-color:invert(1)}.btn--negative{--btn-background:var(--color-negative);--btn-border-color:transparent;--btn-color:#fff;--btn-icon-color:invert(1)}.btn--plain{--btn-background:transparent;--btn-border-color:transparent;--btn-hover-color:transparent;--btn-padding:0;--btn-box-shadow:none}.btn--icon{--btn-padding:var(--size-2)}[aria-busy] .btn--loading:disabled{>*{visibility:hidden}&:after{animation:spin 1s linear infinite;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83'/%3E%3C/svg%3E");background-size:cover;block-size:var(--size-5);content:"";filter:var(--btn-icon-color,var(--color-filter-text));inline-size:var(--size-5);position:absolute}}.card{background-color:var(--color-bg);border-radius:var(--rounded-xl);border-width:var(--border);box-shadow:var(--shadow-sm);padding:var(--size-6)}.card-selectable{background-color:var(--color-bg);border-radius:var(--rounded-xl);border-width:var(--border);padding:var(--size-3);&:has(:checked){background-color:var(--color-secondary);border-color:var(--color-primary)}}.chart-container{aspect-ratio:4/2;width:100%}.chart-container--slim{aspect-ratio:4/3}@media (min-width:64rem){.chart-container,.chart-container--slim{aspect-ratio:16/5}}.collapsible-code.collapsed pre{max-height:4.5em;overflow:hidden;position:relative}.collapsible-code.collapsed pre:after{background:linear-gradient(transparent,var(--color-border-light));bottom:0;content:"";height:1em;left:0;pointer-events:none;position:absolute;right:0}.collapsible-toggle{background:none;border:none;color:var(--color-link);cursor:pointer;font-size:.875rem;font-weight:400;margin-left:10px;margin-top:.5rem;padding:0;text-decoration:underline;transform:lowercase}:root{--rails-pulse-loaded:true}.positioned{--popover-x:0px;--popover-y:0px;--context-menu-x:0px;--context-menu-y:0px}[popover].positioned{inset-block-start:var(--popover-y,0)!important;inset-block-start:var(--context-menu-y,var(--popover-y,0))!important;inset-inline-start:var(--popover-x,0)!important;inset-inline-start:var(--context-menu-x,var(--popover-x,0))!important;position:fixed}[data-controller*=rails-pulse--icon]{display:inline-block;line-height:0}[data-controller*=rails-pulse--icon].loading{opacity:.6}[data-controller*=rails-pulse--icon].error{filter:grayscale(1);opacity:.4}[data-controller*=rails-pulse--icon].loaded{opacity:1}[data-controller*=rails-pulse--icon] svg{display:block;height:inherit;width:inherit}[data-controller*=rails-pulse--icon][aria-label]{position:relative}[data-controller*=rails-pulse--icon]:focus-visible{border-radius:2px;outline:2px solid currentColor;outline-offset:2px}.csp-test-grid-single{--columns:1}.csp-test-context-area{border:2px dashed var(--color-border);padding:2rem;text-align:center}.csp-test-nav-gap{--column-gap:1rem}.csp-test-sheet{--sheet-size:288px}@import url("https://esm.sh/flatpickr@4.6.13/dist/flatpickr.min.css");.flatpickr-calendar{--calendar-size:250px;--container-size:220px;--day-size:var(--size-8);background:var(--color-bg);border:1px solid var(--color-border);border-radius:var(--rounded-md);box-shadow:var(--shadow-md);font-size:var(--text-sm);inline-size:var(--calendar-size);.flatpickr-innerContainer{justify-content:center;padding-block-end:var(--size-3)}.dayContainer,.flatpickr-days{inline-size:var(--container-size)}.dayContainer{max-inline-size:var(--container-size);min-inline-size:var(--container-size)}.dayContainer+.dayContainer{box-shadow:-1px 0 0 var(--color-border)}.flatpickr-months{.flatpickr-month{color:var(--color-text)}span.cur-month{font-size:var(--text-sm);font-weight:var(--font-medium)}svg{fill:var(--color-border-dark)}.flatpickr-next-month:hover svg,.flatpickr-prev-month:hover svg{fill:var(--color-text)}}.flatpickr-monthDropdown-months{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--rounded-md);font-size:var(--text-sm);font-weight:var(--font-medium);line-height:var(--leading-normal);padding:0;text-align:center;&:hover{background:var(--color-border-light)}}.numInputWrapper{input{border-radius:var(--rounded-md);color:var(--color-text);font-size:var(--text-sm);font-weight:var(--font-medium);line-height:var(--leading-normal);padding:0;text-align:center}span{border-color:var(--color-border)}span:hover{background:transparent}span.arrowUp:after{border-bottom-color:var(--color-text)}span.arrowDown:after{border-top-color:var(--color-text)}&:hover{background:transparent}}.flatpickr-weekday{color:var(--color-text-subtle);font-weight:var(--font-normal)}.flatpickr-time{.hasTime &{border-top-color:var(--color-border)}.hasTime.noCalendar &{border:0}.numInput{background:transparent}.flatpickr-am-pm,.flatpickr-time-separator,.numInput{color:var(--color-text)}.flatpickr-am-pm{background:transparent}}.flatpickr-day{border-color:transparent!important;border-radius:var(--rounded-md);box-shadow:none!important;color:var(--color-text);height:var(--day-size);line-height:var(--day-size);margin-block-start:var(--size-2);max-width:var(--day-size);&:is(.inRange){border-radius:0}&:is(.today,.inRange,:hover,:focus){background:var(--color-secondary);color:var(--color-text)}&:is(.flatpickr-disabled,.flatpickr-disabled:hover,.prevMonthDay,.nextMonthDay,.notAllowed,.notAllowed.prevMonthDay,.notAllowed.nextMonthDay){color:var(--color-text-subtle)}&:is(.selected,.startRange,.endRange,.selected.inRange,.startRange.inRange,.endRange.inRange,.selected:focus,.startRange:focus,.endRange:focus,.selected:hover,.startRange:hover,.endRange:hover,.selected.prevMonthDay,.startRange.prevMonthDay,.endRange.prevMonthDay,.selected.nextMonthDay,.startRange.nextMonthDay,.endRange.nextMonthDay){background:var(--color-primary);color:var(--color-text-reversed)}}&:after,&:before{display:none}}.descriptive-list{display:grid;gap:.5rem;grid-template-columns:200px 1fr}.descriptive-list dd,.descriptive-list dt{font-size:var(--text-sm)}.dialog{background-color:var(--color-bg);border-radius:var(--rounded-lg);border-width:var(--border);box-shadow:var(--shadow-lg);color:var(--color-text);inline-size:var(--size-full);margin:auto;max-inline-size:var(--dialog-size,var(--max-i-lg));opacity:0;transform:var(--scale-95);transition-behavior:allow-discrete;transition-duration:var(--time-200);transition-property:display,overlay,opacity,transform;&::backdrop{background-color:rgba(0,0,0,.8)}&::backdrop{opacity:0;transition-behavior:allow-discrete;transition-duration:var(--time-200);transition-property:display,overlay,opacity}&[open]{opacity:1;transform:var(--scale-100)}&[open]::backdrop{opacity:1}@starting-style{&[open]{opacity:0;transform:var(--scale-95)}&[open]::backdrop{opacity:0}}@media (width < 40rem){border-end-end-radius:0;border-end-start-radius:0;margin-block-end:0;max-inline-size:none}}.dialog__content{padding:var(--size-6)}.dialog__close{inset-block-start:var(--size-3);inset-inline-end:var(--size-3);position:absolute}.flash{align-items:center;animation:appear-then-fade 4s .3s both;backdrop-filter:var(--blur-sm) var(--contrast-75);background-color:var(--flash-background,rgb(from var(--color-text) r g b/.65));border-radius:var(--rounded-full);color:var(--flash-color,var(--color-text-reversed));-moz-column-gap:var(--size-2);column-gap:var(--size-2);display:flex;font-size:var(--text-fluid-base);justify-content:center;line-height:var(--leading-none);margin-block-start:var(--flash-position,var(--size-4));margin-inline:auto;min-block-size:var(--size-11);padding:var(--size-1) var(--size-4);text-align:center;[data-turbo-preview] &{display:none}}.flash--positive{--flash-background:var(--color-positive);--flash-color:#fff}.flash--negative{--flash-background:var(--color-negative);--flash-color:#fff}.flash--extended{animation-duration:12s;animation-name:appear-then-fade-extended}@keyframes appear-then-fade{0%,to{opacity:0}5%,60%{opacity:1}}@keyframes appear-then-fade-extended{0%,to{opacity:0}2%,90%{opacity:1}}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--input-background,transparent);block-size:var(--input-block-size,auto);border:1px solid var(--input-border-color,var(--color-border));border-radius:var(--input-radius,var(--rounded-md));box-shadow:var(--input-box-shadow,var(--shadow-xs));font-size:var(--input-font-size,var(--text-sm));inline-size:var(--input-inline-size,var(--size-full));padding:var(--input-padding,.375rem .75rem);&:is(textarea[rows=auto]){field-sizing:content;max-block-size:calc(.875rem + var(--input-max-rows, 10lh));min-block-size:calc(.875rem + var(--input-rows, 2lh))}&:is(select):not([multiple],[size]){background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-position:center right var(--size-2);background-repeat:no-repeat;background-size:var(--size-4) auto}&::file-selector-button{font-weight:var(--font-medium)}&:user-invalid{border-color:var(--color-negative)}&:user-invalid~.invalid-feedback{display:flex}&:disabled{cursor:not-allowed;opacity:var(--opacity-50)}}.input--actor{input{border:0;inline-size:100%;outline:0}img:not([class]){filter:var(--input-icon-color,var(--color-filter-text))}&:focus-within{outline:var(--input-outline-size,2px) solid var(--color-selected-dark)}}.invalid-feedback{display:none}:is(.checkbox,.radio){transform:scale(1.2)}:is(.checkbox,.radio,.range){accent-color:var(--color-primary)}:is(.input,.checkbox,.radio,.range){&:focus-visible{outline:var(--input-outline-size,2px) solid var(--color-selected-dark)}&:focus-visible:user-invalid{outline:none}.field_with_errors &{border-color:var(--color-negative);display:contents}}.sidebar-layout{block-size:100dvh;display:grid;grid-template-areas:"header header" "sidebar main";grid-template-columns:var(--sidebar-width,0) 1fr;grid-template-rows:auto 1fr;@media (width >= 48rem){--sidebar-border-width:var(--border);--sidebar-padding:var(--size-2);--sidebar-width:var(--max-i-3xs)}}.header-layout{block-size:100dvh;display:grid;grid-template-areas:"header" "main";grid-template-rows:auto 1fr}.centered-layout{block-size:100dvh;display:grid;place-items:center}.container{inline-size:100%;margin-inline:auto;max-inline-size:var(--container-width,80rem)}#header{align-items:center;block-size:var(--size-16);border-block-end-width:var(--border);-moz-column-gap:var(--size-4);column-gap:var(--size-4);grid-area:header;padding-inline:var(--size-4)}#header,#sidebar{background-color:rgb(from var(--color-border-light) r g b/.5);display:flex}#sidebar{border-inline-end-width:var(--sidebar-border-width,0);flex-direction:column;grid-area:sidebar;overflow-x:hidden;padding:var(--sidebar-padding,0);row-gap:var(--size-2)}#main{gap:var(--size-4);grid-area:main;overflow:auto;padding:var(--size-4)}#main,.menu{display:flex;flex-direction:column}.menu{padding:var(--size-1);row-gap:var(--size-1)}.menu__header{font-size:var(--text-sm);font-weight:var(--font-semibold);padding:var(--size-1_5) var(--size-2)}.menu__group{display:flex;flex-direction:column;row-gap:1px}.menu__separator{margin-inline:-.25rem}.menu__item{--btn-border-color:transparent;--btn-box-shadow:none;--btn-font-weight:var(--font-normal);--btn-hover-color:var(--color-secondary);--btn-justify-content:start;--btn-outline-size:0;--btn-padding:var(--size-1_5) var(--size-2);--btn-text-align:start;&:focus-visible{--btn-background:var(--color-secondary)}}.menu__item-key{color:var(--color-text-subtle);font-size:var(--text-xs);margin-inline-start:auto}.popover{background-color:var(--color-bg);border-radius:var(--rounded-md);border-width:var(--border);box-shadow:var(--shadow-md);color:var(--color-text);inline-size:var(--popover-size,-moz-max-content);inline-size:var(--popover-size,max-content);opacity:0;transform:var(--scale-95);transition-behavior:allow-discrete;transition-duration:var(--time-150);transition-property:display,overlay,opacity,transform;&:popover-open{opacity:1;transform:var(--scale-100)}@starting-style{&:popover-open{opacity:0;transform:var(--scale-95)}}&.positioned{left:var(--popover-x,0)!important;margin:0!important;position:fixed!important;top:var(--popover-y,0)!important}}.prose{font-size:var(--text-fluid-base);max-inline-size:65ch;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;:is(h1,h2,h3,h4,h5,h6){font-weight:var(--font-extrabold);hyphens:auto;letter-spacing:-.02ch;line-height:1.1;margin-block:.5em;overflow-wrap:break-word;text-wrap:balance}h1{font-size:2.4em}h2{font-size:1.8em}h3{font-size:1.5em}h4{font-size:1.2em}h5{font-size:1em}h6{font-size:.8em}:is(ul,ol,menu){list-style:revert;padding-inline-start:revert}:is(p,ul,ol,dl,blockquote,pre,figure,table,hr){margin-block:.65lh;overflow-wrap:break-word;text-wrap:pretty}hr{border-color:var(--color-border-dark);border-style:var(--border-style,solid) none none;margin:2lh auto}:is(b,strong){font-weight:var(--font-bold)}:is(pre,code){background-color:var(--color-border-light);border:1px solid var(--color-border);border-radius:var(--rounded-sm);font-family:var(--font-monospace-code);font-size:.85em}code{padding:.1em .3em}pre{border-radius:.5em;overflow-x:auto;padding:.5lh 2ch;text-wrap:nowrap}pre code{background-color:transparent;border:0;font-size:1em;padding:0}p{hyphens:auto;letter-spacing:-.005ch}blockquote{font-style:italic;margin:0 3ch}blockquote p{hyphens:none}table{border:1px solid var(--color-border-dark);border-collapse:collapse;margin:1lh 0}th{font-weight:var(--font-bold)}:is(th,td){border:1px solid var(--color-border-dark);padding:.2lh 1ch;text-align:start}th{border-block-end-width:3px}del{background-color:rgb(from var(--color-negative) r g b/.1);color:var(--color-negative)}ins{background-color:rgb(from var(--color-positive) r g b/.1);color:var(--color-positive)}a{color:var(--color-link);text-decoration:underline;-webkit-text-decoration-skip:ink;text-decoration-skip-ink:auto}mark{background-color:var(--color-highlight);color:var(--color-text)}}.row{align-items:stretch;display:flex;gap:var(--column-gap,.5rem);justify-content:space-between;width:100%}.row>*{flex:1;min-width:0}.row>*,.row>.grid-item{display:flex;flex-direction:column}.row>.grid-item>*{flex:1}@media (max-width:768px){.row{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem;justify-content:space-between}.row>*{flex:0 0 calc(50% - 0.25rem);min-width:0}.row>*,.row>.grid-item{height:auto}.row>.grid-item>*{flex:none}.row:has(.table-container)>*{flex:0 0 100%}@media (max-width:480px){.row>*{flex:0 0 100%}.row>.grid-item{min-height:auto}.row>.grid-item .card{padding:var(--size-3)}.row>.grid-item .chart-container{height:60px!important;max-height:60px}}}.sidebar-menu{block-size:var(--size-full);display:flex;flex-direction:column;row-gap:var(--size-4)}.sidebar-menu__button{--btn-background:transparent;--btn-border-color:transparent;--btn-box-shadow:none;--btn-font-weight:var(--font-normal);--btn-hover-color:var(--color-secondary);--btn-justify-content:start;--btn-outline-size:0;--btn-inline-size:var(--size-full);--btn-padding:var(--size-1) var(--size-2);--btn-text-align:start;&:focus-visible{--btn-background:var(--color-secondary)}&:is(summary){&:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");background-size:cover;block-size:var(--size-4);content:"";filter:var(--color-filter-text);inline-size:var(--size-4);margin-inline-start:auto;min-inline-size:var(--size-4);transition:transform var(--time-200)}details[open]>&:after{transform:var(--rotate-90)}&::-webkit-details-marker{display:none}}}.sidebar-menu__content{overflow-y:scroll;row-gap:var(--size-4)}.sidebar-menu__content,.sidebar-menu__group{display:flex;flex-direction:column}.sidebar-menu__group-label{color:var(--color-text-subtle);font-size:var(--text-xs);font-weight:var(--font-medium);padding:var(--size-1_5) var(--size-2)}.sidebar-menu__items,.sidebar-menu__sub{display:flex;flex-direction:column;row-gap:var(--size-1)}.sidebar-menu__sub{border-inline-start-width:var(--border);margin-inline-start:var(--size-4);padding:var(--size-0_5) var(--size-2)}.sheet{background:var(--color-bg);border:0;max-block-size:none;max-inline-size:none;padding:0}.sheet--left{block-size:100vh;inline-size:var(--sheet-size,288px);inset-block-start:0;inset-inline-start:0}.sheet__content{block-size:100%;display:flex;flex-direction:column;overflow-y:auto}.skeleton{animation:var(--animate-blink);background-color:var(--color-border-light);border-radius:var(--rounded-md)}.switch{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--color-border);block-size:var(--size-5);border-color:transparent;border-radius:var(--rounded-full);border-width:var(--border-2);inline-size:var(--size-9);transition:background-color var(--time-150);&:checked{background-color:var(--color-primary)}&:checked:before{margin-inline-start:var(--size-4)}&:before{aspect-ratio:var(--aspect-square);background-color:var(--color-text-reversed);block-size:var(--size-full);border-radius:var(--rounded-full);content:"";display:block;transition:margin var(--time-150)}&:focus-visible{outline:var(--border-2) solid var(--color-selected-dark)}&:disabled{cursor:not-allowed;opacity:var(--opacity-50)}}:where(.table){caption-side:bottom;font-size:var(--text-sm);inline-size:var(--size-full);caption{margin-block-start:var(--size-4)}caption,thead{color:var(--color-text-subtle)}tbody tr{border-block-start-width:var(--border)}tr:hover{background-color:rgb(from var(--color-border-light) r g b/.5)}th{font-weight:var(--font-medium);text-align:start}td,th{padding:var(--size-2)}tfoot{background-color:rgb(from var(--color-border-light) r g b/.5);border-block-start-width:var(--border);font-weight:var(--font-medium)}}.breadcrumb-container{flex-wrap:wrap;gap:1rem;justify-content:space-between}.breadcrumb-container,.breadcrumb-tags{align-items:center;display:flex}.tag-list,.tag-manager{align-items:center;display:flex;gap:.5rem}.tag-list{flex-wrap:wrap}.tag{align-items:center;background-color:var(--color-background-secondary);border:1px solid var(--color-border);border-radius:.375rem;display:inline-flex;font-size:.875rem;gap:.25rem;line-height:1.25;padding:.25rem .5rem;white-space:nowrap}.tag-remove{all:unset;align-items:center;background:none;border:none;color:currentColor;cursor:pointer;display:inline-flex;height:1rem;justify-content:center;margin:0;opacity:.6;padding:0;transition:opacity .15s ease;width:1rem}.tag-remove:hover{opacity:1}.tag-remove span{font-size:1.25rem;font-weight:700;line-height:1}.tag-add-container{display:inline-block;position:relative}.tag-add-button{font-size:.8rem;line-height:1.25;padding:.2rem .5rem;white-space:nowrap}@media (max-width:768px){.breadcrumb-container{align-items:flex-start;flex-direction:column}.breadcrumb-tags,.tag-manager{width:100%}}.w-auto{width:auto}.w-4{width:1rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-12{width:3rem}.w-16{width:4rem}.w-20{width:5rem}.w-24{width:6rem}.w-28{width:7rem}.w-32{width:8rem}.w-36{width:9rem}.w-40{width:10rem}.w-44{width:11rem}.w-48{width:12rem}.w-52{width:13rem}.w-56{width:14rem}.w-60{width:15rem}.w-64{width:16rem}.min-w-0{min-width:0}.min-w-4{min-width:1rem}.min-w-8{min-width:2rem}.min-w-12{min-width:3rem}.min-w-16{min-width:4rem}.min-w-20{min-width:5rem}.min-w-24{min-width:6rem}.min-w-32{min-width:8rem}.max-w-xs{max-width:20rem}.max-w-sm{max-width:24rem}.max-w-md{max-width:28rem}.max-w-lg{max-width:32rem}.max-w-xl{max-width:36rem}.global-filters-active{position:relative}.global-filters-active:after{background-color:var(--color-primary);border:2px solid var(--color-bg);border-radius:50%;content:"";height:8px;position:absolute;right:-2px;top:-2px;width:8px}.flatpickr-calendar,.flatpickr-calendar.inline,.flatpickr-calendar.open,.flatpickr-calendar.static,.flatpickr-calendar.static.open{z-index:999999!important}*{font-family:AvenirNextPro,sans-serif}a{color:var(--color-link);text-decoration:underline}#header{background-color:var(--header-bg)}#header a{color:var(--header-link);text-decoration:none}#header a:hover{background-color:transparent;text-decoration:underline}a:hover{cursor:pointer}html[data-color-scheme=dark] .card{--color-bg:#2f2f2f;--color-border:#404040}html[data-color-scheme=dark] .badge--negative-inverse,html[data-color-scheme=dark] .badge--positive-inverse{--badge-background:#2f2f2f}html[data-color-scheme=dark] .input{--input-background:#535252;--input-border-color:#7e7d7d}.hidden{display:none}.operations-table{width:100%}.operations-table tr{cursor:pointer}.operations-label-cell{max-width:380px;min-width:120px;overflow:hidden;padding-right:10px;text-overflow:ellipsis;vertical-align:middle;white-space:nowrap;width:380px}.operations-label-cell span{font-family:Times New Roman,Times,serif}.operations-duration-cell{max-width:100px;width:60px}.operations-event-cell{background:none;padding:0;position:relative}.operations-event{box-sizing:border-box;height:16px;padding:2px;position:absolute;top:20px}.bar-container{height:10px;position:relative}.bar{background-color:#727579;height:100%;position:absolute;top:0}.bar:first-child{border-bottom-left-radius:1px;border-top-left-radius:1px}.bar:last-child{border-bottom-right-radius:1px;border-top-right-radius:1px}.flex{display:flex}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.inline-flex{display:inline-flex}.justify-start{justify-content:start}.justify-center{justify-content:center}.justify-end{justify-content:end}.justify-between{justify-content:space-between}.items-start{align-items:start}.items-end{align-items:end}.items-center{align-items:center}.grow{flex-grow:1}.grow-0{flex-grow:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.self-start{align-self:start}.self-end{align-self:end}.self-center{align-self:center}.gap{-moz-column-gap:var(--column-gap,.5rem);column-gap:var(--column-gap,.5rem);row-gap:var(--row-gap,1rem)}.gap-half{-moz-column-gap:.25rem;column-gap:.25rem;row-gap:.5rem}.font-normal{font-weight:var(--font-normal)}.font-medium{font-weight:var(--font-medium)}.font-semibold{font-weight:var(--font-semibold)}.font-bold{font-weight:var(--font-bold)}.underline{text-decoration:underline}.no-underline{text-decoration:none}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.whitespace-nowrap{white-space:nowrap}.whitespace-normal{white-space:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.overflow-clip{text-overflow:clip}.overflow-clip,.overflow-ellipsis{overflow:hidden;white-space:nowrap}.overflow-ellipsis{text-overflow:ellipsis}.opacity-75{opacity:var(--opacity-75)}.opacity-50{opacity:var(--opacity-50)}.leading-none{line-height:var(--leading-none)}.leading-tight{line-height:var(--leading-tight)}.text-start{text-align:start}.text-end{text-align:end}.text-center{text-align:center}.text-primary{color:var(--color-text)}.text-reversed{color:var(--color-text-reversed)}.text-negative{color:var(--color-negative)}.text-positive{color:var(--color-positive)}.text-subtle{color:var(--color-text-subtle)}.text-xs{font-size:var(--text-xs)}.text-sm{font-size:var(--text-sm)}.text-base{font-size:var(--text-base)}.text-lg{font-size:var(--text-lg)}.text-xl{font-size:var(--text-xl)}.text-2xl{font-size:var(--text-2xl)}.text-3xl{font-size:var(--text-3xl)}.text-4xl{font-size:var(--text-4xl)}.text-5xl{font-size:var(--text-5xl)}.text-fluid-xs{font-size:var(--text-fluid-xs)}.text-fluid-sm{font-size:var(--text-fluid-sm)}.text-fluid-base{font-size:var(--text-fluid-base)}.text-fluid-lg{font-size:var(--text-fluid-lg)}.text-fluid-xl{font-size:var(--text-fluid-xl)}.text-fluid-2xl{font-size:var(--text-fluid-2xl)}.text-fluid-3xl{font-size:var(--text-fluid-3xl)}.bg-main{background-color:var(--color-bg)}.bg-black{background-color:var(--color-text)}.bg-white{background-color:var(--color-text-reversed)}.bg-shade{background-color:var(--color-border-light)}.bg-transparent{background-color:transparent}.colorize-black{filter:var(--color-filter-text)}.colorize-white{filter:var(--color-filter-text-reversed)}.colorize-negative{filter:var(--color-filter-negative)}.colorize-positive{filter:var(--color-filter-positive)}.border-0{border-width:0}.border{border-width:var(--border-size,1px)}.border-b{border-block-width:var(--border-size,1px)}.border-bs{border-block-start-width:var(--border-size,1px)}.border-be{border-block-end-width:var(--border-size,1px)}.border-i{border-inline-width:var(--border-size,1px)}.border-is{border-inline-start-width:var(--border-size,1px)}.border-ie{border-inline-end-width:var(--border-size,1px)}.border-main{border-color:var(--color-border)}.border-dark{border-color:var(--color-border-dark)}.rounded-none{border-radius:0}.rounded-xs{border-radius:var(--rounded-xs)}.rounded-sm{border-radius:var(--rounded-sm)}.rounded-md{border-radius:var(--rounded-md)}.rounded-lg{border-radius:var(--rounded-lg)}.rounded-full{border-radius:var(--rounded-full)}.shadow-none{box-shadow:none}.shadow-xs{box-shadow:var(--shadow-xs)}.shadow-sm{box-shadow:var(--shadow-sm)}.shadow-md{box-shadow:var(--shadow-md)}.shadow-lg{box-shadow:var(--shadow-lg)}.block{display:block}.inline{display:inline}.inline-block{display:inline-block}.relative{position:relative}.sticky{position:sticky}.min-i-0{min-inline-size:0}.max-i-none{max-inline-size:none}.max-i-full{max-inline-size:100%}.b-full{block-size:100%}.i-full{inline-size:100%}.i-min{inline-size:-moz-min-content;inline-size:min-content}.overflow-x-auto{overflow-x:auto;scroll-snap-type:x mandatory}.overflow-y-auto{overflow-y:auto;scroll-snap-type:y mandatory}.overflow-hidden{overflow:hidden}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.aspect-square{aspect-ratio:1}.aspect-widescreen{aspect-ratio:16/9}.m-0{margin:0}.m-1{margin:var(--size-1)}.m-2{margin:var(--size-2)}.m-3{margin:var(--size-3)}.m-4{margin:var(--size-4)}.m-5{margin:var(--size-5)}.m-6{margin:var(--size-6)}.m-8{margin:var(--size-8)}.m-10{margin:var(--size-10)}.m-auto{margin:auto}.mb-0{margin-block:0}.mb-1{margin-block:var(--size-1)}.mb-2{margin-block:var(--size-2)}.mb-3{margin-block:var(--size-3)}.mb-4{margin-block:var(--size-4)}.mb-5{margin-block:var(--size-5)}.mb-6{margin-block:var(--size-6)}.mb-8{margin-block:var(--size-8)}.mb-10{margin-block:var(--size-10)}.mb-auto{margin-block:auto}.mbs-0{margin-block-start:0}.mbs-1{margin-block-start:var(--size-1)}.mbs-2{margin-block-start:var(--size-2)}.mbs-3{margin-block-start:var(--size-3)}.mbs-4{margin-block-start:var(--size-4)}.mbs-5{margin-block-start:var(--size-5)}.mbs-6{margin-block-start:var(--size-6)}.mbs-8{margin-block-start:var(--size-8)}.mbs-10{margin-block-start:var(--size-10)}.mbs-auto{margin-block-start:auto}.mbe-0{margin-block-end:0}.mbe-1{margin-block-end:var(--size-1)}.mbe-2{margin-block-end:var(--size-2)}.mbe-3{margin-block-end:var(--size-3)}.mbe-4{margin-block-end:var(--size-4)}.mbe-5{margin-block-end:var(--size-5)}.mbe-6{margin-block-end:var(--size-6)}.mbe-8{margin-block-end:var(--size-8)}.mbe-10{margin-block-end:var(--size-10)}.mbe-auto{margin-block-end:auto}.mi-0{margin-inline:0}.mi-1{margin-inline:var(--size-1)}.mi-2{margin-inline:var(--size-2)}.mi-3{margin-inline:var(--size-3)}.mi-4{margin-inline:var(--size-4)}.mi-5{margin-inline:var(--size-5)}.mi-6{margin-inline:var(--size-6)}.mi-8{margin-inline:var(--size-8)}.mi-10{margin-inline:var(--size-10)}.mi-auto{margin-inline:auto}.mis-0{margin-inline-start:0}.mis-1{margin-inline-start:var(--size-1)}.mis-2{margin-inline-start:var(--size-2)}.mis-3{margin-inline-start:var(--size-3)}.mis-4{margin-inline-start:var(--size-4)}.mis-5{margin-inline-start:var(--size-5)}.mis-6{margin-inline-start:var(--size-6)}.mis-8{margin-inline-start:var(--size-8)}.mis-10{margin-inline-start:var(--size-10)}.mis-auto{margin-inline-start:auto}.mie-0{margin-inline-end:0}.mie-1{margin-inline-end:var(--size-1)}.mie-2{margin-inline-end:var(--size-2)}.mie-3{margin-inline-end:var(--size-3)}.mie-4{margin-inline-end:var(--size-4)}.mie-5{margin-inline-end:var(--size-5)}.mie-6{margin-inline-end:var(--size-6)}.mie-8{margin-inline-end:var(--size-8)}.mie-10{margin-inline-end:var(--size-10)}.mie-auto{margin-inline-end:auto}.p-0{padding:0}.p-1{padding:var(--size-1)}.p-2{padding:var(--size-2)}.p-3{padding:var(--size-3)}.p-4{padding:var(--size-4)}.p-5{padding:var(--size-5)}.p-6{padding:var(--size-6)}.p-8{padding:var(--size-8)}.p-10{padding:var(--size-10)}.pb-0{padding-block:0}.pb-1{padding-block:var(--size-1)}.pb-2{padding-block:var(--size-2)}.pb-3{padding-block:var(--size-3)}.pb-4{padding-block:var(--size-4)}.pb-5{padding-block:var(--size-5)}.pb-6{padding-block:var(--size-6)}.pb-8{padding-block:var(--size-8)}.pb-10{padding-block:var(--size-10)}.pbs-0{padding-block-start:0}.pbs-1{padding-block-start:var(--size-1)}.pbs-2{padding-block-start:var(--size-2)}.pbs-3{padding-block-start:var(--size-3)}.pbs-4{padding-block-start:var(--size-4)}.pbs-5{padding-block-start:var(--size-5)}.pbs-6{padding-block-start:var(--size-6)}.pbs-8{padding-block-start:var(--size-8)}.pbs-10{padding-block-start:var(--size-10)}.pbe-0{padding-block-end:0}.pbe-1{padding-block-end:var(--size-1)}.pbe-2{padding-block-end:var(--size-2)}.pbe-3{padding-block-end:var(--size-3)}.pbe-4{padding-block-end:var(--size-4)}.pbe-5{padding-block-end:var(--size-5)}.pbe-6{padding-block-end:var(--size-6)}.pbe-8{padding-block-end:var(--size-8)}.pbe-10{padding-block-end:var(--size-10)}.pi-0{padding-inline:0}.pi-1{padding-inline:var(--size-1)}.pi-2{padding-inline:var(--size-2)}.pi-3{padding-inline:var(--size-3)}.pi-4{padding-inline:var(--size-4)}.pi-5{padding-inline:var(--size-5)}.pi-6{padding-inline:var(--size-6)}.pi-8{padding-inline:var(--size-8)}.pi-10{padding-inline:var(--size-10)}.pis-0{padding-inline-start:0}.pis-1{padding-inline-start:var(--size-1)}.pis-2{padding-inline-start:var(--size-2)}.pis-3{padding-inline-start:var(--size-3)}.pis-4{padding-inline-start:var(--size-4)}.pis-5{padding-inline-start:var(--size-5)}.pis-6{padding-inline-start:var(--size-6)}.pis-8{padding-inline-start:var(--size-8)}.pis-10{padding-inline-start:var(--size-10)}.pie-0{padding-inline-end:0}.pie-1{padding-inline-end:var(--size-1)}.pie-2{padding-inline-end:var(--size-2)}.pie-3{padding-inline-end:var(--size-3)}.pie-4{padding-inline-end:var(--size-4)}.pie-5{padding-inline-end:var(--size-5)}.pie-6{padding-inline-end:var(--size-6)}.pie-8{padding-inline-end:var(--size-8)}.pie-10{padding-inline-end:var(--size-10)}.show\@lg,.show\@md,.show\@sm,.show\@xl{display:none}.show\@sm{@media (width >= 40rem){display:flex}}.show\@md{@media (width >= 48rem){display:flex}}.show\@lg{@media (width >= 64rem){display:flex}}.show\@xl{@media (width >= 80rem){display:flex}}.hide\@sm{@media (width >= 40rem){display:none}}.hide\@md{@media (width >= 48rem){display:none}}.hide\@lg{@media (width >= 64rem){display:none}}.hide\@xl{@media (width >= 80rem){display:none}}.hide\@pwa{@media (display-mode:standalone){display:none}}.hide\@browser{@media (display-mode:browser){display:none}}.hide\@print{@media print{display:none}}.sr-only{block-size:1px;clip-path:inset(50%);inline-size:1px;overflow:hidden;position:absolute;white-space:nowrap} \ No newline at end of file +*,::backdrop,::file-selector-button,:after,:before{border:0 solid;box-sizing:border-box;margin:0;padding:0}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:var(--default-font-family,system-ui,sans-serif);font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{block-size:0;border-block-start-width:1px;color:inherit}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--default-mono-font-family,ui-monospace,monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-size:1em;font-variation-settings:var(--default-mono-font-variation-settings,normal)}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{inset-block-end:-.25em}sup{inset-block-start:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}menu,ol,ul{list-style:none}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{block-size:auto;max-inline-size:100%}::file-selector-button,button,input,optgroup,select,textarea{background-color:transparent;border-radius:0;color:inherit;font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;opacity:1}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::-moz-placeholder{color:color-mix(in oklab,currentColor 50%,transparent);opacity:1}::placeholder{color:color-mix(in oklab,currentColor 50%,transparent);opacity:1}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-block-size:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}::file-selector-button,button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{block-size:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}[contents]{display:contents!important}turbo-frame{display:contents}:root{interpolate-size:allow-keywords;color-scheme:light dark}::-webkit-calendar-picker-indicator{line-height:1em}option{padding:2px 4px}html:has(dialog:modal[open]){overflow:hidden}@media (prefers-reduced-motion:reduce){*,::backdrop,:after,:before{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}:root{--slate-50:oklch(0.984 0.003 247.858);--slate-100:oklch(0.968 0.007 247.896);--slate-200:oklch(0.929 0.013 255.508);--slate-300:oklch(0.869 0.022 252.894);--slate-400:oklch(0.704 0.04 256.788);--slate-500:oklch(0.554 0.046 257.417);--slate-600:oklch(0.446 0.043 257.281);--slate-700:oklch(0.372 0.044 257.287);--slate-800:oklch(0.279 0.041 260.031);--slate-900:oklch(0.208 0.042 265.755);--slate-950:oklch(0.129 0.042 264.695);--gray-50:oklch(0.985 0.002 247.839);--gray-100:oklch(0.967 0.003 264.542);--gray-200:oklch(0.928 0.006 264.531);--gray-300:oklch(0.872 0.01 258.338);--gray-400:oklch(0.707 0.022 261.325);--gray-500:oklch(0.551 0.027 264.364);--gray-600:oklch(0.446 0.03 256.802);--gray-700:oklch(0.373 0.034 259.733);--gray-800:oklch(0.278 0.033 256.848);--gray-900:oklch(0.21 0.034 264.665);--gray-950:oklch(0.13 0.028 261.692);--zinc-50:oklch(0.985 0 0);--zinc-100:oklch(0.967 0.001 286.375);--zinc-200:oklch(0.92 0.004 286.32);--zinc-300:oklch(0.871 0.006 286.286);--zinc-400:oklch(0.705 0.015 286.067);--zinc-500:oklch(0.552 0.016 285.938);--zinc-600:oklch(0.442 0.017 285.786);--zinc-700:oklch(0.37 0.013 285.805);--zinc-800:oklch(0.274 0.006 286.033);--zinc-900:oklch(0.21 0.006 285.885);--zinc-950:oklch(0.141 0.005 285.823);--neutral-50:oklch(0.985 0 0);--neutral-100:oklch(0.97 0 0);--neutral-200:oklch(0.922 0 0);--neutral-300:oklch(0.87 0 0);--neutral-400:oklch(0.708 0 0);--neutral-500:oklch(0.556 0 0);--neutral-600:oklch(0.439 0 0);--neutral-700:oklch(0.371 0 0);--neutral-800:oklch(0.269 0 0);--neutral-900:oklch(0.205 0 0);--neutral-950:oklch(0.145 0 0);--stone-50:oklch(0.985 0.001 106.423);--stone-100:oklch(0.97 0.001 106.424);--stone-200:oklch(0.923 0.003 48.717);--stone-300:oklch(0.869 0.005 56.366);--stone-400:oklch(0.709 0.01 56.259);--stone-500:oklch(0.553 0.013 58.071);--stone-600:oklch(0.444 0.011 73.639);--stone-700:oklch(0.374 0.01 67.558);--stone-800:oklch(0.268 0.007 34.298);--stone-900:oklch(0.216 0.006 56.043);--stone-950:oklch(0.147 0.004 49.25);--red-50:oklch(0.971 0.013 17.38);--red-100:oklch(0.936 0.032 17.717);--red-200:oklch(0.885 0.062 18.334);--red-300:oklch(0.808 0.114 19.571);--red-400:oklch(0.704 0.191 22.216);--red-500:oklch(0.637 0.237 25.331);--red-600:oklch(0.577 0.245 27.325);--red-700:oklch(0.505 0.213 27.518);--red-800:oklch(0.444 0.177 26.899);--red-900:oklch(0.396 0.141 25.723);--red-950:oklch(0.258 0.092 26.042);--orange-50:oklch(0.98 0.016 73.684);--orange-100:oklch(0.954 0.038 75.164);--orange-200:oklch(0.901 0.076 70.697);--orange-300:oklch(0.837 0.128 66.29);--orange-400:oklch(0.75 0.183 55.934);--orange-500:oklch(0.705 0.213 47.604);--orange-600:oklch(0.646 0.222 41.116);--orange-700:oklch(0.553 0.195 38.402);--orange-800:oklch(0.47 0.157 37.304);--orange-900:oklch(0.408 0.123 38.172);--orange-950:oklch(0.266 0.079 36.259);--amber-50:oklch(0.987 0.022 95.277);--amber-100:oklch(0.962 0.059 95.617);--amber-200:oklch(0.924 0.12 95.746);--amber-300:oklch(0.879 0.169 91.605);--amber-400:oklch(0.828 0.189 84.429);--amber-500:oklch(0.769 0.188 70.08);--amber-600:oklch(0.666 0.179 58.318);--amber-700:oklch(0.555 0.163 48.998);--amber-800:oklch(0.473 0.137 46.201);--amber-900:oklch(0.414 0.112 45.904);--amber-950:oklch(0.279 0.077 45.635);--yellow-50:oklch(0.987 0.026 102.212);--yellow-100:oklch(0.973 0.071 103.193);--yellow-200:oklch(0.945 0.129 101.54);--yellow-300:oklch(0.905 0.182 98.111);--yellow-400:oklch(0.852 0.199 91.936);--yellow-500:oklch(0.795 0.184 86.047);--yellow-600:oklch(0.681 0.162 75.834);--yellow-700:oklch(0.554 0.135 66.442);--yellow-800:oklch(0.476 0.114 61.907);--yellow-900:oklch(0.421 0.095 57.708);--yellow-950:oklch(0.286 0.066 53.813);--lime-50:oklch(0.986 0.031 120.757);--lime-100:oklch(0.967 0.067 122.328);--lime-200:oklch(0.938 0.127 124.321);--lime-300:oklch(0.897 0.196 126.665);--lime-400:oklch(0.841 0.238 128.85);--lime-500:oklch(0.768 0.233 130.85);--lime-600:oklch(0.648 0.2 131.684);--lime-700:oklch(0.532 0.157 131.589);--lime-800:oklch(0.453 0.124 130.933);--lime-900:oklch(0.405 0.101 131.063);--lime-950:oklch(0.274 0.072 132.109);--green-50:oklch(0.982 0.018 155.826);--green-100:oklch(0.962 0.044 156.743);--green-200:oklch(0.925 0.084 155.995);--green-300:oklch(0.871 0.15 154.449);--green-400:oklch(0.792 0.209 151.711);--green-500:oklch(0.723 0.219 149.579);--green-600:oklch(0.627 0.194 149.214);--green-700:oklch(0.527 0.154 150.069);--green-800:oklch(0.448 0.119 151.328);--green-900:oklch(0.393 0.095 152.535);--green-950:oklch(0.266 0.065 152.934);--emerald-50:oklch(0.979 0.021 166.113);--emerald-100:oklch(0.95 0.052 163.051);--emerald-200:oklch(0.905 0.093 164.15);--emerald-300:oklch(0.845 0.143 164.978);--emerald-400:oklch(0.765 0.177 163.223);--emerald-500:oklch(0.696 0.17 162.48);--emerald-600:oklch(0.596 0.145 163.225);--emerald-700:oklch(0.508 0.118 165.612);--emerald-800:oklch(0.432 0.095 166.913);--emerald-900:oklch(0.378 0.077 168.94);--emerald-950:oklch(0.262 0.051 172.552);--teal-50:oklch(0.984 0.014 180.72);--teal-100:oklch(0.953 0.051 180.801);--teal-200:oklch(0.91 0.096 180.426);--teal-300:oklch(0.855 0.138 181.071);--teal-400:oklch(0.777 0.152 181.912);--teal-500:oklch(0.704 0.14 182.503);--teal-600:oklch(0.6 0.118 184.704);--teal-700:oklch(0.511 0.096 186.391);--teal-800:oklch(0.437 0.078 188.216);--teal-900:oklch(0.386 0.063 188.416);--teal-950:oklch(0.277 0.046 192.524);--cyan-50:oklch(0.984 0.019 200.873);--cyan-100:oklch(0.956 0.045 203.388);--cyan-200:oklch(0.917 0.08 205.041);--cyan-300:oklch(0.865 0.127 207.078);--cyan-400:oklch(0.789 0.154 211.53);--cyan-500:oklch(0.715 0.143 215.221);--cyan-600:oklch(0.609 0.126 221.723);--cyan-700:oklch(0.52 0.105 223.128);--cyan-800:oklch(0.45 0.085 224.283);--cyan-900:oklch(0.398 0.07 227.392);--cyan-950:oklch(0.302 0.056 229.695);--sky-50:oklch(0.977 0.013 236.62);--sky-100:oklch(0.951 0.026 236.824);--sky-200:oklch(0.901 0.058 230.902);--sky-300:oklch(0.828 0.111 230.318);--sky-400:oklch(0.746 0.16 232.661);--sky-500:oklch(0.685 0.169 237.323);--sky-600:oklch(0.588 0.158 241.966);--sky-700:oklch(0.5 0.134 242.749);--sky-800:oklch(0.443 0.11 240.79);--sky-900:oklch(0.391 0.09 240.876);--sky-950:oklch(0.293 0.066 243.157);--blue-50:oklch(0.97 0.014 254.604);--blue-100:oklch(0.932 0.032 255.585);--blue-200:oklch(0.882 0.059 254.128);--blue-300:oklch(0.809 0.105 251.813);--blue-400:oklch(0.707 0.165 254.624);--blue-500:oklch(0.623 0.214 259.815);--blue-600:oklch(0.546 0.245 262.881);--blue-700:oklch(0.488 0.243 264.376);--blue-800:oklch(0.424 0.199 265.638);--blue-900:oklch(0.379 0.146 265.522);--blue-950:oklch(0.282 0.091 267.935);--indigo-50:oklch(0.962 0.018 272.314);--indigo-100:oklch(0.93 0.034 272.788);--indigo-200:oklch(0.87 0.065 274.039);--indigo-300:oklch(0.785 0.115 274.713);--indigo-400:oklch(0.673 0.182 276.935);--indigo-500:oklch(0.585 0.233 277.117);--indigo-600:oklch(0.511 0.262 276.966);--indigo-700:oklch(0.457 0.24 277.023);--indigo-800:oklch(0.398 0.195 277.366);--indigo-900:oklch(0.359 0.144 278.697);--indigo-950:oklch(0.257 0.09 281.288);--violet-50:oklch(0.969 0.016 293.756);--violet-100:oklch(0.943 0.029 294.588);--violet-200:oklch(0.894 0.057 293.283);--violet-300:oklch(0.811 0.111 293.571);--violet-400:oklch(0.702 0.183 293.541);--violet-500:oklch(0.606 0.25 292.717);--violet-600:oklch(0.541 0.281 293.009);--violet-700:oklch(0.491 0.27 292.581);--violet-800:oklch(0.432 0.232 292.759);--violet-900:oklch(0.38 0.189 293.745);--violet-950:oklch(0.283 0.141 291.089);--purple-50:oklch(0.977 0.014 308.299);--purple-100:oklch(0.946 0.033 307.174);--purple-200:oklch(0.902 0.063 306.703);--purple-300:oklch(0.827 0.119 306.383);--purple-400:oklch(0.714 0.203 305.504);--purple-500:oklch(0.627 0.265 303.9);--purple-600:oklch(0.558 0.288 302.321);--purple-700:oklch(0.496 0.265 301.924);--purple-800:oklch(0.438 0.218 303.724);--purple-900:oklch(0.381 0.176 304.987);--purple-950:oklch(0.291 0.149 302.717);--fuchsia-50:oklch(0.977 0.017 320.058);--fuchsia-100:oklch(0.952 0.037 318.852);--fuchsia-200:oklch(0.903 0.076 319.62);--fuchsia-300:oklch(0.833 0.145 321.434);--fuchsia-400:oklch(0.74 0.238 322.16);--fuchsia-500:oklch(0.667 0.295 322.15);--fuchsia-600:oklch(0.591 0.293 322.896);--fuchsia-700:oklch(0.518 0.253 323.949);--fuchsia-800:oklch(0.452 0.211 324.591);--fuchsia-900:oklch(0.401 0.17 325.612);--fuchsia-950:oklch(0.293 0.136 325.661);--pink-50:oklch(0.971 0.014 343.198);--pink-100:oklch(0.948 0.028 342.258);--pink-200:oklch(0.899 0.061 343.231);--pink-300:oklch(0.823 0.12 346.018);--pink-400:oklch(0.718 0.202 349.761);--pink-500:oklch(0.656 0.241 354.308);--pink-600:oklch(0.592 0.249 0.584);--pink-700:oklch(0.525 0.223 3.958);--pink-800:oklch(0.459 0.187 3.815);--pink-900:oklch(0.408 0.153 2.432);--pink-950:oklch(0.284 0.109 3.907);--rose-50:oklch(0.969 0.015 12.422);--rose-100:oklch(0.941 0.03 12.58);--rose-200:oklch(0.892 0.058 10.001);--rose-300:oklch(0.81 0.117 11.638);--rose-400:oklch(0.712 0.194 13.428);--rose-500:oklch(0.645 0.246 16.439);--rose-600:oklch(0.586 0.253 17.585);--rose-700:oklch(0.514 0.222 16.935);--rose-800:oklch(0.455 0.188 13.697);--rose-900:oklch(0.41 0.159 10.272);--rose-950:oklch(0.271 0.105 12.094);--size-0_5:0.125rem;--size-1:0.25rem;--size-1_5:0.375rem;--size-2:0.5rem;--size-2_5:0.625rem;--size-3:0.75rem;--size-3_5:0.875rem;--size-4:1rem;--size-5:1.25rem;--size-6:1.5rem;--size-7:1.75rem;--size-8:2rem;--size-9:2.25rem;--size-10:2.5rem;--size-11:2.75rem;--size-12:3rem;--size-14:3.5rem;--size-16:4rem;--size-20:5rem;--size-24:6rem;--size-28:7rem;--size-32:8rem;--size-36:9rem;--size-40:10rem;--size-44:11rem;--size-48:12rem;--size-52:13rem;--size-56:14rem;--size-60:15rem;--size-64:16rem;--size-72:18rem;--size-80:20rem;--size-96:24rem;--size-1-2:50%;--size-1-3:33.333333%;--size-2-3:66.666667%;--size-1-4:25%;--size-2-4:50%;--size-3-4:75%;--size-1-5:20%;--size-2-5:40%;--size-3-5:60%;--size-4-5:80%;--size-1-6:16.666667%;--size-2-6:33.333333%;--size-3-6:50%;--size-4-6:66.666667%;--size-5-6:83.333333%;--size-1-12:8.333333%;--size-2-12:16.666667%;--size-3-12:25%;--size-4-12:33.333333%;--size-5-12:41.666667%;--size-6-12:50%;--size-7-12:58.333333%;--size-8-12:66.666667%;--size-9-12:75%;--size-10-12:83.333333%;--size-11-12:91.666667%;--size-full:100%;--max-i-3xs:16rem;--max-i-2xs:18rem;--max-i-xs:20rem;--max-i-sm:24rem;--max-i-md:28rem;--max-i-lg:32rem;--max-i-xl:36rem;--max-i-2xl:42rem;--max-i-3xl:48rem;--max-i-4xl:56rem;--max-i-5xl:64rem;--max-i-6xl:72rem;--max-i-7xl:80rem;--aspect-square:1/1;--aspect-widescreen:16/9;--breakpoint-sm:40rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--border:1px;--border-2:2px;--border-4:4px;--border-8:8px;--rounded-xs:0.125rem;--rounded-sm:0.25rem;--rounded-md:0.375rem;--rounded-lg:0.5rem;--rounded-xl:0.75rem;--rounded-2xl:1rem;--rounded-3xl:1.5rem;--rounded-full:9999px;--shadow-xs:0 1px 2px 0 rgba(0,0,0,.05);--shadow-sm:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--shadow-md:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--shadow-lg:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--shadow-xl:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--shadow-2xl:0 25px 50px -12px rgba(0,0,0,.25);--shadow-inner:inset 0 2px 4px 0 rgba(0,0,0,.05);--opacity-5:0.05;--opacity-10:0.1;--opacity-20:0.2;--opacity-25:0.25;--opacity-30:0.3;--opacity-40:0.4;--opacity-50:0.5;--opacity-60:0.6;--opacity-70:0.7;--opacity-75:0.75;--opacity-80:0.8;--opacity-90:0.9;--opacity-95:0.95;--opacity-100:1;--text-xs:0.75rem;--text-sm:0.875rem;--text-base:1rem;--text-lg:1.125rem;--text-xl:1.25rem;--text-2xl:1.5rem;--text-3xl:1.875rem;--text-4xl:2.25rem;--text-5xl:3rem;--text-6xl:3.75rem;--text-7xl:4.5rem;--text-8xl:6rem;--text-9xl:8rem;--text-fluid-xs:clamp(0.75rem,0.64rem + 0.57vw,1rem);--text-fluid-sm:clamp(0.875rem,0.761rem + 0.568vw,1.125rem);--text-fluid-base:clamp(1rem,0.89rem + 0.57vw,1.25rem);--text-fluid-lg:clamp(1.125rem,0.955rem + 0.852vw,1.5rem);--text-fluid-xl:clamp(1.25rem,0.966rem + 1.42vw,1.875rem);--text-fluid-2xl:clamp(1.5rem,1.16rem + 1.7vw,2.25rem);--text-fluid-3xl:clamp(1.875rem,1.364rem + 2.557vw,3rem);--text-fluid-4xl:clamp(2.25rem,1.57rem + 3.41vw,3.75rem);--text-fluid-5xl:clamp(3rem,2.32rem + 3.41vw,4.5rem);--text-fluid-6xl:clamp(3.75rem,2.73rem + 5.11vw,6rem);--text-fluid-7xl:clamp(4.5rem,2.91rem + 7.95vw,8rem);--font-thin:100;--font-extralight:200;--font-light:300;--font-normal:400;--font-medium:500;--font-semibold:600;--font-bold:700;--font-extrabold:800;--font-black:900;--leading-none:1;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--leading-loose:2;--leading-3:.75rem;--leading-4:1rem;--leading-5:1.25rem;--leading-6:1.5rem;--leading-7:1.75rem;--leading-8:2rem;--leading-9:2.25rem;--leading-10:2.5rem;--font-system-ui:system-ui,sans-serif;--font-transitional:Charter,Bitstream Charter,Sitka Text,Cambria,serif;--font-old-style:Iowan Old Style,Palatino Linotype,URW Palladio L,P052,serif;--font-humanist:Seravek,Gill Sans Nova,Ubuntu,Calibri,DejaVu Sans,source-sans-pro,sans-serif;--font-geometric-humanist:Avenir,Montserrat,Corbel,URW Gothic,source-sans-pro,sans-serif;--font-classical-humanist:Optima,Candara,Noto Sans,source-sans-pro,sans-serif;--font-neo-grotesque:Inter,Roboto,Helvetica Neue,Arial Nova,Nimbus Sans,Arial,sans-serif;--font-monospace-slab-serif:Nimbus Mono PS,Courier New,monospace;--font-monospace-code:Dank Mono,Operator Mono,Inconsolata,Fira Mono,ui-monospace,SF Mono,Monaco,Droid Sans Mono,Source Code Pro,Cascadia Code,Menlo,Consolas,DejaVu Sans Mono,monospace;--font-industrial:Bahnschrift,DIN Alternate,Franklin Gothic Medium,Nimbus Sans Narrow,sans-serif-condensed,sans-serif;--font-rounded-sans:ui-rounded,Hiragino Maru Gothic ProN,Quicksand,Comfortaa,Manjari,Arial Rounded MT,Arial Rounded MT Bold,Calibri,source-sans-pro,sans-serif;--font-slab-serif:Rockwell,Rockwell Nova,Roboto Slab,DejaVu Serif,Sitka Small,serif;--font-antique:Superclarendon,Bookman Old Style,URW Bookman,URW Bookman L,Georgia Pro,Georgia,serif;--font-didone:Didot,Bodoni MT,Noto Serif Display,URW Palladio L,P052,Sylfaen,serif;--font-handwritten:Segoe Print,Bradley Hand,Chilanka,TSCu_Comic,casual,cursive;--tracking-tighter:-0.05em;--tracking-tight:-0.025em;--tracking-normal:0em;--tracking-wide:0.025em;--tracking-wider:0.05em;--tracking-widest:0.1em;--animate-fade-in:fade-in .5s cubic-bezier(.25,0,.3,1);--animate-fade-in-bloom:fade-in-bloom 2s cubic-bezier(.25,0,.3,1);--animate-fade-out:fade-out .5s cubic-bezier(.25,0,.3,1);--animate-fade-out-bloom:fade-out-bloom 2s cubic-bezier(.25,0,.3,1);--animate-scale-up:scale-up .5s cubic-bezier(.25,0,.3,1);--animate-scale-down:scale-down .5s cubic-bezier(.25,0,.3,1);--animate-slide-out-up:slide-out-up .5s cubic-bezier(.25,0,.3,1);--animate-slide-out-down:slide-out-down .5s cubic-bezier(.25,0,.3,1);--animate-slide-out-right:slide-out-right .5s cubic-bezier(.25,0,.3,1);--animate-slide-out-left:slide-out-left .5s cubic-bezier(.25,0,.3,1);--animate-slide-in-up:slide-in-up .5s cubic-bezier(.25,0,.3,1);--animate-slide-in-down:slide-in-down .5s cubic-bezier(.25,0,.3,1);--animate-slide-in-right:slide-in-right .5s cubic-bezier(.25,0,.3,1);--animate-slide-in-left:slide-in-left .5s cubic-bezier(.25,0,.3,1);--animate-shake-x:shake-x .75s cubic-bezier(0,0,0,1);--animate-shake-y:shake-y .75s cubic-bezier(0,0,0,1);--animate-shake-z:shake-z 1s cubic-bezier(0,0,0,1);--animate-spin:spin 2s linear infinite;--animate-ping:ping 5s cubic-bezier(0,0,.3,1) infinite;--animate-blink:blink 1s cubic-bezier(0,0,.3,1) infinite;--animate-float:float 3s cubic-bezier(0,0,0,1) infinite;--animate-bounce:bounce 2s cubic-bezier(.5,-.3,.1,1.5) infinite;--animate-pulse:pulse 2s cubic-bezier(0,0,.3,1) infinite}@keyframes fade-in{to{opacity:1}}@keyframes fade-in-bloom{0%{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(2) blur(10px);opacity:1}to{filter:brightness(1) blur(0);opacity:1}}@keyframes fade-out{to{opacity:0}}@keyframes fade-out-bloom{to{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(2) blur(10px);opacity:1}0%{filter:brightness(1) blur(0);opacity:1}}@keyframes scale-up{to{transform:scale(1.25)}}@keyframes scale-down{to{transform:scale(.75)}}@keyframes slide-out-up{to{transform:translateY(-100%)}}@keyframes slide-out-down{to{transform:translateY(100%)}}@keyframes slide-out-right{to{transform:translateX(100%)}}@keyframes slide-out-left{to{transform:translateX(-100%)}}@keyframes slide-in-up{0%{transform:translateY(100%)}}@keyframes slide-in-down{0%{transform:translateY(-100%)}}@keyframes slide-in-right{0%{transform:translateX(-100%)}}@keyframes slide-in-left{0%{transform:translateX(100%)}}@keyframes shake-x{0%,to{transform:translateX(0)}20%{transform:translateX(-5%)}40%{transform:translateX(5%)}60%{transform:translateX(-5%)}80%{transform:translateX(5%)}}@keyframes shake-y{0%,to{transform:translateY(0)}20%{transform:translateY(-5%)}40%{transform:translateY(5%)}60%{transform:translateY(-5%)}80%{transform:translateY(5%)}}@keyframes shake-z{0%,to{transform:rotate(0deg)}20%{transform:rotate(-2deg)}40%{transform:rotate(2deg)}60%{transform:rotate(-2deg)}80%{transform:rotate(2deg)}}@keyframes spin{to{transform:rotate(1turn)}}@keyframes ping{90%,to{opacity:0;transform:scale(2)}}@keyframes blink{0%,to{opacity:1}50%{opacity:.5}}@keyframes float{50%{transform:translateY(-25%)}}@keyframes bounce{25%{transform:translateY(-20%)}40%{transform:translateY(-3%)}0%,60%,to{transform:translateY(0)}}@keyframes pulse{50%{transform:scale(.9)}}@media (prefers-color-scheme:dark){@keyframes fade-in-bloom{0%{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(.5) blur(10px);opacity:1}to{filter:brightness(1) blur(0);opacity:1}}}@media (prefers-color-scheme:dark){@keyframes fade-out-bloom{to{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(.5) blur(10px);opacity:1}0%{filter:brightness(1) blur(0);opacity:1}}}:root{--scale-50:scale(0.50);--scale-75:scale(0.75);--scale-90:scale(0.90);--scale-95:scale(0.95);--scale-100:scale(1);--scale-105:scale(1.05);--scale-110:scale(1.10);--scale-125:scale(1.25);--scale-150:scale(1.50);--rotate-0:rotate(0deg);--rotate-1:rotate(1deg);--rotate-2:rotate(2deg);--rotate-3:rotate(3deg);--rotate-6:rotate(6deg);--rotate-12:rotate(12deg);--rotate-45:rotate(45deg);--rotate-90:rotate(90deg);--rotate-180:rotate(180deg);--skew-x-0:skewX(0deg);--skew-y-0:skewY(0deg);--skew-x-1:skewX(1deg);--skew-y-1:skewY(1deg);--skew-x-2:skewX(2deg);--skew-y-2:skewY(2deg);--skew-x-3:skewX(3deg);--skew-y-3:skewY(3deg);--skew-x-6:skewX(6deg);--skew-y-6:skewY(6deg);--skew-x-12:skewX(12deg);--skew-y-12:skewY(12deg);--transition:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,translate,scale,rotate,filter,backdrop-filter;--transition-colors:color,background-color,border-color,text-decoration-color,fill,stroke;--transition-transform:transform,translate,scale,rotate;--time-75:75ms;--time-100:100ms;--time-150:150ms;--time-200:200ms;--time-300:300ms;--time-500:500ms;--time-700:700ms;--time-1000:1000ms;--blur-none:blur(0);--blur-xs:blur(4px);--blur-sm:blur(8px);--blur-md:blur(12px);--blur-lg:blur(16px);--blur-xl:blur(24px);--blur-2xl:blur(40px);--blur-3xl:blur(64px);--brightness-0:brightness(0);--brightness-50:brightness(0.5);--brightness-75:brightness(0.75);--brightness-90:brightness(0.9);--brightness-95:brightness(0.95);--brightness-100:brightness(1);--brightness-105:brightness(1.05);--brightness-110:brightness(1.1);--brightness-125:brightness(1.25);--brightness-150:brightness(1.5);--brightness-200:brightness(2);--contrast-0:contrast(0);--contrast-50:contrast(0.5);--contrast-75:contrast(0.75);--contrast-100:contrast(1);--contrast-125:contrast(1.25);--contrast-150:contrast(1.5);--contrast-200:contrast(2);--drop-shadow-none:drop-shadow(0 0 #0000);--drop-shadow-sm:drop-shadow(0 1px 1px rgba(0,0,0,.05));--drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06));--drop-shadow-md:drop-shadow(0 4px 3px rgba(0,0,0,.07)) drop-shadow(0 2px 2px rgba(0,0,0,.06));--drop-shadow-lg:drop-shadow(0 10px 8px rgba(0,0,0,.04)) drop-shadow(0 4px 3px rgba(0,0,0,.1));--drop-shadow-xl:drop-shadow(0 20px 13px rgba(0,0,0,.03)) drop-shadow(0 8px 5px rgba(0,0,0,.08));--drop-shadow-2xl:drop-shadow(0 25px 25px rgba(0,0,0,.15));--grayscale-0:grayscale(0);--grayscale:grayscale(100%);--hue-rotate-0:hue-rotate(0deg);--hue-rotate-15:hue-rotate(15deg);--hue-rotate-30:hue-rotate(30deg);--hue-rotate-60:hue-rotate(60deg);--hue-rotate-90:hue-rotate(90deg);--hue-rotate-180:hue-rotate(180deg);--invert-0:invert(0);--invert:invert(100%);--saturate-0:saturate(0);--saturate-50:saturate(0.5);--saturate-100:saturate(1);--saturate-150:saturate(1.5);--saturate-200:saturate(2);--sepia-0:sepia(0);--sepia:sepia(100%);--alpha-0:opacity(0);--alpha-5:opacity(0.05);--alpha-10:opacity(0.1);--alpha-15:opacity(0.15);--alpha-20:opacity(0.2);--alpha-25:opacity(0.25);--alpha-30:opacity(0.3);--alpha-35:opacity(0.35);--alpha-40:opacity(0.4);--alpha-45:opacity(0.45);--alpha-50:opacity(0.5);--alpha-55:opacity(0.55);--alpha-60:opacity(0.6);--alpha-65:opacity(0.65);--alpha-70:opacity(0.7);--alpha-75:opacity(0.75);--alpha-80:opacity(0.8);--alpha-85:opacity(0.85);--alpha-90:opacity(0.9);--alpha-95:opacity(0.95);--alpha-100:opacity(1)}.flatpickr-calendar{animation:none;background:transparent;background:#fff;border:0;border-radius:5px;box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,.08);box-sizing:border-box;direction:ltr;display:none;font-size:14px;line-height:24px;opacity:0;padding:0;position:absolute;text-align:center;touch-action:manipulation;visibility:hidden;width:307.875px}.flatpickr-calendar.inline,.flatpickr-calendar.open{max-height:640px;opacity:1;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{animation:fpFadeInDown .3s cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{display:block;z-index:999}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){box-shadow:none!important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasTime .dayContainer,.flatpickr-calendar .hasWeeks .dayContainer{border-bottom:0;border-bottom-left-radius:0;border-bottom-right-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{border-top:1px solid #e6e6e6;height:40px}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:after,.flatpickr-calendar:before{border:solid transparent;content:"";display:block;height:0;left:22px;pointer-events:none;position:absolute;width:0}.flatpickr-calendar.arrowRight:after,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.rightMost:before{left:auto;right:22px}.flatpickr-calendar.arrowCenter:after,.flatpickr-calendar.arrowCenter:before{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:after,.flatpickr-calendar.arrowTop:before{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:after,.flatpickr-calendar.arrowBottom:before{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{display:inline-block;position:relative}.flatpickr-months{display:flex}.flatpickr-months .flatpickr-month{background:transparent;flex:1;line-height:1;overflow:hidden;position:relative;text-align:center}.flatpickr-months .flatpickr-month,.flatpickr-months .flatpickr-next-month,.flatpickr-months .flatpickr-prev-month{color:rgba(0,0,0,.9);fill:rgba(0,0,0,.9);height:34px;-webkit-user-select:none;-moz-user-select:none;user-select:none}.flatpickr-months .flatpickr-next-month,.flatpickr-months .flatpickr-prev-month{cursor:pointer;padding:10px;position:absolute;text-decoration:none;top:0;z-index:3}.flatpickr-months .flatpickr-next-month.flatpickr-disabled,.flatpickr-months .flatpickr-prev-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-next-month i,.flatpickr-months .flatpickr-prev-month i{position:relative}.flatpickr-months .flatpickr-next-month.flatpickr-prev-month,.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month{left:0}.flatpickr-months .flatpickr-next-month.flatpickr-next-month,.flatpickr-months .flatpickr-prev-month.flatpickr-next-month{right:0}.flatpickr-months .flatpickr-next-month:hover,.flatpickr-months .flatpickr-prev-month:hover{color:#959ea9}.flatpickr-months .flatpickr-next-month:hover svg,.flatpickr-months .flatpickr-prev-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-next-month svg,.flatpickr-months .flatpickr-prev-month svg{height:14px;width:14px}.flatpickr-months .flatpickr-next-month svg path,.flatpickr-months .flatpickr-prev-month svg path{transition:fill .1s;fill:inherit}.numInputWrapper{height:auto;position:relative}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-inner-spin-button,.numInputWrapper input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.numInputWrapper span{border:1px solid rgba(57,57,57,.15);box-sizing:border-box;cursor:pointer;height:50%;line-height:50%;opacity:0;padding:0 4px 0 2px;position:absolute;right:0;width:14px}.numInputWrapper span:hover{background:rgba(0,0,0,.1)}.numInputWrapper span:active{background:rgba(0,0,0,.2)}.numInputWrapper span:after{content:"";display:block;position:absolute}.numInputWrapper span.arrowUp{border-bottom:0;top:0}.numInputWrapper span.arrowUp:after{border-bottom:4px solid rgba(57,57,57,.6);border-left:4px solid transparent;border-right:4px solid transparent;top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,.6);top:40%}.numInputWrapper span svg{height:auto;width:inherit}.numInputWrapper span svg path{fill:rgba(0,0,0,.5)}.numInputWrapper:hover{background:rgba(0,0,0,.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{color:inherit;display:inline-block;font-size:135%;font-weight:300;height:34px;left:12.5%;line-height:inherit;line-height:1;padding:7.48px 0 0;position:absolute;text-align:center;transform:translateZ(0);width:75%}.flatpickr-current-month span.cur-month{color:inherit;display:inline-block;font-family:inherit;font-weight:700;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,.05)}.flatpickr-current-month .numInputWrapper{display:inline-block;width:6ch;width:7ch\0}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,.9)}.flatpickr-current-month input.cur-year{-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield;background:transparent;border:0;border-radius:0;box-sizing:border-box;color:inherit;cursor:text;display:inline-block;font-family:inherit;font-size:inherit;font-weight:300;height:auto;line-height:inherit;margin:0;padding:0 0 0 .5ch;vertical-align:initial}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{background:transparent;color:rgba(0,0,0,.5);font-size:100%;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;-webkit-appearance:menulist;-moz-appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;-webkit-box-sizing:border-box;color:inherit;cursor:pointer;font-family:inherit;font-size:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:active,.flatpickr-current-month .flatpickr-monthDropdown-months:focus{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{align-items:center;background:transparent;display:flex;height:28px;overflow:hidden;text-align:center;width:100%}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:flex;flex:1}span.flatpickr-weekday{background:transparent;color:rgba(0,0,0,.54);cursor:default;display:block;flex:1;font-size:90%;font-weight:bolder;line-height:1;margin:0;text-align:center}.dayContainer,.flatpickr-weeks{padding:1px 0 0}.flatpickr-days{align-items:flex-start;display:flex;overflow:hidden;position:relative;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{box-sizing:border-box;display:inline-block;display:flex;flex-wrap:wrap;-ms-flex-wrap:wrap;justify-content:space-around;max-width:307.875px;min-width:307.875px;opacity:1;outline:0;padding:0;text-align:left;transform:translateZ(0);width:307.875px}.dayContainer+.dayContainer{box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;box-sizing:border-box;color:#393939;cursor:pointer;display:inline-block;flex-basis:14.2857143%;font-weight:400;height:39px;justify-content:center;line-height:39px;margin:0;max-width:39px;position:relative;text-align:center;width:14.2857143%}.flatpickr-day.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day.nextMonthDay:focus,.flatpickr-day.nextMonthDay:hover,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.today.inRange,.flatpickr-day:focus,.flatpickr-day:hover{background:#e6e6e6;border-color:#e6e6e6;cursor:pointer;outline:0}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:focus,.flatpickr-day.today:hover{background:#959ea9;border-color:#959ea9;color:#fff}.flatpickr-day.endRange,.flatpickr-day.endRange.inRange,.flatpickr-day.endRange.nextMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.endRange:focus,.flatpickr-day.endRange:hover,.flatpickr-day.selected,.flatpickr-day.selected.inRange,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.selected:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange,.flatpickr-day.startRange.inRange,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.startRange:focus,.flatpickr-day.startRange:hover{background:#569ff7;border-color:#569ff7;box-shadow:none;color:#fff}.flatpickr-day.endRange.startRange,.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.endRange.endRange,.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.endRange.startRange+.endRange:not(:nth-child(7n+1)),.flatpickr-day.selected.startRange+.endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange+.endRange:not(:nth-child(7n+1)){box-shadow:-10px 0 0 #569ff7}.flatpickr-day.endRange.startRange.endRange,.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.nextMonthDay,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.prevMonthDay{background:transparent;border-color:transparent;color:rgba(57,57,57,.3);cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{color:rgba(57,57,57,.1);cursor:not-allowed}.flatpickr-day.week.selected{border-radius:0;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{box-shadow:1px 0 0 #e6e6e6;padding:0 12px}.flatpickr-weekwrapper .flatpickr-weekday{float:none;line-height:28px;width:100%}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{background:transparent;border:none;color:rgba(57,57,57,.3);cursor:default;display:block;max-width:none;width:100%}.flatpickr-innerContainer{box-sizing:border-box;display:block;display:flex;overflow:hidden}.flatpickr-rContainer{box-sizing:border-box;display:inline-block;padding:0}.flatpickr-time{box-sizing:border-box;display:block;display:flex;height:0;line-height:40px;max-height:40px;outline:0;overflow:hidden;text-align:center}.flatpickr-time:after{clear:both;content:"";display:table}.flatpickr-time .numInputWrapper{flex:1;float:left;height:40px;width:40%}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield;background:transparent;border:0;border-radius:0;box-shadow:none;box-sizing:border-box;color:#393939;font-size:14px;height:inherit;line-height:inherit;margin:0;padding:0;position:relative;text-align:center}.flatpickr-time input.flatpickr-hour{font-weight:700}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{border:0;outline:0}.flatpickr-time .flatpickr-am-pm,.flatpickr-time .flatpickr-time-separator{align-self:center;color:#393939;float:left;font-weight:700;height:inherit;line-height:inherit;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:2%}.flatpickr-time .flatpickr-am-pm{cursor:pointer;font-weight:400;outline:0;text-align:center;width:18%}.flatpickr-time .flatpickr-am-pm:focus,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time input:hover{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@keyframes fpFadeInDown{0%{opacity:0;transform:translate3d(0,-20px,0)}to{opacity:1;transform:translateZ(0)}}.alert{border:1px solid var(--alert-border-color,var(--color-border));border-radius:var(--rounded-lg);color:var(--alert-color,var(--color-text));font-size:var(--text-sm);inline-size:var(--size-full);padding:var(--size-4);img{filter:var(--alert-icon-color,var(--color-filter-text))}}.alert--positive{--alert-border-color:var(--color-positive);--alert-color:var(--color-positive);--alert-icon-color:var(--color-filter-positive)}.alert--negative{--alert-border-color:var(--color-negative);--alert-color:var(--color-negative);--alert-icon-color:var(--color-filter-negative)}.badge{background-color:var(--badge-background,var(--color-bg));border:1px solid var(--badge-border-color,var(--color-border));border-radius:var(--rounded-md);box-shadow:var(--badge-box-shadow,none);color:var(--badge-color,var(--color-text));display:inline-flex;font-size:var(--text-xs);font-weight:var(--font-semibold);line-height:var(--leading-4);padding:var(--size-0_5) var(--size-2_5)}.badge--primary{--badge-background:var(--color-primary);--badge-border-color:transparent;--badge-box-shadow:var(--shadow-sm);--badge-color:var(--color-text-reversed)}.badge--secondary{--badge-background:var(--color-secondary);--badge-border-color:transparent;--badge-box-shadow:none;--badge-color:var(--color-text)}.badge--positive{--badge-background:var(--color-positive);--badge-border-color:transparent;--badge-box-shadow:var(--shadow-sm);--badge-color:#fff}.badge--negative{--badge-background:var(--color-negative);--badge-border-color:transparent;--badge-box-shadow:var(--shadow-sm);--badge-color:#fff}.badge--positive-inverse,.badge--primary-inverse{--badge-background:var(--color-bg);--badge-border-color:transparent;--badge-color:var(--color-positive)}.badge--negative-inverse{--badge-background:var(--color-bg);--badge-border-color:transparent;--badge-color:var(--color-negative)}.badge--trend rails-pulse-icon{color:var(--badge-color,currentColor)}html[data-color-scheme=dark] .badge--trend rails-pulse-icon{color:color-mix(in srgb,var(--badge-color) 55%,#fff 45%)}.badge--trend .badge__trend-amount{color:var(--badge-color,currentColor)}html[data-color-scheme=dark] .badge--trend .badge__trend-amount{color:color-mix(in srgb,var(--badge-color) 55%,#fff 45%)}:root{--color-bg:#fff;--color-text:#000;--color-text-reversed:#fff;--color-text-subtle:var(--zinc-500);--color-link:var(--blue-700);--header-bg:#ffc91f;--header-link:#000;--header-link-hover-bg:#ffe284;--color-border-light:var(--zinc-100);--color-border:var(--zinc-200);--color-border-dark:var(--zinc-400);--color-selected:var(--blue-100);--color-selected-dark:var(--blue-300);--color-highlight:var(--yellow-200);--color-primary:var(--zinc-900);--color-secondary:var(--zinc-100);--color-negative:var(--red-600);--color-positive:var(--green-600);--color-filter-text:invert(0);--color-filter-text-reversed:invert(1);--color-filter-negative:invert(22%) sepia(85%) saturate(1790%) hue-rotate(339deg) brightness(105%) contrast(108%);--color-filter-positive:invert(44%) sepia(89%) saturate(409%) hue-rotate(89deg) brightness(94%) contrast(97%)}html[data-color-scheme=dark]{--color-bg:var(--zinc-800);--color-text:#fff;--color-text-reversed:#000;--color-text-subtle:var(--zinc-300);--color-link:#ffc91f;--color-border-light:var(--zinc-900);--color-border:var(--zinc-800);--color-border-dark:var(--zinc-600);--color-selected:var(--blue-950);--color-selected-dark:var(--blue-800);--color-highlight:var(--yellow-900);--header-bg:#202020;--header-link:#ffc91f;--header-link-hover-bg:#ffe284;--color-primary:var(--zinc-50);--color-secondary:var(--zinc-800);--color-negative:var(--red-900);--color-positive:var(--green-900);--color-filter-text:invert(1);--color-filter-text-reversed:invert(0);--color-filter-negative:invert(15%) sepia(65%) saturate(2067%) hue-rotate(339deg) brightness(102%) contrast(97%);--color-filter-positive:invert(23%) sepia(62%) saturate(554%) hue-rotate(91deg) brightness(93%) contrast(91%)}*{border-color:var(--color-border);scrollbar-color:#c1c1c1 transparent;scrollbar-width:thin}html{scroll-behavior:smooth}body{background-color:var(--color-bg);color:var(--color-text);font-synthesis-weight:none;overscroll-behavior:none;text-rendering:optimizeLegibility}.turbo-progress-bar{background-color:#4a8136}::-moz-selection{background-color:var(--color-selected)}::selection{background-color:var(--color-selected)}.breadcrumb{align-items:center;color:var(--color-text-subtle);-moz-column-gap:var(--size-1);column-gap:var(--size-1);display:flex;flex-wrap:wrap;font-size:var(--text-sm);overflow-wrap:break-word;a{padding-block-end:2px}img.breadcrumb-separator{filter:var(--color-filter-text);opacity:.5}a:hover{color:var(--color-text)}span[aria-current=page]{color:var(--color-text);font-weight:500}@media (width >= 40rem){-moz-column-gap:var(--size-2);column-gap:var(--size-2)}}.btn{--btn-background:var(--color-bg);--hover-color:oklch(from var(--btn-background) calc(l * .95) c h);align-items:center;background-color:var(--btn-background);block-size:var(--btn-block-size,auto);border:1px solid var(--btn-border-color,var(--color-border));border-radius:var(--btn-radius,var(--rounded-md));box-shadow:var(--btn-box-shadow,var(--shadow-xs));color:var(--btn-color,var(--color-text));-moz-column-gap:var(--size-2);column-gap:var(--size-2);cursor:default;display:inline-flex;font-size:var(--btn-font-size,var(--text-sm));font-weight:var(--btn-font-weight,var(--font-medium));inline-size:var(--btn-inline-size,auto);justify-content:var(--btn-justify-content,center);padding:var(--btn-padding,.375rem 1rem);position:relative;text-align:var(--btn-text-align,center);white-space:nowrap;img:not([class]){filter:var(--btn-icon-color,var(--color-filter-text))}&:hover{background-color:var(--btn-hover-color,var(--hover-color))}&:focus-visible{outline:var(--btn-outline-size,2px) solid var(--color-selected-dark)}&:is(:disabled,[aria-disabled]){opacity:var(--opacity-50);pointer-events:none}}.btn--primary{--btn-background:var(--color-primary);--btn-border-color:transparent;--btn-color:var(--color-text-reversed);--btn-icon-color:var(--color-filter-text-reversed)}.btn--secondary{--btn-background:var(--color-secondary);--btn-border-color:transparent}.btn--borderless{--btn-border-color:transparent;--btn-box-shadow:none}.btn--positive{--btn-background:var(--color-positive);--btn-border-color:transparent;--btn-color:#fff;--btn-icon-color:invert(1)}.btn--negative{--btn-background:var(--color-negative);--btn-border-color:transparent;--btn-color:#fff;--btn-icon-color:invert(1)}.btn--plain{--btn-background:transparent;--btn-border-color:transparent;--btn-hover-color:transparent;--btn-padding:0;--btn-box-shadow:none}.btn--icon{--btn-padding:var(--size-2)}[aria-busy] .btn--loading:disabled{>*{visibility:hidden}&:after{animation:spin 1s linear infinite;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83'/%3E%3C/svg%3E");background-size:cover;block-size:var(--size-5);content:"";filter:var(--btn-icon-color,var(--color-filter-text));inline-size:var(--size-5);position:absolute}}.card{background-color:var(--color-bg);border-radius:var(--rounded-xl);border-width:var(--border);box-shadow:var(--shadow-sm);padding:var(--size-6)}.card-selectable{background-color:var(--color-bg);border-radius:var(--rounded-xl);border-width:var(--border);padding:var(--size-3);&:has(:checked){background-color:var(--color-secondary);border-color:var(--color-primary)}}.chart-container{aspect-ratio:4/2;width:100%}.chart-container--slim{aspect-ratio:4/3}@media (min-width:64rem){.chart-container,.chart-container--slim{aspect-ratio:16/5}}.collapsible-code.collapsed pre{max-height:4.5em;overflow:hidden;position:relative}.collapsible-code.collapsed pre:after{background:linear-gradient(transparent,var(--color-border-light));bottom:0;content:"";height:1em;left:0;pointer-events:none;position:absolute;right:0}.collapsible-toggle{background:none;border:none;color:var(--color-link);cursor:pointer;font-size:.875rem;font-weight:400;margin-left:10px;margin-top:.5rem;padding:0;text-decoration:underline;transform:lowercase}:root{--rails-pulse-loaded:true}.positioned{--popover-x:0px;--popover-y:0px;--context-menu-x:0px;--context-menu-y:0px}[popover].positioned{inset-block-start:var(--popover-y,0)!important;inset-block-start:var(--context-menu-y,var(--popover-y,0))!important;inset-inline-start:var(--popover-x,0)!important;inset-inline-start:var(--context-menu-x,var(--popover-x,0))!important;position:fixed}[data-controller*=rails-pulse--icon]{display:inline-block;line-height:0}[data-controller*=rails-pulse--icon].loading{opacity:.6}[data-controller*=rails-pulse--icon].error{filter:grayscale(1);opacity:.4}[data-controller*=rails-pulse--icon].loaded{opacity:1}[data-controller*=rails-pulse--icon] svg{display:block;height:inherit;width:inherit}[data-controller*=rails-pulse--icon][aria-label]{position:relative}[data-controller*=rails-pulse--icon]:focus-visible{border-radius:2px;outline:2px solid currentColor;outline-offset:2px}.csp-test-grid-single{--columns:1}.csp-test-context-area{border:2px dashed var(--color-border);padding:2rem;text-align:center}.csp-test-nav-gap{--column-gap:1rem}.csp-test-sheet{--sheet-size:288px}@import url("https://esm.sh/flatpickr@4.6.13/dist/flatpickr.min.css");.flatpickr-calendar{--calendar-size:250px;--container-size:220px;--day-size:var(--size-8);background:var(--color-bg);border:1px solid var(--color-border);border-radius:var(--rounded-md);box-shadow:var(--shadow-md);font-size:var(--text-sm);inline-size:var(--calendar-size);.flatpickr-innerContainer{justify-content:center;padding-block-end:var(--size-3)}.dayContainer,.flatpickr-days{inline-size:var(--container-size)}.dayContainer{max-inline-size:var(--container-size);min-inline-size:var(--container-size)}.dayContainer+.dayContainer{box-shadow:-1px 0 0 var(--color-border)}.flatpickr-months{.flatpickr-month{color:var(--color-text)}span.cur-month{font-size:var(--text-sm);font-weight:var(--font-medium)}svg{fill:var(--color-border-dark)}.flatpickr-next-month:hover svg,.flatpickr-prev-month:hover svg{fill:var(--color-text)}}.flatpickr-monthDropdown-months{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--rounded-md);font-size:var(--text-sm);font-weight:var(--font-medium);line-height:var(--leading-normal);padding:0;text-align:center;&:hover{background:var(--color-border-light)}}.numInputWrapper{input{border-radius:var(--rounded-md);color:var(--color-text);font-size:var(--text-sm);font-weight:var(--font-medium);line-height:var(--leading-normal);padding:0;text-align:center}span{border-color:var(--color-border)}span:hover{background:transparent}span.arrowUp:after{border-bottom-color:var(--color-text)}span.arrowDown:after{border-top-color:var(--color-text)}&:hover{background:transparent}}.flatpickr-weekday{color:var(--color-text-subtle);font-weight:var(--font-normal)}.flatpickr-time{.hasTime &{border-top-color:var(--color-border)}.hasTime.noCalendar &{border:0}.numInput{background:transparent}.flatpickr-am-pm,.flatpickr-time-separator,.numInput{color:var(--color-text)}.flatpickr-am-pm{background:transparent}}.flatpickr-day{border-color:transparent!important;border-radius:var(--rounded-md);box-shadow:none!important;color:var(--color-text);height:var(--day-size);line-height:var(--day-size);margin-block-start:var(--size-2);max-width:var(--day-size);&:is(.inRange){border-radius:0}&:is(.today,.inRange,:hover,:focus){background:var(--color-secondary);color:var(--color-text)}&:is(.flatpickr-disabled,.flatpickr-disabled:hover,.prevMonthDay,.nextMonthDay,.notAllowed,.notAllowed.prevMonthDay,.notAllowed.nextMonthDay){color:var(--color-text-subtle)}&:is(.selected,.startRange,.endRange,.selected.inRange,.startRange.inRange,.endRange.inRange,.selected:focus,.startRange:focus,.endRange:focus,.selected:hover,.startRange:hover,.endRange:hover,.selected.prevMonthDay,.startRange.prevMonthDay,.endRange.prevMonthDay,.selected.nextMonthDay,.startRange.nextMonthDay,.endRange.nextMonthDay){background:var(--color-primary);color:var(--color-text-reversed)}}&:after,&:before{display:none}}.descriptive-list{display:grid;gap:.5rem;grid-template-columns:200px 1fr}.descriptive-list dd,.descriptive-list dt{font-size:var(--text-sm)}.dialog{background-color:var(--color-bg);border-radius:var(--rounded-lg);border-width:var(--border);box-shadow:var(--shadow-lg);color:var(--color-text);inline-size:var(--size-full);margin:auto;max-inline-size:var(--dialog-size,var(--max-i-lg));opacity:0;transform:var(--scale-95);transition-behavior:allow-discrete;transition-duration:var(--time-200);transition-property:display,overlay,opacity,transform;&::backdrop{background-color:rgba(0,0,0,.8)}&::backdrop{opacity:0;transition-behavior:allow-discrete;transition-duration:var(--time-200);transition-property:display,overlay,opacity}&[open]{opacity:1;transform:var(--scale-100)}&[open]::backdrop{opacity:1}@starting-style{&[open]{opacity:0;transform:var(--scale-95)}&[open]::backdrop{opacity:0}}@media (width < 40rem){border-end-end-radius:0;border-end-start-radius:0;margin-block-end:0;max-inline-size:none}}.dialog__content{padding:var(--size-6)}.dialog__close{inset-block-start:var(--size-3);inset-inline-end:var(--size-3);position:absolute}.flash{align-items:center;animation:appear-then-fade 4s .3s both;backdrop-filter:var(--blur-sm) var(--contrast-75);background-color:var(--flash-background,rgb(from var(--color-text) r g b/.65));border-radius:var(--rounded-full);color:var(--flash-color,var(--color-text-reversed));-moz-column-gap:var(--size-2);column-gap:var(--size-2);display:flex;font-size:var(--text-fluid-base);justify-content:center;line-height:var(--leading-none);margin-block-start:var(--flash-position,var(--size-4));margin-inline:auto;min-block-size:var(--size-11);padding:var(--size-1) var(--size-4);text-align:center;[data-turbo-preview] &{display:none}}.flash--positive{--flash-background:var(--color-positive);--flash-color:#fff}.flash--negative{--flash-background:var(--color-negative);--flash-color:#fff}.flash--extended{animation-duration:12s;animation-name:appear-then-fade-extended}@keyframes appear-then-fade{0%,to{opacity:0}5%,60%{opacity:1}}@keyframes appear-then-fade-extended{0%,to{opacity:0}2%,90%{opacity:1}}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--input-background,transparent);block-size:var(--input-block-size,auto);border:1px solid var(--input-border-color,var(--color-border));border-radius:var(--input-radius,var(--rounded-md));box-shadow:var(--input-box-shadow,var(--shadow-xs));font-size:var(--input-font-size,var(--text-sm));inline-size:var(--input-inline-size,var(--size-full));padding:var(--input-padding,.375rem .75rem);&:is(textarea[rows=auto]){field-sizing:content;max-block-size:calc(.875rem + var(--input-max-rows, 10lh));min-block-size:calc(.875rem + var(--input-rows, 2lh))}&:is(select):not([multiple],[size]){background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-position:center right var(--size-2);background-repeat:no-repeat;background-size:var(--size-4) auto}&::file-selector-button{font-weight:var(--font-medium)}&:user-invalid{border-color:var(--color-negative)}&:user-invalid~.invalid-feedback{display:flex}&:disabled{cursor:not-allowed;opacity:var(--opacity-50)}}.input--actor{input{border:0;inline-size:100%;outline:0}img:not([class]){filter:var(--input-icon-color,var(--color-filter-text))}&:focus-within{outline:var(--input-outline-size,2px) solid var(--color-selected-dark)}}.invalid-feedback{display:none}:is(.checkbox,.radio){transform:scale(1.2)}:is(.checkbox,.radio,.range){accent-color:var(--color-primary)}:is(.input,.checkbox,.radio,.range){&:focus-visible{outline:var(--input-outline-size,2px) solid var(--color-selected-dark)}&:focus-visible:user-invalid{outline:none}.field_with_errors &{border-color:var(--color-negative);display:contents}}.sidebar-layout{block-size:100dvh;display:grid;grid-template-areas:"header header" "sidebar main";grid-template-columns:var(--sidebar-width,0) 1fr;grid-template-rows:auto 1fr;@media (width >= 48rem){--sidebar-border-width:var(--border);--sidebar-padding:var(--size-2);--sidebar-width:var(--max-i-3xs)}}.header-layout{block-size:100dvh;display:grid;grid-template-areas:"header" "main";grid-template-rows:auto 1fr}.centered-layout{block-size:100dvh;display:grid;place-items:center}.container{inline-size:100%;margin-inline:auto;max-inline-size:var(--container-width,80rem)}#header{align-items:center;block-size:var(--size-16);border-block-end-width:var(--border);-moz-column-gap:var(--size-4);column-gap:var(--size-4);grid-area:header;padding-inline:var(--size-4)}#header,#sidebar{background-color:rgb(from var(--color-border-light) r g b/.5);display:flex}#sidebar{border-inline-end-width:var(--sidebar-border-width,0);flex-direction:column;grid-area:sidebar;overflow-x:hidden;padding:var(--sidebar-padding,0);row-gap:var(--size-2)}#main{gap:var(--size-4);grid-area:main;overflow:auto;padding:var(--size-4)}#main,.menu{display:flex;flex-direction:column}.menu{padding:var(--size-1);row-gap:var(--size-1)}.menu__header{font-size:var(--text-sm);font-weight:var(--font-semibold);padding:var(--size-1_5) var(--size-2)}.menu__group{display:flex;flex-direction:column;row-gap:1px}.menu__separator{margin-inline:-.25rem}.menu__item{--btn-border-color:transparent;--btn-box-shadow:none;--btn-font-weight:var(--font-normal);--btn-hover-color:var(--color-secondary);--btn-justify-content:start;--btn-outline-size:0;--btn-padding:var(--size-1_5) var(--size-2);--btn-text-align:start;&:focus-visible{--btn-background:var(--color-secondary)}}.menu__item-key{color:var(--color-text-subtle);font-size:var(--text-xs);margin-inline-start:auto}.popover{background-color:var(--color-bg);border-radius:var(--rounded-md);border-width:var(--border);box-shadow:var(--shadow-md);color:var(--color-text);inline-size:var(--popover-size,-moz-max-content);inline-size:var(--popover-size,max-content);opacity:0;transform:var(--scale-95);transition-behavior:allow-discrete;transition-duration:var(--time-150);transition-property:display,overlay,opacity,transform;&:popover-open{opacity:1;transform:var(--scale-100)}@starting-style{&:popover-open{opacity:0;transform:var(--scale-95)}}&.positioned{left:var(--popover-x,0)!important;margin:0!important;position:fixed!important;top:var(--popover-y,0)!important}}.prose{font-size:var(--text-fluid-base);max-inline-size:65ch;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;:is(h1,h2,h3,h4,h5,h6){font-weight:var(--font-extrabold);hyphens:auto;letter-spacing:-.02ch;line-height:1.1;margin-block:.5em;overflow-wrap:break-word;text-wrap:balance}h1{font-size:2.4em}h2{font-size:1.8em}h3{font-size:1.5em}h4{font-size:1.2em}h5{font-size:1em}h6{font-size:.8em}:is(ul,ol,menu){list-style:revert;padding-inline-start:revert}:is(p,ul,ol,dl,blockquote,pre,figure,table,hr){margin-block:.65lh;overflow-wrap:break-word;text-wrap:pretty}hr{border-color:var(--color-border-dark);border-style:var(--border-style,solid) none none;margin:2lh auto}:is(b,strong){font-weight:var(--font-bold)}:is(pre,code){background-color:var(--color-border-light);border:1px solid var(--color-border);border-radius:var(--rounded-sm);font-family:var(--font-monospace-code);font-size:.85em}code{padding:.1em .3em}pre{border-radius:.5em;overflow-x:auto;padding:.5lh 2ch;text-wrap:nowrap}pre code{background-color:transparent;border:0;font-size:1em;padding:0}p{hyphens:auto;letter-spacing:-.005ch}blockquote{font-style:italic;margin:0 3ch}blockquote p{hyphens:none}table{border:1px solid var(--color-border-dark);border-collapse:collapse;margin:1lh 0}th{font-weight:var(--font-bold)}:is(th,td){border:1px solid var(--color-border-dark);padding:.2lh 1ch;text-align:start}th{border-block-end-width:3px}del{background-color:rgb(from var(--color-negative) r g b/.1);color:var(--color-negative)}ins{background-color:rgb(from var(--color-positive) r g b/.1);color:var(--color-positive)}a{color:var(--color-link);text-decoration:underline;-webkit-text-decoration-skip:ink;text-decoration-skip-ink:auto}mark{background-color:var(--color-highlight);color:var(--color-text)}}.row{align-items:stretch;display:flex;gap:var(--column-gap,.5rem);justify-content:space-between;width:100%}.row>*{flex:1;min-width:0}.row>*,.row>.grid-item{display:flex;flex-direction:column}.row>.grid-item>*{flex:1}@media (max-width:768px){.row{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem;justify-content:space-between}.row>*{flex:0 0 calc(50% - 0.25rem);min-width:0}.row>*,.row>.grid-item{height:auto}.row>.grid-item>*{flex:none}.row:has(.table-container)>*{flex:0 0 100%}@media (max-width:480px){.row>*{flex:0 0 100%}.row>.grid-item{min-height:auto}.row>.grid-item .card{padding:var(--size-3)}.row>.grid-item .chart-container{height:60px!important;max-height:60px}}}.sidebar-menu{block-size:var(--size-full);display:flex;flex-direction:column;row-gap:var(--size-4)}.sidebar-menu__button{--btn-background:transparent;--btn-border-color:transparent;--btn-box-shadow:none;--btn-font-weight:var(--font-normal);--btn-hover-color:var(--color-secondary);--btn-justify-content:start;--btn-outline-size:0;--btn-inline-size:var(--size-full);--btn-padding:var(--size-1) var(--size-2);--btn-text-align:start;&:focus-visible{--btn-background:var(--color-secondary)}&:is(summary){&:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");background-size:cover;block-size:var(--size-4);content:"";filter:var(--color-filter-text);inline-size:var(--size-4);margin-inline-start:auto;min-inline-size:var(--size-4);transition:transform var(--time-200)}details[open]>&:after{transform:var(--rotate-90)}&::-webkit-details-marker{display:none}}}.sidebar-menu__content{overflow-y:scroll;row-gap:var(--size-4)}.sidebar-menu__content,.sidebar-menu__group{display:flex;flex-direction:column}.sidebar-menu__group-label{color:var(--color-text-subtle);font-size:var(--text-xs);font-weight:var(--font-medium);padding:var(--size-1_5) var(--size-2)}.sidebar-menu__items,.sidebar-menu__sub{display:flex;flex-direction:column;row-gap:var(--size-1)}.sidebar-menu__sub{border-inline-start-width:var(--border);margin-inline-start:var(--size-4);padding:var(--size-0_5) var(--size-2)}.sheet{background:var(--color-bg);border:0;max-block-size:none;max-inline-size:none;padding:0}.sheet--left{block-size:100vh;inline-size:var(--sheet-size,288px);inset-block-start:0;inset-inline-start:0}.sheet__content{block-size:100%;display:flex;flex-direction:column;overflow-y:auto}.skeleton{animation:var(--animate-blink);background-color:var(--color-border-light);border-radius:var(--rounded-md)}.switch{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--color-border);block-size:var(--size-5);border-color:transparent;border-radius:var(--rounded-full);border-width:var(--border-2);inline-size:var(--size-9);transition:background-color var(--time-150);&:checked{background-color:var(--color-primary)}&:checked:before{margin-inline-start:var(--size-4)}&:before{aspect-ratio:var(--aspect-square);background-color:var(--color-text-reversed);block-size:var(--size-full);border-radius:var(--rounded-full);content:"";display:block;transition:margin var(--time-150)}&:focus-visible{outline:var(--border-2) solid var(--color-selected-dark)}&:disabled{cursor:not-allowed;opacity:var(--opacity-50)}}:where(.table){caption-side:bottom;font-size:var(--text-sm);inline-size:var(--size-full);caption{margin-block-start:var(--size-4)}caption,thead{color:var(--color-text-subtle)}tbody tr{border-block-start-width:var(--border)}tr:hover{background-color:rgb(from var(--color-border-light) r g b/.5)}th{font-weight:var(--font-medium);text-align:start}td,th{padding:var(--size-2)}tfoot{background-color:rgb(from var(--color-border-light) r g b/.5);border-block-start-width:var(--border);font-weight:var(--font-medium)}}.breadcrumb-container{flex-wrap:wrap;gap:1rem;justify-content:space-between}.breadcrumb-container,.breadcrumb-tags{align-items:center;display:flex}.tag-list,.tag-manager{align-items:center;display:flex;gap:.5rem}.tag-list{flex-wrap:wrap}.tag-list span{padding-right:3px!important}.tag{align-items:center;background-color:var(--color-background-secondary);border:1px solid var(--color-border);border-radius:.375rem;display:inline-flex;font-size:.875rem;gap:.25rem;line-height:1.25;padding:.25rem .5rem;white-space:nowrap}.tag-remove{all:unset;align-items:center;background:none;border:none;color:currentColor;cursor:pointer;display:inline-flex;height:1rem;justify-content:center;margin:0;opacity:.6;padding:0;transition:opacity .15s ease;width:1rem}.tag-remove:hover{opacity:1}.tag-remove span{font-size:17px;font-weight:inherit;line-height:1;margin-left:6px}.tag-add-container{display:inline-block;position:relative}.tag-add-button{font-size:.8rem;line-height:1.25;padding:.2rem .5rem;white-space:nowrap}@media (max-width:768px){.breadcrumb-container{align-items:flex-start;flex-direction:column}.breadcrumb-tags,.tag-manager{width:100%}}.w-auto{width:auto}.w-4{width:1rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-12{width:3rem}.w-16{width:4rem}.w-20{width:5rem}.w-24{width:6rem}.w-28{width:7rem}.w-32{width:8rem}.w-36{width:9rem}.w-40{width:10rem}.w-44{width:11rem}.w-48{width:12rem}.w-52{width:13rem}.w-56{width:14rem}.w-60{width:15rem}.w-64{width:16rem}.min-w-0{min-width:0}.min-w-4{min-width:1rem}.min-w-8{min-width:2rem}.min-w-12{min-width:3rem}.min-w-16{min-width:4rem}.min-w-20{min-width:5rem}.min-w-24{min-width:6rem}.min-w-32{min-width:8rem}.max-w-xs{max-width:20rem}.max-w-sm{max-width:24rem}.max-w-md{max-width:28rem}.max-w-lg{max-width:32rem}.max-w-xl{max-width:36rem}.global-filters-active{position:relative}.global-filters-active:after{background-color:var(--color-primary);border:2px solid var(--color-bg);border-radius:50%;content:"";height:8px;position:absolute;right:-2px;top:-2px;width:8px}.flatpickr-calendar,.flatpickr-calendar.inline,.flatpickr-calendar.open,.flatpickr-calendar.static,.flatpickr-calendar.static.open{z-index:999999!important}*{font-family:AvenirNextPro,sans-serif}a{color:var(--color-link);text-decoration:underline}#header{background-color:var(--header-bg)}#header a{color:var(--header-link);text-decoration:none}#header a:hover{background-color:transparent;text-decoration:underline}a:hover{cursor:pointer}html[data-color-scheme=dark] .card{--color-bg:#2f2f2f;--color-border:#404040}html[data-color-scheme=dark] .badge--negative-inverse,html[data-color-scheme=dark] .badge--positive-inverse{--badge-background:#2f2f2f}html[data-color-scheme=dark] .input{--input-background:#535252;--input-border-color:#7e7d7d}.hidden{display:none}.operations-table{width:100%}.operations-table tr{cursor:pointer}.operations-label-cell{max-width:380px;min-width:120px;overflow:hidden;padding-right:10px;text-overflow:ellipsis;vertical-align:middle;white-space:nowrap;width:380px}.operations-label-cell span{font-family:Times New Roman,Times,serif}.operations-duration-cell{max-width:100px;width:60px}.operations-event-cell{background:none;padding:0;position:relative}.operations-event{box-sizing:border-box;height:16px;padding:2px;position:absolute;top:20px}.bar-container{height:10px;position:relative}.bar{background-color:#727579;height:100%;position:absolute;top:0}.bar:first-child{border-bottom-left-radius:1px;border-top-left-radius:1px}.bar:last-child{border-bottom-right-radius:1px;border-top-right-radius:1px}.flex{display:flex}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.inline-flex{display:inline-flex}.justify-start{justify-content:start}.justify-center{justify-content:center}.justify-end{justify-content:end}.justify-between{justify-content:space-between}.items-start{align-items:start}.items-end{align-items:end}.items-center{align-items:center}.grow{flex-grow:1}.grow-0{flex-grow:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.self-start{align-self:start}.self-end{align-self:end}.self-center{align-self:center}.gap{-moz-column-gap:var(--column-gap,.5rem);column-gap:var(--column-gap,.5rem);row-gap:var(--row-gap,1rem)}.gap-half{-moz-column-gap:.25rem;column-gap:.25rem;row-gap:.5rem}.font-normal{font-weight:var(--font-normal)}.font-medium{font-weight:var(--font-medium)}.font-semibold{font-weight:var(--font-semibold)}.font-bold{font-weight:var(--font-bold)}.underline{text-decoration:underline}.no-underline{text-decoration:none}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.whitespace-nowrap{white-space:nowrap}.whitespace-normal{white-space:normal}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.overflow-clip{text-overflow:clip}.overflow-clip,.overflow-ellipsis{overflow:hidden;white-space:nowrap}.overflow-ellipsis{text-overflow:ellipsis}.opacity-75{opacity:var(--opacity-75)}.opacity-50{opacity:var(--opacity-50)}.leading-none{line-height:var(--leading-none)}.leading-tight{line-height:var(--leading-tight)}.text-start{text-align:start}.text-end{text-align:end}.text-center{text-align:center}.text-primary{color:var(--color-text)}.text-reversed{color:var(--color-text-reversed)}.text-negative{color:var(--color-negative)}.text-positive{color:var(--color-positive)}.text-subtle{color:var(--color-text-subtle)}.text-xs{font-size:var(--text-xs)}.text-sm{font-size:var(--text-sm)}.text-base{font-size:var(--text-base)}.text-lg{font-size:var(--text-lg)}.text-xl{font-size:var(--text-xl)}.text-2xl{font-size:var(--text-2xl)}.text-3xl{font-size:var(--text-3xl)}.text-4xl{font-size:var(--text-4xl)}.text-5xl{font-size:var(--text-5xl)}.text-fluid-xs{font-size:var(--text-fluid-xs)}.text-fluid-sm{font-size:var(--text-fluid-sm)}.text-fluid-base{font-size:var(--text-fluid-base)}.text-fluid-lg{font-size:var(--text-fluid-lg)}.text-fluid-xl{font-size:var(--text-fluid-xl)}.text-fluid-2xl{font-size:var(--text-fluid-2xl)}.text-fluid-3xl{font-size:var(--text-fluid-3xl)}.bg-main{background-color:var(--color-bg)}.bg-black{background-color:var(--color-text)}.bg-white{background-color:var(--color-text-reversed)}.bg-shade{background-color:var(--color-border-light)}.bg-transparent{background-color:transparent}.colorize-black{filter:var(--color-filter-text)}.colorize-white{filter:var(--color-filter-text-reversed)}.colorize-negative{filter:var(--color-filter-negative)}.colorize-positive{filter:var(--color-filter-positive)}.border-0{border-width:0}.border{border-width:var(--border-size,1px)}.border-b{border-block-width:var(--border-size,1px)}.border-bs{border-block-start-width:var(--border-size,1px)}.border-be{border-block-end-width:var(--border-size,1px)}.border-i{border-inline-width:var(--border-size,1px)}.border-is{border-inline-start-width:var(--border-size,1px)}.border-ie{border-inline-end-width:var(--border-size,1px)}.border-main{border-color:var(--color-border)}.border-dark{border-color:var(--color-border-dark)}.rounded-none{border-radius:0}.rounded-xs{border-radius:var(--rounded-xs)}.rounded-sm{border-radius:var(--rounded-sm)}.rounded-md{border-radius:var(--rounded-md)}.rounded-lg{border-radius:var(--rounded-lg)}.rounded-full{border-radius:var(--rounded-full)}.shadow-none{box-shadow:none}.shadow-xs{box-shadow:var(--shadow-xs)}.shadow-sm{box-shadow:var(--shadow-sm)}.shadow-md{box-shadow:var(--shadow-md)}.shadow-lg{box-shadow:var(--shadow-lg)}.block{display:block}.inline{display:inline}.inline-block{display:inline-block}.relative{position:relative}.sticky{position:sticky}.min-i-0{min-inline-size:0}.max-i-none{max-inline-size:none}.max-i-full{max-inline-size:100%}.b-full{block-size:100%}.i-full{inline-size:100%}.i-min{inline-size:-moz-min-content;inline-size:min-content}.overflow-x-auto{overflow-x:auto;scroll-snap-type:x mandatory}.overflow-y-auto{overflow-y:auto;scroll-snap-type:y mandatory}.overflow-hidden{overflow:hidden}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.aspect-square{aspect-ratio:1}.aspect-widescreen{aspect-ratio:16/9}.m-0{margin:0}.m-1{margin:var(--size-1)}.m-2{margin:var(--size-2)}.m-3{margin:var(--size-3)}.m-4{margin:var(--size-4)}.m-5{margin:var(--size-5)}.m-6{margin:var(--size-6)}.m-8{margin:var(--size-8)}.m-10{margin:var(--size-10)}.m-auto{margin:auto}.mb-0{margin-block:0}.mb-1{margin-block:var(--size-1)}.mb-2{margin-block:var(--size-2)}.mb-3{margin-block:var(--size-3)}.mb-4{margin-block:var(--size-4)}.mb-5{margin-block:var(--size-5)}.mb-6{margin-block:var(--size-6)}.mb-8{margin-block:var(--size-8)}.mb-10{margin-block:var(--size-10)}.mb-auto{margin-block:auto}.mbs-0{margin-block-start:0}.mbs-1{margin-block-start:var(--size-1)}.mbs-2{margin-block-start:var(--size-2)}.mbs-3{margin-block-start:var(--size-3)}.mbs-4{margin-block-start:var(--size-4)}.mbs-5{margin-block-start:var(--size-5)}.mbs-6{margin-block-start:var(--size-6)}.mbs-8{margin-block-start:var(--size-8)}.mbs-10{margin-block-start:var(--size-10)}.mbs-auto{margin-block-start:auto}.mbe-0{margin-block-end:0}.mbe-1{margin-block-end:var(--size-1)}.mbe-2{margin-block-end:var(--size-2)}.mbe-3{margin-block-end:var(--size-3)}.mbe-4{margin-block-end:var(--size-4)}.mbe-5{margin-block-end:var(--size-5)}.mbe-6{margin-block-end:var(--size-6)}.mbe-8{margin-block-end:var(--size-8)}.mbe-10{margin-block-end:var(--size-10)}.mbe-auto{margin-block-end:auto}.mi-0{margin-inline:0}.mi-1{margin-inline:var(--size-1)}.mi-2{margin-inline:var(--size-2)}.mi-3{margin-inline:var(--size-3)}.mi-4{margin-inline:var(--size-4)}.mi-5{margin-inline:var(--size-5)}.mi-6{margin-inline:var(--size-6)}.mi-8{margin-inline:var(--size-8)}.mi-10{margin-inline:var(--size-10)}.mi-auto{margin-inline:auto}.mis-0{margin-inline-start:0}.mis-1{margin-inline-start:var(--size-1)}.mis-2{margin-inline-start:var(--size-2)}.mis-3{margin-inline-start:var(--size-3)}.mis-4{margin-inline-start:var(--size-4)}.mis-5{margin-inline-start:var(--size-5)}.mis-6{margin-inline-start:var(--size-6)}.mis-8{margin-inline-start:var(--size-8)}.mis-10{margin-inline-start:var(--size-10)}.mis-auto{margin-inline-start:auto}.mie-0{margin-inline-end:0}.mie-1{margin-inline-end:var(--size-1)}.mie-2{margin-inline-end:var(--size-2)}.mie-3{margin-inline-end:var(--size-3)}.mie-4{margin-inline-end:var(--size-4)}.mie-5{margin-inline-end:var(--size-5)}.mie-6{margin-inline-end:var(--size-6)}.mie-8{margin-inline-end:var(--size-8)}.mie-10{margin-inline-end:var(--size-10)}.mie-auto{margin-inline-end:auto}.p-0{padding:0}.p-1{padding:var(--size-1)}.p-2{padding:var(--size-2)}.p-3{padding:var(--size-3)}.p-4{padding:var(--size-4)}.p-5{padding:var(--size-5)}.p-6{padding:var(--size-6)}.p-8{padding:var(--size-8)}.p-10{padding:var(--size-10)}.pb-0{padding-block:0}.pb-1{padding-block:var(--size-1)}.pb-2{padding-block:var(--size-2)}.pb-3{padding-block:var(--size-3)}.pb-4{padding-block:var(--size-4)}.pb-5{padding-block:var(--size-5)}.pb-6{padding-block:var(--size-6)}.pb-8{padding-block:var(--size-8)}.pb-10{padding-block:var(--size-10)}.pbs-0{padding-block-start:0}.pbs-1{padding-block-start:var(--size-1)}.pbs-2{padding-block-start:var(--size-2)}.pbs-3{padding-block-start:var(--size-3)}.pbs-4{padding-block-start:var(--size-4)}.pbs-5{padding-block-start:var(--size-5)}.pbs-6{padding-block-start:var(--size-6)}.pbs-8{padding-block-start:var(--size-8)}.pbs-10{padding-block-start:var(--size-10)}.pbe-0{padding-block-end:0}.pbe-1{padding-block-end:var(--size-1)}.pbe-2{padding-block-end:var(--size-2)}.pbe-3{padding-block-end:var(--size-3)}.pbe-4{padding-block-end:var(--size-4)}.pbe-5{padding-block-end:var(--size-5)}.pbe-6{padding-block-end:var(--size-6)}.pbe-8{padding-block-end:var(--size-8)}.pbe-10{padding-block-end:var(--size-10)}.pi-0{padding-inline:0}.pi-1{padding-inline:var(--size-1)}.pi-2{padding-inline:var(--size-2)}.pi-3{padding-inline:var(--size-3)}.pi-4{padding-inline:var(--size-4)}.pi-5{padding-inline:var(--size-5)}.pi-6{padding-inline:var(--size-6)}.pi-8{padding-inline:var(--size-8)}.pi-10{padding-inline:var(--size-10)}.pis-0{padding-inline-start:0}.pis-1{padding-inline-start:var(--size-1)}.pis-2{padding-inline-start:var(--size-2)}.pis-3{padding-inline-start:var(--size-3)}.pis-4{padding-inline-start:var(--size-4)}.pis-5{padding-inline-start:var(--size-5)}.pis-6{padding-inline-start:var(--size-6)}.pis-8{padding-inline-start:var(--size-8)}.pis-10{padding-inline-start:var(--size-10)}.pie-0{padding-inline-end:0}.pie-1{padding-inline-end:var(--size-1)}.pie-2{padding-inline-end:var(--size-2)}.pie-3{padding-inline-end:var(--size-3)}.pie-4{padding-inline-end:var(--size-4)}.pie-5{padding-inline-end:var(--size-5)}.pie-6{padding-inline-end:var(--size-6)}.pie-8{padding-inline-end:var(--size-8)}.pie-10{padding-inline-end:var(--size-10)}.show\@lg,.show\@md,.show\@sm,.show\@xl{display:none}.show\@sm{@media (width >= 40rem){display:flex}}.show\@md{@media (width >= 48rem){display:flex}}.show\@lg{@media (width >= 64rem){display:flex}}.show\@xl{@media (width >= 80rem){display:flex}}.hide\@sm{@media (width >= 40rem){display:none}}.hide\@md{@media (width >= 48rem){display:none}}.hide\@lg{@media (width >= 64rem){display:none}}.hide\@xl{@media (width >= 80rem){display:none}}.hide\@pwa{@media (display-mode:standalone){display:none}}.hide\@browser{@media (display-mode:browser){display:none}}.hide\@print{@media print{display:none}}.sr-only{block-size:1px;clip-path:inset(50%);inline-size:1px;overflow:hidden;position:absolute;white-space:nowrap} \ No newline at end of file diff --git a/public/rails-pulse-assets/rails-pulse.css.map b/public/rails-pulse-assets/rails-pulse.css.map index 699b884..6cb76f5 100644 --- a/public/rails-pulse-assets/rails-pulse.css.map +++ b/public/rails-pulse-assets/rails-pulse.css.map @@ -1 +1 @@ -{"version":3,"sources":["../../rails-pulse.css"],"names":[],"mappings":"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","file":"rails-pulse.css","sourcesContent":["\n/* vendor/css-zero/reset.css */\n/*\n 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)\n 2. Remove default margins and padding\n 3. Reset all borders.\n*/\n\n*,\n::after,\n::before,\n::backdrop,\n::file-selector-button {\n box-sizing: border-box; /* 1 */\n margin: 0; /* 2 */\n padding: 0; /* 2 */\n border: 0 solid; /* 3 */\n}\n\n/*\n 1. Use a consistent sensible line-height in all browsers.\n 2. Prevent adjustments of font size after orientation changes in iOS.\n 3. Use a more readable tab size.\n 4. Use the user's configured `sans` font-family by default.\n 5. Use the user's configured `sans` font-feature-settings by default.\n 6. Use the user's configured `sans` font-variation-settings by default.\n 7. Disable tap highlights on iOS.\n*/\n\nhtml,\n:host {\n line-height: 1.5; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n tab-size: 4; /* 3 */\n font-family: var(--default-font-family, system-ui, sans-serif); /* 4 */\n font-feature-settings: var(--default-font-feature-settings, normal); /* 5 */\n font-variation-settings: var(--default-font-variation-settings, normal); /* 6 */\n -webkit-tap-highlight-color: transparent; /* 7 */\n}\n\n/*\n Inherit line-height from `html` so users can set them as a class directly on the `html` element.\n*/\n\nbody {\n line-height: inherit;\n}\n\n/*\n 1. Add the correct height in Firefox.\n 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)\n 3. Reset the default border style to a 1px solid border.\n*/\n\nhr {\n block-size: 0; /* 1 */\n color: inherit; /* 2 */\n border-block-start-width: 1px; /* 3 */\n}\n\n/*\n Add the correct text decoration in Chrome, Edge, and Safari.\n*/\n\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\n\n/*\n Remove the default font size and weight for headings.\n*/\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n font-size: inherit;\n font-weight: inherit;\n}\n\n/*\n Reset links to optimize for opt-in styling instead of opt-out.\n*/\n\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\n\n/*\n Add the correct font weight in Edge and Safari.\n*/\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/*\n 1. Use the user's configured `mono` font-family by default.\n 2. Use the user's configured `mono` font-feature-settings by default.\n 3. Use the user's configured `mono` font-variation-settings by default.\n 4. Correct the odd `em` font sizing in all browsers.\n*/\n\ncode,\nkbd,\nsamp,\npre {\n font-family: var(--default-mono-font-family, ui-monospace, monospace); /* 4 */\n font-feature-settings: var(--default-mono-font-feature-settings, normal); /* 5 */\n font-variation-settings: var(--default-mono-font-variation-settings, normal); /* 6 */\n font-size: 1em; /* 4 */\n}\n\n/*\n Add the correct font size in all browsers.\n*/\n\nsmall {\n font-size: 80%;\n}\n\n/*\n Prevent `sub` and `sup` elements from affecting the line height in all browsers.\n*/\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n inset-block-end: -0.25em;\n}\n\nsup {\n inset-block-start: -0.5em;\n}\n\n/*\n 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)\n 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)\n 3. Remove gaps between table borders by default.\n*/\n\ntable {\n text-indent: 0; /* 1 */\n border-color: inherit; /* 2 */\n border-collapse: collapse; /* 3 */\n}\n\n/*\n Use the modern Firefox focus style for all focusable elements.\n*/\n\n:-moz-focusring {\n outline: auto;\n}\n\n/*\n Add the correct vertical alignment in Chrome and Firefox.\n*/\n\nprogress {\n vertical-align: baseline;\n}\n\n/*\n Add the correct display in Chrome and Safari.\n*/\n\nsummary {\n display: list-item;\n}\n\n/*\n Make lists unstyled by default.\n*/\n\nol,\nul,\nmenu {\n list-style: none;\n}\n\n/*\n 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)\n 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)\n This can trigger a poorly considered lint error in some tools but is included by design.\n*/\n\nimg,\nsvg,\nvideo,\ncanvas,\naudio,\niframe,\nembed,\nobject {\n display: block; /* 1 */\n vertical-align: middle; /* 2 */\n}\n\n/*\n Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)\n*/\n\nimg,\nvideo {\n max-inline-size: 100%;\n block-size: auto;\n}\n\n/*\n 1. Inherit font styles in all browsers.\n 2. Remove border radius in all browsers.\n 3. Remove background color in all browsers.\n 4. Ensure consistent opacity for disabled states in all browsers.\n*/\n\nbutton,\ninput,\nselect,\noptgroup,\ntextarea,\n::file-selector-button {\n font: inherit; /* 1 */\n font-feature-settings: inherit; /* 1 */\n font-variation-settings: inherit; /* 1 */\n letter-spacing: inherit; /* 1 */\n color: inherit; /* 1 */\n border-radius: 0; /* 2 */\n background-color: transparent; /* 3 */\n opacity: 1; /* 4 */\n}\n\n/*\n Restore default font weight.\n*/\n\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n\n/*\n Restore indentation.\n*/\n\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n\n/*\n Restore space after button.\n*/\n\n::file-selector-button {\n margin-inline-end: 4px;\n}\n\n/*\n 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)\n 2. Set the default placeholder color to a semi-transparent version of the current text color.\n*/\n\n::placeholder {\n opacity: 1; /* 1 */\n color: color-mix(in oklab, currentColor 50%, transparent); /* 2 */\n}\n\n/*\n Prevent resizing textareas horizontally by default.\n*/\n\ntextarea {\n resize: vertical;\n}\n\n/*\n Remove the inner padding in Chrome and Safari on macOS.\n*/\n\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/*\n 1. Ensure date/time inputs have the same height when empty in iOS Safari.\n 2. Ensure text alignment can be changed on date/time inputs in iOS Safari.\n*/\n\n::-webkit-date-and-time-value {\n min-block-size: 1lh; /* 1 */\n text-align: inherit; /* 2 */\n}\n\n/*\n Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`.\n*/\n\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n\n/*\n Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers.\n*/\n\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n\n::-webkit-datetime-edit,\n::-webkit-datetime-edit-year-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-minute-field,\n::-webkit-datetime-edit-second-field,\n::-webkit-datetime-edit-millisecond-field,\n::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n\n/*\n Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)\n*/\n\n:-moz-ui-invalid {\n box-shadow: none;\n}\n\n/*\n Correct the inability to style the border radius in iOS Safari.\n*/\n\nbutton,\ninput:where([type='button'], [type='reset'], [type='submit']),\n::file-selector-button {\n appearance: button;\n}\n\n/*\n Correct the cursor style of increment and decrement buttons in Safari.\n*/\n\n::-webkit-inner-spin-button,\n::-webkit-outer-spin-button {\n block-size: auto;\n}\n\n/*\n Make elements with the HTML hidden attribute stay hidden by default.\n*/\n\n[hidden]:where(:not([hidden='until-found'])) {\n display: none !important;\n}\n\n/*\n Make elements with the HTML contents attribute become pseudo-box by default.\n*/\n\n[contents] {\n display: contents !important;\n}\n\n/*\n Make turbo frame become pseudo-box by default.\n*/\n\nturbo-frame {\n display: contents;\n}\n\n/*\n Enables size interpolation to allow animation.\n*/\n\n:root {\n interpolate-size: allow-keywords;\n}\n\n/*\n Set color scheme to light and dark.\n*/\n\n:root {\n color-scheme: light dark;\n}\n\n/*\n Correct the arrow style of datalist in Chrome.\n*/\n\n::-webkit-calendar-picker-indicator {\n line-height: 1em;\n}\n\n/*\n Restore space between options.\n*/\n\noption {\n padding: 2px 4px;\n}\n\n/*\n Prevent page scroll when modal dialog is open.\n*/\n\nhtml:has(dialog:modal[open]) {\n overflow: hidden;\n}\n\n/*\n Remove all animations and transitions for people that prefer not to see them\n*/\n\n@media (prefers-reduced-motion: reduce) {\n *, ::before, ::after, ::backdrop {\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.01ms !important;\n }\n}\n\n\n/* vendor/css-zero/colors.css */\n:root {\n --slate-50: oklch(0.984 0.003 247.858);\n --slate-100: oklch(0.968 0.007 247.896);\n --slate-200: oklch(0.929 0.013 255.508);\n --slate-300: oklch(0.869 0.022 252.894);\n --slate-400: oklch(0.704 0.04 256.788);\n --slate-500: oklch(0.554 0.046 257.417);\n --slate-600: oklch(0.446 0.043 257.281);\n --slate-700: oklch(0.372 0.044 257.287);\n --slate-800: oklch(0.279 0.041 260.031);\n --slate-900: oklch(0.208 0.042 265.755);\n --slate-950: oklch(0.129 0.042 264.695);\n\n --gray-50: oklch(0.985 0.002 247.839);\n --gray-100: oklch(0.967 0.003 264.542);\n --gray-200: oklch(0.928 0.006 264.531);\n --gray-300: oklch(0.872 0.01 258.338);\n --gray-400: oklch(0.707 0.022 261.325);\n --gray-500: oklch(0.551 0.027 264.364);\n --gray-600: oklch(0.446 0.03 256.802);\n --gray-700: oklch(0.373 0.034 259.733);\n --gray-800: oklch(0.278 0.033 256.848);\n --gray-900: oklch(0.21 0.034 264.665);\n --gray-950: oklch(0.13 0.028 261.692);\n\n --zinc-50: oklch(0.985 0 0);\n --zinc-100: oklch(0.967 0.001 286.375);\n --zinc-200: oklch(0.92 0.004 286.32);\n --zinc-300: oklch(0.871 0.006 286.286);\n --zinc-400: oklch(0.705 0.015 286.067);\n --zinc-500: oklch(0.552 0.016 285.938);\n --zinc-600: oklch(0.442 0.017 285.786);\n --zinc-700: oklch(0.37 0.013 285.805);\n --zinc-800: oklch(0.274 0.006 286.033);\n --zinc-900: oklch(0.21 0.006 285.885);\n --zinc-950: oklch(0.141 0.005 285.823);\n\n --neutral-50: oklch(0.985 0 0);\n --neutral-100: oklch(0.97 0 0);\n --neutral-200: oklch(0.922 0 0);\n --neutral-300: oklch(0.87 0 0);\n --neutral-400: oklch(0.708 0 0);\n --neutral-500: oklch(0.556 0 0);\n --neutral-600: oklch(0.439 0 0);\n --neutral-700: oklch(0.371 0 0);\n --neutral-800: oklch(0.269 0 0);\n --neutral-900: oklch(0.205 0 0);\n --neutral-950: oklch(0.145 0 0);\n\n --stone-50: oklch(0.985 0.001 106.423);\n --stone-100: oklch(0.97 0.001 106.424);\n --stone-200: oklch(0.923 0.003 48.717);\n --stone-300: oklch(0.869 0.005 56.366);\n --stone-400: oklch(0.709 0.01 56.259);\n --stone-500: oklch(0.553 0.013 58.071);\n --stone-600: oklch(0.444 0.011 73.639);\n --stone-700: oklch(0.374 0.01 67.558);\n --stone-800: oklch(0.268 0.007 34.298);\n --stone-900: oklch(0.216 0.006 56.043);\n --stone-950: oklch(0.147 0.004 49.25);\n\n --red-50: oklch(0.971 0.013 17.38);\n --red-100: oklch(0.936 0.032 17.717);\n --red-200: oklch(0.885 0.062 18.334);\n --red-300: oklch(0.808 0.114 19.571);\n --red-400: oklch(0.704 0.191 22.216);\n --red-500: oklch(0.637 0.237 25.331);\n --red-600: oklch(0.577 0.245 27.325);\n --red-700: oklch(0.505 0.213 27.518);\n --red-800: oklch(0.444 0.177 26.899);\n --red-900: oklch(0.396 0.141 25.723);\n --red-950: oklch(0.258 0.092 26.042);\n\n --orange-50: oklch(0.98 0.016 73.684);\n --orange-100: oklch(0.954 0.038 75.164);\n --orange-200: oklch(0.901 0.076 70.697);\n --orange-300: oklch(0.837 0.128 66.29);\n --orange-400: oklch(0.75 0.183 55.934);\n --orange-500: oklch(0.705 0.213 47.604);\n --orange-600: oklch(0.646 0.222 41.116);\n --orange-700: oklch(0.553 0.195 38.402);\n --orange-800: oklch(0.47 0.157 37.304);\n --orange-900: oklch(0.408 0.123 38.172);\n --orange-950: oklch(0.266 0.079 36.259);\n\n --amber-50: oklch(0.987 0.022 95.277);\n --amber-100: oklch(0.962 0.059 95.617);\n --amber-200: oklch(0.924 0.12 95.746);\n --amber-300: oklch(0.879 0.169 91.605);\n --amber-400: oklch(0.828 0.189 84.429);\n --amber-500: oklch(0.769 0.188 70.08);\n --amber-600: oklch(0.666 0.179 58.318);\n --amber-700: oklch(0.555 0.163 48.998);\n --amber-800: oklch(0.473 0.137 46.201);\n --amber-900: oklch(0.414 0.112 45.904);\n --amber-950: oklch(0.279 0.077 45.635);\n\n --yellow-50: oklch(0.987 0.026 102.212);\n --yellow-100: oklch(0.973 0.071 103.193);\n --yellow-200: oklch(0.945 0.129 101.54);\n --yellow-300: oklch(0.905 0.182 98.111);\n --yellow-400: oklch(0.852 0.199 91.936);\n --yellow-500: oklch(0.795 0.184 86.047);\n --yellow-600: oklch(0.681 0.162 75.834);\n --yellow-700: oklch(0.554 0.135 66.442);\n --yellow-800: oklch(0.476 0.114 61.907);\n --yellow-900: oklch(0.421 0.095 57.708);\n --yellow-950: oklch(0.286 0.066 53.813);\n\n --lime-50: oklch(0.986 0.031 120.757);\n --lime-100: oklch(0.967 0.067 122.328);\n --lime-200: oklch(0.938 0.127 124.321);\n --lime-300: oklch(0.897 0.196 126.665);\n --lime-400: oklch(0.841 0.238 128.85);\n --lime-500: oklch(0.768 0.233 130.85);\n --lime-600: oklch(0.648 0.2 131.684);\n --lime-700: oklch(0.532 0.157 131.589);\n --lime-800: oklch(0.453 0.124 130.933);\n --lime-900: oklch(0.405 0.101 131.063);\n --lime-950: oklch(0.274 0.072 132.109);\n\n --green-50: oklch(0.982 0.018 155.826);\n --green-100: oklch(0.962 0.044 156.743);\n --green-200: oklch(0.925 0.084 155.995);\n --green-300: oklch(0.871 0.15 154.449);\n --green-400: oklch(0.792 0.209 151.711);\n --green-500: oklch(0.723 0.219 149.579);\n --green-600: oklch(0.627 0.194 149.214);\n --green-700: oklch(0.527 0.154 150.069);\n --green-800: oklch(0.448 0.119 151.328);\n --green-900: oklch(0.393 0.095 152.535);\n --green-950: oklch(0.266 0.065 152.934);\n\n --emerald-50: oklch(0.979 0.021 166.113);\n --emerald-100: oklch(0.95 0.052 163.051);\n --emerald-200: oklch(0.905 0.093 164.15);\n --emerald-300: oklch(0.845 0.143 164.978);\n --emerald-400: oklch(0.765 0.177 163.223);\n --emerald-500: oklch(0.696 0.17 162.48);\n --emerald-600: oklch(0.596 0.145 163.225);\n --emerald-700: oklch(0.508 0.118 165.612);\n --emerald-800: oklch(0.432 0.095 166.913);\n --emerald-900: oklch(0.378 0.077 168.94);\n --emerald-950: oklch(0.262 0.051 172.552);\n\n --teal-50: oklch(0.984 0.014 180.72);\n --teal-100: oklch(0.953 0.051 180.801);\n --teal-200: oklch(0.91 0.096 180.426);\n --teal-300: oklch(0.855 0.138 181.071);\n --teal-400: oklch(0.777 0.152 181.912);\n --teal-500: oklch(0.704 0.14 182.503);\n --teal-600: oklch(0.6 0.118 184.704);\n --teal-700: oklch(0.511 0.096 186.391);\n --teal-800: oklch(0.437 0.078 188.216);\n --teal-900: oklch(0.386 0.063 188.416);\n --teal-950: oklch(0.277 0.046 192.524);\n\n --cyan-50: oklch(0.984 0.019 200.873);\n --cyan-100: oklch(0.956 0.045 203.388);\n --cyan-200: oklch(0.917 0.08 205.041);\n --cyan-300: oklch(0.865 0.127 207.078);\n --cyan-400: oklch(0.789 0.154 211.53);\n --cyan-500: oklch(0.715 0.143 215.221);\n --cyan-600: oklch(0.609 0.126 221.723);\n --cyan-700: oklch(0.52 0.105 223.128);\n --cyan-800: oklch(0.45 0.085 224.283);\n --cyan-900: oklch(0.398 0.07 227.392);\n --cyan-950: oklch(0.302 0.056 229.695);\n\n --sky-50: oklch(0.977 0.013 236.62);\n --sky-100: oklch(0.951 0.026 236.824);\n --sky-200: oklch(0.901 0.058 230.902);\n --sky-300: oklch(0.828 0.111 230.318);\n --sky-400: oklch(0.746 0.16 232.661);\n --sky-500: oklch(0.685 0.169 237.323);\n --sky-600: oklch(0.588 0.158 241.966);\n --sky-700: oklch(0.5 0.134 242.749);\n --sky-800: oklch(0.443 0.11 240.79);\n --sky-900: oklch(0.391 0.09 240.876);\n --sky-950: oklch(0.293 0.066 243.157);\n\n --blue-50: oklch(0.97 0.014 254.604);\n --blue-100: oklch(0.932 0.032 255.585);\n --blue-200: oklch(0.882 0.059 254.128);\n --blue-300: oklch(0.809 0.105 251.813);\n --blue-400: oklch(0.707 0.165 254.624);\n --blue-500: oklch(0.623 0.214 259.815);\n --blue-600: oklch(0.546 0.245 262.881);\n --blue-700: oklch(0.488 0.243 264.376);\n --blue-800: oklch(0.424 0.199 265.638);\n --blue-900: oklch(0.379 0.146 265.522);\n --blue-950: oklch(0.282 0.091 267.935);\n\n --indigo-50: oklch(0.962 0.018 272.314);\n --indigo-100: oklch(0.93 0.034 272.788);\n --indigo-200: oklch(0.87 0.065 274.039);\n --indigo-300: oklch(0.785 0.115 274.713);\n --indigo-400: oklch(0.673 0.182 276.935);\n --indigo-500: oklch(0.585 0.233 277.117);\n --indigo-600: oklch(0.511 0.262 276.966);\n --indigo-700: oklch(0.457 0.24 277.023);\n --indigo-800: oklch(0.398 0.195 277.366);\n --indigo-900: oklch(0.359 0.144 278.697);\n --indigo-950: oklch(0.257 0.09 281.288);\n\n --violet-50: oklch(0.969 0.016 293.756);\n --violet-100: oklch(0.943 0.029 294.588);\n --violet-200: oklch(0.894 0.057 293.283);\n --violet-300: oklch(0.811 0.111 293.571);\n --violet-400: oklch(0.702 0.183 293.541);\n --violet-500: oklch(0.606 0.25 292.717);\n --violet-600: oklch(0.541 0.281 293.009);\n --violet-700: oklch(0.491 0.27 292.581);\n --violet-800: oklch(0.432 0.232 292.759);\n --violet-900: oklch(0.38 0.189 293.745);\n --violet-950: oklch(0.283 0.141 291.089);\n\n --purple-50: oklch(0.977 0.014 308.299);\n --purple-100: oklch(0.946 0.033 307.174);\n --purple-200: oklch(0.902 0.063 306.703);\n --purple-300: oklch(0.827 0.119 306.383);\n --purple-400: oklch(0.714 0.203 305.504);\n --purple-500: oklch(0.627 0.265 303.9);\n --purple-600: oklch(0.558 0.288 302.321);\n --purple-700: oklch(0.496 0.265 301.924);\n --purple-800: oklch(0.438 0.218 303.724);\n --purple-900: oklch(0.381 0.176 304.987);\n --purple-950: oklch(0.291 0.149 302.717);\n\n --fuchsia-50: oklch(0.977 0.017 320.058);\n --fuchsia-100: oklch(0.952 0.037 318.852);\n --fuchsia-200: oklch(0.903 0.076 319.62);\n --fuchsia-300: oklch(0.833 0.145 321.434);\n --fuchsia-400: oklch(0.74 0.238 322.16);\n --fuchsia-500: oklch(0.667 0.295 322.15);\n --fuchsia-600: oklch(0.591 0.293 322.896);\n --fuchsia-700: oklch(0.518 0.253 323.949);\n --fuchsia-800: oklch(0.452 0.211 324.591);\n --fuchsia-900: oklch(0.401 0.17 325.612);\n --fuchsia-950: oklch(0.293 0.136 325.661);\n\n --pink-50: oklch(0.971 0.014 343.198);\n --pink-100: oklch(0.948 0.028 342.258);\n --pink-200: oklch(0.899 0.061 343.231);\n --pink-300: oklch(0.823 0.12 346.018);\n --pink-400: oklch(0.718 0.202 349.761);\n --pink-500: oklch(0.656 0.241 354.308);\n --pink-600: oklch(0.592 0.249 0.584);\n --pink-700: oklch(0.525 0.223 3.958);\n --pink-800: oklch(0.459 0.187 3.815);\n --pink-900: oklch(0.408 0.153 2.432);\n --pink-950: oklch(0.284 0.109 3.907);\n\n --rose-50: oklch(0.969 0.015 12.422);\n --rose-100: oklch(0.941 0.03 12.58);\n --rose-200: oklch(0.892 0.058 10.001);\n --rose-300: oklch(0.81 0.117 11.638);\n --rose-400: oklch(0.712 0.194 13.428);\n --rose-500: oklch(0.645 0.246 16.439);\n --rose-600: oklch(0.586 0.253 17.585);\n --rose-700: oklch(0.514 0.222 16.935);\n --rose-800: oklch(0.455 0.188 13.697);\n --rose-900: oklch(0.41 0.159 10.272);\n --rose-950: oklch(0.271 0.105 12.094);\n}\n\n\n/* vendor/css-zero/sizes.css */\n:root {\n /****************************************************************\n * Fixed Size\n *****************************************************************/\n --size-0_5: 0.125rem; /* 2px */\n --size-1: 0.25rem; /* 4px */\n --size-1_5: 0.375rem; /* 6px */\n --size-2: 0.5rem; /* 8px */\n --size-2_5: 0.625rem; /* 10px */\n --size-3: 0.75rem; /* 12px */\n --size-3_5: 0.875rem; /* 14px */\n --size-4: 1rem; /* 16px */\n --size-5: 1.25rem; /* 20px */\n --size-6: 1.5rem; /* 24px */\n --size-7: 1.75rem; /* 28px */\n --size-8: 2rem; /* 32px */\n --size-9: 2.25rem; /* 36px */\n --size-10: 2.5rem; /* 40px */\n --size-11: 2.75rem; /* 44px */\n --size-12: 3rem; /* 48px */\n --size-14: 3.5rem; /* 56px */\n --size-16: 4rem; /* 64px */\n --size-20: 5rem; /* 80px */\n --size-24: 6rem; /* 96px */\n --size-28: 7rem; /* 112px */\n --size-32: 8rem; /* 128px */\n --size-36: 9rem; /* 144px */\n --size-40: 10rem; /* 160px */\n --size-44: 11rem; /* 176px */\n --size-48: 12rem; /* 192px */\n --size-52: 13rem; /* 208px */\n --size-56: 14rem; /* 224px */\n --size-60: 15rem; /* 240px */\n --size-64: 16rem; /* 256px */\n --size-72: 18rem; /* 288px */\n --size-80: 20rem; /* 320px */\n --size-96: 24rem; /* 384px */\n\n /****************************************************************\n * Percentual Size\n *****************************************************************/\n --size-1-2: 50%;\n --size-1-3: 33.333333%;\n --size-2-3: 66.666667%;\n --size-1-4: 25%;\n --size-2-4: 50%;\n --size-3-4: 75%;\n --size-1-5: 20%;\n --size-2-5: 40%;\n --size-3-5: 60%;\n --size-4-5: 80%;\n --size-1-6: 16.666667%;\n --size-2-6: 33.333333%;\n --size-3-6: 50%;\n --size-4-6: 66.666667%;\n --size-5-6: 83.333333%;\n --size-1-12: 8.333333%;\n --size-2-12: 16.666667%;\n --size-3-12: 25%;\n --size-4-12: 33.333333%;\n --size-5-12: 41.666667%;\n --size-6-12: 50%;\n --size-7-12: 58.333333%;\n --size-8-12: 66.666667%;\n --size-9-12: 75%;\n --size-10-12: 83.333333%;\n --size-11-12: 91.666667%;\n --size-full: 100%;\n\n /****************************************************************\n * Max Inline Sizes\n *****************************************************************/\n --max-i-3xs: 16rem; /* 256px */\n --max-i-2xs: 18rem; /* 288px */\n --max-i-xs: 20rem; /* 320px */\n --max-i-sm: 24rem; /* 384px */\n --max-i-md: 28rem; /* 448px */\n --max-i-lg: 32rem; /* 512px */\n --max-i-xl: 36rem; /* 576px */\n --max-i-2xl: 42rem; /* 672px */\n --max-i-3xl: 48rem; /* 768px */\n --max-i-4xl: 56rem; /* 896px */\n --max-i-5xl: 64rem; /* 1024px */\n --max-i-6xl: 72rem; /* 1152px */\n --max-i-7xl: 80rem; /* 1280px */\n\n /****************************************************************\n * Aspect Ratio\n *****************************************************************/\n --aspect-square: 1/1;\n --aspect-widescreen: 16/9;\n\n /****************************************************************\n * Breakpoints\n *****************************************************************/\n --breakpoint-sm: 40rem; /* Mobile 640px */\n --breakpoint-md: 48rem; /* Tablet 768px */\n --breakpoint-lg: 64rem; /* Laptop 1024px */\n --breakpoint-xl: 80rem; /* Desktop 1280px */\n}\n\n\n/* vendor/css-zero/borders.css */\n:root {\n /****************************************************************\n * Border Width\n * Variables for controlling the width of an element's borders.\n * border-width: var(--border);\n *****************************************************************/\n --border: 1px;\n --border-2: 2px;\n --border-4: 4px;\n --border-8: 8px;\n\n /****************************************************************\n * Border Radius\n * Variables for controlling the border radius of an element.\n * border-radius: var(--rounded-sm);\n *****************************************************************/\n --rounded-xs: 0.125rem; /* 2px */\n --rounded-sm: 0.25rem; /* 4px */\n --rounded-md: 0.375rem; /* 6px */\n --rounded-lg: 0.5rem; /* 8px */\n --rounded-xl: 0.75rem; /* 12px */\n --rounded-2xl: 1rem; /* 16px */\n --rounded-3xl: 1.5rem; /* 24px */\n --rounded-full: 9999px;\n}\n\n\n/* vendor/css-zero/effects.css */\n:root {\n /****************************************************************\n * Box Shadow\n * Variables for controlling the box shadow of an element.\n * box-shadow: var(--shadow-sm);\n ****************************************************************/\n --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);\n --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);\n --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);\n --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\n --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);\n --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);\n --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);\n\n /****************************************************************\n * Opacity\n * Variables for controlling the opacity of an element.\n * opacity: var(--opacity-25);\n ****************************************************************/\n --opacity-5: 0.05;\n --opacity-10: 0.1;\n --opacity-20: 0.2;\n --opacity-25: 0.25;\n --opacity-30: 0.3;\n --opacity-40: 0.4;\n --opacity-50: 0.5;\n --opacity-60: 0.6;\n --opacity-70: 0.7;\n --opacity-75: 0.75;\n --opacity-80: 0.8;\n --opacity-90: 0.9;\n --opacity-95: 0.95;\n --opacity-100: 1;\n}\n\n\n/* vendor/css-zero/typography.css */\n:root {\n /****************************************************************\n * Font Size\n * Variables for controlling the font size of an element.\n * font-size: var(--text-xs);\n *****************************************************************/\n --text-xs: 0.75rem; /* 12px */\n --text-sm: 0.875rem; /* 14px */\n --text-base: 1rem; /* 16px */\n --text-lg: 1.125rem; /* 18px */\n --text-xl: 1.25rem; /* 20px */\n --text-2xl: 1.5rem; /* 24px */\n --text-3xl: 1.875rem; /* 30px */\n --text-4xl: 2.25rem; /* 36px */\n --text-5xl: 3rem; /* 48px */\n --text-6xl: 3.75rem; /* 60px */\n --text-7xl: 4.5rem; /* 72px */\n --text-8xl: 6rem; /* 96px */\n --text-9xl: 8rem; /* 128px */\n\n --text-fluid-xs: clamp(0.75rem, 0.64rem + 0.57vw, 1rem); /* 12px..16px */\n --text-fluid-sm: clamp(0.875rem, 0.761rem + 0.568vw, 1.125rem); /* 14px..18px */\n --text-fluid-base: clamp(1rem, 0.89rem + 0.57vw, 1.25rem); /* 16px..20px */\n --text-fluid-lg: clamp(1.125rem, 0.955rem + 0.852vw, 1.5rem); /* 18px..24px */\n --text-fluid-xl: clamp(1.25rem, 0.966rem + 1.42vw, 1.875rem); /* 20px..30px */\n --text-fluid-2xl: clamp(1.5rem, 1.16rem + 1.7vw, 2.25rem); /* 24px..36px */\n --text-fluid-3xl: clamp(1.875rem, 1.364rem + 2.557vw, 3rem); /* 30px..48px */\n --text-fluid-4xl: clamp(2.25rem, 1.57rem + 3.41vw, 3.75rem); /* 36px..60px */\n --text-fluid-5xl: clamp(3rem, 2.32rem + 3.41vw, 4.5rem); /* 48px..72px */\n --text-fluid-6xl: clamp(3.75rem, 2.73rem + 5.11vw, 6rem); /* 60px..96px */\n --text-fluid-7xl: clamp(4.5rem, 2.91rem + 7.95vw, 8rem); /* 72px..128px */\n\n /****************************************************************\n * Font Weight\n * Variables for controlling the font weight of an element.\n * font-weight: var(--font-hairline);\n *****************************************************************/\n --font-thin: 100;\n --font-extralight: 200;\n --font-light: 300;\n --font-normal: 400;\n --font-medium: 500;\n --font-semibold: 600;\n --font-bold: 700;\n --font-extrabold: 800;\n --font-black: 900;\n\n /****************************************************************\n * Line Height\n * Variables for controlling the leading (line height) of an element.\n * line-height: var(--leading-tight);\n *****************************************************************/\n --leading-none: 1;\n --leading-tight: 1.25;\n --leading-snug: 1.375;\n --leading-normal: 1.5;\n --leading-relaxed: 1.625;\n --leading-loose: 2;\n --leading-3: .75rem; /* 12px */\n --leading-4: 1rem; /* 16px */\n --leading-5: 1.25rem; /* 20px */\n --leading-6: 1.5rem; /* 24px */\n --leading-7: 1.75rem; /* 28px */\n --leading-8: 2rem; /* 32px */\n --leading-9: 2.25rem; /* 36px */\n --leading-10: 2.5rem; /* 40px */\n\n /****************************************************************\n * Font Family (https://modernfontstacks.com)\n * Variables for controlling the font family of an element.\n * font-family: var(--font-sans);\n *****************************************************************/\n --font-system-ui: system-ui, sans-serif;\n --font-transitional: Charter, Bitstream Charter, Sitka Text, Cambria, serif;\n --font-old-style: Iowan Old Style, Palatino Linotype, URW Palladio L, P052, serif;\n --font-humanist: Seravek, Gill Sans Nova, Ubuntu, Calibri, DejaVu Sans, source-sans-pro, sans-serif;\n --font-geometric-humanist: Avenir, Montserrat, Corbel, URW Gothic, source-sans-pro, sans-serif;\n --font-classical-humanist: Optima, Candara, Noto Sans, source-sans-pro, sans-serif;\n --font-neo-grotesque: Inter, Roboto, Helvetica Neue, Arial Nova, Nimbus Sans, Arial, sans-serif;\n --font-monospace-slab-serif: Nimbus Mono PS, Courier New, monospace;\n --font-monospace-code: Dank Mono, Operator Mono, Inconsolata, Fira Mono, ui-monospace, SF Mono, Monaco, Droid Sans Mono, Source Code Pro, Cascadia Code, Menlo, Consolas, DejaVu Sans Mono, monospace;\n --font-industrial: Bahnschrift, DIN Alternate, Franklin Gothic Medium, Nimbus Sans Narrow, sans-serif-condensed, sans-serif;\n --font-rounded-sans: ui-rounded, Hiragino Maru Gothic ProN, Quicksand, Comfortaa, Manjari, Arial Rounded MT, Arial Rounded MT Bold, Calibri, source-sans-pro, sans-serif;\n --font-slab-serif: Rockwell, Rockwell Nova, Roboto Slab, DejaVu Serif, Sitka Small, serif;\n --font-antique: Superclarendon, Bookman Old Style, URW Bookman, URW Bookman L, Georgia Pro, Georgia, serif;\n --font-didone: Didot, Bodoni MT, Noto Serif Display, URW Palladio L, P052, Sylfaen, serif;\n --font-handwritten: Segoe Print, Bradley Hand, Chilanka, TSCu_Comic, casual, cursive;\n\n /****************************************************************\n * Letter Spacing\n * Variables for controlling the tracking (letter spacing) of an element.\n * letter-spacing: var(--tracking-tighter);\n *****************************************************************/\n --tracking-tighter: -0.05em;\n --tracking-tight: -0.025em;\n --tracking-normal: 0em;\n --tracking-wide: 0.025em;\n --tracking-wider: 0.05em;\n --tracking-widest: 0.1em;\n}\n\n\n/* vendor/css-zero/animations.css */\n/****************************************************************\n* Animation\n* Variables for animating elements with CSS animations.\n* animation: var(--animate-fade-in) forwards;\n*****************************************************************/\n\n:root {\n --animate-fade-in: fade-in .5s cubic-bezier(.25, 0, .3, 1);\n --animate-fade-in-bloom: fade-in-bloom 2s cubic-bezier(.25, 0, .3, 1);\n --animate-fade-out: fade-out .5s cubic-bezier(.25, 0, .3, 1);\n --animate-fade-out-bloom: fade-out-bloom 2s cubic-bezier(.25, 0, .3, 1);\n --animate-scale-up: scale-up .5s cubic-bezier(.25, 0, .3, 1);\n --animate-scale-down: scale-down .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-out-up: slide-out-up .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-out-down: slide-out-down .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-out-right: slide-out-right .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-out-left: slide-out-left .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-in-up: slide-in-up .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-in-down: slide-in-down .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-in-right: slide-in-right .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-in-left: slide-in-left .5s cubic-bezier(.25, 0, .3, 1);\n --animate-shake-x: shake-x .75s cubic-bezier(0, 0, 0, 1);\n --animate-shake-y: shake-y .75s cubic-bezier(0, 0, 0, 1);\n --animate-shake-z: shake-z 1s cubic-bezier(0, 0, 0, 1);\n --animate-spin: spin 2s linear infinite;\n --animate-ping: ping 5s cubic-bezier(0, 0, .3, 1) infinite;\n --animate-blink: blink 1s cubic-bezier(0, 0, .3, 1) infinite;\n --animate-float: float 3s cubic-bezier(0, 0, 0, 1) infinite;\n --animate-bounce: bounce 2s cubic-bezier(.5, -.3, .1, 1.5) infinite;\n --animate-pulse: pulse 2s cubic-bezier(0, 0, .3, 1) infinite;\n}\n\n@keyframes fade-in {\n to { opacity: 1 }\n}\n\n@keyframes fade-in-bloom {\n 0% { opacity: 0; filter: brightness(1) blur(20px) }\n 10% { opacity: 1; filter: brightness(2) blur(10px) }\n 100% { opacity: 1; filter: brightness(1) blur(0) }\n}\n\n@keyframes fade-out {\n to { opacity: 0 }\n}\n\n@keyframes fade-out-bloom {\n 100% { opacity: 0; filter: brightness(1) blur(20px) }\n 10% { opacity: 1; filter: brightness(2) blur(10px) }\n 0% { opacity: 1; filter: brightness(1) blur(0) }\n}\n@keyframes scale-up {\n to { transform: scale(1.25) }\n}\n\n@keyframes scale-down {\n to { transform: scale(.75) }\n}\n\n@keyframes slide-out-up {\n to { transform: translateY(-100%) }\n}\n\n@keyframes slide-out-down {\n to { transform: translateY(100%) }\n}\n\n@keyframes slide-out-right {\n to { transform: translateX(100%) }\n}\n\n@keyframes slide-out-left {\n to { transform: translateX(-100%) }\n}\n\n@keyframes slide-in-up {\n from { transform: translateY(100%) }\n}\n\n@keyframes slide-in-down {\n from { transform: translateY(-100%) }\n}\n\n@keyframes slide-in-right {\n from { transform: translateX(-100%) }\n}\n\n@keyframes slide-in-left {\n from { transform: translateX(100%) }\n}\n\n@keyframes shake-x {\n 0%, 100% { transform: translateX(0%) }\n 20% { transform: translateX(-5%) }\n 40% { transform: translateX(5%) }\n 60% { transform: translateX(-5%) }\n 80% { transform: translateX(5%) }\n}\n\n@keyframes shake-y {\n 0%, 100% { transform: translateY(0%) }\n 20% { transform: translateY(-5%) }\n 40% { transform: translateY(5%) }\n 60% { transform: translateY(-5%) }\n 80% { transform: translateY(5%) }\n}\n\n@keyframes shake-z {\n 0%, 100% { transform: rotate(0deg) }\n 20% { transform: rotate(-2deg) }\n 40% { transform: rotate(2deg) }\n 60% { transform: rotate(-2deg) }\n 80% { transform: rotate(2deg) }\n}\n\n@keyframes spin {\n to { transform: rotate(1turn) }\n}\n\n@keyframes ping {\n 90%, 100% {\n transform: scale(2);\n opacity: 0;\n }\n}\n\n@keyframes blink {\n 0%, 100% {\n opacity: 1\n }\n 50% {\n opacity: .5\n }\n}\n\n@keyframes float {\n 50% { transform: translateY(-25%) }\n}\n\n@keyframes bounce {\n 25% { transform: translateY(-20%) }\n 40% { transform: translateY(-3%) }\n 0%, 60%, 100% { transform: translateY(0) }\n}\n\n@keyframes pulse {\n 50% { transform: scale(.9,.9) }\n}\n\n@media (prefers-color-scheme: dark) {\n @keyframes fade-in-bloom {\n 0% { opacity: 0; filter: brightness(1) blur(20px) }\n 10% { opacity: 1; filter: brightness(0.5) blur(10px) }\n 100% { opacity: 1; filter: brightness(1) blur(0) }\n }\n}\n\n@media (prefers-color-scheme: dark) {\n @keyframes fade-out-bloom {\n 100% { opacity: 0; filter: brightness(1) blur(20px) }\n 10% { opacity: 1; filter: brightness(0.5) blur(10px) }\n 0% { opacity: 1; filter: brightness(1) blur(0) }\n }\n}\n\n/* vendor/css-zero/transforms.css */\n:root {\n /****************************************************************\n * Scale\n * Variables for scaling elements with transform.\n * transform: var(--scale-100);\n *****************************************************************/\n --scale-50: scale(0.50);\n --scale-75: scale(0.75);\n --scale-90: scale(0.90);\n --scale-95: scale(0.95);\n --scale-100: scale(1);\n --scale-105: scale(1.05);\n --scale-110: scale(1.10);\n --scale-125: scale(1.25);\n --scale-150: scale(1.50);\n\n /****************************************************************\n * Rotate\n * Variables for rotating elements with transform.\n * transform: var(--rotate-45);\n *****************************************************************/\n --rotate-0: rotate(0deg);\n --rotate-1: rotate(1deg);\n --rotate-2: rotate(2deg);\n --rotate-3: rotate(3deg);\n --rotate-6: rotate(6deg);\n --rotate-12: rotate(12deg);\n --rotate-45: rotate(45deg);\n --rotate-90: rotate(90deg);\n --rotate-180: rotate(180deg);\n\n /****************************************************************\n * Skew\n * Varibles for skewing elements with transform.\n * transform: var(--skew-x-3);\n *****************************************************************/\n --skew-x-0: skewX(0deg);\n --skew-y-0: skewY(0deg);\n --skew-x-1: skewX(1deg);\n --skew-y-1: skewY(1deg);\n --skew-x-2: skewX(2deg);\n --skew-y-2: skewY(2deg);\n --skew-x-3: skewX(3deg);\n --skew-y-3: skewY(3deg);\n --skew-x-6: skewX(6deg);\n --skew-y-6: skewY(6deg);\n --skew-x-12: skewX(12deg);\n --skew-y-12: skewY(12deg);\n}\n\n\n/* vendor/css-zero/transitions.css */\n:root {\n /****************************************************************\n * Transition Property\n * Variables for controlling which CSS properties transition.\n * transition-property: var(--transition);\n *****************************************************************/\n --transition: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter;\n --transition-colors: color, background-color, border-color, text-decoration-color, fill, stroke;\n --transition-transform: transform, translate, scale, rotate;\n\n /****************************************************************\n * Transition Timing\n * Variables for controlling the timing of CSS transitions.\n * transition-duration|transition-delay: var(--time-75);\n *****************************************************************/\n --time-75: 75ms;\n --time-100: 100ms;\n --time-150: 150ms;\n --time-200: 200ms;\n --time-300: 300ms;\n --time-500: 500ms;\n --time-700: 700ms;\n --time-1000: 1000ms;\n}\n\n\n/* vendor/css-zero/filters.css */\n:root {\n /****************************************************************\n * Blur\n * Variables for applying blur filters to an element.\n * filter|backdrop-filter: var(--blur-sm);\n *****************************************************************/\n --blur-none: blur(0);\n --blur-xs: blur(4px);\n --blur-sm: blur(8px);\n --blur-md: blur(12px);\n --blur-lg: blur(16px);\n --blur-xl: blur(24px);\n --blur-2xl: blur(40px);\n --blur-3xl: blur(64px);\n\n /****************************************************************\n * Brightness\n * Variables for applying brightness filters to an element.\n * filter|backdrop-filter: var(--brightness-50);\n *****************************************************************/\n --brightness-0: brightness(0);\n --brightness-50: brightness(0.5);\n --brightness-75: brightness(0.75);\n --brightness-90: brightness(0.9);\n --brightness-95: brightness(0.95);\n --brightness-100: brightness(1);\n --brightness-105: brightness(1.05);\n --brightness-110: brightness(1.1);\n --brightness-125: brightness(1.25);\n --brightness-150: brightness(1.5);\n --brightness-200: brightness(2);\n\n /****************************************************************\n * Contrast\n * Variables for applying contrast filters to an element.\n * filter|backdrop-filter: var(--contrast-50);\n *****************************************************************/\n --contrast-0: contrast(0);\n --contrast-50: contrast(0.5);\n --contrast-75: contrast(0.75);\n --contrast-100: contrast(1);\n --contrast-125: contrast(1.25);\n --contrast-150: contrast(1.5);\n --contrast-200: contrast(2);\n\n /****************************************************************\n * Drop Shadow\n * Variables for applying drop-shadow filters to an element.\n * filter: var(--drop-shadow);\n *****************************************************************/\n --drop-shadow-none: drop-shadow(0 0 #0000);\n --drop-shadow-sm: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05));\n --drop-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.06));\n --drop-shadow-md: drop-shadow(0 4px 3px rgba(0, 0, 0, 0.07)) drop-shadow(0 2px 2px rgba(0, 0, 0, 0.06));\n --drop-shadow-lg: drop-shadow(0 10px 8px rgba(0, 0, 0, 0.04)) drop-shadow(0 4px 3px rgba(0, 0, 0, 0.1));\n --drop-shadow-xl: drop-shadow(0 20px 13px rgba(0, 0, 0, 0.03)) drop-shadow(0 8px 5px rgba(0, 0, 0, 0.08));\n --drop-shadow-2xl: drop-shadow(0 25px 25px rgba(0, 0, 0, 0.15));\n\n /****************************************************************\n * Grayscale\n * Variables for applying grayscale filters to an element.\n * filter|backdrop-filter: var(--grayscale);\n *****************************************************************/\n --grayscale-0: grayscale(0);\n --grayscale: grayscale(100%);\n\n /****************************************************************\n * Hue Rotate\n * Variables for applying hue-rotate filters to an element.\n * filter|backdrop-filter: var(--hue-rotate-15);\n *****************************************************************/\n --hue-rotate-0: hue-rotate(0deg);\n --hue-rotate-15: hue-rotate(15deg);\n --hue-rotate-30: hue-rotate(30deg);\n --hue-rotate-60: hue-rotate(60deg);\n --hue-rotate-90: hue-rotate(90deg);\n --hue-rotate-180: hue-rotate(180deg);\n\n /****************************************************************\n * Invert\n * Variables for applying invert filters to an element.\n * filter|backdrop-filter: var(--invert);\n *****************************************************************/\n --invert-0: invert(0);\n --invert: invert(100%);\n\n /****************************************************************\n * Saturate\n * Variables for applying saturation filters to an element.\n * filter|backdrop-filter: var(--saturate-50);\n *****************************************************************/\n --saturate-0: saturate(0);\n --saturate-50: saturate(0.5);\n --saturate-100: saturate(1);\n --saturate-150: saturate(1.5);\n --saturate-200: saturate(2);\n\n /****************************************************************\n * Sepia\n * Variables for applying sepia filters to an element.\n * filter|backdrop-filter: var(--sepia);\n *****************************************************************/\n --sepia-0: sepia(0);\n --sepia: sepia(100%);\n\n /****************************************************************\n * Opacity\n * Utilities for applying backdrop opacity filters to an element.\n * backdrop-filter: var(--alpha-45);\n *****************************************************************/\n --alpha-0:\t opacity(0);\n --alpha-5:\t opacity(0.05);\n --alpha-10:\t opacity(0.1);\n --alpha-15:\t opacity(0.15);\n --alpha-20:\t opacity(0.2);\n --alpha-25:\t opacity(0.25);\n --alpha-30:\t opacity(0.3);\n --alpha-35:\t opacity(0.35);\n --alpha-40:\t opacity(0.4);\n --alpha-45:\t opacity(0.45);\n --alpha-50:\t opacity(0.5);\n --alpha-55:\t opacity(0.55);\n --alpha-60:\t opacity(0.6);\n --alpha-65:\t opacity(0.65);\n --alpha-70:\t opacity(0.7);\n --alpha-75:\t opacity(0.75);\n --alpha-80:\t opacity(0.8);\n --alpha-85:\t opacity(0.85);\n --alpha-90:\t opacity(0.9);\n --alpha-95:\t opacity(0.95);\n --alpha-100: opacity(1);\n}\n\n\n/* vendor/flatpickr.css */\n.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/*\n /*rtl:begin:ignore*/left:0/*\n /*rtl:end:ignore*/}/*\n /*rtl:begin:ignore*/\n/*\n /*rtl:end:ignore*/\n.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,.flatpickr-months .flatpickr-next-month.flatpickr-next-month{/*\n /*rtl:begin:ignore*/right:0/*\n /*rtl:end:ignore*/}/*\n /*rtl:begin:ignore*/\n/*\n /*rtl:end:ignore*/\n.flatpickr-months .flatpickr-prev-month:hover,.flatpickr-months .flatpickr-next-month:hover{color:#959ea9}.flatpickr-months .flatpickr-prev-month:hover svg,.flatpickr-months .flatpickr-next-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-prev-month svg,.flatpickr-months .flatpickr-next-month svg{width:14px;height:14px}.flatpickr-months .flatpickr-prev-month svg path,.flatpickr-months .flatpickr-next-month svg path{-webkit-transition:fill .1s;transition:fill .1s;fill:inherit}.numInputWrapper{position:relative;height:auto}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-outer-spin-button,.numInputWrapper input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.numInputWrapper span{position:absolute;right:0;width:14px;padding:0 4px 0 2px;height:50%;line-height:50%;opacity:0;cursor:pointer;border:1px solid rgba(57,57,57,0.15);-webkit-box-sizing:border-box;box-sizing:border-box}.numInputWrapper span:hover{background:rgba(0,0,0,0.1)}.numInputWrapper span:active{background:rgba(0,0,0,0.2)}.numInputWrapper span:after{display:block;content:\"\";position:absolute}.numInputWrapper span.arrowUp{top:0;border-bottom:0}.numInputWrapper span.arrowUp:after{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:4px solid rgba(57,57,57,0.6);top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,0.6);top:40%}.numInputWrapper span svg{width:inherit;height:auto}.numInputWrapper span svg path{fill:rgba(0,0,0,0.5)}.numInputWrapper:hover{background:rgba(0,0,0,0.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{font-size:135%;line-height:inherit;font-weight:300;color:inherit;position:absolute;width:75%;left:12.5%;padding:7.48px 0 0 0;line-height:1;height:34px;display:inline-block;text-align:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.flatpickr-current-month span.cur-month{font-family:inherit;font-weight:700;color:inherit;display:inline-block;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .numInputWrapper{width:6ch;width:7ch\\0;display:inline-block}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,0.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,0.9)}.flatpickr-current-month input.cur-year{background:transparent;-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;cursor:text;padding:0 0 0 .5ch;margin:0;display:inline-block;font-size:inherit;font-family:inherit;font-weight:300;line-height:inherit;height:auto;border:0;border-radius:0;vertical-align:initial;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{font-size:100%;color:rgba(0,0,0,0.5);background:transparent;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;color:inherit;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;-webkit-box-sizing:border-box;-webkit-appearance:menulist;-moz-appearance:menulist;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:focus,.flatpickr-current-month .flatpickr-monthDropdown-months:active{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{background:transparent;text-align:center;overflow:hidden;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:28px}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}span.flatpickr-weekday{cursor:default;font-size:90%;background:transparent;color:rgba(0,0,0,0.54);line-height:1;margin:0;text-align:center;display:block;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;font-weight:bolder}.dayContainer,.flatpickr-weeks{padding:1px 0 0 0}.flatpickr-days{position:relative;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{padding:0;outline:0;text-align:left;width:307.875px;min-width:307.875px;max-width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;display:inline-block;display:-ms-flexbox;display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-wrap:wrap;-ms-flex-pack:justify;-webkit-justify-content:space-around;justify-content:space-around;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.dayContainer + .dayContainer{-webkit-box-shadow:-1px 0 0 #e6e6e6;box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#393939;cursor:pointer;font-weight:400;width:14.2857143%;-webkit-flex-basis:14.2857143%;-ms-flex-preferred-size:14.2857143%;flex-basis:14.2857143%;max-width:39px;height:39px;line-height:39px;margin:0;display:inline-block;position:relative;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center}.flatpickr-day.inRange,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.today.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day:hover,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.nextMonthDay:hover,.flatpickr-day:focus,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.nextMonthDay:focus{cursor:pointer;outline:0;background:#e6e6e6;border-color:#e6e6e6}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:hover,.flatpickr-day.today:focus{border-color:#959ea9;background:#959ea9;color:#fff}.flatpickr-day.selected,.flatpickr-day.startRange,.flatpickr-day.endRange,.flatpickr-day.selected.inRange,.flatpickr-day.startRange.inRange,.flatpickr-day.endRange.inRange,.flatpickr-day.selected:focus,.flatpickr-day.startRange:focus,.flatpickr-day.endRange:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange:hover,.flatpickr-day.endRange:hover,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.endRange.nextMonthDay{background:#569ff7;-webkit-box-shadow:none;box-shadow:none;color:#fff;border-color:#569ff7}.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange,.flatpickr-day.endRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange,.flatpickr-day.endRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)){-webkit-box-shadow:-10px 0 0 #569ff7;box-shadow:-10px 0 0 #569ff7}.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange,.flatpickr-day.endRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;-webkit-box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.prevMonthDay,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.notAllowed.nextMonthDay{color:rgba(57,57,57,0.3);background:transparent;border-color:transparent;cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{cursor:not-allowed;color:rgba(57,57,57,0.1)}.flatpickr-day.week.selected{border-radius:0;-webkit-box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{padding:0 12px;-webkit-box-shadow:1px 0 0 #e6e6e6;box-shadow:1px 0 0 #e6e6e6}.flatpickr-weekwrapper .flatpickr-weekday{float:none;width:100%;line-height:28px}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{display:block;width:100%;max-width:none;color:rgba(57,57,57,0.3);background:transparent;cursor:default;border:none}.flatpickr-innerContainer{display:block;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.flatpickr-rContainer{display:inline-block;padding:0;-webkit-box-sizing:border-box;box-sizing:border-box}.flatpickr-time{text-align:center;outline:0;display:block;height:0;line-height:40px;max-height:40px;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-time:after{content:\"\";display:table;clear:both}.flatpickr-time .numInputWrapper{-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;width:40%;height:40px;float:left}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{background:transparent;-webkit-box-shadow:none;box-shadow:none;border:0;border-radius:0;text-align:center;margin:0;padding:0;height:inherit;line-height:inherit;color:#393939;font-size:14px;position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-time input.flatpickr-hour{font-weight:bold}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{outline:0;border:0}.flatpickr-time .flatpickr-time-separator,.flatpickr-time .flatpickr-am-pm{height:inherit;float:left;line-height:inherit;color:#393939;font-weight:bold;width:2%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.flatpickr-time .flatpickr-am-pm{outline:0;width:18%;cursor:pointer;text-align:center;font-weight:400}.flatpickr-time input:hover,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time .flatpickr-am-pm:focus{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@-webkit-keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}@keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}\n\n/* app/assets/stylesheets/rails_pulse/components/alert.css */\n.alert {\n border: 1px solid var(--alert-border-color, var(--color-border));\n border-radius: var(--rounded-lg);\n color: var(--alert-color, var(--color-text));\n font-size: var(--text-sm);\n inline-size: var(--size-full);\n padding: var(--size-4);\n\n img {\n filter: var(--alert-icon-color, var(--color-filter-text));\n }\n}\n\n.alert--positive {\n --alert-border-color: var(--color-positive);\n --alert-color: var(--color-positive);\n --alert-icon-color: var(--color-filter-positive);\n}\n\n.alert--negative {\n --alert-border-color: var(--color-negative);\n --alert-color: var(--color-negative);\n --alert-icon-color: var(--color-filter-negative);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/badge.css */\n.badge {\n background-color: var(--badge-background, var(--color-bg));\n border-radius: var(--rounded-md);\n border: 1px solid var(--badge-border-color, var(--color-border));\n box-shadow: var(--badge-box-shadow, none);\n color: var(--badge-color, var(--color-text));\n display: inline-flex;\n font-size: var(--text-xs);\n font-weight: var(--font-semibold);\n line-height: var(--leading-4);\n padding: var(--size-0_5) var(--size-2_5);\n}\n\n.badge--primary {\n --badge-background: var(--color-primary);\n --badge-border-color: transparent;\n --badge-box-shadow: var(--shadow-sm);\n --badge-color: var(--color-text-reversed);\n}\n\n.badge--secondary {\n --badge-background: var(--color-secondary);\n --badge-border-color: transparent;\n --badge-box-shadow: none;\n --badge-color: var(--color-text);\n}\n\n.badge--positive {\n --badge-background: var(--color-positive);\n --badge-border-color: transparent;\n --badge-box-shadow: var(--shadow-sm);\n --badge-color: white;\n}\n\n.badge--negative {\n --badge-background: var(--color-negative);\n --badge-border-color: transparent;\n --badge-box-shadow: var(--shadow-sm);\n --badge-color: white;\n}\n\n.badge--primary-inverse {\n --badge-background: var(--color-bg);\n --badge-border-color: transparent;\n --badge-color: var(--color-positive);\n}\n\n.badge--positive-inverse {\n --badge-background: var(--color-bg);\n --badge-border-color: transparent;\n --badge-color: var(--color-positive);\n}\n\n.badge--negative-inverse {\n --badge-background: var(--color-bg);\n --badge-border-color: transparent;\n --badge-color: var(--color-negative);\n}\n\n/* Trend badge icon lightening (dark mode only) */\n.badge--trend rails-pulse-icon { color: var(--badge-color, currentColor); }\nhtml[data-color-scheme=\"dark\"] .badge--trend rails-pulse-icon {\n /* Lighten icon relative to badge text color for contrast */\n color: color-mix(in srgb, var(--badge-color) 55%, white 45%);\n}\n\n/* Trend amount lightening (dark mode only) */\n.badge--trend .badge__trend-amount { color: var(--badge-color, currentColor); }\nhtml[data-color-scheme=\"dark\"] .badge--trend .badge__trend-amount {\n color: color-mix(in srgb, var(--badge-color) 55%, white 45%);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/base.css */\n:root {\n /* Abstractions */\n --color-bg: white;\n --color-text: black;\n --color-text-reversed: white;\n --color-text-subtle: var(--zinc-500);\n --color-link: var(--blue-700);\n /* Header tokens */\n --header-bg: #ffc91f;\n --header-link: black;\n --header-link-hover-bg: #ffe284;\n --color-border-light: var(--zinc-100);\n --color-border: var(--zinc-200);\n --color-border-dark: var(--zinc-400);\n --color-selected: var(--blue-100);\n --color-selected-dark: var(--blue-300);\n --color-highlight: var(--yellow-200);\n\n /* Accent colors */\n --color-primary: var(--zinc-900);\n --color-secondary: var(--zinc-100);\n --color-negative: var(--red-600);\n --color-positive: var(--green-600);\n\n /* SVG color values */\n --color-filter-text: invert(0);\n --color-filter-text-reversed: invert(1);\n --color-filter-negative: invert(22%) sepia(85%) saturate(1790%) hue-rotate(339deg) brightness(105%) contrast(108%);\n --color-filter-positive: invert(44%) sepia(89%) saturate(409%) hue-rotate(89deg) brightness(94%) contrast(97%);\n}\n\nhtml[data-color-scheme=\"dark\"] {\n /* Abstractions */\n --color-bg: var(--zinc-800);\n --color-text: white;\n --color-text-reversed: black;\n --color-text-subtle: var(--zinc-300);\n /* Use brand yellow for links in dark mode */\n --color-link: #ffc91f;\n --color-border-light: var(--zinc-900);\n --color-border: var(--zinc-800);\n --color-border-dark: var(--zinc-600);\n --color-selected: var(--blue-950);\n --color-selected-dark: var(--blue-800);\n --color-highlight: var(--yellow-900);\n\n /* Header tokens */\n --header-bg: rgb(32, 32, 32);\n --header-link: #ffc91f;\n --header-link-hover-bg: #ffe284; /* keep existing hover color */\n\n /* Accent colors */\n --color-primary: var(--zinc-50);\n --color-secondary: var(--zinc-800);\n --color-negative: var(--red-900);\n --color-positive: var(--green-900);\n\n /* SVG color values */\n --color-filter-text: invert(1);\n --color-filter-text-reversed: invert(0);\n --color-filter-negative: invert(15%) sepia(65%) saturate(2067%) hue-rotate(339deg) brightness(102%) contrast(97%);\n --color-filter-positive: invert(23%) sepia(62%) saturate(554%) hue-rotate(91deg) brightness(93%) contrast(91%);\n}\n\n* {\n border-color: var(--color-border);\n scrollbar-color: #C1C1C1 transparent;\n scrollbar-width: thin;\n}\n\nhtml {\n scroll-behavior: smooth;\n}\n\nbody {\n background-color: var(--color-bg);\n color: var(--color-text);\n font-synthesis-weight: none;\n overscroll-behavior: none;\n text-rendering: optimizeLegibility;\n}\n\n.turbo-progress-bar {\n background-color: #4a8136\n}\n\n::selection {\n background-color: var(--color-selected);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/breadcrumb.css */\n.breadcrumb {\n align-items: center;\n color: var(--color-text-subtle);\n column-gap: var(--size-1);\n display: flex;\n flex-wrap: wrap;\n font-size: var(--text-sm);\n overflow-wrap: break-word;\n\n a {\n padding-block-end: 2px;\n }\n\n img.breadcrumb-separator {\n filter: var(--color-filter-text);\n opacity: 0.5;\n }\n\n a:hover {\n color: var(--color-text);\n }\n\n span[aria-current=\"page\"] {\n color: var(--color-text);\n font-weight: 500;\n }\n\n @media (width >= 40rem) {\n column-gap: var(--size-2);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/button.css */\n.btn {\n --btn-background: var(--color-bg);\n --hover-color: oklch(from var(--btn-background) calc(l * .95) c h);\n\n align-items: center;\n background-color: var(--btn-background);\n block-size: var(--btn-block-size, auto);\n border-radius: var(--btn-radius, var(--rounded-md));\n border: 1px solid var(--btn-border-color, var(--color-border));\n box-shadow: var(--btn-box-shadow, var(--shadow-xs));\n color: var(--btn-color, var(--color-text));\n column-gap: var(--size-2);\n cursor: default;\n display: inline-flex;\n font-size: var(--btn-font-size, var(--text-sm));\n font-weight: var(--btn-font-weight, var(--font-medium));\n inline-size: var(--btn-inline-size, auto);\n justify-content: var(--btn-justify-content, center);\n padding: var(--btn-padding, .375rem 1rem);\n position: relative;\n text-align: var(--btn-text-align, center);\n white-space: nowrap;\n\n img:not([class]) {\n filter: var(--btn-icon-color, var(--color-filter-text));\n }\n\n &:hover {\n background-color: var(--btn-hover-color, var(--hover-color));\n }\n\n &:focus-visible {\n outline: var(--btn-outline-size, 2px) solid var(--color-selected-dark);\n }\n\n &:is(:disabled, [aria-disabled]) {\n opacity: var(--opacity-50); pointer-events: none;\n }\n}\n\n.btn--primary {\n --btn-background: var(--color-primary);\n --btn-border-color: transparent;\n --btn-color: var(--color-text-reversed);\n --btn-icon-color: var(--color-filter-text-reversed);\n}\n\n.btn--secondary {\n --btn-background: var(--color-secondary);\n --btn-border-color: transparent;\n}\n\n.btn--borderless {\n --btn-border-color: transparent;\n --btn-box-shadow: none;\n}\n\n.btn--positive {\n --btn-background: var(--color-positive);\n --btn-border-color: transparent;\n --btn-color: white;\n --btn-icon-color: invert(1);\n}\n\n.btn--negative {\n --btn-background: var(--color-negative);\n --btn-border-color: transparent;\n --btn-color: white;\n --btn-icon-color: invert(1);\n}\n\n.btn--plain {\n --btn-background: transparent;\n --btn-border-color: transparent;\n --btn-hover-color: transparent;\n --btn-padding: 0;\n --btn-box-shadow: none;\n}\n\n.btn--icon {\n --btn-padding: var(--size-2);\n}\n\n[aria-busy] .btn--loading:disabled {\n > * {\n visibility: hidden;\n }\n\n &::after {\n animation: spin 1s linear infinite;\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cline x1='12' x2='12' y1='2' y2='6'/%3e%3cline x1='12' x2='12' y1='18' y2='22'/%3e%3cline x1='4.93' x2='7.76' y1='4.93' y2='7.76'/%3e%3cline x1='16.24' x2='19.07' y1='16.24' y2='19.07'/%3e%3cline x1='2' x2='6' y1='12' y2='12'/%3e%3cline x1='18' x2='22' y1='12' y2='12'/%3e%3cline x1='4.93' x2='7.76' y1='19.07' y2='16.24'/%3e%3cline x1='16.24' x2='19.07' y1='7.76' y2='4.93'/%3e%3c/svg%3e\");\n background-size: cover;\n block-size: var(--size-5);\n content: \"\";\n filter: var(--btn-icon-color, var(--color-filter-text));\n inline-size: var(--size-5);\n position: absolute;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/card.css */\n.card {\n background-color: var(--color-bg);\n border-radius: var(--rounded-xl);\n border-width: var(--border);\n padding: var(--size-6);\n box-shadow: var(--shadow-sm);\n}\n\n.card-selectable {\n background-color: var(--color-bg);\n border-radius: var(--rounded-xl);\n border-width: var(--border);\n padding: var(--size-3);\n\n &:has(:checked) {\n background-color: var(--color-secondary);\n border-color: var(--color-primary);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/chart.css */\n.chart-container {\n width: 100%;\n aspect-ratio: 4 / 2;\n}\n\n.chart-container--slim {\n aspect-ratio: 4 / 3;\n}\n\n@media (min-width: 64rem) {\n .chart-container {\n aspect-ratio: 16 / 5;\n }\n\n .chart-container--slim {\n aspect-ratio: 16 / 5;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/collapsible.css */\n.collapsible-code.collapsed pre {\n max-height: 4.5em;\n overflow: hidden;\n position: relative;\n}\n\n.collapsible-code.collapsed pre::after {\n content: '';\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 1em;\n background: linear-gradient(transparent, var(--color-border-light));\n pointer-events: none;\n}\n\n.collapsible-toggle {\n margin-top: 0.5rem;\n font-size: 0.875rem;\n color: var(--color-link);\n text-decoration: underline;\n transform: lowercase;\n cursor: pointer;\n border: none;\n background: none;\n padding: 0;\n font-weight: normal;\n margin-left: 10px;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css */\n/* CSP-Safe Positioning Utilities for Rails Pulse */\n/* Supports dynamic positioning using CSS custom properties */\n\n/* Rails Pulse CSS loaded indicator for CSP testing */\n:root {\n --rails-pulse-loaded: true;\n}\n\n/* Popover positioning using CSS custom properties */\n.positioned {\n --popover-x: 0px;\n --popover-y: 0px;\n --context-menu-x: 0px;\n --context-menu-y: 0px;\n}\n\n/* Popover positioning (used by popover_controller.js) */\n[popover].positioned {\n position: fixed;\n inset-inline-start: var(--popover-x, 0px) !important;\n inset-block-start: var(--popover-y, 0px) !important;\n}\n\n/* Context menu positioning (used by context_menu_controller.js) */\n[popover].positioned {\n inset-inline-start: var(--context-menu-x, var(--popover-x, 0px)) !important;\n inset-block-start: var(--context-menu-y, var(--popover-y, 0px)) !important;\n}\n\n/* Icon loading states for icon_controller.js */\n[data-controller*=\"rails-pulse--icon\"] {\n display: inline-block;\n line-height: 0;\n}\n\n[data-controller*=\"rails-pulse--icon\"].loading {\n opacity: 0.6;\n}\n\n[data-controller*=\"rails-pulse--icon\"].error {\n opacity: 0.4;\n filter: grayscale(1);\n}\n\n[data-controller*=\"rails-pulse--icon\"].loaded {\n opacity: 1;\n}\n\n/* CSP-safe icon rendering */\n[data-controller*=\"rails-pulse--icon\"] svg {\n display: block;\n width: inherit;\n height: inherit;\n}\n\n/* Accessibility improvements */\n[data-controller*=\"rails-pulse--icon\"][aria-label] {\n position: relative;\n}\n\n/* Focus indicators for interactive icons */\n[data-controller*=\"rails-pulse--icon\"]:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 2px;\n border-radius: 2px;\n}\n\n/* CSP Test Page Utilities */\n.csp-test-grid-single {\n --columns: 1;\n}\n\n.csp-test-context-area {\n padding: 2rem;\n border: 2px dashed var(--color-border);\n text-align: center;\n}\n\n.csp-test-nav-gap {\n --column-gap: 1rem;\n}\n\n/* Sheet sizing for dialog */\n.csp-test-sheet {\n --sheet-size: 288px;\n}\n\n/* app/assets/stylesheets/rails_pulse/components/datepicker.css */\n@import url(\"https://esm.sh/flatpickr@4.6.13/dist/flatpickr.min.css\");\n\n.flatpickr-calendar {\n --calendar-size: 250px;\n --container-size: 220px;\n --day-size: var(--size-8);\n\n background: var(--color-bg);\n border: 1px solid var(--color-border);\n border-radius: var(--rounded-md);\n box-shadow: var(--shadow-md);\n font-size: var(--text-sm);\n inline-size: var(--calendar-size);\n\n .flatpickr-innerContainer {\n justify-content: center;\n padding-block-end: var(--size-3);\n }\n\n .flatpickr-days {\n inline-size: var(--container-size);\n }\n\n .dayContainer {\n inline-size: var(--container-size);\n min-inline-size: var(--container-size);\n max-inline-size: var(--container-size);\n }\n\n .dayContainer + .dayContainer {\n box-shadow: -1px 0 0 var(--color-border);\n }\n\n .flatpickr-months {\n .flatpickr-month {\n color: var(--color-text);\n }\n\n span.cur-month {\n font-size: var(--text-sm);\n font-weight: var(--font-medium);\n }\n\n svg {\n fill: var(--color-border-dark);\n }\n\n .flatpickr-prev-month:hover svg {\n fill: var(--color-text);\n }\n\n .flatpickr-next-month:hover svg {\n fill: var(--color-text);\n }\n }\n\n .flatpickr-monthDropdown-months {\n appearance: none;\n border-radius: var(--rounded-md);\n font-size: var(--text-sm);\n font-weight: var(--font-medium);\n line-height: var(--leading-normal);\n padding: 0;\n text-align: center;\n\n &:hover {\n background: var(--color-border-light);\n }\n }\n\n .numInputWrapper {\n input {\n border-radius: var(--rounded-md);\n color: var(--color-text);\n font-size: var(--text-sm);\n font-weight: var(--font-medium);\n line-height: var(--leading-normal);\n padding: 0;\n text-align: center;\n }\n\n span {\n border-color: var(--color-border);\n }\n\n span:hover {\n background: transparent;\n }\n\n span.arrowUp::after {\n border-bottom-color: var(--color-text);\n }\n\n span.arrowDown::after {\n border-top-color: var(--color-text);\n }\n\n &:hover {\n background: transparent;\n }\n }\n\n .flatpickr-weekday {\n color: var(--color-text-subtle);\n font-weight: var(--font-normal);\n }\n\n .flatpickr-time {\n .hasTime & {\n border-top-color: var(--color-border);\n }\n\n .hasTime.noCalendar & {\n border: 0;\n }\n\n .numInput {\n background: transparent;\n color: var(--color-text);\n }\n\n .flatpickr-time-separator {\n color: var(--color-text);\n }\n\n .flatpickr-am-pm {\n background: transparent;\n color: var(--color-text);\n }\n }\n\n .flatpickr-day {\n border-radius: var(--rounded-md);\n border-color: transparent !important;\n box-shadow: none !important;\n color: var(--color-text);\n height: var(--day-size);\n line-height: var(--day-size);\n margin-block-start: var(--size-2);\n max-width: var(--day-size);\n\n &:is(.inRange) {\n border-radius: 0;\n }\n\n &:is(.today, .inRange, :hover, :focus) {\n background: var(--color-secondary);\n color: var(--color-text);\n }\n\n &:is(\n .flatpickr-disabled,\n .flatpickr-disabled:hover,\n .prevMonthDay,\n .nextMonthDay,\n .notAllowed,\n .notAllowed.prevMonthDay,\n .notAllowed.nextMonthDay\n ) {\n color: var(--color-text-subtle);\n }\n\n &:is(\n .selected,\n .startRange,\n .endRange,\n .selected.inRange,\n .startRange.inRange,\n .endRange.inRange,\n .selected:focus,\n .startRange:focus,\n .endRange:focus,\n .selected:hover,\n .startRange:hover,\n .endRange:hover,\n .selected.prevMonthDay,\n .startRange.prevMonthDay,\n .endRange.prevMonthDay,\n .selected.nextMonthDay,\n .startRange.nextMonthDay,\n .endRange.nextMonthDay\n ) {\n background: var(--color-primary);\n color: var(--color-text-reversed);\n }\n }\n\n &::before, &::after {\n display: none;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/descriptive_list.css */\n.descriptive-list {\n display: grid;\n grid-template-columns: 200px 1fr;\n gap: 0.5rem;\n}\n\n.descriptive-list dt, .descriptive-list dd {\n font-size: var(--text-sm);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/dialog.css */\n.dialog {\n background-color: var(--color-bg);\n border-radius: var(--rounded-lg);\n border-width: var(--border);\n box-shadow: var(--shadow-lg);\n color: var(--color-text);\n inline-size: var(--size-full);\n margin: auto;\n max-inline-size: var(--dialog-size, var(--max-i-lg));\n\n &::backdrop {\n background-color: rgba(0, 0, 0, .8);\n }\n\n /* Final state of exit animation and setup */\n opacity: 0;\n transform: var(--scale-95);\n transition-behavior: allow-discrete;\n transition-duration: var(--time-200);\n transition-property: display, overlay, opacity, transform;\n\n &::backdrop {\n opacity: 0;\n transition-behavior: allow-discrete;\n transition-duration: var(--time-200);\n transition-property: display, overlay, opacity;\n }\n\n /* Final state of entry animation */\n &[open] { opacity: 1; transform: var(--scale-100); }\n &[open]::backdrop { opacity: 1; }\n\n /* Initial state of entry animation */\n @starting-style {\n &[open] { opacity: 0; transform: var(--scale-95); }\n &[open]::backdrop { opacity: 0; }\n }\n\n /* Drawer component on mobile */\n @media (width < 40rem) {\n border-end-end-radius: 0;\n border-end-start-radius: 0;\n margin-block-end: 0;\n max-inline-size: none;\n }\n}\n\n.dialog__content {\n padding: var(--size-6);\n}\n\n.dialog__close {\n inset-block-start: var(--size-3);\n inset-inline-end: var(--size-3);\n position: absolute;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/flash.css */\n.flash {\n align-items: center;\n animation: appear-then-fade 4s 300ms both;\n backdrop-filter: var(--blur-sm) var(--contrast-75);\n background-color: var(--flash-background, rgb(from var(--color-text) r g b / .65));\n border-radius: var(--rounded-full);\n color: var(--flash-color, var(--color-text-reversed));\n column-gap: var(--size-2);\n display: flex;\n font-size: var(--text-fluid-base);\n justify-content: center;\n line-height: var(--leading-none);\n margin-block-start: var(--flash-position, var(--size-4));\n margin-inline: auto;\n min-block-size: var(--size-11);\n padding: var(--size-1) var(--size-4);\n text-align: center;\n\n [data-turbo-preview] & {\n display: none;\n }\n}\n\n.flash--positive {\n --flash-background: var(--color-positive);\n --flash-color: white;\n}\n\n.flash--negative {\n --flash-background: var(--color-negative);\n --flash-color: white;\n}\n\n.flash--extended {\n animation-name: appear-then-fade-extended;\n animation-duration: 12s;\n}\n\n@keyframes appear-then-fade {\n 0%, 100% { opacity: 0; }\n 5%, 60% { opacity: 1; }\n}\n\n@keyframes appear-then-fade-extended {\n 0%, 100% { opacity: 0; }\n 2%, 90% { opacity: 1; }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/input.css */\n.input {\n appearance: none;\n background-color: var(--input-background, transparent);\n block-size: var(--input-block-size, auto);\n border: 1px solid var(--input-border-color, var(--color-border));\n border-radius: var(--input-radius, var(--rounded-md));\n box-shadow: var(--input-box-shadow, var(--shadow-xs));\n font-size: var(--input-font-size, var(--text-sm));\n inline-size: var(--input-inline-size, var(--size-full));\n padding: var(--input-padding, .375rem .75rem);\n\n &:is(textarea[rows=auto]) {\n field-sizing: content;\n max-block-size: calc(.875rem + var(--input-max-rows, 10lh));\n min-block-size: calc(.875rem + var(--input-rows, 2lh));\n }\n\n &:is(select):not([multiple], [size]) {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m6 9 6 6 6-6'/%3e%3c/svg%3e\");\n background-position: center right var(--size-2);\n background-repeat: no-repeat;\n background-size: var(--size-4) auto;\n }\n\n &::file-selector-button {\n font-weight: var(--font-medium);\n }\n\n &:user-invalid {\n border-color: var(--color-negative);\n }\n\n &:user-invalid ~ .invalid-feedback {\n display: flex;\n }\n\n &:disabled {\n cursor: not-allowed; opacity: var(--opacity-50);\n }\n}\n\n.input--actor {\n input {\n border: 0; inline-size: 100%; outline: 0;\n }\n\n img:not([class]) {\n filter: var(--input-icon-color, var(--color-filter-text));\n }\n\n &:focus-within {\n outline: var(--input-outline-size, 2px) solid var(--color-selected-dark);\n }\n}\n\n.invalid-feedback {\n display: none;\n}\n\n:is(.checkbox, .radio) {\n transform: scale(1.2);\n}\n\n:is(.checkbox, .radio, .range) {\n accent-color: var(--color-primary);\n}\n\n:is(.input, .checkbox, .radio, .range) {\n &:focus-visible {\n outline: var(--input-outline-size, 2px) solid var(--color-selected-dark);\n }\n\n &:focus-visible:user-invalid {\n outline: none;\n }\n\n .field_with_errors & {\n border-color: var(--color-negative); display: contents;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/layouts.css */\n.sidebar-layout {\n display: grid;\n grid-template-areas: \"header header\" \"sidebar main\";\n grid-template-columns: var(--sidebar-width, 0) 1fr;\n grid-template-rows: auto 1fr;\n block-size: 100dvh;\n\n @media (width >= 48rem) {\n --sidebar-border-width: var(--border);\n --sidebar-padding: var(--size-2);\n --sidebar-width: var(--max-i-3xs);\n }\n}\n\n.header-layout {\n display: grid;\n grid-template-areas: \"header\" \"main\";\n grid-template-rows: auto 1fr;\n block-size: 100dvh;\n}\n\n.centered-layout {\n display: grid;\n place-items: center;\n block-size: 100dvh;\n}\n\n.container {\n inline-size: 100%;\n margin-inline: auto;\n max-inline-size: var(--container-width, 80rem);\n}\n\n#header {\n align-items: center;\n background-color: rgb(from var(--color-border-light) r g b / .5);\n border-block-end-width: var(--border);\n block-size: var(--size-16);\n column-gap: var(--size-4);\n display: flex;\n grid-area: header;\n padding-inline: var(--size-4);\n}\n\n#sidebar {\n background-color: rgb(from var(--color-border-light) r g b / .5);\n border-inline-end-width: var(--sidebar-border-width, 0);\n display: flex;\n flex-direction: column;\n grid-area: sidebar;\n overflow-x: hidden;\n padding: var(--sidebar-padding, 0);\n row-gap: var(--size-2);\n}\n\n#main {\n display: flex;\n flex-direction: column;\n gap: var(--size-4);\n grid-area: main;\n overflow: auto;\n padding: var(--size-4);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/menu.css */\n.menu {\n display: flex;\n flex-direction: column;\n padding: var(--size-1);\n row-gap: var(--size-1);\n}\n\n.menu__header {\n font-size: var(--text-sm);\n font-weight: var(--font-semibold);\n padding: var(--size-1_5) var(--size-2);\n}\n\n.menu__group {\n display: flex;\n flex-direction: column;\n row-gap: 1px;\n}\n\n.menu__separator {\n margin-inline: -0.25rem;\n}\n\n.menu__item {\n --btn-border-color: transparent;\n --btn-box-shadow: none;\n --btn-font-weight: var(--font-normal);\n --btn-hover-color: var(--color-secondary);\n --btn-justify-content: start;\n --btn-outline-size: 0;\n --btn-padding: var(--size-1_5) var(--size-2);\n --btn-text-align: start;\n\n &:focus-visible {\n --btn-background: var(--color-secondary);\n }\n}\n\n.menu__item-key {\n color: var(--color-text-subtle);\n font-size: var(--text-xs);\n margin-inline-start: auto;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/popover.css */\n.popover {\n background-color: var(--color-bg);\n border-radius: var(--rounded-md);\n border-width: var(--border);\n box-shadow: var(--shadow-md);\n color: var(--color-text);\n inline-size: var(--popover-size, max-content);\n\n /* Final state of exit animation and setup */\n opacity: 0;\n transform: var(--scale-95);\n transition-behavior: allow-discrete;\n transition-duration: var(--time-150);\n transition-property: display, overlay, opacity, transform;\n\n /* Final state of entry animation */\n &:popover-open {\n opacity: 1; transform: var(--scale-100);\n }\n\n /* Initial state of entry animation */\n @starting-style {\n &:popover-open {\n opacity: 0; transform: var(--scale-95);\n }\n }\n\n /* Positioning rules for Floating UI */\n &.positioned {\n position: fixed !important;\n left: var(--popover-x, 0) !important;\n top: var(--popover-y, 0) !important;\n margin: 0 !important;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/prose.css */\n.prose {\n font-size: var(--text-fluid-base);\n max-inline-size: 65ch;\n\n /* Antialiased fonts */\n -moz-osx-font-smoothing: grayscale;\n -webkit-font-smoothing: antialiased;\n\n :is(h1, h2, h3, h4, h5, h6) {\n font-weight: var(--font-extrabold);\n hyphens: auto;\n letter-spacing: -0.02ch;\n line-height: 1.1;\n margin-block: 0.5em;\n overflow-wrap: break-word;\n text-wrap: balance;\n }\n\n h1 {\n font-size: 2.4em;\n }\n\n h2 {\n font-size: 1.8em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.2em;\n }\n\n h5 {\n font-size: 1em;\n }\n\n h6 {\n font-size: 0.8em;\n }\n\n :is(ul, ol, menu) {\n list-style: revert;\n padding-inline-start: revert;\n }\n\n :is(p, ul, ol, dl, blockquote, pre, figure, table, hr) {\n margin-block: 0.65lh;\n overflow-wrap: break-word;\n text-wrap: pretty;\n }\n\n hr {\n border-color: var(--color-border-dark);\n border-style: var(--border-style, solid) none none;\n margin: 2lh auto;\n }\n\n :is(b, strong) {\n font-weight: var(--font-bold);\n }\n\n :is(pre, code) {\n background-color: var(--color-border-light);\n border: 1px solid var(--color-border);\n border-radius: var(--rounded-sm);\n font-family: var(--font-monospace-code);\n font-size: 0.85em;\n }\n\n code {\n padding: 0.1em 0.3em;\n }\n\n pre {\n border-radius: 0.5em;\n overflow-x: auto;\n padding: 0.5lh 2ch;\n text-wrap: nowrap;\n }\n\n pre code {\n background-color: transparent;\n border: 0;\n font-size: 1em;\n padding: 0;\n }\n\n p {\n hyphens: auto;\n letter-spacing: -0.005ch;\n }\n\n blockquote {\n font-style: italic;\n margin: 0 3ch;\n }\n\n blockquote p {\n hyphens: none;\n }\n\n table {\n border: 1px solid var(--color-border-dark);\n border-collapse: collapse;\n margin: 1lh 0;\n }\n\n th {\n font-weight: var(--font-bold);\n }\n\n :is(th, td) {\n border: 1px solid var(--color-border-dark);\n padding: 0.2lh 1ch;\n text-align: start;\n }\n\n th {\n border-block-end-width: 3px;\n }\n\n del {\n background-color: rgb(from var(--color-negative) r g b / .1);\n color: var(--color-negative);\n }\n\n ins {\n background-color: rgb(from var(--color-positive) r g b / .1);\n color: var(--color-positive);\n }\n\n a {\n color: var(--color-link);\n text-decoration: underline;\n text-decoration-skip-ink: auto;\n }\n\n mark {\n color: var(--color-text);\n background-color: var(--color-highlight);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/row.css */\n.row {\n display: flex;\n justify-content: space-between;\n width: 100%;\n gap: var(--column-gap, 0.5rem);\n align-items: stretch;\n}\n\n.row > * {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n}\n\n/* Ensure metric cards and their panels stretch to full height */\n.row > .grid-item {\n display: flex;\n flex-direction: column;\n}\n\n.row > .grid-item > * {\n flex: 1;\n}\n\n/* Responsive layout for screens smaller than 768px */\n@media (max-width: 768px) {\n .row {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n gap: 0.5rem;\n align-items: flex-start;\n }\n\n .row > * {\n flex: 0 0 calc(50% - 0.25rem);\n min-width: 0;\n height: auto;\n }\n\n .row > .grid-item {\n height: auto;\n }\n\n .row > .grid-item > * {\n flex: none;\n }\n\n /* Tables should stack in single column on mobile */\n .row:has(.table-container) > * {\n flex: 0 0 100%;\n }\n\n /* Single column for very small screens */\n @media (max-width: 480px) {\n .row > * {\n flex: 0 0 100%;\n }\n\n .row > .grid-item {\n min-height: auto;\n }\n\n /* Make metric cards more compact on mobile */\n .row > .grid-item .card {\n padding: var(--size-3);\n }\n\n /* Make charts smaller on mobile */\n .row > .grid-item .chart-container {\n height: 60px !important;\n max-height: 60px;\n }\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/sidebar_menu.css */\n.sidebar-menu {\n display: flex;\n flex-direction: column;\n row-gap: var(--size-4);\n block-size: var(--size-full);\n}\n\n.sidebar-menu__button {\n --btn-background: transparent;\n --btn-border-color: transparent;\n --btn-box-shadow: none;\n --btn-font-weight: var(--font-normal);\n --btn-hover-color: var(--color-secondary);\n --btn-justify-content: start;\n --btn-outline-size: 0;\n --btn-inline-size: var(--size-full);\n --btn-padding: var(--size-1) var(--size-2);\n --btn-text-align: start;\n\n &:focus-visible {\n --btn-background: var(--color-secondary);\n }\n\n &:is(summary) {\n &::after {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m9 18 6-6-6-6'/%3e%3c/svg%3e\");\n background-size: cover;\n block-size: var(--size-4);\n content: \"\";\n filter: var(--color-filter-text);\n inline-size: var(--size-4);\n margin-inline-start: auto;\n min-inline-size: var(--size-4);\n transition: transform var(--time-200);\n }\n\n details[open] > &::after {\n transform: var(--rotate-90);\n }\n\n &::-webkit-details-marker {\n display: none;\n }\n }\n}\n\n.sidebar-menu__content {\n display: flex;\n flex-direction: column;\n row-gap: var(--size-4);\n overflow-y: scroll;\n}\n\n.sidebar-menu__group {\n display: flex;\n flex-direction: column;\n}\n\n.sidebar-menu__group-label {\n color: var(--color-text-subtle);\n font-size: var(--text-xs);\n font-weight: var(--font-medium);\n padding: var(--size-1_5) var(--size-2);\n}\n\n.sidebar-menu__items {\n display: flex;\n flex-direction: column;\n row-gap: var(--size-1);\n}\n\n.sidebar-menu__sub {\n border-inline-start-width: var(--border);\n display: flex;\n flex-direction: column;\n margin-inline-start: var(--size-4);\n padding: var(--size-0_5) var(--size-2);\n row-gap: var(--size-1);\n}\n\n/* Sheet component styles for mobile menu */\n.sheet {\n border: 0;\n background: var(--color-bg);\n max-block-size: none;\n max-inline-size: none;\n padding: 0;\n}\n\n.sheet--left {\n block-size: 100vh;\n inline-size: var(--sheet-size, 288px);\n inset-block-start: 0;\n inset-inline-start: 0;\n}\n\n.sheet__content {\n block-size: 100%;\n display: flex;\n flex-direction: column;\n overflow-y: auto;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/skeleton.css */\n.skeleton {\n animation: var(--animate-blink);\n border-radius: var(--rounded-md);\n background-color: var(--color-border-light);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/switch.css */\n.switch {\n appearance: none;\n background-color: var(--color-border);\n border-color: transparent;\n border-radius: var(--rounded-full);\n border-width: var(--border-2);\n block-size: var(--size-5);\n inline-size: var(--size-9);\n transition: background-color var(--time-150);\n\n &:checked {\n background-color: var(--color-primary);\n }\n\n &:checked::before {\n margin-inline-start: var(--size-4);\n }\n\n &::before {\n aspect-ratio: var(--aspect-square);\n background-color: var(--color-text-reversed);\n block-size: var(--size-full);\n border-radius: var(--rounded-full);\n content: \"\";\n display: block;\n transition: margin var(--time-150);\n }\n\n &:focus-visible {\n outline: var(--border-2) solid var(--color-selected-dark);\n }\n\n &:disabled {\n cursor: not-allowed; opacity: var(--opacity-50);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/table.css */\n:where(.table) {\n caption-side: bottom;\n font-size: var(--text-sm);\n inline-size: var(--size-full);\n\n caption {\n color: var(--color-text-subtle);\n margin-block-start: var(--size-4);\n }\n\n thead {\n color: var(--color-text-subtle);\n }\n\n tbody tr {\n border-block-start-width: var(--border);\n }\n\n tr:hover {\n background-color: rgb(from var(--color-border-light) r g b / .5);\n }\n\n th {\n font-weight: var(--font-medium);\n text-align: start;\n }\n\n th, td {\n padding: var(--size-2);\n }\n\n tfoot {\n background-color: rgb(from var(--color-border-light) r g b / .5);\n border-block-start-width: var(--border);\n font-weight: var(--font-medium);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/tags.css */\n/* Tag Manager Container */\n.breadcrumb-container {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n flex-wrap: wrap;\n}\n\n.breadcrumb-tags {\n display: flex;\n align-items: center;\n}\n\n/* Tag Manager */\n.tag-manager {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n.tag-list {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n flex-wrap: wrap;\n}\n\n/* Individual Tag */\n.tag {\n display: inline-flex;\n align-items: center;\n gap: 0.25rem;\n padding: 0.25rem 0.5rem;\n background-color: var(--color-background-secondary);\n border: 1px solid var(--color-border);\n border-radius: 0.375rem;\n font-size: 0.875rem;\n line-height: 1.25;\n white-space: nowrap;\n}\n\n/* Tag Remove Button */\n.tag-remove {\n all: unset;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1rem;\n height: 1rem;\n padding: 0;\n margin: 0;\n background: none;\n border: none;\n cursor: pointer;\n color: currentColor;\n opacity: 0.6;\n transition: opacity 0.15s ease;\n}\n\n.tag-remove:hover {\n opacity: 1;\n}\n\n.tag-remove span {\n font-size: 1.25rem;\n line-height: 1;\n font-weight: bold;\n}\n\n/* Add Tag Container */\n.tag-add-container {\n position: relative;\n display: inline-block;\n}\n\n.tag-add-button {\n padding: 0.2rem 0.5rem;\n font-size: 0.8rem;\n line-height: 1.25;\n white-space: nowrap;\n}\n\n/* Responsive Design */\n@media (max-width: 768px) {\n .breadcrumb-container {\n flex-direction: column;\n align-items: flex-start;\n }\n\n .breadcrumb-tags {\n width: 100%;\n }\n\n .tag-manager {\n width: 100%;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/utilities.css */\n/* Width utilities */\n.w-auto { width: auto; }\n.w-4 { width: 1rem; }\n.w-6 { width: 1.5rem; }\n.w-8 { width: 2rem; }\n.w-12 { width: 3rem; }\n.w-16 { width: 4rem; }\n.w-20 { width: 5rem; }\n.w-24 { width: 6rem; }\n.w-28 { width: 7rem; }\n.w-32 { width: 8rem; }\n.w-36 { width: 9rem; }\n.w-40 { width: 10rem; }\n.w-44 { width: 11rem; }\n.w-48 { width: 12rem; }\n.w-52 { width: 13rem; }\n.w-56 { width: 14rem; }\n.w-60 { width: 15rem; }\n.w-64 { width: 16rem; }\n\n/* Min-width utilities */\n.min-w-0 { min-width: 0; }\n.min-w-4 { min-width: 1rem; }\n.min-w-8 { min-width: 2rem; }\n.min-w-12 { min-width: 3rem; }\n.min-w-16 { min-width: 4rem; }\n.min-w-20 { min-width: 5rem; }\n.min-w-24 { min-width: 6rem; }\n.min-w-32 { min-width: 8rem; }\n\n/* Max-width utilities */\n.max-w-xs { max-width: 20rem; }\n.max-w-sm { max-width: 24rem; }\n.max-w-md { max-width: 28rem; }\n.max-w-lg { max-width: 32rem; }\n.max-w-xl { max-width: 36rem; }\n\n/* Global filters active indicator */\n.global-filters-active {\n position: relative;\n}\n\n.global-filters-active::after {\n content: \"\";\n position: absolute;\n top: -2px;\n right: -2px;\n width: 8px;\n height: 8px;\n background-color: var(--color-primary);\n border-radius: 50%;\n border: 2px solid var(--color-bg);\n}\n\n/* Flatpickr z-index fix - ensure calendar appears above dialogs */\n.flatpickr-calendar,\n.flatpickr-calendar.open,\n.flatpickr-calendar.inline,\n.flatpickr-calendar.static,\n.flatpickr-calendar.static.open {\n z-index: 999999 !important;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/application.css */\n* {\n font-family: AvenirNextPro, sans-serif\n}\n\na {\n text-decoration: underline;\n color: var(--color-link);\n}\n\n#header {\n background-color: var(--header-bg);\n}\n\n#header a {\n color: var(--header-link);\n text-decoration: none;\n}\n\n#header a:hover {\n background-color: transparent;\n text-decoration: underline;\n}\n\na:hover {\n cursor: pointer;\n}\n\n/* Dark mode */\n\n/* Dark scheme tweaks via component variables */\nhtml[data-color-scheme=\"dark\"] .card {\n /* Scope card surfaces slightly darker than page */\n --color-bg: rgb(47, 47, 47);\n --color-border: rgb(64, 64, 64);\n}\n\n/* Header colors are handled by --header-* tokens in base.css */\n\nhtml[data-color-scheme=\"dark\"] .badge--positive-inverse,\nhtml[data-color-scheme=\"dark\"] .badge--negative-inverse {\n --badge-background: rgb(47, 47, 47);\n}\n\nhtml[data-color-scheme=\"dark\"] .input {\n --input-background: #535252;\n --input-border-color: #7e7d7d;\n}\n\n.hidden {\n display: none;\n}\n\n/* REQUEST OPERATIONS GRAPH */\n.operations-table {\n width: 100%;\n}\n\n.operations-table tr {\n cursor: pointer;\n}\n\n.operations-label-cell {\n width: 380px;\n max-width: 380px;\n min-width: 120px;\n padding-right: 10px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n vertical-align: middle;\n}\n.operations-label-cell span {\n font-family: 'Times New Roman', Times, serif;\n}\n\n.operations-duration-cell {\n width: 60px;\n max-width: 100px;\n}\n\n.operations-event-cell {\n position: relative;\n background: none;\n padding: 0;\n}\n\n.operations-event {\n box-sizing: border-box;\n height: 16px;\n padding: 2px;\n position: absolute;\n top: 20px;\n}\n\n/* REQUEST OPERATIONS BAR */\n.bar-container {\n height:10px;\n position:relative\n}\n.bar {\n background-color:#727579;\n height:100%;\n position:absolute;\n top:0\n}\n.bar:first-child {\n border-bottom-left-radius:1px;\n border-top-left-radius:1px\n}\n.bar:last-child {\n border-bottom-right-radius:1px;\n border-top-right-radius:1px\n}\n\n\n/* vendor/css-zero/utilities.css */\n/****************************************************************\n* Flex\n*****************************************************************/\n.flex { display: flex; }\n.flex-col { flex-direction: column; }\n.flex-wrap { flex-wrap: wrap; }\n.inline-flex { display: inline-flex; }\n\n.justify-start { justify-content: start; }\n.justify-center { justify-content: center; }\n.justify-end { justify-content: end; }\n.justify-between { justify-content: space-between; }\n\n.items-start { align-items: start; }\n.items-end { align-items: end; }\n.items-center { align-items: center; }\n\n.grow { flex-grow: 1; }\n.grow-0\t{ flex-grow: 0; }\n\n.shrink { flex-shrink: 1; }\n.shrink-0 { flex-shrink: 0; }\n\n.self-start { align-self: start; }\n.self-end { align-self: end; }\n.self-center { align-self: center; }\n\n.gap { column-gap: var(--column-gap, 0.5rem); row-gap: var(--row-gap, 1rem); }\n.gap-half { column-gap: 0.25rem; row-gap: 0.5rem; }\n\n/****************************************************************\n* Text\n*****************************************************************/\n.font-normal { font-weight: var(--font-normal); }\n.font-medium { font-weight: var(--font-medium); }\n.font-semibold { font-weight: var(--font-semibold); }\n.font-bold { font-weight: var(--font-bold); }\n\n.underline { text-decoration: underline; }\n.no-underline\t{ text-decoration: none; }\n\n.uppercase { text-transform: uppercase; }\n.normal-case { text-transform: none; }\n\n.whitespace-nowrap { white-space: nowrap; }\n.whitespace-normal { white-space: normal; }\n\n.break-words { overflow-wrap: break-word; }\n.break-all { word-break: break-all; }\n\n.overflow-clip { text-overflow: clip; white-space: nowrap; overflow: hidden; }\n.overflow-ellipsis { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }\n\n.opacity-75 { opacity: var(--opacity-75); }\n.opacity-50 { opacity: var(--opacity-50); }\n\n.leading-none { line-height: var(--leading-none); }\n.leading-tight { line-height: var(--leading-tight); }\n\n.text-start { text-align: start; }\n.text-end { text-align: end; }\n.text-center { text-align: center; }\n\n.text-primary { color: var(--color-text); }\n.text-reversed { color: var(--color-text-reversed); }\n.text-negative { color: var(--color-negative); }\n.text-positive { color: var(--color-positive); }\n.text-subtle { color: var(--color-text-subtle); }\n\n.text-xs { font-size: var(--text-xs); }\n.text-sm { font-size: var(--text-sm); }\n.text-base { font-size: var(--text-base); }\n.text-lg { font-size: var(--text-lg); }\n.text-xl { font-size: var(--text-xl); }\n.text-2xl { font-size: var(--text-2xl); }\n.text-3xl { font-size: var(--text-3xl); }\n.text-4xl { font-size: var(--text-4xl); }\n.text-5xl { font-size: var(--text-5xl); }\n\n.text-fluid-xs { font-size: var(--text-fluid-xs); }\n.text-fluid-sm { font-size: var(--text-fluid-sm); }\n.text-fluid-base { font-size: var(--text-fluid-base); }\n.text-fluid-lg { font-size: var(--text-fluid-lg); }\n.text-fluid-xl { font-size: var(--text-fluid-xl); }\n.text-fluid-2xl { font-size: var(--text-fluid-2xl); }\n.text-fluid-3xl { font-size: var(--text-fluid-3xl); }\n\n/****************************************************************\n* Background\n*****************************************************************/\n.bg-main { background-color: var(--color-bg); }\n.bg-black { background-color: var(--color-text); }\n.bg-white { background-color: var(--color-text-reversed); }\n.bg-shade { background-color: var(--color-border-light); }\n.bg-transparent { background-color: transparent; }\n\n/****************************************************************\n* SVG colors\n*****************************************************************/\n.colorize-black { filter: var(--color-filter-text); }\n.colorize-white { filter: var(--color-filter-text-reversed); }\n.colorize-negative { filter: var(--color-filter-negative); }\n.colorize-positive { filter: var(--color-filter-positive); }\n\n/****************************************************************\n* Border\n*****************************************************************/\n.border-0 { border-width: 0; }\n.border { border-width: var(--border-size, 1px); }\n\n.border-b { border-block-width: var(--border-size, 1px); }\n.border-bs { border-block-start-width: var(--border-size, 1px); }\n.border-be { border-block-end-width: var(--border-size, 1px); }\n\n.border-i { border-inline-width: var(--border-size, 1px); }\n.border-is { border-inline-start-width: var(--border-size, 1px); }\n.border-ie { border-inline-end-width: var(--border-size, 1px); }\n\n.border-main { border-color: var(--color-border); }\n.border-dark { border-color: var(--color-border-dark); }\n\n.rounded-none { border-radius: 0; }\n.rounded-xs { border-radius: var(--rounded-xs); }\n.rounded-sm { border-radius: var(--rounded-sm); }\n.rounded-md { border-radius: var(--rounded-md); }\n.rounded-lg { border-radius: var(--rounded-lg); }\n.rounded-full { border-radius: var(--rounded-full); }\n\n/****************************************************************\n* Shadow\n*****************************************************************/\n.shadow-none { box-shadow: none; }\n.shadow-xs { box-shadow: var(--shadow-xs); }\n.shadow-sm { box-shadow: var(--shadow-sm); }\n.shadow-md { box-shadow: var(--shadow-md); }\n.shadow-lg { box-shadow: var(--shadow-lg); }\n\n/****************************************************************\n* Layout\n*****************************************************************/\n.block { display: block; }\n.inline { display: inline; }\n.inline-block { display: inline-block; }\n\n.relative { position: relative; }\n.sticky\t{ position: sticky; }\n\n.min-i-0 { min-inline-size: 0; }\n.max-i-none { max-inline-size: none; }\n.max-i-full { max-inline-size: 100%; }\n\n.b-full { block-size: 100%; }\n.i-full { inline-size: 100%; }\n\n.i-min { inline-size: min-content; }\n\n.overflow-x-auto { overflow-x: auto; scroll-snap-type: x mandatory; }\n.overflow-y-auto { overflow-y: auto; scroll-snap-type: y mandatory; }\n.overflow-hidden { overflow: hidden; }\n\n.object-contain\t{ object-fit: contain; }\n.object-cover {\tobject-fit: cover; }\n\n.aspect-square { aspect-ratio: 1; }\n.aspect-widescreen { aspect-ratio: 16 / 9; }\n\n/****************************************************************\n* Margin\n*****************************************************************/\n.m-0 { margin: 0; }\n.m-1 { margin: var(--size-1); }\n.m-2 { margin: var(--size-2); }\n.m-3 { margin: var(--size-3); }\n.m-4 { margin: var(--size-4); }\n.m-5 { margin: var(--size-5); }\n.m-6 { margin: var(--size-6); }\n.m-8 { margin: var(--size-8); }\n.m-10 { margin: var(--size-10); }\n.m-auto { margin: auto; }\n\n.mb-0 { margin-block: 0; }\n.mb-1 { margin-block: var(--size-1); }\n.mb-2 { margin-block: var(--size-2); }\n.mb-3 { margin-block: var(--size-3); }\n.mb-4 { margin-block: var(--size-4); }\n.mb-5 { margin-block: var(--size-5); }\n.mb-6 { margin-block: var(--size-6); }\n.mb-8 { margin-block: var(--size-8); }\n.mb-10 { margin-block: var(--size-10); }\n.mb-auto { margin-block: auto; }\n\n.mbs-0 { margin-block-start: 0; }\n.mbs-1 { margin-block-start: var(--size-1); }\n.mbs-2 { margin-block-start: var(--size-2); }\n.mbs-3 { margin-block-start: var(--size-3); }\n.mbs-4 { margin-block-start: var(--size-4); }\n.mbs-5 { margin-block-start: var(--size-5); }\n.mbs-6 { margin-block-start: var(--size-6); }\n.mbs-8 { margin-block-start: var(--size-8); }\n.mbs-10 { margin-block-start: var(--size-10); }\n.mbs-auto { margin-block-start: auto; }\n\n.mbe-0 { margin-block-end: 0; }\n.mbe-1 { margin-block-end: var(--size-1); }\n.mbe-2 { margin-block-end: var(--size-2); }\n.mbe-3 { margin-block-end: var(--size-3); }\n.mbe-4 { margin-block-end: var(--size-4); }\n.mbe-5 { margin-block-end: var(--size-5); }\n.mbe-6 { margin-block-end: var(--size-6); }\n.mbe-8 { margin-block-end: var(--size-8); }\n.mbe-10 { margin-block-end: var(--size-10); }\n.mbe-auto { margin-block-end: auto; }\n\n.mi-0 { margin-inline: 0; }\n.mi-1 { margin-inline: var(--size-1); }\n.mi-2 { margin-inline: var(--size-2); }\n.mi-3 { margin-inline: var(--size-3); }\n.mi-4 { margin-inline: var(--size-4); }\n.mi-5 { margin-inline: var(--size-5); }\n.mi-6 { margin-inline: var(--size-6); }\n.mi-8 { margin-inline: var(--size-8); }\n.mi-10 { margin-inline: var(--size-10); }\n.mi-auto { margin-inline: auto; }\n\n.mis-0 { margin-inline-start: 0; }\n.mis-1 { margin-inline-start: var(--size-1); }\n.mis-2 { margin-inline-start: var(--size-2); }\n.mis-3 { margin-inline-start: var(--size-3); }\n.mis-4 { margin-inline-start: var(--size-4); }\n.mis-5 { margin-inline-start: var(--size-5); }\n.mis-6 { margin-inline-start: var(--size-6); }\n.mis-8 { margin-inline-start: var(--size-8); }\n.mis-10 { margin-inline-start: var(--size-10); }\n.mis-auto { margin-inline-start: auto; }\n\n.mie-0 { margin-inline-end: 0; }\n.mie-1 { margin-inline-end: var(--size-1); }\n.mie-2 { margin-inline-end: var(--size-2); }\n.mie-3 { margin-inline-end: var(--size-3); }\n.mie-4 { margin-inline-end: var(--size-4); }\n.mie-5 { margin-inline-end: var(--size-5); }\n.mie-6 { margin-inline-end: var(--size-6); }\n.mie-8 { margin-inline-end: var(--size-8); }\n.mie-10 { margin-inline-end: var(--size-10); }\n.mie-auto { margin-inline-end: auto; }\n\n/****************************************************************\n* Padding\n*****************************************************************/\n.p-0 { padding: 0; }\n.p-1 { padding: var(--size-1); }\n.p-2 { padding: var(--size-2); }\n.p-3 { padding: var(--size-3); }\n.p-4 { padding: var(--size-4); }\n.p-5 { padding: var(--size-5); }\n.p-6 { padding: var(--size-6); }\n.p-8 { padding: var(--size-8); }\n.p-10 { padding: var(--size-10); }\n\n.pb-0 { padding-block: 0; }\n.pb-1 { padding-block: var(--size-1); }\n.pb-2 { padding-block: var(--size-2); }\n.pb-3 { padding-block: var(--size-3); }\n.pb-4 { padding-block: var(--size-4); }\n.pb-5 { padding-block: var(--size-5); }\n.pb-6 { padding-block: var(--size-6); }\n.pb-8 { padding-block: var(--size-8); }\n.pb-10 { padding-block: var(--size-10); }\n\n.pbs-0 { padding-block-start: 0; }\n.pbs-1 { padding-block-start: var(--size-1); }\n.pbs-2 { padding-block-start: var(--size-2); }\n.pbs-3 { padding-block-start: var(--size-3); }\n.pbs-4 { padding-block-start: var(--size-4); }\n.pbs-5 { padding-block-start: var(--size-5); }\n.pbs-6 { padding-block-start: var(--size-6); }\n.pbs-8 { padding-block-start: var(--size-8); }\n.pbs-10 { padding-block-start: var(--size-10); }\n\n.pbe-0 { padding-block-end: 0; }\n.pbe-1 { padding-block-end: var(--size-1); }\n.pbe-2 { padding-block-end: var(--size-2); }\n.pbe-3 { padding-block-end: var(--size-3); }\n.pbe-4 { padding-block-end: var(--size-4); }\n.pbe-5 { padding-block-end: var(--size-5); }\n.pbe-6 { padding-block-end: var(--size-6); }\n.pbe-8 { padding-block-end: var(--size-8); }\n.pbe-10 { padding-block-end: var(--size-10); }\n\n.pi-0 { padding-inline: 0; }\n.pi-1 { padding-inline: var(--size-1); }\n.pi-2 { padding-inline: var(--size-2); }\n.pi-3 { padding-inline: var(--size-3); }\n.pi-4 { padding-inline: var(--size-4); }\n.pi-5 { padding-inline: var(--size-5); }\n.pi-6 { padding-inline: var(--size-6); }\n.pi-8 { padding-inline: var(--size-8); }\n.pi-10 { padding-inline: var(--size-10); }\n\n.pis-0 { padding-inline-start: 0; }\n.pis-1 { padding-inline-start: var(--size-1); }\n.pis-2 { padding-inline-start: var(--size-2); }\n.pis-3 { padding-inline-start: var(--size-3); }\n.pis-4 { padding-inline-start: var(--size-4); }\n.pis-5 { padding-inline-start: var(--size-5); }\n.pis-6 { padding-inline-start: var(--size-6); }\n.pis-8 { padding-inline-start: var(--size-8); }\n.pis-10 { padding-inline-start: var(--size-10); }\n\n.pie-0 { padding-inline-end: 0; }\n.pie-1 { padding-inline-end: var(--size-1); }\n.pie-2 { padding-inline-end: var(--size-2); }\n.pie-3 { padding-inline-end: var(--size-3); }\n.pie-4 { padding-inline-end: var(--size-4); }\n.pie-5 { padding-inline-end: var(--size-5); }\n.pie-6 { padding-inline-end: var(--size-6); }\n.pie-8 { padding-inline-end: var(--size-8); }\n.pie-10 { padding-inline-end: var(--size-10); }\n\n/****************************************************************\n* Hiding/Showing\n*****************************************************************/\n.show\\@sm, .show\\@md, .show\\@lg, .show\\@xl { display: none; }\n\n.show\\@sm { @media (width >= 40rem) { display: flex; } }\n.show\\@md { @media (width >= 48rem) { display: flex; } }\n.show\\@lg { @media (width >= 64rem) { display: flex; } }\n.show\\@xl { @media (width >= 80rem) { display: flex; } }\n\n.hide\\@sm { @media (width >= 40rem) { display: none; } }\n.hide\\@md { @media (width >= 48rem) { display: none; } }\n.hide\\@lg { @media (width >= 64rem) { display: none; } }\n.hide\\@xl { @media (width >= 80rem) { display: none; } }\n\n.hide\\@pwa { @media (display-mode: standalone) { display: none; } }\n.hide\\@browser { @media (display-mode: browser) { display: none; } }\n\n.hide\\@print { @media print { display: none; } }\n\n/****************************************************************\n* Accessibility\n*****************************************************************/\n.sr-only { block-size: 1px; clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap; }\n\n"]} \ No newline at end of file +{"version":3,"sources":["../../rails-pulse.css"],"names":[],"mappings":"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","file":"rails-pulse.css","sourcesContent":["\n/* vendor/css-zero/reset.css */\n/*\n 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)\n 2. Remove default margins and padding\n 3. Reset all borders.\n*/\n\n*,\n::after,\n::before,\n::backdrop,\n::file-selector-button {\n box-sizing: border-box; /* 1 */\n margin: 0; /* 2 */\n padding: 0; /* 2 */\n border: 0 solid; /* 3 */\n}\n\n/*\n 1. Use a consistent sensible line-height in all browsers.\n 2. Prevent adjustments of font size after orientation changes in iOS.\n 3. Use a more readable tab size.\n 4. Use the user's configured `sans` font-family by default.\n 5. Use the user's configured `sans` font-feature-settings by default.\n 6. Use the user's configured `sans` font-variation-settings by default.\n 7. Disable tap highlights on iOS.\n*/\n\nhtml,\n:host {\n line-height: 1.5; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n tab-size: 4; /* 3 */\n font-family: var(--default-font-family, system-ui, sans-serif); /* 4 */\n font-feature-settings: var(--default-font-feature-settings, normal); /* 5 */\n font-variation-settings: var(--default-font-variation-settings, normal); /* 6 */\n -webkit-tap-highlight-color: transparent; /* 7 */\n}\n\n/*\n Inherit line-height from `html` so users can set them as a class directly on the `html` element.\n*/\n\nbody {\n line-height: inherit;\n}\n\n/*\n 1. Add the correct height in Firefox.\n 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)\n 3. Reset the default border style to a 1px solid border.\n*/\n\nhr {\n block-size: 0; /* 1 */\n color: inherit; /* 2 */\n border-block-start-width: 1px; /* 3 */\n}\n\n/*\n Add the correct text decoration in Chrome, Edge, and Safari.\n*/\n\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\n\n/*\n Remove the default font size and weight for headings.\n*/\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n font-size: inherit;\n font-weight: inherit;\n}\n\n/*\n Reset links to optimize for opt-in styling instead of opt-out.\n*/\n\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\n\n/*\n Add the correct font weight in Edge and Safari.\n*/\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/*\n 1. Use the user's configured `mono` font-family by default.\n 2. Use the user's configured `mono` font-feature-settings by default.\n 3. Use the user's configured `mono` font-variation-settings by default.\n 4. Correct the odd `em` font sizing in all browsers.\n*/\n\ncode,\nkbd,\nsamp,\npre {\n font-family: var(--default-mono-font-family, ui-monospace, monospace); /* 4 */\n font-feature-settings: var(--default-mono-font-feature-settings, normal); /* 5 */\n font-variation-settings: var(--default-mono-font-variation-settings, normal); /* 6 */\n font-size: 1em; /* 4 */\n}\n\n/*\n Add the correct font size in all browsers.\n*/\n\nsmall {\n font-size: 80%;\n}\n\n/*\n Prevent `sub` and `sup` elements from affecting the line height in all browsers.\n*/\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n inset-block-end: -0.25em;\n}\n\nsup {\n inset-block-start: -0.5em;\n}\n\n/*\n 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)\n 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)\n 3. Remove gaps between table borders by default.\n*/\n\ntable {\n text-indent: 0; /* 1 */\n border-color: inherit; /* 2 */\n border-collapse: collapse; /* 3 */\n}\n\n/*\n Use the modern Firefox focus style for all focusable elements.\n*/\n\n:-moz-focusring {\n outline: auto;\n}\n\n/*\n Add the correct vertical alignment in Chrome and Firefox.\n*/\n\nprogress {\n vertical-align: baseline;\n}\n\n/*\n Add the correct display in Chrome and Safari.\n*/\n\nsummary {\n display: list-item;\n}\n\n/*\n Make lists unstyled by default.\n*/\n\nol,\nul,\nmenu {\n list-style: none;\n}\n\n/*\n 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)\n 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)\n This can trigger a poorly considered lint error in some tools but is included by design.\n*/\n\nimg,\nsvg,\nvideo,\ncanvas,\naudio,\niframe,\nembed,\nobject {\n display: block; /* 1 */\n vertical-align: middle; /* 2 */\n}\n\n/*\n Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)\n*/\n\nimg,\nvideo {\n max-inline-size: 100%;\n block-size: auto;\n}\n\n/*\n 1. Inherit font styles in all browsers.\n 2. Remove border radius in all browsers.\n 3. Remove background color in all browsers.\n 4. Ensure consistent opacity for disabled states in all browsers.\n*/\n\nbutton,\ninput,\nselect,\noptgroup,\ntextarea,\n::file-selector-button {\n font: inherit; /* 1 */\n font-feature-settings: inherit; /* 1 */\n font-variation-settings: inherit; /* 1 */\n letter-spacing: inherit; /* 1 */\n color: inherit; /* 1 */\n border-radius: 0; /* 2 */\n background-color: transparent; /* 3 */\n opacity: 1; /* 4 */\n}\n\n/*\n Restore default font weight.\n*/\n\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n\n/*\n Restore indentation.\n*/\n\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n\n/*\n Restore space after button.\n*/\n\n::file-selector-button {\n margin-inline-end: 4px;\n}\n\n/*\n 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)\n 2. Set the default placeholder color to a semi-transparent version of the current text color.\n*/\n\n::placeholder {\n opacity: 1; /* 1 */\n color: color-mix(in oklab, currentColor 50%, transparent); /* 2 */\n}\n\n/*\n Prevent resizing textareas horizontally by default.\n*/\n\ntextarea {\n resize: vertical;\n}\n\n/*\n Remove the inner padding in Chrome and Safari on macOS.\n*/\n\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/*\n 1. Ensure date/time inputs have the same height when empty in iOS Safari.\n 2. Ensure text alignment can be changed on date/time inputs in iOS Safari.\n*/\n\n::-webkit-date-and-time-value {\n min-block-size: 1lh; /* 1 */\n text-align: inherit; /* 2 */\n}\n\n/*\n Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`.\n*/\n\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n\n/*\n Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers.\n*/\n\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n\n::-webkit-datetime-edit,\n::-webkit-datetime-edit-year-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-minute-field,\n::-webkit-datetime-edit-second-field,\n::-webkit-datetime-edit-millisecond-field,\n::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n\n/*\n Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)\n*/\n\n:-moz-ui-invalid {\n box-shadow: none;\n}\n\n/*\n Correct the inability to style the border radius in iOS Safari.\n*/\n\nbutton,\ninput:where([type='button'], [type='reset'], [type='submit']),\n::file-selector-button {\n appearance: button;\n}\n\n/*\n Correct the cursor style of increment and decrement buttons in Safari.\n*/\n\n::-webkit-inner-spin-button,\n::-webkit-outer-spin-button {\n block-size: auto;\n}\n\n/*\n Make elements with the HTML hidden attribute stay hidden by default.\n*/\n\n[hidden]:where(:not([hidden='until-found'])) {\n display: none !important;\n}\n\n/*\n Make elements with the HTML contents attribute become pseudo-box by default.\n*/\n\n[contents] {\n display: contents !important;\n}\n\n/*\n Make turbo frame become pseudo-box by default.\n*/\n\nturbo-frame {\n display: contents;\n}\n\n/*\n Enables size interpolation to allow animation.\n*/\n\n:root {\n interpolate-size: allow-keywords;\n}\n\n/*\n Set color scheme to light and dark.\n*/\n\n:root {\n color-scheme: light dark;\n}\n\n/*\n Correct the arrow style of datalist in Chrome.\n*/\n\n::-webkit-calendar-picker-indicator {\n line-height: 1em;\n}\n\n/*\n Restore space between options.\n*/\n\noption {\n padding: 2px 4px;\n}\n\n/*\n Prevent page scroll when modal dialog is open.\n*/\n\nhtml:has(dialog:modal[open]) {\n overflow: hidden;\n}\n\n/*\n Remove all animations and transitions for people that prefer not to see them\n*/\n\n@media (prefers-reduced-motion: reduce) {\n *, ::before, ::after, ::backdrop {\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.01ms !important;\n }\n}\n\n\n/* vendor/css-zero/colors.css */\n:root {\n --slate-50: oklch(0.984 0.003 247.858);\n --slate-100: oklch(0.968 0.007 247.896);\n --slate-200: oklch(0.929 0.013 255.508);\n --slate-300: oklch(0.869 0.022 252.894);\n --slate-400: oklch(0.704 0.04 256.788);\n --slate-500: oklch(0.554 0.046 257.417);\n --slate-600: oklch(0.446 0.043 257.281);\n --slate-700: oklch(0.372 0.044 257.287);\n --slate-800: oklch(0.279 0.041 260.031);\n --slate-900: oklch(0.208 0.042 265.755);\n --slate-950: oklch(0.129 0.042 264.695);\n\n --gray-50: oklch(0.985 0.002 247.839);\n --gray-100: oklch(0.967 0.003 264.542);\n --gray-200: oklch(0.928 0.006 264.531);\n --gray-300: oklch(0.872 0.01 258.338);\n --gray-400: oklch(0.707 0.022 261.325);\n --gray-500: oklch(0.551 0.027 264.364);\n --gray-600: oklch(0.446 0.03 256.802);\n --gray-700: oklch(0.373 0.034 259.733);\n --gray-800: oklch(0.278 0.033 256.848);\n --gray-900: oklch(0.21 0.034 264.665);\n --gray-950: oklch(0.13 0.028 261.692);\n\n --zinc-50: oklch(0.985 0 0);\n --zinc-100: oklch(0.967 0.001 286.375);\n --zinc-200: oklch(0.92 0.004 286.32);\n --zinc-300: oklch(0.871 0.006 286.286);\n --zinc-400: oklch(0.705 0.015 286.067);\n --zinc-500: oklch(0.552 0.016 285.938);\n --zinc-600: oklch(0.442 0.017 285.786);\n --zinc-700: oklch(0.37 0.013 285.805);\n --zinc-800: oklch(0.274 0.006 286.033);\n --zinc-900: oklch(0.21 0.006 285.885);\n --zinc-950: oklch(0.141 0.005 285.823);\n\n --neutral-50: oklch(0.985 0 0);\n --neutral-100: oklch(0.97 0 0);\n --neutral-200: oklch(0.922 0 0);\n --neutral-300: oklch(0.87 0 0);\n --neutral-400: oklch(0.708 0 0);\n --neutral-500: oklch(0.556 0 0);\n --neutral-600: oklch(0.439 0 0);\n --neutral-700: oklch(0.371 0 0);\n --neutral-800: oklch(0.269 0 0);\n --neutral-900: oklch(0.205 0 0);\n --neutral-950: oklch(0.145 0 0);\n\n --stone-50: oklch(0.985 0.001 106.423);\n --stone-100: oklch(0.97 0.001 106.424);\n --stone-200: oklch(0.923 0.003 48.717);\n --stone-300: oklch(0.869 0.005 56.366);\n --stone-400: oklch(0.709 0.01 56.259);\n --stone-500: oklch(0.553 0.013 58.071);\n --stone-600: oklch(0.444 0.011 73.639);\n --stone-700: oklch(0.374 0.01 67.558);\n --stone-800: oklch(0.268 0.007 34.298);\n --stone-900: oklch(0.216 0.006 56.043);\n --stone-950: oklch(0.147 0.004 49.25);\n\n --red-50: oklch(0.971 0.013 17.38);\n --red-100: oklch(0.936 0.032 17.717);\n --red-200: oklch(0.885 0.062 18.334);\n --red-300: oklch(0.808 0.114 19.571);\n --red-400: oklch(0.704 0.191 22.216);\n --red-500: oklch(0.637 0.237 25.331);\n --red-600: oklch(0.577 0.245 27.325);\n --red-700: oklch(0.505 0.213 27.518);\n --red-800: oklch(0.444 0.177 26.899);\n --red-900: oklch(0.396 0.141 25.723);\n --red-950: oklch(0.258 0.092 26.042);\n\n --orange-50: oklch(0.98 0.016 73.684);\n --orange-100: oklch(0.954 0.038 75.164);\n --orange-200: oklch(0.901 0.076 70.697);\n --orange-300: oklch(0.837 0.128 66.29);\n --orange-400: oklch(0.75 0.183 55.934);\n --orange-500: oklch(0.705 0.213 47.604);\n --orange-600: oklch(0.646 0.222 41.116);\n --orange-700: oklch(0.553 0.195 38.402);\n --orange-800: oklch(0.47 0.157 37.304);\n --orange-900: oklch(0.408 0.123 38.172);\n --orange-950: oklch(0.266 0.079 36.259);\n\n --amber-50: oklch(0.987 0.022 95.277);\n --amber-100: oklch(0.962 0.059 95.617);\n --amber-200: oklch(0.924 0.12 95.746);\n --amber-300: oklch(0.879 0.169 91.605);\n --amber-400: oklch(0.828 0.189 84.429);\n --amber-500: oklch(0.769 0.188 70.08);\n --amber-600: oklch(0.666 0.179 58.318);\n --amber-700: oklch(0.555 0.163 48.998);\n --amber-800: oklch(0.473 0.137 46.201);\n --amber-900: oklch(0.414 0.112 45.904);\n --amber-950: oklch(0.279 0.077 45.635);\n\n --yellow-50: oklch(0.987 0.026 102.212);\n --yellow-100: oklch(0.973 0.071 103.193);\n --yellow-200: oklch(0.945 0.129 101.54);\n --yellow-300: oklch(0.905 0.182 98.111);\n --yellow-400: oklch(0.852 0.199 91.936);\n --yellow-500: oklch(0.795 0.184 86.047);\n --yellow-600: oklch(0.681 0.162 75.834);\n --yellow-700: oklch(0.554 0.135 66.442);\n --yellow-800: oklch(0.476 0.114 61.907);\n --yellow-900: oklch(0.421 0.095 57.708);\n --yellow-950: oklch(0.286 0.066 53.813);\n\n --lime-50: oklch(0.986 0.031 120.757);\n --lime-100: oklch(0.967 0.067 122.328);\n --lime-200: oklch(0.938 0.127 124.321);\n --lime-300: oklch(0.897 0.196 126.665);\n --lime-400: oklch(0.841 0.238 128.85);\n --lime-500: oklch(0.768 0.233 130.85);\n --lime-600: oklch(0.648 0.2 131.684);\n --lime-700: oklch(0.532 0.157 131.589);\n --lime-800: oklch(0.453 0.124 130.933);\n --lime-900: oklch(0.405 0.101 131.063);\n --lime-950: oklch(0.274 0.072 132.109);\n\n --green-50: oklch(0.982 0.018 155.826);\n --green-100: oklch(0.962 0.044 156.743);\n --green-200: oklch(0.925 0.084 155.995);\n --green-300: oklch(0.871 0.15 154.449);\n --green-400: oklch(0.792 0.209 151.711);\n --green-500: oklch(0.723 0.219 149.579);\n --green-600: oklch(0.627 0.194 149.214);\n --green-700: oklch(0.527 0.154 150.069);\n --green-800: oklch(0.448 0.119 151.328);\n --green-900: oklch(0.393 0.095 152.535);\n --green-950: oklch(0.266 0.065 152.934);\n\n --emerald-50: oklch(0.979 0.021 166.113);\n --emerald-100: oklch(0.95 0.052 163.051);\n --emerald-200: oklch(0.905 0.093 164.15);\n --emerald-300: oklch(0.845 0.143 164.978);\n --emerald-400: oklch(0.765 0.177 163.223);\n --emerald-500: oklch(0.696 0.17 162.48);\n --emerald-600: oklch(0.596 0.145 163.225);\n --emerald-700: oklch(0.508 0.118 165.612);\n --emerald-800: oklch(0.432 0.095 166.913);\n --emerald-900: oklch(0.378 0.077 168.94);\n --emerald-950: oklch(0.262 0.051 172.552);\n\n --teal-50: oklch(0.984 0.014 180.72);\n --teal-100: oklch(0.953 0.051 180.801);\n --teal-200: oklch(0.91 0.096 180.426);\n --teal-300: oklch(0.855 0.138 181.071);\n --teal-400: oklch(0.777 0.152 181.912);\n --teal-500: oklch(0.704 0.14 182.503);\n --teal-600: oklch(0.6 0.118 184.704);\n --teal-700: oklch(0.511 0.096 186.391);\n --teal-800: oklch(0.437 0.078 188.216);\n --teal-900: oklch(0.386 0.063 188.416);\n --teal-950: oklch(0.277 0.046 192.524);\n\n --cyan-50: oklch(0.984 0.019 200.873);\n --cyan-100: oklch(0.956 0.045 203.388);\n --cyan-200: oklch(0.917 0.08 205.041);\n --cyan-300: oklch(0.865 0.127 207.078);\n --cyan-400: oklch(0.789 0.154 211.53);\n --cyan-500: oklch(0.715 0.143 215.221);\n --cyan-600: oklch(0.609 0.126 221.723);\n --cyan-700: oklch(0.52 0.105 223.128);\n --cyan-800: oklch(0.45 0.085 224.283);\n --cyan-900: oklch(0.398 0.07 227.392);\n --cyan-950: oklch(0.302 0.056 229.695);\n\n --sky-50: oklch(0.977 0.013 236.62);\n --sky-100: oklch(0.951 0.026 236.824);\n --sky-200: oklch(0.901 0.058 230.902);\n --sky-300: oklch(0.828 0.111 230.318);\n --sky-400: oklch(0.746 0.16 232.661);\n --sky-500: oklch(0.685 0.169 237.323);\n --sky-600: oklch(0.588 0.158 241.966);\n --sky-700: oklch(0.5 0.134 242.749);\n --sky-800: oklch(0.443 0.11 240.79);\n --sky-900: oklch(0.391 0.09 240.876);\n --sky-950: oklch(0.293 0.066 243.157);\n\n --blue-50: oklch(0.97 0.014 254.604);\n --blue-100: oklch(0.932 0.032 255.585);\n --blue-200: oklch(0.882 0.059 254.128);\n --blue-300: oklch(0.809 0.105 251.813);\n --blue-400: oklch(0.707 0.165 254.624);\n --blue-500: oklch(0.623 0.214 259.815);\n --blue-600: oklch(0.546 0.245 262.881);\n --blue-700: oklch(0.488 0.243 264.376);\n --blue-800: oklch(0.424 0.199 265.638);\n --blue-900: oklch(0.379 0.146 265.522);\n --blue-950: oklch(0.282 0.091 267.935);\n\n --indigo-50: oklch(0.962 0.018 272.314);\n --indigo-100: oklch(0.93 0.034 272.788);\n --indigo-200: oklch(0.87 0.065 274.039);\n --indigo-300: oklch(0.785 0.115 274.713);\n --indigo-400: oklch(0.673 0.182 276.935);\n --indigo-500: oklch(0.585 0.233 277.117);\n --indigo-600: oklch(0.511 0.262 276.966);\n --indigo-700: oklch(0.457 0.24 277.023);\n --indigo-800: oklch(0.398 0.195 277.366);\n --indigo-900: oklch(0.359 0.144 278.697);\n --indigo-950: oklch(0.257 0.09 281.288);\n\n --violet-50: oklch(0.969 0.016 293.756);\n --violet-100: oklch(0.943 0.029 294.588);\n --violet-200: oklch(0.894 0.057 293.283);\n --violet-300: oklch(0.811 0.111 293.571);\n --violet-400: oklch(0.702 0.183 293.541);\n --violet-500: oklch(0.606 0.25 292.717);\n --violet-600: oklch(0.541 0.281 293.009);\n --violet-700: oklch(0.491 0.27 292.581);\n --violet-800: oklch(0.432 0.232 292.759);\n --violet-900: oklch(0.38 0.189 293.745);\n --violet-950: oklch(0.283 0.141 291.089);\n\n --purple-50: oklch(0.977 0.014 308.299);\n --purple-100: oklch(0.946 0.033 307.174);\n --purple-200: oklch(0.902 0.063 306.703);\n --purple-300: oklch(0.827 0.119 306.383);\n --purple-400: oklch(0.714 0.203 305.504);\n --purple-500: oklch(0.627 0.265 303.9);\n --purple-600: oklch(0.558 0.288 302.321);\n --purple-700: oklch(0.496 0.265 301.924);\n --purple-800: oklch(0.438 0.218 303.724);\n --purple-900: oklch(0.381 0.176 304.987);\n --purple-950: oklch(0.291 0.149 302.717);\n\n --fuchsia-50: oklch(0.977 0.017 320.058);\n --fuchsia-100: oklch(0.952 0.037 318.852);\n --fuchsia-200: oklch(0.903 0.076 319.62);\n --fuchsia-300: oklch(0.833 0.145 321.434);\n --fuchsia-400: oklch(0.74 0.238 322.16);\n --fuchsia-500: oklch(0.667 0.295 322.15);\n --fuchsia-600: oklch(0.591 0.293 322.896);\n --fuchsia-700: oklch(0.518 0.253 323.949);\n --fuchsia-800: oklch(0.452 0.211 324.591);\n --fuchsia-900: oklch(0.401 0.17 325.612);\n --fuchsia-950: oklch(0.293 0.136 325.661);\n\n --pink-50: oklch(0.971 0.014 343.198);\n --pink-100: oklch(0.948 0.028 342.258);\n --pink-200: oklch(0.899 0.061 343.231);\n --pink-300: oklch(0.823 0.12 346.018);\n --pink-400: oklch(0.718 0.202 349.761);\n --pink-500: oklch(0.656 0.241 354.308);\n --pink-600: oklch(0.592 0.249 0.584);\n --pink-700: oklch(0.525 0.223 3.958);\n --pink-800: oklch(0.459 0.187 3.815);\n --pink-900: oklch(0.408 0.153 2.432);\n --pink-950: oklch(0.284 0.109 3.907);\n\n --rose-50: oklch(0.969 0.015 12.422);\n --rose-100: oklch(0.941 0.03 12.58);\n --rose-200: oklch(0.892 0.058 10.001);\n --rose-300: oklch(0.81 0.117 11.638);\n --rose-400: oklch(0.712 0.194 13.428);\n --rose-500: oklch(0.645 0.246 16.439);\n --rose-600: oklch(0.586 0.253 17.585);\n --rose-700: oklch(0.514 0.222 16.935);\n --rose-800: oklch(0.455 0.188 13.697);\n --rose-900: oklch(0.41 0.159 10.272);\n --rose-950: oklch(0.271 0.105 12.094);\n}\n\n\n/* vendor/css-zero/sizes.css */\n:root {\n /****************************************************************\n * Fixed Size\n *****************************************************************/\n --size-0_5: 0.125rem; /* 2px */\n --size-1: 0.25rem; /* 4px */\n --size-1_5: 0.375rem; /* 6px */\n --size-2: 0.5rem; /* 8px */\n --size-2_5: 0.625rem; /* 10px */\n --size-3: 0.75rem; /* 12px */\n --size-3_5: 0.875rem; /* 14px */\n --size-4: 1rem; /* 16px */\n --size-5: 1.25rem; /* 20px */\n --size-6: 1.5rem; /* 24px */\n --size-7: 1.75rem; /* 28px */\n --size-8: 2rem; /* 32px */\n --size-9: 2.25rem; /* 36px */\n --size-10: 2.5rem; /* 40px */\n --size-11: 2.75rem; /* 44px */\n --size-12: 3rem; /* 48px */\n --size-14: 3.5rem; /* 56px */\n --size-16: 4rem; /* 64px */\n --size-20: 5rem; /* 80px */\n --size-24: 6rem; /* 96px */\n --size-28: 7rem; /* 112px */\n --size-32: 8rem; /* 128px */\n --size-36: 9rem; /* 144px */\n --size-40: 10rem; /* 160px */\n --size-44: 11rem; /* 176px */\n --size-48: 12rem; /* 192px */\n --size-52: 13rem; /* 208px */\n --size-56: 14rem; /* 224px */\n --size-60: 15rem; /* 240px */\n --size-64: 16rem; /* 256px */\n --size-72: 18rem; /* 288px */\n --size-80: 20rem; /* 320px */\n --size-96: 24rem; /* 384px */\n\n /****************************************************************\n * Percentual Size\n *****************************************************************/\n --size-1-2: 50%;\n --size-1-3: 33.333333%;\n --size-2-3: 66.666667%;\n --size-1-4: 25%;\n --size-2-4: 50%;\n --size-3-4: 75%;\n --size-1-5: 20%;\n --size-2-5: 40%;\n --size-3-5: 60%;\n --size-4-5: 80%;\n --size-1-6: 16.666667%;\n --size-2-6: 33.333333%;\n --size-3-6: 50%;\n --size-4-6: 66.666667%;\n --size-5-6: 83.333333%;\n --size-1-12: 8.333333%;\n --size-2-12: 16.666667%;\n --size-3-12: 25%;\n --size-4-12: 33.333333%;\n --size-5-12: 41.666667%;\n --size-6-12: 50%;\n --size-7-12: 58.333333%;\n --size-8-12: 66.666667%;\n --size-9-12: 75%;\n --size-10-12: 83.333333%;\n --size-11-12: 91.666667%;\n --size-full: 100%;\n\n /****************************************************************\n * Max Inline Sizes\n *****************************************************************/\n --max-i-3xs: 16rem; /* 256px */\n --max-i-2xs: 18rem; /* 288px */\n --max-i-xs: 20rem; /* 320px */\n --max-i-sm: 24rem; /* 384px */\n --max-i-md: 28rem; /* 448px */\n --max-i-lg: 32rem; /* 512px */\n --max-i-xl: 36rem; /* 576px */\n --max-i-2xl: 42rem; /* 672px */\n --max-i-3xl: 48rem; /* 768px */\n --max-i-4xl: 56rem; /* 896px */\n --max-i-5xl: 64rem; /* 1024px */\n --max-i-6xl: 72rem; /* 1152px */\n --max-i-7xl: 80rem; /* 1280px */\n\n /****************************************************************\n * Aspect Ratio\n *****************************************************************/\n --aspect-square: 1/1;\n --aspect-widescreen: 16/9;\n\n /****************************************************************\n * Breakpoints\n *****************************************************************/\n --breakpoint-sm: 40rem; /* Mobile 640px */\n --breakpoint-md: 48rem; /* Tablet 768px */\n --breakpoint-lg: 64rem; /* Laptop 1024px */\n --breakpoint-xl: 80rem; /* Desktop 1280px */\n}\n\n\n/* vendor/css-zero/borders.css */\n:root {\n /****************************************************************\n * Border Width\n * Variables for controlling the width of an element's borders.\n * border-width: var(--border);\n *****************************************************************/\n --border: 1px;\n --border-2: 2px;\n --border-4: 4px;\n --border-8: 8px;\n\n /****************************************************************\n * Border Radius\n * Variables for controlling the border radius of an element.\n * border-radius: var(--rounded-sm);\n *****************************************************************/\n --rounded-xs: 0.125rem; /* 2px */\n --rounded-sm: 0.25rem; /* 4px */\n --rounded-md: 0.375rem; /* 6px */\n --rounded-lg: 0.5rem; /* 8px */\n --rounded-xl: 0.75rem; /* 12px */\n --rounded-2xl: 1rem; /* 16px */\n --rounded-3xl: 1.5rem; /* 24px */\n --rounded-full: 9999px;\n}\n\n\n/* vendor/css-zero/effects.css */\n:root {\n /****************************************************************\n * Box Shadow\n * Variables for controlling the box shadow of an element.\n * box-shadow: var(--shadow-sm);\n ****************************************************************/\n --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);\n --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);\n --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);\n --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\n --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);\n --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);\n --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);\n\n /****************************************************************\n * Opacity\n * Variables for controlling the opacity of an element.\n * opacity: var(--opacity-25);\n ****************************************************************/\n --opacity-5: 0.05;\n --opacity-10: 0.1;\n --opacity-20: 0.2;\n --opacity-25: 0.25;\n --opacity-30: 0.3;\n --opacity-40: 0.4;\n --opacity-50: 0.5;\n --opacity-60: 0.6;\n --opacity-70: 0.7;\n --opacity-75: 0.75;\n --opacity-80: 0.8;\n --opacity-90: 0.9;\n --opacity-95: 0.95;\n --opacity-100: 1;\n}\n\n\n/* vendor/css-zero/typography.css */\n:root {\n /****************************************************************\n * Font Size\n * Variables for controlling the font size of an element.\n * font-size: var(--text-xs);\n *****************************************************************/\n --text-xs: 0.75rem; /* 12px */\n --text-sm: 0.875rem; /* 14px */\n --text-base: 1rem; /* 16px */\n --text-lg: 1.125rem; /* 18px */\n --text-xl: 1.25rem; /* 20px */\n --text-2xl: 1.5rem; /* 24px */\n --text-3xl: 1.875rem; /* 30px */\n --text-4xl: 2.25rem; /* 36px */\n --text-5xl: 3rem; /* 48px */\n --text-6xl: 3.75rem; /* 60px */\n --text-7xl: 4.5rem; /* 72px */\n --text-8xl: 6rem; /* 96px */\n --text-9xl: 8rem; /* 128px */\n\n --text-fluid-xs: clamp(0.75rem, 0.64rem + 0.57vw, 1rem); /* 12px..16px */\n --text-fluid-sm: clamp(0.875rem, 0.761rem + 0.568vw, 1.125rem); /* 14px..18px */\n --text-fluid-base: clamp(1rem, 0.89rem + 0.57vw, 1.25rem); /* 16px..20px */\n --text-fluid-lg: clamp(1.125rem, 0.955rem + 0.852vw, 1.5rem); /* 18px..24px */\n --text-fluid-xl: clamp(1.25rem, 0.966rem + 1.42vw, 1.875rem); /* 20px..30px */\n --text-fluid-2xl: clamp(1.5rem, 1.16rem + 1.7vw, 2.25rem); /* 24px..36px */\n --text-fluid-3xl: clamp(1.875rem, 1.364rem + 2.557vw, 3rem); /* 30px..48px */\n --text-fluid-4xl: clamp(2.25rem, 1.57rem + 3.41vw, 3.75rem); /* 36px..60px */\n --text-fluid-5xl: clamp(3rem, 2.32rem + 3.41vw, 4.5rem); /* 48px..72px */\n --text-fluid-6xl: clamp(3.75rem, 2.73rem + 5.11vw, 6rem); /* 60px..96px */\n --text-fluid-7xl: clamp(4.5rem, 2.91rem + 7.95vw, 8rem); /* 72px..128px */\n\n /****************************************************************\n * Font Weight\n * Variables for controlling the font weight of an element.\n * font-weight: var(--font-hairline);\n *****************************************************************/\n --font-thin: 100;\n --font-extralight: 200;\n --font-light: 300;\n --font-normal: 400;\n --font-medium: 500;\n --font-semibold: 600;\n --font-bold: 700;\n --font-extrabold: 800;\n --font-black: 900;\n\n /****************************************************************\n * Line Height\n * Variables for controlling the leading (line height) of an element.\n * line-height: var(--leading-tight);\n *****************************************************************/\n --leading-none: 1;\n --leading-tight: 1.25;\n --leading-snug: 1.375;\n --leading-normal: 1.5;\n --leading-relaxed: 1.625;\n --leading-loose: 2;\n --leading-3: .75rem; /* 12px */\n --leading-4: 1rem; /* 16px */\n --leading-5: 1.25rem; /* 20px */\n --leading-6: 1.5rem; /* 24px */\n --leading-7: 1.75rem; /* 28px */\n --leading-8: 2rem; /* 32px */\n --leading-9: 2.25rem; /* 36px */\n --leading-10: 2.5rem; /* 40px */\n\n /****************************************************************\n * Font Family (https://modernfontstacks.com)\n * Variables for controlling the font family of an element.\n * font-family: var(--font-sans);\n *****************************************************************/\n --font-system-ui: system-ui, sans-serif;\n --font-transitional: Charter, Bitstream Charter, Sitka Text, Cambria, serif;\n --font-old-style: Iowan Old Style, Palatino Linotype, URW Palladio L, P052, serif;\n --font-humanist: Seravek, Gill Sans Nova, Ubuntu, Calibri, DejaVu Sans, source-sans-pro, sans-serif;\n --font-geometric-humanist: Avenir, Montserrat, Corbel, URW Gothic, source-sans-pro, sans-serif;\n --font-classical-humanist: Optima, Candara, Noto Sans, source-sans-pro, sans-serif;\n --font-neo-grotesque: Inter, Roboto, Helvetica Neue, Arial Nova, Nimbus Sans, Arial, sans-serif;\n --font-monospace-slab-serif: Nimbus Mono PS, Courier New, monospace;\n --font-monospace-code: Dank Mono, Operator Mono, Inconsolata, Fira Mono, ui-monospace, SF Mono, Monaco, Droid Sans Mono, Source Code Pro, Cascadia Code, Menlo, Consolas, DejaVu Sans Mono, monospace;\n --font-industrial: Bahnschrift, DIN Alternate, Franklin Gothic Medium, Nimbus Sans Narrow, sans-serif-condensed, sans-serif;\n --font-rounded-sans: ui-rounded, Hiragino Maru Gothic ProN, Quicksand, Comfortaa, Manjari, Arial Rounded MT, Arial Rounded MT Bold, Calibri, source-sans-pro, sans-serif;\n --font-slab-serif: Rockwell, Rockwell Nova, Roboto Slab, DejaVu Serif, Sitka Small, serif;\n --font-antique: Superclarendon, Bookman Old Style, URW Bookman, URW Bookman L, Georgia Pro, Georgia, serif;\n --font-didone: Didot, Bodoni MT, Noto Serif Display, URW Palladio L, P052, Sylfaen, serif;\n --font-handwritten: Segoe Print, Bradley Hand, Chilanka, TSCu_Comic, casual, cursive;\n\n /****************************************************************\n * Letter Spacing\n * Variables for controlling the tracking (letter spacing) of an element.\n * letter-spacing: var(--tracking-tighter);\n *****************************************************************/\n --tracking-tighter: -0.05em;\n --tracking-tight: -0.025em;\n --tracking-normal: 0em;\n --tracking-wide: 0.025em;\n --tracking-wider: 0.05em;\n --tracking-widest: 0.1em;\n}\n\n\n/* vendor/css-zero/animations.css */\n/****************************************************************\n* Animation\n* Variables for animating elements with CSS animations.\n* animation: var(--animate-fade-in) forwards;\n*****************************************************************/\n\n:root {\n --animate-fade-in: fade-in .5s cubic-bezier(.25, 0, .3, 1);\n --animate-fade-in-bloom: fade-in-bloom 2s cubic-bezier(.25, 0, .3, 1);\n --animate-fade-out: fade-out .5s cubic-bezier(.25, 0, .3, 1);\n --animate-fade-out-bloom: fade-out-bloom 2s cubic-bezier(.25, 0, .3, 1);\n --animate-scale-up: scale-up .5s cubic-bezier(.25, 0, .3, 1);\n --animate-scale-down: scale-down .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-out-up: slide-out-up .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-out-down: slide-out-down .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-out-right: slide-out-right .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-out-left: slide-out-left .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-in-up: slide-in-up .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-in-down: slide-in-down .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-in-right: slide-in-right .5s cubic-bezier(.25, 0, .3, 1);\n --animate-slide-in-left: slide-in-left .5s cubic-bezier(.25, 0, .3, 1);\n --animate-shake-x: shake-x .75s cubic-bezier(0, 0, 0, 1);\n --animate-shake-y: shake-y .75s cubic-bezier(0, 0, 0, 1);\n --animate-shake-z: shake-z 1s cubic-bezier(0, 0, 0, 1);\n --animate-spin: spin 2s linear infinite;\n --animate-ping: ping 5s cubic-bezier(0, 0, .3, 1) infinite;\n --animate-blink: blink 1s cubic-bezier(0, 0, .3, 1) infinite;\n --animate-float: float 3s cubic-bezier(0, 0, 0, 1) infinite;\n --animate-bounce: bounce 2s cubic-bezier(.5, -.3, .1, 1.5) infinite;\n --animate-pulse: pulse 2s cubic-bezier(0, 0, .3, 1) infinite;\n}\n\n@keyframes fade-in {\n to { opacity: 1 }\n}\n\n@keyframes fade-in-bloom {\n 0% { opacity: 0; filter: brightness(1) blur(20px) }\n 10% { opacity: 1; filter: brightness(2) blur(10px) }\n 100% { opacity: 1; filter: brightness(1) blur(0) }\n}\n\n@keyframes fade-out {\n to { opacity: 0 }\n}\n\n@keyframes fade-out-bloom {\n 100% { opacity: 0; filter: brightness(1) blur(20px) }\n 10% { opacity: 1; filter: brightness(2) blur(10px) }\n 0% { opacity: 1; filter: brightness(1) blur(0) }\n}\n@keyframes scale-up {\n to { transform: scale(1.25) }\n}\n\n@keyframes scale-down {\n to { transform: scale(.75) }\n}\n\n@keyframes slide-out-up {\n to { transform: translateY(-100%) }\n}\n\n@keyframes slide-out-down {\n to { transform: translateY(100%) }\n}\n\n@keyframes slide-out-right {\n to { transform: translateX(100%) }\n}\n\n@keyframes slide-out-left {\n to { transform: translateX(-100%) }\n}\n\n@keyframes slide-in-up {\n from { transform: translateY(100%) }\n}\n\n@keyframes slide-in-down {\n from { transform: translateY(-100%) }\n}\n\n@keyframes slide-in-right {\n from { transform: translateX(-100%) }\n}\n\n@keyframes slide-in-left {\n from { transform: translateX(100%) }\n}\n\n@keyframes shake-x {\n 0%, 100% { transform: translateX(0%) }\n 20% { transform: translateX(-5%) }\n 40% { transform: translateX(5%) }\n 60% { transform: translateX(-5%) }\n 80% { transform: translateX(5%) }\n}\n\n@keyframes shake-y {\n 0%, 100% { transform: translateY(0%) }\n 20% { transform: translateY(-5%) }\n 40% { transform: translateY(5%) }\n 60% { transform: translateY(-5%) }\n 80% { transform: translateY(5%) }\n}\n\n@keyframes shake-z {\n 0%, 100% { transform: rotate(0deg) }\n 20% { transform: rotate(-2deg) }\n 40% { transform: rotate(2deg) }\n 60% { transform: rotate(-2deg) }\n 80% { transform: rotate(2deg) }\n}\n\n@keyframes spin {\n to { transform: rotate(1turn) }\n}\n\n@keyframes ping {\n 90%, 100% {\n transform: scale(2);\n opacity: 0;\n }\n}\n\n@keyframes blink {\n 0%, 100% {\n opacity: 1\n }\n 50% {\n opacity: .5\n }\n}\n\n@keyframes float {\n 50% { transform: translateY(-25%) }\n}\n\n@keyframes bounce {\n 25% { transform: translateY(-20%) }\n 40% { transform: translateY(-3%) }\n 0%, 60%, 100% { transform: translateY(0) }\n}\n\n@keyframes pulse {\n 50% { transform: scale(.9,.9) }\n}\n\n@media (prefers-color-scheme: dark) {\n @keyframes fade-in-bloom {\n 0% { opacity: 0; filter: brightness(1) blur(20px) }\n 10% { opacity: 1; filter: brightness(0.5) blur(10px) }\n 100% { opacity: 1; filter: brightness(1) blur(0) }\n }\n}\n\n@media (prefers-color-scheme: dark) {\n @keyframes fade-out-bloom {\n 100% { opacity: 0; filter: brightness(1) blur(20px) }\n 10% { opacity: 1; filter: brightness(0.5) blur(10px) }\n 0% { opacity: 1; filter: brightness(1) blur(0) }\n }\n}\n\n/* vendor/css-zero/transforms.css */\n:root {\n /****************************************************************\n * Scale\n * Variables for scaling elements with transform.\n * transform: var(--scale-100);\n *****************************************************************/\n --scale-50: scale(0.50);\n --scale-75: scale(0.75);\n --scale-90: scale(0.90);\n --scale-95: scale(0.95);\n --scale-100: scale(1);\n --scale-105: scale(1.05);\n --scale-110: scale(1.10);\n --scale-125: scale(1.25);\n --scale-150: scale(1.50);\n\n /****************************************************************\n * Rotate\n * Variables for rotating elements with transform.\n * transform: var(--rotate-45);\n *****************************************************************/\n --rotate-0: rotate(0deg);\n --rotate-1: rotate(1deg);\n --rotate-2: rotate(2deg);\n --rotate-3: rotate(3deg);\n --rotate-6: rotate(6deg);\n --rotate-12: rotate(12deg);\n --rotate-45: rotate(45deg);\n --rotate-90: rotate(90deg);\n --rotate-180: rotate(180deg);\n\n /****************************************************************\n * Skew\n * Varibles for skewing elements with transform.\n * transform: var(--skew-x-3);\n *****************************************************************/\n --skew-x-0: skewX(0deg);\n --skew-y-0: skewY(0deg);\n --skew-x-1: skewX(1deg);\n --skew-y-1: skewY(1deg);\n --skew-x-2: skewX(2deg);\n --skew-y-2: skewY(2deg);\n --skew-x-3: skewX(3deg);\n --skew-y-3: skewY(3deg);\n --skew-x-6: skewX(6deg);\n --skew-y-6: skewY(6deg);\n --skew-x-12: skewX(12deg);\n --skew-y-12: skewY(12deg);\n}\n\n\n/* vendor/css-zero/transitions.css */\n:root {\n /****************************************************************\n * Transition Property\n * Variables for controlling which CSS properties transition.\n * transition-property: var(--transition);\n *****************************************************************/\n --transition: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter;\n --transition-colors: color, background-color, border-color, text-decoration-color, fill, stroke;\n --transition-transform: transform, translate, scale, rotate;\n\n /****************************************************************\n * Transition Timing\n * Variables for controlling the timing of CSS transitions.\n * transition-duration|transition-delay: var(--time-75);\n *****************************************************************/\n --time-75: 75ms;\n --time-100: 100ms;\n --time-150: 150ms;\n --time-200: 200ms;\n --time-300: 300ms;\n --time-500: 500ms;\n --time-700: 700ms;\n --time-1000: 1000ms;\n}\n\n\n/* vendor/css-zero/filters.css */\n:root {\n /****************************************************************\n * Blur\n * Variables for applying blur filters to an element.\n * filter|backdrop-filter: var(--blur-sm);\n *****************************************************************/\n --blur-none: blur(0);\n --blur-xs: blur(4px);\n --blur-sm: blur(8px);\n --blur-md: blur(12px);\n --blur-lg: blur(16px);\n --blur-xl: blur(24px);\n --blur-2xl: blur(40px);\n --blur-3xl: blur(64px);\n\n /****************************************************************\n * Brightness\n * Variables for applying brightness filters to an element.\n * filter|backdrop-filter: var(--brightness-50);\n *****************************************************************/\n --brightness-0: brightness(0);\n --brightness-50: brightness(0.5);\n --brightness-75: brightness(0.75);\n --brightness-90: brightness(0.9);\n --brightness-95: brightness(0.95);\n --brightness-100: brightness(1);\n --brightness-105: brightness(1.05);\n --brightness-110: brightness(1.1);\n --brightness-125: brightness(1.25);\n --brightness-150: brightness(1.5);\n --brightness-200: brightness(2);\n\n /****************************************************************\n * Contrast\n * Variables for applying contrast filters to an element.\n * filter|backdrop-filter: var(--contrast-50);\n *****************************************************************/\n --contrast-0: contrast(0);\n --contrast-50: contrast(0.5);\n --contrast-75: contrast(0.75);\n --contrast-100: contrast(1);\n --contrast-125: contrast(1.25);\n --contrast-150: contrast(1.5);\n --contrast-200: contrast(2);\n\n /****************************************************************\n * Drop Shadow\n * Variables for applying drop-shadow filters to an element.\n * filter: var(--drop-shadow);\n *****************************************************************/\n --drop-shadow-none: drop-shadow(0 0 #0000);\n --drop-shadow-sm: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05));\n --drop-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.06));\n --drop-shadow-md: drop-shadow(0 4px 3px rgba(0, 0, 0, 0.07)) drop-shadow(0 2px 2px rgba(0, 0, 0, 0.06));\n --drop-shadow-lg: drop-shadow(0 10px 8px rgba(0, 0, 0, 0.04)) drop-shadow(0 4px 3px rgba(0, 0, 0, 0.1));\n --drop-shadow-xl: drop-shadow(0 20px 13px rgba(0, 0, 0, 0.03)) drop-shadow(0 8px 5px rgba(0, 0, 0, 0.08));\n --drop-shadow-2xl: drop-shadow(0 25px 25px rgba(0, 0, 0, 0.15));\n\n /****************************************************************\n * Grayscale\n * Variables for applying grayscale filters to an element.\n * filter|backdrop-filter: var(--grayscale);\n *****************************************************************/\n --grayscale-0: grayscale(0);\n --grayscale: grayscale(100%);\n\n /****************************************************************\n * Hue Rotate\n * Variables for applying hue-rotate filters to an element.\n * filter|backdrop-filter: var(--hue-rotate-15);\n *****************************************************************/\n --hue-rotate-0: hue-rotate(0deg);\n --hue-rotate-15: hue-rotate(15deg);\n --hue-rotate-30: hue-rotate(30deg);\n --hue-rotate-60: hue-rotate(60deg);\n --hue-rotate-90: hue-rotate(90deg);\n --hue-rotate-180: hue-rotate(180deg);\n\n /****************************************************************\n * Invert\n * Variables for applying invert filters to an element.\n * filter|backdrop-filter: var(--invert);\n *****************************************************************/\n --invert-0: invert(0);\n --invert: invert(100%);\n\n /****************************************************************\n * Saturate\n * Variables for applying saturation filters to an element.\n * filter|backdrop-filter: var(--saturate-50);\n *****************************************************************/\n --saturate-0: saturate(0);\n --saturate-50: saturate(0.5);\n --saturate-100: saturate(1);\n --saturate-150: saturate(1.5);\n --saturate-200: saturate(2);\n\n /****************************************************************\n * Sepia\n * Variables for applying sepia filters to an element.\n * filter|backdrop-filter: var(--sepia);\n *****************************************************************/\n --sepia-0: sepia(0);\n --sepia: sepia(100%);\n\n /****************************************************************\n * Opacity\n * Utilities for applying backdrop opacity filters to an element.\n * backdrop-filter: var(--alpha-45);\n *****************************************************************/\n --alpha-0:\t opacity(0);\n --alpha-5:\t opacity(0.05);\n --alpha-10:\t opacity(0.1);\n --alpha-15:\t opacity(0.15);\n --alpha-20:\t opacity(0.2);\n --alpha-25:\t opacity(0.25);\n --alpha-30:\t opacity(0.3);\n --alpha-35:\t opacity(0.35);\n --alpha-40:\t opacity(0.4);\n --alpha-45:\t opacity(0.45);\n --alpha-50:\t opacity(0.5);\n --alpha-55:\t opacity(0.55);\n --alpha-60:\t opacity(0.6);\n --alpha-65:\t opacity(0.65);\n --alpha-70:\t opacity(0.7);\n --alpha-75:\t opacity(0.75);\n --alpha-80:\t opacity(0.8);\n --alpha-85:\t opacity(0.85);\n --alpha-90:\t opacity(0.9);\n --alpha-95:\t opacity(0.95);\n --alpha-100: opacity(1);\n}\n\n\n/* vendor/flatpickr.css */\n.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/*\n /*rtl:begin:ignore*/left:0/*\n /*rtl:end:ignore*/}/*\n /*rtl:begin:ignore*/\n/*\n /*rtl:end:ignore*/\n.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,.flatpickr-months .flatpickr-next-month.flatpickr-next-month{/*\n /*rtl:begin:ignore*/right:0/*\n /*rtl:end:ignore*/}/*\n /*rtl:begin:ignore*/\n/*\n /*rtl:end:ignore*/\n.flatpickr-months .flatpickr-prev-month:hover,.flatpickr-months .flatpickr-next-month:hover{color:#959ea9}.flatpickr-months .flatpickr-prev-month:hover svg,.flatpickr-months .flatpickr-next-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-prev-month svg,.flatpickr-months .flatpickr-next-month svg{width:14px;height:14px}.flatpickr-months .flatpickr-prev-month svg path,.flatpickr-months .flatpickr-next-month svg path{-webkit-transition:fill .1s;transition:fill .1s;fill:inherit}.numInputWrapper{position:relative;height:auto}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-outer-spin-button,.numInputWrapper input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.numInputWrapper span{position:absolute;right:0;width:14px;padding:0 4px 0 2px;height:50%;line-height:50%;opacity:0;cursor:pointer;border:1px solid rgba(57,57,57,0.15);-webkit-box-sizing:border-box;box-sizing:border-box}.numInputWrapper span:hover{background:rgba(0,0,0,0.1)}.numInputWrapper span:active{background:rgba(0,0,0,0.2)}.numInputWrapper span:after{display:block;content:\"\";position:absolute}.numInputWrapper span.arrowUp{top:0;border-bottom:0}.numInputWrapper span.arrowUp:after{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:4px solid rgba(57,57,57,0.6);top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,0.6);top:40%}.numInputWrapper span svg{width:inherit;height:auto}.numInputWrapper span svg path{fill:rgba(0,0,0,0.5)}.numInputWrapper:hover{background:rgba(0,0,0,0.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{font-size:135%;line-height:inherit;font-weight:300;color:inherit;position:absolute;width:75%;left:12.5%;padding:7.48px 0 0 0;line-height:1;height:34px;display:inline-block;text-align:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.flatpickr-current-month span.cur-month{font-family:inherit;font-weight:700;color:inherit;display:inline-block;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .numInputWrapper{width:6ch;width:7ch\\0;display:inline-block}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,0.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,0.9)}.flatpickr-current-month input.cur-year{background:transparent;-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;cursor:text;padding:0 0 0 .5ch;margin:0;display:inline-block;font-size:inherit;font-family:inherit;font-weight:300;line-height:inherit;height:auto;border:0;border-radius:0;vertical-align:initial;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{font-size:100%;color:rgba(0,0,0,0.5);background:transparent;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;color:inherit;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;-webkit-box-sizing:border-box;-webkit-appearance:menulist;-moz-appearance:menulist;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:focus,.flatpickr-current-month .flatpickr-monthDropdown-months:active{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{background:transparent;text-align:center;overflow:hidden;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:28px}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}span.flatpickr-weekday{cursor:default;font-size:90%;background:transparent;color:rgba(0,0,0,0.54);line-height:1;margin:0;text-align:center;display:block;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;font-weight:bolder}.dayContainer,.flatpickr-weeks{padding:1px 0 0 0}.flatpickr-days{position:relative;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{padding:0;outline:0;text-align:left;width:307.875px;min-width:307.875px;max-width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;display:inline-block;display:-ms-flexbox;display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-wrap:wrap;-ms-flex-pack:justify;-webkit-justify-content:space-around;justify-content:space-around;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.dayContainer + .dayContainer{-webkit-box-shadow:-1px 0 0 #e6e6e6;box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#393939;cursor:pointer;font-weight:400;width:14.2857143%;-webkit-flex-basis:14.2857143%;-ms-flex-preferred-size:14.2857143%;flex-basis:14.2857143%;max-width:39px;height:39px;line-height:39px;margin:0;display:inline-block;position:relative;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center}.flatpickr-day.inRange,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.today.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day:hover,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.nextMonthDay:hover,.flatpickr-day:focus,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.nextMonthDay:focus{cursor:pointer;outline:0;background:#e6e6e6;border-color:#e6e6e6}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:hover,.flatpickr-day.today:focus{border-color:#959ea9;background:#959ea9;color:#fff}.flatpickr-day.selected,.flatpickr-day.startRange,.flatpickr-day.endRange,.flatpickr-day.selected.inRange,.flatpickr-day.startRange.inRange,.flatpickr-day.endRange.inRange,.flatpickr-day.selected:focus,.flatpickr-day.startRange:focus,.flatpickr-day.endRange:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange:hover,.flatpickr-day.endRange:hover,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.endRange.nextMonthDay{background:#569ff7;-webkit-box-shadow:none;box-shadow:none;color:#fff;border-color:#569ff7}.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange,.flatpickr-day.endRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange,.flatpickr-day.endRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)){-webkit-box-shadow:-10px 0 0 #569ff7;box-shadow:-10px 0 0 #569ff7}.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange,.flatpickr-day.endRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;-webkit-box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.prevMonthDay,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.notAllowed.nextMonthDay{color:rgba(57,57,57,0.3);background:transparent;border-color:transparent;cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{cursor:not-allowed;color:rgba(57,57,57,0.1)}.flatpickr-day.week.selected{border-radius:0;-webkit-box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{padding:0 12px;-webkit-box-shadow:1px 0 0 #e6e6e6;box-shadow:1px 0 0 #e6e6e6}.flatpickr-weekwrapper .flatpickr-weekday{float:none;width:100%;line-height:28px}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{display:block;width:100%;max-width:none;color:rgba(57,57,57,0.3);background:transparent;cursor:default;border:none}.flatpickr-innerContainer{display:block;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.flatpickr-rContainer{display:inline-block;padding:0;-webkit-box-sizing:border-box;box-sizing:border-box}.flatpickr-time{text-align:center;outline:0;display:block;height:0;line-height:40px;max-height:40px;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-time:after{content:\"\";display:table;clear:both}.flatpickr-time .numInputWrapper{-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;width:40%;height:40px;float:left}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{background:transparent;-webkit-box-shadow:none;box-shadow:none;border:0;border-radius:0;text-align:center;margin:0;padding:0;height:inherit;line-height:inherit;color:#393939;font-size:14px;position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-time input.flatpickr-hour{font-weight:bold}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{outline:0;border:0}.flatpickr-time .flatpickr-time-separator,.flatpickr-time .flatpickr-am-pm{height:inherit;float:left;line-height:inherit;color:#393939;font-weight:bold;width:2%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.flatpickr-time .flatpickr-am-pm{outline:0;width:18%;cursor:pointer;text-align:center;font-weight:400}.flatpickr-time input:hover,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time .flatpickr-am-pm:focus{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@-webkit-keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}@keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}\n\n/* app/assets/stylesheets/rails_pulse/components/alert.css */\n.alert {\n border: 1px solid var(--alert-border-color, var(--color-border));\n border-radius: var(--rounded-lg);\n color: var(--alert-color, var(--color-text));\n font-size: var(--text-sm);\n inline-size: var(--size-full);\n padding: var(--size-4);\n\n img {\n filter: var(--alert-icon-color, var(--color-filter-text));\n }\n}\n\n.alert--positive {\n --alert-border-color: var(--color-positive);\n --alert-color: var(--color-positive);\n --alert-icon-color: var(--color-filter-positive);\n}\n\n.alert--negative {\n --alert-border-color: var(--color-negative);\n --alert-color: var(--color-negative);\n --alert-icon-color: var(--color-filter-negative);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/badge.css */\n.badge {\n background-color: var(--badge-background, var(--color-bg));\n border-radius: var(--rounded-md);\n border: 1px solid var(--badge-border-color, var(--color-border));\n box-shadow: var(--badge-box-shadow, none);\n color: var(--badge-color, var(--color-text));\n display: inline-flex;\n font-size: var(--text-xs);\n font-weight: var(--font-semibold);\n line-height: var(--leading-4);\n padding: var(--size-0_5) var(--size-2_5);\n}\n\n.badge--primary {\n --badge-background: var(--color-primary);\n --badge-border-color: transparent;\n --badge-box-shadow: var(--shadow-sm);\n --badge-color: var(--color-text-reversed);\n}\n\n.badge--secondary {\n --badge-background: var(--color-secondary);\n --badge-border-color: transparent;\n --badge-box-shadow: none;\n --badge-color: var(--color-text);\n}\n\n.badge--positive {\n --badge-background: var(--color-positive);\n --badge-border-color: transparent;\n --badge-box-shadow: var(--shadow-sm);\n --badge-color: white;\n}\n\n.badge--negative {\n --badge-background: var(--color-negative);\n --badge-border-color: transparent;\n --badge-box-shadow: var(--shadow-sm);\n --badge-color: white;\n}\n\n.badge--primary-inverse {\n --badge-background: var(--color-bg);\n --badge-border-color: transparent;\n --badge-color: var(--color-positive);\n}\n\n.badge--positive-inverse {\n --badge-background: var(--color-bg);\n --badge-border-color: transparent;\n --badge-color: var(--color-positive);\n}\n\n.badge--negative-inverse {\n --badge-background: var(--color-bg);\n --badge-border-color: transparent;\n --badge-color: var(--color-negative);\n}\n\n/* Trend badge icon lightening (dark mode only) */\n.badge--trend rails-pulse-icon { color: var(--badge-color, currentColor); }\nhtml[data-color-scheme=\"dark\"] .badge--trend rails-pulse-icon {\n /* Lighten icon relative to badge text color for contrast */\n color: color-mix(in srgb, var(--badge-color) 55%, white 45%);\n}\n\n/* Trend amount lightening (dark mode only) */\n.badge--trend .badge__trend-amount { color: var(--badge-color, currentColor); }\nhtml[data-color-scheme=\"dark\"] .badge--trend .badge__trend-amount {\n color: color-mix(in srgb, var(--badge-color) 55%, white 45%);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/base.css */\n:root {\n /* Abstractions */\n --color-bg: white;\n --color-text: black;\n --color-text-reversed: white;\n --color-text-subtle: var(--zinc-500);\n --color-link: var(--blue-700);\n /* Header tokens */\n --header-bg: #ffc91f;\n --header-link: black;\n --header-link-hover-bg: #ffe284;\n --color-border-light: var(--zinc-100);\n --color-border: var(--zinc-200);\n --color-border-dark: var(--zinc-400);\n --color-selected: var(--blue-100);\n --color-selected-dark: var(--blue-300);\n --color-highlight: var(--yellow-200);\n\n /* Accent colors */\n --color-primary: var(--zinc-900);\n --color-secondary: var(--zinc-100);\n --color-negative: var(--red-600);\n --color-positive: var(--green-600);\n\n /* SVG color values */\n --color-filter-text: invert(0);\n --color-filter-text-reversed: invert(1);\n --color-filter-negative: invert(22%) sepia(85%) saturate(1790%) hue-rotate(339deg) brightness(105%) contrast(108%);\n --color-filter-positive: invert(44%) sepia(89%) saturate(409%) hue-rotate(89deg) brightness(94%) contrast(97%);\n}\n\nhtml[data-color-scheme=\"dark\"] {\n /* Abstractions */\n --color-bg: var(--zinc-800);\n --color-text: white;\n --color-text-reversed: black;\n --color-text-subtle: var(--zinc-300);\n /* Use brand yellow for links in dark mode */\n --color-link: #ffc91f;\n --color-border-light: var(--zinc-900);\n --color-border: var(--zinc-800);\n --color-border-dark: var(--zinc-600);\n --color-selected: var(--blue-950);\n --color-selected-dark: var(--blue-800);\n --color-highlight: var(--yellow-900);\n\n /* Header tokens */\n --header-bg: rgb(32, 32, 32);\n --header-link: #ffc91f;\n --header-link-hover-bg: #ffe284; /* keep existing hover color */\n\n /* Accent colors */\n --color-primary: var(--zinc-50);\n --color-secondary: var(--zinc-800);\n --color-negative: var(--red-900);\n --color-positive: var(--green-900);\n\n /* SVG color values */\n --color-filter-text: invert(1);\n --color-filter-text-reversed: invert(0);\n --color-filter-negative: invert(15%) sepia(65%) saturate(2067%) hue-rotate(339deg) brightness(102%) contrast(97%);\n --color-filter-positive: invert(23%) sepia(62%) saturate(554%) hue-rotate(91deg) brightness(93%) contrast(91%);\n}\n\n* {\n border-color: var(--color-border);\n scrollbar-color: #C1C1C1 transparent;\n scrollbar-width: thin;\n}\n\nhtml {\n scroll-behavior: smooth;\n}\n\nbody {\n background-color: var(--color-bg);\n color: var(--color-text);\n font-synthesis-weight: none;\n overscroll-behavior: none;\n text-rendering: optimizeLegibility;\n}\n\n.turbo-progress-bar {\n background-color: #4a8136\n}\n\n::selection {\n background-color: var(--color-selected);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/breadcrumb.css */\n.breadcrumb {\n align-items: center;\n color: var(--color-text-subtle);\n column-gap: var(--size-1);\n display: flex;\n flex-wrap: wrap;\n font-size: var(--text-sm);\n overflow-wrap: break-word;\n\n a {\n padding-block-end: 2px;\n }\n\n img.breadcrumb-separator {\n filter: var(--color-filter-text);\n opacity: 0.5;\n }\n\n a:hover {\n color: var(--color-text);\n }\n\n span[aria-current=\"page\"] {\n color: var(--color-text);\n font-weight: 500;\n }\n\n @media (width >= 40rem) {\n column-gap: var(--size-2);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/button.css */\n.btn {\n --btn-background: var(--color-bg);\n --hover-color: oklch(from var(--btn-background) calc(l * .95) c h);\n\n align-items: center;\n background-color: var(--btn-background);\n block-size: var(--btn-block-size, auto);\n border-radius: var(--btn-radius, var(--rounded-md));\n border: 1px solid var(--btn-border-color, var(--color-border));\n box-shadow: var(--btn-box-shadow, var(--shadow-xs));\n color: var(--btn-color, var(--color-text));\n column-gap: var(--size-2);\n cursor: default;\n display: inline-flex;\n font-size: var(--btn-font-size, var(--text-sm));\n font-weight: var(--btn-font-weight, var(--font-medium));\n inline-size: var(--btn-inline-size, auto);\n justify-content: var(--btn-justify-content, center);\n padding: var(--btn-padding, .375rem 1rem);\n position: relative;\n text-align: var(--btn-text-align, center);\n white-space: nowrap;\n\n img:not([class]) {\n filter: var(--btn-icon-color, var(--color-filter-text));\n }\n\n &:hover {\n background-color: var(--btn-hover-color, var(--hover-color));\n }\n\n &:focus-visible {\n outline: var(--btn-outline-size, 2px) solid var(--color-selected-dark);\n }\n\n &:is(:disabled, [aria-disabled]) {\n opacity: var(--opacity-50); pointer-events: none;\n }\n}\n\n.btn--primary {\n --btn-background: var(--color-primary);\n --btn-border-color: transparent;\n --btn-color: var(--color-text-reversed);\n --btn-icon-color: var(--color-filter-text-reversed);\n}\n\n.btn--secondary {\n --btn-background: var(--color-secondary);\n --btn-border-color: transparent;\n}\n\n.btn--borderless {\n --btn-border-color: transparent;\n --btn-box-shadow: none;\n}\n\n.btn--positive {\n --btn-background: var(--color-positive);\n --btn-border-color: transparent;\n --btn-color: white;\n --btn-icon-color: invert(1);\n}\n\n.btn--negative {\n --btn-background: var(--color-negative);\n --btn-border-color: transparent;\n --btn-color: white;\n --btn-icon-color: invert(1);\n}\n\n.btn--plain {\n --btn-background: transparent;\n --btn-border-color: transparent;\n --btn-hover-color: transparent;\n --btn-padding: 0;\n --btn-box-shadow: none;\n}\n\n.btn--icon {\n --btn-padding: var(--size-2);\n}\n\n[aria-busy] .btn--loading:disabled {\n > * {\n visibility: hidden;\n }\n\n &::after {\n animation: spin 1s linear infinite;\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cline x1='12' x2='12' y1='2' y2='6'/%3e%3cline x1='12' x2='12' y1='18' y2='22'/%3e%3cline x1='4.93' x2='7.76' y1='4.93' y2='7.76'/%3e%3cline x1='16.24' x2='19.07' y1='16.24' y2='19.07'/%3e%3cline x1='2' x2='6' y1='12' y2='12'/%3e%3cline x1='18' x2='22' y1='12' y2='12'/%3e%3cline x1='4.93' x2='7.76' y1='19.07' y2='16.24'/%3e%3cline x1='16.24' x2='19.07' y1='7.76' y2='4.93'/%3e%3c/svg%3e\");\n background-size: cover;\n block-size: var(--size-5);\n content: \"\";\n filter: var(--btn-icon-color, var(--color-filter-text));\n inline-size: var(--size-5);\n position: absolute;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/card.css */\n.card {\n background-color: var(--color-bg);\n border-radius: var(--rounded-xl);\n border-width: var(--border);\n padding: var(--size-6);\n box-shadow: var(--shadow-sm);\n}\n\n.card-selectable {\n background-color: var(--color-bg);\n border-radius: var(--rounded-xl);\n border-width: var(--border);\n padding: var(--size-3);\n\n &:has(:checked) {\n background-color: var(--color-secondary);\n border-color: var(--color-primary);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/chart.css */\n.chart-container {\n width: 100%;\n aspect-ratio: 4 / 2;\n}\n\n.chart-container--slim {\n aspect-ratio: 4 / 3;\n}\n\n@media (min-width: 64rem) {\n .chart-container {\n aspect-ratio: 16 / 5;\n }\n\n .chart-container--slim {\n aspect-ratio: 16 / 5;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/collapsible.css */\n.collapsible-code.collapsed pre {\n max-height: 4.5em;\n overflow: hidden;\n position: relative;\n}\n\n.collapsible-code.collapsed pre::after {\n content: '';\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 1em;\n background: linear-gradient(transparent, var(--color-border-light));\n pointer-events: none;\n}\n\n.collapsible-toggle {\n margin-top: 0.5rem;\n font-size: 0.875rem;\n color: var(--color-link);\n text-decoration: underline;\n transform: lowercase;\n cursor: pointer;\n border: none;\n background: none;\n padding: 0;\n font-weight: normal;\n margin-left: 10px;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css */\n/* CSP-Safe Positioning Utilities for Rails Pulse */\n/* Supports dynamic positioning using CSS custom properties */\n\n/* Rails Pulse CSS loaded indicator for CSP testing */\n:root {\n --rails-pulse-loaded: true;\n}\n\n/* Popover positioning using CSS custom properties */\n.positioned {\n --popover-x: 0px;\n --popover-y: 0px;\n --context-menu-x: 0px;\n --context-menu-y: 0px;\n}\n\n/* Popover positioning (used by popover_controller.js) */\n[popover].positioned {\n position: fixed;\n inset-inline-start: var(--popover-x, 0px) !important;\n inset-block-start: var(--popover-y, 0px) !important;\n}\n\n/* Context menu positioning (used by context_menu_controller.js) */\n[popover].positioned {\n inset-inline-start: var(--context-menu-x, var(--popover-x, 0px)) !important;\n inset-block-start: var(--context-menu-y, var(--popover-y, 0px)) !important;\n}\n\n/* Icon loading states for icon_controller.js */\n[data-controller*=\"rails-pulse--icon\"] {\n display: inline-block;\n line-height: 0;\n}\n\n[data-controller*=\"rails-pulse--icon\"].loading {\n opacity: 0.6;\n}\n\n[data-controller*=\"rails-pulse--icon\"].error {\n opacity: 0.4;\n filter: grayscale(1);\n}\n\n[data-controller*=\"rails-pulse--icon\"].loaded {\n opacity: 1;\n}\n\n/* CSP-safe icon rendering */\n[data-controller*=\"rails-pulse--icon\"] svg {\n display: block;\n width: inherit;\n height: inherit;\n}\n\n/* Accessibility improvements */\n[data-controller*=\"rails-pulse--icon\"][aria-label] {\n position: relative;\n}\n\n/* Focus indicators for interactive icons */\n[data-controller*=\"rails-pulse--icon\"]:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 2px;\n border-radius: 2px;\n}\n\n/* CSP Test Page Utilities */\n.csp-test-grid-single {\n --columns: 1;\n}\n\n.csp-test-context-area {\n padding: 2rem;\n border: 2px dashed var(--color-border);\n text-align: center;\n}\n\n.csp-test-nav-gap {\n --column-gap: 1rem;\n}\n\n/* Sheet sizing for dialog */\n.csp-test-sheet {\n --sheet-size: 288px;\n}\n\n/* app/assets/stylesheets/rails_pulse/components/datepicker.css */\n@import url(\"https://esm.sh/flatpickr@4.6.13/dist/flatpickr.min.css\");\n\n.flatpickr-calendar {\n --calendar-size: 250px;\n --container-size: 220px;\n --day-size: var(--size-8);\n\n background: var(--color-bg);\n border: 1px solid var(--color-border);\n border-radius: var(--rounded-md);\n box-shadow: var(--shadow-md);\n font-size: var(--text-sm);\n inline-size: var(--calendar-size);\n\n .flatpickr-innerContainer {\n justify-content: center;\n padding-block-end: var(--size-3);\n }\n\n .flatpickr-days {\n inline-size: var(--container-size);\n }\n\n .dayContainer {\n inline-size: var(--container-size);\n min-inline-size: var(--container-size);\n max-inline-size: var(--container-size);\n }\n\n .dayContainer + .dayContainer {\n box-shadow: -1px 0 0 var(--color-border);\n }\n\n .flatpickr-months {\n .flatpickr-month {\n color: var(--color-text);\n }\n\n span.cur-month {\n font-size: var(--text-sm);\n font-weight: var(--font-medium);\n }\n\n svg {\n fill: var(--color-border-dark);\n }\n\n .flatpickr-prev-month:hover svg {\n fill: var(--color-text);\n }\n\n .flatpickr-next-month:hover svg {\n fill: var(--color-text);\n }\n }\n\n .flatpickr-monthDropdown-months {\n appearance: none;\n border-radius: var(--rounded-md);\n font-size: var(--text-sm);\n font-weight: var(--font-medium);\n line-height: var(--leading-normal);\n padding: 0;\n text-align: center;\n\n &:hover {\n background: var(--color-border-light);\n }\n }\n\n .numInputWrapper {\n input {\n border-radius: var(--rounded-md);\n color: var(--color-text);\n font-size: var(--text-sm);\n font-weight: var(--font-medium);\n line-height: var(--leading-normal);\n padding: 0;\n text-align: center;\n }\n\n span {\n border-color: var(--color-border);\n }\n\n span:hover {\n background: transparent;\n }\n\n span.arrowUp::after {\n border-bottom-color: var(--color-text);\n }\n\n span.arrowDown::after {\n border-top-color: var(--color-text);\n }\n\n &:hover {\n background: transparent;\n }\n }\n\n .flatpickr-weekday {\n color: var(--color-text-subtle);\n font-weight: var(--font-normal);\n }\n\n .flatpickr-time {\n .hasTime & {\n border-top-color: var(--color-border);\n }\n\n .hasTime.noCalendar & {\n border: 0;\n }\n\n .numInput {\n background: transparent;\n color: var(--color-text);\n }\n\n .flatpickr-time-separator {\n color: var(--color-text);\n }\n\n .flatpickr-am-pm {\n background: transparent;\n color: var(--color-text);\n }\n }\n\n .flatpickr-day {\n border-radius: var(--rounded-md);\n border-color: transparent !important;\n box-shadow: none !important;\n color: var(--color-text);\n height: var(--day-size);\n line-height: var(--day-size);\n margin-block-start: var(--size-2);\n max-width: var(--day-size);\n\n &:is(.inRange) {\n border-radius: 0;\n }\n\n &:is(.today, .inRange, :hover, :focus) {\n background: var(--color-secondary);\n color: var(--color-text);\n }\n\n &:is(\n .flatpickr-disabled,\n .flatpickr-disabled:hover,\n .prevMonthDay,\n .nextMonthDay,\n .notAllowed,\n .notAllowed.prevMonthDay,\n .notAllowed.nextMonthDay\n ) {\n color: var(--color-text-subtle);\n }\n\n &:is(\n .selected,\n .startRange,\n .endRange,\n .selected.inRange,\n .startRange.inRange,\n .endRange.inRange,\n .selected:focus,\n .startRange:focus,\n .endRange:focus,\n .selected:hover,\n .startRange:hover,\n .endRange:hover,\n .selected.prevMonthDay,\n .startRange.prevMonthDay,\n .endRange.prevMonthDay,\n .selected.nextMonthDay,\n .startRange.nextMonthDay,\n .endRange.nextMonthDay\n ) {\n background: var(--color-primary);\n color: var(--color-text-reversed);\n }\n }\n\n &::before, &::after {\n display: none;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/descriptive_list.css */\n.descriptive-list {\n display: grid;\n grid-template-columns: 200px 1fr;\n gap: 0.5rem;\n}\n\n.descriptive-list dt, .descriptive-list dd {\n font-size: var(--text-sm);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/dialog.css */\n.dialog {\n background-color: var(--color-bg);\n border-radius: var(--rounded-lg);\n border-width: var(--border);\n box-shadow: var(--shadow-lg);\n color: var(--color-text);\n inline-size: var(--size-full);\n margin: auto;\n max-inline-size: var(--dialog-size, var(--max-i-lg));\n\n &::backdrop {\n background-color: rgba(0, 0, 0, .8);\n }\n\n /* Final state of exit animation and setup */\n opacity: 0;\n transform: var(--scale-95);\n transition-behavior: allow-discrete;\n transition-duration: var(--time-200);\n transition-property: display, overlay, opacity, transform;\n\n &::backdrop {\n opacity: 0;\n transition-behavior: allow-discrete;\n transition-duration: var(--time-200);\n transition-property: display, overlay, opacity;\n }\n\n /* Final state of entry animation */\n &[open] { opacity: 1; transform: var(--scale-100); }\n &[open]::backdrop { opacity: 1; }\n\n /* Initial state of entry animation */\n @starting-style {\n &[open] { opacity: 0; transform: var(--scale-95); }\n &[open]::backdrop { opacity: 0; }\n }\n\n /* Drawer component on mobile */\n @media (width < 40rem) {\n border-end-end-radius: 0;\n border-end-start-radius: 0;\n margin-block-end: 0;\n max-inline-size: none;\n }\n}\n\n.dialog__content {\n padding: var(--size-6);\n}\n\n.dialog__close {\n inset-block-start: var(--size-3);\n inset-inline-end: var(--size-3);\n position: absolute;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/flash.css */\n.flash {\n align-items: center;\n animation: appear-then-fade 4s 300ms both;\n backdrop-filter: var(--blur-sm) var(--contrast-75);\n background-color: var(--flash-background, rgb(from var(--color-text) r g b / .65));\n border-radius: var(--rounded-full);\n color: var(--flash-color, var(--color-text-reversed));\n column-gap: var(--size-2);\n display: flex;\n font-size: var(--text-fluid-base);\n justify-content: center;\n line-height: var(--leading-none);\n margin-block-start: var(--flash-position, var(--size-4));\n margin-inline: auto;\n min-block-size: var(--size-11);\n padding: var(--size-1) var(--size-4);\n text-align: center;\n\n [data-turbo-preview] & {\n display: none;\n }\n}\n\n.flash--positive {\n --flash-background: var(--color-positive);\n --flash-color: white;\n}\n\n.flash--negative {\n --flash-background: var(--color-negative);\n --flash-color: white;\n}\n\n.flash--extended {\n animation-name: appear-then-fade-extended;\n animation-duration: 12s;\n}\n\n@keyframes appear-then-fade {\n 0%, 100% { opacity: 0; }\n 5%, 60% { opacity: 1; }\n}\n\n@keyframes appear-then-fade-extended {\n 0%, 100% { opacity: 0; }\n 2%, 90% { opacity: 1; }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/input.css */\n.input {\n appearance: none;\n background-color: var(--input-background, transparent);\n block-size: var(--input-block-size, auto);\n border: 1px solid var(--input-border-color, var(--color-border));\n border-radius: var(--input-radius, var(--rounded-md));\n box-shadow: var(--input-box-shadow, var(--shadow-xs));\n font-size: var(--input-font-size, var(--text-sm));\n inline-size: var(--input-inline-size, var(--size-full));\n padding: var(--input-padding, .375rem .75rem);\n\n &:is(textarea[rows=auto]) {\n field-sizing: content;\n max-block-size: calc(.875rem + var(--input-max-rows, 10lh));\n min-block-size: calc(.875rem + var(--input-rows, 2lh));\n }\n\n &:is(select):not([multiple], [size]) {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m6 9 6 6 6-6'/%3e%3c/svg%3e\");\n background-position: center right var(--size-2);\n background-repeat: no-repeat;\n background-size: var(--size-4) auto;\n }\n\n &::file-selector-button {\n font-weight: var(--font-medium);\n }\n\n &:user-invalid {\n border-color: var(--color-negative);\n }\n\n &:user-invalid ~ .invalid-feedback {\n display: flex;\n }\n\n &:disabled {\n cursor: not-allowed; opacity: var(--opacity-50);\n }\n}\n\n.input--actor {\n input {\n border: 0; inline-size: 100%; outline: 0;\n }\n\n img:not([class]) {\n filter: var(--input-icon-color, var(--color-filter-text));\n }\n\n &:focus-within {\n outline: var(--input-outline-size, 2px) solid var(--color-selected-dark);\n }\n}\n\n.invalid-feedback {\n display: none;\n}\n\n:is(.checkbox, .radio) {\n transform: scale(1.2);\n}\n\n:is(.checkbox, .radio, .range) {\n accent-color: var(--color-primary);\n}\n\n:is(.input, .checkbox, .radio, .range) {\n &:focus-visible {\n outline: var(--input-outline-size, 2px) solid var(--color-selected-dark);\n }\n\n &:focus-visible:user-invalid {\n outline: none;\n }\n\n .field_with_errors & {\n border-color: var(--color-negative); display: contents;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/layouts.css */\n.sidebar-layout {\n display: grid;\n grid-template-areas: \"header header\" \"sidebar main\";\n grid-template-columns: var(--sidebar-width, 0) 1fr;\n grid-template-rows: auto 1fr;\n block-size: 100dvh;\n\n @media (width >= 48rem) {\n --sidebar-border-width: var(--border);\n --sidebar-padding: var(--size-2);\n --sidebar-width: var(--max-i-3xs);\n }\n}\n\n.header-layout {\n display: grid;\n grid-template-areas: \"header\" \"main\";\n grid-template-rows: auto 1fr;\n block-size: 100dvh;\n}\n\n.centered-layout {\n display: grid;\n place-items: center;\n block-size: 100dvh;\n}\n\n.container {\n inline-size: 100%;\n margin-inline: auto;\n max-inline-size: var(--container-width, 80rem);\n}\n\n#header {\n align-items: center;\n background-color: rgb(from var(--color-border-light) r g b / .5);\n border-block-end-width: var(--border);\n block-size: var(--size-16);\n column-gap: var(--size-4);\n display: flex;\n grid-area: header;\n padding-inline: var(--size-4);\n}\n\n#sidebar {\n background-color: rgb(from var(--color-border-light) r g b / .5);\n border-inline-end-width: var(--sidebar-border-width, 0);\n display: flex;\n flex-direction: column;\n grid-area: sidebar;\n overflow-x: hidden;\n padding: var(--sidebar-padding, 0);\n row-gap: var(--size-2);\n}\n\n#main {\n display: flex;\n flex-direction: column;\n gap: var(--size-4);\n grid-area: main;\n overflow: auto;\n padding: var(--size-4);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/menu.css */\n.menu {\n display: flex;\n flex-direction: column;\n padding: var(--size-1);\n row-gap: var(--size-1);\n}\n\n.menu__header {\n font-size: var(--text-sm);\n font-weight: var(--font-semibold);\n padding: var(--size-1_5) var(--size-2);\n}\n\n.menu__group {\n display: flex;\n flex-direction: column;\n row-gap: 1px;\n}\n\n.menu__separator {\n margin-inline: -0.25rem;\n}\n\n.menu__item {\n --btn-border-color: transparent;\n --btn-box-shadow: none;\n --btn-font-weight: var(--font-normal);\n --btn-hover-color: var(--color-secondary);\n --btn-justify-content: start;\n --btn-outline-size: 0;\n --btn-padding: var(--size-1_5) var(--size-2);\n --btn-text-align: start;\n\n &:focus-visible {\n --btn-background: var(--color-secondary);\n }\n}\n\n.menu__item-key {\n color: var(--color-text-subtle);\n font-size: var(--text-xs);\n margin-inline-start: auto;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/popover.css */\n.popover {\n background-color: var(--color-bg);\n border-radius: var(--rounded-md);\n border-width: var(--border);\n box-shadow: var(--shadow-md);\n color: var(--color-text);\n inline-size: var(--popover-size, max-content);\n\n /* Final state of exit animation and setup */\n opacity: 0;\n transform: var(--scale-95);\n transition-behavior: allow-discrete;\n transition-duration: var(--time-150);\n transition-property: display, overlay, opacity, transform;\n\n /* Final state of entry animation */\n &:popover-open {\n opacity: 1; transform: var(--scale-100);\n }\n\n /* Initial state of entry animation */\n @starting-style {\n &:popover-open {\n opacity: 0; transform: var(--scale-95);\n }\n }\n\n /* Positioning rules for Floating UI */\n &.positioned {\n position: fixed !important;\n left: var(--popover-x, 0) !important;\n top: var(--popover-y, 0) !important;\n margin: 0 !important;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/prose.css */\n.prose {\n font-size: var(--text-fluid-base);\n max-inline-size: 65ch;\n\n /* Antialiased fonts */\n -moz-osx-font-smoothing: grayscale;\n -webkit-font-smoothing: antialiased;\n\n :is(h1, h2, h3, h4, h5, h6) {\n font-weight: var(--font-extrabold);\n hyphens: auto;\n letter-spacing: -0.02ch;\n line-height: 1.1;\n margin-block: 0.5em;\n overflow-wrap: break-word;\n text-wrap: balance;\n }\n\n h1 {\n font-size: 2.4em;\n }\n\n h2 {\n font-size: 1.8em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.2em;\n }\n\n h5 {\n font-size: 1em;\n }\n\n h6 {\n font-size: 0.8em;\n }\n\n :is(ul, ol, menu) {\n list-style: revert;\n padding-inline-start: revert;\n }\n\n :is(p, ul, ol, dl, blockquote, pre, figure, table, hr) {\n margin-block: 0.65lh;\n overflow-wrap: break-word;\n text-wrap: pretty;\n }\n\n hr {\n border-color: var(--color-border-dark);\n border-style: var(--border-style, solid) none none;\n margin: 2lh auto;\n }\n\n :is(b, strong) {\n font-weight: var(--font-bold);\n }\n\n :is(pre, code) {\n background-color: var(--color-border-light);\n border: 1px solid var(--color-border);\n border-radius: var(--rounded-sm);\n font-family: var(--font-monospace-code);\n font-size: 0.85em;\n }\n\n code {\n padding: 0.1em 0.3em;\n }\n\n pre {\n border-radius: 0.5em;\n overflow-x: auto;\n padding: 0.5lh 2ch;\n text-wrap: nowrap;\n }\n\n pre code {\n background-color: transparent;\n border: 0;\n font-size: 1em;\n padding: 0;\n }\n\n p {\n hyphens: auto;\n letter-spacing: -0.005ch;\n }\n\n blockquote {\n font-style: italic;\n margin: 0 3ch;\n }\n\n blockquote p {\n hyphens: none;\n }\n\n table {\n border: 1px solid var(--color-border-dark);\n border-collapse: collapse;\n margin: 1lh 0;\n }\n\n th {\n font-weight: var(--font-bold);\n }\n\n :is(th, td) {\n border: 1px solid var(--color-border-dark);\n padding: 0.2lh 1ch;\n text-align: start;\n }\n\n th {\n border-block-end-width: 3px;\n }\n\n del {\n background-color: rgb(from var(--color-negative) r g b / .1);\n color: var(--color-negative);\n }\n\n ins {\n background-color: rgb(from var(--color-positive) r g b / .1);\n color: var(--color-positive);\n }\n\n a {\n color: var(--color-link);\n text-decoration: underline;\n text-decoration-skip-ink: auto;\n }\n\n mark {\n color: var(--color-text);\n background-color: var(--color-highlight);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/row.css */\n.row {\n display: flex;\n justify-content: space-between;\n width: 100%;\n gap: var(--column-gap, 0.5rem);\n align-items: stretch;\n}\n\n.row > * {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n}\n\n/* Ensure metric cards and their panels stretch to full height */\n.row > .grid-item {\n display: flex;\n flex-direction: column;\n}\n\n.row > .grid-item > * {\n flex: 1;\n}\n\n/* Responsive layout for screens smaller than 768px */\n@media (max-width: 768px) {\n .row {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n gap: 0.5rem;\n align-items: flex-start;\n }\n\n .row > * {\n flex: 0 0 calc(50% - 0.25rem);\n min-width: 0;\n height: auto;\n }\n\n .row > .grid-item {\n height: auto;\n }\n\n .row > .grid-item > * {\n flex: none;\n }\n\n /* Tables should stack in single column on mobile */\n .row:has(.table-container) > * {\n flex: 0 0 100%;\n }\n\n /* Single column for very small screens */\n @media (max-width: 480px) {\n .row > * {\n flex: 0 0 100%;\n }\n\n .row > .grid-item {\n min-height: auto;\n }\n\n /* Make metric cards more compact on mobile */\n .row > .grid-item .card {\n padding: var(--size-3);\n }\n\n /* Make charts smaller on mobile */\n .row > .grid-item .chart-container {\n height: 60px !important;\n max-height: 60px;\n }\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/sidebar_menu.css */\n.sidebar-menu {\n display: flex;\n flex-direction: column;\n row-gap: var(--size-4);\n block-size: var(--size-full);\n}\n\n.sidebar-menu__button {\n --btn-background: transparent;\n --btn-border-color: transparent;\n --btn-box-shadow: none;\n --btn-font-weight: var(--font-normal);\n --btn-hover-color: var(--color-secondary);\n --btn-justify-content: start;\n --btn-outline-size: 0;\n --btn-inline-size: var(--size-full);\n --btn-padding: var(--size-1) var(--size-2);\n --btn-text-align: start;\n\n &:focus-visible {\n --btn-background: var(--color-secondary);\n }\n\n &:is(summary) {\n &::after {\n background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m9 18 6-6-6-6'/%3e%3c/svg%3e\");\n background-size: cover;\n block-size: var(--size-4);\n content: \"\";\n filter: var(--color-filter-text);\n inline-size: var(--size-4);\n margin-inline-start: auto;\n min-inline-size: var(--size-4);\n transition: transform var(--time-200);\n }\n\n details[open] > &::after {\n transform: var(--rotate-90);\n }\n\n &::-webkit-details-marker {\n display: none;\n }\n }\n}\n\n.sidebar-menu__content {\n display: flex;\n flex-direction: column;\n row-gap: var(--size-4);\n overflow-y: scroll;\n}\n\n.sidebar-menu__group {\n display: flex;\n flex-direction: column;\n}\n\n.sidebar-menu__group-label {\n color: var(--color-text-subtle);\n font-size: var(--text-xs);\n font-weight: var(--font-medium);\n padding: var(--size-1_5) var(--size-2);\n}\n\n.sidebar-menu__items {\n display: flex;\n flex-direction: column;\n row-gap: var(--size-1);\n}\n\n.sidebar-menu__sub {\n border-inline-start-width: var(--border);\n display: flex;\n flex-direction: column;\n margin-inline-start: var(--size-4);\n padding: var(--size-0_5) var(--size-2);\n row-gap: var(--size-1);\n}\n\n/* Sheet component styles for mobile menu */\n.sheet {\n border: 0;\n background: var(--color-bg);\n max-block-size: none;\n max-inline-size: none;\n padding: 0;\n}\n\n.sheet--left {\n block-size: 100vh;\n inline-size: var(--sheet-size, 288px);\n inset-block-start: 0;\n inset-inline-start: 0;\n}\n\n.sheet__content {\n block-size: 100%;\n display: flex;\n flex-direction: column;\n overflow-y: auto;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/skeleton.css */\n.skeleton {\n animation: var(--animate-blink);\n border-radius: var(--rounded-md);\n background-color: var(--color-border-light);\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/switch.css */\n.switch {\n appearance: none;\n background-color: var(--color-border);\n border-color: transparent;\n border-radius: var(--rounded-full);\n border-width: var(--border-2);\n block-size: var(--size-5);\n inline-size: var(--size-9);\n transition: background-color var(--time-150);\n\n &:checked {\n background-color: var(--color-primary);\n }\n\n &:checked::before {\n margin-inline-start: var(--size-4);\n }\n\n &::before {\n aspect-ratio: var(--aspect-square);\n background-color: var(--color-text-reversed);\n block-size: var(--size-full);\n border-radius: var(--rounded-full);\n content: \"\";\n display: block;\n transition: margin var(--time-150);\n }\n\n &:focus-visible {\n outline: var(--border-2) solid var(--color-selected-dark);\n }\n\n &:disabled {\n cursor: not-allowed; opacity: var(--opacity-50);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/table.css */\n:where(.table) {\n caption-side: bottom;\n font-size: var(--text-sm);\n inline-size: var(--size-full);\n\n caption {\n color: var(--color-text-subtle);\n margin-block-start: var(--size-4);\n }\n\n thead {\n color: var(--color-text-subtle);\n }\n\n tbody tr {\n border-block-start-width: var(--border);\n }\n\n tr:hover {\n background-color: rgb(from var(--color-border-light) r g b / .5);\n }\n\n th {\n font-weight: var(--font-medium);\n text-align: start;\n }\n\n th, td {\n padding: var(--size-2);\n }\n\n tfoot {\n background-color: rgb(from var(--color-border-light) r g b / .5);\n border-block-start-width: var(--border);\n font-weight: var(--font-medium);\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/tags.css */\n/* Tag Manager Container */\n.breadcrumb-container {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n flex-wrap: wrap;\n}\n\n.breadcrumb-tags {\n display: flex;\n align-items: center;\n}\n\n/* Tag Manager */\n.tag-manager {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n.tag-list {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n flex-wrap: wrap;\n}\n\n.tag-list span {\n padding-right: 3px !important;\n}\n\n/* Individual Tag */\n.tag {\n display: inline-flex;\n align-items: center;\n gap: 0.25rem;\n padding: 0.25rem 0.5rem;\n background-color: var(--color-background-secondary);\n border: 1px solid var(--color-border);\n border-radius: 0.375rem;\n font-size: 0.875rem;\n line-height: 1.25;\n white-space: nowrap;\n}\n\n/* Tag Remove Button */\n.tag-remove {\n all: unset;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1rem;\n height: 1rem;\n padding: 0;\n margin: 0;\n background: none;\n border: none;\n cursor: pointer;\n color: currentColor;\n opacity: 0.6;\n transition: opacity 0.15s ease;\n}\n\n.tag-remove:hover {\n opacity: 1;\n}\n\n.tag-remove span {\n line-height: 1;\n font-weight: inherit;\n margin-left: 6px;\n font-size: 17px;\n}\n\n/* Add Tag Container */\n.tag-add-container {\n position: relative;\n display: inline-block;\n}\n\n.tag-add-button {\n padding: 0.2rem 0.5rem;\n font-size: 0.8rem;\n line-height: 1.25;\n white-space: nowrap;\n}\n\n/* Responsive Design */\n@media (max-width: 768px) {\n .breadcrumb-container {\n flex-direction: column;\n align-items: flex-start;\n }\n\n .breadcrumb-tags {\n width: 100%;\n }\n\n .tag-manager {\n width: 100%;\n }\n}\n\n\n/* app/assets/stylesheets/rails_pulse/components/utilities.css */\n/* Width utilities */\n.w-auto { width: auto; }\n.w-4 { width: 1rem; }\n.w-6 { width: 1.5rem; }\n.w-8 { width: 2rem; }\n.w-12 { width: 3rem; }\n.w-16 { width: 4rem; }\n.w-20 { width: 5rem; }\n.w-24 { width: 6rem; }\n.w-28 { width: 7rem; }\n.w-32 { width: 8rem; }\n.w-36 { width: 9rem; }\n.w-40 { width: 10rem; }\n.w-44 { width: 11rem; }\n.w-48 { width: 12rem; }\n.w-52 { width: 13rem; }\n.w-56 { width: 14rem; }\n.w-60 { width: 15rem; }\n.w-64 { width: 16rem; }\n\n/* Min-width utilities */\n.min-w-0 { min-width: 0; }\n.min-w-4 { min-width: 1rem; }\n.min-w-8 { min-width: 2rem; }\n.min-w-12 { min-width: 3rem; }\n.min-w-16 { min-width: 4rem; }\n.min-w-20 { min-width: 5rem; }\n.min-w-24 { min-width: 6rem; }\n.min-w-32 { min-width: 8rem; }\n\n/* Max-width utilities */\n.max-w-xs { max-width: 20rem; }\n.max-w-sm { max-width: 24rem; }\n.max-w-md { max-width: 28rem; }\n.max-w-lg { max-width: 32rem; }\n.max-w-xl { max-width: 36rem; }\n\n/* Global filters active indicator */\n.global-filters-active {\n position: relative;\n}\n\n.global-filters-active::after {\n content: \"\";\n position: absolute;\n top: -2px;\n right: -2px;\n width: 8px;\n height: 8px;\n background-color: var(--color-primary);\n border-radius: 50%;\n border: 2px solid var(--color-bg);\n}\n\n/* Flatpickr z-index fix - ensure calendar appears above dialogs */\n.flatpickr-calendar,\n.flatpickr-calendar.open,\n.flatpickr-calendar.inline,\n.flatpickr-calendar.static,\n.flatpickr-calendar.static.open {\n z-index: 999999 !important;\n}\n\n\n/* app/assets/stylesheets/rails_pulse/application.css */\n* {\n font-family: AvenirNextPro, sans-serif\n}\n\na {\n text-decoration: underline;\n color: var(--color-link);\n}\n\n#header {\n background-color: var(--header-bg);\n}\n\n#header a {\n color: var(--header-link);\n text-decoration: none;\n}\n\n#header a:hover {\n background-color: transparent;\n text-decoration: underline;\n}\n\na:hover {\n cursor: pointer;\n}\n\n/* Dark mode */\n\n/* Dark scheme tweaks via component variables */\nhtml[data-color-scheme=\"dark\"] .card {\n /* Scope card surfaces slightly darker than page */\n --color-bg: rgb(47, 47, 47);\n --color-border: rgb(64, 64, 64);\n}\n\n/* Header colors are handled by --header-* tokens in base.css */\n\nhtml[data-color-scheme=\"dark\"] .badge--positive-inverse,\nhtml[data-color-scheme=\"dark\"] .badge--negative-inverse {\n --badge-background: rgb(47, 47, 47);\n}\n\nhtml[data-color-scheme=\"dark\"] .input {\n --input-background: #535252;\n --input-border-color: #7e7d7d;\n}\n\n.hidden {\n display: none;\n}\n\n/* REQUEST OPERATIONS GRAPH */\n.operations-table {\n width: 100%;\n}\n\n.operations-table tr {\n cursor: pointer;\n}\n\n.operations-label-cell {\n width: 380px;\n max-width: 380px;\n min-width: 120px;\n padding-right: 10px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n vertical-align: middle;\n}\n.operations-label-cell span {\n font-family: 'Times New Roman', Times, serif;\n}\n\n.operations-duration-cell {\n width: 60px;\n max-width: 100px;\n}\n\n.operations-event-cell {\n position: relative;\n background: none;\n padding: 0;\n}\n\n.operations-event {\n box-sizing: border-box;\n height: 16px;\n padding: 2px;\n position: absolute;\n top: 20px;\n}\n\n/* REQUEST OPERATIONS BAR */\n.bar-container {\n height:10px;\n position:relative\n}\n.bar {\n background-color:#727579;\n height:100%;\n position:absolute;\n top:0\n}\n.bar:first-child {\n border-bottom-left-radius:1px;\n border-top-left-radius:1px\n}\n.bar:last-child {\n border-bottom-right-radius:1px;\n border-top-right-radius:1px\n}\n\n\n/* vendor/css-zero/utilities.css */\n/****************************************************************\n* Flex\n*****************************************************************/\n.flex { display: flex; }\n.flex-col { flex-direction: column; }\n.flex-wrap { flex-wrap: wrap; }\n.inline-flex { display: inline-flex; }\n\n.justify-start { justify-content: start; }\n.justify-center { justify-content: center; }\n.justify-end { justify-content: end; }\n.justify-between { justify-content: space-between; }\n\n.items-start { align-items: start; }\n.items-end { align-items: end; }\n.items-center { align-items: center; }\n\n.grow { flex-grow: 1; }\n.grow-0\t{ flex-grow: 0; }\n\n.shrink { flex-shrink: 1; }\n.shrink-0 { flex-shrink: 0; }\n\n.self-start { align-self: start; }\n.self-end { align-self: end; }\n.self-center { align-self: center; }\n\n.gap { column-gap: var(--column-gap, 0.5rem); row-gap: var(--row-gap, 1rem); }\n.gap-half { column-gap: 0.25rem; row-gap: 0.5rem; }\n\n/****************************************************************\n* Text\n*****************************************************************/\n.font-normal { font-weight: var(--font-normal); }\n.font-medium { font-weight: var(--font-medium); }\n.font-semibold { font-weight: var(--font-semibold); }\n.font-bold { font-weight: var(--font-bold); }\n\n.underline { text-decoration: underline; }\n.no-underline\t{ text-decoration: none; }\n\n.uppercase { text-transform: uppercase; }\n.normal-case { text-transform: none; }\n\n.whitespace-nowrap { white-space: nowrap; }\n.whitespace-normal { white-space: normal; }\n\n.break-words { overflow-wrap: break-word; }\n.break-all { word-break: break-all; }\n\n.overflow-clip { text-overflow: clip; white-space: nowrap; overflow: hidden; }\n.overflow-ellipsis { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }\n\n.opacity-75 { opacity: var(--opacity-75); }\n.opacity-50 { opacity: var(--opacity-50); }\n\n.leading-none { line-height: var(--leading-none); }\n.leading-tight { line-height: var(--leading-tight); }\n\n.text-start { text-align: start; }\n.text-end { text-align: end; }\n.text-center { text-align: center; }\n\n.text-primary { color: var(--color-text); }\n.text-reversed { color: var(--color-text-reversed); }\n.text-negative { color: var(--color-negative); }\n.text-positive { color: var(--color-positive); }\n.text-subtle { color: var(--color-text-subtle); }\n\n.text-xs { font-size: var(--text-xs); }\n.text-sm { font-size: var(--text-sm); }\n.text-base { font-size: var(--text-base); }\n.text-lg { font-size: var(--text-lg); }\n.text-xl { font-size: var(--text-xl); }\n.text-2xl { font-size: var(--text-2xl); }\n.text-3xl { font-size: var(--text-3xl); }\n.text-4xl { font-size: var(--text-4xl); }\n.text-5xl { font-size: var(--text-5xl); }\n\n.text-fluid-xs { font-size: var(--text-fluid-xs); }\n.text-fluid-sm { font-size: var(--text-fluid-sm); }\n.text-fluid-base { font-size: var(--text-fluid-base); }\n.text-fluid-lg { font-size: var(--text-fluid-lg); }\n.text-fluid-xl { font-size: var(--text-fluid-xl); }\n.text-fluid-2xl { font-size: var(--text-fluid-2xl); }\n.text-fluid-3xl { font-size: var(--text-fluid-3xl); }\n\n/****************************************************************\n* Background\n*****************************************************************/\n.bg-main { background-color: var(--color-bg); }\n.bg-black { background-color: var(--color-text); }\n.bg-white { background-color: var(--color-text-reversed); }\n.bg-shade { background-color: var(--color-border-light); }\n.bg-transparent { background-color: transparent; }\n\n/****************************************************************\n* SVG colors\n*****************************************************************/\n.colorize-black { filter: var(--color-filter-text); }\n.colorize-white { filter: var(--color-filter-text-reversed); }\n.colorize-negative { filter: var(--color-filter-negative); }\n.colorize-positive { filter: var(--color-filter-positive); }\n\n/****************************************************************\n* Border\n*****************************************************************/\n.border-0 { border-width: 0; }\n.border { border-width: var(--border-size, 1px); }\n\n.border-b { border-block-width: var(--border-size, 1px); }\n.border-bs { border-block-start-width: var(--border-size, 1px); }\n.border-be { border-block-end-width: var(--border-size, 1px); }\n\n.border-i { border-inline-width: var(--border-size, 1px); }\n.border-is { border-inline-start-width: var(--border-size, 1px); }\n.border-ie { border-inline-end-width: var(--border-size, 1px); }\n\n.border-main { border-color: var(--color-border); }\n.border-dark { border-color: var(--color-border-dark); }\n\n.rounded-none { border-radius: 0; }\n.rounded-xs { border-radius: var(--rounded-xs); }\n.rounded-sm { border-radius: var(--rounded-sm); }\n.rounded-md { border-radius: var(--rounded-md); }\n.rounded-lg { border-radius: var(--rounded-lg); }\n.rounded-full { border-radius: var(--rounded-full); }\n\n/****************************************************************\n* Shadow\n*****************************************************************/\n.shadow-none { box-shadow: none; }\n.shadow-xs { box-shadow: var(--shadow-xs); }\n.shadow-sm { box-shadow: var(--shadow-sm); }\n.shadow-md { box-shadow: var(--shadow-md); }\n.shadow-lg { box-shadow: var(--shadow-lg); }\n\n/****************************************************************\n* Layout\n*****************************************************************/\n.block { display: block; }\n.inline { display: inline; }\n.inline-block { display: inline-block; }\n\n.relative { position: relative; }\n.sticky\t{ position: sticky; }\n\n.min-i-0 { min-inline-size: 0; }\n.max-i-none { max-inline-size: none; }\n.max-i-full { max-inline-size: 100%; }\n\n.b-full { block-size: 100%; }\n.i-full { inline-size: 100%; }\n\n.i-min { inline-size: min-content; }\n\n.overflow-x-auto { overflow-x: auto; scroll-snap-type: x mandatory; }\n.overflow-y-auto { overflow-y: auto; scroll-snap-type: y mandatory; }\n.overflow-hidden { overflow: hidden; }\n\n.object-contain\t{ object-fit: contain; }\n.object-cover {\tobject-fit: cover; }\n\n.aspect-square { aspect-ratio: 1; }\n.aspect-widescreen { aspect-ratio: 16 / 9; }\n\n/****************************************************************\n* Margin\n*****************************************************************/\n.m-0 { margin: 0; }\n.m-1 { margin: var(--size-1); }\n.m-2 { margin: var(--size-2); }\n.m-3 { margin: var(--size-3); }\n.m-4 { margin: var(--size-4); }\n.m-5 { margin: var(--size-5); }\n.m-6 { margin: var(--size-6); }\n.m-8 { margin: var(--size-8); }\n.m-10 { margin: var(--size-10); }\n.m-auto { margin: auto; }\n\n.mb-0 { margin-block: 0; }\n.mb-1 { margin-block: var(--size-1); }\n.mb-2 { margin-block: var(--size-2); }\n.mb-3 { margin-block: var(--size-3); }\n.mb-4 { margin-block: var(--size-4); }\n.mb-5 { margin-block: var(--size-5); }\n.mb-6 { margin-block: var(--size-6); }\n.mb-8 { margin-block: var(--size-8); }\n.mb-10 { margin-block: var(--size-10); }\n.mb-auto { margin-block: auto; }\n\n.mbs-0 { margin-block-start: 0; }\n.mbs-1 { margin-block-start: var(--size-1); }\n.mbs-2 { margin-block-start: var(--size-2); }\n.mbs-3 { margin-block-start: var(--size-3); }\n.mbs-4 { margin-block-start: var(--size-4); }\n.mbs-5 { margin-block-start: var(--size-5); }\n.mbs-6 { margin-block-start: var(--size-6); }\n.mbs-8 { margin-block-start: var(--size-8); }\n.mbs-10 { margin-block-start: var(--size-10); }\n.mbs-auto { margin-block-start: auto; }\n\n.mbe-0 { margin-block-end: 0; }\n.mbe-1 { margin-block-end: var(--size-1); }\n.mbe-2 { margin-block-end: var(--size-2); }\n.mbe-3 { margin-block-end: var(--size-3); }\n.mbe-4 { margin-block-end: var(--size-4); }\n.mbe-5 { margin-block-end: var(--size-5); }\n.mbe-6 { margin-block-end: var(--size-6); }\n.mbe-8 { margin-block-end: var(--size-8); }\n.mbe-10 { margin-block-end: var(--size-10); }\n.mbe-auto { margin-block-end: auto; }\n\n.mi-0 { margin-inline: 0; }\n.mi-1 { margin-inline: var(--size-1); }\n.mi-2 { margin-inline: var(--size-2); }\n.mi-3 { margin-inline: var(--size-3); }\n.mi-4 { margin-inline: var(--size-4); }\n.mi-5 { margin-inline: var(--size-5); }\n.mi-6 { margin-inline: var(--size-6); }\n.mi-8 { margin-inline: var(--size-8); }\n.mi-10 { margin-inline: var(--size-10); }\n.mi-auto { margin-inline: auto; }\n\n.mis-0 { margin-inline-start: 0; }\n.mis-1 { margin-inline-start: var(--size-1); }\n.mis-2 { margin-inline-start: var(--size-2); }\n.mis-3 { margin-inline-start: var(--size-3); }\n.mis-4 { margin-inline-start: var(--size-4); }\n.mis-5 { margin-inline-start: var(--size-5); }\n.mis-6 { margin-inline-start: var(--size-6); }\n.mis-8 { margin-inline-start: var(--size-8); }\n.mis-10 { margin-inline-start: var(--size-10); }\n.mis-auto { margin-inline-start: auto; }\n\n.mie-0 { margin-inline-end: 0; }\n.mie-1 { margin-inline-end: var(--size-1); }\n.mie-2 { margin-inline-end: var(--size-2); }\n.mie-3 { margin-inline-end: var(--size-3); }\n.mie-4 { margin-inline-end: var(--size-4); }\n.mie-5 { margin-inline-end: var(--size-5); }\n.mie-6 { margin-inline-end: var(--size-6); }\n.mie-8 { margin-inline-end: var(--size-8); }\n.mie-10 { margin-inline-end: var(--size-10); }\n.mie-auto { margin-inline-end: auto; }\n\n/****************************************************************\n* Padding\n*****************************************************************/\n.p-0 { padding: 0; }\n.p-1 { padding: var(--size-1); }\n.p-2 { padding: var(--size-2); }\n.p-3 { padding: var(--size-3); }\n.p-4 { padding: var(--size-4); }\n.p-5 { padding: var(--size-5); }\n.p-6 { padding: var(--size-6); }\n.p-8 { padding: var(--size-8); }\n.p-10 { padding: var(--size-10); }\n\n.pb-0 { padding-block: 0; }\n.pb-1 { padding-block: var(--size-1); }\n.pb-2 { padding-block: var(--size-2); }\n.pb-3 { padding-block: var(--size-3); }\n.pb-4 { padding-block: var(--size-4); }\n.pb-5 { padding-block: var(--size-5); }\n.pb-6 { padding-block: var(--size-6); }\n.pb-8 { padding-block: var(--size-8); }\n.pb-10 { padding-block: var(--size-10); }\n\n.pbs-0 { padding-block-start: 0; }\n.pbs-1 { padding-block-start: var(--size-1); }\n.pbs-2 { padding-block-start: var(--size-2); }\n.pbs-3 { padding-block-start: var(--size-3); }\n.pbs-4 { padding-block-start: var(--size-4); }\n.pbs-5 { padding-block-start: var(--size-5); }\n.pbs-6 { padding-block-start: var(--size-6); }\n.pbs-8 { padding-block-start: var(--size-8); }\n.pbs-10 { padding-block-start: var(--size-10); }\n\n.pbe-0 { padding-block-end: 0; }\n.pbe-1 { padding-block-end: var(--size-1); }\n.pbe-2 { padding-block-end: var(--size-2); }\n.pbe-3 { padding-block-end: var(--size-3); }\n.pbe-4 { padding-block-end: var(--size-4); }\n.pbe-5 { padding-block-end: var(--size-5); }\n.pbe-6 { padding-block-end: var(--size-6); }\n.pbe-8 { padding-block-end: var(--size-8); }\n.pbe-10 { padding-block-end: var(--size-10); }\n\n.pi-0 { padding-inline: 0; }\n.pi-1 { padding-inline: var(--size-1); }\n.pi-2 { padding-inline: var(--size-2); }\n.pi-3 { padding-inline: var(--size-3); }\n.pi-4 { padding-inline: var(--size-4); }\n.pi-5 { padding-inline: var(--size-5); }\n.pi-6 { padding-inline: var(--size-6); }\n.pi-8 { padding-inline: var(--size-8); }\n.pi-10 { padding-inline: var(--size-10); }\n\n.pis-0 { padding-inline-start: 0; }\n.pis-1 { padding-inline-start: var(--size-1); }\n.pis-2 { padding-inline-start: var(--size-2); }\n.pis-3 { padding-inline-start: var(--size-3); }\n.pis-4 { padding-inline-start: var(--size-4); }\n.pis-5 { padding-inline-start: var(--size-5); }\n.pis-6 { padding-inline-start: var(--size-6); }\n.pis-8 { padding-inline-start: var(--size-8); }\n.pis-10 { padding-inline-start: var(--size-10); }\n\n.pie-0 { padding-inline-end: 0; }\n.pie-1 { padding-inline-end: var(--size-1); }\n.pie-2 { padding-inline-end: var(--size-2); }\n.pie-3 { padding-inline-end: var(--size-3); }\n.pie-4 { padding-inline-end: var(--size-4); }\n.pie-5 { padding-inline-end: var(--size-5); }\n.pie-6 { padding-inline-end: var(--size-6); }\n.pie-8 { padding-inline-end: var(--size-8); }\n.pie-10 { padding-inline-end: var(--size-10); }\n\n/****************************************************************\n* Hiding/Showing\n*****************************************************************/\n.show\\@sm, .show\\@md, .show\\@lg, .show\\@xl { display: none; }\n\n.show\\@sm { @media (width >= 40rem) { display: flex; } }\n.show\\@md { @media (width >= 48rem) { display: flex; } }\n.show\\@lg { @media (width >= 64rem) { display: flex; } }\n.show\\@xl { @media (width >= 80rem) { display: flex; } }\n\n.hide\\@sm { @media (width >= 40rem) { display: none; } }\n.hide\\@md { @media (width >= 48rem) { display: none; } }\n.hide\\@lg { @media (width >= 64rem) { display: none; } }\n.hide\\@xl { @media (width >= 80rem) { display: none; } }\n\n.hide\\@pwa { @media (display-mode: standalone) { display: none; } }\n.hide\\@browser { @media (display-mode: browser) { display: none; } }\n\n.hide\\@print { @media print { display: none; } }\n\n/****************************************************************\n* Accessibility\n*****************************************************************/\n.sr-only { block-size: 1px; clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap; }\n\n"]} \ No newline at end of file diff --git a/scripts/benchmark_performance.rb b/scripts/benchmark_performance.rb new file mode 100755 index 0000000..7294e57 --- /dev/null +++ b/scripts/benchmark_performance.rb @@ -0,0 +1,425 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Comprehensive performance benchmark script for Rails Pulse +# This script measures the real-world impact of Rails Pulse on a Rails application + +require "bundler/setup" +require "benchmark" +require "benchmark/ips" +require "memory_profiler" + +# Load Rails environment +require_relative "../test/dummy/config/environment" + +class RailsPulseBenchmark + WARMUP_TIME = 2 + BENCHMARK_TIME = 10 + SAMPLE_SIZE = 1000 + + def initialize + @results = {} + setup_test_data + end + + def run_all + print_header + + benchmark_middleware_overhead + benchmark_instrumentation_overhead + benchmark_memory_impact + benchmark_request_scenarios + benchmark_job_scenarios + benchmark_database_impact + + print_summary + save_results + end + + private + + def setup_test_data + puts "Setting up test data..." + + # Clean up existing test data + RailsPulse::Request.where("route_id IN (SELECT id FROM rails_pulse_routes WHERE path LIKE '/benchmark%')").delete_all + RailsPulse::Route.where("path LIKE '/benchmark%'").delete_all + + # Create test routes + @test_route_fast = RailsPulse::Route.find_or_create_by!( + method: "GET", + path: "/benchmark/fast" + ) + + @test_route_slow = RailsPulse::Route.find_or_create_by!( + method: "GET", + path: "/benchmark/slow" + ) + + @test_query = RailsPulse::Query.find_or_create_by!( + normalized_sql: "SELECT * FROM users WHERE id = ?" + ) + + puts "Test data ready.\n" + end + + def print_header + puts "\n" + "=" * 100 + puts " " * 30 + "Rails Pulse Performance Benchmark" + puts "=" * 100 + puts "\nEnvironment:" + puts " Ruby Version: #{RUBY_VERSION}" + puts " Rails Version: #{Rails.version}" + puts " Database: #{ActiveRecord::Base.connection.adapter_name}" + puts " Rails Pulse: #{RailsPulse::VERSION}" + puts " Machine: #{`uname -m`.strip} (#{`uname -s`.strip})" + puts "\nConfiguration:" + puts " Warmup Time: #{WARMUP_TIME}s" + puts " Benchmark Time: #{BENCHMARK_TIME}s" + puts " Sample Size: #{SAMPLE_SIZE} iterations" + puts "\n" + "=" * 100 + end + + def benchmark_middleware_overhead + section_header("Middleware Overhead") + + # Create a minimal Rack application + app = ->(env) { [ 200, { "Content-Type" => "text/plain" }, [ "OK" ] ] } + middleware = RailsPulse::Middleware::RequestCollector.new(app) + + env = { + "REQUEST_METHOD" => "GET", + "PATH_INFO" => "/test", + "QUERY_STRING" => "", + "rack.input" => StringIO.new, + "rack.errors" => $stderr, + "action_controller.instance" => Object.new + } + + puts "\nMeasuring middleware call overhead:\n" + + # Baseline without middleware + baseline = measure_time(SAMPLE_SIZE) { app.call(env.dup) } + + # With Rails Pulse enabled + RailsPulse.configuration.enabled = true + enabled = measure_time(SAMPLE_SIZE) { middleware.call(env.dup) } + + # With Rails Pulse disabled + RailsPulse.configuration.enabled = false + disabled = measure_time(SAMPLE_SIZE) { middleware.call(env.dup) } + + RailsPulse.configuration.enabled = true + + overhead_enabled = ((enabled - baseline) / SAMPLE_SIZE * 1000).round(4) + overhead_disabled = ((disabled - baseline) / SAMPLE_SIZE * 1000).round(4) + + puts " Baseline (no middleware): #{format_time(baseline)} (#{SAMPLE_SIZE} calls)" + puts " Rails Pulse enabled: #{format_time(enabled)} (#{SAMPLE_SIZE} calls)" + puts " Rails Pulse disabled: #{format_time(disabled)} (#{SAMPLE_SIZE} calls)" + puts "\n Per-request overhead:" + puts " Enabled: #{overhead_enabled}ms" + puts " Disabled: #{overhead_disabled}ms" + puts " Impact: #{(overhead_enabled / baseline * 100).round(2)}% when enabled" + + @results[:middleware_overhead_ms] = overhead_enabled + end + + def benchmark_instrumentation_overhead + section_header("ActiveSupport Instrumentation Overhead") + + puts "\nMeasuring Rails instrumentation hooks:\n" + + # Simulate SQL query instrumentation + payload = { + sql: "SELECT * FROM users WHERE id = 1", + name: "User Load", + binds: [], + type_casted_binds: [] + } + + event_name = "sql.active_record" + + # Baseline without subscribers + ActiveSupport::Notifications.unsubscribe("sql.active_record") + baseline = measure_time(SAMPLE_SIZE) do + ActiveSupport::Notifications.instrument(event_name, payload) { } + end + + # Reload Rails Pulse instrumentation + load Rails.root.join("../../lib/rails_pulse/instrumentation.rb") + + # With Rails Pulse subscriber + instrumented = measure_time(SAMPLE_SIZE) do + ActiveSupport::Notifications.instrument(event_name, payload) { } + end + + overhead = ((instrumented - baseline) / SAMPLE_SIZE * 1000).round(4) + + puts " Baseline (no subscribers): #{format_time(baseline)}" + puts " With Rails Pulse: #{format_time(instrumented)}" + puts "\n Per-event overhead: #{overhead}ms" + puts " Impact: #{(overhead / baseline * 100).round(2)}%" + + @results[:instrumentation_overhead_ms] = overhead + end + + def benchmark_memory_impact + section_header("Memory Allocation Impact") + + puts "\nMeasuring memory allocations:\n" + + # Memory profile for creating requests + report = MemoryProfiler.report do + 100.times do + req = RailsPulse::Request.create!( + route: @test_route_fast, + occurred_at: Time.current, + duration: rand(50..200), + status: 200, + request_uuid: SecureRandom.uuid + ) + + # Add some operations + 3.times do + RailsPulse::Operation.create!( + request: req, + query: @test_query, + operation_type: "sql", + label: "User Load", + occurred_at: Time.current, + duration: rand(5..50) + ) + end + end + end + + total_kb = (report.total_allocated_memsize / 1024.0).round(2) + per_request_kb = (total_kb / 100.0).round(2) + + puts " Total memory allocated: #{total_kb} KB (100 requests)" + puts " Per request: #{per_request_kb} KB" + puts " Allocated objects: #{report.total_allocated}" + puts " Per request: #{report.total_allocated / 100} objects" + puts "\n Retained memory: #{(report.total_retained_memsize / 1024.0).round(2)} KB" + puts " Retained objects: #{report.total_retained}" + + @results[:memory_per_request_kb] = per_request_kb + @results[:objects_per_request] = report.total_allocated / 100 + + # Cleanup + RailsPulse::Request.where(route: @test_route_fast).delete_all + end + + def benchmark_request_scenarios + section_header("Real-World Request Scenarios") + + scenarios = { + "Fast request (minimal DB)" => -> { simulate_fast_request }, + "Moderate request (5 queries)" => -> { simulate_moderate_request }, + "Slow request (15 queries)" => -> { simulate_slow_request }, + "API request with JSON" => -> { simulate_api_request } + } + + puts "\nSimulating different request patterns:\n" + + scenarios.each do |name, scenario| + # With Rails Pulse enabled + RailsPulse.configuration.enabled = true + enabled_time = measure_time(100, &scenario) + + # With Rails Pulse disabled + RailsPulse.configuration.enabled = false + disabled_time = measure_time(100, &scenario) + + RailsPulse.configuration.enabled = true + + overhead = ((enabled_time - disabled_time) / 100 * 1000).round(3) + + puts " #{name}:" + puts " Enabled: #{format_time(enabled_time)} (100 requests)" + puts " Disabled: #{format_time(disabled_time)} (100 requests)" + puts " Overhead: #{overhead}ms per request" + puts "" + end + end + + def benchmark_job_scenarios + section_header("Background Job Tracking Overhead") + + return unless RailsPulse.configuration.track_jobs + + puts "\nMeasuring job execution overhead:\n" + + # Simple job + simple_job = -> { User.count } + + # Database-heavy job + heavy_job = -> do + User.includes(:posts, :comments).limit(10).each do |user| + user.posts.count + user.comments.count + end + end + + jobs = { + "Simple job (1 query)" => simple_job, + "Heavy job (complex queries)" => heavy_job + } + + jobs.each do |name, job| + RailsPulse.configuration.track_jobs = true + enabled_time = measure_time(50, &job) + + RailsPulse.configuration.track_jobs = false + disabled_time = measure_time(50, &job) + + RailsPulse.configuration.track_jobs = true + + overhead = ((enabled_time - disabled_time) / 50 * 1000).round(3) + + puts " #{name}:" + puts " Enabled: #{format_time(enabled_time)} (50 jobs)" + puts " Disabled: #{format_time(disabled_time)} (50 jobs)" + puts " Overhead: #{overhead}ms per job" + puts "" + end + end + + def benchmark_database_impact + section_header("Database Query Performance") + + # Create sample data + 50.times do |i| + RailsPulse::Request.create!( + route: @test_route_fast, + occurred_at: i.hours.ago, + duration: rand(50..500), + status: [ 200, 201, 404, 500 ].sample, + request_uuid: SecureRandom.uuid + ) + end + + puts "\nMeasuring Rails Pulse's own query performance:\n" + + queries = { + "Average duration" => -> { RailsPulse::Request.average(:duration) }, + "Group by hour" => -> { RailsPulse::Request.group_by_hour(:occurred_at, last: 24).count }, + "Slow requests" => -> { RailsPulse::Request.where("duration > ?", 300).count }, + "Join with routes" => -> { RailsPulse::Request.joins(:route).group("rails_pulse_routes.path").count } + } + + queries.each do |name, query| + time = measure_time(100, &query) + per_query = (time / 100 * 1000).round(3) + + puts " #{name}:" + puts " Time: #{format_time(time)} (100 queries)" + puts " Average: #{per_query}ms per query" + puts "" + end + + # Cleanup + RailsPulse::Request.where(route: @test_route_fast).delete_all + end + + def simulate_fast_request + User.count + Post.first + end + + def simulate_moderate_request + User.includes(:posts).limit(5).each { |u| u.posts.count } + Post.where(created_at: 1.week.ago..).count + Comment.limit(10).pluck(:id, :content) + end + + def simulate_slow_request + User.includes(:posts, :comments).limit(10).each do |user| + user.posts.where(created_at: 1.month.ago..).count + user.comments.where(created_at: 1.week.ago..).count + end + Post.joins(:user, :comments).group("users.name").count + end + + def simulate_api_request + { + users: User.count, + posts: Post.count, + recent: Post.where(created_at: 1.day.ago..).limit(5).pluck(:id, :title) + }.to_json + end + + def measure_time(iterations) + GC.start + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + iterations.times { yield } + + end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + end_time - start_time + end + + def format_time(seconds) + if seconds < 1 + "#{(seconds * 1000).round(2)}ms" + else + "#{seconds.round(3)}s" + end + end + + def section_header(title) + puts "\n" + "-" * 100 + puts " #{title}" + puts "-" * 100 + end + + def print_summary + puts "\n" + "=" * 100 + puts " " * 40 + "SUMMARY" + puts "=" * 100 + puts "\nKey Performance Metrics:" + puts " Middleware overhead: #{@results[:middleware_overhead_ms]}ms per request" + puts " Instrumentation overhead: #{@results[:instrumentation_overhead_ms]}ms per event" + puts " Memory per request: #{@results[:memory_per_request_kb]} KB" + puts " Objects allocated: #{@results[:objects_per_request]} per request" + puts "\nConclusion:" + + total_overhead = @results[:middleware_overhead_ms] + @results[:instrumentation_overhead_ms] + + if total_overhead < 2 + puts " ✅ EXCELLENT - Negligible overhead (< 2ms)" + elsif total_overhead < 5 + puts " ✅ GOOD - Low overhead (2-5ms)" + elsif total_overhead < 10 + puts " ⚠️ MODERATE - Noticeable overhead (5-10ms)" + else + puts " ⚠️ HIGH - Consider optimizing (> 10ms)" + end + + puts "\n" + "=" * 100 + end + + def save_results + output_file = File.join(__dir__, "../docs/benchmark_results_#{Time.current.to_i}.json") + + File.write(output_file, JSON.pretty_generate({ + timestamp: Time.current.iso8601, + environment: { + ruby: RUBY_VERSION, + rails: Rails.version, + database: ActiveRecord::Base.connection.adapter_name, + rails_pulse: RailsPulse::VERSION + }, + results: @results + })) + + puts "\n📊 Results saved to: #{output_file}" + end +end + +# Run benchmarks if executed directly +if __FILE__ == $0 + benchmark = RailsPulseBenchmark.new + benchmark.run_all +end diff --git a/scripts/build-icons.js b/scripts/build-icons.js index 5f5f1a3..0adcac4 100755 --- a/scripts/build-icons.js +++ b/scripts/build-icons.js @@ -45,7 +45,8 @@ const REQUIRED_ICONS = [ 'trending-up', 'trending-down', 'move-right', - 'eye' + 'eye', + 'zap' ]; // Icon name mappings for different naming conventions diff --git a/test/controllers/rails_pulse/job_runs_controller_test.rb b/test/controllers/rails_pulse/job_runs_controller_test.rb new file mode 100644 index 0000000..5a54cfc --- /dev/null +++ b/test/controllers/rails_pulse/job_runs_controller_test.rb @@ -0,0 +1,189 @@ +require "test_helper" + +class RailsPulse::JobRunsControllerTest < ActionDispatch::IntegrationTest + include Rails::Controller::Testing::TestProcess + include Rails::Controller::Testing::TemplateAssertions + include Rails::Controller::Testing::Integration + + def setup + ENV["TEST_TYPE"] = "functional" + super + @job = rails_pulse_jobs(:report_job) + @run = rails_pulse_job_runs(:report_run_retried) + end + + test "controller includes required concerns" do + assert_includes RailsPulse::JobRunsController.included_modules, TagFilterConcern + + # Check for Pagy module (Backend in 8.x, Method in 43+) + pagy_module = defined?(Pagy::Method) ? Pagy::Method : Pagy::Backend + + assert_includes RailsPulse::JobRunsController.included_modules, pagy_module + end + + test "controller has index and show actions" do + controller = RailsPulse::JobRunsController.new + + assert_respond_to controller, :index + assert_respond_to controller, :show + end + + # Index Action Tests + test "index action loads successfully" do + get rails_pulse.job_runs_path(@job) + + assert_response :success + assert_not_nil assigns(:job) + assert_not_nil assigns(:ransack_query) + assert_not_nil assigns(:pagy) + assert_not_nil assigns(:runs) + assert_not_nil assigns(:table_data) + assert_equal @job, assigns(:job) + end + + test "index action orders runs by occurred_at desc" do + get rails_pulse.job_runs_path(@job) + + assert_response :success + runs = assigns(:runs) + + # Verify runs are ordered by occurred_at desc (most recent first) + assert_operator runs.size, :>, 1 + runs.each_cons(2) do |current, next_run| + assert_operator current.occurred_at, :>=, next_run.occurred_at + end + end + + test "index action with ransack search by status" do + get rails_pulse.job_runs_path(@job), params: { q: { status_eq: "success" } } + + assert_response :success + runs = assigns(:runs) + + # All returned runs should have status "success" + assert runs.all? { |run| run.status == "success" } + end + + test "index action with ransack search by adapter" do + get rails_pulse.job_runs_path(@job), params: { q: { adapter_eq: "sidekiq" } } + + assert_response :success + runs = assigns(:runs) + + # All returned runs should have adapter "sidekiq" + assert runs.all? { |run| run.adapter == "sidekiq" } + end + + test "index action respects pagination" do + get rails_pulse.job_runs_path(@job), params: { limit: 10 } + + assert_response :success + pagy = assigns(:pagy) + runs = assigns(:runs) + + # Should have pagination set up correctly + assert_not_nil pagy + assert_operator runs.size, :<=, 10 + end + + # Show Action Tests + test "show action loads successfully" do + get rails_pulse.job_run_path(@job, @run) + + assert_response :success + assert_not_nil assigns(:job) + assert_not_nil assigns(:run) + assert_not_nil assigns(:operations) + assert_not_nil assigns(:operation_timeline) + assert_not_nil assigns(:operations_by_type) + assert_not_nil assigns(:sql_operations) + assert_equal @job, assigns(:job) + assert_equal @run, assigns(:run) + end + + test "show action orders operations by start_time" do + get rails_pulse.job_run_path(@job, @run) + + assert_response :success + operations = assigns(:operations) + + # Operations should be ordered by start_time ascending if there are multiple + if operations.size > 1 + operations.each_cons(2) do |current, next_op| + assert_operator current.start_time, :<=, next_op.start_time + end + end + end + + test "show action groups operations by type" do + get rails_pulse.job_run_path(@job, @run) + + assert_response :success + operations_by_type = assigns(:operations_by_type) + + # operations_by_type should be a hash grouping operations by their type + assert_instance_of Hash, operations_by_type + + # Each group should only contain operations of that type + operations_by_type.each do |type, ops| + assert ops.all? { |op| op.operation_type == type } + end + end + + test "show action loads sql operations with includes" do + get rails_pulse.job_run_path(@job, @run) + + assert_response :success + sql_operations = assigns(:sql_operations) + + # SQL operations should be filtered to only sql type + assert sql_operations.all? { |op| op.operation_type == "sql" } + + # Check that query associations are eager loaded (no N+1) + assert_no_queries do + sql_operations.each { |op| op.query&.normalized_sql } + end + end + + test "show action orders sql operations by duration desc" do + get rails_pulse.job_run_path(@job, @run) + + assert_response :success + sql_operations = assigns(:sql_operations) + + # SQL operations should be ordered by duration desc (slowest first) + if sql_operations.size > 1 + sql_operations.each_cons(2) do |current, next_op| + assert_operator current.duration.to_f, :>=, next_op.duration.to_f + end + end + end + + test "show action creates operation timeline chart" do + get rails_pulse.job_run_path(@job, @run) + + assert_response :success + operation_timeline = assigns(:operation_timeline) + + assert_instance_of RailsPulse::Charts::OperationsChart, operation_timeline + end + + private + + def rails_pulse + RailsPulse::Engine.routes.url_helpers + end + + def assert_no_queries(&block) + queries = [] + query_subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + queries << payload[:sql] unless payload[:name] == "SCHEMA" + end + + block.call + + assert_equal 0, queries.size, "Expected no queries, but #{queries.size} were executed:\n#{queries.join("\n")}" + ensure + ActiveSupport::Notifications.unsubscribe(query_subscriber) if query_subscriber + end +end diff --git a/test/controllers/rails_pulse/jobs_controller_test.rb b/test/controllers/rails_pulse/jobs_controller_test.rb new file mode 100644 index 0000000..0f203d4 --- /dev/null +++ b/test/controllers/rails_pulse/jobs_controller_test.rb @@ -0,0 +1,231 @@ +require "test_helper" + +class RailsPulse::JobsControllerTest < ActionDispatch::IntegrationTest + include Rails::Controller::Testing::TestProcess + include Rails::Controller::Testing::TemplateAssertions + include Rails::Controller::Testing::Integration + + def setup + ENV["TEST_TYPE"] = "functional" + super + @job = rails_pulse_jobs(:report_job) + end + + # Controller Structure Tests + + test "controller includes required concerns" do + assert_includes RailsPulse::JobsController.included_modules, TagFilterConcern + assert_includes RailsPulse::JobsController.included_modules, TimeRangeConcern + + # Check for Pagy module (Backend in 8.x, Method in 43+) + pagy_module = defined?(Pagy::Method) ? Pagy::Method : Pagy::Backend + + assert_includes RailsPulse::JobsController.included_modules, pagy_module + end + + test "controller has index and show actions" do + controller = RailsPulse::JobsController.new + + assert_respond_to controller, :index + assert_respond_to controller, :show + end + + test "controller inherits from ApplicationController" do + assert_operator RailsPulse::JobsController, :<, RailsPulse::ApplicationController + end + + test "controller defines custom TIME_RANGE_OPTIONS" do + expected_options = [ + [ "Recent", "recent" ], + [ "Custom Range", "custom" ] + ] + + assert_equal expected_options, RailsPulse::JobsController::TIME_RANGE_OPTIONS + end + + # Index Action Tests + + test "index action loads successfully" do + get rails_pulse.jobs_path + + assert_response :success + assert_not_nil assigns(:ransack_query) + assert_not_nil assigns(:pagy) + assert_not_nil assigns(:jobs) + assert_not_nil assigns(:table_data) + assert_not_nil assigns(:available_queues) + end + + test "index action orders jobs by runs_count desc" do + get rails_pulse.jobs_path + + assert_response :success + jobs = assigns(:jobs) + + # Verify jobs are ordered by runs_count desc + if jobs.size > 1 + jobs.each_cons(2) do |current, next_job| + assert_operator current.runs_count, :>=, next_job.runs_count + end + end + end + + test "index action with ransack search by name" do + get rails_pulse.jobs_path, params: { q: { name_cont: "Report" } } + + assert_response :success + jobs = assigns(:jobs) + + # All returned jobs should have "Report" in the name + assert jobs.all? { |job| job.name.include?("Report") } + end + + test "index action with ransack search by queue_name" do + get rails_pulse.jobs_path, params: { q: { queue_name_eq: "default" } } + + assert_response :success + jobs = assigns(:jobs) + + # All returned jobs should have queue_name "default" + assert jobs.all? { |job| job.queue_name == "default" } + end + + test "index action respects pagination" do + get rails_pulse.jobs_path, params: { limit: 10 } + + assert_response :success + pagy = assigns(:pagy) + jobs = assigns(:jobs) + + assert_not_nil pagy + assert_operator jobs.size, :<=, 10 + end + + test "index action sets available_queues" do + get rails_pulse.jobs_path + + assert_response :success + available_queues = assigns(:available_queues) + + assert_kind_of Array, available_queues + # Should be sorted alphabetically + assert_equal available_queues.sort, available_queues + end + + test "index action with custom sorting" do + get rails_pulse.jobs_path, params: { q: { s: "name asc" } } + + assert_response :success + jobs = assigns(:jobs) + + # Verify jobs are ordered by name asc + if jobs.size > 1 + jobs.each_cons(2) do |current, next_job| + assert_operator current.name, :<=, next_job.name + end + end + end + + # Show Action Tests + + test "show action loads successfully" do + get rails_pulse.job_path(@job) + + assert_response :success + assert_not_nil assigns(:job) + assert_not_nil assigns(:ransack_query) + assert_not_nil assigns(:pagy) + assert_not_nil assigns(:recent_runs) + assert_not_nil assigns(:table_data) + assert_not_nil assigns(:selected_time_range) + assert_equal @job, assigns(:job) + end + + test "show action defaults to recent mode" do + get rails_pulse.job_path(@job) + + assert_response :success + assert_equal "recent", assigns(:selected_time_range) + end + + test "show action with recent mode does not filter by time" do + get rails_pulse.job_path(@job), params: { q: { period_start_range: "recent" } } + + assert_response :success + assert_equal "recent", assigns(:selected_time_range) + # In recent mode, start_time and end_time should not be set + assert_nil assigns(:start_time) + end + + test "show action orders runs by occurred_at desc" do + get rails_pulse.job_path(@job) + + assert_response :success + runs = assigns(:recent_runs) + + # Verify runs are ordered by occurred_at desc + if runs.size > 1 + runs.each_cons(2) do |current, next_run| + assert_operator current.occurred_at, :>=, next_run.occurred_at + end + end + end + + test "show action with ransack search by status" do + get rails_pulse.job_path(@job), params: { q: { status_eq: "success" } } + + assert_response :success + runs = assigns(:recent_runs) + + # All returned runs should have status "success" + assert runs.all? { |run| run.status == "success" } + end + + test "show action with ransack search by duration" do + get rails_pulse.job_path(@job), params: { q: { duration_gteq: 300 } } + + assert_response :success + runs = assigns(:recent_runs) + + # All returned runs should have duration >= 300 + assert runs.all? { |run| run.duration.to_f >= 300 } + end + + test "show action respects pagination" do + get rails_pulse.job_path(@job), params: { limit: 10 } + + assert_response :success + pagy = assigns(:pagy) + runs = assigns(:recent_runs) + + assert_not_nil pagy + assert_operator runs.size, :<=, 10 + end + + test "show action with custom sorting" do + get rails_pulse.job_path(@job), params: { q: { s: "duration asc" } } + + assert_response :success + runs = assigns(:recent_runs) + + # Verify runs are ordered by duration asc + if runs.size > 1 + runs.each_cons(2) do |current, next_run| + assert_operator current.duration.to_f, :<=, next_run.duration.to_f + end + end + end + + test "show action table_data matches recent_runs" do + get rails_pulse.job_path(@job) + + assert_response :success + assert_equal assigns(:recent_runs), assigns(:table_data) + end + + private + + def rails_pulse + RailsPulse::Engine.routes.url_helpers + end +end diff --git a/test/controllers/rails_pulse/operations_controller_test.rb b/test/controllers/rails_pulse/operations_controller_test.rb index 54c2042..c6e325e 100644 --- a/test/controllers/rails_pulse/operations_controller_test.rb +++ b/test/controllers/rails_pulse/operations_controller_test.rb @@ -1,11 +1,19 @@ require "test_helper" class RailsPulse::OperationsControllerTest < ActionDispatch::IntegrationTest + include Rails::Controller::Testing::TestProcess + include Rails::Controller::Testing::TemplateAssertions + include Rails::Controller::Testing::Integration + def setup ENV["TEST_TYPE"] = "functional" super + @request_operation = rails_pulse_operations(:sql_operation_1) + @job_run_operation = rails_pulse_operations(:job_sql_operation) end + # Controller Structure Tests + test "controller has show action" do controller = RailsPulse::OperationsController.new @@ -20,17 +28,114 @@ def setup assert_includes private_methods, :find_related_operations assert_includes private_methods, :calculate_performance_context assert_includes private_methods, :generate_optimization_suggestions + assert_includes private_methods, :calculate_percentile end - test "controller has optimization suggestion methods" do + test "controller has all optimization suggestion methods" do controller = RailsPulse::OperationsController.new private_methods = controller.private_methods assert_includes private_methods, :sql_optimization_suggestions assert_includes private_methods, :view_optimization_suggestions assert_includes private_methods, :controller_optimization_suggestions + assert_includes private_methods, :cache_optimization_suggestions + assert_includes private_methods, :http_optimization_suggestions + end + + test "controller inherits from ApplicationController" do + assert_operator RailsPulse::OperationsController, :<, RailsPulse::ApplicationController + end + + # Show Action Tests - Request Operations + + test "show action loads successfully for request operation" do + get rails_pulse.operation_path(@request_operation) + + assert_response :success + assert_not_nil assigns(:operation) + assert_not_nil assigns(:request) + assert_not_nil assigns(:parent) + assert_not_nil assigns(:related_operations) + assert_not_nil assigns(:performance_context) + assert_not_nil assigns(:optimization_suggestions) + assert_equal @request_operation, assigns(:operation) + assert_equal @request_operation.request, assigns(:request) + end + + test "show action sets parent to request for request operation" do + get rails_pulse.operation_path(@request_operation) + + assert_response :success + assert_equal @request_operation.request, assigns(:parent) + assert_nil assigns(:job_run) + end + + # Show Action Tests - Job Run Operations + + test "show action loads successfully for job run operation" do + get rails_pulse.operation_path(@job_run_operation) + + assert_response :success + assert_not_nil assigns(:operation) + assert_not_nil assigns(:job_run) + assert_not_nil assigns(:parent) + assert_not_nil assigns(:related_operations) + assert_not_nil assigns(:performance_context) + assert_not_nil assigns(:optimization_suggestions) + assert_equal @job_run_operation, assigns(:operation) + assert_equal @job_run_operation.job_run, assigns(:job_run) + end + + test "show action sets parent to job_run for job run operation" do + get rails_pulse.operation_path(@job_run_operation) + + assert_response :success + assert_equal @job_run_operation.job_run, assigns(:parent) + assert_nil assigns(:request) + end + + # Related Operations Tests + + test "show action finds related operations" do + get rails_pulse.operation_path(@request_operation) + + assert_response :success + related = assigns(:related_operations) + + # Should be an ActiveRecord relation or array + assert_respond_to related, :each + # Should not include the current operation + refute_includes related.map(&:id), @request_operation.id end + # Performance Context Tests + + test "show action calculates performance context" do + get rails_pulse.operation_path(@request_operation) + + assert_response :success + context = assigns(:performance_context) + + assert_kind_of Hash, context + # Should have percentile keys + if context.any? + assert context.key?(:percentile_50) || context.key?(:average) + end + end + + # Optimization Suggestions Tests + + test "show action generates optimization suggestions" do + get rails_pulse.operation_path(@request_operation) + + assert_response :success + suggestions = assigns(:optimization_suggestions) + + assert_kind_of Array, suggestions + end + + # Private Method Tests + test "calculates percentile correctly" do controller = RailsPulse::OperationsController.new @@ -40,21 +145,29 @@ def setup # 25 should be at 40th percentile (between 20 and 30) percentile = controller.send(:calculate_percentile, 25, sorted_array) - assert_in_delta(40.0, percentile) + assert_in_delta 40.0, percentile, 0.1 # 35 should be at 60th percentile percentile = controller.send(:calculate_percentile, 35, sorted_array) - assert_in_delta(60.0, percentile) + assert_in_delta 60.0, percentile, 0.1 + + # Test edge cases + assert_equal 0, controller.send(:calculate_percentile, 5, sorted_array) + assert_in_delta(100.0, controller.send(:calculate_percentile, 100, sorted_array)) end - test "controller inherits from ApplicationController" do - assert_operator RailsPulse::OperationsController, :<, RailsPulse::ApplicationController + test "calculates percentile for empty array" do + controller = RailsPulse::OperationsController.new + + percentile = controller.send(:calculate_percentile, 50, []) + + assert_equal 0, percentile end private - def rails_pulse_engine + def rails_pulse RailsPulse::Engine.routes.url_helpers end end diff --git a/test/controllers/rails_pulse/requests_controller_test.rb b/test/controllers/rails_pulse/requests_controller_test.rb index 10ef15f..88de963 100644 --- a/test/controllers/rails_pulse/requests_controller_test.rb +++ b/test/controllers/rails_pulse/requests_controller_test.rb @@ -1,11 +1,21 @@ require "test_helper" class RailsPulse::RequestsControllerTest < ActionDispatch::IntegrationTest + include Rails::Controller::Testing::TestProcess + include Rails::Controller::Testing::TemplateAssertions + include Rails::Controller::Testing::Integration + def setup ENV["TEST_TYPE"] = "functional" + super + @request_record = rails_pulse_requests(:users_request_1) + end + # Controller Structure Tests - super + test "controller includes required concerns" do + assert_includes RailsPulse::RequestsController.included_modules, ChartTableConcern + assert_includes RailsPulse::RequestsController.included_modules, TagFilterConcern end test "controller has index and show actions" do @@ -15,10 +25,6 @@ def setup assert_respond_to controller, :show end - test "controller includes ChartTableConcern" do - assert_includes RailsPulse::RequestsController.included_modules, ChartTableConcern - end - test "controller has required private methods" do controller = RailsPulse::RequestsController.new private_methods = controller.private_methods @@ -27,6 +33,23 @@ def setup assert_includes private_methods, :table_model assert_includes private_methods, :chart_class assert_includes private_methods, :set_request + assert_includes private_methods, :setup_metric_cards + assert_includes private_methods, :build_chart_ransack_params + assert_includes private_methods, :build_table_ransack_params + assert_includes private_methods, :build_table_results + end + + test "controller inherits from ApplicationController" do + assert_operator RailsPulse::RequestsController, :<, RailsPulse::ApplicationController + end + + test "controller defines custom TIME_RANGE_OPTIONS" do + expected_options = [ + [ "Recent", "recent" ], + [ "Custom Range", "custom" ] + ] + + assert_equal expected_options, RailsPulse::RequestsController::TIME_RANGE_OPTIONS end test "uses correct chart and table models" do @@ -46,7 +69,7 @@ def setup controller = RailsPulse::RequestsController.new options = controller.send(:chart_options) - assert_empty(options) + assert_empty options end test "default table sort is by occurred_at descending" do @@ -55,13 +78,96 @@ def setup assert_equal "occurred_at desc", controller.send(:default_table_sort) end - test "controller inherits from ApplicationController" do - assert_operator RailsPulse::RequestsController, :<, RailsPulse::ApplicationController + # Index Action Tests + + test "index action loads successfully" do + get rails_pulse.requests_path + + assert_response :success + # ChartTableConcern should set up these variables + assert_not_nil assigns(:chart_data) + assert_not_nil assigns(:table_data) + assert_not_nil assigns(:pagy) + end + + test "index action with ransack search by status" do + get rails_pulse.requests_path, params: { q: { status_eq: 200 } } + + assert_response :success + requests = assigns(:table_data) + + # All returned requests should have status 200 + assert requests.all? { |req| req.status == 200 } + end + + test "index action with ransack search by controller_action" do + get rails_pulse.requests_path, params: { q: { controller_action_cont: "Users" } } + + assert_response :success + requests = assigns(:table_data) + + # Should have at least one request with "Users" in controller_action + assert requests.any? { |req| req.controller_action.include?("Users") } + end + + test "index action with error filter" do + get rails_pulse.requests_path, params: { q: { is_error_eq: true } } + + assert_response :success + requests = assigns(:table_data) + + # Should have at least one error request + assert requests.any?(&:is_error) + end + + test "index action respects pagination" do + get rails_pulse.requests_path, params: { limit: 5 } + + assert_response :success + pagy = assigns(:pagy) + requests = assigns(:table_data) + + assert_not_nil pagy + assert_operator requests.size, :<=, 5 + end + + test "index action with custom sorting" do + get rails_pulse.requests_path, params: { q: { s: "duration asc" } } + + assert_response :success + requests = assigns(:table_data) + + # Verify requests are ordered by duration asc + if requests.size > 1 + requests.each_cons(2) do |current, next_req| + assert_operator current.duration, :<=, next_req.duration + end + end + end + + # Show Action Tests + + test "show action loads successfully" do + get rails_pulse.request_path(@request_record) + + assert_response :success + assert_not_nil assigns(:request) + assert_not_nil assigns(:operation_timeline) + assert_equal @request_record, assigns(:request) + end + + test "show action creates operation timeline chart" do + get rails_pulse.request_path(@request_record) + + assert_response :success + operation_timeline = assigns(:operation_timeline) + + assert_instance_of RailsPulse::Charts::OperationsChart, operation_timeline end private - def rails_pulse_engine + def rails_pulse RailsPulse::Engine.routes.url_helpers end end diff --git a/test/controllers/rails_pulse/tags_controller_test.rb b/test/controllers/rails_pulse/tags_controller_test.rb index 90cb814..7bea453 100644 --- a/test/controllers/rails_pulse/tags_controller_test.rb +++ b/test/controllers/rails_pulse/tags_controller_test.rb @@ -1,7 +1,7 @@ require "test_helper" class RailsPulse::TagsControllerTest < ActionDispatch::IntegrationTest - fixtures :rails_pulse_routes, :rails_pulse_requests, :rails_pulse_queries + fixtures :rails_pulse_routes, :rails_pulse_requests, :rails_pulse_queries, :rails_pulse_jobs, :rails_pulse_job_runs def setup ENV["TEST_TYPE"] = "functional" @@ -16,6 +16,12 @@ def setup @test_query = rails_pulse_queries(:simple_query) @test_query.update!(tags: [ "slow" ].to_json) + + @test_job = rails_pulse_jobs(:report_job) + @test_job.update!(tags: [ "report" ].to_json) + + @test_job_run = rails_pulse_job_runs(:report_run_success) + @test_job_run.update!(tags: [].to_json) end test "controller has create action" do @@ -68,6 +74,24 @@ def setup assert_response :success end + test "create action adds tag to job" do + assert_difference -> { @test_job.reload.tag_list.count }, 1 do + post rails_pulse_engine.add_tag_path("job", @test_job.id, tag: "background") + end + + assert_includes @test_job.reload.tag_list, "background" + assert_response :success + end + + test "create action adds tag to job_run" do + assert_difference -> { @test_job_run.reload.tag_list.count }, 1 do + post rails_pulse_engine.add_tag_path("job_run", @test_job_run.id, tag: "retry") + end + + assert_includes @test_job_run.reload.tag_list, "retry" + assert_response :success + end + test "create action does not add duplicate tags" do post rails_pulse_engine.add_tag_path("route", @test_route.id, tag: "production") @@ -127,6 +151,26 @@ def setup assert_response :success end + test "destroy action removes tag from job" do + assert_difference -> { @test_job.reload.tag_list.count }, -1 do + delete rails_pulse_engine.remove_tag_path("job", @test_job.id, tag: "report") + end + + assert_not_includes @test_job.reload.tag_list, "report" + assert_response :success + end + + test "destroy action removes tag from job_run" do + @test_job_run.add_tag("failed") + + assert_difference -> { @test_job_run.reload.tag_list.count }, -1 do + delete rails_pulse_engine.remove_tag_path("job_run", @test_job_run.id, tag: "failed") + end + + assert_not_includes @test_job_run.reload.tag_list, "failed" + assert_response :success + end + test "destroy action renders turbo stream response" do delete rails_pulse_engine.remove_tag_path("route", @test_route.id, tag: "production") diff --git a/test/dummy/Gemfile b/test/dummy/Gemfile index 8ce1a73..b099b90 100644 --- a/test/dummy/Gemfile +++ b/test/dummy/Gemfile @@ -22,3 +22,7 @@ gem "chartkick" gem "groupdate" gem "pry-byebug" gem "debug" + +# Performance benchmarking +gem "benchmark-ips" +gem "memory_profiler" diff --git a/test/dummy/Gemfile.lock b/test/dummy/Gemfile.lock index 79453a3..87ca2ca 100644 --- a/test/dummy/Gemfile.lock +++ b/test/dummy/Gemfile.lock @@ -86,6 +86,7 @@ GEM uri (>= 0.13.1) base64 (0.2.0) benchmark (0.4.0) + benchmark-ips (2.14.0) bigdecimal (3.1.9) builder (3.3.0) byebug (12.0.0) @@ -128,6 +129,7 @@ GEM net-pop net-smtp marcel (1.0.4) + memory_profiler (1.1.0) method_source (1.1.0) mini_mime (1.1.5) minitest (5.25.4) @@ -265,6 +267,7 @@ DEPENDENCIES dotenv-rails groupdate lucide-rails (= 0.5.1) + memory_profiler mysql2 pg pry-byebug diff --git a/test/dummy/app/jobs/test_job.rb b/test/dummy/app/jobs/test_job.rb new file mode 100644 index 0000000..3a7d209 --- /dev/null +++ b/test/dummy/app/jobs/test_job.rb @@ -0,0 +1,7 @@ +class TestJob < ApplicationJob + queue_as :default + + def perform(value) + User.create!(email: "job-#{value}@example.com", name: "Test User #{value}") + end +end diff --git a/test/dummy/config/initializers/rails_pulse.rb b/test/dummy/config/initializers/rails_pulse.rb new file mode 100644 index 0000000..cc3f69c --- /dev/null +++ b/test/dummy/config/initializers/rails_pulse.rb @@ -0,0 +1,207 @@ +RailsPulse.configure do |config| + # ==================================================================================================== + # GLOBAL CONFIGURATION + # ==================================================================================================== + + # Enable or disable Rails Pulse + config.enabled = true + + # ==================================================================================================== + # THRESHOLDS + # ==================================================================================================== + # These thresholds are used to determine if a route, request, or query is slow, very slow, or critical. + # Values are in milliseconds (ms). Adjust these based on your application's performance requirements. + + # Thresholds for an individual route + config.route_thresholds = { + slow: 500, + very_slow: 1500, + critical: 3000 + } + + # Thresholds for an individual request + config.request_thresholds = { + slow: 700, + very_slow: 2000, + critical: 4000 + } + + # Thresholds for an individual database query + config.query_thresholds = { + slow: 100, + very_slow: 500, + critical: 1000 + } + + # ==================================================================================================== + # FILTERING + # ==================================================================================================== + + # Asset Tracking Configuration + # By default, Rails Pulse ignores asset requests (images, CSS, JS files) to focus on application performance. + # Set track_assets to true if you want to monitor asset delivery performance. + config.track_assets = false + + # Custom asset patterns to ignore (in addition to the built-in defaults) + # Only applies when track_assets is false. Add patterns for app-specific asset paths. + config.custom_asset_patterns = [ + # Example: ignore specific asset directories + # %r{^/uploads/}, + # %r{^/media/}, + # "/special-assets/" + ] + + # Rails Pulse Mount Path (optional) + # If Rails Pulse is mounted at a custom path, specify it here to prevent + # Rails Pulse from tracking its own requests. Leave as nil for default '/rails_pulse'. + # Examples: + # config.mount_path = "/admin/monitoring" + config.mount_path = nil + + # Manual route filtering + # Specify additional routes, requests, or queries to ignore from performance tracking. + # Each array can include strings (exact matches) or regular expressions. + # + # Examples: + # config.ignored_routes = ["/health_check", %r{^/admin}] + # config.ignored_requests = ["GET /status", %r{POST /api/v1/.*}] + # config.ignored_queries = ["SELECT 1", %r{FROM \"schema_migrations\"}] + + config.ignored_routes = [] + config.ignored_requests = [] + config.ignored_queries = [] + + # ==================================================================================================== + # TAGGING + # ==================================================================================================== + # Define custom tags for categorizing routes, requests, and queries. + # You can add any custom tags you want for filtering and organization. + # + # Tag names should be in present tense and describe the current state or category. + # Examples of good tag names: + # - "critical" (for high-priority endpoints) + # - "experimental" (for routes under development) + # - "deprecated" (for routes being phased out) + # - "external" (for third-party API calls) + # - "background" (for async job-related operations) + # - "admin" (for administrative routes) + # - "public" (for public-facing routes) + # + # Example configuration: + # config.tags = ["ignored", "critical", "experimental", "deprecated", "external", "admin"] + + config.tags = [ "ignored", "critical", "experimental" ] + + # ==================================================================================================== + # DATABASE CONFIGURATION + # ==================================================================================================== + # Configure Rails Pulse to use a separate database for performance monitoring data. + # This is optional but recommended for production applications to isolate performance + # data from your main application database. + # + # Uncomment and configure one of the following patterns: + + # Option 1: Separate single database for Rails Pulse + # config.connects_to = { + # database: { writing: :rails_pulse, reading: :rails_pulse } + # } + + # Option 2: Primary/replica configuration for Rails Pulse + # config.connects_to = { + # database: { writing: :rails_pulse_primary, reading: :rails_pulse_replica } + # } + + # Don't forget to add the database configuration to config/database.yml: + # + # production: + # # ... your main database config ... + # rails_pulse: + # adapter: postgresql # or mysql2, sqlite3 + # database: myapp_rails_pulse_production + # username: rails_pulse_user + # password: <%= Rails.application.credentials.dig(:rails_pulse, :database_password) %> + # host: localhost + # pool: 5 + + # ==================================================================================================== + # AUTHENTICATION + # ==================================================================================================== + # Configure authentication to secure access to the Rails Pulse dashboard. + # Authentication is ENABLED BY DEFAULT in production environments for security. + # + # If no authentication method is configured, Rails Pulse will use HTTP Basic Auth + # with credentials from RAILS_PULSE_USERNAME (default: 'admin') and RAILS_PULSE_PASSWORD + # environment variables. Set RAILS_PULSE_PASSWORD to enable this fallback. + # + # Uncomment and configure one of the following patterns based on your authentication system: + + # Enable/disable authentication (enabled by default in production) + # config.authentication_enabled = Rails.env.production? + + # Where to redirect unauthorized users + # config.authentication_redirect_path = "/" + + # Custom authentication method - choose one of the examples below: + + # Example 1: Devise with admin role check + # config.authentication_method = proc { + # unless user_signed_in? && current_user.admin? + # redirect_to main_app.root_path, alert: "Access denied" + # end + # } + + # Example 2: Custom session-based authentication + # config.authentication_method = proc { + # unless session[:user_id] && User.find_by(id: session[:user_id])&.admin? + # redirect_to main_app.login_path, alert: "Please log in as an admin" + # end + # } + + # Example 3: Warden authentication + # config.authentication_method = proc { + # warden.authenticate!(:scope => :admin) + # } + + # Example 4: Basic HTTP authentication + # config.authentication_method = proc { + # authenticate_or_request_with_http_basic do |username, password| + # username == ENV['RAILS_PULSE_USERNAME'] && password == ENV['RAILS_PULSE_PASSWORD'] + # end + # } + + # Example 5: Custom authorization check + # config.authentication_method = proc { + # current_user = User.find_by(id: session[:user_id]) + # unless current_user&.can_access_rails_pulse? + # render plain: "Forbidden", status: :forbidden + # end + # } + + # ==================================================================================================== + # DATA CLEANUP + # ==================================================================================================== + # Configure automatic cleanup of old performance data to manage database size. + # Rails Pulse provides two cleanup mechanisms that work together: + # + # 1. Time-based cleanup: Delete records older than the retention period + # 2. Count-based cleanup: Keep only the specified number of records per table + # + # Cleanup order respects foreign key constraints: + # operations → requests → queries/routes + + # Enable or disable automatic data cleanup + config.archiving_enabled = true + + # Time-based retention - delete records older than this period + config.full_retention_period = 2.weeks + + # Count-based retention - maximum records to keep per table + # After time-based cleanup, if tables still exceed these limits, + # the oldest remaining records will be deleted to stay under the limit + config.max_table_records = { + rails_pulse_requests: 10000, # HTTP requests (moderate volume) + rails_pulse_operations: 50000, # Operations within requests (high volume) + rails_pulse_routes: 1000, # Unique routes (low volume) + rails_pulse_queries: 500 # Normalized SQL queries (low volume) + } +end diff --git a/test/dummy/db/migrate/20251021102632_install_rails_pulse_tables.rb b/test/dummy/db/migrate/20251021102632_install_rails_pulse_tables.rb new file mode 100644 index 0000000..a499d86 --- /dev/null +++ b/test/dummy/db/migrate/20251021102632_install_rails_pulse_tables.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class InstallRailsPulseTables < ActiveRecord::Migration[7.2] + def up + # Check if Rails Pulse is already installed + if rails_pulse_installed? + say "Rails Pulse tables already exist. Skipping installation.", :yellow + return + end + + schema_file = File.join(Rails.root.to_s, "db/rails_pulse_schema.rb") + + if File.exist?(schema_file) + say "Loading Rails Pulse schema from db/rails_pulse_schema.rb" + load schema_file + RailsPulse::Schema.call(connection) + say "Rails Pulse tables created successfully" + else + raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb" + end + end + + def down + # Rollback: drop all Rails Pulse tables in reverse dependency order + say "Dropping Rails Pulse tables..." + drop_table :rails_pulse_operations if table_exists?(:rails_pulse_operations) + drop_table :rails_pulse_job_runs if table_exists?(:rails_pulse_job_runs) + drop_table :rails_pulse_jobs if table_exists?(:rails_pulse_jobs) + drop_table :rails_pulse_summaries if table_exists?(:rails_pulse_summaries) + drop_table :rails_pulse_requests if table_exists?(:rails_pulse_requests) + drop_table :rails_pulse_routes if table_exists?(:rails_pulse_routes) + drop_table :rails_pulse_queries if table_exists?(:rails_pulse_queries) + say "Rails Pulse tables dropped successfully" + end + + private + + def rails_pulse_installed? + table_exists?(:rails_pulse_routes) && table_exists?(:rails_pulse_requests) + end +end diff --git a/test/dummy/db/rails_pulse_schema.rb b/test/dummy/db/rails_pulse_schema.rb new file mode 100644 index 0000000..6aeae45 --- /dev/null +++ b/test/dummy/db/rails_pulse_schema.rb @@ -0,0 +1,214 @@ +# Rails Pulse Database Schema +# This file contains the complete schema for Rails Pulse tables +# Load with: rails db:schema:load:rails_pulse or db:prepare + +RailsPulse::Schema = lambda do |connection| + adapter = connection.adapter_name.downcase + # Skip if all tables already exist to prevent conflicts + required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries ] + + # Check which tables already exist + existing_tables = required_tables.select { |table| connection.table_exists?(table) } + missing_tables = required_tables - existing_tables + + # Always log for transparency (not just in CI) + if existing_tables.any? + puts "[RailsPulse::Schema] Existing tables detected: #{existing_tables.join(', ')}" + end + + if missing_tables.any? + puts "[RailsPulse::Schema] Creating missing tables: #{missing_tables.join(', ')}" + end + + # If all tables exist, skip creation entirely + if missing_tables.empty? + puts "[RailsPulse::Schema] All Rails Pulse tables already exist. Skipping schema load." + return + end + + unless connection.table_exists?(:rails_pulse_routes) + connection.create_table :rails_pulse_routes do |t| + t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)" + t.string :path, null: false, comment: "Request path (e.g., /posts/index)" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path" + end + + unless connection.table_exists?(:rails_pulse_queries) + connection.create_table :rails_pulse_queries do |t| + t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)" + t.datetime :analyzed_at, comment: "When query analysis was last performed" + t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution" + t.text :issues, comment: "JSON array of detected performance issues" + t.text :metadata, comment: "JSON object containing query complexity metrics" + t.text :query_stats, comment: "JSON object with query characteristics analysis" + t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection" + t.text :index_recommendations, comment: "JSON array of database index recommendations" + t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results" + t.text :suggestions, comment: "JSON array of optimization recommendations" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191 + end + + unless connection.table_exists?(:rails_pulse_requests) + connection.create_table :rails_pulse_requests do |t| + t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds" + t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)" + t.boolean :is_error, null: false, default: false, comment: "True if status >= 500" + t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)" + t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at" + connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid" + connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" + end + + unless connection.table_exists?(:rails_pulse_jobs) + connection.create_table :rails_pulse_jobs do |t| + t.string :name, null: false, comment: "Job class name" + t.string :queue_name, comment: "Default queue" + t.text :description, comment: "Optional description" + t.integer :runs_count, null: false, default: 0, comment: "Cache of total runs" + t.integer :failures_count, null: false, default: 0, comment: "Cache of failed runs" + t.integer :retries_count, null: false, default: 0, comment: "Cache of retried runs" + t.decimal :avg_duration, precision: 15, scale: 6, comment: "Average duration in milliseconds" + t.text :tags, comment: "JSON array of tags" + t.timestamps + end + + connection.add_index :rails_pulse_jobs, :name, unique: true, name: "index_rails_pulse_jobs_on_name" + connection.add_index :rails_pulse_jobs, :queue_name, name: "index_rails_pulse_jobs_on_queue" + connection.add_index :rails_pulse_jobs, :runs_count, name: "index_rails_pulse_jobs_on_runs_count" + end + + unless connection.table_exists?(:rails_pulse_job_runs) + connection.create_table :rails_pulse_job_runs do |t| + t.references :job, null: false, foreign_key: { to_table: :rails_pulse_jobs }, comment: "Link to job definition" + t.string :run_id, null: false, comment: "Adapter specific run id" + t.decimal :duration, precision: 15, scale: 6, comment: "Execution duration in milliseconds" + t.string :status, null: false, comment: "Execution status" + t.string :error_class, comment: "Error class name" + t.text :error_message, comment: "Error message" + t.integer :attempts, null: false, default: 0, comment: "Retry attempts" + t.timestamp :occurred_at, null: false, comment: "When the job started" + t.timestamp :enqueued_at, comment: "When the job was enqueued" + t.text :arguments, comment: "Serialized arguments" + t.string :adapter, comment: "Queue adapter" + t.text :tags, comment: "Execution tags" + t.timestamps + end + + connection.add_index :rails_pulse_job_runs, :run_id, unique: true, name: "index_rails_pulse_job_runs_on_run_id" + connection.add_index :rails_pulse_job_runs, [ :job_id, :occurred_at ], name: "index_rails_pulse_job_runs_on_job_and_occurred" + connection.add_index :rails_pulse_job_runs, :occurred_at, name: "index_rails_pulse_job_runs_on_occurred_at" + connection.add_index :rails_pulse_job_runs, :status, name: "index_rails_pulse_job_runs_on_status" + connection.add_index :rails_pulse_job_runs, [ :job_id, :status ], name: "index_rails_pulse_job_runs_on_job_and_status" + end + + unless connection.table_exists?(:rails_pulse_operations) + connection.create_table :rails_pulse_operations do |t| + t.references :request, null: true, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request" + t.references :job_run, null: true, foreign_key: { to_table: :rails_pulse_job_runs }, comment: "Link to a background job execution" + t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query" + t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)" + t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds" + t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)" + t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.timestamps + end + + connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type" + connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at" + connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time" + connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance" + connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type" + + if adapter.include?("postgres") || adapter.include?("mysql") + connection.add_check_constraint :rails_pulse_operations, + "(request_id IS NOT NULL OR job_run_id IS NOT NULL)", + name: "rails_pulse_operations_request_or_job_run" + end + end + + unless connection.table_exists?(:rails_pulse_summaries) + connection.create_table :rails_pulse_summaries do |t| + # Time fields + t.datetime :period_start, null: false, comment: "Start of the aggregation period" + t.datetime :period_end, null: false, comment: "End of the aggregation period" + t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month" + + # Polymorphic association to handle both routes and queries + t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query" + # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query') + # and summarizable_id (route_id or query_id) + + # Universal metrics + t.integer :count, default: 0, null: false, comment: "Total number of requests/operations" + t.float :avg_duration, comment: "Average duration in milliseconds" + t.float :min_duration, comment: "Minimum duration in milliseconds" + t.float :max_duration, comment: "Maximum duration in milliseconds" + t.float :p50_duration, comment: "50th percentile duration" + t.float :p95_duration, comment: "95th percentile duration" + t.float :p99_duration, comment: "99th percentile duration" + t.float :total_duration, comment: "Total duration in milliseconds" + t.float :stddev_duration, comment: "Standard deviation of duration" + + # Request/Route specific metrics + t.integer :error_count, default: 0, comment: "Number of error responses (5xx)" + t.integer :success_count, default: 0, comment: "Number of successful responses" + t.integer :status_2xx, default: 0, comment: "Number of 2xx responses" + t.integer :status_3xx, default: 0, comment: "Number of 3xx responses" + t.integer :status_4xx, default: 0, comment: "Number of 4xx responses" + t.integer :status_5xx, default: 0, comment: "Number of 5xx responses" + + t.timestamps + end + + # Unique constraint and indexes for summaries + connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], + unique: true, + name: "idx_pulse_summaries_unique" + connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" + connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + end + + # Add indexes to existing tables for efficient aggregation + unless connection.index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation") + connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" + end + + unless connection.index_exists?(:rails_pulse_requests, :created_at, name: "idx_requests_created_at") + connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + end + + unless connection.index_exists?(:rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation") + connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" + end + + unless connection.index_exists?(:rails_pulse_operations, :created_at, name: "idx_operations_created_at") + connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + end + + # Log successful creation + created_tables = required_tables.select { |table| connection.table_exists?(table) } + newly_created = created_tables - existing_tables + if newly_created.any? + puts "[RailsPulse::Schema] Successfully created tables: #{newly_created.join(', ')}" + end +end + +# In test/dummy, the schema is loaded explicitly by the install migration +# DO NOT auto-execute here to avoid double execution diff --git a/test/dummy/db/seeds.rb b/test/dummy/db/seeds.rb index 12127e7..6591ff6 100644 --- a/test/dummy/db/seeds.rb +++ b/test/dummy/db/seeds.rb @@ -1,3 +1,31 @@ +# Display database connection info +db_config = ActiveRecord::Base.connection_db_config +puts "=" * 80 +puts "Adapter: #{db_config.adapter}" +puts "Database: #{db_config.database}" +puts "Host: #{db_config.host}" if db_config.host +puts "=" * 80 +puts "" + +# Helper methods for job summaries +def job_seed_percentile(values, fraction) + return nil if values.empty? + + index = (fraction * (values.length - 1)).floor + fraction_part = (fraction * (values.length - 1)) - index + + return values[index] if fraction_part.zero? || index + 1 >= values.length + + values[index] + (values[index + 1] - values[index]) * fraction_part +end + +def job_seed_stddev(values, mean) + return nil if values.length < 2 || mean.nil? + + sum_of_squares = values.sum { |value| (value - mean) ** 2 } + Math.sqrt(sum_of_squares / (values.length - 1)) +end + # Clear existing data Comment.destroy_all Post.destroy_all @@ -415,6 +443,399 @@ puts "- #{RailsPulse::Request.count} requests" puts "- #{RailsPulse::Operation.count} operations" + # Generate background job data + puts "\nGenerating background job data..." + + # Clear existing job data + RailsPulse::JobRun.destroy_all + RailsPulse::Job.destroy_all + + # Define realistic job classes + job_definitions = [ + { + name: "UserMailerJob", + queue_name: "mailers", + base_duration: 150, + variance: 100, + error_rate: 0.02, + runs_per_day: 50 + }, + { + name: "DataExportJob", + queue_name: "default", + base_duration: 2500, + variance: 1500, + error_rate: 0.08, + runs_per_day: 12 + }, + { + name: "ImageProcessingJob", + queue_name: "media", + base_duration: 800, + variance: 400, + error_rate: 0.05, + runs_per_day: 35 + }, + { + name: "ReportGeneratorJob", + queue_name: "reports", + base_duration: 5000, + variance: 3000, + error_rate: 0.10, + runs_per_day: 8 + }, + { + name: "CacheWarmingJob", + queue_name: "default", + base_duration: 450, + variance: 200, + error_rate: 0.01, + runs_per_day: 100 + }, + { + name: "CleanupJob", + queue_name: "maintenance", + base_duration: 1200, + variance: 600, + error_rate: 0.03, + runs_per_day: 4 + }, + { + name: "NotificationJob", + queue_name: "notifications", + base_duration: 200, + variance: 150, + error_rate: 0.04, + runs_per_day: 80 + }, + { + name: "AnalyticsJob", + queue_name: "analytics", + base_duration: 3500, + variance: 2000, + error_rate: 0.06, + runs_per_day: 6 + }, + { + name: "WebhookDeliveryJob", + queue_name: "webhooks", + base_duration: 350, + variance: 250, + error_rate: 0.15, + runs_per_day: 45 + }, + { + name: "ImportJob", + queue_name: "imports", + base_duration: 8000, + variance: 5000, + error_rate: 0.12, + runs_per_day: 3 + } + ] + + # Create job records + created_jobs = job_definitions.map do |job_def| + RailsPulse::Job.create!( + name: job_def[:name], + queue_name: job_def[:queue_name] + ) + end + + # Generate historical job runs + total_days = 7 # 1 week of data + job_runs_count = 0 + + job_definitions.each_with_index do |job_def, index| + job = created_jobs[index] + + total_days.times do |day_offset| + day_start = (total_days + 7 - day_offset).days.ago.beginning_of_day + # Reduce runs per day to 20% of original for faster seeding + runs_for_day = (job_def[:runs_per_day] * 0.2).to_i + + # Add some variance to runs per day + runs_for_day += rand(-2..2) + runs_for_day = [ runs_for_day, 1 ].max + + runs_for_day.times do + occurred_at = day_start + rand(0..86400).seconds + + # Calculate duration with variance + duration = job_def[:base_duration] + rand(-job_def[:variance]..job_def[:variance]) + duration = [ duration, 10 ].max + + # Determine status + rand_val = rand + status = if rand_val < job_def[:error_rate] + [ "failed", "discarded" ].sample + elsif rand_val < job_def[:error_rate] + 0.03 + "retried" + else + "success" + end + + # Attempts based on status + attempts = case status + when "success" + 1 + when "retried" + rand(2..3) + when "failed" + rand(1..3) + when "discarded" + rand(3..5) + else + 1 + end + + # Error details for failed jobs + error_class = nil + error_message = nil + + if [ "failed", "discarded" ].include?(status) + error_classes = [ + "ActiveRecord::RecordInvalid", + "Net::ReadTimeout", + "StandardError", + "ArgumentError", + "ActiveJob::DeserializationError", + "JSON::ParserError", + "Redis::ConnectionError" + ] + + error_messages = [ + "Validation failed: Email can't be blank", + "Connection timeout after 30 seconds", + "Unable to process request", + "Invalid argument provided", + "Failed to deserialize job arguments", + "Unexpected token in JSON", + "Connection refused - unable to connect to Redis" + ] + + error_class = error_classes.sample + error_message = error_messages.sample + end + + # Enqueued time (a few seconds before occurred_at) + enqueued_at = occurred_at - rand(1..30).seconds + + # Create job run + job_run = RailsPulse::JobRun.create!( + job: job, + run_id: SecureRandom.uuid, + status: status, + duration: duration, + occurred_at: occurred_at, + enqueued_at: enqueued_at, + attempts: attempts, + adapter: [ "active_job", "sidekiq", "solid_queue" ].sample, + error_class: error_class, + error_message: error_message + ) + + job_runs_count += 1 + + # Create some operations for this job run (reduced for faster seeding) + operation_count = case job_def[:name] + when "UserMailerJob", "NotificationJob" + rand(1..3) + when "DataExportJob", "ReportGeneratorJob", "AnalyticsJob", "ImportJob" + rand(3..8) + when "ImageProcessingJob" + rand(2..5) + when "CacheWarmingJob" + rand(2..4) + when "WebhookDeliveryJob" + rand(1..3) + when "CleanupJob" + rand(2..5) + else + rand(1..3) + end + + current_time = 0.0 + operation_count.times do + operation_type = [ "sql", "template", "controller" ].sample + + operation_duration = case operation_type + when "sql" + rand(10..300) + when "template" + rand(50..150) + when "controller" + rand(20..100) + end + + # Assign query for SQL operations + query = if operation_type == "sql" && created_queries.any? + created_queries.sample + else + nil + end + + operation_label = case operation_type + when "sql" + query&.normalized_sql&.split(" ")&.first(5)&.join(" ") || "SQL Query" + when "template" + [ "layouts/application", "mailers/user_mailer", "jobs/export" ].sample + when "controller" + [ "JobController#perform", "Processing job", "Job execution" ].sample + end + + codebase_location = case job_def[:name] + when "UserMailerJob" + "app/mailers/user_mailer.rb:#{rand(10..50)}" + when "DataExportJob" + "app/jobs/data_export_job.rb:#{rand(20..80)}" + when "ImageProcessingJob" + "app/jobs/image_processing_job.rb:#{rand(15..60)}" + when "ReportGeneratorJob" + "app/jobs/report_generator_job.rb:#{rand(25..100)}" + when "CacheWarmingJob" + "app/jobs/cache_warming_job.rb:#{rand(10..40)}" + when "CleanupJob" + "app/jobs/cleanup_job.rb:#{rand(15..50)}" + when "NotificationJob" + "app/jobs/notification_job.rb:#{rand(10..45)}" + when "AnalyticsJob" + "app/jobs/analytics_job.rb:#{rand(30..90)}" + when "WebhookDeliveryJob" + "app/jobs/webhook_delivery_job.rb:#{rand(15..55)}" + when "ImportJob" + "app/jobs/import_job.rb:#{rand(40..120)}" + else + "app/jobs/application_job.rb:#{rand(5..30)}" + end + + RailsPulse::Operation.create!( + job_run: job_run, + query: query, + operation_type: operation_type, + label: operation_label, + duration: operation_duration, + codebase_location: codebase_location, + start_time: current_time, + occurred_at: occurred_at + ) + + current_time += operation_duration + end + end + + print "." if day_offset % 5 == 0 + end + end + + puts "\n\nGenerated background job data:" + puts "- #{RailsPulse::Job.count} job classes" + puts "- #{job_runs_count} job runs" + puts "- #{RailsPulse::Operation.where.not(job_run_id: nil).count} job operations" + + puts "\nAggregating job summaries..." + RailsPulse::Summary.where(summarizable_type: "RailsPulse::Job").delete_all + + summary_periods = %w[hour day week] + summary_count = 0 + + RailsPulse::Job.find_each do |job| + runs = job.runs.where(status: RailsPulse::JobRun::FINAL_STATUSES).to_a + next if runs.empty? + + summary_periods.each do |period_type| + runs.group_by { |run| RailsPulse::Summary.normalize_period_start(period_type, run.occurred_at) }.each do |period_start, grouped_runs| + durations = grouped_runs.map(&:duration).compact.map(&:to_f).sort + next if durations.empty? + + average_duration = durations.sum / durations.size + + summary = RailsPulse::Summary.find_or_initialize_by( + summarizable: job, + period_type: period_type, + period_start: period_start + ) + + summary.assign_attributes( + period_end: RailsPulse::Summary.calculate_period_end(period_type, period_start), + count: grouped_runs.size, + avg_duration: average_duration, + min_duration: durations.first, + max_duration: durations.last, + total_duration: durations.sum, + p50_duration: job_seed_percentile(durations, 0.5), + p95_duration: job_seed_percentile(durations, 0.95), + p99_duration: job_seed_percentile(durations, 0.99), + stddev_duration: job_seed_stddev(durations, average_duration), + error_count: grouped_runs.count { |run| run.failure_like_status? }, + success_count: grouped_runs.count { |run| run.status == "success" } + ) + + summary.save! + summary_count += 1 + end + end + end + + puts "- #{summary_count} job summaries from historical runs" + + puts "\nCreating synthetic job summaries for the most recent week..." + + job_definitions_by_name = job_definitions.index_by { |defn| defn[:name] } + synthetic_summary_count = 0 + + # Include current day plus the previous 7 full days to avoid gaps between + # historical run data (two weeks ago) and the synthetic summaries for last week. + recent_days = (0..7).map { |offset| offset.days.ago.beginning_of_day } + + RailsPulse::Job.find_each do |job| + job_def = job_definitions_by_name[job.name] + next unless job_def + + recent_days.each do |period_start| + summary = RailsPulse::Summary.find_or_initialize_by( + summarizable: job, + period_type: "day", + period_start: period_start + ) + + # Skip if historical data already generated this summary + next if summary.persisted? + + # Build synthetic durations based on job definition to mimic recent activity + run_count = [ (job_def[:runs_per_day] * 0.15).round, 1 ].max + durations = Array.new(run_count) do + value = job_def[:base_duration] + rand(-job_def[:variance]..job_def[:variance]) + [ value, 10 ].max.to_f + end.sort + + average_duration = durations.sum / durations.size + error_estimate = [ (durations.size * job_def[:error_rate]).round, durations.size ].min + success_estimate = durations.size - error_estimate + + summary.assign_attributes( + period_end: RailsPulse::Summary.calculate_period_end("day", period_start), + count: durations.size, + avg_duration: average_duration, + min_duration: durations.first, + max_duration: durations.last, + total_duration: durations.sum, + p50_duration: job_seed_percentile(durations, 0.5), + p95_duration: job_seed_percentile(durations, 0.95), + p99_duration: job_seed_percentile(durations, 0.99), + stddev_duration: job_seed_stddev(durations, average_duration), + error_count: error_estimate, + success_count: success_estimate + ) + + summary.save! + synthetic_summary_count += 1 + end + end + + puts "- #{synthetic_summary_count} synthetic job summaries for last week" + puts "- #{summary_count + synthetic_summary_count} job summaries total" + # Add some additional user/post data for more realistic scenarios first_names = %w[Isabella Jack Kate Liam Maya Noah Olivia Parker Quinn Ruby Sam Tara Ulysses Victoria William Xavier Yara Zoe Alexander Benjamin Charlotte Daniel Elizabeth Felix Gabriel Hannah Isaac Julia Kevin Luna Marcus Natalie Oscar Penelope] last_names = %w[Anderson Thomas Jackson White Harris Martin Thompson Garcia Martinez Robinson Clark Rodriguez Lewis Lee Walker Hall Allen Young Hernandez King Wright Lopez Hill Green Adams Baker Gonzalez Nelson Carter Mitchell] @@ -540,8 +961,12 @@ puts "Queries: #{RailsPulse::Query.count}" puts "Requests: #{RailsPulse::Request.count}" puts "Operations: #{RailsPulse::Operation.count}" + puts "Jobs: #{RailsPulse::Job.count}" + puts "Job Runs: #{RailsPulse::JobRun.count}" puts "Average request duration: #{RailsPulse::Request.average(:duration).to_f.round(2)} ms" - puts "Error rate: #{(RailsPulse::Request.where(is_error: true).count.to_f / RailsPulse::Request.count * 100).round(2)}%" + puts "Average job duration: #{RailsPulse::JobRun.average(:duration).to_f.round(2)} ms" + puts "Request error rate: #{(RailsPulse::Request.where(is_error: true).count.to_f / RailsPulse::Request.count * 100).round(2)}%" + puts "Job failure rate: #{(RailsPulse::JobRun.where(status: %w[failed discarded]).count.to_f / RailsPulse::JobRun.count * 100).round(2)}%" # Generate day summaries for all historical data puts "\nGenerating day summaries for all historical data..." @@ -597,7 +1022,7 @@ is_complex && !query.operations.exists? end - puts " Found #{complex_queries_without_ops.count} complex queries without operations" + puts "Found #{complex_queries_without_ops.count} complex queries without operations" # Create operations for these complex queries analytics_route = created_routes.find { |r| r.path.include?("complex") } || created_routes.first @@ -634,12 +1059,12 @@ print "." end - puts "\n Created operations for #{complex_queries_without_ops.count} complex queries" + puts "\nCreated operations for #{complex_queries_without_ops.count} complex queries" # Select queries that now have operations for analysis complex_queries_for_analysis = RailsPulse::Query.joins(:operations).distinct.limit(20) - puts "Analyzing #{complex_queries_for_analysis.count} complex queries..." + puts "\nAnalyzing #{complex_queries_for_analysis.count} complex queries..." analyzed_successfully = 0 @@ -647,7 +1072,6 @@ begin # Only analyze queries that have operations (remove time restriction for seed data) if query.operations.exists? - puts " Analyzing query #{query.id}: #{query.normalized_sql[0..80]}..." RailsPulse::QueryAnalysisService.analyze_query(query.id) analyzed_successfully += 1 print "." @@ -660,34 +1084,12 @@ end end - puts "\n Successfully analyzed: #{analyzed_successfully}" - analyzed_count = RailsPulse::Query.where.not(analyzed_at: nil).count puts "\n\nQuery analysis completed!" puts "Analyzed queries: #{analyzed_count}" puts "Total issues detected: #{RailsPulse::Query.where.not(issues: [ nil, "[]" ]).count}" puts "Queries with suggestions: #{RailsPulse::Query.where.not(suggestions: [ nil, "[]" ]).count}" - # Display some example analysis results - if analyzed_count > 0 - puts "\nExample analysis results:" - RailsPulse::Query.where.not(analyzed_at: nil).limit(3).each do |analyzed_query| - puts "\nQuery: #{analyzed_query.normalized_sql[0..100]}..." - if analyzed_query.issues.any? - puts " Issues (#{analyzed_query.issues.count}):" - analyzed_query.issues.first(2).each do |issue| - puts " - #{issue['severity'].upcase}: #{issue['description']}" - end - end - if analyzed_query.suggestions.any? - puts " Suggestions (#{analyzed_query.suggestions.count}):" - analyzed_query.suggestions.first(2).each do |suggestion| - puts " - #{suggestion['type'].upcase}: #{suggestion['action']}" - end - end - end - end - # Generate summaries for the complex queries we just created operations for puts "\nGenerating summaries for newly created complex query operations..." puts "This ensures complex queries appear on the index page." diff --git a/test/fixtures/rails_pulse_job_runs.yml b/test/fixtures/rails_pulse_job_runs.yml new file mode 100644 index 0000000..136c705 --- /dev/null +++ b/test/fixtures/rails_pulse_job_runs.yml @@ -0,0 +1,79 @@ +mailer_run_success: + job: mailer_job + run_id: mailer-run-1 + duration: 150.0 + status: success + error_class: + error_message: + attempts: 1 + occurred_at: 2024-01-01 00:05:00 + enqueued_at: 2024-01-01 00:00:00 + arguments: '["user_id", 42]' + adapter: active_job + tags: '[]' + created_at: 2024-01-01 00:05:01 + updated_at: 2024-01-01 00:05:01 + +report_run_retried: + job: report_job + run_id: report-run-1 + duration: 650.0 + status: retried + error_class: RuntimeError + error_message: Timeout reached + attempts: 2 + occurred_at: 2024-01-02 09:00:00 + enqueued_at: 2024-01-02 08:50:00 + arguments: '["report_id", 100]' + adapter: sidekiq + tags: '[]' + created_at: 2024-01-02 09:00:01 + updated_at: 2024-01-02 09:00:01 + +report_run_success: + job: report_job + run_id: report-run-2 + duration: 250.0 + status: success + error_class: + error_message: + attempts: 1 + occurred_at: 2024-01-02 12:00:00 + enqueued_at: 2024-01-02 11:45:00 + arguments: '["report_id", 101]' + adapter: sidekiq + tags: '[]' + created_at: 2024-01-02 12:00:01 + updated_at: 2024-01-02 12:00:01 + +report_run_failed: + job: report_job + run_id: report-run-3 + duration: 900.0 + status: failed + error_class: StandardError + error_message: Reporting failed due to timeout + attempts: 3 + occurred_at: 2024-01-03 08:30:00 + enqueued_at: 2024-01-03 08:20:00 + arguments: '["report_id", 102]' + adapter: sidekiq + tags: '[]' + created_at: 2024-01-03 08:30:01 + updated_at: 2024-01-03 08:30:01 + +report_run_success_2: + job: report_job + run_id: report-run-4 + duration: 350.0 + status: success + error_class: + error_message: + attempts: 1 + occurred_at: 2024-01-03 14:00:00 + enqueued_at: 2024-01-03 13:45:00 + arguments: '["report_id", 103]' + adapter: sidekiq + tags: '[]' + created_at: 2024-01-03 14:00:01 + updated_at: 2024-01-03 14:00:01 diff --git a/test/fixtures/rails_pulse_jobs.yml b/test/fixtures/rails_pulse_jobs.yml new file mode 100644 index 0000000..bf9e1a2 --- /dev/null +++ b/test/fixtures/rails_pulse_jobs.yml @@ -0,0 +1,23 @@ +mailer_job: + name: UserMailerJob + queue_name: mailers + description: Sends notification emails + runs_count: 1 + failures_count: 0 + retries_count: 0 + avg_duration: 150.0 + tags: '[]' + created_at: 2024-01-01 00:00:00 + updated_at: 2024-01-01 00:00:00 + +report_job: + name: GenerateReportJob + queue_name: default + description: Generates analytics reports + runs_count: 4 + failures_count: 2 + retries_count: 1 + avg_duration: 450.0 + tags: '["report"]' + created_at: 2024-01-02 00:00:00 + updated_at: 2024-01-02 00:00:00 diff --git a/test/fixtures/rails_pulse_operations.yml b/test/fixtures/rails_pulse_operations.yml index 3006ba6..be981c6 100644 --- a/test/fixtures/rails_pulse_operations.yml +++ b/test/fixtures/rails_pulse_operations.yml @@ -64,4 +64,14 @@ sql_operation_4: start_time: 20.0 occurred_at: <%= Time.current - 1.hour %> created_at: <%= Time.current - 1.hour %> - updated_at: <%= Time.current - 1.hour %> \ No newline at end of file + updated_at: <%= Time.current - 1.hour %> +job_sql_operation: + job_run: report_run_retried + operation_type: "sql" + label: "SELECT count(*) FROM reports" + duration: 75.0 + codebase_location: "app/jobs/generate_report_job.rb:42" + start_time: 2.0 + occurred_at: <%= Time.current - 30.minutes %> + created_at: <%= Time.current - 30.minutes %> + updated_at: <%= Time.current - 30.minutes %> diff --git a/test/helpers/rails_pulse/application_helper_test.rb b/test/helpers/rails_pulse/application_helper_test.rb index 28edb33..0604c8a 100644 --- a/test/helpers/rails_pulse/application_helper_test.rb +++ b/test/helpers/rails_pulse/application_helper_test.rb @@ -11,6 +11,7 @@ class RailsPulse::ApplicationHelperTest < ActionView::TestCase assert_includes html, "rails-pulse--icon-name-value=\"alert\"" assert_includes html, "rails-pulse--icon-width-value=\"24\"" assert_includes html, "rails-pulse--icon-height-value=\"24\"" + assert_includes html, "style=\"display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;flex-shrink:0\"" end test "rails_pulse_icon applies custom width, height and class" do @@ -19,6 +20,13 @@ class RailsPulse::ApplicationHelperTest < ActionView::TestCase assert_includes html, "rails-pulse--icon-width-value=\"32\"" assert_includes html, "rails-pulse--icon-height-value=\"32\"" assert_includes html, "class=\"my-class\"" + assert_includes html, "width:32px;height:32px" + end + + test "rails_pulse_icon merges custom style with default dimensions" do + html = rails_pulse_icon("alert", style: "margin-left:4px") + + assert_includes html, "flex-shrink:0;margin-left:4px" end test "rails_pulse_icon passes through extra attributes" do diff --git a/test/helpers/rails_pulse/breadcrumbs_helper_test.rb b/test/helpers/rails_pulse/breadcrumbs_helper_test.rb index 64e4015..8dac9b9 100644 --- a/test/helpers/rails_pulse/breadcrumbs_helper_test.rb +++ b/test/helpers/rails_pulse/breadcrumbs_helper_test.rb @@ -2,14 +2,199 @@ class RailsPulse::BreadcrumbsHelperTest < ActionView::TestCase include RailsPulse::BreadcrumbsHelper + fixtures :rails_pulse_routes, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_requests - # TODO: Test breadcrumbs returns array with Home link for root path - # TODO: Test breadcrumbs builds path segments after engine mount point - # TODO: Test breadcrumbs converts numeric segments to resource names using to_breadcrumb - # TODO: Test breadcrumbs falls back to to_s when to_breadcrumb not available - # TODO: Test breadcrumbs titleizes non-numeric segments - # TODO: Test breadcrumbs marks last segment as current - # TODO: Test breadcrumbs builds correct paths for each segment - # TODO: Test breadcrumbs handles missing resources gracefully - # TODO: Test breadcrumbs returns empty array when no segments after mount point + def setup + ENV["TEST_TYPE"] = "functional" + super + @route = rails_pulse_routes(:api_users) + @job = rails_pulse_jobs(:report_job) + @job_run = rails_pulse_job_runs(:report_run_success) + @request_record = rails_pulse_requests(:users_request_1) + end + + # Helper Structure Tests + + test "helper module is included" do + assert_respond_to self, :breadcrumbs + end + + # Root Path Tests + + test "breadcrumbs returns empty array for engine root path" do + setup_request_path("/rails_pulse") + + crumbs = breadcrumbs + + # At the root path, there are no segments after mount point + assert_equal 0, crumbs.length + end + + test "breadcrumbs returns empty array when no segments after mount point" do + setup_request_path("/rails_pulse") + + crumbs = breadcrumbs + + # When at the mount point itself, no breadcrumbs are shown + assert_equal 0, crumbs.length + end + + # Simple Path Tests + + test "breadcrumbs builds path segments after engine mount point" do + setup_request_path("/rails_pulse/routes") + + crumbs = breadcrumbs + + assert_equal 2, crumbs.length + assert_equal "Home", crumbs[0][:title] + assert_equal "Routes", crumbs[1][:title] + end + + test "breadcrumbs titleizes non-numeric segments" do + setup_request_path("/rails_pulse/routes") + + crumbs = breadcrumbs + + assert_equal "Routes", crumbs[1][:title] + end + + test "breadcrumbs marks last segment as current" do + setup_request_path("/rails_pulse/routes") + + crumbs = breadcrumbs + + assert crumbs.last[:current] + refute crumbs.first[:current] + end + + # Resource with ID Tests + + test "breadcrumbs converts numeric segments to resource names using to_breadcrumb for Route" do + setup_request_path("/rails_pulse/routes/#{@route.id}") + + crumbs = breadcrumbs + + assert_equal 3, crumbs.length + assert_equal "Home", crumbs[0][:title] + assert_equal "Routes", crumbs[1][:title] + assert_equal @route.path, crumbs[2][:title] + end + + test "breadcrumbs converts numeric segments to resource names using to_breadcrumb for Job" do + setup_request_path("/rails_pulse/jobs/#{@job.id}") + + crumbs = breadcrumbs + + assert_equal 3, crumbs.length + assert_equal "Home", crumbs[0][:title] + assert_equal "Jobs", crumbs[1][:title] + assert_equal @job.name, crumbs[2][:title] + end + + test "breadcrumbs falls back to to_s when to_breadcrumb not available" do + setup_request_path("/rails_pulse/requests/#{@request_record.id}") + + crumbs = breadcrumbs + + assert_equal 3, crumbs.length + assert_equal "Home", crumbs[0][:title] + assert_equal "Requests", crumbs[1][:title] + # Request doesn't have to_breadcrumb, so it uses to_s which returns a formatted date + assert_equal @request_record.to_s, crumbs[2][:title] + end + + # Path Building Tests + + test "breadcrumbs builds correct paths for each segment" do + setup_request_path("/rails_pulse/routes/#{@route.id}") + + crumbs = breadcrumbs + + assert_equal main_app.rails_pulse_path, crumbs[0][:path] + assert_equal "#{main_app.rails_pulse_path.chomp('/')}/routes", crumbs[1][:path] + assert_equal "#{main_app.rails_pulse_path.chomp('/')}/routes/#{@route.id}", crumbs[2][:path] + end + + test "breadcrumbs builds progressive paths for deep nesting" do + setup_request_path("/rails_pulse/routes/#{@route.id}/details") + + crumbs = breadcrumbs + + assert_equal 4, crumbs.length + assert_equal main_app.rails_pulse_path, crumbs[0][:path] + assert_equal "#{main_app.rails_pulse_path.chomp('/')}/routes", crumbs[1][:path] + assert_equal "#{main_app.rails_pulse_path.chomp('/')}/routes/#{@route.id}", crumbs[2][:path] + assert_equal "#{main_app.rails_pulse_path.chomp('/')}/routes/#{@route.id}/details", crumbs[3][:path] + end + + # Nested Resource Tests (NEW - The key feature updated in this branch) + + test "breadcrumbs links nested collection to parent show page" do + setup_request_path("/rails_pulse/jobs/#{@job.id}/runs/#{@job_run.id}") + + crumbs = breadcrumbs + + # Should have: Home > Jobs > GenerateReportJob > Runs > [job_run_id] + assert_equal 5, crumbs.length + assert_equal "Home", crumbs[0][:title] + assert_equal "Jobs", crumbs[1][:title] + assert_equal @job.name, crumbs[2][:title] + assert_equal "Runs", crumbs[3][:title] + assert_equal @job_run.id.to_s, crumbs[4][:title] + + # The "Runs" breadcrumb should link to the parent job show page, not /jobs/:id/runs + assert_equal "#{main_app.rails_pulse_path.chomp('/')}/jobs/#{@job.id}", crumbs[3][:path] + end + + test "breadcrumbs nested collection does not affect non-nested paths" do + setup_request_path("/rails_pulse/jobs/#{@job.id}/runs") + + crumbs = breadcrumbs + + # Should have: Home > Jobs > GenerateReportJob > Runs + assert_equal 4, crumbs.length + + runs_breadcrumb = crumbs[3] + + assert_equal "Runs", runs_breadcrumb[:title] + # This is NOT a nested collection (no ID after "runs"), so normal path + assert_equal "#{main_app.rails_pulse_path.chomp('/')}/jobs/#{@job.id}/runs", runs_breadcrumb[:path] + end + + # Error Handling Tests + + test "breadcrumbs handles missing resources gracefully" do + non_existent_id = 999999 + setup_request_path("/rails_pulse/routes/#{non_existent_id}") + + assert_raises ActiveRecord::RecordNotFound do + breadcrumbs + end + end + + test "breadcrumbs handles path with multiple segments" do + setup_request_path("/rails_pulse/routes/#{@route.id}/details/performance") + + crumbs = breadcrumbs + + assert_equal 5, crumbs.length + assert_equal "Home", crumbs[0][:title] + assert_equal "Routes", crumbs[1][:title] + assert_equal @route.path, crumbs[2][:title] + assert_equal "Details", crumbs[3][:title] + assert_equal "Performance", crumbs[4][:title] + end + + private + + def setup_request_path(path) + # Create a simple request stub with the path method + request_stub = Struct.new(:path).new(path) + @request = request_stub + end + + def main_app + Rails.application.routes.url_helpers + end end diff --git a/test/instrumentation/operation_subscriber_test.rb b/test/instrumentation/operation_subscriber_test.rb index b103224..da14a2d 100644 --- a/test/instrumentation/operation_subscriber_test.rb +++ b/test/instrumentation/operation_subscriber_test.rb @@ -7,6 +7,7 @@ def setup # Setup request context for operation tracking RequestStore.store[:rails_pulse_request_id] = @request.id + RequestStore.store[:rails_pulse_job_run_id] = nil RequestStore.store[:rails_pulse_operations] = [] super @@ -184,6 +185,7 @@ def teardown test "should not capture operations without request context" do RequestStore.store[:rails_pulse_request_id] = nil + RequestStore.store[:rails_pulse_job_run_id] = nil payload = { sql: "SELECT * FROM users", @@ -199,6 +201,26 @@ def teardown assert_equal 0, operations.size end + test "should capture operations for background job context" do + job_run = rails_pulse_job_runs(:mailer_run_success) + + RequestStore.store[:rails_pulse_request_id] = nil + RequestStore.store[:rails_pulse_job_run_id] = job_run.id + RequestStore.store[:rails_pulse_operations] = [] + + payload = { sql: "SELECT 1", name: "Job SQL" } + + ActiveSupport::Notifications.instrument("sql.active_record", payload) do + sleep(0.001) + end + + operations = RequestStore.store[:rails_pulse_operations] + + assert_equal 1, operations.size + assert_nil operations.first[:request_id] + assert_equal job_run.id, operations.first[:job_run_id] + end + test "should clean SQL labels by removing Rails comments" do payload = { sql: "/*action='search',application='Dummy',controller='home'*/ SELECT * FROM users", diff --git a/test/models/rails_pulse/charts/operations_chart_test.rb b/test/models/rails_pulse/charts/operations_chart_test.rb new file mode 100644 index 0000000..ba2cc2a --- /dev/null +++ b/test/models/rails_pulse/charts/operations_chart_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +module RailsPulse + module Charts + class OperationsChartTest < ActiveSupport::TestCase + # TODO: Test that chart formats operations as waterfall/timeline data + # TODO: Test that chart includes operation type, label, duration, and start_time + # TODO: Test that chart orders operations by start_time + # TODO: Test that chart handles empty operations collection + # TODO: Test that chart data format supports waterfall visualization + end + end +end diff --git a/test/models/rails_pulse/job_run_test.rb b/test/models/rails_pulse/job_run_test.rb new file mode 100644 index 0000000..28940b9 --- /dev/null +++ b/test/models/rails_pulse/job_run_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +module RailsPulse + class JobRunTest < ActiveSupport::TestCase + test "validations" do + run = JobRun.new + + assert_not run.valid? + assert_includes run.errors[:job], "must exist" + assert_includes run.errors[:run_id], "can't be blank" + assert_includes run.errors[:status], "is not included in the list" + assert_includes run.errors[:occurred_at], "can't be blank" + end + + test "all_tags combines job and run tags" do + job = rails_pulse_jobs(:report_job) + job.update!(tags: '["critical"]') + + run = rails_pulse_job_runs(:report_run_retried) + run.update!(tags: '["retry"]') + + assert_equal %w[critical retry], run.all_tags.sort + end + + test "performance_status uses job thresholds" do + run = rails_pulse_job_runs(:report_run_success) + original_thresholds = RailsPulse.configuration.job_thresholds.dup + RailsPulse.configuration.job_thresholds = { slow: 100, very_slow: 500, critical: 1_000 } + + run.update!(duration: 50) + + assert_equal :fast, run.performance_status + + run.update!(duration: 300) + + assert_equal :slow, run.performance_status + + run.update!(duration: 700) + + assert_equal :very_slow, run.performance_status + + run.update!(duration: 1_200) + + assert_equal :critical, run.performance_status + ensure + RailsPulse.configuration.job_thresholds = original_thresholds + end + + test "finalized? detects transition to final status" do + job = RailsPulse::Job.create!(name: "CallbackTestJob") + run = job.runs.create!(run_id: "callback-run", status: "running", occurred_at: Time.current) + + run.update!(status: "success", duration: 25.0) + + assert_predicate run, :finalized? + assert_not run.failure_like_status? + + run.update!(status: "retried", duration: 50.0) + + assert_not run.finalized?, "transition between final states should not trigger" + assert_predicate run, :failure_like_status? + end + end +end diff --git a/test/models/rails_pulse/job_test.rb b/test/models/rails_pulse/job_test.rb new file mode 100644 index 0000000..e26e400 --- /dev/null +++ b/test/models/rails_pulse/job_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +module RailsPulse + class JobTest < ActiveSupport::TestCase + test "validations" do + job = Job.new + + assert_not job.valid? + assert_includes job.errors[:name], "can't be blank" + + duplicate = Job.new(name: rails_pulse_jobs(:mailer_job).name) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:name], "has already been taken" + end + + test "failure rate calculation" do + job = rails_pulse_jobs(:report_job) + + assert_in_delta(50.0, job.failure_rate) + end + + test "apply_run! updates aggregates" do + job = Job.create!(name: "RailsPulse::TestJob", queue_name: "default") + run = job.runs.create!(run_id: "test-run-1", status: "running", occurred_at: Time.current, attempts: 0) + + run.update_columns(status: "retried", duration: 200.0) + + job.apply_run!(run.reload) + job.reload + + assert_in_delta 200.0, job.avg_duration, 0.01 + assert_equal 1, job.failures_count + assert_equal 1, job.retries_count + end + + test "performance_status respects thresholds" do + job = Job.create!(name: "ThresholdJob", avg_duration: 0, queue_name: "default") + original_thresholds = RailsPulse.configuration.job_thresholds.dup + + RailsPulse.configuration.job_thresholds = { slow: 100, very_slow: 500, critical: 1000 } + + job.update!(avg_duration: 50) + + assert_equal :fast, job.performance_status + + job.update!(avg_duration: 200) + + assert_equal :slow, job.performance_status + + job.update!(avg_duration: 700) + + assert_equal :very_slow, job.performance_status + + job.update!(avg_duration: 1_500) + + assert_equal :critical, job.performance_status + ensure + RailsPulse.configuration.job_thresholds = original_thresholds + end + end +end diff --git a/test/models/rails_pulse/jobs/cards/average_duration_test.rb b/test/models/rails_pulse/jobs/cards/average_duration_test.rb new file mode 100644 index 0000000..2efa5fb --- /dev/null +++ b/test/models/rails_pulse/jobs/cards/average_duration_test.rb @@ -0,0 +1,231 @@ +require "test_helper" + +module RailsPulse + module Jobs + module Cards + class AverageDurationTest < ActiveSupport::TestCase + fixtures :rails_pulse_jobs + + def setup + ENV["TEST_TYPE"] = "functional" + super + @job = rails_pulse_jobs(:report_job) + + # Clean up any existing summaries + RailsPulse::Summary.delete_all + + # Freeze time for consistent testing + @now = Time.current + travel_to @now + end + + def teardown + travel_back + super + end + + # Structure Tests + + test "card returns hash with required keys" do + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_kind_of Hash, result + assert_equal "jobs_average_duration", result[:id] + assert_equal "jobs", result[:context] + assert_equal "Average Duration", result[:title] + assert_includes result.keys, :summary + assert_includes result.keys, :chart_data + assert_includes result.keys, :trend_icon + assert_includes result.keys, :trend_amount + assert_includes result.keys, :trend_text + end + + # Calculation Tests - Specific Job + + test "card calculates average duration for specific job" do + # Create summaries for the report_job + # Current window: last 7 days + # Previous window: 8-14 days ago + + # Current window data (3 days ago: 100ms avg, 10 runs) + create_job_summary( + job: @job, + days_ago: 3, + count: 10, + avg_duration: 100.0 + ) + + # Previous window data (10 days ago: 200ms avg, 5 runs) + create_job_summary( + job: @job, + days_ago: 10, + count: 5, + avg_duration: 200.0 + ) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + # Total average: (100*10 + 200*5) / (10+5) = 2000/15 = 133.3ms + assert_equal "133 ms", result[:summary] + + # Trend: current 100ms vs previous 200ms = -50% (improvement) + assert_equal "trending-down", result[:trend_icon] + assert_equal "50.0%", result[:trend_amount] + end + + test "card calculates average duration for all jobs when job is nil" do + job1 = rails_pulse_jobs(:report_job) + job2 = rails_pulse_jobs(:mailer_job) + + # Current window data + create_job_summary(job: job1, days_ago: 3, count: 10, avg_duration: 100.0) + create_job_summary(job: job2, days_ago: 3, count: 5, avg_duration: 200.0) + + # Previous window data + create_job_summary(job: job1, days_ago: 10, count: 10, avg_duration: 150.0) + create_job_summary(job: job2, days_ago: 10, count: 5, avg_duration: 300.0) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: nil) + result = card.to_metric_card + + # Total average: (100*10 + 200*5 + 150*10 + 300*5) / (10+5+10+5) = 5000/30 = 166.7ms -> rounds to 167ms + assert_equal "167 ms", result[:summary] + end + + test "card only includes summaries for specified job" do + other_job = rails_pulse_jobs(:mailer_job) + + # Create summaries for both jobs + create_job_summary(job: @job, days_ago: 3, count: 10, avg_duration: 100.0) + create_job_summary(job: other_job, days_ago: 3, count: 10, avg_duration: 500.0) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + # Should only include report_job's 100ms, not mailer_job's 500ms + assert_equal "100 ms", result[:summary] + end + + # Trend Tests + + test "card shows trending up when current period is slower" do + create_job_summary(job: @job, days_ago: 3, count: 10, avg_duration: 200.0) + create_job_summary(job: @job, days_ago: 10, count: 10, avg_duration: 100.0) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_equal "trending-up", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + test "card shows trending down when current period is faster" do + create_job_summary(job: @job, days_ago: 3, count: 10, avg_duration: 100.0) + create_job_summary(job: @job, days_ago: 10, count: 10, avg_duration: 200.0) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_equal "trending-down", result[:trend_icon] + assert_equal "50.0%", result[:trend_amount] + end + + test "card shows move right when trend is minimal" do + create_job_summary(job: @job, days_ago: 3, count: 10, avg_duration: 100.0) + create_job_summary(job: @job, days_ago: 10, count: 10, avg_duration: 100.0) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + # Sparkline Tests + + test "card generates sparkline data for date range" do + create_job_summary(job: @job, days_ago: 3, count: 10, avg_duration: 100.0) + create_job_summary(job: @job, days_ago: 5, count: 5, avg_duration: 150.0) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_kind_of Hash, result[:chart_data] + # Should have 14 days of data (RANGE_DAYS = 14) + assert_equal 15, result[:chart_data].size + + # Each entry should have a label and value + result[:chart_data].each do |label, data| + assert_kind_of String, label + assert_kind_of Hash, data + assert_includes data.keys, :value + end + end + + test "card sparkline includes zero values for days with no data" do + create_job_summary(job: @job, days_ago: 3, count: 10, avg_duration: 100.0) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + # Most days should have 0.0 value + zero_value_count = result[:chart_data].values.count { |v| v[:value] == 0.0 } + + assert_operator zero_value_count, :>, 10 + end + + # Edge Cases + + test "card handles job with no summaries" do + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_equal "0 ms", result[:summary] + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + test "card handles only current window data" do + create_job_summary(job: @job, days_ago: 3, count: 10, avg_duration: 100.0) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_equal "100 ms", result[:summary] + # No previous data means 0% trend + assert_equal "move-right", result[:trend_icon] + end + + test "card handles only previous window data" do + create_job_summary(job: @job, days_ago: 10, count: 10, avg_duration: 200.0) + + card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job) + result = card.to_metric_card + + assert_equal "200 ms", result[:summary] + # Current is 0, previous is 200, so trending down 100% + assert_equal "trending-down", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + private + + def create_job_summary(job:, days_ago:, count:, avg_duration:) + period_start = days_ago.days.ago.beginning_of_day + + RailsPulse::Summary.create!( + summarizable_type: "RailsPulse::Job", + summarizable_id: job.id, + period_start: period_start, + period_end: period_start.end_of_day, + period_type: "day", + count: count, + avg_duration: avg_duration + ) + end + end + end + end +end diff --git a/test/models/rails_pulse/jobs/cards/failure_rate_test.rb b/test/models/rails_pulse/jobs/cards/failure_rate_test.rb new file mode 100644 index 0000000..f74430c --- /dev/null +++ b/test/models/rails_pulse/jobs/cards/failure_rate_test.rb @@ -0,0 +1,264 @@ +require "test_helper" + +module RailsPulse + module Jobs + module Cards + class FailureRateTest < ActiveSupport::TestCase + fixtures :rails_pulse_jobs + + def setup + ENV["TEST_TYPE"] = "functional" + super + @job = rails_pulse_jobs(:report_job) + + # Clean up any existing summaries + RailsPulse::Summary.delete_all + + # Freeze time for consistent testing + @now = Time.current + travel_to @now + end + + def teardown + travel_back + super + end + + # Structure Tests + + test "card returns hash with required keys" do + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + assert_kind_of Hash, result + assert_equal "jobs_failure_rate", result[:id] + assert_equal "jobs", result[:context] + assert_equal "Failure Rate", result[:title] + assert_includes result.keys, :summary + assert_includes result.keys, :chart_data + assert_includes result.keys, :trend_icon + assert_includes result.keys, :trend_amount + assert_includes result.keys, :trend_text + end + + # Calculation Tests - Specific Job + + test "card calculates failure rate for specific job" do + # Create summaries for the report_job + # Current window: 10 runs, 2 errors = 20% failure rate + create_job_summary( + job: @job, + days_ago: 3, + count: 10, + error_count: 2 + ) + + # Previous window: 10 runs, 1 error = 10% failure rate + create_job_summary( + job: @job, + days_ago: 10, + count: 10, + error_count: 1 + ) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + # Total failure rate: 3 errors / 20 runs = 15% + assert_equal "15.0%", result[:summary] + + # Trend: current 20% vs previous 10% = +100% (worse) + assert_equal "trending-up", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + test "card calculates failure rate for all jobs when job is nil" do + job1 = rails_pulse_jobs(:report_job) + job2 = rails_pulse_jobs(:mailer_job) + + # Current window data + create_job_summary(job: job1, days_ago: 3, count: 10, error_count: 2) + create_job_summary(job: job2, days_ago: 3, count: 10, error_count: 3) + + # Previous window data + create_job_summary(job: job1, days_ago: 10, count: 10, error_count: 1) + create_job_summary(job: job2, days_ago: 10, count: 10, error_count: 1) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: nil) + result = card.to_metric_card + + # Total failure rate: 7 errors / 40 runs = 17.5% + assert_equal "17.5%", result[:summary] + end + + test "card only includes summaries for specified job" do + other_job = rails_pulse_jobs(:mailer_job) + + # Create summaries for both jobs + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 1) + create_job_summary(job: other_job, days_ago: 3, count: 10, error_count: 5) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + # Should only include report_job's 10% failure rate, not mailer_job's 50% + assert_equal "10.0%", result[:summary] + end + + # Trend Tests + + test "card shows trending up when current failure rate is higher" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 5) + create_job_summary(job: @job, days_ago: 10, count: 10, error_count: 1) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + # Current 50% vs previous 10% = +400% increase (worse) + assert_equal "trending-up", result[:trend_icon] + assert_equal "400.0%", result[:trend_amount] + end + + test "card shows trending down when current failure rate is lower" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 1) + create_job_summary(job: @job, days_ago: 10, count: 10, error_count: 5) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + # Current 10% vs previous 50% = -80% decrease (better) + assert_equal "trending-down", result[:trend_icon] + assert_equal "80.0%", result[:trend_amount] + end + + test "card shows move right when trend is minimal" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 1) + create_job_summary(job: @job, days_ago: 10, count: 10, error_count: 1) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + # Sparkline Tests + + test "card generates sparkline data for date range" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 2) + create_job_summary(job: @job, days_ago: 5, count: 10, error_count: 3) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + assert_kind_of Hash, result[:chart_data] + # Should have 14 days of data (RANGE_DAYS = 14) + assert_equal 15, result[:chart_data].size + + # Each entry should have a label and value + result[:chart_data].each do |label, data| + assert_kind_of String, label + assert_kind_of Hash, data + assert_includes data.keys, :value + end + end + + test "card sparkline includes zero values for days with no data" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 2) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + # Most days should have 0.0 value + zero_value_count = result[:chart_data].values.count { |v| v[:value] == 0.0 } + + assert_operator zero_value_count, :>, 10 + end + + test "card sparkline calculates failure rate percentages correctly" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 5) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + # Find the day with data + day_with_data = result[:chart_data].values.find { |v| v[:value] > 0 } + + # Should be 50% (5 errors / 10 runs) + assert_in_delta(50.0, day_with_data[:value]) + end + + # Edge Cases + + test "card handles job with no summaries" do + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + assert_equal "0.0%", result[:summary] + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + test "card handles job with no errors" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 0) + create_job_summary(job: @job, days_ago: 10, count: 10, error_count: 0) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + assert_equal "0.0%", result[:summary] + assert_equal "move-right", result[:trend_icon] + end + + test "card handles 100% failure rate" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 10) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + assert_equal "100.0%", result[:summary] + end + + test "card handles only current window data" do + create_job_summary(job: @job, days_ago: 3, count: 10, error_count: 2) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + assert_equal "20.0%", result[:summary] + # No previous data means 0% trend + assert_equal "move-right", result[:trend_icon] + end + + test "card handles only previous window data" do + create_job_summary(job: @job, days_ago: 10, count: 10, error_count: 3) + + card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job) + result = card.to_metric_card + + assert_equal "30.0%", result[:summary] + # Current is 0%, previous is 30%, so trending down 100% + assert_equal "trending-down", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + private + + def create_job_summary(job:, days_ago:, count:, error_count:) + period_start = days_ago.days.ago.beginning_of_day + + RailsPulse::Summary.create!( + summarizable_type: "RailsPulse::Job", + summarizable_id: job.id, + period_start: period_start, + period_end: period_start.end_of_day, + period_type: "day", + count: count, + error_count: error_count, + avg_duration: 0.0 # Not used for failure rate, but required by schema + ) + end + end + end + end +end diff --git a/test/models/rails_pulse/jobs/cards/total_jobs_test.rb b/test/models/rails_pulse/jobs/cards/total_jobs_test.rb new file mode 100644 index 0000000..c1df08b --- /dev/null +++ b/test/models/rails_pulse/jobs/cards/total_jobs_test.rb @@ -0,0 +1,327 @@ +require "test_helper" + +module RailsPulse + module Jobs + module Cards + class TotalJobsTest < ActiveSupport::TestCase + fixtures :rails_pulse_jobs + + def setup + ENV["TEST_TYPE"] = "functional" + super + @job = rails_pulse_jobs(:report_job) + + # Clean up any existing summaries + RailsPulse::Summary.delete_all + + # Freeze time for consistent testing + @now = Time.current + travel_to @now + + # Store initial job count for tests + @initial_job_count = RailsPulse::Job.count + end + + def teardown + travel_back + super + end + + # Structure Tests - Specific Job + + test "card returns hash with required keys for specific job" do + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + assert_kind_of Hash, result + assert_equal "jobs_total_jobs", result[:id] + assert_equal "jobs", result[:context] + assert_equal "Total Runs", result[:title] + assert_includes result.keys, :summary + assert_includes result.keys, :chart_data + assert_includes result.keys, :trend_icon + assert_includes result.keys, :trend_amount + assert_includes result.keys, :trend_text + assert_equal "Compared to previous week", result[:trend_text] + end + + # Structure Tests - All Jobs + + test "card returns hash with required keys for all jobs" do + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: nil) + result = card.to_metric_card + + assert_kind_of Hash, result + assert_equal "jobs_total_jobs", result[:id] + assert_equal "jobs", result[:context] + assert_equal "Total Jobs", result[:title] + assert_includes result.keys, :summary + assert_includes result.keys, :chart_data + assert_includes result.keys, :trend_icon + assert_includes result.keys, :trend_amount + assert_includes result.keys, :trend_text + assert_equal "New jobs vs previous week", result[:trend_text] + end + + # Calculation Tests - Specific Job (Total Runs) + + test "card calculates total runs for specific job" do + # Current window: 10 runs + create_job_summary(job: @job, days_ago: 3, count: 10) + + # Previous window: 5 runs + create_job_summary(job: @job, days_ago: 10, count: 5) + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + # Total: 15 runs + assert_equal "15 runs", result[:summary] + + # Trend: current 10 vs previous 5 = +100% + assert_equal "trending-up", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + test "card only includes summaries for specified job" do + other_job = rails_pulse_jobs(:mailer_job) + + # Create summaries for both jobs + create_job_summary(job: @job, days_ago: 3, count: 10) + create_job_summary(job: other_job, days_ago: 3, count: 100) + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + # Should only include report_job's 10 runs, not other_job's 100 runs + assert_equal "10 runs", result[:summary] + end + + # Calculation Tests - All Jobs (Total Jobs Count) + + test "card calculates total jobs count" do + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: nil) + result = card.to_metric_card + + # Should count all fixture jobs + assert_equal "#{@initial_job_count} jobs", result[:summary] + end + + test "card tracks new jobs created in time windows" do + # Current window: 2 new jobs + travel_to 3.days.ago do + RailsPulse::Job.create!( + name: "CurrentJob1", + queue_name: "default", + runs_count: 0, + failures_count: 0, + retries_count: 0, + avg_duration: 0.0 + ) + end + + travel_to 2.days.ago do + RailsPulse::Job.create!( + name: "CurrentJob2", + queue_name: "default", + runs_count: 0, + failures_count: 0, + retries_count: 0, + avg_duration: 0.0 + ) + end + + # Previous window: 1 new job + travel_to 10.days.ago do + RailsPulse::Job.create!( + name: "PreviousJob1", + queue_name: "default", + runs_count: 0, + failures_count: 0, + retries_count: 0, + avg_duration: 0.0 + ) + end + + travel_to @now + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: nil) + result = card.to_metric_card + + # Total: initial fixture jobs + 3 created above + expected_count = @initial_job_count + 3 + + assert_equal "#{expected_count} jobs", result[:summary] + + # Trend: 2 current vs 1 previous = +100% + assert_equal "trending-up", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + # Trend Tests - Specific Job + + test "card shows trending up when current runs increase" do + create_job_summary(job: @job, days_ago: 3, count: 20) + create_job_summary(job: @job, days_ago: 10, count: 10) + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + assert_equal "trending-up", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + test "card shows trending down when current runs decrease" do + create_job_summary(job: @job, days_ago: 3, count: 5) + create_job_summary(job: @job, days_ago: 10, count: 10) + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + assert_equal "trending-down", result[:trend_icon] + assert_equal "50.0%", result[:trend_amount] + end + + test "card shows move right when runs are stable" do + create_job_summary(job: @job, days_ago: 3, count: 10) + create_job_summary(job: @job, days_ago: 10, count: 10) + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + # Trend Tests - All Jobs + + test "card shows trending down when fewer new jobs are created" do + # Previous window: 2 new jobs + travel_to 12.days.ago do + RailsPulse::Job.create!(name: "OldJob1", queue_name: "default", runs_count: 0, failures_count: 0, retries_count: 0, avg_duration: 0.0) + end + + travel_to 10.days.ago do + RailsPulse::Job.create!(name: "OldJob2", queue_name: "default", runs_count: 0, failures_count: 0, retries_count: 0, avg_duration: 0.0) + end + + # Current window: 1 new job + travel_to 3.days.ago do + RailsPulse::Job.create!(name: "NewJob1", queue_name: "default", runs_count: 0, failures_count: 0, retries_count: 0, avg_duration: 0.0) + end + + travel_to @now + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: nil) + result = card.to_metric_card + + assert_equal "trending-down", result[:trend_icon] + assert_equal "50.0%", result[:trend_amount] + end + + # Sparkline Tests + + test "card generates sparkline data for specific job" do + create_job_summary(job: @job, days_ago: 3, count: 10) + create_job_summary(job: @job, days_ago: 5, count: 5) + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + assert_kind_of Hash, result[:chart_data] + # Should have 15 days of data (14 days + today) + assert_equal 15, result[:chart_data].size + + # Each entry should have a label and value + result[:chart_data].each do |label, data| + assert_kind_of String, label + assert_kind_of Hash, data + assert_includes data.keys, :value + end + end + + test "card generates sparkline data for all jobs" do + travel_to 3.days.ago do + RailsPulse::Job.create!(name: "RecentJob", queue_name: "default", runs_count: 0, failures_count: 0, retries_count: 0, avg_duration: 0.0) + end + + travel_to @now + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: nil) + result = card.to_metric_card + + assert_kind_of Hash, result[:chart_data] + # Should have 15 days of data + assert_equal 15, result[:chart_data].size + end + + # Edge Cases - Specific Job + + test "card handles job with no runs" do + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + assert_equal "0 runs", result[:summary] + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + test "card handles job with only current window data" do + create_job_summary(job: @job, days_ago: 3, count: 15) + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + assert_equal "15 runs", result[:summary] + # No previous data means infinite growth, but previous is 0 so move-right + assert_equal "move-right", result[:trend_icon] + end + + test "card handles job with only previous window data" do + create_job_summary(job: @job, days_ago: 10, count: 20) + + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: @job) + result = card.to_metric_card + + assert_equal "20 runs", result[:summary] + # Current is 0, previous is 20, so trending down 100% + assert_equal "trending-down", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + # Edge Cases - All Jobs + + test "card handles no new jobs in either window" do + # Fixture jobs were created before the test, so they're outside the 14-day range + # No new jobs created in current or previous window + card = RailsPulse::Jobs::Cards::TotalJobs.new(job: nil) + result = card.to_metric_card + + # Should still count the fixture jobs + assert_equal "#{@initial_job_count} jobs", result[:summary] + + # But trend should be flat (0 new jobs in both windows) + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + private + + def create_job_summary(job:, days_ago:, count:) + period_start = days_ago.days.ago.beginning_of_day + + RailsPulse::Summary.create!( + summarizable_type: "RailsPulse::Job", + summarizable_id: job.id, + period_start: period_start, + period_end: period_start.end_of_day, + period_type: "day", + count: count, + error_count: 0, # Not used for total jobs, but required by schema + avg_duration: 0.0 # Not used for total jobs, but required by schema + ) + end + end + end + end +end diff --git a/test/models/rails_pulse/jobs/cards/total_runs_test.rb b/test/models/rails_pulse/jobs/cards/total_runs_test.rb new file mode 100644 index 0000000..6091607 --- /dev/null +++ b/test/models/rails_pulse/jobs/cards/total_runs_test.rb @@ -0,0 +1,279 @@ +require "test_helper" + +module RailsPulse + module Jobs + module Cards + class TotalRunsTest < ActiveSupport::TestCase + fixtures :rails_pulse_jobs + + def setup + ENV["TEST_TYPE"] = "functional" + super + @job = rails_pulse_jobs(:report_job) + + # Clean up any existing summaries + RailsPulse::Summary.delete_all + + # Freeze time for consistent testing + @now = Time.current + travel_to @now + end + + def teardown + travel_back + super + end + + # Structure Tests + + test "card returns hash with required keys for specific job" do + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_kind_of Hash, result + assert_equal "jobs_total_runs", result[:id] + assert_equal "jobs", result[:context] + assert_equal "Job Runs", result[:title] + assert_includes result.keys, :summary + assert_includes result.keys, :chart_data + assert_includes result.keys, :trend_icon + assert_includes result.keys, :trend_amount + assert_includes result.keys, :trend_text + assert_equal "Compared to previous week", result[:trend_text] + end + + test "card returns hash with required keys for all jobs" do + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: nil) + result = card.to_metric_card + + assert_kind_of Hash, result + assert_equal "jobs_total_runs", result[:id] + assert_equal "jobs", result[:context] + assert_equal "Job Runs", result[:title] + assert_includes result.keys, :summary + assert_includes result.keys, :chart_data + assert_includes result.keys, :trend_icon + assert_includes result.keys, :trend_amount + assert_includes result.keys, :trend_text + end + + # Calculation Tests - Specific Job + + test "card calculates total runs for specific job" do + # Current window: 10 runs + create_job_summary(job: @job, days_ago: 3, count: 10) + + # Previous window: 5 runs + create_job_summary(job: @job, days_ago: 10, count: 5) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + # Total: 15 runs + assert_equal "15 runs", result[:summary] + + # Trend: current 10 vs previous 5 = +100% + assert_equal "trending-up", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + test "card only includes summaries for specified job" do + other_job = rails_pulse_jobs(:mailer_job) + + # Create summaries for both jobs + create_job_summary(job: @job, days_ago: 3, count: 10) + create_job_summary(job: other_job, days_ago: 3, count: 100) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + # Should only include report_job's 10 runs, not mailer_job's 100 runs + assert_equal "10 runs", result[:summary] + end + + # Calculation Tests - All Jobs + + test "card calculates total runs for all jobs when job is nil" do + job1 = rails_pulse_jobs(:report_job) + job2 = rails_pulse_jobs(:mailer_job) + + # Current window data + create_job_summary(job: job1, days_ago: 3, count: 10) + create_job_summary(job: job2, days_ago: 3, count: 20) + + # Previous window data + create_job_summary(job: job1, days_ago: 10, count: 5) + create_job_summary(job: job2, days_ago: 10, count: 15) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: nil) + result = card.to_metric_card + + # Total: 10 + 20 + 5 + 15 = 50 runs + assert_equal "50 runs", result[:summary] + + # Trend: current 30 vs previous 20 = +50% + assert_equal "trending-up", result[:trend_icon] + assert_equal "50.0%", result[:trend_amount] + end + + # Trend Tests + + test "card shows trending up when current runs increase" do + create_job_summary(job: @job, days_ago: 3, count: 20) + create_job_summary(job: @job, days_ago: 10, count: 10) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_equal "trending-up", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + test "card shows trending down when current runs decrease" do + create_job_summary(job: @job, days_ago: 3, count: 5) + create_job_summary(job: @job, days_ago: 10, count: 10) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_equal "trending-down", result[:trend_icon] + assert_equal "50.0%", result[:trend_amount] + end + + test "card shows move right when runs are stable" do + create_job_summary(job: @job, days_ago: 3, count: 10) + create_job_summary(job: @job, days_ago: 10, count: 10) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + # Sparkline Tests + + test "card generates sparkline data for date range" do + create_job_summary(job: @job, days_ago: 3, count: 10) + create_job_summary(job: @job, days_ago: 5, count: 5) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_kind_of Hash, result[:chart_data] + # Should have 15 days of data (14 days + today) + assert_equal 15, result[:chart_data].size + + # Each entry should have a label and value + result[:chart_data].each do |label, data| + assert_kind_of String, label + assert_kind_of Hash, data + assert_includes data.keys, :value + end + end + + test "card sparkline includes zero values for days with no data" do + create_job_summary(job: @job, days_ago: 3, count: 10) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + # Most days should have 0 value + zero_value_count = result[:chart_data].values.count { |v| v[:value] == 0 } + + assert_operator zero_value_count, :>, 10 + end + + test "card sparkline shows run counts for days with data" do + create_job_summary(job: @job, days_ago: 3, count: 25) + create_job_summary(job: @job, days_ago: 5, count: 15) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + # Find days with data + days_with_data = result[:chart_data].values.select { |v| v[:value] > 0 } + + # Should have 2 days with data + assert_equal 2, days_with_data.length + + # Values should match the counts + values = days_with_data.map { |d| d[:value] }.sort + + assert_equal [ 15, 25 ], values + end + + # Edge Cases + + test "card handles job with no runs" do + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_equal "0 runs", result[:summary] + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + test "card handles all jobs with no runs" do + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: nil) + result = card.to_metric_card + + assert_equal "0 runs", result[:summary] + assert_equal "move-right", result[:trend_icon] + assert_equal "0.0%", result[:trend_amount] + end + + test "card handles only current window data" do + create_job_summary(job: @job, days_ago: 3, count: 15) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_equal "15 runs", result[:summary] + # No previous data means 0, so move-right + assert_equal "move-right", result[:trend_icon] + end + + test "card handles only previous window data" do + create_job_summary(job: @job, days_ago: 10, count: 20) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + assert_equal "20 runs", result[:summary] + # Current is 0, previous is 20, so trending down 100% + assert_equal "trending-down", result[:trend_icon] + assert_equal "100.0%", result[:trend_amount] + end + + test "card handles large run counts with number formatting" do + create_job_summary(job: @job, days_ago: 3, count: 5000) + create_job_summary(job: @job, days_ago: 5, count: 3500) + + card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job) + result = card.to_metric_card + + # Should format with commas + assert_equal "8,500 runs", result[:summary] + end + + private + + def create_job_summary(job:, days_ago:, count:) + period_start = days_ago.days.ago.beginning_of_day + + RailsPulse::Summary.create!( + summarizable_type: "RailsPulse::Job", + summarizable_id: job.id, + period_start: period_start, + period_end: period_start.end_of_day, + period_type: "day", + count: count, + error_count: 0, # Not used for total runs, but required by schema + avg_duration: 0.0 # Not used for total runs, but required by schema + ) + end + end + end + end +end diff --git a/test/models/rails_pulse/operation_test.rb b/test/models/rails_pulse/operation_test.rb index b410123..e4e1b9d 100644 --- a/test/models/rails_pulse/operation_test.rb +++ b/test/models/rails_pulse/operation_test.rb @@ -6,7 +6,8 @@ class RailsPulse::OperationTest < ActiveSupport::TestCase # Test associations test "should have correct associations" do - assert belong_to(:request).matches?(RailsPulse::Operation.new) + assert belong_to(:request).optional.matches?(RailsPulse::Operation.new) + assert belong_to(:job_run).optional.matches?(RailsPulse::Operation.new) assert belong_to(:query).optional.matches?(RailsPulse::Operation.new) end @@ -15,7 +16,6 @@ class RailsPulse::OperationTest < ActiveSupport::TestCase operation = RailsPulse::Operation.new # Presence validations - assert validate_presence_of(:request_id).matches?(operation) assert validate_presence_of(:operation_type).matches?(operation) assert validate_presence_of(:label).matches?(operation) assert validate_presence_of(:occurred_at).matches?(operation) @@ -28,6 +28,22 @@ class RailsPulse::OperationTest < ActiveSupport::TestCase assert validate_numericality_of(:duration).is_greater_than_or_equal_to(0).matches?(operation) end + test "should require either request or job run" do + operation = RailsPulse::Operation.new( + operation_type: "sql", + label: "TEST", + duration: 10, + occurred_at: Time.current + ) + + assert_not operation.valid? + assert_includes operation.errors[:base], "Operation must belong to a request or a job run" + + operation.request = rails_pulse_requests(:users_request_1) + + assert_predicate operation, :valid?, "operation should be valid with request present" + end + test "should be valid with required attributes" do operation = rails_pulse_operations(:sql_operation_1) @@ -124,7 +140,7 @@ class RailsPulse::OperationTest < ActiveSupport::TestCase assert_equal "SELECT * FROM posts WHERE id = ?", sql_op_2.query.normalized_sql # Verify we have exactly 6 operations total (4 original + 2 added for queries) - assert_equal 6, RailsPulse::Operation.count + assert_equal 7, RailsPulse::Operation.count # Test that we can access other fixture types route = rails_pulse_routes(:api_users) @@ -139,5 +155,11 @@ class RailsPulse::OperationTest < ActiveSupport::TestCase assert_equal route, request.route assert_equal query, sql_op.query assert_equal request, sql_op.request + + # Verify job operation is associated with job run only + job_operation = rails_pulse_operations(:job_sql_operation) + + assert_nil job_operation.request + assert_equal rails_pulse_job_runs(:report_run_retried), job_operation.job_run end end diff --git a/test/models/rails_pulse/requests/charts/operations_chart_test.rb b/test/models/rails_pulse/requests/charts/operations_chart_test.rb deleted file mode 100644 index 2e508bc..0000000 --- a/test/models/rails_pulse/requests/charts/operations_chart_test.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "test_helper" - -module RailsPulse - module Requests - module Charts - class OperationsChartTest < ActiveSupport::TestCase - # TODO: Test that chart returns operations for a specific request - # TODO: Test that chart formats operations as waterfall/timeline data - # TODO: Test that chart includes operation type, label, duration, and start_time - # TODO: Test that chart orders operations by start_time - # TODO: Test that chart handles requests with no operations - # TODO: Test that chart data format supports waterfall visualization - end - end - end -end diff --git a/test/models/rails_pulse/summary_test.rb b/test/models/rails_pulse/summary_test.rb index c8101cb..a27718b 100644 --- a/test/models/rails_pulse/summary_test.rb +++ b/test/models/rails_pulse/summary_test.rb @@ -9,6 +9,7 @@ class RailsPulse::SummaryTest < ActiveSupport::TestCase assert belong_to(:summarizable).optional.matches?(RailsPulse::Summary.new) assert belong_to(:route).optional.matches?(RailsPulse::Summary.new) assert belong_to(:query).optional.matches?(RailsPulse::Summary.new) + assert belong_to(:job).optional.matches?(RailsPulse::Summary.new) end # Test validations @@ -47,7 +48,7 @@ class RailsPulse::SummaryTest < ActiveSupport::TestCase end test "should include ransackable associations" do - expected_associations = %w[route query] + expected_associations = %w[job query route] assert_equal expected_associations.sort, RailsPulse::Summary.ransackable_associations.sort end diff --git a/test/services/rails_pulse/job_run_collector_test.rb b/test/services/rails_pulse/job_run_collector_test.rb new file mode 100644 index 0000000..09f2fa1 --- /dev/null +++ b/test/services/rails_pulse/job_run_collector_test.rb @@ -0,0 +1,120 @@ +require "securerandom" + +require "test_helper" + +module RailsPulse + class JobRunCollectorTest < ActiveSupport::TestCase + class FakeJob + attr_reader :job_id, :queue_name, :executions + + def initialize(job_id: SecureRandom.uuid, queue_name: "default", executions: 0) + @job_id = job_id + @queue_name = queue_name + @executions = executions + end + + def self.name + "JobRunCollectorFakeJob" + end + + def arguments + [ 1, 2, 3 ] + end + + def enqueued_at + Time.current + end + end + + setup do + RequestStore.clear! + @original_capture_arguments = RailsPulse.configuration.capture_job_arguments + RailsPulse.configuration.capture_job_arguments = true + end + + teardown do + RequestStore.clear! + RailsPulse.configuration.capture_job_arguments = @original_capture_arguments + end + + test "track records job run and operations" do + job = FakeJob.new + + assert_difference -> { RailsPulse::Job.count }, 1 do + assert_difference -> { RailsPulse::JobRun.count }, 1 do + RailsPulse::JobRunCollector.track(job) do + ActiveSupport::Notifications.instrument("sql.active_record", sql: "SELECT 1") do + sleep(0.001) + end + end + end + end + + job_run = RailsPulse::JobRun.order(:created_at).last + + assert_equal FakeJob.name, job_run.job.name + assert_equal "success", job_run.status + assert_equal job.job_id, job_run.run_id + assert_equal job.queue_name, job_run.job.queue_name + assert_not_nil job_run.duration + assert_equal "[1,2,3]", job_run.arguments + + operations = RailsPulse::Operation.where(job_run: job_run) + + assert_equal 1, operations.count + assert_nil operations.first.request_id + end + + test "track marks failures and surfaces exceptions" do + job = FakeJob.new + + assert_raises RuntimeError do + RailsPulse::JobRunCollector.track(job) do + raise RuntimeError, "boom" + end + end + + job_run = RailsPulse::JobRun.order(:created_at).last + + assert_equal "failed", job_run.status + assert_equal "RuntimeError", job_run.error_class + assert_equal "boom", job_run.error_message + end + + test "active job integration wraps perform now" do + klass = Class.new(ActiveJob::Base) do + queue_as :default + + def perform + ActiveSupport::Notifications.instrument("sql.active_record", sql: "SELECT 1") do + sleep(0.001) + end + end + end + + if Object.const_defined?(:JobRunCollectorTestInstrumentedJob) + Object.send(:remove_const, :JobRunCollectorTestInstrumentedJob) + end + + Object.const_set(:JobRunCollectorTestInstrumentedJob, klass) + + assert_difference -> { RailsPulse::Job.where(name: JobRunCollectorTestInstrumentedJob.name).count }, 1 do + assert_difference -> { RailsPulse::JobRun.count }, 1 do + JobRunCollectorTestInstrumentedJob.perform_now + end + end + + job = RailsPulse::Job.find_by(name: JobRunCollectorTestInstrumentedJob.name) + + assert_not_nil job + run = job.runs.order(:created_at).last + + assert_equal "success", run.status + assert_not_empty RailsPulse::Operation.where(job_run: run) + ensure + if Object.const_defined?(:JobRunCollectorTestInstrumentedJob) + Object.send(:remove_const, :JobRunCollectorTestInstrumentedJob) + end + end + end +end diff --git a/test/support/global_filters_helpers.rb b/test/support/global_filters_helpers.rb index fb116e6..e8476df 100644 --- a/test/support/global_filters_helpers.rb +++ b/test/support/global_filters_helpers.rb @@ -121,7 +121,7 @@ def select_custom_date_range(start_date, end_date) end def assert_custom_picker_visible - assert_selector '[data-rails-pulse--custom-range-target="pickerWrapper"]', visible: true + assert_selector '[data-rails-pulse--custom-range-target="pickerWrapper"]', visible: true, wait: 3 assert_selector '[data-rails-pulse--custom-range-target="selectWrapper"]', visible: false end diff --git a/test/system/dashboard_index_page_test.rb b/test/system/dashboard_index_page_test.rb index 056dd17..bc601b6 100644 --- a/test/system/dashboard_index_page_test.rb +++ b/test/system/dashboard_index_page_test.rb @@ -170,8 +170,6 @@ def within_panel(panel_title, &block) within(panel_element, &block) end - - def create_summary_data_for_dashboard # Create summary data for recent time periods using fixture data # Need to create summaries for multiple days within "this week" (1.week.ago to now) diff --git a/test/system/global_filters_test.rb b/test/system/global_filters_test.rb index 9a9d599..1572fd8 100644 --- a/test/system/global_filters_test.rb +++ b/test/system/global_filters_test.rb @@ -10,6 +10,7 @@ def setup super load_shared_test_data create_comprehensive_test_data + @report_job = rails_pulse_jobs(:report_job) # Configure tags for testing RailsPulse.configure do |config| @@ -62,19 +63,25 @@ def teardown assert_custom_picker_visible assert_selector "table tbody tr", wait: 5 + # Navigate to jobs page + visit_rails_pulse_path "/jobs" + + assert_global_filters_active + assert_selector "table tbody tr", wait: 5 + assert_selector ".card", text: "Global Filters:" + # === STEP 3: Test clearing global filters === - # Clear filters from queries page + # Clear filters from jobs page clear_global_filters # Verify filters removed assert_global_filters_inactive - assert_dropdown_visible # Should show dropdown, not custom picker assert_selector "table tbody tr", wait: 5 - # Navigate to routes page and verify filters cleared + # Navigate to routes page and verify filters cleared (routes has time range dropdown) visit_rails_pulse_path "/routes" - assert_dropdown_visible + assert_dropdown_visible # Should show dropdown, not custom picker assert_global_filters_inactive # Default "Last 24 hours" should be selected @@ -93,6 +100,11 @@ def teardown assert_custom_picker_visible assert_global_filters_active + visit_rails_pulse_path "/jobs" + + assert_global_filters_active + assert_selector ".card", text: "Global Filters:" + # Now override with page-specific preset by navigating to a URL with preset parameter # This simulates selecting "Last 24 hours" from the dropdown visit_rails_pulse_path "/routes?q[period_start_range]=last_day" @@ -129,6 +141,10 @@ def teardown assert_custom_picker_visible assert_selector "table tbody tr", wait: 5 + visit_rails_pulse_path "/jobs/#{@report_job.id}" + + assert_selector "table tbody tr", wait: 5 + # === STEP 6: Clear all global filters and verify clean state === clear_global_filters diff --git a/test/system/job_runs_show_page_test.rb b/test/system/job_runs_show_page_test.rb new file mode 100644 index 0000000..b623454 --- /dev/null +++ b/test/system/job_runs_show_page_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class JobRunsShowPageTest < ApplicationSystemTestCase + def setup + super + @success_run = rails_pulse_job_runs(:mailer_run_success) + @failed_run = rails_pulse_job_runs(:report_run_failed) + @operation_run = rails_pulse_job_runs(:report_run_retried) + @operation = rails_pulse_operations(:job_sql_operation) + end + + test "job run show displays arguments for successful run" do + visit_job_run(@success_run) + + assert_selector "h2", text: /job run details/i + assert_selector "pre", text: /user_id/ + assert_no_text "Error Details" + end + + test "job run show displays error details for failed runs" do + visit_job_run(@failed_run) + + assert_selector "h2", text: /job run details/i + assert_selector "h2", text: /error details/i + assert_text "StandardError" + assert_text "Reporting failed due to timeout" + end + + test "job run operations link to operation detail" do + visit_job_run(@operation_run) + + assert_selector ".operations-table tbody tr", minimum: 1 + + find("a[title='View details']", match: :first).click + + assert_current_path "/rails_pulse/operations/#{@operation.id}" + assert_text "Job Run Impact" + end + + private + + def visit_job_run(run) + visit_rails_pulse_path "/jobs/#{run.job_id}/runs/#{run.id}" + end +end diff --git a/test/system/jobs_index_page_test.rb b/test/system/jobs_index_page_test.rb new file mode 100644 index 0000000..8c3cb94 --- /dev/null +++ b/test/system/jobs_index_page_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class JobsIndexPageTest < ApplicationSystemTestCase + def setup + super + @report_job = rails_pulse_jobs(:report_job) + @mailer_job = rails_pulse_jobs(:mailer_job) + end + + test "index page displays job metrics and table data" do + visit_rails_pulse_path "/jobs" + + assert_current_path "/rails_pulse/jobs" + assert_selector "#jobs_total_runs", wait: 5 + assert_selector "#jobs_failure_rate" + assert_selector "#jobs_average_duration" + + within("table tbody") do + assert_text @report_job.name + assert_text @mailer_job.name + end + end + + test "jobs index filters by name" do + visit_rails_pulse_path "/jobs" + + fill_in "q[name_cont]", with: "Mailer" + click_button "Search" + + within("table tbody") do + assert_text @mailer_job.name + assert_no_text @report_job.name + end + + click_link "Reset" + + within("table tbody") do + assert_text @report_job.name + end + end + + test "jobs index filters by queue" do + visit_rails_pulse_path "/jobs" + + select "mailers", from: "q[queue_name_eq]" + click_button "Search" + + within("table tbody") do + assert_text @mailer_job.name + assert_no_text @report_job.name + end + end + + test "jobs index shows empty state when no jobs exist" do + RailsPulse::Job.destroy_all + + visit_rails_pulse_path "/jobs" + + assert_text "No jobs found" + assert_text "No background jobs have been executed yet." + end +end diff --git a/test/system/jobs_show_page_test.rb b/test/system/jobs_show_page_test.rb new file mode 100644 index 0000000..387e7bb --- /dev/null +++ b/test/system/jobs_show_page_test.rb @@ -0,0 +1,95 @@ +require "test_helper" + +class JobsShowPageTest < ApplicationSystemTestCase + def setup + super + @job = rails_pulse_jobs(:report_job) + @failed_run = rails_pulse_job_runs(:report_run_failed) + @original_tags = RailsPulse.configuration.tags.dup + RailsPulse.configuration.tags = (@original_tags | [ "report", "critical", "maintenance" ]) + end + + def teardown + RailsPulse.configuration.tags = @original_tags + super + end + + test "job show displays metrics and allows navigation to job run" do + visit_job_show + + assert_selector "#jobs_total_runs", wait: 5 + assert_selector "#jobs_failure_rate" + assert_selector "#jobs_average_duration" + assert_selector "table tbody tr", minimum: 1 + + latest_run = @job.runs.order(occurred_at: :desc).first + # HTML collapses multiple spaces, so we normalize the timestamp + timestamp = latest_run.occurred_at.in_time_zone.strftime("%b %d, %Y %l:%M %p").gsub(/\s+/, " ").strip + + click_link timestamp + + assert_current_path "/rails_pulse/jobs/#{@job.id}/runs/#{latest_run.id}" + assert_selector "h2", text: /job run details/i + end + + test "job show filters by status and duration" do + visit_job_show + + select "Retried", from: "q[status_eq]" + click_button "Search" + + within("table tbody") do + assert_text "Retried" + assert_no_text "Failed" + end + + select "Slow (≥ 5000ms)", from: "q[duration_gteq]" + click_button "Search" + + assert_text "No runs found" + + click_link "Reset" + + within("table tbody") do + assert_text "Failed" + assert_text "Retried" + end + end + + test "job tag manager allows removing and re-adding tags" do + visit_job_show + + tag_manager_selector = "#tag_manager_job_#{@job.id}" + + within(tag_manager_selector) do + assert_text "Report" + find("button.tag-remove", match: :first).click + end + + assert_selector tag_manager_selector + + within(tag_manager_selector) do + assert_no_text "Report" + end + + within(tag_manager_selector) do + find("button.tag-add-button").click + end + + menu_id = "#tag_menu_job_#{@job.id}" + + assert_selector menu_id + + within(menu_id) do + click_button "Report" + end + + assert_selector "#{tag_manager_selector} .badge", text: "Report", wait: 5 + end + + private + + def visit_job_show + visit_rails_pulse_path "/jobs/#{@job.id}" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 620ad88..0be10da 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -20,7 +20,8 @@ class ActiveSupport::TestCase # Enable parallel testing for local performance - parallelize(workers: :number_of_processors) + # Disable when BROWSER is set to avoid multiple browser windows + parallelize(workers: ENV["BROWSER"] ? 0 : :number_of_processors) # Use Rails' built-in transactional cleanup self.use_transactional_tests = true