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.
- checksums.yaml +4 -4
- data/README.md +208 -56
- data/Rakefile +22 -0
- data/app/assets/images/upright/.keep +0 -0
- data/app/assets/stylesheets/upright/_global.css +2 -0
- data/app/assets/stylesheets/upright/artifact.css +7 -25
- data/app/assets/stylesheets/upright/flash.css +19 -0
- data/app/assets/stylesheets/upright/header.css +14 -0
- data/app/assets/stylesheets/upright/incidents.css +610 -0
- data/app/assets/stylesheets/upright/map.css +6 -0
- data/app/assets/stylesheets/upright/public_status.css +534 -0
- data/app/assets/stylesheets/upright/uptime-bars.css +10 -1
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/concerns/upright/authentication.rb +1 -0
- data/app/controllers/concerns/upright/proxy_authentication.rb +134 -0
- data/app/controllers/upright/alertmanager_proxy_controller.rb +12 -4
- data/app/controllers/upright/artifacts_controller.rb +1 -1
- data/app/controllers/upright/incidents/updates_controller.rb +13 -0
- data/app/controllers/upright/incidents_controller.rb +53 -0
- data/app/controllers/upright/probe_results_controller.rb +1 -0
- data/app/controllers/upright/prometheus_proxy_controller.rb +13 -18
- data/app/controllers/upright/public/base_controller.rb +16 -0
- data/app/controllers/upright/public/incidents_controller.rb +6 -0
- data/app/controllers/upright/public/service_incidents_controller.rb +7 -0
- data/app/controllers/upright/public/services_controller.rb +6 -0
- data/app/controllers/upright/sessions_controller.rb +37 -1
- data/app/controllers/upright/traces_controller.rb +88 -0
- data/app/helpers/upright/application_helper.rb +22 -2
- data/app/helpers/upright/artifacts_helper.rb +20 -0
- data/app/helpers/upright/probe_results_helper.rb +4 -10
- data/app/helpers/upright/public/services_helper.rb +70 -0
- data/app/javascript/upright/application.js +3 -0
- data/app/javascript/upright/controllers/sites_map_controller.js +13 -5
- data/app/javascript/upright/controllers/template_controller.js +11 -0
- data/app/javascript/upright/public.js +5 -0
- data/app/jobs/upright/health_metrics_job.rb +27 -0
- data/app/jobs/upright/incident_reporter_job.rb +7 -0
- data/app/jobs/upright/maintenance_advance_job.rb +8 -0
- data/app/jobs/upright/playwright_video_sweep_job.rb +21 -0
- data/app/jobs/upright/rollups/daily_aggregation_job.rb +14 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/concerns/upright/incidents/auto_reporting.rb +93 -0
- data/app/models/concerns/upright/incidents/lifecycle.rb +47 -0
- data/app/models/concerns/upright/playwright/form_authentication.rb +3 -5
- data/app/models/concerns/upright/playwright/lifecycle.rb +8 -13
- data/app/models/concerns/upright/playwright/trace_recording.rb +53 -0
- data/app/models/concerns/upright/playwright/video_recording.rb +55 -14
- data/app/models/concerns/upright/probe_result/stale_cleanup.rb +47 -0
- data/app/models/concerns/upright/probeable.rb +16 -2
- data/app/models/concerns/upright/services/live_status.rb +69 -0
- data/app/models/concerns/upright/services/maintenance_status.rb +19 -0
- data/app/models/upright/artifact.rb +2 -1
- data/app/models/upright/http/request.rb +13 -4
- data/app/models/upright/incident/automatic_report.rb +3 -0
- data/app/models/upright/incident.rb +87 -0
- data/app/models/upright/incident_affected_service.rb +9 -0
- data/app/models/upright/incident_update.rb +7 -0
- data/app/models/upright/maintenance.rb +36 -0
- data/app/models/upright/persistent_record.rb +12 -0
- data/app/models/upright/playwright/authenticator/base.rb +50 -70
- data/app/models/upright/probe_result.rb +6 -1
- data/app/models/upright/probes/http_probe.rb +30 -1
- data/app/models/upright/probes/playwright/base.rb +2 -0
- data/app/models/upright/probes/status/probe.rb +8 -0
- data/app/models/upright/probes/status.rb +5 -13
- data/app/models/upright/probes/uptime.rb +5 -12
- data/app/models/upright/rollups/daily_uptime.rb +28 -0
- data/app/models/upright/rollups/probe_rollup.rb +64 -0
- data/app/models/upright/rollups/probe_uptime.rb +9 -0
- data/app/models/upright/rollups/site_uptime.rb +75 -0
- data/app/models/upright/service/daily_status.rb +39 -0
- data/app/models/upright/service/incident_history.rb +24 -0
- data/app/models/upright/service/outage.rb +15 -0
- data/app/models/upright/service/status_page.rb +35 -0
- data/app/models/upright/service.rb +101 -0
- data/app/models/upright/status.rb +21 -0
- data/app/models/upright/traceroute/ip_metadata_lookup.rb +21 -2
- data/app/models/upright/traceroute/result.rb +8 -1
- data/app/views/layouts/upright/_header.html.erb +12 -2
- data/app/views/layouts/upright/application.html.erb +4 -1
- data/app/views/layouts/upright/public.html.erb +18 -0
- data/app/views/layouts/upright/public.rss.builder +1 -0
- data/app/views/upright/active_storage/attachments/_attachment.html.erb +1 -1
- data/app/views/upright/artifacts/show.html.erb +8 -0
- data/app/views/upright/dashboards/_uptime_probe_row.html.erb +5 -3
- data/app/views/upright/dashboards/probe_statuses/show.html.erb +1 -1
- data/app/views/upright/dashboards/uptimes/show.html.erb +1 -1
- data/app/views/upright/incidents/_form.html.erb +93 -0
- data/app/views/upright/incidents/_row.html.erb +15 -0
- data/app/views/upright/incidents/edit.html.erb +64 -0
- data/app/views/upright/incidents/index.html.erb +53 -0
- data/app/views/upright/incidents/new.html.erb +6 -0
- data/app/views/upright/probe_results/index.html.erb +7 -4
- data/app/views/upright/public/_branding.html.erb +3 -0
- data/app/views/upright/public/_footer.html.erb +3 -0
- data/app/views/upright/public/incidents/show.html.erb +29 -0
- data/app/views/upright/public/service_incidents/_incident.html.erb +14 -0
- data/app/views/upright/public/service_incidents/index.html.erb +50 -0
- data/app/views/upright/public/services/_active_events.html.erb +28 -0
- data/app/views/upright/public/services/_degraded_list.html.erb +14 -0
- data/app/views/upright/public/services/_overall_banner.html.erb +4 -0
- data/app/views/upright/public/services/_service.html.erb +34 -0
- data/app/views/upright/public/services/_upcoming_maintenance.html.erb +21 -0
- data/app/views/upright/public/services/_uptime_bar.html.erb +9 -0
- data/app/views/upright/public/services/index.html.erb +13 -0
- data/app/views/upright/public/services/index.rss.builder +29 -0
- data/app/views/upright/sessions/new.html.erb +14 -1
- data/config/ci.rb +2 -0
- data/config/importmap.rb +25 -14
- data/config/initializers/date_formats.rb +5 -0
- data/config/initializers/mime_types.rb +1 -0
- data/config/routes.rb +26 -2
- data/db/migrate/20260629000001_fix_probe_result_duration_precision.rb +14 -0
- data/db/persistent_migrate/20260512000001_create_upright_rollups.rb +16 -0
- data/db/persistent_migrate/20260702000001_create_upright_incidents.rb +34 -0
- data/db/persistent_migrate/20260707000001_add_probe_identity_to_upright_rollups.rb +11 -0
- data/db/persistent_migrate/20260709000001_add_created_by_to_incidents.rb +7 -0
- data/db/persistent_migrate/20260901000001_add_auto_reporting_to_upright_incidents.rb +22 -0
- data/lib/generators/upright/install/database_config.rb +46 -0
- data/lib/generators/upright/install/install_generator.rb +52 -3
- data/lib/generators/upright/install/templates/Dockerfile +80 -0
- data/lib/generators/upright/install/templates/content_security_policy.rb +42 -0
- data/lib/generators/upright/install/templates/deploy.yml +13 -13
- data/lib/generators/upright/install/templates/development_prometheus.yml +1 -1
- data/lib/generators/upright/install/templates/docker-compose.yml +12 -11
- data/lib/generators/upright/install/templates/http_probes.yml +1 -0
- data/lib/generators/upright/install/templates/omniauth.rb +7 -3
- data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
- data/lib/generators/upright/install/templates/recurring.yml +45 -0
- data/lib/generators/upright/install/templates/sites.yml +4 -0
- data/lib/generators/upright/install/templates/smtp_probes.yml +3 -0
- data/lib/generators/upright/install/templates/traceroute_probes.yml +3 -0
- data/lib/generators/upright/install/templates/upright.rb +24 -2
- data/lib/generators/upright/install/templates/upright.rules.yml +14 -7
- data/lib/upright/configuration.rb +127 -6
- data/lib/upright/engine.rb +32 -2
- data/lib/upright/metrics.rb +27 -2
- data/lib/upright/probe_type_registry.rb +33 -0
- data/lib/upright/site.rb +31 -1
- data/lib/upright/version.rb +3 -1
- data/lib/upright.rb +34 -2
- metadata +110 -7
- data/config/credentials/development.key +0 -1
- data/config/credentials/test.key +0 -1
- data/lib/tasks/upright_tasks.rake +0 -4
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
class Upright::AlertmanagerProxyController < Upright::ApplicationController
|
|
2
|
-
|
|
2
|
+
include Upright::ProxyAuthentication
|
|
3
3
|
|
|
4
4
|
def show
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
def proxy
|
|
8
|
-
|
|
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(
|
|
13
|
-
response = Faraday.new(url: alertmanager_url).run_request(method.downcase.to_sym,
|
|
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
|
|
@@ -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
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
class Upright::PrometheusProxyController < Upright::ApplicationController
|
|
2
|
-
|
|
2
|
+
include Upright::ProxyAuthentication
|
|
3
3
|
|
|
4
4
|
skip_before_action :authenticate_user, only: :otlp
|
|
5
|
-
|
|
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
|
-
|
|
14
|
+
url = upstream_url(Upright.configuration.prometheus_url, request.fullpath.delete_prefix("/prometheus"))
|
|
14
15
|
|
|
15
|
-
if
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
12
|
-
content_tag(:span, icon, title:
|
|
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
|
|
@@ -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(
|
|
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
|
|
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 '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>
|
|
39
|
+
return '© <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,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,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
|