rails_pulse 0.2.3 → 0.2.5.pre.pre.2
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/README.md +270 -13
- data/Rakefile +142 -8
- data/app/assets/stylesheets/rails_pulse/components/table.css +16 -1
- data/app/assets/stylesheets/rails_pulse/components/tags.css +7 -2
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +3 -0
- data/app/controllers/concerns/chart_table_concern.rb +3 -3
- data/app/controllers/rails_pulse/application_controller.rb +20 -3
- data/app/controllers/rails_pulse/assets_controller.rb +18 -2
- data/app/controllers/rails_pulse/job_runs_controller.rb +37 -0
- data/app/controllers/rails_pulse/jobs_controller.rb +80 -0
- data/app/controllers/rails_pulse/operations_controller.rb +43 -31
- data/app/controllers/rails_pulse/queries_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +3 -8
- data/app/controllers/rails_pulse/routes_controller.rb +1 -1
- data/app/controllers/rails_pulse/tags_controller.rb +31 -5
- data/app/helpers/rails_pulse/application_helper.rb +79 -3
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
- data/app/helpers/rails_pulse/chart_helper.rb +32 -2
- data/app/helpers/rails_pulse/status_helper.rb +16 -0
- data/app/helpers/rails_pulse/tags_helper.rb +39 -1
- data/app/javascript/rails_pulse/application.js +3 -54
- data/app/javascript/rails_pulse/controllers/chart_controller.js +333 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +9 -14
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +27 -33
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +0 -2
- data/app/jobs/rails_pulse/cleanup_job.rb +0 -2
- data/app/jobs/rails_pulse/summary_job.rb +0 -2
- data/app/models/concerns/rails_pulse/taggable.rb +25 -2
- data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
- data/app/models/rails_pulse/job.rb +85 -0
- data/app/models/rails_pulse/job_run.rb +76 -0
- data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
- data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
- data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
- data/app/models/rails_pulse/operation.rb +16 -3
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +1 -1
- data/app/models/rails_pulse/queries/tables/index.rb +2 -1
- data/app/models/rails_pulse/query.rb +10 -1
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +1 -1
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +1 -1
- data/app/models/rails_pulse/routes/tables/index.rb +2 -1
- data/app/models/rails_pulse/summary.rb +10 -3
- data/app/services/rails_pulse/summary_service.rb +46 -0
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/application.html.erb +23 -0
- data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
- data/app/views/rails_pulse/components/_metric_card.html.erb +2 -2
- data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +1 -1
- data/app/views/rails_pulse/components/_table.html.erb +7 -4
- data/app/views/rails_pulse/components/_table_pagination.html.erb +8 -6
- data/app/views/rails_pulse/csp_test/show.html.erb +1 -1
- data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -1
- data/app/views/rails_pulse/dashboard/index.html.erb +5 -4
- data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
- data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
- data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
- data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
- data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
- data/app/views/rails_pulse/jobs/index.html.erb +34 -0
- data/app/views/rails_pulse/jobs/show.html.erb +49 -0
- data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
- data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
- data/app/views/rails_pulse/operations/show.html.erb +10 -8
- data/app/views/rails_pulse/queries/_table.html.erb +3 -3
- data/app/views/rails_pulse/queries/index.html.erb +2 -1
- data/app/views/rails_pulse/queries/show.html.erb +2 -1
- data/app/views/rails_pulse/requests/_table.html.erb +6 -6
- data/app/views/rails_pulse/routes/_table.html.erb +3 -3
- data/app/views/rails_pulse/routes/index.html.erb +2 -1
- data/app/views/rails_pulse/routes/show.html.erb +3 -2
- data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
- data/config/brakeman.ignore +213 -0
- data/config/brakeman.yml +68 -0
- data/config/importmap.rb +1 -1
- data/config/initializers/rails_pulse.rb +52 -0
- data/config/routes.rb +6 -0
- data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
- data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
- data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
- data/db/rails_pulse_schema.rb +186 -103
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
- data/lib/rails_pulse/active_job_extensions.rb +13 -0
- data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
- data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
- data/lib/rails_pulse/cleanup_service.rb +65 -0
- data/lib/rails_pulse/configuration.rb +80 -7
- data/lib/rails_pulse/engine.rb +29 -28
- data/lib/rails_pulse/extensions/active_record.rb +82 -0
- data/lib/rails_pulse/job_run_collector.rb +172 -0
- data/lib/rails_pulse/middleware/request_collector.rb +20 -43
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
- data/lib/rails_pulse/tracker.rb +82 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +2 -0
- data/lib/rails_pulse_server.ru +107 -0
- data/lib/tasks/rails_pulse_benchmark.rake +382 -0
- data/public/rails-pulse-assets/csp-test.js +10 -10
- data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +48 -48
- data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
- metadata +38 -30
- data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
- data/config/initializers/rails_charts_csp_patch.rb +0 -75
- data/db/migrate/20250930105043_install_rails_pulse_tables.rb +0 -23
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
module TagsHelper
|
|
3
|
+
# Render a single tag badge
|
|
4
|
+
# Options:
|
|
5
|
+
# - variant: :default (no class), :secondary, :positive
|
|
6
|
+
# - removable: boolean - whether to include a remove button
|
|
7
|
+
# - taggable_type: string - type of taggable object (for remove button)
|
|
8
|
+
# - taggable_id: integer - id of taggable object (for remove button)
|
|
9
|
+
def render_tag_badge(tag, variant: :default, removable: false, taggable_type: nil, taggable_id: nil)
|
|
10
|
+
badge_class = case variant
|
|
11
|
+
when :secondary
|
|
12
|
+
"badge badge--secondary font-normal"
|
|
13
|
+
when :positive
|
|
14
|
+
"badge badge--positive font-normal"
|
|
15
|
+
else
|
|
16
|
+
"badge font-normal"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if removable && taggable_type && taggable_id
|
|
20
|
+
# For removable tags, render the full structure with button_to
|
|
21
|
+
content_tag(:span, class: badge_class) do
|
|
22
|
+
concat tag.humanize
|
|
23
|
+
concat " "
|
|
24
|
+
concat(
|
|
25
|
+
button_to(
|
|
26
|
+
remove_tag_path(taggable_type, taggable_id, tag: tag),
|
|
27
|
+
method: :delete,
|
|
28
|
+
class: "tag-remove",
|
|
29
|
+
data: { turbo_frame: "_top" }
|
|
30
|
+
) do
|
|
31
|
+
content_tag(:span, "×", "aria-hidden": "true")
|
|
32
|
+
end
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
# For non-removable tags, just render the badge
|
|
37
|
+
content_tag(:span, tag, class: badge_class)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
3
41
|
# Display tags as badge elements
|
|
4
42
|
# Accepts:
|
|
5
43
|
# - Taggable objects (with tag_list method)
|
|
@@ -23,7 +61,7 @@ module RailsPulse
|
|
|
23
61
|
|
|
24
62
|
return content_tag(:span, "-", class: "text-subtle") if tag_array.empty?
|
|
25
63
|
|
|
26
|
-
safe_join(tag_array.map { |tag| content_tag(:div, tag, class: "badge") }, " ")
|
|
64
|
+
safe_join(tag_array.map { |tag| content_tag(:div, tag.humanize, class: "badge") }, " ")
|
|
27
65
|
end
|
|
28
66
|
end
|
|
29
67
|
end
|
|
@@ -12,6 +12,7 @@ import PopoverController from "./controllers/popover_controller";
|
|
|
12
12
|
import FormController from "./controllers/form_controller";
|
|
13
13
|
|
|
14
14
|
// Rails Pulse Controllers
|
|
15
|
+
import ChartController from "./controllers/chart_controller";
|
|
15
16
|
import IndexController from "./controllers/index_controller";
|
|
16
17
|
import ColorSchemeController from "./controllers/color_scheme_controller";
|
|
17
18
|
import PaginationController from "./controllers/pagination_controller";
|
|
@@ -29,7 +30,7 @@ const application = Application.start();
|
|
|
29
30
|
application.debug = false;
|
|
30
31
|
window.Stimulus = application;
|
|
31
32
|
|
|
32
|
-
// Make ECharts available globally for
|
|
33
|
+
// Make ECharts available globally for chart rendering
|
|
33
34
|
window.echarts = echarts;
|
|
34
35
|
|
|
35
36
|
// Make Turbo available globally
|
|
@@ -42,6 +43,7 @@ application.register("rails-pulse--menu", MenuController);
|
|
|
42
43
|
application.register("rails-pulse--popover", PopoverController);
|
|
43
44
|
application.register("rails-pulse--form", FormController);
|
|
44
45
|
|
|
46
|
+
application.register("rails-pulse--chart", ChartController);
|
|
45
47
|
application.register("rails-pulse--index", IndexController);
|
|
46
48
|
application.register("rails-pulse--color-scheme", ColorSchemeController);
|
|
47
49
|
application.register("rails-pulse--pagination", PaginationController);
|
|
@@ -96,59 +98,6 @@ echarts.registerTheme('railspulse', {
|
|
|
96
98
|
"bar": { "itemStyle": { "barBorderWidth": 0 } }
|
|
97
99
|
});
|
|
98
100
|
|
|
99
|
-
// Chart resize functionality (moved from inline script for CSP compliance)
|
|
100
|
-
window.addEventListener('resize', function() {
|
|
101
|
-
if (window.RailsCharts && window.RailsCharts.charts) {
|
|
102
|
-
Object.keys(window.RailsCharts.charts).forEach(function(chartID) {
|
|
103
|
-
window.RailsCharts.charts[chartID].resize();
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// Apply axis label colors based on current color scheme
|
|
109
|
-
function applyChartAxisLabelColors() {
|
|
110
|
-
if (!window.RailsCharts || !window.RailsCharts.charts) return;
|
|
111
|
-
const scheme = document.documentElement.getAttribute('data-color-scheme');
|
|
112
|
-
const isDark = scheme === 'dark';
|
|
113
|
-
const axisColor = isDark ? '#ffffff' : '#999999';
|
|
114
|
-
Object.keys(window.RailsCharts.charts).forEach(function(chartID) {
|
|
115
|
-
const chart = window.RailsCharts.charts[chartID];
|
|
116
|
-
try {
|
|
117
|
-
chart.setOption({
|
|
118
|
-
xAxis: { axisLabel: { color: axisColor } },
|
|
119
|
-
yAxis: { axisLabel: { color: axisColor } }
|
|
120
|
-
});
|
|
121
|
-
} catch (e) {
|
|
122
|
-
// noop
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Initial apply after charts initialize and on scheme changes
|
|
128
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
129
|
-
// run shortly after load to allow charts to initialize
|
|
130
|
-
setTimeout(applyChartAxisLabelColors, 50);
|
|
131
|
-
});
|
|
132
|
-
document.addEventListener('rails-pulse:color-scheme-changed', applyChartAxisLabelColors);
|
|
133
|
-
|
|
134
|
-
// Global function to initialize Rails Charts in any container.
|
|
135
|
-
// This is needed as we render Rails Charts in Turbo Frames.
|
|
136
|
-
window.initializeChartsInContainer = function(containerId) {
|
|
137
|
-
requestAnimationFrame(() => {
|
|
138
|
-
const container = containerId ? document.getElementById(containerId) : document;
|
|
139
|
-
const scripts = container.querySelectorAll('script');
|
|
140
|
-
scripts.forEach(script => {
|
|
141
|
-
const content = script.textContent;
|
|
142
|
-
const match = content.match(/function\s+(init_rails_charts_[a-f0-9]+)/);
|
|
143
|
-
if (match && window[match[1]]) {
|
|
144
|
-
window[match[1]]();
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
// ensure colors are correct for any charts initialized in this container
|
|
148
|
-
setTimeout(applyChartAxisLabelColors, 10);
|
|
149
|
-
});
|
|
150
|
-
};
|
|
151
|
-
|
|
152
101
|
// Export for global access
|
|
153
102
|
window.RailsPulse = {
|
|
154
103
|
application,
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = {
|
|
5
|
+
type: String, // "bar", "line", "area", "sparkline"
|
|
6
|
+
data: Object, // Chart data
|
|
7
|
+
options: Object, // ECharts configuration
|
|
8
|
+
theme: String // ECharts theme
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.initializeChart()
|
|
13
|
+
this.handleColorSchemeChange = this.onColorSchemeChange.bind(this)
|
|
14
|
+
document.addEventListener('rails-pulse:color-scheme-changed', this.handleColorSchemeChange)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnect() {
|
|
18
|
+
document.removeEventListener('rails-pulse:color-scheme-changed', this.handleColorSchemeChange)
|
|
19
|
+
this.disposeChart()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Main initialization with retry logic
|
|
23
|
+
initializeChart() {
|
|
24
|
+
this.retryCount = 0
|
|
25
|
+
this.maxRetries = 100 // 5 seconds
|
|
26
|
+
this.attemptInit()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
attemptInit() {
|
|
30
|
+
if (typeof echarts === 'undefined') {
|
|
31
|
+
this.retryCount++
|
|
32
|
+
if (this.retryCount >= this.maxRetries) {
|
|
33
|
+
console.error('[RailsPulse] echarts not loaded after 5 seconds for', this.element.id)
|
|
34
|
+
this.showError()
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
setTimeout(() => this.attemptInit(), 50)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.renderChart()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
renderChart() {
|
|
45
|
+
try {
|
|
46
|
+
// Initialize chart
|
|
47
|
+
this.chart = echarts.init(this.element, this.themeValue || 'railspulse')
|
|
48
|
+
|
|
49
|
+
// Build and set options
|
|
50
|
+
const config = this.buildChartConfig()
|
|
51
|
+
this.chart.setOption(config)
|
|
52
|
+
|
|
53
|
+
// Apply current color scheme
|
|
54
|
+
this.applyColorScheme()
|
|
55
|
+
|
|
56
|
+
// Dispatch event for other controllers (event-based communication)
|
|
57
|
+
document.dispatchEvent(new CustomEvent('stimulus:echarts:rendered', {
|
|
58
|
+
detail: {
|
|
59
|
+
containerId: this.element.id,
|
|
60
|
+
chart: this.chart,
|
|
61
|
+
controller: this
|
|
62
|
+
}
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
// Responsive resize
|
|
66
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
67
|
+
if (this.chart) {
|
|
68
|
+
this.chart.resize()
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
this.resizeObserver.observe(this.element)
|
|
72
|
+
|
|
73
|
+
// Mark as rendered for tests
|
|
74
|
+
this.element.setAttribute('data-chart-rendered', 'true')
|
|
75
|
+
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error('[RailsPulse] Error initializing chart:', error)
|
|
78
|
+
this.showError()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
buildChartConfig() {
|
|
83
|
+
// Start with provided options
|
|
84
|
+
const config = { ...this.optionsValue }
|
|
85
|
+
|
|
86
|
+
// Process formatters (convert function strings to actual functions)
|
|
87
|
+
this.processFormatters(config)
|
|
88
|
+
|
|
89
|
+
// Set data (xAxis and series)
|
|
90
|
+
this.setChartData(config)
|
|
91
|
+
|
|
92
|
+
return config
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setChartData(config) {
|
|
96
|
+
const data = this.dataValue
|
|
97
|
+
|
|
98
|
+
// Extract labels and values
|
|
99
|
+
const labels = Object.keys(data).map(k => {
|
|
100
|
+
const num = Number(k)
|
|
101
|
+
return isNaN(num) ? k : num
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const values = Object.values(data).map(v => {
|
|
105
|
+
if (typeof v === 'object' && v !== null) {
|
|
106
|
+
return v.value !== undefined ? v.value : v
|
|
107
|
+
}
|
|
108
|
+
return v
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Set xAxis data
|
|
112
|
+
config.xAxis = config.xAxis || {}
|
|
113
|
+
config.xAxis.type = 'category'
|
|
114
|
+
config.xAxis.data = labels
|
|
115
|
+
|
|
116
|
+
// Set yAxis
|
|
117
|
+
config.yAxis = config.yAxis || {}
|
|
118
|
+
config.yAxis.type = 'value'
|
|
119
|
+
|
|
120
|
+
// Set series data
|
|
121
|
+
if (Array.isArray(config.series)) {
|
|
122
|
+
// If series is already an array, update first series
|
|
123
|
+
config.series[0] = config.series[0] || {}
|
|
124
|
+
config.series[0].type = this.typeValue
|
|
125
|
+
config.series[0].data = values
|
|
126
|
+
} else if (config.series && typeof config.series === 'object') {
|
|
127
|
+
// If series is a single object (from helper), convert to array
|
|
128
|
+
const seriesConfig = { ...config.series }
|
|
129
|
+
config.series = [{
|
|
130
|
+
type: this.typeValue,
|
|
131
|
+
data: values,
|
|
132
|
+
...seriesConfig
|
|
133
|
+
}]
|
|
134
|
+
} else {
|
|
135
|
+
// No series provided, create default
|
|
136
|
+
config.series = [{
|
|
137
|
+
type: this.typeValue,
|
|
138
|
+
data: values
|
|
139
|
+
}]
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
processFormatters(config) {
|
|
144
|
+
// Process tooltip formatter
|
|
145
|
+
if (config.tooltip?.formatter && typeof config.tooltip.formatter === 'string') {
|
|
146
|
+
config.tooltip.formatter = this.parseFormatter(config.tooltip.formatter)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Process xAxis formatter
|
|
150
|
+
if (config.xAxis?.axisLabel?.formatter && typeof config.xAxis.axisLabel.formatter === 'string') {
|
|
151
|
+
config.xAxis.axisLabel.formatter = this.parseFormatter(config.xAxis.axisLabel.formatter)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Process yAxis formatter
|
|
155
|
+
if (config.yAxis?.axisLabel?.formatter && typeof config.yAxis.axisLabel.formatter === 'string') {
|
|
156
|
+
config.yAxis.axisLabel.formatter = this.parseFormatter(config.yAxis.axisLabel.formatter)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
parseFormatter(formatterString) {
|
|
161
|
+
// Remove function markers if present
|
|
162
|
+
const cleanString = formatterString.replace(/__FUNCTION_START__|__FUNCTION_END__/g, '')
|
|
163
|
+
|
|
164
|
+
// If it's a function string, use safe formatter registry instead of eval()
|
|
165
|
+
if (cleanString.trim().startsWith('function')) {
|
|
166
|
+
// Extract formatter logic using safe parsing
|
|
167
|
+
// Rather than eval(), we match against known safe patterns
|
|
168
|
+
return this.getSafeFormatter(cleanString)
|
|
169
|
+
}
|
|
170
|
+
return cleanString
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns a safe formatter function based on the formatter string.
|
|
175
|
+
* This prevents arbitrary code execution by using a whitelist approach.
|
|
176
|
+
*
|
|
177
|
+
* Security: Replaces eval() to prevent XSS and code injection attacks.
|
|
178
|
+
*/
|
|
179
|
+
getSafeFormatter(formatterString) {
|
|
180
|
+
// Whitelist of safe formatter patterns
|
|
181
|
+
// Each pattern maps to a safe implementation
|
|
182
|
+
const SAFE_FORMATTERS = {
|
|
183
|
+
// Duration formatter (milliseconds)
|
|
184
|
+
'duration_ms': (value) => {
|
|
185
|
+
if (typeof value === 'number') {
|
|
186
|
+
return value.toFixed(2) + ' ms'
|
|
187
|
+
}
|
|
188
|
+
return value
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
// Percentage formatter
|
|
192
|
+
'percentage': (value) => {
|
|
193
|
+
if (typeof value === 'number') {
|
|
194
|
+
return value.toFixed(1) + '%'
|
|
195
|
+
}
|
|
196
|
+
return value
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
// Number with commas
|
|
200
|
+
'number_delimited': (value) => {
|
|
201
|
+
if (typeof value === 'number') {
|
|
202
|
+
return value.toLocaleString()
|
|
203
|
+
}
|
|
204
|
+
return value
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
// Timestamp formatter
|
|
208
|
+
'timestamp': (value) => {
|
|
209
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
210
|
+
const date = new Date(value)
|
|
211
|
+
return date.toLocaleString()
|
|
212
|
+
}
|
|
213
|
+
return value
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
// Date only
|
|
217
|
+
'date': (value) => {
|
|
218
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
219
|
+
const date = new Date(value)
|
|
220
|
+
return date.toLocaleDateString()
|
|
221
|
+
}
|
|
222
|
+
return value
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
// Time only
|
|
226
|
+
'time': (value) => {
|
|
227
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
228
|
+
const date = new Date(value)
|
|
229
|
+
return date.toLocaleTimeString()
|
|
230
|
+
}
|
|
231
|
+
return value
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
// Bytes formatter
|
|
235
|
+
'bytes': (value) => {
|
|
236
|
+
if (typeof value !== 'number') return value
|
|
237
|
+
|
|
238
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
239
|
+
let size = value
|
|
240
|
+
let unitIndex = 0
|
|
241
|
+
|
|
242
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
243
|
+
size /= 1024
|
|
244
|
+
unitIndex++
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return size.toFixed(2) + ' ' + units[unitIndex]
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Try to match the formatter string to a known safe pattern
|
|
252
|
+
for (const [key, formatter] of Object.entries(SAFE_FORMATTERS)) {
|
|
253
|
+
if (formatterString.includes(key) ||
|
|
254
|
+
formatterString.includes(key.replace('_', ''))) {
|
|
255
|
+
return formatter
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check for specific safe patterns in the function string
|
|
260
|
+
if (formatterString.includes('toFixed(2)') && formatterString.includes('ms')) {
|
|
261
|
+
return SAFE_FORMATTERS.duration_ms
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (formatterString.includes('toLocaleString')) {
|
|
265
|
+
return SAFE_FORMATTERS.number_delimited
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (formatterString.includes('toLocaleDateString')) {
|
|
269
|
+
return SAFE_FORMATTERS.date
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (formatterString.includes('toLocaleTimeString')) {
|
|
273
|
+
return SAFE_FORMATTERS.time
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Default: return a safe identity function that just returns the value
|
|
277
|
+
console.warn('[RailsPulse] Unknown formatter pattern, using identity function:', formatterString)
|
|
278
|
+
return (value) => value
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
showError() {
|
|
282
|
+
this.element.classList.add('chart-error')
|
|
283
|
+
this.element.innerHTML = '<p class="text-subtle p-4">Chart failed to load</p>'
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Public accessor for chart instance
|
|
287
|
+
get chartInstance() {
|
|
288
|
+
return this.chart
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
disposeChart() {
|
|
292
|
+
if (this.resizeObserver) {
|
|
293
|
+
this.resizeObserver.disconnect()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (this.chart) {
|
|
297
|
+
this.chart.dispose()
|
|
298
|
+
this.chart = null
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Action for dynamic updates
|
|
303
|
+
update(event) {
|
|
304
|
+
if (event.detail?.data) {
|
|
305
|
+
this.dataValue = event.detail.data
|
|
306
|
+
}
|
|
307
|
+
if (event.detail?.options) {
|
|
308
|
+
this.optionsValue = event.detail.options
|
|
309
|
+
}
|
|
310
|
+
if (this.chart) {
|
|
311
|
+
const config = this.buildChartConfig()
|
|
312
|
+
this.chart.setOption(config, true) // true = not merge
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Color scheme management
|
|
317
|
+
onColorSchemeChange() {
|
|
318
|
+
this.applyColorScheme()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
applyColorScheme() {
|
|
322
|
+
if (!this.chart) return
|
|
323
|
+
|
|
324
|
+
const scheme = document.documentElement.getAttribute('data-color-scheme')
|
|
325
|
+
const isDark = scheme === 'dark'
|
|
326
|
+
const axisColor = isDark ? '#ffffff' : '#999999'
|
|
327
|
+
|
|
328
|
+
this.chart.setOption({
|
|
329
|
+
xAxis: { axisLabel: { color: axisColor } },
|
|
330
|
+
yAxis: { axisLabel: { color: axisColor } }
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -15,21 +15,16 @@ export default class extends Controller {
|
|
|
15
15
|
originalSeriesOption = null;
|
|
16
16
|
|
|
17
17
|
connect() {
|
|
18
|
-
// Listen for the custom event '
|
|
19
|
-
// This event is
|
|
18
|
+
// Listen for the custom event 'stimulus:echarts:rendered' to set up the chart.
|
|
19
|
+
// This event is dispatched by the chart controller when the chart is ready.
|
|
20
20
|
this.handleChartInitialized = this.onChartInitialized.bind(this);
|
|
21
21
|
|
|
22
|
-
document.addEventListener('
|
|
23
|
-
|
|
24
|
-
// If the chart is already initialized (e.g., on back navigation), set up immediately
|
|
25
|
-
if (window.RailsCharts?.charts?.[this.chartIdValue]) {
|
|
26
|
-
this.setup();
|
|
27
|
-
}
|
|
22
|
+
document.addEventListener('stimulus:echarts:rendered', this.handleChartInitialized);
|
|
28
23
|
}
|
|
29
24
|
|
|
30
25
|
disconnect() {
|
|
31
|
-
// Remove the event listener
|
|
32
|
-
document.removeEventListener('
|
|
26
|
+
// Remove the event listener when the controller is disconnected
|
|
27
|
+
document.removeEventListener('stimulus:echarts:rendered', this.handleChartInitialized);
|
|
33
28
|
|
|
34
29
|
// Remove chart event listeners if they exist
|
|
35
30
|
if (this.hasChartTarget && this.chartTarget) {
|
|
@@ -47,6 +42,8 @@ export default class extends Controller {
|
|
|
47
42
|
// After the chart is initialized, set up the event listeners and data tracking
|
|
48
43
|
onChartInitialized(event) {
|
|
49
44
|
if (event.detail.containerId === this.chartIdValue) {
|
|
45
|
+
// Store the chart instance from the event
|
|
46
|
+
this.chart = event.detail.chart;
|
|
50
47
|
this.setup();
|
|
51
48
|
}
|
|
52
49
|
}
|
|
@@ -56,7 +53,7 @@ export default class extends Controller {
|
|
|
56
53
|
return; // Prevent multiple setups
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
// We need both the chart target in DOM and the chart object from
|
|
56
|
+
// We need both the chart target in DOM and the chart object from the event
|
|
60
57
|
let hasTarget = false;
|
|
61
58
|
try {
|
|
62
59
|
hasTarget = !!this.chartTarget;
|
|
@@ -64,10 +61,8 @@ export default class extends Controller {
|
|
|
64
61
|
hasTarget = false;
|
|
65
62
|
}
|
|
66
63
|
|
|
67
|
-
// Get the chart element which the RailsCharts library has created
|
|
68
|
-
this.chart = window.RailsCharts.charts[this.chartIdValue];
|
|
69
|
-
|
|
70
64
|
// Only proceed if we have BOTH the DOM target and the chart object
|
|
65
|
+
// (chart is set by onChartInitialized from the event)
|
|
71
66
|
if (!hasTarget || !this.chart) {
|
|
72
67
|
return;
|
|
73
68
|
}
|
|
@@ -3,56 +3,50 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
3
3
|
export default class extends Controller {
|
|
4
4
|
static targets = ["limit"]
|
|
5
5
|
static values = {
|
|
6
|
-
storageKey: { type: String, default: "rails_pulse_pagination_limit" }
|
|
7
|
-
url: String
|
|
6
|
+
storageKey: { type: String, default: "rails_pulse_pagination_limit" }
|
|
8
7
|
}
|
|
9
8
|
|
|
10
9
|
connect() {
|
|
11
10
|
this.restorePaginationLimit()
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
// Update pagination limit and
|
|
13
|
+
// Update pagination limit and navigate to page 1 with new limit
|
|
15
14
|
updateLimit() {
|
|
16
15
|
const limit = this.limitTarget.value
|
|
17
16
|
|
|
18
|
-
// Save to session storage
|
|
17
|
+
// Save to session storage for persistence
|
|
19
18
|
sessionStorage.setItem(this.storageKeyValue, limit)
|
|
20
19
|
|
|
21
|
-
//
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
// Build URL with limit parameter and reset to page 1
|
|
21
|
+
const currentUrl = new URL(window.location)
|
|
22
|
+
currentUrl.searchParams.set('limit', limit)
|
|
23
|
+
currentUrl.searchParams.delete('page')
|
|
24
|
+
|
|
25
|
+
// Use Turbo.visit for smooth navigation that preserves query params
|
|
26
|
+
if (typeof Turbo !== 'undefined') {
|
|
27
|
+
Turbo.visit(currentUrl.toString(), { action: 'replace' })
|
|
28
28
|
} else {
|
|
29
|
-
|
|
30
|
-
const currentUrl = new URL(window.location)
|
|
31
|
-
currentUrl.searchParams.set('limit', limit)
|
|
32
|
-
window.location.href = currentUrl.pathname + currentUrl.search
|
|
29
|
+
window.location.href = currentUrl.toString()
|
|
33
30
|
}
|
|
34
31
|
}
|
|
35
32
|
|
|
36
|
-
//
|
|
37
|
-
getCSRFToken() {
|
|
38
|
-
const token = document.querySelector('meta[name="csrf-token"]')
|
|
39
|
-
return token ? token.getAttribute('content') : ''
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Save the pagination limit to session storage when it changes
|
|
43
|
-
savePaginationLimit() {
|
|
44
|
-
const limit = this.limitTarget.value
|
|
45
|
-
sessionStorage.setItem(this.storageKeyValue, limit)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Restore the pagination limit from session storage on page load
|
|
33
|
+
// Restore the pagination limit from URL or session storage on page load
|
|
49
34
|
restorePaginationLimit() {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
35
|
+
// URL params take precedence over session storage
|
|
36
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
37
|
+
const urlLimit = urlParams.get('limit')
|
|
38
|
+
|
|
39
|
+
if (urlLimit && this.limitTarget) {
|
|
40
|
+
// Sync sessionStorage with URL param
|
|
41
|
+
sessionStorage.setItem(this.storageKeyValue, urlLimit)
|
|
42
|
+
if (this.limitTarget.value !== urlLimit) {
|
|
43
|
+
this.limitTarget.value = urlLimit
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
// Fall back to sessionStorage if no URL param
|
|
47
|
+
const savedLimit = sessionStorage.getItem(this.storageKeyValue)
|
|
48
|
+
if (savedLimit && this.limitTarget && this.limitTarget.value !== savedLimit) {
|
|
54
49
|
this.limitTarget.value = savedLimit
|
|
55
|
-
// Don't trigger change event when restoring from session - prevents infinite loops
|
|
56
50
|
}
|
|
57
51
|
}
|
|
58
52
|
}
|