upright 0.2.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +208 -56
  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/assets/stylesheets/upright/uptime-bars.css +10 -1
  13. data/app/controllers/concerns/.keep +0 -0
  14. data/app/controllers/concerns/upright/authentication.rb +1 -0
  15. data/app/controllers/concerns/upright/proxy_authentication.rb +134 -0
  16. data/app/controllers/upright/alertmanager_proxy_controller.rb +12 -4
  17. data/app/controllers/upright/artifacts_controller.rb +1 -1
  18. data/app/controllers/upright/incidents/updates_controller.rb +13 -0
  19. data/app/controllers/upright/incidents_controller.rb +53 -0
  20. data/app/controllers/upright/probe_results_controller.rb +1 -0
  21. data/app/controllers/upright/prometheus_proxy_controller.rb +13 -18
  22. data/app/controllers/upright/public/base_controller.rb +16 -0
  23. data/app/controllers/upright/public/incidents_controller.rb +6 -0
  24. data/app/controllers/upright/public/service_incidents_controller.rb +7 -0
  25. data/app/controllers/upright/public/services_controller.rb +6 -0
  26. data/app/controllers/upright/sessions_controller.rb +37 -1
  27. data/app/controllers/upright/traces_controller.rb +88 -0
  28. data/app/helpers/upright/application_helper.rb +22 -2
  29. data/app/helpers/upright/artifacts_helper.rb +20 -0
  30. data/app/helpers/upright/probe_results_helper.rb +4 -10
  31. data/app/helpers/upright/public/services_helper.rb +70 -0
  32. data/app/javascript/upright/application.js +3 -0
  33. data/app/javascript/upright/controllers/sites_map_controller.js +13 -5
  34. data/app/javascript/upright/controllers/template_controller.js +11 -0
  35. data/app/javascript/upright/public.js +5 -0
  36. data/app/jobs/upright/health_metrics_job.rb +27 -0
  37. data/app/jobs/upright/incident_reporter_job.rb +7 -0
  38. data/app/jobs/upright/maintenance_advance_job.rb +8 -0
  39. data/app/jobs/upright/playwright_video_sweep_job.rb +21 -0
  40. data/app/jobs/upright/rollups/daily_aggregation_job.rb +14 -0
  41. data/app/models/concerns/.keep +0 -0
  42. data/app/models/concerns/upright/incidents/auto_reporting.rb +93 -0
  43. data/app/models/concerns/upright/incidents/lifecycle.rb +47 -0
  44. data/app/models/concerns/upright/playwright/form_authentication.rb +3 -5
  45. data/app/models/concerns/upright/playwright/lifecycle.rb +8 -13
  46. data/app/models/concerns/upright/playwright/trace_recording.rb +53 -0
  47. data/app/models/concerns/upright/playwright/video_recording.rb +55 -14
  48. data/app/models/concerns/upright/probe_result/stale_cleanup.rb +47 -0
  49. data/app/models/concerns/upright/probeable.rb +16 -2
  50. data/app/models/concerns/upright/services/live_status.rb +69 -0
  51. data/app/models/concerns/upright/services/maintenance_status.rb +19 -0
  52. data/app/models/upright/artifact.rb +2 -1
  53. data/app/models/upright/http/request.rb +13 -4
  54. data/app/models/upright/incident/automatic_report.rb +3 -0
  55. data/app/models/upright/incident.rb +87 -0
  56. data/app/models/upright/incident_affected_service.rb +9 -0
  57. data/app/models/upright/incident_update.rb +7 -0
  58. data/app/models/upright/maintenance.rb +36 -0
  59. data/app/models/upright/persistent_record.rb +12 -0
  60. data/app/models/upright/playwright/authenticator/base.rb +50 -70
  61. data/app/models/upright/probe_result.rb +6 -1
  62. data/app/models/upright/probes/http_probe.rb +30 -1
  63. data/app/models/upright/probes/playwright/base.rb +2 -0
  64. data/app/models/upright/probes/status/probe.rb +8 -0
  65. data/app/models/upright/probes/status.rb +5 -13
  66. data/app/models/upright/probes/uptime.rb +5 -12
  67. data/app/models/upright/rollups/daily_uptime.rb +28 -0
  68. data/app/models/upright/rollups/probe_rollup.rb +64 -0
  69. data/app/models/upright/rollups/probe_uptime.rb +9 -0
  70. data/app/models/upright/rollups/site_uptime.rb +75 -0
  71. data/app/models/upright/service/daily_status.rb +39 -0
  72. data/app/models/upright/service/incident_history.rb +24 -0
  73. data/app/models/upright/service/outage.rb +15 -0
  74. data/app/models/upright/service/status_page.rb +35 -0
  75. data/app/models/upright/service.rb +101 -0
  76. data/app/models/upright/status.rb +21 -0
  77. data/app/models/upright/traceroute/ip_metadata_lookup.rb +21 -2
  78. data/app/models/upright/traceroute/result.rb +8 -1
  79. data/app/views/layouts/upright/_header.html.erb +12 -2
  80. data/app/views/layouts/upright/application.html.erb +4 -1
  81. data/app/views/layouts/upright/public.html.erb +18 -0
  82. data/app/views/layouts/upright/public.rss.builder +1 -0
  83. data/app/views/upright/active_storage/attachments/_attachment.html.erb +1 -1
  84. data/app/views/upright/artifacts/show.html.erb +8 -0
  85. data/app/views/upright/dashboards/_uptime_probe_row.html.erb +5 -3
  86. data/app/views/upright/dashboards/probe_statuses/show.html.erb +1 -1
  87. data/app/views/upright/dashboards/uptimes/show.html.erb +1 -1
  88. data/app/views/upright/incidents/_form.html.erb +93 -0
  89. data/app/views/upright/incidents/_row.html.erb +15 -0
  90. data/app/views/upright/incidents/edit.html.erb +64 -0
  91. data/app/views/upright/incidents/index.html.erb +53 -0
  92. data/app/views/upright/incidents/new.html.erb +6 -0
  93. data/app/views/upright/probe_results/index.html.erb +7 -4
  94. data/app/views/upright/public/_branding.html.erb +3 -0
  95. data/app/views/upright/public/_footer.html.erb +3 -0
  96. data/app/views/upright/public/incidents/show.html.erb +29 -0
  97. data/app/views/upright/public/service_incidents/_incident.html.erb +14 -0
  98. data/app/views/upright/public/service_incidents/index.html.erb +50 -0
  99. data/app/views/upright/public/services/_active_events.html.erb +28 -0
  100. data/app/views/upright/public/services/_degraded_list.html.erb +14 -0
  101. data/app/views/upright/public/services/_overall_banner.html.erb +4 -0
  102. data/app/views/upright/public/services/_service.html.erb +34 -0
  103. data/app/views/upright/public/services/_upcoming_maintenance.html.erb +21 -0
  104. data/app/views/upright/public/services/_uptime_bar.html.erb +9 -0
  105. data/app/views/upright/public/services/index.html.erb +13 -0
  106. data/app/views/upright/public/services/index.rss.builder +29 -0
  107. data/app/views/upright/sessions/new.html.erb +14 -1
  108. data/config/ci.rb +2 -0
  109. data/config/importmap.rb +25 -14
  110. data/config/initializers/date_formats.rb +5 -0
  111. data/config/initializers/mime_types.rb +1 -0
  112. data/config/routes.rb +26 -2
  113. data/db/migrate/20260629000001_fix_probe_result_duration_precision.rb +14 -0
  114. data/db/persistent_migrate/20260512000001_create_upright_rollups.rb +16 -0
  115. data/db/persistent_migrate/20260702000001_create_upright_incidents.rb +34 -0
  116. data/db/persistent_migrate/20260707000001_add_probe_identity_to_upright_rollups.rb +11 -0
  117. data/db/persistent_migrate/20260709000001_add_created_by_to_incidents.rb +7 -0
  118. data/db/persistent_migrate/20260901000001_add_auto_reporting_to_upright_incidents.rb +22 -0
  119. data/lib/generators/upright/install/database_config.rb +46 -0
  120. data/lib/generators/upright/install/install_generator.rb +52 -3
  121. data/lib/generators/upright/install/templates/Dockerfile +80 -0
  122. data/lib/generators/upright/install/templates/content_security_policy.rb +42 -0
  123. data/lib/generators/upright/install/templates/deploy.yml +13 -13
  124. data/lib/generators/upright/install/templates/development_prometheus.yml +1 -1
  125. data/lib/generators/upright/install/templates/docker-compose.yml +12 -11
  126. data/lib/generators/upright/install/templates/http_probes.yml +1 -0
  127. data/lib/generators/upright/install/templates/omniauth.rb +7 -3
  128. data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
  129. data/lib/generators/upright/install/templates/recurring.yml +45 -0
  130. data/lib/generators/upright/install/templates/sites.yml +4 -0
  131. data/lib/generators/upright/install/templates/smtp_probes.yml +3 -0
  132. data/lib/generators/upright/install/templates/traceroute_probes.yml +3 -0
  133. data/lib/generators/upright/install/templates/upright.rb +24 -2
  134. data/lib/generators/upright/install/templates/upright.rules.yml +14 -7
  135. data/lib/upright/configuration.rb +127 -6
  136. data/lib/upright/engine.rb +32 -2
  137. data/lib/upright/metrics.rb +27 -2
  138. data/lib/upright/probe_type_registry.rb +33 -0
  139. data/lib/upright/site.rb +31 -1
  140. data/lib/upright/version.rb +3 -1
  141. data/lib/upright.rb +34 -2
  142. metadata +110 -7
  143. data/config/credentials/development.key +0 -1
  144. data/config/credentials/test.key +0 -1
  145. data/lib/tasks/upright_tasks.rake +0 -4
@@ -1,16 +1,24 @@
1
1
  class Upright::AlertmanagerProxyController < Upright::ApplicationController
2
- skip_forgery_protection
2
+ include Upright::ProxyAuthentication
3
3
 
4
4
  def show
5
5
  end
6
6
 
7
7
  def proxy
8
- proxy_to_alertmanager request.fullpath.delete_prefix("/alertmanager"), body: request.body&.read
8
+ url = upstream_url(alertmanager_url, request.fullpath.delete_prefix("/alertmanager"))
9
+
10
+ if url.nil?
11
+ head :bad_request
12
+ elsif token_forbidden_path?(url.path)
13
+ head :forbidden
14
+ else
15
+ proxy_to_alertmanager url, body: request.body&.read
16
+ end
9
17
  end
10
18
 
11
19
  private
12
- def proxy_to_alertmanager(path, method: request.method, body: nil)
13
- response = Faraday.new(url: alertmanager_url).run_request(method.downcase.to_sym, path, body, { "Content-Type" => request.content_type })
20
+ def proxy_to_alertmanager(url, method: request.method, body: nil)
21
+ response = Faraday.new(url: alertmanager_url).run_request(method.downcase.to_sym, url.to_s, body, { "Content-Type" => request.content_type })
14
22
 
15
23
  render body: response.body, status: response.status, content_type: response.headers["content-type"]
16
24
  end
@@ -1,5 +1,5 @@
1
1
  class Upright::ArtifactsController < Upright::ApplicationController
2
2
  def show
3
- @artifact = ActiveStorage::Attachment.find(params[:id])
3
+ @artifact = ActiveStorage::Attachment.where(record_type: "Upright::ProbeResult").find(params[:id])
4
4
  end
5
5
  end
@@ -0,0 +1,13 @@
1
+ class Upright::Incidents::UpdatesController < Upright::ApplicationController
2
+ def create
3
+ @incident = Upright::Incident.find(params[:incident_id])
4
+ update = @incident.record_update(incident_update_params)
5
+ redirect_to edit_incident_path(@incident),
6
+ flash: update.persisted? ? { notice: "Update posted." } : { alert: "Couldn't post update — check the status and message." }
7
+ end
8
+
9
+ private
10
+ def incident_update_params
11
+ params.expect(incident_update: [ :status, :body ])
12
+ end
13
+ end
@@ -0,0 +1,53 @@
1
+ class Upright::IncidentsController < Upright::ApplicationController
2
+ before_action :set_incident, only: %i[ edit update destroy ]
3
+
4
+ def index
5
+ @current = Upright::Incident.active.order(starts_at: :desc)
6
+ @upcoming = Upright::Maintenance.upcoming.order(:starts_at)
7
+ @past = Upright::Incident.past.limit(50)
8
+ end
9
+
10
+ def new
11
+ @incident = incident_class.new(starts_at: Time.current)
12
+ end
13
+
14
+ def create
15
+ @incident = incident_class.new(incident_params)
16
+
17
+ if @incident.save
18
+ redirect_to incidents_path, notice: "#{@incident.model_name.human} created."
19
+ else
20
+ render :new, status: :unprocessable_entity
21
+ end
22
+ end
23
+
24
+ def edit
25
+ end
26
+
27
+ def update
28
+ if @incident.update(incident_params)
29
+ redirect_to incidents_path, notice: "Saved."
30
+ else
31
+ render :edit, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ @incident.destroy
37
+ redirect_to incidents_path, notice: "Deleted."
38
+ end
39
+
40
+ private
41
+ def set_incident
42
+ @incident = Upright::Incident.find(params[:id])
43
+ end
44
+
45
+ def incident_class
46
+ maintenance = ActiveModel::Type::Boolean.new.cast(params[:maintenance])
47
+ maintenance ? Upright::Maintenance : Upright::Incident
48
+ end
49
+
50
+ def incident_params
51
+ params.expect(incident: [ :title, :impact, :starts_at, :ends_at, :body, service_codes: [] ])
52
+ end
53
+ end
@@ -12,6 +12,7 @@ class Upright::ProbeResultsController < Upright::ApplicationController
12
12
  .by_type(params[:probe_type])
13
13
  .by_status(params[:status])
14
14
  .by_name(params[:probe_name])
15
+ .by_date(params[:date])
15
16
  .with_attached_artifacts
16
17
  end
17
18
  end
@@ -1,8 +1,9 @@
1
1
  class Upright::PrometheusProxyController < Upright::ApplicationController
2
- skip_forgery_protection
2
+ include Upright::ProxyAuthentication
3
3
 
4
4
  skip_before_action :authenticate_user, only: :otlp
5
- before_action :authenticate_otlp_token, only: :otlp
5
+ skip_before_action :block_cross_site_session_requests, only: :otlp
6
+ before_action :authenticate_proxy_token, only: :otlp
6
7
 
7
8
  UNSUPPORTED_PATHS = %w[/api/v1/notifications]
8
9
 
@@ -10,12 +11,16 @@ class Upright::PrometheusProxyController < Upright::ApplicationController
10
11
  end
11
12
 
12
13
  def proxy
13
- path = request.fullpath.sub(%r{^/prometheus}, "")
14
+ url = upstream_url(Upright.configuration.prometheus_url, request.fullpath.delete_prefix("/prometheus"))
14
15
 
15
- if path.start_with?(*UNSUPPORTED_PATHS)
16
+ if url.nil?
17
+ head :bad_request
18
+ elsif url.path.start_with?(*UNSUPPORTED_PATHS)
16
19
  head :not_found
20
+ elsif token_forbidden_path?(url.path)
21
+ head :forbidden
17
22
  else
18
- proxy_to_prometheus(path)
23
+ proxy_to_prometheus(url)
19
24
  end
20
25
  end
21
26
 
@@ -29,10 +34,10 @@ class Upright::PrometheusProxyController < Upright::ApplicationController
29
34
  end
30
35
 
31
36
  private
32
- def proxy_to_prometheus(path, method: request.method, body: nil)
37
+ def proxy_to_prometheus(url, method: request.method, body: nil)
33
38
  response = prometheus_connection.run_request(
34
39
  method.downcase.to_sym,
35
- path,
40
+ url.to_s,
36
41
  body,
37
42
  { "Content-Type" => request.content_type }
38
43
  )
@@ -45,18 +50,8 @@ class Upright::PrometheusProxyController < Upright::ApplicationController
45
50
  end
46
51
 
47
52
  def prometheus_connection
48
- @prometheus_connection ||= Faraday.new(url: prometheus_url) do |f|
53
+ @prometheus_connection ||= Faraday.new(url: Upright.configuration.prometheus_url) do |f|
49
54
  f.options.timeout = 30
50
55
  end
51
56
  end
52
-
53
- def prometheus_url
54
- ENV.fetch("PROMETHEUS_URL", "http://localhost:9090")
55
- end
56
-
57
- def authenticate_otlp_token
58
- authenticate_or_request_with_http_token do |token|
59
- ActiveSupport::SecurityUtils.secure_compare(token, ENV.fetch("PROMETHEUS_OTLP_TOKEN", ""))
60
- end
61
- end
62
57
  end
@@ -0,0 +1,16 @@
1
+ # TODO: Throttle the public status routes (e.g. with Rack::Attack) if the 15s
2
+ # Prometheus result cache (Upright::Services::LiveStatus::CACHE_TTL) proves
3
+ # insufficient against anonymous request bursts. rack-attack isn't a dependency
4
+ # yet, so we lean on caching for now.
5
+ class Upright::Public::BaseController < ActionController::Base
6
+ layout "upright/public"
7
+
8
+ helper :all
9
+ protect_from_forgery with: :exception
10
+ rescue_from FrozenRecord::RecordNotFound, with: -> { head :not_found }
11
+
12
+ private
13
+ def default_url_options
14
+ Rails.application.routes.default_url_options
15
+ end
16
+ end
@@ -0,0 +1,6 @@
1
+ class Upright::Public::IncidentsController < Upright::Public::BaseController
2
+ def show
3
+ @incident = Upright::Incident.public_facing.find(params[:id])
4
+ expires_in 15.seconds, public: true
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ class Upright::Public::ServiceIncidentsController < Upright::Public::BaseController
2
+ def index
3
+ service = Upright::Service.public_facing.find_by!(code: params[:service_code])
4
+ @incident_history = service.incident_history(page: params[:page])
5
+ expires_in 15.seconds, public: true
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ class Upright::Public::ServicesController < Upright::Public::BaseController
2
+ def index
3
+ @status_page = Upright::Service::StatusPage.current
4
+ expires_in 15.seconds, public: true
5
+ end
6
+ end
@@ -1,16 +1,24 @@
1
1
  class Upright::SessionsController < Upright::ApplicationController
2
2
  skip_before_action :authenticate_user, only: [ :new, :create ]
3
+ # External identity providers (OpenID Connect, including
4
+ # `response_mode=form_post`) return to the callback without a Rails authenticity
5
+ # token and are validated by the provider's own state nonce, so Rails' automatic
6
+ # token check is skipped here. The static_credentials callback is same-origin and
7
+ # MUST present a token — enforced explicitly in verify_credential_callback.
3
8
  skip_forgery_protection only: :create
4
9
 
5
10
  before_action :ensure_not_signed_in, only: [ :new, :create ]
11
+ before_action :verify_credential_callback, only: :create
6
12
 
7
13
  def new
8
14
  end
9
15
 
10
16
  def create
17
+ return_to = safe_return_to(session[:return_to])
18
+ reset_session
11
19
  user = Upright::User.from_omniauth(request.env["omniauth.auth"])
12
20
  session[:user_info] = { email: user.email, name: user.name }
13
- redirect_to upright.root_path
21
+ redirect_to return_to || upright.root_path, allow_other_host: true
14
22
  end
15
23
 
16
24
  def destroy
@@ -23,6 +31,34 @@ class Upright::SessionsController < Upright::ApplicationController
23
31
  redirect_to upright.site_root_path if session[:user_info].present?
24
32
  end
25
33
 
34
+ # The static_credentials callback must arrive as a POST carrying a valid
35
+ # authenticity token: any other verb (a Lax-cookie GET, or a HEAD that CSRF
36
+ # skips) or a missing/foreign token could otherwise plant an attacker-chosen
37
+ # session (CVE-2026-67993). External providers are validated by their own
38
+ # state nonce (see skip_forgery_protection above) and are exempt.
39
+ def verify_credential_callback
40
+ return unless params[:provider].to_s == "static_credentials"
41
+
42
+ if !request.post?
43
+ head :not_found
44
+ elsif !verified_request?
45
+ raise ActionController::InvalidAuthenticityToken
46
+ end
47
+ end
48
+
49
+ # `allow_other_host: true` is needed to return across our own subdomains
50
+ # (app → a site), so bound it: only honour a stored return_to whose host is
51
+ # this deployment's hostname or a subdomain of it, never a foreign host (F-15).
52
+ def safe_return_to(url)
53
+ return nil if url.blank?
54
+
55
+ host = URI.parse(url).host
56
+ hostname = Upright.configuration.hostname
57
+ url if host && hostname.present? && (host == hostname || host.end_with?(".#{hostname}"))
58
+ rescue URI::InvalidURIError
59
+ nil
60
+ end
61
+
26
62
  def upright
27
63
  Upright::Engine.routes.url_helpers
28
64
  end
@@ -0,0 +1,88 @@
1
+ class Upright::TracesController < Upright::ApplicationController
2
+ include ActiveStorage::DisableSession
3
+
4
+ PURPOSE = :upright_trace
5
+ EXPIRES_IN = 24.hours
6
+
7
+ skip_before_action :authenticate_user
8
+
9
+ prepend_before_action :answer_preflight, if: -> { request.options? }
10
+
11
+ before_action :allow_trace_viewer_origin
12
+ before_action :set_trace
13
+
14
+ def self.signed_id_for(attachment)
15
+ attachment.signed_id(purpose: PURPOSE, expires_in: EXPIRES_IN)
16
+ end
17
+
18
+ def show
19
+ expires_in EXPIRES_IN, public: false
20
+ response.headers["Accept-Ranges"] = "bytes"
21
+
22
+ if request.head?
23
+ send_trace_size
24
+ elsif (range = request.headers["Range"]).present?
25
+ send_trace_range range
26
+ else
27
+ send_trace @trace.download
28
+ end
29
+ end
30
+
31
+ private
32
+ def allow_trace_viewer_origin
33
+ origin = Upright.configuration.trace_viewer_origin
34
+ return head :not_found if origin.blank?
35
+
36
+ response.headers["Access-Control-Allow-Origin"] = origin
37
+ response.headers["Access-Control-Expose-Headers"] = "Accept-Ranges, Content-Range"
38
+ response.headers["Vary"] = "Origin"
39
+ end
40
+
41
+ def answer_preflight
42
+ allow_trace_viewer_origin
43
+ return if performed?
44
+
45
+ response.headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"
46
+ response.headers["Access-Control-Allow-Headers"] = request.headers["Access-Control-Request-Headers"].to_s
47
+ response.headers["Access-Control-Max-Age"] = 1.day.to_i.to_s
48
+ response.headers["Vary"] = "Origin, Access-Control-Request-Headers"
49
+
50
+ head :no_content
51
+ end
52
+
53
+ def send_trace_size
54
+ response.headers["Content-Length"] = @trace.byte_size.to_s
55
+ response.headers["Content-Type"] = @trace.content_type_for_serving
56
+
57
+ head :ok
58
+ end
59
+
60
+ def send_trace_range(header)
61
+ ranges = Rack::Utils.get_byte_ranges(header, @trace.byte_size)
62
+ return head :range_not_satisfiable unless ranges&.one?
63
+
64
+ range = ranges.first
65
+ response.headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{@trace.byte_size}"
66
+
67
+ send_trace @trace.download_chunk(range), status: :partial_content
68
+ end
69
+
70
+ def send_trace(data, status: :ok)
71
+ send_data data,
72
+ status: status,
73
+ type: @trace.content_type_for_serving,
74
+ disposition: :inline,
75
+ filename: @trace.filename.sanitized
76
+ end
77
+
78
+ def set_trace
79
+ @trace = ActiveStorage::Blob.find_signed(params[:signed_id], purpose: PURPOSE)
80
+
81
+ head :not_found unless @trace && probe_result_trace?
82
+ end
83
+
84
+ def probe_result_trace?
85
+ @trace.filename.to_s.end_with?(".zip") &&
86
+ @trace.attachments.exists?(record_type: "Upright::ProbeResult")
87
+ end
88
+ end
@@ -7,14 +7,34 @@ module Upright::ApplicationHelper
7
7
  "#{country_flag(site.country)} #{site.city}"
8
8
  end
9
9
 
10
+ def metrics_subdomain
11
+ if Upright::Current.site.nil? || metrics_sites.none?
12
+ Upright.configuration.global_subdomain
13
+ elsif Upright::Current.site.stores_metrics?
14
+ Upright::Current.site.code
15
+ end
16
+ end
17
+
18
+ def metrics_sites_sentence
19
+ metrics_sites.map(&:city).to_sentence
20
+ end
21
+
22
+ def page_title_tag(app_name = "Upright")
23
+ tag.title [ @page_title, app_name ].compact.join(" · ")
24
+ end
25
+
10
26
  def upright_stylesheet_link_tag(**options)
11
- Upright::Engine.root.join("app/assets/stylesheets/upright").glob("*.css")
27
+ engine_stylesheets = Upright::Engine.root.join("app/assets/stylesheets/upright").glob("*.css")
12
28
  .map { |f| "upright/#{f.basename('.css')}" }.sort
13
- .then { |stylesheets| stylesheet_link_tag(*stylesheets, **options) }
29
+ stylesheet_link_tag(*engine_stylesheets, *Upright.configuration.public_stylesheets, **options)
14
30
  end
15
31
 
16
32
  private
17
33
 
34
+ def metrics_sites
35
+ Upright.sites.select(&:stores_metrics?)
36
+ end
37
+
18
38
  def country_flag(country_code)
19
39
  country_code&.upcase&.gsub(/[A-Z]/) { |c| (c.ord + 0x1F1A5).chr(Encoding::UTF_8) }
20
40
  end
@@ -0,0 +1,20 @@
1
+ module Upright::ArtifactsHelper
2
+ def trace?(artifact)
3
+ artifact.filename.to_s.end_with?(".zip")
4
+ end
5
+
6
+ def trace_viewer_url_for(artifact)
7
+ viewer = Upright.configuration.trace_viewer_url
8
+ return if viewer.blank?
9
+
10
+ URI(viewer).tap do |url|
11
+ query = Rack::Utils.parse_query(url.query).except("trace")
12
+ url.query = query.merge("trace" => trace_url_for(artifact)).to_query
13
+ end.to_s
14
+ end
15
+
16
+ private
17
+ def trace_url_for(artifact)
18
+ site_trace_url(signed_id: Upright::TracesController.signed_id_for(artifact), host: request.host)
19
+ end
20
+ end
@@ -1,15 +1,7 @@
1
1
  module Upright::ProbeResultsHelper
2
- PROBE_TYPE_ICONS = {
3
- http: "🌐",
4
- playwright: "🎭",
5
- ping: "📶",
6
- smtp: "✉️",
7
- traceroute: "🛤️"
8
- }
9
-
10
2
  def probe_type_icon(probe_type)
11
- icon = PROBE_TYPE_ICONS.fetch(probe_type.to_s.downcase.to_sym)
12
- content_tag(:span, icon, title: probe_type.titleize)
3
+ registered = Upright.probe_types.find(probe_type)
4
+ content_tag(:span, registered.icon, title: registered.name)
13
5
  end
14
6
 
15
7
  def type_filter_link(label, probe_type = nil)
@@ -27,6 +19,7 @@ module Upright::ProbeResultsHelper
27
19
  when /^request\.log$/ then "📤"
28
20
  when /^response\./ then "📥"
29
21
  when /^smtp\.log$/ then "📧"
22
+ when /\.zip$/ then "🎭"
30
23
  else "📎"
31
24
  end
32
25
  end
@@ -44,6 +37,7 @@ module Upright::ProbeResultsHelper
44
37
  parts << "for #{params[:probe_type].titleize} probes" if params[:probe_type].present?
45
38
  parts << "named #{params[:probe_name]}" if params[:probe_name].present?
46
39
  parts << "with status #{params[:status]}" if params[:status].present?
40
+ parts << "on #{Date.parse(params[:date]).to_fs(:long)}" if params[:date].present?
47
41
  parts.join(" ")
48
42
  end
49
43
  end
@@ -0,0 +1,70 @@
1
+ module Upright::Public::ServicesHelper
2
+ include LocalTimeHelper
3
+
4
+ OVERALL_STATUS_LABELS = {
5
+ operational: "All Systems Operational",
6
+ degraded: "Some Systems Degraded",
7
+ partial_outage: "Partial Outage",
8
+ major_outage: "Major Outage",
9
+ maintenance: "Service Under Maintenance"
10
+ }
11
+
12
+ def overall_status_label(status)
13
+ OVERALL_STATUS_LABELS.fetch(status)
14
+ end
15
+
16
+ def maintenance_window_description(maintenance)
17
+ start, finish = maintenance.starts_at, maintenance.ends_at
18
+ same_day = finish && start.to_date == finish.to_date
19
+
20
+ if same_day
21
+ "#{start.to_fs(:month_day_at)}–#{finish.to_fs(:clock_zone)}"
22
+ elsif finish
23
+ "#{start.to_fs(:month_day_at)} – #{finish.to_fs(:month_day_at_zone)}"
24
+ else
25
+ start.to_fs(:month_day_at_zone)
26
+ end
27
+ end
28
+
29
+ def local_maintenance_window(event)
30
+ if event.ends_at
31
+ safe_join([ local_time(event.starts_at, format: :month_day_at),
32
+ local_time(event.ends_at, format: :month_day_at_zone) ], " – ")
33
+ else
34
+ local_time(event.starts_at, format: :month_day_at_zone)
35
+ end
36
+ end
37
+
38
+ def status_label(status)
39
+ status.to_s.humanize
40
+ end
41
+
42
+ def outage_duration_description(started_at:)
43
+ if started_at
44
+ "for #{distance_of_time_in_words(started_at, Time.current)}"
45
+ else
46
+ "for 24 hours+"
47
+ end
48
+ end
49
+
50
+ # Stable per-outage id so feed readers treat one ongoing outage as a single
51
+ # item. Falls back to the service code alone when the outage predates the live
52
+ # lookback window and has no known start time.
53
+ def feed_item_guid(outage)
54
+ [ outage.service.code, outage.started_at&.to_i ].compact.join("-")
55
+ end
56
+
57
+ def uptime_percentage_label(fractions)
58
+ if fractions.present?
59
+ percentage = fractions.sum.fdiv(fractions.size) * 100
60
+ # A flawless window is a bare "100%". Otherwise round down (so a real
61
+ # outage never reads as 100%) and always show three decimals for a
62
+ # consistent, precise read: "99.990%", "99.800%".
63
+ if percentage >= 100
64
+ number_to_percentage(100, precision: 0)
65
+ else
66
+ number_to_percentage(percentage, precision: 3, round_mode: :down)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,2 +1,5 @@
1
1
  import "@hotwired/turbo-rails"
2
2
  import "upright/controllers"
3
+ import LocalTime from "local-time"
4
+
5
+ LocalTime.start()
@@ -14,20 +14,28 @@ export default class extends Controller {
14
14
 
15
15
  L.marker([site.lat, site.lon])
16
16
  .addTo(map)
17
- .bindPopup(`<strong>${site.hostname}</strong><br>${site.city}`)
17
+ .bindPopup(this.popupContent(site))
18
18
  .on("mouseover", function() { this.openPopup() })
19
19
  .on("mouseout", function() { this.closePopup() })
20
20
  .on("click", () => window.location.href = site.url)
21
21
  }
22
22
  }
23
23
 
24
+ // Build the popup as DOM nodes so hostname and city render as text,
25
+ // never as HTML.
26
+ popupContent(site) {
27
+ const content = document.createElement("div")
28
+ const hostname = document.createElement("strong")
29
+ hostname.textContent = site.hostname
30
+ content.append(hostname, document.createElement("br"), site.city ?? "")
31
+ return content
32
+ }
33
+
24
34
  get tileUrl() {
25
- return window.matchMedia("(prefers-color-scheme: dark)").matches
26
- ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
27
- : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
35
+ return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
28
36
  }
29
37
 
30
38
  get attribution() {
31
- return '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
39
+ return '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
32
40
  }
33
41
  }
@@ -0,0 +1,11 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [ "body" ]
5
+
6
+ fill({ params: { body } }) {
7
+ this.bodyTarget.value = body
8
+ this.bodyTarget.dispatchEvent(new Event("input", { bubbles: true }))
9
+ this.bodyTarget.focus()
10
+ }
11
+ }
@@ -0,0 +1,5 @@
1
+ // Entry point for the public status page. It loads only what that page uses:
2
+ // local-time converts the server-rendered UTC timestamps to the visitor's zone.
3
+ import LocalTime from "local-time"
4
+
5
+ LocalTime.start()
@@ -0,0 +1,27 @@
1
+ class Upright::HealthMetricsJob < Upright::ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform
5
+ Yabeda.upright_primary_site.set({}, primary? ? 1 : 0)
6
+
7
+ # Probe-only sites are firewalled off the persistent database, where a
8
+ # connection attempt hangs rather than being refused and ties up this worker.
9
+ if stores_metrics?
10
+ if Upright::PersistentRecord.up?
11
+ Yabeda.upright_persistent_db_up.set({}, 1)
12
+ Upright::Rollups::ProbeRollup.export_metrics
13
+ else
14
+ Yabeda.upright_persistent_db_up.set({}, 0)
15
+ end
16
+ end
17
+ end
18
+
19
+ private
20
+ def primary?
21
+ Upright.current_site&.primary?
22
+ end
23
+
24
+ def stores_metrics?
25
+ Upright.current_site&.stores_metrics?
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ class Upright::IncidentReporterJob < Upright::ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform
5
+ Upright::Incident.report_downtime
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ class Upright::MaintenanceAdvanceJob < Upright::ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform
5
+ Upright::Maintenance.unresolved.find_each(&:auto_advance_status)
6
+ Upright::Maintenance.export_service_metrics
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ # Playwright source recordings are deleted right after they're saved as
2
+ # artifacts, but a crashed run can still strand them. Sweep any video files
3
+ # old enough that no probe run could still be using them.
4
+ class Upright::PlaywrightVideoSweepJob < Upright::ApplicationJob
5
+ queue_as :default
6
+
7
+ MAX_AGE = 1.hour
8
+
9
+ def perform
10
+ stray_videos.each do |path|
11
+ File.delete(path) if File.mtime(path) < MAX_AGE.ago
12
+ rescue Errno::ENOENT
13
+ # Already gone
14
+ end
15
+ end
16
+
17
+ private
18
+ def stray_videos
19
+ Dir.glob(Upright.configuration.video_storage_dir.join("*.webm").to_s)
20
+ end
21
+ end