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) %>