From caebc0607624988475dc48c5fc2bd20e57f8bf32 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 19:24:58 +0700 Subject: [PATCH] feat(web): filter domains by template and status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The domains list had no way to narrow itself down, so an operator with dozens of zones had to scan the whole table to find the drifted ones. Add two client-side filters above the table (the full list already arrives in one request, so server-side filtering would only add contract surface). The status filter compares against the same derived value the row's badge renders — a domain with no template shows "без шаблона" and must not match "drift" just because it kept a stale last_check_status from before its template was detached, so that derivation lives in one function used by both. Sorting is now decided in the store: ListDomains orders by zone_name instead of created_at, so every consumer of the list gets the same order and the filters never reorder anything. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU --- internal/store/db/domains.sql.go | 2 +- internal/store/queries/domains.sql | 2 +- internal/store/store_test.go | 39 +++++++++++ web/src/pages/DomainsPage.test.tsx | 82 +++++++++++++++++++++++ web/src/pages/DomainsPage.tsx | 100 ++++++++++++++++++++++++++++- 5 files changed, 222 insertions(+), 3 deletions(-) diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index a9afa71..075cd59 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -192,7 +192,7 @@ func (q *Queries) ImportDomain(ctx context.Context, arg ImportDomainParams) (Dom } const listDomains = `-- name: ListDomains :many -SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE project_id = $1 ORDER BY created_at +SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE project_id = $1 ORDER BY zone_name ` func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) { diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index f406bff..87b0d89 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -17,7 +17,7 @@ RETURNING *; SELECT * FROM domains WHERE id = $1 AND project_id = $2; -- name: ListDomains :many -SELECT * FROM domains WHERE project_id = $1 ORDER BY created_at; +SELECT * FROM domains WHERE project_id = $1 ORDER BY zone_name; -- name: DeleteDomain :exec DELETE FROM domains WHERE id = $1 AND project_id = $2; diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 89fd964..9b874af 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "reflect" "testing" "github.com/google/uuid" @@ -506,3 +507,41 @@ func TestImportDomainsOnlyTouchesItsOwnAccount(t *testing.T) { t.Fatalf("expected both accounts' domains to survive, got %+v", left) } } + +// TestListDomains_SortedByZoneName pins the list order: domains come back +// alphabetically by zone name, never in creation order. The domains page +// relies on this — its filters reorder nothing, so the store is the single +// place the order is decided. +func TestListDomains_SortedByZoneName(t *testing.T) { + s, ctx := newStore(t) + acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{ + ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob", + }) + if err != nil { + t.Fatal(err) + } + + // Inserted in deliberately non-alphabetical order (one import call: a + // per-zone call would prune the previously imported domains), so a query + // still sorting by created_at would return zulu first. + if _, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{ + {ID: "z1", Name: "zulu.example.com"}, + {ID: "z2", Name: "mike.example.com"}, + {ID: "z3", Name: "alpha.example.com"}, + }); err != nil { + t.Fatal(err) + } + + list, err := s.ListDomains(ctx, defaultProject) + if err != nil { + t.Fatal(err) + } + got := make([]string, 0, len(list)) + for _, d := range list { + got = append(got, d.ZoneName) + } + want := []string{"alpha.example.com", "mike.example.com", "zulu.example.com"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected domains sorted by zone name %v, got %v", want, got) + } +} diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx index 96c6947..7b9c7dd 100644 --- a/web/src/pages/DomainsPage.test.tsx +++ b/web/src/pages/DomainsPage.test.tsx @@ -152,3 +152,85 @@ test("кнопки удаления домена нет", async () => { expect(screen.queryByRole("button", { name: /удалить/i })).not.toBeInTheDocument() }) + +test("фильтр по шаблону оставляет только домены с этим шаблоном", async () => { + const user = userEvent.setup() + renderPage() + + await screen.findByText("example.com.") + + await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i })) + await user.click(await screen.findByRole("option", { name: /^standard$/i })) + + expect(screen.getByText("test.org.")).toBeInTheDocument() + expect(screen.queryByText("example.com.")).not.toBeInTheDocument() +}) + +test("фильтр по шаблону «Без шаблона» оставляет только непривязанные домены", async () => { + const user = userEvent.setup() + renderPage() + + await screen.findByText("example.com.") + + await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i })) + await user.click(await screen.findByRole("option", { name: /^без шаблона$/i })) + + expect(screen.getByText("example.com.")).toBeInTheDocument() + expect(screen.queryByText("test.org.")).not.toBeInTheDocument() +}) + +test("фильтр по статусу оставляет только домены с этим статусом", async () => { + const user = userEvent.setup() + renderPage() + + await screen.findByText("example.com.") + + await user.click(screen.getByRole("combobox", { name: /фильтр по статусу/i })) + await user.click(await screen.findByRole("option", { name: /^in sync$/i })) + + expect(screen.getByText("test.org.")).toBeInTheDocument() + expect(screen.queryByText("example.com.")).not.toBeInTheDocument() +}) + +// d1 не имеет шаблона, но хранит протухший lastCheckStatus="drift". Бейдж +// показывает «без шаблона», поэтому и фильтр обязан считать его таким же — +// иначе фильтр и таблица говорят разное об одной строке. +test("домен без шаблона не попадает в фильтр «drift», хотя хранит такой статус", async () => { + const user = userEvent.setup() + renderPage() + + await screen.findByText("example.com.") + + await user.click(screen.getByRole("combobox", { name: /фильтр по статусу/i })) + await user.click(await screen.findByRole("option", { name: /^drift$/i })) + + expect(screen.queryByText("example.com.")).not.toBeInTheDocument() + expect(await screen.findByText(/под фильтр ничего не попало/i)).toBeInTheDocument() +}) + +test("комбинация фильтров сужает список до пересечения", async () => { + const user = userEvent.setup() + renderPage() + + await screen.findByText("example.com.") + + await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i })) + await user.click(await screen.findByRole("option", { name: /^standard$/i })) + await user.click(screen.getByRole("combobox", { name: /фильтр по статусу/i })) + await user.click(await screen.findByRole("option", { name: /^без шаблона$/i })) + + expect(await screen.findByText(/под фильтр ничего не попало/i)).toBeInTheDocument() +}) + +test("счётчик показывает, сколько доменов отфильтровано", async () => { + const user = userEvent.setup() + renderPage() + + await screen.findByText("example.com.") + expect(screen.getByText(/показано 2 из 2/i)).toBeInTheDocument() + + await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i })) + await user.click(await screen.findByRole("option", { name: /^standard$/i })) + + expect(screen.getByText(/показано 1 из 2/i)).toBeInTheDocument() +}) diff --git a/web/src/pages/DomainsPage.tsx b/web/src/pages/DomainsPage.tsx index c15462b..fc2aefb 100644 --- a/web/src/pages/DomainsPage.tsx +++ b/web/src/pages/DomainsPage.tsx @@ -25,8 +25,31 @@ import { useSetDomainTemplate, useTemplates, } from "@/hooks/useApi" +import type { Domain } from "@/api/types" const NO_TEMPLATE = "__none__" +// Значения «фильтр не задан». Отдельные от NO_TEMPLATE: «без шаблона» — это +// осознанный выбор пользователя, а не отсутствие фильтра. +const ANY_TEMPLATE = "__any_template__" +const ANY_STATUS = "__any_status__" + +const STATUS_FILTER_ITEMS = [ + { value: ANY_STATUS, label: "Все статусы" }, + { value: "in_sync", label: "in sync" }, + { value: "drift", label: "drift" }, + { value: "error", label: "error" }, + { value: "unknown", label: "unknown" }, + { value: "no_template", label: "без шаблона" }, +] + +// Статус, который пользователь реально видит в бейдже строки: у домена без +// шаблона диффа нет, поэтому его хранимый lastCheckStatus (возможно, +// протухший с тех пор, как шаблон отвязали) не показывается — и не должен +// фильтроваться. Единственная точка вычисления: иначе фильтр и таблица +// разойдутся в том, что за строка перед пользователем. +function displayStatus(domain: Domain): string { + return domain.templateId ? (domain.lastCheckStatus ?? "unknown") : "no_template" +} export function DomainsPage() { const domains = useDomains() @@ -39,6 +62,30 @@ export function DomainsPage() { const templateList = templates.data ?? [] const domainList = domains.data ?? [] + const [templateFilter, setTemplateFilter] = useState(ANY_TEMPLATE) + const [statusFilter, setStatusFilter] = useState(ANY_STATUS) + + // Порядок задаёт бэкенд (ListDomains сортирует по zone_name) — фильтры + // только выбрасывают строки, никогда не пересортировывают. + const visibleDomains = domainList.filter((d) => { + if (templateFilter === NO_TEMPLATE && d.templateId) return false + if ( + templateFilter !== ANY_TEMPLATE && + templateFilter !== NO_TEMPLATE && + d.templateId !== templateFilter + ) { + return false + } + if (statusFilter !== ANY_STATUS && displayStatus(d) !== statusFilter) return false + return true + }) + + const templateFilterItems = [ + { value: ANY_TEMPLATE, label: "Все шаблоны" }, + { value: NO_TEMPLATE, label: "Без шаблона" }, + ...templateList.map((t) => ({ value: t.id, label: t.name })), + ] + const [importAccountId, setImportAccountId] = useState(null) const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null @@ -109,6 +156,52 @@ export function DomainsPage() { )} +
+
+ Шаблон + +
+ +
+ Статус + +
+ + + Показано {visibleDomains.length} из {domainList.length} + +
+ {setTemplate.isError && ( {setTemplate.error?.message} @@ -119,6 +212,11 @@ export function DomainsPage() { Доменов пока нет — импортируйте зоны из учётной записи. + ) : visibleDomains.length === 0 ? ( +
+ + Под фильтр ничего не попало — измените шаблон или статус. +
) : ( @@ -131,7 +229,7 @@ export function DomainsPage() { - {domainList.map((d) => { + {visibleDomains.map((d) => { const templateItems = [ { value: NO_TEMPLATE, label: "Без шаблона" }, ...templateList.map((t) => ({ value: t.id, label: t.name })),