rails_error_dashboard 0.14.4 → 0.16.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/README.md +19 -8
- data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +97 -4
- data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
- data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +63 -16
- data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
- data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
- data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +31 -0
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
- data/app/models/rails_error_dashboard/error_log.rb +54 -20
- data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
- data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
- data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +14 -2
- data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +12 -3
- data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
- data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
- data/app/views/rails_error_dashboard/errors/settings.html.erb +23 -8
- data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
- data/config/locales/de.yml +87 -5
- data/config/locales/en.yml +99 -4
- data/config/locales/es.yml +88 -5
- data/config/locales/fr.yml +88 -5
- data/config/locales/it.yml +88 -5
- data/config/locales/ja.yml +86 -4
- data/config/locales/pl.yml +89 -5
- data/config/locales/pt-BR.yml +88 -5
- data/config/locales/ru.yml +89 -5
- data/config/locales/uk.yml +89 -5
- data/config/locales/zh-CN.yml +86 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
- data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +41 -5
- data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +16 -3
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +101 -0
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
- data/lib/rails_error_dashboard/commands/log_error.rb +20 -8
- data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
- data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
- data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
- data/lib/rails_error_dashboard/configuration.rb +61 -4
- data/lib/rails_error_dashboard/engine.rb +42 -1
- data/lib/rails_error_dashboard/error_reporter.rb +4 -2
- data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
- data/lib/rails_error_dashboard/plugin.rb +19 -3
- data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
- data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
- data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
- data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
- data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
- data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
- data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
- data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -7
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +90 -2
- data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -14
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
- data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
- data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
- data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
- data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
- data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
- data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
- data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
- data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
- data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
- data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
- data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
- data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +28 -0
- data/lib/tasks/error_dashboard.rake +2 -3
- metadata +29 -2
|
@@ -11,9 +11,10 @@ module RailsErrorDashboard
|
|
|
11
11
|
module PlainChannelMessage
|
|
12
12
|
private
|
|
13
13
|
|
|
14
|
-
# Slack takes {text:}, Discord {content:},
|
|
15
|
-
# machine-readable event. Email and PagerDuty are not
|
|
16
|
-
# a sensible shape for a message that is not an
|
|
14
|
+
# Slack takes {text:}, Discord {content:}, Telegram a plain sendMessage,
|
|
15
|
+
# custom webhooks a machine-readable event. Email and PagerDuty are not
|
|
16
|
+
# used: neither has a sensible shape for a message that is not an
|
|
17
|
+
# incident.
|
|
17
18
|
def deliver_plain_message(message, webhook_payload)
|
|
18
19
|
config = RailsErrorDashboard.configuration
|
|
19
20
|
delivered = false
|
|
@@ -28,15 +29,20 @@ module RailsErrorDashboard
|
|
|
28
29
|
delivered = true
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
if Services::TelegramDelivery.configured?(config)
|
|
33
|
+
post_telegram(message)
|
|
34
|
+
delivered = true
|
|
35
|
+
end
|
|
36
|
+
|
|
31
37
|
if config.enable_webhook_notifications && config.webhook_urls.present?
|
|
32
|
-
config.webhook_urls.each { |url|
|
|
38
|
+
config.webhook_urls.each { |url| post_webhook(url, webhook_payload) }
|
|
33
39
|
delivered = true
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
# An email-only or PagerDuty-only deployment has no channel for this.
|
|
37
43
|
# Say so where an operator can find it rather than dropping it silently.
|
|
38
44
|
unless delivered
|
|
39
|
-
Rails.logger.warn("[RailsErrorDashboard] #{self.class.name.demodulize}: no Slack, Discord or webhook channel is enabled; message not sent: #{message}")
|
|
45
|
+
Rails.logger.warn("[RailsErrorDashboard] #{self.class.name.demodulize}: no Slack, Discord, Telegram or webhook channel is enabled; message not sent: #{message}")
|
|
40
46
|
end
|
|
41
47
|
end
|
|
42
48
|
|
|
@@ -49,6 +55,27 @@ module RailsErrorDashboard
|
|
|
49
55
|
(defined?(Rails) && Rails.application.class.module_parent_name) || "Rails Application"
|
|
50
56
|
end
|
|
51
57
|
|
|
58
|
+
# Custom webhooks go through WebhookDelivery so they are signed when
|
|
59
|
+
# webhook_signing_secret is set. Slack and Discord stay on post_json:
|
|
60
|
+
# their payloads are not ours to sign. Same per-URL rescue as post_json,
|
|
61
|
+
# so one unreachable receiver does not stop the next.
|
|
62
|
+
def post_webhook(url, payload)
|
|
63
|
+
Services::WebhookDelivery.post(url, payload)
|
|
64
|
+
rescue => e
|
|
65
|
+
Rails.logger.error("[RailsErrorDashboard] #{self.class.name.demodulize} post failed: #{e.message}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# No parse_mode: the sentence is sent verbatim, so an app name with a
|
|
69
|
+
# "<" in it cannot break Telegram's HTML parser. The token sits in the
|
|
70
|
+
# request URL, hence the redaction before logging.
|
|
71
|
+
def post_telegram(message)
|
|
72
|
+
Services::TelegramDelivery.send_message(message)
|
|
73
|
+
rescue => e
|
|
74
|
+
Rails.logger.error(
|
|
75
|
+
"[RailsErrorDashboard] #{self.class.name.demodulize} Telegram post failed: #{Services::TelegramDelivery.redact(e.message)}"
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
52
79
|
def post_json(url, payload)
|
|
53
80
|
if defined?(HTTParty)
|
|
54
81
|
HTTParty.post(url, body: payload.to_json,
|
|
@@ -59,7 +86,7 @@ module RailsErrorDashboard
|
|
|
59
86
|
http.use_ssl = uri.scheme == "https"
|
|
60
87
|
http.open_timeout = 5
|
|
61
88
|
http.read_timeout = 10
|
|
62
|
-
request = Net::HTTP::Post.new(uri.
|
|
89
|
+
request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
|
|
63
90
|
request.body = payload.to_json
|
|
64
91
|
http.request(request)
|
|
65
92
|
end
|
|
@@ -32,7 +32,7 @@ module RailsErrorDashboard
|
|
|
32
32
|
http.use_ssl = uri.scheme == "https"
|
|
33
33
|
http.open_timeout = 5
|
|
34
34
|
http.read_timeout = 10
|
|
35
|
-
request = Net::HTTP::Post.new(uri.
|
|
35
|
+
request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
|
|
36
36
|
request.body = payload.to_json
|
|
37
37
|
http.request(request)
|
|
38
38
|
end
|
|
@@ -44,7 +44,7 @@ module RailsErrorDashboard
|
|
|
44
44
|
http.use_ssl = uri.scheme == "https"
|
|
45
45
|
http.open_timeout = 5
|
|
46
46
|
http.read_timeout = 10
|
|
47
|
-
request = Net::HTTP::Post.new(uri.
|
|
47
|
+
request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
|
|
48
48
|
request.body = payload.to_json
|
|
49
49
|
http.request(request)
|
|
50
50
|
end
|
|
@@ -44,6 +44,11 @@ module RailsErrorDashboard
|
|
|
44
44
|
def self.delete_with_dependents(scope)
|
|
45
45
|
ids = scope.select(:id)
|
|
46
46
|
|
|
47
|
+
# Each occurrence's stored context. Named here for the same reason as the
|
|
48
|
+
# event counts below: delete_all fires no :dependent callback.
|
|
49
|
+
if ErrorOccurrenceContext.table_exists?
|
|
50
|
+
ErrorOccurrenceContext.where(error_log_id: ids).in_batches(of: 1000).delete_all
|
|
51
|
+
end
|
|
47
52
|
ErrorOccurrence.where(error_log_id: ids).in_batches(of: 1000).delete_all
|
|
48
53
|
# Hour buckets for storm-shed events. Listed explicitly because delete_all
|
|
49
54
|
# does not fire the has_many :dependent callback on ErrorLog -- without
|
|
@@ -70,6 +75,7 @@ module RailsErrorDashboard
|
|
|
70
75
|
# errors — clean them up BEFORE the early return below, which fires
|
|
71
76
|
# whenever no error logs happen to be expired.
|
|
72
77
|
cleanup_rack_attack_events(cutoff)
|
|
78
|
+
cleanup_missing_translations(cutoff)
|
|
73
79
|
cleanup_storm_flush_batches(cutoff)
|
|
74
80
|
cleanup_diagnostic_dumps(cutoff)
|
|
75
81
|
cleanup_swallowed_exceptions(cutoff)
|
|
@@ -211,6 +217,31 @@ module RailsErrorDashboard
|
|
|
211
217
|
)
|
|
212
218
|
end
|
|
213
219
|
|
|
220
|
+
# Expire missing-translation rows not seen since the cutoff. A key that
|
|
221
|
+
# is still missing is still being seen, so it keeps its row (and its
|
|
222
|
+
# first_seen_at); only keys that stopped missing age out.
|
|
223
|
+
#
|
|
224
|
+
# Not gated on the feature flag: rows collected while tracking was on
|
|
225
|
+
# must still age out after it is turned off.
|
|
226
|
+
def cleanup_missing_translations(cutoff)
|
|
227
|
+
return unless MissingTranslation.table_exists?
|
|
228
|
+
|
|
229
|
+
deleted = 0
|
|
230
|
+
MissingTranslation.where("last_seen_at < ?", cutoff).in_batches(of: 1000) do |batch|
|
|
231
|
+
deleted += batch.delete_all
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
if deleted > 0
|
|
235
|
+
RailsErrorDashboard::Logger.info(
|
|
236
|
+
"[RailsErrorDashboard] Retention cleanup: deleted #{deleted} missing translations"
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
rescue => e
|
|
240
|
+
RailsErrorDashboard::Logger.debug(
|
|
241
|
+
"[RailsErrorDashboard] Missing translation retention cleanup failed: #{e.class} - #{e.message}"
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
214
245
|
# Expire aggregated Rack Attack event rows. Isolated in its own rescue so a
|
|
215
246
|
# failure here (e.g. table not yet migrated) never blocks error cleanup.
|
|
216
247
|
def cleanup_rack_attack_events(cutoff)
|
|
@@ -12,11 +12,16 @@ module RailsErrorDashboard
|
|
|
12
12
|
class ScheduledDigestJob < ApplicationJob
|
|
13
13
|
queue_as :default
|
|
14
14
|
|
|
15
|
+
# @param period [String, Symbol, nil] "daily" or "weekly"; nil takes
|
|
16
|
+
# config.digest_frequency, which was shown on the Settings page but
|
|
17
|
+
# read by nothing until now.
|
|
15
18
|
# @param locale [String, nil] resolved at enqueue time. nil for jobs
|
|
16
19
|
# enqueued by a pre-Phase-4 version still draining from the queue.
|
|
17
|
-
def perform(period:
|
|
20
|
+
def perform(period: nil, application_id: nil, locale: nil)
|
|
18
21
|
return unless RailsErrorDashboard.configuration.enable_scheduled_digests
|
|
19
22
|
|
|
23
|
+
period ||= RailsErrorDashboard.configuration.digest_frequency || :daily
|
|
24
|
+
|
|
20
25
|
recipients = effective_recipients
|
|
21
26
|
return if recipients.blank?
|
|
22
27
|
|
|
@@ -32,7 +32,7 @@ module RailsErrorDashboard
|
|
|
32
32
|
http.open_timeout = 5 # 5 seconds to establish connection
|
|
33
33
|
http.read_timeout = 10 # 10 seconds to read response
|
|
34
34
|
|
|
35
|
-
request = Net::HTTP::Post.new(uri.
|
|
35
|
+
request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
|
|
36
36
|
request.body = Services::SlackPayloadBuilder.call(error_log, locale: locale).to_json
|
|
37
37
|
|
|
38
38
|
response = http.request(request)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Job to send error notifications to a Telegram chat via the Bot API
|
|
5
|
+
class TelegramErrorNotificationJob < ApplicationJob
|
|
6
|
+
queue_as :default
|
|
7
|
+
|
|
8
|
+
# @param locale [String, nil] resolved at enqueue time.
|
|
9
|
+
def perform(error_log_id, locale = nil)
|
|
10
|
+
return unless Services::TelegramDelivery.credentials?
|
|
11
|
+
|
|
12
|
+
error_log = ErrorLog.find(error_log_id)
|
|
13
|
+
payload = Services::TelegramPayloadBuilder.call(error_log, locale: job_locale(locale))
|
|
14
|
+
|
|
15
|
+
Services::TelegramDelivery.post(payload)
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
# The bot token is in the request URL; an exception that quotes it must
|
|
18
|
+
# not reach the log as-is.
|
|
19
|
+
Rails.logger.error(
|
|
20
|
+
"[RailsErrorDashboard] Failed to send Telegram notification: #{Services::TelegramDelivery.redact(e.message)}"
|
|
21
|
+
)
|
|
22
|
+
Rails.logger.error(e.backtrace&.first(5)&.join("\n")) if e.backtrace
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -31,13 +31,12 @@ module RailsErrorDashboard
|
|
|
31
31
|
|
|
32
32
|
def send_webhook(url, payload, error_log)
|
|
33
33
|
headers = {
|
|
34
|
-
"Content-Type" => "application/json",
|
|
35
|
-
"User-Agent" => "RailsErrorDashboard/1.0",
|
|
36
34
|
"X-Error-Dashboard-Event" => "error.created",
|
|
37
35
|
"X-Error-Dashboard-ID" => error_log.id.to_s
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
|
|
38
|
+
# Serialises, signs (when webhook_signing_secret is set) and posts.
|
|
39
|
+
response = Services::WebhookDelivery.post(url, payload, headers: headers)
|
|
41
40
|
|
|
42
41
|
unless response_success?(response)
|
|
43
42
|
Rails.logger.warn("[RailsErrorDashboard] Webhook failed for #{url}: #{response_code(response)}")
|
|
@@ -46,21 +45,6 @@ module RailsErrorDashboard
|
|
|
46
45
|
Rails.logger.error("[RailsErrorDashboard] Webhook error for #{url}: #{e.message}")
|
|
47
46
|
end
|
|
48
47
|
|
|
49
|
-
def post_json(url, payload, headers)
|
|
50
|
-
if defined?(HTTParty)
|
|
51
|
-
HTTParty.post(url, body: payload.to_json, headers: headers, timeout: 10)
|
|
52
|
-
else
|
|
53
|
-
uri = URI(url)
|
|
54
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
55
|
-
http.use_ssl = uri.scheme == "https"
|
|
56
|
-
http.open_timeout = 5
|
|
57
|
-
http.read_timeout = 10
|
|
58
|
-
request = Net::HTTP::Post.new(uri.path, headers)
|
|
59
|
-
request.body = payload.to_json
|
|
60
|
-
http.request(request)
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
48
|
def response_success?(response)
|
|
65
49
|
if response.respond_to?(:success?)
|
|
66
50
|
response.success?
|
|
@@ -36,6 +36,8 @@ module RailsErrorDashboard
|
|
|
36
36
|
|
|
37
37
|
# Association for tracking individual error occurrences
|
|
38
38
|
has_many :error_occurrences, class_name: "RailsErrorDashboard::ErrorOccurrence", dependent: :destroy
|
|
39
|
+
# Deleted before the occurrences, without loading their payloads
|
|
40
|
+
has_many :occurrence_contexts, class_name: "RailsErrorDashboard::ErrorOccurrenceContext", dependent: :delete_all
|
|
39
41
|
|
|
40
42
|
# Hour buckets for storm-shed events. delete_all, not destroy: these rows
|
|
41
43
|
# are pure counters with no callbacks, and a group can own one per hour.
|
|
@@ -179,6 +181,14 @@ module RailsErrorDashboard
|
|
|
179
181
|
Services::SeverityClassifier.critical?(error_type)
|
|
180
182
|
end
|
|
181
183
|
|
|
184
|
+
# Whether the backtrace this row shows was sent by a client through
|
|
185
|
+
# ManualErrorReporter. Its frames describe code that ran on the client, so
|
|
186
|
+
# View Source, git blame and the Markdown formatter read no file for them.
|
|
187
|
+
# nil (a row from before the column, or an app not yet migrated) is server.
|
|
188
|
+
def client_backtrace?
|
|
189
|
+
has_attribute?(:backtrace_source) && backtrace_source == "client"
|
|
190
|
+
end
|
|
191
|
+
|
|
182
192
|
# Check if error is recent (< 1 hour)
|
|
183
193
|
def recent?
|
|
184
194
|
occurred_at >= 1.hour.ago
|
|
@@ -204,6 +214,13 @@ module RailsErrorDashboard
|
|
|
204
214
|
Commands::LogError.call(exception, context)
|
|
205
215
|
end
|
|
206
216
|
|
|
217
|
+
# nil rather than a wrong user: an integer column casts "3f2a9c1e-..." to 3
|
|
218
|
+
def self.storable_user_id(user_id)
|
|
219
|
+
return user_id unless type_for_attribute("user_id").type == :integer
|
|
220
|
+
|
|
221
|
+
Integer(user_id.to_s, 10, exception: false)
|
|
222
|
+
end
|
|
223
|
+
|
|
207
224
|
# Mark error as resolved (delegates to Command)
|
|
208
225
|
def resolve!(resolution_data = {})
|
|
209
226
|
Commands::ResolveError.call(id, resolution_data)
|
|
@@ -332,14 +349,7 @@ module RailsErrorDashboard
|
|
|
332
349
|
[]
|
|
333
350
|
end
|
|
334
351
|
|
|
335
|
-
lines.first(20).map
|
|
336
|
-
# Extract file path and method name, ignore line numbers
|
|
337
|
-
if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
|
|
338
|
-
"#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
|
|
339
|
-
elsif line =~ %r{([^/]+\.rb)}
|
|
340
|
-
Regexp.last_match(1)
|
|
341
|
-
end
|
|
342
|
-
end.compact.uniq
|
|
352
|
+
lines.first(20).map { |line| Services::BacktraceProcessor.frame_key(line) }.compact.uniq
|
|
343
353
|
end
|
|
344
354
|
|
|
345
355
|
# Calculate backtrace signature — delegates to Service
|
|
@@ -415,13 +425,8 @@ module RailsErrorDashboard
|
|
|
415
425
|
return {} unless RailsErrorDashboard.configuration.enable_occurrence_patterns
|
|
416
426
|
return {} unless defined?(Services::PatternDetector)
|
|
417
427
|
|
|
418
|
-
timestamps = self.class
|
|
419
|
-
.where(error_type: error_type, platform: platform)
|
|
420
|
-
.where("occurred_at >= ?", days.days.ago)
|
|
421
|
-
.pluck(:occurred_at)
|
|
422
|
-
|
|
423
428
|
Services::PatternDetector.analyze_cyclical_pattern(
|
|
424
|
-
timestamps:
|
|
429
|
+
timestamps: pattern_timestamps(days: days),
|
|
425
430
|
days: days
|
|
426
431
|
)
|
|
427
432
|
end
|
|
@@ -433,13 +438,42 @@ module RailsErrorDashboard
|
|
|
433
438
|
return [] unless RailsErrorDashboard.configuration.enable_occurrence_patterns
|
|
434
439
|
return [] unless defined?(Services::PatternDetector)
|
|
435
440
|
|
|
436
|
-
timestamps
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
+
Services::PatternDetector.detect_bursts(timestamps: pattern_timestamps(days: days))
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# The most recent occurrence timestamps a Pattern Insights sample reads.
|
|
445
|
+
# A grouped row holds one occurred_at for hundreds of events, so the
|
|
446
|
+
# pattern used to be computed from one timestamp per error: the per-event
|
|
447
|
+
# rows are what a daily or weekly rhythm is visible in. Capped so an error
|
|
448
|
+
# with a million occurrences does not load a million Time objects to
|
|
449
|
+
# bucket them by hour; the newest PATTERN_SAMPLE_LIMIT are enough for a
|
|
450
|
+
# distribution and for the bursts of the last week.
|
|
451
|
+
PATTERN_SAMPLE_LIMIT = 10_000
|
|
452
|
+
|
|
453
|
+
def pattern_timestamps(days:)
|
|
454
|
+
since = days.days.ago
|
|
455
|
+
grouped = self.class.where(error_type: error_type, platform: platform)
|
|
456
|
+
|
|
457
|
+
if occurrence_rows_available?
|
|
458
|
+
timestamps = ErrorOccurrence.where(error_log_id: grouped.select(:id))
|
|
459
|
+
.where("occurred_at >= ?", since)
|
|
460
|
+
.order(occurred_at: :desc)
|
|
461
|
+
.limit(PATTERN_SAMPLE_LIMIT)
|
|
462
|
+
.pluck(:occurred_at)
|
|
463
|
+
.reverse
|
|
464
|
+
return timestamps if timestamps.any?
|
|
465
|
+
end
|
|
441
466
|
|
|
442
|
-
|
|
467
|
+
# No per-event rows: an install from before the occurrences table, or
|
|
468
|
+
# rows captured while a storm was being shed. The grouped rows are the
|
|
469
|
+
# only timestamps there are.
|
|
470
|
+
grouped.where("occurred_at >= ?", since).order(:occurred_at).pluck(:occurred_at)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def occurrence_rows_available?
|
|
474
|
+
defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
|
|
475
|
+
rescue StandardError
|
|
476
|
+
false
|
|
443
477
|
end
|
|
444
478
|
|
|
445
479
|
private
|
|
@@ -10,6 +10,7 @@ module RailsErrorDashboard
|
|
|
10
10
|
self.table_name = "rails_error_dashboard_error_occurrences"
|
|
11
11
|
|
|
12
12
|
belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog"
|
|
13
|
+
has_one :context, class_name: "RailsErrorDashboard::ErrorOccurrenceContext", dependent: :delete
|
|
13
14
|
|
|
14
15
|
# Only define user association if User model exists
|
|
15
16
|
if defined?(::User)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# The moment-of-failure context of one occurrence: breadcrumbs, system
|
|
5
|
+
# health, local and instance variables, the same JSON strings the error row
|
|
6
|
+
# holds for its latest capture. Commands::RecordOccurrenceContext writes them
|
|
7
|
+
# and keeps the newest config.occurrence_context_limit per error.
|
|
8
|
+
class ErrorOccurrenceContext < ErrorLogsRecord
|
|
9
|
+
self.table_name = "rails_error_dashboard_error_occurrence_contexts"
|
|
10
|
+
|
|
11
|
+
belongs_to :error_occurrence, class_name: "RailsErrorDashboard::ErrorOccurrence"
|
|
12
|
+
belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# A translation key the host app looked up and did not have, counted per
|
|
5
|
+
# (locale, key, application).
|
|
6
|
+
#
|
|
7
|
+
# Rows are written by Commands::FlushMissingTranslations from the tracker's
|
|
8
|
+
# in-memory buffer, never one per lookup. Inherits ErrorLogsRecord so
|
|
9
|
+
# separate-database routing applies.
|
|
10
|
+
class MissingTranslation < ErrorLogsRecord
|
|
11
|
+
self.table_name = "rails_error_dashboard_missing_translations"
|
|
12
|
+
|
|
13
|
+
# Not a translation key: a synthetic row holding the misses the tracker
|
|
14
|
+
# dropped because its per-thread buffer was full, kept so the totals the
|
|
15
|
+
# dashboard shows are never silently understated. Stored under a locale
|
|
16
|
+
# no real lookup uses. The tracker owns the values; aliased here so the
|
|
17
|
+
# writer and the scopes below cannot drift apart.
|
|
18
|
+
OVERFLOW_KEY = Services::MissingTranslationTracker::OVERFLOW_KEY
|
|
19
|
+
OVERFLOW_LOCALE = Services::MissingTranslationTracker::OVERFLOW_LOCALE
|
|
20
|
+
|
|
21
|
+
belongs_to :application, optional: true
|
|
22
|
+
|
|
23
|
+
validates :locale, presence: true
|
|
24
|
+
validates :translation_key, presence: true
|
|
25
|
+
validates :miss_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
26
|
+
|
|
27
|
+
scope :for_application, ->(app_id) { where(application_id: app_id) }
|
|
28
|
+
scope :seen_since, ->(time) { where("last_seen_at >= ?", time) }
|
|
29
|
+
scope :overflow, -> { where(translation_key: OVERFLOW_KEY) }
|
|
30
|
+
scope :real, -> { where.not(translation_key: OVERFLOW_KEY) }
|
|
31
|
+
|
|
32
|
+
# The table may not be migrated yet on a host that upgraded the gem before
|
|
33
|
+
# running the migration. Everything that touches this model on a request
|
|
34
|
+
# path checks first and must never raise.
|
|
35
|
+
def self.table_exists?
|
|
36
|
+
connection.table_exists?(table_name)
|
|
37
|
+
rescue *Commands::LogError::RETRYABLE_STORE_ERRORS
|
|
38
|
+
raise
|
|
39
|
+
rescue StandardError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -1156,6 +1156,12 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
|
|
|
1156
1156
|
<% if RailsErrorDashboard.configuration.enable_storm_protection %>
|
|
1157
1157
|
<% diag_items << { path: storms_errors_path(nav_params), icon: 'bi-cloud-lightning-rain', label: red_t("red.nav.storms") } %>
|
|
1158
1158
|
<% end %>
|
|
1159
|
+
<% if RailsErrorDashboard.configuration.enable_missing_translation_tracking %>
|
|
1160
|
+
<% diag_items << { path: missing_translations_errors_path(nav_params), icon: 'bi-translate', label: red_t("red.nav.missing_translations") } %>
|
|
1161
|
+
<% end %>
|
|
1162
|
+
<% if RailsErrorDashboard.configuration.enable_crash_capture %>
|
|
1163
|
+
<% diag_items << { path: boot_errors_errors_path(nav_params), icon: 'bi-power', label: red_t("red.nav.boot_errors") } %>
|
|
1164
|
+
<% end %>
|
|
1159
1165
|
|
|
1160
1166
|
<% if diag_items.any? %>
|
|
1161
1167
|
<div style="margin-bottom: var(--space-2);" id="navDiagSection">
|
|
@@ -1552,7 +1558,11 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
|
|
|
1552
1558
|
});
|
|
1553
1559
|
|
|
1554
1560
|
if (typeof hljs.lineNumbersBlock === 'function') {
|
|
1555
|
-
|
|
1561
|
+
// The gutter shows the file's own line numbers: the excerpt starts at
|
|
1562
|
+
// data-start-line, not at 1.
|
|
1563
|
+
codeBlocks.forEach(function(block) {
|
|
1564
|
+
hljs.lineNumbersBlock(block, { startFrom: parseInt(block.dataset.startLine) || 1 });
|
|
1565
|
+
});
|
|
1556
1566
|
} else if (typeof hljs.initLineNumbersOnLoad === 'function') {
|
|
1557
1567
|
hljs.initLineNumbersOnLoad();
|
|
1558
1568
|
}
|
|
@@ -2504,7 +2514,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
2504
2514
|
case 'toggle-next':
|
|
2505
2515
|
var nextEl = actionEl.nextElementSibling;
|
|
2506
2516
|
if (nextEl) {
|
|
2507
|
-
|
|
2517
|
+
// The computed display: an open element usually has no inline display
|
|
2518
|
+
// at all, and reading '' as hidden made the first click a no-op.
|
|
2519
|
+
var isHidden = window.getComputedStyle(nextEl).display === 'none';
|
|
2508
2520
|
nextEl.style.display = isHidden ? (actionEl.dataset.redDisplay || 'block') : 'none';
|
|
2509
2521
|
var chev = actionEl.querySelector('.chev');
|
|
2510
2522
|
if (chev) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<!-- Captured occurrences: the ones whose context is still stored -->
|
|
2
|
+
<% if occurrences.any? %>
|
|
3
|
+
<div class="card mb-4" id="section-captured-occurrences">
|
|
4
|
+
<div class="card-header">
|
|
5
|
+
<h5 class="mb-0">
|
|
6
|
+
<i class="bi bi-camera"></i> <%= red_t("red.errors.captured_occurrences.title") %>
|
|
7
|
+
</h5>
|
|
8
|
+
<small class="text-muted"><%= red_t("red.errors.captured_occurrences.hint") %></small>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="card-body p-0">
|
|
11
|
+
<div class="table-responsive">
|
|
12
|
+
<table class="table table-hover mb-0">
|
|
13
|
+
<thead class="table-light">
|
|
14
|
+
<tr>
|
|
15
|
+
<th><%= red_t("red.errors.captured_occurrences.column_occurrence") %></th>
|
|
16
|
+
<th><%= red_t("red.errors.captured_occurrences.column_occurred_at") %></th>
|
|
17
|
+
<th><%= red_t("red.errors.captured_occurrences.column_version") %></th>
|
|
18
|
+
<th></th>
|
|
19
|
+
</tr>
|
|
20
|
+
</thead>
|
|
21
|
+
<tbody>
|
|
22
|
+
<% occurrences.each do |occurrence| %>
|
|
23
|
+
<tr>
|
|
24
|
+
<td>
|
|
25
|
+
<code>#<%= occurrence.id %></code>
|
|
26
|
+
<% if occurrence.id == current_id %>
|
|
27
|
+
<span class="badge bg-info text-dark ms-1"><%= red_t("red.errors.captured_occurrences.viewing") %></span>
|
|
28
|
+
<% end %>
|
|
29
|
+
</td>
|
|
30
|
+
<td><%= local_time(occurrence.occurred_at) %></td>
|
|
31
|
+
<td>
|
|
32
|
+
<% version = occurrence.app_version.presence || occurrence.git_sha.to_s.first(7).presence %>
|
|
33
|
+
<small class="text-muted"><%= version || red_t("red.common.not_available") %></small>
|
|
34
|
+
</td>
|
|
35
|
+
<td class="text-end">
|
|
36
|
+
<%= link_to red_t("red.errors.captured_occurrences.view"), error_path(error, occurrence: occurrence.id, **app_context), class: "btn btn-sm btn-outline-primary" %>
|
|
37
|
+
</td>
|
|
38
|
+
</tr>
|
|
39
|
+
<% end %>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<% end %>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<%# The locale is part of the cache key: this fragment now contains
|
|
2
2
|
translated text, and without it the first locale to render an error
|
|
3
3
|
would be served to every other one. %>
|
|
4
|
-
|
|
4
|
+
<%# Plus the source-integration configuration and a TTL, and not at all
|
|
5
|
+
while coverage is live: see BacktraceHelper#source_view_cache_key. %>
|
|
6
|
+
<% cache_if source_view_cacheable?, [error, 'error_details_v3', red_locale, source_view_cache_key, error.client_backtrace?], **source_view_cache_options do %>
|
|
5
7
|
<div class="card mb-4" id="section-error-info">
|
|
6
8
|
<div class="card-header bg-danger text-white">
|
|
7
9
|
<div class="d-flex justify-content-between align-items-center">
|
|
@@ -106,6 +108,8 @@
|
|
|
106
108
|
<% if error.backtrace.present? %>
|
|
107
109
|
<% frames = parse_backtrace(error.backtrace) %>
|
|
108
110
|
<% app_frames = filter_app_code(frames) %>
|
|
111
|
+
<%# A client's frames describe code that ran on the client: no file is read for them %>
|
|
112
|
+
<% source_viewable = RailsErrorDashboard.configuration.enable_source_code_integration && !error.client_backtrace? %>
|
|
109
113
|
<% framework_frames = filter_framework_code(frames) %>
|
|
110
114
|
|
|
111
115
|
<% if app_frames.any? %>
|
|
@@ -120,6 +124,11 @@
|
|
|
120
124
|
</div>
|
|
121
125
|
</div>
|
|
122
126
|
<div class="card-body p-0">
|
|
127
|
+
<% if RailsErrorDashboard.configuration.enable_source_code_integration && error.client_backtrace? %>
|
|
128
|
+
<div class="alert alert-info m-3 mb-0" id="client-backtrace-note" style="font-size: 12px;">
|
|
129
|
+
<i class="bi bi-phone"></i> <%= red_t("red.errors.info.backtrace.client_report_note") %>
|
|
130
|
+
</div>
|
|
131
|
+
<% end %>
|
|
123
132
|
<div class="code-block p-3" style="max-height: none; overflow-y: visible; overflow-x: auto;">
|
|
124
133
|
<% app_frames.each_with_index do |frame, index| %>
|
|
125
134
|
<div class="backtrace-frame-wrapper mb-2">
|
|
@@ -131,7 +140,7 @@
|
|
|
131
140
|
<%= frame_icon(frame[:category]) %> <%= frame[:short_path] %>:<%= frame[:line_number] %>
|
|
132
141
|
</span> in <span class="backtrace-method-name">`<%= frame[:method_name] %>'</span>
|
|
133
142
|
</div>
|
|
134
|
-
<% if
|
|
143
|
+
<% if source_viewable && frame[:category] == :app %>
|
|
135
144
|
<button class="btn btn-sm btn-outline-secondary" type="button"
|
|
136
145
|
data-bs-toggle="collapse"
|
|
137
146
|
data-bs-target="#source-code-<%= index %>"
|
|
@@ -143,7 +152,7 @@
|
|
|
143
152
|
</div>
|
|
144
153
|
|
|
145
154
|
<!-- Collapsible source code (only for app frames) -->
|
|
146
|
-
<% if
|
|
155
|
+
<% if source_viewable && frame[:category] == :app %>
|
|
147
156
|
<div class="collapse" id="source-code-<%= index %>">
|
|
148
157
|
<%= render "source_code", frame: frame, error: error, index: index %>
|
|
149
158
|
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<% if occurrence_context %>
|
|
2
|
+
<%# Only the context cards follow ?occurrence=; the rest of the page is the error row's latest capture %>
|
|
3
|
+
<div class="alert alert-info mb-3" id="occurrence-banner" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4);">
|
|
4
|
+
<div style="font-size: 12px;">
|
|
5
|
+
<i class="bi bi-clock-history"></i>
|
|
6
|
+
<strong><%= red_t("red.errors.show.occurrence.title", id: occurrence_context.error_occurrence_id) %></strong>
|
|
7
|
+
· <%= local_time(occurrence_context.error_occurrence&.occurred_at || occurrence_context.created_at) %>
|
|
8
|
+
<div><%= red_t("red.errors.show.occurrence.body") %></div>
|
|
9
|
+
</div>
|
|
10
|
+
<%= link_to red_t("red.errors.show.occurrence.show_latest"), error_path(error, **app_context), class: "btn btn-sm" %>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
@@ -187,8 +187,10 @@
|
|
|
187
187
|
</small>
|
|
188
188
|
<% end %>
|
|
189
189
|
</div>
|
|
190
|
+
<%# data-red-action, not data-turbo-confirm: the layout runs with
|
|
191
|
+
Turbo off and loads no UJS, so nothing read the old attribute. %>
|
|
190
192
|
<%= button_to unassign_error_path(error), method: :post, class: "btn btn-sm btn-outline-secondary",
|
|
191
|
-
data: {
|
|
193
|
+
form: { data: { red_action: "confirm-submit", red_confirm_message: red_t("red.errors.sidebar.remove_assignment") } } do %>
|
|
192
194
|
<i class="bi bi-x"></i>
|
|
193
195
|
<% end %>
|
|
194
196
|
</div>
|
|
@@ -20,17 +20,11 @@
|
|
|
20
20
|
</div>
|
|
21
21
|
|
|
22
22
|
<% if repo_link %>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# sentence around the name is translated.
|
|
26
|
-
repo_host = if repo_link.include?("github") then "GitHub"
|
|
27
|
-
elsif repo_link.include?("gitlab") then "GitLab"
|
|
28
|
-
else "Bitbucket"
|
|
29
|
-
end
|
|
30
|
-
%>
|
|
23
|
+
<%# Repository host names are brands and stay English; only the
|
|
24
|
+
sentence around the name is translated. %>
|
|
31
25
|
<%= link_to repo_link, target: "_blank", rel: "noopener",
|
|
32
26
|
class: "btn btn-sm btn-primary",
|
|
33
|
-
title: red_t("red.errors.source_code.view_on", host:
|
|
27
|
+
title: red_t("red.errors.source_code.view_on", host: repository_host_label(repo_link)) do %>
|
|
34
28
|
<i class="bi bi-box-arrow-up-right"></i> <%= red_t("red.errors.source_code.view_source") %>
|
|
35
29
|
<% end %>
|
|
36
30
|
<% end %>
|
|
@@ -20,6 +20,34 @@
|
|
|
20
20
|
</div>
|
|
21
21
|
</div>
|
|
22
22
|
|
|
23
|
+
<% probe = @storage_probe || {} %>
|
|
24
|
+
<% if probe[:status] && probe[:status] != "skipped" %>
|
|
25
|
+
<% reachable = probe[:status] == "ok" %>
|
|
26
|
+
<div class="card mb-4 border-<%= reachable ? "success" : "danger" %>">
|
|
27
|
+
<div class="card-body d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
28
|
+
<div>
|
|
29
|
+
<h6 class="mb-1">
|
|
30
|
+
<i class="bi <%= reachable ? "bi-check-circle-fill text-success" : "bi-x-circle-fill text-danger" %> me-1"></i>
|
|
31
|
+
<%= red_t("red.health.activestorage.probe.title") %>
|
|
32
|
+
<span class="badge <%= reachable ? "bg-success" : "bg-danger" %> ms-1">
|
|
33
|
+
<%= reachable ? red_t("red.health.activestorage.probe.reachable") : red_t("red.health.activestorage.probe.unreachable") %>
|
|
34
|
+
</span>
|
|
35
|
+
</h6>
|
|
36
|
+
<small class="text-muted">
|
|
37
|
+
<%# The service name comes from config/storage.yml and the error is an exception class: machine values, shown as-is. %>
|
|
38
|
+
<%= red_t("red.health.activestorage.probe.service", name: probe[:service].presence || "?") %>
|
|
39
|
+
<% if reachable %>
|
|
40
|
+
· <%= red_t("red.health.activestorage.probe.latency", ms: probe[:latency_ms]) %>
|
|
41
|
+
<% else %>
|
|
42
|
+
· <code><%= probe[:error] %></code>
|
|
43
|
+
<% end %>
|
|
44
|
+
</small>
|
|
45
|
+
</div>
|
|
46
|
+
<small class="text-muted"><%= red_t("red.health.activestorage.probe.how") %></small>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
50
|
+
|
|
23
51
|
<% if @unique_services == 0 %>
|
|
24
52
|
<div class="red-empty-state">
|
|
25
53
|
<i class="bi bi-cloud-arrow-up display-1 text-success mb-3"></i>
|