upright 0.3.0 → 0.4.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -7
  3. data/Rakefile +22 -0
  4. data/app/assets/images/upright/.keep +0 -0
  5. data/app/assets/stylesheets/upright/_global.css +2 -0
  6. data/app/assets/stylesheets/upright/artifact.css +7 -25
  7. data/app/assets/stylesheets/upright/flash.css +19 -0
  8. data/app/assets/stylesheets/upright/header.css +14 -0
  9. data/app/assets/stylesheets/upright/incidents.css +610 -0
  10. data/app/assets/stylesheets/upright/map.css +6 -0
  11. data/app/assets/stylesheets/upright/public_status.css +534 -0
  12. data/app/controllers/concerns/.keep +0 -0
  13. data/app/controllers/concerns/upright/authentication.rb +1 -0
  14. data/app/controllers/concerns/upright/proxy_authentication.rb +134 -0
  15. data/app/controllers/upright/alertmanager_proxy_controller.rb +12 -4
  16. data/app/controllers/upright/artifacts_controller.rb +1 -1
  17. data/app/controllers/upright/incidents/updates_controller.rb +13 -0
  18. data/app/controllers/upright/incidents_controller.rb +53 -0
  19. data/app/controllers/upright/prometheus_proxy_controller.rb +13 -18
  20. data/app/controllers/upright/public/base_controller.rb +16 -0
  21. data/app/controllers/upright/public/incidents_controller.rb +6 -0
  22. data/app/controllers/upright/public/service_incidents_controller.rb +7 -0
  23. data/app/controllers/upright/public/services_controller.rb +6 -0
  24. data/app/controllers/upright/sessions_controller.rb +36 -1
  25. data/app/controllers/upright/traces_controller.rb +88 -0
  26. data/app/helpers/upright/application_helper.rb +22 -2
  27. data/app/helpers/upright/artifacts_helper.rb +20 -0
  28. data/app/helpers/upright/probe_results_helper.rb +1 -0
  29. data/app/helpers/upright/public/services_helper.rb +70 -0
  30. data/app/javascript/upright/application.js +3 -0
  31. data/app/javascript/upright/controllers/sites_map_controller.js +13 -5
  32. data/app/javascript/upright/controllers/template_controller.js +11 -0
  33. data/app/javascript/upright/public.js +5 -0
  34. data/app/jobs/upright/health_metrics_job.rb +27 -0
  35. data/app/jobs/upright/incident_reporter_job.rb +7 -0
  36. data/app/jobs/upright/maintenance_advance_job.rb +8 -0
  37. data/app/jobs/upright/playwright_video_sweep_job.rb +21 -0
  38. data/app/jobs/upright/rollups/daily_aggregation_job.rb +14 -0
  39. data/app/models/concerns/.keep +0 -0
  40. data/app/models/concerns/upright/incidents/auto_reporting.rb +93 -0
  41. data/app/models/concerns/upright/incidents/lifecycle.rb +47 -0
  42. data/app/models/concerns/upright/playwright/form_authentication.rb +3 -2
  43. data/app/models/concerns/upright/playwright/lifecycle.rb +8 -13
  44. data/app/models/concerns/upright/playwright/trace_recording.rb +53 -0
  45. data/app/models/concerns/upright/playwright/video_recording.rb +55 -14
  46. data/app/models/concerns/upright/probe_result/stale_cleanup.rb +26 -2
  47. data/app/models/concerns/upright/probeable.rb +9 -1
  48. data/app/models/concerns/upright/services/live_status.rb +69 -0
  49. data/app/models/concerns/upright/services/maintenance_status.rb +19 -0
  50. data/app/models/upright/artifact.rb +2 -1
  51. data/app/models/upright/http/request.rb +12 -3
  52. data/app/models/upright/incident/automatic_report.rb +3 -0
  53. data/app/models/upright/incident.rb +87 -0
  54. data/app/models/upright/incident_affected_service.rb +9 -0
  55. data/app/models/upright/incident_update.rb +7 -0
  56. data/app/models/upright/maintenance.rb +36 -0
  57. data/app/models/upright/persistent_record.rb +12 -0
  58. data/app/models/upright/playwright/authenticator/base.rb +50 -70
  59. data/app/models/upright/probes/http_probe.rb +30 -1
  60. data/app/models/upright/probes/playwright/base.rb +2 -0
  61. data/app/models/upright/probes/status/probe.rb +8 -0
  62. data/app/models/upright/probes/status.rb +3 -11
  63. data/app/models/upright/probes/uptime.rb +2 -9
  64. data/app/models/upright/rollups/daily_uptime.rb +28 -0
  65. data/app/models/upright/rollups/probe_rollup.rb +64 -0
  66. data/app/models/upright/rollups/probe_uptime.rb +9 -0
  67. data/app/models/upright/rollups/site_uptime.rb +75 -0
  68. data/app/models/upright/service/daily_status.rb +39 -0
  69. data/app/models/upright/service/incident_history.rb +24 -0
  70. data/app/models/upright/service/outage.rb +15 -0
  71. data/app/models/upright/service/status_page.rb +35 -0
  72. data/app/models/upright/service.rb +101 -0
  73. data/app/models/upright/status.rb +21 -0
  74. data/app/models/upright/traceroute/ip_metadata_lookup.rb +21 -2
  75. data/app/models/upright/traceroute/result.rb +8 -1
  76. data/app/views/layouts/upright/_header.html.erb +12 -2
  77. data/app/views/layouts/upright/application.html.erb +4 -1
  78. data/app/views/layouts/upright/public.html.erb +18 -0
  79. data/app/views/layouts/upright/public.rss.builder +1 -0
  80. data/app/views/upright/active_storage/attachments/_attachment.html.erb +1 -1
  81. data/app/views/upright/artifacts/show.html.erb +8 -0
  82. data/app/views/upright/incidents/_form.html.erb +93 -0
  83. data/app/views/upright/incidents/_row.html.erb +15 -0
  84. data/app/views/upright/incidents/edit.html.erb +64 -0
  85. data/app/views/upright/incidents/index.html.erb +53 -0
  86. data/app/views/upright/incidents/new.html.erb +6 -0
  87. data/app/views/upright/public/_branding.html.erb +3 -0
  88. data/app/views/upright/public/_footer.html.erb +3 -0
  89. data/app/views/upright/public/incidents/show.html.erb +29 -0
  90. data/app/views/upright/public/service_incidents/_incident.html.erb +14 -0
  91. data/app/views/upright/public/service_incidents/index.html.erb +50 -0
  92. data/app/views/upright/public/services/_active_events.html.erb +28 -0
  93. data/app/views/upright/public/services/_degraded_list.html.erb +14 -0
  94. data/app/views/upright/public/services/_overall_banner.html.erb +4 -0
  95. data/app/views/upright/public/services/_service.html.erb +34 -0
  96. data/app/views/upright/public/services/_upcoming_maintenance.html.erb +21 -0
  97. data/app/views/upright/public/services/_uptime_bar.html.erb +9 -0
  98. data/app/views/upright/public/services/index.html.erb +13 -0
  99. data/app/views/upright/public/services/index.rss.builder +29 -0
  100. data/app/views/upright/sessions/new.html.erb +14 -1
  101. data/config/importmap.rb +25 -14
  102. data/config/initializers/date_formats.rb +5 -0
  103. data/config/initializers/mime_types.rb +1 -0
  104. data/config/routes.rb +26 -2
  105. data/db/migrate/20260629000001_fix_probe_result_duration_precision.rb +14 -0
  106. data/db/persistent_migrate/20260512000001_create_upright_rollups.rb +16 -0
  107. data/db/persistent_migrate/20260702000001_create_upright_incidents.rb +34 -0
  108. data/db/persistent_migrate/20260707000001_add_probe_identity_to_upright_rollups.rb +11 -0
  109. data/db/persistent_migrate/20260709000001_add_created_by_to_incidents.rb +7 -0
  110. data/db/persistent_migrate/20260901000001_add_auto_reporting_to_upright_incidents.rb +22 -0
  111. data/lib/generators/upright/install/database_config.rb +46 -0
  112. data/lib/generators/upright/install/install_generator.rb +18 -10
  113. data/lib/generators/upright/install/templates/Dockerfile +80 -0
  114. data/lib/generators/upright/install/templates/content_security_policy.rb +42 -0
  115. data/lib/generators/upright/install/templates/deploy.yml +13 -13
  116. data/lib/generators/upright/install/templates/development_prometheus.yml +1 -1
  117. data/lib/generators/upright/install/templates/docker-compose.yml +12 -11
  118. data/lib/generators/upright/install/templates/omniauth.rb +7 -3
  119. data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
  120. data/lib/generators/upright/install/templates/recurring.yml +20 -0
  121. data/lib/generators/upright/install/templates/sites.yml +4 -0
  122. data/lib/generators/upright/install/templates/upright.rb +21 -2
  123. data/lib/generators/upright/install/templates/upright.rules.yml +11 -4
  124. data/lib/upright/configuration.rb +113 -6
  125. data/lib/upright/engine.rb +25 -2
  126. data/lib/upright/metrics.rb +25 -0
  127. data/lib/upright/site.rb +31 -1
  128. data/lib/upright/version.rb +3 -1
  129. data/lib/upright.rb +29 -2
  130. metadata +107 -7
  131. data/config/credentials/development.key +0 -1
  132. data/config/credentials/test.key +0 -1
  133. data/lib/tasks/upright_tasks.rake +0 -4
@@ -0,0 +1,28 @@
1
+ <% events = incidents.to_a + maintenances.to_a %>
2
+ <% if events.any? %>
3
+ <section class="events">
4
+ <% events.each do |event| %>
5
+ <%= link_to public_incident_path(event), class: "event event--#{event.maintenance? ? "maintenance" : event.impact}" do %>
6
+ <div class="event__head">
7
+ <span class="event__title"><%= event.title %></span>
8
+ <span class="event__status"><%= status_label(event.status) %></span>
9
+ </div>
10
+
11
+ <% if event.maintenance? %>
12
+ <p class="event__meta"><%= local_maintenance_window(event) %></p>
13
+ <% end %>
14
+
15
+ <% if event.public_services.any? %>
16
+ <p class="event__services"><%= event.public_services.map(&:name).to_sentence %></p>
17
+ <% end %>
18
+
19
+ <% if update = event.updates.first %>
20
+ <p class="event__update">
21
+ <%= update.body %>
22
+ <%= local_time update.created_at, format: :month_day_at_zone, class: "event__time" %>
23
+ </p>
24
+ <% end %>
25
+ <% end %>
26
+ <% end %>
27
+ </section>
28
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <% if degraded.any? %>
2
+ <ul class="status-degraded">
3
+ <% degraded.each do |outage| %>
4
+ <li class="status-degraded__item">
5
+ <span class="status-degraded__dot status-degraded__dot--<%= outage.status %>" aria-hidden="true"></span>
6
+ <span class="status-degraded__name"><%= outage.service.name %></span>
7
+ <span class="status-degraded__detail">
8
+ <%= status_label(outage.status).downcase %>
9
+ <% if outage.started_at %>· <%= local_time_ago outage.started_at %><% end %>
10
+ </span>
11
+ </li>
12
+ <% end %>
13
+ </ul>
14
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <div class="status-banner status-banner--<%= status %>">
2
+ <span class="status-banner__dot" aria-hidden="true"></span>
3
+ <%= overall_status_label(status) %>
4
+ </div>
@@ -0,0 +1,34 @@
1
+ <% today_status = history.last.status %>
2
+
3
+ <section class="service">
4
+ <div class="service__row">
5
+ <%= link_to service.name, public_service_incidents_path(service.code), class: "service__name" %>
6
+ <% if service.maintenance_active? %>
7
+ <span class="service__status service__status--maintenance">Under maintenance</span>
8
+ <% else %>
9
+ <span class="service__status service__status--<%= today_status %>">
10
+ <%= status_label(today_status) %>
11
+ </span>
12
+ <% end %>
13
+ </div>
14
+
15
+ <% if description = service.try(:description).presence %>
16
+ <p class="service__description"><%= description %></p>
17
+ <% end %>
18
+
19
+ <div class="uptime">
20
+ <div class="uptime__bars" role="img" aria-label="<%= history.size %>-day uptime for <%= service.name %>">
21
+ <%= render partial: "uptime_bar", collection: history, as: :day %>
22
+ </div>
23
+
24
+ <div class="uptime__meta">
25
+ <span><%= history.size %> days ago</span>
26
+ <% if uptime = uptime_percentage_label(history.map(&:uptime_fraction).compact) %>
27
+ <span class="uptime__meta-percent"><%= uptime %> uptime</span>
28
+ <% else %>
29
+ <span class="uptime__meta-percent">No data yet</span>
30
+ <% end %>
31
+ <span>Today</span>
32
+ </div>
33
+ </div>
34
+ </section>
@@ -0,0 +1,21 @@
1
+ <% if maintenances.any? %>
2
+ <section class="upcoming">
3
+ <h2 class="upcoming__title">Scheduled maintenance</h2>
4
+ <% maintenances.each do |maintenance| %>
5
+ <%= link_to public_incident_path(maintenance), class: "upcoming__item" do %>
6
+ <div class="event__head">
7
+ <span class="event__title"><%= maintenance.title %></span>
8
+ <span class="upcoming__when"><%= local_maintenance_window(maintenance) %></span>
9
+ </div>
10
+
11
+ <% if maintenance.public_services.any? %>
12
+ <p class="event__services"><%= maintenance.public_services.map(&:name).to_sentence %></p>
13
+ <% end %>
14
+
15
+ <% if (update = maintenance.updates.first) && update.body.present? %>
16
+ <p class="event__update"><%= update.body %></p>
17
+ <% end %>
18
+ <% end %>
19
+ <% end %>
20
+ </section>
21
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <span class="uptime__bar uptime__bar--<%= day.status %>" aria-label="<%= day.aria_label %>">
2
+ <span class="uptime__tooltip" aria-hidden="true">
3
+ <span class="uptime__tooltip-date"><%= day.date_label %></span>
4
+ <span class="uptime__tooltip-detail">
5
+ <span class="uptime__tooltip-dot uptime__tooltip-dot--<%= day.status || "none" %>"></span>
6
+ <%= day.detail %>
7
+ </span>
8
+ </span>
9
+ </span>
@@ -0,0 +1,13 @@
1
+ <div class="status-page">
2
+ <h1 class="status-page__title">Status</h1>
3
+
4
+ <%= render "overall_banner", status: @status_page.overall_status %>
5
+ <%= render "degraded_list", degraded: @status_page.outages %>
6
+
7
+ <%= render "active_events", incidents: @status_page.active_incidents, maintenances: @status_page.active_maintenances %>
8
+ <%= render "upcoming_maintenance", maintenances: @status_page.upcoming_maintenances %>
9
+
10
+ <% @status_page.service_histories.each do |service, history| %>
11
+ <%= render "service", service: service, history: history %>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,29 @@
1
+ xml.instruct! :xml, version: "1.0", encoding: "UTF-8"
2
+ xml.rss(version: "2.0") do
3
+ xml.channel do
4
+ xml.title "Upright Status"
5
+ xml.link request.base_url
6
+ xml.description "Currently degraded services"
7
+ xml.lastBuildDate Time.current.rfc822
8
+
9
+ @status_page.outages.each do |outage|
10
+ xml.item do
11
+ xml.title "#{outage.service.name} — #{status_label(outage.status)}"
12
+ xml.description "#{outage.service.name} is currently #{status_label(outage.status).downcase} #{outage_duration_description(started_at: outage.started_at)}."
13
+ xml.pubDate outage.started_at.rfc822 if outage.started_at
14
+ xml.guid feed_item_guid(outage), isPermaLink: "false"
15
+ end
16
+ end
17
+
18
+ @status_page.active_events.each do |event|
19
+ update = event.updates.first
20
+ xml.item do
21
+ xml.title "#{event.title} — #{status_label(event.status)}"
22
+ xml.description update&.body.to_s
23
+ xml.link public_incident_url(event)
24
+ xml.pubDate (update&.created_at || event.starts_at).rfc822
25
+ xml.guid "incident-#{event.id}-#{update&.id}", isPermaLink: "false"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,6 +1,19 @@
1
1
  <div class="sign-in-container">
2
2
  <div class="sign-in-box">
3
3
  <h1>Upright</h1>
4
- <%= button_to "Sign in", "/auth/#{Upright.configuration.auth_provider}", method: :post, data: { turbo: false }, class: "btn" %>
4
+ <% if Upright.configuration.auth_provider.to_sym == :static_credentials %>
5
+ <%# Post credentials straight to the OmniAuth callback, but as a same-origin
6
+ form carrying a Rails authenticity token so a cross-site request can't
7
+ forge a sign-in (CVE-2026-67993). %>
8
+ <%= form_with url: "/auth/static_credentials/callback", method: :post, data: { turbo: false } do |form| %>
9
+ <%= form.label :username, "Username" %>
10
+ <%= form.text_field :username, autocomplete: "username" %>
11
+ <%= form.label :password, "Password" %>
12
+ <%= form.password_field :password, autocomplete: "current-password" %>
13
+ <%= form.submit "Sign in", class: "btn" %>
14
+ <% end %>
15
+ <% else %>
16
+ <%= button_to "Sign in", "/auth/#{Upright.configuration.auth_provider}", method: :post, data: { turbo: false }, class: "btn" %>
17
+ <% end %>
5
18
  </div>
6
19
  </div>
data/config/importmap.rb CHANGED
@@ -1,18 +1,29 @@
1
- # Pin engine JavaScript
2
- pin "application", to: "upright/application.js"
3
- pin "upright/application", to: "upright/application.js"
4
- pin "upright/controllers/application", to: "upright/controllers/application.js"
5
- pin "upright/controllers", to: "upright/controllers/index.js"
1
+ # Two entry points share this map. The admin app loads "application"; the public
2
+ # status page loads "upright/public". Pins used only by the admin app are
3
+ # preloaded for the "application" entry point alone, so the public page does not
4
+ # fetch Turbo, Stimulus, Leaflet or the charts library.
5
+
6
+ # Admin app
7
+ pin "application", to: "upright/application.js", preload: "application"
8
+ pin "upright/application", to: "upright/application.js", preload: "application"
9
+ pin "upright/controllers/application", to: "upright/controllers/application.js", preload: "application"
10
+ pin "upright/controllers", to: "upright/controllers/index.js", preload: "application"
6
11
 
7
12
  pin_all_from Upright::Engine.root.join("app/javascript/upright/controllers"),
8
13
  under: "upright/controllers",
9
- to: "upright/controllers"
14
+ to: "upright/controllers",
15
+ preload: "application"
16
+
17
+ pin "@hotwired/turbo-rails", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo-rails@8.0.20/+esm", preload: "application"
18
+ pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.20/+esm", preload: "application"
19
+ pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js", preload: "application"
20
+ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: "application"
21
+ pin "@rails/actioncable/src", to: "https://cdn.jsdelivr.net/npm/@rails/actioncable@8.1.100/src/index.js", preload: "application"
22
+ pin "leaflet", to: "https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet-src.esm.js", preload: "application"
23
+ pin "frappe-charts", to: "https://cdn.jsdelivr.net/npm/frappe-charts@1.6.2/dist/frappe-charts.min.esm.js", preload: "application"
24
+
25
+ # Public status page
26
+ pin "upright/public", to: "upright/public.js", preload: "upright/public"
10
27
 
11
- # Dependencies
12
- pin "@hotwired/turbo-rails", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo-rails@8.0.20/+esm"
13
- pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.20/+esm"
14
- pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
15
- pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
16
- pin "@rails/actioncable/src", to: "https://cdn.jsdelivr.net/npm/@rails/actioncable@8.1.100/src/index.js"
17
- pin "leaflet", to: "https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet-src.esm.js"
18
- pin "frappe-charts", to: "https://cdn.jsdelivr.net/npm/frappe-charts@1.6.2/dist/frappe-charts.min.esm.js"
28
+ # Shared
29
+ pin "local-time", to: "local-time.es2017-esm.js", preload: true
@@ -0,0 +1,5 @@
1
+ Date::DATE_FORMATS[:month_day] ||= "%b %-d"
2
+
3
+ Time::DATE_FORMATS[:month_day_at] ||= "%b %-d, %H:%M"
4
+ Time::DATE_FORMATS[:month_day_at_zone] ||= "%b %-d, %H:%M %Z"
5
+ Time::DATE_FORMATS[:clock_zone] ||= "%H:%M %Z"
@@ -0,0 +1 @@
1
+ Mime::Type.register "application/rss+xml", :rss unless Mime::Type.lookup_by_extension(:rss)
data/config/routes.rb CHANGED
@@ -1,6 +1,11 @@
1
1
  Upright::Engine.routes.draw do
2
- global_subdomain = ->(req) { Upright.configuration.global_subdomain == req.subdomain }
3
- site_subdomain = ->(req) { Upright.configuration.site_subdomains.include?(req.subdomain) }
2
+ global_subdomain = ->(req) { Upright.configuration.global_subdomain == req.subdomain }
3
+ site_subdomain = ->(req) { Upright.configuration.site_subdomains.include?(req.subdomain) }
4
+ metrics_subdomain = ->(req) { global_subdomain.call(req) || Upright.find_site(req.subdomain)&.stores_metrics? }
5
+ public_status = ->(req) {
6
+ Upright.configuration.public_status_enabled &&
7
+ req.subdomain == Upright.configuration.public_status_subdomain
8
+ }
4
9
 
5
10
  constraints global_subdomain do
6
11
  root "sites#index", as: :admin_root
@@ -13,6 +18,12 @@ Upright::Engine.routes.draw do
13
18
  resource :probe_status, only: :show
14
19
  end
15
20
 
21
+ resources :incidents do
22
+ resources :updates, only: :create, controller: "incidents/updates"
23
+ end
24
+ end
25
+
26
+ constraints metrics_subdomain do
16
27
  scope :framed do
17
28
  resource :prometheus, only: :show, controller: :prometheus_proxy
18
29
  resource :alertmanager, only: :show, controller: :alertmanager_proxy
@@ -29,6 +40,8 @@ Upright::Engine.routes.draw do
29
40
 
30
41
  resources :artifacts, only: :show, as: :site_artifacts
31
42
 
43
+ match "traces/:signed_id", to: "traces#show", as: :site_trace, via: [ :get, :options ]
44
+
32
45
  scope :framed do
33
46
  resource :jobs, only: :show
34
47
  end
@@ -36,6 +49,17 @@ Upright::Engine.routes.draw do
36
49
  mount MissionControl::Jobs::Engine, at: "/jobs"
37
50
  end
38
51
 
52
+ constraints public_status do
53
+ scope module: :public, as: :public do
54
+ root "services#index", as: :services_root
55
+ get "feed", to: "services#index", as: :services_feed, defaults: { format: :rss }
56
+ resources :services, only: [], param: :code do
57
+ resources :incidents, only: :index, controller: :service_incidents
58
+ end
59
+ resources :incidents, only: :show
60
+ end
61
+ end
62
+
39
63
  # Base routes (no subdomain constraint)
40
64
  resource :session, only: :destroy
41
65
  root "sites#index"
@@ -0,0 +1,14 @@
1
+ class FixProbeResultDurationPrecision < ActiveRecord::Migration[8.0]
2
+ # `t.decimal :duration` with no precision becomes DECIMAL(10,0) on MySQL,
3
+ # which rounds every duration to a whole second on write. Probe durations
4
+ # are sub-second floats from a monotonic clock, so they were all stored as
5
+ # 0. SQLite preserved the fractional value, which is why this only surfaced
6
+ # after the MySQL migration. Give the column microsecond precision.
7
+ def up
8
+ change_column :upright_probe_results, :duration, :decimal, precision: 10, scale: 6
9
+ end
10
+
11
+ def down
12
+ change_column :upright_probe_results, :duration, :decimal
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ class CreateUprightRollups < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :upright_rollups_probe_rollups do |t|
4
+ t.string :probe_name, null: false
5
+ t.string :probe_service
6
+ t.datetime :period_start, null: false
7
+ t.float :uptime_fraction, null: false
8
+ t.integer :status, default: 0, null: false
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :upright_rollups_probe_rollups, [ :probe_name, :period_start ], unique: true
14
+ add_index :upright_rollups_probe_rollups, [ :probe_service, :period_start ]
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ class CreateUprightIncidents < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :upright_incidents do |t|
4
+ t.string :type
5
+ t.string :title, null: false
6
+ t.string :status, null: false
7
+ t.string :impact, null: false
8
+ t.datetime :starts_at, null: false
9
+ t.datetime :ends_at
10
+ t.datetime :resolved_at
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :upright_incidents, [ :resolved_at, :starts_at ]
16
+ add_index :upright_incidents, [ :type, :starts_at ]
17
+
18
+ create_table :upright_incident_updates do |t|
19
+ t.references :incident, null: false, foreign_key: { to_table: :upright_incidents }
20
+ t.string :status, null: false
21
+ t.text :body
22
+
23
+ t.datetime :created_at, null: false
24
+ end
25
+
26
+ create_table :upright_incident_affected_services do |t|
27
+ t.references :incident, null: false, foreign_key: { to_table: :upright_incidents }
28
+ t.string :service_code, null: false
29
+ end
30
+
31
+ add_index :upright_incident_affected_services, [ :incident_id, :service_code ], unique: true
32
+ add_index :upright_incident_affected_services, :service_code
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ class AddProbeIdentityToUprightRollups < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :upright_rollups_probe_rollups, :probe_type, :string
4
+ add_column :upright_rollups_probe_rollups, :probe_target, :string
5
+
6
+ remove_index :upright_rollups_probe_rollups, column: [ :probe_name, :period_start ], unique: true
7
+ add_index :upright_rollups_probe_rollups,
8
+ [ :probe_name, :probe_type, :probe_target, :period_start ],
9
+ unique: true, name: "idx_probe_rollups_identity_period"
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ class AddCreatedByToIncidents < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :upright_incidents, :created_by, :string
4
+ add_column :upright_incidents, :updated_by, :string
5
+ add_column :upright_incident_updates, :created_by, :string
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ class AddAutoReportingToUprightIncidents < ActiveRecord::Migration[8.0]
2
+ def up
3
+ add_column :upright_incidents, :auto_created, :boolean, default: false, null: false
4
+ add_column :upright_incidents, :last_seen_down_at, :datetime
5
+ add_column :upright_incidents, :recovery_started_at, :datetime
6
+
7
+ create_table :upright_incident_automatic_reports do |t|
8
+ t.references :incident, null: false, foreign_key: { to_table: :upright_incidents }, index: { unique: true }
9
+ t.string :service_code, null: false, index: { unique: true }
10
+ t.timestamps
11
+ end
12
+ end
13
+
14
+ def down
15
+ drop_table :upright_incident_automatic_reports, if_exists: true
16
+ remove_index :upright_incidents, :auto_service_code, if_exists: true
17
+ remove_column :upright_incidents, :auto_service_code, if_exists: true
18
+ remove_column :upright_incidents, :recovery_started_at, if_exists: true
19
+ remove_column :upright_incidents, :last_seen_down_at
20
+ remove_column :upright_incidents, :auto_created
21
+ end
22
+ end
@@ -0,0 +1,46 @@
1
+ module Upright
2
+ module Generators
3
+ # Rewrites the single-database development and test entries that `rails new`
4
+ # writes into the three databases Upright uses:
5
+ #
6
+ # primary probe results and Active Storage artifacts
7
+ # persistent rollups, incidents and maintenance windows, which outlive a
8
+ # site's probe data; its migrations ship with the gem
9
+ # queue Solid Queue
10
+ #
11
+ # Pure string transformation so the install generator's edit can be tested
12
+ # without a generated application.
13
+ module DatabaseConfig
14
+ ENVIRONMENTS = %w[development test].freeze
15
+
16
+ PERSISTENT_MIGRATIONS = %(<%= Gem.loaded_specs["upright"].gem_dir %>/db/persistent_migrate)
17
+
18
+ def self.rewrite(yaml)
19
+ ENVIRONMENTS.reduce(yaml) do |rewritten, env|
20
+ rewritten.sub(single_database(env), split_databases(env))
21
+ end
22
+ end
23
+
24
+ def self.single_database(env)
25
+ "#{env}:\n <<: *default\n database: storage/#{env}.sqlite3"
26
+ end
27
+
28
+ def self.split_databases(env)
29
+ <<~YAML.chomp
30
+ #{env}:
31
+ primary:
32
+ <<: *default
33
+ database: storage/#{env}.sqlite3
34
+ persistent:
35
+ <<: *default
36
+ database: storage/#{env}_persistent.sqlite3
37
+ migrations_paths: #{PERSISTENT_MIGRATIONS}
38
+ queue:
39
+ <<: *default
40
+ database: storage/#{env}_queue.sqlite3
41
+ migrations_paths: db/queue_migrate
42
+ YAML
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,3 +1,5 @@
1
+ require_relative "database_config"
2
+
1
3
  module Upright
2
4
  module Generators
3
5
  class InstallGenerator < Rails::Generators::Base
@@ -17,6 +19,11 @@ module Upright
17
19
  def copy_initializers
18
20
  template "upright.rb", "config/initializers/upright.rb"
19
21
  template "omniauth.rb", "config/initializers/omniauth.rb"
22
+ # Never force: an app may already run an enforced CSP, and silently
23
+ # replacing it with this commented template would disable it. On a
24
+ # collision Thor prompts instead of overwriting; the recommended policy is
25
+ # documented for the operator to merge.
26
+ template "content_security_policy.rb", "config/initializers/content_security_policy.rb"
20
27
  end
21
28
 
22
29
  def copy_sites_config
@@ -46,6 +53,7 @@ module Upright
46
53
 
47
54
  def copy_deploy_config
48
55
  template "deploy.yml", "config/deploy.yml"
56
+ template "Dockerfile", "Dockerfile"
49
57
  end
50
58
 
51
59
  def copy_puma_config
@@ -56,14 +64,14 @@ module Upright
56
64
  rails_command "solid_queue:install"
57
65
  end
58
66
 
59
- def add_queue_database
60
- gsub_file "config/database.yml",
61
- "development:\n <<: *default\n database: storage/development.sqlite3",
62
- "development:\n primary:\n <<: *default\n database: storage/development.sqlite3\n queue:\n <<: *default\n database: storage/development_queue.sqlite3\n migrations_paths: db/queue_migrate"
63
-
64
- gsub_file "config/database.yml",
65
- "test:\n <<: *default\n database: storage/test.sqlite3",
66
- "test:\n primary:\n <<: *default\n database: storage/test.sqlite3\n queue:\n <<: *default\n database: storage/test_queue.sqlite3\n migrations_paths: db/queue_migrate"
67
+ # Splits the development and test databases into primary, persistent and
68
+ # queue. The persistent database holds rollups, incidents and maintenance
69
+ # windows, and its migrations come from the gem, so database.yml points
70
+ # migrations_paths at the installed gem's db/persistent_migrate.
71
+ def add_persistent_and_queue_databases
72
+ gsub_file "config/database.yml", /\A.*\z/m do |yaml|
73
+ DatabaseConfig.rewrite(yaml)
74
+ end
67
75
  end
68
76
 
69
77
  def copy_recurring_config
@@ -101,11 +109,11 @@ module Upright
101
109
  say "Upright has been installed!", :green
102
110
  say ""
103
111
  say "Next steps:"
104
- say " 1. Prepare the database: bin/rails db:prepare"
112
+ say " 1. Prepare the databases (primary, persistent and queue): bin/rails db:prepare"
105
113
  say " 2. Configure your servers in config/deploy.yml"
106
114
  say " 3. Configure sites in config/sites.yml"
107
115
  say " 4. Add probes in probes/*.yml"
108
- say " 5. Set ADMIN_PASSWORD env var (default: upright)"
116
+ say " 5. Set the ADMIN_PASSWORD env var — required, there is no default password"
109
117
  say ""
110
118
  say "For production, review config/initializers/upright.rb and update:"
111
119
  say " config.hostname = \"example.com\""
@@ -0,0 +1,80 @@
1
+ # syntax = docker/dockerfile:1
2
+
3
+ # This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
4
+ # docker build -t my-app .
5
+ # docker run -d -p 80:80 -p 443:443 --name my-app -e RAILS_MASTER_KEY=<value from config/master.key> my-app
6
+
7
+ # Make sure RUBY_VERSION matches the Ruby version in .ruby-version
8
+ ARG RUBY_VERSION=<%= RUBY_VERSION %>
9
+ FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
10
+
11
+ # Rails app lives here
12
+ WORKDIR /rails
13
+
14
+ # Install base packages and Node.js (for Playwright)
15
+ RUN apt-get update -qq && \
16
+ apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 iputils-ping mtr-tiny \
17
+ nodejs npm && \
18
+ rm -rf /var/lib/apt/lists /var/cache/apt/archives
19
+
20
+ # Set production environment
21
+ ENV RAILS_ENV="production" \
22
+ BUNDLE_DEPLOYMENT="1" \
23
+ BUNDLE_PATH="/usr/local/bundle" \
24
+ BUNDLE_WITHOUT="development"
25
+
26
+ # Throw-away build stage to reduce size of final image
27
+ FROM base AS build
28
+
29
+ # Install packages needed to build gems
30
+ RUN apt-get update -qq && \
31
+ apt-get install --no-install-recommends -y build-essential git pkg-config libssl-dev libyaml-dev rustc cargo libclang-dev && \
32
+ rm -rf /var/lib/apt/lists /var/cache/apt/archives
33
+
34
+ # Install application gems
35
+ COPY Gemfile Gemfile.lock ./
36
+ RUN --mount=type=secret,id=GITHUB_TOKEN \
37
+ BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \
38
+ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
39
+ bundle exec bootsnap precompile --gemfile && \
40
+ bundle exec ruby -e "require 'upright/version'; puts Upright::PLAYWRIGHT_VERSION" > /tmp/playwright_version
41
+
42
+ # Copy application code
43
+ COPY . .
44
+
45
+ # Precompile bootsnap code for faster boot times
46
+ RUN bundle exec bootsnap precompile app.rb probes/
47
+
48
+ # Precompiling assets for production without requiring secret RAILS_MASTER_KEY
49
+ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
50
+
51
+ # Final stage for app image
52
+ FROM base
53
+
54
+ # Install Playwright before copying app code so the layer is cached on code-only deploys.
55
+ # The version is extracted to a file in the build stage; BuildKit's content-based caching
56
+ # keeps this layer cached even across Gemfile changes when the version is unchanged.
57
+ COPY --from=build /tmp/playwright_version /tmp/playwright_version
58
+ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
59
+ RUN PLAYWRIGHT_VERSION=$(cat /tmp/playwright_version) && \
60
+ npm install -g playwright@~${PLAYWRIGHT_VERSION}.0 && \
61
+ playwright install --with-deps chromium && \
62
+ chmod -R o+rx /ms-playwright
63
+
64
+ # Copy built artifacts: gems, application
65
+ COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
66
+ COPY --from=build /rails /rails
67
+
68
+ # Run and own only the runtime files as a non-root user for security
69
+ RUN groupadd --system --gid 1000 rails && \
70
+ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
71
+ mkdir -p storage tmp && \
72
+ chown -R rails:rails storage tmp
73
+ USER 1000:1000
74
+
75
+ # Entrypoint prepares the database.
76
+ ENTRYPOINT ["/rails/bin/docker-entrypoint"]
77
+
78
+ # Start the server by default, this can be overwritten at runtime
79
+ EXPOSE 3000 9394
80
+ CMD ["./bin/rails", "server"]
@@ -0,0 +1,42 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # Content Security Policy tuned for Upright. Uncomment the block below to
4
+ # enforce it. Every source the engine actually uses is listed explicitly:
5
+ #
6
+ # * JavaScript dependencies (Turbo, Stimulus, Leaflet, Frappe Charts) are
7
+ # pinned to https://cdn.jsdelivr.net in the engine's config/importmap.rb.
8
+ # * The Leaflet stylesheet is loaded from https://unpkg.com in the engine
9
+ # layout; Turbo injects an inline <style> for its progress bar, which is
10
+ # why style-src keeps :unsafe_inline (styles only — scripts stay strict).
11
+ # * Map tiles come from OpenStreetMap (light) and Carto (dark); Leaflet's
12
+ # default marker icons load from unpkg alongside its stylesheet.
13
+ # * The inline <script type="importmap"> tag is authorized by a per-session
14
+ # nonce via csp_meta_tag, which the engine layouts already render.
15
+ #
16
+ # To verify without breaking anything, start with
17
+ # config.content_security_policy_report_only = true and watch the browser
18
+ # console before enforcing.
19
+
20
+ # Rails.application.configure do
21
+ # config.content_security_policy do |policy|
22
+ # policy.default_src :self
23
+ # policy.font_src :self, :data
24
+ # policy.img_src :self, :data, "https://unpkg.com",
25
+ # "https://*.tile.openstreetmap.org"
26
+ # policy.object_src :none
27
+ # policy.script_src :self, "https://cdn.jsdelivr.net"
28
+ # policy.style_src :self, :unsafe_inline, "https://unpkg.com"
29
+ # policy.connect_src :self
30
+ # policy.frame_ancestors :self
31
+ # policy.base_uri :self
32
+ # # Specify URI for violation reports
33
+ # # policy.report_uri "/csp-violation-report-endpoint"
34
+ # end
35
+ #
36
+ # # Generate session nonces for the inline importmap script tag.
37
+ # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
38
+ # config.content_security_policy_nonce_directives = %w(script-src)
39
+ #
40
+ # # Report violations without enforcing the policy.
41
+ # # config.content_security_policy_report_only = true
42
+ # end