bingo: planned status and capacity type
"MW" on the card mixed IT load, connected power, total site power,
design capacity and even MVA (Oxygen, Sber Yuzhny Port). Record which
one a figure is in a new mw_type column; the dialog tags the number
("64 MW · design") and MVA replaces the unit, while cells keep the bare
figure to stay readable. Add a "planned" status for projects not yet
under construction; like "building", it is left out of the operational
view.
Assisted-by: Claude:opus-5
This commit is contained in:
parent
9e885dd8c1
commit
e5bd1c1ac7
9 changed files with 69 additions and 9 deletions
|
|
@ -123,6 +123,7 @@ A second public site served from the **same process** on `BINGO_HOST` (`datacent
|
|||
- 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; 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".
|
||||
- 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.
|
||||
- Views: `?view=operational` builds the card from operational rows only (same ranking, construction projects skipped, lines recomputed); it is noindex with canonical to the bare locale URL.
|
||||
- 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, metadata stripped (GPS!). URL import is https-only, admin-only. On store, ImageMagick (`convert`, installed in the Dockerfile) derives `-480.webp` (card cells) and `-1200.webp` (dialog, og:image); `photo_variants` records success and the page falls back to the original without them. "Rebuild resized photos" in admin re-derives from stored originals.
|
||||
|
|
|
|||
13
bingo_web.rb
13
bingo_web.rb
|
|
@ -57,8 +57,17 @@ class BingoWeb < Sinatra::Base
|
|||
end
|
||||
def fmt_date(d) = d.strftime('%d.%m.%Y')
|
||||
|
||||
def mw(dc)
|
||||
dc.mw.to_s.strip.empty? ? nil : "#{dc.mw} #{t(:mw)}"
|
||||
# "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
|
||||
|
||||
# Badge for anything not yet in service.
|
||||
def status_label(dc)
|
||||
{ 'building' => t(:building), 'planned' => t(:planned) }[dc.status]
|
||||
end
|
||||
|
||||
def preferred_locale
|
||||
|
|
|
|||
7
db/migrations/006_bingo_mw_type.rb
Normal file
7
db/migrations/006_bingo_mw_type.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Sequel.migration do
|
||||
change do
|
||||
# What the mw figure measures: it | connected | total | design | mva.
|
||||
# NULL = not stated by the source.
|
||||
add_column :bingo_datacenters, :mw_type, String
|
||||
end
|
||||
end
|
||||
11
lib/bingo.rb
11
lib/bingo.rb
|
|
@ -12,6 +12,8 @@ module Bingo
|
|||
KINDS = %w[strike fire outage flood cooling other].freeze
|
||||
ICONS = { 'strike' => '💥', 'fire' => '🔥', 'outage' => '⚡', 'flood' => '💧', 'cooling' => '🌡️', 'other' => '⚠️' }.freeze
|
||||
SIZE = 5
|
||||
STATUSES = %w[operational building planned].freeze
|
||||
MW_TYPES = %w[it connected total design mva].freeze
|
||||
|
||||
# Every row, column and both diagonals, as 0-based cell indices.
|
||||
LINES = (
|
||||
|
|
@ -30,6 +32,8 @@ module Bingo
|
|||
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: 'Инцидентов не зафиксировано.',
|
||||
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
|
||||
|
|
@ -46,6 +50,8 @@ module Bingo
|
|||
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.',
|
||||
footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.',
|
||||
|
|
@ -62,6 +68,8 @@ module Bingo
|
|||
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: 'Інцидентів не зафіксовано.',
|
||||
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
|
||||
|
|
@ -88,6 +96,9 @@ module Bingo
|
|||
super
|
||||
end
|
||||
|
||||
def operational? = status == 'operational'
|
||||
def planned? = status == 'planned'
|
||||
|
||||
def building?
|
||||
status == 'building'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ require 'uri'
|
|||
# /admin/bingo/* on asxp.io: edit the card and record incidents for BingoWeb.
|
||||
# Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply.
|
||||
module BingoAdmin
|
||||
DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator mw status
|
||||
DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator mw mw_type status
|
||||
photo_credit photo_source_url info_url].freeze
|
||||
|
||||
module Helpers
|
||||
|
|
@ -23,7 +23,8 @@ module BingoAdmin
|
|||
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)
|
||||
dc.status = 'operational' unless Bingo::STATUSES.include?(dc.status)
|
||||
dc.mw_type = nil unless Bingo::MW_TYPES.include?(dc.mw_type)
|
||||
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) }
|
||||
|
|
|
|||
|
|
@ -144,6 +144,30 @@ class BingoTest < Minitest::Test
|
|||
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'
|
||||
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
|
||||
@app = AsxpioWeb
|
||||
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
|
||||
@dcs[0].refresh
|
||||
assert @dcs[0].planned?
|
||||
assert_equal 'connected', @dcs[0].mw_type
|
||||
post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', mw_type: 'bogus'), admin_env
|
||||
assert_nil @dcs[0].refresh.mw_type
|
||||
end
|
||||
|
||||
def test_robots_and_sitemap_are_the_bingos_own
|
||||
get '/robots.txt'
|
||||
assert last_response.ok?
|
||||
|
|
|
|||
|
|
@ -41,10 +41,17 @@
|
|||
<% end %>
|
||||
<div class="field"><label for="operator">Operator</label><input type="text" id="operator" name="operator" value="<%= @dc.operator %>" required /></div>
|
||||
<div class="field"><label for="mw">MW <span class="optional">(number or range)</span></label><input type="text" id="mw" name="mw" value="<%= @dc.mw %>" /></div>
|
||||
<div class="field">
|
||||
<label for="mw_type">Capacity type</label>
|
||||
<select id="mw_type" name="mw_type">
|
||||
<option value="" <%= 'selected' unless @dc.mw_type %>>not stated</option>
|
||||
<% Bingo::MW_TYPES.each do |m| %><option value="<%= m %>" <%= 'selected' if @dc.mw_type == m %>><%= m %></option><% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<% %w[operational building].each do |s| %><option <%= 'selected' if @dc.status == s %>><%= s %></option><% end %>
|
||||
<% Bingo::STATUSES.each do |s| %><option <%= 'selected' if @dc.status == s %>><%= s %></option><% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label for="year">Year</label><input type="text" id="year" name="year" value="<%= @dc.year %>" inputmode="numeric" /></div>
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@
|
|||
<td><% if dc.photo_file %><img src="/admin/bingo/p/<%= dc.photo_file(480) %>" alt="" width="64" height="40" style="object-fit: cover" /><% else %>—<% end %></td>
|
||||
<td><%= dc.name_ru %><br /><span class="optional"><%= dc.city_ru %></span></td>
|
||||
<td><%= dc.operator %></td>
|
||||
<td class="num mono"><%= dc.mw %></td>
|
||||
<td class="num mono"><%= dc.mw %><%= " #{dc.mw_type}" if dc.mw_type %></td>
|
||||
<td><%= dc.status %></td>
|
||||
<td class="num mono"><%= dc.incidents.size %></td>
|
||||
<td class="actions"><a href="/admin/bingo/dc/<%= dc.id %>">edit</a></td>
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@
|
|||
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)}" if mw(dc) %></div>
|
||||
<% if dc.building? %><div class="tag"><%= t(:building) %></div><% end %>
|
||||
<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>
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
<div class="body">
|
||||
<h3><%= dc.l(:name, @locale) %></h3>
|
||||
<div class="meta">
|
||||
<%= [dc.operator, mw(dc), dc.l(:city, @locale), dc.building? ? t(:building) : (dc.year && format(t(:since), dc.year))].compact.join(' · ') %>
|
||||
<%= [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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue