rebuild self-hosted page as Clusters with type features (#641)
Some checks failed
build and push / build_n_push (push) Has been cancelled

* feat(reverse-proxy): rebuild self-hosted page as Clusters with type + features

The Self-Hosted Proxies page was account-only by design but the
underlying API already returned every cluster the account could see.
Lifting that filter and renaming the page surfaces shared clusters
too — operators can see what NetBird-deployed clusters are reachable
alongside their own self-hosted ones, with online status and feature
support visible per row.

ReverseProxyCluster matches the new backend shape: `type`
(account/shared), `online`, and the three capability flags. The
`isSelfHostedCluster` provider hook now compares against `type ===
account` instead of a deprecated boolean.

Page folder renamed self-hosted-proxies → clusters (history-preserving
git mv). Table columns: Cluster (with an EphemeralPeerIndicator-style
icon next to the name marking account vs shared and a colored dot for
online status), Connected Proxies (plain numeric), Features (one
tooltip-backed badge per supported capability), Actions (Delete only
on account-owned rows; shared clusters render an empty action cell).

Empty state shows when the list is fully empty with a doc link in the
page header. Sidebar entry restored under Reverse Proxy.

* Update record in modal, update doc link, update modal title

* update reverse proxy documentation links to latest anchors

* update cluster modal description to "proxy cluster" instead of "self-hosted cluster"

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
This commit is contained in:
Maycon Santos
2026-05-20 11:48:38 +02:00
committed by GitHub
parent 7400ac806e
commit 42cd088c5d
12 changed files with 218 additions and 72 deletions

View File

@@ -3,6 +3,6 @@ import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Self-Hosted Proxies - Reverse Proxy - ${globalMetaTitle}`,
title: `Clusters - Reverse Proxy - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -13,11 +13,8 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const SelfHostedProxiesTable = lazy(
() =>
import(
"@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesTable"
),
const ClustersTable = lazy(
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
);
export default function ReverseProxyClustersPage() {
@@ -36,31 +33,32 @@ export default function ReverseProxyClustersPage() {
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/self-hosted-proxies"}
label={"Self-Hosted Proxies"}
href={"/reverse-proxy/clusters"}
label={"Clusters"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Self-Hosted Proxies</h1>
<h1 ref={headingRef}>Clusters</h1>
<Paragraph>
Setup self-hosted proxies on your own infrastructure for full control
over traffic and geographic location.
Proxy clusters that route inbound traffic to your services. Shared
clusters are deployed at the server level; account clusters are
self-hosted on your own infrastructure.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={REVERSE_PROXY_CLUSTERS_DOCS_LINK} target={"_blank"}>
Self-Hosted Proxies
Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Self-Hosted Proxies"}
page={"Clusters"}
hasAccess={permission?.services?.read}
>
<Suspense fallback={<SkeletonTable />}>
<SelfHostedProxiesTable headingTarget={portalTarget} />
<ClustersTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</PageContainer>

View File

@@ -17,6 +17,7 @@ import { Peer } from "@/interfaces/Peer";
import {
ReverseProxy,
ReverseProxyCluster,
ReverseProxyClusterType,
ReverseProxyDomain,
ReverseProxyFlatTarget,
ReverseProxyTarget,
@@ -152,7 +153,10 @@ export default function ReverseProxiesProvider({
const isSelfHostedCluster = useCallback(
(clusterAddress?: string) => {
if (!clusterAddress) return false;
return !!clusters?.find((c) => c.address === clusterAddress)?.self_hosted;
return (
clusters?.find((c) => c.address === clusterAddress)?.type ===
ReverseProxyClusterType.ACCOUNT
);
},
[clusters],
);

View File

@@ -163,11 +163,20 @@ export interface ReverseProxyEvent {
metadata?: Record<string, string>;
}
export enum ReverseProxyClusterType {
ACCOUNT = "account",
SHARED = "shared",
}
export interface ReverseProxyCluster {
id?: string;
address: string;
type: ReverseProxyClusterType;
online: boolean;
connected_proxies: number;
self_hosted: boolean;
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
}
export interface ReverseProxyClusterToken {
@@ -217,19 +226,19 @@ export const REVERSE_PROXY_SETTINGS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#step-4-configure-advanced-settings";
export const REVERSE_PROXY_CLUSTERS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#self-hosted-proxy-setup";
"https://docs.netbird.io/manage/reverse-proxy/bring-your-own-proxy#shared-and-account-clusters";
export const REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/custom-domains";
export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
"https://docs.netbird.io/manage/reverse-proxy/custom-domains#validating-a-custom-domain";
"https://docs.netbird.io/manage/reverse-proxy/custom-domains#verifying-a-custom-domain";
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
export const REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
"https://docs.netbird.io/manage/reverse-proxy#step-3b-configure-access-control";
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";
"https://docs.netbird.io/manage/reverse-proxy/troubleshooting";

View File

@@ -166,6 +166,13 @@ export default function Navigation({
exactPathMatch={true}
visible={permission?.services?.read}
/>
<SidebarItem
label="Clusters"
isChild
href={"/reverse-proxy/clusters"}
exactPathMatch={true}
visible={permission?.services?.read}
/>
</SidebarItem>
<SidebarItem

View File

@@ -0,0 +1,45 @@
import FullTooltip from "@components/FullTooltip";
import { ServerIcon, UserCog } from "lucide-react";
import * as React from "react";
import {
ReverseProxyCluster,
ReverseProxyClusterType,
} from "@/interfaces/ReverseProxy";
type Props = {
cluster: ReverseProxyCluster;
};
// ClusterTypeIndicator renders a small icon next to the cluster name —
// same pattern as EphemeralPeerIndicator — so the source of the
// cluster is visible at a glance without a dedicated column.
export const ClusterTypeIndicator = ({ cluster }: Props) => {
if (cluster.type === ReverseProxyClusterType.ACCOUNT) {
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
<span className={"font-medium text-white"}>Account cluster.</span>{" "}
Self-hosted on your own infrastructure you operate the proxy
nodes and control where traffic terminates.
</div>
}
>
<UserCog size={12} className={"shrink-0 text-netbird"} />
</FullTooltip>
);
}
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
<span className={"font-medium text-white"}>Shared cluster.</span>{" "}
Deployed at the server level and available to every account on this
instance.
</div>
}
>
<ServerIcon size={12} className={"shrink-0 text-nb-gray-300"} />
</FullTooltip>
);
};

View File

@@ -6,20 +6,28 @@ import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
import {
ReverseProxyCluster,
ReverseProxyClusterType,
} from "@/interfaces/ReverseProxy";
type Props = {
cluster: ReverseProxyCluster;
};
export default function SelfHostedProxiesActionCell({
cluster,
}: Readonly<Props>) {
export default function ClustersActionCell({ cluster }: Readonly<Props>) {
const { confirm } = useDialog();
const request = useApiCall<ReverseProxyCluster>("/reverse-proxies/clusters");
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
// Shared clusters are operated by NetBird; only account-owned (BYOP)
// clusters can be deleted from this page. Rendering nothing for
// shared rows keeps the cell column-aligned without an inert button.
if (cluster.type !== ReverseProxyClusterType.ACCOUNT) {
return <div className={"pr-4"} />;
}
const handleDelete = async () => {
const choice = await confirm({
title: `Delete '${cluster.address}'?`,

View File

@@ -6,18 +6,14 @@ type Props = {
cluster: ReverseProxyCluster;
};
export default function SelfHostedProxiesConnectedCell({
cluster,
}: Readonly<Props>) {
export default function ClustersConnectedCell({ cluster }: Readonly<Props>) {
const count = cluster.connected_proxies;
return (
<div className="flex items-center w-full">
<div className={"flex"}>
<Badge variant={"gray"}>
<Server size={11} className={"relative -top-[0.5px]"} />
<Server size={11} />
<div>
<span className="font-medium text-xs">
{count > 0 ? count : "No Proxies Connected"}
</span>
<span className={"font-medium text-xs"}>{count}</span>
</div>
</Badge>
</div>

View File

@@ -0,0 +1,75 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { ShieldAlert, SlidersHorizontal, Globe } from "lucide-react";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
cluster: ReverseProxyCluster;
};
type Feature = {
key: string;
label: string;
description: string;
icon: React.ReactNode;
};
// ClustersFeaturesCell renders one badge per supported capability.
// Only "true" flags get a badge; nil and false are omitted (the
// backend distinguishes "unsupported" from "not yet reported" via
// nullable booleans, but visually both mean "not available here").
export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
const features: Feature[] = [];
if (cluster.supports_custom_ports) {
features.push({
key: "custom-ports",
label: "Custom Ports",
description: "Cluster can bind arbitrary TCP/UDP ports for services.",
icon: <SlidersHorizontal size={14} className={"text-netbird"} />,
});
}
if (cluster.require_subdomain) {
features.push({
key: "subdomain",
label: "Subdomain Required",
description:
"Services on this cluster must use a subdomain — the bare cluster domain is not addressable.",
icon: <Globe size={14} className={"text-nb-gray-300"} />,
});
}
if (cluster.supports_crowdsec) {
features.push({
key: "crowdsec",
label: "CrowdSec",
description:
"Cluster has CrowdSec IP reputation configured across all active proxies.",
icon: <ShieldAlert size={14} className={"text-green-500"} />,
});
}
if (features.length === 0) {
return <EmptyRow />;
}
return (
<div className="flex items-center gap-1.5 flex-wrap">
{features.map((f) => (
<FullTooltip
key={f.key}
content={
<div className={"text-xs max-w-xs"}>
<div className={"font-medium text-white"}>{f.label}</div>
<div className={"text-nb-gray-300 mt-1"}>{f.description}</div>
</div>
}
>
<Badge variant={"gray"} className={"h-[34px] cursor-help"}>
{f.icon}
<span className="font-medium text-xs">{f.label}</span>
</Badge>
</FullTooltip>
))}
</div>
);
}

View File

@@ -39,7 +39,7 @@ type Props = {
onOpenChange: (open: boolean) => void;
};
export const SelfHostedProxiesModal = ({ open, onOpenChange }: Props) => {
export const ClustersModal = ({ open, onOpenChange }: Props) => {
const { mutate } = useSWRConfig();
const [tab, setTab] = useState("domain");
const [domain, setDomain] = useState("");
@@ -119,8 +119,8 @@ export const SelfHostedProxiesModal = ({ open, onOpenChange }: Props) => {
<ModalContent maxWidthClass={"relative max-w-[600px]"} showClose={true}>
<ModalHeader
icon={<ServerIcon size={16} />}
title={"Setup Proxy"}
description={"Setup a self-hosted reverse proxy"}
title={"Setup Cluster"}
description={"Setup a proxy cluster"}
color={"netbird"}
/>
@@ -154,7 +154,7 @@ export const SelfHostedProxiesModal = ({ open, onOpenChange }: Props) => {
<div>
<Label>Domain</Label>
<HelpText>
Enter a domain name that will be used for your proxy.
Enter a domain name that will be used for your cluster.
</HelpText>
<Input
autoFocus={true}
@@ -201,13 +201,13 @@ export const SelfHostedProxiesModal = ({ open, onOpenChange }: Props) => {
</div>
<CardTable>
<CardTable.Header>
<CardTable.HeaderCell width={120}>Type</CardTable.HeaderCell>
<CardTable.HeaderCell width={100}>Type</CardTable.HeaderCell>
<CardTable.HeaderCell>Name</CardTable.HeaderCell>
<CardTable.HeaderCell>Content</CardTable.HeaderCell>
</CardTable.Header>
<CardTable.Body>
<CardTable.Row>
<CardTable.Cell>A Record</CardTable.Cell>
<CardTable.Cell>A</CardTable.Cell>
<CardTable.Cell copy copyText={domain}>
{domain}
</CardTable.Cell>
@@ -216,12 +216,12 @@ export const SelfHostedProxiesModal = ({ open, onOpenChange }: Props) => {
</CardTable.Cell>
</CardTable.Row>
<CardTable.Row>
<CardTable.Cell>A Record</CardTable.Cell>
<CardTable.Cell>CNAME</CardTable.Cell>
<CardTable.Cell copy copyText={`*.${domain}`}>
{`*.${domain}`}
</CardTable.Cell>
<CardTable.Cell className={"italic"}>
Your machine&apos;s IP
<CardTable.Cell copy copyText={domain}>
{domain}
</CardTable.Cell>
</CardTable.Row>
</CardTable.Body>
@@ -291,7 +291,7 @@ export const SelfHostedProxiesModal = ({ open, onOpenChange }: Props) => {
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Self-Hosted Proxies
Proxy Cluster
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>

View File

@@ -1,20 +1,22 @@
import CopyToClipboardText from "@components/CopyToClipboardText";
import CircleIcon from "@/assets/icons/CircleIcon";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
import { ClusterTypeIndicator } from "@/modules/reverse-proxy/clusters/ClusterTypeIndicator";
type Props = {
cluster: ReverseProxyCluster;
};
export default function SelfHostedProxiesNameCell({ cluster }: Readonly<Props>) {
export default function ClustersNameCell({ cluster }: Readonly<Props>) {
return (
<div className="flex items-center gap-2.5 ml-2">
<CircleIcon active={true} size={8} inactiveDot={"gray"} />
<CircleIcon active={cluster.online} size={8} inactiveDot={"gray"} />
<CopyToClipboardText
message={`${cluster.address} has been copied to clipboard`}
>
<span className="font-medium">{cluster.address}</span>
</CopyToClipboardText>
<ClusterTypeIndicator cluster={cluster} />
</div>
);
}

View File

@@ -10,25 +10,26 @@ import { ColumnDef, SortingState } from "@tanstack/react-table";
import { PlusCircle, ServerIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useMemo, useState } from "react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
import useFetchApi from "@/utils/api";
import SelfHostedProxiesActionCell from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesActionCell";
import SelfHostedProxiesConnectedCell from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesConnectedCell";
import { SelfHostedProxiesModal } from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesModal";
import SelfHostedProxiesNameCell from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesNameCell";
import ClustersActionCell from "@/modules/reverse-proxy/clusters/ClustersActionCell";
import ClustersConnectedCell from "@/modules/reverse-proxy/clusters/ClustersConnectedCell";
import ClustersFeaturesCell from "@/modules/reverse-proxy/clusters/ClustersFeaturesCell";
import { ClustersModal } from "@/modules/reverse-proxy/clusters/ClustersModal";
import ClustersNameCell from "@/modules/reverse-proxy/clusters/ClustersNameCell";
const ClustersColumns: ColumnDef<ReverseProxyCluster>[] = [
{
accessorKey: "address",
header: ({ column }) => {
return <DataTableHeader column={column}>Proxy Cluster</DataTableHeader>;
return <DataTableHeader column={column}>Cluster</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <SelfHostedProxiesNameCell cluster={row.original} />,
cell: ({ row }) => <ClustersNameCell cluster={row.original} />,
},
{
accessorKey: "connected_proxies",
@@ -37,9 +38,14 @@ const ClustersColumns: ColumnDef<ReverseProxyCluster>[] = [
<DataTableHeader column={column}>Connected Proxies</DataTableHeader>
);
},
cell: ({ row }) => (
<SelfHostedProxiesConnectedCell cluster={row.original} />
),
sortingFn: "basic",
cell: ({ row }) => <ClustersConnectedCell cluster={row.original} />,
},
{
id: "features",
header: () => <span className={"font-medium text-xs"}>Features</span>,
enableSorting: false,
cell: ({ row }) => <ClustersFeaturesCell cluster={row.original} />,
},
{
id: "searchString",
@@ -49,7 +55,7 @@ const ClustersColumns: ColumnDef<ReverseProxyCluster>[] = [
id: "actions",
accessorKey: "address",
header: "",
cell: ({ row }) => <SelfHostedProxiesActionCell cluster={row.original} />,
cell: ({ row }) => <ClustersActionCell cluster={row.original} />,
},
];
@@ -57,9 +63,7 @@ type Props = {
headingTarget?: HTMLHeadingElement | null;
};
export default function SelfHostedProxiesTable({
headingTarget,
}: Readonly<Props>) {
export default function ClustersTable({ headingTarget }: Readonly<Props>) {
const { mutate } = useSWRConfig();
const path = usePathname();
const { permission } = usePermissions();
@@ -67,9 +71,7 @@ export default function SelfHostedProxiesTable({
"/reverse-proxies/clusters",
);
const selfHostedClusters = useMemo(() => {
return clusters?.filter((c) => c.self_hosted) ?? [];
}, [clusters]);
const rows = clusters ?? [];
const [addModalOpen, setAddModalOpen] = useState(false);
@@ -85,7 +87,7 @@ export default function SelfHostedProxiesTable({
return (
<>
<SelfHostedProxiesModal
<ClustersModal
open={addModalOpen}
onOpenChange={setAddModalOpen}
key={addModalOpen ? 1 : 0}
@@ -96,13 +98,13 @@ export default function SelfHostedProxiesTable({
isLoading={isLoading}
inset={false}
keepStateInLocalStorage={false}
text={"Self-Hosted Proxies"}
text={"Clusters"}
sorting={sorting}
setSorting={setSorting}
columns={ClustersColumns}
data={selfHostedClusters}
data={rows}
useRowId={true}
searchPlaceholder={"Search by proxy cluster domain..."}
searchPlaceholder={"Search by cluster domain..."}
columnVisibility={{ searchString: false }}
getStartedCard={
<GetStartedTest
@@ -113,9 +115,9 @@ export default function SelfHostedProxiesTable({
size={"large"}
/>
}
title={"Setup Your Own Self-Hosted Proxy Cluster"}
title={"No clusters available"}
description={
"Setup self-hosted proxies on your own infrastructure for full control over traffic and geographic location."
"There are no shared clusters connected to your account and no self-hosted clusters configured. Set up a self-hosted cluster to route traffic through your own infrastructure — see the documentation linked above for setup steps."
}
button={
<Button
@@ -124,14 +126,14 @@ export default function SelfHostedProxiesTable({
disabled={!permission?.services?.create}
>
<PlusCircle size={16} />
Setup Proxy
Setup Self-Hosted Cluster
</Button>
}
/>
}
rightSide={() => (
<>
{selfHostedClusters.length > 0 && (
{rows.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
@@ -139,7 +141,7 @@ export default function SelfHostedProxiesTable({
disabled={!permission?.services?.create}
>
<PlusCircle size={16} />
Setup Proxy
Setup Self-Hosted Cluster
</Button>
)}
</>
@@ -149,10 +151,10 @@ export default function SelfHostedProxiesTable({
<>
<DataTableRowsPerPage
table={table}
disabled={selfHostedClusters.length === 0}
disabled={rows.length === 0}
/>
<DataTableRefreshButton
isDisabled={selfHostedClusters.length === 0}
isDisabled={rows.length === 0}
onClick={() => {
mutate("/reverse-proxies/clusters").then();
}}