diff --git a/backend/migrations/20260402000000_host_group_label.js b/backend/migrations/20260402000000_host_group_label.js new file mode 100644 index 0000000000..4a4df40d68 --- /dev/null +++ b/backend/migrations/20260402000000_host_group_label.js @@ -0,0 +1,43 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "host_group_label"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = function (knex) { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .alterTable('proxy_host', (table) => { + table.string('host_group_label').notNullable().defaultTo(''); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = function (knex) { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .alterTable('proxy_host', (table) => { + table.dropColumn('host_group_label'); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); +}; + +export { up, down }; diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 3ac6462136..882ae7fc4d 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -23,7 +23,8 @@ "locations", "hsts_enabled", "hsts_subdomains", - "trust_forwarded_proto" + "trust_forwarded_proto", + "host_group_label" ], "properties": { "id": { @@ -147,6 +148,12 @@ "description": "Trust the forwarded headers", "example": false }, + "host_group_label": { + "type": "string", + "description": "Grouping label for organising proxy hosts (e.g. project, organisation)", + "maxLength": 150, + "example": "" + }, "certificate": { "oneOf": [ { diff --git a/backend/schema/paths/nginx/proxy-hosts/get.json b/backend/schema/paths/nginx/proxy-hosts/get.json index 301e28bfdf..f4d5c7c608 100644 --- a/backend/schema/paths/nginx/proxy-hosts/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/get.json @@ -59,7 +59,8 @@ "locations": [], "hsts_enabled": false, "hsts_subdomains": false, - "trust_forwarded_proto": false + "trust_forwarded_proto": false, + "host_group_label": "" } ] } diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json index 2e677fed32..6be14056bf 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json @@ -57,6 +57,7 @@ "hsts_enabled": false, "hsts_subdomains": false, "trust_forwarded_proto": false, + "host_group_label": "", "owner": { "id": 1, "created_on": "2025-10-28T00:50:24.000Z", diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index fc3198456b..8cc418c034 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -85,6 +85,9 @@ }, "locations": { "$ref": "../../../../components/proxy-host-object.json#/properties/locations" + }, + "host_group_label": { + "$ref": "../../../../components/proxy-host-object.json#/properties/host_group_label" } } } @@ -126,6 +129,7 @@ "hsts_enabled": false, "hsts_subdomains": false, "trust_forwarded_proto": false, + "host_group_label": "", "owner": { "id": 1, "created_on": "2025-10-28T00:50:24.000Z", diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index 28ddad8fc2..fff52214f3 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -77,6 +77,9 @@ }, "locations": { "$ref": "../../../components/proxy-host-object.json#/properties/locations" + }, + "host_group_label": { + "$ref": "../../../components/proxy-host-object.json#/properties/host_group_label" } } }, @@ -123,6 +126,7 @@ "hsts_enabled": false, "hsts_subdomains": false, "trust_forwarded_proto": false, + "host_group_label": "", "certificate": null, "owner": { "id": 1, diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..d3d7f15f9a 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -128,6 +128,7 @@ export interface ProxyHost { hstsEnabled: boolean; hstsSubdomains: boolean; trustForwardedProto: boolean; + hostGroupLabel: string; // Expansions: owner?: User; accessList?: AccessList; diff --git a/frontend/src/hooks/useProxyHost.ts b/frontend/src/hooks/useProxyHost.ts index 24e7f4fae2..c43f38a76e 100644 --- a/frontend/src/hooks/useProxyHost.ts +++ b/frontend/src/hooks/useProxyHost.ts @@ -25,6 +25,7 @@ const fetchProxyHost = (id: number | "new") => { hstsEnabled: false, hstsSubdomains: false, trustForwardedProto: false, + hostGroupLabel: "", } as ProxyHost); } return getProxyHost(id, ["owner"]); diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..75f1d72216 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -263,6 +263,9 @@ "column.details": { "defaultMessage": "Details" }, + "column.group": { + "defaultMessage": "Group" + }, "column.email": { "defaultMessage": "Email" }, @@ -599,6 +602,9 @@ "proxy-host.forward-host": { "defaultMessage": "Forward Hostname / IP" }, + "proxy-host.group-label": { + "defaultMessage": "Group Label" + }, "proxy-hosts": { "defaultMessage": "Proxy Hosts" }, @@ -770,6 +776,12 @@ "username": { "defaultMessage": "Username" }, + "ungrouped": { + "defaultMessage": "Ungrouped" + }, + "all-groups": { + "defaultMessage": "All Groups" + }, "users": { "defaultMessage": "Users" } diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 3227be51bb..20622dca10 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -72,6 +72,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { initialValues={ { // Details tab + hostGroupLabel: data?.hostGroupLabel || "", domainNames: data?.domainNames || [], forwardScheme: data?.forwardScheme || "http", forwardHost: data?.forwardHost || "", @@ -163,6 +164,23 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
+ + {({ field, form }: any) => ( +
+ + +
+ )} +
diff --git a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx index 9d58b26acd..7832020d51 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx @@ -1,6 +1,6 @@ import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react"; -import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; -import { useMemo } from "react"; +import { type Row, createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { Fragment, useMemo } from "react"; import type { ProxyHost } from "src/api/backend"; import { AccessListFormatter, @@ -11,7 +11,7 @@ import { HasPermission, TrueFalseFormatter, } from "src/components"; -import { TableLayout } from "src/components/Table/TableLayout"; +import { TableHeader } from "src/components/Table/TableHeader"; import { intl, T } from "src/locale"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; @@ -24,6 +24,33 @@ interface Props { onDisableToggle?: (id: number, enabled: boolean) => void; onNew?: () => void; } + +interface Group { + label: string; + rows: ProxyHost[]; +} + +function groupByLabel(data: ProxyHost[]): Group[] { + const map = new Map(); + for (const item of data) { + const label = item.hostGroupLabel || ""; + if (!map.has(label)) { + map.set(label, []); + } + map.get(label)!.push(item); + } + const groups: Group[] = []; + for (const [label, rows] of map) { + groups.push({ label, rows }); + } + groups.sort((a, b) => { + if (!a.label && b.label) return 1; + if (a.label && !b.label) return -1; + return a.label.localeCompare(b.label); + }); + return groups; +} + export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) { const columnHelper = createColumnHelper(); const columns = useMemo( @@ -155,20 +182,72 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog enableSortingRemoval: false, }); + const groups = useMemo(() => groupByLabel(data), [data]); + const hasMultipleGroups = groups.length > 1 || (groups.length === 1 && groups[0].label !== ""); + const rows = tableInstance.getRowModel().rows; + + if (rows.length === 0) { + return ( +
+ + + + +
+
+ ); + } + + const colCount = tableInstance.getVisibleFlatColumns().length; + const rowsByOriginalId = new Map>(rows.map((r) => [r.original.id, r])); + return ( - - } - /> +
+ + + + {groups.map((group) => { + const groupLabel = group.label || intl.formatMessage({ id: "ungrouped" }); + return group.rows.map((host, idx) => { + const row = rowsByOriginalId.get(host.id); + if (!row) return null; + return ( + + {hasMultipleGroups && idx === 0 && ( + + + + )} + + {row.getVisibleCells().map((cell: any) => { + const { className } = (cell.column.columnDef.meta as any) ?? {}; + return ( + + ); + })} + + + ); + }); + })} + +
+ {groupLabel} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
); } diff --git a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx index 68af43e10a..e699a7c993 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx @@ -1,11 +1,11 @@ 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"; import { useProxyHosts } from "src/hooks"; -import { T } from "src/locale"; +import { intl, T } from "src/locale"; import { showDeleteConfirmModal, showHelpModal, showProxyHostModal } from "src/modals"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; import { showObjectSuccess } from "src/notifications"; @@ -14,8 +14,24 @@ import Table from "./Table"; export default function TableWrapper() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); + const [groupFilter, setGroupFilter] = useState("__all__"); const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]); + const uniqueGroups = useMemo(() => { + if (!data) return []; + const labels = new Set(); + for (const item of data) { + labels.add(item.hostGroupLabel || ""); + } + return Array.from(labels).sort((a, b) => { + if (!a && b) return 1; + if (a && !b) return -1; + return a.localeCompare(b); + }); + }, [data]); + + const hasGroups = uniqueGroups.length > 1 || (uniqueGroups.length === 1 && uniqueGroups[0] !== ""); + if (isLoading) { return ; } @@ -36,17 +52,20 @@ export default function TableWrapper() { showObjectSuccess("proxy-host", enabled ? "enabled" : "disabled"); }; - let filtered = null; - if (search && data) { - filtered = data?.filter( + let filtered = data ?? []; + + if (groupFilter !== "__all__") { + filtered = filtered.filter((item) => (item.hostGroupLabel || "") === groupFilter); + } + + if (search) { + filtered = filtered.filter( (item) => item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) || item.forwardHost.toLowerCase().includes(search) || - `${item.forwardPort}`.includes(search), + `${item.forwardPort}`.includes(search) || + (item.hostGroupLabel || "").toLowerCase().includes(search), ); - } else if (search !== "") { - // this can happen if someone deletes the last item while searching - setSearch(""); } return ( @@ -62,6 +81,21 @@ export default function TableWrapper() {
+ {hasGroups && data?.length ? ( + + ) : null} {data?.length ? (
@@ -95,8 +129,8 @@ export default function TableWrapper() {
showProxyHostModal(id)} onDelete={(id: number) =>