From 9e885dd8c14f8eb9ff10e1a1f581825ec2750a94 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 06:58:30 +0400 Subject: [PATCH] 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 --- CLAUDE.md | 1 + bingo_web.rb | 8 +++++++- lib/bingo.rb | 25 ++++++++++++++++++------- test/bingo_test.rb | 30 ++++++++++++++++++++++++++++++ views/bingo/index.erb | 17 ++++++++++++----- views/bingo/layout.erb | 7 ++++++- 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dc4ce38..805e362 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/-.` (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. diff --git a/bingo_web.rb b/bingo_web.rb index d04cdf4..42d4280 100644 --- a/bingo_web.rb +++ b/bingo_web.rb @@ -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 diff --git a/lib/bingo.rb b/lib/bingo.rb index 58367cf..ae1f9ae 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -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 diff --git a/test/bingo_test.rb b/test/bingo_test.rb index bdd5ce2..d9169a7 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -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? diff --git a/views/bingo/index.erb b/views/bingo/index.erb index e8c2e6c..3f11d29 100644 --- a/views/bingo/index.erb +++ b/views/bingo/index.erb @@ -2,11 +2,18 @@

<%= t(:lead) %> <%= t(:accent) %>

- +
+ + +

<%= t(:desc) %>

diff --git a/views/bingo/layout.erb b/views/bingo/layout.erb index c3d3a76..fa14265 100644 --- a/views/bingo/layout.erb +++ b/views/bingo/layout.erb @@ -8,7 +8,7 @@ <%= t(:seo_title) %> - + <% 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); }