bingo: add, delete and reorder datacenters from admin
All checks were successful
Build and Deploy to Production / test (push) Successful in 22s
Build and Deploy to Production / build (push) Successful in 52s
Build and Deploy to Production / deploy (push) Successful in 38s

The card was fixed to the 25 seeded rows, so a new site such as
IXcellerate MOS7 (34 MW, under construction) could only be shoehorned in
by overwriting an existing row. Add create and delete (cascading to
incidents), make position an insert that shifts the rest, and keep
positions dense. More than 25 rows are allowed: the card shows the first
25 and the rest wait in reserve. "Sort by MW" re-ranks by the parsed
power figure, unknown last.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 06:00:46 +04:00
commit e725395825
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
6 changed files with 197 additions and 14 deletions

View file

@ -117,12 +117,13 @@ Admin pages use a shared dark palette extended in `public/style.css` (search for
## Datacenter bingo
A second public site served from the **same process** on `BINGO_HOST` (`datacenterbingo.xyz`, `www.` redirects to it; 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.
A second public site served from the **same process** on `BINGO_HOST` (`datacenterbingo.xyz`, `www.` redirects to it; `BINGO_ALT_HOST=bingo.asxp.io` serves the same card; 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`.
- 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".
- Ordering: `position` is dense 1..N (`Bingo.place!` inserts and shifts, `renumber!` after deletes). The card shows positions 1–25; anything beyond is reserve. Slug is fixed at creation (it names photo objects).
- Photos: stored in the existing bucket under `bingo/<slug>-<hex>.<ext>` (no storage-repo change), proxied by `BingoWeb` at `/p/:file` with immutable caching (new upload = new key; old objects are kept). Type sniffed from magic bytes (JPEG/PNG/WebP), 8 MB cap. 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.

View file

@ -238,6 +238,34 @@ module Bingo
end
end
# Positions stay dense 1..N; the card shows the first SIZE*SIZE, the rest
# wait in reserve. Placing a datacenter inserts it at pos (default: last)
# and shifts everyone after it.
def self.place!(dc, pos = nil)
DB.connection.transaction do
ids = Datacenter.exclude(id: dc.id).order(:position, :id).select_map(:id)
pos = (pos || ids.size + 1).clamp(1, ids.size + 1)
renumber!(ids.insert(pos - 1, dc.id))
end
end
def self.renumber!(ids = Datacenter.order(:position, :id).select_map(:id))
ids.each_with_index { |id, i| Datacenter.where(id: id).update(position: i + 1) }
end
# "64" → 64.0, "25–30" → 30.0, ">40" → 40.0, "13,7" → 13.7, "" → nil
def self.mw_value(mw)
mw.to_s.tr(',', '.').scan(/\d+(?:\.\d+)?/).map(&:to_f).max
end
# Rank by power, unknown last; ties keep their current order.
def self.sort_by_mw!
DB.connection.transaction do
renumber!(Datacenter.order(:position, :id).all
.sort_by.with_index { |dc, i| [-(mw_value(dc.mw) || -1), i] }.map(&:id))
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.

View file

@ -15,6 +15,21 @@ module BingoAdmin
s.empty? || s.match?(URI::DEFAULT_PARSER.make_regexp(%w[http https]))
end
def position_param
params[:position].to_s =~ /\A\d+\z/ ? params[:position].to_i : nil
end
# Copies the edit form onto dc (position is handled by Bingo.place!).
def assign_dc(dc)
DC_TEXT.each { |f| dc[f.to_sym] = blank_nil(params[f]) }
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) }
errs
end
def blank_nil(s)
s = s.to_s.strip
s.empty? ? nil : s
@ -33,6 +48,33 @@ module BingoAdmin
erb :'admin/bingo/index'
end
app.get '/admin/bingo/dc/new' do
@dc = Bingo::Datacenter.new(status: 'operational')
@page_title = 'New datacenter · bingo admin'
@noindex = true
erb :'admin/bingo/edit'
end
app.post '/admin/bingo/dc' do
@dc = Bingo::Datacenter.new
@dc.slug = params[:slug].to_s.strip.downcase
errs = assign_dc(@dc)
errs.unshift('Slug: lowercase letters, digits and dashes') unless @dc.slug.match?(/\A[a-z0-9]+(-[a-z0-9]+)*\z/)
errs.unshift('Slug already taken') if Bingo::Datacenter.where(slug: @dc.slug).any?
if errs.any?
@flash = errs.join('; ')
@page_title = 'New datacenter · bingo admin'
@noindex = true
status 422
return erb :'admin/bingo/edit'
end
@dc.position = Bingo::Datacenter.count + 1
@dc.save
Bingo.place!(@dc, position_param)
session[:bingo_flash] = "Added #{@dc.name_ru} at ##{@dc.refresh.position}."
redirect "/admin/bingo/dc/#{@dc.id}"
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"
@ -43,15 +85,10 @@ module BingoAdmin
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) }
errs = assign_dc(dc)
if errs.empty?
dc.save_changes
Bingo.place!(dc, position_param) if position_param && position_param != dc.position
session[:bingo_flash] = 'Saved.'
else
session[:bingo_flash] = errs.join('; ')
@ -59,6 +96,25 @@ module BingoAdmin
redirect "/admin/bingo/dc/#{dc.id}"
end
# Deletes the row and (FK cascade) its incidents; the photo object stays
# in the bucket.
app.post '/admin/bingo/dc/:id/delete' do
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
n = dc.incidents.size
DB.connection.transaction do
dc.destroy
Bingo.renumber!
end
session[:bingo_flash] = "Deleted #{dc.name_ru}#{" and #{n} incident(s)" if n.positive?}."
redirect '/admin/bingo'
end
app.post '/admin/bingo/sort' do
Bingo.sort_by_mw!
session[:bingo_flash] = 'Sorted by MW (unknown last).'
redirect '/admin/bingo'
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

View file

@ -46,6 +46,7 @@ class BingoTest < Minitest::Test
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
@ -162,6 +163,79 @@ class BingoTest < Minitest::Test
assert_equal 2027, @dcs[0].year
end
def admin_token(path = '/admin/bingo')
get path, {}, admin_env
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
end
def dc_params(extra = {})
{ name_ru: 'IXcellerate MOS7', city_ru: 'Москва', operator: 'IXcellerate', mw: '34', status: 'building' }.merge(extra)
end
def test_mw_value
assert_equal 64.0, Bingo.mw_value('64')
assert_equal 30.0, Bingo.mw_value('25–30')
assert_equal 13.7, Bingo.mw_value('13,7')
assert_equal 40.0, Bingo.mw_value('>40')
assert_nil Bingo.mw_value('')
end
def test_admin_adds_datacenter_at_position
@app = AsxpioWeb
token = admin_token('/admin/bingo/dc/new')
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
mos7 = Bingo::Datacenter[slug: 'ix-mos7']
assert_equal 9, mos7.position
assert_equal 10, @dcs[8].refresh.position # old #9 shifted down
assert_equal 26, @dcs[24].refresh.position # old #25 now in reserve
assert_equal (1..26).to_a, Bingo::Datacenter.order(:position).select_map(:position)
board = Bingo::Board.load
assert_equal 25, board.cells.size
refute_includes board.cells.map(&:slug), 'dc-25'
assert_includes board.cells.map(&:slug), 'ix-mos7'
end
def test_admin_rejects_bad_or_duplicate_slug
@app = AsxpioWeb
token = admin_token('/admin/bingo/dc/new')
post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'Bad Slug'), admin_env
assert_equal 422, last_response.status
post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'dc-1'), admin_env
assert_equal 422, last_response.status
assert_equal 25, Bingo::Datacenter.count
end
def test_admin_moves_datacenter
@app = AsxpioWeb
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
assert_equal 1, @dcs[24].refresh.position
assert_equal 2, @dcs[0].refresh.position
assert_equal (1..25).to_a, Bingo::Datacenter.order(:position).select_map(:position)
end
def test_admin_deletes_datacenter_with_incidents
@app = AsxpioWeb
hit!(4)
token = admin_token("/admin/bingo/dc/#{@dcs[4].id}")
post "/admin/bingo/dc/#{@dcs[4].id}/delete", { authenticity_token: token }, admin_env
assert_equal 302, last_response.status
assert_nil Bingo::Datacenter[@dcs[4].id]
assert_equal 0, Bingo::Incident.count
assert_equal (1..24).to_a, Bingo::Datacenter.order(:position).select_map(:position)
end
def test_sort_by_mw
@dcs[0].update(mw: '') # unknown → last
@dcs[1].update(mw: '25–30')
Bingo.sort_by_mw!
order = Bingo::Datacenter.order(:position).select_map(:slug)
assert_equal %w[dc-2 dc-25 dc-24], order.first(3) # "25–30" ranks as 30
assert_equal 'dc-1', order.last
end
def test_upload_photo_via_admin
@app = AsxpioWeb
get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env

View file

@ -1,10 +1,12 @@
<% fresh = @dc.new? %>
<div class="header">
<h1>#<%= @dc.position %> <%= @dc.name_ru %></h1>
<p class="tagline"><a href="/admin/bingo">← bingo</a></p>
<h1><%= fresh ? 'New datacenter' : "##{@dc.position} #{@dc.name_ru}" %></h1>
<p class="tagline"><a href="/admin/bingo">← bingo</a><% if !fresh && @dc.position > Bingo::SIZE**2 %> · off card (reserve)<% end %></p>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<% unless fresh %>
<section class="section">
<h2>Photo</h2>
<% if @dc.photo_file %>
@ -21,11 +23,15 @@
<button type="submit" class="download-button">Store photo</button>
</form>
</section>
<% end %>
<section class="section">
<h2>Details</h2>
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>" autocomplete="off">
<form class="invoice-form" method="post" action="<%= fresh ? '/admin/bingo/dc' : "/admin/bingo/dc/#{@dc.id}" %>" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<% if fresh %>
<div class="field"><label for="slug">Slug <span class="optional">(permanent; used in photo names, e.g. ix-mos7)</span></label><input type="text" id="slug" name="slug" value="<%= @dc.slug %>" required pattern="[a-z0-9]+(-[a-z0-9]+)*" /></div>
<% end %>
<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>
@ -42,11 +48,21 @@
</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 class="field"><label for="position">Position <span class="optional">(inserts here; 1–<%= Bingo::SIZE**2 %> are on the card<%= ', blank = last' if fresh %>)</span></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>
<button type="submit" class="download-button"><%= fresh ? 'Add datacenter' : 'Save' %></button>
</form>
</section>
<% unless fresh %>
<section class="section">
<h2>Danger zone</h2>
<form method="post" action="/admin/bingo/dc/<%= @dc.id %>/delete" onsubmit="return confirm('Delete this datacenter and all its incidents? This cannot be undone.')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">delete this datacenter<%= " (and #{@dc.incidents.size} incident(s))" if @dc.incidents.any? %></button>
</form>
</section>
<% end %>

View file

@ -67,6 +67,11 @@
<section class="section">
<h2>Card</h2>
<p><a href="/admin/bingo/dc/new">+ Add datacenter</a> · positions 1–<%= Bingo::SIZE**2 %> are on the card, the rest are reserve</p>
<form method="post" action="/admin/bingo/sort" onsubmit="return confirm('Re-rank every datacenter by MW? Manual ordering is lost.')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">⇅ Sort by MW (unknown last)</button>
</form>
<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>
@ -80,6 +85,9 @@
<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| %>
<% if dc.position == Bingo::SIZE**2 + 1 %>
<tr><td colspan="8" class="optional">— reserve (not on the card) —</td></tr>
<% end %>
<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>