flare 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/app/controllers/flare/application_controller.rb +22 -0
  6. data/app/controllers/flare/jobs_controller.rb +55 -0
  7. data/app/controllers/flare/requests_controller.rb +73 -0
  8. data/app/controllers/flare/spans_controller.rb +101 -0
  9. data/app/helpers/flare/application_helper.rb +168 -0
  10. data/app/views/flare/jobs/index.html.erb +69 -0
  11. data/app/views/flare/jobs/show.html.erb +323 -0
  12. data/app/views/flare/requests/index.html.erb +120 -0
  13. data/app/views/flare/requests/show.html.erb +498 -0
  14. data/app/views/flare/spans/index.html.erb +112 -0
  15. data/app/views/flare/spans/show.html.erb +184 -0
  16. data/app/views/layouts/flare/application.html.erb +126 -0
  17. data/config/routes.rb +20 -0
  18. data/exe/flare +9 -0
  19. data/lib/flare/backoff_policy.rb +73 -0
  20. data/lib/flare/cli/doctor_command.rb +129 -0
  21. data/lib/flare/cli/output.rb +45 -0
  22. data/lib/flare/cli/setup_command.rb +404 -0
  23. data/lib/flare/cli/status_command.rb +47 -0
  24. data/lib/flare/cli.rb +50 -0
  25. data/lib/flare/configuration.rb +121 -0
  26. data/lib/flare/engine.rb +43 -0
  27. data/lib/flare/http_metrics_config.rb +101 -0
  28. data/lib/flare/metric_counter.rb +45 -0
  29. data/lib/flare/metric_flusher.rb +124 -0
  30. data/lib/flare/metric_key.rb +42 -0
  31. data/lib/flare/metric_span_processor.rb +470 -0
  32. data/lib/flare/metric_storage.rb +42 -0
  33. data/lib/flare/metric_submitter.rb +221 -0
  34. data/lib/flare/source_location.rb +113 -0
  35. data/lib/flare/sqlite_exporter.rb +279 -0
  36. data/lib/flare/storage/sqlite.rb +789 -0
  37. data/lib/flare/storage.rb +54 -0
  38. data/lib/flare/version.rb +5 -0
  39. data/lib/flare.rb +411 -0
  40. data/public/flare-assets/flare.css +1245 -0
  41. data/public/flare-assets/images/flipper.png +0 -0
  42. metadata +240 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8eb495874fecf193c205e02c4254d2229ad264cc3e640bba5239940ac3de8e5d
4
+ data.tar.gz: 71218afa6c8972dcf6cf76dc3dddc059a5197a7542a05cf8d0ad8a8f964c290f
5
+ SHA512:
6
+ metadata.gz: '035922584796dd1033486f6723cf859ae0d9f8dd169221817c120f66d3352ad5bbea6793f154d2963476a77e58b7f1c5a3635bf965821d245176271bd4d752de'
7
+ data.tar.gz: 72338f6d8503a8b464f86de6e5397023716d55a810623eefe1d9b81cfdeae4ef3829f15b1228ccb1bdbacf0d957938d54a9e345a5c08f6ba09a7c68cd4b2624e
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-12-17
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 John Nunemaker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # Flare — App Monitoring
2
+
3
+ Light up what's slowing you down. [flare.am](https://flare.am)
4
+
5
+ App monitoring for Rails. Captures requests, queries, jobs, cache, views, HTTP calls, mail, and exceptions in development with a waterfall visualization dashboard. Sends lightweight aggregated metrics in production.
6
+
7
+ ## Features
8
+
9
+ - **HTTP Requests** - Track all incoming requests with status codes, durations, and controller actions
10
+ - **Background Jobs** - Monitor ActiveJob processing with queue names and execution times
11
+ - **Database Queries** - See all SQL queries with the source location that triggered them
12
+ - **Cache Operations** - Track cache reads, writes, hits, and misses
13
+ - **View Rendering** - Monitor template and partial rendering times
14
+ - **HTTP Client Calls** - See outgoing HTTP requests to external services
15
+ - **Email Delivery** - Track ActionMailer sends with recipients and subjects
16
+ - **Exceptions** - View errors with full stacktraces
17
+
18
+ Each span shows a waterfall visualization of child operations, making it easy to understand request timing and identify bottlenecks.
19
+
20
+ ## Installation
21
+
22
+ Add Flare to your Gemfile:
23
+
24
+ ```ruby
25
+ gem "flare"
26
+ ```
27
+
28
+ The local development dashboard uses SQLite to store spans. If your app doesn't already have `sqlite3` in its Gemfile, add it to the development group:
29
+
30
+ ```ruby
31
+ group :development do
32
+ gem "sqlite3"
33
+ end
34
+ ```
35
+
36
+ Then run:
37
+
38
+ ```bash
39
+ bundle install
40
+ flare setup
41
+ ```
42
+
43
+ The setup command will:
44
+
45
+ 1. Authenticate with flare.am to configure metrics
46
+ 2. Create a config initializer with sensible defaults
47
+ 3. Update .gitignore
48
+
49
+ Start your Rails server and visit `/flare` to see the dashboard.
50
+
51
+ ### Manual Configuration
52
+
53
+ If you prefer to skip the setup wizard, just add the gem and visit `/flare` in development. The dashboard works out of the box with no configuration needed.
54
+
55
+ To enable metrics, set `FLARE_KEY` in your environment (get one at [flare.am](https://flare.am)).
56
+
57
+ ### CLI Commands
58
+
59
+ ```bash
60
+ flare setup # Authenticate and configure Flare
61
+ flare doctor # Check your setup for issues
62
+ flare status # Show current configuration
63
+ ```
64
+
65
+ ## Configuration
66
+
67
+ All configuration is optional. Flare works out of the box with sensible defaults.
68
+
69
+ ```ruby
70
+ Flare.configure do |config|
71
+ # Enable or disable Flare (default: true)
72
+ config.enabled = true
73
+
74
+ # How long to keep spans in hours (default: 24)
75
+ config.retention_hours = 24
76
+
77
+ # Maximum number of spans to store (default: 10000)
78
+ config.max_spans = 10_000
79
+
80
+ # Path to the SQLite database (default: db/flare.sqlite3)
81
+ config.database_path = Rails.root.join("db", "flare.sqlite3").to_s
82
+
83
+ # Ignore specific requests (receives a Rack::Request, return true to ignore)
84
+ config.ignore_request = ->(request) {
85
+ request.path.start_with?("/health")
86
+ }
87
+
88
+ # Subscribe to custom notification prefixes (default: ["app."])
89
+ config.subscribe_patterns << "mycompany."
90
+ end
91
+ ```
92
+
93
+ ## Custom Instrumentation
94
+
95
+ Flare automatically captures Rails internals, but you can also instrument your own code. Use `ActiveSupport::Notifications.instrument` with an `app.` prefix:
96
+
97
+ ```ruby
98
+ # In your application code
99
+ ActiveSupport::Notifications.instrument("app.geocoding", address: address) do
100
+ geocoder.lookup(address)
101
+ end
102
+
103
+ ActiveSupport::Notifications.instrument("app.stripe.charge", amount: 1000) do
104
+ Stripe::Charge.create(amount: 1000, currency: "usd")
105
+ end
106
+
107
+ ActiveSupport::Notifications.instrument("app.send_sms", to: phone) do
108
+ twilio.messages.create(to: phone, body: message)
109
+ end
110
+ ```
111
+
112
+ This works in all environments - in production it's essentially a no-op, in development Flare automatically captures and displays it.
113
+
114
+ ### Custom Notification Prefixes
115
+
116
+ By default, Flare subscribes to notifications starting with `app.`. You can add additional prefixes:
117
+
118
+ ```ruby
119
+ Flare.configure do |config|
120
+ config.subscribe_patterns << "mycompany."
121
+ config.subscribe_patterns << "external_service."
122
+ end
123
+ ```
124
+
125
+ ## How It Works
126
+
127
+ Flare uses [OpenTelemetry](https://opentelemetry.io/) for instrumentation. It automatically configures:
128
+
129
+ - `OpenTelemetry::Instrumentation::Rack` - HTTP requests
130
+ - `OpenTelemetry::Instrumentation::ActiveSupport` - Notifications (SQL, cache, mail)
131
+ - `OpenTelemetry::Instrumentation::ActionPack` - Controller actions
132
+ - `OpenTelemetry::Instrumentation::ActionView` - View rendering
133
+ - `OpenTelemetry::Instrumentation::ActiveJob` - Background jobs
134
+ - `OpenTelemetry::Instrumentation::Net::HTTP` - Outgoing HTTP calls
135
+
136
+ Spans are stored in a local SQLite database (`db/flare.sqlite3` by default) and automatically pruned based on retention settings.
137
+
138
+ ## Development
139
+
140
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
141
+
142
+ ## Contributing
143
+
144
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jnunemaker/flare.
145
+
146
+ ## License
147
+
148
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
7
+ layout "flare/application"
8
+
9
+ helper_method :show_redis_tab?
10
+
11
+ private
12
+
13
+ # Only show the Redis tab if:
14
+ # 1. The Redis client library is loaded
15
+ # 2. There are Redis spans in the database
16
+ def show_redis_tab?
17
+ return false unless defined?(::Redis)
18
+
19
+ Flare.storage.count_spans_by_category("redis") > 0
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ class JobsController < ApplicationController
5
+ around_action :untrace_request
6
+
7
+ helper_method :current_section, :page_title
8
+
9
+ PER_PAGE = 50
10
+
11
+ def index
12
+ @offset = params[:offset].to_i
13
+ filter_params = {
14
+ name: params[:name].presence
15
+ }
16
+ # Fetch one extra to know if there's a next page
17
+ jobs = Flare.storage.list_jobs(**filter_params, limit: PER_PAGE + 1, offset: @offset)
18
+ @total_count = Flare.storage.count_jobs(**filter_params)
19
+ @has_next = jobs.size > PER_PAGE
20
+ @jobs = jobs.first(PER_PAGE)
21
+ @has_prev = @offset > 0
22
+ end
23
+
24
+ def show
25
+ @job = Flare.storage.find_job(params[:id])
26
+
27
+ if @job.blank?
28
+ redirect_to jobs_path, alert: "Job not found"
29
+ return
30
+ end
31
+
32
+ @spans = Flare.storage.spans_for_trace(params[:id])
33
+
34
+ # Find the root span (the job itself) with full properties
35
+ @root_span = @spans.find { |s| s[:parent_span_id] == Flare::MISSING_PARENT_ID }
36
+
37
+ # Child spans (everything except the root)
38
+ @child_spans = @spans.reject { |s| s[:parent_span_id] == Flare::MISSING_PARENT_ID }
39
+ end
40
+
41
+ private
42
+
43
+ def untrace_request
44
+ Flare.untraced { yield }
45
+ end
46
+
47
+ def current_section
48
+ "jobs"
49
+ end
50
+
51
+ def page_title
52
+ "Jobs"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ class RequestsController < ApplicationController
5
+ around_action :untrace_request
6
+
7
+ helper_method :current_section, :page_title, :current_origin
8
+
9
+ PER_PAGE = 50
10
+
11
+ def index
12
+ @offset = params[:offset].to_i
13
+ filter_params = {
14
+ status: params[:status].presence,
15
+ method: params[:method].presence,
16
+ name: params[:name].presence,
17
+ origin: current_origin
18
+ }
19
+ # Fetch one extra to know if there's a next page
20
+ requests = Flare.storage.list_requests(**filter_params, limit: PER_PAGE + 1, offset: @offset)
21
+ @total_count = Flare.storage.count_requests(**filter_params)
22
+ @has_next = requests.size > PER_PAGE
23
+ @requests = requests.first(PER_PAGE)
24
+ @has_prev = @offset > 0
25
+ end
26
+
27
+ def show
28
+ @request = Flare.storage.find_request(params[:id])
29
+
30
+ if @request.blank?
31
+ redirect_to requests_path, alert: "Request not found"
32
+ return
33
+ end
34
+
35
+ @spans = Flare.storage.spans_for_trace(params[:id])
36
+
37
+ # Find the root span (the request itself) with full properties
38
+ @root_span = @spans.find { |s| s[:parent_span_id] == Flare::MISSING_PARENT_ID }
39
+
40
+ # Child spans (everything except the root)
41
+ @child_spans = @spans.reject { |s| s[:parent_span_id] == Flare::MISSING_PARENT_ID }
42
+ end
43
+
44
+ def clear
45
+ Flare.storage.clear_all
46
+ redirect_to root_path
47
+ end
48
+
49
+ private
50
+
51
+ def untrace_request
52
+ Flare.untraced { yield }
53
+ end
54
+
55
+ def current_section
56
+ "requests"
57
+ end
58
+
59
+ def page_title
60
+ "Requests"
61
+ end
62
+
63
+ def current_origin
64
+ # If origin was explicitly set (even to empty for "All"), use that
65
+ if params.key?(:origin)
66
+ return params[:origin].presence # nil for "All Origins", "app" or "rails" otherwise
67
+ end
68
+
69
+ # Default to "app" to hide Rails framework noise
70
+ "app"
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ class SpansController < ApplicationController
5
+ around_action :untrace_request
6
+
7
+ helper_method :current_section, :page_title, :category_config
8
+
9
+ PER_PAGE = 50
10
+
11
+ CATEGORIES = {
12
+ "queries" => { title: "Queries", icon: "database", badge_class: "sql" },
13
+ "cache" => { title: "Cache", icon: "archive", badge_class: "cache" },
14
+ "views" => { title: "Views", icon: "layout", badge_class: "view" },
15
+ "http" => { title: "HTTP", icon: "globe", badge_class: "http" },
16
+ "mail" => { title: "Mail", icon: "mail", badge_class: "mail" },
17
+ "redis" => { title: "Redis", icon: "database", badge_class: "other" },
18
+ "exceptions" => { title: "Exceptions", icon: "alert-triangle", badge_class: "exception" }
19
+ }.freeze
20
+
21
+ def queries
22
+ list_spans("queries")
23
+ end
24
+
25
+ def cache
26
+ list_spans("cache")
27
+ end
28
+
29
+ def views
30
+ list_spans("views")
31
+ end
32
+
33
+ def http
34
+ list_spans("http")
35
+ end
36
+
37
+ def mail
38
+ list_spans("mail")
39
+ end
40
+
41
+ def redis
42
+ list_spans("redis")
43
+ end
44
+
45
+ def exceptions
46
+ list_spans("exceptions")
47
+ end
48
+
49
+ def show
50
+ @span = Flare.storage.find_span(params[:id])
51
+
52
+ if @span.blank?
53
+ redirect_to requests_path, alert: "Span not found"
54
+ return
55
+ end
56
+
57
+ @category = params[:category]
58
+ end
59
+
60
+ private
61
+
62
+ def list_spans(category)
63
+ @category = category
64
+ @offset = params[:offset].to_i
65
+ filter_params = { name: params[:name].presence }
66
+
67
+ spans = Flare.storage.list_spans_by_category(category, **filter_params, limit: PER_PAGE + 1, offset: @offset)
68
+ @total_count = Flare.storage.count_spans_by_category(category, **filter_params)
69
+ @has_next = spans.size > PER_PAGE
70
+ @spans = spans.first(PER_PAGE)
71
+ @has_prev = @offset > 0
72
+
73
+ # Load properties for display
74
+ span_ids = @spans.map { |s| s[:id] }
75
+ if span_ids.any?
76
+ all_properties = Flare.storage.load_properties_for_ids("Flare::Span", span_ids)
77
+ @spans.each do |span|
78
+ span[:properties] = all_properties[span[:id]] || {}
79
+ end
80
+ end
81
+
82
+ render :index
83
+ end
84
+
85
+ def untrace_request
86
+ Flare.untraced { yield }
87
+ end
88
+
89
+ def current_section
90
+ @category || "spans"
91
+ end
92
+
93
+ def page_title
94
+ category_config[:title]
95
+ end
96
+
97
+ def category_config
98
+ CATEGORIES[@category] || { title: "Spans", icon: "activity", badge_class: "other" }
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module ApplicationHelper
5
+ def span_category(span)
6
+ name = span[:name].to_s.downcase
7
+ case name
8
+ when /sql\.active_record/, /mysql/, /postgres/, /sqlite/
9
+ "sql"
10
+ when /cache/
11
+ "cache"
12
+ when /render/, /view/
13
+ "view"
14
+ when /http/, /net_http/, /faraday/
15
+ "http"
16
+ when /mail/
17
+ "mailer"
18
+ when /job/, /active_job/
19
+ "job"
20
+ when /action_controller/, /process_action/
21
+ "controller"
22
+ else
23
+ "other"
24
+ end
25
+ end
26
+
27
+ def span_display_info(span, category)
28
+ props = span[:properties] || {}
29
+ case category
30
+ when "queries"
31
+ stmt = props["db.statement"]&.to_s
32
+ name = props["name"]&.to_s
33
+ db_name = props["db.name"]&.to_s
34
+ source_loc = if props["code.filepath"] && props["code.lineno"]
35
+ "#{props["code.filepath"]}:#{props["code.lineno"]}"
36
+ end
37
+ secondary = [name.presence, db_name.presence, source_loc].compact.join(" \u00b7 ").presence
38
+ if stmt.present?
39
+ { primary: stmt, secondary: secondary }
40
+ elsif name.present?
41
+ { primary: name, secondary: [db_name.presence, source_loc].compact.join(" \u00b7 ").presence }
42
+ else
43
+ { primary: span[:name], secondary: [db_name.presence, source_loc].compact.join(" \u00b7 ").presence }
44
+ end
45
+ when "cache"
46
+ key = props["key"]&.to_s
47
+ op = span[:name].to_s.sub(".active_support", "").sub("cache_", "")
48
+ store = props["store"]&.to_s&.sub(/^ActiveSupport::Cache::/, "")
49
+ { primary: key.presence || span[:name], secondary: store, cache_op: op }
50
+ when "views"
51
+ identifier = props["identifier"] || props["code.filepath"]
52
+ primary = identifier ? identifier.to_s.sub(/^.*\/app\/views\//, "") : span[:name]
53
+ { primary: primary, secondary: nil }
54
+ when "http"
55
+ full_url = props["http.url"] || ""
56
+ target = props["http.target"] || ""
57
+ host = props["http.host"] || props["net.peer.name"] || props["peer.service"]
58
+ uri = URI.parse(full_url) rescue nil
59
+ if uri && uri.host
60
+ domain = uri.host
61
+ path = uri.path.presence || "/"
62
+ path = "#{path}?#{uri.query}" if uri.query.present?
63
+ else
64
+ domain = host
65
+ path = target.presence || full_url
66
+ end
67
+ method = props["http.method"]
68
+ status = props["http.status_code"]
69
+ { primary: path.to_s.truncate(100), secondary: domain, http_method: method, http_status: status }
70
+ when "mail"
71
+ mailer = props["mailer"]
72
+ action = props["action"]
73
+ subject = props["subject"]
74
+ if mailer && action
75
+ { primary: "#{mailer}##{action}", secondary: subject }
76
+ else
77
+ { primary: span[:name], secondary: nil }
78
+ end
79
+ when "redis"
80
+ cmd = props["db.statement"]&.to_s
81
+ { primary: cmd.presence || span[:name], secondary: nil }
82
+ when "exceptions"
83
+ exc_type = span[:exception_type]
84
+ exc_message = span[:exception_message]
85
+ primary = if exc_type.present? && exc_message.present?
86
+ "#{exc_type}: #{exc_message}"
87
+ elsif exc_message.present?
88
+ exc_message
89
+ elsif exc_type.present?
90
+ exc_type
91
+ else
92
+ span[:name]
93
+ end
94
+ stacktrace = span[:exception_stacktrace].to_s
95
+ first_app_line = stacktrace.split("\n").find { |line| line.include?("/app/") } || stacktrace.split("\n").first
96
+ secondary = first_app_line&.strip.to_s.truncate(200)
97
+ { primary: primary, secondary: secondary }
98
+ else
99
+ { primary: span[:name], secondary: nil }
100
+ end
101
+ end
102
+
103
+ def format_duration(ms)
104
+ return "-" if ms.nil?
105
+
106
+ if ms >= 1000
107
+ "#{(ms / 1000.0).round(1)}s"
108
+ else
109
+ "#{ms.round(1)}ms"
110
+ end
111
+ end
112
+
113
+ def format_content(data, indent = 0)
114
+ return "" if data.nil?
115
+
116
+ lines = []
117
+ prefix = " " * indent
118
+
119
+ case data
120
+ when Hash
121
+ data.each do |key, value|
122
+ if value.is_a?(Hash) || value.is_a?(Array)
123
+ lines << "#{prefix}#{key}:"
124
+ lines << format_content(value, indent + 1)
125
+ else
126
+ formatted_value = format_value(value)
127
+ if formatted_value.include?("\n")
128
+ lines << "#{prefix}#{key}:"
129
+ formatted_value.each_line do |line|
130
+ lines << "#{prefix} #{line.rstrip}"
131
+ end
132
+ else
133
+ lines << "#{prefix}#{key}: #{formatted_value}"
134
+ end
135
+ end
136
+ end
137
+ when Array
138
+ data.each do |item|
139
+ if item.is_a?(Hash) || item.is_a?(Array)
140
+ lines << "#{prefix}-"
141
+ lines << format_content(item, indent + 1)
142
+ else
143
+ lines << "#{prefix}- #{format_value(item)}"
144
+ end
145
+ end
146
+ else
147
+ lines << "#{prefix}#{format_value(data)}"
148
+ end
149
+
150
+ lines.join("\n")
151
+ end
152
+
153
+ private
154
+
155
+ def format_value(value)
156
+ case value
157
+ when nil
158
+ "null"
159
+ when true, false
160
+ value.to_s
161
+ when Numeric
162
+ value.to_s
163
+ else
164
+ value.to_s
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,69 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title"><%= page_title %></h1>
3
+ <div class="filters">
4
+ <%= form_tag(jobs_path, method: :get, id: "filter-form") do %>
5
+ <%= text_field_tag :name, params[:name], placeholder: "Search...", class: "filter-select", style: "min-width: 200px;", onchange: "this.form.submit()" %>
6
+ <% end %>
7
+ </div>
8
+ </div>
9
+
10
+ <div class="page-body">
11
+ <div class="card">
12
+ <div class="card-body">
13
+ <% if @jobs.blank? %>
14
+ <div class="empty-state">
15
+ <div class="empty-state-icon">
16
+ <i class="bi bi-briefcase" style="font-size: 1.5rem;"></i>
17
+ </div>
18
+ <h3>No jobs found</h3>
19
+ <p>Data will appear here as your application processes background jobs.</p>
20
+ </div>
21
+ <% else %>
22
+ <table class="data-table">
23
+ <thead>
24
+ <tr>
25
+ <th>Job</th>
26
+ <th style="width: 100px; text-align: right;">Duration</th>
27
+ <th style="width: 120px; text-align: right;">Happened</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ <% @jobs.each do |job| %>
32
+ <% started_at = Time.at(job[:start_timestamp] / 1_000_000_000.0) rescue nil %>
33
+ <%
34
+ display_name = job[:job_class] || job[:name].to_s.sub(/ (process|publish)$/, '')
35
+ %>
36
+ <tr onclick="navigateIfNotSelecting('<%= job_path(job[:trace_id]) %>')" style="cursor: pointer;">
37
+ <td class="path-cell">
38
+ <span class="job-name path-text" title="<%= display_name %>"><%= display_name %></span>
39
+ <% queue = job[:queue_name] || "default" %>
40
+ <span class="path-subtext" title="<%= queue %>"><%= queue %></span>
41
+ </td>
42
+ <td class="duration" style="text-align: right;"><%= format_duration(job[:duration_ms]) %></td>
43
+ <td class="timestamp" style="text-align: right;"><%= started_at ? time_ago_in_words(started_at) : "-" %></td>
44
+ </tr>
45
+ <% end %>
46
+ </tbody>
47
+ </table>
48
+
49
+ <% if @total_count > 0 %>
50
+ <div class="pagination">
51
+ <div class="pagination-side">
52
+ <% if @has_prev %>
53
+ <%= link_to "Previous", jobs_path(request.query_parameters.merge(offset: [@offset - 50, 0].max)), class: "pagination-link" %>
54
+ <% end %>
55
+ </div>
56
+ <div class="pagination-info">
57
+ <%= number_with_delimiter(@offset + 1) %> - <%= number_with_delimiter([@offset + @jobs.size, @total_count].min) %> of <%= number_with_delimiter(@total_count) %>
58
+ </div>
59
+ <div class="pagination-side" style="text-align: right;">
60
+ <% if @has_next %>
61
+ <%= link_to "Next", jobs_path(request.query_parameters.merge(offset: @offset + 50)), class: "pagination-link" %>
62
+ <% end %>
63
+ </div>
64
+ </div>
65
+ <% end %>
66
+ <% end %>
67
+ </div>
68
+ </div>
69
+ </div>