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
12 changes: 12 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,9 @@
"proxy-host.forward-host": {
"defaultMessage": "Forward Hostname / IP"
},
"proxy-host.group-label": {
"defaultMessage": "Group Label"
},
"proxy-hosts": {
"defaultMessage": "Proxy Hosts"
},
Expand Down Expand Up @@ -770,6 +776,12 @@
"username": {
"defaultMessage": "Username"
},
"ungrouped": {
"defaultMessage": "Ungrouped"
},
"all-groups": {
"defaultMessage": "All Groups"
},
"users": {
"defaultMessage": "Users"
}
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/modals/ProxyHostModal.tsx
Original file line number Diff line number Diff line change
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,23 @@ 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">
{({ 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="e.g. Production, Development, My Project"
maxLength={150}
{...field}
/>
</div>
)}
</Field>
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
<div className="row">
<div className="col-md-3">
Expand Down
113 changes: 96 additions & 17 deletions frontend/src/pages/Nginx/ProxyHosts/Table.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";

Expand All @@ -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<string, ProxyHost[]>();
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<ProxyHost>();
const columns = useMemo(
Expand Down Expand Up @@ -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 (
<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;
const rowsByOriginalId = new Map<number, Row<ProxyHost>>(rows.map((r) => [r.original.id, r]));

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">
{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 (
<Fragment key={row.id}>
{hasMultipleGroups && idx === 0 && (
<tr>
<td
colSpan={colCount}
className="bg-light fw-bold text-muted px-3 py-2"
style={{ fontSize: "0.8rem", letterSpacing: "0.03em" }}
>
{groupLabel}
</td>
</tr>
)}
<tr>
{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>
</Fragment>
);
});
})}
</tbody>
</table>
</div>
);
}
Loading