From 98e50908cad2532648631560006794409ffd1014 Mon Sep 17 00:00:00 2001 From: jiemCode Date: Mon, 13 Jul 2026 17:21:59 +0000 Subject: [PATCH] Group ProxyHosts table by base domain Cluster proxy hosts under collapsible group headers keyed by the registrable domain (PSL via tldts). Grouping is always on; row order within groups still honors column sorting. Multi-domain hosts group by the first domain. --- frontend/package.json | 3 +- frontend/src/components/Table/TableBody.tsx | 28 +++++++++++++ frontend/src/pages/Nginx/ProxyHosts/Table.tsx | 26 ++++++++++-- .../pages/Nginx/ProxyHosts/TableWrapper.tsx | 42 ++++++++++++------- .../src/pages/Nginx/ProxyHosts/baseDomain.ts | 6 +++ frontend/yarn.lock | 12 ++++++ 6 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 frontend/src/pages/Nginx/ProxyHosts/baseDomain.ts diff --git a/frontend/package.json b/frontend/package.json index a592fda5c8..6a955c52ef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,7 +37,8 @@ "react-router-dom": "^7.16.0", "react-select": "^5.10.2", "react-toastify": "^11.1.0", - "rooks": "^9.8.0" + "rooks": "^9.8.0", + "tldts": "^7.4.8" }, "devDependencies": { "@biomejs/biome": "^2.4.15", diff --git a/frontend/src/components/Table/TableBody.tsx b/frontend/src/components/Table/TableBody.tsx index d8fe9bf3a4..bcc4bbc9bd 100644 --- a/frontend/src/components/Table/TableBody.tsx +++ b/frontend/src/components/Table/TableBody.tsx @@ -1,4 +1,5 @@ import { flexRender } from "@tanstack/react-table"; +import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import type { TableLayoutProps } from "src/components"; import { EmptyRow } from "./EmptyRow"; @@ -14,12 +15,39 @@ function TableBody(props: TableLayoutProps) { ); } + const visibleColumnCount = tableInstance.getVisibleLeafColumns().length; + return ( {rows.map((row: any) => { + if (row.getIsGrouped()) { + const isExpanded = row.getIsExpanded(); + return ( + + + + {isExpanded ? ( + + ) : ( + + )} + {String(row.groupingValue ?? "")} + {row.subRows.length} + + + + ); + } return ( {row.getVisibleCells().map((cell: any) => { + if (cell.getIsPlaceholder()) { + return null; + } const { className } = (cell.column.columnDef.meta as any) ?? {}; return ( diff --git a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx index 5af58081ad..697121e757 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx @@ -1,7 +1,10 @@ import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react"; import { createColumnHelper, + type ExpandedState, getCoreRowModel, + getExpandedRowModel, + getGroupedRowModel, getSortedRowModel, type SortingState, useReactTable, @@ -21,8 +24,10 @@ import { TableLayout } from "src/components/Table/TableLayout"; import { intl, T } from "src/locale"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; +type ProxyHostRow = ProxyHost & { baseDomain: string }; + interface Props { - data: ProxyHost[]; + data: ProxyHostRow[]; isFiltered?: boolean; isFetching?: boolean; onEdit?: (id: number) => void; @@ -31,9 +36,13 @@ interface Props { onNew?: () => void; } export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) { - const columnHelper = createColumnHelper(); + const columnHelper = createColumnHelper(); const columns = useMemo( () => [ + columnHelper.accessor((row: any) => row.baseDomain, { + id: "baseDomain", + enableSorting: false, + }), columnHelper.accessor((row: any) => row.owner, { id: "owner", enableSorting: false, @@ -164,14 +173,23 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog ); const [sorting, setSorting] = useState([]); + const [expanded, setExpanded] = useState(true); - const tableInstance = useReactTable({ + const tableInstance = useReactTable({ columns, data, - state: { sorting }, + state: { sorting, expanded }, + initialState: { + grouping: ["baseDomain"], + columnVisibility: { baseDomain: false }, + }, onSortingChange: setSorting, + onExpandedChange: setExpanded, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + getGroupedRowModel: getGroupedRowModel(), + getExpandedRowModel: getExpandedRowModel(), + autoResetExpanded: false, rowCount: data.length, meta: { isFetching, diff --git a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx index 5d6602e2db..37c2f20c3f 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx @@ -1,6 +1,6 @@ import { IconHelp, IconSearch } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import Alert from "react-bootstrap/Alert"; import { deleteProxyHost, toggleProxyHost } from "src/api/backend"; import { Button, HasPermission, LoadingPage } from "src/components"; @@ -9,6 +9,7 @@ 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 { getBaseDomain } from "./baseDomain"; import Table from "./Table"; export default function TableWrapper() { @@ -16,6 +17,25 @@ export default function TableWrapper() { const [search, setSearch] = useState(""); const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]); + const filtered = useMemo(() => { + if (!search || !data) return null; + return data.filter( + (item) => + item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) || + item.forwardHost.toLowerCase().includes(search) || + `${item.forwardPort}`.includes(search), + ); + }, [search, data]); + + const tableData = useMemo( + () => + (filtered ?? data ?? []).map((item) => ({ + ...item, + baseDomain: getBaseDomain(item.domainNames?.[0]), + })), + [filtered, data], + ); + if (isLoading) { return ; } @@ -24,6 +44,11 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } + if (search !== "" && !data?.length) { + // this can happen if someone deletes the last item while searching + setSearch(""); + } + const handleDelete = async (id: number) => { await deleteProxyHost(id); showObjectSuccess("proxy-host", "deleted"); @@ -36,19 +61,6 @@ export default function TableWrapper() { showObjectSuccess("proxy-host", enabled ? "enabled" : "disabled"); }; - let filtered = null; - if (search && data) { - filtered = data?.filter( - (item) => - item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) || - item.forwardHost.toLowerCase().includes(search) || - `${item.forwardPort}`.includes(search), - ); - } else if (search !== "") { - // this can happen if someone deletes the last item while searching - setSearch(""); - } - return (
@@ -95,7 +107,7 @@ export default function TableWrapper() {
showProxyHostModal(id)} diff --git a/frontend/src/pages/Nginx/ProxyHosts/baseDomain.ts b/frontend/src/pages/Nginx/ProxyHosts/baseDomain.ts new file mode 100644 index 0000000000..3134344edb --- /dev/null +++ b/frontend/src/pages/Nginx/ProxyHosts/baseDomain.ts @@ -0,0 +1,6 @@ +import { getDomain } from "tldts"; + +export function getBaseDomain(domain: string | undefined): string { + if (!domain) return ""; + return getDomain(domain) ?? domain; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5fe787ee2b..d14a611e43 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2555,6 +2555,18 @@ tinyrainbow@^3.1.0: resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz#1d8a623893f95cf0a2ddb9e5d11150e191409421" integrity sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw== +tldts-core@^7.4.8: + version "7.4.8" + resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.4.8.tgz#c729aee4ff9d3670741193682a98e25a3d780dd9" + integrity sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw== + +tldts@^7.4.8: + version "7.4.8" + resolved "https://registry.yarnpkg.com/tldts/-/tldts-7.4.8.tgz#f2edc0d81483ea76c45827d642ccd535a3a7a4f7" + integrity sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A== + dependencies: + tldts-core "^7.4.8" + tmp@^0.2.7: version "0.2.7" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059"