asxpio/bingo_web.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

121 lines
4.6 KiB
Ruby

# Public datacenter bingo, served on BINGO_HOST from the same process as
# AsxpioWeb (config.ru dispatches by Host). Read-only: no session, no CSRF.
# Content is managed from asxp.io/admin/bingo (lib/bingo_admin.rb).
class BingoWeb < Sinatra::Base
HOSTS = [ENV['BINGO_HOST'], ENV['BINGO_ALT_HOST']].compact.reject(&:empty?).freeze
configure do
set :root, $root
set :views, File.join($root, 'views', 'bingo')
set :erb, layout: :layout, escape_html: true
# public/ belongs to asxp.io (its robots.txt, sitemap, keys, admin JS);
# the card needs none of it.
set :static, false
set :show_exceptions, $env == 'development'
set :host_authorization,
{ permitted_hosts: $env == 'production' ? HOSTS : [] }
end
helpers do
def t(key) = Bingo::T[@locale][key]
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.
def json_ld(board, desc)
url = "#{base_url}/#{@locale}"
JSON.generate(
'@context' => 'https://schema.org',
'@graph' => [
{ '@type' => 'WebSite', '@id' => "#{base_url}/#website", 'url' => "#{base_url}/",
'name' => t(:title), 'inLanguage' => Bingo::LOCALES,
'publisher' => { '@type' => 'Person', 'name' => 'asxpi', 'url' => 'https://asxp.io' } },
{ '@type' => 'ItemList', 'url' => url, 'name' => t(:seo_title), 'description' => desc,
'inLanguage' => @locale, 'numberOfItems' => board.cells.size,
'itemListElement' => board.cells.each_with_index.map do |dc, i|
place = { '@type' => 'Place', 'name' => dc.l(:name, @locale),
'address' => { '@type' => 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' } }
place['image'] = "#{base_url}#{photo_path(dc, 1200)}" if dc.photo_key
place['sameAs'] = dc.info_url if dc.info_url
{ '@type' => 'ListItem', 'position' => i + 1, 'item' => place }
end }
]
).gsub('</', '<\/')
end
# Preview image: the most recent hit with a photo, else the first photo.
def og_photo(board)
dc = board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key)
dc && "#{base_url}#{photo_path(dc, 1200)}"
end
def fmt_date(d) = d.strftime('%d.%m.%Y')
# "64 MW", "64 MW · design", "17 MVA" (MVA is a unit, not a tag).
def mw(dc, tag: true)
return if dc.mw.to_s.strip.empty?
return "#{dc.mw} #{t(:mva)}" if dc.mw_type == 'mva'
tag &&= t(:mw_types)[dc.mw_type]
["#{dc.mw} #{t(:mw)}", tag || nil].compact.join(' · ')
end
# Badge for anything not yet in service.
def status_label(dc)
{ 'building' => t(:building), 'planned' => t(:planned) }[dc.status]
end
def preferred_locale
langs = request.env['HTTP_ACCEPT_LANGUAGE'].to_s.downcase.scan(/([a-z]{2})(?:-[a-z]+)?(?:;q=([\d.]+))?/)
langs.sort_by { |_, q| -(q || '1').to_f }.map(&:first).find { |l| Bingo::LOCALES.include?(l) } || 'en'
end
end
before do
halt 503, 'Bingo not configured (DATABASE_URL missing).' unless defined?(Bingo::Datacenter)
end
get '/' do
headers 'Vary' => 'Accept-Language'
redirect "/#{preferred_locale}", 302
end
get '/robots.txt' do
content_type 'text/plain'
"User-agent: *\nAllow: /\n\nSitemap: #{base_url}/sitemap.xml\n"
end
get '/sitemap.xml' do
content_type 'application/xml'
cache_control :public, max_age: 3600
@lastmod = Bingo::Board.load.updated_on
erb :sitemap, layout: false
end
get %r{/(ru|en|uk)/?} do |locale|
@locale = locale
@view = Bingo::VIEWS.include?(params[:view]) ? params[:view] : 'all'
@board = Bingo::Board.load(@view)
cache_control :public, max_age: 60
erb :index
end
# Photos are immutable per key (a new upload gets a new key).
get '/p/:file' do
bytes, type = Bingo::Photo.read(params[:file])
content_type type
cache_control :public, :immutable, max_age: 31_536_000
bytes
rescue Bingo::Photo::Error, Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound
halt 404
end
not_found do
'Not found'
end
end