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:
parent
61bb1393fe
commit
9e885dd8c1
6 changed files with 74 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
lib/bingo.rb
25
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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue