Rank by operational sites; show what each status check probes
All checks were successful
Build and Deploy to Production / test (push) Successful in 16s
Build and Deploy to Production / build (push) Successful in 47s
Build and Deploy to Production / deploy (push) Successful in 32s

The datacenter text said "No. 33 by power" for a site that is 25th
on the default card, because it counted construction projects. Rank
among operational datacenters only, matching the card, and leave the
rank out for sites not yet in service. data.json and status.json gain
an operational_rank next to the list position.

The status page now lists, under each site, the hosts it probes with
their label, last result and the source that ties them to the site,
folded away behind a click; the control hosts in Russia get the same
list, and datacenter pages show it under their reachability block.
status.json carries the same targets.

For search: a keyword title tagline and meta description, WebPage and
Dataset structured data (pointing at status.json), x-default, and the
status pages in the sitemap.

Assisted-by: Claude:opus-5-5
This commit is contained in:
Sergei Poljanski 2026-10-18 11:12:47 +04:00
commit 5e36cc6a95
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
8 changed files with 118 additions and 28 deletions

52
app.rb
View file

@ -121,7 +121,7 @@ class BingoWeb < Sinatra::Base
[format(t(:dc_intro), name: dc.l(:name, @locale), city: dc.l(:city, @locale), operator: dc.operator), [format(t(:dc_intro), name: dc.l(:name, @locale), city: dc.l(:city, @locale), operator: dc.operator),
(format(t(:dc_capacity), mw: mw_prose(dc)) if mw(dc)), (format(t(:dc_capacity), mw: mw_prose(dc)) if mw(dc)),
{ 'building' => t(:dc_building), 'planned' => t(:dc_planned) }[dc.status] || (dc.year && format(t(:dc_since), dc.year)), { 'building' => t(:dc_building), 'planned' => t(:dc_planned) }[dc.status] || (dc.year && format(t(:dc_since), dc.year)),
format(t(:dc_rank), pos: dc.position)].compact (format(t(:dc_rank), pos: op_rank(dc)) if op_rank(dc))].compact
end end
def dc_latest(dc) def dc_latest(dc)
@ -181,6 +181,41 @@ class BingoWeb < Sinatra::Base
def feed_time(i) = Time.utc(i.occurred_on.year, i.occurred_on.month, i.occurred_on.day) def feed_time(i) = Time.utc(i.occurred_on.year, i.occurred_on.month, i.occurred_on.day)
# Rank by power among operational datacenters only, the same order as the
# default card; nil for sites not yet in service.
def op_rank(dc)
@op_ranks ||= Bingo::Datacenter.where(status: 'operational').order(:position, :id).select_map(:id)
.each_with_index.to_h { |id, i| [id, i + 1] }
@op_ranks[dc.id]
end
def probe_json(t)
{ host: t[:host], port: t[:port], label: t[:label], source_url: t[:source_url],
answered: t[:last_ok], rtt_ms: t[:last_rtt_ms] }
end
# WebPage + Dataset (status.json), so the page reads as live open data.
def status_ld(report)
url = "#{base_url}/#{@locale}/status"
JSON.generate(
'@context' => 'https://schema.org',
'@graph' => [
{ '@type' => 'WebPage', '@id' => url, 'url' => url, 'name' => t(:status_title), 'description' => @desc,
'inLanguage' => @locale, 'dateModified' => report&.checked_at&.utc&.iso8601,
'isPartOf' => { '@id' => "#{base_url}/#website" },
'breadcrumb' => { '@type' => 'BreadcrumbList', 'itemListElement' => [
{ '@type' => 'ListItem', 'position' => 1, 'name' => t(:title), 'item' => "#{base_url}/#{@locale}" },
{ '@type' => 'ListItem', 'position' => 2, 'name' => t(:status_link), 'item' => url }
] } }.compact,
{ '@type' => 'Dataset', 'name' => t(:status_link), 'description' => t(:status_lead), 'url' => url,
'license' => LICENSE_URL, 'isAccessibleForFree' => true, 'temporalCoverage' => 'P1D',
'creator' => { '@type' => 'Person', 'name' => 'asxpi', 'url' => 'https://asxp.io' },
'distribution' => [{ '@type' => 'DataDownload', 'encodingFormat' => 'application/json',
'contentUrl' => "#{base_url}/status.json" }] }
]
).gsub('</', '<\/')
end
# Bar colour for an answered share (nil = no data). # Bar colour for an answered share (nil = no data).
def avail_class(a) def avail_class(a)
return '' if a.nil? return '' if a.nil?
@ -193,9 +228,11 @@ class BingoWeb < Sinatra::Base
{ checked_at: report&.checked_at&.utc&.iso8601, monitor: report&.state, { checked_at: report&.checked_at&.utc&.iso8601, monitor: report&.state,
controls: report&.controls && { ok: report.controls[0], checked: report.controls[1] }, controls: report&.controls && { ok: report.controls[0], checked: report.controls[1] },
datacenters: (report&.sites || []).map do |s| datacenters: (report&.sites || []).map do |s|
{ slug: s.dc.slug, rank: s.dc.position, state: s.state, availability: s.availability&.round(3), { slug: s.dc.slug, rank: s.dc.position, operational_rank: op_rank(s.dc), state: s.state,
rtt_ms: s.rtt_ms, targets: s.targets, hourly: s.history.map { |a| a&.round(3) } } availability: s.availability&.round(3), rtt_ms: s.rtt_ms, hourly: s.history.map { |a| a&.round(3) },
targets: s.targets.map { |t| probe_json(t) } }
end, end,
control_hosts: (report&.control_hosts || []).map { |t| probe_json(t) },
unmonitored: (report&.unmonitored || []).map(&:slug) } unmonitored: (report&.unmonitored || []).map(&:slug) }
end end
@ -231,7 +268,8 @@ class BingoWeb < Sinatra::Base
content_type 'application/xml' content_type 'application/xml'
cache_control :public, max_age: 3600 cache_control :public, max_age: 3600
dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all
@entries = [[->(l) { "/#{l}" }, Bingo::Board.load.updated_on, '/']] + @entries = [[->(l) { "/#{l}" }, Bingo::Board.load.updated_on, '/'],
[->(l) { "/#{l}/status" }, nil, '/en/status']] +
dcs.map { |dc| [->(l) { dc_path(dc, l) }, [dc.updated_at, *dc.incidents.map(&:updated_at)].compact.max] } dcs.map { |dc| [->(l) { dc_path(dc, l) }, [dc.updated_at, *dc.incidents.map(&:updated_at)].compact.max] }
erb :sitemap, layout: false erb :sitemap, layout: false
end end
@ -280,8 +318,10 @@ class BingoWeb < Sinatra::Base
@locale = locale @locale = locale
@report = status_report @report = status_report
@title = t(:status_title) @title = t(:status_title)
@desc = snippet(t(:status_lead)) @desc = t(:status_desc)
@path_for = ->(l) { "/#{l}/status" } @path_for = ->(l) { "/#{l}/status" }
@x_default = '/en/status'
@ld = status_ld(@report)
cache_control :public, max_age: 60 cache_control :public, max_age: 60
erb :status erb :status
end end
@ -345,7 +385,7 @@ class BingoWeb < Sinatra::Base
'part of the list from the hackyourmom datacenter map (data by Cat Eyes OSINT), re-verified.', 'part of the list from the hackyourmom datacenter map (data by Cat Eyes OSINT), re-verified.',
updated_at: Bingo::Board.load.updated_on&.utc&.iso8601, updated_at: Bingo::Board.load.updated_on&.utc&.iso8601,
datacenters: dcs.map do |dc| datacenters: dcs.map do |dc|
{ slug: dc.slug, rank: dc.position, name: loc.(dc, :name), city: loc.(dc, :city), { slug: dc.slug, rank: dc.position, operational_rank: op_rank(dc), name: loc.(dc, :name), city: loc.(dc, :city),
operator: dc.operator, provider: dc.provider, status: dc.status, operator: dc.operator, provider: dc.provider, status: dc.status,
mw: dc.mw, mw_value: Bingo.mw_value(dc.mw), mw_type: dc.mw_type, year: dc.year, mw: dc.mw, mw_value: Bingo.mw_value(dc.mw), mw_type: dc.mw_type, year: dc.year,
info_url: dc.info_url, info_url: dc.info_url,

View file

@ -70,11 +70,11 @@ module Bingo
dc_intro: '%{name} — дата-центр в городе %{city}, оператор — %{operator}.', dc_intro: '%{name} — дата-центр в городе %{city}, оператор — %{operator}.',
dc_capacity: 'Мощность: %{mw}.', dc_building: 'Строится.', dc_planned: 'Планируется.', dc_capacity: 'Мощность: %{mw}.', dc_building: 'Строится.', dc_planned: 'Планируется.',
dc_since: 'Работает с %d года.', dc_since: 'Работает с %d года.',
dc_rank: '№ %{pos} по мощности среди крупнейших дата-центров России.', dc_rank: '№ %{pos} по мощности среди действующих крупнейших дата-центров России.',
dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты', dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты',
back: '← К карточке', dc_page: 'Страница ЦОДа →', back: '← К карточке', dc_page: 'Страница ЦОДа →',
status_link: 'Доступность ЦОДов', status_title: 'Доступность ЦОДов России — сетевой мониторинг | Бинго ЦОДов', status_link: 'Доступность ЦОДов', status_title: 'Доступность ЦОДов России — сетевой мониторинг | Бинго ЦОДов',
status_h1: 'Доступность', status_accent: 'ЦОДов', status_h1: 'Доступность', status_accent: 'ЦОДов', status_tagline: 'Сетевые проверки крупнейших дата-центров России раз в минуту',
status_lead: 'Раз в минуту мы открываем TCP-соединение с публичными адресами, которые открытые источники связывают с конкретной площадкой. Проверка идёт из Финляндии: российская фильтрация и отключения внешних каналов могут выглядеть как авария, поэтому параллельно проверяются контрольные узлы в России.', status_lead: 'Раз в минуту мы открываем TCP-соединение с публичными адресами, которые открытые источники связывают с конкретной площадкой. Проверка идёт из Финляндии: российская фильтрация и отключения внешних каналов могут выглядеть как авария, поэтому параллельно проверяются контрольные узлы в России.',
status_note: 'Это не подтверждённые инциденты: клетка на карточке зачёркивается только по публичному источнику.', status_note: 'Это не подтверждённые инциденты: клетка на карточке зачёркивается только по публичному источнику.',
status_states: { 'up' => 'доступен', 'degraded' => 'частично', 'down' => 'не отвечает', 'unknown' => 'нет данных' }, status_states: { 'up' => 'доступен', 'degraded' => 'частично', 'down' => 'не отвечает', 'unknown' => 'нет данных' },
@ -84,7 +84,9 @@ module Bingo
status_checked: 'Последняя проверка: %{time} UTC.', status_never: 'Проверок ещё не было.', status_checked: 'Последняя проверка: %{time} UTC.', status_never: 'Проверок ещё не было.',
status_unmonitored: 'Без мониторинга', status_unmonitored_note: 'Действующие ЦОДы, для которых нет публичных адресов, надёжно привязанных к площадке (обычно собственные ЦОДы Яндекса, VK, Сбера).', status_unmonitored: 'Без мониторинга', status_unmonitored_note: 'Действующие ЦОДы, для которых нет публичных адресов, надёжно привязанных к площадке (обычно собственные ЦОДы Яндекса, VK, Сбера).',
status_cols: { 'dc' => 'ЦОД', 'state' => 'Сейчас', 'hours' => '24 часа', 'rtt' => 'мс', 'targets' => 'адр.' }, status_cols: { 'dc' => 'ЦОД', 'state' => 'Сейчас', 'hours' => '24 часа', 'rtt' => 'мс', 'targets' => 'адр.' },
status_dc: 'Доступность сети', status_hour: '%{h} ч назад: %{p}' status_dc: 'Доступность сети', status_hour: '%{h} ч назад: %{p}',
status_desc: 'Доступность крупнейших дата-центров России онлайн: ежеминутные сетевые проверки IXcellerate, Selectel, DataPro, Key Point и других, история за 24 часа и открытые данные.',
status_probes: 'Что проверяем', status_controls_list: 'Контрольные узлы в России', status_no_answer: 'не отвечает', status_source: 'почему этот адрес'
}, },
'en' => { 'en' => {
seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters', seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters',
@ -114,11 +116,11 @@ module Bingo
dc_intro: '%{name} is a datacenter in %{city} operated by %{operator}.', dc_intro: '%{name} is a datacenter in %{city} operated by %{operator}.',
dc_capacity: 'Capacity: %{mw}.', dc_building: 'Under construction.', dc_planned: 'Planned.', dc_capacity: 'Capacity: %{mw}.', dc_building: 'Under construction.', dc_planned: 'Planned.',
dc_since: 'In service since %d.', dc_since: 'In service since %d.',
dc_rank: "No. %{pos} by power among Russia's largest datacenters.", dc_rank: "No. %{pos} by power among Russia's largest operational datacenters.",
dc_latest: 'Latest reported incident (%{date}): %{title}', incidents: 'Incidents', 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_link: 'Datacenter reachability', status_title: "Russian datacenter reachability — network monitor | Datacenter Bingo",
status_h1: 'Datacenter', status_accent: 'reachability', status_h1: 'Datacenter', status_accent: 'reachability', status_tagline: "Minute-by-minute network checks of Russia's largest datacenters",
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_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_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_states: { 'up' => 'reachable', 'degraded' => 'partial', 'down' => 'not answering', 'unknown' => 'no data' },
@ -128,7 +130,9 @@ module Bingo
status_checked: 'Last check: %{time} UTC.', status_never: 'No checks yet.', 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_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_cols: { 'dc' => 'Datacenter', 'state' => 'Now', 'hours' => '24 hours', 'rtt' => 'ms', 'targets' => 'addr.' },
status_dc: 'Network reachability', status_hour: '%{h} h ago: %{p}' status_dc: 'Network reachability', status_hour: '%{h} h ago: %{p}',
status_desc: "Live reachability of Russia's largest datacenters: minute-by-minute network checks of IXcellerate, Selectel, DataPro, Key Point and others, 24-hour history and open data.",
status_probes: 'What we check', status_controls_list: 'Control hosts in Russia', status_no_answer: 'no answer', status_source: 'why this address'
}, },
'uk' => { 'uk' => {
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії', seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
@ -158,11 +162,11 @@ module Bingo
dc_intro: '%{name} — дата-центр у місті %{city}, оператор — %{operator}.', dc_intro: '%{name} — дата-центр у місті %{city}, оператор — %{operator}.',
dc_capacity: 'Потужність: %{mw}.', dc_building: 'Будується.', dc_planned: 'Планується.', dc_capacity: 'Потужність: %{mw}.', dc_building: 'Будується.', dc_planned: 'Планується.',
dc_since: 'Працює з %d року.', dc_since: 'Працює з %d року.',
dc_rank: '№ %{pos} за потужністю серед найбільших дата-центрів Росії.', dc_rank: '№ %{pos} за потужністю серед найбільших діючих дата-центрів Росії.',
dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти', dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти',
back: '← До картки', dc_page: 'Сторінка ЦОДу →', back: '← До картки', dc_page: 'Сторінка ЦОДу →',
status_link: 'Доступність ЦОДів', status_title: 'Доступність ЦОДів Росії — мережевий моніторинг | Бінго ЦОДів', status_link: 'Доступність ЦОДів', status_title: 'Доступність ЦОДів Росії — мережевий моніторинг | Бінго ЦОДів',
status_h1: 'Доступність', status_accent: 'ЦОДів', status_h1: 'Доступність', status_accent: 'ЦОДів', status_tagline: 'Мережеві перевірки найбільших дата-центрів Росії щохвилини',
status_lead: 'Раз на хвилину ми відкриваємо TCP-з’єднання з публічними адресами, які відкриті джерела пов’язують із конкретним майданчиком. Перевірка йде з Фінляндії: російська фільтрація та відключення зовнішніх каналів можуть виглядати як аварія, тому паралельно перевіряються контрольні вузли в Росії.', status_lead: 'Раз на хвилину ми відкриваємо TCP-з’єднання з публічними адресами, які відкриті джерела пов’язують із конкретним майданчиком. Перевірка йде з Фінляндії: російська фільтрація та відключення зовнішніх каналів можуть виглядати як аварія, тому паралельно перевіряються контрольні вузли в Росії.',
status_note: 'Це не підтверджені інциденти: клітинка на картці закреслюється лише за публічним джерелом.', status_note: 'Це не підтверджені інциденти: клітинка на картці закреслюється лише за публічним джерелом.',
status_states: { 'up' => 'доступний', 'degraded' => 'частково', 'down' => 'не відповідає', 'unknown' => 'немає даних' }, status_states: { 'up' => 'доступний', 'degraded' => 'частково', 'down' => 'не відповідає', 'unknown' => 'немає даних' },
@ -172,7 +176,9 @@ module Bingo
status_checked: 'Остання перевірка: %{time} UTC.', status_never: 'Перевірок ще не було.', status_checked: 'Остання перевірка: %{time} UTC.', status_never: 'Перевірок ще не було.',
status_unmonitored: 'Без моніторингу', status_unmonitored_note: 'Діючі ЦОДи, для яких немає публічних адрес, надійно прив’язаних до майданчика (здебільшого власні ЦОДи Яндекса, VK, Сбера).', status_unmonitored: 'Без моніторингу', status_unmonitored_note: 'Діючі ЦОДи, для яких немає публічних адрес, надійно прив’язаних до майданчика (здебільшого власні ЦОДи Яндекса, VK, Сбера).',
status_cols: { 'dc' => 'ЦОД', 'state' => 'Зараз', 'hours' => '24 години', 'rtt' => 'мс', 'targets' => 'адр.' }, status_cols: { 'dc' => 'ЦОД', 'state' => 'Зараз', 'hours' => '24 години', 'rtt' => 'мс', 'targets' => 'адр.' },
status_dc: 'Доступність мережі', status_hour: '%{h} год тому: %{p}' status_dc: 'Доступність мережі', status_hour: '%{h} год тому: %{p}',
status_desc: 'Доступність найбільших дата-центрів Росії онлайн: щохвилинні мережеві перевірки IXcellerate, Selectel, DataPro, Key Point та інших, історія за 24 години та відкриті дані.',
status_probes: 'Що перевіряємо', status_controls_list: 'Контрольні вузли в Росії', status_no_answer: 'не відповідає', status_source: 'чому ця адреса'
} }
}.freeze }.freeze
@ -313,8 +319,10 @@ module Bingo
STALE = 900 # newest worker bucket older than this: nothing is current STALE = 900 # newest worker bucket older than this: nothing is current
HOURS = 24 HOURS = 24
# targets / control_hosts: the probed hosts (endpoint, label, source and
# last result), shown so readers can see and check what is measured.
Site = Struct.new(:dc, :state, :availability, :rtt_ms, :history, :targets) Site = Struct.new(:dc, :state, :availability, :rtt_ms, :history, :targets)
Report = Struct.new(:sites, :unmonitored, :checked_at, :controls, :state) do Report = Struct.new(:sites, :unmonitored, :checked_at, :controls, :state, :control_hosts) do
def counts = sites.map(&:state).tally def counts = sites.map(&:state).tally
end end
@ -355,11 +363,14 @@ module Bingo
bad_hours = buckets.reject { |b| vantage_ok?(b) }.map { |b| hour_index(b[:bucket_at], now, hours) }.to_set 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 } samples = db[:bingo_monitor_samples].where { bucket_at >= since }
targets = db[:bingo_monitor_targets].where(active: true).exclude(datacenter_id: nil) targets = db[:bingo_monitor_targets].where(active: true)
.select(:datacenter_id, :host, :port, :label, :source_url, :last_ok, :last_rtt_ms)
.order(:host, :port)
dcs = Datacenter.order(:position, :id) 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, targets, dcs = [samples.where(datacenter_id: only), targets.where(datacenter_id: [only, nil]), dcs.where(id: only)] if only
samples = samples.all.group_by { |r| r[:datacenter_id] } samples = samples.all.group_by { |r| r[:datacenter_id] }
targets = targets.group_and_count(:datacenter_id).to_hash(:datacenter_id, :count) targets = targets.all.group_by { |r| r[:datacenter_id] }
controls = targets.delete(nil) || []
dcs = dcs.all dcs = dcs.all
monitored, unmonitored = dcs.partition { |dc| targets.key?(dc.id) } monitored, unmonitored = dcs.partition { |dc| targets.key?(dc.id) }
@ -373,7 +384,7 @@ module Bingo
end end
Report.new(sites, unmonitored.select(&:operational?), latest && latest[:bucket_at], Report.new(sites, unmonitored.select(&:operational?), latest && latest[:bucket_at],
latest && [latest[:control_ok], latest[:control_checks]], latest && [latest[:control_ok], latest[:control_checks]],
!fresh ? 'stale' : (trusted ? 'ok' : 'vantage')) !fresh ? 'stale' : (trusted ? 'ok' : 'vantage'), controls)
end end
def hour_index(time, now, hours) = hours - 1 - ((now - time) / 3600).floor def hour_index(time, now, hours) = hours - 1 - ((now - time) / 3600).floor

View file

@ -160,7 +160,8 @@ class BingoTest < Minitest::Test
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}</loc>" } %w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}</loc>" }
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}/dc/dc-25</loc>" } %w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}/dc/dc-25</loc>" }
assert_includes last_response.body, '<xhtml:link rel="alternate" hreflang="uk" href="https://example.org/uk/dc/dc-7"/>' assert_includes last_response.body, '<xhtml:link rel="alternate" hreflang="uk" href="https://example.org/uk/dc/dc-7"/>'
assert_equal 3, last_response.body.scan('hreflang="x-default"').size # card URLs only %w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}/status</loc>" }
assert_equal 6, last_response.body.scan('hreflang="x-default"').size # card and status URLs only
assert_includes last_response.body, 'hreflang="x-default"' assert_includes last_response.body, 'hreflang="x-default"'
refute_includes last_response.body, 'changefreq' refute_includes last_response.body, 'changefreq'
assert_match %r{</loc>\n <lastmod>\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ</lastmod>\n <xhtml:link}, last_response.body assert_match %r{</loc>\n <lastmod>\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ</lastmod>\n <xhtml:link}, last_response.body

View file

@ -16,7 +16,10 @@ class StatusTest < Minitest::Test
Bingo::Datacenter.create(slug: "dc-#{s}", position: i + 1, name_ru: "ЦОД #{s}", city_ru: 'Москва', Bingo::Datacenter.create(slug: "dc-#{s}", position: i + 1, name_ru: "ЦОД #{s}", city_ru: 'Москва',
operator: 'Op', status: 'operational') operator: 'Op', status: 'operational')
end end
[@up, @down, @part].each { |dc| @db[:bingo_monitor_targets].insert(datacenter_id: dc.id, host: "h-#{dc.slug}", port: 443, created_at: B) } [@up, @down, @part].each do |dc|
@db[:bingo_monitor_targets].insert(datacenter_id: dc.id, host: "h-#{dc.slug}", port: 443, label: "probe #{dc.slug}",
source_url: 'https://example.com/src', last_ok: true, last_rtt_ms: 40, created_at: B)
end
@db[:bingo_monitor_targets].insert(datacenter_id: nil, host: 'ctl', port: 80, created_at: B) @db[:bingo_monitor_targets].insert(datacenter_id: nil, host: 'ctl', port: 80, created_at: B)
end end
@ -86,12 +89,26 @@ class StatusTest < Minitest::Test
data = JSON.parse(last_response.body) data = JSON.parse(last_response.body)
assert_equal %w[dc-up dc-down dc-part], data['datacenters'].map { |d| d['slug'] } assert_equal %w[dc-up dc-down dc-part], data['datacenters'].map { |d| d['slug'] }
assert_equal ['dc-none'], data['unmonitored'] assert_equal ['dc-none'], data['unmonitored']
assert_equal({ 'host' => 'h-dc-up', 'port' => 443, 'label' => 'probe dc-up', 'source_url' => 'https://example.com/src',
'answered' => true, 'rtt_ms' => 40 }, data['datacenters'][0]['targets'][0])
assert_equal ['ctl'], data['control_hosts'].map { |t| t['host'] }
get '/ru/status'
assert_includes last_response.body, 'h-dc-part:443'
assert_includes last_response.body, '"@type":"Dataset"'
get '/en/dc/dc-up' get '/en/dc/dc-up'
assert_includes last_response.body, 'Network reachability' assert_includes last_response.body, 'Network reachability'
get '/en/dc/dc-none' get '/en/dc/dc-none'
refute_includes last_response.body, 'Network reachability' refute_includes last_response.body, 'Network reachability'
end end
def test_rank_counts_operational_sites_only
@part.update(status: 'building') # position 3 is not in service
get '/en/dc/dc-none' # position 4
assert_includes last_response.body, 'No. 3 by power among'
get '/en/dc/dc-part'
refute_includes last_response.body, 'by power among'
end
def test_status_page_before_any_round def test_status_page_before_any_round
get '/en/status' get '/en/status'
assert last_response.ok? assert last_response.ok?

13
views/_probes.erb Normal file
View file

@ -0,0 +1,13 @@
<details class="probes">
<summary><%= title %> (<%= probes.size %>)</summary>
<ul>
<% probes.each do |p| %>
<li>
<span class="st <%= { true => 'up', false => 'down', nil => 'unknown' }[p[:last_ok]] %>"></span>
<code><%= p[:host] %>:<%= p[:port] %></code><% if p[:label] %> — <%= p[:label] %><% end %>
<% if p[:last_ok] %> · <%= p[:last_rtt_ms] %> <%= t(:status_cols)['rtt'] %><% elsif p[:last_ok] == false %> · <%= t(:status_no_answer) %><% end %>
<% if p[:source_url] %> · <a href="<%= p[:source_url] %>" target="_blank" rel="nofollow noopener"><%= t(:status_source) %></a><% end %>
</li>
<% end %>
</ul>
</details>

View file

@ -20,6 +20,7 @@
<h2><%= t(:status_dc) %></h2> <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> <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 } %> <%== erb :_status_bars, layout: false, locals: { history: @status.history } %>
<%== erb :_probes, layout: false, locals: { probes: @status.targets, title: t(:status_probes) } %>
</section> </section>
<% end %> <% end %>
<section> <section>
@ -29,8 +30,8 @@
</article> </article>
<nav class="pager"> <nav class="pager">
<% if @prev %><a href="<%= dc_path(@prev) %>" rel="prev">← #<%= @prev.position %> <%= @prev.l(:name, @locale) %></a><% else %><span></span><% end %> <% if @prev %><a href="<%= dc_path(@prev) %>" rel="prev">← <%= "##{op_rank(@prev)} " if op_rank(@prev) %><%= @prev.l(:name, @locale) %></a><% else %><span></span><% end %>
<% if @next %><a href="<%= dc_path(@next) %>" rel="next">#<%= @next.position %> <%= @next.l(:name, @locale) %> →</a><% end %> <% if @next %><a href="<%= dc_path(@next) %>" rel="next"><%= "##{op_rank(@next)} " if op_rank(@next) %><%= @next.l(:name, @locale) %> →</a><% end %>
</nav> </nav>
<p><a href="/<%= @locale %>"><%= t(:back) %></a></p> <p><a href="/<%= @locale %>"><%= t(:back) %></a></p>

View file

@ -168,6 +168,13 @@
.bars i.down { background: var(--hit); } .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; } .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; } .unmonitored { color: var(--muted); font-size: 13px; max-width: 75ch; }
.probes { font-size: 13px; color: var(--muted); margin: 4px 0 8px; }
.probes summary { cursor: pointer; width: max-content; }
.probes ul { list-style: none; margin: 6px 0 0; padding: 0; }
.probes li { margin: 2px 0; overflow-wrap: anywhere; }
.probes code { color: var(--ink); }
.status-table .probe-row td { border-top: 0; padding-top: 0; }
.status-table tr:has(+ .probe-row) td { border-bottom: 0; padding-bottom: 2px; }
footer { margin-top: 32px; color: var(--muted); font-size: 13px; } footer { margin-top: 32px; color: var(--muted); font-size: 13px; }
footer p { margin: 0 0 4px; } footer p { margin: 0 0 4px; }
@media (max-width: 560px) { @media (max-width: 560px) {

View file

@ -1,7 +1,7 @@
<% r = @report %> <% r = @report %>
<main> <main>
<header> <header>
<h1><%= t(:status_h1) %> <span><%= t(:status_accent) %></span></h1> <h1><%= t(:status_h1) %> <span><%= t(:status_accent) %></span> <small><%= t(:status_tagline) %></small></h1>
<nav class="langs"> <nav class="langs">
<% Bingo::LOCALES.each do |l| %> <% Bingo::LOCALES.each do |l| %>
<a href="<%= @path_for.(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a> <a href="<%= @path_for.(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a>
@ -24,25 +24,25 @@
<% c = r.counts %> <% 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 %> <% %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> </p>
<% if r.control_hosts.any? %><%== erb :_probes, layout: false, locals: { probes: r.control_hosts, title: t(:status_controls_list) } %><% end %>
<table class="status-table"> <table class="status-table">
<thead> <thead>
<tr> <tr>
<th>#</th><th><%= t(:status_cols)['dc'] %></th><th><%= t(:status_cols)['state'] %></th> <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><%= 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> </tr>
</thead> </thead>
<tbody> <tbody>
<% r.sites.each do |s| %> <% r.sites.each do |s| %>
<tr> <tr>
<td class="num"><%= s.dc.position %></td> <td class="num"><%= op_rank(s.dc) %></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><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><span class="st <%= s.state %>"><%= t(:status_states)[s.state] %></span></td>
<td><%== erb :_status_bars, layout: false, locals: { history: s.history } %></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.rtt_ms %></td>
<td class="num hide-sm"><%= s.targets %></td>
</tr> </tr>
<tr class="probe-row"><td></td><td colspan="4"><%== erb :_probes, layout: false, locals: { probes: s.targets, title: t(:status_probes) } %></td></tr>
<% end %> <% end %>
</tbody> </tbody>
</table> </table>