diff --git a/CLAUDE.md b/CLAUDE.md index 9d807d5..797c915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/-.` (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. diff --git a/lib/bingo.rb b/lib/bingo.rb index 9609b1c..4f170ad 100644 --- a/lib/bingo.rb +++ b/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. diff --git a/lib/bingo_admin.rb b/lib/bingo_admin.rb index 1957c06..c4ffd19 100644 --- a/lib/bingo_admin.rb +++ b/lib/bingo_admin.rb @@ -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 diff --git a/test/bingo_test.rb b/test/bingo_test.rb index e6fa277..a6729d9 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -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, 'asxp.io' 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 diff --git a/views/admin/bingo/edit.erb b/views/admin/bingo/edit.erb index 3582551..7e7f542 100644 --- a/views/admin/bingo/edit.erb +++ b/views/admin/bingo/edit.erb @@ -1,10 +1,12 @@ +<% fresh = @dc.new? %>
-

#<%= @dc.position %> <%= @dc.name_ru %>

-

← bingo

+

<%= fresh ? 'New datacenter' : "##{@dc.position} #{@dc.name_ru}" %>

+

← bingo<% if !fresh && @dc.position > Bingo::SIZE**2 %> · off card (reserve)<% end %>

<% if @flash %>
<%= @flash %>
<% end %> +<% unless fresh %>

Photo

<% if @dc.photo_file %> @@ -21,11 +23,15 @@
+<% end %>

Details

-
+ " autocomplete="off"> + <% if fresh %> +
+ <% end %>
<% %w[ru en uk].each do |l| %>
" <%= 'required' if l == 'ru' %> />
@@ -42,11 +48,21 @@
-
+
- +
+ +<% unless fresh %> +
+

Danger zone

+
+ + +
+
+<% end %> diff --git a/views/admin/bingo/index.erb b/views/admin/bingo/index.erb index 9523f19..fd8ec49 100644 --- a/views/admin/bingo/index.erb +++ b/views/admin/bingo/index.erb @@ -67,6 +67,11 @@

Card

+

+ Add datacenter · positions 1–<%= Bingo::SIZE**2 %> are on the card, the rest are reserve

+
+ + +
@@ -80,6 +85,9 @@ #PhotoNameOperatorMWStatusIncidents <% @dcs.each do |dc| %> + <% if dc.position == Bingo::SIZE**2 + 1 %> + — reserve (not on the card) — + <% end %> <%= dc.position %> <% if dc.photo_file %><% else %>—<% end %>