rails_error_dashboard 0.9.1 → 0.11.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 +16 -3
- data/app/controllers/rails_error_dashboard/errors_controller.rb +9 -0
- data/app/helpers/rails_error_dashboard/i18n_helper.rb +26 -0
- data/app/jobs/rails_error_dashboard/rack_attack_flush_job.rb +7 -3
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +8 -1
- data/app/models/rails_error_dashboard/error_log.rb +1 -0
- data/app/models/rails_error_dashboard/rack_attack_event.rb +6 -2
- data/app/views/layouts/rails_error_dashboard.html.erb +85 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +5 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +8 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +9 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +58 -13
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +4 -4
- data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/index.html.erb +8 -1
- data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +36 -3
- data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
- data/config/locales/de.yml +22 -0
- data/config/locales/en.yml +24 -0
- data/config/locales/es.yml +21 -0
- data/config/locales/fr.yml +22 -0
- data/config/locales/it.yml +30 -1
- data/config/locales/ja.yml +19 -0
- data/config/locales/pl.yml +22 -0
- data/config/locales/pt-BR.yml +21 -0
- data/config/locales/ru.yml +21 -0
- data/config/locales/uk.yml +21 -0
- data/config/locales/zh-CN.yml +17 -0
- data/db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb +19 -0
- data/db/migrate/20260826000001_add_environment_to_error_logs.rb +26 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +11 -0
- data/lib/rails_error_dashboard/commands/backfill_environments.rb +58 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +55 -29
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +12 -3
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +40 -9
- data/lib/rails_error_dashboard/commands/log_error.rb +17 -0
- data/lib/rails_error_dashboard/configuration.rb +52 -0
- data/lib/rails_error_dashboard/engine.rb +7 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +9 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
- data/lib/rails_error_dashboard/queries/filter_options.rb +9 -0
- data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +31 -5
- data/lib/rails_error_dashboard/services/ai_agent_classifier.rb +154 -0
- data/lib/rails_error_dashboard/services/discord_payload_builder.rb +8 -0
- data/lib/rails_error_dashboard/services/error_broadcaster.rb +6 -2
- data/lib/rails_error_dashboard/services/issue_body_formatter.rb +1 -0
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +1 -0
- data/lib/rails_error_dashboard/services/notification_throttler.rb +23 -0
- data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +1 -0
- data/lib/rails_error_dashboard/services/rack_attack_tracker.rb +91 -9
- data/lib/rails_error_dashboard/services/slack_payload_builder.rb +9 -0
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -0
- data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +1 -0
- data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +40 -2
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +2 -0
- data/lib/tasks/rails_error_dashboard_tasks.rake +6 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: defed3f836bc0e812c76abfb81b95e5e3f229e5338bfa40cde4604643eb95582
|
|
4
|
+
data.tar.gz: 7657043dbf617694e5318a5b0e2829ecca78ade0c67412ede16fbecabc696c36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79992d38956c257a116c1cfcb153429cbf3a082892bc71664c20ba57e801ba33325ae69f43bd0ef8404a3cd08a6d5df9727a989a0f8977826786a179ad70b448
|
|
7
|
+
data.tar.gz: 3bbcc477eff8db567415c727037a58d7e27bd8e01ad602674beb05cd6891f75bb06e665273698fed1b49c20806e4f07dbea586647583f6b7da3052a744d58fae
|
data/README.md
CHANGED
|
@@ -23,7 +23,7 @@ gem 'rails_error_dashboard'
|
|
|
23
23
|
|
|
24
24
|
**[rails-error-dashboard.anjan.dev](https://rails-error-dashboard.anjan.dev)** — Username: `gandalf` · Password: `youshallnotpass`
|
|
25
25
|
|
|
26
|
-
> **Beta Software** — Functional and tested (
|
|
26
|
+
> **Beta Software** — Functional and tested (4,200+ tests passing), but the API may change before v1.0. Supports Rails 7.0-8.1 and Ruby 3.2-4.0.
|
|
27
27
|
|
|
28
28
|
### Screenshots
|
|
29
29
|
|
|
@@ -372,7 +372,7 @@ config.llm_model = "gpt-5"
|
|
|
372
372
|
<details>
|
|
373
373
|
<summary><strong>Notifications — Slack, Discord, PagerDuty, Email, Webhooks</strong></summary>
|
|
374
374
|
|
|
375
|
-
Multi-channel alerting with severity filters, per-error cooldown,
|
|
375
|
+
Multi-channel alerting with severity filters, per-error cooldown, milestone threshold alerts, and a per-environment allowlist (`config.notification_environments = %w[production]`) so a staging deploy never pages anyone.
|
|
376
376
|
|
|
377
377
|
```ruby
|
|
378
378
|
config.enable_slack_notifications = true
|
|
@@ -382,6 +382,19 @@ config.slack_webhook_url = ENV['SLACK_WEBHOOK_URL']
|
|
|
382
382
|
[Notification setup guide →](docs/guides/NOTIFICATIONS.md)
|
|
383
383
|
</details>
|
|
384
384
|
|
|
385
|
+
<details>
|
|
386
|
+
<summary><strong>Environment Awareness — Filter, Badge, Notify per Environment</strong></summary>
|
|
387
|
+
|
|
388
|
+
Every error records the environment it came from — `production`, `staging`, `uat`, `preprod`, any name your deploys use. The errors index filters by it, rows and the detail page carry a badge, the analytics page breaks errors down by environment, and every notification names it. The same error in staging and production is two rows with independent status, so resolving one never hides the other.
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
config.environment = ENV.fetch("ERROR_DASHBOARD_ENVIRONMENT", Rails.env) # free-form, defaults to Rails.env
|
|
392
|
+
config.notification_environments = %w[production] # nil = notify everywhere
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Errors captured before v0.11.0 show no badge until they recur (the next occurrence claims the row) or you run `rails rails_error_dashboard:backfill_environments`.
|
|
396
|
+
</details>
|
|
397
|
+
|
|
385
398
|
<details>
|
|
386
399
|
<summary><strong>Advanced Analytics</strong></summary>
|
|
387
400
|
|
|
@@ -709,7 +722,7 @@ Built with **CQRS (Command/Query Responsibility Segregation)**:
|
|
|
709
722
|
|
|
710
723
|
## Testing
|
|
711
724
|
|
|
712
|
-
|
|
725
|
+
4,200+ tests covering unit, integration, and browser-based system tests.
|
|
713
726
|
|
|
714
727
|
```bash
|
|
715
728
|
bundle exec rspec # Full suite
|
|
@@ -11,6 +11,7 @@ module RailsErrorDashboard
|
|
|
11
11
|
error_type
|
|
12
12
|
unresolved
|
|
13
13
|
platform
|
|
14
|
+
environment
|
|
14
15
|
application_id
|
|
15
16
|
search
|
|
16
17
|
severity
|
|
@@ -94,6 +95,7 @@ module RailsErrorDashboard
|
|
|
94
95
|
filter_options = Queries::FilterOptions.call(application_id: @current_application_id)
|
|
95
96
|
@error_types = filter_options[:error_types]
|
|
96
97
|
@platforms = filter_options[:platforms]
|
|
98
|
+
@environments = filter_options[:environments]
|
|
97
99
|
@assignees = filter_options[:assignees]
|
|
98
100
|
end
|
|
99
101
|
|
|
@@ -242,6 +244,7 @@ module RailsErrorDashboard
|
|
|
242
244
|
@errors_over_time = analytics[:errors_over_time]
|
|
243
245
|
@errors_by_type = analytics[:errors_by_type]
|
|
244
246
|
@errors_by_platform = analytics[:errors_by_platform]
|
|
247
|
+
@errors_by_environment = analytics[:errors_by_environment]
|
|
245
248
|
@errors_by_hour = analytics[:errors_by_hour]
|
|
246
249
|
@top_users = analytics[:top_users]
|
|
247
250
|
@resolution_rate = analytics[:resolution_rate]
|
|
@@ -535,6 +538,12 @@ module RailsErrorDashboard
|
|
|
535
538
|
@unique_rules = all_events.size
|
|
536
539
|
@total_events = all_events.sum { |e| e[:count] }
|
|
537
540
|
@unique_ips = all_events.flat_map { |e| e[:ips] }.uniq.size
|
|
541
|
+
# Events matched by a recognised AI agent. Counts requests, not addresses —
|
|
542
|
+
# one agent rotates through many IPs, so unique_ips overstates it (#170).
|
|
543
|
+
@ai_events = all_events.sum { |e| e[:ai_count].to_i }
|
|
544
|
+
# Counts the tracker's LRU eviction dropped; shown so the totals above are
|
|
545
|
+
# never silently understated.
|
|
546
|
+
@overflow_count = result[:overflow_count].to_i
|
|
538
547
|
|
|
539
548
|
@pagy, @events = pagy(:offset, all_events, limit: params[:per_page] || 25)
|
|
540
549
|
end
|
|
@@ -150,5 +150,31 @@ module RailsErrorDashboard
|
|
|
150
150
|
rescue StandardError
|
|
151
151
|
"%B %d, %Y %I:%M:%S %p"
|
|
152
152
|
end
|
|
153
|
+
|
|
154
|
+
# A finished, localized date string for somewhere the browser will not
|
|
155
|
+
# re-render it.
|
|
156
|
+
#
|
|
157
|
+
# Almost every timestamp on the dashboard goes out as a <span data-format>
|
|
158
|
+
# that formatDateTime() localizes client-side. Chart labels cannot: they
|
|
159
|
+
# are serialized into a JS array and handed to Chart.js as plain strings,
|
|
160
|
+
# so whatever the server writes is what the axis shows. Calling strftime
|
|
161
|
+
# directly there is what put English month names on every locale's charts
|
|
162
|
+
# (#178) — Ruby's strftime is not locale-aware.
|
|
163
|
+
#
|
|
164
|
+
# Same formatter the mailers and the Slack/Discord payloads use, so a chart
|
|
165
|
+
# axis and an email agree about what a date looks like.
|
|
166
|
+
#
|
|
167
|
+
# @param time [Time, Date, nil]
|
|
168
|
+
# @param pattern [String] a strftime pattern
|
|
169
|
+
# @return [String] "" for nil — an axis label must never read "undefined"
|
|
170
|
+
def red_chart_date(time, pattern)
|
|
171
|
+
return "" if time.nil?
|
|
172
|
+
|
|
173
|
+
time = time.to_time if time.respond_to?(:to_time) && !time.is_a?(Time)
|
|
174
|
+
Services::LocalizedTimeFormatter.call(time, pattern: pattern, locale: red_locale)
|
|
175
|
+
rescue StandardError
|
|
176
|
+
# A chart with an English axis beats a chart that fails to render.
|
|
177
|
+
time.respond_to?(:strftime) ? time.strftime(pattern) : time.to_s
|
|
178
|
+
end
|
|
153
179
|
end
|
|
154
180
|
end
|
|
@@ -22,9 +22,13 @@ module RailsErrorDashboard
|
|
|
22
22
|
# Mode 1: Persist provided snapshot (dispatched from tracker flush)
|
|
23
23
|
Commands::FlushRackAttackEvents.call(counts: counts)
|
|
24
24
|
else
|
|
25
|
-
# Mode 2: Flush
|
|
26
|
-
#
|
|
27
|
-
|
|
25
|
+
# Mode 2: Flush EVERY live thread's buffer (scheduled cron safety net).
|
|
26
|
+
#
|
|
27
|
+
# This used to call flush!, which only ever sees Thread.current — the
|
|
28
|
+
# job worker's own buffer, which is always empty. The counts live on the
|
|
29
|
+
# Puma threads that served the requests, so the documented safety net
|
|
30
|
+
# swept nothing. flush_all_threads! is what makes the promise true.
|
|
31
|
+
Services::RackAttackTracker.flush_all_threads!
|
|
28
32
|
end
|
|
29
33
|
end
|
|
30
34
|
end
|
|
@@ -20,12 +20,19 @@ module RailsErrorDashboard
|
|
|
20
20
|
# The subject is assembled from a key rather than interpolated inline, so a
|
|
21
21
|
# locale can reorder or drop the emoji. error_type and message are verbatim
|
|
22
22
|
# error content and are never translated.
|
|
23
|
+
#
|
|
24
|
+
# An error that carries an environment uses subject_with_environment; a
|
|
25
|
+
# legacy row keeps the pre-0.11 subject so existing mail filters still match.
|
|
23
26
|
def subject_for(error_log, locale)
|
|
27
|
+
environment = error_log.respond_to?(:environment) ? error_log.environment : nil
|
|
28
|
+
key = environment.present? ? "red.mailers.error_alert.subject_with_environment" : "red.mailers.error_alert.subject"
|
|
29
|
+
|
|
24
30
|
I18nStore.translate(
|
|
25
|
-
|
|
31
|
+
key,
|
|
26
32
|
locale: locale,
|
|
27
33
|
application: error_log.application&.name ||
|
|
28
34
|
I18nStore.translate("red.mailers.shared.unknown_application", locale: locale),
|
|
35
|
+
environment: environment,
|
|
29
36
|
error_type: error_log.error_type,
|
|
30
37
|
message: truncate_subject(error_log.message)
|
|
31
38
|
)
|
|
@@ -61,6 +61,7 @@ module RailsErrorDashboard
|
|
|
61
61
|
scope :by_error_type, ->(type) { where(error_type: type) }
|
|
62
62
|
scope :by_type, ->(type) { where(error_type: type) }
|
|
63
63
|
scope :by_platform, ->(platform) { where(platform: platform) }
|
|
64
|
+
scope :by_environment, ->(environment) { where(environment: environment) }
|
|
64
65
|
scope :last_24_hours, -> { where("occurred_at >= ?", 24.hours.ago) }
|
|
65
66
|
scope :last_week, -> { where("occurred_at >= ?", 1.week.ago) }
|
|
66
67
|
|
|
@@ -12,8 +12,12 @@ module RailsErrorDashboard
|
|
|
12
12
|
class RackAttackEvent < ErrorLogsRecord
|
|
13
13
|
self.table_name = "rails_error_dashboard_rack_attack_events"
|
|
14
14
|
|
|
15
|
-
# Event types emitted by Rack::Attack (v5.0+)
|
|
16
|
-
|
|
15
|
+
# Event types emitted by Rack::Attack (v5.0+), plus "overflow" — not a
|
|
16
|
+
# Rack::Attack event at all, but a synthetic bucket holding counts dropped
|
|
17
|
+
# by the tracker's LRU eviction so totals stay truthful. Overflow rows are
|
|
18
|
+
# excluded from the per-rule listing and surfaced separately.
|
|
19
|
+
MATCH_TYPES = %w[throttle blocklist track safelist overflow].freeze
|
|
20
|
+
OVERFLOW_MATCH_TYPE = "overflow"
|
|
17
21
|
|
|
18
22
|
belongs_to :application, optional: true
|
|
19
23
|
|
|
@@ -660,7 +660,16 @@ dd { color: var(--text-primary); }
|
|
|
660
660
|
height: 48px; flex-shrink: 0;
|
|
661
661
|
background: var(--surface-primary);
|
|
662
662
|
border-bottom: 1px solid var(--border-primary);
|
|
663
|
+
/* A flex item's default min-width is auto, meaning "never shrink below my
|
|
664
|
+
content". Both navbar groups therefore kept their full intrinsic width on a
|
|
665
|
+
narrow screen and pushed the right-hand controls past the viewport — the
|
|
666
|
+
dashboard scrolled sideways at 375px in EVERY locale, English included.
|
|
667
|
+
min-width:0 lets them shrink; overflow:hidden keeps whatever still cannot
|
|
668
|
+
fit from extending the page rather than being clipped inside the bar. */
|
|
669
|
+
min-width: 0;
|
|
670
|
+
overflow: hidden;
|
|
663
671
|
}
|
|
672
|
+
.red-navbar > * { min-width: 0; }
|
|
664
673
|
.red-search-input {
|
|
665
674
|
width: 240px; padding: 5px 10px 5px 30px; font-size: 13px;
|
|
666
675
|
border: 1px solid var(--border-primary); border-radius: var(--radius-sm);
|
|
@@ -720,6 +729,21 @@ dd { color: var(--text-primary); }
|
|
|
720
729
|
.red-sidebar.collapsed { display: none; }
|
|
721
730
|
}
|
|
722
731
|
|
|
732
|
+
/* Stat grids
|
|
733
|
+
These were inline `grid-template-columns: repeat(N, 1fr)`, which cannot carry
|
|
734
|
+
a media query, so three or four cards stayed side by side at 375px and pushed
|
|
735
|
+
the page to 488px — it scrolled sideways on a phone in EVERY locale, English
|
|
736
|
+
worst of all. auto-fit + minmax lets the row reflow to two columns and then
|
|
737
|
+
one, and minmax's 0 floor stops a long word forcing a track wider than the
|
|
738
|
+
screen. */
|
|
739
|
+
.red-stat-grid {
|
|
740
|
+
display: grid;
|
|
741
|
+
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
|
742
|
+
gap: var(--space-4);
|
|
743
|
+
margin-bottom: var(--space-6);
|
|
744
|
+
}
|
|
745
|
+
.red-stat-grid > * { min-width: 0; }
|
|
746
|
+
|
|
723
747
|
/* Error detail responsive grid */
|
|
724
748
|
.red-detail-grid { display: grid; grid-template-columns: 1fr 260px; gap: var(--space-4); }
|
|
725
749
|
@media (max-width: 1023.98px) { .red-detail-grid { grid-template-columns: 1fr; } }
|
|
@@ -729,7 +753,13 @@ dd { color: var(--text-primary); }
|
|
|
729
753
|
padding: 3px 10px; font-size: 11px; font-weight: 600;
|
|
730
754
|
border-radius: var(--radius-full);
|
|
731
755
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
756
|
+
white-space: nowrap;
|
|
732
757
|
}
|
|
758
|
+
/* Below the sidebar breakpoint the navbar has to fit a menu button, search,
|
|
759
|
+
language picker, shortcuts, theme toggle and the env badge. The badge is
|
|
760
|
+
decoration — the environment is also on the overview page — so it is the
|
|
761
|
+
first thing dropped rather than letting it push the page sideways. */
|
|
762
|
+
@media (max-width: 575.98px) { .red-env-badge { display: none; } }
|
|
733
763
|
|
|
734
764
|
/* Kbd shortcut */
|
|
735
765
|
kbd {
|
|
@@ -1751,6 +1781,51 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
1751
1781
|
.replace('%H',pad(h)).replace('%I',pad(h12)).replace('%M',pad(mi)).replace('%S',pad(s)).replace('%p',ampm).replace('%P',ampm.toLowerCase());
|
|
1752
1782
|
}
|
|
1753
1783
|
|
|
1784
|
+
// Chart.js renders date axes through chartjs-adapter-date-fns, whose bundle
|
|
1785
|
+
// ships English locale data only. Loading eleven date-fns locales from a CDN
|
|
1786
|
+
// to fix that would be absurd when RED already carries localized month and
|
|
1787
|
+
// day names in red.js.* for formatDateTime(). So the adapter's format() is
|
|
1788
|
+
// redirected through the same vocabulary the rest of the dashboard uses, and
|
|
1789
|
+
// a chart axis, a page timestamp and an email now agree.
|
|
1790
|
+
//
|
|
1791
|
+
// Only the tokens the adapter's own format table can emit are mapped:
|
|
1792
|
+
// datetime "MMM d, yyyy, h:mm:ss aaaa" millisecond "h:mm:ss.SSS aaaa"
|
|
1793
|
+
// second "h:mm:ss aaaa" minute "h:mm aaaa" hour "ha" day "MMM d"
|
|
1794
|
+
// week "PP" month "MMM yyyy" quarter "qqq - yyyy" year "yyyy"
|
|
1795
|
+
// Anything else — a caller-supplied pattern, a future adapter version — is
|
|
1796
|
+
// handed back to the original implementation rather than half-translated.
|
|
1797
|
+
function installRedChartDateAdapter() {
|
|
1798
|
+
if (typeof Chart === 'undefined' || !Chart._adapters || !Chart._adapters._date) return;
|
|
1799
|
+
var proto = Chart._adapters._date.prototype;
|
|
1800
|
+
if (!proto || typeof proto.format !== 'function' || proto._redPatched) return;
|
|
1801
|
+
|
|
1802
|
+
// date-fns token -> strftime, for the patterns the adapter actually uses.
|
|
1803
|
+
var TOKENS = {
|
|
1804
|
+
'MMM d, yyyy, h:mm:ss aaaa': '%b %d, %Y, %I:%M:%S %p',
|
|
1805
|
+
'h:mm:ss.SSS aaaa': '%I:%M:%S %p',
|
|
1806
|
+
'h:mm:ss aaaa': '%I:%M:%S %p',
|
|
1807
|
+
'h:mm aaaa': '%I:%M %p',
|
|
1808
|
+
'ha': '%I %p',
|
|
1809
|
+
'MMM d': '%b %d',
|
|
1810
|
+
'PP': '%b %d, %Y',
|
|
1811
|
+
'MMM yyyy': '%b %Y',
|
|
1812
|
+
'yyyy': '%Y'
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
var original = proto.format;
|
|
1816
|
+
proto.format = function(time, fmt) {
|
|
1817
|
+
try {
|
|
1818
|
+
var strf = TOKENS[fmt];
|
|
1819
|
+
if (strf) return formatDateTime(new Date(time), strf);
|
|
1820
|
+
} catch (e) {
|
|
1821
|
+
// Fall through — a chart with English axes beats a chart that throws.
|
|
1822
|
+
}
|
|
1823
|
+
return original.call(this, time, fmt);
|
|
1824
|
+
};
|
|
1825
|
+
proto._redPatched = true;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
|
|
1754
1829
|
function getTimezoneAbbreviation(date) {
|
|
1755
1830
|
// RED's locale set and the browser's Intl data need not agree, so an
|
|
1756
1831
|
// unsupported tag throws RangeError here rather than degrading. Catch it
|
|
@@ -1801,6 +1876,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
1801
1876
|
}
|
|
1802
1877
|
|
|
1803
1878
|
convertToLocalTime();
|
|
1879
|
+
|
|
1880
|
+
// Localize Chart.js date axes. Kept out of the block above on purpose: that
|
|
1881
|
+
// region is extracted and run in bare node by js_date_parity_spec, where
|
|
1882
|
+
// `document` is a stub, so it must contain definitions only — no top-level
|
|
1883
|
+
// browser calls. The adapter is patched on the prototype, so charts drawn
|
|
1884
|
+
// later pick it up; chartkick:load re-asserts for page scripts whose own
|
|
1885
|
+
// DOMContentLoaded handler beat this one, and _redPatched makes that a no-op.
|
|
1886
|
+
installRedChartDateAdapter();
|
|
1887
|
+
document.addEventListener('chartkick:load', installRedChartDateAdapter);
|
|
1888
|
+
|
|
1804
1889
|
if (typeof Turbo !== 'undefined') {
|
|
1805
1890
|
document.addEventListener('turbo:load', convertToLocalTime);
|
|
1806
1891
|
document.addEventListener('turbo:frame-load', convertToLocalTime);
|
|
@@ -148,6 +148,11 @@
|
|
|
148
148
|
</div>
|
|
149
149
|
<% end %>
|
|
150
150
|
|
|
151
|
+
<% if @error_log.respond_to?(:environment) && @error_log.environment.present? %>
|
|
152
|
+
<div class="label"><%= red_t("red.mailers.error_alert.labels.environment") %>:</div>
|
|
153
|
+
<div class="value"><strong><%= @error_log.environment %></strong></div>
|
|
154
|
+
<% end %>
|
|
155
|
+
|
|
151
156
|
<div class="label"><%= red_t("red.mailers.error_alert.labels.occurred_at") %>:</div>
|
|
152
157
|
<div class="value"><%= red_mail_time(@error_log.occurred_at) %></div>
|
|
153
158
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
<%= red_mail_t("red.mailers.error_alert.labels.application") %>: <%= @error_log.application&.name || red_mail_t("red.mailers.shared.unknown_application") %>
|
|
11
11
|
<%= red_mail_t("red.mailers.error_alert.labels.error_type") %>: <%= @error_log.error_type %>
|
|
12
12
|
<% if @error_log.platform.present? %><%= red_mail_t("red.mailers.error_alert.labels.platform") %>: <%= @error_log.platform %><% end %>
|
|
13
|
+
<% if @error_log.respond_to?(:environment) && @error_log.environment.present? %><%= red_mail_t("red.mailers.error_alert.labels.environment") %>: <%= @error_log.environment %><% end %>
|
|
13
14
|
<%= red_mail_t("red.mailers.error_alert.labels.occurred_at") %>: <%= red_mail_time(@error_log.occurred_at) %>
|
|
14
15
|
<% if @error_log.user_id.present? %><%= red_mail_t("red.mailers.error_alert.labels.user_id") %>: <%= @error_log.user_id %><% end %>
|
|
15
16
|
<% if @error_log.ip_address.present? %><%= red_mail_t("red.mailers.error_alert.labels.ip_address") %>: <%= @error_log.ip_address %><% end %>
|
|
@@ -99,4 +99,12 @@
|
|
|
99
99
|
<% end %>
|
|
100
100
|
</td>
|
|
101
101
|
<% end %>
|
|
102
|
+
<% if local_assigns[:show_environment] %>
|
|
103
|
+
<%# Environment names are free-form data (production, staging, uat...) and are never translated. %>
|
|
104
|
+
<td style="padding: var(--space-3) var(--space-4); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="<%= error.environment %>">
|
|
105
|
+
<% if error.environment.present? %>
|
|
106
|
+
<span class="badge badge-api" data-environment-badge="<%= error.environment %>"><i class="bi bi-hdd-stack"></i> <%= error.environment %></span>
|
|
107
|
+
<% end %>
|
|
108
|
+
</td>
|
|
109
|
+
<% end %>
|
|
102
110
|
</tr>
|
|
@@ -70,6 +70,15 @@
|
|
|
70
70
|
<% end %>
|
|
71
71
|
</div>
|
|
72
72
|
|
|
73
|
+
<% if error.respond_to?(:environment) && error.environment.present? %>
|
|
74
|
+
<div class="mb-3">
|
|
75
|
+
<small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.environment") %></small>
|
|
76
|
+
<%= link_to errors_path(environment: error.environment, unresolved: '0'), class: "text-decoration-none", title: red_t("red.errors.sidebar.view_all_environment", environment: error.environment) do %>
|
|
77
|
+
<span class="badge badge-api" data-environment-badge="<%= error.environment %>"><i class="bi bi-hdd-stack"></i> <%= error.environment %></span>
|
|
78
|
+
<% end %>
|
|
79
|
+
</div>
|
|
80
|
+
<% end %>
|
|
81
|
+
|
|
73
82
|
<div class="mb-3">
|
|
74
83
|
<small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.user") %></small>
|
|
75
84
|
<% if error.respond_to?(:user) && error.user %>
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
</div>
|
|
46
46
|
|
|
47
47
|
<!-- Summary Stats -->
|
|
48
|
-
<div
|
|
48
|
+
<div class="red-stat-grid">
|
|
49
49
|
<div class="card stat-card" style="border-left: 3px solid var(--accent); padding: var(--space-5) var(--space-6);">
|
|
50
50
|
<div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.analytics.overview.stats.total_errors") %></div>
|
|
51
51
|
<span class="stat-value"><%= @error_stats[:total] %></span>
|
|
@@ -75,8 +75,6 @@
|
|
|
75
75
|
curve: false,
|
|
76
76
|
points: true,
|
|
77
77
|
height: "300px",
|
|
78
|
-
xtitle: "Date",
|
|
79
|
-
ytitle: "Number of Errors",
|
|
80
78
|
library: {
|
|
81
79
|
scales: {
|
|
82
80
|
x: {
|
|
@@ -157,6 +155,56 @@
|
|
|
157
155
|
</div>
|
|
158
156
|
<% end %>
|
|
159
157
|
|
|
158
|
+
<%# Errors by environment: same shape as the platform chart, shown only when
|
|
159
|
+
there is something to compare. Environment names are data, not text to
|
|
160
|
+
translate; NULL rows (captured before the column existed) are :unknown. %>
|
|
161
|
+
<% environment_counts = (@errors_by_environment || {}).transform_keys { |env| env == :unknown ? red_t("red.analytics.overview.environment_chart.unknown") : env } %>
|
|
162
|
+
<% if environment_counts.size > 1 %>
|
|
163
|
+
<div class="row g-4 mb-4">
|
|
164
|
+
<div class="col-md-12">
|
|
165
|
+
<div class="card">
|
|
166
|
+
<div class="card-header">
|
|
167
|
+
<h5 class="mb-0"><i class="bi bi-hdd-stack"></i> <%= red_t("red.analytics.overview.environment_chart.title") %></h5>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="card-body">
|
|
170
|
+
<div id="errors-by-environment-chart"></div>
|
|
171
|
+
<script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
|
|
172
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
173
|
+
const colors = window.getChartColors();
|
|
174
|
+
const environmentColors = ["#EF4444", "#F59E0B", "#2563EB", "#10B981", "#8B5CF6", "#6B7280"];
|
|
175
|
+
|
|
176
|
+
new Chartkick.PieChart("errors-by-environment-chart", <%= raw environment_counts.to_json %>, {
|
|
177
|
+
colors: environmentColors,
|
|
178
|
+
height: "300px",
|
|
179
|
+
legend: "bottom",
|
|
180
|
+
donut: true,
|
|
181
|
+
library: {
|
|
182
|
+
plugins: {
|
|
183
|
+
legend: {
|
|
184
|
+
labels: {
|
|
185
|
+
color: colors.textColor,
|
|
186
|
+
font: { size: 12, weight: 'bold' }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
</script>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="card-footer border-top-0">
|
|
196
|
+
<div class="d-flex gap-2 flex-wrap">
|
|
197
|
+
<small class="text-muted me-2"><%= red_t("red.analytics.overview.environment_chart.quick_links") %></small>
|
|
198
|
+
<% (@errors_by_environment || {}).keys.reject { |env| env == :unknown }.each do |environment| %>
|
|
199
|
+
<%= link_to environment, errors_path(environment: environment, unresolved: '0'), class: "btn btn-sm btn-outline-secondary" %>
|
|
200
|
+
<% end %>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
<% end %>
|
|
207
|
+
|
|
160
208
|
<!-- Charts Row 2 -->
|
|
161
209
|
<div class="row g-4 mb-4">
|
|
162
210
|
<div class="col-md-12">
|
|
@@ -172,18 +220,19 @@
|
|
|
172
220
|
new Chartkick.BarChart("errors-by-type-chart", <%= raw @errors_by_type.to_json %>, {
|
|
173
221
|
color: "#EF4444",
|
|
174
222
|
height: "400px",
|
|
175
|
-
xtitle: "Error Type",
|
|
176
|
-
ytitle: "Count",
|
|
177
223
|
library: {
|
|
178
224
|
scales: {
|
|
225
|
+
// Chartkick's BarChart is horizontal (it sets indexAxis:"y"),
|
|
226
|
+
// so the categories sit on y and the counts on x — the
|
|
227
|
+
// opposite of the ColumnCharts on this page. #178.
|
|
179
228
|
x: {
|
|
180
229
|
ticks: { color: colors.textColor },
|
|
181
|
-
title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.
|
|
230
|
+
title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.axis_count") %>' },
|
|
182
231
|
grid: { color: colors.gridColor }
|
|
183
232
|
},
|
|
184
233
|
y: {
|
|
185
234
|
ticks: { color: colors.textColor },
|
|
186
|
-
title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.
|
|
235
|
+
title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.axis_error_type") %>' },
|
|
187
236
|
grid: { color: colors.gridColor }
|
|
188
237
|
}
|
|
189
238
|
},
|
|
@@ -214,8 +263,6 @@
|
|
|
214
263
|
new Chartkick.ColumnChart("errors-by-hour-chart", <%= raw @errors_by_hour.to_json %>, {
|
|
215
264
|
color: "#DC2626",
|
|
216
265
|
height: "300px",
|
|
217
|
-
xtitle: "Hour",
|
|
218
|
-
ytitle: "Number of Errors",
|
|
219
266
|
library: {
|
|
220
267
|
scales: {
|
|
221
268
|
x: {
|
|
@@ -422,8 +469,6 @@
|
|
|
422
469
|
new Chartkick.ColumnChart("errors-by-version-chart", versionData, {
|
|
423
470
|
colors: ["#8B5CF6", "#EF4444", "#F59E0B", "#10B981"],
|
|
424
471
|
height: "300px",
|
|
425
|
-
xtitle: "Version",
|
|
426
|
-
ytitle: "Error Count",
|
|
427
472
|
library: window.getChartLibraryOptions()
|
|
428
473
|
});
|
|
429
474
|
});
|
|
@@ -558,7 +603,7 @@
|
|
|
558
603
|
const colors = window.getChartColors();
|
|
559
604
|
new Chartkick.BarChart("mttr-by-platform-chart",
|
|
560
605
|
<%= raw @mttr_by_platform.to_json %>, {
|
|
561
|
-
suffix: "
|
|
606
|
+
suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
|
|
562
607
|
height: "200px",
|
|
563
608
|
colors: ["#8B5CF6"],
|
|
564
609
|
library: window.getChartLibraryOptions()
|
|
@@ -611,7 +656,7 @@
|
|
|
611
656
|
const colors = window.getChartColors();
|
|
612
657
|
new Chartkick.LineChart("mttr-trend-chart",
|
|
613
658
|
<%= raw @mttr_stats[:mttr_trend].to_json %>, {
|
|
614
|
-
suffix: "
|
|
659
|
+
suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
|
|
615
660
|
height: "250px",
|
|
616
661
|
colors: ["#10B981"],
|
|
617
662
|
curve: false,
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
<div class="display-6"><%= @period_comparison[:current_period][:count] %></div>
|
|
37
37
|
<small class="text-muted">
|
|
38
38
|
<%= red_t("red.analytics.correlation.trend_analysis.period_range",
|
|
39
|
-
start: @period_comparison[:current_period][:start]
|
|
40
|
-
end: @period_comparison[:current_period][:end]
|
|
39
|
+
start: red_chart_date(@period_comparison[:current_period][:start], "%b %d"),
|
|
40
|
+
end: red_chart_date(@period_comparison[:current_period][:end], "%b %d")) %>
|
|
41
41
|
</small>
|
|
42
42
|
</div>
|
|
43
43
|
<div class="col-md-4">
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
<div class="display-6"><%= @period_comparison[:previous_period][:count] %></div>
|
|
46
46
|
<small class="text-muted">
|
|
47
47
|
<%= red_t("red.analytics.correlation.trend_analysis.period_range",
|
|
48
|
-
start: @period_comparison[:previous_period][:start]
|
|
49
|
-
end: @period_comparison[:previous_period][:end]
|
|
48
|
+
start: red_chart_date(@period_comparison[:previous_period][:start], "%b %d"),
|
|
49
|
+
end: red_chart_date(@period_comparison[:previous_period][:end], "%b %d")) %>
|
|
50
50
|
</small>
|
|
51
51
|
</div>
|
|
52
52
|
<div class="col-md-4">
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
<% if @live_health[:postgresql] %>
|
|
92
92
|
<%# Summary cards for PostgreSQL %>
|
|
93
93
|
<% total_db_size = (@host_tables + @gem_tables).sum { |t| t[:total_bytes] } %>
|
|
94
|
-
<div
|
|
94
|
+
<div class="red-stat-grid" style="margin-bottom: var(--space-4);">
|
|
95
95
|
<div class="card stat-card" style="padding: var(--space-4) var(--space-5); text-align: center;">
|
|
96
96
|
<div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.health.database.summary.total_db_size") %></div>
|
|
97
97
|
<div class="stat-value"><%= number_to_human_size(total_db_size) %></div>
|
|
@@ -157,6 +157,9 @@
|
|
|
157
157
|
<% if @platforms.size > 1 %>
|
|
158
158
|
<%= select_tag :platform, options_for_select([[red_t("red.errors.index.filters.all_platforms"), '']] + @platforms.map { |p| [p, p] }, params[:platform]), class: "form-select", style: "width: auto; min-width: 120px;" %>
|
|
159
159
|
<% end %>
|
|
160
|
+
<% if @environments.size > 1 %>
|
|
161
|
+
<%= select_tag :environment, options_for_select([[red_t("red.errors.index.filters.all_environments"), '']] + @environments.map { |e| [e, e] }, params[:environment]), class: "form-select", style: "width: auto; min-width: 120px;" %>
|
|
162
|
+
<% end %>
|
|
160
163
|
<%= select_tag :error_type, options_for_select([[red_t("red.errors.index.filters.all_types"), '']] + @error_types.map { |t| [t, t] }, params[:error_type]), class: "form-select", style: "width: auto; min-width: 120px;" %>
|
|
161
164
|
<%
|
|
162
165
|
# Built from the same keys the active chips read, so a label can never
|
|
@@ -217,6 +220,7 @@
|
|
|
217
220
|
active_filters << { label: red_t("red.errors.index.filters.chips.application", value: app_name), param: :application_id } if app_name
|
|
218
221
|
end
|
|
219
222
|
active_filters << { label: red_t("red.errors.index.filters.chips.platform", value: params[:platform]), param: :platform } if params[:platform].present?
|
|
223
|
+
active_filters << { label: red_t("red.errors.index.filters.chips.environment", value: params[:environment]), param: :environment } if params[:environment].present?
|
|
220
224
|
active_filters << { label: red_t("red.errors.index.filters.chips.error_type", value: params[:error_type]), param: :error_type } if params[:error_type].present?
|
|
221
225
|
active_filters << { label: red_t("red.errors.index.filters.chips.severity", value: severity_label.call(params[:severity])), param: :severity } if params[:severity].present?
|
|
222
226
|
active_filters << { label: red_t("red.errors.index.filters.chips.timeframe", value: timeframe_label.call(params[:timeframe])), param: :timeframe } if params[:timeframe].present?
|
|
@@ -287,11 +291,14 @@
|
|
|
287
291
|
<% if @platforms.size > 1 %>
|
|
288
292
|
<th style="padding: var(--space-3) var(--space-4); width: 100px;"><%= red_t("red.errors.index.columns.platform") %></th>
|
|
289
293
|
<% end %>
|
|
294
|
+
<% if @environments.size > 1 %>
|
|
295
|
+
<th style="padding: var(--space-3) var(--space-4); width: 110px;"><%= red_t("red.errors.index.columns.environment") %></th>
|
|
296
|
+
<% end %>
|
|
290
297
|
</tr>
|
|
291
298
|
</thead>
|
|
292
299
|
<tbody id="error_list">
|
|
293
300
|
<% @errors.each do |error| %>
|
|
294
|
-
<%= render "error_row", error: error, show_platform: @platforms.size > 1, show_application: (@applications.size > 1 && params[:application_id].blank?) %>
|
|
301
|
+
<%= render "error_row", error: error, show_platform: @platforms.size > 1, show_environment: @environments.size > 1, show_application: (@applications.size > 1 && params[:application_id].blank?) %>
|
|
295
302
|
<% end %>
|
|
296
303
|
</tbody>
|
|
297
304
|
</table>
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
trend_values = @stats[:errors_trend_7d].values rescue []
|
|
33
33
|
trend_max = [ trend_values.max || 1, 1 ].max
|
|
34
34
|
%>
|
|
35
|
-
<div
|
|
35
|
+
<div class="red-stat-grid">
|
|
36
36
|
<!-- Error Rate -->
|
|
37
37
|
<div class="card stat-card" style="border-left: 3px solid var(--status-critical); padding: var(--space-5) var(--space-6);">
|
|
38
38
|
<div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.errors.overview_page.error_rate") %></div>
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
</div>
|
|
90
90
|
|
|
91
91
|
<!-- Secondary Stats (3 cards) -->
|
|
92
|
-
<div
|
|
92
|
+
<div class="red-stat-grid">
|
|
93
93
|
<!-- Affected Users -->
|
|
94
94
|
<div class="card stat-card" style="padding: var(--space-5) var(--space-6);">
|
|
95
95
|
<div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.errors.overview_page.affected_users") %></div>
|
|
@@ -358,7 +358,7 @@
|
|
|
358
358
|
new Chart(dailyTrendCtx, {
|
|
359
359
|
type: 'line',
|
|
360
360
|
data: {
|
|
361
|
-
labels: <%= raw @daily_trends.values.first&.keys&.map { |d| d
|
|
361
|
+
labels: <%= raw @daily_trends.values.first&.keys&.map { |d| red_chart_date(d, '%b %d') }&.to_json || [].to_json %>,
|
|
362
362
|
datasets: datasets
|
|
363
363
|
},
|
|
364
364
|
options: {
|