rails_pulse 0.4.2 → 0.5.0.pre.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -1
- data/README.md +15 -4
- data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
- data/app/controllers/concerns/chart_table_concern.rb +28 -69
- data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
- data/app/controllers/concerns/metric_card_concern.rb +6 -7
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +130 -0
- data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
- data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +24 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -0
- data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
- data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +114 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
- data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
- data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
- data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
- data/app/controllers/rails_pulse/queries_controller.rb +6 -5
- data/app/controllers/rails_pulse/requests_controller.rb +15 -14
- data/app/controllers/rails_pulse/routes_controller.rb +4 -8
- data/app/helpers/rails_pulse/application_helper.rb +15 -0
- data/app/helpers/rails_pulse/chart_helper.rb +9 -2
- data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
- data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
- data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
- data/app/models/concerns/rails_pulse/taggable.rb +5 -12
- data/app/models/rails_pulse/cards/base.rb +20 -11
- data/app/models/rails_pulse/charts/base.rb +2 -6
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +1 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
- data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
- data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +40 -0
- data/app/models/rails_pulse/exception_occurrence.rb +1 -1
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -5
- data/app/models/rails_pulse/job_run.rb +1 -1
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
- data/app/models/rails_pulse/jobs/charts/duration.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
- data/app/models/rails_pulse/like_pattern.rb +29 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/charts/database_load.rb +5 -6
- data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
- data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
- data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
- data/app/models/rails_pulse/summary.rb +5 -0
- data/app/models/rails_pulse/tables/base.rb +5 -6
- data/app/models/rails_pulse/time_range.rb +309 -0
- data/app/models/rails_pulse/writer_heartbeat.rb +86 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -0
- data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
- data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
- data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
- data/app/serializers/rails_pulse/api/v1/request_serializer.rb +21 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
- data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
- data/app/services/rails_pulse/summary_service.rb +121 -133
- data/app/services/rails_pulse/tag_filter_service.rb +5 -5
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
- data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
- data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/jobs/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/queries/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/show.html.erb +1 -1
- data/app/views/rails_pulse/requests/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/routes/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
- data/app/views/rails_pulse/storage/show.html.erb +37 -0
- data/config/routes.rb +19 -0
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
- data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
- data/db/rails_pulse_schema.rb +20 -2
- data/exe/rails-pulse +7 -0
- data/lib/generators/rails_pulse/base_methods.rb +1 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
- data/lib/rails_pulse/cleanup_service.rb +26 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -0
- data/lib/rails_pulse/cli/base_command.rb +37 -0
- data/lib/rails_pulse/cli/client.rb +64 -0
- data/lib/rails_pulse/cli/config.rb +87 -0
- data/lib/rails_pulse/cli/configure.rb +75 -0
- data/lib/rails_pulse/cli/coverage.rb +91 -0
- data/lib/rails_pulse/cli/deployments.rb +40 -0
- data/lib/rails_pulse/cli/exceptions.rb +111 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -0
- data/lib/rails_pulse/cli/install.rb +145 -0
- data/lib/rails_pulse/cli/job_runs.rb +53 -0
- data/lib/rails_pulse/cli/jobs.rb +71 -0
- data/lib/rails_pulse/cli/main.rb +72 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +62 -0
- data/lib/rails_pulse/cli/requests.rb +49 -0
- data/lib/rails_pulse/cli/routes.rb +66 -0
- data/lib/rails_pulse/configuration.rb +23 -1
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +76 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +97 -0
- data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -9
- data/lib/rails_pulse/scoped_inflector.rb +35 -0
- data/lib/rails_pulse/statistics.rb +27 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
- data/lib/rails_pulse/tasks/status_reporter.rb +26 -0
- data/lib/rails_pulse/tracker.rb +75 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +96 -24
- data/app/controllers/concerns/page_timings.rb +0 -10
- data/app/controllers/concerns/response_range_concern.rb +0 -45
- data/app/controllers/concerns/time_range_concern.rb +0 -152
- data/app/controllers/concerns/zoom_range_concern.rb +0 -86
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class RoutesController < BaseController
|
|
5
|
+
SORT_COLUMNS = %w[request_count avg_duration error_count].freeze
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
parsed_range = time_range
|
|
9
|
+
return unless parsed_range
|
|
10
|
+
since_start, until_end = parsed_range
|
|
11
|
+
|
|
12
|
+
sort = params[:sort].presence
|
|
13
|
+
if sort && !SORT_COLUMNS.include?(sort)
|
|
14
|
+
return render json: { error: "Invalid sort. Valid values: #{SORT_COLUMNS.join(', ')}" }, status: :bad_request
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
scope = RailsPulse::Route.all
|
|
18
|
+
if params[:search].present?
|
|
19
|
+
term = RailsPulse::LikePattern.containing(params[:search].downcase)
|
|
20
|
+
scope = scope.where(
|
|
21
|
+
"LOWER(path) LIKE :term #{RailsPulse::LikePattern::CLAUSE} " \
|
|
22
|
+
"OR LOWER(controller_action) LIKE :term #{RailsPulse::LikePattern::CLAUSE}",
|
|
23
|
+
term: term
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if since_start || until_end || sort
|
|
28
|
+
since_start ||= 24.hours.ago if until_end.nil?
|
|
29
|
+
render_with_stats(scope, since_start..until_end, sort || "request_count")
|
|
30
|
+
else
|
|
31
|
+
data, meta = paginated(scope.order(:path, :id))
|
|
32
|
+
render json: { data: data.map { |route| RouteSerializer.serialize(route) }, meta: meta }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def render_with_stats(scope, range, sort)
|
|
39
|
+
base = RailsPulse::Request.where(occurred_at: range)
|
|
40
|
+
base = base.where(route_id: scope.select(:id)) if params[:search].present?
|
|
41
|
+
|
|
42
|
+
# HAVING rather than a filter over the fetched page: a route busy
|
|
43
|
+
# enough to qualify can rank below the limit, and dropping it after
|
|
44
|
+
# the fact returns nothing while an answer exists.
|
|
45
|
+
grouped = base.group(:route_id)
|
|
46
|
+
grouped = grouped.having("COUNT(*) >= ?", min_requests) if min_requests > 1
|
|
47
|
+
|
|
48
|
+
# Grouping yields one row per route, so counting the rows counts the
|
|
49
|
+
# routes that qualify.
|
|
50
|
+
routes_with_traffic = base.distinct.count(:route_id)
|
|
51
|
+
total = min_requests > 1 ? grouped.count.size : routes_with_traffic
|
|
52
|
+
|
|
53
|
+
# CASE WHEN on the boolean column itself is valid on SQLite, PostgreSQL
|
|
54
|
+
# and MySQL alike, so no adapter-specific literal is interpolated.
|
|
55
|
+
rows = grouped
|
|
56
|
+
.select(
|
|
57
|
+
"route_id, COUNT(*) AS request_count, AVG(duration) AS avg_duration, " \
|
|
58
|
+
"SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS error_count"
|
|
59
|
+
)
|
|
60
|
+
# route_id breaks ties so offset pages neither repeat nor skip rows.
|
|
61
|
+
.order(Arel.sql("#{sort} DESC, route_id ASC"))
|
|
62
|
+
.limit(limit)
|
|
63
|
+
.offset(offset)
|
|
64
|
+
.to_a
|
|
65
|
+
|
|
66
|
+
routes = RailsPulse::Route.where(id: rows.map(&:route_id)).index_by(&:id)
|
|
67
|
+
data = rows.filter_map do |row|
|
|
68
|
+
route = routes[row.route_id]
|
|
69
|
+
RouteSerializer.serialize(route, stats: stats_for(row)) if route
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
meta = { total: total, limit: limit, offset: offset }
|
|
73
|
+
# Lets a caller tell "nothing ran in this window" from "nothing ran
|
|
74
|
+
# often enough", which are different next steps, without asking again
|
|
75
|
+
# at a lower threshold.
|
|
76
|
+
if min_requests > 1
|
|
77
|
+
meta[:min_requests] = min_requests
|
|
78
|
+
meta[:routes_with_traffic] = routes_with_traffic
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
render json: { data: data, meta: meta }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def min_requests
|
|
85
|
+
integer_param(:min_requests, 1, 1..MAX_INTEGER)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stats_for(row)
|
|
89
|
+
{
|
|
90
|
+
request_count: row.request_count.to_i,
|
|
91
|
+
avg_duration_ms: row.avg_duration.to_f.round(1),
|
|
92
|
+
error_count: row.error_count.to_i
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -102,7 +102,7 @@ module RailsPulse
|
|
|
102
102
|
private
|
|
103
103
|
|
|
104
104
|
# The original string if it is short enough to live in the session and
|
|
105
|
-
# Time.parse accepts it (
|
|
105
|
+
# Time.parse accepts it (RailsPulse::TimeRange parses it back the same way);
|
|
106
106
|
# nil otherwise.
|
|
107
107
|
def valid_time_param(value)
|
|
108
108
|
value = value.to_s.strip
|
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
class DashboardController < ApplicationController
|
|
3
|
-
include TimeRangeConcern
|
|
4
3
|
include DeploymentMarkersConcern
|
|
5
4
|
|
|
6
5
|
def index
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
@time_range = RailsPulse::TimeRange.resolve(
|
|
7
|
+
params: params, session: session, default_key: default_time_range_key
|
|
8
|
+
)
|
|
9
9
|
populate_deployment_markers
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# @period days ending now".
|
|
15
|
-
@period = RailsPulse::TimeWindow.new(@start_time, @end_time).days
|
|
16
|
-
|
|
17
|
-
# Determine period type based on time range
|
|
18
|
-
# If 24 hours or less, use hourly summaries, otherwise use daily
|
|
19
|
-
@period_type = @period <= 1 ? "hour" : "day"
|
|
11
|
+
window = @time_range.window
|
|
12
|
+
period = window.days
|
|
13
|
+
period_type = @time_range.period_type
|
|
20
14
|
|
|
21
15
|
# Get tag filter values from session
|
|
22
16
|
disabled_tags = session_disabled_tags
|
|
@@ -24,13 +18,13 @@ module RailsPulse
|
|
|
24
18
|
|
|
25
19
|
card_and_chart_options = {
|
|
26
20
|
disabled_tags: disabled_tags, show_non_tagged: show_non_tagged,
|
|
27
|
-
period:
|
|
28
|
-
|
|
21
|
+
period: period, period_type: period_type,
|
|
22
|
+
window: window
|
|
29
23
|
}
|
|
30
24
|
|
|
31
|
-
@percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(
|
|
32
|
-
@request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(
|
|
33
|
-
@error_rates_metric_card = RailsPulse::Routes::Cards::ErrorRates.new(
|
|
25
|
+
@percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(**card_and_chart_options).to_metric_card
|
|
26
|
+
@request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(**card_and_chart_options).to_metric_card
|
|
27
|
+
@error_rates_metric_card = RailsPulse::Routes::Cards::ErrorRates.new(**card_and_chart_options).to_metric_card
|
|
34
28
|
@job_failure_rate_metric_card = RailsPulse::Jobs::Cards::FailureRate.new(**card_and_chart_options).to_metric_card if RailsPulse.configuration.track_jobs
|
|
35
29
|
|
|
36
30
|
# Generate chart data for inline rendering
|
|
@@ -38,19 +32,25 @@ module RailsPulse
|
|
|
38
32
|
@throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(**card_and_chart_options).to_chart_data
|
|
39
33
|
|
|
40
34
|
# Needs Attention panel
|
|
41
|
-
@needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period:
|
|
35
|
+
@needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, window: window).to_attention_data
|
|
42
36
|
|
|
43
37
|
# System Health bar
|
|
44
|
-
@health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period:
|
|
38
|
+
@health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, window: window).to_health_data
|
|
45
39
|
|
|
46
40
|
@storage_status = RailsPulse::Dashboard::StorageStatus.new(cached_sizes: true)
|
|
47
41
|
|
|
48
42
|
# Deployments panel — scoped to the same window as the chart markers so
|
|
49
43
|
# the panel and the markers drawn on the charts always agree.
|
|
50
|
-
deployments_in_range = RailsPulse::Deployment.for_range(
|
|
44
|
+
deployments_in_range = RailsPulse::Deployment.for_range(window.start_time, window.end_time)
|
|
51
45
|
@deployment_count = deployments_in_range.count
|
|
52
46
|
@recent_deployments = deployments_in_range.recent.limit(4)
|
|
53
47
|
@last_deployment = RailsPulse::Deployment.recent.first
|
|
54
48
|
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def default_time_range_key
|
|
53
|
+
:last_24_hours
|
|
54
|
+
end
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -6,7 +6,10 @@ module RailsPulse
|
|
|
6
6
|
# widen the hole the 0.4.0 audit closed.
|
|
7
7
|
skip_before_action :authenticate_rails_pulse_user!, only: %i[create finish]
|
|
8
8
|
skip_before_action :verify_authenticity_token, only: %i[create finish]
|
|
9
|
-
|
|
9
|
+
# Prepended so the token is checked before the inherited callbacks run:
|
|
10
|
+
# the schema check would otherwise show an anonymous caller the list of
|
|
11
|
+
# missing tables, and the dashboard callbacks would query on its behalf.
|
|
12
|
+
prepend_before_action :authenticate_deployment_request!, only: %i[create finish]
|
|
10
13
|
|
|
11
14
|
def index
|
|
12
15
|
@ransack_query = Deployment.ransack(ransack_params)
|
|
@@ -53,19 +56,26 @@ module RailsPulse
|
|
|
53
56
|
|
|
54
57
|
private
|
|
55
58
|
|
|
59
|
+
# Only config.deployment_token authorizes a write here. config.api_token
|
|
60
|
+
# reads the JSON API and is the credential given to CLI callers and coding
|
|
61
|
+
# agents, so accepting it would let any of them record a release.
|
|
62
|
+
#
|
|
63
|
+
# The dashboard login is never accepted instead. CSRF protection is off for
|
|
64
|
+
# these actions so CI can post, and a browser attaches a saved login (HTTP
|
|
65
|
+
# Basic in particular) to a request another site triggers, so a login
|
|
66
|
+
# fallback would let any page an admin visits record a release. A header
|
|
67
|
+
# token cannot be sent cross-site. The rake tasks run inside the app and
|
|
68
|
+
# need no token.
|
|
56
69
|
def authenticate_deployment_request!
|
|
57
|
-
token = RailsPulse.configuration.
|
|
58
|
-
if token.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
# No token configured and authentication disabled — fail closed.
|
|
67
|
-
# Without a token there is no way to verify the caller.
|
|
68
|
-
render json: { error: "Unauthorized — set deployment_api_token or enable authentication" }, status: :unauthorized
|
|
70
|
+
token = RailsPulse.configuration.deployment_token.to_s
|
|
71
|
+
if token.empty?
|
|
72
|
+
render json: { error: "Unauthorized — set config.deployment_token to record deployments over HTTP" }, status: :unauthorized
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
provided = request.headers["X-Rails-Pulse-Token"].to_s
|
|
77
|
+
unless ActiveSupport::SecurityUtils.secure_compare(provided, token)
|
|
78
|
+
render json: { error: "Unauthorized" }, status: :unauthorized
|
|
69
79
|
end
|
|
70
80
|
end
|
|
71
81
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
class ExceptionsController < ApplicationController
|
|
3
|
-
include
|
|
3
|
+
include RansackParamsConcern
|
|
4
4
|
include TagFilterConcern
|
|
5
5
|
include DeploymentMarkersConcern
|
|
6
6
|
|
|
@@ -38,22 +38,20 @@ module RailsPulse
|
|
|
38
38
|
# without them the page can say what is failing but not whether it is
|
|
39
39
|
# getting worse, which is the question users actually arrive with.
|
|
40
40
|
def setup_frequency_view
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
@time_range = RailsPulse::TimeRange.resolve(
|
|
42
|
+
params: params, session: session, default_key: default_time_range_key
|
|
43
|
+
)
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
period_days = window_days_for(@selected_time_range)
|
|
45
|
+
period_days = window_days_for(@time_range.selected_time_range, @time_range.window)
|
|
47
46
|
|
|
48
47
|
populate_deployment_markers
|
|
49
48
|
|
|
50
49
|
@occurrence_volume_chart = Exceptions::Charts::OccurrenceVolume.new(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
period_type: @period_type
|
|
50
|
+
window: @time_range.window,
|
|
51
|
+
period_type: @time_range.period_type
|
|
54
52
|
).to_chart_data
|
|
55
53
|
|
|
56
|
-
card_params = { period: period_days, period_type: @period_type }
|
|
54
|
+
card_params = { period: period_days, period_type: @time_range.period_type }
|
|
57
55
|
|
|
58
56
|
@total_occurrences_metric_card = Exceptions::Cards::TotalOccurrences.new(**card_params).to_metric_card
|
|
59
57
|
@exception_rate_metric_card = Exceptions::Cards::ExceptionRate.new(**card_params).to_metric_card
|
|
@@ -67,13 +65,13 @@ module RailsPulse
|
|
|
67
65
|
# The card copy ("compared to previous N days") has to name the range the
|
|
68
66
|
# user picked. Measuring the span instead reads one day long, because the
|
|
69
67
|
# range runs from the start of the first day to the end of the last.
|
|
70
|
-
def window_days_for(selected_range)
|
|
68
|
+
def window_days_for(selected_range, window)
|
|
71
69
|
case selected_range.to_s
|
|
72
70
|
when "last_24_hours" then 1
|
|
73
71
|
when "last_7_days" then 7
|
|
74
72
|
when "last_14_days" then 14
|
|
75
73
|
when "last_30_days" then 30
|
|
76
|
-
else
|
|
74
|
+
else window.days
|
|
77
75
|
end
|
|
78
76
|
end
|
|
79
77
|
|
|
@@ -57,7 +57,7 @@ module RailsPulse
|
|
|
57
57
|
|
|
58
58
|
# Pass the job to chart classes on show pages
|
|
59
59
|
def chart_options
|
|
60
|
-
show_action? ? {
|
|
60
|
+
show_action? ? { subject: @job } : {}
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Jobs use polymorphic summaries, so we need to filter by type
|
|
@@ -102,7 +102,6 @@ module RailsPulse
|
|
|
102
102
|
Jobs::Tables::Index.new(
|
|
103
103
|
ransack_query: @ransack_query,
|
|
104
104
|
period_type: period_type,
|
|
105
|
-
start_time: @start_time,
|
|
106
105
|
params: params,
|
|
107
106
|
disabled_tags: session_disabled_tags,
|
|
108
107
|
show_non_tagged: session[:show_non_tagged] != false,
|
|
@@ -30,8 +30,7 @@ module RailsPulse
|
|
|
30
30
|
|
|
31
31
|
# Database load chart doesn't use ransack_query, so generate it separately
|
|
32
32
|
@database_load_chart_data = Queries::Charts::DatabaseLoad.new(
|
|
33
|
-
|
|
34
|
-
end_time: @end_time,
|
|
33
|
+
window: @time_range&.window,
|
|
35
34
|
period_type: period_type,
|
|
36
35
|
disabled_tags: session_disabled_tags,
|
|
37
36
|
show_non_tagged: session[:show_non_tagged] != false
|
|
@@ -55,10 +54,13 @@ module RailsPulse
|
|
|
55
54
|
|
|
56
55
|
# Database load card only shows on index page and doesn't accept query param
|
|
57
56
|
if current_resource.nil?
|
|
57
|
+
window = @time_range&.window
|
|
58
|
+
period_days = window ? ((window.end_time - window.start_time) / 1.day).round : 7
|
|
59
|
+
|
|
58
60
|
@database_load_metric_card = Queries::Cards::DatabaseLoad.new(
|
|
59
61
|
disabled_tags: session_disabled_tags,
|
|
60
62
|
show_non_tagged: session[:show_non_tagged] != false,
|
|
61
|
-
period:
|
|
63
|
+
period: period_days,
|
|
62
64
|
period_type: period_type.to_s
|
|
63
65
|
).to_metric_card
|
|
64
66
|
end
|
|
@@ -87,7 +89,7 @@ module RailsPulse
|
|
|
87
89
|
|
|
88
90
|
# Pass the query to chart classes on show pages
|
|
89
91
|
def chart_options
|
|
90
|
-
show_action? ? {
|
|
92
|
+
show_action? ? { subject: @query } : {}
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
# Queries use polymorphic summaries, so we need to filter by type
|
|
@@ -127,7 +129,6 @@ module RailsPulse
|
|
|
127
129
|
Queries::Tables::Index.new(
|
|
128
130
|
ransack_query: @ransack_query,
|
|
129
131
|
period_type: period_type,
|
|
130
|
-
start_time: @start_time,
|
|
131
132
|
params: params,
|
|
132
133
|
disabled_tags: session_disabled_tags,
|
|
133
134
|
show_non_tagged: session[:show_non_tagged] != false
|
|
@@ -40,23 +40,23 @@ module RailsPulse
|
|
|
40
40
|
|
|
41
41
|
# Override: Requests aggregate all request data (summarizable_id: 0)
|
|
42
42
|
# rather than scoping to a specific resource
|
|
43
|
-
# Also handles "recent" mode where @start_time may be nil
|
|
44
43
|
def build_chart_ransack_params(ransack_params)
|
|
45
44
|
base_params = ransack_params.except(:s).merge(
|
|
46
45
|
summarizable_type_eq: "RailsPulse::Request",
|
|
47
46
|
summarizable_id_eq: 0
|
|
48
47
|
)
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
if @start_time && @end_time
|
|
49
|
+
if @time_range&.window
|
|
52
50
|
base_params.merge!(
|
|
53
|
-
period_start_gteq:
|
|
54
|
-
period_start_lt:
|
|
51
|
+
period_start_gteq: @time_range.window.start_time,
|
|
52
|
+
period_start_lt: @time_range.window.end_time
|
|
55
53
|
)
|
|
56
54
|
end
|
|
57
55
|
|
|
58
56
|
# Only add duration filter if we have a meaningful threshold
|
|
59
|
-
|
|
57
|
+
if @time_range&.start_duration && @time_range.start_duration > 0
|
|
58
|
+
base_params[:avg_duration_gteq] = @time_range.start_duration
|
|
59
|
+
end
|
|
60
60
|
base_params
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -67,25 +67,26 @@ module RailsPulse
|
|
|
67
67
|
# Handle time mode - check if recent mode is selected
|
|
68
68
|
time_mode = params[:period_start_range] || "recent"
|
|
69
69
|
|
|
70
|
-
if time_mode != "recent" && @
|
|
70
|
+
if time_mode != "recent" && @time_range&.table_window
|
|
71
71
|
# Custom mode - apply time filters
|
|
72
72
|
params.merge!(
|
|
73
|
-
occurred_at_gteq:
|
|
74
|
-
occurred_at_lt:
|
|
73
|
+
occurred_at_gteq: @time_range.table_window.start_time,
|
|
74
|
+
occurred_at_lt: @time_range.table_window.end_time
|
|
75
75
|
)
|
|
76
76
|
end
|
|
77
77
|
# else: Recent mode - no time filters, just rely on sort + pagination
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
start_duration = @time_range&.start_duration
|
|
80
|
+
# Duration filter - convert symbol to numeric threshold or use start_duration
|
|
80
81
|
if params[:duration_gteq].present?
|
|
81
82
|
# If it's a symbol like :slow, convert it to the numeric threshold
|
|
82
83
|
if params[:duration_gteq].to_s.in?(%w[slow very_slow critical])
|
|
83
|
-
params[:duration_gteq] =
|
|
84
|
+
params[:duration_gteq] = start_duration
|
|
84
85
|
end
|
|
85
86
|
# else: it's already a number, keep it as is
|
|
86
|
-
elsif
|
|
87
|
-
# No duration_gteq param, use
|
|
88
|
-
params[:duration_gteq] =
|
|
87
|
+
elsif start_duration && start_duration > 0
|
|
88
|
+
# No duration_gteq param, use start_duration from @time_range
|
|
89
|
+
params[:duration_gteq] = start_duration
|
|
89
90
|
end
|
|
90
91
|
|
|
91
92
|
# Min response size filter - input is in KB, convert to bytes for the column
|
|
@@ -52,7 +52,7 @@ module RailsPulse
|
|
|
52
52
|
|
|
53
53
|
# Pass the route to chart classes on show pages
|
|
54
54
|
def chart_options
|
|
55
|
-
show_action? ? {
|
|
55
|
+
show_action? ? { subject: @route } : {}
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def show_resource_filter
|
|
@@ -103,7 +103,6 @@ module RailsPulse
|
|
|
103
103
|
Routes::Tables::Index.new(
|
|
104
104
|
ransack_query: @ransack_query,
|
|
105
105
|
period_type: period_type,
|
|
106
|
-
start_time: @start_time,
|
|
107
106
|
params: params,
|
|
108
107
|
disabled_tags: session_disabled_tags,
|
|
109
108
|
show_non_tagged: session[:show_non_tagged] != false
|
|
@@ -135,17 +134,14 @@ module RailsPulse
|
|
|
135
134
|
# day/week/month periods) so the table isn't just blank for old ranges.
|
|
136
135
|
def setup_archived_summary_data
|
|
137
136
|
cutoff = retention_cutoff
|
|
138
|
-
|
|
137
|
+
table_window = @time_range&.table_window
|
|
139
138
|
|
|
140
|
-
scope = if cutoff &&
|
|
139
|
+
scope = if cutoff && table_window && table_window.start_time < cutoff
|
|
141
140
|
scope = Summary.for_routes
|
|
142
141
|
.where(summarizable_id: @route.id)
|
|
143
142
|
.where(period_type: period_type)
|
|
144
143
|
.where("period_start < ?", cutoff)
|
|
145
|
-
|
|
146
|
-
if @page_timings&.table_end_time
|
|
147
|
-
scope = scope.where("period_start < ?", Time.at(@page_timings.table_end_time))
|
|
148
|
-
end
|
|
144
|
+
.where("period_start < ?", table_window.end_time)
|
|
149
145
|
|
|
150
146
|
scope.order(period_start: :desc)
|
|
151
147
|
else
|
|
@@ -28,6 +28,21 @@ module RailsPulse
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Tooltip text for the chart-panel zone badge: the exact resolved interval
|
|
32
|
+
# (when a controller has resolved one) plus the aggregation zone every
|
|
33
|
+
# chart — hourly or daily — is consistently labeled in.
|
|
34
|
+
def time_range_zone_tooltip
|
|
35
|
+
zone = RailsPulse::TimeRange.aggregation_zone_label
|
|
36
|
+
lines = []
|
|
37
|
+
|
|
38
|
+
if (window = @time_range&.window)
|
|
39
|
+
lines << "Showing #{window.start_time.strftime('%b %-d, %Y %l:%M %p')} – #{window.end_time.strftime('%b %-d, %Y %l:%M %p')} (#{zone})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
lines << "All times are shown in #{zone}."
|
|
43
|
+
lines.join("\n")
|
|
44
|
+
end
|
|
45
|
+
|
|
31
46
|
def page_url(page_number)
|
|
32
47
|
url_for(request.query_parameters.merge(page: page_number))
|
|
33
48
|
end
|
|
@@ -22,7 +22,14 @@ module RailsPulse
|
|
|
22
22
|
rails_pulse__chart_type_value: type,
|
|
23
23
|
rails_pulse__chart_data_value: data.to_json,
|
|
24
24
|
rails_pulse__chart_options_value: chart_options.to_json,
|
|
25
|
-
rails_pulse__chart_theme_value: theme
|
|
25
|
+
rails_pulse__chart_theme_value: theme,
|
|
26
|
+
# Lets the chart format daily labels/tooltips in the zone summaries
|
|
27
|
+
# were actually bucketed in, instead of the browser's zone (which can
|
|
28
|
+
# land a daily boundary on the wrong calendar day — see #303). The
|
|
29
|
+
# short label keeps sparkline tooltips readable; the full zone name
|
|
30
|
+
# is in the badge's title next to each chart.
|
|
31
|
+
rails_pulse__chart_timezone_value: RailsPulse::TimeRange.aggregation_zone_iana,
|
|
32
|
+
rails_pulse__chart_timezone_label_value: RailsPulse::TimeRange.aggregation_zone_short_label
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
content_tag(:div, "",
|
|
@@ -34,7 +41,7 @@ module RailsPulse
|
|
|
34
41
|
|
|
35
42
|
# Base chart options shared across all chart types
|
|
36
43
|
def base_chart_options(units: nil, zoom: false)
|
|
37
|
-
hourly = @period_type == "hour"
|
|
44
|
+
hourly = @time_range&.period_type == "hour"
|
|
38
45
|
x_formatter = hourly ? "time" : "timestamp_to_date"
|
|
39
46
|
tooltip_formatter = units == "%" ? "tooltip_with_timestamp_rate" : "tooltip_with_timestamp"
|
|
40
47
|
{
|
|
@@ -2,20 +2,19 @@ module RailsPulse
|
|
|
2
2
|
module FormattingHelper
|
|
3
3
|
def human_readable_occurred_at(occurred_at)
|
|
4
4
|
return "" unless occurred_at.present?
|
|
5
|
-
|
|
6
|
-
#
|
|
7
|
-
time.
|
|
5
|
+
# Time.zone, not the server's OS zone — the same zone charts label as
|
|
6
|
+
# the aggregation zone, so this and the charts always agree.
|
|
7
|
+
time = occurred_at.is_a?(String) ? Time.zone.parse(occurred_at) : occurred_at
|
|
8
|
+
return "" if time.nil?
|
|
9
|
+
time.strftime("%b %d, %Y %l:%M %p")
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
def time_ago_in_words(time)
|
|
11
13
|
return "Unknown" if time.blank?
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
time = Time.parse(time.to_s) if time.is_a?(String)
|
|
15
|
-
# Convert to local system timezone for consistent calculation
|
|
16
|
-
time = time.getlocal
|
|
15
|
+
time = Time.zone.parse(time.to_s) if time.is_a?(String)
|
|
17
16
|
|
|
18
|
-
seconds_ago = [ Time.
|
|
17
|
+
seconds_ago = [ Time.current - time, 0 ].max
|
|
19
18
|
|
|
20
19
|
case seconds_ago
|
|
21
20
|
when 0..59
|
|
@@ -37,10 +36,10 @@ module RailsPulse
|
|
|
37
36
|
def human_readable_summary_period(summary)
|
|
38
37
|
return "" unless summary&.period_start&.present? && summary&.period_end&.present?
|
|
39
38
|
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
# Already Time.zone-aware from ActiveRecord — the same aggregation
|
|
40
|
+
# zone charts label, so no conversion needed.
|
|
41
|
+
start_time = summary.period_start
|
|
42
|
+
end_time = summary.period_end
|
|
44
43
|
|
|
45
44
|
case summary.period_type
|
|
46
45
|
when "hour"
|