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
43 changes: 43 additions & 0 deletions backend/migrations/20260402000000_host_group_label.js
Original file line number Diff line number Diff line change
@@ -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 };
9 changes: 8 additions & 1 deletion backend/schema/components/proxy-host-object.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"locations",
"hsts_enabled",
"hsts_subdomains",
"trust_forwarded_proto"
"trust_forwarded_proto",
"host_group_label"
],
"properties": {
"id": {
Expand Down Expand Up @@ -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": [
{
Expand Down
3 changes: 2 additions & 1 deletion backend/schema/paths/nginx/proxy-hosts/get.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
"locations": [],
"hsts_enabled": false,
"hsts_subdomains": false,
"trust_forwarded_proto": false
"trust_forwarded_proto": false,
"host_group_label": ""
}
]
}
Expand Down
1 change: 1 addition & 0 deletions backend/schema/paths/nginx/proxy-hosts/hostID/get.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions backend/schema/paths/nginx/proxy-hosts/hostID/put.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions backend/schema/paths/nginx/proxy-hosts/post.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down Expand Up @@ -123,6 +126,7 @@
"hsts_enabled": false,
"hsts_subdomains": false,
"trust_forwarded_proto": false,
"host_group_label": "",
"certificate": null,
"owner": {
"id": 1,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/backend/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export interface ProxyHost {
hstsEnabled: boolean;
hstsSubdomains: boolean;
trustForwardedProto: boolean;
hostGroupLabel: string;
// Expansions:
owner?: User;
accessList?: AccessList;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/useProxyHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const fetchProxyHost = (id: number | "new") => {
hstsEnabled: false,
hstsSubdomains: false,
trustForwardedProto: false,
hostGroupLabel: "",
} as ProxyHost);
}
return getProxyHost(id, ["owner"]);
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/locale/src/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@
"column.details": {
"defaultMessage": "Details"
},
"column.group": {
"defaultMessage": "Group"
},
"column.email": {
"defaultMessage": "Email"
},
Expand Down Expand Up @@ -599,6 +602,12 @@
"proxy-host.forward-host": {
"defaultMessage": "Forward Hostname / IP"
},
"proxy-host.group-label": {
"defaultMessage": "Group Label"
},
"proxy-host.group-label.placeholder": {
"defaultMessage": "e.g. Production, Development, My Project"
},
"proxy-hosts": {
"defaultMessage": "Proxy Hosts"
},
Expand Down Expand Up @@ -770,6 +779,12 @@
"username": {
"defaultMessage": "Username"
},
"ungrouped": {
"defaultMessage": "Ungrouped"
},
"all-groups": {
"defaultMessage": "All Groups"
},
"users": {
"defaultMessage": "Users"
}
Expand Down
30 changes: 29 additions & 1 deletion frontend/src/modals/ProxyHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
SSLOptionsFields,
} from "src/components";
import { useProxyHost, useSetProxyHost, useUser } from "src/hooks";
import { T } from "src/locale";
import { intl, T } from "src/locale";
import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions";
import { validateNumber, validateString } from "src/modules/Validations";
import { showObjectSuccess } from "src/notifications";
Expand Down Expand Up @@ -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 || "",
Expand Down Expand Up @@ -163,6 +164,33 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
<div className="card-body">
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<Field name="hostGroupLabel" validate={validateString(0, 150)}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="hostGroupLabel">
<T id="proxy-host.group-label" />
</label>
<input
id="hostGroupLabel"
type="text"
className={`form-control ${form.errors.hostGroupLabel && form.touched.hostGroupLabel ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({
id: "proxy-host.group-label.placeholder",
})}
maxLength={150}
{...field}
/>
{form.errors.hostGroupLabel ? (
<div className="invalid-feedback">
{form.errors.hostGroupLabel &&
form.touched.hostGroupLabel
? form.errors.hostGroupLabel
: null}
</div>
) : null}
</div>
)}
</Field>
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
<div className="row">
<div className="col-md-3">
Expand Down
123 changes: 105 additions & 18 deletions frontend/src/pages/Nginx/ProxyHosts/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { type ReactNode, useMemo, useState } from "react";
import type { ProxyHost } from "src/api/backend";
import {
AccessListFormatter,
Expand All @@ -17,7 +18,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";

Expand All @@ -30,10 +31,29 @@ interface Props {
onDisableToggle?: (id: number, enabled: boolean) => void;
onNew?: () => void;
}

// Ungrouped hosts (empty label) sort last; the rest sort alphabetically.
function compareGroupLabels(a: string, b: string): number {
if (!a && b) return 1;
if (a && !b) return -1;
return a.localeCompare(b);
}

// Group label is pinned as the primary sort key so each group's rows stay
// contiguous; a column the user sorts by then orders rows within each group.
const GROUP_SORT: SortingState[number] = { id: "hostGroupLabel", desc: false };

export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) {
const columnHelper = createColumnHelper<ProxyHost>();
const columns = useMemo(
() => [
// Sort-only column (never rendered): lets the table sort by group
// label so grouped rows stay contiguous in the row model.
columnHelper.accessor((row: any) => row.hostGroupLabel || "", {
id: "hostGroupLabel",
sortingFn: (a, b) =>
compareGroupLabels(a.original.hostGroupLabel || "", b.original.hostGroupLabel || ""),
}),
columnHelper.accessor((row: any) => row.owner, {
id: "owner",
enableSorting: false,
Expand Down Expand Up @@ -163,13 +183,21 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
[columnHelper, onEdit, onDisableToggle, onDelete],
);

const [sorting, setSorting] = useState<SortingState>([]);
const [sorting, setSorting] = useState<SortingState>([GROUP_SORT]);

const tableInstance = useReactTable<ProxyHost>({
columns,
data,
state: { sorting },
onSortingChange: setSorting,
// Keep the group-label sort pinned as the primary key; whatever column
// the user clicks becomes the secondary sort, applied within each group.
onSortingChange: (updater) => {
setSorting((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
return [GROUP_SORT, ...next.filter((sort) => sort.id !== "hostGroupLabel")];
});
},
initialState: { columnVisibility: { hostGroupLabel: false } },
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
rowCount: data.length,
Expand All @@ -179,20 +207,79 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
enableSortingRemoval: false,
});

const groupLabels = useMemo(() => {
const labels = new Set<string>();
for (const item of data) {
labels.add(item.hostGroupLabel || "");
}
return labels;
}, [data]);
const hasMultipleGroups = groupLabels.size > 1 || (groupLabels.size === 1 && !groupLabels.has(""));
const rows = tableInstance.getRowModel().rows;

if (rows.length === 0) {
return (
<div className="table-responsive">
<table className="table table-vcenter table-selectable mb-0">
<tbody className="table-tbody">
<EmptyData
object="proxy-host"
objects="proxy-hosts"
tableInstance={tableInstance}
onNew={onNew}
isFiltered={isFiltered}
color="lime"
permissionSection={PROXY_HOSTS}
/>
</tbody>
</table>
</div>
);
}

const colCount = tableInstance.getVisibleFlatColumns().length;

// Walk the row model in order, emitting a group-header row whenever the
// group label changes. Driving this off the row model (rather than the raw
// data) keeps any column sorting applied to rows within each group.
const bodyRows: ReactNode[] = [];
let previousLabel: string | null = null;
for (const row of rows) {
const label = row.original.hostGroupLabel || "";
if (hasMultipleGroups && label !== previousLabel) {
bodyRows.push(
<tr key={`group-header-${label}`}>
<td
colSpan={colCount}
className="bg-light fw-bold text-muted px-3 py-2"
style={{ fontSize: "0.8rem", letterSpacing: "0.03em" }}
>
{label || intl.formatMessage({ id: "ungrouped" })}
</td>
</tr>,
);
}
previousLabel = label;
bodyRows.push(
<tr key={row.id}>
{row.getVisibleCells().map((cell: any) => {
const { className } = (cell.column.columnDef.meta as any) ?? {};
return (
<td key={cell.id} className={className}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>,
);
}

return (
<TableLayout
tableInstance={tableInstance}
emptyState={
<EmptyData
object="proxy-host"
objects="proxy-hosts"
tableInstance={tableInstance}
onNew={onNew}
isFiltered={isFiltered}
color="lime"
permissionSection={PROXY_HOSTS}
/>
}
/>
<div className="table-responsive">
<table className="table table-vcenter table-selectable mb-0">
<TableHeader tableInstance={tableInstance} />
<tbody className="table-tbody">{bodyRows}</tbody>
</table>
</div>
);
}
Loading