2026-08-23 20:39:22 +03:00
{{define "ips_page"}}
<!doctype html>
< html lang = "ru" >
< head > {{template "html_head" .}}</ head >
< body >
2026-08-26 22:19:42 +03:00
< div class = "bg-grid" ></ div >
2026-08-23 21:58:19 +03:00
< input type = "checkbox" id = "nav-toggle" class = "nav-toggle" >
< div class = "shell" >
{{template "sidebar_nav" .}}
< div >
{{template "topbar_mobile" .}}
< main class = "main" >
2026-08-23 20:39:22 +03:00
< div id = "error-banner" > {{template "banner_inner" .Banner}}</ div >
{{template "ips_content" .}}
</ main >
2026-08-23 21:58:19 +03:00
</ div >
</ div >
2026-08-23 20:39:22 +03:00
</ body >
</ html >
{{end}}
{{define "ips_content"}}
2026-08-23 21:58:19 +03:00
< div class = "topbar" >< h1 > Очередь IP</ h1 ></ div >
2026-08-23 20:39:22 +03:00
2026-08-23 21:58:19 +03:00
< div class = "panel" >
< div class = "panel-body" >
2026-10-01 19:31:11 +03:00
< 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()" >
2026-08-23 21:58:19 +03:00
< 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 203.0.113.11" ></ textarea >
2026-08-23 21:15:31 +03:00
</ div >
2026-08-23 21:58:19 +03:00
< button type = "submit" class = "btn btn-primary" > Добавить / перепроверить</ button >
2026-08-23 21:15:31 +03:00
</ div >
2026-08-23 20:39:22 +03:00
</ form >
2026-08-23 21:58:19 +03:00
< p class = "muted" style = "margin-top:8px" > Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
2026-09-23 09:52:01 +03:00
(тот же вызов); тот, что сейчас проверяется, не трогается. Кнопка «Сканировать Floating IP» ниже делает то же самое
автоматически: находит в проекте OpenStack все свободные (не привязанные к порту) Floating IP и сразу передаёт их
2026-10-01 19:31:11 +03:00
в очередь на проверку — сканирование идёт в фоне, прогресс виден в панели под кнопкой. Полная история проверок по
каждому адресу — включая уже удалённые из очереди — доступна в < a href = "/registry" > реестре</ a > .</ p >
2026-08-23 21:58:19 +03:00
</ div >
</ div >
2026-08-23 20:39:22 +03:00
2026-10-01 19:31:11 +03:00
{{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 = "*" >
2026-08-23 20:39:22 +03:00
{{template "ips_table" .}}
</ div >
{{end}}
2026-10-01 19:31:11 +03:00
{{/* 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}}
2026-08-23 20:39:22 +03:00
{{define "ips_table"}}
2026-10-01 19:31:11 +03:00
< 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 = "" >
2026-08-23 21:58:19 +03:00
< div class = "panel" >
2026-10-01 19:31:11 +03:00
< 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}}
2026-08-23 22:24:55 +03:00
</ div >
2026-08-23 21:58:19 +03:00
< div class = "table-scroll" >
< table >
2026-10-01 19:31:11 +03:00
< 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 >
2026-08-23 20:39:22 +03:00
< tbody >
{{range .Items}}
2026-08-24 10:29:08 +03:00
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt $.FIPSettleSeconds}}
2026-09-13 23:54:35 +03:00
{{$terminal := or (eq .State "done") (eq .State "failed") (eq .State "occupied")}}
2026-08-23 20:39:22 +03:00
< tr >
2026-09-18 19:29:26 +03:00
< td data-label = "" >< input type = "checkbox" name = "addresses" value = "{{.IPAddress}}" ></ td >
2026-08-23 21:58:19 +03:00
< 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" >
2026-08-23 20:39:22 +03:00
{{if $terminal}}
2026-10-01 19:31:11 +03:00
< 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 >
2026-08-23 20:39:22 +03:00
{{else}}
2026-10-01 19:31:11 +03:00
< 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 >
2026-08-23 20:39:22 +03:00
{{end}}
2026-10-01 19:31:11 +03:00
< 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 >
2026-08-23 21:58:19 +03:00
</ div >
2026-08-23 20:39:22 +03:00
</ td >
</ tr >
{{end}}
</ tbody >
</ table >
2026-08-23 21:15:31 +03:00
</ div >
2026-08-23 21:58:19 +03:00
</ div >
2026-10-01 19:31:11 +03:00
{{template "pager" .Pager}}
2026-08-23 22:24:55 +03:00
</ form >
2026-10-01 19:31:11 +03:00
{{if not .Items}}< p class = "muted" > {{if .Filter.Active}}Ничего не найдено по текущему фильтру.{{else}}Очередь пуста.{{end}}</ p > {{end}}
2026-08-23 20:39:22 +03:00
{{end}}
2026-10-01 19:31:11 +03:00
{{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}}