bingo: add, delete and reorder datacenters from admin
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:
parent
cd04b01e57
commit
e725395825
6 changed files with 197 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
|
|||
28
lib/bingo.rb
28
lib/bingo.rb
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue