rails_pulse 0.3.3 → 0.4.0.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/CHANGELOG.md +299 -0
- data/README.md +44 -3
- data/app/assets/stylesheets/rails_pulse/application.css +21 -32
- data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
- data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
- data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
- data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
- data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
- data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
- data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
- data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
- data/app/controllers/concerns/chart_table_concern.rb +15 -3
- data/app/controllers/concerns/ransack_params_concern.rb +18 -0
- data/app/controllers/concerns/response_range_concern.rb +1 -1
- data/app/controllers/concerns/time_range_concern.rb +28 -14
- data/app/controllers/rails_pulse/application_controller.rb +104 -31
- data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
- data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
- data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
- data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
- data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +1 -1
- data/app/controllers/rails_pulse/routes_controller.rb +7 -1
- data/app/controllers/rails_pulse/storage_controller.rb +7 -0
- data/app/controllers/rails_pulse/tags_controller.rb +2 -2
- data/app/helpers/rails_pulse/application_helper.rb +1 -0
- data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
- data/app/helpers/rails_pulse/chart_helper.rb +0 -3
- data/app/helpers/rails_pulse/route_helper.rb +8 -16
- data/app/javascript/rails_pulse/application.js +29 -8
- data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
- data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
- data/app/models/concerns/rails_pulse/taggable.rb +11 -6
- data/app/models/rails_pulse/cards/base.rb +53 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
- data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
- data/app/models/rails_pulse/deployment.rb +47 -1
- data/app/models/rails_pulse/exception_group.rb +37 -0
- data/app/models/rails_pulse/exception_occurrence.rb +27 -0
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
- data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
- data/app/models/rails_pulse/operation.rb +13 -4
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
- data/app/models/rails_pulse/request.rb +5 -1
- data/app/models/rails_pulse/route.rb +71 -21
- data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
- data/app/models/rails_pulse/routes/tables/index.rb +8 -6
- data/app/models/rails_pulse/summary.rb +21 -10
- data/app/models/rails_pulse/tables/base.rb +4 -2
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
- data/app/services/rails_pulse/exception_capture_service.rb +228 -0
- data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
- data/app/services/rails_pulse/operations/change_point.rb +144 -0
- data/app/services/rails_pulse/operations/compare.rb +92 -0
- data/app/services/rails_pulse/operations/comparison.rb +126 -0
- data/app/services/rails_pulse/operations/metric.rb +63 -0
- data/app/services/rails_pulse/operations/series.rb +84 -0
- data/app/services/rails_pulse/operations/subject.rb +77 -0
- data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
- data/app/services/rails_pulse/route_merger.rb +99 -0
- data/app/services/rails_pulse/route_migrator.rb +139 -0
- data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
- data/app/services/rails_pulse/route_recognizer.rb +47 -0
- data/app/services/rails_pulse/summary_service.rb +45 -0
- data/app/services/rails_pulse/tag_filter_service.rb +5 -3
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
- data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
- data/app/views/layouts/rails_pulse/application.html.erb +4 -4
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_panel.html.erb +16 -6
- data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
- data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
- data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
- data/app/views/rails_pulse/deployments/index.html.erb +15 -0
- data/app/views/rails_pulse/deployments/show.html.erb +62 -0
- data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
- data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
- data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
- data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
- data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
- data/app/views/rails_pulse/requests/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
- data/app/views/rails_pulse/routes/_table.html.erb +22 -8
- data/app/views/rails_pulse/routes/index.html.erb +5 -1
- data/app/views/rails_pulse/routes/show.html.erb +5 -0
- data/app/views/rails_pulse/storage/show.html.erb +123 -0
- data/config/routes.rb +13 -4
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
- data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
- data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
- data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
- data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
- data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
- data/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/base_methods.rb +2 -0
- data/lib/generators/rails_pulse/install_generator.rb +5 -0
- data/lib/generators/rails_pulse/schema_parser.rb +22 -2
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
- data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
- data/lib/rails_pulse/cleanup_service.rb +110 -15
- data/lib/rails_pulse/configuration.rb +125 -6
- data/lib/rails_pulse/engine.rb +32 -17
- data/lib/rails_pulse/installers/config_updater.rb +202 -0
- data/lib/rails_pulse/job_run_collector.rb +43 -16
- data/lib/rails_pulse/middleware/asset_server.rb +11 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -4
- data/lib/rails_pulse/packaged_assets.rb +147 -0
- data/lib/rails_pulse/route_indexes.rb +66 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
- data/lib/rails_pulse/tracker.rb +29 -10
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +1 -0
- data/lib/rails_pulse_server.ru +16 -47
- data/lib/tasks/rails_pulse.rake +83 -4
- data/lib/tasks/rails_pulse_assets.rake +15 -0
- data/lib/tasks/rails_pulse_tasks.rake +29 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +18 -117
- metadata +78 -37
- data/Rakefile +0 -554
- data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
- data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
- data/app/javascript/rails_pulse/theme.js +0 -416
- data/config/brakeman.ignore +0 -190
- data/config/brakeman.yml +0 -68
- data/config/initializers/rails_pulse.rb +0 -287
- data/public/rails-pulse-assets/csp-test.js +0 -120
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
- data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
- data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
- data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
- data/vendor/assets/javascripts/rails-pulse.js +0 -157
- data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
- data/vendor/assets/stylesheets/rails-pulse.css +0 -1
- data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
|
@@ -1,24 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Only the chart types and components Rails Pulse actually renders are pulled in.
|
|
2
|
+
// Importing the "echarts" barrel instead would bundle every chart type (sankey,
|
|
3
|
+
// gauge, map, ...) and roughly quintuple the bundle.
|
|
4
|
+
import * as echarts from "echarts/core";
|
|
5
|
+
import { BarChart, LineChart } from "echarts/charts";
|
|
6
|
+
import {
|
|
7
|
+
DataZoomComponent,
|
|
8
|
+
GridComponent,
|
|
9
|
+
LegendComponent,
|
|
10
|
+
MarkAreaComponent,
|
|
11
|
+
MarkLineComponent,
|
|
12
|
+
TooltipComponent
|
|
13
|
+
} from "echarts/components";
|
|
14
|
+
import { CanvasRenderer } from "echarts/renderers";
|
|
3
15
|
import { Application } from "@hotwired/stimulus";
|
|
4
16
|
|
|
17
|
+
// TooltipComponent installs AxisPointer itself, so `axisPointer` options work.
|
|
18
|
+
echarts.use([
|
|
19
|
+
BarChart,
|
|
20
|
+
LineChart,
|
|
21
|
+
DataZoomComponent,
|
|
22
|
+
GridComponent,
|
|
23
|
+
LegendComponent,
|
|
24
|
+
MarkAreaComponent,
|
|
25
|
+
MarkLineComponent,
|
|
26
|
+
TooltipComponent,
|
|
27
|
+
CanvasRenderer
|
|
28
|
+
]);
|
|
29
|
+
|
|
5
30
|
// CSS Zero Controllers
|
|
6
31
|
import ContextMenuController from "./controllers/context_menu_controller";
|
|
7
32
|
import DatePickerController from "./controllers/datepicker_controller";
|
|
8
33
|
import DialogController from "./controllers/dialog_controller";
|
|
9
34
|
import MenuController from "./controllers/menu_controller";
|
|
10
35
|
import PopoverController from "./controllers/popover_controller";
|
|
11
|
-
import FormController from "./controllers/form_controller";
|
|
12
36
|
|
|
13
37
|
// Rails Pulse Controllers
|
|
14
38
|
import ChartController from "./controllers/chart_controller";
|
|
15
39
|
import IndexController from "./controllers/index_controller";
|
|
16
40
|
import ColorSchemeController from "./controllers/color_scheme_controller";
|
|
17
41
|
import PaginationController from "./controllers/pagination_controller";
|
|
18
|
-
import TimezoneController from "./controllers/timezone_controller";
|
|
19
42
|
import IconController from "./controllers/icon_controller";
|
|
20
43
|
import ExpandableRowsController from "./controllers/expandable_rows_controller";
|
|
21
44
|
import CollapsibleController from "./controllers/collapsible_controller";
|
|
45
|
+
import ClipboardController from "./controllers/clipboard_controller";
|
|
22
46
|
import TableSortController from "./controllers/table_sort_controller";
|
|
23
47
|
import GlobalFiltersController from "./controllers/global_filters_controller";
|
|
24
48
|
import CustomRangeController from "./controllers/custom_range_controller"
|
|
@@ -26,7 +50,6 @@ import SeriesToggleController from "./controllers/series_toggle_controller";
|
|
|
26
50
|
import DeploymentMarkersToggleController from "./controllers/deployment_markers_toggle_controller";
|
|
27
51
|
import FlameGraphController from "./controllers/flame_graph_controller";
|
|
28
52
|
import ChartSwitcherController from "./controllers/chart_switcher_controller";
|
|
29
|
-
import PeriodSelectorController from "./controllers/period_selector_controller";
|
|
30
53
|
import TimeRangeController from "./controllers/time_range_controller";
|
|
31
54
|
const application = Application.start();
|
|
32
55
|
|
|
@@ -42,16 +65,15 @@ application.register("rails-pulse--datepicker", DatePickerController);
|
|
|
42
65
|
application.register("rails-pulse--dialog", DialogController);
|
|
43
66
|
application.register("rails-pulse--menu", MenuController);
|
|
44
67
|
application.register("rails-pulse--popover", PopoverController);
|
|
45
|
-
application.register("rails-pulse--form", FormController);
|
|
46
68
|
|
|
47
69
|
application.register("rails-pulse--chart", ChartController);
|
|
48
70
|
application.register("rails-pulse--index", IndexController);
|
|
49
71
|
application.register("rails-pulse--color-scheme", ColorSchemeController);
|
|
50
72
|
application.register("rails-pulse--pagination", PaginationController);
|
|
51
|
-
application.register("rails-pulse--timezone", TimezoneController);
|
|
52
73
|
application.register("rails-pulse--icon", IconController);
|
|
53
74
|
application.register("rails-pulse--expandable-rows", ExpandableRowsController);
|
|
54
75
|
application.register("rails-pulse--collapsible", CollapsibleController);
|
|
76
|
+
application.register("rails-pulse--clipboard", ClipboardController);
|
|
55
77
|
application.register("rails-pulse--table-sort", TableSortController);
|
|
56
78
|
application.register("rails-pulse--global-filters", GlobalFiltersController);
|
|
57
79
|
application.register("rails-pulse--custom-range", CustomRangeController);
|
|
@@ -59,7 +81,6 @@ application.register("rails-pulse--series-toggle", SeriesToggleController);
|
|
|
59
81
|
application.register("rails-pulse--deployment-markers-toggle", DeploymentMarkersToggleController);
|
|
60
82
|
application.register("rails-pulse--flame-graph", FlameGraphController);
|
|
61
83
|
application.register("rails-pulse--chart-switcher", ChartSwitcherController);
|
|
62
|
-
application.register("rails-pulse--period-selector", PeriodSelectorController);
|
|
63
84
|
application.register("rails-pulse--time-range", TimeRangeController);
|
|
64
85
|
|
|
65
86
|
// Register ECharts theme for Rails Pulse
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["source"]
|
|
5
|
+
static values = {
|
|
6
|
+
label: { type: String, default: "copy" },
|
|
7
|
+
copiedLabel: { type: String, default: "copied!" },
|
|
8
|
+
errorLabel: { type: String, default: "copy failed" },
|
|
9
|
+
resetDelay: { type: Number, default: 1500 }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
disconnect() {
|
|
13
|
+
if (this.resetTimeout) clearTimeout(this.resetTimeout)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async copy(event) {
|
|
17
|
+
const button = event.currentTarget
|
|
18
|
+
const text = this.hasSourceTarget ? this.sourceTarget.textContent : ""
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await this.write(text)
|
|
22
|
+
this.flash(button, this.copiedLabelValue)
|
|
23
|
+
} catch {
|
|
24
|
+
this.flash(button, this.errorLabelValue)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// navigator.clipboard is undefined on insecure origins, which is where a
|
|
29
|
+
// mounted dashboard often lives (http://staging.internal). Fall back to the
|
|
30
|
+
// legacy execCommand path there rather than silently doing nothing.
|
|
31
|
+
async write(text) {
|
|
32
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
33
|
+
return navigator.clipboard.writeText(text)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const textarea = document.createElement("textarea")
|
|
37
|
+
textarea.value = text
|
|
38
|
+
textarea.setAttribute("readonly", "")
|
|
39
|
+
textarea.style.position = "fixed"
|
|
40
|
+
textarea.style.opacity = "0"
|
|
41
|
+
document.body.appendChild(textarea)
|
|
42
|
+
textarea.select()
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (!document.execCommand("copy")) throw new Error("execCommand copy failed")
|
|
46
|
+
} finally {
|
|
47
|
+
document.body.removeChild(textarea)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
flash(button, message) {
|
|
52
|
+
button.textContent = message
|
|
53
|
+
if (this.resetTimeout) clearTimeout(this.resetTimeout)
|
|
54
|
+
this.resetTimeout = setTimeout(() => {
|
|
55
|
+
button.textContent = this.labelValue
|
|
56
|
+
}, this.resetDelayValue)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -40,6 +40,16 @@ export default class extends Controller {
|
|
|
40
40
|
this.handleChartInitialized = this.onChartInitialized.bind(this);
|
|
41
41
|
|
|
42
42
|
document.addEventListener('stimulus:echarts:rendered', this.handleChartInitialized);
|
|
43
|
+
|
|
44
|
+
// Releasing the mouse outside the chart still ends a drag-zoom. This is not
|
|
45
|
+
// chart-specific, so it is bound once per controller rather than in
|
|
46
|
+
// setupChartEventListeners — that runs again on every chart switch and used
|
|
47
|
+
// to stack a new document listener each time.
|
|
48
|
+
this.handleDocumentMouseUp = () => {
|
|
49
|
+
if (!this.chart || !this.visibleData) return;
|
|
50
|
+
this.handleZoomChange();
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener('mouseup', this.handleDocumentMouseUp);
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
disconnect() {
|
|
@@ -226,12 +236,6 @@ export default class extends Controller {
|
|
|
226
236
|
this.handleZoomChange();
|
|
227
237
|
});
|
|
228
238
|
|
|
229
|
-
// When releasing the mouse button outside the chart, we want to check if the visible data has changed
|
|
230
|
-
this.handleDocumentMouseUp = () => {
|
|
231
|
-
this.handleZoomChange();
|
|
232
|
-
};
|
|
233
|
-
document.addEventListener('mouseup', this.handleDocumentMouseUp);
|
|
234
|
-
|
|
235
239
|
// Use ZRender (the underlying canvas renderer) to catch clicks anywhere in the
|
|
236
240
|
// chart grid — not just on data point markers — then map the pixel position back
|
|
237
241
|
// to the nearest x-axis bucket so any click in a column triggers filtering.
|
|
@@ -398,8 +402,8 @@ export default class extends Controller {
|
|
|
398
402
|
}
|
|
399
403
|
|
|
400
404
|
if (this.getAxisType(currentOption) === 'time') {
|
|
401
|
-
const startValue = dataZoom.startValue
|
|
402
|
-
const endValue = dataZoom.endValue
|
|
405
|
+
const startValue = dataZoom.startValue ?? xAxisData[0];
|
|
406
|
+
const endValue = dataZoom.endValue ?? xAxisData[xAxisData.length - 1];
|
|
403
407
|
|
|
404
408
|
return {
|
|
405
409
|
xAxis: xAxisData.filter(value => value >= startValue && value <= endValue),
|
|
@@ -410,8 +414,10 @@ export default class extends Controller {
|
|
|
410
414
|
};
|
|
411
415
|
}
|
|
412
416
|
|
|
413
|
-
|
|
414
|
-
|
|
417
|
+
// ?? not ||: index 0 is a valid category position, and `0 || fallback`
|
|
418
|
+
// silently widens the range back to the full axis.
|
|
419
|
+
const startValue = dataZoom.startValue ?? 0;
|
|
420
|
+
const endValue = dataZoom.endValue ?? xAxisData.length - 1;
|
|
415
421
|
|
|
416
422
|
return {
|
|
417
423
|
xAxis: xAxisData.slice(startValue, endValue + 1),
|
|
@@ -28,11 +28,15 @@ export default class extends Controller {
|
|
|
28
28
|
|
|
29
29
|
// Restore the pagination limit from URL or session storage on page load
|
|
30
30
|
restorePaginationLimit() {
|
|
31
|
+
// hasLimitTarget, not truthiness: accessing a missing Stimulus target throws
|
|
32
|
+
// rather than returning undefined, which would break connect() outright.
|
|
33
|
+
if (!this.hasLimitTarget) return
|
|
34
|
+
|
|
31
35
|
// URL params take precedence over session storage
|
|
32
36
|
const urlParams = new URLSearchParams(window.location.search)
|
|
33
37
|
const urlLimit = urlParams.get('limit')
|
|
34
38
|
|
|
35
|
-
if (urlLimit
|
|
39
|
+
if (urlLimit) {
|
|
36
40
|
// Sync sessionStorage with URL param
|
|
37
41
|
sessionStorage.setItem(this.storageKeyValue, urlLimit)
|
|
38
42
|
if (this.limitTarget.value !== urlLimit) {
|
|
@@ -41,7 +45,7 @@ export default class extends Controller {
|
|
|
41
45
|
} else {
|
|
42
46
|
// Fall back to sessionStorage if no URL param
|
|
43
47
|
const savedLimit = sessionStorage.getItem(this.storageKeyValue)
|
|
44
|
-
if (savedLimit && this.limitTarget
|
|
48
|
+
if (savedLimit && this.limitTarget.value !== savedLimit) {
|
|
45
49
|
this.limitTarget.value = savedLimit
|
|
46
50
|
}
|
|
47
51
|
}
|
|
@@ -42,6 +42,10 @@ export default class extends Controller {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
disconnect() {
|
|
45
|
+
// A pending debouncedShow would otherwise fire showPopover() on an element
|
|
46
|
+
// that has already been removed (e.g. the table was replaced by a fetch).
|
|
47
|
+
clearTimeout(this.#showTimer)
|
|
48
|
+
clearTimeout(this.#hideTimer)
|
|
45
49
|
this.cleanup()
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -5,20 +5,25 @@ module RailsPulse
|
|
|
5
5
|
# Tag validation constants
|
|
6
6
|
TAG_NAME_REGEX = /\A[a-z0-9_-]+\z/i
|
|
7
7
|
MAX_TAG_LENGTH = 50
|
|
8
|
+
# Escape character for LIKE patterns over the serialized tags column.
|
|
9
|
+
# Never appears in a valid tag name, and needs no quoting on any adapter.
|
|
10
|
+
LIKE_ESCAPE = "!".freeze
|
|
8
11
|
|
|
9
12
|
included do
|
|
10
13
|
# Callbacks
|
|
11
14
|
before_save :ensure_tags_is_array
|
|
12
15
|
|
|
13
|
-
# Scopes with table name qualification to avoid ambiguity
|
|
14
|
-
#
|
|
16
|
+
# Scopes with table name qualification to avoid ambiguity.
|
|
17
|
+
# LIKE patterns are sanitized so `_`/`%` in a tag are matched literally.
|
|
18
|
+
# The ESCAPE clause is explicit because SQLite has no default escape
|
|
19
|
+
# character — without it `my\_tag` matches a literal backslash there
|
|
20
|
+
# and the filter silently matches nothing. `!` is used rather than `\`
|
|
21
|
+
# because MySQL and PostgreSQL disagree on how to quote a backslash.
|
|
15
22
|
scope :with_tag, ->(tag) {
|
|
16
|
-
|
|
17
|
-
where("#{table_name}.tags LIKE ?", "%\"#{sanitized_tag}\"%")
|
|
23
|
+
where("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
|
|
18
24
|
}
|
|
19
25
|
scope :without_tag, ->(tag) {
|
|
20
|
-
|
|
21
|
-
where.not("#{table_name}.tags LIKE ?", "%\"#{sanitized_tag}\"%")
|
|
26
|
+
where.not("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
|
|
22
27
|
}
|
|
23
28
|
scope :with_tags, -> { where("#{table_name}.tags IS NOT NULL AND #{table_name}.tags != '[]'") }
|
|
24
29
|
end
|
|
@@ -173,6 +173,59 @@ module RailsPulse
|
|
|
173
173
|
[ icon, format_percentage(percentage.abs, precision) ]
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
# Compares a subject against its own history rather than against the
|
|
177
|
+
# immediately preceding window.
|
|
178
|
+
#
|
|
179
|
+
# The period-over-period arrow above answers "is this week worse than last
|
|
180
|
+
# week?", which is noisy — last week may itself have been unusual. This
|
|
181
|
+
# answers "is this worse than how this normally behaves?", which is the
|
|
182
|
+
# question the number is actually being read for.
|
|
183
|
+
#
|
|
184
|
+
# Only available where there is a single subject to have a history. Index
|
|
185
|
+
# pages aggregate across every route or query, so they keep the
|
|
186
|
+
# period-over-period arrow.
|
|
187
|
+
#
|
|
188
|
+
# @return [Array(String, String, String), nil] icon, amount, caption
|
|
189
|
+
def baseline_trend_for(subject, metric:)
|
|
190
|
+
return nil if subject.nil?
|
|
191
|
+
|
|
192
|
+
comparison = RailsPulse::Operations::Compare.call(subject, metric: metric)
|
|
193
|
+
return nil unless comparison.sufficient_data?
|
|
194
|
+
|
|
195
|
+
percentage = comparison.percent_change
|
|
196
|
+
|
|
197
|
+
icon = if percentage.abs < 0.1
|
|
198
|
+
"move-right"
|
|
199
|
+
elsif percentage.positive?
|
|
200
|
+
"trending-up"
|
|
201
|
+
else
|
|
202
|
+
"trending-down"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
[ icon, format_percentage(percentage.abs, 1), baseline_caption(comparison) ]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Names the change point only when there is a regression to explain. On a
|
|
209
|
+
# healthy metric "since Aug 26" would be noise, and locating it costs a
|
|
210
|
+
# second query that is not worth spending to say nothing.
|
|
211
|
+
def baseline_caption(comparison)
|
|
212
|
+
caption = "Compared to its #{baseline_window_text} normal"
|
|
213
|
+
return caption unless comparison.regression?
|
|
214
|
+
|
|
215
|
+
change_point = RailsPulse::Operations::ChangePoint.call(
|
|
216
|
+
comparison.subject, metric: comparison.metric
|
|
217
|
+
)
|
|
218
|
+
return caption if change_point.nil?
|
|
219
|
+
|
|
220
|
+
stamp = change_point.hourly? ? change_point.at.strftime("%b %-d %H:%M") : change_point.at.strftime("%b %-d")
|
|
221
|
+
"#{caption} · since #{stamp}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def baseline_window_text
|
|
225
|
+
days = (RailsPulse.configuration.baseline_window / 1.day).round
|
|
226
|
+
"#{days}-day"
|
|
227
|
+
end
|
|
228
|
+
|
|
176
229
|
# Existing format helpers (keep as-is)
|
|
177
230
|
def format_percentage(value, precision = 1)
|
|
178
231
|
"#{value.round(precision)}%"
|
|
@@ -11,6 +11,7 @@ module RailsPulse
|
|
|
11
11
|
@route_thresholds = RailsPulse.configuration.route_thresholds
|
|
12
12
|
@query_thresholds = RailsPulse.configuration.query_thresholds
|
|
13
13
|
@job_thresholds = RailsPulse.configuration.job_thresholds
|
|
14
|
+
@exception_thresholds = RailsPulse.configuration.exception_thresholds
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def to_health_data
|
|
@@ -18,6 +19,7 @@ module RailsPulse
|
|
|
18
19
|
routes: route_counts,
|
|
19
20
|
queries: query_counts,
|
|
20
21
|
jobs: job_counts,
|
|
22
|
+
exceptions: exception_counts,
|
|
21
23
|
storage: storage_counts
|
|
22
24
|
}
|
|
23
25
|
end
|
|
@@ -108,6 +110,56 @@ module RailsPulse
|
|
|
108
110
|
|
|
109
111
|
{ healthy: healthy, slow: slow, critical: critical }
|
|
110
112
|
end
|
|
113
|
+
|
|
114
|
+
# Exception groups classified by how often they fired over the dashboard
|
|
115
|
+
# period, read from summaries rather than from ExceptionGroup's lifetime
|
|
116
|
+
# occurrence_count — a group that fired ten thousand times last year and
|
|
117
|
+
# is now silent is not a critical problem today.
|
|
118
|
+
#
|
|
119
|
+
# A group with occurrences in the period but below the warning threshold
|
|
120
|
+
# counts as "slow" rather than "healthy": an exception happening at all is
|
|
121
|
+
# not healthy, it is just not yet urgent.
|
|
122
|
+
def exception_counts
|
|
123
|
+
return nil unless RailsPulse.configuration.track_exceptions
|
|
124
|
+
return nil unless exception_summaries_available?
|
|
125
|
+
|
|
126
|
+
start, finish = period_range
|
|
127
|
+
|
|
128
|
+
counts = RailsPulse::Summary
|
|
129
|
+
.for_exceptions
|
|
130
|
+
.where.not(summarizable_id: 0)
|
|
131
|
+
.where(period_start: start..finish)
|
|
132
|
+
.group(:summarizable_id)
|
|
133
|
+
.sum(:count)
|
|
134
|
+
|
|
135
|
+
healthy = slow = critical = 0
|
|
136
|
+
|
|
137
|
+
open_group_ids.each do |group_id|
|
|
138
|
+
occurrences = counts[group_id].to_i
|
|
139
|
+
|
|
140
|
+
if occurrences >= @exception_thresholds[:critical]
|
|
141
|
+
critical += 1
|
|
142
|
+
elsif occurrences >= @exception_thresholds[:warning]
|
|
143
|
+
slow += 1
|
|
144
|
+
elsif occurrences.positive?
|
|
145
|
+
slow += 1
|
|
146
|
+
else
|
|
147
|
+
healthy += 1
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
{ healthy: healthy, slow: slow, critical: critical }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def open_group_ids
|
|
155
|
+
RailsPulse::ExceptionGroup.where(status: "open").pluck(:id)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def exception_summaries_available?
|
|
159
|
+
RailsPulse::ExceptionGroup.table_exists?
|
|
160
|
+
rescue ActiveRecord::ActiveRecordError
|
|
161
|
+
false
|
|
162
|
+
end
|
|
111
163
|
end
|
|
112
164
|
end
|
|
113
165
|
end
|
|
@@ -11,12 +11,13 @@ module RailsPulse
|
|
|
11
11
|
@route_thresholds = RailsPulse.configuration.route_thresholds
|
|
12
12
|
@query_thresholds = RailsPulse.configuration.query_thresholds
|
|
13
13
|
@job_thresholds = RailsPulse.configuration.job_thresholds
|
|
14
|
+
@exception_thresholds = RailsPulse.configuration.exception_thresholds
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
MAX_ITEMS = 10
|
|
17
18
|
|
|
18
19
|
def to_attention_data
|
|
19
|
-
items = route_items + query_items + job_items
|
|
20
|
+
items = route_items + query_items + job_items + exception_items
|
|
20
21
|
critical = items.select { |i| i[:severity] == :critical }.sort_by { |i| -i[:sort_score] }
|
|
21
22
|
warning = items.select { |i| i[:severity] == :warning }.sort_by { |i| -i[:sort_score] }
|
|
22
23
|
|
|
@@ -247,6 +248,77 @@ module RailsPulse
|
|
|
247
248
|
end
|
|
248
249
|
end
|
|
249
250
|
|
|
251
|
+
# Exception groups that fired often enough over the period to be worth
|
|
252
|
+
# looking at. Read from summaries, not from ExceptionGroup#occurrence_count
|
|
253
|
+
# — that is a lifetime counter, so a group that was noisy last year and is
|
|
254
|
+
# silent now would otherwise be reported forever.
|
|
255
|
+
#
|
|
256
|
+
# Ignored groups are excluded by design: a user marking something ignored
|
|
257
|
+
# is saying "stop telling me about this", and the attention list is
|
|
258
|
+
# exactly where that must be honoured.
|
|
259
|
+
def exception_items
|
|
260
|
+
return [] unless RailsPulse.configuration.track_exceptions
|
|
261
|
+
return [] unless exception_tables_available?
|
|
262
|
+
|
|
263
|
+
start, finish = period_range
|
|
264
|
+
|
|
265
|
+
counts = RailsPulse::Summary
|
|
266
|
+
.for_exceptions
|
|
267
|
+
.where.not(summarizable_id: 0)
|
|
268
|
+
.where(period_start: start..finish)
|
|
269
|
+
.group(:summarizable_id)
|
|
270
|
+
.sum(:count)
|
|
271
|
+
|
|
272
|
+
return [] if counts.empty?
|
|
273
|
+
|
|
274
|
+
groups = RailsPulse::ExceptionGroup
|
|
275
|
+
.where(id: counts.keys, status: "open")
|
|
276
|
+
.index_by(&:id)
|
|
277
|
+
|
|
278
|
+
counts.filter_map do |group_id, occurrences|
|
|
279
|
+
group = groups[group_id]
|
|
280
|
+
next if group.nil?
|
|
281
|
+
|
|
282
|
+
severity = exception_severity(occurrences)
|
|
283
|
+
next if severity.nil?
|
|
284
|
+
|
|
285
|
+
{
|
|
286
|
+
type: "EXCEPTION",
|
|
287
|
+
name: group.exception_class,
|
|
288
|
+
reason: "#{occurrences} occurrence#{occurrences == 1 ? "" : "s"}#{group.location.present? ? " · #{group.location}" : ""}",
|
|
289
|
+
metric: "#{occurrences} this period",
|
|
290
|
+
metric_sub: group.last_seen_at ? "last seen #{time_ago_phrase(group.last_seen_at)}" : "open",
|
|
291
|
+
link: url_helpers.exception_path(group.id),
|
|
292
|
+
severity: severity,
|
|
293
|
+
sort_score: occurrences.to_f
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def exception_severity(occurrences)
|
|
299
|
+
return :critical if occurrences >= @exception_thresholds[:critical]
|
|
300
|
+
return :warning if occurrences >= @exception_thresholds[:warning]
|
|
301
|
+
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def time_ago_phrase(time)
|
|
306
|
+
seconds = (Time.current - time).to_i
|
|
307
|
+
return "just now" if seconds < 60
|
|
308
|
+
return "#{seconds / 60}m ago" if seconds < 3600
|
|
309
|
+
return "#{seconds / 3600}h ago" if seconds < 86_400
|
|
310
|
+
|
|
311
|
+
"#{seconds / 86_400}d ago"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# The exception tables arrive in a migration, so a host that has upgraded
|
|
315
|
+
# the gem but not yet run migrations must still get a working dashboard.
|
|
316
|
+
def exception_tables_available?
|
|
317
|
+
RailsPulse::ExceptionGroup.table_exists?
|
|
318
|
+
rescue ActiveRecord::ActiveRecordError
|
|
319
|
+
false
|
|
320
|
+
end
|
|
321
|
+
|
|
250
322
|
def truncate_sql(sql)
|
|
251
323
|
return "" if sql.blank?
|
|
252
324
|
cleaned = sql.gsub(/\s+/, " ").strip
|