Compare commits

..
Author SHA1 Message Date
Viktor Liu e8c6d44452 Add transparent proxy inspection policies 2026-04-18 13:13:18 +02:00
raghvendra b53802a5c5 fix: prevent storage clear and logout on failed account deletion (#611) 2026-04-13 09:07:14 +02:00
Eduard Gert 9addc18956 Fix reverse proxy mode selection (#606)
* Fix reverse proxy mode selection

* Fix isNetBirdHosted

* Fix activity description
2026-04-09 09:52:35 +02:00
85 changed files with 3702 additions and 3821 deletions

No files matched your search

+1 -4
View File
@@ -5,9 +5,6 @@ const nextConfig = {
unoptimized: true,
},
reactStrictMode: false,
experimental: {
externalDir: true,
},
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
@@ -15,4 +12,4 @@ const nextConfig = {
},
};
module.exports = nextConfig;
module.exports = nextConfig;
+62 -2378
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -18,7 +18,6 @@
"dependencies": {
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@netbirdio/explain": "^0.1.5",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -50,7 +49,7 @@
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"commander": "^14.0.3","chart.js": "^4.4.8",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
@@ -67,9 +66,10 @@
"ip-address": "^10.1.0",
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"js-yaml": "^4.1.1",
"lodash": "^4.17.23",
"lucide-react": "^0.566.0",
"next": "^16.1.7",
"next": "16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
@@ -81,11 +81,8 @@
"react-icons": "^5.5.0",
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-markdown": "^10.1.0",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
@@ -93,10 +90,14 @@
"timescape": "^0.7.1",
"typescript": "^5"
},
"overrides": {
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@types/chroma-js": "^3.1.1",
"@types/js-cookie": "^3.0.6",
"@types/js-yaml": "^4.0.9",
"eslint": "^9.39.1",
"eslint-config-next": "^16.1.6",
"postcss": "^8",
@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Inspection Policies - ${globalMetaTitle}`,
};
export default BlankLayout;
@@ -0,0 +1,61 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon, ShieldCheck } from "lucide-react";
import React, { lazy, Suspense } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PoliciesProvider from "@/contexts/PoliciesProvider";
import { InspectionPolicy } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
const InspectionPoliciesTable = lazy(
() =>
import(
"@/modules/networks/inspection/InspectionPoliciesTable"
),
);
export default function InspectionPoliciesPage() {
const { permission } = usePermissions();
const { data: policies, isLoading } =
useFetchApi<InspectionPolicy[]>("/inspection-policies");
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/access-control"}
label={"Access Control"}
icon={<AccessControlIcon size={14} />}
/>
<Breadcrumbs.Item
href={"/access-control/inspection-policies"}
label={"Inspection Policies"}
icon={<ShieldCheck size={15} />}
/>
</Breadcrumbs>
<h1>Inspection Policies</h1>
<Paragraph>
Create reusable inspection rule sets for transparent proxy traffic
inspection. Attach them to access control policies to inspect traffic
flowing through routing peers.
</Paragraph>
</div>
<PoliciesProvider>
<Suspense fallback={<SkeletonTable />}>
<InspectionPoliciesTable
policies={policies ?? []}
isLoading={isLoading}
/>
</Suspense>
</PoliciesProvider>
</PageContainer>
);
}
+1 -9
View File
@@ -2,7 +2,6 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import ExplainButton from "@components/ExplainButton";
import Card from "@components/Card";
import {
DropdownMenu,
@@ -100,7 +99,6 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
<PageContainer>
<NetworkAccessControlProvider>
<NetworkProvider network={network}>
<div>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
@@ -133,10 +131,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
description={network.description}
/>
</div>
<div className="flex items-center gap-2">
<ExplainButton />
<NetworkActions />
</div>
<NetworkActions />
</div>
</div>
@@ -197,7 +192,6 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
/>
</TabsContent>
</Tabs>
</div>
</NetworkProvider>
</NetworkAccessControlProvider>
</PageContainer>
@@ -288,7 +282,6 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
<Card.List>
<Card.ListItem
tooltip={false}
data-nb-explain="High Availability"
label={
<>
<ServerIcon size={16} />
@@ -334,7 +327,6 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
/>
<Card.ListItem
tooltip={false}
data-nb-explain="Active Policies"
label={
policyCount > 0 ? (
<>
+92 -108
View File
@@ -2,7 +2,6 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
@@ -72,6 +71,7 @@ import ReverseProxiesProvider, {
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
@@ -469,31 +469,55 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
const { update } = usePeer();
const { mutate } = useSWRConfig();
const [showEditIPModal, setShowEditIPModal] = useState(false);
const [showEditIPv6Modal, setShowEditIPv6Modal] = useState(false);
const { permission } = usePermissions();
const countryText = useMemo(() => {
return getRegionByPeer(peer);
}, [getRegionByPeer, peer]);
const handleSaveIP = (newIP: string) => {
notify({
title: peer.name,
description: "NetBird Peer IP was successfully updated",
promise: update({ ip: newIP }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPModal(false);
}),
loadingMessage: "Updating peer IP...",
});
};
const handleSaveIPv6 = (newIPv6: string) => {
notify({
title: peer.name,
description: "NetBird Peer IPv6 was successfully updated",
promise: update({ ipv6: newIPv6 }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPv6Modal(false);
}),
loadingMessage: "Updating peer IPv6...",
});
};
return (
<>
<Modal open={showEditIPModal} onOpenChange={setShowEditIPModal}>
<EditIPModal
onSuccess={(newIP) => {
notify({
title: peer.name,
description: "Peer IP was successfully updated",
promise: update({ ip: newIP }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPModal(false);
}),
loadingMessage: "Updating peer IP...",
});
}}
peer={peer}
key={showEditIPModal ? 1 : 0}
/>
</Modal>
<PeerEditIPModal
version="v4"
currentIP={peer.ip}
open={showEditIPModal}
onOpenChange={setShowEditIPModal}
onSave={handleSaveIP}
key={showEditIPModal ? "v4-open" : "v4-closed"}
/>
<PeerEditIPModal
version="v6"
currentIP={peer.ipv6 || ""}
open={showEditIPv6Modal}
onOpenChange={setShowEditIPv6Modal}
onSave={handleSaveIPv6}
key={showEditIPv6Modal ? "v6-open" : "v6-closed"}
/>
<Card className={"w-full xl:w-1/2"}>
<Card.List>
<Card.ListItem
@@ -508,23 +532,36 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
}
valueToCopy={peer.ip}
value={
<div className="flex items-center gap-2 justify-between w-full">
<span>{peer.ip}</span>
{permission.peers.update && (
<button
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setShowEditIPModal(true);
}}
>
<PencilIcon size={14} />
</button>
)}
</div>
<EditableValue
value={peer.ip}
canEdit={permission.peers.update}
onEdit={() => setShowEditIPModal(true)}
/>
}
/>
{peer.ipv6 && (
<Card.ListItem
copy
tooltip={false}
copyText={"NetBird IPv6 Address"}
label={
<>
<MapPin size={16} />
NetBird IPv6 Address
</>
}
valueToCopy={peer.ipv6}
value={
<EditableValue
value={peer.ipv6}
canEdit={permission.peers.update}
onEdit={() => setShowEditIPv6Modal(true)}
/>
}
/>
)}
<Card.ListItem
copy
copyText={"Public IP Address"}
@@ -765,82 +802,29 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
);
}
interface EditIPModalProps {
onSuccess: (ip: string) => void;
peer: Peer;
}
function EditIPModal({ onSuccess, peer }: Readonly<EditIPModalProps>) {
const [ip, setIP] = useState(peer.ip);
const [error, setError] = useState("");
const validateIP = (ipAddress: string) => {
const ipRegex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipRegex.test(ipAddress);
};
const isDisabled = useMemo(() => {
if (ip === peer.ip) return true;
const trimmedIP = trim(ip);
return trimmedIP.length === 0 || !validateIP(ip);
}, [ip, peer.ip]);
React.useEffect(() => {
switch (true) {
case ip === peer.ip:
setError("");
break;
case !validateIP(ip):
setError("Please enter a valid IP, e.g., 100.64.0.15");
break;
default:
setError("");
break;
}
}, [ip, peer.ip]);
function EditableValue({
value,
canEdit,
onEdit,
}: {
value: string;
canEdit: boolean;
onEdit: () => void;
}) {
return (
<ModalContent maxWidthClass={"max-w-md"}>
<form>
<ModalHeader
title={"Edit Peer IP Address"}
description={"Update the NetBird IP address for this peer."}
color={"blue"}
/>
<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
placeholder={"e.g., 100.64.0.15"}
value={ip}
onChange={(e) => setIP(e.target.value)}
error={error}
/>
</div>
<Callout>Changes take effect when the peer reconnects.</Callout>
</div>
<ModalFooter className={"items-center"} separator={false}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => onSuccess(ip)}
disabled={isDisabled}
>
Save
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
<div className="flex items-center gap-2 justify-between w-full">
<span>{value}</span>
{canEdit && (
<button
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<PencilIcon size={14} />
</button>
)}
</div>
);
}
+13 -6
View File
@@ -18,7 +18,7 @@ import {
} from "@utils/version";
export default function SSHPage() {
const { peerId, username, port } = useSSHQueryParams();
const { peerId, username, port, ipVersion } = useSSHQueryParams();
const {
data: peer,
@@ -48,6 +48,7 @@ export default function SSHPage() {
peer={peer}
username={username}
port={port}
ipVersion={ipVersion}
/>
) : (
<LoadingMessage message={"Starting ssh session..."} />
@@ -60,9 +61,10 @@ type Props = {
username: string;
port: string;
peer: Peer;
ipVersion: string | null;
};
function SSHTerminal({ username, port, peer }: Props) {
function SSHTerminal({ username, port, peer, ipVersion }: Props) {
const client = useNetBirdClient();
const connected = useRef(false);
const sshConnectedOnce = useRef(false);
@@ -81,9 +83,12 @@ function SSHTerminal({ username, port, peer }: Props) {
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
// Use the FQDN when an IP version is specified so the dialer resolves to the correct address family.
const sshHost = ipVersion ? peer.dns_label || peer.ip : peer.ip;
useEffect(() => {
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
}, [username, peer, client]);
document.title = `${username}@${sshHost} - ${peer.hostname}`;
}, [username, peer, client, sshHost]);
const handleReconnect = async () => {
if (!peer?.id) return;
@@ -97,9 +102,10 @@ function SSHTerminal({ username, port, peer }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
} catch (error) {
console.error("Reconnection failed:", error);
@@ -123,9 +129,10 @@ function SSHTerminal({ username, port, peer }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
if (res === SSHStatus.CONNECTED) {
sshConnectedOnce.current = true;
-195
View File
@@ -200,198 +200,3 @@ p {
.react-flow__node-groupNode .selected{
@apply border-netbird;
}
/* @netbirdio/explain theme overrides — match dashboard palette */
html:root {
--nb-explain-bg: #1c1e21;
--nb-explain-bg-subtle: #25282d;
--nb-explain-bg-hover: #2e3238;
--nb-explain-border: #363b40;
--nb-explain-text: #e4e7e9;
--nb-explain-text-muted: #a3adb5;
--nb-explain-text-dim: #7c8994;
--nb-explain-accent: #f68330;
--nb-explain-accent-hover: #f46d1b;
--nb-explain-accent-glow: rgba(246, 131, 48, 0.15);
--nb-explain-user-bg: #f68330;
--nb-explain-user-text: #ffffff;
--nb-explain-user-glow: rgba(246, 131, 48, 0.25);
--nb-explain-radius: 8px;
--nb-explain-radius-sm: 6px;
--nb-explain-radius-xs: 4px;
--nb-explain-font: inherit;
--nb-explain-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6), 0 0 0 1px #363b40;
--nb-explain-banner-bg: rgba(246, 131, 48, 0.92);
--nb-explain-banner-text: #ffffff;
--nb-explain-error-text: #F98080;
}
/* Override explain highlight outline to match netbird orange */
[data-nb-explain-highlight] {
outline: 1px solid rgba(246, 131, 48, 0.7) !important;
}
/* Markdown body — Dify-style rendering for AI chat messages */
.nb-markdown-body {
font-size: 0.875rem;
line-height: 1.6;
color: var(--nb-explain-text, #e4e7e9);
word-wrap: break-word;
overflow-wrap: break-word;
}
.nb-markdown-body > *:first-child {
margin-top: 0;
}
.nb-markdown-body > *:last-child {
margin-bottom: 0;
}
.nb-markdown-body p {
margin: 0.4em 0;
font-weight: 400;
letter-spacing: normal;
color: inherit;
font-size: inherit;
}
.nb-markdown-body strong {
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
}
.nb-markdown-body em {
font-style: italic;
}
/* Headings */
.nb-markdown-body h1 {
font-size: 1.25em;
font-weight: 600;
margin: 0.6em 0 0.3em;
color: rgba(255, 255, 255, 0.95);
}
.nb-markdown-body h2 {
font-size: 1.15em;
font-weight: 600;
margin: 0.5em 0 0.3em;
color: rgba(255, 255, 255, 0.95);
}
.nb-markdown-body h3 {
font-size: 1.05em;
font-weight: 600;
margin: 0.4em 0 0.2em;
color: rgba(255, 255, 255, 0.95);
}
/* Lists */
.nb-markdown-body ul {
margin: 0.4em 0;
padding-left: 1.4em;
list-style-type: disc;
}
.nb-markdown-body ol {
margin: 0.4em 0;
padding-left: 1.4em;
list-style-type: decimal;
}
.nb-markdown-body li {
margin: 0.15em 0;
}
.nb-markdown-body li > p {
margin: 0.1em 0;
}
/* Task lists (GFM) */
.nb-markdown-body li input[type="checkbox"] {
margin-right: 0.4em;
vertical-align: middle;
}
/* Inline code */
.nb-markdown-body code {
background: rgba(0, 0, 0, 0.25);
border-radius: 4px;
padding: 0.15em 0.4em;
font-size: 0.88em;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
/* Code blocks */
.nb-markdown-body pre {
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 0.7em 0.9em;
margin: 0.5em 0;
overflow-x: auto;
font-size: 0.85em;
}
.nb-markdown-body pre code {
background: none;
border-radius: 0;
padding: 0;
font-size: inherit;
}
/* Blockquotes */
.nb-markdown-body blockquote {
margin: 0.5em 0;
padding: 0.3em 0.8em;
border-left: 3px solid var(--nb-explain-accent, #f68330);
color: var(--nb-explain-text-muted, #a3adb5);
background: rgba(0, 0, 0, 0.15);
border-radius: 0 4px 4px 0;
}
.nb-markdown-body blockquote p {
margin: 0.2em 0;
}
/* Links */
.nb-markdown-body a {
color: var(--nb-explain-accent, #f68330);
text-decoration: underline;
text-underline-offset: 2px;
}
.nb-markdown-body a:hover {
opacity: 0.85;
}
/* Horizontal rules */
.nb-markdown-body hr {
border: none;
border-top: 1px solid var(--nb-explain-border, #363b40);
margin: 0.8em 0;
}
/* Tables (GFM) */
.nb-markdown-body table {
border-collapse: collapse;
margin: 0.5em 0;
width: 100%;
font-size: 0.88em;
}
.nb-markdown-body th,
.nb-markdown-body td {
border: 1px solid var(--nb-explain-border, #363b40);
padding: 0.35em 0.6em;
text-align: left;
}
.nb-markdown-body th {
background: rgba(0, 0, 0, 0.2);
font-weight: 600;
}
.nb-markdown-body tr:nth-child(even) {
background: rgba(0, 0, 0, 0.08);
}
+2
View File
@@ -23,6 +23,7 @@ const variants = cva("", {
purple: ["bg-purple-950/50 border-purple-500 border text-purple-500"],
yellow: ["bg-yellow-950 border-yellow-500 border text-yellow-400"],
gray: ["bg-nb-gray-930/60 border-nb-gray-800/40 text-nb-gray-300 border"],
lightGray: ["bg-nb-gray-910 text-nb-gray-200 border border-nb-gray-900"],
grayer: [
"bg-nb-gray-900/40 border-nb-gray-800/40 text-nb-gray-300 border",
],
@@ -45,6 +46,7 @@ const variants = cva("", {
"blue-darker": ["hover:bg-sky-800"],
red: ["hover:bg-red-950/40"],
gray: ["hover:bg-nb-gray-900"],
lightGray: ["hover:bg-nb-gray-900"],
grayer: ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
green: ["hover:bg-green-950/50"],
+7 -2
View File
@@ -28,8 +28,13 @@ export const DeviceCard = ({
const descriptionText = useMemo(() => {
return description !== undefined
? description
: address || device?.ip || resource?.address;
}, [description, address, device]);
: address ||
(device?.ip
? device.ipv6
? `${device.ip}, ${device.ipv6}`
: device.ip
: resource?.address);
}, [description, address, device, resource]);
return (
<div
+7 -3
View File
@@ -8,7 +8,7 @@ import React from "react";
export const fancyToggleSwitchVariants = cva([], {
variants: {
variant: {
default: ["px-6 py-4 border rounded-md"],
default: ["px-5 py-4 border rounded-md"],
blank: null,
},
state: {
@@ -45,6 +45,8 @@ interface Props extends FancyToggleSwitchVariants {
disabled?: boolean;
dataCy?: string;
className?: string;
labelClassName?: string;
textWrapperClassName?: string;
}
export default function FancyToggleSwitch({
@@ -57,6 +59,8 @@ export default function FancyToggleSwitch({
dataCy,
className,
variant = "default",
labelClassName,
textWrapperClassName = "max-w-sm",
}: Readonly<Props>) {
const handleToggle = () => {
if (disabled) return;
@@ -87,8 +91,8 @@ export default function FancyToggleSwitch({
)}
>
<div className={"flex justify-between gap-10"}>
<div className={"max-w-sm"}>
<Label>{label}</Label>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
</div>
<div className={"mt-2 pr-1"}>
+3 -1
View File
@@ -968,7 +968,8 @@ const ResourcesList = ({
const peersSearchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().includes(lowerCaseQuery);
if (item.ip.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ipv6?.toLowerCase().includes(lowerCaseQuery) ?? false;
};
const PeersList = ({
@@ -1059,6 +1060,7 @@ const PeersList = ({
}
>
{res.ip}
{res.ipv6 && `, ${res.ipv6}`}
<RadioItem value={res.id} />
</div>
</div>
+4 -1
View File
@@ -30,7 +30,8 @@ const searchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().startsWith(lowerCaseQuery);
if (item.ip.toLowerCase().startsWith(lowerCaseQuery)) return true;
return !!item.ipv6?.toLowerCase().startsWith(lowerCaseQuery);
};
export function PeerSelector({
@@ -126,6 +127,7 @@ export function PeerSelector({
>
<MapPinIcon />
{value.ip}
{value.ipv6 && `, ${value.ipv6}`}
</div>
</div>
) : (
@@ -240,6 +242,7 @@ export function PeerSelector({
>
<MapPinIcon />
{option.ip}
{option.ipv6 && `, ${option.ipv6}`}
</div>
</FullTooltip>
);
@@ -1,253 +0,0 @@
"use client";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import AIChatBot from "./AIChatBot";
import AIFloatingButton from "./AIFloatingButton";
type ExplainContext = {
modalName: string;
pageName: string;
docsUrl: string;
};
type AIAssistantContextType = {
openChat: (selectedText?: string) => void;
closeChat: () => void;
isChatOpen: boolean;
explainMode: boolean;
enterExplainMode: () => void;
exitExplainMode: () => void;
setExplainContext: (ctx: ExplainContext) => void;
clearExplainContext: () => void;
};
const AIAssistantContext = createContext<AIAssistantContextType>({
openChat: () => {},
closeChat: () => {},
isChatOpen: false,
explainMode: false,
enterExplainMode: () => {},
exitExplainMode: () => {},
setExplainContext: () => {},
clearExplainContext: () => {},
});
export const useAIAssistant = () => useContext(AIAssistantContext);
/**
* Find the closest ancestor (or self) with a data-explain attribute.
* Returns null if nothing is explainable.
*/
function findExplainable(el: HTMLElement): HTMLElement | null {
return el.closest("[data-explain]") as HTMLElement | null;
}
/**
* Extract a short label from an explainable element by looking for
* a label, heading, or first bit of text content.
*/
function extractLabel(el: HTMLElement): string {
const label = el.querySelector("label") as HTMLElement | null;
if (label?.innerText?.trim()) return label.innerText.trim();
const heading = el.querySelector("h1, h2, h3, h4") as HTMLElement | null;
if (heading?.innerText?.trim()) return heading.innerText.trim();
const text = el.innerText?.trim();
if (text && text.length <= 80) return text;
if (text) return text.slice(0, 80) + "...";
return "this element";
}
function buildQuery(label: string, ctx: ExplainContext | null): string {
let userMessage = `Explain "${label}"`;
if (ctx) {
userMessage += ` on ${ctx.modalName} modal`;
if (ctx.pageName) userMessage += ` in ${ctx.pageName}`;
}
const parts = [userMessage];
if (ctx?.docsUrl) {
parts.push(`Docs: ${ctx.docsUrl}`);
}
return parts.join("\n");
}
export default function AIAssistantProvider({
children,
}: {
children: React.ReactNode;
}) {
const [isChatOpen, setIsChatOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState("");
const [explainMode, setExplainMode] = useState(false);
const [hoveredEl, setHoveredEl] = useState<HTMLElement | null>(null);
const [explainCtx, setExplainCtx] = useState<ExplainContext | null>(null);
const openChat = useCallback((selectedText?: string) => {
setInitialQuery(selectedText || "");
setIsChatOpen(true);
setExplainMode(false);
setHoveredEl(null);
}, []);
const closeChat = useCallback(() => {
setIsChatOpen(false);
setInitialQuery("");
}, []);
const enterExplainMode = useCallback(() => {
setExplainMode(true);
}, []);
const exitExplainMode = useCallback(() => {
setExplainMode(false);
setHoveredEl(null);
}, []);
const setExplainContext = useCallback((ctx: ExplainContext) => {
setExplainCtx(ctx);
}, []);
const clearExplainContext = useCallback(() => {
setExplainCtx(null);
}, []);
// Explain mode: highlight explainable elements on hover, open chat on click
useEffect(() => {
if (!explainMode) return;
const handleMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-explain-ignore]") || target.closest("[data-ai-banner]"))
return;
const explainable = findExplainable(target);
setHoveredEl(explainable);
};
const handleMouseOut = () => {
setHoveredEl(null);
};
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-explain-ignore]") || target.closest("[data-ai-banner]"))
return;
e.preventDefault();
e.stopPropagation();
const explainable = findExplainable(target);
if (!explainable) return;
const attrValue = explainable.getAttribute("data-explain") || "";
const label = (attrValue && attrValue !== "true") ? attrValue : extractLabel(explainable);
const query = buildQuery(label, explainCtx);
openChat(query);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
exitExplainMode();
}
};
document.addEventListener("mouseover", handleMouseOver, true);
document.addEventListener("mouseout", handleMouseOut, true);
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mouseover", handleMouseOver, true);
document.removeEventListener("mouseout", handleMouseOut, true);
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [explainMode, explainCtx, openChat, exitExplainMode]);
// Apply/remove highlight on hovered explainable element via CSS class
useEffect(() => {
if (!hoveredEl) return;
hoveredEl.classList.add("ai-explain-highlight");
return () => {
hoveredEl.classList.remove("ai-explain-highlight");
};
}, [hoveredEl]);
// Also clean up all highlights when leaving explain mode
useEffect(() => {
if (!explainMode) {
document.querySelectorAll(".ai-explain-highlight").forEach((el) => {
el.classList.remove("ai-explain-highlight");
});
}
}, [explainMode]);
return (
<AIAssistantContext.Provider
value={{
openChat,
closeChat,
isChatOpen,
explainMode,
enterExplainMode,
exitExplainMode,
setExplainContext,
clearExplainContext,
}}
>
{children}
{/* Explain mode styles + banner */}
{explainMode && (
<style>{`
.ai-explain-highlight {
outline: 2px solid rgba(234, 179, 8, 0.7) !important;
border-radius: 6px;
cursor: help !important;
transition: outline 0.1s ease;
}
`}</style>
)}
{explainMode && (
<div
data-ai-banner
className="fixed top-3 left-1/2 -translate-x-1/2 z-[9996] bg-yellow-500/90 text-black text-sm font-medium px-4 py-1.5 rounded-full shadow-lg animate-in fade-in-0 slide-in-from-top-2 duration-200 flex items-center gap-2"
>
<span>Click on a highlighted element to explain it</span>
<button
onClick={() => exitExplainMode()}
className="ml-1 text-black/60 hover:text-black underline cursor-pointer text-xs"
>
Cancel
</button>
</div>
)}
<AIFloatingButton
isOpen={isChatOpen}
onClick={() => {
if (isChatOpen) {
closeChat();
} else {
openChat();
}
}}
/>
<AIChatBot
open={isChatOpen}
onClose={closeChat}
initialQuery={initialQuery}
/>
</AIAssistantContext.Provider>
);
}
-327
View File
@@ -1,327 +0,0 @@
"use client";
import { cn } from "@utils/helpers";
import {
Bot,
MessageCircleQuestion,
Send,
Sparkles,
User,
X,
} from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
type Message = {
id: string;
role: "user" | "assistant" | "context";
content: string;
};
type Props = {
open: boolean;
onClose: () => void;
initialQuery: string;
};
const AI_SERVER_URL =
process.env.NEXT_PUBLIC_AI_SERVER_URL || "http://localhost:3080";
const AI_API_KEY =
process.env.NEXT_PUBLIC_AI_API_KEY || "nb-ai-dev-key-change-me";
async function fetchAIResponse(messages: Message[]): Promise<string> {
const response = await fetch(`${AI_SERVER_URL}/api/ai/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${AI_API_KEY}`,
},
body: JSON.stringify({
messages: messages.map((m) => ({
role: m.role,
content: m.content,
})),
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status}: ${text}`);
}
const data = await response.json();
return data.reply;
}
export default function AIChatBot({ open, onClose, initialQuery }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const hasProcessedInitialQuery = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, isTyping, scrollToBottom]);
const getAIResponse = useCallback(async (allMessages: Message[]) => {
setIsTyping(true);
try {
const reply = await fetchAIResponse(allMessages);
const response: Message = {
id: Date.now().toString(),
role: "assistant",
content: reply,
};
setMessages((prev) => [...prev, response]);
} catch (err) {
const errorMsg =
err instanceof Error ? err.message : "Unknown error";
const response: Message = {
id: Date.now().toString(),
role: "assistant",
content: `Sorry, I couldn't get a response. Error: ${errorMsg}`,
};
setMessages((prev) => [...prev, response]);
} finally {
setIsTyping(false);
}
}, []);
// Handle initial query from explain mode
useEffect(() => {
if (open && initialQuery && !hasProcessedInitialQuery.current) {
hasProcessedInitialQuery.current = true;
// Parse structured query: "Explain "X" on Y modal in Z\nDocs: ..."
const lines = initialQuery.split("\n");
const userMessage = lines[0];
const docsLine = lines.find((l) => l.startsWith("Docs: "));
const msgs: Message[] = [];
if (docsLine) {
msgs.push({
id: Date.now().toString() + "-ctx",
role: "context",
content: docsLine,
});
}
msgs.push({
id: Date.now().toString(),
role: "user",
content: userMessage,
});
setMessages(msgs);
getAIResponse(msgs);
}
}, [open, initialQuery, getAIResponse]);
// Reset when closed
useEffect(() => {
if (!open) {
hasProcessedInitialQuery.current = false;
setMessages([]);
setInput("");
setIsTyping(false);
abortControllerRef.current?.abort();
}
}, [open]);
// Focus input when opened
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 200);
}
}, [open]);
const sendMessage = useCallback(() => {
const text = input.trim();
if (!text || isTyping) return;
const userMsg: Message = {
id: Date.now().toString(),
role: "user",
content: text,
};
const updatedMessages = [...messages, userMsg];
setMessages(updatedMessages);
setInput("");
getAIResponse(updatedMessages);
}, [input, isTyping, messages, getAIResponse]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
if (!open) return null;
return (
<div
className={cn(
"fixed bottom-20 right-5 z-[9998] w-[420px] h-[600px]",
"flex flex-col rounded-xl border border-nb-gray-900 bg-nb-gray-950 shadow-2xl",
"animate-in slide-in-from-bottom-4 fade-in-0 duration-200",
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-nb-gray-900">
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-yellow-400/20 to-orange-400/20">
<Sparkles size={15} className="text-yellow-400" />
</div>
<div>
<h3 className="text-sm font-semibold text-white leading-none">
NetBird AI Assistant
</h3>
<span className="text-[11px] text-nb-gray-500">
Ask anything about NetBird
</span>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md text-nb-gray-500 hover:text-white hover:bg-nb-gray-900 transition-colors"
>
<X size={16} />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.length === 0 && !isTyping && (
<div className="flex flex-col items-center justify-center h-full text-center gap-3 opacity-60">
<MessageCircleQuestion
size={40}
className="text-nb-gray-500"
/>
<div>
<p className="text-sm text-nb-gray-400 font-medium">
How can I help?
</p>
<p className="text-xs text-nb-gray-500 mt-1">
Use the Explain button to click on any element, or ask a
question below.
</p>
</div>
</div>
)}
{messages.map((msg) =>
msg.role === "context" ? (
<div key={msg.id} className="flex justify-center">
<div className="text-[11px] text-nb-gray-500 bg-nb-gray-900/50 rounded-full px-3 py-1 flex items-center gap-1.5">
<Sparkles size={10} className="text-yellow-500/60" />
{msg.content}
</div>
</div>
) : (
<div
key={msg.id}
className={cn(
"flex gap-2.5",
msg.role === "user" ? "justify-end" : "justify-start",
)}
>
{msg.role === "assistant" && (
<div className="flex-shrink-0 w-6 h-6 rounded-md bg-gradient-to-br from-yellow-400/20 to-orange-400/20 flex items-center justify-center mt-0.5">
<Bot size={13} className="text-yellow-400" />
</div>
)}
<div
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed",
msg.role === "user"
? "bg-indigo-600 text-white"
: "bg-nb-gray-900/80 text-nb-gray-200",
)}
>
{msg.content.split("\n").map((line, i) => (
<React.Fragment key={i}>
{line
.split(/(\*\*[^*]+\*\*)/)
.map((part, j) =>
part.startsWith("**") && part.endsWith("**") ? (
<strong
key={j}
className="font-semibold text-white"
>
{part.slice(2, -2)}
</strong>
) : (
<React.Fragment key={j}>{part}</React.Fragment>
),
)}
{i < msg.content.split("\n").length - 1 && <br />}
</React.Fragment>
))}
</div>
{msg.role === "user" && (
<div className="flex-shrink-0 w-6 h-6 rounded-md bg-indigo-600/30 flex items-center justify-center mt-0.5">
<User size={13} className="text-indigo-300" />
</div>
)}
</div>
),
)}
{isTyping && (
<div className="flex gap-2.5">
<div className="flex-shrink-0 w-6 h-6 rounded-md bg-gradient-to-br from-yellow-400/20 to-orange-400/20 flex items-center justify-center mt-0.5">
<Bot size={13} className="text-yellow-400" />
</div>
<div className="bg-nb-gray-900/80 rounded-lg px-4 py-3">
<div className="flex gap-1.5">
<span className="w-1.5 h-1.5 bg-nb-gray-500 rounded-full animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 bg-nb-gray-500 rounded-full animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 bg-nb-gray-500 rounded-full animate-bounce [animation-delay:300ms]" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="px-4 py-3 border-t border-nb-gray-900">
<div className="flex items-center gap-2 bg-nb-gray-900/60 rounded-lg px-3 py-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a follow-up question..."
className="flex-1 bg-transparent text-sm text-white placeholder-nb-gray-500 outline-none"
/>
<button
onClick={sendMessage}
disabled={!input.trim() || isTyping}
className={cn(
"p-1.5 rounded-md transition-colors",
input.trim() && !isTyping
? "text-yellow-400 hover:bg-nb-gray-800 cursor-pointer"
: "text-nb-gray-600 cursor-not-allowed",
)}
>
<Send size={15} />
</button>
</div>
<p className="text-[10px] text-nb-gray-600 mt-1.5 text-center">
AI-powered assistant
</p>
</div>
</div>
);
}
@@ -1,74 +0,0 @@
"use client";
import { cn } from "@utils/helpers";
import { MessageCircleQuestion, Sparkles } from "lucide-react";
import React, { forwardRef } from "react";
type Props = {
x: number;
y: number;
selectedText: string;
onExplain: () => void;
onClose: () => void;
};
const AIContextMenu = forwardRef<HTMLDivElement, Props>(
({ x, y, selectedText, onExplain, onClose }, ref) => {
// Adjust position to stay within viewport
const adjustedX = Math.min(x, window.innerWidth - 220);
const adjustedY = Math.min(y, window.innerHeight - 100);
const truncatedText =
selectedText.length > 40
? selectedText.slice(0, 40) + "..."
: selectedText;
return (
<div
ref={ref}
className={cn(
"fixed z-[9999] min-w-[200px] rounded-lg border",
"border-nb-gray-900 bg-nb-gray-940 shadow-2xl",
"animate-in fade-in-0 zoom-in-95 duration-100",
"py-1",
)}
style={{ left: adjustedX, top: adjustedY }}
>
<div className="px-3 py-1.5 border-b border-nb-gray-900">
<div className="flex items-center gap-1.5 text-[11px] text-nb-gray-500 font-medium uppercase tracking-wider">
<Sparkles size={12} className="text-yellow-400" />
AI Assistant
</div>
</div>
<button
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 text-sm text-nb-gray-300",
"hover:bg-nb-gray-900 hover:text-white transition-colors cursor-pointer",
"outline-none",
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onExplain();
}}
>
<MessageCircleQuestion size={15} className="text-yellow-400 shrink-0" />
<div className="flex flex-col items-start gap-0.5">
<span className="font-medium">Explain</span>
<span className="text-[11px] text-nb-gray-500 leading-tight truncate max-w-[160px]">
&quot;{truncatedText}&quot;
</span>
</div>
</button>
</div>
);
},
);
AIContextMenu.displayName = "AIContextMenu";
export default AIContextMenu;
@@ -1,29 +0,0 @@
"use client";
import { cn } from "@utils/helpers";
import { MessageCircleQuestion, X } from "lucide-react";
import React from "react";
type Props = {
isOpen: boolean;
onClick: () => void;
};
export default function AIFloatingButton({ isOpen, onClick }: Props) {
return (
<button
onClick={onClick}
className={cn(
"fixed bottom-5 right-5 z-[9997] w-12 h-12 rounded-full",
"flex items-center justify-center",
"shadow-lg transition-all duration-200 cursor-pointer",
"hover:scale-105 active:scale-95",
isOpen
? "bg-nb-gray-800 text-nb-gray-400 hover:text-white"
: "bg-gradient-to-br from-yellow-500 to-orange-500 text-white hover:from-yellow-400 hover:to-orange-400",
)}
>
{isOpen ? <X size={20} /> : <MessageCircleQuestion size={22} />}
</button>
);
}
+2 -4
View File
@@ -563,8 +563,7 @@ export function DataTable<TData, TValue>({
}
}}
>
{row.getVisibleCells().map((cell) => {
return (
{row.getVisibleCells().map((cell) => (
<TableCellComponent
key={cell.id}
className={cn("relative", tableCellClassName)}
@@ -586,8 +585,7 @@ export function DataTable<TData, TValue>({
)}
</div>
</TableCellComponent>
);
})}
))}
</TableRowComponent>
{expandedRow && isExpanded && (
+3
View File
@@ -30,6 +30,7 @@ const PeerContext = React.createContext(
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
ipv6?: string;
}) => Promise<Peer>;
toggleSSH: (newState: boolean) => Promise<void>;
setSSHInstructionsModal: (open: boolean) => void;
@@ -80,6 +81,7 @@ export default function PeerProvider({
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
ipv6?: string;
}) => {
return peerRequest.put(
{
@@ -99,6 +101,7 @@ export default function PeerProvider({
? undefined
: props.approval_required,
ip: props.ip != undefined ? props.ip : undefined,
ipv6: props.ipv6 != undefined ? props.ipv6 : undefined,
},
`/${peer.id}`,
);
Loaded 20 of 85 files, more files were not shown because too many files have changed in this diff. Show more