package server import ( "cmp" "gitea.hsrv.site/mrleo1nid/backup-master/internal/version" "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) for i := range v.Rows { v.Rows[i].Outdated = version.ShouldUpdate(s.cfg.Version, v.Rows[i].AgentVersion) && v.Rows[i].AgentVersion != "" } 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 }