Files
cloud-ip-validator/internal/dashboard/templates/ips.html
T
ayurishchevandClaude Sonnet 5.5 aff8fe38b5 Scan floating IPs in the background, page by page, so thousands of addresses work
The "Scan Floating IP" button failed with a client timeout: the project now
holds ~6.4k floating IPs and the scan listed them all in one unpaginated,
timeout-less Neutron request on the HTTP request context.

openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them
small) with per-page retry/backoff on transport errors, 5xx and 429, and every
request now has a timeout (also ends hangs inside the orchestrator tick).

orchestrator: the scan is a single-flight background job on the process
context with progress (clearing/listing/enqueuing/done/error), dry_run, full
discovery before anything is enqueued, then SubmitIPs in chunks of 500 in
ascending IP order; a failed read leaves the queue untouched. The auto-cycle
gets a "scanning" phase that polls the job, so the control loop and
autoCycleMu are never held across OpenStack/DB work; it recovers after a
restart and waits for (instead of adopting) a scan started by someone else.

db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY
counters, EXISTS completion check, set-based ClearAllIPs.

API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging
and filters on /admin/ips and /admin/registry (bare arrays without limit),
results_by_overall in /admin/status.

dashboard: scan progress panel and dry-run button, paginated /ips and
/registry with server-side filters, Overview on counters and capped lists
with progress/ETA, "select all N by filter", hx-params fix for per-row
buttons, real counts in confirmations.

Also: docs (API, USAGE, DASHBOARD, README), plan and review under
docs/changes/, bin/ rebuilt with new SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 19:31:11 +03:00

202 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "ips_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
<div class="bg-grid"></div>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "ips_content" .}}
</main>
</div>
</div>
</body>
</html>
{{end}}
{{define "ips_content"}}
<div class="topbar"><h1>Очередь IP</h1></div>
<div class="panel">
<div class="panel-body">
<form hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-include=".ips-ctx" hx-sync="#ips-table-wrap:queue last" hx-on::after-request="this.reset()">
<div class="field-row">
<div class="field" style="flex: 1 1 420px;">
<label for="addresses">Адреса (по одному на строку или через запятую)</label>
<textarea id="addresses" name="addresses" rows="2" placeholder="203.0.113.10&#10;203.0.113.11"></textarea>
</div>
<button type="submit" class="btn btn-primary">Добавить / перепроверить</button>
</div>
</form>
<p class="muted" style="margin-top:8px">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
(тот же вызов); тот, что сейчас проверяется, не трогается. Кнопка «Сканировать Floating IP» ниже делает то же самое
автоматически: находит в проекте OpenStack все свободные (не привязанные к порту) Floating IP и сразу передаёт их
в очередь на проверку — сканирование идёт в фоне, прогресс виден в панели под кнопкой. Полная история проверок по
каждому адресу — включая уже удалённые из очереди — доступна в <a href="/registry">реестре</a>.</p>
</div>
</div>
{{template "scan_progress" .Scan}}
<form id="ips-filter" class="panel" onsubmit="return false" style="margin-top:16px">
<div class="panel-body field-row">
<div class="field" style="flex:1 1 260px">
<label for="ips-q">Поиск по IP</label>
<input type="search" id="ips-q" name="q" value="{{.Filter.Q}}" placeholder="203.0.113.10"
hx-get="/ips" hx-select="#ips-table-wrap" hx-target="#ips-table-wrap" hx-swap="outerHTML"
hx-include="#ips-filter" hx-trigger="input changed delay:300ms"
hx-replace-url="true" hx-sync="#ips-table-wrap:queue last">
</div>
<div class="field">
<label for="ips-state">Состояние</label>
<select id="ips-state" name="state"
hx-get="/ips" hx-select="#ips-table-wrap" hx-target="#ips-table-wrap" hx-swap="outerHTML"
hx-include="#ips-filter" hx-trigger="change"
hx-replace-url="true" hx-sync="#ips-table-wrap:queue last">
{{$tok := .Filter.Token}}
<option value="">Все</option>
<option value="queued" {{if eq $tok "queued"}}selected{{end}}>в очереди (queued)</option>
<option value="active" {{if eq $tok "active"}}selected{{end}}>в работе (assigning_fip, awaiting_self_check, checking, aggregating)</option>
<option value="done" {{if eq $tok "done"}}selected{{end}}>done</option>
<option value="failed" {{if eq $tok "failed"}}selected{{end}}>failed</option>
<option value="occupied" {{if eq $tok "occupied"}}selected{{end}}>occupied</option>
<option value="result:pass" {{if eq $tok "result:pass"}}selected{{end}}>результат: pass</option>
<option value="result:partial" {{if eq $tok "result:partial"}}selected{{end}}>результат: partial</option>
<option value="result:fail" {{if eq $tok "result:fail"}}selected{{end}}>результат: fail</option>
<option value="result:cancelled" {{if eq $tok "result:cancelled"}}selected{{end}}>результат: cancelled</option>
</select>
</div>
<div class="field" style="flex:0 0 120px">
<label for="ips-per-page">На странице</label>
<select id="ips-per-page" name="per_page"
hx-get="/ips" hx-select="#ips-table-wrap" hx-target="#ips-table-wrap" hx-swap="outerHTML"
hx-include="#ips-filter" hx-trigger="change"
hx-replace-url="true" hx-sync="#ips-table-wrap:queue last">
{{$pp := .PerPage}}
{{range .PerPageOptions}}<option value="{{.}}" {{if eq . $pp}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</form>
{{template "ips_table_wrap" .}}
{{end}}
{{/* #ips-table-wrap is the swap target of every table mutation, the filter form
and the pager (all hx-swap="outerHTML", so the wrapper's own hx-get always
points at the page/filter it currently shows). It listens for the
scan-finished event (HX-Trigger from the scan progress panel) and reloads
itself. hx-disinherit="*": its attributes must not leak into the buttons
inside. */}}
{{define "ips_table_wrap"}}
<div id="ips-table-wrap" hx-get="{{.SelfURL}}" hx-trigger="scan-finished from:body" hx-select="#ips-table-wrap" hx-target="this" hx-swap="outerHTML" hx-disinherit="*">
{{template "ips_table" .}}
</div>
{{end}}
{{/* Scan progress panel. Lives outside #ips-form (no checkbox payload, survives
table swaps). While the job runs it polls itself; the response that finds
it finished has no hx-trigger and carries HX-Trigger: scan-finished. */}}
{{define "scan_progress"}}
{{$s := .Status}}
<div id="scan-progress" class="panel scan-progress"{{if .Poll}} hx-get="/ips/scan/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
<div class="panel-body">
<div class="scan-actions">
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/scan" hx-target="#scan-progress" hx-swap="outerHTML" hx-params="page,per_page,q,state,result"{{if $s.Running}} disabled{{end}}>Сканировать Floating IP</button>
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/scan?dry_run=true" hx-target="#scan-progress" hx-swap="outerHTML" hx-params="page,per_page,q,state,result"{{if $s.Running}} disabled{{end}} title="Только найти и посчитать свободные адреса, очередь не меняется">Пробное сканирование</button>
{{if and $s.State (ne $s.State "idle")}}<span class="pill {{$s.PillClass}}">{{$s.StateLabel}}</span>{{if $s.DryRun}} <span class="muted">пробный запуск: очередь не меняется</span>{{end}}{{else}}<span class="muted">Найти все свободные Floating IP проекта и поставить их в очередь.</span>{{end}}
</div>
{{if and $s.State (ne $s.State "idle")}}
{{if or $s.Running (eq $s.State "done")}}
{{if $s.Indeterminate}}<progress aria-label="Сканирование"></progress>
{{else if eq $s.State "done"}}<progress max="1" value="1" aria-label="Сканирование"></progress>
{{else}}<progress max="{{$s.Free}}" value="{{$s.Handled}}" aria-label="Сканирование"></progress>{{end}}
{{end}}
<dl class="scan-counters">
<div><dt>прочитано страниц</dt><dd>{{$s.Pages}}</dd></div>
<div><dt>найдено адресов</dt><dd>{{$s.Discovered}}</dd></div>
<div><dt>свободных</dt><dd>{{$s.Free}}</dd></div>
<div><dt>добавлено</dt><dd>{{$s.Added}}</dd></div>
<div><dt>повторно</dt><dd>{{$s.Requeued}}</dd></div>
<div><dt>переупорядочено</dt><dd>{{$s.Reordered}}</dd></div>
{{if $s.SkippedInProgress}}<div><dt>уже в работе</dt><dd>{{$s.SkippedInProgress}}</dd></div>{{end}}
{{with $s.Elapsed}}<div><dt>время</dt><dd>{{.}}</dd></div>{{end}}
</dl>
{{if $s.Error}}<p class="scan-error">{{$s.Error}}</p>{{end}}
{{end}}
</div>
</div>
{{end}}
{{define "ips_table"}}
<form id="ips-form" onchange="var f=this,k=f.querySelectorAll('input[name=addresses]:checked').length,n=f.querySelectorAll('input[name=addresses]').length;f.querySelector('[data-sel-count]').textContent=k;f.classList.toggle('sel-all',n>0&&k===n);if(k!==n){f.elements.scope.value='';f.classList.remove('scope-all')}">
<input type="hidden" class="ips-ctx" name="page" value="{{.Page}}">
<input type="hidden" class="ips-ctx" name="per_page" value="{{.PerPage}}">
<input type="hidden" class="ips-ctx" name="q" value="{{.Filter.Q}}">
<input type="hidden" class="ips-ctx" name="state" value="{{.Filter.State}}">
<input type="hidden" class="ips-ctx" name="result" value="{{.Filter.Result}}">
<input type="hidden" name="scope" value="">
<div class="panel">
<div class="panel-body bulk-bar">
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/recheck" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-sync="#ips-table-wrap:queue last">Перепроверить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm btn-del-sel" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить выбранные адреса без возможности восстановления?">Удалить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm btn-del-all" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить ВСЕ {{.Total}} {{pluralAddr .Total}}{{if .Filter.Active}} по текущему фильтру{{end}}, включая идущие проверки? Действие необратимо.">Удалить все {{.Total}} по фильтру</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/clear" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить ВСЕ {{.QueueTotal}} {{pluralAddr .QueueTotal}} из очереди, включая идущие проверки? Действие необратимо.">Очистить всё</button>
</div>
<div class="bulk-hint">
Выбрано на странице: <b data-sel-count>0</b> из {{len .Items}}
{{if gt .Total (len .Items)}}<a href="#" class="select-all-link" onclick="var f=this.closest('form');f.elements.scope.value='all';f.classList.add('scope-all');return false">Выбрать все {{.Total}} по фильтру</a>
<span class="scope-all-msg">Выбраны все {{.Total}} {{pluralAddr .Total}} по фильтру (не только на этой странице).</span>{{end}}
</div>
<div class="table-scroll">
<table>
<thead><tr><th><input type="checkbox" aria-label="Выбрать все на странице" onclick="this.closest('table').querySelectorAll('input[name=addresses]').forEach(cb => cb.checked = this.checked)"></th><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th></th></tr></thead>
<tbody>
{{range .Items}}
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt $.FIPSettleSeconds}}
{{$terminal := or (eq .State "done") (eq .State "failed") (eq .State "occupied")}}
<tr>
<td data-label=""><input type="checkbox" name="addresses" value="{{.IPAddress}}"></td>
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
<td data-label="Валидатор">{{deref .OwnerValidatorID}}</td>
<td class="num" data-label="Попытка">{{.AttemptNumber}}</td>
<td class="num" data-label="Обновлено">{{fmtTime .UpdatedAt}}</td>
<td data-label="">
<div class="actions">
{{if $terminal}}
<button class="btn btn-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last">Перепроверить</button>
{{else}}
<button class="btn btn-danger-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
{{end}}
<button class="btn btn-danger-ghost btn-sm" hx-delete="/ips/{{.IPAddress}}" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить {{.IPAddress}} без возможности восстановления?">Удалить</button>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "pager" .Pager}}
</form>
{{if not .Items}}<p class="muted">{{if .Filter.Active}}Ничего не найдено по текущему фильтру.{{else}}Очередь пуста.{{end}}</p>{{end}}
{{end}}
{{define "pager"}}{{if .Total}}
<div class="pager">
<span class="pager-info">Показано {{.From}}–{{.To}} из {{.Total}}</span>
<span class="pager-nav">
{{if .PrevURL}}<a class="btn btn-ghost btn-sm" href="{{.PrevURL}}" hx-get="{{.PrevURL}}" hx-select="#{{.Wrap}}" hx-target="#{{.Wrap}}" hx-swap="outerHTML" hx-push-url="true" rel="prev" aria-label="Предыдущая страница">‹</a>{{else}}<span class="btn btn-ghost btn-sm is-disabled" aria-disabled="true">‹</span>{{end}}
<span class="pager-page">{{.Page}} / {{.Pages}}</span>
{{if .NextURL}}<a class="btn btn-ghost btn-sm" href="{{.NextURL}}" hx-get="{{.NextURL}}" hx-select="#{{.Wrap}}" hx-target="#{{.Wrap}}" hx-swap="outerHTML" hx-push-url="true" rel="next" aria-label="Следующая страница">›</a>{{else}}<span class="btn btn-ghost btn-sm is-disabled" aria-disabled="true">›</span>{{end}}
</span>
</div>
{{end}}{{end}}