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
116 lines
6.5 KiB
Text
116 lines
6.5 KiB
Text
<%== erb :'admin/_nav', layout: false %>
|
||
<div class="header">
|
||
<h1>Bingo</h1>
|
||
<p class="tagline">
|
||
<% if bingo_public_url %><a href="<%= bingo_public_url %>" target="_blank" rel="noopener">public card ↗</a> · <% end %><a href="/admin/bingo/monitor">monitor</a>
|
||
</p>
|
||
</div>
|
||
|
||
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
|
||
|
||
<% if can?('bingo', 'write') %>
|
||
<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>
|
||
<% end %>
|
||
|
||
<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">
|
||
<% if can?('bingo', 'delete') %>
|
||
<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>
|
||
<% end %>
|
||
</td>
|
||
</tr>
|
||
<% end %>
|
||
</tbody>
|
||
</table>
|
||
<% end %>
|
||
</section>
|
||
|
||
<section class="section">
|
||
<h2>Card</h2>
|
||
<% if can?('bingo', 'write') %>
|
||
<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>
|
||
</form>
|
||
<form method="post" action="/admin/bingo/photos/rebuild">
|
||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||
<button type="submit" class="link-button">⟳ Rebuild resized photos from stored originals</button>
|
||
</form>
|
||
<form method="post" action="/admin/bingo/photos/import" onsubmit="return confirm('Re-download every photo that has a source URL? Manually uploaded photos with a source URL get replaced too.')">
|
||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||
<input type="hidden" name="all" value="1" />
|
||
<button type="submit" class="link-button">↻ Re-import all photos from their source URLs</button>
|
||
</form>
|
||
<% else %>
|
||
<p class="optional">Positions 1–<%= Bingo::SIZE**2 %> are on the card, the rest are reserve.</p>
|
||
<% end %>
|
||
<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| %>
|
||
<% 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(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 %><%= " #{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 %>"><%= can?('bingo', 'write') ? 'edit' : 'view' %></a></td>
|
||
</tr>
|
||
<% end %>
|
||
</tbody>
|
||
</table>
|
||
</section>
|