2026-08-23 20:39:22 +03:00
|
|
|
package dashboard
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"sort"
|
2026-09-23 11:58:53 +03:00
|
|
|
"strings"
|
2026-08-23 20:39:22 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type overviewData struct {
|
|
|
|
|
PageData
|
|
|
|
|
Status statusResponse
|
|
|
|
|
CurrentItems []ipQueueItem
|
|
|
|
|
LastCompleted []ipQueueItem
|
|
|
|
|
Breakdown map[string]int
|
|
|
|
|
LastN int
|
|
|
|
|
PollSeconds int
|
2026-09-23 11:58:53 +03:00
|
|
|
Query string
|
|
|
|
|
StatusFilter string
|
2026-10-01 10:28:53 +03:00
|
|
|
// AutoCycle is nil when the auto-cycle status could not be fetched; the
|
|
|
|
|
// indicator is then simply hidden (a non-fatal failure).
|
|
|
|
|
AutoCycle *autoCycleDTO
|
2026-08-23 20:39:22 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-10-01 10:28:53 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-23 11:58:53 +03:00
|
|
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
|
|
|
resultFilter := r.URL.Query().Get("status")
|
2026-08-23 20:39:22 +03:00
|
|
|
last := lastCompleted(ips, s.Cfg.LastCompletedCount)
|
|
|
|
|
return overviewData{
|
|
|
|
|
Status: status,
|
2026-09-23 11:58:53 +03:00
|
|
|
CurrentItems: filterQueueItems(currentlyChecking(ips), q, resultFilter),
|
|
|
|
|
LastCompleted: filterQueueItems(last, q, resultFilter),
|
2026-08-23 20:39:22 +03:00
|
|
|
Breakdown: resultBreakdown(last),
|
|
|
|
|
LastN: s.Cfg.LastCompletedCount,
|
|
|
|
|
PollSeconds: s.Cfg.OverviewPollIntervalS,
|
2026-09-23 11:58:53 +03:00
|
|
|
Query: q,
|
|
|
|
|
StatusFilter: resultFilter,
|
2026-10-01 10:28:53 +03:00
|
|
|
AutoCycle: autoCycle,
|
2026-08-23 20:39:22 +03:00
|
|
|
}, 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 12:13:23 +03:00
|
|
|
// 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.
|
2026-08-23 20:39:22 +03:00
|
|
|
func (s *Server) handleOverviewFragment(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
data, err := s.loadOverview(r)
|
2026-09-23 12:13:23 +03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-08-23 20:39:22 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-09-23 11:58:53 +03:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|