railwatch 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +212 -0
- data/README.md +10 -0
- data/app/channels/railwatch/environment_channel.rb +28 -0
- data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
- data/app/controllers/railwatch/alerts_controller.rb +48 -0
- data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
- data/app/controllers/railwatch/attachments_controller.rb +22 -0
- data/app/controllers/railwatch/beacon_controller.rb +67 -7
- data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
- data/app/controllers/railwatch/cache_events_controller.rb +30 -0
- data/app/controllers/railwatch/commands_controller.rb +16 -0
- data/app/controllers/railwatch/comments_controller.rb +11 -0
- data/app/controllers/railwatch/dashboard_controller.rb +69 -0
- data/app/controllers/railwatch/deploys_controller.rb +34 -0
- data/app/controllers/railwatch/deprecations_controller.rb +54 -0
- data/app/controllers/railwatch/environment_scoped.rb +99 -0
- data/app/controllers/railwatch/exceptions_controller.rb +53 -0
- data/app/controllers/railwatch/executions_controller.rb +13 -0
- data/app/controllers/railwatch/issues_controller.rb +258 -0
- data/app/controllers/railwatch/jobs_controller.rb +63 -0
- data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
- data/app/controllers/railwatch/logs_controller.rb +40 -0
- data/app/controllers/railwatch/mails_controller.rb +19 -0
- data/app/controllers/railwatch/notifications_controller.rb +11 -0
- data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
- data/app/controllers/railwatch/overview_controller.rb +38 -0
- data/app/controllers/railwatch/people_controller.rb +25 -0
- data/app/controllers/railwatch/processes_controller.rb +33 -0
- data/app/controllers/railwatch/profiles_controller.rb +50 -0
- data/app/controllers/railwatch/queries_controller.rb +59 -0
- data/app/controllers/railwatch/releases_controller.rb +75 -0
- data/app/controllers/railwatch/requests_controller.rb +55 -0
- data/app/controllers/railwatch/saved_views_controller.rb +53 -0
- data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
- data/app/controllers/railwatch/spans_controller.rb +47 -0
- data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
- data/app/controllers/railwatch/tenants_controller.rb +44 -0
- data/app/controllers/railwatch/thresholds_controller.rb +40 -0
- data/app/controllers/railwatch/traces_controller.rb +72 -0
- data/app/controllers/railwatch/transactions_controller.rb +21 -0
- data/app/controllers/railwatch/view_renders_controller.rb +28 -0
- data/app/controllers/railwatch/visits_controller.rb +53 -0
- data/app/helpers/railwatch/assets_helper.rb +52 -0
- data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
- data/app/jobs/railwatch/application_job.rb +7 -0
- data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
- data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
- data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
- data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
- data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
- data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
- data/app/jobs/railwatch/performance_scan_job.rb +11 -0
- data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
- data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
- data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
- data/app/jobs/railwatch/rollup_job.rb +130 -0
- data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
- data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
- data/app/models/railwatch/alert.rb +229 -0
- data/app/models/railwatch/alert_rule.rb +121 -0
- data/app/models/railwatch/anomaly_rule.rb +33 -0
- data/app/models/railwatch/application.rb +44 -0
- data/app/models/railwatch/application_record.rb +33 -0
- data/app/models/railwatch/comment.rb +36 -0
- data/app/models/railwatch/deploy.rb +67 -0
- data/app/models/railwatch/environment.rb +54 -0
- data/app/models/railwatch/execution_presenter.rb +185 -0
- data/app/models/railwatch/filter_query.rb +143 -0
- data/app/models/railwatch/followup_receipt.rb +27 -0
- data/app/models/railwatch/ingest/batch.rb +305 -0
- data/app/models/railwatch/ingest/mapper.rb +575 -0
- data/app/models/railwatch/ingest/payload.rb +96 -0
- data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
- data/app/models/railwatch/ingest/writer.rb +137 -0
- data/app/models/railwatch/issue.rb +277 -0
- data/app/models/railwatch/issue_activity.rb +23 -0
- data/app/models/railwatch/issue_detection_presenter.rb +309 -0
- data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
- data/app/models/railwatch/maintenance_task.rb +53 -0
- data/app/models/railwatch/saved_view.rb +63 -0
- data/app/models/railwatch/telemetry/aggregations.rb +116 -0
- data/app/models/railwatch/telemetry/attachment.rb +31 -0
- data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
- data/app/models/railwatch/telemetry/broadcast.rb +13 -0
- data/app/models/railwatch/telemetry/cache_event.rb +16 -0
- data/app/models/railwatch/telemetry/child.rb +53 -0
- data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
- data/app/models/railwatch/telemetry/deprecation.rb +13 -0
- data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
- data/app/models/railwatch/telemetry/exception.rb +21 -0
- data/app/models/railwatch/telemetry/execution.rb +71 -0
- data/app/models/railwatch/telemetry/health_sample.rb +72 -0
- data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
- data/app/models/railwatch/telemetry/llm_call.rb +37 -0
- data/app/models/railwatch/telemetry/log.rb +85 -0
- data/app/models/railwatch/telemetry/mail.rb +13 -0
- data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
- data/app/models/railwatch/telemetry/notification.rb +13 -0
- data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
- data/app/models/railwatch/telemetry/person.rb +50 -0
- data/app/models/railwatch/telemetry/process.rb +10 -0
- data/app/models/railwatch/telemetry/profile.rb +37 -0
- data/app/models/railwatch/telemetry/query.rb +50 -0
- data/app/models/railwatch/telemetry/query_shape.rb +45 -0
- data/app/models/railwatch/telemetry/release_health.rb +63 -0
- data/app/models/railwatch/telemetry/rollup.rb +78 -0
- data/app/models/railwatch/telemetry/session.rb +24 -0
- data/app/models/railwatch/telemetry/span.rb +19 -0
- data/app/models/railwatch/telemetry/storage_op.rb +13 -0
- data/app/models/railwatch/telemetry/tenant.rb +221 -0
- data/app/models/railwatch/telemetry/transaction.rb +13 -0
- data/app/models/railwatch/telemetry/view_render.rb +13 -0
- data/app/models/railwatch/telemetry/visit.rb +24 -0
- data/app/models/railwatch/telemetry_record.rb +57 -0
- data/app/models/railwatch/threshold.rb +29 -0
- data/app/models/railwatch/user.rb +47 -0
- data/app/models/railwatch/viewer.rb +13 -0
- data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
- data/config/routes.rb +59 -0
- data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
- data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
- data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
- data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +71 -0
- data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
- data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
- data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
- data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
- data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
- data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
- data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
- data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
- data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
- data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
- data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
- data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
- data/docs/configuration.md +26 -0
- data/docs/embedded.md +352 -0
- data/docs/getting-started.md +5 -0
- data/lib/generators/railwatch/install/install_generator.rb +222 -1
- data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
- data/lib/generators/railwatch/install/templates/post-deploy +8 -0
- data/lib/puma/plugin/railwatch.rb +170 -0
- data/lib/railwatch/authentication.rb +83 -0
- data/lib/railwatch/configuration.rb +134 -3
- data/lib/railwatch/dashboard_assets.rb +45 -0
- data/lib/railwatch/embedded.rb +54 -0
- data/lib/railwatch/engine.rb +89 -1
- data/lib/railwatch/ingest_request_body_limit.rb +10 -0
- data/lib/railwatch/json_compat.rb +60 -0
- data/lib/railwatch/maintenance.rb +183 -0
- data/lib/railwatch/patches/runner_command.rb +21 -1
- data/lib/railwatch/record.rb +33 -8
- data/lib/railwatch/reporter.rb +16 -0
- data/lib/railwatch/subscribers/process_info.rb +2 -1
- data/lib/railwatch/transport/local.rb +78 -0
- data/lib/railwatch/transport/socket.rb +183 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/writer.rb +370 -0
- data/lib/railwatch.rb +38 -1
- data/lib/tasks/railwatch_tasks.rake +128 -0
- data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
- data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
- data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
- data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
- data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
- data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
- data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
- data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
- data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
- data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
- data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
- data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
- data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
- data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
- data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
- data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
- data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
- data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
- data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
- data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
- data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
- data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
- data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
- data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
- data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
- data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
- data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
- data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
- data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
- data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
- data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
- data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
- data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
- data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
- data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
- data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
- data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
- data/public/railwatch/assets/index-BBchRy0M.js +1 -0
- data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
- data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
- data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
- data/public/railwatch/assets/index-BprKx8QO.js +1 -0
- data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
- data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
- data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
- data/public/railwatch/assets/index-CeYKnapu.js +1 -0
- data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
- data/public/railwatch/assets/index-Cwx6058d.js +1 -0
- data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
- data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
- data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
- data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
- data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
- data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
- data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
- data/public/railwatch/assets/index-DeOe83F4.js +1 -0
- data/public/railwatch/assets/index-DiucHN4B.js +1 -0
- data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
- data/public/railwatch/assets/index-DlumCsWY.js +2 -0
- data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
- data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
- data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
- data/public/railwatch/assets/index-OqI59zPb.js +1 -0
- data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
- data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
- data/public/railwatch/assets/index-oVkururr.js +1 -0
- data/public/railwatch/assets/index-p9puqVge.js +1 -0
- data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
- data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
- data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
- data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
- data/public/railwatch/assets/label-COUKWqE_.js +1 -0
- data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
- data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
- data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
- data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
- data/public/railwatch/assets/new-Be55nmt9.js +1 -0
- data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
- data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
- data/public/railwatch/assets/new-D05SajFR.js +1 -0
- data/public/railwatch/assets/new-D4uewYC8.js +1 -0
- data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
- data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
- data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
- data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
- data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
- data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
- data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
- data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
- data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
- data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
- data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
- data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
- data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
- data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
- data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
- data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
- data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
- data/public/railwatch/assets/show-C8sHalnw.js +1 -0
- data/public/railwatch/assets/show-CeTL4B37.js +2 -0
- data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
- data/public/railwatch/assets/show-DACku6AD.js +3 -0
- data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
- data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
- data/public/railwatch/assets/show-DVNz46RI.js +1 -0
- data/public/railwatch/assets/show-DYteoYWW.js +1 -0
- data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
- data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
- data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
- data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
- data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
- data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
- data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
- data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
- data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
- data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
- data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
- data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
- data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
- data/public/railwatch/icon.png +0 -0
- data/public/railwatch/icon.svg +5 -0
- data/public/railwatch/manifest.json +2171 -0
- data/public/railwatch/rails-vite.json +1 -0
- metadata +314 -4
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Base for every dashboard page: the prebuilt Inertia bundle, the shared
|
|
5
|
+
# props its layouts read, and per-controller Inertia config so a host that
|
|
6
|
+
# also uses Inertia keeps its own.
|
|
7
|
+
#
|
|
8
|
+
# Inherits from Railwatch.config.base_controller_class (default
|
|
9
|
+
# ActionController::Base) so a host can put its own admin gate in front of
|
|
10
|
+
# every page the way Mission Control Jobs allows: point it at a controller
|
|
11
|
+
# whose before_action requires an admin, and turn HTTP Basic off.
|
|
12
|
+
class DashboardController < Railwatch.config.base_controller_class.constantize
|
|
13
|
+
include Railwatch::AssetsHelper
|
|
14
|
+
helper Railwatch::AssetsHelper
|
|
15
|
+
include Railwatch::EnvironmentScoped
|
|
16
|
+
|
|
17
|
+
layout "railwatch/dashboard"
|
|
18
|
+
# The bundle's pages are named after the platform's controllers
|
|
19
|
+
# (overview/show, requests/index); strip the engine namespace so
|
|
20
|
+
# default_render finds them.
|
|
21
|
+
inertia_config version: -> { Railwatch::AssetsHelper.digest }, layout: "railwatch/dashboard",
|
|
22
|
+
use_script_element_for_initial_page: true, always_include_errors_hash: true, default_render: true,
|
|
23
|
+
component_path_resolver: ->(path:, action:) { "#{path.delete_prefix('railwatch/')}/#{action}" }
|
|
24
|
+
rescue_from Telemetry::CursorPage::InvalidCursor do |exception|
|
|
25
|
+
render plain: exception.message, status: :unprocessable_content
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# HTTP Basic, on and closed by default (Configuration#http_basic_auth_*).
|
|
29
|
+
# Runs before anything reads telemetry; a host that authenticates in its
|
|
30
|
+
# base controller or a routes constraint turns it off.
|
|
31
|
+
before_action :authenticate_by_http_basic
|
|
32
|
+
before_action { Viewer.user = Railwatch.config.resolve_dashboard_user(request) }
|
|
33
|
+
|
|
34
|
+
inertia_share auth: -> { { user: Viewer.user.as_json, session: { id: "embedded", recently_authenticated: true } } },
|
|
35
|
+
account: -> { Railwatch::Embedded::Account.as_json },
|
|
36
|
+
accounts: -> { [ { id: 1, name: Railwatch::Embedded::Account.name } ] },
|
|
37
|
+
applications: -> { [ { id: 1, name: Application.current.name, slug: Application.current.slug,
|
|
38
|
+
issue_prefix: Application.current.issue_prefix,
|
|
39
|
+
environments: [ { id: 1, name: Environment.current.name, slug: Environment.current.slug,
|
|
40
|
+
last_seen_at: Environment.current.last_seen_at, paused: false } ] } ] },
|
|
41
|
+
flash: -> { { alert: flash.alert, warning: flash[:warning], notice: flash.notice } },
|
|
42
|
+
google_oauth: false,
|
|
43
|
+
embedded: true
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def authenticate_by_http_basic
|
|
48
|
+
config = Railwatch.config
|
|
49
|
+
return unless config.http_basic_auth_enabled
|
|
50
|
+
|
|
51
|
+
if config.http_basic_auth_configured?
|
|
52
|
+
http_basic_authenticate_or_request_with(name: config.http_basic_auth_user, password: config.http_basic_auth_password,
|
|
53
|
+
realm: "Railwatch")
|
|
54
|
+
else
|
|
55
|
+
# Closed, not open: same as Mission Control with no credentials. The
|
|
56
|
+
# challenge header is still sent so a client or a monitor sees the
|
|
57
|
+
# scheme, and the body says what to do, since a bare 401 looks like a
|
|
58
|
+
# broken install rather than an unconfigured one.
|
|
59
|
+
response.set_header("WWW-Authenticate", %(Basic realm="Railwatch"))
|
|
60
|
+
render plain: "Railwatch: HTTP Basic authentication is on and no credentials are configured, so the " \
|
|
61
|
+
"dashboard is closed. Run `bin/rails railwatch:authentication:configure` (writes " \
|
|
62
|
+
"railwatch.http_basic_auth_user/_password to Rails credentials), or set " \
|
|
63
|
+
"RAILWATCH_HTTP_BASIC_AUTH_USER and _PASSWORD, or turn Basic off " \
|
|
64
|
+
"(c.http_basic_auth_enabled = false) once your own auth gates the mount. See docs/embedded.md.",
|
|
65
|
+
status: :unauthorized
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class DeploysController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
render inertia: { deploys: environment.deploys.recent.limit(100).map { |d| deploy_row(d) } }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Before/after comparison: the hour before the deploy versus the hour after.
|
|
10
|
+
def show
|
|
11
|
+
deploy = environment.deploys.find(params[:id])
|
|
12
|
+
before = [ deploy.deployed_at - 1.hour, deploy.deployed_at ]
|
|
13
|
+
after = [ deploy.deployed_at, deploy.deployed_at + 1.hour ]
|
|
14
|
+
compare, health = telemetry do
|
|
15
|
+
[ %w[request job_attempt].to_h do |type|
|
|
16
|
+
[ type, { before: Telemetry::Rollup.summarize(Telemetry::Rollup.for_type(type).where(bucket: before.first.beginning_of_hour..before.last)),
|
|
17
|
+
after: Telemetry::Rollup.summarize(Telemetry::Rollup.for_type(type).where(bucket: after.first.beginning_of_hour..after.last)) } ]
|
|
18
|
+
end, Telemetry::ReleaseHealth.for_deploy(deploy.deploy, *deploy.window) ]
|
|
19
|
+
end
|
|
20
|
+
new_issues = environment.issues.where(first_seen_at: after.first..after.last + 23.hours).recent.map { |i| { id: i.id, key: i.key, title: i.title, status: i.status } }
|
|
21
|
+
resolved = environment.issues.where(resolved_in_deploy: deploy.deploy).map { |i| { id: i.id, key: i.key, title: i.title } }
|
|
22
|
+
render inertia: { deploy: deploy_row(deploy).merge(commits: deploy.commits, previous_ref: deploy.previous_ref,
|
|
23
|
+
repository_ref: deploy.ref, detail: deploy.detail),
|
|
24
|
+
compare: compare, health: health, new_issues: new_issues, resolved_issues: resolved }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def deploy_row(d)
|
|
30
|
+
{ id: d.id, deploy: d.deploy, ref: d.short_ref, name: d.name, url: d.url, server: d.server, deployed_at: d.deployed_at,
|
|
31
|
+
commits_count: d.commits_count, performer: d.detail["performer"] }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class DeprecationsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
parsed = FilterQuery.parse(params[:q])
|
|
8
|
+
fields = parsed[:fields]
|
|
9
|
+
data = telemetry do
|
|
10
|
+
scope = Telemetry::Deprecation.between(from, to)
|
|
11
|
+
scope = scope.where(gem_name: fields["gem"]) if fields["gem"].present?
|
|
12
|
+
scope = scope.where("message LIKE ?", "%#{Telemetry::Deprecation.sanitize_sql_like(parsed[:text])}%") if parsed[:text].present?
|
|
13
|
+
rows = scope.to_a
|
|
14
|
+
hashes = rows.map(&:group_hash).uniq
|
|
15
|
+
first_ever = Telemetry::Deprecation.where(group_hash: hashes).group(:group_hash).minimum(:occurred_at)
|
|
16
|
+
groups = rows.group_by(&:group_hash)
|
|
17
|
+
.map { |group_hash, group_rows| deprecation_row(group_hash, group_rows, first_ever[group_hash], from, to) }
|
|
18
|
+
.sort_by { |r| -r[:count] }
|
|
19
|
+
{ groups: groups.first(200), distinct: groups.size, total_occurrences: groups.sum { |g| g[:count] },
|
|
20
|
+
new_this_window: groups.count { |g| g[:first_seen_at] && g[:first_seen_at] >= from } }
|
|
21
|
+
end
|
|
22
|
+
render inertia: { deprecations: data[:groups], distinct: data[:distinct], total_occurrences: data[:total_occurrences],
|
|
23
|
+
new_this_window: data[:new_this_window], q: params[:q].to_s }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def deprecation_row(group_hash, group_rows, first_seen_at, from, to)
|
|
29
|
+
sorted = group_rows.sort_by(&:occurred_at)
|
|
30
|
+
{ group_hash: group_hash, message: sorted.last.message, gem_name: sorted.last.gem_name, horizon: sorted.last.horizon,
|
|
31
|
+
source: sorted.last.source, count: group_rows.size, first_seen_at: first_seen_at, last_seen_at: sorted.last.occurred_at,
|
|
32
|
+
sparkline: hourly_sparkline(group_rows, from, to), occurrences: sorted.reverse.first(20).map { |r| occurrence_row(r) } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def occurrence_row(r)
|
|
36
|
+
{ id: r.id, occurred_at: r.occurred_at, execution_id: r.execution_id, execution_source: r.execution_source,
|
|
37
|
+
execution_preview: r.execution_preview }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Hourly counts for raw (non-rollup) rows, zero-filled and coarsened to at
|
|
41
|
+
# most 30 points for wide windows -- deprecations have no duration column
|
|
42
|
+
# so they never land in Telemetry::Rollup and can't use
|
|
43
|
+
# EnvironmentScoped#sparkline, which keys off a rollup row's bucket/count.
|
|
44
|
+
def hourly_sparkline(rows, from, to)
|
|
45
|
+
hours = [ ((to - from) / 1.hour).ceil, 1 ].max
|
|
46
|
+
coarsen = (hours / 30.0).ceil
|
|
47
|
+
base = from.beginning_of_hour
|
|
48
|
+
buckets = Hash.new(0)
|
|
49
|
+
rows.each { |r| buckets[((r.occurred_at - base) / 1.hour).to_i / coarsen] += 1 }
|
|
50
|
+
bucket_count = (hours.to_f / coarsen).ceil
|
|
51
|
+
(0...bucket_count).map { |i| buckets[i] }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# The platform's EnvironmentScoped concern with the account lookup replaced
|
|
5
|
+
# by the embedded singleton. Everything below the seam is the platform's code.
|
|
6
|
+
module EnvironmentScoped
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
WINDOWS = { "1h" => 1.hour, "6h" => 6.hours, "24h" => 24.hours, "7d" => 7.days, "30d" => 30.days }.freeze
|
|
10
|
+
MAX_CUSTOM_RANGE = 90.days
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
before_action :set_environment
|
|
14
|
+
inertia_share environment: -> { environment_props }
|
|
15
|
+
inertia_share window: -> { window_key }
|
|
16
|
+
inertia_share range: -> { from, to = window_range; { from: from.iso8601(6), to: to.iso8601(6) } }
|
|
17
|
+
inertia_share saved_views: -> { SavedView.props_for(environment, Viewer.user) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :environment
|
|
23
|
+
|
|
24
|
+
def set_environment
|
|
25
|
+
@environment = Environment.find(params[:environment_id] || Environment::ID)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def window_key
|
|
29
|
+
custom_range ? "custom" : (WINDOWS.key?(params[:window].to_s) ? params[:window].to_s : "24h")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def window_range
|
|
33
|
+
return @window_range if defined?(@window_range)
|
|
34
|
+
@window_range = custom_range || begin
|
|
35
|
+
key = WINDOWS.key?(params[:window].to_s) ? params[:window].to_s : "24h"
|
|
36
|
+
to = Time.current
|
|
37
|
+
[ to - WINDOWS[key], to ]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def custom_range
|
|
42
|
+
return @custom_range if defined?(@custom_range)
|
|
43
|
+
from = parse_time(params[:from])
|
|
44
|
+
to = from && (parse_time(params[:to]) || Time.current)
|
|
45
|
+
valid = from && to && to > from && (to - from) <= MAX_CUSTOM_RANGE
|
|
46
|
+
@custom_range = valid ? [ from, to ] : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_time(value)
|
|
50
|
+
return nil if value.blank?
|
|
51
|
+
Time.zone.parse(value.to_s)
|
|
52
|
+
rescue ArgumentError, TypeError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def previous_window_range
|
|
57
|
+
from, to = window_range
|
|
58
|
+
span = to - from
|
|
59
|
+
[ from - span, from ]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def telemetry(&block) = environment.with_telemetry(&block)
|
|
63
|
+
|
|
64
|
+
def telemetry_cursor_context(resource)
|
|
65
|
+
from, to = window_range
|
|
66
|
+
FilterQuery.cursor_context(environment_id: environment.id, resource: resource, query: params[:q], window: [ from.iso8601(6), to.iso8601(6) ])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def application = Application.current
|
|
70
|
+
|
|
71
|
+
def environment_props
|
|
72
|
+
{ id: environment.id, name: environment.name, slug: environment.slug, application_id: application.id,
|
|
73
|
+
application_name: application.name, issue_prefix: application.issue_prefix, last_seen_at: environment.last_seen_at,
|
|
74
|
+
paused: false, token_prefix: environment.token_prefix,
|
|
75
|
+
repository_url: application.repository_url, default_branch: application.default_branch }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def deploys_in_window
|
|
79
|
+
environment.deploys.between(*window_range).recent.limit(50).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def series(record_type, group_hash: nil, name: nil)
|
|
83
|
+
from, to = window_range
|
|
84
|
+
Telemetry::Aggregations.series(environment, record_type, from: from, to: to, group_hash: group_hash, name: name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def grouped(record_type, limit: 100, order: nil, dir: nil)
|
|
88
|
+
from, to = window_range
|
|
89
|
+
Telemetry::Aggregations.grouped(environment, record_type, from: from, to: to, limit: limit, order: order, dir: dir)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def summary_with_delta(record_type, group_hash: nil)
|
|
93
|
+
from, to = window_range
|
|
94
|
+
previous_from, previous_to = previous_window_range
|
|
95
|
+
Telemetry::Aggregations.summary_with_delta(environment, record_type, from: from, to: to,
|
|
96
|
+
previous_from: previous_from, previous_to: previous_to, group_hash: group_hash)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class ExceptionsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
page_data = nil
|
|
8
|
+
page = lambda do
|
|
9
|
+
page_data ||= telemetry do
|
|
10
|
+
scope = FilterQuery.apply(Telemetry::Exception.all, resource: :exceptions, query: params[:q], from: from, to: to)
|
|
11
|
+
recent, pagination = Telemetry::CursorPage.call(scope, cursor: params[:cursor], limit: params[:limit],
|
|
12
|
+
context: telemetry_cursor_context(:exceptions))
|
|
13
|
+
issues_by_group = environment.issues.where(group_hash: recent.map(&:group_hash).uniq).index_by(&:group_hash)
|
|
14
|
+
[ recent.map { |e| exception_row(e, issues_by_group) }, pagination ]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
render inertia: {
|
|
18
|
+
exceptions: InertiaRails.merge { page.call.first }, pagination: -> { page.call.last },
|
|
19
|
+
classes: -> { telemetry { Telemetry::Exception.between(from, to).group(:class_name).count.sort_by { |_class, n| -n }.first(15) } },
|
|
20
|
+
series: -> { telemetry { exception_series(from, to) } },
|
|
21
|
+
total: -> { telemetry { Telemetry::Exception.between(from, to).count } },
|
|
22
|
+
unhandled: -> { telemetry { Telemetry::Exception.between(from, to).unhandled.count } }, q: params[:q].to_s
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def exception_row(e, issues_by_group)
|
|
29
|
+
issue = issues_by_group[e.group_hash]
|
|
30
|
+
{ id: e.id, class_name: e.class_name, message: e.message.first(500), handled: e.handled, severity: e.severity, source: e.source,
|
|
31
|
+
file: e.file, line: e.line, occurred_at: e.occurred_at, execution_id: e.execution_id, execution_source: e.execution_source,
|
|
32
|
+
execution_preview: e.execution_preview, user_ref: e.user_ref, group_hash: e.group_hash, issue_id: issue&.id, issue_key: issue&.key }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Telemetry::Rollup has no "exception" record type (exceptions carry no
|
|
36
|
+
# duration to absorb), so the chart is a plain bucketed count over the raw
|
|
37
|
+
# table instead of the usual EnvironmentScoped#series.
|
|
38
|
+
# `errors` carries the unhandled count so the chart legend (handled /
|
|
39
|
+
# unhandled) agrees with the Unhandled card above the table.
|
|
40
|
+
def exception_series(from, to)
|
|
41
|
+
buckets = Hash.new { |h, k| h[k] = { count: 0, errors: 0 } }
|
|
42
|
+
Telemetry::Exception.between(from, to)
|
|
43
|
+
.group(Arel.sql("strftime('%Y-%m-%dT%H:00:00Z', occurred_at)"), :handled)
|
|
44
|
+
.count
|
|
45
|
+
.each do |(bucket, handled), c|
|
|
46
|
+
buckets[bucket][:count] += c
|
|
47
|
+
buckets[bucket][:errors] += c unless handled
|
|
48
|
+
end
|
|
49
|
+
buckets.map { |bucket, b| { t: bucket, count: b[:count], errors: b[:errors], client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0 } }
|
|
50
|
+
.sort_by { |r| r[:t] }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generic detail route for execution kinds that do not have a dedicated index
|
|
4
|
+
# yet (for example Action Cable channel actions), and for legacy child records
|
|
5
|
+
# whose execution_source was absent.
|
|
6
|
+
module Railwatch
|
|
7
|
+
class ExecutionsController < DashboardController
|
|
8
|
+
def show
|
|
9
|
+
execution = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
|
|
10
|
+
render inertia: "executions/show", props: ExecutionPresenter.new(execution, environment).props
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class IssuesController < DashboardController
|
|
5
|
+
BULK_LIMIT = 200
|
|
6
|
+
|
|
7
|
+
before_action :set_issue, only: %i[show update merge_candidates]
|
|
8
|
+
rescue_from Issue::InvalidMerge, with: :invalid_merge
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
scope = Issue.all
|
|
12
|
+
scope = scope.where(status: params[:status]) if Issue::STATUSES.include?(params[:status].to_s)
|
|
13
|
+
scope = scope.where(status: "open") unless params.key?(:status)
|
|
14
|
+
scope = scope.where(application_id: params[:application_id]) if params[:application_id].present?
|
|
15
|
+
scope = scope.where(environment_id: params[:environment_id]) if params[:environment_id].present?
|
|
16
|
+
scope = scope.where(kind: params[:kind]) if Issue::KINDS.include?(params[:kind].to_s)
|
|
17
|
+
scope = scope.where(assignee_id: Viewer.user.id) if params[:mine] == "1"
|
|
18
|
+
# Same "key:value free text" grammar as the other list pages: today the
|
|
19
|
+
# only key is source, which is how you pull out the browser's own errors.
|
|
20
|
+
parsed = FilterQuery.parse(params[:q])
|
|
21
|
+
scope = scope.where(source: parsed[:fields]["source"]) if parsed[:fields]["source"].present?
|
|
22
|
+
scope = scope.where("title LIKE ?", "%#{Issue.sanitize_sql_like(parsed[:text])}%") if parsed[:text].present?
|
|
23
|
+
render inertia: { issues: scope.recent.limit(200).map { |i| row(i) },
|
|
24
|
+
filters: params.permit(:status, :application_id, :environment_id, :kind, :mine, :q).to_h,
|
|
25
|
+
counts: Issue.all.group(:status).count,
|
|
26
|
+
kindCounts: Issue.all.open.group(:kind).count,
|
|
27
|
+
members: User.default.then { |u| [ { id: u.id, name: u.name } ] } }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def show
|
|
31
|
+
env = @issue.environment
|
|
32
|
+
return render_detection_issue(env) unless @issue.kind == "exception"
|
|
33
|
+
|
|
34
|
+
from = 30.days.ago
|
|
35
|
+
occurrences = env.with_telemetry do
|
|
36
|
+
Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).recent.limit(50)
|
|
37
|
+
.map { |e| { id: e.id, message: e.message.first(300), handled: e.handled, occurred_at: e.occurred_at, execution_id: e.execution_id, execution_source: e.execution_source, execution_preview: e.execution_preview, user_ref: e.user_ref, tenant: e.app_tenant, deploy: e.deploy, server: e.server } }
|
|
38
|
+
end
|
|
39
|
+
latest = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).recent.first }
|
|
40
|
+
browser_breadcrumbs = browser_breadcrumbs_for(latest)
|
|
41
|
+
breadcrumbs = latest&.execution_id ? env.with_telemetry { breadcrumbs_for(latest) } : []
|
|
42
|
+
daily_counts = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).group(Arel.sql("date(occurred_at)")).count }.transform_keys(&:to_s)
|
|
43
|
+
daily = (from.to_date..Date.current).map { |d| { day: d.to_s, count: daily_counts[d.to_s].to_i } }
|
|
44
|
+
by_deploy = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).group(:deploy).count }
|
|
45
|
+
by_tenant = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).where.not(app_tenant: nil).group(:app_tenant).count }
|
|
46
|
+
attachments = env.with_telemetry { attachments_for_issue }
|
|
47
|
+
# What a split by message would produce, and what the fingerprint that
|
|
48
|
+
# grouped these occurrences was -- both drive the Grouping panel.
|
|
49
|
+
messages = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).recent.limit(Issue::SPLIT_SAMPLE).pluck(:message) }.tally
|
|
50
|
+
render inertia: {
|
|
51
|
+
issue: row(@issue).merge(first_seen_at: @issue.first_seen_at, resolved_at: @issue.resolved_at, resolved_in_deploy: @issue.resolved_in_deploy, regressed_at: @issue.regressed_at, sample: @issue.sample),
|
|
52
|
+
detection: nil,
|
|
53
|
+
latest: latest && { id: latest.id, class_name: latest.class_name, message: latest.message, handled: latest.handled, severity: latest.severity, source: latest.source, file: latest.file, line: latest.line, frames: latest.frames, cause: latest.cause, locals: latest.locals, context: context_without_breadcrumbs(latest), ruby_version: latest.ruby_version, rails_version: latest.rails_version, execution_id: latest.execution_id, execution_source: latest.execution_source, execution_preview: latest.execution_preview, deploy: latest.deploy },
|
|
54
|
+
breadcrumbs: breadcrumbs, browser_breadcrumbs: browser_breadcrumbs,
|
|
55
|
+
fingerprint: (latest&.fingerprint).presence || Array(@issue.sample["fingerprint"]),
|
|
56
|
+
fingerprint_source: latest&.fingerprint_source || @issue.sample["fingerprint_source"],
|
|
57
|
+
distinct_messages: messages.size,
|
|
58
|
+
top_messages: messages.sort_by { |_, n| -n }.first(5).map { |message, n| { message: message.to_s.first(200), count: n } },
|
|
59
|
+
occurrences: occurrences, daily: daily, by_deploy: by_deploy, by_tenant: by_tenant, attachments: attachments,
|
|
60
|
+
comments: @issue.comments.order(:created_at).map { |c| { id: c.id, body: c.body, user: c.user&.name || c.author_name || "Linear", source: c.source, created_at: c.created_at } },
|
|
61
|
+
activities: @issue.activities.order(:created_at).map { |a| activity_row(a) },
|
|
62
|
+
members: User.default.then { |u| [ { id: u.id, name: u.name } ] },
|
|
63
|
+
deploys: env.deploys.recent.limit(20).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } },
|
|
64
|
+
related_issues: related_issues,
|
|
65
|
+
merged_into: @issue.merged_into && { id: @issue.merged_into.id, key: @issue.merged_into.key, title: @issue.merged_into.title },
|
|
66
|
+
merged_issues: @issue.merged_issues.map { |i| { id: i.id, key: i.key, title: i.title } },
|
|
67
|
+
alerts: @issue.alerts.order(created_at: :desc).map { |a| alert_row(a) }
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def merge_candidates
|
|
72
|
+
query = params[:q].to_s.strip.first(200)
|
|
73
|
+
scope = @issue.environment.issues.open.where.not(id: @issue.id)
|
|
74
|
+
if query.present?
|
|
75
|
+
pattern = "%#{Issue.sanitize_sql_like(query)}%"
|
|
76
|
+
key_match = query.upcase.match(/\A#{Regexp.escape(@issue.application.issue_prefix)}-(\d+)\z/)
|
|
77
|
+
scope = if key_match
|
|
78
|
+
scope.where("title LIKE :pattern OR number = :number", pattern: pattern, number: key_match[1].to_i)
|
|
79
|
+
else
|
|
80
|
+
scope.where("title LIKE ?", pattern)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
render json: { issues: scope.order(last_seen_at: :desc, id: :desc).limit(20)
|
|
85
|
+
.map { |issue| { id: issue.id, key: issue.key, title: issue.title } } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def update
|
|
89
|
+
notice = "Issue updated"
|
|
90
|
+
case params[:action_name]
|
|
91
|
+
when "resolve" then @issue.resolve!(deploy: params[:deploy].presence || @issue.environment.deploys.recent.first&.deploy)
|
|
92
|
+
when "ignore" then @issue.ignore!
|
|
93
|
+
when "reopen" then @issue.reopen!
|
|
94
|
+
when "assign" then @issue.update!(assignee: assignee_from(params[:assignee_id]))
|
|
95
|
+
when "assign_me" then @issue.update!(assignee_id: Viewer.user.id)
|
|
96
|
+
when "priority" then @issue.update!(priority: params[:priority])
|
|
97
|
+
when "merge" then @issue.merge_into!(@issue.environment.issues.open.find(params[:target_id]))
|
|
98
|
+
when "unmerge" then @issue.unmerge!
|
|
99
|
+
when "split" then notice = split_notice(@issue.split!(by: params[:by].to_s))
|
|
100
|
+
else @issue.update!(params.permit(:title))
|
|
101
|
+
end
|
|
102
|
+
redirect_to issue_path(@issue), notice: notice
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def bulk
|
|
106
|
+
ids = Array(params[:ids]).first(BULK_LIMIT)
|
|
107
|
+
scope = Issue.all.where(id: ids)
|
|
108
|
+
if params[:action_name] == "merge"
|
|
109
|
+
bulk_merge(scope)
|
|
110
|
+
else
|
|
111
|
+
scope.find_each do |issue|
|
|
112
|
+
case params[:action_name]
|
|
113
|
+
when "resolve" then issue.resolve!(deploy: issue.environment.deploys.recent.first&.deploy)
|
|
114
|
+
when "ignore" then issue.ignore!
|
|
115
|
+
when "priority" then issue.update!(priority: params[:value])
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
redirect_back fallback_location: issues_path, notice: "#{ids.size} issue(s) updated"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# A stale selection can contain issues that have already been merged since
|
|
125
|
+
# the list rendered. Ignore those sources, choose an open target, and wrap
|
|
126
|
+
# the whole operation so a concurrent state change cannot partially fold a
|
|
127
|
+
# bulk selection before the model rejects it.
|
|
128
|
+
def bulk_merge(scope)
|
|
129
|
+
issues = scope.order(:first_seen_at, :id).to_a
|
|
130
|
+
target = issues.find(&:open?)
|
|
131
|
+
return unless target
|
|
132
|
+
|
|
133
|
+
Issue.transaction do
|
|
134
|
+
issues.each do |issue|
|
|
135
|
+
next if issue.id == target.id || issue.environment_id != target.environment_id || issue.merged?
|
|
136
|
+
|
|
137
|
+
issue.merge_into!(target)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def invalid_merge(error)
|
|
143
|
+
redirect_back fallback_location: (@issue ? issue_path(@issue) : issues_path), alert: error.message
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def render_detection_issue(env)
|
|
147
|
+
render inertia: {
|
|
148
|
+
issue: row(@issue).merge(first_seen_at: @issue.first_seen_at, resolved_at: @issue.resolved_at,
|
|
149
|
+
resolved_in_deploy: @issue.resolved_in_deploy, regressed_at: @issue.regressed_at, sample: @issue.sample),
|
|
150
|
+
detection: IssueDetectionPresenter.new(@issue).as_json,
|
|
151
|
+
latest: nil, breadcrumbs: [], browser_breadcrumbs: [], fingerprint: [], fingerprint_source: nil,
|
|
152
|
+
distinct_messages: 0, top_messages: [], occurrences: [], daily: [], by_deploy: {}, by_tenant: {}, attachments: [],
|
|
153
|
+
comments: @issue.comments.order(:created_at).map { |c| { id: c.id, body: c.body, user: c.user.name, created_at: c.created_at } },
|
|
154
|
+
activities: @issue.activities.order(:created_at).map { |a| activity_row(a) },
|
|
155
|
+
members: User.default.then { |u| [ { id: u.id, name: u.name } ] },
|
|
156
|
+
deploys: env.deploys.recent.limit(20).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } },
|
|
157
|
+
related_issues: related_issues,
|
|
158
|
+
merged_into: @issue.merged_into && { id: @issue.merged_into.id, key: @issue.merged_into.key, title: @issue.merged_into.title },
|
|
159
|
+
merged_issues: @issue.merged_issues.map { |i| { id: i.id, key: i.key, title: i.title } },
|
|
160
|
+
alerts: @issue.alerts.order(created_at: :desc).map { |a| alert_row(a) }
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Sentry-style breadcrumbs: everything the execution did before it raised
|
|
165
|
+
# (queries, cache, outgoing calls, logs, jobs), newest last, capped at 30.
|
|
166
|
+
def breadcrumbs_for(exception)
|
|
167
|
+
exe = Telemetry::Execution.find_by(execution_id: exception.execution_id) or return []
|
|
168
|
+
ExecutionPresenter.new(exe, @issue.environment).timeline_entries
|
|
169
|
+
.select { |e| e[:offset] <= ((exception.occurred_at - exe.occurred_at) * 1000.0) + 1 }
|
|
170
|
+
.reject { |e| e[:type] == "exception" && e[:id] == exception.id }
|
|
171
|
+
.last(30)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A browser exception's execution is the beacon POST that carried it, whose
|
|
175
|
+
# timeline says nothing about the crash. What it has instead is the trail
|
|
176
|
+
# the browser client recorded -- console errors, clicks, navigations --
|
|
177
|
+
# which the gem stores in the exception's context.
|
|
178
|
+
def browser_breadcrumbs_for(exception)
|
|
179
|
+
trail = browser_context(exception)["breadcrumbs"]
|
|
180
|
+
return [] unless trail.is_a?(Array)
|
|
181
|
+
trail.filter_map { |crumb| crumb.slice("at", "kind", "text") if crumb.is_a?(Hash) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The same context with the trail taken back out, since the page renders it
|
|
185
|
+
# as a breadcrumb list rather than as raw JSON in the context panel.
|
|
186
|
+
def context_without_breadcrumbs(exception)
|
|
187
|
+
context = parsed_context(exception)
|
|
188
|
+
return exception.context unless context["browser"].is_a?(Hash) && context["browser"].key?("breadcrumbs")
|
|
189
|
+
context.merge("browser" => context["browser"].except("breadcrumbs")).to_json
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def browser_context(exception)
|
|
193
|
+
context = parsed_context(exception)
|
|
194
|
+
context["browser"].is_a?(Hash) ? context["browser"] : {}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Exception context is a JSON string the gem built; anything else in that
|
|
198
|
+
# column is an older or garbled record, and reads as empty rather than 500.
|
|
199
|
+
def parsed_context(exception)
|
|
200
|
+
parsed = JSON.parse(exception&.context.to_s)
|
|
201
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
202
|
+
rescue JSON::ParserError
|
|
203
|
+
{}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Files the app attached while raising this exception, from any execution.
|
|
207
|
+
# `data` is left out of the select: it is the whole gzipped file and the
|
|
208
|
+
# page only links to the download.
|
|
209
|
+
def attachments_for_issue
|
|
210
|
+
Telemetry::Attachment.where(exception_group_hash: @issue.group_hash).recent.limit(20)
|
|
211
|
+
.select(:id, :name, :content_type, :bytes, :truncated, :occurred_at, :execution_id, :execution_preview)
|
|
212
|
+
.map { |a| { id: a.id, name: a.name, content_type: a.content_type, bytes: a.bytes, truncated: a.truncated,
|
|
213
|
+
viewable: a.viewable?, occurred_at: a.occurred_at, execution_id: a.execution_id, execution_preview: a.execution_preview } }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# split! no-ops when there is nothing to split apart (the button is hidden
|
|
217
|
+
# then, but a stale page can still ask), so say which one happened.
|
|
218
|
+
def split_notice(created)
|
|
219
|
+
created.empty? ? "Nothing to split \u2014 every recent occurrence is already in its own issue" : "Split into #{created.size} issue(s)"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def set_issue
|
|
223
|
+
@issue = Issue.all.find(params[:id])
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Only a member of the current account can be assigned; nil clears.
|
|
227
|
+
def assignee_from(id)
|
|
228
|
+
id.present? ? User.find(id) : nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Same exception class seen in other environments of the same application:
|
|
232
|
+
# issue titles are stored as "ClassName: message", so we match on the
|
|
233
|
+
# "ClassName:" prefix.
|
|
234
|
+
def related_issues
|
|
235
|
+
class_name = @issue.title.to_s.split(":", 2).first
|
|
236
|
+
return [] if class_name.blank?
|
|
237
|
+
Issue.all.where.not(id: @issue.id)
|
|
238
|
+
.where("title LIKE ?", "#{Issue.sanitize_sql_like(class_name)}:%").limit(20)
|
|
239
|
+
.map { |i| { id: i.id, key: i.key, title: i.title, status: i.status, environment: { id: i.environment.id, name: i.environment.name } } }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def row(i)
|
|
243
|
+
{ id: i.id, key: i.key, title: i.title, kind: i.kind, status: i.status, priority: i.priority, culprit: i.culprit, occurrences: i.occurrences,
|
|
244
|
+
affected_users: i.affected_users, first_seen_at: i.first_seen_at, last_seen_at: i.last_seen_at, assignee: i.assignee && { id: i.assignee.id, name: i.assignee.name },
|
|
245
|
+
fingerprint_source: i.sample["fingerprint_source"], source: i.source,
|
|
246
|
+
application: { id: i.application.id, name: i.application.name }, environment: { id: i.environment.id, name: i.environment.name } }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def activity_row(a)
|
|
250
|
+
{ id: a.id, kind: a.kind, data: a.data, user: a.user && { id: a.user.id, name: a.user.name }, created_at: a.created_at }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def alert_row(a)
|
|
254
|
+
{ id: a.id, event: a.event, status: a.status, sent_at: a.sent_at, error: a.error,
|
|
255
|
+
integration: { kind: a.integration.kind, name: a.integration.name } }
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class JobsController < DashboardController
|
|
5
|
+
include TelemetryIdentity
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
from, to = window_range
|
|
9
|
+
page_data = nil
|
|
10
|
+
page = lambda do
|
|
11
|
+
page_data ||= telemetry do
|
|
12
|
+
scope = FilterQuery.apply(Telemetry::Execution.jobs, resource: :jobs, query: params[:q], from: from, to: to)
|
|
13
|
+
rows, meta = Telemetry::CursorPage.call(scope, cursor: params[:cursor], limit: params[:limit],
|
|
14
|
+
context: telemetry_cursor_context(:jobs))
|
|
15
|
+
people = origin_people(rows)
|
|
16
|
+
[ rows.map { |r| job_row(r, people) }, meta ]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
render inertia: {
|
|
20
|
+
classes: -> { grouped("job_attempt", limit: 200) }, series: -> { series("job_attempt") },
|
|
21
|
+
queues: -> { queue_stats(from, to) }, recent: InertiaRails.merge { page.call.first },
|
|
22
|
+
pagination: -> { page.call.last }, deploys: -> { deploys_in_window }, q: params[:q].to_s
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def klass
|
|
27
|
+
group_hash = params[:group_hash]
|
|
28
|
+
from, to = window_range
|
|
29
|
+
name, attempts = telemetry do
|
|
30
|
+
base = Telemetry::Execution.jobs.where(group_hash: group_hash)
|
|
31
|
+
rows = FilterQuery.apply(base, resource: :jobs,
|
|
32
|
+
query: params[:q], from: from, to: to).recent.limit(200).to_a
|
|
33
|
+
people = origin_people(rows)
|
|
34
|
+
[ base.between(from, to).pick(:name), rows.map { |row| job_row(row, people) } ]
|
|
35
|
+
end
|
|
36
|
+
render inertia: "jobs/klass", props: { name: name, group_hash: group_hash,
|
|
37
|
+
summary: summary_with_delta("job_attempt", group_hash: group_hash),
|
|
38
|
+
series: series("job_attempt", group_hash: group_hash), attempts: attempts,
|
|
39
|
+
deploys: deploys_in_window, q: params[:q].to_s }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def show
|
|
43
|
+
exe = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
|
|
44
|
+
render inertia: "executions/show", props: ExecutionPresenter.new(exe, environment).props
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def queue_stats(from, to)
|
|
50
|
+
telemetry do
|
|
51
|
+
Telemetry::Execution.jobs.between(from, to).group(:queue)
|
|
52
|
+
.pluck(:queue, Arel.sql("COUNT(*)"), Arel.sql("AVG(queue_latency)"), Arel.sql("SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END)"))
|
|
53
|
+
.map { |q, c, lat, f| { queue: q, count: c, avg_latency: (lat.to_f / 1000.0).round(1), failed: f } }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def job_row(r, people)
|
|
58
|
+
{ execution_id: r.execution_id, name: r.name, outcome: r.outcome, queue: r.queue, attempt: r.attempt, duration: r.duration_ms.round(2),
|
|
59
|
+
queue_latency: r.queue_latency && (r.queue_latency / 1000.0).round(1), occurred_at: r.occurred_at, exception_preview: r.exception_preview,
|
|
60
|
+
job_id: r.job_id, deploy: r.deploy }.merge(origin_identity(r, people))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|