diff --git a/bingo_web.rb b/bingo_web.rb index cf72a86..5d7c567 100644 --- a/bingo_web.rb +++ b/bingo_web.rb @@ -28,6 +28,8 @@ class BingoWeb < Sinatra::Base view == 'operational' ? "/#{locale}?view=operational" : "/#{locale}" end + def dc_path(dc, locale = @locale) = "/#{locale}/dc/#{dc.slug}" + def base_url = "https://#{ENV.fetch('BINGO_HOST', request.host)}" # WebSite + ItemList of the card's datacenters, for rich results. @@ -54,7 +56,8 @@ class BingoWeb < Sinatra::Base # Incidents are Events at the place. def ld_place(dc) address = { '@type' => 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' } - place = { '@type' => 'Place', 'name' => dc.l(:name, @locale), 'address' => address } + url = "#{base_url}#{dc_path(dc)}" + place = { '@type' => 'Place', '@id' => "#{url}#place", 'url' => url, 'name' => dc.l(:name, @locale), 'address' => address } place['image'] = "#{base_url}#{photo_path(dc, 1200)}" if dc.photo_key place['subjectOf'] = { '@type' => 'CreativeWork', 'url' => dc.info_url } if dc.info_url events = dc.incidents.map do |i| @@ -66,6 +69,38 @@ class BingoWeb < Sinatra::Base place end + # Datacenter page: the Place plus a breadcrumb back to the card. + def dc_json_ld(dc) + JSON.generate( + '@context' => 'https://schema.org', + '@graph' => [ + ld_place(dc), + { '@type' => 'BreadcrumbList', 'itemListElement' => [ + { '@type' => 'ListItem', 'position' => 1, 'name' => t(:title), 'item' => "#{base_url}/#{@locale}" }, + { '@type' => 'ListItem', 'position' => 2, 'name' => dc.l(:name, @locale), 'item' => "#{base_url}#{dc_path(dc)}" } + ] } + ] + ).gsub(' t(:dc_building), 'planned' => t(:dc_planned) }[dc.status] || (dc.year && format(t(:dc_since), dc.year)), + format(t(:dc_rank), pos: dc.position)].compact + end + + def dc_latest(dc) + i = dc.incidents.first or return t(:no_incidents) + format(t(:dc_latest), date: fmt_date(i.occurred_on), title: i.l(:title, @locale)) + end + + # Cut at a word boundary to fit a search snippet. + def snippet(text, max = 160) + text.length <= max ? text : "#{text[0, max - 1][/\A.*(?=\s)/m] || text[0, max - 1]}…" + end + # Preview image: the most recent hit with a photo, else the first photo. def og_dc(board) board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key) @@ -105,27 +140,54 @@ class BingoWeb < Sinatra::Base "User-agent: *\nAllow: /\n\nSitemap: #{base_url}/sitemap.xml\n" end + # The card and every datacenter page (reserve included), each with its + # language alternates. get '/sitemap.xml' do content_type 'application/xml' cache_control :public, max_age: 3600 - @lastmod = Bingo::Board.load.updated_on + dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all + @entries = [[->(l) { "/#{l}" }, Bingo::Board.load.updated_on, '/']] + + dcs.map { |dc| [->(l) { dc_path(dc, l) }, [dc.updated_at, *dc.incidents.map(&:updated_at)].compact.max] } erb :sitemap, layout: false end # One URL per page: /en/ is a duplicate of /en. - get %r{/(ru|en|uk)/} do |locale| + get %r{/((?:ru|en|uk)(?:/dc/[a-z0-9-]+)?)/} do |path| qs = request.query_string - redirect "/#{locale}#{"?#{qs}" unless qs.empty?}", 301 + redirect "/#{path}#{"?#{qs}" unless qs.empty?}", 301 end get %r{/(ru|en|uk)} do |locale| @locale = locale @view = Bingo::VIEWS.include?(params[:view]) ? params[:view] : 'all' @board = Bingo::Board.load(@view) + @title = t(:seo_title) + @desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) + @noindex = @board.operational_only? + @path_for = ->(l) { "/#{l}" } + @x_default = '/' + @og = og_dc(@board) + @ld = json_ld(@board, @desc) cache_control :public, max_age: 60 erb :index end + get %r{/(ru|en|uk)/dc/([a-z0-9-]+)} do |locale, slug| + @locale = locale + @dc = Bingo::Datacenter.where(slug: slug).eager(:incidents).first or halt 404 + @prev, @next = [-1, 1].map { |d| Bingo::Datacenter.first(position: @dc.position + d) } + name, city = @dc.l(:name, locale), @dc.l(:city, locale) + hit = @dc.incidents.first + @title = hit ? format(t(:dc_title_hit), name: name, city: city, kind: kind_label(hit.kind), date: fmt_date(hit.occurred_on)) + : format(t(:dc_title), name: name, city: city) + @desc = snippet([*dc_facts(@dc).first(2), dc_latest(@dc)].join(' ')) + @path_for = ->(l) { dc_path(@dc, l) } + @og = @dc if @dc.photo_key + @ld = dc_json_ld(@dc) + cache_control :public, max_age: 60 + erb :dc + end + # Icons from assets/bingo/ (public/ is asxp.io's). Search engines want # a crawlable favicon URL; a data: URI doesn't count. ICONS = { 'favicon.svg' => 'image/svg+xml', 'favicon.ico' => 'image/x-icon', diff --git a/lib/bingo.rb b/lib/bingo.rb index 707e96c..0b052b5 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -40,7 +40,15 @@ module Bingo footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.', kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' }, letters: %w[Б И Н Г О], - view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать' + 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: 'Страница ЦОДа →' }, 'en' => { seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters', @@ -59,7 +67,15 @@ module Bingo 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' + 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 →' }, 'uk' => { seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії', @@ -78,7 +94,15 @@ module Bingo footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.', kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' }, letters: %w[Б І Н Г О], - view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати' + 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: 'Сторінка ЦОДу →' } }.freeze diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 808d8af..1bf0232 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -174,6 +174,9 @@ class BingoTest < Minitest::Test get '/sitemap.xml' assert last_response.ok? %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}" } + %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}/dc/dc-25" } + assert_includes last_response.body, '' + assert_equal 3, last_response.body.scan('hreflang="x-default"').size # card URLs only assert_includes last_response.body, 'hreflang="x-default"' refute_includes last_response.body, 'changefreq' assert_match %r{\n \d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ\n ЦОД 4 (Moscow) — Outage, #{Date.today.strftime('%d.%m.%Y')} | Datacenter Bingo" + assert_includes body, '' + assert_includes body, '' + refute_includes body, 'x-default' + assert_includes body, '

ЦОД 4

' + assert_includes body, 'ЦОД 4 is a datacenter in Moscow operated by Op. Capacity: 4 MW.' + assert_includes body, 'No. 4 by power' + assert_includes body, 'href="/en/dc/dc-3" rel="prev"' + assert_includes body, 'href="/en/dc/dc-5" rel="next"' + assert_match(/(.*?)}m, 1]) + place = ld['@graph'].find { |n| n['@type'] == 'Place' } + assert_equal 'https://example.org/en/dc/dc-4', place['url'] + assert_equal 'Outage: Авария 3', place['event'][0]['name'] + crumbs = ld['@graph'].find { |n| n['@type'] == 'BreadcrumbList' } + assert_equal 'https://example.org/en', crumbs['itemListElement'][0]['item'] + + get '/uk/dc/dc-1' + assert_includes last_response.body, 'ЦОД 1 (Москва) — інциденти ЦОДу | Бінго ЦОДів' + refute_includes last_response.body, 'rel="prev"' + end + + def test_reserve_dc_has_a_page + Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Тверь', operator: 'Op') + get '/ru/dc/dc-26' + assert last_response.ok? + assert_includes last_response.body, 'href="/ru/dc/dc-25" rel="prev"' + get '/sitemap.xml' + assert_includes last_response.body, 'https://example.org/ru/dc/dc-26' + end + + def test_dc_page_unknown_and_trailing_slash + get '/en/dc/nope' + assert_equal 404, last_response.status + get '/en/dc/dc-1/' + assert_equal 301, last_response.status + assert_equal 'http://example.org/en/dc/dc-1', last_response.location + end + + def test_card_links_to_dc_pages + hit!(0) + get '/en' + body = last_response.body + assert_includes body, 'Datacenter page →' + assert_includes body, '
DC 1' + end + def test_root_varies_on_language get '/' assert_equal 'Accept-Language', last_response.headers['Vary'] diff --git a/views/bingo/_dc_incidents.erb b/views/bingo/_dc_incidents.erb new file mode 100644 index 0000000..63f78f7 --- /dev/null +++ b/views/bingo/_dc_incidents.erb @@ -0,0 +1,10 @@ +<% if dc.incidents.empty? %> +

<%= t(:no_incidents) %>

+<% else %> +
    + <% dc.incidents.each do |i| %> +
  • <%= kind_icon(i.kind) %> — + <% if i.url %><%= i.l(:title, @locale) %><% else %><%= i.l(:title, @locale) %><% end %>
  • + <% end %> +
+<% end %> diff --git a/views/bingo/_dc_meta.erb b/views/bingo/_dc_meta.erb new file mode 100644 index 0000000..d71b859 --- /dev/null +++ b/views/bingo/_dc_meta.erb @@ -0,0 +1,9 @@ +
+ <%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (dc.year && format(t(:since), dc.year))].compact.join(' · ') %> +
+<% if dc.photo_credit || dc.info_url %> +
+ <% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %> + <% if dc.info_url %> · <%= t(:source) %><% end %> +
+<% end %> diff --git a/views/bingo/dc.erb b/views/bingo/dc.erb new file mode 100644 index 0000000..5beced5 --- /dev/null +++ b/views/bingo/dc.erb @@ -0,0 +1,33 @@ +<% dc = @dc %> +
+
+ + +
+ +
+ <% if (photo = photo_path(dc, 1200)) %><%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %><% end %> +

<%= dc.l(:name, @locale) %>

+ <%== erb :_dc_meta, layout: false, locals: { dc: dc } %> +

<%= dc_facts(dc).join(' ') %>

+
+

<%= t(:incidents) %>

+ <%== erb :_dc_incidents, layout: false, locals: { dc: dc } %> +
+
+ + +

<%= t(:back) %>

+ +
+

<%= t(:footer) %>

+

© <%= Date.today.year %> asxp.io

+
+
diff --git a/views/bingo/index.erb b/views/bingo/index.erb index c9c85d0..2628dd3 100644 --- a/views/bingo/index.erb +++ b/views/bingo/index.erb @@ -33,7 +33,7 @@
<% b.cells.each_with_index do |dc, idx| %> <% photo = photo_path(dc, 480) %> -
<% if b.hit[idx] %>
<%= kind_icon(dc.incidents.first.kind) %>
<% end %> - + <% end %>
@@ -62,7 +62,7 @@ <%= kind_icon(i.kind) %>
<% if i.url %><%= i.l(:title, @locale) %><% else %><%= i.l(:title, @locale) %><% end %> -
<%= i.datacenter.l(:name, @locale) %> · <%= kind_label(i.kind) %>
+
<%= i.datacenter.l(:name, @locale) %> · <%= kind_label(i.kind) %>
<% end %> @@ -80,31 +80,22 @@ <% if (photo = photo_path(dc, 1200)) %><%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %><% end %>

<%= dc.l(:name, @locale) %>

-
- <%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (dc.year && format(t(:since), dc.year))].compact.join(' · ') %> + <%== erb :_dc_meta, layout: false, locals: { dc: dc } %> + <%== erb :_dc_incidents, layout: false, locals: { dc: dc } %> +
+ <%= t(:dc_page) %> +
- <% if dc.photo_credit || dc.info_url %> -
- <% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %> - <% if dc.info_url %> · <%= t(:source) %><% end %> -
- <% end %> - <% if dc.incidents.empty? %> -

<%= t(:no_incidents) %>

- <% else %> -
    - <% dc.incidents.each do |i| %> -
  • <%= kind_icon(i.kind) %> <%= fmt_date(i.occurred_on) %> — - <% if i.url %><%= i.l(:title, @locale) %><% else %><%= i.l(:title, @locale) %><% end %>
  • - <% end %> -
- <% end %> -
<% end %> diff --git a/views/bingo/layout.erb b/views/bingo/layout.erb index f559b77..7811422 100644 --- a/views/bingo/layout.erb +++ b/views/bingo/layout.erb @@ -1,45 +1,45 @@ <% base = base_url %> -<% desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) %> -<% og = og_dc(@board) %> -<% og_img = og && "#{base}/p/#{og.og_file}" %> +<% og_img = @og && "#{base}/p/#{@og.og_file}" %> -<%= t(:seo_title) %> - - +<%= @title %> + + - + <% Bingo::LOCALES.each do |l| %> - + +<% end %> +<% if @x_default %> + <% end %> - - - - + + + <% (Bingo::LOCALES - [@locale]).each do |l| %> <% end %> <% if og_img %> -<% if og.photo_og %> +<% if @og.photo_og %> <% end %> - + <% end %> - - - + + + @@ -87,7 +87,7 @@ position: relative; aspect-ratio: 1; padding: 8px; overflow: hidden; width: 100%; background: var(--card); border: 1px solid var(--line); border-radius: 8px; display: flex; flex-direction: column; justify-content: space-between; - text-align: left; color: inherit; font: inherit; cursor: pointer; + text-align: left; color: inherit; font: inherit; text-decoration: none; cursor: pointer; } .cell.photo { color: var(--on-photo); border-color: transparent; } .cell .bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; } @@ -125,6 +125,17 @@ dialog h3 { margin: 0 0 4px; } dialog .credit { font-size: 12px; color: var(--muted); margin-top: 4px; } dialog ul { padding-left: 18px; } + dialog .actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 12px; } + dialog form { margin: 0; } + .crumbs a { font-weight: 700; text-decoration: none; } + .crumbs a:hover { text-decoration: underline; } + .dc-page img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 10px; margin-bottom: 16px; } + .dc-page h1 { font-size: clamp(26px, 5vw, 38px); } + .dc-page.hit h1 { color: var(--hit); } + .dc-page .credit { font-size: 12px; color: var(--muted); margin-top: 4px; } + .dc-page .facts { max-width: 65ch; } + .dc-page ul { padding-left: 18px; } + .pager { display: flex; justify-content: space-between; gap: 12px; margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--line); } dialog button { font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; } footer { margin-top: 32px; color: var(--muted); font-size: 13px; } footer p { margin: 0 0 4px; } diff --git a/views/bingo/sitemap.erb b/views/bingo/sitemap.erb index fe49fef..0c3013b 100644 --- a/views/bingo/sitemap.erb +++ b/views/bingo/sitemap.erb @@ -1,12 +1,16 @@ +<% @entries.each do |path_for, lastmod, x_default| %> <% Bingo::LOCALES.each do |l| %> - <%= base_url %>/<%= l %> -<% if @lastmod %> <%= @lastmod.utc.iso8601 %> + <%= base_url %><%= path_for.(l) %> +<% if lastmod %> <%= lastmod.utc.iso8601 %> +<% end %> +<% Bingo::LOCALES.each do |alt| %> +<% end %> +<% if x_default %> <% end %> -<% Bingo::LOCALES.each do |alt| %> -<% end %> <% end %> +<% end %>