Files
cloud-ip-validator/internal/dashboard/handlers_overview.go
T

179 lines
5.9 KiB
Go
Raw Normal View History

2026-08-23 20:39:22 +03:00
package dashboard
import (
"net/http"
"strings"
"time"
2026-08-23 20:39:22 +03:00
)
const (
// overviewActiveLimit caps the "В работе" table; overviewQueueLimit caps
// the compact list of the next queued addresses. The queue itself can hold
// thousands of rows, so the overview never lists more than these.
overviewActiveLimit = 100
overviewQueueLimit = 10
// etaMinSamples is how many completed rows (with AggregatedAt) the ETA
// needs to derive a rate from.
etaMinSamples = 5
)
// overviewProgress is the "Готово D из T" indicator of the stats block.
type overviewProgress struct {
Total, Done, Active, Queued, Percent int
// ETA is a rough time-to-finish estimate ("" when not enough data).
ETA string
}
2026-08-23 20:39:22 +03:00
type overviewData struct {
PageData
Status statusResponse
// ActiveItems are the addresses being checked right now (≤ overviewActiveLimit
// of ActiveTotal); QueuedItems are the next few waiting ones (QueuedTotal
// in total). Both stay empty under a result filter: an address without a
// verdict can't match one.
ActiveItems []ipQueueItem
ActiveTotal int
QueuedItems []ipQueueItem
QueuedTotal int
2026-08-23 20:39:22 +03:00
LastCompleted []ipQueueItem
Breakdown map[string]int
LastN int
PollSeconds int
Query string
StatusFilter string
Progress overviewProgress
// AutoCycle is nil when the auto-cycle status could not be fetched; the
// indicator is then simply hidden (a non-fatal failure). Scan is likewise
// nil when the scan status is unavailable.
AutoCycle *autoCycleDTO
Scan *scanStatusDTO
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
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
resultFilter := r.URL.Query().Get("status")
if !containsStr(ipResults, resultFilter) {
resultFilter = ""
}
lastN := s.Cfg.LastCompletedCount
if lastN < 1 {
lastN = 20
}
data := overviewData{
Status: status,
LastN: lastN,
PollSeconds: s.Cfg.OverviewPollIntervalS,
Query: q,
StatusFilter: resultFilter,
}
if resultFilter == "" {
active, err := s.CA.ListIPsPage(ctx, ipsQuery{States: activeStates, Q: q, Order: "sequence", Limit: overviewActiveLimit})
if err != nil {
return overviewData{}, err
}
queued, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"queued"}, Q: q, Order: "sequence", Limit: overviewQueueLimit})
if err != nil {
return overviewData{}, err
}
data.ActiveItems, data.ActiveTotal = active.Items, active.Total
data.QueuedItems, data.QueuedTotal = queued.Items, queued.Total
}
completed, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"done", "failed"}, Q: q, Result: resultFilter, Order: "aggregated_at_desc", Limit: lastN})
2026-08-23 20:39:22 +03:00
if err != nil {
return overviewData{}, err
}
data.LastCompleted = completed.Items
data.Breakdown = resultBreakdown(completed.Items)
// ETA from the unfiltered window only: a filtered list is not a sample of
// the checks' real throughput.
data.Progress = computeProgress(status, completed.Items, q == "" && resultFilter == "")
if ac, acErr := s.CA.GetAutoCycle(ctx); acErr != nil {
s.Log.Warn("overview: auto-cycle status unavailable", "err", acErr)
} else {
data.AutoCycle = &ac
}
if sc, scErr := s.CA.ScanStatus(ctx); scErr != nil {
s.Log.Warn("overview: scan status unavailable", "err", scErr)
} else if sc.Running {
data.Scan = &sc
}
return data, nil
2026-08-23 20:39:22 +03:00
}
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, r, "overview_page", data)
2026-08-23 20:39:22 +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)
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
}
// computeProgress derives the done/active/queued counters from the status
// breakdown (done = every terminal state, "occupied" included) and, when
// useETA is set and the newest-first list of completed rows has at least
// etaMinSamples with AggregatedAt, a rough ETA from their completion rate.
func computeProgress(st statusResponse, completed []ipQueueItem, useETA bool) overviewProgress {
p := overviewProgress{
Total: st.TotalIPs,
Done: sumStates(st.IPsByState, terminalStates),
Active: sumStates(st.IPsByState, activeStates),
Queued: st.IPsByState["queued"],
}
if p.Total > 0 {
p.Percent = p.Done * 100 / p.Total
}
remaining := p.Active + p.Queued
if !useETA || remaining == 0 {
return p
}
var stamps []time.Time
for _, ip := range completed {
if ip.AggregatedAt != nil {
stamps = append(stamps, *ip.AggregatedAt)
2026-08-23 20:39:22 +03:00
}
}
if len(stamps) < etaMinSamples {
return p
2026-08-23 20:39:22 +03:00
}
// completed is newest-first: stamps[0] is the newest, the last the oldest.
span := stamps[0].Sub(stamps[len(stamps)-1])
if span <= 0 {
return p
2026-08-23 20:39:22 +03:00
}
perItem := span / time.Duration(len(stamps)-1)
p.ETA = fmtDuration(perItem * time.Duration(remaining))
return p
2026-08-23 20:39:22 +03:00
}
// resultBreakdown counts OverallResult values across exactly the given
// items (the "последние N завершённых" window) — pass/partial/fail/cancelled.
2026-08-23 20:39:22 +03:00
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
}