Add a public reachability status page
asxpio now probes public hosts tied to each datacenter once a minute and stores 5-minute rows (bingo_monitor_* tables, granted to the read-only bingo role). Show them: /:locale/status lists every monitored site with its current state and a 24-hour bar, /status.json has the same data, and monitored datacenter pages get a small block. A site is down only when nothing answered for two buckets. Everything reads as unknown when the worker is stale or fewer than half of the control hosts in Russia answer, since probes run from Finland and filtering can look like an outage. The page says plainly that this is not an incident record. Refresh test/schema.sql for asxpio's migration 017. Assisted-by: Claude:opus-5-5
This commit is contained in:
parent
2fe297fce1
commit
1a5506c77d
10 changed files with 414 additions and 9 deletions
|
|
@ -46,6 +46,13 @@ renderings only for unbuilt sites) and credit format.
|
|||
photo credit and incidents with sources. Licensed CC BY 4.0, with the
|
||||
attribution inside the file. Feeds and dataset send
|
||||
`Access-Control-Allow-Origin: *`.
|
||||
- Reachability: `/:locale/status` and `/status.json` (`Bingo::Status`),
|
||||
plus a block on monitored datacenter pages. They read the
|
||||
`bingo_monitor_*` tables that asxpio's `bin/monitor` writes (TCP probes
|
||||
from Finland, 5-minute buckets, control hosts in Russia). States: up,
|
||||
degraded, down (nothing answered for two buckets), unknown (worker stale,
|
||||
or under half the controls answering). Not incidents: never cross out a
|
||||
cell from this alone.
|
||||
- `config.ru`: `www.` → `BINGO_HOST` 301.
|
||||
- `views/`: `layout.erb` (CSS and SEO head), `index.erb` (card, provider
|
||||
filter, dialogs), `dc.erb`, partials. `assets/`: icons.
|
||||
|
|
|
|||
37
app.rb
37
app.rb
|
|
@ -181,6 +181,24 @@ class BingoWeb < Sinatra::Base
|
|||
|
||||
def feed_time(i) = Time.utc(i.occurred_on.year, i.occurred_on.month, i.occurred_on.day)
|
||||
|
||||
# Bar colour for an answered share (nil = no data).
|
||||
def avail_class(a)
|
||||
return '' if a.nil?
|
||||
a >= 0.8 ? 'up' : (a.zero? ? 'down' : 'degraded')
|
||||
end
|
||||
|
||||
def status_report(only: nil) = Bingo::Status.available? ? Bingo::Status.load(only: only) : nil
|
||||
|
||||
def status_json(report)
|
||||
{ checked_at: report&.checked_at&.utc&.iso8601, monitor: report&.state,
|
||||
controls: report&.controls && { ok: report.controls[0], checked: report.controls[1] },
|
||||
datacenters: (report&.sites || []).map do |s|
|
||||
{ slug: s.dc.slug, rank: s.dc.position, state: s.state, availability: s.availability&.round(3),
|
||||
rtt_ms: s.rtt_ms, targets: s.targets, hourly: s.history.map { |a| a&.round(3) } }
|
||||
end,
|
||||
unmonitored: (report&.unmonitored || []).map(&:slug) }
|
||||
end
|
||||
|
||||
def preferred_locale
|
||||
langs = request.env['HTTP_ACCEPT_LANGUAGE'].to_s.downcase.scan(/([a-z]{2})(?:-[a-z]+)?(?:;q=([\d.]+))?/)
|
||||
langs.sort_by { |_, q| -(q || '1').to_f }.map(&:first).find { |l| Bingo::LOCALES.include?(l) } || 'en'
|
||||
|
|
@ -252,10 +270,29 @@ class BingoWeb < Sinatra::Base
|
|||
@path_for = ->(l) { dc_path(@dc, l) }
|
||||
@og = @dc if @dc.photo_key
|
||||
@ld = dc_json_ld(@dc)
|
||||
@status = status_report(only: @dc.id)&.sites&.first
|
||||
cache_control :public, max_age: 60
|
||||
erb :dc
|
||||
end
|
||||
|
||||
# Reachability from asxpio's monitor; not incidents (see Bingo::Status).
|
||||
get %r{/(ru|en|uk)/status} do |locale|
|
||||
@locale = locale
|
||||
@report = status_report
|
||||
@title = t(:status_title)
|
||||
@desc = snippet(t(:status_lead))
|
||||
@path_for = ->(l) { "/#{l}/status" }
|
||||
cache_control :public, max_age: 60
|
||||
erb :status
|
||||
end
|
||||
|
||||
get '/status.json' do
|
||||
content_type 'application/json', charset: 'utf-8'
|
||||
headers 'Access-Control-Allow-Origin' => '*'
|
||||
cache_control :public, max_age: 60
|
||||
JSON.pretty_generate(status_json(status_report))
|
||||
end
|
||||
|
||||
# Incident feeds per locale: RSS 2.0 and JSON Feed 1.1, newest first.
|
||||
get %r{/(ru|en|uk)/feed\.xml} do |locale|
|
||||
@locale = locale
|
||||
|
|
|
|||
126
lib/bingo.rb
126
lib/bingo.rb
|
|
@ -72,7 +72,19 @@ module Bingo
|
|||
dc_since: 'Работает с %d года.',
|
||||
dc_rank: '№ %{pos} по мощности среди крупнейших дата-центров России.',
|
||||
dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты',
|
||||
back: '← К карточке', dc_page: 'Страница ЦОДа →'
|
||||
back: '← К карточке', dc_page: 'Страница ЦОДа →',
|
||||
status_link: 'Доступность ЦОДов', status_title: 'Доступность ЦОДов России — сетевой мониторинг | Бинго ЦОДов',
|
||||
status_h1: 'Доступность', status_accent: 'ЦОДов',
|
||||
status_lead: 'Раз в минуту мы открываем TCP-соединение с публичными адресами, которые открытые источники связывают с конкретной площадкой. Проверка идёт из Финляндии: российская фильтрация и отключения внешних каналов могут выглядеть как авария, поэтому параллельно проверяются контрольные узлы в России.',
|
||||
status_note: 'Это не подтверждённые инциденты: клетка на карточке зачёркивается только по публичному источнику.',
|
||||
status_states: { 'up' => 'доступен', 'degraded' => 'частично', 'down' => 'не отвечает', 'unknown' => 'нет данных' },
|
||||
status_controls: 'Контрольные узлы в России: %{ok} из %{n} отвечают.',
|
||||
status_stale: 'Монитор давно не присылал данных — состояние неизвестно.',
|
||||
status_vantage: 'Большинство контрольных узлов в России не отвечает: проблема, скорее всего, на пути из-за границы, а не на площадках.',
|
||||
status_checked: 'Последняя проверка: %{time} UTC.', status_never: 'Проверок ещё не было.',
|
||||
status_unmonitored: 'Без мониторинга', status_unmonitored_note: 'Действующие ЦОДы, для которых нет публичных адресов, надёжно привязанных к площадке (обычно собственные ЦОДы Яндекса, VK, Сбера).',
|
||||
status_cols: { 'dc' => 'ЦОД', 'state' => 'Сейчас', 'hours' => '24 часа', 'rtt' => 'мс', 'targets' => 'адр.' },
|
||||
status_dc: 'Доступность сети', status_hour: '%{h} ч назад: %{p}'
|
||||
},
|
||||
'en' => {
|
||||
seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters',
|
||||
|
|
@ -104,7 +116,19 @@ module Bingo
|
|||
dc_since: 'In service since %d.',
|
||||
dc_rank: "No. %{pos} by power among Russia's largest datacenters.",
|
||||
dc_latest: 'Latest reported incident (%{date}): %{title}', incidents: 'Incidents',
|
||||
back: '← Back to the card', dc_page: 'Datacenter page →'
|
||||
back: '← Back to the card', dc_page: 'Datacenter page →',
|
||||
status_link: 'Datacenter reachability', status_title: "Russian datacenter reachability — network monitor | Datacenter Bingo",
|
||||
status_h1: 'Datacenter', status_accent: 'reachability',
|
||||
status_lead: 'Once a minute we open a TCP connection to public addresses that open sources tie to a specific site. Probes run from Finland: Russian filtering and cuts to international links can look like an outage, so control hosts inside Russia are checked alongside.',
|
||||
status_note: 'These are not confirmed incidents: a cell on the card is crossed out only on a public source.',
|
||||
status_states: { 'up' => 'reachable', 'degraded' => 'partial', 'down' => 'not answering', 'unknown' => 'no data' },
|
||||
status_controls: 'Control hosts in Russia: %{ok} of %{n} answering.',
|
||||
status_stale: 'The monitor has not reported for a while — state unknown.',
|
||||
status_vantage: 'Most control hosts in Russia are not answering: the problem is most likely on the path from abroad, not at the sites.',
|
||||
status_checked: 'Last check: %{time} UTC.', status_never: 'No checks yet.',
|
||||
status_unmonitored: 'Not monitored', status_unmonitored_note: 'Operational datacenters with no public address reliably tied to the site (mostly Yandex, VK and Sber in-house sites).',
|
||||
status_cols: { 'dc' => 'Datacenter', 'state' => 'Now', 'hours' => '24 hours', 'rtt' => 'ms', 'targets' => 'addr.' },
|
||||
status_dc: 'Network reachability', status_hour: '%{h} h ago: %{p}'
|
||||
},
|
||||
'uk' => {
|
||||
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
|
||||
|
|
@ -136,7 +160,19 @@ module Bingo
|
|||
dc_since: 'Працює з %d року.',
|
||||
dc_rank: '№ %{pos} за потужністю серед найбільших дата-центрів Росії.',
|
||||
dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти',
|
||||
back: '← До картки', dc_page: 'Сторінка ЦОДу →'
|
||||
back: '← До картки', dc_page: 'Сторінка ЦОДу →',
|
||||
status_link: 'Доступність ЦОДів', status_title: 'Доступність ЦОДів Росії — мережевий моніторинг | Бінго ЦОДів',
|
||||
status_h1: 'Доступність', status_accent: 'ЦОДів',
|
||||
status_lead: 'Раз на хвилину ми відкриваємо TCP-з’єднання з публічними адресами, які відкриті джерела пов’язують із конкретним майданчиком. Перевірка йде з Фінляндії: російська фільтрація та відключення зовнішніх каналів можуть виглядати як аварія, тому паралельно перевіряються контрольні вузли в Росії.',
|
||||
status_note: 'Це не підтверджені інциденти: клітинка на картці закреслюється лише за публічним джерелом.',
|
||||
status_states: { 'up' => 'доступний', 'degraded' => 'частково', 'down' => 'не відповідає', 'unknown' => 'немає даних' },
|
||||
status_controls: 'Контрольні вузли в Росії: %{ok} з %{n} відповідають.',
|
||||
status_stale: 'Монітор давно не надсилав даних — стан невідомий.',
|
||||
status_vantage: 'Більшість контрольних вузлів у Росії не відповідає: проблема, найімовірніше, на шляху з-за кордону, а не на майданчиках.',
|
||||
status_checked: 'Остання перевірка: %{time} UTC.', status_never: 'Перевірок ще не було.',
|
||||
status_unmonitored: 'Без моніторингу', status_unmonitored_note: 'Діючі ЦОДи, для яких немає публічних адрес, надійно прив’язаних до майданчика (здебільшого власні ЦОДи Яндекса, VK, Сбера).',
|
||||
status_cols: { 'dc' => 'ЦОД', 'state' => 'Зараз', 'hours' => '24 години', 'rtt' => 'мс', 'targets' => 'адр.' },
|
||||
status_dc: 'Доступність мережі', status_hour: '%{h} год тому: %{p}'
|
||||
}
|
||||
}.freeze
|
||||
|
||||
|
|
@ -268,6 +304,90 @@ module Bingo
|
|||
end
|
||||
end
|
||||
|
||||
# Network reachability from asxpio's monitor (bin/monitor there writes the
|
||||
# bingo_monitor_* tables). Each 5-minute bucket counts TCP probes sent and
|
||||
# answered per datacenter, plus probes to control hosts in Russia that
|
||||
# belong to no listed site.
|
||||
module Status
|
||||
BUCKET = 300
|
||||
STALE = 900 # newest worker bucket older than this: nothing is current
|
||||
HOURS = 24
|
||||
|
||||
Site = Struct.new(:dc, :state, :availability, :rtt_ms, :history, :targets)
|
||||
Report = Struct.new(:sites, :unmonitored, :checked_at, :controls, :state) do
|
||||
def counts = sites.map(&:state).tally
|
||||
end
|
||||
|
||||
module_function
|
||||
|
||||
def available? = DB.connection.table_exists?(:bingo_monitor_samples)
|
||||
|
||||
# A bucket is trusted when at least half the control hosts answered; with
|
||||
# none answering, a dark site may just mean our vantage point lost Russia.
|
||||
def vantage_ok?(bucket)
|
||||
bucket[:control_checks].zero? || bucket[:control_ok] * 2 >= bucket[:control_checks]
|
||||
end
|
||||
|
||||
# state: up (≥80 % of probes answered), degraded, down (nothing answered
|
||||
# in the last two buckets), unknown (worker stale, vantage lost, or the
|
||||
# site wasn't probed in the newest bucket).
|
||||
def site_state(rows, latest, previous, trusted)
|
||||
return 'unknown' unless latest && trusted
|
||||
cur = rows[latest] or return 'unknown'
|
||||
prev = previous && rows[previous]
|
||||
return 'down' if cur[:ok].zero? && (prev.nil? || prev[:ok].zero?)
|
||||
cur[:ok] >= cur[:checks] * 0.8 ? 'up' : 'degraded'
|
||||
end
|
||||
|
||||
# only: a datacenter id, for one datacenter page.
|
||||
def load(now = Time.now.utc, hours: HOURS, only: nil)
|
||||
db = DB.connection
|
||||
since = now - (hours * 3600)
|
||||
buckets = db[:bingo_monitor_buckets].where { bucket_at >= since }.order(:bucket_at).all
|
||||
latest = buckets.last
|
||||
fresh = latest && now - latest[:bucket_at] < STALE
|
||||
trusted = fresh && vantage_ok?(latest)
|
||||
latest_at = fresh ? latest[:bucket_at] : nil
|
||||
previous_at = fresh && buckets.size > 1 ? buckets[-2][:bucket_at] : nil
|
||||
bad_hours = buckets.reject { |b| vantage_ok?(b) }.map { |b| hour_index(b[:bucket_at], now, hours) }.to_set
|
||||
|
||||
samples = db[:bingo_monitor_samples].where { bucket_at >= since }
|
||||
targets = db[:bingo_monitor_targets].where(active: true).exclude(datacenter_id: nil)
|
||||
dcs = Datacenter.order(:position, :id)
|
||||
samples, targets, dcs = [samples.where(datacenter_id: only), targets.where(datacenter_id: only), dcs.where(id: only)] if only
|
||||
samples = samples.all.group_by { |r| r[:datacenter_id] }
|
||||
targets = targets.group_and_count(:datacenter_id).to_hash(:datacenter_id, :count)
|
||||
dcs = dcs.all
|
||||
monitored, unmonitored = dcs.partition { |dc| targets.key?(dc.id) }
|
||||
|
||||
sites = monitored.map do |dc|
|
||||
rows = (samples[dc.id] || []).to_h { |r| [r[:bucket_at], r] }
|
||||
cur = latest_at && rows[latest_at]
|
||||
Site.new(dc, site_state(rows, latest_at, previous_at, trusted),
|
||||
cur && cur[:checks].positive? ? cur[:ok].fdiv(cur[:checks]) : nil,
|
||||
cur && cur[:ok].positive? ? (cur[:rtt_ms_sum] / cur[:ok]) : nil,
|
||||
history(rows.values, now, hours, bad_hours), targets[dc.id])
|
||||
end
|
||||
Report.new(sites, unmonitored.select(&:operational?), latest && latest[:bucket_at],
|
||||
latest && [latest[:control_ok], latest[:control_checks]],
|
||||
!fresh ? 'stale' : (trusted ? 'ok' : 'vantage'))
|
||||
end
|
||||
|
||||
def hour_index(time, now, hours) = hours - 1 - ((now - time) / 3600).floor
|
||||
|
||||
# Answered share per hour, oldest first; nil = no data or untrusted hour.
|
||||
def history(rows, now, hours, bad_hours)
|
||||
per = Array.new(hours) { [0, 0] }
|
||||
rows.each do |r|
|
||||
i = hour_index(r[:bucket_at], now, hours)
|
||||
next unless i.between?(0, hours - 1)
|
||||
per[i][0] += r[:ok]
|
||||
per[i][1] += r[:checks]
|
||||
end
|
||||
per.each_with_index.map { |(ok, n), i| n.zero? || bad_hours.include?(i) ? nil : ok.fdiv(n) }
|
||||
end
|
||||
end
|
||||
|
||||
# IndexNow pings are sent by the asxp.io admin; this site only serves the
|
||||
# key file at /<key>.txt to prove they come from us.
|
||||
module IndexNow
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
-- bingo tables as created by the asxpio migrations (004–016), for tests.
|
||||
-- bingo tables as created by the asxpio migrations (004–017), for tests.
|
||||
-- Refresh when asxpio adds a bingo migration: run them into an empty DB and
|
||||
-- pg_dump -s -O -x -t bingo_datacenters -t bingo_incidents.
|
||||
-- pg_dump -s -O -x -t bingo_datacenters -t bingo_incidents -t 'bingo_monitor_*'.
|
||||
|
||||
CREATE TABLE public.bingo_datacenters (
|
||||
id integer NOT NULL,
|
||||
slug text NOT NULL,
|
||||
|
|
@ -61,6 +62,45 @@ ALTER TABLE public.bingo_incidents ALTER COLUMN id ADD GENERATED BY DEFAULT AS I
|
|||
CACHE 1
|
||||
);
|
||||
|
||||
CREATE TABLE public.bingo_monitor_buckets (
|
||||
bucket_at timestamp without time zone NOT NULL,
|
||||
rounds integer DEFAULT 0 NOT NULL,
|
||||
control_checks integer DEFAULT 0 NOT NULL,
|
||||
control_ok integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.bingo_monitor_samples (
|
||||
datacenter_id integer NOT NULL,
|
||||
bucket_at timestamp without time zone NOT NULL,
|
||||
checks integer DEFAULT 0 NOT NULL,
|
||||
ok integer DEFAULT 0 NOT NULL,
|
||||
rtt_ms_sum integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.bingo_monitor_targets (
|
||||
id integer NOT NULL,
|
||||
datacenter_id integer,
|
||||
host text NOT NULL,
|
||||
port integer DEFAULT 443 NOT NULL,
|
||||
label text,
|
||||
source_url text,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
last_ok boolean,
|
||||
last_rtt_ms integer,
|
||||
last_checked_at timestamp without time zone,
|
||||
last_change_at timestamp without time zone,
|
||||
created_at timestamp without time zone NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE public.bingo_monitor_targets ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
|
||||
SEQUENCE NAME public.bingo_monitor_targets_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY public.bingo_datacenters
|
||||
ADD CONSTRAINT bingo_datacenters_pkey PRIMARY KEY (id);
|
||||
|
||||
|
|
@ -70,9 +110,25 @@ ALTER TABLE ONLY public.bingo_datacenters
|
|||
ALTER TABLE ONLY public.bingo_incidents
|
||||
ADD CONSTRAINT bingo_incidents_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY public.bingo_monitor_buckets
|
||||
ADD CONSTRAINT bingo_monitor_buckets_pkey PRIMARY KEY (bucket_at);
|
||||
|
||||
ALTER TABLE ONLY public.bingo_monitor_samples
|
||||
ADD CONSTRAINT bingo_monitor_samples_pkey PRIMARY KEY (datacenter_id, bucket_at);
|
||||
|
||||
ALTER TABLE ONLY public.bingo_monitor_targets
|
||||
ADD CONSTRAINT bingo_monitor_targets_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE INDEX bingo_incidents_datacenter_id_index ON public.bingo_incidents USING btree (datacenter_id);
|
||||
|
||||
CREATE INDEX bingo_monitor_targets_datacenter_id_index ON public.bingo_monitor_targets USING btree (datacenter_id);
|
||||
|
||||
ALTER TABLE ONLY public.bingo_incidents
|
||||
ADD CONSTRAINT bingo_incidents_datacenter_id_fkey FOREIGN KEY (datacenter_id) REFERENCES public.bingo_datacenters(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.bingo_monitor_samples
|
||||
ADD CONSTRAINT bingo_monitor_samples_datacenter_id_fkey FOREIGN KEY (datacenter_id) REFERENCES public.bingo_datacenters(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.bingo_monitor_targets
|
||||
ADD CONSTRAINT bingo_monitor_targets_datacenter_id_fkey FOREIGN KEY (datacenter_id) REFERENCES public.bingo_datacenters(id) ON DELETE CASCADE;
|
||||
|
||||
|
|
|
|||
93
test/status_test.rb
Normal file
93
test/status_test.rb
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
require_relative 'test_helper'
|
||||
|
||||
class StatusTest < Minitest::Test
|
||||
include Rack::Test::Methods
|
||||
|
||||
def app = BingoWeb
|
||||
|
||||
NOW = Time.utc(2026, 10, 18, 12, 2)
|
||||
B = Time.utc(2026, 10, 18, 12, 0) # newest bucket at NOW
|
||||
|
||||
def setup
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
@db = DB.connection
|
||||
%i[bingo_monitor_buckets bingo_monitor_samples bingo_monitor_targets bingo_incidents bingo_datacenters].each { |t| @db[t].delete }
|
||||
@up, @down, @part, @none = %w[up down part none].each_with_index.map do |s, i|
|
||||
Bingo::Datacenter.create(slug: "dc-#{s}", position: i + 1, name_ru: "ЦОД #{s}", city_ru: 'Москва',
|
||||
operator: 'Op', status: 'operational')
|
||||
end
|
||||
[@up, @down, @part].each { |dc| @db[:bingo_monitor_targets].insert(datacenter_id: dc.id, host: "h-#{dc.slug}", port: 443, created_at: B) }
|
||||
@db[:bingo_monitor_targets].insert(datacenter_id: nil, host: 'ctl', port: 80, created_at: B)
|
||||
end
|
||||
|
||||
def bucket(at, control_ok: 4, control_checks: 4)
|
||||
@db[:bingo_monitor_buckets].insert(bucket_at: at, rounds: 5, control_checks: control_checks, control_ok: control_ok)
|
||||
end
|
||||
|
||||
def sample(dc, at, ok, checks = 5)
|
||||
@db[:bingo_monitor_samples].insert(datacenter_id: dc.id, bucket_at: at, checks: checks, ok: ok, rtt_ms_sum: ok * 40)
|
||||
end
|
||||
|
||||
def two_buckets(base = B, **controls)
|
||||
[base - 300, base].each do |at|
|
||||
bucket(at, **controls)
|
||||
sample(@up, at, 5)
|
||||
sample(@down, at, 0)
|
||||
sample(@part, at, 2)
|
||||
end
|
||||
end
|
||||
|
||||
def states(report) = report.sites.to_h { |s| [s.dc.slug, s.state] }
|
||||
|
||||
def test_states_from_newest_buckets
|
||||
two_buckets
|
||||
r = Bingo::Status.load(NOW)
|
||||
assert_equal({ 'dc-up' => 'up', 'dc-down' => 'down', 'dc-part' => 'degraded' }, states(r))
|
||||
assert_equal ['dc-none'], r.unmonitored.map(&:slug)
|
||||
assert_equal 40, r.sites.first.rtt_ms
|
||||
assert_equal 'ok', r.state
|
||||
end
|
||||
|
||||
def test_one_dark_bucket_after_a_good_one_is_not_yet_down
|
||||
bucket(B - 300); sample(@down, B - 300, 5)
|
||||
bucket(B); sample(@down, B, 0)
|
||||
assert_equal 'degraded', states(Bingo::Status.load(NOW))['dc-down']
|
||||
end
|
||||
|
||||
def test_lost_vantage_makes_everything_unknown
|
||||
two_buckets(control_ok: 1, control_checks: 4)
|
||||
r = Bingo::Status.load(NOW)
|
||||
assert_equal %w[unknown], states(r).values.uniq
|
||||
assert_equal 'vantage', r.state
|
||||
assert_nil r.sites.first.history.last, 'untrusted hour shows no data'
|
||||
end
|
||||
|
||||
def test_stale_worker_makes_everything_unknown
|
||||
two_buckets
|
||||
r = Bingo::Status.load(NOW + 3600)
|
||||
assert_equal %w[unknown], states(r).values.uniq
|
||||
assert_equal 'stale', r.state
|
||||
end
|
||||
|
||||
def test_status_page_and_json
|
||||
two_buckets(Time.at(Time.now.to_i / 300 * 300).utc) # pages use the real clock
|
||||
get '/ru/status'
|
||||
assert last_response.ok?
|
||||
assert_includes last_response.body, 'не отвечает'
|
||||
assert_includes last_response.body, 'ЦОД none' # unmonitored list
|
||||
get '/status.json'
|
||||
data = JSON.parse(last_response.body)
|
||||
assert_equal %w[dc-up dc-down dc-part], data['datacenters'].map { |d| d['slug'] }
|
||||
assert_equal ['dc-none'], data['unmonitored']
|
||||
get '/en/dc/dc-up'
|
||||
assert_includes last_response.body, 'Network reachability'
|
||||
get '/en/dc/dc-none'
|
||||
refute_includes last_response.body, 'Network reachability'
|
||||
end
|
||||
|
||||
def test_status_page_before_any_round
|
||||
get '/en/status'
|
||||
assert last_response.ok?
|
||||
assert_includes last_response.body, 'No checks yet.'
|
||||
end
|
||||
end
|
||||
|
|
@ -2,6 +2,6 @@
|
|||
<p><%= t(:footer) %></p>
|
||||
<p class="credits"><%= t(:credits_ai) %>
|
||||
<%== format(Rack::Utils.escape_html(t(:credits_hym)), link: '<a href="https://hackyourmom.com/en/karta-data-czentriv-na-rasii/" target="_blank" rel="noopener">hackyourmom</a>') %></p>
|
||||
<p><a href="/<%= @locale %>/feed.xml">RSS</a> · <a href="/<%= @locale %>/feed.json">JSON Feed</a> · <a href="/data.json"><%= t(:open_data) %></a></p>
|
||||
<p><a href="/<%= @locale %>/feed.xml">RSS</a> · <a href="/<%= @locale %>/feed.json">JSON Feed</a> · <a href="/data.json"><%= t(:open_data) %></a> · <a href="/<%= @locale %>/status"><%= t(:status_link) %></a></p>
|
||||
<p>© <%= Date.today.year %> <a href="https://asxp.io">asxp.io</a> · <a href="<%= BingoWeb::SOURCE_URL %>" rel="noopener"><%= t(:source_code) %></a></p>
|
||||
</footer>
|
||||
|
|
|
|||
3
views/_status_bars.erb
Normal file
3
views/_status_bars.erb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<div class="bars" role="img" aria-label="<%= t(:status_cols)['hours'] %>">
|
||||
<% history.each_with_index do |a, i| %><i class="<%= avail_class(a) %>" title="<%= format(t(:status_hour), h: history.size - i, p: a ? "#{(a * 100).round}%" : t(:status_states)['unknown']) %>"></i><% end %>
|
||||
</div>
|
||||
|
|
@ -15,6 +15,13 @@
|
|||
<%== erb :_dc_meta, layout: false, locals: { dc: dc } %>
|
||||
<p class="facts"><%= dc_facts(dc).join(' ') %></p>
|
||||
<% dc.about(@locale).each do |para| %><p class="facts"><%= para %></p><% end %>
|
||||
<% if @status %>
|
||||
<section>
|
||||
<h2><%= t(:status_dc) %></h2>
|
||||
<p><span class="st <%= @status.state %>"><%= t(:status_states)[@status.state] %></span> · <a href="/<%= @locale %>/status"><%= t(:status_link) %></a></p>
|
||||
<%== erb :_status_bars, layout: false, locals: { history: @status.history } %>
|
||||
</section>
|
||||
<% end %>
|
||||
<section>
|
||||
<h2><%= t(:incidents) %></h2>
|
||||
<%== erb :_dc_incidents, layout: false, locals: { dc: dc } %>
|
||||
|
|
|
|||
|
|
@ -48,18 +48,18 @@
|
|||
<style>
|
||||
:root {
|
||||
--bg: #f4f1ea; --card: #fffdf8; --ink: #1d1b18; --muted: #6f6a60; --line: #d9d3c6;
|
||||
--hit: #c8361d; --hit-bg: #fbe3dc; --win: #e7b416; --on-win: #1d1b18;
|
||||
--hit: #c8361d; --hit-bg: #fbe3dc; --win: #e7b416; --on-win: #1d1b18; --up: #2f7d32;
|
||||
--shade: rgb(0 0 0 / .62); --on-photo: #fff; --on-photo-muted: rgb(255 255 255 / .75);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #121212; --card: #1c1b19; --ink: #ece7dc; --muted: #9a9384; --line: #34312b;
|
||||
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a;
|
||||
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a; --up: #5cc066;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #121212; --card: #1c1b19; --ink: #ece7dc; --muted: #9a9384; --line: #34312b;
|
||||
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a;
|
||||
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a; --up: #5cc066;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
|
|
@ -149,6 +149,25 @@
|
|||
.dc-page ul { padding-left: 18px; }
|
||||
.pager { display: flex; justify-content: space-between; gap: 12px; margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
dialog button { font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; }
|
||||
/* Reachability (status page and datacenter pages) */
|
||||
.st { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.st::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--line); flex: none; }
|
||||
.st.up::before { background: var(--up); }
|
||||
.st.degraded::before { background: var(--win); }
|
||||
.st.down::before { background: var(--hit); }
|
||||
.st.down { color: var(--hit); font-weight: 600; }
|
||||
.status-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.status-table th { text-align: left; font-weight: 600; color: var(--muted); font-size: 12px; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--line); }
|
||||
.status-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
.status-table .num { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
.status-table .city { display: block; color: var(--muted); font-size: 12px; }
|
||||
.bars { display: grid; grid-template-columns: repeat(24, 1fr); gap: 2px; min-width: 120px; height: 18px; }
|
||||
.bars i { border-radius: 2px; background: var(--line); }
|
||||
.bars i.up { background: var(--up); }
|
||||
.bars i.degraded { background: var(--win); }
|
||||
.bars i.down { background: var(--hit); }
|
||||
.status-banner { padding: 10px 14px; border-radius: 8px; background: var(--hit-bg); margin: 0 0 16px; max-width: 65ch; }
|
||||
.unmonitored { color: var(--muted); font-size: 13px; max-width: 75ch; }
|
||||
footer { margin-top: 32px; color: var(--muted); font-size: 13px; }
|
||||
footer p { margin: 0 0 4px; }
|
||||
@media (max-width: 560px) {
|
||||
|
|
@ -156,6 +175,7 @@
|
|||
.cell .sub, .cell .n, .cell .tag { display: none; }
|
||||
.letters, .grid { gap: 4px; }
|
||||
.log li { grid-template-columns: 84px 24px 1fr; }
|
||||
.status-table .hide-sm { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
|
|||
62
views/status.erb
Normal file
62
views/status.erb
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<% r = @report %>
|
||||
<main>
|
||||
<header>
|
||||
<h1><%= t(:status_h1) %> <span><%= t(:status_accent) %></span></h1>
|
||||
<nav class="langs">
|
||||
<% Bingo::LOCALES.each do |l| %>
|
||||
<a href="<%= @path_for.(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a>
|
||||
<% end %>
|
||||
</nav>
|
||||
</header>
|
||||
<nav class="crumbs"><a href="/<%= @locale %>"><%= t(:back) %></a></nav>
|
||||
|
||||
<p class="lead"><%= t(:status_lead) %></p>
|
||||
<p class="lead"><%= t(:status_note) %></p>
|
||||
|
||||
<% if r.nil? || r.checked_at.nil? %>
|
||||
<p class="empty"><%= t(:status_never) %></p>
|
||||
<% else %>
|
||||
<% if r.state == 'stale' %><p class="status-banner"><%= t(:status_stale) %></p><% end %>
|
||||
<% if r.state == 'vantage' %><p class="status-banner"><%= t(:status_vantage) %></p><% end %>
|
||||
<p class="meta">
|
||||
<%= format(t(:status_checked), time: r.checked_at.strftime('%d.%m.%Y %H:%M')) %>
|
||||
<% if r.controls && r.controls[1].positive? %><%= format(t(:status_controls), ok: r.controls[0], n: r.controls[1]) %><% end %>
|
||||
<% c = r.counts %>
|
||||
<% %w[up degraded down unknown].each do |s| %><% if c[s] %> <span class="st <%= s %>"><%= t(:status_states)[s] %>: <%= c[s] %></span><% end %><% end %>
|
||||
</p>
|
||||
|
||||
<table class="status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th><%= t(:status_cols)['dc'] %></th><th><%= t(:status_cols)['state'] %></th>
|
||||
<th><%= t(:status_cols)['hours'] %></th><th class="num hide-sm"><%= t(:status_cols)['rtt'] %></th>
|
||||
<th class="num hide-sm"><%= t(:status_cols)['targets'] %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% r.sites.each do |s| %>
|
||||
<tr>
|
||||
<td class="num"><%= s.dc.position %></td>
|
||||
<td><a href="<%= dc_path(s.dc) %>"><%= s.dc.l(:name, @locale) %></a><span class="city"><%= s.dc.l(:city, @locale) %> · <%= s.dc.operator %></span></td>
|
||||
<td><span class="st <%= s.state %>"><%= t(:status_states)[s.state] %></span></td>
|
||||
<td><%== erb :_status_bars, layout: false, locals: { history: s.history } %></td>
|
||||
<td class="num hide-sm"><%= s.rtt_ms %></td>
|
||||
<td class="num hide-sm"><%= s.targets %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
|
||||
<% if r && r.unmonitored.any? %>
|
||||
<section>
|
||||
<h2><%= t(:status_unmonitored) %> (<%= r.unmonitored.size %>)</h2>
|
||||
<p class="unmonitored"><%= t(:status_unmonitored_note) %></p>
|
||||
<p class="unmonitored">
|
||||
<% r.unmonitored.each_with_index do |dc, i| %><%= ', ' if i.positive? %><a href="<%= dc_path(dc) %>"><%= dc.l(:name, @locale) %></a><% end %>
|
||||
</p>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%== erb :_footer, layout: false %>
|
||||
</main>
|
||||
Loading…
Add table
Add a link
Reference in a new issue