From b6192277c16b66ca129e5cbcccd909269d8611b6 Mon Sep 17 00:00:00 2001 From: Anil Khanna Date: Wed, 3 Jun 2026 18:04:33 +0200 Subject: [PATCH] Add base-domain filter chips to Proxy Hosts page Adds a single-select chip row above the Proxy Hosts table that filters hosts by their base domain, with a per-domain host count and an "All" chip to clear. Base-domain extraction uses a dependency-free last-two-labels rule. The chip filter composes with the existing text search. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/locale/src/en.json | 3 + .../pages/Nginx/ProxyHosts/DomainFilter.tsx | 64 ++++++++++++++++++ .../pages/Nginx/ProxyHosts/TableWrapper.tsx | 28 ++++++-- .../Nginx/ProxyHosts/domainUtils.test.ts | 66 +++++++++++++++++++ .../src/pages/Nginx/ProxyHosts/domainUtils.ts | 62 +++++++++++++++++ 5 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/Nginx/ProxyHosts/DomainFilter.tsx create mode 100644 frontend/src/pages/Nginx/ProxyHosts/domainUtils.test.ts create mode 100644 frontend/src/pages/Nginx/ProxyHosts/domainUtils.ts diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..c707ec8727 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -419,6 +419,9 @@ "expires.on": { "defaultMessage": "Expires: {date}" }, + "filter.all": { + "defaultMessage": "All" + }, "footer.github-fork": { "defaultMessage": "Fork me on Github" }, diff --git a/frontend/src/pages/Nginx/ProxyHosts/DomainFilter.tsx b/frontend/src/pages/Nginx/ProxyHosts/DomainFilter.tsx new file mode 100644 index 0000000000..b07af4b2ad --- /dev/null +++ b/frontend/src/pages/Nginx/ProxyHosts/DomainFilter.tsx @@ -0,0 +1,64 @@ +import cn from "classnames"; +import { useMemo } from "react"; +import { T } from "src/locale"; +import { getBaseDomainCounts } from "./domainUtils"; + +interface Props { + /** Per-host domain name arrays used to derive the base-domain chips. */ + hostDomainNames: string[][]; + /** Currently selected base domain, or null for "All". */ + selected: string | null; + onSelect: (base: string | null) => void; +} + +interface ChipProps { + label: React.ReactNode; + count?: number; + active: boolean; + onClick: () => void; +} + +const Chip = ({ label, count, active, onClick }: ChipProps) => ( + +); + +/** + * A row of clickable chips that filter the proxy host list by base domain. + * Renders nothing unless there are at least two distinct base domains. + */ +export default function DomainFilter({ hostDomainNames, selected, onSelect }: Props) { + const baseDomains = useMemo(() => getBaseDomainCounts(hostDomainNames), [hostDomainNames]); + + if (baseDomains.length < 2) { + return null; + } + + return ( +
+ } active={selected === null} onClick={() => onSelect(null)} /> + {baseDomains.map(({ base, count }) => ( + onSelect(selected === base ? null : base)} + /> + ))} +
+ ); +} diff --git a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx index 5d6602e2db..ffb6be18d8 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx @@ -9,11 +9,14 @@ import { T } from "src/locale"; import { showDeleteConfirmModal, showHelpModal, showProxyHostModal } from "src/modals"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; import { showObjectSuccess } from "src/notifications"; +import DomainFilter from "./DomainFilter"; +import { getBaseDomain } from "./domainUtils"; import Table from "./Table"; export default function TableWrapper() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); + const [selectedBaseDomain, setSelectedBaseDomain] = useState(null); const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]); if (isLoading) { @@ -36,19 +39,31 @@ export default function TableWrapper() { showObjectSuccess("proxy-host", enabled ? "enabled" : "disabled"); }; - let filtered = null; + const allHosts = data ?? []; + + // Chip filter: keep hosts that have any domain under the selected base domain. + const byDomain = selectedBaseDomain + ? allHosts.filter((item) => + item.domainNames.some((domain: string) => getBaseDomain(domain) === selectedBaseDomain), + ) + : allHosts; + + // Text search filter applies on top of the chip filter (they compose). + let filtered: typeof allHosts | null = selectedBaseDomain ? byDomain : null; if (search && data) { - filtered = data?.filter( + filtered = byDomain.filter( (item) => item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) || item.forwardHost.toLowerCase().includes(search) || `${item.forwardPort}`.includes(search), ); - } else if (search !== "") { + } else if (search !== "" && !data) { // this can happen if someone deletes the last item while searching setSearch(""); } + const isFiltered = !!search || selectedBaseDomain !== null; + return (
@@ -94,9 +109,14 @@ export default function TableWrapper() {
+ host.domainNames)} + selected={selectedBaseDomain} + onSelect={setSelectedBaseDomain} + /> showProxyHostModal(id)} onDelete={(id: number) => { diff --git a/frontend/src/pages/Nginx/ProxyHosts/domainUtils.test.ts b/frontend/src/pages/Nginx/ProxyHosts/domainUtils.test.ts new file mode 100644 index 0000000000..60e410ba6b --- /dev/null +++ b/frontend/src/pages/Nginx/ProxyHosts/domainUtils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { getBaseDomain, getBaseDomainCounts } from "./domainUtils"; + +describe("getBaseDomain", () => { + it("returns a plain two-label domain unchanged", () => { + expect(getBaseDomain("example.dev")).toBe("example.dev"); + expect(getBaseDomain("example.com")).toBe("example.com"); + }); + + it("reduces a subdomain to its base domain", () => { + expect(getBaseDomain("api.example.com")).toBe("example.com"); + expect(getBaseDomain("app.example.com")).toBe("example.com"); + expect(getBaseDomain("a.b.c.example.com")).toBe("example.com"); + }); + + it("strips a leading wildcard", () => { + expect(getBaseDomain("*.example.com")).toBe("example.com"); + expect(getBaseDomain("*.sub.example.com")).toBe("example.com"); + }); + + it("uses the last two labels for multi-part suffixes (known trade-off)", () => { + // Dependency-free rule groups multi-part TLDs one level too shallow. + expect(getBaseDomain("example.co.uk")).toBe("co.uk"); + expect(getBaseDomain("www.example.co.uk")).toBe("co.uk"); + expect(getBaseDomain("shop.example.com.au")).toBe("com.au"); + }); + + it("normalises case and trailing dots", () => { + expect(getBaseDomain("API.EXAMPLE.COM")).toBe("example.com"); + expect(getBaseDomain("example.com.")).toBe("example.com"); + expect(getBaseDomain(" api.example.com ")).toBe("example.com"); + }); + + it("falls back to the cleaned input for single-label or empty values", () => { + expect(getBaseDomain("localhost")).toBe("localhost"); + expect(getBaseDomain("")).toBe(""); + }); +}); + +describe("getBaseDomainCounts", () => { + it("counts hosts per base domain, sorted alphabetically", () => { + const result = getBaseDomainCounts([["alpha.dev"], ["api.beta.com", "app.beta.com"], ["gamma.com"]]); + expect(result).toEqual([ + { base: "alpha.dev", count: 1 }, + { base: "beta.com", count: 1 }, + { base: "gamma.com", count: 1 }, + ]); + }); + + it("counts a host once per base even with several matching subdomains", () => { + const result = getBaseDomainCounts([["api.beta.com", "app.beta.com", "beta.com"]]); + expect(result).toEqual([{ base: "beta.com", count: 1 }]); + }); + + it("aggregates the same base across multiple hosts", () => { + const result = getBaseDomainCounts([["api.beta.com"], ["www.beta.com"], ["other.dev"]]); + expect(result).toEqual([ + { base: "beta.com", count: 2 }, + { base: "other.dev", count: 1 }, + ]); + }); + + it("ignores empty or missing domain lists", () => { + expect(getBaseDomainCounts([[], ["example.com"]])).toEqual([{ base: "example.com", count: 1 }]); + }); +}); diff --git a/frontend/src/pages/Nginx/ProxyHosts/domainUtils.ts b/frontend/src/pages/Nginx/ProxyHosts/domainUtils.ts new file mode 100644 index 0000000000..4341c44e27 --- /dev/null +++ b/frontend/src/pages/Nginx/ProxyHosts/domainUtils.ts @@ -0,0 +1,62 @@ +/** + * Returns the "base" domain for a domain name using a dependency-free + * last-two-labels rule (e.g. "api.example.com" -> "example.com"). + * + * - Strips a leading wildcard ("*.example.com" -> "example.com"). + * - Falls back to the cleaned input when there are fewer than two labels + * (e.g. a single label like "localhost"). + * + * Note: multi-part suffixes such as ".co.uk" group one level too shallow + * ("www.example.co.uk" -> "co.uk"). This is a deliberate trade-off to avoid + * pulling in the full public suffix list as a dependency. + */ +export function getBaseDomain(domain: string): string { + if (!domain) { + return ""; + } + + let cleaned = domain.trim().toLowerCase(); + if (cleaned.startsWith("*.")) { + cleaned = cleaned.slice(2); + } + // Drop a trailing dot (fully-qualified domain names). + cleaned = cleaned.replace(/\.$/, ""); + + const labels = cleaned.split(".").filter(Boolean); + if (labels.length <= 2) { + return labels.join("."); + } + return labels.slice(-2).join("."); +} + +export interface BaseDomainCount { + base: string; + count: number; +} + +/** + * Given a list of per-host domain-name arrays, returns the unique base + * domains across all hosts with how many hosts contain each one, sorted + * alphabetically. A host that holds several domains sharing the same base + * (e.g. api.x.com + app.x.com) counts once for that base. + */ +export function getBaseDomainCounts(hostDomainNames: string[][]): BaseDomainCount[] { + const counts = new Map(); + + for (const domainNames of hostDomainNames) { + const basesForHost = new Set(); + for (const domain of domainNames ?? []) { + const base = getBaseDomain(domain); + if (base) { + basesForHost.add(base); + } + } + for (const base of basesForHost) { + counts.set(base, (counts.get(base) ?? 0) + 1); + } + } + + return Array.from(counts.entries()) + .map(([base, count]) => ({ base, count })) + .sort((a, b) => a.base.localeCompare(b.base)); +}