bingo: move the public site to the datacenterbingo repo
The public card now runs from its own repo and container, with a Traefik router of its own. Drop BingoWeb, its views, icons, Host dispatch and the asxpio-bingo router here; keep the admin, the schema and the photo pipeline, which the new site reads through a read-only role. BINGO_HOST stays for IndexNow pings and the admin's site link. Assisted-by: Claude:opus-5
This commit is contained in:
parent
510998c0ed
commit
849980042e
19 changed files with 34 additions and 1007 deletions
|
|
@ -18,9 +18,8 @@ MAIL_TO=ie@asxp.io
|
||||||
# Legacy: LTC_ADDRESS still fills the LTC default when set.
|
# Legacy: LTC_ADDRESS still fills the LTC default when set.
|
||||||
# LTC_ADDRESS=ltc1...
|
# LTC_ADDRESS=ltc1...
|
||||||
|
|
||||||
# Datacenter bingo hostname (same process, routed by Host). Unset = bingo off.
|
# Public bingo hostname (served by the datacenterbingo repo); used here for
|
||||||
|
# IndexNow pings and admin links only.
|
||||||
# BINGO_HOST=bingo.localhost
|
# BINGO_HOST=bingo.localhost
|
||||||
# Optional second hostname serving the same card.
|
|
||||||
# BINGO_ALT_HOST=bingo2.localhost
|
|
||||||
# IndexNow key (8-128 chars, A-Z a-z 0-9 -); unset = no pings, no key file.
|
# IndexNow key (8-128 chars, A-Z a-z 0-9 -); unset = no pings, no key file.
|
||||||
# INDEXNOW_KEY=
|
# INDEXNOW_KEY=
|
||||||
|
|
|
||||||
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -121,22 +121,23 @@ 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.
|
- 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.
|
- 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
|
## Datacenter bingo (admin side)
|
||||||
|
|
||||||
A second public site served from the **same process** on `BINGO_HOST` (`datacenterbingo.xyz`, `www.` redirects to it; optional `BINGO_ALT_HOST` serves the same card on a second hostname (unused; it also needs adding to the Traefik rule); 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.
|
The public site (datacenterbingo.xyz) lives in its own repo and container: `git.czsk.it/asxpi/datacenterbingo`, mirrored to GitHub. It reads the tables and photos below through a read-only DB role (`bingo`) and MinIO user. What stays here is the admin, which writes, and the schema.
|
||||||
|
|
||||||
- 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.
|
- **Schema is owned here.** After adding a bingo migration, refresh `test/schema.sql` in datacenterbingo and update its models. Keep `Bingo::PROVIDERS`, `Board` and the model methods in step with its `lib/bingo.rb`. UI strings (`T`) and views are only there.
|
||||||
- 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`).
|
- `BINGO_HOST` (deploy.yaml → `.env`) is used only for the IndexNow host and the admin's "view site" link; Traefik no longer routes it here.
|
||||||
- Tables `bingo_datacenters`, `bingo_incidents` (`db/migrations/004_bingo.rb`) in the asxpio DB. `updated_at` is set on save; `renumber!` bumps only moved rows and deleting an incident bumps its datacenter (use `destroy`, not dataset `delete`, so the hook runs). 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.
|
- Tables `bingo_datacenters`, `bingo_incidents` (`db/migrations/004_bingo.rb`…). `updated_at` is set on save; `renumber!` bumps only moved rows, and deleting an incident bumps its datacenter (use `destroy`, not dataset `delete`, so the hook runs). First boot seeds from `db/bingo_seed.json` only if the table is empty; after that, edit via the admin.
|
||||||
- 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".
|
- Admin: `/admin/bingo` (`lib/bingo_admin.rb`). Record and delete incidents; add, edit and delete DCs (delete cascades); upload a photo or import it from a URL; bulk import or re-import photos from `photo_source_url`; "Sort by MW".
|
||||||
- Status: operational | building | planned. `mw_type` (it | connected | total | design | mva, NULL = not stated) tags the MW figure in the dialog; mva swaps the unit.
|
- Status: operational | building | planned. `mw_type` (it | connected | total | design | mva, NULL = not stated) tags the MW figure; mva swaps the unit. The public default card is operational only, and so is the dashboard tile (`Board.load('operational')`).
|
||||||
- Views: the bare locale URL is the **operational** card (default, indexed); `?view=all` adds construction/planned projects in the same ranking with lines recomputed, and is noindex with canonical to the bare URL. The admin dashboard tile shows the operational card too.
|
- Provider: `bingo_datacenters.provider` holds a `Bingo::PROVIDERS` slug (backfilled by migration 016; new rows get it from `Bingo.guess_provider`; editable in the admin). The public site filters cards by it.
|
||||||
- Provider filter: `bingo_datacenters.provider` (a `Bingo::PROVIDERS` slug; migration 016 backfilled it from `operator`, `Bingo.guess_provider` fills new rows, editable in admin). `?p[]=yandex&p[]=rtk` (or `?p=yandex,rtk`) builds the usual 5×5 card from only those providers' datacenters, first 25 by position; a smaller selection leaves empty cells. Filtered URLs are noindex with canonical to the bare page; language and view links keep the selection.
|
- Ordering: `position` is dense 1..N (`Bingo.place!` inserts and shifts; `renumber!` runs after deletes). Positions 1–25 are the card; the rest are reserve. Slug is fixed at creation because it names the photo objects.
|
||||||
- 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 bucket as `bingo/<slug>-<hex>.<ext>`, with the type sniffed from magic bytes (JPEG/PNG/WebP).
|
||||||
- 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), `-1200.webp` (dialog, JSON-LD) and `-og.jpg` (1200×630 centre crop, og:image); `photo_variants`/`photo_og` record success (rows without `photo_og` use the 1200 WebP as og:image) and the page falls back to the original without them. "Rebuild resized photos" in admin re-derives from stored originals.
|
- Limits: 8 MB cap, and metadata is stripped (GPS!). URL import is https-only.
|
||||||
- SEO: `BingoWeb` has `static` off (public/ is asxp.io's), its own `/robots.txt`, `/sitemap.xml` (hreflang alternates, `lastmod` = max `updated_at`), and favicon/apple-touch-icon routes serving `assets/bingo/`. The h1 carries a per-locale `tagline`; `/en/` 301s to `/en`. Per-locale `seo_title`/`seo_desc` with the live score, OG/Twitter tags (og:image = latest hit's photo, alt = its name and city), JSON-LD WebSite + ItemList of Places (`info_url` as `subjectOf`, incidents as `event`). HSTS via Rack::Protection. Canonical/hreflang always use `BINGO_HOST`, so `BINGO_ALT_HOST` doesn't compete.
|
- On store, ImageMagick derives `-480.webp`, `-1200.webp` and `-og.jpg` (1200×630). `photo_variants` / `photo_og` record whether that succeeded.
|
||||||
- Datacenter pages: `/:locale/dc/:slug` (`views/bingo/dc.erb`) for every row, reserve included. Title/description/facts are generated (`dc_facts`, `dc_title*` strings); optional `about_*` prose (admin form, no fallback across locales). Card cells are `<a>` to these pages, and JS opens the dialog on a plain click. Dialog and page share `_dc_meta`/`_dc_incidents`. The layout takes `@title @desc @path_for @x_default @og @ld @noindex` from the route. The sitemap lists the card and all datacenter pages × locales.
|
- "Rebuild resized photos" re-derives the variants from the stored originals.
|
||||||
- IndexNow (`Bingo::IndexNow`): admin incident/DC/sort actions ping api.indexnow.org in a thread with the changed URLs; key from the `INDEXNOW` secret → `INDEXNOW_KEY`, served at `/<key>.txt`. Unset = no-op.
|
- Served publicly by the bingo site at `/p/:file`; previewed here at `/admin/bingo/p/:file`.
|
||||||
|
- IndexNow (`Bingo::IndexNow`): admin actions ping api.indexnow.org with the changed URLs. The key comes from `INDEXNOW` → `INDEXNOW_KEY`; the bingo site has the same key and serves `/<key>.txt`. Unset = no-op.
|
||||||
- Photos are third-party (operator sites, Habr, Wikimedia Commons CC BY-SA) — keep `photo_credit` filled; renders/illustrations are marked as such.
|
- 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.
|
- No street addresses or coordinates on the page — city level only.
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#121212"/><rect x="5" y="5" width="10" height="10" rx="1.5" fill="#ff6a4d"/><rect x="16" y="5" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="27" y="5" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="38" y="5" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="49" y="5" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="5" y="16" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="16" y="16" width="10" height="10" rx="1.5" fill="#ff6a4d"/><rect x="27" y="16" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="38" y="16" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="49" y="16" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="5" y="27" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="16" y="27" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="27" y="27" width="10" height="10" rx="1.5" fill="#ff6a4d"/><rect x="38" y="27" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="49" y="27" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="5" y="38" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="16" y="38" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="27" y="38" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="38" y="38" width="10" height="10" rx="1.5" fill="#ff6a4d"/><rect x="49" y="38" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="5" y="49" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="16" y="49" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="27" y="49" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="38" y="49" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="49" y="49" width="10" height="10" rx="1.5" fill="#ff6a4d"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
238
bingo_web.rb
238
bingo_web.rb
|
|
@ -1,238 +0,0 @@
|
||||||
# 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'
|
|
||||||
# HSTS (1 year, this host only); http:// already 308s at the edge.
|
|
||||||
set :protection, use: %i[strict_transport]
|
|
||||||
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 (operational)
|
|
||||||
# keeps a bare URL, the full list is ?view=all.
|
|
||||||
# Path for a locale/view/provider selection; the default (operational,
|
|
||||||
# every provider) keeps a bare URL.
|
|
||||||
def card_path(locale = @locale, view = @view, providers = @providers)
|
|
||||||
q = []
|
|
||||||
q << 'view=all' if view == 'all'
|
|
||||||
Array(providers).each { |p| q << "p[]=#{Rack::Utils.escape(p)}" }
|
|
||||||
q.empty? ? "/#{locale}" : "/#{locale}?#{q.join('&')}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def selected_providers
|
|
||||||
Array(params[:p]).flat_map { |v| v.to_s.split(',') }.select { |p| Bingo::PROVIDERS.key?(p) }.uniq
|
|
||||||
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.
|
|
||||||
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,
|
|
||||||
'dateModified' => board.updated_on&.utc&.iso8601,
|
|
||||||
'itemListElement' => board.cells.each_with_index.map do |dc, i|
|
|
||||||
{ '@type' => 'ListItem', 'position' => i + 1, 'item' => ld_place(dc) }
|
|
||||||
end }.compact
|
|
||||||
]
|
|
||||||
).gsub('</', '<\/')
|
|
||||||
end
|
|
||||||
|
|
||||||
# The info_url describes the datacenter (operator page, news), it isn't
|
|
||||||
# the datacenter's own identity, so subjectOf rather than sameAs.
|
|
||||||
# Incidents are Events at the place.
|
|
||||||
def ld_place(dc)
|
|
||||||
address = { '@type' => 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' }
|
|
||||||
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|
|
|
||||||
{ '@type' => 'Event', 'name' => "#{kind_label(i.kind)}: #{i.l(:title, @locale)}",
|
|
||||||
'startDate' => i.occurred_on.iso8601, 'url' => i.url,
|
|
||||||
'location' => { '@type' => 'Place', 'name' => place['name'], 'address' => address } }.compact
|
|
||||||
end
|
|
||||||
place['event'] = events if events.any?
|
|
||||||
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('</', '<\/')
|
|
||||||
end
|
|
||||||
|
|
||||||
# Plain-language facts for the datacenter page and its meta description.
|
|
||||||
def dc_facts(dc)
|
|
||||||
[format(t(:dc_intro), name: dc.l(:name, @locale), city: dc.l(:city, @locale), operator: dc.operator),
|
|
||||||
(format(t(:dc_capacity), mw: mw_prose(dc)) if mw(dc)),
|
|
||||||
{ 'building' => 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)
|
|
||||||
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
|
|
||||||
|
|
||||||
# "63 MW (design)": the dialog's " · design" tag reads badly in a sentence.
|
|
||||||
def mw_prose(dc)
|
|
||||||
tag = dc.mw_type != 'mva' && t(:mw_types)[dc.mw_type]
|
|
||||||
tag ? "#{mw(dc, tag: false)} (#{tag})" : mw(dc)
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
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)(?:/dc/[a-z0-9-]+)?)/} do |path|
|
|
||||||
qs = request.query_string
|
|
||||||
redirect "/#{path}#{"?#{qs}" unless qs.empty?}", 301
|
|
||||||
end
|
|
||||||
|
|
||||||
get %r{/(ru|en|uk)} do |locale|
|
|
||||||
@locale = locale
|
|
||||||
@view = params[:view] == 'all' ? 'all' : Bingo::VIEWS.first
|
|
||||||
@providers = selected_providers
|
|
||||||
@board = Bingo::Board.load(@view, providers: @providers)
|
|
||||||
@title = t(:seo_title)
|
|
||||||
@desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size)
|
|
||||||
@noindex = !@board.operational_only? || @board.filtered? # variants of the bare page
|
|
||||||
@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
|
|
||||||
|
|
||||||
# IndexNow key file: proves to the engines that pings come from us.
|
|
||||||
get %r{/([A-Za-z0-9-]{8,128})\.txt} do |name|
|
|
||||||
halt 404 unless name == Bingo::IndexNow.key
|
|
||||||
content_type 'text/plain'
|
|
||||||
name
|
|
||||||
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',
|
|
||||||
'apple-touch-icon.png' => 'image/png' }.freeze
|
|
||||||
|
|
||||||
get %r{/(favicon\.svg|favicon\.ico|apple-touch-icon\.png)} do |name|
|
|
||||||
cache_control :public, max_age: 86_400
|
|
||||||
send_file File.join(settings.root, 'assets', 'bingo', name), type: ICONS[name]
|
|
||||||
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
|
|
||||||
20
config.ru
20
config.ru
|
|
@ -1,20 +1,6 @@
|
||||||
require 'sinatra/base'
|
require 'sinatra/base'
|
||||||
require './asxpio.rb'
|
require './asxpio.rb'
|
||||||
require './bingo_web.rb'
|
|
||||||
|
|
||||||
# One process, two sites: the bingo hosts get BingoWeb, everything else
|
# The public bingo site moved to its own repo/container (datacenterbingo);
|
||||||
# asxp.io. BINGO_HOST is canonical (www. redirects to it); BINGO_ALT_HOST
|
# only its admin (/admin/bingo) is still served here.
|
||||||
# serves the same card, e.g. while the canonical domain's DNS settles.
|
run AsxpioWeb.new
|
||||||
asxpio = AsxpioWeb.new
|
|
||||||
bingo = BingoWeb.new
|
|
||||||
bingo_host = ENV['BINGO_HOST']
|
|
||||||
bingo_hosts = BingoWeb::HOSTS
|
|
||||||
|
|
||||||
run lambda { |env|
|
|
||||||
req = Rack::Request.new(env)
|
|
||||||
if bingo_host && req.host == "www.#{bingo_host}"
|
|
||||||
[301, { 'location' => "https://#{bingo_host}#{req.fullpath}" }, []]
|
|
||||||
else
|
|
||||||
(bingo_hosts.include?(req.host) ? bingo : asxpio).call(env)
|
|
||||||
end
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,6 @@ services:
|
||||||
- "traefik.http.routers.asxpio.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.asxpio.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.asxpio.service=asxpio"
|
- "traefik.http.routers.asxpio.service=asxpio"
|
||||||
- "traefik.http.services.asxpio.loadbalancer.server.port=3000"
|
- "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}`) || Host(`www.${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:
|
networks:
|
||||||
traefik:
|
traefik:
|
||||||
|
|
|
||||||
101
lib/bingo.rb
101
lib/bingo.rb
|
|
@ -46,98 +46,8 @@ module Bingo
|
||||||
[(0...SIZE).map { |i| i * SIZE + i }, (0...SIZE).map { |i| i * SIZE + SIZE - 1 - i }]
|
[(0...SIZE).map { |i| i * SIZE + i }, (0...SIZE).map { |i| i * SIZE + SIZE - 1 - i }]
|
||||||
).freeze
|
).freeze
|
||||||
|
|
||||||
T = {
|
# UI strings (T) and the public views live in the datacenterbingo repo,
|
||||||
'ru' => {
|
# which reads these tables; keep PROVIDERS, Board and the models in step.
|
||||||
seo_title: 'Бинго ЦОДов — атаки, пожары и аварии дата-центров России',
|
|
||||||
seo_desc: '%{hit} из %{total} крупнейших дата-центров России уже зачёркнуты. Бинго-карточка ЦОДов IXcellerate, Яндекса, Ростелекома, Росатома и других: атаки БПЛА, пожары и отключения с источниками.',
|
|
||||||
og_locale: 'ru_RU',
|
|
||||||
title: 'Бинго ЦОДов', accent: 'ЦОДов', lead: 'Бинго',
|
|
||||||
tagline: 'Атаки БПЛА, пожары и аварии в 25 крупнейших дата-центрах России',
|
|
||||||
desc: 'Рейтинг по мощности. Клетка зачёркивается после публично подтверждённого инцидента.',
|
|
||||||
crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено',
|
|
||||||
log: 'Журнал инцидентов', empty: 'Пока всё работает. Пока.',
|
|
||||||
bingo: 'БИНГО!', bingo_n: 'БИНГО ×%d!', full: 'ПОЛНАЯ КАРТА. Интернета больше нет.',
|
|
||||||
planned: 'планируется', mva: 'МВА',
|
|
||||||
mw_types: { 'it' => 'ИТ', 'connected' => 'подв.', 'total' => 'общ.', 'design' => 'проект' },
|
|
||||||
building: 'строится', mw: 'МВт', since: 'с %d г.', photo: 'Фото', source: 'Источник',
|
|
||||||
close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.',
|
|
||||||
credits_ai: 'Информация собрана и проверена с помощью Claude (Anthropic).',
|
|
||||||
credits_hym: 'Спасибо %{link} (данные Cat Eyes OSINT) за часть списка: каждая запись перепроверена по сайтам операторов и прессе.',
|
|
||||||
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
|
|
||||||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
|
|
||||||
letters: %w[Б И Н Г О],
|
|
||||||
providers_label: 'Собрать карточку из провайдеров', providers_apply: 'Показать', providers_reset: 'Все провайдеры',
|
|
||||||
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',
|
|
||||||
seo_desc: '%{hit} of Russia\'s %{total} largest datacenters are already crossed out. A bingo card of IXcellerate, Yandex, Rostelecom, Rosatom and other datacenters: drone strikes, fires and outages, each with a source.',
|
|
||||||
og_locale: 'en_US',
|
|
||||||
title: 'Datacenter Bingo', accent: 'Bingo', lead: 'Datacenter',
|
|
||||||
tagline: "Drone strikes, fires and outages at Russia's 25 largest datacenters",
|
|
||||||
desc: "Ranked 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.',
|
|
||||||
planned: 'planned', mva: 'MVA',
|
|
||||||
mw_types: { 'it' => 'IT', 'connected' => 'connected', 'total' => 'total', 'design' => 'design' },
|
|
||||||
building: 'under construction', mw: 'MW', since: 'since %d', photo: 'Photo', source: 'Source',
|
|
||||||
close: 'Close', no_incidents: 'No incidents recorded.',
|
|
||||||
credits_ai: 'Information collected and verified with the help of Claude (Anthropic).',
|
|
||||||
credits_hym: 'Thanks to %{link} (data by Cat Eyes OSINT) for part of the list; every entry was re-verified against operator sites and the press.',
|
|
||||||
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],
|
|
||||||
providers_label: 'Build a card from providers', providers_apply: 'Show', providers_reset: 'All providers',
|
|
||||||
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: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
|
|
||||||
seo_desc: '%{hit} з %{total} найбільших дата-центрів Росії вже закреслено. Бінго-картка ЦОДів IXcellerate, Яндекса, Ростелекому, Росатому та інших: атаки БПЛА, пожежі й відключення з джерелами.',
|
|
||||||
og_locale: 'uk_UA',
|
|
||||||
title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго',
|
|
||||||
tagline: 'Атаки БПЛА, пожежі та аварії у 25 найбільших дата-центрах Росії',
|
|
||||||
desc: 'Рейтинг за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.',
|
|
||||||
crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено',
|
|
||||||
log: 'Журнал інцидентів', empty: 'Поки що все працює. Поки що.',
|
|
||||||
bingo: 'БІНГО!', bingo_n: 'БІНГО ×%d!', full: 'ПОВНА КАРТКА. Інтернету більше немає.',
|
|
||||||
planned: 'планується', mva: 'МВА',
|
|
||||||
mw_types: { 'it' => 'ІТ', 'connected' => 'підв.', 'total' => 'заг.', 'design' => 'проєкт' },
|
|
||||||
building: 'будується', mw: 'МВт', since: 'з %d р.', photo: 'Фото', source: 'Джерело',
|
|
||||||
close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.',
|
|
||||||
credits_ai: 'Інформацію зібрано й перевірено за допомогою Claude (Anthropic).',
|
|
||||||
credits_hym: 'Дякуємо %{link} (дані Cat Eyes OSINT) за частину списку: кожен запис перевірено за сайтами операторів і пресою.',
|
|
||||||
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
|
|
||||||
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
|
|
||||||
letters: %w[Б І Н Г О],
|
|
||||||
providers_label: 'Зібрати картку з провайдерів', providers_apply: 'Показати', providers_reset: 'Усі провайдери',
|
|
||||||
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
|
|
||||||
|
|
||||||
# Localized column with fallback to ru.
|
# Localized column with fallback to ru.
|
||||||
module Localized
|
module Localized
|
||||||
|
|
@ -250,8 +160,8 @@ module Bingo
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Photos live in the app's MinIO bucket under bingo/ and are proxied by the
|
# Photos live in the app's MinIO bucket under bingo/ and are proxied by
|
||||||
# app, so the bucket stays private.
|
# the bingo site (and /admin/bingo/p here), so the bucket stays private.
|
||||||
module Photo
|
module Photo
|
||||||
PREFIX = 'bingo/'.freeze
|
PREFIX = 'bingo/'.freeze
|
||||||
MAX_BYTES = 8 * 1024 * 1024
|
MAX_BYTES = 8 * 1024 * 1024
|
||||||
|
|
@ -448,7 +358,8 @@ module Bingo
|
||||||
|
|
||||||
# Tells IndexNow engines (Bing, Yandex, Seznam, ...) which bingo URLs
|
# Tells IndexNow engines (Bing, Yandex, Seznam, ...) which bingo URLs
|
||||||
# changed, so new incidents get recrawled within minutes. The key comes
|
# changed, so new incidents get recrawled within minutes. The key comes
|
||||||
# from INDEXNOW_KEY and BingoWeb serves it at /<key>.txt; without the key
|
# from INDEXNOW_KEY; the bingo site (datacenterbingo repo, same key)
|
||||||
|
# serves it at /<key>.txt. Without the key
|
||||||
# or BINGO_HOST (dev, tests) this does nothing.
|
# or BINGO_HOST (dev, tests) this does nothing.
|
||||||
module IndexNow
|
module IndexNow
|
||||||
ENDPOINT = URI('https://api.indexnow.org/indexnow')
|
ENDPOINT = URI('https://api.indexnow.org/indexnow')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
require 'uri'
|
require 'uri'
|
||||||
|
|
||||||
# /admin/bingo/* on asxp.io: edit the card and record incidents for BingoWeb.
|
# /admin/bingo/* on asxp.io: edit the card and record incidents for the public bingo site (datacenterbingo repo).
|
||||||
# Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply.
|
# Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply.
|
||||||
module BingoAdmin
|
module BingoAdmin
|
||||||
DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator provider mw mw_type status
|
DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator provider mw mw_type status
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ class BingoTest < Minitest::Test
|
||||||
# Minimal well-formed JPEG: SOI, start-of-scan, data, EOI.
|
# Minimal well-formed JPEG: SOI, start-of-scan, data, EOI.
|
||||||
JPEG = ("\xFF\xD8\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6data\xFF\xD9".b).freeze
|
JPEG = ("\xFF\xD8\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6data\xFF\xD9".b).freeze
|
||||||
|
|
||||||
def app
|
def app = AsxpioWeb
|
||||||
@app || BingoWeb
|
|
||||||
end
|
|
||||||
|
|
||||||
def setup
|
def setup
|
||||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||||
|
|
@ -28,60 +26,19 @@ class BingoTest < Minitest::Test
|
||||||
title_ru: "Авария #{idx}", title_uk: "Аварія #{idx}")
|
title_ru: "Авария #{idx}", title_uk: "Аварія #{idx}")
|
||||||
end
|
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, 'Бінго'
|
|
||||||
assert_includes last_response.body, '<a href="https://asxp.io">asxp.io</a>'
|
|
||||||
get '/xx'
|
|
||||||
assert_equal 404, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_board_detects_lines
|
def test_board_detects_lines
|
||||||
(0..4).each { |i| hit!(i) } # top row
|
(0..4).each { |i| hit!(i) } # top row
|
||||||
[6, 12, 18, 24].each { |i| hit!(i) } # + main diagonal (0 already hit)
|
[6, 12, 18, 24].each { |i| hit!(i) } # + main diagonal (0 already hit)
|
||||||
board = Bingo::Board.load
|
board = Bingo::Board.load
|
||||||
assert_equal 2, board.wins.size
|
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
|
end
|
||||||
|
|
||||||
def test_no_banner_without_line
|
def test_photo_store
|
||||||
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)
|
key = Bingo::Photo.store('dc-1', JPEG)
|
||||||
assert_match %r{\Abingo/dc-1-[0-9a-f]{8}\.jpg\z}, key
|
assert_match %r{\Abingo/dc-1-[0-9a-f]{8}\.jpg\z}, key
|
||||||
@dcs[0].update(photo_key: key)
|
assert_raises(Bingo::Photo::Error) { Bingo::Photo.read('..%2Finvoices%2Fx.pdf') }
|
||||||
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
|
end
|
||||||
|
|
||||||
def test_strips_jpeg_exif
|
def test_strips_jpeg_exif
|
||||||
|
|
@ -132,39 +89,7 @@ class BingoTest < Minitest::Test
|
||||||
assert_equal 1, Bingo::Board.load('operational').wins.size
|
assert_equal 1, Bingo::Board.load('operational').wins.size
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_default_view_is_operational_and_all_is_a_noindex_variant
|
|
||||||
@dcs[0].update(status: 'building')
|
|
||||||
get '/ru'
|
|
||||||
assert last_response.ok?
|
|
||||||
refute_includes last_response.body, 'ЦОД 1<' # construction project hidden by default
|
|
||||||
assert_includes last_response.body, 'index, follow, max-image-preview'
|
|
||||||
assert_includes last_response.body, '<a href="/ru" aria-current="page">'
|
|
||||||
get '/ru?view=all'
|
|
||||||
assert last_response.ok?
|
|
||||||
assert_includes last_response.body, 'ЦОД 1<'
|
|
||||||
assert_includes last_response.body, 'noindex, follow'
|
|
||||||
assert_includes last_response.body, 'href="/en?view=all"' # language links keep the view
|
|
||||||
assert_includes last_response.body, '<a href="/ru?view=all" aria-current="page">'
|
|
||||||
assert_includes last_response.body, 'rel="canonical" href="https://example.org/ru"'
|
|
||||||
get '/ru?view=operational' # old links still land on the default
|
|
||||||
assert_includes last_response.body, 'index, follow, max-image-preview'
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_capacity_type_and_planned_status
|
|
||||||
@dcs[0].update(mw: '64', mw_type: 'design', status: 'planned')
|
|
||||||
@dcs[1].update(mw: '17', mw_type: 'mva')
|
|
||||||
get '/en?view=all' # planned sites only appear in the full list
|
|
||||||
body = last_response.body
|
|
||||||
assert_includes body, '64 MW · design' # dialog
|
|
||||||
assert_includes body, '#1 · 64 MW</div>' # cell: no tag
|
|
||||||
assert_includes body, '17 MVA'
|
|
||||||
assert_includes body, '#2 · 17 MVA</div>' # MVA replaces the unit in cells too
|
|
||||||
assert_includes body, '>planned<'
|
|
||||||
refute_includes Bingo::Board.load('operational').cells.map(&:slug), 'dc-1'
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_admin_saves_capacity_type_and_planned
|
def test_admin_saves_capacity_type_and_planned
|
||||||
@app = AsxpioWeb
|
|
||||||
token = admin_token("/admin/bingo/dc/#{@dcs[0].id}")
|
token = admin_token("/admin/bingo/dc/#{@dcs[0].id}")
|
||||||
post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', status: 'planned', mw_type: 'connected'), admin_env
|
post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', status: 'planned', mw_type: 'connected'), admin_env
|
||||||
@dcs[0].refresh
|
@dcs[0].refresh
|
||||||
|
|
@ -186,18 +111,6 @@ class BingoTest < Minitest::Test
|
||||||
hit!(0)
|
hit!(0)
|
||||||
board = Bingo::Board.load('operational', providers: %w[yandex rtk])
|
board = Bingo::Board.load('operational', providers: %w[yandex rtk])
|
||||||
assert_equal %w[dc-1 dc-2 dc-3 dc-4], board.cells.map(&:slug)
|
assert_equal %w[dc-1 dc-2 dc-3 dc-4], board.cells.map(&:slug)
|
||||||
get '/ru?p[]=yandex&p[]=rtk&p[]=bogus'
|
|
||||||
assert last_response.ok?
|
|
||||||
body = last_response.body
|
|
||||||
assert_equal 4, body.scan(/class="cell(?! empty)[ "]/).size # the four selected datacenters
|
|
||||||
assert_equal 21, body.scan('class="cell empty"').size
|
|
||||||
assert_includes body, 'noindex, follow'
|
|
||||||
assert_includes body, 'rel="canonical" href="https://example.org/ru"'
|
|
||||||
assert_includes body, 'href="/en?p[]=yandex&p[]=rtk"' # language links keep the selection
|
|
||||||
assert_includes body, 'name="p[]" value="yandex" checked'
|
|
||||||
refute_includes body, 'ЦОД 5<' # unselected provider
|
|
||||||
get '/ru?p=yandex,rtk' # comma form works too
|
|
||||||
assert_equal 21, last_response.body.scan('class="cell empty"').size
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_filtered_card_still_scores_lines
|
def test_filtered_card_still_scores_lines
|
||||||
|
|
@ -207,7 +120,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_saves_provider
|
def test_admin_saves_provider
|
||||||
@app = AsxpioWeb
|
|
||||||
token = admin_token("/admin/bingo/dc/#{@dcs[0].id}")
|
token = admin_token("/admin/bingo/dc/#{@dcs[0].id}")
|
||||||
post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', status: 'operational', provider: 'selectel'), admin_env
|
post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', status: 'operational', provider: 'selectel'), admin_env
|
||||||
assert_equal 'selectel', @dcs[0].refresh.provider
|
assert_equal 'selectel', @dcs[0].refresh.provider
|
||||||
|
|
@ -215,32 +127,6 @@ class BingoTest < Minitest::Test
|
||||||
assert_nil @dcs[0].refresh.provider
|
assert_nil @dcs[0].refresh.provider
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_footer_credits_in_every_language
|
|
||||||
{ 'ru' => 'с помощью Claude', 'en' => 'with the help of Claude', 'uk' => 'за допомогою Claude' }.each do |l, text|
|
|
||||||
get "/#{l}"
|
|
||||||
assert_includes last_response.body, text
|
|
||||||
assert_includes last_response.body, '<a href="https://hackyourmom.com/en/karta-data-czentriv-na-rasii/" target="_blank" rel="noopener">hackyourmom</a>'
|
|
||||||
end
|
|
||||||
get "/en/dc/#{@dcs[0].slug}"
|
|
||||||
assert_includes last_response.body, 'hackyourmom</a>'
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_robots_and_sitemap_are_the_bingos_own
|
|
||||||
get '/robots.txt'
|
|
||||||
assert last_response.ok?
|
|
||||||
assert_includes last_response.body, 'Sitemap: https://example.org/sitemap.xml'
|
|
||||||
refute_includes last_response.body, 'asxp.io'
|
|
||||||
get '/sitemap.xml'
|
|
||||||
assert last_response.ok?
|
|
||||||
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}</loc>" }
|
|
||||||
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}/dc/dc-25</loc>" }
|
|
||||||
assert_includes last_response.body, '<xhtml:link rel="alternate" hreflang="uk" href="https://example.org/uk/dc/dc-7"/>'
|
|
||||||
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{</loc>\n <lastmod>\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ</lastmod>\n <xhtml:link}, last_response.body
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_updated_on_follows_edits_and_deletes
|
def test_updated_on_follows_edits_and_deletes
|
||||||
old = Time.utc(2026, 1, 1)
|
old = Time.utc(2026, 1, 1)
|
||||||
DB.connection[:bingo_datacenters].update(created_at: old, updated_at: old)
|
DB.connection[:bingo_datacenters].update(created_at: old, updated_at: old)
|
||||||
|
|
@ -254,100 +140,12 @@ class BingoTest < Minitest::Test
|
||||||
assert_operator Bingo::Board.load.updated_on, :>, old
|
assert_operator Bingo::Board.load.updated_on, :>, old
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_asxpio_static_files_not_served
|
|
||||||
get '/style.css'
|
|
||||||
assert_equal 404, last_response.status
|
|
||||||
get '/pgp.pub'
|
|
||||||
assert_equal 404, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_trailing_slash_redirects
|
|
||||||
get '/en/', view: 'all'
|
|
||||||
assert_equal 301, last_response.status
|
|
||||||
assert_equal 'http://example.org/en?view=all', last_response.location
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_icons
|
|
||||||
{ '/favicon.svg' => 'image/svg+xml', '/favicon.ico' => 'image/x-icon',
|
|
||||||
'/apple-touch-icon.png' => 'image/png' }.each do |path, type|
|
|
||||||
get path
|
|
||||||
assert last_response.ok?, path
|
|
||||||
assert_equal type, last_response.content_type
|
|
||||||
end
|
|
||||||
get '/en'
|
|
||||||
assert_includes last_response.body, '<link rel="icon" href="/favicon.svg" type="image/svg+xml">'
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_hsts
|
|
||||||
get '/en'
|
|
||||||
assert_equal 'max-age=31536000', last_response.headers['strict-transport-security']
|
|
||||||
assert_equal 'nosniff', last_response.headers['x-content-type-options']
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_dc_page
|
|
||||||
hit!(3)
|
|
||||||
get '/en/dc/dc-4'
|
|
||||||
assert last_response.ok?
|
|
||||||
body = last_response.body
|
|
||||||
assert_includes body, "<title>ЦОД 4 (Moscow) — Outage, #{Date.today.strftime('%d.%m.%Y')} | Datacenter Bingo</title>"
|
|
||||||
assert_includes body, '<link rel="canonical" href="https://example.org/en/dc/dc-4">'
|
|
||||||
assert_includes body, '<link rel="alternate" hreflang="uk" href="https://example.org/uk/dc/dc-4">'
|
|
||||||
refute_includes body, 'x-default'
|
|
||||||
assert_includes body, '<h1>ЦОД 4</h1>'
|
|
||||||
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(/<meta name="description" content="[^"]*Latest reported incident/, body)
|
|
||||||
ld = JSON.parse(body[%r{<script type="application/ld\+json">(.*?)</script>}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']
|
|
||||||
|
|
||||||
@dcs[3].update(mw_type: 'design')
|
|
||||||
get '/en/dc/dc-4'
|
|
||||||
assert_includes last_response.body, 'Capacity: 4 MW (design).'
|
|
||||||
get '/uk/dc/dc-1'
|
|
||||||
assert_includes last_response.body, '<title>ЦОД 1 (Москва) — інциденти ЦОДу | Бінго ЦОДів</title>'
|
|
||||||
refute_includes last_response.body, 'rel="prev"'
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_dc_about_text
|
def test_dc_about_text
|
||||||
@app = AsxpioWeb
|
|
||||||
token = admin_token("/admin/bingo/dc/#{@dcs[1].id}")
|
token = admin_token("/admin/bingo/dc/#{@dcs[1].id}")
|
||||||
post "/admin/bingo/dc/#{@dcs[1].id}", dc_params(authenticity_token: token, about_en: "First <b>para</b>.\r\n\r\nSecond."), admin_env
|
post "/admin/bingo/dc/#{@dcs[1].id}", dc_params(authenticity_token: token, about_en: "First <b>para</b>.\r\n\r\nSecond."), admin_env
|
||||||
assert_equal "First <b>para</b>.\r\n\r\nSecond.", @dcs[1].refresh.about_en
|
assert_equal "First <b>para</b>.\r\n\r\nSecond.", @dcs[1].refresh.about_en
|
||||||
public = Rack::MockRequest.new(BingoWeb) # rack-test's session is bound to AsxpioWeb now
|
assert_equal ['First <b>para</b>.', 'Second.'], @dcs[1].about('en')
|
||||||
assert_includes public.get('/en/dc/dc-2').body, '<p class="facts">First <b>para</b>.</p><p class="facts">Second.</p>'
|
assert_empty @dcs[1].about('ru') # no fallback across languages
|
||||||
refute_includes public.get('/ru/dc/dc-2').body, 'Second.' # no fallback across languages
|
|
||||||
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, '<loc>https://example.org/ru/dc/dc-26</loc>'
|
|
||||||
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, '<a href="/en/dc/dc-1" data-dlg="dlg-dc-1"'
|
|
||||||
assert_includes body, '<a href="/en/dc/dc-2">Datacenter page →</a>'
|
|
||||||
assert_includes body, '<div class="dc"><a href="/en/dc/dc-1">DC 1</a>'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_env(vars)
|
def with_env(vars)
|
||||||
|
|
@ -358,18 +156,6 @@ class BingoTest < Minitest::Test
|
||||||
old.each { |k, v| ENV[k] = v }
|
old.each { |k, v| ENV[k] = v }
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_indexnow_key_file
|
|
||||||
get '/0123456789abcdef.txt'
|
|
||||||
assert_equal 404, last_response.status
|
|
||||||
with_env('INDEXNOW_KEY' => '0123456789abcdef') do
|
|
||||||
get '/0123456789abcdef.txt'
|
|
||||||
assert last_response.ok?
|
|
||||||
assert_equal '0123456789abcdef', last_response.body
|
|
||||||
get '/fedcba9876543210.txt'
|
|
||||||
assert_equal 404, last_response.status
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_indexnow_ping
|
def test_indexnow_ping
|
||||||
assert_nil Bingo::IndexNow.ping([@dcs[0]]) # no key: nothing sent
|
assert_nil Bingo::IndexNow.ping([@dcs[0]]) # no key: nothing sent
|
||||||
sent = []
|
sent = []
|
||||||
|
|
@ -385,7 +171,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_incident_pings_indexnow
|
def test_admin_incident_pings_indexnow
|
||||||
@app = AsxpioWeb
|
|
||||||
token = admin_token
|
token = admin_token
|
||||||
pinged = []
|
pinged = []
|
||||||
Bingo::IndexNow.stub(:ping, ->(dcs) { pinged << dcs.map(&:slug) }) do
|
Bingo::IndexNow.stub(:ping, ->(dcs) { pinged << dcs.map(&:slug) }) do
|
||||||
|
|
@ -396,51 +181,6 @@ class BingoTest < Minitest::Test
|
||||||
assert_equal [['dc-3'], ['dc-3']], pinged
|
assert_equal [['dc-3'], ['dc-3']], pinged
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_root_varies_on_language
|
|
||||||
get '/'
|
|
||||||
assert_equal 'Accept-Language', last_response.headers['Vary']
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_seo_head
|
|
||||||
hit!(3)
|
|
||||||
@dcs[3].update(photo_key: 'bingo/dc-4-0123abcd.jpg', photo_variants: true)
|
|
||||||
@dcs[0].update(info_url: 'https://example.com/dc-1')
|
|
||||||
get '/en'
|
|
||||||
body = last_response.body
|
|
||||||
assert_includes body, '<title>Datacenter Bingo — strikes'
|
|
||||||
assert_match %r{<h1>Datacenter <span>Bingo</span> <small>Drone strikes, fires and outages at Russia's 25 largest datacenters</small></h1>}, body
|
|
||||||
assert_includes body, '1 of Russia's 25 largest'
|
|
||||||
assert_includes body, '<meta property="og:image" content="https://example.org/p/dc-4-0123abcd-1200.webp">'
|
|
||||||
assert_includes body, 'src="/p/dc-4-0123abcd-480.webp"'
|
|
||||||
refute_includes body, 'og:image:width'
|
|
||||||
@dcs[3].update(photo_og: true)
|
|
||||||
get '/en'
|
|
||||||
body = last_response.body
|
|
||||||
assert_includes body, '<meta property="og:image" content="https://example.org/p/dc-4-0123abcd-og.jpg">'
|
|
||||||
assert_includes body, '<meta property="og:image:width" content="1200">'
|
|
||||||
assert_includes body, '<meta property="og:image:alt" content="ЦОД 4, Moscow">'
|
|
||||||
assert_includes body, 'og:locale:alternate" content="uk_UA"'
|
|
||||||
ld = JSON.parse(body[%r{<script type="application/ld\+json">(.*?)</script>}m, 1])
|
|
||||||
list = ld['@graph'].find { |n| n['@type'] == 'ItemList' }
|
|
||||||
assert_equal 25, list['itemListElement'].size
|
|
||||||
assert_equal 'DC 1', list['itemListElement'][0]['item']['name']
|
|
||||||
hit = list['itemListElement'][3]['item']
|
|
||||||
assert_equal Date.today.iso8601, hit['event'][0]['startDate']
|
|
||||||
assert_equal 'Outage: Авария 3', hit['event'][0]['name']
|
|
||||||
refute list['itemListElement'][0]['item'].key?('event')
|
|
||||||
assert_equal({ '@type' => 'CreativeWork', 'url' => 'https://example.com/dc-1' }, list['itemListElement'][0]['item']['subjectOf'])
|
|
||||||
refute_includes body, 'sameAs'
|
|
||||||
assert list['dateModified']
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_photo_falls_back_to_original_without_variants
|
|
||||||
@dcs[0].update(photo_key: 'bingo/dc-1-0123abcd.jpg', photo_variants: false)
|
|
||||||
assert_equal 'dc-1-0123abcd.jpg', @dcs[0].photo_file(480)
|
|
||||||
@dcs[0].update(photo_variants: true)
|
|
||||||
assert_equal 'dc-1-0123abcd-480.webp', @dcs[0].photo_file(480)
|
|
||||||
assert_equal 'dc-1-0123abcd.jpg', @dcs[0].photo_file
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_resize_produces_small_webp
|
def test_resize_produces_small_webp
|
||||||
skip 'ImageMagick not available' unless system(Bingo::Photo::CONVERT, '-version', out: File::NULL, err: File::NULL)
|
skip 'ImageMagick not available' unless system(Bingo::Photo::CONVERT, '-version', out: File::NULL, err: File::NULL)
|
||||||
png, = Open3.capture2(Bingo::Photo::CONVERT, '-size', '2000x1000', 'gradient:red-blue', 'png:-', binmode: true)
|
png, = Open3.capture2(Bingo::Photo::CONVERT, '-size', '2000x1000', 'gradient:red-blue', 'png:-', binmode: true)
|
||||||
|
|
@ -477,16 +217,12 @@ class BingoTest < Minitest::Test
|
||||||
assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '<html>') }
|
assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '<html>') }
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- admin (served by AsxpioWeb) -------------------------------------------
|
|
||||||
|
|
||||||
def test_admin_requires_login
|
def test_admin_requires_login
|
||||||
@app = AsxpioWeb
|
|
||||||
get '/admin/bingo'
|
get '/admin/bingo'
|
||||||
assert_equal 302, last_response.status
|
assert_equal 302, last_response.status
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_dashboard
|
def test_admin_dashboard
|
||||||
@app = AsxpioWeb
|
|
||||||
hit!(2)
|
hit!(2)
|
||||||
DB.connection[:invoices].delete
|
DB.connection[:invoices].delete
|
||||||
inv = Invoice.build(client_name: 'ACME', client_email: 'b@example.com', currency: 'EUR', gel_rate: '3.05',
|
inv = Invoice.build(client_name: 'ACME', client_email: 'b@example.com', currency: 'EUR', gel_rate: '3.05',
|
||||||
|
|
@ -510,7 +246,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_records_and_deletes_incident
|
def test_admin_records_and_deletes_incident
|
||||||
@app = AsxpioWeb
|
|
||||||
get '/admin/bingo', {}, admin_env
|
get '/admin/bingo', {}, admin_env
|
||||||
assert last_response.ok?
|
assert last_response.ok?
|
||||||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||||
|
|
@ -533,7 +268,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_edits_datacenter
|
def test_admin_edits_datacenter
|
||||||
@app = AsxpioWeb
|
|
||||||
get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env
|
get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env
|
||||||
assert last_response.ok?
|
assert last_response.ok?
|
||||||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||||
|
|
@ -564,7 +298,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_adds_datacenter_at_position
|
def test_admin_adds_datacenter_at_position
|
||||||
@app = AsxpioWeb
|
|
||||||
token = admin_token('/admin/bingo/dc/new')
|
token = admin_token('/admin/bingo/dc/new')
|
||||||
post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'ix-mos7', position: '9'), admin_env
|
post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'ix-mos7', position: '9'), admin_env
|
||||||
assert_equal 302, last_response.status, last_response.body
|
assert_equal 302, last_response.status, last_response.body
|
||||||
|
|
@ -581,7 +314,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_rejects_bad_or_duplicate_slug
|
def test_admin_rejects_bad_or_duplicate_slug
|
||||||
@app = AsxpioWeb
|
|
||||||
token = admin_token('/admin/bingo/dc/new')
|
token = admin_token('/admin/bingo/dc/new')
|
||||||
post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'Bad Slug'), admin_env
|
post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'Bad Slug'), admin_env
|
||||||
assert_equal 422, last_response.status
|
assert_equal 422, last_response.status
|
||||||
|
|
@ -591,7 +323,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_moves_datacenter
|
def test_admin_moves_datacenter
|
||||||
@app = AsxpioWeb
|
|
||||||
token = admin_token("/admin/bingo/dc/#{@dcs[24].id}")
|
token = admin_token("/admin/bingo/dc/#{@dcs[24].id}")
|
||||||
post "/admin/bingo/dc/#{@dcs[24].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 25', position: '1'), admin_env
|
post "/admin/bingo/dc/#{@dcs[24].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 25', position: '1'), admin_env
|
||||||
assert_equal 1, @dcs[24].refresh.position
|
assert_equal 1, @dcs[24].refresh.position
|
||||||
|
|
@ -600,7 +331,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_deletes_datacenter_with_incidents
|
def test_admin_deletes_datacenter_with_incidents
|
||||||
@app = AsxpioWeb
|
|
||||||
hit!(4)
|
hit!(4)
|
||||||
token = admin_token("/admin/bingo/dc/#{@dcs[4].id}")
|
token = admin_token("/admin/bingo/dc/#{@dcs[4].id}")
|
||||||
post "/admin/bingo/dc/#{@dcs[4].id}/delete", { authenticity_token: token }, admin_env
|
post "/admin/bingo/dc/#{@dcs[4].id}/delete", { authenticity_token: token }, admin_env
|
||||||
|
|
@ -620,7 +350,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_upload_photo_via_admin
|
def test_upload_photo_via_admin
|
||||||
@app = AsxpioWeb
|
|
||||||
get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env
|
get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env
|
||||||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||||
file = Rack::Test::UploadedFile.new(StringIO.new(JPEG), 'image/jpeg', original_filename: 'a.jpg')
|
file = Rack::Test::UploadedFile.new(StringIO.new(JPEG), 'image/jpeg', original_filename: 'a.jpg')
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ else
|
||||||
end
|
end
|
||||||
|
|
||||||
require_relative '../asxpio'
|
require_relative '../asxpio'
|
||||||
require_relative '../bingo_web'
|
|
||||||
require 'minitest/autorun'
|
require 'minitest/autorun'
|
||||||
require 'rack/test'
|
require 'rack/test'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
<p class="credits"><%= t(:credits_ai) %>
|
|
||||||
<%== format(Rack::Utils.escape_html(t(:credits_hym)), link: '<a href="https://hackyourmom.com/en/karta-data-czentriv-na-rasii/" target="_blank" rel="noopener">hackyourmom</a>') %></p>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<% if dc.incidents.empty? %>
|
|
||||||
<p class="empty"><%= t(:no_incidents) %></p>
|
|
||||||
<% else %>
|
|
||||||
<ul>
|
|
||||||
<% dc.incidents.each do |i| %>
|
|
||||||
<li><%= kind_icon(i.kind) %> <time datetime="<%= i.occurred_on %>"><%= fmt_date(i.occurred_on) %></time> —
|
|
||||||
<% 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 %>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<div class="meta">
|
|
||||||
<%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (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 %>
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<% dc = @dc %>
|
|
||||||
<main>
|
|
||||||
<header>
|
|
||||||
<nav class="crumbs"><a href="/<%= @locale %>"><%= t(:title) %></a></nav>
|
|
||||||
<nav class="langs">
|
|
||||||
<% Bingo::LOCALES.each do |l| %>
|
|
||||||
<a href="<%= @path_for.(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a>
|
|
||||||
<% end %>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<article class="dc-page<%= ' hit' if dc.incidents.any? %>">
|
|
||||||
<% if (photo = photo_path(dc, 1200)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %>" decoding="async"><% end %>
|
|
||||||
<h1><%= dc.l(:name, @locale) %></h1>
|
|
||||||
<%== erb :_dc_meta, layout: false, locals: { dc: dc } %>
|
|
||||||
<p class="facts"><%= dc_facts(dc).join(' ') %></p>
|
|
||||||
<% dc.about(@locale).each do |para| %><p class="facts"><%= para %></p><% end %>
|
|
||||||
<section>
|
|
||||||
<h2><%= t(:incidents) %></h2>
|
|
||||||
<%== erb :_dc_incidents, layout: false, locals: { dc: dc } %>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<nav class="pager">
|
|
||||||
<% if @prev %><a href="<%= dc_path(@prev) %>" rel="prev">← #<%= @prev.position %> <%= @prev.l(:name, @locale) %></a><% else %><span></span><% end %>
|
|
||||||
<% if @next %><a href="<%= dc_path(@next) %>" rel="next">#<%= @next.position %> <%= @next.l(:name, @locale) %> →</a><% end %>
|
|
||||||
</nav>
|
|
||||||
<p><a href="/<%= @locale %>"><%= t(:back) %></a></p>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p><%= t(:footer) %></p>
|
|
||||||
<%== erb :_credits, layout: false %>
|
|
||||||
<p>© <%= Date.today.year %> <a href="https://asxp.io">asxp.io</a></p>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
<% b = @board %>
|
|
||||||
<main>
|
|
||||||
<header>
|
|
||||||
<h1><%= t(:lead) %> <span><%= t(:accent) %></span> <small><%= t(:tagline) %></small></h1>
|
|
||||||
<div class="switches">
|
|
||||||
<nav class="toggle" aria-label="<%= t(:view_label) %>">
|
|
||||||
<% Bingo::VIEWS.each do |v| %>
|
|
||||||
<a href="<%= card_path(@locale, v) %>" <%== '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>
|
|
||||||
<% provs = Bingo::Board.providers(@view) %>
|
|
||||||
<form class="providers" method="get" action="/<%= @locale %>">
|
|
||||||
<% if @view == 'all' %><input type="hidden" name="view" value="all"><% end %>
|
|
||||||
<fieldset>
|
|
||||||
<legend><%= t(:providers_label) %></legend>
|
|
||||||
<% provs.each do |slug, total, crossed| %>
|
|
||||||
<label><input type="checkbox" name="p[]" value="<%= slug %>" <%= 'checked' if @providers.include?(slug) %>> <%= Bingo.provider_name(slug, @locale) %> <span><%= crossed %>/<%= total %></span></label>
|
|
||||||
<% end %>
|
|
||||||
</fieldset>
|
|
||||||
<div class="provider-actions">
|
|
||||||
<button type="submit"><%= t(:providers_apply) %></button>
|
|
||||||
<% if @board.filtered? %><a href="<%= card_path(@locale, @view, []) %>"><%= t(:providers_reset) %></a><% end %>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<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, 480) %>
|
|
||||||
<a href="<%= dc_path(dc) %>" data-dlg="dlg-<%= dc.slug %>"
|
|
||||||
class="cell<%= ' photo' if photo %><%= ' hit' if b.hit[idx] %><%= ' win' if b.win_cells.include?(idx) %>"
|
|
||||||
aria-label="<%= dc.l(:name, @locale) %><%= " — #{kind_label(dc.incidents.first.kind)}" if b.hit[idx] %>">
|
|
||||||
<% if photo %><img class="bg" src="<%= photo %>" alt="" width="480" height="480" decoding="async"<%== ' loading="lazy"' if idx >= 10 %>><% end %>
|
|
||||||
<div>
|
|
||||||
<div class="n">#<%= idx + 1 %><%= " · #{mw(dc, tag: false)}" if mw(dc) %></div>
|
|
||||||
<% if status_label(dc) %><div class="tag"><%= status_label(dc) %></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 %>
|
|
||||||
</a>
|
|
||||||
<% end %>
|
|
||||||
<% (b.cells.size...Bingo::SIZE**2).each do %><div class="cell empty" aria-hidden="true"></div><% 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"><a href="<%= dc_path(i.datacenter) %>"><%= i.datacenter.l(:name, @locale) %></a> · <%= kind_label(i.kind) %></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p><%= t(:footer) %></p>
|
|
||||||
<%== erb :_credits, layout: false %>
|
|
||||||
<p>© <%= Date.today.year %> <a href="https://asxp.io">asxp.io</a></p>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<% b.cells.each do |dc| %>
|
|
||||||
<dialog id="dlg-<%= dc.slug %>">
|
|
||||||
<% if (photo = photo_path(dc, 1200)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %>" loading="lazy" decoding="async"><% end %>
|
|
||||||
<div class="body">
|
|
||||||
<h3><%= dc.l(:name, @locale) %></h3>
|
|
||||||
<%== erb :_dc_meta, layout: false, locals: { dc: dc } %>
|
|
||||||
<%== erb :_dc_incidents, layout: false, locals: { dc: dc } %>
|
|
||||||
<div class="actions">
|
|
||||||
<a href="<%= dc_path(dc) %>"><%= t(:dc_page) %></a>
|
|
||||||
<form method="dialog"><button><%= t(:close) %></button></form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Cells are links to the datacenter pages; a plain click opens the dialog
|
|
||||||
// instead, modified clicks (new tab etc.) keep the link.
|
|
||||||
document.querySelectorAll('[data-dlg]').forEach(a => a.addEventListener('click', e => {
|
|
||||||
if (e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
||||||
e.preventDefault();
|
|
||||||
document.getElementById(a.dataset.dlg).showModal();
|
|
||||||
}));
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
<% base = base_url %>
|
|
||||||
<% og_img = @og && "#{base}/p/#{@og.og_file}" %>
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="<%= @locale %>">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title><%= @title %></title>
|
|
||||||
<meta name="description" content="<%= @desc %>">
|
|
||||||
<meta name="robots" content="<%= @noindex ? 'noindex, follow' : 'index, follow, max-image-preview:large' %>">
|
|
||||||
<meta name="theme-color" content="#121212">
|
|
||||||
<link rel="canonical" href="<%= base %><%= @path_for.(@locale) %>">
|
|
||||||
<% Bingo::LOCALES.each do |l| %>
|
|
||||||
<link rel="alternate" hreflang="<%= l %>" href="<%= base %><%= @path_for.(l) %>">
|
|
||||||
<% end %>
|
|
||||||
<% if @x_default %>
|
|
||||||
<link rel="alternate" hreflang="x-default" href="<%= base %><%= @x_default %>">
|
|
||||||
<% end %>
|
|
||||||
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:site_name" content="<%= t(:title) %>">
|
|
||||||
<meta property="og:title" content="<%= @title %>">
|
|
||||||
<meta property="og:description" content="<%= @desc %>">
|
|
||||||
<meta property="og:url" content="<%= base %><%= @path_for.(@locale) %>">
|
|
||||||
<meta property="og:locale" content="<%= t(:og_locale) %>">
|
|
||||||
<% (Bingo::LOCALES - [@locale]).each do |l| %>
|
|
||||||
<meta property="og:locale:alternate" content="<%= Bingo::T[l][:og_locale] %>">
|
|
||||||
<% end %>
|
|
||||||
<% if og_img %>
|
|
||||||
<meta property="og:image" content="<%= og_img %>">
|
|
||||||
<% if @og.photo_og %>
|
|
||||||
<meta property="og:image:type" content="image/jpeg">
|
|
||||||
<meta property="og:image:width" content="<%= Bingo::Photo::OG_SIZE[0] %>">
|
|
||||||
<meta property="og:image:height" content="<%= Bingo::Photo::OG_SIZE[1] %>">
|
|
||||||
<% end %>
|
|
||||||
<meta property="og:image:alt" content="<%= @og.l(:name, @locale) %>, <%= @og.l(:city, @locale) %>">
|
|
||||||
<meta name="twitter:image" content="<%= og_img %>">
|
|
||||||
<% end %>
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
|
||||||
<meta name="twitter:title" content="<%= @title %>">
|
|
||||||
<meta name="twitter:description" content="<%= @desc %>">
|
|
||||||
<script type="application/ld+json"><%== @ld %></script>
|
|
||||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
||||||
<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); }
|
|
||||||
h1 small { display: block; margin-top: 4px; font-size: 16px; font-weight: 600; letter-spacing: 0; color: var(--muted); }
|
|
||||||
.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); }
|
|
||||||
.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; }
|
|
||||||
.cell.empty { background: transparent; border-style: dashed; cursor: default; }
|
|
||||||
.providers { margin: 0 0 16px; }
|
|
||||||
.providers fieldset { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
||||||
.providers legend { font-size: 13px; color: var(--muted); margin-bottom: 6px; padding: 0; }
|
|
||||||
.providers label { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
|
||||||
.providers label:has(input:checked) { border-color: var(--ink); }
|
|
||||||
.providers input { accent-color: var(--hit); margin: 0; }
|
|
||||||
.providers label span { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
||||||
.provider-actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; font-size: 13px; }
|
|
||||||
.provider-actions button { font: inherit; padding: 3px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--card); color: var(--ink); cursor: pointer; }
|
|
||||||
.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); border: 1px solid var(--line); border-radius: 8px;
|
|
||||||
display: flex; flex-direction: column; justify-content: space-between;
|
|
||||||
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; }
|
|
||||||
.cell.photo::before { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, var(--shade), rgb(0 0 0 / .15) 70%); }
|
|
||||||
.cell > :not(.bg) { position: relative; z-index: 2; }
|
|
||||||
.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: 3; 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: 4; }
|
|
||||||
.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 .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; }
|
|
||||||
@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>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
|
||||||
<% @entries.each do |path_for, lastmod, x_default| %>
|
|
||||||
<% Bingo::LOCALES.each do |l| %>
|
|
||||||
<url>
|
|
||||||
<loc><%= base_url %><%= path_for.(l) %></loc>
|
|
||||||
<% if lastmod %> <lastmod><%= lastmod.utc.iso8601 %></lastmod>
|
|
||||||
<% end %>
|
|
||||||
<% Bingo::LOCALES.each do |alt| %> <xhtml:link rel="alternate" hreflang="<%= alt %>" href="<%= base_url %><%= path_for.(alt) %>"/>
|
|
||||||
<% end %>
|
|
||||||
<% if x_default %> <xhtml:link rel="alternate" hreflang="x-default" href="<%= base_url %><%= x_default %>"/>
|
|
||||||
<% end %>
|
|
||||||
</url>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
</urlset>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue