rails_error_dashboard 0.13.0 → 0.14.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
  3. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +54 -0
  4. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +7 -4
  5. data/app/models/rails_error_dashboard/error_log.rb +10 -0
  6. data/app/models/rails_error_dashboard/event_count.rb +132 -0
  7. data/app/models/rails_error_dashboard/event_timing_gap.rb +55 -0
  8. data/app/views/layouts/rails_error_dashboard.html.erb +47 -2
  9. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +2 -0
  10. data/app/views/rails_error_dashboard/errors/overview.html.erb +12 -0
  11. data/app/views/rails_error_dashboard/errors/show.html.erb +3 -3
  12. data/config/locales/de.yml +2 -0
  13. data/config/locales/en.yml +2 -0
  14. data/config/locales/es.yml +2 -0
  15. data/config/locales/fr.yml +2 -0
  16. data/config/locales/it.yml +2 -0
  17. data/config/locales/ja.yml +2 -0
  18. data/config/locales/pl.yml +2 -0
  19. data/config/locales/pt-BR.yml +2 -0
  20. data/config/locales/ru.yml +2 -0
  21. data/config/locales/uk.yml +2 -0
  22. data/config/locales/zh-CN.yml +2 -0
  23. data/db/migrate/20260919000001_create_event_counts.rb +71 -0
  24. data/db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb +25 -0
  25. data/db/migrate/20260920000002_create_event_timing_gaps.rb +55 -0
  26. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +70 -4
  27. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +225 -8
  28. data/lib/rails_error_dashboard/commands/log_error.rb +213 -21
  29. data/lib/rails_error_dashboard/configuration.rb +20 -0
  30. data/lib/rails_error_dashboard/engine.rb +13 -0
  31. data/lib/rails_error_dashboard/manual_error_reporter.rb +16 -5
  32. data/lib/rails_error_dashboard/queries/analytics_stats.rb +85 -27
  33. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +167 -30
  34. data/lib/rails_error_dashboard/queries/event_volume.rb +503 -0
  35. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
  36. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +64 -6
  37. data/lib/rails_error_dashboard/services/variable_serializer.rb +125 -10
  38. data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +111 -0
  39. data/lib/rails_error_dashboard/value_objects/error_context.rb +37 -2
  40. data/lib/rails_error_dashboard/version.rb +1 -1
  41. data/lib/rails_error_dashboard.rb +3 -0
  42. metadata +8 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d762f92c5645173b17543925db4d5740d1294af6a2da64e0a9cef6b6032211b
4
- data.tar.gz: 056bca6e5ba6083666cf44302979bc8d3ff276d76563de87793051619b616994
3
+ metadata.gz: 15c3e5f83e8ce2990ce4649cd7c71be0542ca7149863a6bc85ff8505d5ee0a4d
4
+ data.tar.gz: ccd0115a385bfbfeab743f6e84a4ff77875f449a0adfc16c54b2fa0e21f43029
5
5
  SHA512:
6
- metadata.gz: 5383eafcd408b881b4721fc496df7077c387c6ecb7f10543dfe8570049dda45c2c408f59f2f0e10c2c385a448a8d1acc6719da0367a654b8e42455bd4c568797
7
- data.tar.gz: 6c77de41c4c43f81eab6f7a3bd59626ca2d48d002fe312c535c71e8b8ffd33ec5eddcbec2b3601a8d3d52aee25ceb65037fdf4d56497730f3d85b7a5c8b463e7
6
+ metadata.gz: 972ecd630a40e2aa58dc33e22492acafb4910310a078a6172458796b6073420a27f773e6709004bd6172eabc6db1acd0de9128f345fa3931a905ea59592e591b
7
+ data.tar.gz: a1b25a254a0154f94f260054ddcf7cedaf08039f4f62b234270eb9b7c0ffa84d03d0e09fb0213f7c53aa143200aa6edcfcd4916d54eb8495b6dcb375b8dca2c7
@@ -23,6 +23,16 @@ module RailsErrorDashboard
23
23
  context[:_serialized_cause_chain] = exception_data[:cause_chain]
24
24
  end
25
25
 
26
+ # The type as REPORTED, independent of whether a Ruby class of that name
27
+ # exists here. reconstruct_exception falls back to StandardError for an
28
+ # unconstantizable name -- which is the normal case for a frontend or
29
+ # mobile report -- and reading error_type off the reconstructed object
30
+ # then renamed every such error StandardError, collapsing distinct
31
+ # client errors into one group.
32
+ if exception_data[:class_name].present?
33
+ context[:_reported_error_type] = exception_data[:class_name]
34
+ end
35
+
26
36
  # Log the error synchronously in the background job.
27
37
  # .new(...).call bypasses the async check (we're already async);
28
38
  # worker: true makes an unreachable error store raise instead of being
@@ -47,6 +47,7 @@ module RailsErrorDashboard
47
47
  cleanup_storm_flush_batches(cutoff)
48
48
  cleanup_diagnostic_dumps(cutoff)
49
49
  cleanup_swallowed_exceptions(cutoff)
50
+ cleanup_event_timing_gaps(cutoff)
50
51
 
51
52
  expired_scope = self.class.expired_scope(cutoff)
52
53
  return 0 if expired_scope.none?
@@ -58,6 +59,12 @@ module RailsErrorDashboard
58
59
 
59
60
  # Batch delete dependent records (occurrences, comments, cascade patterns)
60
61
  ErrorOccurrence.where(error_log_id: expired_ids_scope).in_batches(of: 1000).delete_all
62
+ # Hour buckets for storm-shed events. Listed explicitly because this job
63
+ # deletes with delete_all, which does not fire the has_many :dependent
64
+ # callback on ErrorLog -- without this line the buckets outlive the group
65
+ # they describe, forever. The migration's own comment promised this
66
+ # pruning before the code existed.
67
+ EventCount.where(error_log_id: expired_ids_scope).in_batches(of: 1000).delete_all if EventCount.table_exists?
61
68
  ErrorComment.where(error_log_id: expired_ids_scope).in_batches(of: 1000).delete_all
62
69
  CascadePattern.where(parent_error_id: expired_ids_scope)
63
70
  .or(CascadePattern.where(child_error_id: expired_ids_scope))
@@ -153,6 +160,53 @@ module RailsErrorDashboard
153
160
  )
154
161
  end
155
162
 
163
+ # Timing gaps are pruned by their OWN age, not by a group.
164
+ #
165
+ # A gap describes an interval, not an error, so there is no error_log_id to
166
+ # cascade from -- without this it would accumulate for the life of the
167
+ # installation. It also runs ABOVE the early return in #perform, which
168
+ # fires whenever no error logs happen to be expired: gaps expire
169
+ # independently of errors, exactly like the rack-attack events whose
170
+ # comment already warns about this.
171
+ #
172
+ # Safe to prune on covered_until: once the cutoff has moved past a gap, no
173
+ # window the dashboard displays can still reach it, so the warning it
174
+ # carries is no longer meaningful. Own rescue, like its siblings.
175
+ def cleanup_event_timing_gaps(cutoff)
176
+ return unless EventTimingGap.table_exists?
177
+
178
+ # Kept for the LONGER of the two horizons that govern it.
179
+ #
180
+ # This used the configured retention cutoff alone, which is wrong
181
+ # whenever retention is shorter than the window the dashboard reports on:
182
+ # at retention_days = 7 the gap was deleted while the events it qualified
183
+ # were still on the page -- ten monthly events, no warning, group still
184
+ # active. A gap outlives its own retention precisely because the figures
185
+ # it qualifies do.
186
+ #
187
+ # Deleting it only once BOTH clocks have passed means the warning can
188
+ # never disappear while the numbers it describes are still displayed.
189
+ # The reverse case is unaffected: with the 90-day default the retention
190
+ # cutoff is already the later of the two, so nothing is kept longer than
191
+ # before.
192
+ gap_cutoff = [ cutoff, Queries::DashboardStats::WIDEST_DISPLAYED_WINDOW.ago ].min
193
+
194
+ deleted = 0
195
+ EventTimingGap.where("covered_until < ?", gap_cutoff).in_batches(of: 1000) do |batch|
196
+ deleted += batch.delete_all
197
+ end
198
+
199
+ if deleted > 0
200
+ RailsErrorDashboard::Logger.info(
201
+ "[RailsErrorDashboard] Retention cleanup: deleted #{deleted} event timing gaps"
202
+ )
203
+ end
204
+ rescue => e
205
+ RailsErrorDashboard::Logger.debug(
206
+ "[RailsErrorDashboard] Event timing gap retention cleanup failed: #{e.class} - #{e.message}"
207
+ )
208
+ end
209
+
156
210
  # Expire aggregated Rack Attack event rows. Isolated in its own rescue so a
157
211
  # failure here (e.g. table not yet migrated) never blocks error cleanup.
158
212
  def cleanup_rack_attack_events(cutoff)
@@ -21,11 +21,14 @@ module RailsErrorDashboard
21
21
  entries: entries, overflow: overflow, episode: episode, batch_id: batch_id
22
22
  )
23
23
 
24
- # A batch that reconciled nothing because every write failed is not a
25
- # delivered batch. Fail the job so Active Job retries it rather than
26
- # dropping counts that were only ever held in one process's memory.
24
+ # A batch that wrote nothing is not a delivered batch. Fail the job so
25
+ # Active Job retries it rather than dropping counts that were only ever
26
+ # held in one process's memory. Two shapes reach here: every entry failed
27
+ # permanently, and a transient store failure that rolled the whole batch
28
+ # back (retryable: true) -- the latter is intact and safe to replay.
27
29
  if result.is_a?(Hash) && result[:success] == false
28
- raise FlushFailed, "storm flush reconciled nothing: #{result[:error]}"
30
+ reason = result[:retryable] ? "storm flush rolled back" : "storm flush reconciled nothing"
31
+ raise FlushFailed, "#{reason}: #{result[:error]}"
29
32
  end
30
33
 
31
34
  result
@@ -37,6 +37,16 @@ module RailsErrorDashboard
37
37
  # Association for tracking individual error occurrences
38
38
  has_many :error_occurrences, class_name: "RailsErrorDashboard::ErrorOccurrence", dependent: :destroy
39
39
 
40
+ # Hour buckets for storm-shed events. delete_all, not destroy: these rows
41
+ # are pure counters with no callbacks, and a group can own one per hour.
42
+ #
43
+ # The association has to live HERE. `dependent:` on EventCount's own
44
+ # belongs_to is rejected by Rails (":dependent option must be one of
45
+ # [:destroy, :delete, :destroy_async]"), so the cleanup can only be
46
+ # declared from the parent side. Retention deletes them separately as well,
47
+ # because it uses delete_all, which does not fire callbacks.
48
+ has_many :event_counts, class_name: "RailsErrorDashboard::EventCount", dependent: :delete_all
49
+
40
50
  # Comments used as internal audit trail for workflow actions (snooze, mute, status changes).
41
51
  # Manual comment form removed in v0.6 — discussion now lives on issue tracker.
42
52
  has_many :comments, class_name: "RailsErrorDashboard::ErrorComment", foreign_key: :error_log_id, dependent: :destroy
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # How many storm-shed events landed on one error group in one hour.
5
+ #
6
+ # Storm protection sheds events by folding N of them into the group's
7
+ # occurrence_count and writing no ErrorOccurrence row. That keeps the total
8
+ # exact but leaves the events with no timestamp of their own, so a window
9
+ # query has nothing to filter them by. This table gives them one.
10
+ #
11
+ # Window volume is therefore:
12
+ #
13
+ # ErrorOccurrence rows in the window + EventCount buckets in the window
14
+ #
15
+ # Ordinary captures contribute the first term, shed events the second, and
16
+ # neither is double counted: an event that wrote an occurrence row is never
17
+ # also bucketed here.
18
+ #
19
+ # Inherits ErrorLogsRecord so separate-database routing applies.
20
+ class EventCount < ErrorLogsRecord
21
+ self.table_name = "rails_error_dashboard_event_counts"
22
+
23
+ belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog", optional: true
24
+
25
+ scope :in_window, ->(from, to = nil) {
26
+ scope = where(arel_table[:bucket_at].gteq(from))
27
+ to ? scope.where(arel_table[:bucket_at].lt(to)) : scope
28
+ }
29
+
30
+ # Bucket width, shared with the PRODUCER.
31
+ #
32
+ # This must equal CountBuffer::BUCKET_SECONDS, and there is a spec that
33
+ # asserts it. The producer tallies 15-minute buckets precisely so that a
34
+ # local midnight falls on a bucket EDGE in every UTC offset in use --
35
+ # including +05:30 (Kolkata) and +05:45 (Kathmandu). Rounding those buckets
36
+ # to the hour here destroyed exactly the property the producer paid for:
37
+ # a storm event at 23:59:30 and one at 00:00:30 in Kolkata shared one
38
+ # bucket, and a day's total was wrong by the whole straddle.
39
+ BUCKET_SECONDS = Services::StormProtection::CountBuffer::BUCKET_SECONDS
40
+
41
+ # The bucket a time belongs to, in UTC. One definition, used by the writer
42
+ # and the readers -- a mismatch here silently splits or merges a bucket.
43
+ # @param time [Time]
44
+ # @return [Time]
45
+ def self.bucket_for(time)
46
+ time = (time || Time.current)
47
+ Time.at((time.to_i / BUCKET_SECONDS) * BUCKET_SECONDS).utc
48
+ end
49
+
50
+ # Add +count+ shed events to (error_log_id, bucket_at), creating the row if
51
+ # it is not there yet.
52
+ #
53
+ # Adapter-portable by hand rather than via upsert_all: the increment has to
54
+ # read the existing value, and the ON CONFLICT / ON DUPLICATE KEY syntaxes
55
+ # differ. The UPDATE-first shape means the common case (a storm flushing
56
+ # repeatedly into the same hour) is a single statement, and the INSERT race
57
+ # is resolved by retrying the UPDATE once.
58
+ #
59
+ # @return [Symbol] :written when the bucket landed, :unavailable when the
60
+ # rollup is permanently unusable (no table, bad args, an adapter that
61
+ # refuses the statement). A TRANSIENT failure raises instead.
62
+ #
63
+ # Three states, not a boolean: the caller has to tell "the bucket is
64
+ # permanently unavailable, degrade" from "the write failed, retry the
65
+ # batch". Collapsing both into false made FlushStormCounts abort the
66
+ # whole transaction on a host that had simply never migrated the rollup
67
+ # table -- rolling back the authoritative lifetime count with it.
68
+ def self.accumulate(error_log_id:, bucket_at:, count:)
69
+ return :unavailable unless error_log_id && count.to_i.positive?
70
+ return :unavailable unless table_exists?
71
+
72
+ bucket = bucket_for(bucket_at)
73
+ updated = where(error_log_id: error_log_id, bucket_at: bucket)
74
+ .update_all([ "count = count + ?, updated_at = ?", count.to_i, Time.current ])
75
+ return :written if updated.positive?
76
+
77
+ begin
78
+ # requires_new: a failed INSERT aborts its transaction on PostgreSQL,
79
+ # which would take the caller's surrounding transaction with it.
80
+ transaction(requires_new: true) do
81
+ create!(error_log_id: error_log_id, bucket_at: bucket, count: count.to_i)
82
+ end
83
+ :written
84
+ rescue ActiveRecord::RecordNotUnique
85
+ # Another process created the same bucket between the UPDATE and the
86
+ # INSERT. The row exists now, so the UPDATE that missed a moment ago
87
+ # succeeds.
88
+ where(error_log_id: error_log_id, bucket_at: bucket)
89
+ .update_all([ "count = count + ?, updated_at = ?", count.to_i, Time.current ])
90
+ .positive? ? :written : :unavailable
91
+ end
92
+ rescue *Commands::LogError::RETRYABLE_STORE_ERRORS => e
93
+ # TRANSIENT: the store may be back in a moment. Do NOT swallow it.
94
+ #
95
+ # Returning false here let FlushStormCounts finalize its batch ledger
96
+ # with the bucket missing, so the replay was suppressed as
97
+ # already-applied and those events were erased from every time window --
98
+ # permanently -- while the lifetime count stayed correct. The caller
99
+ # turns this into an abort-and-retry of the whole batch.
100
+ RailsErrorDashboard::Logger.debug(
101
+ "[RailsErrorDashboard] EventCount.accumulate hit a transient failure: #{e.class} - #{e.message}"
102
+ )
103
+ raise
104
+ rescue StandardError => e
105
+ # PERMANENT: a malformed row, a missing table, an adapter that refuses
106
+ # this statement. Retrying cannot help, and failing the flush would turn
107
+ # a lost time bucket into a lost COUNT -- the authoritative total is
108
+ # occurrence_count on the group, and it is already written.
109
+ #
110
+ # This is the only case the old comment actually described.
111
+ RailsErrorDashboard::Logger.debug(
112
+ "[RailsErrorDashboard] EventCount.accumulate failed permanently: #{e.class} - #{e.message}"
113
+ )
114
+ :unavailable
115
+ end
116
+
117
+ # Whether the rollup table is usable.
118
+ #
119
+ # A transient connection failure here is NOT "the table does not exist":
120
+ # swallowing it returned false before any write was attempted, so a storm
121
+ # flush reported success with no bucket written, and EventVolume silently
122
+ # dropped the bucket term from its reads. Let the transient class through
123
+ # so the caller can retry; only a genuinely absent table returns false.
124
+ def self.table_exists?
125
+ connection.table_exists?(table_name)
126
+ rescue *Commands::LogError::RETRYABLE_STORE_ERRORS
127
+ raise
128
+ rescue StandardError
129
+ false
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # An interval whose per-event timing evidence was lost.
5
+ #
6
+ # See the migration for why this is its own table rather than a flag on the
7
+ # storm episode: the episode is optional, and it is written after the counts
8
+ # transaction has already committed.
9
+ #
10
+ # Inherits ErrorLogsRecord so separate-database routing applies.
11
+ class EventTimingGap < ErrorLogsRecord
12
+ self.table_name = "rails_error_dashboard_event_timing_gaps"
13
+
14
+ # Gaps overlapping [from, to). Open-ended when +to+ is nil.
15
+ #
16
+ # Overlap, not containment: a gap that began before the window and runs
17
+ # into it still makes that window's timing unreliable. Checking only
18
+ # "starts inside the window" is the mistake the episode predicate made.
19
+ scope :overlapping, ->(from, to = nil) {
20
+ scope = where(arel_table[:covered_until].gteq(from))
21
+ to ? scope.where(arel_table[:covered_from].lt(to)) : scope
22
+ }
23
+
24
+ # Whether any recorded gap affects the given window.
25
+ #
26
+ # Every guard is deliberate: the table may not be migrated on an older
27
+ # host, and this is read on the dashboard path, which must never raise.
28
+ #
29
+ # @param from [Time] start of the window being displayed
30
+ # @param application_id [Integer, nil]
31
+ # @return [Boolean]
32
+ def self.affecting?(from, application_id: nil)
33
+ return false unless table_exists?
34
+
35
+ scope = overlapping(from)
36
+ # A NULL application_id means the gap applies everywhere, so it is
37
+ # included whatever is being filtered for.
38
+ scope = scope.where(application_id: [ application_id, nil ]) if application_id.present?
39
+ scope.exists?
40
+ rescue StandardError => e
41
+ RailsErrorDashboard::Logger.debug(
42
+ "[RailsErrorDashboard] EventTimingGap.affecting? failed: #{e.class} - #{e.message}"
43
+ )
44
+ false
45
+ end
46
+
47
+ def self.table_exists?
48
+ connection.table_exists?(table_name)
49
+ rescue *Commands::LogError::RETRYABLE_STORE_ERRORS
50
+ raise
51
+ rescue StandardError
52
+ false
53
+ end
54
+ end
55
+ end
@@ -744,9 +744,54 @@ dd { color: var(--text-primary); }
744
744
  }
745
745
  .red-stat-grid > * { min-width: 0; }
746
746
 
747
- /* Error detail responsive grid */
747
+ /* Error detail hero
748
+ Same failure as the stat grids above: an inline `display: flex` cannot carry
749
+ a media query. The action row held `flex-shrink: 0` while the title column
750
+ was plain `flex: 1`, so at 390px up to five buttons kept their full width
751
+ and the title absorbed every pixel of the squeeze — "NoMethodError" wrapped
752
+ to roughly one character per line, making a very tall header before the
753
+ backtrace on the one screen an on-call engineer actually reads.
754
+
755
+ `flex: 1 1 260px` is the load-bearing part: the 260px basis stops the text
756
+ column collapsing toward zero. Dropping flex-shrink from the actions lets
757
+ them wrap onto their own line instead. */
758
+ .red-error-hero {
759
+ display: flex;
760
+ align-items: flex-start;
761
+ justify-content: space-between;
762
+ gap: var(--space-4);
763
+ flex-wrap: wrap;
764
+ }
765
+ .red-error-hero-text { flex: 1 1 260px; min-width: 0; }
766
+ .red-error-hero-actions { display: flex; gap: 6px; flex-wrap: wrap; }
767
+ @media (max-width: 575.98px) {
768
+ /* Full-width buttons once stacked: a wrapped row of five reads as rubble. */
769
+ .red-error-hero-actions { width: 100%; }
770
+ .red-error-hero-actions .btn { flex: 1 1 auto; }
771
+ }
772
+
773
+ /* Error detail responsive grid
774
+ The single-column rule below stacks the panels, but `1fr` is min-content by
775
+ default, so a track still refused to shrink below its widest child: at 390px
776
+ the grid measured 334px while its content needed 419px, and the page scrolled
777
+ sideways independently of the hero. Same fix as the stat grids: minmax's 0
778
+ floor lets the track shrink, and min-width:0 lets its children do the same. */
748
779
  .red-detail-grid { display: grid; grid-template-columns: 1fr 260px; gap: var(--space-4); }
749
- @media (max-width: 1023.98px) { .red-detail-grid { grid-template-columns: 1fr; } }
780
+ .red-detail-grid > * { min-width: 0; }
781
+ @media (max-width: 1023.98px) { .red-detail-grid { grid-template-columns: minmax(0, 1fr); } }
782
+
783
+ /* Detail tables inside those panels (Request Context, Metadata).
784
+ Their label column is a fixed `width="200"` and their values are URLs and
785
+ JSON, so the table's intrinsic width stayed ~416px however narrow the panel
786
+ became — the last thing still pushing the detail page sideways on a phone
787
+ once the grid and the hero were fixed. Below the stacking breakpoint the
788
+ label column narrows and long values wrap on any character, so the table
789
+ fits its panel instead of widening the page. */
790
+ @media (max-width: 1023.98px) {
791
+ .red-detail-grid .card-body > .table { table-layout: fixed; width: 100%; }
792
+ .red-detail-grid .card-body > .table th { width: 40%; }
793
+ .red-detail-grid .card-body > .table td { overflow-wrap: anywhere; }
794
+ }
750
795
 
751
796
  /* Env badge */
752
797
  .red-env-badge {
@@ -30,6 +30,8 @@
30
30
  <div><small class="text-muted"><%= red_t("red.errors.request_context.snapshot_fidelity_lite") %></small></div>
31
31
  <% when "minimal" %>
32
32
  <div><small class="text-muted"><%= red_t("red.errors.request_context.snapshot_fidelity_minimal") %></small></div>
33
+ <% when "partial" %>
34
+ <div><small class="text-muted"><%= red_t("red.errors.request_context.snapshot_fidelity_partial") %></small></div>
33
35
  <% end %>
34
36
  <% end %>
35
37
  </td>
@@ -16,6 +16,18 @@
16
16
  </div>
17
17
  <% end %>
18
18
 
19
+ <%# A storm episode degraded without writing its time buckets, so the
20
+ time-window figures below rest on each group's first-seen timestamp
21
+ rather than on per-event records. The COUNTS are still exact -- only
22
+ their placement in time is approximate, and saying so beats presenting
23
+ a figure of unknown completeness as fact. %>
24
+ <% if @stats[:event_timing_incomplete] %>
25
+ <div class="alert-warning" style="display: flex; align-items: center; gap: 10px; padding: 10px var(--space-5); background: var(--status-warning-bg); border-radius: var(--radius-md); border: 1px solid var(--status-warning); margin-bottom: var(--space-6); font-size: 13px; color: var(--status-warning);">
26
+ <i class="bi bi-clock-history"></i>
27
+ <strong><%= red_t("red.errors.overview_page.event_timing_incomplete") %></strong>
28
+ </div>
29
+ <% end %>
30
+
19
31
  <!-- Spike / Critical Alerts Banner -->
20
32
  <% if @critical_alerts.any? %>
21
33
  <%
@@ -13,8 +13,8 @@
13
13
  </div>
14
14
 
15
15
  <!-- Hero card -->
16
- <div style="display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); background: var(--surface-primary); border-radius: var(--radius-md); border: 1px solid var(--border-primary); margin-bottom: var(--space-4);">
17
- <div style="flex: 1; min-width: 0;">
16
+ <div class="red-error-hero" style="padding: var(--space-6); background: var(--surface-primary); border-radius: var(--radius-md); border: 1px solid var(--border-primary); margin-bottom: var(--space-4);">
17
+ <div class="red-error-hero-text">
18
18
  <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
19
19
  <span class="badge bg-<%= severity_color(@error.severity) %>" style="display: inline-flex; align-items: center; gap: 4px;">
20
20
  <span style="width: 6px; height: 6px; border-radius: 50%; background: currentColor;"></span>
@@ -75,7 +75,7 @@
75
75
  <span><%= red_t("red.errors.show.hero.last_seen_html", time: local_time_ago(@error.last_seen_at)) %></span>
76
76
  </div>
77
77
  </div>
78
- <div style="display: flex; gap: 6px; flex-shrink: 0;">
78
+ <div class="red-error-hero-actions">
79
79
  <% unless @error.resolved? %>
80
80
  <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#resolveModal" style="font-weight: 600;">
81
81
  <%= red_t("red.errors.show.actions.resolve") %>
@@ -583,6 +583,7 @@ de:
583
583
  snapshot_stale: aus einem früheren Auftreten — spätere lieferten keinen Kontext
584
584
  snapshot_fidelity_lite: reduzierte Erfassung (der Storm-Schutz hat die Kontext-Payloads verworfen)
585
585
  snapshot_fidelity_minimal: während eines Storms gezählt — es wurde kein Kontext erfasst
586
+ snapshot_fidelity_partial: gemischter Snapshot — einige Felder stammen aus einem früheren Vorkommen
586
587
  snapshot_unknown: erfasst, bevor RED die Herkunft von Momentaufnahmen aufzeichnete
587
588
  similar_errors:
588
589
  title: Ähnliche Fehler
@@ -940,6 +941,7 @@ de:
940
941
  rate_per_hour: "%{value}/Std."
941
942
  affected_users_incomplete: mindestens — der Storm-Schutz hat Detaildaten pro Ereignis verworfen
942
943
  data_unavailable: Statistiken sind derzeit nicht verfügbar — die Zahlen unten sind keine Messung.
944
+ event_timing_incomplete: "Zeitangaben sind unvollständig — der Sturmschutz hat Zeitstempel verworfen; die Gesamtzahlen stimmen."
943
945
  unresolved: Offen
944
946
  unresolved_hint: Behebung ausstehend
945
947
  resolution_rate: Behebungsquote
@@ -821,6 +821,7 @@ en:
821
821
  # but no backtrace or context was ever captured for it.
822
822
  snapshot_fidelity_lite: "reduced capture (storm protection shed the context payloads)"
823
823
  snapshot_fidelity_minimal: "counted during a storm — no context was captured"
824
+ snapshot_fidelity_partial: "mixed snapshot — some fields are kept from an earlier occurrence"
824
825
  snapshot_unknown: "captured before RED recorded snapshot provenance"
825
826
 
826
827
  similar_errors:
@@ -1330,6 +1331,7 @@ en:
1330
1331
  # Zero errors and "the dashboard could not read its data" are
1331
1332
  # different states. This banner says which one you are looking at.
1332
1333
  data_unavailable: "Statistics are currently unavailable — the figures below are not a measurement."
1334
+ event_timing_incomplete: "Event timing is incomplete — storm protection dropped per-event timestamps; the totals are still exact."
1333
1335
  unresolved: "Unresolved"
1334
1336
  unresolved_hint: "Pending resolution"
1335
1337
  resolution_rate: "Resolution Rate"
@@ -608,6 +608,7 @@ es:
608
608
  snapshot_stale: de una aparición anterior — las posteriores no aportaron contexto
609
609
  snapshot_fidelity_lite: captura reducida (la protección ante storms descartó los payloads de contexto)
610
610
  snapshot_fidelity_minimal: contabilizado durante un storm — no se capturó ningún contexto
611
+ snapshot_fidelity_partial: instantánea mixta — algunos campos provienen de una aparición anterior
611
612
  snapshot_unknown: capturada antes de que RED registrara la procedencia de las instantáneas
612
613
  similar_errors:
613
614
  title: Errores similares
@@ -970,6 +971,7 @@ es:
970
971
  rate_per_hour: "%{value}/h"
971
972
  affected_users_incomplete: al menos — la protección ante storms descartó el detalle por evento
972
973
  data_unavailable: Las estadísticas no están disponibles por ahora — las cifras siguientes no son una medición.
974
+ event_timing_incomplete: "Las marcas de tiempo están incompletas — la protección contra tormentas las descartó; los totales siguen siendo exactos."
973
975
  unresolved: Sin resolver
974
976
  unresolved_hint: Pendientes de resolución
975
977
  resolution_rate: Tasa de resolución
@@ -607,6 +607,7 @@ fr:
607
607
  snapshot_stale: issu d'une occurrence antérieure — les suivantes n'ont apporté aucun contexte
608
608
  snapshot_fidelity_lite: capture réduite (la protection contre les storms a écarté les payloads de contexte)
609
609
  snapshot_fidelity_minimal: comptabilisé pendant un storm — aucun contexte n'a été capturé
610
+ snapshot_fidelity_partial: instantané mixte — certains champs proviennent d'une occurrence antérieure
610
611
  snapshot_unknown: capturé avant que RED n'enregistre la provenance des instantanés
611
612
  similar_errors:
612
613
  title: Erreurs similaires
@@ -971,6 +972,7 @@ fr:
971
972
  rate_per_hour: "%{value}/h"
972
973
  affected_users_incomplete: au moins — la protection contre les storms a écarté le détail par événement
973
974
  data_unavailable: Les statistiques sont indisponibles pour le moment — les chiffres ci-dessous ne sont pas une mesure.
975
+ event_timing_incomplete: "L'horodatage est incomplet — la protection anti-tempête a supprimé les horodatages ; les totaux restent exacts."
974
976
  unresolved: Non résolues
975
977
  unresolved_hint: En attente de résolution
976
978
  resolution_rate: Taux de résolution
@@ -601,6 +601,7 @@ it:
601
601
  snapshot_stale: da un'occorrenza precedente — quelle successive non hanno fornito contesto
602
602
  snapshot_fidelity_lite: acquisizione ridotta (la protezione dagli storm ha scartato i payload di contesto)
603
603
  snapshot_fidelity_minimal: conteggiato durante uno storm — non è stato acquisito alcun contesto
604
+ snapshot_fidelity_partial: snapshot misto — alcuni campi provengono da un'occorrenza precedente
604
605
  snapshot_unknown: acquisita prima che RED registrasse la provenienza delle istantanee
605
606
  similar_errors:
606
607
  title: Errori simili
@@ -962,6 +963,7 @@ it:
962
963
  rate_per_hour: "%{value}/h"
963
964
  affected_users_incomplete: almeno — la protezione dagli storm ha scartato il dettaglio per evento
964
965
  data_unavailable: Le statistiche non sono al momento disponibili — i valori seguenti non sono una misurazione.
966
+ event_timing_incomplete: "Le marche temporali sono incomplete — la protezione tempeste le ha scartate; i totali restano esatti."
965
967
  unresolved: Non risolti
966
968
  unresolved_hint: In attesa di risoluzione
967
969
  resolution_rate: Tasso di risoluzione
@@ -532,6 +532,7 @@ ja:
532
532
  snapshot_stale: より前の発生から取得 — それ以降の発生はコンテキストを伴いませんでした
533
533
  snapshot_fidelity_lite: 縮小された取得 (storm 保護によりコンテキストの payload が破棄されました)
534
534
  snapshot_fidelity_minimal: storm 中にカウントされました — コンテキストは取得されていません
535
+ snapshot_fidelity_partial: 混在したスナップショット — 一部のフィールドは以前の発生時のものです
535
536
  snapshot_unknown: RED がスナップショットの出所を記録する前に取得されました
536
537
  similar_errors:
537
538
  title: 類似エラー
@@ -846,6 +847,7 @@ ja:
846
847
  rate_per_hour: "%{value}/時"
847
848
  affected_users_incomplete: 最少値 — storm 保護によりイベントごとの詳細が破棄されました
848
849
  data_unavailable: 統計は現在利用できません — 以下の数値は計測値ではありません。
850
+ event_timing_incomplete: "発生時刻の記録が不完全です — ストーム保護がタイムスタンプを破棄しました。合計値は正確です。"
849
851
  unresolved: 未解決
850
852
  unresolved_hint: 解決待ち
851
853
  resolution_rate: 解決率
@@ -611,6 +611,7 @@ pl:
611
611
  snapshot_stale: z wcześniejszego wystąpienia — późniejsze nie przyniosły kontekstu
612
612
  snapshot_fidelity_lite: ograniczone przechwytywanie (ochrona przed storm odrzuciła payloady kontekstu)
613
613
  snapshot_fidelity_minimal: zliczone podczas storm — nie przechwycono żadnego kontekstu
614
+ snapshot_fidelity_partial: mieszany zrzut — część pól pochodzi z wcześniejszego wystąpienia
614
615
  snapshot_unknown: przechwycono, zanim RED zaczął zapisywać pochodzenie migawek
615
616
  similar_errors:
616
617
  title: Podobne błędy
@@ -978,6 +979,7 @@ pl:
978
979
  rate_per_hour: "%{value}/godz."
979
980
  affected_users_incomplete: co najmniej — ochrona przed storm odrzuciła szczegóły poszczególnych zdarzeń
980
981
  data_unavailable: Statystyki są obecnie niedostępne — poniższe liczby nie są pomiarem.
982
+ event_timing_incomplete: "Znaczniki czasu są niepełne — ochrona przed burzą je odrzuciła; sumy pozostają dokładne."
981
983
  unresolved: Nierozwiązane
982
984
  unresolved_hint: Oczekują na rozwiązanie
983
985
  resolution_rate: Wskaźnik rozwiązań
@@ -606,6 +606,7 @@ pt-BR:
606
606
  snapshot_stale: de uma ocorrência anterior — as posteriores não trouxeram contexto
607
607
  snapshot_fidelity_lite: captura reduzida (a proteção contra storms descartou os payloads de contexto)
608
608
  snapshot_fidelity_minimal: contabilizado durante um storm — nenhum contexto foi capturado
609
+ snapshot_fidelity_partial: snapshot misto — alguns campos vêm de uma ocorrência anterior
609
610
  snapshot_unknown: capturado antes de o RED registrar a procedência dos instantâneos
610
611
  similar_errors:
611
612
  title: Erros semelhantes
@@ -966,6 +967,7 @@ pt-BR:
966
967
  rate_per_hour: "%{value}/h"
967
968
  affected_users_incomplete: no mínimo — a proteção contra storms descartou o detalhe por evento
968
969
  data_unavailable: As estatísticas estão indisponíveis no momento — os números abaixo não são uma medição.
970
+ event_timing_incomplete: "Os horários estão incompletos — a proteção contra tempestades descartou os carimbos de tempo; os totais continuam exatos."
969
971
  unresolved: Não resolvidos
970
972
  unresolved_hint: Aguardando resolução
971
973
  resolution_rate: Taxa de resolução
@@ -614,6 +614,7 @@ ru:
614
614
  snapshot_stale: из более раннего появления — последующие не содержали контекста
615
615
  snapshot_fidelity_lite: сокращённый сбор (защита от storm отбросила payload контекста)
616
616
  snapshot_fidelity_minimal: учтено во время storm — контекст не собирался
617
+ snapshot_fidelity_partial: смешанный снимок — часть полей осталась от более раннего события
617
618
  snapshot_unknown: получен до того, как RED начал записывать происхождение снимков
618
619
  similar_errors:
619
620
  title: Похожие ошибки
@@ -981,6 +982,7 @@ ru:
981
982
  rate_per_hour: "%{value}/ч"
982
983
  affected_users_incomplete: не менее — защита от storm отбросила детализацию по событиям
983
984
  data_unavailable: Статистика сейчас недоступна — приведённые ниже числа не являются измерением.
985
+ event_timing_incomplete: "Отметки времени неполные — защита от шторма отбросила их; итоговые значения остаются точными."
984
986
  unresolved: Не решено
985
987
  unresolved_hint: Ожидают решения
986
988
  resolution_rate: Доля решённых
@@ -612,6 +612,7 @@ uk:
612
612
  snapshot_stale: з ранішого випадку — подальші не містили контексту
613
613
  snapshot_fidelity_lite: скорочений збір (захист від storm відкинув payload контексту)
614
614
  snapshot_fidelity_minimal: враховано під час storm — контекст не збирався
615
+ snapshot_fidelity_partial: змішаний знімок — частина полів залишилася від давнішої події
615
616
  snapshot_unknown: отримано до того, як RED почав записувати походження знімків
616
617
  similar_errors:
617
618
  title: Схожі помилки
@@ -978,6 +979,7 @@ uk:
978
979
  rate_per_hour: "%{value}/год"
979
980
  affected_users_incomplete: щонайменше — захист від storm відкинув деталізацію за подіями
980
981
  data_unavailable: Статистика зараз недоступна — наведені нижче числа не є вимірюванням.
982
+ event_timing_incomplete: "Позначки часу неповні — захист від шторму відкинув їх; підсумкові значення залишаються точними."
981
983
  unresolved: Не вирішено
982
984
  unresolved_hint: Очікують вирішення
983
985
  resolution_rate: Частка вирішених
@@ -529,6 +529,7 @@ zh-CN:
529
529
  snapshot_stale: 来自更早的一次发生 — 之后的发生未携带上下文
530
530
  snapshot_fidelity_lite: 已精简的捕获(storm 保护丢弃了上下文 payload)
531
531
  snapshot_fidelity_minimal: 在 storm 期间计数 — 未捕获任何上下文
532
+ snapshot_fidelity_partial: 混合快照 — 部分字段来自更早的一次发生
532
533
  snapshot_unknown: 在 RED 记录快照来源之前捕获
533
534
  similar_errors:
534
535
  title: 相似错误
@@ -842,6 +843,7 @@ zh-CN:
842
843
  rate_per_hour: "%{value}/小时"
843
844
  affected_users_incomplete: 至少 — storm 保护丢弃了逐事件的明细
844
845
  data_unavailable: 统计数据当前不可用 — 下方数字并非测量结果。
846
+ event_timing_incomplete: "事件时间记录不完整 — 风暴保护丢弃了时间戳;总数仍然准确。"
845
847
  unresolved: 未解决
846
848
  unresolved_hint: 等待处理
847
849
  resolution_rate: 解决率