The .xyz registry put the domain on serverHold after SURBL listed it in its ABUSE list. SURBL only delists a site that links to a published policy against advertising it by unsolicited messages, and a privacy policy does not count. Add the policy at /:locale/policy in all three languages, linked from the footer and listed in the sitemap, and the same text in the README so it can be read on GitHub while the domain does not resolve. Reports go to the asxp.io contact form; the site itself has no email. Assisted-by: Claude:opus-5-5
427 lines
32 KiB
Ruby
427 lines
32 KiB
Ruby
require 'sequel'
|
||
require 'json'
|
||
require_relative 's3'
|
||
|
||
# Datacenter bingo: a 5x5 card of the largest Russian datacenters; a cell is
|
||
# crossed out once a publicly sourced incident is recorded for it.
|
||
#
|
||
# Read side only. The tables live in the asxpio database and are written by
|
||
# the asxp.io admin (asxpio repo: lib/bingo.rb, lib/bingo_admin.rb, which also
|
||
# owns the migrations). Keep the models and Board here in step with it.
|
||
module Bingo
|
||
LOCALES = %w[ru en uk].freeze
|
||
KINDS = %w[strike fire outage flood cooling other].freeze
|
||
ICONS = { 'strike' => '💥', 'fire' => '🔥', 'outage' => '⚡', 'flood' => '💧', 'cooling' => '🌡️', 'other' => '⚠️' }.freeze
|
||
SIZE = 5
|
||
STATUSES = %w[operational building planned].freeze
|
||
|
||
# Providers (companies) for the per-provider cards: slug => name per locale.
|
||
PROVIDERS = {
|
||
'yandex' => %w[Яндекс Yandex Яндекс], 'ixcellerate' => %w[IXcellerate] * 3,
|
||
'rtk' => %w[РТК-ЦОД RTK-DC РТК-ЦОД], 'atomdata' => ['Атомдата (Росатом)', 'Atomdata (Rosatom)', 'Атомдата (Росатом)'],
|
||
'datapro' => %w[DataPro] * 3, 'selectel' => %w[Selectel] * 3, 'mws' => ['MWS / МТС', 'MWS / MTS', 'MWS / МТС'],
|
||
'keypoint' => ['Key Point'] * 3, '3data' => %w[3data] * 3, 'sber' => %w[Сбер Sber Сбер],
|
||
'megafon' => %w[МегаФон MegaFon МегаФон], 'datahouse' => %w[DataHouse] * 3, 'oxygen' => %w[OXYGEN] * 3,
|
||
'linx' => %w[Linx] * 3, 'cloudx' => ['Cloud X'] * 3, 'nkgroup' => ['NK Group'] * 3, 'goznak' => %w[Гознак Goznak Гознак],
|
||
'dataspace' => %w[DataSpace] * 3, 'cloudru' => %w[Cloud.ru] * 3, 'x5' => ['X5 Group'] * 3, 'krok' => %w[КРОК CROC КРОК],
|
||
'stack' => ['Stack Group'] * 3, 'beeline' => %w[Билайн Beeline Білайн], 'vk' => %w[VK] * 3
|
||
}.transform_values { |names| %w[ru en uk].zip(names).to_h }.freeze
|
||
|
||
def self.provider_name(slug, locale) = PROVIDERS.dig(slug, locale) || slug
|
||
|
||
# "64" → 64.0, "25–30" → 30.0, ">40" → 40.0, "13,7" → 13.7, "" → nil
|
||
def self.mw_value(mw)
|
||
mw.to_s.tr(',', '.').scan(/\d+(?:\.\d+)?/).map(&:to_f).max
|
||
end
|
||
|
||
# Every row, column and both diagonals, as 0-based cell indices.
|
||
LINES = (
|
||
(0...SIZE).map { |r| (0...SIZE).map { |c| r * SIZE + c } } +
|
||
(0...SIZE).map { |c| (0...SIZE).map { |r| r * SIZE + c } } +
|
||
[(0...SIZE).map { |i| i * SIZE + i }, (0...SIZE).map { |i| i * SIZE + SIZE - 1 - i }]
|
||
).freeze
|
||
|
||
T = {
|
||
'ru' => {
|
||
seo_title: 'Бинго ЦОДов — атаки, пожары и аварии дата-центров России',
|
||
seo_desc: '%{hit} из %{total} крупнейших дата-центров России уже зачёркнуты. Бинго-карточка ЦОДов IXcellerate, Яндекса, Ростелекома, Росатома и других: атаки БПЛА, пожары и отключения с источниками.',
|
||
og_locale: 'ru_RU',
|
||
title: 'Бинго ЦОДов', accent: 'ЦОДов', lead: 'Бинго',
|
||
tagline: 'Атаки БПЛА, пожары и аварии в 25 крупнейших дата-центрах России',
|
||
desc: 'Рейтинг по мощности. Клетка зачёркивается после публично подтверждённого инцидента.',
|
||
crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено',
|
||
log: 'Журнал инцидентов', empty: 'Пока всё работает. Пока.',
|
||
bingo: 'БИНГО!', bingo_n: 'БИНГО ×%d!', full: 'ПОЛНАЯ КАРТА. Интернета больше нет.',
|
||
planned: 'планируется', mva: 'МВА',
|
||
mw_types: { 'it' => 'ИТ', 'connected' => 'подв.', 'total' => 'общ.', 'design' => 'проект' },
|
||
building: 'строится', mw: 'МВт', since: 'с %d г.', photo: 'Фото', source: 'Источник',
|
||
close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.',
|
||
credits_ai: 'Информация собрана и проверена с помощью Claude (Anthropic).',
|
||
credits_hym: 'Спасибо %{link} (данные Cat Eyes OSINT) за часть списка: каждая запись перепроверена по сайтам операторов и прессе.',
|
||
source_code: 'Исходный код на GitHub',
|
||
feed_title: '%{site} — Журнал инцидентов', open_data: 'Открытые данные (CC BY 4.0)',
|
||
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
|
||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
|
||
letters: %w[Б И Н Г О],
|
||
providers_label: 'Собрать карточку из провайдеров', providers_apply: 'Показать', providers_reset: 'Все провайдеры',
|
||
view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать',
|
||
dc_title: '%{name} (%{city}) — инциденты ЦОДа | Бинго ЦОДов',
|
||
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бинго ЦОДов',
|
||
dc_intro: '%{name} — дата-центр в городе %{city}, оператор — %{operator}.',
|
||
dc_capacity: 'Мощность: %{mw}.', dc_building: 'Строится.', dc_planned: 'Планируется.',
|
||
dc_since: 'Работает с %d года.',
|
||
dc_rank: '№ %{pos} по мощности среди действующих крупнейших дата-центров России.',
|
||
dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты',
|
||
back: '← К карточке', dc_page: 'Страница ЦОДа →',
|
||
status_link: 'Доступность ЦОДов', status_title: 'Доступность ЦОДов России — сетевой мониторинг | Бинго ЦОДов',
|
||
status_h1: 'Доступность', status_accent: 'ЦОДов', status_tagline: 'Сетевые проверки крупнейших дата-центров России раз в минуту',
|
||
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}',
|
||
status_desc: 'Доступность крупнейших дата-центров России онлайн: ежеминутные сетевые проверки IXcellerate, Selectel, DataPro, Key Point и других, история за 24 часа и открытые данные.',
|
||
status_probes: 'Что проверяем', status_controls_list: 'Контрольные узлы в России', status_no_answer: 'не отвечает', status_source: 'почему этот адрес',
|
||
policy_link: 'Без спама', policy_title: 'Без нежелательных рассылок | Бинго ЦОДов',
|
||
policy_desc: 'Бинго ЦОДов никогда не рекламируется нежелательными сообщениями. Сайт не отправляет писем и не ведёт рассылок.',
|
||
policy_text: ['datacenterbingo.xyz никогда не рекламируется нежелательными письмами, SMS, сообщениями в мессенджерах, на форумах или в соцсетях. Ни мы, ни кто-либо другой не вправе продвигать сайт таким образом.',
|
||
'Сайт не отправляет писем, у него нет рассылок, подписок и форм обратной связи. На нём нет рекламы, партнёрских программ и платного продвижения.',
|
||
'Если вы получили сообщение со ссылкой на этот сайт, его отправили не мы. Пожалуйста, сообщите о нём через %{link}.']
|
||
},
|
||
'en' => {
|
||
seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters',
|
||
seo_desc: '%{hit} of Russia\'s %{total} largest datacenters are already crossed out. A bingo card of IXcellerate, Yandex, Rostelecom, Rosatom and other datacenters: drone strikes, fires and outages, each with a source.',
|
||
og_locale: 'en_US',
|
||
title: 'Datacenter Bingo', accent: 'Bingo', lead: 'Datacenter',
|
||
tagline: "Drone strikes, fires and outages at Russia's 25 largest datacenters",
|
||
desc: "Ranked by power. A cell is crossed out after a publicly reported incident.",
|
||
crossed: 'Crossed out', lines: 'lines', updated: 'updated',
|
||
log: 'Incident log', empty: 'Everything is up. For now.',
|
||
bingo: 'BINGO!', bingo_n: 'BINGO ×%d!', full: 'FULL CARD. There is no more internet.',
|
||
planned: 'planned', mva: 'MVA',
|
||
mw_types: { 'it' => 'IT', 'connected' => 'connected', 'total' => 'total', 'design' => 'design' },
|
||
building: 'under construction', mw: 'MW', since: 'since %d', photo: 'Photo', source: 'Source',
|
||
close: 'Close', no_incidents: 'No incidents recorded.',
|
||
credits_ai: 'Information collected and verified with the help of Claude (Anthropic).',
|
||
credits_hym: 'Thanks to %{link} (data by Cat Eyes OSINT) for part of the list; every entry was re-verified against operator sites and the press.',
|
||
source_code: 'Source code on GitHub',
|
||
feed_title: '%{site} — Incident log', open_data: 'Open data (CC BY 4.0)',
|
||
footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.',
|
||
kinds: { 'strike' => 'Drone strike', 'fire' => 'Fire', 'outage' => 'Outage', 'flood' => 'Flood', 'cooling' => 'Overheating', 'other' => 'Incident' },
|
||
letters: %w[B I N G O],
|
||
providers_label: 'Build a card from providers', providers_apply: 'Show', providers_reset: 'All providers',
|
||
view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show',
|
||
dc_title: '%{name} (%{city}) — datacenter incidents | Datacenter Bingo',
|
||
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Datacenter 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 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_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' },
|
||
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}',
|
||
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',
|
||
policy_link: 'No-spam policy', policy_title: 'No unsolicited messages | Datacenter Bingo',
|
||
policy_desc: 'Datacenter Bingo is never advertised by unsolicited messages. The site sends no email and runs no mailing list.',
|
||
policy_text: ['datacenterbingo.xyz is never advertised by unsolicited email, SMS, messenger, forum or social media messages. Neither we nor any third party may promote the site this way.',
|
||
'The site sends no email and has no mailing list, newsletter or contact form. It carries no ads, affiliate programs or paid promotion.',
|
||
'If you received a message linking to this site, we did not send it. Please report it via %{link}.']
|
||
},
|
||
'uk' => {
|
||
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
|
||
seo_desc: '%{hit} з %{total} найбільших дата-центрів Росії вже закреслено. Бінго-картка ЦОДів IXcellerate, Яндекса, Ростелекому, Росатому та інших: атаки БПЛА, пожежі й відключення з джерелами.',
|
||
og_locale: 'uk_UA',
|
||
title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго',
|
||
tagline: 'Атаки БПЛА, пожежі та аварії у 25 найбільших дата-центрах Росії',
|
||
desc: 'Рейтинг за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.',
|
||
crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено',
|
||
log: 'Журнал інцидентів', empty: 'Поки що все працює. Поки що.',
|
||
bingo: 'БІНГО!', bingo_n: 'БІНГО ×%d!', full: 'ПОВНА КАРТКА. Інтернету більше немає.',
|
||
planned: 'планується', mva: 'МВА',
|
||
mw_types: { 'it' => 'ІТ', 'connected' => 'підв.', 'total' => 'заг.', 'design' => 'проєкт' },
|
||
building: 'будується', mw: 'МВт', since: 'з %d р.', photo: 'Фото', source: 'Джерело',
|
||
close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.',
|
||
credits_ai: 'Інформацію зібрано й перевірено за допомогою Claude (Anthropic).',
|
||
credits_hym: 'Дякуємо %{link} (дані Cat Eyes OSINT) за частину списку: кожен запис перевірено за сайтами операторів і пресою.',
|
||
source_code: 'Вихідний код на GitHub',
|
||
feed_title: '%{site} — Журнал інцидентів', open_data: 'Відкриті дані (CC BY 4.0)',
|
||
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
|
||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
|
||
letters: %w[Б І Н Г О],
|
||
providers_label: 'Зібрати картку з провайдерів', providers_apply: 'Показати', providers_reset: 'Усі провайдери',
|
||
view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати',
|
||
dc_title: '%{name} (%{city}) — інциденти ЦОДу | Бінго ЦОДів',
|
||
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бінго ЦОДів',
|
||
dc_intro: '%{name} — дата-центр у місті %{city}, оператор — %{operator}.',
|
||
dc_capacity: 'Потужність: %{mw}.', dc_building: 'Будується.', dc_planned: 'Планується.',
|
||
dc_since: 'Працює з %d року.',
|
||
dc_rank: '№ %{pos} за потужністю серед найбільших діючих дата-центрів Росії.',
|
||
dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти',
|
||
back: '← До картки', dc_page: 'Сторінка ЦОДу →',
|
||
status_link: 'Доступність ЦОДів', status_title: 'Доступність ЦОДів Росії — мережевий моніторинг | Бінго ЦОДів',
|
||
status_h1: 'Доступність', status_accent: 'ЦОДів', status_tagline: 'Мережеві перевірки найбільших дата-центрів Росії щохвилини',
|
||
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}',
|
||
status_desc: 'Доступність найбільших дата-центрів Росії онлайн: щохвилинні мережеві перевірки IXcellerate, Selectel, DataPro, Key Point та інших, історія за 24 години та відкриті дані.',
|
||
status_probes: 'Що перевіряємо', status_controls_list: 'Контрольні вузли в Росії', status_no_answer: 'не відповідає', status_source: 'чому ця адреса',
|
||
policy_link: 'Без спаму', policy_title: 'Без небажаних розсилок | Бінго ЦОДів',
|
||
policy_desc: 'Бінго ЦОДів ніколи не рекламується небажаними повідомленнями. Сайт не надсилає листів і не веде розсилок.',
|
||
policy_text: ['datacenterbingo.xyz ніколи не рекламується небажаними листами, SMS, повідомленнями в месенджерах, на форумах чи в соцмережах. Ні ми, ні будь-хто інший не має права просувати сайт у такий спосіб.',
|
||
'Сайт не надсилає листів, не має розсилок, підписок і форм зворотного зв’язку. На ньому немає реклами, партнерських програм і платного просування.',
|
||
'Якщо ви отримали повідомлення з посиланням на цей сайт, його надіслали не ми. Будь ласка, повідомте про нього через %{link}.']
|
||
}
|
||
}.freeze
|
||
|
||
# Localized column with fallback to ru.
|
||
module Localized
|
||
def l(field, locale)
|
||
v = self[:"#{field}_#{locale}"].to_s.strip
|
||
v.empty? ? self[:"#{field}_ru"] : v
|
||
end
|
||
end
|
||
|
||
class Datacenter < Sequel::Model(:bingo_datacenters)
|
||
include Localized
|
||
one_to_many :incidents, class: 'Bingo::Incident', key: :datacenter_id, order: Sequel.desc(:occurred_on)
|
||
|
||
def before_create
|
||
self.created_at ||= Time.now.utc
|
||
super
|
||
end
|
||
|
||
def before_save
|
||
self.updated_at = Time.now.utc
|
||
super
|
||
end
|
||
|
||
def operational? = status == 'operational'
|
||
def planned? = status == 'planned'
|
||
|
||
def building?
|
||
status == 'building'
|
||
end
|
||
|
||
# width: a resized variant (480, 1200) (falls back to the original until the
|
||
# variants exist), nil for the original.
|
||
def photo_file(width = nil)
|
||
return unless photo_key
|
||
key = width && photo_variants ? Photo.variant_key(photo_key, width) : photo_key
|
||
key.delete_prefix(Photo::PREFIX)
|
||
end
|
||
|
||
# Paragraphs of the datacenter page's prose in exactly this locale.
|
||
def about(locale)
|
||
self[:"about_#{locale}"].to_s.strip.split(/\n\s*\n/).map(&:strip)
|
||
end
|
||
|
||
# 1200x630 JPEG for og:image (every preview client renders it); the
|
||
# webp dialog variant until it exists.
|
||
def og_file
|
||
return photo_file(1200) unless photo_key && photo_og
|
||
Photo.og_key(photo_key).delete_prefix(Photo::PREFIX)
|
||
end
|
||
end
|
||
|
||
class Incident < Sequel::Model(:bingo_incidents)
|
||
include Localized
|
||
many_to_one :datacenter, class: 'Bingo::Datacenter'
|
||
|
||
def before_create
|
||
self.created_at ||= Time.now.utc
|
||
super
|
||
end
|
||
|
||
def before_save
|
||
self.updated_at = Time.now.utc
|
||
super
|
||
end
|
||
end
|
||
|
||
# Board state for rendering: ordered cells, hits and completed lines.
|
||
VIEWS = %w[operational all].freeze # first is the default (bare URL)
|
||
|
||
# The card is always 5×5: the first SIZE*SIZE datacenters by position,
|
||
# optionally only operational ones and/or only the chosen providers. A
|
||
# selection with fewer datacenters leaves the remaining cells empty.
|
||
Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on, :view, :providers) do
|
||
def self.load(view = 'all', providers: nil)
|
||
scope = Datacenter.order(:position, :id)
|
||
scope = scope.where(status: 'operational') if view == 'operational'
|
||
scope = scope.where(provider: providers) if providers&.any?
|
||
cells = scope.limit(SIZE * SIZE).eager(:incidents).all
|
||
hit = cells.map { |dc| dc.incidents.any? }
|
||
wins = LINES.select { |line| line.all? { |i| hit[i] } }
|
||
incidents = cells.flat_map(&:incidents).sort_by { |i| [i.occurred_on, i.id] }.reverse
|
||
updated = [Datacenter.max(:updated_at), Incident.max(:updated_at)].compact.max
|
||
new(cells, hit, wins, incidents, updated, view, providers)
|
||
end
|
||
|
||
def filtered? = providers&.any? || false
|
||
|
||
# [[slug, total, crossed]] for every provider with datacenters in this view.
|
||
def self.providers(view)
|
||
scope = Datacenter.exclude(provider: nil)
|
||
scope = scope.where(status: 'operational') if view == 'operational'
|
||
scope.eager(:incidents).all.group_by(&:provider)
|
||
.map { |slug, dcs| [slug, dcs.size, dcs.count { |d| d.incidents.any? }] }
|
||
.sort_by { |slug, n, x| [-x, -n, slug] }
|
||
end
|
||
|
||
def operational_only? = view == 'operational'
|
||
|
||
def win_cells
|
||
@win_cells ||= wins.flatten.to_set
|
||
end
|
||
end
|
||
|
||
# Photos live in the asxpio MinIO bucket under bingo/ (uploaded and resized
|
||
# by the asxp.io admin) and are proxied here, so the bucket stays private.
|
||
module Photo
|
||
PREFIX = 'bingo/'.freeze
|
||
OG_SIZE = [1200, 630].freeze # og:image, cropped to the 1.91:1 card
|
||
FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}(?:-(?:480|1200|og))?\.(jpg|png|webp)\z/
|
||
TYPES = { 'jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' }.freeze
|
||
|
||
class Error < StandardError; end
|
||
|
||
module_function
|
||
|
||
def variant_key(key, width)
|
||
key.sub(/\.\w+\z/, "-#{width}.webp")
|
||
end
|
||
|
||
def og_key(key)
|
||
key.sub(/\.\w+\z/, '-og.jpg')
|
||
end
|
||
|
||
def read(file)
|
||
raise Error, 'bad name' unless file =~ FILE_RE
|
||
[S3.get(PREFIX + file), TYPES[Regexp.last_match(1)]]
|
||
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
|
||
|
||
# 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, :control_hosts) 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, the
|
||
# site wasn't probed in the newest bucket, or its targets never answered
|
||
# in the window: a host that is filtered from abroad would otherwise
|
||
# read as a dark site).
|
||
def site_state(rows, latest, previous, trusted)
|
||
return 'unknown' unless latest && trusted
|
||
cur = rows[latest] or return 'unknown'
|
||
return 'unknown' if rows.each_value.sum { |r| r[:ok] }.zero?
|
||
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)
|
||
.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, nil]), dcs.where(id: only)] if only
|
||
samples = samples.all.group_by { |r| r[:datacenter_id] }
|
||
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) }
|
||
|
||
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'), controls)
|
||
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
|
||
module_function
|
||
|
||
def key = ENV['INDEXNOW_KEY'].to_s[/\A[A-Za-z0-9-]{8,128}\z/]
|
||
end
|
||
end
|