package dashboard import ( "net/http" "sort" "strings" ) type overviewData struct { PageData Status statusResponse CurrentItems []ipQueueItem LastCompleted []ipQueueItem Breakdown map[string]int LastN int PollSeconds int Query string StatusFilter string // AutoCycle is nil when the auto-cycle status could not be fetched; the // indicator is then simply hidden (a non-fatal failure). AutoCycle *autoCycleDTO } func (s *Server) loadOverview(r *http.Request) (overviewData, error) { ctx := r.Context() status, err := s.CA.Status(ctx) if err != nil { return overviewData{}, err } ips, err := s.CA.ListIPs(ctx) if err != nil { return overviewData{}, err } var autoCycle *autoCycleDTO if ac, acErr := s.CA.GetAutoCycle(ctx); acErr != nil { s.Log.Warn("overview: auto-cycle status unavailable", "err", acErr) } else { autoCycle = &ac } q := strings.TrimSpace(r.URL.Query().Get("q")) resultFilter := r.URL.Query().Get("status") last := lastCompleted(ips, s.Cfg.LastCompletedCount) return overviewData{ Status: status, CurrentItems: filterQueueItems(currentlyChecking(ips), q, resultFilter), LastCompleted: filterQueueItems(last, q, resultFilter), Breakdown: resultBreakdown(last), LastN: s.Cfg.LastCompletedCount, PollSeconds: s.Cfg.OverviewPollIntervalS, Query: q, StatusFilter: resultFilter, AutoCycle: autoCycle, }, nil } func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { data, err := s.loadOverview(r) data.ActiveNav = "overview" data.Banner = bannerFor(err) s.renderPage(w, "overview_page", data) } // handleOverviewFragment serves both the recurring poll and every // filter-triggered request. Its hx-target is #overview-tables, but the // stat-grid (#overview-stats) needs to stay in sync too — it lives outside // #overview-tables in the DOM (see overview.html) so the filter form can // sit between them, so it's refreshed via an out-of-band swap appended // after the main content, the same idiom templates/layout.html's // error_banner already uses for the shared error banner. func (s *Server) handleOverviewFragment(w http.ResponseWriter, r *http.Request) { data, err := s.loadOverview(r) s.renderFragment(w, "overview_tables", data, err) if tplErr := s.tmpl.ExecuteTemplate(w, "overview_stats_oob", data); tplErr != nil { s.Log.Error("render overview stats oob", "err", tplErr) } } // currentlyChecking is every IP not yet in a terminal state, ordered by // queue position — the "текущая проверка" live snapshot. No backend // concept of a "run" exists; this is computed fresh on every request. func currentlyChecking(ips []ipQueueItem) []ipQueueItem { var out []ipQueueItem for _, ip := range ips { if ip.State != "done" && ip.State != "failed" { out = append(out, ip) } } sort.Slice(out, func(i, j int) bool { return out[i].Sequence < out[j].Sequence }) return out } // lastCompleted returns the n most recently completed (done/failed) IPs by // AggregatedAt descending — the "последняя завершённая проверка" summary // window. This is an operational definition, not a real "batch": resubmit // n if the window size needs tuning (overview.last_completed_count). func lastCompleted(ips []ipQueueItem, n int) []ipQueueItem { var done []ipQueueItem for _, ip := range ips { if (ip.State == "done" || ip.State == "failed") && ip.AggregatedAt != nil { done = append(done, ip) } } sort.Slice(done, func(i, j int) bool { return done[i].AggregatedAt.After(*done[j].AggregatedAt) }) if len(done) > n { done = done[:n] } return done } // resultBreakdown counts OverallResult values across exactly the given // items (normally the output of lastCompleted) — pass/partial/fail/cancelled. func resultBreakdown(items []ipQueueItem) map[string]int { out := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0} for _, ip := range items { out[ip.OverallResult]++ } return out } // filterQueueItems narrows items to those whose address contains q // (case-insensitive substring) and, if status is set, whose OverallResult // matches it exactly. A still-in-progress item always has an empty // OverallResult, so picking any specific status hides it — the intended // behavior for "Текущая проверка", which has no verdict yet. func filterQueueItems(items []ipQueueItem, q, status string) []ipQueueItem { if q == "" && status == "" { return items } q = strings.ToLower(q) out := make([]ipQueueItem, 0, len(items)) for _, ip := range items { if q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), q) { continue } if status != "" && ip.OverallResult != status { continue } out = append(out, ip) } return out }