asxpio/lib/bingo.rb
Sergei Poljanski e5bd1c1ac7
All checks were successful
Build and Deploy to Production / test (push) Successful in 23s
Build and Deploy to Production / build (push) Successful in 1m0s
Build and Deploy to Production / deploy (push) Successful in 39s
bingo: planned status and capacity type
"MW" on the card mixed IT load, connected power, total site power,
design capacity and even MVA (Oxygen, Sber Yuzhny Port). Record which
one a figure is in a new mw_type column; the dialog tags the number
("64 MW · design") and MVA replaces the unit, while cells keep the bare
figure to stay readable. Add a "planned" status for projects not yet
under construction; like "building", it is left out of the operational
view.

Assisted-by: Claude:opus-5
2026-10-10 06:59:04 +04:00

376 lines
17 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 'net/http'
require 'securerandom'
require 'open3'
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.
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
MW_TYPES = %w[it connected total design mva].freeze
# 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: 'Бинго',
desc: 'Топ-25 дата-центров России по мощности. Клетка зачёркивается после публично подтверждённого инцидента.',
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: 'Инцидентов не зафиксировано.',
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
letters: %w[Б И Н Г О],
view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать'
},
'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',
desc: "Russia's 25 largest datacenters 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.',
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],
view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show'
},
'uk' => {
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
seo_desc: '%{hit} з %{total} найбільших дата-центрів Росії вже закреслено. Бінго-картка ЦОДів IXcellerate, Яндекса, Ростелекому, Росатому та інших: атаки БПЛА, пожежі й відключення з джерелами.',
og_locale: 'uk_UA',
title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго',
desc: 'Топ-25 дата-центрів Росії за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.',
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: 'Інцидентів не зафіксовано.',
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
letters: %w[Б І Н Г О],
view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати'
}
}.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 operational? = status == 'operational'
def planned? = status == 'planned'
def building?
status == 'building'
end
# width: a Photo::WIDTHS variant (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
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
end
# Board state for rendering: ordered cells, hits and completed lines.
VIEWS = %w[all operational].freeze
# The card: the first SIZE*SIZE datacenters by position, optionally only
# operational ones (same ranking, construction projects skipped).
Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on, :view) do
def self.load(view = 'all')
scope = Datacenter.order(:position, :id)
scope = scope.where(status: 'operational') if view == 'operational'
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(:created_at), Incident.max(:created_at)].compact.max
new(cells, hit, wins, incidents, updated, view)
end
def operational_only? = view == 'operational'
def win_cells
@win_cells ||= wins.flatten.to_set
end
end
# Photos live in the app's MinIO bucket under bingo/ and are proxied by the
# app, so the bucket stays private.
module Photo
PREFIX = 'bingo/'.freeze
MAX_BYTES = 8 * 1024 * 1024
FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}(?:-(?:480|1200))?\.(jpg|png|webp)\z/
WIDTHS = [480, 1200].freeze # card cell (2x), dialog / og:image
CONVERT = ENV.fetch('BINGO_CONVERT', 'convert') # ImageMagick
TYPES = { 'jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' }.freeze
class Error < StandardError; end
module_function
# Input may arrive tagged UTF-8 (aws-sdk bodies are plain StringIOs), and
# comparing that with binary literals raises, so look at raw bytes.
def sniff(bytes)
bytes = bytes.b
return 'jpg' if bytes.start_with?("\xFF\xD8\xFF".b)
return 'png' if bytes.start_with?("\x89PNG\r\n\x1A\n".b)
return 'webp' if bytes[0, 4] == 'RIFF' && bytes[8, 4] == 'WEBP'
nil
end
# Stores bytes and returns the new key; the old object is left in place
# (cheap, and keeps cached URLs valid until their max-age runs out).
def store(slug, bytes)
bytes = bytes.b
raise Error, 'image too large (max 8 MB)' if bytes.bytesize > MAX_BYTES
ext = sniff(bytes) or raise Error, 'not a JPEG, PNG or WebP image'
bytes = strip_metadata(bytes, ext)
key = "#{PREFIX}#{slug}-#{SecureRandom.hex(4)}.#{ext}"
S3.put(key, bytes, content_type: TYPES[ext])
key
end
# Stores the photo for dc and makes it current. Variants are best effort:
# without them the card falls back to the original.
def attach(dc, bytes)
key = store(dc.slug, bytes)
dc.update(photo_key: key, photo_variants: build_variants(key, bytes))
key
end
# Re-derives the variants from the stored original.
def rebuild(dc)
dc.update(photo_variants: build_variants(dc.photo_key, S3.get(dc.photo_key)))
end
def variant_key(key, width)
key.sub(/\.\w+\z/, "-#{width}.webp")
end
def build_variants(key, bytes)
bytes = bytes.b
ext = sniff(bytes) or raise Error, 'not an image'
WIDTHS.each { |w| S3.put(variant_key(key, w), resize(bytes, ext, w), content_type: TYPES['webp']) }
true
rescue Error => e
$logger.warn("bingo: no variants for #{key}: #{e.message}")
false
end
# Downscale (never up) to fit width x width, re-encode as WebP; -strip
# drops metadata again. The input coder is pinned to the sniffed type and
# memory/area limits guard against decompression bombs.
def resize(bytes, ext, width)
coder = { 'jpg' => 'jpeg', 'png' => 'png', 'webp' => 'webp' }.fetch(ext)
out, err, st = Open3.capture3(
CONVERT, '-limit', 'memory', '256MiB', '-limit', 'area', '128MP',
"#{coder}:-", '-auto-orient', '-resize', "#{width}x#{width}>",
'-strip', '-quality', '80', 'webp:-',
stdin_data: bytes, binmode: true
)
raise Error, "convert: #{err.lines.first&.strip}" unless st.success? && sniff(out.b) == 'webp'
out.b
rescue Errno::ENOENT
raise Error, 'ImageMagick (convert) not installed'
rescue Errno::EPIPE, IOError => e # convert exited before reading all input
raise Error, "convert: #{e.class}"
end
# Photos are served byte-for-byte, so drop EXIF/XMP/IPTC/text metadata:
# phone shots carry GPS coordinates, and the card stays city-level.
# Pixel data is untouched; malformed input is rejected rather than
# passed through.
def strip_metadata(bytes, ext)
case ext
when 'jpg' then strip_jpeg(bytes)
when 'png' then strip_png(bytes)
when 'webp' then strip_webp(bytes)
end
end
JPEG_DROP = [0xE1, *0xE3..0xEF, 0xFE].freeze # APP1 (Exif/XMP), APP3-15 (IPTC...), COM
def strip_jpeg(b)
out = +"\xFF\xD8".b
i = 2
while i < b.bytesize
raise Error, 'corrupt JPEG' unless b.getbyte(i) == 0xFF
marker = b.getbyte(i + 1)
if marker == 0xFF then i += 1; next end # fill byte
if marker == 0xDA || marker == 0xD9 # start of scan: rest is image data
return out << b.byteslice(i..)
end
len = b.byteslice(i + 2, 2)&.unpack1('n') or raise Error, 'corrupt JPEG'
out << b.byteslice(i, len + 2) unless JPEG_DROP.include?(marker)
i += len + 2
end
raise Error, 'corrupt JPEG'
end
PNG_DROP = %w[eXIf tEXt iTXt zTXt tIME].freeze
def strip_png(b)
out = b.byteslice(0, 8)
i = 8
while i + 8 <= b.bytesize
len, type = b.byteslice(i, 8).unpack('Na4')
out << b.byteslice(i, len + 12) unless PNG_DROP.include?(type)
i += len + 12
return out if type == 'IEND'
end
raise Error, 'corrupt PNG'
end
def strip_webp(b)
body = +''.b
i = 12
while i + 8 <= b.bytesize
type, len = b.byteslice(i, 8).unpack('a4V')
chunk = b.byteslice(i, 8 + len + (len & 1))
if type == 'VP8X'
chunk = chunk.dup
chunk.setbyte(8, chunk.getbyte(8) & ~0x0C) # clear EXIF + XMP flags
end
body << chunk unless %w[EXIF XMP\ ].include?(type)
i += 8 + len + (len & 1)
end
raise Error, 'corrupt WebP' if body.empty?
'RIFF'.b + [body.bytesize + 4].pack('V') + 'WEBP'.b + body
end
# Admin-only "import from URL". https only, a few redirects, size-capped.
def fetch(url, redirects: 3)
uri = URI(url)
raise Error, 'https URL required' unless uri.is_a?(URI::HTTPS)
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http|
req = Net::HTTP::Get.new(uri)
# Wikimedia rate-limits generic agents; its policy wants name + contact.
req['User-Agent'] = 'asxpio-bingo/1.0 (https://asxp.io; ie@asxp.io)'
http.request(req) do |res|
case res
when Net::HTTPRedirection
raise Error, 'too many redirects' if redirects.zero?
return fetch(URI.join(uri, res['location']).to_s, redirects: redirects - 1)
when Net::HTTPSuccess
body = +''.b
res.read_body do |chunk|
body << chunk
raise Error, 'image too large (max 8 MB)' if body.bytesize > MAX_BYTES
end
return body
else
raise Error, "HTTP #{res.code} from #{uri.host}"
end
end
end
rescue URI::InvalidURIError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, Errno::ECONNREFUSED => e
raise Error, e.message
end
def read(file)
raise Error, 'bad name' unless file =~ FILE_RE
[S3.get(PREFIX + file), TYPES[Regexp.last_match(1)]]
end
end
# Positions stay dense 1..N; the card shows the first SIZE*SIZE, the rest
# wait in reserve. Placing a datacenter inserts it at pos (default: last)
# and shifts everyone after it.
def self.place!(dc, pos = nil)
DB.connection.transaction do
ids = Datacenter.exclude(id: dc.id).order(:position, :id).select_map(:id)
pos = (pos || ids.size + 1).clamp(1, ids.size + 1)
renumber!(ids.insert(pos - 1, dc.id))
end
end
def self.renumber!(ids = Datacenter.order(:position, :id).select_map(:id))
ids.each_with_index { |id, i| Datacenter.where(id: id).update(position: i + 1) }
end
# "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
# Rank by power, unknown last; ties keep their current order.
def self.sort_by_mw!
DB.connection.transaction do
renumber!(Datacenter.order(:position, :id).all
.sort_by.with_index { |dc, i| [-(mw_value(dc.mw) || -1), i] }.map(&:id))
end
end
# First boot: fill the card (and any already-known incidents) from
# db/bingo_seed.json. Never touches rows once any exist; edits happen in
# /admin/bingo.
def self.seed!
return if Datacenter.count.positive?
path = File.join($root, 'db', 'bingo_seed.json')
return unless File.exist?(path)
seed = JSON.parse(File.read(path, encoding: 'UTF-8'))
cols = Datacenter.columns.map(&:to_s) - %w[id created_at]
DB.connection.transaction do
ids = seed.fetch('datacenters').each_with_index.to_h do |row, i|
dc = Datacenter.create(row.slice(*cols).merge('position' => row['position'] || i + 1))
[dc.slug, dc.id]
end
seed.fetch('incidents', []).each do |row|
Incident.create(row.slice(*(Incident.columns.map(&:to_s) - %w[id created_at datacenter_id]))
.merge('datacenter_id' => ids.fetch(row.fetch('slug'))))
end
end
end
end