rails_error_dashboard 0.8.4 → 0.9.1

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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -0
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +107 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +91 -73
  5. data/app/controllers/rails_error_dashboard/locales_controller.rb +87 -0
  6. data/app/helpers/rails_error_dashboard/application_helper.rb +20 -14
  7. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +15 -6
  8. data/app/helpers/rails_error_dashboard/i18n_helper.rb +154 -0
  9. data/app/helpers/rails_error_dashboard/mailer_i18n_helper.rb +69 -0
  10. data/app/helpers/rails_error_dashboard/overview_helper.rb +18 -6
  11. data/app/helpers/rails_error_dashboard/user_agent_helper.rb +25 -11
  12. data/app/jobs/rails_error_dashboard/application_job.rb +5 -0
  13. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +18 -16
  14. data/app/jobs/rails_error_dashboard/concerns/localized_job.rb +83 -0
  15. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -2
  16. data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +4 -2
  17. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +6 -2
  18. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +8 -3
  19. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +6 -4
  20. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +26 -8
  21. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +4 -2
  22. data/app/mailers/rails_error_dashboard/application_mailer.rb +14 -0
  23. data/app/mailers/rails_error_dashboard/digest_mailer.rb +18 -2
  24. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +19 -2
  25. data/app/views/layouts/rails_error_dashboard.html.erb +255 -92
  26. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +23 -22
  27. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +30 -19
  28. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +18 -16
  29. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +21 -14
  30. data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +11 -7
  31. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +45 -38
  32. data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +17 -14
  33. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +27 -9
  34. data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +25 -21
  35. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +25 -22
  36. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +24 -12
  37. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +12 -11
  38. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +19 -13
  39. data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +14 -14
  40. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +8 -8
  41. data/app/views/rails_error_dashboard/errors/_modals.html.erb +60 -52
  42. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +59 -49
  43. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +23 -20
  44. data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +14 -14
  45. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +118 -93
  46. data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +18 -12
  47. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -5
  48. data/app/views/rails_error_dashboard/errors/_stats.html.erb +5 -5
  49. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +7 -7
  50. data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +15 -12
  51. data/app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb +25 -25
  52. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -28
  53. data/app/views/rails_error_dashboard/errors/analytics.html.erb +82 -82
  54. data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +27 -27
  55. data/app/views/rails_error_dashboard/errors/correlation.html.erb +61 -57
  56. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +64 -65
  57. data/app/views/rails_error_dashboard/errors/deprecations.html.erb +23 -23
  58. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +48 -36
  59. data/app/views/rails_error_dashboard/errors/index.html.erb +121 -70
  60. data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +30 -30
  61. data/app/views/rails_error_dashboard/errors/llm_health_summary.html.erb +38 -39
  62. data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +28 -25
  63. data/app/views/rails_error_dashboard/errors/overview.html.erb +45 -39
  64. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +45 -44
  65. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +30 -29
  66. data/app/views/rails_error_dashboard/errors/releases.html.erb +46 -47
  67. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +48 -44
  68. data/app/views/rails_error_dashboard/errors/settings.html.erb +122 -112
  69. data/app/views/rails_error_dashboard/errors/show.html.erb +44 -37
  70. data/app/views/rails_error_dashboard/errors/storms.html.erb +30 -24
  71. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +26 -26
  72. data/app/views/rails_error_dashboard/errors/user_impact.html.erb +31 -30
  73. data/config/locales/de.yml +2007 -0
  74. data/config/locales/en.yml +2719 -0
  75. data/config/locales/es.yml +2079 -0
  76. data/config/locales/fr.yml +2082 -0
  77. data/config/locales/it.yml +2060 -0
  78. data/config/locales/ja.yml +1802 -0
  79. data/config/locales/pl.yml +2108 -0
  80. data/config/locales/pt-BR.yml +2064 -0
  81. data/config/locales/ru.yml +2109 -0
  82. data/config/locales/uk.yml +2106 -0
  83. data/config/locales/zh-CN.yml +1791 -0
  84. data/config/routes.rb +5 -0
  85. data/db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb +9 -3
  86. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +3 -1
  87. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +4 -2
  88. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +4 -2
  89. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +4 -2
  90. data/lib/rails_error_dashboard/commands/create_issue.rb +5 -3
  91. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +4 -2
  92. data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
  93. data/lib/rails_error_dashboard/configuration.rb +23 -6
  94. data/lib/rails_error_dashboard/current.rb +56 -0
  95. data/lib/rails_error_dashboard/i18n_store.rb +293 -0
  96. data/lib/rails_error_dashboard/private_backend.rb +213 -0
  97. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +89 -22
  98. data/lib/rails_error_dashboard/services/digest_builder.rb +21 -7
  99. data/lib/rails_error_dashboard/services/discord_payload_builder.rb +27 -15
  100. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +10 -5
  101. data/lib/rails_error_dashboard/services/localized_time_formatter.rb +117 -0
  102. data/lib/rails_error_dashboard/services/notification_helpers.rb +87 -4
  103. data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +22 -5
  104. data/lib/rails_error_dashboard/services/slack_payload_builder.rb +48 -28
  105. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -1
  106. data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +15 -1
  107. data/lib/rails_error_dashboard/translation.rb +39 -0
  108. data/lib/rails_error_dashboard/version.rb +1 -1
  109. data/lib/rails_error_dashboard.rb +4 -0
  110. data/lib/tasks/error_dashboard.rake +5 -1
  111. metadata +35 -8
@@ -26,7 +26,7 @@
26
26
  nil
27
27
  end
28
28
  %>
29
- <html lang="en" data-theme="light">
29
+ <html lang="<%= red_locale %>" data-theme="light">
30
30
  <head>
31
31
  <title><%= content_for?(:page_title) ? "#{content_for(:page_title)} | " : "" %><%= Rails.application.class.module_parent_name %> - RED</title>
32
32
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -48,6 +48,48 @@
48
48
  })();
49
49
  </script>
50
50
 
51
+ <!-- Translation payload for client-rendered strings (P3-T1).
52
+ In <head> and before every other inline block so any script, in head
53
+ or body, can call redT() without ordering assumptions. -->
54
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
55
+ window.RED_I18N = <%= js_safe_json(red_js_translations) %>;
56
+ (function(){
57
+ // Lookup + %{name} interpolation. Total by construction: a missing key
58
+ // returns the key itself, so a forgotten translation shows as
59
+ // "js.copied" on screen rather than "undefined" or a thrown error that
60
+ // would take the rest of the block down with it.
61
+ window.redT = function(key, params) {
62
+ var value;
63
+ try {
64
+ value = String(key).split('.').reduce(function(node, segment) {
65
+ return (node && typeof node === 'object') ? node[segment] : undefined;
66
+ }, window.RED_I18N);
67
+ } catch (e) {
68
+ return String(key);
69
+ }
70
+ // A plural key resolves to a branch of forms, not a string. When the
71
+ // caller passes a count, select the form here rather than making every
72
+ // call site do it — a ternary on "s" at the call site is the exact
73
+ // anti-pattern this whole namespace exists to avoid.
74
+ if (value && typeof value === 'object' && params && typeof params.count === 'number') {
75
+ var form = (params.count === 1 && typeof value.one === 'string') ? value.one : value.other;
76
+ // A locale supplying only :other still renders; so does one that
77
+ // supplies only :one, rather than either becoming "undefined".
78
+ if (typeof form !== 'string') form = value.one;
79
+ if (typeof form !== 'string') return String(key);
80
+ value = form;
81
+ }
82
+ if (typeof value !== 'string') return String(key);
83
+ if (!params) return value;
84
+ // Leaves an unknown %{name} in place rather than blanking it — a
85
+ // visible marker beats silently losing the interpolated value.
86
+ return value.replace(/%\{(\w+)\}/g, function(match, name) {
87
+ return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match;
88
+ });
89
+ };
90
+ })();
91
+ </script>
92
+
51
93
  <!-- Fonts -->
52
94
  <link rel="preconnect" href="https://fonts.googleapis.com">
53
95
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -940,7 +982,7 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
940
982
  <!-- Sidebar -->
941
983
  <nav class="red-sidebar d-none d-md-flex" id="sidebar">
942
984
  <!-- Logo -->
943
- <a href="/" class="red-sidebar-logo" title="Back to <%= Rails.application.class.module_parent_name %>" style="text-decoration: none; color: inherit;">
985
+ <a href="/" class="red-sidebar-logo" title="<%= red_t("red.sidebar.back_to_app", app: Rails.application.class.module_parent_name) %>" style="text-decoration: none; color: inherit;">
944
986
  <div class="red-sidebar-logo-icon">R</div>
945
987
  <div>
946
988
  <div style="font-size: 14px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em;">RED</div>
@@ -959,17 +1001,17 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
959
1001
 
960
1002
  <!-- CORE section -->
961
1003
  <div style="margin-bottom: var(--space-2);">
962
- <div class="red-sidebar-section-label">Core</div>
1004
+ <div class="red-sidebar-section-label"><%= red_t("red.nav.sections.core") %></div>
963
1005
  <ul class="nav flex-column">
964
1006
  <li class="nav-item">
965
1007
  <%= link_to root_path(nav_params), class: "nav-link #{request.path == root_path ? 'active' : ''}" do %>
966
- <i class="bi bi-grid-1x2"></i> Overview
1008
+ <i class="bi bi-grid-1x2"></i> <%= red_t("red.nav.overview") %>
967
1009
  <% end %>
968
1010
  </li>
969
1011
  <li class="nav-item">
970
1012
  <%= link_to errors_path(nav_params), class: "nav-link #{(controller_name == 'errors' && action_name == 'index') ? 'active' : ''}" do %>
971
1013
  <i class="bi bi-bug"></i>
972
- <span style="flex: 1;">Errors</span>
1014
+ <span style="flex: 1;"><%= red_t("red.nav.errors") %></span>
973
1015
  <% if unresolved_badge_count && unresolved_badge_count > 0 %>
974
1016
  <span class="red-nav-badge"><%= unresolved_badge_count %></span>
975
1017
  <% end %>
@@ -977,7 +1019,7 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
977
1019
  </li>
978
1020
  <li class="nav-item">
979
1021
  <%= link_to analytics_errors_path(nav_params), class: "nav-link #{request.path == analytics_errors_path ? 'active' : ''}" do %>
980
- <i class="bi bi-bar-chart-line"></i> Analytics
1022
+ <i class="bi bi-bar-chart-line"></i> <%= red_t("red.nav.analytics") %>
981
1023
  <% end %>
982
1024
  </li>
983
1025
  </ul>
@@ -985,31 +1027,31 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
985
1027
 
986
1028
  <!-- HEALTH section (feature-gated) -->
987
1029
  <% health_items = [] %>
988
- <% health_items << { path: platform_comparison_errors_path(nav_params), icon: 'bi-cpu', label: 'Platform' } %>
1030
+ <% health_items << { path: platform_comparison_errors_path(nav_params), icon: 'bi-cpu', label: red_t("red.nav.platform") } %>
989
1031
  <% if RailsErrorDashboard.configuration.enable_breadcrumbs %>
990
- <% health_items << { path: cache_health_summary_errors_path(nav_params), icon: 'bi-hdd-stack', label: 'Cache' } %>
1032
+ <% health_items << { path: cache_health_summary_errors_path(nav_params), icon: 'bi-hdd-stack', label: red_t("red.nav.cache") } %>
991
1033
  <% end %>
992
1034
  <% if RailsErrorDashboard.configuration.enable_system_health %>
993
- <% health_items << { path: job_health_summary_errors_path(nav_params), icon: 'bi-gear-wide-connected', label: 'Jobs' } %>
994
- <% health_items << { path: database_health_summary_errors_path(nav_params), icon: 'bi-database', label: 'Database' } %>
1035
+ <% health_items << { path: job_health_summary_errors_path(nav_params), icon: 'bi-gear-wide-connected', label: red_t("red.nav.jobs") } %>
1036
+ <% health_items << { path: database_health_summary_errors_path(nav_params), icon: 'bi-database', label: red_t("red.nav.database") } %>
995
1037
  <% end %>
996
1038
  <% if RailsErrorDashboard.configuration.enable_rack_attack_tracking %>
997
- <% health_items << { path: rack_attack_summary_errors_path(nav_params), icon: 'bi-shield-exclamation', label: 'Rate Limits' } %>
1039
+ <% health_items << { path: rack_attack_summary_errors_path(nav_params), icon: 'bi-shield-exclamation', label: red_t("red.nav.rate_limits") } %>
998
1040
  <% end %>
999
1041
  <% if RailsErrorDashboard.configuration.enable_actioncable_tracking && RailsErrorDashboard.configuration.enable_breadcrumbs %>
1000
- <% health_items << { path: actioncable_health_summary_errors_path(nav_params), icon: 'bi-broadcast', label: 'ActionCable' } %>
1042
+ <% health_items << { path: actioncable_health_summary_errors_path(nav_params), icon: 'bi-broadcast', label: red_t("red.nav.actioncable") } %>
1001
1043
  <% end %>
1002
1044
  <% if RailsErrorDashboard.configuration.enable_activestorage_tracking && RailsErrorDashboard.configuration.enable_breadcrumbs %>
1003
- <% health_items << { path: activestorage_health_summary_errors_path(nav_params), icon: 'bi-cloud-arrow-up', label: 'ActiveStorage' } %>
1045
+ <% health_items << { path: activestorage_health_summary_errors_path(nav_params), icon: 'bi-cloud-arrow-up', label: red_t("red.nav.activestorage") } %>
1004
1046
  <% end %>
1005
1047
  <% if RailsErrorDashboard.configuration.enable_llm_observability && RailsErrorDashboard.configuration.enable_breadcrumbs %>
1006
- <% health_items << { path: llm_health_summary_errors_path(nav_params), icon: 'bi-cpu-fill', label: 'LLM' } %>
1048
+ <% health_items << { path: llm_health_summary_errors_path(nav_params), icon: 'bi-cpu-fill', label: red_t("red.nav.llm") } %>
1007
1049
  <% end %>
1008
1050
 
1009
1051
  <% if health_items.any? %>
1010
1052
  <div style="margin-bottom: var(--space-2);" id="navHealthSection">
1011
1053
  <button type="button" class="red-sidebar-section-label" data-red-action="toggle-nav-section" data-red-target="navHealthItems">
1012
- Health
1054
+ <%= red_t("red.nav.sections.health") %>
1013
1055
  <i class="bi bi-chevron-down" style="font-size: 10px;" id="navHealthChevron"></i>
1014
1056
  </button>
1015
1057
  <ul class="nav flex-column" id="navHealthItems">
@@ -1027,23 +1069,23 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1027
1069
  <!-- DIAGNOSTICS section (feature-gated) -->
1028
1070
  <% diag_items = [] %>
1029
1071
  <% if RailsErrorDashboard.configuration.enable_breadcrumbs %>
1030
- <% diag_items << { path: n_plus_one_summary_errors_path(nav_params), icon: 'bi-layers', label: 'N+1 Queries' } %>
1031
- <% diag_items << { path: deprecations_errors_path(nav_params), icon: 'bi-exclamation-triangle', label: 'Deprecations' } %>
1072
+ <% diag_items << { path: n_plus_one_summary_errors_path(nav_params), icon: 'bi-layers', label: red_t("red.nav.n_plus_one") } %>
1073
+ <% diag_items << { path: deprecations_errors_path(nav_params), icon: 'bi-exclamation-triangle', label: red_t("red.nav.deprecations") } %>
1032
1074
  <% end %>
1033
1075
  <% if RailsErrorDashboard.configuration.detect_swallowed_exceptions %>
1034
- <% diag_items << { path: swallowed_exceptions_errors_path(nav_params), icon: 'bi-eye-slash', label: 'Swallowed' } %>
1076
+ <% diag_items << { path: swallowed_exceptions_errors_path(nav_params), icon: 'bi-eye-slash', label: red_t("red.nav.swallowed") } %>
1035
1077
  <% end %>
1036
1078
  <% if RailsErrorDashboard.configuration.enable_diagnostic_dump %>
1037
- <% diag_items << { path: diagnostic_dumps_errors_path(nav_params), icon: 'bi-clipboard-pulse', label: 'Diagnostics' } %>
1079
+ <% diag_items << { path: diagnostic_dumps_errors_path(nav_params), icon: 'bi-clipboard-pulse', label: red_t("red.nav.diagnostic_dumps") } %>
1038
1080
  <% end %>
1039
1081
  <% if RailsErrorDashboard.configuration.enable_storm_protection %>
1040
- <% diag_items << { path: storms_errors_path(nav_params), icon: 'bi-cloud-lightning-rain', label: 'Storms' } %>
1082
+ <% diag_items << { path: storms_errors_path(nav_params), icon: 'bi-cloud-lightning-rain', label: red_t("red.nav.storms") } %>
1041
1083
  <% end %>
1042
1084
 
1043
1085
  <% if diag_items.any? %>
1044
1086
  <div style="margin-bottom: var(--space-2);" id="navDiagSection">
1045
1087
  <button type="button" class="red-sidebar-section-label" data-red-action="toggle-nav-section" data-red-target="navDiagItems">
1046
- Diagnostics
1088
+ <%= red_t("red.nav.sections.diagnostics") %>
1047
1089
  <i class="bi bi-chevron-down" style="font-size: 10px;" id="navDiagChevron"></i>
1048
1090
  </button>
1049
1091
  <ul class="nav flex-column" id="navDiagItems">
@@ -1061,23 +1103,23 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1061
1103
  <!-- INSIGHTS section -->
1062
1104
  <div style="margin-bottom: var(--space-2);" id="navInsightsSection">
1063
1105
  <button type="button" class="red-sidebar-section-label" data-red-action="toggle-nav-section" data-red-target="navInsightsItems">
1064
- Insights
1106
+ <%= red_t("red.nav.sections.insights") %>
1065
1107
  <i class="bi bi-chevron-down" style="font-size: 10px;" id="navInsightsChevron"></i>
1066
1108
  </button>
1067
1109
  <ul class="nav flex-column" id="navInsightsItems">
1068
1110
  <li class="nav-item">
1069
1111
  <%= link_to correlation_errors_path(nav_params), class: "nav-link #{request.path == correlation_errors_path ? 'active' : ''}" do %>
1070
- <i class="bi bi-diagram-3"></i> Correlation
1112
+ <i class="bi bi-diagram-3"></i> <%= red_t("red.nav.correlation") %>
1071
1113
  <% end %>
1072
1114
  </li>
1073
1115
  <li class="nav-item">
1074
1116
  <%= link_to releases_errors_path(nav_params), class: "nav-link #{request.path == releases_errors_path ? 'active' : ''}" do %>
1075
- <i class="bi bi-rocket-takeoff"></i> Releases
1117
+ <i class="bi bi-rocket-takeoff"></i> <%= red_t("red.nav.releases") %>
1076
1118
  <% end %>
1077
1119
  </li>
1078
1120
  <li class="nav-item">
1079
1121
  <%= link_to user_impact_errors_path(nav_params), class: "nav-link #{request.path == user_impact_errors_path ? 'active' : ''}" do %>
1080
- <i class="bi bi-people"></i> User Impact
1122
+ <i class="bi bi-people"></i> <%= red_t("red.nav.user_impact") %>
1081
1123
  <% end %>
1082
1124
  </li>
1083
1125
  </ul>
@@ -1089,7 +1131,7 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1089
1131
  <ul class="nav flex-column">
1090
1132
  <li class="nav-item">
1091
1133
  <%= link_to settings_path(nav_params), class: "nav-link #{request.path == settings_path ? 'active' : ''}" do %>
1092
- <i class="bi bi-sliders"></i> Settings
1134
+ <i class="bi bi-sliders"></i> <%= red_t("red.nav.settings") %>
1093
1135
  <% end %>
1094
1136
  </li>
1095
1137
  </ul>
@@ -1106,13 +1148,13 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1106
1148
  <i class="bi bi-list"></i>
1107
1149
  </button>
1108
1150
  <!-- Desktop sidebar toggle -->
1109
- <button class="btn btn-link d-none d-md-flex" type="button" id="sidebarToggle" title="Toggle sidebar (S)" style="font-size: 16px; color: var(--text-secondary);">
1151
+ <button class="btn btn-link d-none d-md-flex" type="button" id="sidebarToggle" title="<%= red_t("red.navbar.toggle_sidebar") %>" style="font-size: 16px; color: var(--text-secondary);">
1110
1152
  <i class="bi bi-layout-sidebar-inset"></i>
1111
1153
  </button>
1112
1154
  <!-- Search -->
1113
1155
  <div style="position: relative;">
1114
1156
  <i class="bi bi-search" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; color: var(--text-tertiary);"></i>
1115
- <input class="red-search-input" placeholder="Search errors... /" id="globalSearch" />
1157
+ <input class="red-search-input" placeholder="<%= red_t("red.navbar.search_placeholder") %>" id="globalSearch" />
1116
1158
  </div>
1117
1159
  </div>
1118
1160
  <div style="display: flex; align-items: center; gap: var(--space-3);">
@@ -1122,9 +1164,9 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1122
1164
  <button class="red-app-switcher dropdown-toggle" type="button" id="appSwitcher" data-bs-toggle="dropdown" aria-expanded="false">
1123
1165
  <i class="bi bi-layers"></i>
1124
1166
  <% if params[:application_id].present? %>
1125
- <%= @applications.find { |name, id| id.to_s == params[:application_id].to_s }&.first || 'Unknown' %>
1167
+ <%= @applications.find { |name, id| id.to_s == params[:application_id].to_s }&.first || red_t("red.common.unknown") %>
1126
1168
  <% else %>
1127
- All Apps (<%= @applications.size %>)
1169
+ <%= red_t("red.navbar.all_apps", count: @applications.size) %>
1128
1170
  <% end %>
1129
1171
  </button>
1130
1172
  <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="appSwitcher">
@@ -1141,7 +1183,7 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1141
1183
  end
1142
1184
  %>
1143
1185
  <li>
1144
- <%= link_to "All Applications",
1186
+ <%= link_to red_t("red.navbar.all_applications"),
1145
1187
  url_for(switcher_base.merge(switcher_filter_params.except(:application_id))),
1146
1188
  class: "dropdown-item #{'active' unless params[:application_id].present?}" %>
1147
1189
  </li>
@@ -1156,8 +1198,42 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1156
1198
  </ul>
1157
1199
  </div>
1158
1200
  <% end %>
1159
- <button type="button" title="Keyboard shortcuts (?)" data-bs-toggle="modal" data-bs-target="#keyboardShortcutsModal" style="background: none; border: none; padding: 0; cursor: pointer; line-height: 1;"><kbd>?</kbd></button>
1160
- <button class="red-theme-toggle" id="themeToggle" title="Toggle theme">
1201
+ <%# Language picker (P5-T1). Only rendered when there is a choice
1202
+ to make: with a single shipped locale a dropdown listing it is
1203
+ noise. Each option is its own button_to, so switching is a real
1204
+ CSRF-protected POST and works without JavaScript. %>
1205
+ <% red_locale_options = RailsErrorDashboard::I18nStore.locale_options %>
1206
+ <% if red_locale_options.size > 1 %>
1207
+ <div class="dropdown">
1208
+ <button class="red-app-switcher dropdown-toggle" type="button" id="localePicker"
1209
+ data-bs-toggle="dropdown" aria-expanded="false"
1210
+ aria-label="<%= red_t("red.navbar.choose_language") %>"
1211
+ title="<%= red_t("red.navbar.choose_language") %>">
1212
+ <i class="bi bi-translate"></i>
1213
+ <span class="d-none d-md-inline"><%= RailsErrorDashboard::I18nStore::ENDONYMS.fetch(red_locale, red_locale) %></span>
1214
+ </button>
1215
+ <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="localePicker">
1216
+ <% red_locale_options.each do |locale_code, endonym| %>
1217
+ <li>
1218
+ <%# The endonym is deliberately NOT translated — see
1219
+ I18nStore::ENDONYMS. lang= tells a screen reader to
1220
+ pronounce each entry in its own language. %>
1221
+ <%= button_to locale_path,
1222
+ method: :post,
1223
+ params: { locale: locale_code },
1224
+ class: "dropdown-item #{'active' if locale_code == red_locale}",
1225
+ form: { style: "display: contents;" },
1226
+ lang: locale_code,
1227
+ "aria-current": (locale_code == red_locale ? "true" : nil) do %>
1228
+ <%= endonym %>
1229
+ <% end %>
1230
+ </li>
1231
+ <% end %>
1232
+ </ul>
1233
+ </div>
1234
+ <% end %>
1235
+ <button type="button" title="<%= red_t("red.navbar.keyboard_shortcuts_title") %>" data-bs-toggle="modal" data-bs-target="#keyboardShortcutsModal" style="background: none; border: none; padding: 0; cursor: pointer; line-height: 1;"><kbd>?</kbd></button>
1236
+ <button class="red-theme-toggle" id="themeToggle" title="<%= red_t("red.navbar.toggle_theme") %>">
1161
1237
  <i class="bi bi-moon" id="themeIcon"></i>
1162
1238
  </button>
1163
1239
  <span class="red-env-badge" style="background: var(--status-success-bg); color: var(--status-success);"><%= Rails.env %></span>
@@ -1170,11 +1246,9 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1170
1246
  <div id="security-warning" class="alert alert-warning" style="display: flex; align-items: center; gap: 10px; margin-top: var(--space-2); margin-bottom: var(--space-4); border-left: 4px solid var(--status-warning);">
1171
1247
  <i class="bi bi-exclamation-triangle-fill" style="font-size: 18px; flex-shrink: 0;"></i>
1172
1248
  <div style="flex: 1;">
1173
- <strong>Security Warning:</strong> You are using default credentials (gandalf/youshallnotpass).
1174
- Set <code>ERROR_DASHBOARD_USER</code> and <code>ERROR_DASHBOARD_PASSWORD</code> environment variables,
1175
- or configure <code>authenticate_with</code> in your initializer.
1249
+ <strong><%= red_t("red.banners.credentials.label") %></strong> <%= red_t("red.banners.credentials.body_html") %>
1176
1250
  </div>
1177
- <button type="button" data-red-action="dismiss-creds-warning" style="background: none; border: none; cursor: pointer; color: var(--text-secondary); font-size: 18px; padding: 0 4px; flex-shrink: 0; line-height: 1;" title="Dismiss for this session">&times;</button>
1251
+ <button type="button" data-red-action="dismiss-creds-warning" style="background: none; border: none; cursor: pointer; color: var(--text-secondary); font-size: 18px; padding: 0 4px; flex-shrink: 0; line-height: 1;" title="<%= red_t("red.banners.credentials.dismiss") %>">&times;</button>
1178
1252
  </div>
1179
1253
  <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>try { if (sessionStorage.getItem('red_dismiss_creds_warning') === '1') { document.getElementById('security-warning').style.display = 'none'; } } catch(e) {}</script>
1180
1254
  <% end %>
@@ -1184,15 +1258,20 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1184
1258
  <i class="bi <%= storm_active ? 'bi-cloud-lightning-rain-fill' : 'bi-cloud-check' %>" style="font-size: 18px; flex-shrink: 0;"></i>
1185
1259
  <div style="flex: 1;">
1186
1260
  <% if storm_active %>
1187
- <strong>Error storm in progress</strong> (since <%= @storm_banner_event.started_at.strftime("%H:%M %Z") %>) —
1188
- storm protection engaged. Occurrences are being counted exactly;
1189
- <%= @storm_banner_event.reached_open ? "detail capture is paused (count-only mode)" : "context capture is sampled" %>.
1261
+ <strong><%= red_t("red.banners.storm.active_title") %></strong>
1262
+ <%= red_t(
1263
+ @storm_banner_event.reached_open ? "red.banners.storm.active_paused" : "red.banners.storm.active_sampled",
1264
+ time: @storm_banner_event.started_at.strftime("%H:%M %Z")
1265
+ ) %>
1190
1266
  <% else %>
1191
- <strong>Storm protection engaged recently</strong> (ended <%= @storm_banner_event.ended_at.strftime("%H:%M %Z") %>) —
1192
- <%= number_with_delimiter(@storm_banner_event.events_counted_only.to_i + @storm_banner_event.events_overflow.to_i) %> occurrences
1193
- were recorded as exact counts with sampled detail.
1267
+ <strong><%= red_t("red.banners.storm.recent_title") %></strong>
1268
+ <%= red_t(
1269
+ "red.banners.storm.recent_body",
1270
+ time: @storm_banner_event.ended_at.strftime("%H:%M %Z"),
1271
+ count: number_with_delimiter(@storm_banner_event.events_counted_only.to_i + @storm_banner_event.events_overflow.to_i)
1272
+ ) %>
1194
1273
  <% end %>
1195
- <a href="<%= storms_errors_path %>" style="margin-left: 4px;">View storm history</a>
1274
+ <a href="<%= storms_errors_path %>" style="margin-left: 4px;"><%= red_t("red.banners.storm.view_history") %></a>
1196
1275
  </div>
1197
1276
  </div>
1198
1277
  <% end %>
@@ -1203,10 +1282,10 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1203
1282
  <footer class="red-footer">
1204
1283
  <p style="margin-bottom: 4px;">
1205
1284
  <i class="bi bi-bug-fill" style="color: var(--accent);"></i>
1206
- Built with <a href="https://github.com/AnjanJ/rails_error_dashboard" target="_blank"><strong>RED</strong> &mdash; Rails Error Dashboard</a>
1285
+ <%= red_t("red.footer.built_with") %> <a href="https://github.com/AnjanJ/rails_error_dashboard" target="_blank"><strong>RED</strong> &mdash; Rails Error Dashboard</a>
1207
1286
  </p>
1208
1287
  <p style="font-size: 12px; color: var(--text-tertiary); margin: 0;">
1209
- v<%= RailsErrorDashboard::VERSION %> &middot; Built with ❤️ by <a href="https://anjan.dev" target="_blank">Anjan Jagirdar</a>
1288
+ v<%= RailsErrorDashboard::VERSION %> &middot; <%= red_t("red.footer.credit_html", author: '<a href="https://anjan.dev" target="_blank">Anjan Jagirdar</a>'.html_safe) %>
1210
1289
  </p>
1211
1290
  </footer>
1212
1291
  </div>
@@ -1215,25 +1294,25 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1215
1294
  <!-- Mobile Sidebar (Offcanvas) -->
1216
1295
  <div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarMenu" aria-labelledby="sidebarMenuLabel">
1217
1296
  <div class="offcanvas-header">
1218
- <a href="/" style="display: flex; align-items: center; gap: 10px; text-decoration: none; color: inherit;" title="Back to app home">
1297
+ <a href="/" style="display: flex; align-items: center; gap: 10px; text-decoration: none; color: inherit;" title="<%= red_t("red.sidebar.back_to_app_home") %>">
1219
1298
  <div class="red-sidebar-logo-icon">R</div>
1220
1299
  <strong>RED</strong>
1221
1300
  </a>
1222
- <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
1301
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="<%= red_t("red.common.close") %>"></button>
1223
1302
  </div>
1224
1303
  <div class="offcanvas-body">
1225
1304
  <%
1226
1305
  nav_params = params[:application_id].present? ? { application_id: params[:application_id] } : {}
1227
1306
  %>
1228
1307
  <ul class="nav flex-column">
1229
- <li class="nav-item"><%= link_to root_path(nav_params), class: "nav-link" do %><i class="bi bi-grid-1x2"></i> Overview<% end %></li>
1230
- <li class="nav-item"><%= link_to errors_path(nav_params), class: "nav-link" do %><i class="bi bi-bug"></i> Errors<% end %></li>
1231
- <li class="nav-item"><%= link_to analytics_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-bar-chart-line"></i> Analytics<% end %></li>
1232
- <li class="nav-item"><%= link_to platform_comparison_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-cpu"></i> Platform<% end %></li>
1233
- <li class="nav-item"><%= link_to correlation_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-diagram-3"></i> Correlation<% end %></li>
1234
- <li class="nav-item"><%= link_to releases_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-rocket-takeoff"></i> Releases<% end %></li>
1235
- <li class="nav-item"><%= link_to user_impact_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-people"></i> User Impact<% end %></li>
1236
- <li class="nav-item"><%= link_to settings_path(nav_params), class: "nav-link" do %><i class="bi bi-sliders"></i> Settings<% end %></li>
1308
+ <li class="nav-item"><%= link_to root_path(nav_params), class: "nav-link" do %><i class="bi bi-grid-1x2"></i> <%= red_t("red.nav.overview") %><% end %></li>
1309
+ <li class="nav-item"><%= link_to errors_path(nav_params), class: "nav-link" do %><i class="bi bi-bug"></i> <%= red_t("red.nav.errors") %><% end %></li>
1310
+ <li class="nav-item"><%= link_to analytics_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-bar-chart-line"></i> <%= red_t("red.nav.analytics") %><% end %></li>
1311
+ <li class="nav-item"><%= link_to platform_comparison_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-cpu"></i> <%= red_t("red.nav.platform") %><% end %></li>
1312
+ <li class="nav-item"><%= link_to correlation_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-diagram-3"></i> <%= red_t("red.nav.correlation") %><% end %></li>
1313
+ <li class="nav-item"><%= link_to releases_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-rocket-takeoff"></i> <%= red_t("red.nav.releases") %><% end %></li>
1314
+ <li class="nav-item"><%= link_to user_impact_errors_path(nav_params), class: "nav-link" do %><i class="bi bi-people"></i> <%= red_t("red.nav.user_impact") %><% end %></li>
1315
+ <li class="nav-item"><%= link_to settings_path(nav_params), class: "nav-link" do %><i class="bi bi-sliders"></i> <%= red_t("red.nav.settings") %><% end %></li>
1237
1316
  </ul>
1238
1317
  </div>
1239
1318
  </div>
@@ -1244,30 +1323,31 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1244
1323
  <div class="modal-content">
1245
1324
  <div class="modal-header">
1246
1325
  <h5 class="modal-title" id="keyboardShortcutsModalLabel">
1247
- <i class="bi bi-keyboard"></i> Keyboard Shortcuts
1326
+ <i class="bi bi-keyboard"></i> <%= red_t("red.shortcuts.title") %>
1248
1327
  </h5>
1249
- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
1328
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<%= red_t("red.common.close") %>"></button>
1250
1329
  </div>
1251
1330
  <div class="modal-body">
1252
1331
  <div class="list-group list-group-flush">
1332
+ <%# Key names (R, /, A, S, ?) stay literal — they are physical keys, not words. %>
1253
1333
  <div class="list-group-item d-flex justify-content-between align-items-center">
1254
- <span><i class="bi bi-arrow-clockwise" style="color: var(--accent);"></i> Refresh page</span>
1334
+ <span><i class="bi bi-arrow-clockwise" style="color: var(--accent);"></i> <%= red_t("red.shortcuts.refresh") %></span>
1255
1335
  <kbd>R</kbd>
1256
1336
  </div>
1257
1337
  <div class="list-group-item d-flex justify-content-between align-items-center">
1258
- <span><i class="bi bi-search" style="color: var(--accent);"></i> Focus search</span>
1338
+ <span><i class="bi bi-search" style="color: var(--accent);"></i> <%= red_t("red.shortcuts.focus_search") %></span>
1259
1339
  <kbd>/</kbd>
1260
1340
  </div>
1261
1341
  <div class="list-group-item d-flex justify-content-between align-items-center">
1262
- <span><i class="bi bi-bar-chart-line" style="color: var(--accent);"></i> Analytics</span>
1342
+ <span><i class="bi bi-bar-chart-line" style="color: var(--accent);"></i> <%= red_t("red.shortcuts.analytics") %></span>
1263
1343
  <kbd>A</kbd>
1264
1344
  </div>
1265
1345
  <div class="list-group-item d-flex justify-content-between align-items-center">
1266
- <span><i class="bi bi-layout-sidebar" style="color: var(--accent);"></i> Toggle sidebar</span>
1346
+ <span><i class="bi bi-layout-sidebar" style="color: var(--accent);"></i> <%= red_t("red.shortcuts.toggle_sidebar") %></span>
1267
1347
  <kbd>S</kbd>
1268
1348
  </div>
1269
1349
  <div class="list-group-item d-flex justify-content-between align-items-center">
1270
- <span><i class="bi bi-keyboard" style="color: var(--accent);"></i> Show shortcuts</span>
1350
+ <span><i class="bi bi-keyboard" style="color: var(--accent);"></i> <%= red_t("red.shortcuts.show_shortcuts") %></span>
1271
1351
  <kbd>?</kbd>
1272
1352
  </div>
1273
1353
  </div>
@@ -1339,7 +1419,7 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1339
1419
  this.submitButtonTargets.forEach(function(btn) {
1340
1420
  btn.disabled = true;
1341
1421
  if (!btn.dataset.loadingOriginalHtml) { btn.dataset.loadingOriginalHtml = btn.innerHTML; }
1342
- btn.innerHTML = '<span class="loading-spinner"></span> Loading...';
1422
+ btn.innerHTML = '<span class="loading-spinner"></span> <%= red_js_t("red.ui_js.toast.loading") %>';
1343
1423
  });
1344
1424
  }
1345
1425
 
@@ -1511,18 +1591,18 @@ document.addEventListener('DOMContentLoaded', function() {
1511
1591
  window.copyToClipboard = function(text, button) {
1512
1592
  navigator.clipboard.writeText(text).then(function() {
1513
1593
  var originalHTML = button.innerHTML;
1514
- button.innerHTML = '<i class="bi bi-check"></i> Copied!';
1594
+ button.innerHTML = '<i class="bi bi-check"></i> <%= red_js_t("red.ui_js.toast.copied") %>';
1515
1595
  button.classList.remove('btn-outline-secondary');
1516
1596
  button.classList.add('btn-success');
1517
- showToast('Copied to clipboard!', 'success');
1597
+ showToast('<%= red_js_t("red.ui_js.toast.copied_to_clipboard") %>', 'success');
1518
1598
  setTimeout(function() {
1519
1599
  button.innerHTML = originalHTML;
1520
1600
  button.classList.remove('btn-success');
1521
1601
  button.classList.add('btn-outline-secondary');
1522
1602
  }, 2000);
1523
1603
  }).catch(function() {
1524
- button.innerHTML = '<i class="bi bi-x"></i> Failed';
1525
- showToast('Failed to copy to clipboard', 'danger');
1604
+ button.innerHTML = '<i class="bi bi-x"></i> <%= red_js_t("red.ui_js.toast.failed") %>';
1605
+ showToast('<%= red_js_t("red.ui_js.toast.copy_failed") %>', 'danger');
1526
1606
  });
1527
1607
  };
1528
1608
 
@@ -1536,7 +1616,7 @@ document.addEventListener('DOMContentLoaded', function() {
1536
1616
  '<div id="' + toastId + '" class="toast align-items-center text-white ' + bgClass + ' border-0" role="alert" aria-live="assertive" aria-atomic="true">' +
1537
1617
  '<div class="d-flex">' +
1538
1618
  '<div class="toast-body"><i class="bi ' + iconClass + ' me-2"></i>' + message + '</div>' +
1539
- '<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>' +
1619
+ '<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="<%= red_js_t("red.common.close") %>"></button>' +
1540
1620
  '</div>' +
1541
1621
  '</div>';
1542
1622
  var container = document.querySelector('.toast-container');
@@ -1561,7 +1641,7 @@ document.addEventListener('DOMContentLoaded', function() {
1561
1641
  var formatted = formatDateTime(date, formatString);
1562
1642
  var timezone = getTimezoneAbbreviation(date);
1563
1643
  element.textContent = formatted + ' ' + timezone;
1564
- element.title = 'Your local time (click to see UTC)';
1644
+ element.title = redDate('titles').local_time || RED_DATE_EN.titles.local_time;
1565
1645
  element.style.cursor = 'pointer';
1566
1646
  element.dataset.originalUtc = utcString;
1567
1647
  element.dataset.localFormatted = formatted + ' ' + timezone;
@@ -1570,11 +1650,11 @@ document.addEventListener('DOMContentLoaded', function() {
1570
1650
  if (this.dataset.showingLocal === 'true') {
1571
1651
  var utcDate = new Date(this.dataset.originalUtc);
1572
1652
  this.textContent = formatDateTime(utcDate, formatString) + ' UTC';
1573
- this.title = 'UTC time (click to see local time)';
1653
+ this.title = redDate('titles').utc_time || RED_DATE_EN.titles.utc_time;
1574
1654
  this.dataset.showingLocal = 'false';
1575
1655
  } else {
1576
1656
  this.textContent = this.dataset.localFormatted;
1577
- this.title = 'Your local time (click to see UTC)';
1657
+ this.title = redDate('titles').local_time || RED_DATE_EN.titles.local_time;
1578
1658
  this.dataset.showingLocal = 'true';
1579
1659
  }
1580
1660
  });
@@ -1588,19 +1668,20 @@ document.addEventListener('DOMContentLoaded', function() {
1588
1668
  var date = new Date(utcString);
1589
1669
  if (isNaN(date.getTime())) return;
1590
1670
  element.textContent = formatRelativeTime(new Date() - date);
1591
- element.title = 'Click to see exact time';
1671
+ element.title = redDate('titles').exact_time || RED_DATE_EN.titles.exact_time;
1592
1672
  element.style.cursor = 'pointer';
1593
1673
  element.dataset.originalUtc = utcString;
1594
1674
  element.dataset.showingRelative = 'true';
1595
1675
  element.addEventListener('click', function() {
1596
1676
  if (this.dataset.showingRelative === 'true') {
1597
1677
  var d = new Date(this.dataset.originalUtc);
1598
- this.textContent = formatDateTime(d, '%B %d, %Y %I:%M:%S %p') + ' ' + getTimezoneAbbreviation(d);
1599
- this.title = 'Click to see relative time';
1678
+ var fullFmt = (window.RED_I18N && window.RED_I18N.formats && typeof window.RED_I18N.formats.full === 'string') ? window.RED_I18N.formats.full : '%B %d, %Y %I:%M:%S %p';
1679
+ this.textContent = formatDateTime(d, fullFmt) + ' ' + getTimezoneAbbreviation(d);
1680
+ this.title = redDate('titles').relative_time || RED_DATE_EN.titles.relative_time;
1600
1681
  this.dataset.showingRelative = 'false';
1601
1682
  } else {
1602
1683
  this.textContent = formatRelativeTime(new Date() - new Date(this.dataset.originalUtc));
1603
- this.title = 'Click to see exact time';
1684
+ this.title = redDate('titles').exact_time || RED_DATE_EN.titles.exact_time;
1604
1685
  this.dataset.showingRelative = 'true';
1605
1686
  }
1606
1687
  });
@@ -1608,14 +1689,62 @@ document.addEventListener('DOMContentLoaded', function() {
1608
1689
  });
1609
1690
  }
1610
1691
 
1692
+ // English defaults for every localized value below. RED_I18N is written in
1693
+ // <head> and should always be there, but a cached page or a JS error earlier
1694
+ // in the document would leave these functions rendering "undefined" into
1695
+ // every timestamp on the page. Falling back to English is the same choice
1696
+ // I18nStore makes on the server.
1697
+ var RED_DATE_EN = {
1698
+ months: ['January','February','March','April','May','June','July','August','September','October','November','December'],
1699
+ months_short: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
1700
+ days: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
1701
+ days_short: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'],
1702
+ meridian: { am: 'AM', pm: 'PM' },
1703
+ intl_locale: 'en-US',
1704
+ duration: {
1705
+ seconds: { one: '1 second', other: '%{count} seconds' },
1706
+ minutes: { one: '1 minute', other: '%{count} minutes' },
1707
+ hours: { one: '1 hour', other: '%{count} hours' },
1708
+ days: { one: '1 day', other: '%{count} days' },
1709
+ months: { one: '1 month', other: '%{count} months' },
1710
+ years: { one: '1 year', other: '%{count} years' }
1711
+ },
1712
+ titles: {
1713
+ local_time: 'Your local time (click to see UTC)',
1714
+ utc_time: 'UTC time (click to see local time)',
1715
+ exact_time: 'Click to see exact time',
1716
+ relative_time: 'Click to see relative time'
1717
+ }
1718
+ };
1719
+
1720
+ // Read one localized date value, falling back to the English default at the
1721
+ // same path. Only accepts a value of the same shape as the default, so a
1722
+ // half-written locale cannot substitute a string where an array is indexed.
1723
+ function redDate(name) {
1724
+ var fallback = RED_DATE_EN[name];
1725
+ try {
1726
+ var value = window.RED_I18N && window.RED_I18N.js ? window.RED_I18N.js[name] : undefined;
1727
+ if (Array.isArray(fallback)) {
1728
+ return (Array.isArray(value) && value.length === fallback.length) ? value : fallback;
1729
+ }
1730
+ if (value && typeof value === 'object') return value;
1731
+ return fallback;
1732
+ } catch (e) {
1733
+ return fallback;
1734
+ }
1735
+ }
1736
+
1611
1737
  function formatDateTime(date, fmt) {
1612
- var months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
1613
- var monthsShort = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1614
- var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
1615
- var daysShort = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
1738
+ var months = redDate('months'), monthsShort = redDate('months_short');
1739
+ var days = redDate('days'), daysShort = redDate('days_short');
1740
+ var meridian = redDate('meridian');
1616
1741
  var y = date.getFullYear(), mo = date.getMonth(), d = date.getDate();
1617
1742
  var h = date.getHours(), mi = date.getMinutes(), s = date.getSeconds(), dow = date.getDay();
1618
- var h12 = h % 12 || 12, ampm = h >= 12 ? 'PM' : 'AM';
1743
+ // A 24-hour locale may translate these to empty strings, which is correct
1744
+ // and must not become the string "undefined".
1745
+ var pm = typeof meridian.pm === 'string' ? meridian.pm : RED_DATE_EN.meridian.pm;
1746
+ var am = typeof meridian.am === 'string' ? meridian.am : RED_DATE_EN.meridian.am;
1747
+ var h12 = h % 12 || 12, ampm = h >= 12 ? pm : am;
1619
1748
  var pad = function(n) { return n.toString().padStart(2, '0'); };
1620
1749
  return fmt.replace('%Y',y).replace('%y',y.toString().substr(2)).replace('%B',months[mo]).replace('%b',monthsShort[mo])
1621
1750
  .replace('%m',pad(mo+1)).replace('%d',pad(d)).replace('%e',d).replace('%A',days[dow]).replace('%a',daysShort[dow])
@@ -1623,18 +1752,52 @@ document.addEventListener('DOMContentLoaded', function() {
1623
1752
  }
1624
1753
 
1625
1754
  function getTimezoneAbbreviation(date) {
1626
- var parts = date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ');
1755
+ // RED's locale set and the browser's Intl data need not agree, so an
1756
+ // unsupported tag throws RangeError here rather than degrading. Catch it
1757
+ // and use en-US: an abbreviation in the wrong language beats no timestamp.
1758
+ var tag = redDate('intl_locale');
1759
+ if (typeof tag !== 'string' || !tag) tag = RED_DATE_EN.intl_locale;
1760
+ var parts;
1761
+ try {
1762
+ parts = date.toLocaleTimeString(tag, { timeZoneName: 'short' }).split(' ');
1763
+ } catch (e) {
1764
+ parts = date.toLocaleTimeString(RED_DATE_EN.intl_locale, { timeZoneName: 'short' }).split(' ');
1765
+ }
1627
1766
  return parts[parts.length - 1];
1628
1767
  }
1629
1768
 
1769
+ // Plural selection. English needs one/other; other languages need more, and
1770
+ // some need only one form. Falling back to 'other' means a locale that ships
1771
+ // a partial set still renders rather than showing "undefined".
1772
+ function redPluralize(forms, count) {
1773
+ if (!forms || typeof forms !== 'object') return String(count);
1774
+ var form = (count === 1 && typeof forms.one === 'string') ? forms.one : forms.other;
1775
+ if (typeof form !== 'string') form = forms.one;
1776
+ if (typeof form !== 'string') return String(count);
1777
+ return form.replace(/%\{count\}/g, String(count));
1778
+ }
1779
+
1630
1780
  function formatRelativeTime(diffMs) {
1631
1781
  var sec = Math.floor(diffMs / 1000), min = Math.floor(sec / 60), hr = Math.floor(min / 60), d = Math.floor(hr / 24), mo = Math.floor(d / 30), yr = Math.floor(d / 365);
1632
- if (sec < 60) return sec <= 1 ? '1 second ago' : sec + ' seconds ago';
1633
- if (min < 60) return min === 1 ? '1 minute ago' : min + ' minutes ago';
1634
- if (hr < 24) return hr === 1 ? '1 hour ago' : hr + ' hours ago';
1635
- if (d < 30) return d === 1 ? '1 day ago' : d + ' days ago';
1636
- if (mo < 12) return mo === 1 ? '1 month ago' : mo + ' months ago';
1637
- return yr === 1 ? '1 year ago' : yr + ' years ago';
1782
+ var units = redDate('duration'), duration;
1783
+ var pick = function(name, n) {
1784
+ var forms = units && units[name] ? units[name] : RED_DATE_EN.duration[name];
1785
+ return redPluralize(forms, n);
1786
+ };
1787
+
1788
+ // Thresholds unchanged from the pre-i18n version, including sec <= 1
1789
+ // reading as "1 second" for a 0-second difference.
1790
+ if (sec < 60) duration = pick('seconds', sec <= 1 ? 1 : sec);
1791
+ else if (min < 60) duration = pick('minutes', min);
1792
+ else if (hr < 24) duration = pick('hours', hr);
1793
+ else if (d < 30) duration = pick('days', d);
1794
+ else if (mo < 12) duration = pick('months', mo);
1795
+ else duration = pick('years', yr);
1796
+
1797
+ // "%{duration} ago" as one key — never duration + ' ago'. Several
1798
+ // languages put the equivalent of "ago" first. Same key the server uses.
1799
+ var template = (window.RED_I18N && typeof window.RED_I18N.ago === 'string') ? window.RED_I18N.ago : '%{duration} ago';
1800
+ return template.replace(/%\{duration\}/g, duration);
1638
1801
  }
1639
1802
 
1640
1803
  convertToLocalTime();
@@ -1823,13 +1986,13 @@ document.addEventListener('DOMContentLoaded', function() {
1823
1986
  var button = form.querySelector('button[type="submit"]');
1824
1987
  var question = input ? input.value.trim() : '';
1825
1988
  if (!question) {
1826
- if (status) status.textContent = 'Enter a question first.';
1989
+ if (status) status.textContent = '<%= red_js_t("red.ui_js.ai_help.question_required") %>';
1827
1990
  return;
1828
1991
  }
1829
1992
 
1830
1993
  appendAiHelpMessage('user', question);
1831
1994
  if (input) input.value = '';
1832
- if (status) status.textContent = 'Streaming...';
1995
+ if (status) status.textContent = '<%= red_js_t("red.ui_js.ai_help.streaming") %>';
1833
1996
  if (button) button.disabled = true;
1834
1997
 
1835
1998
  var tokenMeta = document.querySelector('meta[name="csrf-token"]');
@@ -1848,7 +2011,7 @@ document.addEventListener('DOMContentLoaded', function() {
1848
2011
  });
1849
2012
  }
1850
2013
  if (!response.body || !window.TextDecoder) {
1851
- throw new Error('Streaming is not supported by this browser.');
2014
+ throw new Error('<%= red_js_t("red.ui_js.ai_help.streaming_unsupported") %>');
1852
2015
  }
1853
2016
 
1854
2017
  var assistantMessage = appendAiHelpMessage('assistant', '');
@@ -1916,7 +2079,7 @@ document.addEventListener('DOMContentLoaded', function() {
1916
2079
  return readNext();
1917
2080
  }).catch(function(error) {
1918
2081
  appendAiHelpMessage('error', error.message);
1919
- if (status) status.textContent = 'Request failed.';
2082
+ if (status) status.textContent = '<%= red_js_t("red.ui_js.ai_help.request_failed") %>';
1920
2083
  }).finally(function() {
1921
2084
  if (button) button.disabled = false;
1922
2085
  if (input) input.focus();