Add lazy connection setting (#465)

This commit is contained in:
Eduard Gert
2025-06-04 11:54:18 +02:00
committed by GitHub
parent 0e2661caea
commit 3f6e4c4e4f
19 changed files with 232 additions and 35 deletions

8
package-lock.json generated
View File

@@ -53,7 +53,7 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.479.0",
"lucide-react": "^0.481.0",
"next": "^14.2.28",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
@@ -6621,9 +6621,9 @@
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.479.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.479.0.tgz",
"integrity": "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ==",
"version": "0.481.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.481.0.tgz",
"integrity": "sha512-NrvUDNFwgLIvHiwTEq9boa5Kiz1KdUT8RJ+wmNijwxdn9U737Fw42c43sRxJTMqhL+ySHpGRVCWpwiF+abrEjw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"

View File

@@ -58,7 +58,7 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.479.0",
"lucide-react": "^0.481.0",
"next": "^14.2.28",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",

View File

@@ -157,20 +157,19 @@ const PeerGeneralInformation = () => {
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
name,
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
]);
const updatePeer = async () => {
const updatePeer = async (newName?: string) => {
let batchCall: Promise<any>[] = [];
const groupCalls = getAllGroupCalls();
if (permission.peers.update) {
const updateRequest = update({
name,
name: newName ?? name,
ssh,
loginExpiration,
inactivityExpiration,
@@ -187,7 +186,6 @@ const PeerGeneralInformation = () => {
mutate("/peers/" + peer.id);
mutate("/groups");
updateHasChangedRef([
name,
ssh,
selectedGroups,
loginExpiration,
@@ -229,8 +227,10 @@ const PeerGeneralInformation = () => {
</ModalTrigger>
<EditNameModal
onSuccess={(newName) => {
setName(newName);
setShowEditNameModal(false);
updatePeer(newName).then(() => {
setName(newName);
setShowEditNameModal(false);
});
}}
peer={peer}
initialName={name}

View File

@@ -6,6 +6,7 @@ import {
AlertOctagonIcon,
FolderGit2Icon,
LockIcon,
MonitorSmartphoneIcon,
NetworkIcon,
ShieldIcon,
} from "lucide-react";
@@ -16,6 +17,7 @@ import { useLoggedInUser } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { useAccount } from "@/modules/account/useAccount";
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import GroupsTab from "@/modules/settings/GroupsTab";
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
@@ -63,6 +65,10 @@ export default function NetBirdSettings() {
<NetworkIcon size={14} />
Networks
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="clients">
<MonitorSmartphoneIcon size={14} />
Clients
</VerticalTabs.Trigger>
</>
)}
@@ -77,6 +83,7 @@ export default function NetBirdSettings() {
{account && <PermissionsTab account={account} />}
{account && <GroupsTab account={account} />}
{account && <NetworkSettingsTab account={account} />}
{account && <ClientSettingsTab account={account} />}
{account && <DangerZoneTab account={account} />}
</div>
</RestrictedAccess>

View File

@@ -8,6 +8,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement>, BadgeVariants {
children: React.ReactNode;
className?: string;
useHover?: boolean;
disabled?: boolean;
}
const variants = cva("", {
@@ -53,6 +54,7 @@ export default function Badge({
className,
variant = "blue",
useHover = false,
disabled = false,
...props
}: Readonly<Props>) {
return (
@@ -61,6 +63,7 @@ export default function Badge({
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
className,
variants({ variant, hover: useHover ? variant : "none" }),
disabled && "cursor-not-allowed opacity-50 select-none",
)}
{...props}
>

View File

@@ -14,6 +14,8 @@ type Props = {
onError?: (error: boolean) => void;
error?: string;
disabled?: boolean;
preventLeadingAndTrailingDots?: boolean;
allowWildcard?: boolean;
};
enum ActionType {
ADD = "ADD",
@@ -40,6 +42,8 @@ export default function InputDomain({
onRemove,
onError,
disabled,
preventLeadingAndTrailingDots,
allowWildcard = true,
}: Readonly<Props>) {
const [name, setName] = useState(value?.name || "");
@@ -52,7 +56,11 @@ export default function InputDomain({
if (name == "") {
return "";
}
const valid = validator.isValidDomain(name);
const valid = validator.isValidDomain(name, {
allowOnlyTld: true,
allowWildcard,
preventLeadingAndTrailingDots,
});
if (!valid) {
return "Please enter a valid domain, e.g. example.com or intra.example.com";
}

View File

@@ -16,9 +16,8 @@ export const useRedirect = (
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// If redirect is disabled or the url is already in the callback urls or the url is the current path then do not redirect
if (!enable || callBackUrls.current.includes(url) || url === currentPath)
return;
// If redirect is disabled or the url is already in the callback urls then do not redirect
if (!enable || callBackUrls.current.includes(url)) return;
const performRedirect = () => {
if (!isRedirecting.current) {

View File

@@ -19,5 +19,6 @@ export interface Account {
regular_users_view_blocked: boolean;
routing_peer_dns_resolution_enabled: boolean;
dns_domain: string;
lazy_connection_enabled: boolean;
};
}

View File

@@ -2,12 +2,17 @@ import Badge from "@components/Badge";
import { IconCirclePlus } from "@tabler/icons-react";
import { ShieldCheck } from "lucide-react";
import React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Policy } from "@/interfaces/Policy";
type Props = {
policy: Policy;
};
export default function AccessControlPostureCheckCell({ policy }: Props) {
const { permission } = usePermissions();
const isDisabled = !permission.policies.create || !permission.policies.update;
return policy.source_posture_checks &&
policy.source_posture_checks.length > 0 ? (
<div className={"flex"}>
@@ -18,7 +23,14 @@ export default function AccessControlPostureCheckCell({ policy }: Props) {
</div>
) : (
<div className={"flex"}>
<Badge variant={"gray"} useHover={true}>
<Badge
variant={"gray"}
useHover={!isDisabled}
onClick={(e) => {
if (isDisabled) e.stopPropagation();
}}
disabled={isDisabled}
>
<IconCirclePlus size={14} />
Add Posture Check
</Badge>

View File

@@ -8,6 +8,7 @@ import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import { removeAllSpaces } from "@utils/helpers";
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useState } from "react";
@@ -37,16 +38,20 @@ type Props = {
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
{
accessorKey: "name",
id: "name",
accessorFn: (row) => removeAllSpaces(row?.name),
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
},
sortingFn: "text",
filterFn: "fuzzy",
cell: ({ cell }) => <AccessControlNameCell policy={cell.row.original} />,
},
{
accessorKey: "description",
id: "description",
accessorFn: (row) => removeAllSpaces(row?.description),
sortingFn: "text",
filterFn: "fuzzy",
},
{
id: "enabled",

View File

@@ -371,6 +371,8 @@ export function NameserverModalContent({
{domains.map((domain, i) => {
return (
<InputDomain
preventLeadingAndTrailingDots={true}
allowWildcard={false}
key={domain.id}
value={domain}
onChange={(d) =>

View File

@@ -1,5 +1,5 @@
import { useApiCall } from "@utils/api";
import { isEmpty } from "lodash";
import { isEmpty, orderBy } from "lodash";
import { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useGroups } from "@/contexts/GroupsProvider";
@@ -20,13 +20,13 @@ export default function useGroupHelper({ initial = [], peer }: Props) {
const initialGroups = useMemo(() => {
if (!initial) return [];
const isArrayOfStrings = initial.every((item) => typeof item === "string");
if (!isArrayOfStrings) return initial as Group[];
if (!isArrayOfStrings) return orderBy(initial as Group[], ["name"]);
const foundGroups = initial
.map((id) => {
return groups?.find((g) => g.id === id);
})
.filter((g) => g !== undefined) as Group[];
return foundGroups ?? [];
return orderBy(foundGroups, ["name"]) ?? [];
}, [groups, initial]);
const [selectedGroups, setSelectedGroups] = useState<Group[]>(initialGroups);

View File

@@ -173,7 +173,7 @@ export function ResourceModalContent({
<ResourceSingleAddressInput value={address} onChange={setAddress} />
<div>
<Label>Assigned Groups</Label>
<Label>Destination Groups</Label>
<HelpText>
Add this resource to groups and use them as destinations when
creating policies

View File

@@ -147,7 +147,9 @@ export default function PostureCheckTable({
<Button
variant={"primary"}
className={"ml-auto"}
disabled={!permission.policies.create}
disabled={
!permission.policies.create || !permission.policies.update
}
onClick={() => {
setCurrentRow(undefined);
setPostureCheckModal(true);
@@ -176,6 +178,9 @@ export default function PostureCheckTable({
<Button
variant={"primary"}
className={"ml-auto"}
disabled={
!permission.policies.create || !permission.policies.update
}
onClick={() => setPostureCheckModal(true)}
>
<IconCirclePlus size={16} />

View File

@@ -0,0 +1,119 @@
import Breadcrumbs from "@components/Breadcrumbs";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import InlineLink from "@components/InlineLink";
import { notify } from "@components/Notification";
import * as Tabs from "@radix-ui/react-tabs";
import { useApiCall } from "@utils/api";
import {
ClockFadingIcon,
ExternalLinkIcon,
FlaskConicalIcon,
MonitorSmartphoneIcon,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Account } from "@/interfaces/Account";
type Props = {
account: Account;
};
export default function ClientSettingsTab({ account }: Readonly<Props>) {
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id, true);
const [lazyConnection, setLazyConnection] = useState(
account.settings?.lazy_connection_enabled ?? false,
);
const toggleLazyConnection = async (toggle: boolean) => {
notify({
title: "Lazy Connections",
description: `Lazy Connections successfully ${
toggle ? "enabled" : "disabled"
}.`,
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
lazy_connection_enabled: toggle,
},
})
.then(() => {
setLazyConnection(toggle);
mutate("/accounts");
}),
loadingMessage: "Updating Lazy Connections setting...",
});
};
return (
<Tabs.Content value={"clients"}>
<div className={"p-default py-6 max-w-xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={"Settings"}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=clients"}
label={"Clients"}
icon={<MonitorSmartphoneIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<h1>Clients</h1>
</div>
<div className={"flex flex-col gap-6 w-full mt-8"}>
<div className={"mt-0"}>
<h2 className={"text-lg font-medium"}>
Experimental
<FlaskConicalIcon
size={16}
className={"inline ml-1.5 relative -top-[2px]"}
/>
</h2>
<div className={"text-sm text-gray-400"}>
Lazy connections are an experimental feature. Functionality and
behavior may evolve. Instead of maintaining always-on connections,
NetBird activates them on-demand based on activity or signaling.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/lazy-connection"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</div>
</div>
<FancyToggleSwitch
value={lazyConnection}
onChange={toggleLazyConnection}
label={
<>
<ClockFadingIcon size={15} />
Enable Lazy Connections
</>
}
helpText={
<>
Allow to establish connections between peers only when required.
This requires NetBird client v0.45 or higher. Changes will only
take effect after restarting the clients.
</>
}
disabled={!permission.settings.update}
/>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -168,6 +168,7 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
"https://docs.netbird.io/how-to/accessing-entire-domains-within-networks#enabling-dns-wildcard-routing"
}
target={"_blank"}
onClick={(e) => e.stopPropagation()}
>
Learn more
<ExternalLinkIcon size={12} />

View File

@@ -2,6 +2,7 @@ import FullTooltip from "@components/FullTooltip";
import { ScrollArea } from "@components/ScrollArea";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { cn, generateColorFromString } from "@utils/helpers";
import { orderBy } from "lodash";
import * as React from "react";
import { User } from "@/interfaces/User";
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
@@ -19,7 +20,7 @@ export const HorizontalUsersStack = ({
avatarClassName,
side = "top",
}: Props) => {
let usersToDisplay = users?.slice(0, max) || [];
let usersToDisplay = orderBy(users?.slice(0, max) || [], ["name"]);
return (
<FullTooltip
@@ -33,7 +34,7 @@ export const HorizontalUsersStack = ({
className={"flex flex-col gap-2.5"}
onClick={(e) => e.stopPropagation()}
>
{users?.map((user, index) => (
{orderBy(users, ["name"])?.map((user, index) => (
<div
className={"flex items-center gap-2 first:pt-2 last:pb-2 pr-6"}
key={user?.id || index}
@@ -92,7 +93,7 @@ export const HorizontalUsersStack = ({
<div
className={cn(
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2 text-xs ml-1.5 transition-colors",
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2 text-xs ml-1.5 transition-colors whitespace-nowrap",
users.length > 0 && "group-hover/user-stack:text-nb-gray-200 ",
)}
>

View File

@@ -29,6 +29,8 @@ interface MultiSelectProps {
hideOwner?: boolean;
currentUser?: User;
customTrigger?: React.ReactNode;
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
}
export const UserRoles = [
@@ -72,6 +74,8 @@ export function UserRoleSelector({
hideOwner = false,
currentUser,
customTrigger,
side = "bottom",
align = "start",
}: Readonly<MultiSelectProps>) {
const [inputRef, { width }] = useElementSize<
HTMLButtonElement | HTMLDivElement
@@ -124,13 +128,15 @@ export function UserRoleSelector({
>
<PopoverTrigger asChild={true}>
{customTrigger ? (
<div ref={inputRef}>{customTrigger}</div>
<div ref={inputRef} className={"group/user-role-selector"}>
{customTrigger}
</div>
) : (
<Button
variant={"input"}
disabled={disabled}
ref={inputRef}
className={"w-full"} // [data-state] open
className={"w-full group/user-role-selector"}
data-cy={"user-role-selector"}
>
<div className={"w-full flex justify-between items-center gap-2"}>
@@ -157,8 +163,8 @@ export function UserRoleSelector({
style={{
width: popoverWidth === "auto" ? width : popoverWidth,
}}
align="start"
side={"bottom"}
align={align}
side={side}
sideOffset={10}
>
<Command

View File

@@ -26,6 +26,7 @@ export function removeAllSpaces(str: string) {
export const generateColorFromString = (str: string) => {
if (str.includes("System")) return "#808080";
if (str.toLowerCase().startsWith("netbird")) return "#f68330";
let hash = 0;
str.split("").forEach((char) => {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
@@ -59,22 +60,49 @@ export const sleep = (ms: number) => {
export const validator = {
isValidDomain: (
domain: string,
options?: { allowWildcard?: boolean; allowOnlyTld?: boolean },
options?: {
allowWildcard?: boolean;
allowOnlyTld?: boolean;
preventLeadingAndTrailingDots?: boolean;
},
) => {
const { allowWildcard = true, allowOnlyTld = true } = options || {
const {
allowWildcard = true,
allowOnlyTld = true,
preventLeadingAndTrailingDots = false,
} = options || {
allowWildcard: true,
allowOnlyTld: true,
preventLeadingAndTrailingDots: false,
};
try {
const includesAtLeastOneDot = domain.includes(".");
const includesAtLeastOneDot = allowOnlyTld ? true : domain.includes(".");
const hasWhitespace = domain.includes(" ");
const domainRegex =
/^(?!-)[a-z0-9\u00a1-\uffff-*]{0,63}(?<!-)(\.[a-z0-9\u00a1-\uffff-*]{0,63})*$/i;
/**
* Do not start or end with hyphen
* Allow any Unicode character
* Allow any Unicode number
* Allow hyphen, dot and asterisks
*/
const domainRegex = /^(?!-)[\p{L}\p{N}.*-]+(?<!-)$/u;
const isValidUnicodeDomain = domainRegex.test(domain);
if (
preventLeadingAndTrailingDots &&
(domain.startsWith(".") || domain.endsWith("."))
) {
return false;
}
if (domain.length < 1 || domain.length > 255) {
return false;
}
if (!allowWildcard && domain.includes("*")) {
return false;
}
if (!allowWildcard && domain.startsWith("*.")) {
return false;
}