Skip to content

Commit 19b7f72

Browse files
authored
Merge pull request #79 from basecamp/lewis/public-status-pages
Public status pages v1: live today + 90-day history + RSS feed
2 parents a35a671 + b5e7c3b commit 19b7f72

48 files changed

Lines changed: 983 additions & 74 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Upright engine
2+
3+
This is the `upright` Rails engine. All development, testing, and database
4+
management run from this directory (the engine root). Never `cd` into
5+
`test/dummy/`.
6+
7+
## Running tests
8+
9+
```bash
10+
bin/rails test # full suite
11+
bin/rails test test/models/upright/service_test.rb
12+
bin/rails test test/models/upright/service_test.rb:10
13+
```
14+
15+
## Database management
16+
17+
The test/dev database lives in `test/dummy/storage/`. The schema is
18+
checked in at `test/dummy/db/schema.rb`. Always operate via the engine
19+
root — don't prefix with `app:`.
20+
21+
```bash
22+
RAILS_ENV=test bin/rails db:drop db:create db:schema:load # reset test DB
23+
RAILS_ENV=test bin/rails db:schema:load # apply current schema
24+
```
25+
26+
### Adding a new engine migration
27+
28+
Engine migrations live in `db/migrate/`. The `upright.migrations`
29+
initializer deliberately skips the dummy app, so `bin/rails db:migrate`
30+
from the engine root won't apply engine migrations to the test DB. To
31+
apply a new migration and refresh `schema.rb`:
32+
33+
```bash
34+
RAILS_ENV=test bin/rails app:db:migrate # one-time, after adding a migration
35+
```
36+
37+
After this, `schema.rb` is updated and subsequent runs of
38+
`db:schema:load` will pick up the new tables. Commit `schema.rb`
39+
alongside the migration file.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
@layer components {
2+
:root {
3+
--status-operational: oklch(70% 0.18 145);
4+
--status-degraded: oklch(78% 0.18 95);
5+
--status-partial: oklch(70% 0.18 50);
6+
--status-major: oklch(60% 0.21 28);
7+
--status-none: oklch(var(--lch-ink-lightest));
8+
}
9+
10+
.public {
11+
font-family: var(--font-sans);
12+
}
13+
14+
.status-page {
15+
margin: 0 auto;
16+
max-width: 56rem;
17+
padding: calc(var(--block-space) * 3) var(--main-padding);
18+
}
19+
20+
.status-page__title {
21+
font-size: var(--text-x-large);
22+
font-weight: 600;
23+
letter-spacing: -0.02em;
24+
margin: 0 0 calc(var(--block-space) * 1.5);
25+
}
26+
27+
.status-banner {
28+
align-items: center;
29+
border-radius: 0.5rem;
30+
color: var(--color-white);
31+
display: flex;
32+
font-size: var(--text-large);
33+
font-weight: 600;
34+
gap: 0.75rem;
35+
padding: 1rem 1.25rem;
36+
margin-bottom: calc(var(--block-space) * 3);
37+
}
38+
39+
.status-banner--operational { background: var(--status-operational); }
40+
.status-banner--degraded { background: var(--status-degraded); color: oklch(20% 0 0); }
41+
.status-banner--partial_outage { background: var(--status-partial); }
42+
.status-banner--major_outage { background: var(--status-major); }
43+
44+
.status-banner__dot {
45+
background: currentColor;
46+
border-radius: 50%;
47+
height: 0.6rem;
48+
opacity: 0.8;
49+
width: 0.6rem;
50+
}
51+
52+
.status-degraded {
53+
border: var(--border);
54+
border-radius: 0.5rem;
55+
list-style: none;
56+
margin: calc(var(--block-space) * -2) 0 calc(var(--block-space) * 3);
57+
padding: 0.25rem 0;
58+
}
59+
60+
.status-degraded__item {
61+
align-items: center;
62+
display: flex;
63+
gap: 0.75rem;
64+
padding: 0.5rem 1rem;
65+
}
66+
67+
.status-degraded__item + .status-degraded__item {
68+
border-top: var(--border);
69+
}
70+
71+
.status-degraded__dot {
72+
border-radius: 50%;
73+
flex-shrink: 0;
74+
height: 0.6rem;
75+
width: 0.6rem;
76+
}
77+
78+
.status-degraded__dot--degraded { background: var(--status-degraded); }
79+
.status-degraded__dot--partial_outage { background: var(--status-partial); }
80+
.status-degraded__dot--major_outage { background: var(--status-major); }
81+
82+
.status-degraded__name { font-weight: 600; }
83+
84+
.status-degraded__detail {
85+
color: var(--color-ink-medium);
86+
margin-left: auto;
87+
}
88+
89+
.service {
90+
border-top: var(--border);
91+
padding: calc(var(--block-space) * 1.25) 0;
92+
}
93+
94+
.service__row {
95+
align-items: center;
96+
display: flex;
97+
gap: 1rem;
98+
justify-content: space-between;
99+
margin-bottom: 0.75rem;
100+
}
101+
102+
.service__name {
103+
font-size: var(--text-large);
104+
font-weight: 500;
105+
}
106+
107+
.service__status {
108+
color: var(--color-ink-medium);
109+
font-size: var(--text-small);
110+
font-weight: 500;
111+
text-transform: uppercase;
112+
letter-spacing: 0.05em;
113+
}
114+
115+
.service__status--operational { color: var(--status-operational); }
116+
.service__status--degraded { color: var(--status-degraded); }
117+
.service__status--partial_outage { color: var(--status-partial); }
118+
.service__status--major_outage { color: var(--status-major); }
119+
120+
.service__description {
121+
color: var(--color-ink-medium);
122+
font-size: var(--text-small);
123+
margin: 0 0 0.75rem;
124+
}
125+
126+
.uptime {
127+
display: flex;
128+
flex-direction: column;
129+
gap: 0.4rem;
130+
}
131+
132+
.uptime__bars {
133+
display: flex;
134+
gap: 2px;
135+
height: 2rem;
136+
}
137+
138+
.uptime__bar {
139+
background: var(--status-none);
140+
border-radius: 1px;
141+
flex: 1 1 0;
142+
min-width: 2px;
143+
transition: filter 100ms ease-out;
144+
}
145+
146+
.uptime__bar:hover {
147+
filter: brightness(0.9);
148+
}
149+
150+
.uptime__bar--operational { background: var(--status-operational); }
151+
.uptime__bar--degraded { background: var(--status-degraded); }
152+
.uptime__bar--partial_outage { background: var(--status-partial); }
153+
.uptime__bar--major_outage { background: var(--status-major); }
154+
155+
.uptime__meta {
156+
color: var(--color-ink-medium);
157+
display: flex;
158+
font-size: var(--text-x-small);
159+
justify-content: space-between;
160+
}
161+
162+
.uptime__meta-percent {
163+
color: var(--color-ink-dark);
164+
font-weight: 500;
165+
}
166+
}

app/controllers/upright/prometheus_proxy_controller.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,11 @@ def proxy_to_prometheus(path, method: request.method, body: nil)
4545
end
4646

4747
def prometheus_connection
48-
@prometheus_connection ||= Faraday.new(url: prometheus_url) do |f|
48+
@prometheus_connection ||= Faraday.new(url: Upright.configuration.prometheus_url) do |f|
4949
f.options.timeout = 30
5050
end
5151
end
5252

53-
def prometheus_url
54-
ENV.fetch("PROMETHEUS_URL", "http://localhost:9090")
55-
end
56-
5753
def authenticate_otlp_token
5854
authenticate_or_request_with_http_token do |token|
5955
ActiveSupport::SecurityUtils.secure_compare(token, ENV.fetch("PROMETHEUS_OTLP_TOKEN", ""))
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class Upright::Public::BaseController < ActionController::Base
2+
layout "upright/public"
3+
4+
helper :all
5+
protect_from_forgery with: :exception
6+
7+
private
8+
def default_url_options
9+
Rails.application.routes.default_url_options
10+
end
11+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class Upright::Public::ServicesController < Upright::Public::BaseController
2+
def index
3+
@services = Upright::Service.public_facing
4+
expires_in 15.seconds, public: true
5+
end
6+
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
module Upright::Public::ServicesHelper
2+
OVERALL_STATUS_LABELS = {
3+
operational: "All Systems Operational",
4+
degraded: "Some Systems Degraded",
5+
partial_outage: "Partial Outage",
6+
major_outage: "Major Outage"
7+
}
8+
9+
def overall_status_label(status)
10+
OVERALL_STATUS_LABELS.fetch(status)
11+
end
12+
13+
def status_label(status)
14+
status.to_s.humanize
15+
end
16+
17+
def outage_duration_phrase(started_at:)
18+
if started_at
19+
"for #{distance_of_time_in_words(started_at, Time.current)}"
20+
else
21+
"for 24 hours+"
22+
end
23+
end
24+
25+
# Stable per-outage id so feed readers treat one ongoing outage as a single
26+
# item. Falls back to the service code alone when the outage predates the live
27+
# lookback window and has no known start time.
28+
def feed_item_guid(issue)
29+
[ issue[:service].code, issue[:started_at]&.to_i ].compact.join("-")
30+
end
31+
32+
def average_uptime_percentage(fractions)
33+
if fractions.present?
34+
(fractions.sum.to_f / fractions.size) * 100
35+
end
36+
end
37+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class Upright::Rollups::DailyAggregationJob < Upright::ApplicationJob
2+
queue_as :default
3+
4+
# Aggregates daily rollups for completed days only — today is still in progress
5+
# and is represented live by Service#live_status, so persisting a half-day
6+
# rollup would just produce a stale value the rest of the day.
7+
def perform(past: 1.day)
8+
(past.ago.to_date..Date.yesterday).each do |day|
9+
Upright::Rollups::ProbeRollup.rollup_day(day)
10+
end
11+
end
12+
end

app/models/concerns/upright/probeable.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ module Upright::Probeable
44

55
ALERT_SEVERITIES = %i[ medium high critical ]
66

7+
mattr_accessor :probe_classes, default: []
8+
79
included do
810
attr_writer :logger
911

1012
def logger
1113
@logger || Rails.logger
1214
end
15+
16+
Upright::Probeable.probe_classes |= [ self ]
1317
end
1418

1519
class_methods do
@@ -61,7 +65,7 @@ def on_check_recorded(probe_result)
6165
end
6266

6367
def probe_service
64-
nil
68+
try(:service)
6569
end
6670

6771
def probe_alert_severity
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
module Upright::Services::LiveStatus
2+
extend ActiveSupport::Concern
3+
4+
OUTAGE_LOOKBACK = 24.hours
5+
6+
def live_status
7+
Upright::Status.for(live_up_fraction)
8+
end
9+
10+
# Earliest moment of the current outage, or nil if the service is currently
11+
# clear OR the outage predates OUTAGE_LOOKBACK. Callers should treat nil on a
12+
# non-operational service as "longer than the live window."
13+
def current_outage_started_at(now: Time.current)
14+
history = live_down_history(now: now)
15+
last_clear = history.rindex { |_ts, value| value.to_f == 0 }
16+
17+
if last_clear && last_clear < history.length - 1
18+
Time.zone.at(history[last_clear + 1].first.to_f)
19+
end
20+
end
21+
22+
private
23+
def live_up_fraction
24+
1 - live_down_fraction
25+
end
26+
27+
def live_down_fraction
28+
response = Upright.prometheus_client.query(
29+
query: "max(upright:probe_down_fraction{probe_service=\"#{code}\"}) or vector(0)"
30+
).deep_symbolize_keys
31+
response.dig(:result, 0, :value, 1).to_f
32+
end
33+
34+
def live_down_history(now:)
35+
response = Upright.prometheus_client.query_range(
36+
query: "max(upright:probe_down_fraction{probe_service=\"#{code}\"}) or vector(0)",
37+
start: (now - OUTAGE_LOOKBACK).iso8601,
38+
end: now.iso8601,
39+
step: "300s"
40+
).deep_symbolize_keys
41+
response.dig(:result, 0, :values) || []
42+
end
43+
end

0 commit comments

Comments
 (0)