2026-08-23 20:39:22 +03:00
|
|
|
|
package dashboard
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"net/http"
|
2026-09-23 11:58:53 +03:00
|
|
|
|
"strings"
|
2026-10-01 19:31:11 +03:00
|
|
|
|
"time"
|
2026-08-23 20:39:22 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-01 19:31:11 +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
|
2026-10-01 19:31:11 +03:00
|
|
|
|
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
|
2026-09-23 11:58:53 +03:00
|
|
|
|
Query string
|
|
|
|
|
|
StatusFilter string
|
2026-10-01 19:31:11 +03:00
|
|
|
|
Progress overviewProgress
|
2026-10-01 10:28:53 +03:00
|
|
|
|
// AutoCycle is nil when the auto-cycle status could not be fetched; the
|
2026-10-01 19:31:11 +03:00
|
|
|
|
// indicator is then simply hidden (a non-fatal failure). Scan is likewise
|
|
|
|
|
|
// nil when the scan status is unavailable.
|
2026-10-01 10:28:53 +03:00
|
|
|
|
AutoCycle *autoCycleDTO
|
2026-10-01 19:31:11 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-10-01 19:31:11 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-10-01 19:31:11 +03:00
|
|
|
|
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 == "")
|
|
|
|
|
|
|
2026-10-01 10:28:53 +03:00
|
|
|
|
if ac, acErr := s.CA.GetAutoCycle(ctx); acErr != nil {
|
|
|
|
|
|
s.Log.Warn("overview: auto-cycle status unavailable", "err", acErr)
|
|
|
|
|
|
} else {
|
2026-10-01 19:31:11 +03:00
|
|
|
|
data.AutoCycle = &ac
|
2026-10-01 10:28:53 +03:00
|
|
|
|
}
|
2026-10-01 19:31:11 +03:00
|
|
|
|
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)
|
2026-10-01 11:35:24 +03:00
|
|
|
|
s.renderPage(w, r, "overview_page", data)
|
2026-08-23 20:39:22 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 19:31:11 +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
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-01 19:31:11 +03:00
|
|
|
|
if len(stamps) < etaMinSamples {
|
|
|
|
|
|
return p
|
2026-08-23 20:39:22 +03:00
|
|
|
|
}
|
2026-10-01 19:31:11 +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
|
|
|
|
}
|
2026-10-01 19:31:11 +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
|
2026-10-01 19:31:11 +03:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|