- список клиентов: поиск по домену и названию, фильтр по статусу (чипы со счётчиками), сортировка по любой колонке, пагинация 10/25/50/100; состояние в URL — отфильтрованный вид можно сохранить ссылкой - размеры последних бэкапов одним запросом вместо запроса на клиента - шапка выровнена по колонке контента; настройки на всю ширину: ntfy и Telegram двумя карточками, на узком экране — друг под другом Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
6.8 KiB
Go
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
|
|
}
|