upright 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +66 -7
- 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/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/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 +36 -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 +1 -0
- 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 -2
- 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 +26 -2
- data/app/models/concerns/upright/probeable.rb +9 -1
- 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 +12 -3
- 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/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 +3 -11
- data/app/models/upright/probes/uptime.rb +2 -9
- 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/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/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/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 +18 -10
- 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/omniauth.rb +7 -3
- data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
- data/lib/generators/upright/install/templates/recurring.yml +20 -0
- data/lib/generators/upright/install/templates/sites.yml +4 -0
- data/lib/generators/upright/install/templates/upright.rb +21 -2
- data/lib/generators/upright/install/templates/upright.rules.yml +11 -4
- data/lib/upright/configuration.rb +113 -6
- data/lib/upright/engine.rb +25 -2
- data/lib/upright/metrics.rb +25 -0
- data/lib/upright/site.rb +31 -1
- data/lib/upright/version.rb +3 -1
- data/lib/upright.rb +29 -2
- metadata +107 -7
- data/config/credentials/development.key +0 -1
- data/config/credentials/test.key +0 -1
- data/lib/tasks/upright_tasks.rake +0 -4
|
@@ -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 =
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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)
|
|
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 %>
|
|
@@ -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>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<% @page_title = "Incidents and maintenance" %>
|
|
2
|
+
|
|
3
|
+
<div class="incidents-index">
|
|
4
|
+
<header class="incidents-header">
|
|
5
|
+
<h1 class="incidents-header__title">Incidents & maintenance</h1>
|
|
6
|
+
<div class="incidents-actions">
|
|
7
|
+
<%= link_to "New incident", new_incident_path, class: "btn" %>
|
|
8
|
+
<%= link_to "New maintenance", new_incident_path(maintenance: true), class: "btn btn--ghost" %>
|
|
9
|
+
</div>
|
|
10
|
+
</header>
|
|
11
|
+
|
|
12
|
+
<% if @current.none? && @upcoming.none? && @past.none? %>
|
|
13
|
+
<div class="incidents-empty">
|
|
14
|
+
<p class="incidents-empty__headline">All clear</p>
|
|
15
|
+
<p class="incidents-empty__body">Nothing to report. Incidents and maintenance you declare here appear on the public status page.</p>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<% if @current.any? %>
|
|
20
|
+
<section class="incident-section">
|
|
21
|
+
<h2 class="incident-section__title">Active</h2>
|
|
22
|
+
<div class="incident-list">
|
|
23
|
+
<% @current.each do |incident| %>
|
|
24
|
+
<%= render "row", incident: incident,
|
|
25
|
+
when_label: incident.maintenance? ? maintenance_window_description(incident) : "Started #{incident.starts_at.to_fs(:month_day_at_zone)}" %>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
</section>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if @upcoming.any? %>
|
|
32
|
+
<section class="incident-section">
|
|
33
|
+
<h2 class="incident-section__title">Upcoming maintenance</h2>
|
|
34
|
+
<div class="incident-list">
|
|
35
|
+
<% @upcoming.each do |incident| %>
|
|
36
|
+
<%= render "row", incident: incident, when_label: maintenance_window_description(incident) %>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
39
|
+
</section>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
42
|
+
<% if @past.any? %>
|
|
43
|
+
<section class="incident-section">
|
|
44
|
+
<h2 class="incident-section__title">Past</h2>
|
|
45
|
+
<div class="incident-list">
|
|
46
|
+
<% @past.each do |incident| %>
|
|
47
|
+
<%= render "row", incident: incident,
|
|
48
|
+
when_label: "Resolved #{incident.resolved_at&.to_fs(:month_day_at_zone)}" %>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
<% end %>
|
|
53
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<% @page_title = @incident.title %>
|
|
2
|
+
|
|
3
|
+
<article class="incident-detail incident-detail--<%= @incident.maintenance? ? "maintenance" : @incident.impact %>">
|
|
4
|
+
<%= link_to "← Status", "/", class: "incident-detail__back" %>
|
|
5
|
+
|
|
6
|
+
<header class="incident-detail__head">
|
|
7
|
+
<p class="incident-detail__eyebrow"><%= @incident.maintenance? ? "Scheduled maintenance" : "Incident" %></p>
|
|
8
|
+
<h1 class="incident-detail__title"><%= @incident.title %></h1>
|
|
9
|
+
<div class="incident-detail__meta">
|
|
10
|
+
<span class="incident-detail__status"><%= status_label(@incident.status) %></span>
|
|
11
|
+
<% if @incident.maintenance? %>
|
|
12
|
+
<span><%= local_maintenance_window(@incident) %></span>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% if @incident.public_services.any? %>
|
|
15
|
+
<span><%= @incident.public_services.map(&:name).to_sentence %></span>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
</header>
|
|
19
|
+
|
|
20
|
+
<ol class="incident-detail__timeline">
|
|
21
|
+
<% @incident.updates.each do |update| %>
|
|
22
|
+
<li class="incident-detail__update">
|
|
23
|
+
<span class="incident-detail__update-status"><%= status_label(update.status) %></span>
|
|
24
|
+
<%= local_time update.created_at, format: "%b %-d, %Y · %H:%M %Z", class: "incident-detail__update-time" %>
|
|
25
|
+
<% if update.body.present? %><p class="incident-detail__update-body"><%= update.body %></p><% end %>
|
|
26
|
+
</li>
|
|
27
|
+
<% end %>
|
|
28
|
+
</ol>
|
|
29
|
+
</article>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%= link_to public_incident_path(incident), class: "service-history__incident service-history__incident--#{incident.impact}" do %>
|
|
2
|
+
<div class="service-history__incident-head">
|
|
3
|
+
<h3 class="service-history__incident-title"><%= incident.title %></h3>
|
|
4
|
+
<span class="service-history__incident-impact"><%= incident.impact.humanize %></span>
|
|
5
|
+
</div>
|
|
6
|
+
<p class="service-history__incident-dates">
|
|
7
|
+
<%= local_time incident.starts_at, format: "%b %-d, %Y · %H:%M %Z" %>
|
|
8
|
+
—
|
|
9
|
+
<%= incident.resolved_at? ? local_time(incident.resolved_at, format: "%b %-d, %Y · %H:%M %Z") : "Ongoing" %>
|
|
10
|
+
</p>
|
|
11
|
+
<% if incident.updates.first&.body.present? %>
|
|
12
|
+
<p class="service-history__incident-update"><%= incident.updates.first.body %></p>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<% @page_title = @incident_history.name %>
|
|
2
|
+
<% uptime_history = @incident_history.daily_status_history %>
|
|
3
|
+
<% today_status = uptime_history.last.status %>
|
|
4
|
+
|
|
5
|
+
<article class="service-detail">
|
|
6
|
+
<%= link_to "← Status", public_services_root_path, class: "service-detail__back" %>
|
|
7
|
+
|
|
8
|
+
<header class="service-detail__head">
|
|
9
|
+
<div class="service__row">
|
|
10
|
+
<h1 class="service-detail__title"><%= @incident_history.name %></h1>
|
|
11
|
+
<% if @incident_history.maintenance_active? %>
|
|
12
|
+
<span class="service__status service__status--maintenance">Under maintenance</span>
|
|
13
|
+
<% else %>
|
|
14
|
+
<span class="service__status service__status--<%= today_status %>"><%= status_label(today_status) %></span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="uptime">
|
|
19
|
+
<div class="uptime__bars" role="img" aria-label="<%= uptime_history.size %>-day uptime for <%= @incident_history.name %>">
|
|
20
|
+
<%= render partial: "upright/public/services/uptime_bar", collection: uptime_history, as: :day %>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="uptime__meta">
|
|
23
|
+
<span><%= uptime_history.size %> days ago</span>
|
|
24
|
+
<% if uptime = uptime_percentage_label(uptime_history.map(&:uptime_fraction).compact) %>
|
|
25
|
+
<span class="uptime__meta-percent"><%= uptime %> uptime</span>
|
|
26
|
+
<% else %>
|
|
27
|
+
<span class="uptime__meta-percent">No data yet</span>
|
|
28
|
+
<% end %>
|
|
29
|
+
<span>Today</span>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</header>
|
|
33
|
+
|
|
34
|
+
<% if @incident_history.active_incidents.any? %>
|
|
35
|
+
<section class="service-history">
|
|
36
|
+
<h2 class="service-history__title">Active incidents</h2>
|
|
37
|
+
<%= render partial: "incident", collection: @incident_history.active_incidents %>
|
|
38
|
+
</section>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<section class="service-history">
|
|
42
|
+
<h2 class="service-history__title">Incident history</h2>
|
|
43
|
+
<% if @incident_history.past_incidents.any? %>
|
|
44
|
+
<%= render partial: "incident", collection: @incident_history.past_incidents %>
|
|
45
|
+
<%= render "upright/probe_results/pagination", page: @incident_history.page %>
|
|
46
|
+
<% else %>
|
|
47
|
+
<p class="service-history__empty">No past incidents.</p>
|
|
48
|
+
<% end %>
|
|
49
|
+
</section>
|
|
50
|
+
</article>
|