bingo: serve datacenter bingo on bingo.asxp.io
Some checks failed
Tests / test (push) Successful in 18s
Build and Deploy to Production / test (push) Successful in 18s
Build and Deploy to Production / build (push) Failing after 2s
Build and Deploy to Production / deploy (push) Has been skipped

Add a second public site to the same process: a 5x5 card of the 25
largest Russian datacenters where a cell is crossed out once an incident
with a public source is recorded (Yandex Sasovo and Kaluga drone strikes
on 2026-10-08/09 are seeded). config.ru dispatches by Host, so it rides
the existing container, DB and bucket instead of a new deploy.

The card is localized ru/en/uk with per-locale columns falling back to
ru. Content is managed from /admin/bingo on asxp.io, reusing AdminAuth
and CSRF. Photos go to the existing bucket under bingo/ and are proxied
by the app, so the bucket stays private and storage needs no change.

The seed data was fact-checked against operator sites and press on
2026-10-10; several entries in the original list were wrong or vague.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 04:51:26 +04:00
commit bdb3682e18
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
19 changed files with 1428 additions and 2 deletions

View file

@ -0,0 +1,52 @@
<div class="header">
<h1>#<%= @dc.position %> <%= @dc.name_ru %></h1>
<p class="tagline"><a href="/admin/bingo">← bingo</a></p>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<section class="section">
<h2>Photo</h2>
<% if @dc.photo_file %>
<p><img src="/admin/bingo/p/<%= @dc.photo_file %>" alt="" style="max-width: 100%; max-height: 280px" /></p>
<form method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo/delete">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">remove from card</button>
</form>
<% end %>
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo" enctype="multipart/form-data">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<div class="field"><label for="photo">Upload <span class="optional">(JPEG/PNG/WebP, ≤ 8 MB)</span></label><input type="file" id="photo" name="photo" accept="image/jpeg,image/png,image/webp" /></div>
<div class="field"><label for="photo_url">…or import from URL <span class="optional">(blank = saved source URL)</span></label><input type="url" id="photo_url" name="url" placeholder="<%= @dc.photo_source_url %>" /></div>
<button type="submit" class="download-button">Store photo</button>
</form>
</section>
<section class="section">
<h2>Details</h2>
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<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>
<% end %>
<% %w[ru en uk].each do |l| %>
<div class="field"><label for="city_<%= l %>">City (<%= l %>)</label><input type="text" id="city_<%= l %>" name="city_<%= l %>" value="<%= @dc[:"city_#{l}"] %>" <%= 'required' if l == 'ru' %> /></div>
<% 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="status">Status</label>
<select id="status" name="status">
<% %w[operational building].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>
<div class="field"><label for="position">Position (1–25)</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>
</form>
</section>

View file

@ -0,0 +1,91 @@
<div class="header">
<h1>Bingo</h1>
<p class="tagline">
<a href="/admin/invoices">invoices</a>
<% if bingo_public_url %> · <a href="<%= bingo_public_url %>" target="_blank" rel="noopener">public card ↗</a><% end %>
</p>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<section class="section">
<h2>Record incident</h2>
<form class="invoice-form" method="post" action="/admin/bingo/incidents" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<div class="invoice-grid">
<div class="field">
<label for="datacenter_id">Datacenter</label>
<select id="datacenter_id" name="datacenter_id" required>
<% @dcs.each do |dc| %><option value="<%= dc.id %>">#<%= dc.position %> <%= dc.name_ru %></option><% end %>
</select>
</div>
<div class="field">
<label for="occurred_on">Date</label>
<input type="date" id="occurred_on" name="occurred_on" value="<%= Date.today %>" required />
</div>
<div class="field">
<label for="kind">Kind</label>
<select id="kind" name="kind">
<% Bingo::KINDS.each do |k| %><option value="<%= k %>"><%= Bingo::ICONS[k] %> <%= k %></option><% end %>
</select>
</div>
</div>
<div class="field"><label for="title_ru">Title (ru)</label><input type="text" id="title_ru" name="title_ru" required maxlength="300" /></div>
<div class="field"><label for="title_en">Title (en) <span class="optional">(falls back to ru)</span></label><input type="text" id="title_en" name="title_en" maxlength="300" /></div>
<div class="field"><label for="title_uk">Title (uk) <span class="optional">(falls back to ru)</span></label><input type="text" id="title_uk" name="title_uk" maxlength="300" /></div>
<div class="field"><label for="url">Source URL</label><input type="url" id="url" name="url" placeholder="https://" /></div>
<button type="submit" class="download-button">Cross it out</button>
</form>
</section>
<section class="section">
<h2>Incidents</h2>
<% if @incidents.empty? %>
<p>None yet.</p>
<% else %>
<table class="invoice-list">
<thead><tr><th>Date</th><th>Datacenter</th><th>Kind</th><th>Title</th><th></th></tr></thead>
<tbody>
<% @incidents.each do |i| %>
<tr>
<td class="mono"><%= i.occurred_on %></td>
<td><%= i.datacenter.name_ru %></td>
<td><%= Bingo::ICONS[i.kind] %> <%= i.kind %></td>
<td><% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener"><%= i.title_ru %></a><% else %><%= i.title_ru %><% end %></td>
<td class="actions">
<form method="post" action="/admin/bingo/incidents/<%= i.id %>/delete" onsubmit="return confirm('Delete this incident?')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">delete</button>
</form>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</section>
<section class="section">
<h2>Card</h2>
<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>
</form>
<table class="invoice-list">
<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| %>
<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>
<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><%= dc.status %></td>
<td class="num mono"><%= dc.incidents.size %></td>
<td class="actions"><a href="/admin/bingo/dc/<%= dc.id %>">edit</a></td>
</tr>
<% end %>
</tbody>
</table>
</section>

View file

@ -1,6 +1,6 @@
<div class="header">
<h1>Invoices</h1>
<p class="tagline"><a href="/admin/invoices/new">+ New invoice</a></p>
<p class="tagline"><a href="/admin/invoices/new">+ New invoice</a> · <a href="/admin/bingo">bingo</a></p>
</div>
<% if @invoices.empty? %>