datacenterbingo/lib/bingo.rb
Sergei Poljanski 1a5506c77d
All checks were successful
Build and Deploy to Production / test (push) Successful in 18s
Build and Deploy to Production / build (push) Successful in 49s
Build and Deploy to Production / deploy (push) Successful in 33s
Add a public reachability status page
asxpio now probes public hosts tied to each datacenter once a minute
and stores 5-minute rows (bingo_monitor_* tables, granted to the
read-only bingo role). Show them: /:locale/status lists every monitored
site with its current state and a 24-hour bar, /status.json has the
same data, and monitored datacenter pages get a small block.

A site is down only when nothing answered for two buckets. Everything
reads as unknown when the worker is stale or fewer than half of the
control hosts in Russia answer, since probes run from Finland and
filtering can look like an outage. The page says plainly that this is
not an incident record.

Refresh test/schema.sql for asxpio's migration 017.

Assisted-by: Claude:opus-5-5
2026-10-18 10:33:52 +04:00

398 lines
26 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_lead: 'Раз в минуту мы открываем TCP-соединение с публичными адресами, которые открытые источники связывают с конкретной площадкой. Проверка идёт из Финляндии: российская фильтрация и отключения внешних каналов могут выглядеть как авария, поэтому параллельно проверяются контрольные узлы в России.',
status_note: 'Это не подтверждённые инциденты: клетка на карточке зачёркивается только по публичному источнику.',
status_states: { 'up' => 'доступен', 'degraded' => 'частично', 'down' => 'не отвечает', 'unknown' => 'нет данных' },
status_controls: 'Контрольные узлы в России: %{ok} из %{n} отвечают.',
status_stale: 'Монитор давно не присылал данных — состояние неизвестно.',
status_vantage: 'Большинство контрольных узлов в России не отвечает: проблема, скорее всего, на пути из-за границы, а не на площадках.',
status_checked: 'Последняя проверка: %{time} UTC.', status_never: 'Проверок ещё не было.',
status_unmonitored: 'Без мониторинга', status_unmonitored_note: 'Действующие ЦОДы, для которых нет публичных адресов, надёжно привязанных к площадке (обычно собственные ЦОДы Яндекса, VK, Сбера).',
status_cols: { 'dc' => 'ЦОД', 'state' => 'Сейчас', 'hours' => '24 часа', 'rtt' => 'мс', 'targets' => 'адр.' },
status_dc: 'Доступность сети', status_hour: '%{h} ч назад: %{p}'
},
'en' => {
seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters',
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 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_lead: 'Once a minute we open a TCP connection to public addresses that open sources tie to a specific site. Probes run from Finland: Russian filtering and cuts to international links can look like an outage, so control hosts inside Russia are checked alongside.',
status_note: 'These are not confirmed incidents: a cell on the card is crossed out only on a public source.',
status_states: { 'up' => 'reachable', 'degraded' => 'partial', 'down' => 'not answering', 'unknown' => 'no data' },
status_controls: 'Control hosts in Russia: %{ok} of %{n} answering.',
status_stale: 'The monitor has not reported for a while — state unknown.',
status_vantage: 'Most control hosts in Russia are not answering: the problem is most likely on the path from abroad, not at the sites.',
status_checked: 'Last check: %{time} UTC.', status_never: 'No checks yet.',
status_unmonitored: 'Not monitored', status_unmonitored_note: 'Operational datacenters with no public address reliably tied to the site (mostly Yandex, VK and Sber in-house sites).',
status_cols: { 'dc' => 'Datacenter', 'state' => 'Now', 'hours' => '24 hours', 'rtt' => 'ms', 'targets' => 'addr.' },
status_dc: 'Network reachability', status_hour: '%{h} h ago: %{p}'
},
'uk' => {
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
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_lead: 'Раз на хвилину ми відкриваємо TCP-з’єднання з публічними адресами, які відкриті джерела пов’язують із конкретним майданчиком. Перевірка йде з Фінляндії: російська фільтрація та відключення зовнішніх каналів можуть виглядати як аварія, тому паралельно перевіряються контрольні вузли в Росії.',
status_note: 'Це не підтверджені інциденти: клітинка на картці закреслюється лише за публічним джерелом.',
status_states: { 'up' => 'доступний', 'degraded' => 'частково', 'down' => 'не відповідає', 'unknown' => 'немає даних' },
status_controls: 'Контрольні вузли в Росії: %{ok} з %{n} відповідають.',
status_stale: 'Монітор давно не надсилав даних — стан невідомий.',
status_vantage: 'Більшість контрольних вузлів у Росії не відповідає: проблема, найімовірніше, на шляху з-за кордону, а не на майданчиках.',
status_checked: 'Остання перевірка: %{time} UTC.', status_never: 'Перевірок ще не було.',
status_unmonitored: 'Без моніторингу', status_unmonitored_note: 'Діючі ЦОДи, для яких немає публічних адрес, надійно прив’язаних до майданчика (здебільшого власні ЦОДи Яндекса, VK, Сбера).',
status_cols: { 'dc' => 'ЦОД', 'state' => 'Зараз', 'hours' => '24 години', 'rtt' => 'мс', 'targets' => 'адр.' },
status_dc: 'Доступність мережі', status_hour: '%{h} год тому: %{p}'
}
}.freeze
# 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
Site = Struct.new(:dc, :state, :availability, :rtt_ms, :history, :targets)
Report = Struct.new(:sites, :unmonitored, :checked_at, :controls, :state) do
def counts = sites.map(&:state).tally
end
module_function
def available? = DB.connection.table_exists?(:bingo_monitor_samples)
# A bucket is trusted when at least half the control hosts answered; with
# none answering, a dark site may just mean our vantage point lost Russia.
def vantage_ok?(bucket)
bucket[:control_checks].zero? || bucket[:control_ok] * 2 >= bucket[:control_checks]
end
# state: up (≥80 % of probes answered), degraded, down (nothing answered
# in the last two buckets), unknown (worker stale, vantage lost, or the
# site wasn't probed in the newest bucket).
def site_state(rows, latest, previous, trusted)
return 'unknown' unless latest && trusted
cur = rows[latest] or return 'unknown'
prev = previous && rows[previous]
return 'down' if cur[:ok].zero? && (prev.nil? || prev[:ok].zero?)
cur[:ok] >= cur[:checks] * 0.8 ? 'up' : 'degraded'
end
# only: a datacenter id, for one datacenter page.
def load(now = Time.now.utc, hours: HOURS, only: nil)
db = DB.connection
since = now - (hours * 3600)
buckets = db[:bingo_monitor_buckets].where { bucket_at >= since }.order(:bucket_at).all
latest = buckets.last
fresh = latest && now - latest[:bucket_at] < STALE
trusted = fresh && vantage_ok?(latest)
latest_at = fresh ? latest[:bucket_at] : nil
previous_at = fresh && buckets.size > 1 ? buckets[-2][:bucket_at] : nil
bad_hours = buckets.reject { |b| vantage_ok?(b) }.map { |b| hour_index(b[:bucket_at], now, hours) }.to_set
samples = db[:bingo_monitor_samples].where { bucket_at >= since }
targets = db[:bingo_monitor_targets].where(active: true).exclude(datacenter_id: nil)
dcs = Datacenter.order(:position, :id)
samples, targets, dcs = [samples.where(datacenter_id: only), targets.where(datacenter_id: only), dcs.where(id: only)] if only
samples = samples.all.group_by { |r| r[:datacenter_id] }
targets = targets.group_and_count(:datacenter_id).to_hash(:datacenter_id, :count)
dcs = dcs.all
monitored, unmonitored = dcs.partition { |dc| targets.key?(dc.id) }
sites = monitored.map do |dc|
rows = (samples[dc.id] || []).to_h { |r| [r[:bucket_at], r] }
cur = latest_at && rows[latest_at]
Site.new(dc, site_state(rows, latest_at, previous_at, trusted),
cur && cur[:checks].positive? ? cur[:ok].fdiv(cur[:checks]) : nil,
cur && cur[:ok].positive? ? (cur[:rtt_ms_sum] / cur[:ok]) : nil,
history(rows.values, now, hours, bad_hours), targets[dc.id])
end
Report.new(sites, unmonitored.select(&:operational?), latest && latest[:bucket_at],
latest && [latest[:control_ok], latest[:control_checks]],
!fresh ? 'stale' : (trusted ? 'ok' : 'vantage'))
end
def hour_index(time, now, hours) = hours - 1 - ((now - time) / 3600).floor
# Answered share per hour, oldest first; nil = no data or untrusted hour.
def history(rows, now, hours, bad_hours)
per = Array.new(hours) { [0, 0] }
rows.each do |r|
i = hour_index(r[:bucket_at], now, hours)
next unless i.between?(0, hours - 1)
per[i][0] += r[:ok]
per[i][1] += r[:checks]
end
per.each_with_index.map { |(ok, n), i| n.zero? || bad_hours.include?(i) ? nil : ok.fdiv(n) }
end
end
# IndexNow pings are sent by the asxp.io admin; this site only serves the
# key file at /<key>.txt to prove they come from us.
module IndexNow
module_function
def key = ENV['INDEXNOW_KEY'].to_s[/\A[A-Za-z0-9-]{8,128}\z/]
end
end