diff --git a/bin/SHA256SUMS b/bin/SHA256SUMS index be66589..c04d0fa 100644 --- a/bin/SHA256SUMS +++ b/bin/SHA256SUMS @@ -1,4 +1,4 @@ -d3559945184a9716fc1dae0defbd008b23352f8695ad9b96174838523da9c30d control-api -bad182f50f6ec38cb767bd275d91497298188258306a81178547b80a81fcbfe3 validator-agent -383858b838e2707b7eae271e5023f9a28806ff8b34b3f88b40e27eb9e4eb8128 prober -8f5ebc9983dd1aa3cdff212e6d7b17406e49fdd2de7364ae1833a0a5629f0217 admin-dashboard +1f0f7390b28778530f0d2a3b9d00371f5fd4abd4fa182b47d746ecc2f381aaac control-api +d4075822020e461b3e2adfa2752fb84356c3e922ba758dfb047e7c30e6abc51c validator-agent +abb55560d98b80ecceb31fa46f92140e1196a1c08812b066a4a759b51748a453 prober +fbdb0fe69872bd4fd2d4ec0dcd25b453d7ee92ae4d12eee29d872c883a697eb7 admin-dashboard diff --git a/bin/admin-dashboard b/bin/admin-dashboard index 22ee189..3c7abfe 100755 Binary files a/bin/admin-dashboard and b/bin/admin-dashboard differ diff --git a/bin/control-api b/bin/control-api index ea426e4..a2097d6 100755 Binary files a/bin/control-api and b/bin/control-api differ diff --git a/bin/prober b/bin/prober index 1eebc52..2c610ea 100755 Binary files a/bin/prober and b/bin/prober differ diff --git a/bin/validator-agent b/bin/validator-agent index 820a239..93fd59a 100755 Binary files a/bin/validator-agent and b/bin/validator-agent differ diff --git a/internal/dashboard/handlers_overview.go b/internal/dashboard/handlers_overview.go index d46e92d..161b7aa 100644 --- a/internal/dashboard/handlers_overview.go +++ b/internal/dashboard/handlers_overview.go @@ -3,6 +3,7 @@ package dashboard import ( "net/http" "sort" + "strings" ) type overviewData struct { @@ -13,6 +14,8 @@ type overviewData struct { Breakdown map[string]int LastN int PollSeconds int + Query string + StatusFilter string } func (s *Server) loadOverview(r *http.Request) (overviewData, error) { @@ -25,14 +28,18 @@ func (s *Server) loadOverview(r *http.Request) (overviewData, error) { if err != nil { return overviewData{}, err } + 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: currentlyChecking(ips), - LastCompleted: last, + 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, }, nil } @@ -89,3 +96,26 @@ func resultBreakdown(items []ipQueueItem) map[string]int { } 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 +} diff --git a/internal/dashboard/handlers_registry.go b/internal/dashboard/handlers_registry.go index 7cf847d..af3d92d 100644 --- a/internal/dashboard/handlers_registry.go +++ b/internal/dashboard/handlers_registry.go @@ -1,10 +1,15 @@ package dashboard -import "net/http" +import ( + "net/http" + "strings" +) type registryPageData struct { PageData - Items []registryItem + Items []registryItem + Query string + StatusFilter string } type registryDetailData struct { @@ -15,15 +20,45 @@ type registryDetailData struct { // handleRegistryPage lists every address ever submitted to the check // queue, with a summary of its accumulated check history — the durable // record that survives an address being deleted from /ips and later -// re-added. See internal/db/migrations/0007_ip_registry.sql. +// re-added. See internal/db/migrations/0007_ip_registry.sql. Optional +// ?q=&status= query params narrow the list by address substring and by +// LastResult — see filterRegistryItems. func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) { items, err := s.CA.ListRegistry(r.Context()) - data := registryPageData{Items: items} + q := strings.TrimSpace(r.URL.Query().Get("q")) + status := r.URL.Query().Get("status") + data := registryPageData{ + Items: filterRegistryItems(items, q, status), + Query: q, + StatusFilter: status, + } data.ActiveNav = "registry" data.Banner = bannerFor(err) s.renderPage(w, "registry_page", data) } +// filterRegistryItems narrows items to those whose address contains q +// (case-insensitive substring) and, if status is set, whose LastResult +// matches it exactly — the registry list's search-by-IP and +// filter-by-status, mirroring filterQueueItems in handlers_overview.go. +func filterRegistryItems(items []registryItem, q, status string) []registryItem { + if q == "" && status == "" { + return items + } + q = strings.ToLower(q) + out := make([]registryItem, 0, len(items)) + for _, it := range items { + if q != "" && !strings.Contains(strings.ToLower(it.IPAddress), q) { + continue + } + if status != "" && it.LastResult != status { + continue + } + out = append(out, it) + } + return out +} + // handleRegistryDetail shows one address's full retained check history // across every cycle it has ever run, not just the current attempt — see // ip_detail_content in ip_detail.html for the attempt-scoped equivalent. diff --git a/internal/dashboard/handlers_test.go b/internal/dashboard/handlers_test.go index 3eb7df5..771f7b8 100644 --- a/internal/dashboard/handlers_test.go +++ b/internal/dashboard/handlers_test.go @@ -36,6 +36,51 @@ func TestOverviewFragment(t *testing.T) { } } +// TestOverviewFilterByQueryAndStatus proves the ?q=&status= params on both +// /overview and /overview/fragment narrow the current-checking and +// last-completed tables to matching rows only, and that the filter form +// echoes the applied values back (so they survive the recurring poll, +// which only re-renders overview_fragment, not the form around it). +func TestOverviewFilterByQueryAndStatus(t *testing.T) { + fake, caURL := newFakeControlAPI(t) + now := time.Now() + fake.ips = []ipQueueItem{ + {IPAddress: "1.1.1.1", State: "checking", Sequence: 1, AssignedAt: &now, UpdatedAt: now, CreatedAt: now}, + {IPAddress: "1.1.1.2", State: "checking", Sequence: 2, AssignedAt: &now, UpdatedAt: now, CreatedAt: now}, + {IPAddress: "2.2.2.2", State: "done", OverallResult: "pass", AggregatedAt: &now, UpdatedAt: now, CreatedAt: now}, + {IPAddress: "3.3.3.3", State: "failed", OverallResult: "fail", AggregatedAt: &now, UpdatedAt: now, CreatedAt: now}, + } + ts := newTestServer(t, caURL) + + // Search narrows both tables by IP substring. + body := get(t, ts, "/overview/fragment?q=1.1.1.1") + if !strings.Contains(body, "1.1.1.1") || strings.Contains(body, "1.1.1.2") { + t.Fatalf("expected search to match only 1.1.1.1, got:\n%s", body) + } + + // Status filter narrows the completed table and hides in-progress rows + // entirely (they have no OverallResult yet). + body = get(t, ts, "/overview/fragment?status=fail") + if strings.Contains(body, "1.1.1.1") || strings.Contains(body, "2.2.2.2") { + t.Fatalf("expected in-progress and passing rows hidden under status=fail, got:\n%s", body) + } + if !strings.Contains(body, "3.3.3.3") { + t.Fatalf("expected failing row to remain under status=fail, got:\n%s", body) + } + if !strings.Contains(body, "Ничего не найдено по текущему фильтру") { + t.Fatalf("expected the empty current-checking table to show the filtered empty state, got:\n%s", body) + } + + // The full page echoes q/status back into the filter form's inputs. + page := get(t, ts, "/overview?q=1.1.1.1&status=pass") + if !strings.Contains(page, `value="1.1.1.1"`) { + t.Fatalf("expected search box to echo back the query, got:\n%s", page) + } + if !strings.Contains(page, `value="pass" selected`) { + t.Fatalf("expected status select to echo back the selection, got:\n%s", page) + } +} + func TestIPsSubmitAddAndForceRecheck(t *testing.T) { fake, caURL := newFakeControlAPI(t) now := time.Now() @@ -526,6 +571,58 @@ func TestRegistryPageAndDetail(t *testing.T) { } } +// TestRegistryFilterByQueryAndStatus proves the ?q=&status= params on +// /registry narrow the list by address substring and by LastResult, and +// that the filter form echoes the applied values back into its inputs. +func TestRegistryFilterByQueryAndStatus(t *testing.T) { + fake, caURL := newFakeControlAPI(t) + now := time.Now() + fake.registry["1.1.1.1"] = registryItem{IPAddress: "1.1.1.1", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass"} + fake.registry["1.1.1.2"] = registryItem{IPAddress: "1.1.1.2", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "fail"} + fake.registry["2.2.2.2"] = registryItem{IPAddress: "2.2.2.2", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass"} + ts := newTestServer(t, caURL) + + // Table rows link to /registry/{ip} — check for that instead of the + // bare IP substring, since the filter form echoes q/status back into + // its own input's value="..." attribute, which would otherwise cause + // a false match/mismatch for whatever address happens to equal q. + rowLink := func(ip string) string { return `href="/registry/` + ip + `"` } + + // Search by IP substring. + body := get(t, ts, "/registry?q=1.1.1") + if !strings.Contains(body, rowLink("1.1.1.1")) || !strings.Contains(body, rowLink("1.1.1.2")) || strings.Contains(body, rowLink("2.2.2.2")) { + t.Fatalf("expected only the 1.1.1.x addresses to match the search, got:\n%s", body) + } + + // Filter by status. + body = get(t, ts, "/registry?status=fail") + if strings.Contains(body, rowLink("1.1.1.1")) || strings.Contains(body, rowLink("2.2.2.2")) { + t.Fatalf("expected only the failing address under status=fail, got:\n%s", body) + } + if !strings.Contains(body, rowLink("1.1.1.2")) { + t.Fatalf("expected the failing address to remain under status=fail, got:\n%s", body) + } + + // Both together (AND): 2.2.2.2 matches q but not status=fail (it's + // pass), so no row should match. + body = get(t, ts, "/registry?q=2.2.2.2&status=fail") + if strings.Contains(body, rowLink("1.1.1.2")) || strings.Contains(body, rowLink("2.2.2.2")) { + t.Fatalf("expected no match for q=2.2.2.2&status=fail (2.2.2.2 is pass), got:\n%s", body) + } + if !strings.Contains(body, "Ничего не найдено по текущему фильтру") { + t.Fatalf("expected the filtered-empty state, got:\n%s", body) + } + + // The page echoes q/status back into the filter form's inputs. + page := get(t, ts, "/registry?q=1.1.1&status=pass") + if !strings.Contains(page, `value="1.1.1"`) { + t.Fatalf("expected search box to echo back the query, got:\n%s", page) + } + if !strings.Contains(page, `value="pass" selected`) { + t.Fatalf("expected status select to echo back the selection, got:\n%s", page) + } +} + func TestControlAPIUnreachable(t *testing.T) { // Point the dashboard at an address nothing listens on, rather than a // closed httptest.Server, to get a deterministic connection-refused @@ -536,3 +633,61 @@ func TestControlAPIUnreachable(t *testing.T) { t.Fatalf("expected server/transport error banner when control-api is unreachable, got:\n%s", body) } } + +func TestFilterQueueItems(t *testing.T) { + items := []ipQueueItem{ + {IPAddress: "1.1.1.1", OverallResult: "pass"}, + {IPAddress: "1.1.1.2", OverallResult: "fail"}, + {IPAddress: "2.2.2.2", OverallResult: "pass"}, + } + + if got := filterQueueItems(items, "", ""); len(got) != 3 { + t.Fatalf("expected no-op with empty q/status, got %+v", got) + } + if got := filterQueueItems(items, "1.1.1", ""); len(got) != 2 { + t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got) + } + if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 || got[0].IPAddress != "1.1.1.1" { + t.Fatalf("expected exact-substring match, got %+v", got) + } + if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 { + t.Fatalf("expected search to be case/substring based, got %+v", got) + } + if got := filterQueueItems(items, "", "pass"); len(got) != 2 { + t.Fatalf("expected 2 matches for status=pass, got %+v", got) + } + if got := filterQueueItems(items, "1.1.1", "pass"); len(got) != 1 || got[0].IPAddress != "1.1.1.1" { + t.Fatalf("expected q+status combined with AND, got %+v", got) + } + if got := filterQueueItems(items, "9.9.9.9", ""); len(got) != 0 { + t.Fatalf("expected no matches, got %+v", got) + } + + // Case-insensitivity, via a query with mixed-case letters (IP octets + // are numeric, so exercise it through IPv6-shaped input instead). + mixed := []ipQueueItem{{IPAddress: "fe80::AbCd"}} + if got := filterQueueItems(mixed, "abcd", ""); len(got) != 1 { + t.Fatalf("expected case-insensitive search to match, got %+v", got) + } +} + +func TestFilterRegistryItems(t *testing.T) { + items := []registryItem{ + {IPAddress: "1.1.1.1", LastResult: "pass"}, + {IPAddress: "1.1.1.2", LastResult: "partial"}, + {IPAddress: "2.2.2.2", LastResult: ""}, + } + + if got := filterRegistryItems(items, "", ""); len(got) != 3 { + t.Fatalf("expected no-op with empty q/status, got %+v", got) + } + if got := filterRegistryItems(items, "1.1.1", ""); len(got) != 2 { + t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got) + } + if got := filterRegistryItems(items, "", "partial"); len(got) != 1 || got[0].IPAddress != "1.1.1.2" { + t.Fatalf("expected exactly the partial-result address, got %+v", got) + } + if got := filterRegistryItems(items, "2.2.2", "partial"); len(got) != 0 { + t.Fatalf("expected q+status combined with AND to exclude non-matching, got %+v", got) + } +} diff --git a/internal/dashboard/templates/overview.html b/internal/dashboard/templates/overview.html index 8eedd38..f904716 100644 --- a/internal/dashboard/templates/overview.html +++ b/internal/dashboard/templates/overview.html @@ -23,7 +23,36 @@

Обзор

-
+ +
+
+
+ + +
+
+ + +
+
+

Действует на обе таблицы ниже. Фильтр по статусу — это фильтр по +итоговому результату, поэтому при выборе конкретного статуса строки «Текущей проверки» (у неё ещё нет результата) +не показываются.

+
+ +
{{template "overview_fragment" .}}
{{end}} diff --git a/internal/dashboard/templates/overview_fragment.html b/internal/dashboard/templates/overview_fragment.html index 3fbbcbf..49abea8 100644 --- a/internal/dashboard/templates/overview_fragment.html +++ b/internal/dashboard/templates/overview_fragment.html @@ -29,7 +29,8 @@
{{else}} -

Сейчас нет адресов в обработке.

+{{if or .Query .StatusFilter}}

Ничего не найдено по текущему фильтру.

+{{else}}

Сейчас нет адресов в обработке.

{{end}} {{end}}

Последние {{.LastN}} завершённых

@@ -58,6 +59,7 @@ {{else}} -

Пока ничего не завершено.

+{{if or .Query .StatusFilter}}

Ничего не найдено по текущему фильтру.

+{{else}}

Пока ничего не завершено.

{{end}} {{end}} {{end}} diff --git a/internal/dashboard/templates/registry.html b/internal/dashboard/templates/registry.html index 4758416..cfcd56c 100644 --- a/internal/dashboard/templates/registry.html +++ b/internal/dashboard/templates/registry.html @@ -25,6 +25,37 @@ сохраняется здесь даже после удаления адреса из очереди и не теряется при повторном добавлении. Глубина хранимой истории на адрес настраивается на странице настроек.

+
+
+
+ + +
+
+ + +
+
+
+ +
+{{template "registry_table" .}} +
+{{end}} + +{{define "registry_table"}} {{if .Items}}
@@ -53,5 +84,6 @@
+{{else if or .Query .StatusFilter}}

Ничего не найдено по текущему фильтру.

{{else}}

Реестр пуст — ни один адрес ещё не ставился на проверку.

{{end}} {{end}}