Files
backup-master/internal/server/clients_list.go
T
mrleo1nidandClaude Opus 5.5 83832fc734
CI / test (push) Successful in 1m19s
Release / release (push) Successful in 1m18s
Веб-морда: поиск, фильтры, сортировка и пагинация клиентов; раскладка настроек
- список клиентов: поиск по домену и названию, фильтр по статусу (чипы
  со счётчиками), сортировка по любой колонке, пагинация 10/25/50/100;
  состояние в URL — отфильтрованный вид можно сохранить ссылкой
- размеры последних бэкапов одним запросом вместо запроса на клиента
- шапка выровнена по колонке контента; настройки на всю ширину:
  ntfy и Telegram двумя карточками, на узком экране — друг под другом

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:42:32 +03:00

281 lines
6.8 KiB
Go

package server
import (
"cmp"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
)
// Параметры списка клиентов живут в URL: ссылку на отфильтрованный вид можно сохранить.
type clientsQuery struct {
Q string
Status string // "" — все
Sort string
Desc bool
Page int
Per int
}
var (
sortColumns = []string{"domain", "name", "status", "last_backup", "size", "last_seen"}
perOptions = []int{10, 25, 50, 100}
statusOrder = []struct{ Key, Title string }{
{"fail", "ошибка"}, {"stale", "просрочен"}, {"never", "нет бэкапов"}, {"ok", "ok"}, {"off", "отключён"},
}
)
// defaultDesc — направление по умолчанию при выборе колонки: даты и размеры — сначала большие.
func defaultDesc(col string) bool {
switch col {
case "last_backup", "size", "last_seen":
return true
}
return false
}
func parseClientsQuery(r *http.Request) clientsQuery {
v := r.URL.Query()
q := clientsQuery{
Q: strings.TrimSpace(v.Get("q")),
Status: v.Get("status"),
Sort: v.Get("sort"),
Page: 1,
Per: 25,
}
if !slices.ContainsFunc(statusOrder, func(s struct{ Key, Title string }) bool { return s.Key == q.Status }) {
q.Status = ""
}
if !slices.Contains(sortColumns, q.Sort) {
q.Sort = "domain"
}
switch v.Get("dir") {
case "asc":
q.Desc = false
case "desc":
q.Desc = true
default:
q.Desc = defaultDesc(q.Sort)
}
if p, err := strconv.Atoi(v.Get("page")); err == nil && p > 0 {
q.Page = p
}
if p, err := strconv.Atoi(v.Get("per")); err == nil && slices.Contains(perOptions, p) {
q.Per = p
}
return q
}
func (q clientsQuery) values() url.Values {
v := url.Values{}
if q.Q != "" {
v.Set("q", q.Q)
}
if q.Status != "" {
v.Set("status", q.Status)
}
if q.Sort != "domain" || q.Desc {
v.Set("sort", q.Sort)
if q.Desc != defaultDesc(q.Sort) {
if q.Desc {
v.Set("dir", "desc")
} else {
v.Set("dir", "asc")
}
}
}
if q.Page > 1 {
v.Set("page", strconv.Itoa(q.Page))
}
if q.Per != 25 {
v.Set("per", strconv.Itoa(q.Per))
}
return v
}
func (q clientsQuery) url() string {
if enc := q.values().Encode(); enc != "" {
return "/?" + enc
}
return "/"
}
// StatusURL — ссылка на фильтр по статусу (с первой страницы).
func (q clientsQuery) StatusURL(status string) string {
q.Status, q.Page = status, 1
return q.url()
}
// SortURL — ссылка для заголовка колонки: повторный клик меняет направление.
func (q clientsQuery) SortURL(col string) string {
if q.Sort == col {
q.Desc = !q.Desc
} else {
q.Sort, q.Desc = col, defaultDesc(col)
}
q.Page = 1
return q.url()
}
func (q clientsQuery) SortMark(col string) string {
if q.Sort != col {
return ""
}
if q.Desc {
return "↓"
}
return "↑"
}
// DirParam — значение dir для формы поиска, если направление отличается от умолчания колонки.
func (q clientsQuery) DirParam() string {
return q.values().Get("dir")
}
func (q clientsQuery) PageURL(p int) string {
q.Page = p
return q.url()
}
type statusChip struct {
Key, Title string
Count int
URL string
Active bool
}
type pageLink struct {
N int
URL string
Current bool
Gap bool
}
type clientsView struct {
Query clientsQuery
Rows []clientRow
Chips []statusChip
Total int // всего клиентов
Found int // после поиска и фильтра
From, To int
Pages []pageLink
Prev string
Next string
PerOpts []int
}
func (s *Server) webClients(w http.ResponseWriter, r *http.Request) {
q := parseClientsQuery(r)
clients, err := s.st.ListClients()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sizes, err := s.st.LastSizes()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
v := buildClientsView(q, clients, sizes)
s.render(w, r, "clients", map[string]any{"V": v})
}
func buildClientsView(q clientsQuery, clients []*Client, sizes map[int64]int64) *clientsView {
v := &clientsView{Query: q, Total: len(clients), PerOpts: perOptions}
// поиск
needle := strings.ToLower(q.Q)
var matched []clientRow
for _, c := range clients {
if needle != "" && !strings.Contains(strings.ToLower(c.Domain), needle) && !strings.Contains(strings.ToLower(c.Name), needle) {
continue
}
row := clientRow{Client: c, LastSize: sizes[c.ID]}
row.StatusClass, row.StatusText = clientStatus(c)
matched = append(matched, row)
}
// чипы статусов считаются по результату поиска, до фильтра по статусу
counts := map[string]int{}
for _, r := range matched {
counts[r.StatusClass]++
}
v.Chips = append(v.Chips, statusChip{Title: "все", Count: len(matched), URL: q.StatusURL(""), Active: q.Status == ""})
for _, st := range statusOrder {
if counts[st.Key] == 0 && q.Status != st.Key {
continue
}
v.Chips = append(v.Chips, statusChip{Key: st.Key, Title: st.Title, Count: counts[st.Key], URL: q.StatusURL(st.Key), Active: q.Status == st.Key})
}
rows := matched[:0:0]
for _, r := range matched {
if q.Status == "" || r.StatusClass == q.Status {
rows = append(rows, r)
}
}
rank := map[string]int{}
for i, st := range statusOrder {
rank[st.Key] = i
}
slices.SortStableFunc(rows, func(a, b clientRow) int {
var c int
switch q.Sort {
case "name":
c = cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
case "status":
c = cmp.Compare(rank[a.StatusClass], rank[b.StatusClass])
case "last_backup":
c = cmp.Compare(a.LastBackupAt, b.LastBackupAt)
case "size":
c = cmp.Compare(a.LastSize, b.LastSize)
case "last_seen":
c = cmp.Compare(a.LastSeenAt, b.LastSeenAt)
}
if q.Desc {
c = -c
}
if c == 0 { // стабильный вторичный порядок — по домену
c = cmp.Compare(a.Domain, b.Domain)
}
return c
})
// пагинация
v.Found = len(rows)
pages := max(1, (v.Found+q.Per-1)/q.Per)
if q.Page > pages {
q.Page = pages
v.Query = q
}
start := (q.Page - 1) * q.Per
end := min(start+q.Per, v.Found)
v.Rows = rows[start:end]
if v.Found > 0 {
v.From, v.To = start+1, end
}
if pages > 1 {
if q.Page > 1 {
v.Prev = q.PageURL(q.Page - 1)
}
if q.Page < pages {
v.Next = q.PageURL(q.Page + 1)
}
last := 0
for p := 1; p <= pages; p++ {
if p != 1 && p != pages && (p < q.Page-2 || p > q.Page+2) {
continue
}
if last != 0 && p > last+1 {
v.Pages = append(v.Pages, pageLink{Gap: true})
}
v.Pages = append(v.Pages, pageLink{N: p, URL: q.PageURL(p), Current: p == q.Page})
last = p
}
}
return v
}