bingo: serve datacenter bingo on bingo.asxp.io
Add a second public site to the same process: a 5x5 card of the 25 largest Russian datacenters where a cell is crossed out once an incident with a public source is recorded (Yandex Sasovo and Kaluga drone strikes on 2026-10-08/09 are seeded). config.ru dispatches by Host, so it rides the existing container, DB and bucket instead of a new deploy. The card is localized ru/en/uk with per-locale columns falling back to ru. Content is managed from /admin/bingo on asxp.io, reusing AdminAuth and CSRF. Photos go to the existing bucket under bingo/ and are proxied by the app, so the bucket stays private and storage needs no change. The seed data was fact-checked against operator sites and press on 2026-10-10; several entries in the original list were wrong or vague. Assisted-by: Claude:opus-5
This commit is contained in:
parent
1f059dc2f6
commit
bdb3682e18
19 changed files with 1428 additions and 2 deletions
|
|
@ -17,3 +17,6 @@ MAIL_TO=ie@asxp.io
|
|||
# CRYPTO_ADDRESSES={"BTC":"bc1...","USDT-TRC20":"T..."}
|
||||
# Legacy: LTC_ADDRESS still fills the LTC default when set.
|
||||
# LTC_ADDRESS=ltc1...
|
||||
|
||||
# Datacenter bingo hostname (same process, routed by Host). Unset = bingo off.
|
||||
# BINGO_HOST=bingo.localhost
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ jobs:
|
|||
S3_SECRET_KEY=${ASXPIO_S3_SECRET_KEY}
|
||||
LTC_ADDRESS=${LTC_ADDRESS}
|
||||
CRYPTO_ADDRESSES=${CRYPTO_ADDRESSES}
|
||||
BINGO_HOST=bingo.asxp.io
|
||||
EOF
|
||||
chmod 600 /opt/asxpio/.env
|
||||
|
||||
|
|
|
|||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -115,6 +115,18 @@ Admin pages use a shared dark palette extended in `public/style.css` (search for
|
|||
- Anyone with the UUID can fetch the PDF. UUIDs are 122-bit random so not enumerable, but they aren't access-controlled. If a stronger gate is ever needed (email confirmation, expiry on paid, etc.) it goes in the `/i/:uuid` and `/i/:uuid/pdf` handlers.
|
||||
- The MinIO bucket is **private**; only the app's scoped service account can put/get. Presigned URLs use `S3_PUBLIC_ENDPOINT=https://s3.asxp.io` so the signed host matches what the browser fetches.
|
||||
|
||||
## Datacenter bingo
|
||||
|
||||
A second public site served from the **same process** on `BINGO_HOST` (`bingo.asxp.io`, non-secret config in `deploy.yaml` → `.env` → Traefik router `asxpio-bingo` via compose interpolation). `config.ru` dispatches by Host: `BINGO_HOST` → `BingoWeb` (`bingo_web.rb`), everything else → `AsxpioWeb`. Unset `BINGO_HOST` = bingo unreachable.
|
||||
|
||||
- 5×5 card of the 25 largest Russian datacenters; a cell is crossed out when an incident (with a public source) is recorded. Rows/columns/diagonals → BINGO banner.
|
||||
- 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, edit DCs, upload a photo or import it from a URL, bulk "import all missing photos" from `photo_source_url`.
|
||||
- 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. URL import is https-only, admin-only.
|
||||
- Photos are third-party (operator sites, Habr, Wikimedia Commons CC BY-SA) — keep `photo_credit` filled; renders/illustrations are marked as such.
|
||||
- No street addresses or coordinates on the page — city level only.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- `client_ip` reads `X-Forwarded-For` first (Traefik sets it), falling back to `X-Real-IP` and `REMOTE_ADDR`.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ require_relative 'lib/rate_limit'
|
|||
require_relative 'lib/db'
|
||||
require_relative 'lib/admin_auth'
|
||||
require_relative 'lib/s3'
|
||||
require_relative 'lib/bingo_admin'
|
||||
|
||||
# Invoice model needs a live Sequel connection at class-definition time.
|
||||
if ENV['DATABASE_URL']
|
||||
|
|
@ -34,6 +35,8 @@ if ENV['DATABASE_URL']
|
|||
require_relative 'lib/crypto_qr'
|
||||
require_relative 'lib/gel_rate'
|
||||
require_relative 'lib/invoice_pdf'
|
||||
require_relative 'lib/bingo'
|
||||
Bingo.seed!
|
||||
else
|
||||
$logger.warn('DATABASE_URL not set — invoicing disabled')
|
||||
end
|
||||
|
|
@ -61,6 +64,7 @@ class AsxpioWeb < Sinatra::Base
|
|||
|
||||
use Rack::Protection::AuthenticityToken
|
||||
use AdminAuth
|
||||
register BingoAdmin
|
||||
|
||||
Mailer.configure!
|
||||
|
||||
|
|
|
|||
59
bingo_web.rb
Normal file
59
bingo_web.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# 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
|
||||
configure do
|
||||
set :root, $root
|
||||
set :views, File.join($root, 'views', 'bingo')
|
||||
set :erb, layout: :layout, escape_html: true
|
||||
set :show_exceptions, $env == 'development'
|
||||
set :host_authorization,
|
||||
{ permitted_hosts: $env == 'production' && ENV['BINGO_HOST'] ? [ENV['BINGO_HOST']] : [] }
|
||||
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) = dc.photo_file && "/p/#{dc.photo_file}"
|
||||
def fmt_date(d) = d.strftime('%d.%m.%Y')
|
||||
|
||||
def mw(dc)
|
||||
dc.mw.to_s.strip.empty? ? nil : "#{dc.mw} #{t(:mw)}"
|
||||
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
|
||||
redirect "/#{preferred_locale}", 302
|
||||
end
|
||||
|
||||
get %r{/(ru|en|uk)/?} do |locale|
|
||||
@locale = locale
|
||||
@board = Bingo::Board.load
|
||||
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
|
||||
12
config.ru
12
config.ru
|
|
@ -1,3 +1,13 @@
|
|||
require 'sinatra/base'
|
||||
require './asxpio.rb'
|
||||
run AsxpioWeb.new
|
||||
require './bingo_web.rb'
|
||||
|
||||
# One process, two sites: BINGO_HOST gets the bingo, everything else asxp.io.
|
||||
asxpio = AsxpioWeb.new
|
||||
bingo = BingoWeb.new
|
||||
bingo_host = ENV['BINGO_HOST']
|
||||
|
||||
run lambda { |env|
|
||||
host = Rack::Request.new(env).host
|
||||
(bingo_host && host == bingo_host ? bingo : asxpio).call(env)
|
||||
}
|
||||
|
|
|
|||
449
db/bingo_seed.json
Normal file
449
db/bingo_seed.json
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
{
|
||||
"datacenters": [
|
||||
{
|
||||
"slug": "ix-mos5",
|
||||
"name_ru": "IXcellerate MOS5",
|
||||
"name_en": "IXcellerate MOS5",
|
||||
"name_uk": "IXcellerate MOS5",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "IXcellerate",
|
||||
"mw": "64",
|
||||
"status": "operational",
|
||||
"year": 2021,
|
||||
"photo_source_url": "https://www.ixcellerate.com/wp-content/uploads/2025/04/mos5_2-scaled.jpg",
|
||||
"photo_credit": "© IXcellerate",
|
||||
"info_url": "https://www.ixcellerate.com/data-centers/moscow-south-campus/",
|
||||
"position": 1
|
||||
},
|
||||
{
|
||||
"slug": "yandex-kaluga",
|
||||
"name_ru": "Яндекс Калуга",
|
||||
"name_en": "Yandex Kaluga",
|
||||
"name_uk": "Яндекс Калуга",
|
||||
"city_ru": "Калуга",
|
||||
"city_en": "Kaluga",
|
||||
"city_uk": "Калуга",
|
||||
"operator": "Яндекс",
|
||||
"mw": "63",
|
||||
"status": "operational",
|
||||
"year": 2023,
|
||||
"photo_source_url": "https://storage.yandexcloud.net/cloud-www-assets/blog-assets/ru/posts/2022/scale/cloud-data-center-kaluga/kaluga_2.jpg",
|
||||
"photo_credit": "© Yandex Cloud (визуализация)",
|
||||
"info_url": "https://yandex.cloud/ru/blog/posts/2022/09/cloud-data-center-kaluga",
|
||||
"position": 2
|
||||
},
|
||||
{
|
||||
"slug": "kalininsky",
|
||||
"name_ru": "ЦОД «Калининский»",
|
||||
"name_en": "Kalininsky DC",
|
||||
"name_uk": "ЦОД «Калінінський»",
|
||||
"city_ru": "Удомля",
|
||||
"city_en": "Udomlya",
|
||||
"city_uk": "Удомля",
|
||||
"operator": "Атомдата / Ростелеком",
|
||||
"mw": "48",
|
||||
"status": "operational",
|
||||
"year": 2019,
|
||||
"photo_source_url": "https://upload.wikimedia.org/wikipedia/commons/c/c8/%D0%A6%D0%9E%D0%94_%D0%9A%D0%B0%D0%BB%D0%B8%D0%BD%D0%B8%D0%BD%D1%81%D0%BA%D0%B8%D0%B9.jpg",
|
||||
"photo_credit": "Sergei2709 / Wikimedia Commons, CC BY-SA 4.0",
|
||||
"info_url": "https://www.comnews.ru/content/121093/2019-07-29/rostelekom-vvel-centr-obrabotki-dannyh-udomlya-v-promyshlennuyu-ekspluataciyu",
|
||||
"position": 3
|
||||
},
|
||||
{
|
||||
"slug": "yandex-vladimir-2",
|
||||
"name_ru": "Яндекс Владимир-2",
|
||||
"name_en": "Yandex Vladimir-2",
|
||||
"name_uk": "Яндекс Владимир-2",
|
||||
"city_ru": "Владимир",
|
||||
"city_en": "Vladimir",
|
||||
"city_uk": "Владимир",
|
||||
"operator": "Яндекс",
|
||||
"mw": ">40",
|
||||
"status": "building",
|
||||
"year": 2027,
|
||||
"photo_source_url": "https://www.tadviser.ru/images/d/d9/Yan1.jpg",
|
||||
"photo_credit": "Госстройнадзор Владимирской обл. / TAdviser",
|
||||
"info_url": "https://www.cableman.ru/content/yandeks-zapuskaet-data-tsentr-vo-vladimirskoi-oblasti-s-moshchnostyu-bolee-40-mvt",
|
||||
"position": 4
|
||||
},
|
||||
{
|
||||
"slug": "ix-mos11",
|
||||
"name_ru": "IXcellerate MOS11 «Вёшки»",
|
||||
"name_en": "IXcellerate MOS11 Vyoshki",
|
||||
"name_uk": "IXcellerate MOS11 «Вьошки»",
|
||||
"city_ru": "Мытищи",
|
||||
"city_en": "Mytishchi",
|
||||
"city_uk": "Митищі",
|
||||
"operator": "IXcellerate",
|
||||
"mw": "40",
|
||||
"status": "building",
|
||||
"year": 2026,
|
||||
"photo_source_url": "https://www.ixcellerate.com/wp-content/uploads/2025/12/vyoshki-campus5.jpg",
|
||||
"photo_credit": "© IXcellerate (визуализация)",
|
||||
"info_url": "https://www.ixcellerate.com/data-centers/moscow-veshki-campus/",
|
||||
"position": 5
|
||||
},
|
||||
{
|
||||
"slug": "rtk-medvedkovo-2",
|
||||
"name_ru": "РТК-ЦОД Медведково-2",
|
||||
"name_en": "RTK-DC Medvedkovo-2",
|
||||
"name_uk": "РТК-ЦОД Медведково-2",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "РТК-ЦОД",
|
||||
"mw": "36",
|
||||
"status": "operational",
|
||||
"year": 2024,
|
||||
"photo_source_url": "https://upload.wikimedia.org/wikipedia/commons/b/b5/%D0%A6%D0%B5%D0%BD%D1%82%D1%80_%D0%BE%D0%B1%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%BA%D0%B8_%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D1%85_%28%D0%9C%D0%B5%D0%B4%D0%B2%D0%B5%D0%B4%D0%BA%D0%BE%D0%B2%D0%BE%29_20260621_121548.jpg",
|
||||
"photo_credit": "Lesless / Wikimedia Commons, CC BY-SA 4.0",
|
||||
"info_url": "https://www.interfax.ru/business/976641",
|
||||
"position": 6
|
||||
},
|
||||
{
|
||||
"slug": "atomdata-moskva-2",
|
||||
"name_ru": "Атомдата «Москва-2»",
|
||||
"name_en": "Atomdata Moskva-2",
|
||||
"name_uk": "Атомдата «Москва-2»",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "Атомдата",
|
||||
"mw": "36",
|
||||
"status": "operational",
|
||||
"year": 2024,
|
||||
"photo_source_url": "https://atomdata.ru/upload/iblock/671/gchj4a2c7gwg0v18v1nqehosu4c8zgtz.jpg",
|
||||
"photo_credit": "© АО «Атомдата»",
|
||||
"info_url": "https://habr.com/ru/articles/795381/",
|
||||
"position": 7
|
||||
},
|
||||
{
|
||||
"slug": "3data-hsm",
|
||||
"name_ru": "3data HyperScale Медведково",
|
||||
"name_en": "3data HyperScale Medvedkovo",
|
||||
"name_uk": "3data HyperScale Медведково",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "3data",
|
||||
"mw": "36",
|
||||
"status": "operational",
|
||||
"year": 2023,
|
||||
"photo_source_url": "https://3data.ru/1024/upload/1691061832_01.jpg",
|
||||
"photo_credit": "© 3data",
|
||||
"info_url": "https://3data.ru/dc/hsm",
|
||||
"position": 8
|
||||
},
|
||||
{
|
||||
"slug": "ix-mos3",
|
||||
"name_ru": "IXcellerate MOS3",
|
||||
"name_en": "IXcellerate MOS3",
|
||||
"name_uk": "IXcellerate MOS3",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "IXcellerate",
|
||||
"mw": "30",
|
||||
"status": "operational",
|
||||
"year": 2025,
|
||||
"photo_source_url": "https://www.ixcellerate.com/wp-content/uploads/2025/11/IXC_0111-2.jpg",
|
||||
"photo_credit": "© IXcellerate",
|
||||
"info_url": "https://www.ixcellerate.com/data-centers/moscow-north-campus/",
|
||||
"position": 9
|
||||
},
|
||||
{
|
||||
"slug": "datapro-moscow-3",
|
||||
"name_ru": "DataPro Moscow III",
|
||||
"name_en": "DataPro Moscow III",
|
||||
"name_uk": "DataPro Moscow III",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "DataPro",
|
||||
"mw": "30",
|
||||
"status": "operational",
|
||||
"year": 2022,
|
||||
"photo_source_url": "https://www.datapro.ru/assets/images/pages/data-centers/3/0.png",
|
||||
"photo_credit": "© DataPro (иллюстрация)",
|
||||
"info_url": "https://www.datapro.ru/data-centers/datapro-moscow-iii",
|
||||
"position": 10
|
||||
},
|
||||
{
|
||||
"slug": "mws-avantazh",
|
||||
"name_ru": "MWS «Авантаж»",
|
||||
"name_en": "MWS Avantazh",
|
||||
"name_uk": "MWS «Авантаж»",
|
||||
"city_ru": "Лыткарино",
|
||||
"city_en": "Lytkarino",
|
||||
"city_uk": "Литкаріно",
|
||||
"operator": "MWS / МТС",
|
||||
"mw": "20",
|
||||
"status": "operational",
|
||||
"year": 2018,
|
||||
"photo_source_url": "https://habrastorage.org/r/w1560/webt/f3/mb/1a/f3mb1avrss4og8wlza4pspv4hzu.jpeg",
|
||||
"photo_credit": "Т1 / Техносерв, Хабр",
|
||||
"info_url": "https://www.comnews.ru/content/239118/2025-05-06/2025-w19/1010/mws-attestovala-odin-krupneyshikh-codov-rossii-vysshemu-urovnyu-zaschity-personalnykh-dannykh",
|
||||
"position": 11
|
||||
},
|
||||
{
|
||||
"slug": "oxygen-msk1",
|
||||
"name_ru": "OXYGEN МСК-1",
|
||||
"name_en": "OXYGEN MSK-1",
|
||||
"name_uk": "OXYGEN МСК-1",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "OXYGEN",
|
||||
"mw": "≈17",
|
||||
"status": "operational",
|
||||
"year": 2011,
|
||||
"photo_source_url": "https://oxygen.cloud/wp-content/uploads/2024/09/msk_1.jpg",
|
||||
"photo_credit": "© OXYGEN",
|
||||
"info_url": "https://oxygen.cloud/data-center/",
|
||||
"position": 12
|
||||
},
|
||||
{
|
||||
"slug": "datahouse-m1",
|
||||
"name_ru": "DataHouse «Магистральный-1»",
|
||||
"name_en": "DataHouse Magistralny-1",
|
||||
"name_uk": "DataHouse «Магістральний-1»",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "DataHouse",
|
||||
"mw": "15",
|
||||
"status": "operational",
|
||||
"year": 2024,
|
||||
"photo_source_url": "https://datahouse.ru/wp-content/themes/datahouse/frontend/build/src/img/dc-page-m1-01-01.jpg",
|
||||
"photo_credit": "© DataHouse (визуализация)",
|
||||
"info_url": "https://datahouse.ru/datacenter/magistralnyj-1/",
|
||||
"position": 13
|
||||
},
|
||||
{
|
||||
"slug": "megafon-spb",
|
||||
"name_ru": "МегаФон Санкт-Петербург",
|
||||
"name_en": "MegaFon St Petersburg",
|
||||
"name_uk": "МегаФон Санкт-Петербург",
|
||||
"city_ru": "Санкт-Петербург",
|
||||
"city_en": "St Petersburg",
|
||||
"city_uk": "Санкт-Петербург",
|
||||
"operator": "МегаФон",
|
||||
"mw": "до 14",
|
||||
"status": "operational",
|
||||
"year": 2025,
|
||||
"photo_source_url": null,
|
||||
"photo_credit": null,
|
||||
"info_url": "https://corp.megafon.ru/press/news/federalnye_novosti/federal/20251009-1139.html",
|
||||
"position": 14
|
||||
},
|
||||
{
|
||||
"slug": "ix-mos1",
|
||||
"name_ru": "IXcellerate MOS1",
|
||||
"name_en": "IXcellerate MOS1",
|
||||
"name_uk": "IXcellerate MOS1",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "IXcellerate",
|
||||
"mw": "13,7",
|
||||
"status": "operational",
|
||||
"year": 2012,
|
||||
"photo_source_url": "https://www.ixcellerate.com/wp-content/uploads/2022/04/mos1_3-scaled.jpg",
|
||||
"photo_credit": "© IXcellerate",
|
||||
"info_url": "https://www.ixcellerate.com/data-centers/moscow-north-campus/",
|
||||
"position": 15
|
||||
},
|
||||
{
|
||||
"slug": "ix-mos2",
|
||||
"name_ru": "IXcellerate MOS2",
|
||||
"name_en": "IXcellerate MOS2",
|
||||
"name_uk": "IXcellerate MOS2",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "IXcellerate",
|
||||
"mw": "13",
|
||||
"status": "operational",
|
||||
"year": 2019,
|
||||
"photo_source_url": "https://ixcellerate.ru/wp-content/uploads/2025/07/mos2_8.jpg",
|
||||
"photo_credit": "© IXcellerate",
|
||||
"info_url": "https://ixcellerate.ru/data-centers/moscow-north-campus/",
|
||||
"position": 16
|
||||
},
|
||||
{
|
||||
"slug": "linx-spb",
|
||||
"name_ru": "Linx Санкт-Петербург",
|
||||
"name_en": "Linx St Petersburg",
|
||||
"name_uk": "Linx Санкт-Петербург",
|
||||
"city_ru": "Санкт-Петербург",
|
||||
"city_en": "St Petersburg",
|
||||
"city_uk": "Санкт-Петербург",
|
||||
"operator": "Linx",
|
||||
"mw": "12",
|
||||
"status": "operational",
|
||||
"year": null,
|
||||
"photo_source_url": "https://linx.ru/upload/iblock/684/auw00pdcma1b24pxlgj2b0kewwppnkrl/Sp_10.jpg.webp",
|
||||
"photo_credit": "© Linx",
|
||||
"info_url": "https://linx.ru/datacenter/colocation/",
|
||||
"position": 17
|
||||
},
|
||||
{
|
||||
"slug": "keypoint-ekb",
|
||||
"name_ru": "Key Point Екатеринбург",
|
||||
"name_en": "Key Point Yekaterinburg",
|
||||
"name_uk": "Key Point Єкатеринбург",
|
||||
"city_ru": "Берёзовский",
|
||||
"city_en": "Beryozovsky",
|
||||
"city_uk": "Березовський",
|
||||
"operator": "Key Point",
|
||||
"mw": "11",
|
||||
"status": "operational",
|
||||
"year": 2026,
|
||||
"photo_source_url": null,
|
||||
"photo_credit": null,
|
||||
"info_url": "https://rg.ru/2026/07/15/reg-urfo/cennost-tehnologij.html",
|
||||
"position": 18
|
||||
},
|
||||
{
|
||||
"slug": "datapro-moscow-2",
|
||||
"name_ru": "DataPro Moscow II",
|
||||
"name_en": "DataPro Moscow II",
|
||||
"name_uk": "DataPro Moscow II",
|
||||
"city_ru": "Химки",
|
||||
"city_en": "Khimki",
|
||||
"city_uk": "Хімки",
|
||||
"operator": "DataPro",
|
||||
"mw": "11",
|
||||
"status": "operational",
|
||||
"year": 2020,
|
||||
"photo_source_url": "https://www.datapro.ru/assets/images/pages/data-centers/2/0.png",
|
||||
"photo_credit": "© DataPro (иллюстрация)",
|
||||
"info_url": "https://www.datapro.ru/data-centers/datapro-moscow-ii",
|
||||
"position": 19
|
||||
},
|
||||
{
|
||||
"slug": "selectel-berzarina",
|
||||
"name_ru": "Selectel Берзарина",
|
||||
"name_en": "Selectel Berzarina",
|
||||
"name_uk": "Selectel Берзаріна",
|
||||
"city_ru": "Москва",
|
||||
"city_en": "Moscow",
|
||||
"city_uk": "Москва",
|
||||
"operator": "Selectel",
|
||||
"mw": "10",
|
||||
"status": "operational",
|
||||
"year": null,
|
||||
"photo_source_url": "https://selectel.ru/images/components/content/data-centers/berzarina-2.jpg",
|
||||
"photo_credit": "© Selectel (машзал)",
|
||||
"info_url": "https://selectel.ru/about/data-centers/",
|
||||
"position": 20
|
||||
},
|
||||
{
|
||||
"slug": "selectel-tsvetochnaya-2",
|
||||
"name_ru": "Selectel Цветочная-2",
|
||||
"name_en": "Selectel Tsvetochnaya-2",
|
||||
"name_uk": "Selectel Цвєточна-2",
|
||||
"city_ru": "Санкт-Петербург",
|
||||
"city_en": "St Petersburg",
|
||||
"city_uk": "Санкт-Петербург",
|
||||
"operator": "Selectel",
|
||||
"mw": "10",
|
||||
"status": "operational",
|
||||
"year": 2015,
|
||||
"photo_source_url": "https://habrastorage.org/r/w1560/getpro/habr/post_images/215/75d/111/21575d1111b364f3112d7f76e9f8fcab.png",
|
||||
"photo_credit": "Selectel, Хабр",
|
||||
"info_url": "https://selectel.ru/about/data-centers/",
|
||||
"position": 21
|
||||
},
|
||||
{
|
||||
"slug": "yandex-sasovo",
|
||||
"name_ru": "Яндекс Сасово",
|
||||
"name_en": "Yandex Sasovo",
|
||||
"name_uk": "Яндекс Сасово",
|
||||
"city_ru": "Сасово",
|
||||
"city_en": "Sasovo",
|
||||
"city_uk": "Сасово",
|
||||
"operator": "Яндекс",
|
||||
"mw": null,
|
||||
"status": "operational",
|
||||
"year": 2014,
|
||||
"photo_source_url": null,
|
||||
"photo_credit": null,
|
||||
"info_url": "https://www.vedomosti.ru/technology/articles/2026/10/08/1235289-chto-izvestno",
|
||||
"position": 22
|
||||
},
|
||||
{
|
||||
"slug": "rtk-ekb-3",
|
||||
"name_ru": "РТК-ЦОД Екатеринбург-3",
|
||||
"name_en": "RTK-DC Yekaterinburg-3",
|
||||
"name_uk": "РТК-ЦОД Єкатеринбург-3",
|
||||
"city_ru": "Екатеринбург",
|
||||
"city_en": "Yekaterinburg",
|
||||
"city_uk": "Єкатеринбург",
|
||||
"operator": "РТК-ЦОД",
|
||||
"mw": "6",
|
||||
"status": "operational",
|
||||
"year": 2018,
|
||||
"photo_source_url": null,
|
||||
"photo_credit": null,
|
||||
"info_url": "https://www.comnews.ru/content/244980/2026-04-27/2026-w18/1007/bitva-za-ural-kto-zagruzit-novye-cody-ekaterinburga",
|
||||
"position": 23
|
||||
},
|
||||
{
|
||||
"slug": "rtk-nn",
|
||||
"name_ru": "РТК-ЦОД Нижний Новгород",
|
||||
"name_en": "RTK-DC Nizhny Novgorod",
|
||||
"name_uk": "РТК-ЦОД Нижній Новгород",
|
||||
"city_ru": "Нижний Новгород",
|
||||
"city_en": "Nizhny Novgorod",
|
||||
"city_uk": "Нижній Новгород",
|
||||
"operator": "РТК-ЦОД",
|
||||
"mw": "5",
|
||||
"status": "operational",
|
||||
"year": 2025,
|
||||
"photo_source_url": "https://habrastorage.org/r/w1560/getpro/habr/upload_files/f8f/6b6/417/f8f6b6417ff5810a2efc00b7f71355c4.jpg",
|
||||
"photo_credit": "РТК-ЦОД, Хабр",
|
||||
"info_url": "https://www.comnews.ru/content/239515/2025-06-02/2025-w23/1011/rtk-cod-zapustil-data-centr-nizhnem-novgorode",
|
||||
"position": 24
|
||||
},
|
||||
{
|
||||
"slug": "selectel-dubrovka-1",
|
||||
"name_ru": "Selectel Дубровка-1",
|
||||
"name_en": "Selectel Dubrovka-1",
|
||||
"name_uk": "Selectel Дубровка-1",
|
||||
"city_ru": "Невская Дубровка",
|
||||
"city_en": "Nevskaya Dubrovka",
|
||||
"city_uk": "Невська Дубровка",
|
||||
"operator": "Selectel",
|
||||
"mw": "2,5",
|
||||
"status": "operational",
|
||||
"year": null,
|
||||
"photo_source_url": "https://habrastorage.org/r/w1560/webt/c7/ye/v5/c7yev5bghjanyobpg0_mnxoiwlm.jpeg",
|
||||
"photo_credit": "Selectel, Хабр",
|
||||
"info_url": "https://selectel.ru/about/data-centers/",
|
||||
"position": 25
|
||||
}
|
||||
],
|
||||
"incidents": [
|
||||
{
|
||||
"slug": "yandex-sasovo",
|
||||
"occurred_on": "2026-10-08",
|
||||
"kind": "strike",
|
||||
"title_ru": "Атака БПЛА и пожар, работа ЦОД полностью остановлена",
|
||||
"title_en": "Drone strike and fire; the datacenter is fully shut down",
|
||||
"title_uk": "Атака БПЛА та пожежа, роботу ЦОД повністю зупинено",
|
||||
"url": "https://www.vedomosti.ru/technology/articles/2026/10/08/1235289-chto-izvestno"
|
||||
},
|
||||
{
|
||||
"slug": "yandex-kaluga",
|
||||
"occurred_on": "2026-10-09",
|
||||
"kind": "strike",
|
||||
"title_ru": "Удары БПЛА: часть инфраструктуры повреждена, несколько модулей выведены из строя",
|
||||
"title_en": "Drone strikes: part of the infrastructure damaged, several modules knocked out",
|
||||
"title_uk": "Удари БПЛА: частину інфраструктури пошкоджено, кілька модулів виведено з ладу",
|
||||
"url": "https://www.bfm.ru/news/620355"
|
||||
}
|
||||
]
|
||||
}
|
||||
39
db/migrations/004_bingo.rb
Normal file
39
db/migrations/004_bingo.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
Sequel.migration do
|
||||
change do
|
||||
# Datacenter bingo (served on BINGO_HOST). Localized fields are plain
|
||||
# per-locale columns; ru is the source of truth, en/uk fall back to it.
|
||||
create_table(:bingo_datacenters) do
|
||||
primary_key :id
|
||||
String :slug, null: false, unique: true
|
||||
Integer :position, null: false
|
||||
String :name_ru, null: false
|
||||
String :name_en
|
||||
String :name_uk
|
||||
String :city_ru, null: false
|
||||
String :city_en
|
||||
String :city_uk
|
||||
String :operator, null: false
|
||||
String :mw # free-form number: "64", "≈36", "25–30"
|
||||
String :status, null: false, default: 'operational' # | building
|
||||
Integer :year
|
||||
String :photo_key # MinIO key under bingo/
|
||||
String :photo_source_url, text: true
|
||||
String :photo_credit
|
||||
String :info_url, text: true
|
||||
DateTime :created_at, null: false
|
||||
end
|
||||
|
||||
create_table(:bingo_incidents) do
|
||||
primary_key :id
|
||||
foreign_key :datacenter_id, :bingo_datacenters, null: false, on_delete: :cascade
|
||||
Date :occurred_on, null: false
|
||||
String :kind, null: false # strike | fire | outage | flood | cooling | other
|
||||
String :title_ru, null: false
|
||||
String :title_en
|
||||
String :title_uk
|
||||
String :url, text: true
|
||||
DateTime :created_at, null: false
|
||||
index :datacenter_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -16,7 +16,15 @@ services:
|
|||
- "traefik.http.routers.asxpio.entrypoints=websecure"
|
||||
- "traefik.http.routers.asxpio.tls=true"
|
||||
- "traefik.http.routers.asxpio.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.asxpio.service=asxpio"
|
||||
- "traefik.http.services.asxpio.loadbalancer.server.port=3000"
|
||||
# Datacenter bingo: same container, routed by Host in config.ru.
|
||||
# BINGO_HOST is interpolated from /opt/asxpio/.env (written by deploy).
|
||||
- "traefik.http.routers.asxpio-bingo.rule=Host(`${BINGO_HOST}`)"
|
||||
- "traefik.http.routers.asxpio-bingo.entrypoints=websecure"
|
||||
- "traefik.http.routers.asxpio-bingo.tls=true"
|
||||
- "traefik.http.routers.asxpio-bingo.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.asxpio-bingo.service=asxpio"
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
|
|
|
|||
197
lib/bingo.rb
Normal file
197
lib/bingo.rb
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
require 'sequel'
|
||||
require 'json'
|
||||
require 'net/http'
|
||||
require 'securerandom'
|
||||
require_relative 's3'
|
||||
|
||||
# Datacenter bingo: a 5x5 card of the largest Russian datacenters; a cell is
|
||||
# crossed out once a publicly sourced incident is recorded for it.
|
||||
module Bingo
|
||||
LOCALES = %w[ru en uk].freeze
|
||||
KINDS = %w[strike fire outage flood cooling other].freeze
|
||||
ICONS = { 'strike' => '💥', 'fire' => '🔥', 'outage' => '⚡', 'flood' => '💧', 'cooling' => '🌡️', 'other' => '⚠️' }.freeze
|
||||
SIZE = 5
|
||||
|
||||
# Every row, column and both diagonals, as 0-based cell indices.
|
||||
LINES = (
|
||||
(0...SIZE).map { |r| (0...SIZE).map { |c| r * SIZE + c } } +
|
||||
(0...SIZE).map { |c| (0...SIZE).map { |r| r * SIZE + c } } +
|
||||
[(0...SIZE).map { |i| i * SIZE + i }, (0...SIZE).map { |i| i * SIZE + SIZE - 1 - i }]
|
||||
).freeze
|
||||
|
||||
T = {
|
||||
'ru' => {
|
||||
title: 'Бинго ЦОДов', accent: 'ЦОДов', lead: 'Бинго',
|
||||
desc: 'Топ-25 дата-центров России по мощности. Клетка зачёркивается после публично подтверждённого инцидента.',
|
||||
crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено',
|
||||
log: 'Журнал инцидентов', empty: 'Пока всё работает. Пока.',
|
||||
bingo: 'БИНГО!', bingo_n: 'БИНГО ×%d!', full: 'ПОЛНАЯ КАРТА. Интернета больше нет.',
|
||||
building: 'строится', mw: 'МВт', since: 'с %d г.', photo: 'Фото', source: 'Источник',
|
||||
close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.',
|
||||
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
|
||||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
|
||||
letters: %w[Б И Н Г О]
|
||||
},
|
||||
'en' => {
|
||||
title: 'Datacenter Bingo', accent: 'Bingo', lead: 'Datacenter',
|
||||
desc: "Russia's 25 largest datacenters by power. A cell is crossed out after a publicly reported incident.",
|
||||
crossed: 'Crossed out', lines: 'lines', updated: 'updated',
|
||||
log: 'Incident log', empty: 'Everything is up. For now.',
|
||||
bingo: 'BINGO!', bingo_n: 'BINGO ×%d!', full: 'FULL CARD. There is no more internet.',
|
||||
building: 'under construction', mw: 'MW', since: 'since %d', photo: 'Photo', source: 'Source',
|
||||
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]
|
||||
},
|
||||
'uk' => {
|
||||
title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго',
|
||||
desc: 'Топ-25 дата-центрів Росії за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.',
|
||||
crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено',
|
||||
log: 'Журнал інцидентів', empty: 'Поки що все працює. Поки що.',
|
||||
bingo: 'БІНГО!', bingo_n: 'БІНГО ×%d!', full: 'ПОВНА КАРТКА. Інтернету більше немає.',
|
||||
building: 'будується', mw: 'МВт', since: 'з %d р.', photo: 'Фото', source: 'Джерело',
|
||||
close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.',
|
||||
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
|
||||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
|
||||
letters: %w[Б І Н Г О]
|
||||
}
|
||||
}.freeze
|
||||
|
||||
# Localized column with fallback to ru.
|
||||
module Localized
|
||||
def l(field, locale)
|
||||
v = self[:"#{field}_#{locale}"].to_s.strip
|
||||
v.empty? ? self[:"#{field}_ru"] : v
|
||||
end
|
||||
end
|
||||
|
||||
class Datacenter < Sequel::Model(:bingo_datacenters)
|
||||
include Localized
|
||||
one_to_many :incidents, class: 'Bingo::Incident', key: :datacenter_id, order: Sequel.desc(:occurred_on)
|
||||
|
||||
def before_create
|
||||
self.created_at ||= Time.now.utc
|
||||
super
|
||||
end
|
||||
|
||||
def building?
|
||||
status == 'building'
|
||||
end
|
||||
|
||||
def photo_file
|
||||
photo_key&.delete_prefix(Photo::PREFIX)
|
||||
end
|
||||
end
|
||||
|
||||
class Incident < Sequel::Model(:bingo_incidents)
|
||||
include Localized
|
||||
many_to_one :datacenter, class: 'Bingo::Datacenter'
|
||||
|
||||
def before_create
|
||||
self.created_at ||= Time.now.utc
|
||||
super
|
||||
end
|
||||
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
|
||||
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)
|
||||
end
|
||||
|
||||
def win_cells
|
||||
@win_cells ||= wins.flatten.to_set
|
||||
end
|
||||
end
|
||||
|
||||
# Photos live in the app's MinIO bucket under bingo/ and are proxied by the
|
||||
# app, so the bucket stays private.
|
||||
module Photo
|
||||
PREFIX = 'bingo/'.freeze
|
||||
MAX_BYTES = 8 * 1024 * 1024
|
||||
FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}\.(jpg|png|webp)\z/
|
||||
TYPES = { 'jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' }.freeze
|
||||
|
||||
class Error < StandardError; end
|
||||
|
||||
module_function
|
||||
|
||||
def sniff(bytes)
|
||||
return 'jpg' if bytes.start_with?("\xFF\xD8\xFF".b)
|
||||
return 'png' if bytes.start_with?("\x89PNG\r\n\x1A\n".b)
|
||||
return 'webp' if bytes[0, 4] == 'RIFF' && bytes[8, 4] == 'WEBP'
|
||||
nil
|
||||
end
|
||||
|
||||
# Stores bytes and returns the new key; the old object is left in place
|
||||
# (cheap, and keeps cached URLs valid until their max-age runs out).
|
||||
def store(slug, bytes)
|
||||
bytes = bytes.b
|
||||
raise Error, 'image too large (max 8 MB)' if bytes.bytesize > MAX_BYTES
|
||||
ext = sniff(bytes) or raise Error, 'not a JPEG, PNG or WebP image'
|
||||
key = "#{PREFIX}#{slug}-#{SecureRandom.hex(4)}.#{ext}"
|
||||
S3.put(key, bytes, content_type: TYPES[ext])
|
||||
key
|
||||
end
|
||||
|
||||
# Admin-only "import from URL". https only, a few redirects, size-capped.
|
||||
def fetch(url, redirects: 3)
|
||||
uri = URI(url)
|
||||
raise Error, 'https URL required' unless uri.is_a?(URI::HTTPS)
|
||||
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http|
|
||||
req = Net::HTTP::Get.new(uri)
|
||||
req['User-Agent'] = 'Mozilla/5.0 (bingo photo import)'
|
||||
http.request(req) do |res|
|
||||
case res
|
||||
when Net::HTTPRedirection
|
||||
raise Error, 'too many redirects' if redirects.zero?
|
||||
return fetch(URI.join(uri, res['location']).to_s, redirects: redirects - 1)
|
||||
when Net::HTTPSuccess
|
||||
body = +''
|
||||
res.read_body do |chunk|
|
||||
body << chunk
|
||||
raise Error, 'image too large (max 8 MB)' if body.bytesize > MAX_BYTES
|
||||
end
|
||||
return body
|
||||
else
|
||||
raise Error, "HTTP #{res.code} from #{uri.host}"
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue URI::InvalidURIError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, Errno::ECONNREFUSED => e
|
||||
raise Error, e.message
|
||||
end
|
||||
|
||||
def read(file)
|
||||
raise Error, 'bad name' unless file =~ FILE_RE
|
||||
[S3.get(PREFIX + file), TYPES[Regexp.last_match(1)]]
|
||||
end
|
||||
end
|
||||
|
||||
# First boot: fill the card (and any already-known incidents) from
|
||||
# db/bingo_seed.json. Never touches rows once any exist; edits happen in
|
||||
# /admin/bingo.
|
||||
def self.seed!
|
||||
return if Datacenter.count.positive?
|
||||
path = File.join($root, 'db', 'bingo_seed.json')
|
||||
return unless File.exist?(path)
|
||||
seed = JSON.parse(File.read(path, encoding: 'UTF-8'))
|
||||
cols = Datacenter.columns.map(&:to_s) - %w[id created_at]
|
||||
DB.connection.transaction do
|
||||
ids = seed.fetch('datacenters').each_with_index.to_h do |row, i|
|
||||
dc = Datacenter.create(row.slice(*cols).merge('position' => row['position'] || i + 1))
|
||||
[dc.slug, dc.id]
|
||||
end
|
||||
seed.fetch('incidents', []).each do |row|
|
||||
Incident.create(row.slice(*(Incident.columns.map(&:to_s) - %w[id created_at datacenter_id]))
|
||||
.merge('datacenter_id' => ids.fetch(row.fetch('slug'))))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
144
lib/bingo_admin.rb
Normal file
144
lib/bingo_admin.rb
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
require 'uri'
|
||||
|
||||
# /admin/bingo/* on asxp.io: edit the card and record incidents for BingoWeb.
|
||||
# Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply.
|
||||
module BingoAdmin
|
||||
DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator mw status
|
||||
photo_credit photo_source_url info_url].freeze
|
||||
|
||||
module Helpers
|
||||
def bingo_public_url
|
||||
ENV['BINGO_HOST'] && "https://#{ENV['BINGO_HOST']}/ru"
|
||||
end
|
||||
|
||||
def http_url?(s)
|
||||
s.empty? || s.match?(URI::DEFAULT_PARSER.make_regexp(%w[http https]))
|
||||
end
|
||||
|
||||
def blank_nil(s)
|
||||
s = s.to_s.strip
|
||||
s.empty? ? nil : s
|
||||
end
|
||||
end
|
||||
|
||||
def self.registered(app)
|
||||
app.helpers Helpers
|
||||
|
||||
app.get '/admin/bingo' do
|
||||
@page_title = 'Bingo · admin'
|
||||
@noindex = true
|
||||
@dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all
|
||||
@incidents = Bingo::Incident.order(Sequel.desc(:occurred_on), Sequel.desc(:id)).eager(:datacenter).all
|
||||
@flash = session.delete(:bingo_flash)
|
||||
erb :'admin/bingo/index'
|
||||
end
|
||||
|
||||
app.get '/admin/bingo/dc/:id' do
|
||||
@dc = Bingo::Datacenter[params[:id].to_i] or halt 404
|
||||
@page_title = "#{@dc.name_ru} · bingo admin"
|
||||
@noindex = true
|
||||
@flash = session.delete(:bingo_flash)
|
||||
erb :'admin/bingo/edit'
|
||||
end
|
||||
|
||||
app.post '/admin/bingo/dc/:id' do
|
||||
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
|
||||
DC_TEXT.each { |f| dc[f.to_sym] = blank_nil(params[f]) }
|
||||
dc.position = params[:position].to_i if params[:position].to_s =~ /\A\d+\z/
|
||||
dc.year = params[:year].to_s =~ /\A\d{4}\z/ ? params[:year].to_i : nil
|
||||
dc.status = 'operational' unless %w[operational building].include?(dc.status)
|
||||
errs = []
|
||||
errs << 'Name (ru), city (ru) and operator are required' if [dc.name_ru, dc.city_ru, dc.operator].any?(&:nil?)
|
||||
errs << 'URLs must be http(s)' unless [dc.photo_source_url, dc.info_url].all? { |u| http_url?(u.to_s) }
|
||||
if errs.empty?
|
||||
dc.save_changes
|
||||
session[:bingo_flash] = 'Saved.'
|
||||
else
|
||||
session[:bingo_flash] = errs.join('; ')
|
||||
end
|
||||
redirect "/admin/bingo/dc/#{dc.id}"
|
||||
end
|
||||
|
||||
# Either an uploaded file or "import from URL" (defaults to photo_source_url).
|
||||
app.post '/admin/bingo/dc/:id/photo' do
|
||||
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
|
||||
begin
|
||||
bytes =
|
||||
if params[:photo].is_a?(Hash) && params[:photo][:tempfile]
|
||||
params[:photo][:tempfile].read(Bingo::Photo::MAX_BYTES + 1)
|
||||
else
|
||||
url = blank_nil(params[:url]) || dc.photo_source_url or raise Bingo::Photo::Error, 'no file and no URL'
|
||||
dc.photo_source_url = url
|
||||
Bingo::Photo.fetch(url)
|
||||
end
|
||||
dc.photo_key = Bingo::Photo.store(dc.slug, bytes)
|
||||
dc.save_changes
|
||||
session[:bingo_flash] = 'Photo stored.'
|
||||
rescue Bingo::Photo::Error => e
|
||||
session[:bingo_flash] = "Photo failed: #{e.message}"
|
||||
end
|
||||
redirect "/admin/bingo/dc/#{dc.id}"
|
||||
end
|
||||
|
||||
app.post '/admin/bingo/dc/:id/photo/delete' do
|
||||
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
|
||||
dc.update(photo_key: nil)
|
||||
session[:bingo_flash] = 'Photo removed from card (object kept in bucket).'
|
||||
redirect "/admin/bingo/dc/#{dc.id}"
|
||||
end
|
||||
|
||||
# Bulk: import every missing photo from its photo_source_url.
|
||||
app.post '/admin/bingo/photos/import' do
|
||||
ok, failed = 0, []
|
||||
Bingo::Datacenter.where(photo_key: nil).exclude(photo_source_url: nil).each do |dc|
|
||||
dc.update(photo_key: Bingo::Photo.store(dc.slug, Bingo::Photo.fetch(dc.photo_source_url)))
|
||||
ok += 1
|
||||
rescue Bingo::Photo::Error => e
|
||||
failed << "#{dc.slug}: #{e.message}"
|
||||
end
|
||||
session[:bingo_flash] = (["Imported #{ok}."] + failed).join(' | ')
|
||||
redirect '/admin/bingo'
|
||||
end
|
||||
|
||||
app.get '/admin/bingo/p/:file' do
|
||||
bytes, type = Bingo::Photo.read(params[:file])
|
||||
content_type type
|
||||
bytes
|
||||
rescue Bingo::Photo::Error, Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound
|
||||
halt 404
|
||||
end
|
||||
|
||||
app.post '/admin/bingo/incidents' do
|
||||
dc = Bingo::Datacenter[params[:datacenter_id].to_i]
|
||||
date = begin
|
||||
Date.iso8601(params[:occurred_on].to_s)
|
||||
rescue Date::Error
|
||||
nil
|
||||
end
|
||||
url = params[:url].to_s.strip
|
||||
errs = []
|
||||
errs << 'Pick a datacenter' unless dc
|
||||
errs << 'Valid date required' unless date
|
||||
errs << 'Unknown kind' unless Bingo::KINDS.include?(params[:kind])
|
||||
errs << 'Title (ru) required' if params[:title_ru].to_s.strip.empty?
|
||||
errs << 'Source URL must be http(s)' unless http_url?(url)
|
||||
if errs.empty?
|
||||
Bingo::Incident.create(
|
||||
datacenter_id: dc.id, occurred_on: date, kind: params[:kind],
|
||||
title_ru: params[:title_ru].strip, title_en: blank_nil(params[:title_en]),
|
||||
title_uk: blank_nil(params[:title_uk]), url: blank_nil(url)
|
||||
)
|
||||
session[:bingo_flash] = "Incident recorded for #{dc.name_ru}."
|
||||
else
|
||||
session[:bingo_flash] = errs.join('; ')
|
||||
end
|
||||
redirect '/admin/bingo'
|
||||
end
|
||||
|
||||
app.post '/admin/bingo/incidents/:id/delete' do
|
||||
Bingo::Incident.where(id: params[:id].to_i).delete
|
||||
session[:bingo_flash] = 'Incident deleted.'
|
||||
redirect '/admin/bingo'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -40,6 +40,10 @@ module S3
|
|||
key
|
||||
end
|
||||
|
||||
def get(key)
|
||||
client.get_object(bucket: bucket, key: key).body.read
|
||||
end
|
||||
|
||||
def exists?(key)
|
||||
client.head_object(bucket: bucket, key: key)
|
||||
true
|
||||
|
|
|
|||
145
test/bingo_test.rb
Normal file
145
test/bingo_test.rb
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
require_relative 'test_helper'
|
||||
|
||||
class BingoTest < Minitest::Test
|
||||
include Rack::Test::Methods
|
||||
|
||||
JPEG = "\xFF\xD8\xFF\xE0fakejpeg".b
|
||||
|
||||
def app
|
||||
@app || BingoWeb
|
||||
end
|
||||
|
||||
def setup
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
DB.connection[:bingo_incidents].delete
|
||||
DB.connection[:bingo_datacenters].delete
|
||||
@dcs = (1..25).map do |n|
|
||||
Bingo::Datacenter.create(slug: "dc-#{n}", position: n, name_ru: "ЦОД #{n}", name_en: ("DC #{n}" if n == 1),
|
||||
city_ru: 'Москва', city_en: 'Moscow', operator: 'Op', mw: n.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def admin_env
|
||||
{ 'HTTP_AUTHORIZATION' =>
|
||||
"Basic #{Base64.strict_encode64("#{ENV['ADMIN_USER']}:#{ENV['ADMIN_PASSWORD']}")}" }
|
||||
end
|
||||
|
||||
def hit!(idx, kind: 'outage')
|
||||
Bingo::Incident.create(datacenter_id: @dcs[idx].id, occurred_on: Date.today, kind: kind,
|
||||
title_ru: "Авария #{idx}", title_uk: "Аварія #{idx}")
|
||||
end
|
||||
|
||||
def test_root_redirects_by_accept_language
|
||||
get '/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'uk-UA,uk;q=0.9,en;q=0.8'
|
||||
assert_equal 302, last_response.status
|
||||
assert_match %r{/uk\z}, last_response.location
|
||||
get '/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'de-DE'
|
||||
assert_match %r{/en\z}, last_response.location
|
||||
end
|
||||
|
||||
def test_locales_render_with_ru_fallback
|
||||
get '/en'
|
||||
assert last_response.ok?
|
||||
assert_includes last_response.body, 'DC 1' # localized
|
||||
assert_includes last_response.body, 'ЦОД 2' # falls back to ru
|
||||
assert_includes last_response.body, 'Moscow'
|
||||
get '/uk'
|
||||
assert_includes last_response.body, 'Бінго'
|
||||
get '/xx'
|
||||
assert_equal 404, last_response.status
|
||||
end
|
||||
|
||||
def test_board_detects_lines
|
||||
(0..4).each { |i| hit!(i) } # top row
|
||||
[6, 12, 18, 24].each { |i| hit!(i) } # + main diagonal (0 already hit)
|
||||
board = Bingo::Board.load
|
||||
assert_equal 2, board.wins.size
|
||||
get '/ru'
|
||||
assert_includes last_response.body, 'БИНГО ×2!'
|
||||
assert_includes last_response.body, 'Авария 24'
|
||||
get '/uk'
|
||||
assert_includes last_response.body, 'Аварія 24'
|
||||
end
|
||||
|
||||
def test_no_banner_without_line
|
||||
hit!(0)
|
||||
get '/ru'
|
||||
refute_includes last_response.body, 'class="banner"'
|
||||
assert_includes last_response.body, 'Авария 0'
|
||||
end
|
||||
|
||||
def test_photo_store_and_serve
|
||||
Aws.config[:s3] = { stub_responses: { get_object: { body: JPEG } } }
|
||||
S3.instance_variable_set(:@client, nil)
|
||||
key = Bingo::Photo.store('dc-1', JPEG)
|
||||
assert_match %r{\Abingo/dc-1-[0-9a-f]{8}\.jpg\z}, key
|
||||
@dcs[0].update(photo_key: key)
|
||||
get "/p/#{@dcs[0].photo_file}"
|
||||
assert last_response.ok?
|
||||
assert_equal 'image/jpeg', last_response.content_type
|
||||
get '/p/..%2Finvoices%2Fx.pdf'
|
||||
assert_equal 404, last_response.status
|
||||
ensure
|
||||
Aws.config[:s3] = { stub_responses: true }
|
||||
S3.instance_variable_set(:@client, nil)
|
||||
end
|
||||
|
||||
def test_photo_rejects_non_images
|
||||
assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '<html>') }
|
||||
end
|
||||
|
||||
# --- admin (served by AsxpioWeb) -------------------------------------------
|
||||
|
||||
def test_admin_requires_auth
|
||||
@app = AsxpioWeb
|
||||
get '/admin/bingo'
|
||||
assert_equal 401, last_response.status
|
||||
end
|
||||
|
||||
def test_admin_records_and_deletes_incident
|
||||
@app = AsxpioWeb
|
||||
get '/admin/bingo', {}, admin_env
|
||||
assert last_response.ok?
|
||||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||
|
||||
post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dcs[3].id,
|
||||
occurred_on: '2026-10-01', kind: 'fire', title_ru: 'Пожар',
|
||||
url: 'https://example.com/news' }, admin_env
|
||||
assert_equal 302, last_response.status
|
||||
inc = Bingo::Incident.first
|
||||
assert_equal 'fire', inc.kind
|
||||
assert_equal @dcs[3].id, inc.datacenter_id
|
||||
|
||||
post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dcs[3].id,
|
||||
occurred_on: '2026-10-01', kind: 'fire', title_ru: 'x',
|
||||
url: 'javascript:alert(1)' }, admin_env
|
||||
assert_equal 1, Bingo::Incident.count
|
||||
|
||||
post "/admin/bingo/incidents/#{inc.id}/delete", { authenticity_token: token }, admin_env
|
||||
assert_equal 0, Bingo::Incident.count
|
||||
end
|
||||
|
||||
def test_admin_edits_datacenter
|
||||
@app = AsxpioWeb
|
||||
get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env
|
||||
assert last_response.ok?
|
||||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||
post "/admin/bingo/dc/#{@dcs[0].id}", { authenticity_token: token, name_ru: 'Новое', city_ru: 'Калуга',
|
||||
name_uk: 'Нове', operator: 'Yandex', mw: '63', status: 'building',
|
||||
year: '2027', position: '1' }, admin_env
|
||||
@dcs[0].refresh
|
||||
assert_equal 'Нове', @dcs[0].name_uk
|
||||
assert @dcs[0].building?
|
||||
assert_equal 2027, @dcs[0].year
|
||||
end
|
||||
|
||||
def test_upload_photo_via_admin
|
||||
@app = AsxpioWeb
|
||||
get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env
|
||||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||
file = Rack::Test::UploadedFile.new(StringIO.new(JPEG), 'image/jpeg', original_filename: 'a.jpg')
|
||||
post "/admin/bingo/dc/#{@dcs[0].id}/photo", { authenticity_token: token, photo: file }, admin_env
|
||||
assert_equal 302, last_response.status
|
||||
assert_match %r{\Abingo/dc-1-}, @dcs[0].refresh.photo_key
|
||||
end
|
||||
end
|
||||
|
|
@ -27,6 +27,7 @@ else
|
|||
end
|
||||
|
||||
require_relative '../asxpio'
|
||||
require_relative '../bingo_web'
|
||||
require 'minitest/autorun'
|
||||
require 'rack/test'
|
||||
|
||||
|
|
|
|||
52
views/admin/bingo/edit.erb
Normal file
52
views/admin/bingo/edit.erb
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<div class="header">
|
||||
<h1>#<%= @dc.position %> <%= @dc.name_ru %></h1>
|
||||
<p class="tagline"><a href="/admin/bingo">← bingo</a></p>
|
||||
</div>
|
||||
|
||||
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
|
||||
|
||||
<section class="section">
|
||||
<h2>Photo</h2>
|
||||
<% if @dc.photo_file %>
|
||||
<p><img src="/admin/bingo/p/<%= @dc.photo_file %>" alt="" style="max-width: 100%; max-height: 280px" /></p>
|
||||
<form method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo/delete">
|
||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||
<button type="submit" class="link-button">remove from card</button>
|
||||
</form>
|
||||
<% end %>
|
||||
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo" enctype="multipart/form-data">
|
||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||
<div class="field"><label for="photo">Upload <span class="optional">(JPEG/PNG/WebP, ≤ 8 MB)</span></label><input type="file" id="photo" name="photo" accept="image/jpeg,image/png,image/webp" /></div>
|
||||
<div class="field"><label for="photo_url">…or import from URL <span class="optional">(blank = saved source URL)</span></label><input type="url" id="photo_url" name="url" placeholder="<%= @dc.photo_source_url %>" /></div>
|
||||
<button type="submit" class="download-button">Store photo</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Details</h2>
|
||||
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>" autocomplete="off">
|
||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||
<div class="invoice-grid">
|
||||
<% %w[ru en uk].each do |l| %>
|
||||
<div class="field"><label for="name_<%= l %>">Name (<%= l %>)</label><input type="text" id="name_<%= l %>" name="name_<%= l %>" value="<%= @dc[:"name_#{l}"] %>" <%= 'required' if l == 'ru' %> /></div>
|
||||
<% end %>
|
||||
<% %w[ru en uk].each do |l| %>
|
||||
<div class="field"><label for="city_<%= l %>">City (<%= l %>)</label><input type="text" id="city_<%= l %>" name="city_<%= l %>" value="<%= @dc[:"city_#{l}"] %>" <%= 'required' if l == 'ru' %> /></div>
|
||||
<% end %>
|
||||
<div class="field"><label for="operator">Operator</label><input type="text" id="operator" name="operator" value="<%= @dc.operator %>" required /></div>
|
||||
<div class="field"><label for="mw">MW <span class="optional">(number or range)</span></label><input type="text" id="mw" name="mw" value="<%= @dc.mw %>" /></div>
|
||||
<div class="field">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<% %w[operational building].each do |s| %><option <%= 'selected' if @dc.status == s %>><%= s %></option><% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label for="year">Year</label><input type="text" id="year" name="year" value="<%= @dc.year %>" inputmode="numeric" /></div>
|
||||
<div class="field"><label for="position">Position (1–25)</label><input type="text" id="position" name="position" value="<%= @dc.position %>" inputmode="numeric" /></div>
|
||||
</div>
|
||||
<div class="field"><label for="photo_credit">Photo credit</label><input type="text" id="photo_credit" name="photo_credit" value="<%= @dc.photo_credit %>" /></div>
|
||||
<div class="field"><label for="photo_source_url">Photo source URL</label><input type="url" id="photo_source_url" name="photo_source_url" value="<%= @dc.photo_source_url %>" /></div>
|
||||
<div class="field"><label for="info_url">Info / fact source URL</label><input type="url" id="info_url" name="info_url" value="<%= @dc.info_url %>" /></div>
|
||||
<button type="submit" class="download-button">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
91
views/admin/bingo/index.erb
Normal file
91
views/admin/bingo/index.erb
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<div class="header">
|
||||
<h1>Bingo</h1>
|
||||
<p class="tagline">
|
||||
<a href="/admin/invoices">invoices</a>
|
||||
<% if bingo_public_url %> · <a href="<%= bingo_public_url %>" target="_blank" rel="noopener">public card ↗</a><% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
|
||||
|
||||
<section class="section">
|
||||
<h2>Record incident</h2>
|
||||
<form class="invoice-form" method="post" action="/admin/bingo/incidents" autocomplete="off">
|
||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||
<div class="invoice-grid">
|
||||
<div class="field">
|
||||
<label for="datacenter_id">Datacenter</label>
|
||||
<select id="datacenter_id" name="datacenter_id" required>
|
||||
<% @dcs.each do |dc| %><option value="<%= dc.id %>">#<%= dc.position %> <%= dc.name_ru %></option><% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="occurred_on">Date</label>
|
||||
<input type="date" id="occurred_on" name="occurred_on" value="<%= Date.today %>" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kind">Kind</label>
|
||||
<select id="kind" name="kind">
|
||||
<% Bingo::KINDS.each do |k| %><option value="<%= k %>"><%= Bingo::ICONS[k] %> <%= k %></option><% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label for="title_ru">Title (ru)</label><input type="text" id="title_ru" name="title_ru" required maxlength="300" /></div>
|
||||
<div class="field"><label for="title_en">Title (en) <span class="optional">(falls back to ru)</span></label><input type="text" id="title_en" name="title_en" maxlength="300" /></div>
|
||||
<div class="field"><label for="title_uk">Title (uk) <span class="optional">(falls back to ru)</span></label><input type="text" id="title_uk" name="title_uk" maxlength="300" /></div>
|
||||
<div class="field"><label for="url">Source URL</label><input type="url" id="url" name="url" placeholder="https://" /></div>
|
||||
<button type="submit" class="download-button">Cross it out</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Incidents</h2>
|
||||
<% if @incidents.empty? %>
|
||||
<p>None yet.</p>
|
||||
<% else %>
|
||||
<table class="invoice-list">
|
||||
<thead><tr><th>Date</th><th>Datacenter</th><th>Kind</th><th>Title</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @incidents.each do |i| %>
|
||||
<tr>
|
||||
<td class="mono"><%= i.occurred_on %></td>
|
||||
<td><%= i.datacenter.name_ru %></td>
|
||||
<td><%= Bingo::ICONS[i.kind] %> <%= i.kind %></td>
|
||||
<td><% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener"><%= i.title_ru %></a><% else %><%= i.title_ru %><% end %></td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/bingo/incidents/<%= i.id %>/delete" onsubmit="return confirm('Delete this incident?')">
|
||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||
<button type="submit" class="link-button">delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Card</h2>
|
||||
<form method="post" action="/admin/bingo/photos/import">
|
||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||
<button type="submit" class="link-button">↻ Import all missing photos from their source URLs</button>
|
||||
</form>
|
||||
<table class="invoice-list">
|
||||
<thead><tr><th>#</th><th>Photo</th><th>Name</th><th>Operator</th><th class="num">MW</th><th>Status</th><th class="num">Incidents</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @dcs.each do |dc| %>
|
||||
<tr>
|
||||
<td class="mono"><%= dc.position %></td>
|
||||
<td><% if dc.photo_file %><img src="/admin/bingo/p/<%= dc.photo_file %>" alt="" width="64" height="40" style="object-fit: cover" /><% else %>—<% end %></td>
|
||||
<td><%= dc.name_ru %><br /><span class="optional"><%= dc.city_ru %></span></td>
|
||||
<td><%= dc.operator %></td>
|
||||
<td class="num mono"><%= dc.mw %></td>
|
||||
<td><%= dc.status %></td>
|
||||
<td class="num mono"><%= dc.incidents.size %></td>
|
||||
<td class="actions"><a href="/admin/bingo/dc/<%= dc.id %>">edit</a></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="header">
|
||||
<h1>Invoices</h1>
|
||||
<p class="tagline"><a href="/admin/invoices/new">+ New invoice</a></p>
|
||||
<p class="tagline"><a href="/admin/invoices/new">+ New invoice</a> · <a href="/admin/bingo">bingo</a></p>
|
||||
</div>
|
||||
|
||||
<% if @invoices.empty? %>
|
||||
|
|
|
|||
100
views/bingo/index.erb
Normal file
100
views/bingo/index.erb
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<% b = @board %>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<p class="lead"><%= t(:desc) %></p>
|
||||
<p class="meta">
|
||||
<%= t(:crossed) %> <b><%= b.hit.count(true) %></b>/<%= b.cells.size %>
|
||||
· <%= t(:lines) %> <b><%= b.wins.size %></b>
|
||||
<% if b.updated_on %>· <%= t(:updated) %> <b><%= fmt_date(b.updated_on) %></b><% end %>
|
||||
</p>
|
||||
|
||||
<% if b.wins.any? %>
|
||||
<div class="banner">
|
||||
<%= b.wins.size == Bingo::LINES.size ? t(:full) : b.wins.size > 1 ? format(t(:bingo_n), b.wins.size) : t(:bingo) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="letters" aria-hidden="true"><% t(:letters).each do |ch| %><div><%= ch %></div><% end %></div>
|
||||
<div class="grid">
|
||||
<% b.cells.each_with_index do |dc, idx| %>
|
||||
<% photo = photo_path(dc) %>
|
||||
<button type="button" data-dlg="dlg-<%= dc.slug %>"
|
||||
class="cell<%= ' photo' if photo %><%= ' hit' if b.hit[idx] %><%= ' win' if b.win_cells.include?(idx) %>"
|
||||
<% if photo %>style="background-image: url('<%= photo %>')"<% end %>
|
||||
aria-label="<%= dc.l(:name, @locale) %><%= " — #{kind_label(dc.incidents.first.kind)}" if b.hit[idx] %>">
|
||||
<div>
|
||||
<div class="n">#<%= idx + 1 %><%= " · #{mw(dc)}" if mw(dc) %></div>
|
||||
<% if dc.building? %><div class="tag"><%= t(:building) %></div><% end %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="name"><%= dc.l(:name, @locale) %></div>
|
||||
<div class="sub"><%= dc.l(:city, @locale) %></div>
|
||||
</div>
|
||||
<% if b.hit[idx] %><div class="stamp"><%= kind_icon(dc.incidents.first.kind) %></div><% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2><%= t(:log) %></h2>
|
||||
<ul class="log">
|
||||
<% if b.incidents.empty? %>
|
||||
<li class="empty"><%= t(:empty) %></li>
|
||||
<% end %>
|
||||
<% b.incidents.each do |i| %>
|
||||
<li>
|
||||
<time datetime="<%= i.occurred_on %>"><%= fmt_date(i.occurred_on) %></time>
|
||||
<span><%= kind_icon(i.kind) %></span>
|
||||
<div>
|
||||
<% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener nofollow"><%= i.l(:title, @locale) %></a><% else %><%= i.l(:title, @locale) %><% end %>
|
||||
<div class="dc"><%= i.datacenter.l(:name, @locale) %> · <%= kind_label(i.kind) %></div>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer><%= t(:footer) %></footer>
|
||||
</main>
|
||||
|
||||
<% b.cells.each do |dc| %>
|
||||
<dialog id="dlg-<%= dc.slug %>">
|
||||
<% if (photo = photo_path(dc)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>" loading="lazy"><% end %>
|
||||
<div class="body">
|
||||
<h3><%= dc.l(:name, @locale) %></h3>
|
||||
<div class="meta">
|
||||
<%= [dc.operator, mw(dc), dc.l(:city, @locale), dc.building? ? t(:building) : (dc.year && format(t(:since), dc.year))].compact.join(' · ') %>
|
||||
</div>
|
||||
<% if dc.photo_credit || dc.info_url %>
|
||||
<div class="credit">
|
||||
<% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %>
|
||||
<% if dc.info_url %> · <a href="<%= dc.info_url %>" target="_blank" rel="noopener nofollow"><%= t(:source) %></a><% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if dc.incidents.empty? %>
|
||||
<p class="empty"><%= t(:no_incidents) %></p>
|
||||
<% else %>
|
||||
<ul>
|
||||
<% dc.incidents.each do |i| %>
|
||||
<li><%= kind_icon(i.kind) %> <%= fmt_date(i.occurred_on) %> —
|
||||
<% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener nofollow"><%= i.l(:title, @locale) %></a><% else %><%= i.l(:title, @locale) %><% end %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
<form method="dialog"><button><%= t(:close) %></button></form>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-dlg]').forEach(b =>
|
||||
b.addEventListener('click', () => document.getElementById(b.dataset.dlg).showModal()));
|
||||
</script>
|
||||
107
views/bingo/layout.erb
Normal file
107
views/bingo/layout.erb
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<% base = "https://#{ENV.fetch('BINGO_HOST', request.host)}" %>
|
||||
<!doctype html>
|
||||
<html lang="<%= @locale %>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= t(:title) %></title>
|
||||
<meta name="description" content="<%= t(:desc) %>">
|
||||
<link rel="canonical" href="<%= base %>/<%= @locale %>">
|
||||
<% Bingo::LOCALES.each do |l| %>
|
||||
<link rel="alternate" hreflang="<%= l %>" href="<%= base %>/<%= l %>">
|
||||
<% end %>
|
||||
<link rel="alternate" hreflang="x-default" href="<%= base %>/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="<%= t(:title) %>">
|
||||
<meta property="og:description" content="<%= t(:desc) %>">
|
||||
<meta property="og:url" content="<%= base %>/<%= @locale %>">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎱</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f1ea; --card: #fffdf8; --ink: #1d1b18; --muted: #6f6a60; --line: #d9d3c6;
|
||||
--hit: #c8361d; --hit-bg: #fbe3dc; --win: #e7b416; --on-win: #1d1b18;
|
||||
--shade: rgb(0 0 0 / .62); --on-photo: #fff; --on-photo-muted: rgb(255 255 255 / .75);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #121212; --card: #1c1b19; --ink: #ece7dc; --muted: #9a9384; --line: #34312b;
|
||||
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #121212; --card: #1c1b19; --ink: #ece7dc; --muted: #9a9384; --line: #34312b;
|
||||
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
main { max-width: 980px; margin: 0 auto; padding: 24px 16px 48px; }
|
||||
a { color: inherit; }
|
||||
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); }
|
||||
.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); }
|
||||
.meta { color: var(--muted); font-size: 14px; }
|
||||
.meta b { color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.lead { color: var(--muted); margin: 0 0 16px; max-width: 60ch; }
|
||||
.banner { margin: 0 0 16px; padding: 12px 16px; border-radius: 10px; background: var(--win); color: var(--on-win); font-weight: 700; font-size: 18px; }
|
||||
|
||||
.letters, .grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
|
||||
.letters div { text-align: center; font-weight: 900; font-size: clamp(22px, 5vw, 36px); color: var(--hit); }
|
||||
.grid { margin-top: 6px; }
|
||||
.cell {
|
||||
position: relative; aspect-ratio: 1; padding: 8px; overflow: hidden; width: 100%;
|
||||
background: var(--card) center / cover no-repeat; 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;
|
||||
}
|
||||
.cell.photo { color: var(--on-photo); border-color: transparent; }
|
||||
.cell.photo::before { content: ""; position: absolute; inset: 0; background: linear-gradient(to top, var(--shade), rgb(0 0 0 / .15) 70%); }
|
||||
.cell > * { position: relative; z-index: 1; }
|
||||
.cell .n { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.cell.photo .n, .cell.photo .sub { color: var(--on-photo-muted); }
|
||||
.cell .name { font-weight: 700; font-size: clamp(11px, 1.9vw, 15px); line-height: 1.15; }
|
||||
.cell .sub { font-size: clamp(10px, 1.4vw, 12px); color: var(--muted); }
|
||||
.cell .tag { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.cell.hit { border-color: var(--hit); }
|
||||
.cell.hit:not(.photo) { background-color: var(--hit-bg); }
|
||||
.cell.hit::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; opacity: .8;
|
||||
background:
|
||||
linear-gradient(to top right, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px)),
|
||||
linear-gradient(to top left, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px));
|
||||
}
|
||||
.cell .stamp { position: absolute; right: 6px; top: 4px; font-size: clamp(16px, 3vw, 24px); z-index: 3; }
|
||||
.cell.win { box-shadow: 0 0 0 3px var(--win); }
|
||||
.cell:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
|
||||
section { margin-top: 32px; }
|
||||
h2 { font-size: 18px; margin: 0 0 12px; }
|
||||
.log { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.log li { display: grid; grid-template-columns: 96px 28px 1fr; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.log time, .log .dc { color: var(--muted); }
|
||||
.log .dc { font-size: 13px; }
|
||||
.empty { color: var(--muted); }
|
||||
|
||||
dialog { width: min(520px, calc(100vw - 32px)); border: 1px solid var(--line); border-radius: 12px; background: var(--card); color: var(--ink); padding: 0; }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .55); }
|
||||
dialog img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
|
||||
dialog .body { padding: 16px 20px 20px; }
|
||||
dialog h3 { margin: 0 0 4px; }
|
||||
dialog .credit { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
dialog ul { padding-left: 18px; }
|
||||
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; }
|
||||
@media (max-width: 560px) {
|
||||
.cell { padding: 5px; border-radius: 6px; }
|
||||
.cell .sub, .cell .n, .cell .tag { display: none; }
|
||||
.letters, .grid { gap: 4px; }
|
||||
.log li { grid-template-columns: 84px 24px 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<%== yield %>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue