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));
+}