bingo: operational-only view of the card

The card mixes facilities in service with construction projects (Sber
Balakovo at #1, MOS7, MOS11, Yandex Vladimir-2), which is fine for a
bingo but not when you want to compare what actually runs today. Add a
toggle that builds the card from operational rows only, in the same
ranking, with bingo lines recomputed for that card. The filtered view is
noindex with canonical to the plain locale URL, so it does not compete
with the main page in search.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 06:58:30 +04:00
commit 9e885dd8c1
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
6 changed files with 74 additions and 14 deletions

View file

@ -123,6 +123,7 @@ A second public site served from the **same process** on `BINGO_HOST` (`datacent
- Locales `ru` / `en` / `uk` at `/ru`, `/en`, `/uk`; `/` redirects by `Accept-Language` (fallback `en`). Per-locale DB columns (`name_*`, `city_*`, `title_*`); `ru` is required, others fall back to it. UI strings in `Bingo::T` (`lib/bingo.rb`).
- Tables `bingo_datacenters`, `bingo_incidents` (`db/migrations/004_bingo.rb`) in the asxpio DB. First boot seeds from `db/bingo_seed.json` (verified 2026-10-10, sources in `info_url`) only if the table is empty — after that, edit via admin, not the JSON.
- Admin: `/admin/bingo` on asxp.io (`lib/bingo_admin.rb`, registered into `AsxpioWeb`, so HTTP Basic + CSRF apply). Record/delete incidents; add, edit, delete DCs (delete cascades to incidents); upload a photo or import it from a URL; bulk import missing / re-import all photos from `photo_source_url`; "Sort by MW".
- Views: `?view=operational` builds the card from operational rows only (same ranking, construction projects skipped, lines recomputed); it is noindex with canonical to the bare locale URL.
- Ordering: `position` is dense 1..N (`Bingo.place!` inserts and shifts, `renumber!` after deletes). The card shows positions 1–25; anything beyond is reserve. Slug is fixed at creation (it names photo objects).
- Photos: stored in the existing bucket under `bingo/<slug>-<hex>.<ext>` (no storage-repo change), proxied by `BingoWeb` at `/p/:file` with immutable caching (new upload = new key; old objects are kept). Type sniffed from magic bytes (JPEG/PNG/WebP), 8 MB cap, metadata stripped (GPS!). URL import is https-only, admin-only. On store, ImageMagick (`convert`, installed in the Dockerfile) derives `-480.webp` (card cells) and `-1200.webp` (dialog, og:image); `photo_variants` records success and the page falls back to the original without them. "Rebuild resized photos" in admin re-derives from stored originals.
- SEO: `BingoWeb` has `static` off (public/ is asxp.io's), its own `/robots.txt` and `/sitemap.xml` (hreflang alternates), per-locale `seo_title`/`seo_desc` with the live score, OG/Twitter tags (og:image = latest hit's photo), JSON-LD WebSite + ItemList. Canonical/hreflang always use `BINGO_HOST`, so `BINGO_ALT_HOST` doesn't compete.

View file

@ -21,6 +21,11 @@ class BingoWeb < Sinatra::Base
def kind_label(kind) = Bingo::T[@locale][:kinds][kind] || Bingo::T[@locale][:kinds]['other']
def kind_icon(kind) = Bingo::ICONS[kind] || Bingo::ICONS['other']
def photo_path(dc, width = nil) = dc.photo_file(width) && "/p/#{dc.photo_file(width)}"
# Path for a locale/view combination; the default view keeps a bare URL.
def card_path(locale = @locale, view = @view)
view == 'operational' ? "/#{locale}?view=operational" : "/#{locale}"
end
def base_url = "https://#{ENV.fetch('BINGO_HOST', request.host)}"
# WebSite + ItemList of the card's datacenters, for rich results.
@ -85,7 +90,8 @@ class BingoWeb < Sinatra::Base
get %r{/(ru|en|uk)/?} do |locale|
@locale = locale
@board = Bingo::Board.load
@view = Bingo::VIEWS.include?(params[:view]) ? params[:view] : 'all'
@board = Bingo::Board.load(@view)
cache_control :public, max_age: 60
erb :index
end

View file

@ -34,7 +34,8 @@ module Bingo
close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.',
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
letters: %w[Б И Н Г О]
letters: %w[Б И Н Г О],
view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать'
},
'en' => {
seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters',
@ -49,7 +50,8 @@ module Bingo
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]
letters: %w[B I N G O],
view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show'
},
'uk' => {
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
@ -64,7 +66,8 @@ module Bingo
close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.',
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
letters: %w[Б І Н Г О]
letters: %w[Б І Н Г О],
view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати'
}
}.freeze
@ -109,16 +112,24 @@ module Bingo
end
# Board state for rendering: ordered cells, hits and completed lines.
Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on) do
def self.load
cells = Datacenter.order(:position, :id).limit(SIZE * SIZE).eager(:incidents).all
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)
new(cells, hit, wins, incidents, updated, view)
end
def operational_only? = view == 'operational'
def win_cells
@win_cells ||= wins.flatten.to_set
end

View file

@ -114,6 +114,36 @@ class BingoTest < Minitest::Test
assert_equal out.bytesize - 8, out.byteslice(4, 4).unpack1('V')
end
def test_operational_view_skips_construction_projects
@dcs[0].update(status: 'building')
Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Москва', operator: 'Op', mw: '1')
all = Bingo::Board.load
op = Bingo::Board.load('operational')
assert_includes all.cells.map(&:slug), 'dc-1'
refute_includes op.cells.map(&:slug), 'dc-1'
assert_equal 'dc-26', op.cells.last.slug # reserve moves up to fill the card
assert_equal 25, op.cells.size
end
def test_operational_view_recomputes_lines
(1..4).each { |i| hit!(i) } # row 1 minus dc-1
@dcs[0].update(status: 'building')
Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Москва', operator: 'Op')
assert_equal 0, Bingo::Board.load.wins.size # dc-1 not hit
hit!(5) # dc-6 now sits at index 4 in operational view
assert_equal 1, Bingo::Board.load('operational').wins.size
end
def test_view_toggle_page
get '/ru?view=operational'
assert last_response.ok?
assert_includes last_response.body, 'noindex, follow'
assert_includes last_response.body, 'href="/en?view=operational"' # language links keep the view
assert_includes last_response.body, 'rel="canonical" href="https://example.org/ru"'
get '/ru?view=bogus'
assert_includes last_response.body, 'index, follow, max-image-preview'
end
def test_robots_and_sitemap_are_the_bingos_own
get '/robots.txt'
assert last_response.ok?

View file

@ -2,11 +2,18 @@
<main>
<header>
<h1><%= t(:lead) %> <span><%= t(:accent) %></span></h1>
<nav class="langs">
<% Bingo::LOCALES.each do |l| %>
<a href="/<%= l %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a>
<% end %>
</nav>
<div class="switches">
<nav class="toggle" aria-label="<%= t(:view_label) %>">
<% Bingo::VIEWS.each do |v| %>
<a href="<%= card_path(@locale, v) %>" rel="nofollow" <%== 'aria-current="page"' if v == @view %>><%= t(:"view_#{v}") %></a>
<% end %>
</nav>
<nav class="langs">
<% Bingo::LOCALES.each do |l| %>
<a href="<%= card_path(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a>
<% end %>
</nav>
</div>
</header>
<p class="lead"><%= t(:desc) %></p>

View file

@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= t(:seo_title) %></title>
<meta name="description" content="<%= desc %>">
<meta name="robots" content="index, follow, max-image-preview:large">
<meta name="robots" content="<%= @board.operational_only? ? 'noindex, follow' : 'index, follow, max-image-preview:large' %>">
<meta name="theme-color" content="#121212">
<link rel="canonical" href="<%= base %>/<%= @locale %>">
<% Bingo::LOCALES.each do |l| %>
@ -58,6 +58,11 @@
header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; margin-bottom: 16px; }
h1 { margin: 0; font-size: clamp(28px, 6vw, 44px); letter-spacing: -.02em; }
h1 span { color: var(--hit); }
.switches { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.toggle { display: flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
.toggle a { padding: 2px 10px; font-size: 13px; text-decoration: none; }
.toggle a + a { border-left: 1px solid var(--line); }
.toggle a[aria-current] { background: var(--hit); color: var(--bg); }
.langs { display: flex; gap: 4px; }
.langs a { padding: 2px 8px; border: 1px solid var(--line); border-radius: 6px; text-decoration: none; font-size: 13px; text-transform: uppercase; }
.langs a[aria-current] { background: var(--ink); color: var(--bg); border-color: var(--ink); }