Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/locale/src/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,9 @@
"expires.on": {
"defaultMessage": "Expires: {date}"
},
"filter.all": {
"defaultMessage": "All"
},
"footer.github-fork": {
"defaultMessage": "Fork me on Github"
},
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/pages/Nginx/ProxyHosts/DomainFilter.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<button
type="button"
onClick={onClick}
className={cn(
"badge",
"domain-name",
"me-2",
"mb-2",
"border-0",
active ? "bg-lime text-white" : "bg-secondary-lt",
)}
>
{label}
{count != null ? <span className="ms-1 opacity-75">({count})</span> : null}
</button>
);

/**
* 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 (
<div className="px-3 pt-3 d-flex flex-wrap align-items-center">
<Chip label={<T id="filter.all" />} active={selected === null} onClick={() => onSelect(null)} />
{baseDomains.map(({ base, count }) => (
<Chip
key={base}
label={base}
count={count}
active={selected === base}
onClick={() => onSelect(selected === base ? null : base)}
/>
))}
</div>
);
}
28 changes: 24 additions & 4 deletions frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]);

if (isLoading) {
Expand All @@ -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 (
<div className="card mt-4">
<div className="card-status-top bg-lime" />
Expand Down Expand Up @@ -94,9 +109,14 @@ export default function TableWrapper() {
</div>
</div>
</div>
<DomainFilter
hostDomainNames={allHosts.map((host) => host.domainNames)}
selected={selectedBaseDomain}
onSelect={setSelectedBaseDomain}
/>
<Table
data={filtered ?? data ?? []}
isFiltered={!!search}
isFiltered={isFiltered}
isFetching={isFetching}
onEdit={(id: number) => showProxyHostModal(id)}
onDelete={(id: number) => {
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/pages/Nginx/ProxyHosts/domainUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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 }]);
});
});
62 changes: 62 additions & 0 deletions frontend/src/pages/Nginx/ProxyHosts/domainUtils.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

for (const domainNames of hostDomainNames) {
const basesForHost = new Set<string>();
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));
}