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
@@ -0,0 +1,39 @@
1
+ class Upright::Service::DailyStatus
2
+ attr_reader :date, :status, :uptime_fraction
3
+
4
+ def initialize(date:, status: nil, uptime_fraction: nil)
5
+ @date = date
6
+ @status = status
7
+ @uptime_fraction = uptime_fraction
8
+ end
9
+
10
+ def operational?
11
+ status == :operational
12
+ end
13
+
14
+ def date_label
15
+ date == Date.current ? "Today" : date.to_fs(:month_day)
16
+ end
17
+
18
+ def detail
19
+ if uptime_fraction
20
+ [ "%.2f%% uptime" % (uptime_fraction * 100), downtime ].compact.join(" · ")
21
+ elsif status
22
+ status.to_s.humanize.downcase
23
+ else
24
+ "no data"
25
+ end
26
+ end
27
+
28
+ def aria_label
29
+ "#{date_label}: #{detail}"
30
+ end
31
+
32
+ private
33
+ def downtime
34
+ if uptime_fraction && uptime_fraction < 1
35
+ minutes = ((1 - uptime_fraction) * 24 * 60).round
36
+ minutes.zero? ? "<1 min down" : "#{minutes} #{'min'.pluralize(minutes)} down"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,24 @@
1
+ class Upright::Service::IncidentHistory
2
+ attr_reader :service, :page
3
+
4
+ delegate :daily_status_history, :maintenance_active?, :name, to: :service
5
+
6
+ def self.for(service, page: nil)
7
+ new service, page: GearedPagination::Recordset.new(
8
+ service.incidents.past.reorder(nil).preload(:updates), ordered_by: { starts_at: :desc }
9
+ ).page(page)
10
+ end
11
+
12
+ def initialize(service, page:)
13
+ @service = service
14
+ @page = page
15
+ end
16
+
17
+ def active_incidents
18
+ @active_incidents ||= service.incidents.reactive.active.preload(:updates)
19
+ end
20
+
21
+ def past_incidents
22
+ page.records
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ class Upright::Service::Outage < Data.define(:service, :status, :started_at)
2
+ IMPACT_BY_STATUS = {
3
+ degraded: "minor",
4
+ partial_outage: "major",
5
+ major_outage: "critical"
6
+ }
7
+
8
+ def impact
9
+ IMPACT_BY_STATUS.fetch(status)
10
+ end
11
+
12
+ def started_at_or_lookback(observed_at:)
13
+ started_at || observed_at - Upright::Services::LiveStatus::OUTAGE_LOOKBACK
14
+ end
15
+ end
@@ -0,0 +1,35 @@
1
+ class Upright::Service::StatusPage
2
+ attr_reader :services, :active_incidents, :active_maintenances, :upcoming_maintenances
3
+
4
+ def self.current
5
+ services = Upright::Service.public_facing
6
+
7
+ new services: services,
8
+ active_incidents: Upright::Incident.public_facing.reactive.active.order(starts_at: :desc),
9
+ active_maintenances: Upright::Maintenance.public_facing.active.order(:starts_at),
10
+ upcoming_maintenances: Upright::Maintenance.public_facing.upcoming.order(:starts_at)
11
+ end
12
+
13
+ def initialize(services:, active_incidents:, active_maintenances:, upcoming_maintenances:)
14
+ @services = services
15
+ @active_incidents = active_incidents
16
+ @active_maintenances = active_maintenances
17
+ @upcoming_maintenances = upcoming_maintenances
18
+ end
19
+
20
+ def overall_status
21
+ services.overall_status(incidents: Upright::Incident.public_facing)
22
+ end
23
+
24
+ def outages
25
+ services.degraded
26
+ end
27
+
28
+ def active_events
29
+ active_incidents.to_a + active_maintenances.to_a
30
+ end
31
+
32
+ def service_histories
33
+ services.by_history
34
+ end
35
+ end
@@ -0,0 +1,101 @@
1
+ class Upright::Service < FrozenRecord::Base
2
+ include Upright::Services::LiveStatus
3
+ include Upright::Services::MaintenanceStatus
4
+
5
+ def self.file_path
6
+ Upright.configuration.services_path.to_s
7
+ end
8
+
9
+ scope :public_facing, -> { where(public: true) }
10
+
11
+ # The public status page passes `incidents: Upright::Incident.public_facing`
12
+ # so internal-only incidents and maintenances never color the public banner.
13
+ def self.overall_status(incidents: Upright::Incident.all)
14
+ probe_statuses = all.reject(&:maintenance_active?).map(&:live_status)
15
+ worst = Upright::Status.worst(probe_statuses + incidents.active_statuses)
16
+ worst == :operational && incidents.planned.active.any? ? :maintenance : worst
17
+ end
18
+
19
+ def self.by_history(past: 90.days)
20
+ all.to_h { |service| [ service, service.daily_status_history(past: past) ] }
21
+ end
22
+
23
+ def self.degraded
24
+ all.filter_map(&:current_outage)
25
+ end
26
+
27
+ def current_outage
28
+ return if maintenance_active?
29
+
30
+ live_status.then do |status|
31
+ Upright::Service::Outage.new(service: self, status: status, started_at: current_outage_started_at) unless status == :operational
32
+ end
33
+ end
34
+
35
+ def incidents
36
+ Upright::Incident.public_facing.for_service(code)
37
+ end
38
+
39
+ def incident_history(page: nil)
40
+ Upright::Service::IncidentHistory.for(self, page: page)
41
+ end
42
+
43
+ def incident_update_template(key)
44
+ self[:incident_updates]&.fetch(key.to_s, nil)
45
+ end
46
+
47
+ DEFAULT_UPTIME_PROBE_TYPES = %w[ http ]
48
+
49
+ # Probe types whose results decide this service's live status and daily
50
+ # uptime: HTTP unless `uptime_probe_types` in services.yml says otherwise.
51
+ # Other types bound to the service are still probed, rolled up and alerted on.
52
+ # A type that isn't registered raises rather than matching nothing, since an
53
+ # empty match would report the service operational.
54
+ def uptime_probe_types
55
+ types = Array(self[:uptime_probe_types]).map(&:to_s).presence || DEFAULT_UPTIME_PROBE_TYPES
56
+ unknown = types - Upright.configuration.probe_types.types
57
+
58
+ if unknown.any?
59
+ raise Upright::ConfigurationError, "#{code}: uptime_probe_types #{unknown.join(", ")} not registered with config.probe_types"
60
+ end
61
+
62
+ types
63
+ end
64
+
65
+ # Rollups written before probe_type was recorded (July 2026) have none and
66
+ # keep counting, so an upgrade doesn't drop history.
67
+ def probe_rollups
68
+ Upright::Rollups::ProbeRollup.where(probe_service: code, probe_type: [ *uptime_probe_types, nil ])
69
+ end
70
+
71
+ def uptime_for(day)
72
+ probe_rollups.where(period_start: day.beginning_of_day).minimum(:uptime_fraction)
73
+ end
74
+
75
+ def daily_uptime(past: 90.days)
76
+ probe_rollups
77
+ .where(period_start: past.ago.beginning_of_day..)
78
+ .group(:period_start)
79
+ .minimum(:uptime_fraction)
80
+ end
81
+
82
+ # Unified day-by-day view: past days from ProbeRollup, today from live
83
+ # Prometheus state, missing days as no-data. Callers iterate without caring
84
+ # which source backs each entry.
85
+ def daily_status_history(past: 90.days)
86
+ rollup_by_day = daily_uptime(past: past)
87
+
88
+ (past.ago.to_date.next_day..Date.current).map do |date|
89
+ if date == Date.current
90
+ DailyStatus.new(date: date, status: live_status)
91
+ else
92
+ fraction = rollup_by_day[date.beginning_of_day]
93
+ DailyStatus.new(
94
+ date: date,
95
+ status: Upright::Status.for(fraction),
96
+ uptime_fraction: fraction
97
+ )
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,21 @@
1
+ module Upright::Status
2
+ VALUES = %i[ operational degraded partial_outage major_outage ]
3
+ PRIORITY = VALUES.reverse # worst first — for picking overall_status across services
4
+
5
+ # nil fraction means we have no measurement for the period — distinct from
6
+ # :operational, so we return nil rather than guessing.
7
+ def self.worst(statuses)
8
+ PRIORITY.find { |status| statuses.include?(status) } || :operational
9
+ end
10
+
11
+ def self.for(uptime_fraction)
12
+ if uptime_fraction
13
+ case uptime_fraction
14
+ when 1.0.. then :operational
15
+ when ...0.5 then :major_outage
16
+ when ...0.9 then :partial_outage
17
+ else :degraded
18
+ end
19
+ end
20
+ end
21
+ end
@@ -3,7 +3,14 @@ require "json"
3
3
  require "resolv"
4
4
 
5
5
  class Upright::Traceroute::IpMetadataLookup
6
+ # ip-api.com only offers TLS with a paid key (the free /batch endpoint
7
+ # rejects HTTPS outright). With IP_API_KEY set, lookups use the TLS pro
8
+ # endpoint. Without a key they fall back to plain HTTP, which exposes the
9
+ # looked-up hop IPs to on-path observers — the response is public geo
10
+ # metadata, but consider a paid key or a local GeoIP database (e.g. maxmind's
11
+ # GeoLite2 via the maxmind-geoip2 gem) if that leak matters to you.
6
12
  API_URL = "http://ip-api.com/batch"
13
+ TLS_API_URL = "https://pro.ip-api.com/batch"
7
14
  TIMEOUT = 5.seconds
8
15
  GEOHASH_PRECISION = 6
9
16
  CACHE_TTL = 24.hours
@@ -48,12 +55,12 @@ class Upright::Traceroute::IpMetadataLookup
48
55
  end
49
56
 
50
57
  def fetch_batch(ips)
51
- uri = URI(API_URL)
58
+ uri = api_uri
52
59
  request = Net::HTTP::Post.new(uri)
53
60
  request.content_type = "application/json"
54
61
  request.body = ips.to_json
55
62
 
56
- response = Net::HTTP.start(uri.hostname, uri.port, read_timeout: TIMEOUT, open_timeout: TIMEOUT) do |http|
63
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: TIMEOUT, open_timeout: TIMEOUT) do |http|
57
64
  http.request(request)
58
65
  end
59
66
 
@@ -64,6 +71,18 @@ class Upright::Traceroute::IpMetadataLookup
64
71
  end
65
72
  end
66
73
 
74
+ def api_uri
75
+ if api_key.present?
76
+ URI("#{TLS_API_URL}?key=#{api_key}")
77
+ else
78
+ URI(API_URL)
79
+ end
80
+ end
81
+
82
+ def api_key
83
+ ENV["IP_API_KEY"]
84
+ end
85
+
67
86
  def parse_response(results)
68
87
  results.select { |result| result["status"] == "success" }.to_h do |result|
69
88
  [ result["query"], build_metadata(result) ]
@@ -4,6 +4,11 @@ class Upright::Traceroute::Result
4
4
  PROBE_COUNT = 10
5
5
  MAX_HOPS = 30
6
6
 
7
+ # Hostnames, IPv4, and IPv6 addresses (including a link-local zone such as
8
+ # fe80::1%eth0) only. The first character may not be a dash, so a configured
9
+ # host can never be mistaken for an mtr flag.
10
+ HOST_PATTERN = /\A[a-z0-9:][a-z0-9:._-]*(?:%[a-z0-9._-]+)?\z/i
11
+
7
12
  attr_reader :host, :hops, :raw_json
8
13
 
9
14
  def self.for(host)
@@ -11,6 +16,8 @@ class Upright::Traceroute::Result
11
16
  end
12
17
 
13
18
  def initialize(host)
19
+ raise ArgumentError, "invalid traceroute host: #{host.inspect}" unless HOST_PATTERN.match?(host.to_s)
20
+
14
21
  @host = host
15
22
  @hops = []
16
23
  end
@@ -32,7 +39,7 @@ class Upright::Traceroute::Result
32
39
  "--aslookup",
33
40
  "--report-cycles", PROBE_COUNT.to_s,
34
41
  "--max-ttl", MAX_HOPS.to_s,
35
- host.to_s
42
+ "--", host.to_s
36
43
  )
37
44
 
38
45
  Rails.logger.info stdout
@@ -4,17 +4,27 @@
4
4
  <% if Upright::Current.site.present? %>
5
5
  <span class="header__site"><%= site_name(Upright::Current.site) %></span>
6
6
  <% end %>
7
+ <% unless Rails.env.production? %>
8
+ <span class="header__env"><%= Rails.env %></span>
9
+ <% end %>
7
10
  </div>
8
11
 
9
12
  <nav class="header__nav">
10
13
  <%= link_to "Probes", upright.site_root_url(subdomain: current_or_default_site.code), class: "header__link" %>
11
14
  <%= link_to "Uptime", upright.dashboards_uptime_url(subdomain: Upright.configuration.global_subdomain), class: "header__link" %>
12
15
  <%= link_to "Status", upright.dashboards_probe_status_url(subdomain: Upright.configuration.global_subdomain), class: "header__link" %>
16
+ <%= link_to "Incidents", upright.incidents_url(subdomain: Upright.configuration.global_subdomain), class: "header__link" %>
13
17
  <span class="header__divider"></span>
14
18
  <%= link_to "Jobs", upright.jobs_url(subdomain: current_or_default_site.code), class: "header__link" %>
15
19
  <span class="header__divider"></span>
16
- <%= link_to "Prometheus", upright.prometheus_url(subdomain: Upright.configuration.global_subdomain), class: "header__link" %>
17
- <%= link_to "Alertmanager", upright.alertmanager_url(subdomain: Upright.configuration.global_subdomain), class: "header__link" %>
20
+ <% if metrics_subdomain %>
21
+ <%= link_to "Prometheus", upright.prometheus_url(subdomain: metrics_subdomain), class: "header__link" %>
22
+ <%= link_to "Alertmanager", upright.alertmanager_url(subdomain: metrics_subdomain), class: "header__link" %>
23
+ <% else %>
24
+ <% available_on = "Available on #{metrics_sites_sentence}" %>
25
+ <%= tag.span "Prometheus", class: "header__link unavailable", title: available_on %>
26
+ <%= tag.span "Alertmanager", class: "header__link unavailable", title: available_on %>
27
+ <% end %>
18
28
  </nav>
19
29
 
20
30
  <div class="header__user">
@@ -1,7 +1,7 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
- <title>Upright</title>
4
+ <%= page_title_tag %>
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
7
7
  <meta name="theme-color" content="#1f2937" media="(prefers-color-scheme: dark)">
@@ -16,6 +16,9 @@
16
16
  <% if signed_in? %>
17
17
  <%= render "layouts/upright/header" %>
18
18
  <main class="main">
19
+ <% flash.each do |type, message| %>
20
+ <p class="flash flash--<%= type %>"><%= message %></p>
21
+ <% end %>
19
22
  <%= yield %>
20
23
  </main>
21
24
  <% else %>
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <%= page_title_tag "Status" %>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= upright_stylesheet_link_tag "data-turbo-track": "reload" %>
9
+ <%= javascript_importmap_tags "upright/public" %>
10
+ </head>
11
+ <body class="public">
12
+ <%= render "upright/public/branding" %>
13
+ <main class="main">
14
+ <%= yield %>
15
+ </main>
16
+ <%= render "upright/public/footer" %>
17
+ </body>
18
+ </html>
@@ -0,0 +1 @@
1
+ xml << yield
@@ -10,7 +10,7 @@
10
10
  <% end %>
11
11
  <div class="artifact__probe-time" title="<%= probe_result.created_at.to_fs(:db) %>"><%= time_ago_in_words(probe_result.created_at) %> ago</div>
12
12
  <% end %>
13
- <h3 class="artifact__title"><%= artifact_icon(attachment) %> <%= attachment.filename %></h3>
13
+ <h3 class="artifact__title"><%= link_to "#{artifact_icon(attachment)} #{attachment.filename} ↓", main_app.rails_blob_path(attachment, disposition: :attachment), download: attachment.filename %></h3>
14
14
  </div>
15
15
  <button type="button" class="artifact__close" data-action="popover#close" aria-label="Close">×</button>
16
16
  </header>
@@ -3,6 +3,14 @@
3
3
  <video controls width="960" preload="none" autoplay muted>
4
4
  <source src="<%= main_app.rails_blob_path(@artifact, disposition: :inline) %>" type="video/webm">
5
5
  </video>
6
+ <% elsif trace?(@artifact) %>
7
+ <div class="artifact__trace">
8
+ <% if (viewer = trace_viewer_url_for(@artifact)) %>
9
+ <p><%= link_to "Open in the Playwright Trace Viewer ↗", viewer, target: "_blank", rel: "noopener noreferrer" %></p>
10
+ <% else %>
11
+ <p>Download the trace above, then open it with <code>npx playwright show-trace <%= @artifact.filename %></code>.</p>
12
+ <% end %>
13
+ </div>
6
14
  <% else %>
7
15
  <pre><%= @artifact.download %></pre>
8
16
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <div class="data-table__row uptime-bars__row">
2
- <%= link_to upright.site_root_url(subdomain: Upright.sites.first.code, probe_type: probe.type, probe_name: probe.name), class: "data-table__probe" do %>
2
+ <%= link_to upright.site_root_url(subdomain: current_or_default_site.code, probe_type: probe.type, probe_name: probe.name), class: "data-table__probe" do %>
3
3
  <%= probe_type_icon(probe.type) %>
4
4
  <span class="data-table__probe-name"><%= probe.name %></span>
5
5
  <% end %>
@@ -11,8 +11,10 @@
11
11
  <% else %>
12
12
  <% uptime_percent = uptime * 100 %>
13
13
  <% downtime_minutes = ((1 - uptime) * 24 * 60).round %>
14
- <div class="uptime-bar uptime-bar--<%= uptime_label(uptime_percent) %>"
15
- title="<%= uptime_bar_tooltip(date, uptime_percent, downtime_minutes) %>"></div>
14
+ <%= link_to "", upright.site_root_url(subdomain: current_or_default_site.code, probe_type: probe.type, probe_name: probe.name, date: date.iso8601),
15
+ class: "uptime-bar uptime-bar--#{uptime_label(uptime_percent)}",
16
+ title: uptime_bar_tooltip(date, uptime_percent, downtime_minutes),
17
+ aria: { label: uptime_bar_tooltip(date, uptime_percent, downtime_minutes) } %>
16
18
  <% end %>
17
19
  <% end %>
18
20
  </div>
@@ -4,7 +4,7 @@
4
4
 
5
5
  <div class="dashboard-filters">
6
6
  <%= form_with url: dashboards_probe_status_path, method: :get, data: { controller: "form", turbo_frame: :probe_status_matrix } do %>
7
- <%= collection_select nil, :probe_type, Upright::Probeable::TYPES, :itself, :titleize,
7
+ <%= collection_select nil, :probe_type, Upright.probe_types, :type, :name,
8
8
  { selected: @probe_type },
9
9
  { class: "input--select", data: { action: "change->form#submit" } } %>
10
10
  <% end %>
@@ -4,7 +4,7 @@
4
4
 
5
5
  <div class="dashboard-filters">
6
6
  <%= form_with url: dashboards_uptime_path, method: :get, data: { controller: "form", turbo_frame: :uptime_bars } do %>
7
- <%= collection_select nil, :probe_type, Upright::Probeable::TYPES, :itself, :titleize,
7
+ <%= collection_select nil, :probe_type, Upright.probe_types, :type, :name,
8
8
  { selected: @probe_type },
9
9
  { class: "input--select", data: { action: "change->form#submit" } } %>
10
10
  <% end %>
@@ -0,0 +1,93 @@
1
+ <% url = @incident.new_record? ? incidents_path : incident_path(@incident) %>
2
+ <%= form_with model: @incident, scope: :incident, url: url,
3
+ class: class_names("incident-form",
4
+ "incident-form--maintenance" => @incident.persisted? && @incident.maintenance?,
5
+ "incident-form--incident" => @incident.persisted? && !@incident.maintenance?) do |f| %>
6
+ <span class="incident-form__spine" aria-hidden="true"></span>
7
+
8
+ <div class="incident-form__body">
9
+ <header class="incident-form__head">
10
+ <p class="incident-form__eyebrow"><%= @incident.new_record? ? "New event" : (@incident.maintenance? ? "Maintenance" : "Incident") %></p>
11
+ <% unless @incident.new_record? %>
12
+ <h1 class="incident-form__title"><%= @incident.title %></h1>
13
+ <% end %>
14
+ </header>
15
+
16
+ <% if @incident.errors.any? %>
17
+ <ul class="incident-errors">
18
+ <% @incident.errors.full_messages.each do |message| %>
19
+ <li><%= message %></li>
20
+ <% end %>
21
+ </ul>
22
+ <% end %>
23
+
24
+ <% if @incident.new_record? %>
25
+ <div class="field">
26
+ <span class="field__label">Type</span>
27
+ <div class="segmented" role="radiogroup" aria-label="Type">
28
+ <input type="radio" name="maintenance" id="kind_incident" value="false" <%= "checked" unless @incident.maintenance? %>>
29
+ <label for="kind_incident">Incident</label>
30
+ <input type="radio" name="maintenance" id="kind_maintenance" value="true" <%= "checked" if @incident.maintenance? %>>
31
+ <label for="kind_maintenance">Maintenance</label>
32
+ </div>
33
+ </div>
34
+ <% end %>
35
+
36
+ <div class="field">
37
+ <%= f.label :title, "Title", class: "field__label" %>
38
+ <%= f.text_field :title, class: "field__input",
39
+ placeholder: "Short summary customers will see", autofocus: @incident.new_record? %>
40
+ </div>
41
+
42
+ <div class="field field--incident">
43
+ <span class="field__label">Impact</span>
44
+ <div class="impact-picker" role="radiogroup" aria-label="Impact">
45
+ <% Upright::Incident::IMPACTS.each do |level| %>
46
+ <label class="impact impact--<%= level %>">
47
+ <%= f.radio_button :impact, level, id: "impact_#{level}" %>
48
+ <span class="impact__dot" aria-hidden="true"></span>
49
+ <span class="impact__name"><%= level.humanize %></span>
50
+ </label>
51
+ <% end %>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="field">
56
+ <%= f.label :starts_at, "Starts", class: "field__label" %>
57
+ <%= f.datetime_field :starts_at, class: "field__input" %>
58
+ </div>
59
+
60
+ <div class="field field--maintenance">
61
+ <%= f.label :ends_at, "Ends", class: "field__label" %>
62
+ <%= f.datetime_field :ends_at, class: "field__input" %>
63
+ <span class="field__hint">Affected services read as “Under maintenance” for this window and stay out of the outage alarm.</span>
64
+ </div>
65
+
66
+ <div class="field">
67
+ <span class="field__label">Affected services</span>
68
+ <div class="chips">
69
+ <input type="hidden" name="incident[service_codes][]" value="">
70
+ <% Upright::Service.all.each do |service| %>
71
+ <label class="chip">
72
+ <%= check_box_tag "incident[service_codes][]", service.code,
73
+ @incident.service_codes.include?(service.code), id: "service_#{service.code}" %>
74
+ <%= service.name %>
75
+ </label>
76
+ <% end %>
77
+ </div>
78
+ </div>
79
+
80
+ <% if @incident.new_record? %>
81
+ <div class="field">
82
+ <%= label_tag "incident_body", "First update", class: "field__label" %>
83
+ <%= text_area_tag "incident[body]", nil, id: "incident_body", class: "field__input",
84
+ placeholder: "What’s happening, and what you’re doing about it." %>
85
+ </div>
86
+ <% end %>
87
+
88
+ <div class="incident-form__actions">
89
+ <%= link_to "Cancel", incidents_path, class: "incident-form__cancel" %>
90
+ <%= f.submit(@incident.new_record? ? "Create" : "Save changes", class: "btn") %>
91
+ </div>
92
+ </div>
93
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <%= link_to edit_incident_path(incident), class: "incident-row incident-row--#{incident.maintenance? ? "maintenance" : incident.impact}" do %>
2
+ <span class="incident-row__spine" aria-hidden="true"></span>
3
+ <div class="incident-row__main">
4
+ <div class="incident-row__top">
5
+ <span class="incident-row__kind"><%= incident.maintenance? ? "Maintenance" : "Incident" %></span>
6
+ <span class="incident-row__status"><%= incident.status.humanize %></span>
7
+ </div>
8
+ <span class="incident-row__title"><%= incident.title %></span>
9
+ <p class="incident-row__meta">
10
+ <% if incident.services.any? %><%= incident.services.map(&:name).to_sentence %> · <% end %>
11
+ <time datetime="<%= incident.starts_at.iso8601 %>"><%= when_label %></time>
12
+ <% if incident.created_by.present? %> · Declared by <%= incident.created_by %><% end %>
13
+ </p>
14
+ </div>
15
+ <% end %>
@@ -0,0 +1,64 @@
1
+ <% @page_title = @incident.title %>
2
+
3
+ <div class="incident-editor incident-editor--<%= @incident.maintenance? ? "maintenance" : @incident.impact %>">
4
+ <%= link_to "← All incidents", incidents_path, class: "incident-editor__back" %>
5
+
6
+ <%= render "form" %>
7
+
8
+ <section class="incident-section">
9
+ <h2 class="incident-section__title">Lifecycle</h2>
10
+ <ol class="lifecycle">
11
+ <% statuses = @incident.class::STATUSES %>
12
+ <% current = statuses.index(@incident.status) %>
13
+ <% statuses.each_with_index do |status, index| %>
14
+ <li class="lifecycle__step <%= "lifecycle__step--done" if current && index < current %> <%= "lifecycle__step--current" if index == current %>"><%= status.humanize %></li>
15
+ <% end %>
16
+ </ol>
17
+ </section>
18
+
19
+ <section class="incident-section">
20
+ <h2 class="incident-section__title">Post an update</h2>
21
+ <%= form_with url: incident_updates_path(@incident), scope: :incident_update, class: "composer", data: { controller: "template" } do |f| %>
22
+ <div class="field">
23
+ <%= f.label :status, "Status", class: "field__label" %>
24
+ <%= f.select :status, @incident.class::STATUSES.map { |status| [ status.humanize, status ] },
25
+ { selected: @incident.status }, class: "input--select" %>
26
+ </div>
27
+ <div class="field">
28
+ <%= f.label :body, "Message", class: "field__label" %>
29
+ <% unless @incident.maintenance? %>
30
+ <div class="composer__templates">
31
+ <% @incident.class::TEMPLATES.each_key do |template| %>
32
+ <%= button_tag template.to_s.humanize, type: "button", class: "btn btn--small",
33
+ data: { action: "template#fill", template_body_param: @incident.update_body_for(template) } %>
34
+ <% end %>
35
+ </div>
36
+ <% end %>
37
+ <%= f.text_area :body, class: "field__input", placeholder: "What changed.", data: { template_target: "body" } %>
38
+ </div>
39
+ <div class="incident-form__actions">
40
+ <%= f.submit "Post update", class: "btn" %>
41
+ </div>
42
+ <% end %>
43
+ </section>
44
+
45
+ <section class="incident-section">
46
+ <h2 class="incident-section__title">Timeline</h2>
47
+ <ol class="timeline">
48
+ <% @incident.updates.each do |update| %>
49
+ <li class="timeline__item">
50
+ <span class="timeline__status"><%= update.status.humanize %></span>
51
+ <time class="timeline__time" datetime="<%= update.created_at.iso8601 %>"><%= update.created_at.strftime("%b %-d, %H:%M %Z") %></time>
52
+ <span class="timeline__author">by <%= update.created_by %></span>
53
+ <% if update.body.present? %><p class="timeline__body"><%= update.body %></p><% end %>
54
+ </li>
55
+ <% end %>
56
+ </ol>
57
+ </section>
58
+
59
+ <section class="incident-section">
60
+ <%= button_to "Delete #{@incident.maintenance? ? "maintenance" : "incident"}",
61
+ incident_path(@incident), method: :delete, class: "btn",
62
+ form: { data: { turbo_confirm: "Delete this #{@incident.maintenance? ? "maintenance" : "incident"}?" } } %>
63
+ </section>
64
+ </div>