datacenterbingo gets a public page showing whether each datacenter's
network answers. The probing lives here, next to the schema and the
admin, since the public site only has a read-only role.
bin/monitor runs as a second container from the same image. Once a
minute it opens one TCP connection to every active target (a handshake
or a refusal both count as answered) and folds the results into
5-minute rows per datacenter, plus a per-bucket row for control hosts in
Russia, so a lost vantage point isn't mistaken for dark sites. Rows are
kept 30 days. Targets are managed at /admin/bingo/monitor; only hosts a
public source ties to that one site belong there.
Migration 017 also grants the new tables to the bingo role, so the
public site can read them without a storage redeploy.
Assisted-by: Claude:opus-5-5
The public card now runs from its own repo and container, with a
Traefik router of its own. Drop BingoWeb, its views, icons, Host
dispatch and the asxpio-bingo router here; keep the admin, the schema
and the photo pipeline, which the new site reads through a read-only
role.
BINGO_HOST stays for IndexNow pings and the admin's site link.
Assisted-by: Claude:opus-5
Part of the list was collected and verified with Claude, and several
entries came from the hackyourmom data-center map (Cat Eyes OSINT data).
Say so in the footer of the card and data-center pages, in all three
languages, and note that each entry was re-verified against operator
sites and the press.
Assisted-by: Claude:opus-5
Let visitors tick providers (Yandex, IXcellerate, RTK-DC, Sber, …) and
get the usual 5×5 card made of only those providers' datacenters, first
25 in ranking order, with lines scored as normal. A selection with fewer
than 25 sites leaves the rest of the card empty, so the card is always
5×5. Each provider chip shows crossed/total for the current view.
Datacenters get a provider slug, backfilled from the free-text operator,
guessed for new rows and editable in the admin. Filtered URLs are
noindex with canonical to the plain card.
Assisted-by: Claude:opus-5
The datacenter pages carry only generated sentences, which is thin
content to rank on. Migration 015 adds about_ru/en/uk, edited in the
admin form and rendered as paragraphs below the facts.
There is deliberately no fallback to ru: Russian prose on the English
page would hurt more than an absent paragraph.
Assisted-by: Claude:opus-5
Everything lived on one URL per language, with each datacenter's
details and incidents inside a <dialog>. That page can rank for the
brand and a couple of head terms, but not for searches like
"Yandex Sasovo drone strike".
Add /:locale/dc/:slug for every datacenter, reserve included. Each page
has its own title (naming the latest incident when there is one), a
description built from the facts, canonical and hreflang links, the
photo as og:image, and JSON-LD Place + BreadcrumbList. It shows the
facts in a sentence or two, the incident list with sources, and
prev/next links by position. The slug never changes, so the URLs are
stable.
The card cells are now links to these pages. A plain click still opens
the dialog, while modified clicks open the page. The dialog and the
incident log link to the page too, and the sitemap lists every page in
all three languages. The layout takes title, description, alternates
and JSON-LD from the route, and the dialog and the page share the meta
and incident partials.
Assisted-by: Claude:opus-5
The "updated" date and the sitemap lastmod came from max(created_at),
so editing a datacenter, fixing an incident title or deleting an
incident left them stale. Google stops trusting lastmod once it is
seen to be wrong.
Migration 014 adds updated_at (backfilled from created_at). Saves set
it, renumbering bumps only the rows whose position actually moved, and
deleting an incident bumps its datacenter. The admin delete now goes
through the model so the hook runs. The sitemap gives lastmod as a
full timestamp and drops changefreq, which Google ignores.
Assisted-by: Claude:opus-5
og:image pointed at the 1200px WebP dialog variant: no declared size,
a 16:9 frame that link previews crop unpredictably, and WebP isn't
rendered by every preview client. The alt text was just the site name.
Photo variants now include <slug>-<hex>-og.jpg, a centre crop to
1200x630, and the head declares its type and size. Migration 013 adds
photo_og so rows built before this keep using the WebP until "Rebuild
resized photos" is run. The alt text names the pictured datacenter and
city. resize and the new og_crop share one convert call.
Assisted-by: Claude:opus-5
The favicon was an emoji in a data: URI and /favicon.ico was a 404.
Google only shows a site icon in results when the favicon has a
crawlable URL, and the emoji looked different on every platform.
Add an SVG (a 5x5 card with a crossed-out diagonal), a 16/32/48 ICO
and a 180px apple-touch-icon under assets/bingo/, served by explicit
routes because public/ belongs to asxp.io.
Assisted-by: Claude:opus-5
/en/ answered 200 with the same page as /en. The canonical tag covered
it, but a 301 keeps crawlers and shared links on a single URL.
The All / Operational toggle links were rel="nofollow". The filtered
page is already noindex with a canonical back to the locale URL, so
nofollow on our own links only threw away internal link signals.
Assisted-by: Claude:opus-5
The h1 was just the brand ("Datacenter Bingo"), so the page's main
heading carried none of the terms people search for. Add a per-locale
tagline inside the h1 and shorten the lead paragraph so it no longer
repeats it.
Assisted-by: Claude:opus-5
The username field's attributes were emitted as one string through
<%= %>, which HTML-escapes the quotes, so the browser read the
placeholder as the literal "leave and dropped the rest. Write the
attributes in the template and test the rendered field.
Assisted-by: Claude:opus-5
Crypto options were limited to a hard-coded asset list, with one
address per coin in a side table. A payout address now carries
everything an invoice needs: ticker, name, QR URI scheme, amount
parameter, CoinGecko id, address and an active flag. Any coin can be
added, several addresses per ticker are fine, and known tickers fill the
QR and rate fields from the built-in templates.
The new-invoice form picks a payout address. The invoice snapshots the
ticker, address, name, scheme and amount parameter, and the PDF and QR
render from that copy, so editing, retiring or deleting a payout entry
never changes an issued invoice. "Fetch live" looks up the rate by the
payout address's CoinGecko id.
Migration 012 keeps the existing rows and backfills both the payout
metadata and the snapshots on existing crypto invoices.
Assisted-by: Claude:opus-5
Leaving the username empty when creating a user makes the account
invite-<hex>; the invitee picks the real username on the invite page
(taken names and the invite- prefix are refused). Without an email this
gives a plain invite link to share.
Assisted-by: Claude:opus-5
Add /admin/invoices/stats with a year filter: invoiced, received and
outstanding totals in GEL at each invoice's captured rate (the one
currency every invoice has), overdue count, average days to pay, money
received per month by payment date as a bar chart with the same figures
in a table, per-currency totals, top clients and crypto option usage.
Cancelled invoices are left out of every amount; hidden ones follow the
usual superuser-only visibility.
Assisted-by: Claude:opus-5
Replacing the paid button with the cancel/hide action block cut the old
markup at the first <% end %>, which belonged to the button's inner
if/else, leaving a stray </button></form><% end %> behind. show.erb no
longer compiled and every admin invoice page returned 500; nothing
rendered that page in tests.
Remove the leftovers, render the page in every state (pending, paid,
cancelled, hidden), and compile every view in a test so an unbalanced
template fails CI even when no test visits that page.
Assisted-by: Claude:opus-5
ADMIN_USER only seeds the first superuser on an empty table, so renaming
the secret afterwards had no effect and there was no other way to change
a login name. Superusers can now edit usernames in /admin/users,
including their own; sessions key on the user id and survive, duplicates
are refused, and the rename is audited.
Assisted-by: Claude:opus-5
Default crypto payout addresses lived in the CRYPTO_ADDRESSES and
LTC_ADDRESS Forgejo secrets, so changing one meant editing a secret and
redeploying. Keep them in a payout_addresses table edited at
/admin/invoices/payout-addresses (read to view, write to change, every
change audited), and prefill the new-invoice form from it.
The old env values are imported once on the first boot with an empty
table, so the current addresses carry over; after that the secrets can
be removed.
Assisted-by: Claude:opus-5
An invoice issued by mistake could only be left pending or marked paid.
Add cancel (with an optional reason) and reinstate. Cancelling renders a
third PDF variant with a diagonal CANCELLED stamp and the date/reason,
and it becomes the current one, so the client's link and download show
the invoice as void. Cancelled wins over paid in the status, and marking
a cancelled invoice paid is refused.
Superusers can also hide an invoice: it disappears from the admin list,
dashboard and invoice pages for every other user, while the client's
public link keeps working.
Assisted-by: Claude:opus-5
The invoice form's text inputs, textareas and label layout depended on
.contact-form rules that were deleted with the contact form, so they
fell back to white browser defaults with labels running inline. Give
every admin form (.invoice-form) one style: labels above fields, gray
inputs on the surface token, a focus state, single column on phones,
and the submit button styled like the rest.
Assisted-by: Claude:opus-5
Add /admin/users to create, edit, disable and delete accounts with the
per-area permission grid. New users get a one-time invite link (48 h)
by email; the admin also sees it once, so an account without email can
still be onboarded. Forgotten passwords get a 1 h reset link with the
same answer for unknown accounts. Only token hashes are stored.
Guards against escalation and lockout: a user manager can only grant
flags they hold and cannot touch flags they lack; only superusers change
superuser accounts; nobody can disable or delete themselves; the last
active superuser stays.
Logins, user changes, and bingo and invoice actions are recorded in an
audit log at /admin/audit.
Read timestamps as UTC: they are all written with Time.now.utc, and on a
UTC+4 host a fresh 1 h token read back as already expired.
Assisted-by: Claude:opus-5
Give each user independent read, write and delete flags per area
(invoices, bingo, users), so someone can e.g. review and remove bingo
incidents without being able to add any. The request method and path
decide the flag needed: GET is read except the new/duplicate forms, POST
to .../delete is delete, any other POST is write. Unmapped admin paths
are superuser-only, and a test enumerates every registered admin route
so a new one cannot ship without a rule.
Pages follow the same flags: nav and dashboard tiles show readable areas
only, write forms and delete buttons are hidden, and the datacenter form
is shown read-only.
Assisted-by: Claude:opus-5
The admin was one shared HTTP Basic login: no way to give someone
partial access, revoke one person, or tell who changed what. Add a users
table and a session login form. Passwords are scrypt hashes from Ruby's
OpenSSL, so no native gem is needed. Sessions carry a per-user version
that is bumped on password change, which logs that user out everywhere.
ADMIN_USER/ADMIN_PASSWORD now bootstrap the first superuser on an empty
table and afterwards only work while no active superuser exists, as a
recovery path that re-creates one. Until per-area permissions land,
non-superusers only reach the dashboard and their own password page.
Assisted-by: Claude:opus-5
/admin redirected to the invoice list, which stopped making sense once
the admin also ran the bingo. Make it a dashboard with one tile per
area: invoice totals, outstanding amount per currency, overdue count and
the five latest invoices; the bingo score, reserve size, latest incident
and card cells still missing a photo. A shared nav links the sections.
Tiles are per area so they can follow user permissions later.
Assisted-by: Claude:opus-5
"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
The card mixes facilities in service with construction projects (Sber
Balakovo at #1, MOS7, MOS11, Yandex Vladimir-2), which is fine for a
bingo but not when you want to compare what actually runs today. Add a
toggle that builds the card from operational rows only, in the same
ranking, with bingo lines recomputed for that card. The filtered view is
noindex with canonical to the plain locale URL, so it does not compete
with the main page in search.
Assisted-by: Claude:opus-5
The bingo host fell through to asxp.io's public/, so crawlers got
asxp.io's robots.txt pointing at asxp.io's sitemap (and the keys and
invoice JS were served too). Turn static files off for BingoWeb and give
it its own robots.txt and a sitemap with hreflang alternates.
The card loaded 15.6 MB of full-size photos as cell backgrounds. Derive
480/1200 px WebP variants with ImageMagick on store (memory/area limits,
input coder pinned to the sniffed type), render cells as <img> with lazy
loading past the first two rows, and add an admin action that rebuilds
variants from the stored originals.
Head gets per-locale titles and descriptions carrying the live score,
og:image from the latest hit, Twitter card, og:locale alternates,
JSON-LD WebSite + ItemList, and Vary: Accept-Language on the redirect.
Assisted-by: Claude:opus-5
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
datacenterbingo.xyz is not in the .xyz zone yet, while bingo.asxp.io
already resolves. Serve the card on both (BINGO_ALT_HOST) so it is
reachable now; canonical and hreflang links keep pointing at the .xyz
domain. Footer gets a copyright line linking back to asxp.io.
Assisted-by: Claude:opus-5
Photos are served byte-for-byte, and the Commons shot of Medvedkovo
carries GPS coordinates in its EXIF, while the card is meant to stay
city-level. Drop Exif/XMP/IPTC/comment segments from JPEG, text/eXIf
chunks from PNG and EXIF/XMP chunks from WebP on store; pixel data is
left alone and malformed files are rejected.
upload.wikimedia.org answered 429 to the generic agent; its policy asks
for a tool name and contact, so send one.
Add a "re-import all" admin action so photos stored before this change
get re-fetched and stripped.
Assisted-by: Claude:opus-5
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
One asset per invoice from a registry (lib/crypto_asset.rb): BTC, LTC,
ETH, XMR, SOL, ALGO, USDT/USDC on ERC-20/TRC-20/BEP-20/Solana/Algorand.
Migration 003 generalizes the ltc_* columns to crypto_* + crypto_coin
(existing LTC invoices backfilled). QR payload adapts per asset: BIP21
amount URIs where supported, bare address for tokens with a network
hint on the PDF. Default addresses come from the CRYPTO_ADDRESSES
secret (JSON code=>address); LTC_ADDRESS still works as legacy.
Three admin templates:
- index: invoice list with status badge and link to detail.
- new: client + terms fields, dynamic line-item rows backed by
admin-invoice-form.js (add/remove + re-index of items[N][...] names).
- show: invoice detail, public URL to share, toggle-paid button.
One public template, invoice_public.erb, shown at /i/:uuid: client name,
number, total, status, and a single big download button to /i/:uuid/pdf.
All admin pages are noindex; same for the public landing (it's a
client-specific URL, not search-discoverable content).