From 5e36cc6a95d550d45b32c68a884749899156ecc3 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sun, 18 Oct 2026 11:12:47 +0400 Subject: [PATCH] Rank by operational sites; show what each status check probes 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 --- app.rb | 52 +++++++++++++++++++++++++++++++++++++++------ lib/bingo.rb | 39 ++++++++++++++++++++++------------ test/bingo_test.rb | 3 ++- test/status_test.rb | 19 ++++++++++++++++- views/_probes.erb | 13 ++++++++++++ views/dc.erb | 5 +++-- views/layout.erb | 7 ++++++ views/status.erb | 8 +++---- 8 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 views/_probes.erb diff --git a/app.rb b/app.rb index 6d18197..8afc537 100644 --- a/app.rb +++ b/app.rb @@ -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_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)), - format(t(:dc_rank), pos: dc.position)].compact + (format(t(:dc_rank), pos: op_rank(dc)) if op_rank(dc))].compact end 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) + # 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('(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] } erb :sitemap, layout: false end @@ -280,8 +318,10 @@ class BingoWeb < Sinatra::Base @locale = locale @report = status_report @title = t(:status_title) - @desc = snippet(t(:status_lead)) + @desc = t(:status_desc) @path_for = ->(l) { "/#{l}/status" } + @x_default = '/en/status' + @ld = status_ld(@report) cache_control :public, max_age: 60 erb :status 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.', updated_at: Bingo::Board.load.updated_on&.utc&.iso8601, 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, mw: dc.mw, mw_value: Bingo.mw_value(dc.mw), mw_type: dc.mw_type, year: dc.year, info_url: dc.info_url, diff --git a/lib/bingo.rb b/lib/bingo.rb index b359ebe..33fc3d5 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -70,11 +70,11 @@ module Bingo dc_intro: '%{name} — дата-центр в городе %{city}, оператор — %{operator}.', dc_capacity: 'Мощность: %{mw}.', dc_building: 'Строится.', dc_planned: 'Планируется.', dc_since: 'Работает с %d года.', - dc_rank: '№ %{pos} по мощности среди крупнейших дата-центров России.', + dc_rank: '№ %{pos} по мощности среди действующих крупнейших дата-центров России.', dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты', back: '← К карточке', dc_page: 'Страница ЦОДа →', status_link: 'Доступность ЦОДов', status_title: 'Доступность ЦОДов России — сетевой мониторинг | Бинго ЦОДов', - status_h1: 'Доступность', status_accent: 'ЦОДов', + status_h1: 'Доступность', status_accent: 'ЦОДов', status_tagline: 'Сетевые проверки крупнейших дата-центров России раз в минуту', status_lead: 'Раз в минуту мы открываем TCP-соединение с публичными адресами, которые открытые источники связывают с конкретной площадкой. Проверка идёт из Финляндии: российская фильтрация и отключения внешних каналов могут выглядеть как авария, поэтому параллельно проверяются контрольные узлы в России.', status_note: 'Это не подтверждённые инциденты: клетка на карточке зачёркивается только по публичному источнику.', status_states: { 'up' => 'доступен', 'degraded' => 'частично', 'down' => 'не отвечает', 'unknown' => 'нет данных' }, @@ -84,7 +84,9 @@ module Bingo 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}' + 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' => { 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_capacity: 'Capacity: %{mw}.', dc_building: 'Under construction.', dc_planned: 'Planned.', 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', 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_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_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' }, @@ -128,7 +130,9 @@ module Bingo 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}' + 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' => { seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії', @@ -158,11 +162,11 @@ module Bingo dc_intro: '%{name} — дата-центр у місті %{city}, оператор — %{operator}.', dc_capacity: 'Потужність: %{mw}.', dc_building: 'Будується.', dc_planned: 'Планується.', dc_since: 'Працює з %d року.', - dc_rank: '№ %{pos} за потужністю серед найбільших дата-центрів Росії.', + dc_rank: '№ %{pos} за потужністю серед найбільших діючих дата-центрів Росії.', dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти', back: '← До картки', dc_page: 'Сторінка ЦОДу →', status_link: 'Доступність ЦОДів', status_title: 'Доступність ЦОДів Росії — мережевий моніторинг | Бінго ЦОДів', - status_h1: 'Доступність', status_accent: 'ЦОДів', + status_h1: 'Доступність', status_accent: 'ЦОДів', status_tagline: 'Мережеві перевірки найбільших дата-центрів Росії щохвилини', status_lead: 'Раз на хвилину ми відкриваємо TCP-з’єднання з публічними адресами, які відкриті джерела пов’язують із конкретним майданчиком. Перевірка йде з Фінляндії: російська фільтрація та відключення зовнішніх каналів можуть виглядати як аварія, тому паралельно перевіряються контрольні вузли в Росії.', status_note: 'Це не підтверджені інциденти: клітинка на картці закреслюється лише за публічним джерелом.', status_states: { 'up' => 'доступний', 'degraded' => 'частково', 'down' => 'не відповідає', 'unknown' => 'немає даних' }, @@ -172,7 +176,9 @@ module Bingo 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}' + 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 @@ -313,8 +319,10 @@ module Bingo STALE = 900 # newest worker bucket older than this: nothing is current 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) - 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 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 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) - 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] } - 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 monitored, unmonitored = dcs.partition { |dc| targets.key?(dc.id) } @@ -373,7 +384,7 @@ module Bingo end Report.new(sites, unmonitored.select(&:operational?), latest && latest[:bucket_at], latest && [latest[:control_ok], latest[:control_checks]], - !fresh ? 'stale' : (trusted ? 'ok' : 'vantage')) + !fresh ? 'stale' : (trusted ? 'ok' : 'vantage'), controls) end def hour_index(time, now, hours) = hours - 1 - ((now - time) / 3600).floor diff --git a/test/bingo_test.rb b/test/bingo_test.rb index ad18d43..f3cf031 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -160,7 +160,8 @@ class BingoTest < Minitest::Test %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}" } %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}/dc/dc-25" } assert_includes last_response.body, '' - 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, "https://example.org/#{l}/status" } + assert_equal 6, last_response.body.scan('hreflang="x-default"').size # card and status URLs only assert_includes last_response.body, 'hreflang="x-default"' refute_includes last_response.body, 'changefreq' assert_match %r{\n \d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ\n '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' assert_includes last_response.body, 'Network reachability' get '/en/dc/dc-none' refute_includes last_response.body, 'Network reachability' 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 get '/en/status' assert last_response.ok? diff --git a/views/_probes.erb b/views/_probes.erb new file mode 100644 index 0000000..d5147f3 --- /dev/null +++ b/views/_probes.erb @@ -0,0 +1,13 @@ +
+ <%= title %> (<%= probes.size %>) +
    + <% probes.each do |p| %> +
  • + + <%= p[:host] %>:<%= p[:port] %><% 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] %> · <%= t(:status_source) %><% end %> +
  • + <% end %> +
+
diff --git a/views/dc.erb b/views/dc.erb index 4954f32..82b5ea2 100644 --- a/views/dc.erb +++ b/views/dc.erb @@ -20,6 +20,7 @@

<%= t(:status_dc) %>

<%= t(:status_states)[@status.state] %> · <%= t(:status_link) %>

<%== erb :_status_bars, layout: false, locals: { history: @status.history } %> + <%== erb :_probes, layout: false, locals: { probes: @status.targets, title: t(:status_probes) } %> <% end %>
@@ -29,8 +30,8 @@

<%= t(:back) %>

diff --git a/views/layout.erb b/views/layout.erb index cd81049..f2714ea 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -168,6 +168,13 @@ .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; } + .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 p { margin: 0 0 4px; } @media (max-width: 560px) { diff --git a/views/status.erb b/views/status.erb index b4ae444..1c92bd3 100644 --- a/views/status.erb +++ b/views/status.erb @@ -1,7 +1,7 @@ <% r = @report %>
-

<%= t(:status_h1) %> <%= t(:status_accent) %>

+

<%= t(:status_h1) %> <%= t(:status_accent) %> <%= t(:status_tagline) %>