Compare commits

...
2 Commits
Author SHA1 Message Date
Maycon Santos bc4aac10aa Add browser client support (#490)
* Sync wasm rdp and ssh client

* sync package-lock

* remove msp ref

* add ephemeral info
2025-10-02 00:41:08 +02:00
Eduard Gert 38e14a6c64 Allow delete groups issued by jwt (#487) 2025-09-10 14:41:19 +02:00
68 changed files with 6246 additions and 335 deletions

No files matched your search

+27
View File
@@ -28,6 +28,33 @@ jobs:
- run: echo '{}' > .local-config.json
- name: Download IronRDP release TS files
uses: robinraju/release-downloader@v1.7
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
- name: Download IronRDP release JS files
uses: robinraju/release-downloader@v1.7
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
- name: Download IronRDP release WASM file
uses: robinraju/release-downloader@v1.7
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
- name: Build
run: npm run build
-
+5
View File
@@ -41,3 +41,8 @@ next-env.d.ts
.configs/.staging-config.json
.configs/.temp-config.json
.configs
/public/ironrdp-pkg/
/public/netbird.wasm
.idea
src/.local-config*
+4
View File
@@ -3,6 +3,10 @@ server {
listen [::]:80 default_server;
root /usr/share/nginx/html;
location = /netbird.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location / {
try_files $uri $uri.html $uri/ =404;
+1
View File
@@ -101,6 +101,7 @@ http {
application/rss+xml
application/vnd.geo+json
application/vnd.ms-fontobject
application/wasm
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
+17
View File
@@ -35,6 +35,8 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
@@ -2894,6 +2896,21 @@
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
+2
View File
@@ -40,6 +40,8 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
+575
View File
File diff suppressed because it is too large. Load diff
+13
View File
@@ -33,6 +33,7 @@ import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
Barcode,
CalendarDays,
Cpu,
FlagIcon,
Globe,
@@ -65,6 +66,8 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
export default function PeerPage() {
const queryParameter = useSearchParams();
@@ -347,6 +350,16 @@ const PeerGeneralInformation = () => {
/>
</FullTooltip>
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
{permission.groups.read && (
<div>
<Label>Assigned Groups</Label>
+9
View File
@@ -0,0 +1,9 @@
"use client";
import UsersProvider from "@/contexts/UsersProvider";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<UsersProvider>{children}</UsersProvider>
);
}
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { notify } from "@components/Notification";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { IconCircleX } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Peer } from "@/interfaces/Peer";
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
import {
RDPCredentials,
RDPStatus,
useRemoteDesktop,
} from "@/modules/remote-access/rdp/useRemoteDesktop";
import {
NetBirdStatus,
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import { cn } from "@utils/helpers";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
const {
data: peer,
isLoading,
error,
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
return (
<div className={"w-screen h-screen overflow-hidden"}>
{peerId && peer && !isLoading ? (
<RDPSession key={peer.id} peer={peer} />
) : (
<FullScreenLoading />
)}
</div>
);
}
type Props = {
peer: Peer;
};
function RDPSession({ peer }: Props) {
const client = useNetBirdClient();
const [isNetBirdConnecting, setIsNetBirdConnecting] = useState(false);
const rdp = useRemoteDesktop(client);
const [credentialsModal, setCredentialsModal] = useState(true);
const [credentials, setCredentials] = useState<RDPCredentials | null>(null);
const connected = useRef(false);
useEffect(() => {
document.title = `${peer.name} - ${peer.ip} - RDP`;
}, []);
const sendErrorNotification = (title: string, message: string) => {
notify({
title: title,
description: message,
icon: <IconCircleX size={24} />,
backgroundColor: "bg-red-500",
duration: 10000,
});
};
const reset = useCallback(async () => {
setCredentials(null);
connected.current = false;
setCredentialsModal(true);
rdp.session?.disconnect();
await client.disconnect();
}, [client, rdp]);
/**
* Establishes a connection to the peer
*/
const connect = async (rdpCredentials: RDPCredentials) => {
if (!peer?.id) return;
if (client.status === NetBirdStatus.DISCONNECTED) {
try {
setCredentials(rdpCredentials);
setIsNetBirdConnecting(true);
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
setIsNetBirdConnecting(false);
} catch (error) {
sendErrorNotification(
"NetBird Connection Error",
(error as Error).message,
);
setIsNetBirdConnecting(false);
}
}
};
const startSession = useCallback(async () => {
if (!credentials) return;
try {
const result = await rdp.connect({
hostname: peer.ip,
port: credentials.port,
username: credentials.username,
password: credentials.password,
width: window.innerWidth,
height: window.innerHeight,
});
if (result === RDPStatus.CONNECTED) {
connected.current = true;
} else {
}
} catch (error) {
sendErrorNotification("RDP Connection Error", (error as Error).message);
setCredentialsModal(true);
await reset();
}
}, [credentials, peer.ip, rdp, reset]);
/**
* Establish RDP session when NetBird connection is ready
*/
useEffect(() => {
if (
client.status === NetBirdStatus.CONNECTED &&
rdp.status === RDPStatus.DISCONNECTED &&
credentials &&
!connected.current &&
!isNetBirdConnecting
) {
startSession().catch(console.error);
}
}, [
client.status,
credentials,
peer.ip,
rdp,
startSession,
isNetBirdConnecting,
]);
/**
* Display notifications for RDP and NetBird client errors
*/
useEffect(() => {
if (rdp.error) {
sendErrorNotification("RDP Error", rdp.error);
}
if (client.error) {
sendErrorNotification("NetBird Client Error", client.error);
}
}, [rdp, client]);
/**
* Close credentials modal when RDP is connected
*/
useEffect(() => {
if (rdp.status === RDPStatus.CONNECTED) {
setCredentialsModal(false);
}
}, [rdp.status]);
const isLoading =
client.status === NetBirdStatus.CONNECTING ||
rdp.status === RDPStatus.CONNECTING ||
rdp.isResizing ||
isNetBirdConnecting;
return (
<>
{/* Credentials Modal */}
<RDPCredentialsModal
open={credentialsModal}
peer={peer}
onConnect={connect}
loading={isLoading}
/>
{/* Certificate Modal */}
<RDPCertificateModal
open={!!rdp.pendingCertificate}
certificateInfo={rdp.pendingCertificate}
onAccept={rdp.acceptCertificatePrompt}
onReject={async () => {
rdp.rejectCertificatePrompt();
await reset();
}}
/>
{rdp.isResizing && (
<div
className={
"fixed w-screen h-screen z-50 backdrop-blur bg-black/50 flex items-center justify-center"
}
>
<Loader2Icon size={20} className={"animate-spin"} />
</div>
)}
{/* RDP Canvas */}
<canvas
ref={rdp.canvasRef}
className={cn(
rdp.status === RDPStatus.CONNECTED ? "block" : "hidden",
"w-full h-full select-none bg-nb-gray-950",
)}
style={{ imageRendering: "pixelated" }}
/>
</>
);
}
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { PageNotFound } from "@components/ui/PageNotFound";
import useFetchApi, { ErrorResponse } from "@utils/api";
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
import React, { useEffect, useRef } from "react";
import type { Peer } from "@/interfaces/Peer";
import { Terminal } from "@/modules/remote-access/ssh/Terminal";
import { SSHStatus, useSSH } from "@/modules/remote-access/ssh/useSSH";
import { useSSHQueryParams } from "@/modules/remote-access/ssh/useSSHQueryParams";
import {
NetBirdStatus,
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
export default function SSHPage() {
const { peerId, username, port } = useSSHQueryParams();
const {
data: peer,
isLoading,
error,
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
if (error) {
return (
<div className={"w-screen h-screen overflow-hidden"}>
<ErrorMessage
error={{
message:
"This peer may have been deleted, or you may not have permission to view it.",
code: error.code,
}}
/>
</div>
);
}
return (
<div className={"w-screen h-screen overflow-hidden"}>
{peerId && peer && !isLoading && username && port ? (
<SSHTerminal
key={peer.id}
peer={peer}
username={username}
port={port}
/>
) : (
<LoadingMessage message={"Starting ssh session..."} />
)}
</div>
);
}
type Props = {
username: string;
port: string;
peer: Peer;
};
function SSHTerminal({ username, port, peer }: Props) {
const client = useNetBirdClient();
const connected = useRef(false);
const sshConnectedOnce = useRef(false);
const {
connect: ssh,
disconnect,
status,
session,
error: sshError,
} = useSSH(client);
const isSSHConnecting = status === SSHStatus.CONNECTING;
const isSSHConnected = status === SSHStatus.CONNECTED;
const isSSHDisconnected = status === SSHStatus.DISCONNECTED;
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
useEffect(() => {
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
}, [username, peer, client]);
const handleReconnect = async () => {
if (!peer?.id) return;
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const rules = [`tcp/${port}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
port: Number(port),
username,
});
} catch (error) {
console.error("Reconnection failed:", error);
}
};
useEffect(() => {
if (isSSHConnected || isSSHConnecting) return;
if (isClientConnecting || client.status === NetBirdStatus.CONNECTED) return;
const connect = async () => {
if (!peer.id) return;
if (connected.current) return;
connected.current = true;
try {
const rules = [`tcp/${port}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,
port: Number(port),
username,
});
if (res === SSHStatus.CONNECTED) {
sshConnectedOnce.current = true;
}
} catch (error) {
console.error("Connection failed:", error);
}
};
if (isClientDisconnected) connect().catch(console.error);
}, [
isClientDisconnected,
isSSHConnected,
isSSHConnecting,
isClientConnecting,
peer.id,
port,
ssh,
username,
client.connectTemporary,
client.status,
]);
if (client.error) {
return <ErrorMessage error={{ message: client.error, code: 0 }} />;
}
if (sshError) {
return <ErrorMessage error={{ message: sshError, code: 0 }} />;
}
if (isSSHDisconnected && sshConnectedOnce.current) {
return (
<DisconnectedMessage
username={username}
peerIp={peer.ip}
onReconnect={handleReconnect}
/>
);
}
return (
<>
{session && <Terminal session={session} onClose={disconnect} />}
{!isSSHConnected && (
<LoadingMessage message={`Connecting to ${username}@${peer.ip}...`} />
)}
</>
);
}
type MessageProps = {
message?: string;
error?: ErrorResponse;
};
const LoadingMessage = ({ message }: MessageProps) => {
return (
<div
className={
"w-full h-full flex items-center justify-center flex-col text-center"
}
>
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
<Loader2Icon size={16} className={"animate-spin shrink-0"} />
{message}
</div>
</div>
);
};
const ErrorMessage = ({ error }: MessageProps) => {
return (
<div
className={
"w-full h-full flex items-center justify-center flex-col text-center"
}
>
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
<CircleXIcon size={16} className={"shrink-0 text-red-500"} />
{error?.message}
</div>
</div>
);
};
type DisconnectedMessageProps = {
username: string;
peerIp: string;
onReconnect: () => void;
};
const DisconnectedMessage = ({
username,
peerIp,
onReconnect,
}: DisconnectedMessageProps) => {
return (
<div
className={
"w-full h-full flex items-center justify-center flex-col text-center gap-4"
}
>
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
<InfoIcon size={16} className={"shrink-0 text-nb-gray-200"} />
Disconnected from {username}@{peerIp}
<button
className={
"underline-offset-4 items-center transition-all duration-200 inline-flex texts-inherit gap-1 text-netbird hover:underline font-normal"
}
onClick={onReconnect}
>
Reconnect
</button>
</div>
</div>
);
};
+11
View File
@@ -157,3 +157,14 @@ p {
.animate-bg-scroll-faster {
animation: bg-scroll 1.8s linear infinite;
}
/**
* Terminal (xterm)
*/
.xterm {
@apply m-0 p-1 box-border h-full w-full;
}
.xterm-viewport {
@apply m-0 p-0 box-border;
}
+2
View File
@@ -58,6 +58,8 @@ export default function OIDCProvider({ children }: Props) {
"utm_content",
"utm_campaign",
"hs_id",
"user",
"port",
];
try {
+1 -1
View File
@@ -18,7 +18,7 @@ export const buttonVariants = cva(
"relative",
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm",
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
"disabled:opacity-20 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
],
{
variants: {
+1 -1
View File
@@ -26,7 +26,7 @@ export const calloutVariants = cva(
export const Callout = ({
children,
icon = <InfoIcon size={14} className={"shrink-0 relative top-[2px]"} />,
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
className,
variant = "default",
}: Props) => {
+221 -55
View File
@@ -43,6 +43,13 @@ import type { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
const groupsSearchPredicate = (item: Group, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item?.id?.toLowerCase().includes(lowerCaseQuery) ?? false;
};
interface MultiSelectProps {
values: Group[];
@@ -60,6 +67,7 @@ interface MultiSelectProps {
dataCy?: string;
showResourceCounter?: boolean;
showResources?: boolean;
showPeers?: boolean;
resource?: PolicyRuleResource;
onResourceChange?: (resource?: PolicyRuleResource) => void;
placeholder?: string;
@@ -67,6 +75,7 @@ interface MultiSelectProps {
align?: "start" | "end";
side?: "top" | "bottom";
users?: User[];
placeholderForSearch?: string;
}
export function PeerGroupSelector({
onChange,
@@ -84,6 +93,7 @@ export function PeerGroupSelector({
dataCy = "group-selector-dropdown",
showResourceCounter = true,
showResources = false,
showPeers = false,
resource,
onResourceChange,
placeholder = "Add or select group(s)...",
@@ -91,16 +101,35 @@ export function PeerGroupSelector({
align = "start",
side = "bottom",
users,
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
}: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
useGroups();
const searchRef = React.useRef<HTMLInputElement>(null);
const [inputRef, { width }] = useElementSize<
HTMLButtonElement | HTMLSpanElement
>();
const [search, setSearch] = useState("");
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
"/networks/resources",
const [open, setOpen] = useState(false);
const sortedDropdownOptions = useSortedDropdownOptions(
dropdownOptions,
values,
open,
);
const [filteredGroups, search, setSearch] = useSearch(
sortedDropdownOptions,
groupsSearchPredicate,
{ filter: true, debounce: 150 },
);
// Update dropdown options when groups change
@@ -189,16 +218,6 @@ export function PeerGroupSelector({
return isSearching && groupDoesNotExist && !isAllGroup;
}, [search, dropdownOptions]);
const [open, setOpen] = useState(false);
const folderIcon = useMemo(() => {
return <FolderGit2 size={12} className={"shrink-0"} />;
}, []);
const peerIcon = useMemo(() => {
return <MonitorSmartphoneIcon size={14} className={"shrink-0"} />;
}, []);
const [slice, setSlice] = useState(10);
const [tab, setTab] = useState("groups");
@@ -219,12 +238,6 @@ export function PeerGroupSelector({
onChange(union);
};
const sortedDropdownOptions = useSortedDropdownOptions(
dropdownOptions,
values,
open,
);
// Reset the search input when switching tabs
useEffect(() => {
setSearch("");
@@ -233,10 +246,12 @@ export function PeerGroupSelector({
}, 0);
}, [tab]);
const searchPlaceholder =
tab === "groups"
? 'Search groups or add new group by pressing "Enter"...'
: "Search resource...";
const searchPlaceholder = useMemo(() => {
if (tab === "groups") return placeholderForSearch;
if (tab === "resources") return "Search resource...";
if (tab === "peers") return "Search peer...";
return "Search...";
}, [tab, placeholderForSearch]);
const selectResource = (resource?: NetworkResource) => {
onResourceChange?.(
@@ -250,6 +265,15 @@ export function PeerGroupSelector({
onChange([]);
};
const selectPeer = (peer?: Peer) => {
if (!peer?.id) return;
onResourceChange?.({
id: peer.id,
type: "peer",
});
onChange([]);
};
return (
<Popover
open={open}
@@ -288,6 +312,7 @@ export function PeerGroupSelector({
<ResourceBadge
className={"py-[3px]"}
resource={resources?.find((r) => r.id === resource.id)}
peer={peers?.find((p) => p.id === resource.id)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -364,16 +389,7 @@ export function PeerGroupSelector({
side={side}
sideOffset={10}
>
<Command
className={"w-full flex"}
loop
filter={(value, search) => {
const formatValue = trim(value.toLowerCase());
const formatSearch = trim(search.toLowerCase());
if (formatValue.includes(formatSearch)) return 1;
return 0;
}}
>
<Command className={"w-full flex"} loop shouldFilter={false}>
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
@@ -414,13 +430,17 @@ export function PeerGroupSelector({
</div>
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
{showResources && <TabTriggers searchRef={searchRef} />}
<TabTriggers
searchRef={searchRef}
showPeers={showPeers}
showResources={showResources}
/>
<TabsContent value={"groups"} className={"p-0 my-0"}>
<CommandGroup>
<ScrollArea
className={cn(
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
sortedDropdownOptions.length == 0 && !search && "py-0",
filteredGroups.length == 0 && !search && "py-0",
)}
>
{searchedGroupNotFound && (
@@ -433,8 +453,8 @@ export function PeerGroupSelector({
value={search}
onClick={(e) => e.preventDefault()}
>
<Badge variant={"gray-ghost"}>
{folderIcon}
<Badge variant={"gray-ghost"} className={"h-7"}>
<FolderGit2 size={12} className={"shrink-0"} />
{search}
</Badge>
<div
@@ -448,7 +468,7 @@ export function PeerGroupSelector({
</CommandItem>
)}
{sortedDropdownOptions.slice(0, slice).map((option) => {
{filteredGroups.slice(0, slice).map((option) => {
const isSelected =
values.find((group) => group.name == option.name) !=
undefined;
@@ -490,7 +510,11 @@ export function PeerGroupSelector({
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<GroupBadge group={option} showNewBadge={true} />
<GroupBadge
group={option}
showNewBadge={true}
className={"h-7"}
/>
</div>
<div className={"flex items-center gap-5"}>
@@ -509,7 +533,10 @@ export function PeerGroupSelector({
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{peerIcon}
<MonitorSmartphoneIcon
size={14}
className={"shrink-0"}
/>
{peerCount} Peer(s)
</div>
) : (
@@ -535,12 +562,23 @@ export function PeerGroupSelector({
<ResourcesList
search={search}
resources={resources}
isLoading={isLoading}
isLoading={isResourcesLoading}
value={resource}
onChange={selectResource}
/>
</TabsContent>
)}
{showPeers && (
<TabsContent value={"peers"} className={"p-0 my-0"}>
<PeersList
search={search}
peers={peers}
isLoading={isPeersLoading}
value={resource}
onChange={selectPeer}
/>
</TabsContent>
)}
</Tabs>
</CommandList>
</Command>
@@ -551,9 +589,14 @@ export function PeerGroupSelector({
const TabTriggers = ({
searchRef,
showResources = false,
showPeers = false,
}: {
searchRef: React.MutableRefObject<HTMLInputElement | null>;
showResources?: boolean;
showPeers?: boolean;
}) => {
if (!showResources && !showPeers) return null;
return (
<TabsList justify={"start"} className={"px-3"}>
<TabsTrigger
@@ -569,19 +612,38 @@ const TabTriggers = ({
/>
Groups
</TabsTrigger>
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resource
</TabsTrigger>
{showResources && (
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
)}
{showPeers && (
<TabsTrigger
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
)}
</TabsList>
);
};
@@ -700,7 +762,7 @@ const ResourcesList = ({
useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap")}
className={cn("transition-all group whitespace-nowrap h-7")}
onClick={(e) => {
e.preventDefault();
}}
@@ -736,3 +798,107 @@ const ResourcesList = ({
</Radio>
);
};
const peersSearchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().includes(lowerCaseQuery);
};
const PeersList = ({
search,
peers,
isLoading,
value,
onChange,
}: {
search: string;
peers?: Peer[];
isLoading: boolean;
value?: PolicyRuleResource;
onChange: (peer: Peer) => void;
}) => {
const [filteredItems, _, setSearch] = useSearch(
peers || [],
peersSearchPredicate,
{ filter: true, debounce: 150 },
);
useEffect(() => {
setSearch(search);
}, [search, setSearch]);
if (isLoading) {
return (
<div className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}>
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
</div>
);
}
if (search != "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no peers matching your search. Please try a different search
term.
</DropdownInfoText>
);
}
if (search == "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no peers available yet. <br />
Go to <InlineLink href={"/peers"}>Peers</InlineLink> to add some peers.
</DropdownInfoText>
);
}
return (
<Radio defaultValue={value?.id} name={"peer"} value={value?.id}>
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
if (!res?.id) return;
return (
<Fragment key={res.id}>
<div className={"flex items-center gap-2"}>
<Badge
useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn(
"transition-all group whitespace-nowrap h-7 px-2",
)}
onClick={(e) => {
e.preventDefault();
}}
>
<PeerOperatingSystemIcon os={res.os} />
<TextWithTooltip text={res?.name || ""} maxChars={20} />
</Badge>
</div>
<div className={"flex items-center gap-5"}>
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{res.ip}
<RadioItem value={res.id} />
</div>
</div>
</Fragment>
);
}}
/>
</Radio>
);
};
+5 -16
View File
@@ -4,7 +4,6 @@ import FullTooltip from "@components/FullTooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { useSearch } from "@hooks/useSearch";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
@@ -16,7 +15,7 @@ import { memo, useEffect, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { OSLogo } from "@/modules/peers/PeerOSCell";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
const MapPinIcon = memo(() => <MapPin size={12} />);
MapPinIcon.displayName = "MapPinIcon";
@@ -182,7 +181,6 @@ export function PeerSelector({
togglePeer(item);
}}
renderItem={(option) => {
const os = getOperatingSystem(option.os);
const isSupported = isRoutingPeerSupported(
option.version,
option.os,
@@ -210,19 +208,10 @@ export function PeerSelector({
: "text-nb-gray-300",
)}
>
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
os === OperatingSystem.WINDOWS && "p-[2.5px]",
os === OperatingSystem.APPLE && "p-[2.7px]",
os === OperatingSystem.FREEBSD && "p-[1.5px]",
!isSupported && "opacity-50",
)}
>
<OSLogo os={option.os} />
</div>
<PeerOperatingSystemIcon
os={option.os}
className={isSupported ? "" : "opacity-50"}
/>
<div className={cn(!isSupported && "opacity-50")}>
<TextWithTooltip
text={option.name}
+10 -2
View File
@@ -14,6 +14,7 @@ type Props<T extends { id?: string }> = {
renderHeading?: (item: T) => React.ReactNode;
renderBeforeItem?: (item: T) => React.ReactNode;
itemClassName?: string;
itemClassNameWithItem?: (item: T) => string;
itemWrapperClassName?: string;
scrollAreaClassName?: string;
maxHeight?: number;
@@ -21,6 +22,7 @@ type Props<T extends { id?: string }> = {
estimatedHeadingHeight?: number;
heightAdjustment?: number;
groupKey?: (item: T) => string | undefined;
itemKey?: (item: T) => string;
};
export function VirtualScrollAreaList<T extends { id?: string }>({
@@ -30,6 +32,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
renderBeforeItem,
renderHeading,
itemClassName,
itemClassNameWithItem,
itemWrapperClassName,
scrollAreaClassName,
maxHeight,
@@ -37,6 +40,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
estimatedHeadingHeight = 16,
heightAdjustment = 8,
groupKey,
itemKey,
}: Readonly<Props<T>>) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">(
@@ -159,10 +163,14 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
setSelected(index);
}
}}
id={option.id}
id={itemKey ? itemKey(option) : option?.id}
onClick={() => onClick(option)}
ariaSelected={selected === index}
itemClassName={itemClassName}
itemClassName={
itemClassNameWithItem
? itemClassNameWithItem(option)
: itemClassName
}
className={itemWrapperClassName}
isLast={index === items.length - 1}
>
@@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) {
}}
>
<Button
className={"h-[42px]"}
className={"h-[44px]"}
variant={"secondary"}
disabled={isDisabled == true ? true : disabled}
>
@@ -36,7 +36,7 @@ export default function DataTableResetFilterButton<TData>({
}}
>
<Button
className={"h-[42px]"}
className={"h-[44px]"}
variant={"secondary"}
onClick={onClick}
>
Loaded 20 of 68 files, more files were not shown because too many files have changed in this diff. Show more