mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42cd088c5d | ||
|
|
7400ac806e | ||
|
|
240ff5af9a | ||
|
|
dc86c30463 | ||
|
|
e58f75ae3c | ||
|
|
dc1adebd27 | ||
|
|
d76cbd1122 | ||
|
|
01330e0f58 | ||
|
|
e9ac1a1a23 | ||
|
|
b53802a5c5 | ||
|
|
9addc18956 |
No files matched your search
+1
-4
@@ -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;
|
||||
Generated
+72
-2379
File diff suppressed because it is too large.
Load diff
+5
-6
@@ -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",
|
||||
@@ -69,7 +68,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"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 +80,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,6 +89,9 @@
|
||||
"timescape": "^0.7.1",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": ">=10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
+103
-119
@@ -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
|
||||
@@ -502,35 +526,48 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
copyText={"NetBird IP Address"}
|
||||
label={
|
||||
<>
|
||||
<MapPin size={16} />
|
||||
<MapPin size={16} className={"shrink-0"} />
|
||||
NetBird IP Address
|
||||
</>
|
||||
}
|
||||
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} className={"shrink-0"} />
|
||||
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"}
|
||||
label={
|
||||
<>
|
||||
<NetworkIcon size={16} />
|
||||
<NetworkIcon size={16} className={"shrink-0"} />
|
||||
Public IP Address
|
||||
</>
|
||||
}
|
||||
@@ -542,7 +579,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
copyText={"DNS label"}
|
||||
label={
|
||||
<>
|
||||
<Globe size={16} />
|
||||
<Globe size={16} className={"shrink-0"} />
|
||||
Domain Name
|
||||
</>
|
||||
}
|
||||
@@ -560,7 +597,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
copyText={"Hostname"}
|
||||
label={
|
||||
<>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
<MonitorSmartphoneIcon size={16} className={"shrink-0"} />
|
||||
Hostname
|
||||
</>
|
||||
}
|
||||
@@ -570,7 +607,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<FlagIcon size={16} />
|
||||
<FlagIcon size={16} className={"shrink-0"} />
|
||||
Region
|
||||
</>
|
||||
}
|
||||
@@ -600,7 +637,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Cpu size={16} />
|
||||
<Cpu size={16} className={"shrink-0"} />
|
||||
Operating System
|
||||
</>
|
||||
}
|
||||
@@ -611,7 +648,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Barcode size={16} />
|
||||
<Barcode size={16} className={"shrink-0"} />
|
||||
Serial Number
|
||||
</>
|
||||
}
|
||||
@@ -623,7 +660,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<CalendarDays size={16} />
|
||||
<CalendarDays size={16} className={"shrink-0"} />
|
||||
Registered on
|
||||
</>
|
||||
}
|
||||
@@ -639,7 +676,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<History size={16} />
|
||||
<History size={16} className={"shrink-0"} />
|
||||
Last seen
|
||||
</>
|
||||
}
|
||||
@@ -656,7 +693,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<NetBirdIcon size={16} />
|
||||
<NetBirdIcon size={16} className={"shrink-0"} />
|
||||
Agent Version
|
||||
</>
|
||||
}
|
||||
@@ -667,7 +704,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<NetBirdIcon size={16} />
|
||||
<NetBirdIcon size={16} className={"shrink-0"} />
|
||||
UI Version
|
||||
</>
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Clusters - Reverse Proxy - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
@@ -0,0 +1,66 @@
|
||||
"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 { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const ClustersTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
|
||||
);
|
||||
|
||||
export default function ReverseProxyClustersPage() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={"Reverse Proxy"}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/clusters"}
|
||||
label={"Clusters"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Clusters</h1>
|
||||
<Paragraph>
|
||||
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"}>
|
||||
Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Clusters"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ClustersTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const idpIcon = (
|
||||
zitadel: <ZitadelIcon size={size} />,
|
||||
authentik: <AuthentikIcon size={size} />,
|
||||
keycloak: <KeycloakIcon size={size} />,
|
||||
adfs: <MicrosoftIcon size={size} />,
|
||||
oidc: <KeyRound size={size} className="text-nb-gray-400" />,
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -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"],
|
||||
|
||||
@@ -50,11 +50,11 @@ function CardListItem({
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex justify-between px-4 border-b border-nb-gray-900 py-4 last:border-b-0 items-center h-full",
|
||||
"flex justify-between px-4 border-b border-nb-gray-900 py-3.5 last:border-b-0 items-center h-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex gap-2.5 items-center text-sm"}>{label}</div>
|
||||
<div className={"flex gap-2.5 items-center text-[0.84rem]"}>{label}</div>
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<CardTextItem
|
||||
label={label}
|
||||
@@ -100,7 +100,7 @@ const CardTextItem = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
"text-right text-nb-gray-400 text-[0.84rem] flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import useCopyToClipboard from "@hooks/useCopyToClipboard";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Copy } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
type CardTableProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTable({ children, className }: CardTableProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-nb-gray-940 rounded-md border border-nb-gray-900 w-full overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<table className={"w-full border-collapse text-sm"}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTableHeader({ children, className }: CardTableProps) {
|
||||
return (
|
||||
<thead>
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTableHeaderCellProps = {
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableHeaderCell({
|
||||
children,
|
||||
width,
|
||||
className,
|
||||
}: CardTableHeaderCellProps) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-left text-sm font-normal",
|
||||
className,
|
||||
)}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTableBody({ children, className }: CardTableProps) {
|
||||
return <tbody className={className}>{children}</tbody>;
|
||||
}
|
||||
|
||||
type CardTableRowProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableRow({ children, className }: CardTableRowProps) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-nb-gray-900 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTableCellProps = {
|
||||
children: React.ReactNode;
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
width?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableCell({
|
||||
children,
|
||||
copy = false,
|
||||
copyText,
|
||||
width,
|
||||
className,
|
||||
}: CardTableCellProps) {
|
||||
const [, copyToClipBoard] = useCopyToClipboard(copyText ?? "");
|
||||
return (
|
||||
<td
|
||||
className={cn("px-4 py-3", className)}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
copy &&
|
||||
copyToClipBoard(`${copyText} has been copied to clipboard.`)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{copy && <Copy size={13} className={"shrink-0"} />}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
CardTable.Header = CardTableHeader;
|
||||
CardTable.HeaderCell = CardTableHeaderCell;
|
||||
CardTable.Body = CardTableBody;
|
||||
CardTable.Row = CardTableRow;
|
||||
CardTable.Cell = CardTableCell;
|
||||
|
||||
export default CardTable;
|
||||
@@ -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"}>
|
||||
|
||||
@@ -290,7 +290,7 @@ export function PeerGroupSelector({
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (tab === "groups") return placeholderForSearch;
|
||||
if (tab === "resources") return "Search resource...";
|
||||
if (tab === "peers") return "Search peer...";
|
||||
if (tab === "peers") return "Search peer by name or ip...";
|
||||
return "Search...";
|
||||
}, [tab, placeholderForSearch]);
|
||||
|
||||
@@ -537,9 +537,6 @@ export function PeerGroupSelector({
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
const peerCount =
|
||||
option.peers?.length ?? option?.peers_count ?? 0;
|
||||
|
||||
const isDisabled = disabledGroups
|
||||
? disabledGroups?.findIndex(
|
||||
(g) => g.id === option.id,
|
||||
@@ -968,7 +965,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 = ({
|
||||
|
||||
@@ -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({
|
||||
@@ -124,7 +125,6 @@ export function PeerSelector({
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
|
||||
}
|
||||
>
|
||||
<MapPinIcon />
|
||||
{value.ip}
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,7 +238,6 @@ export function PeerSelector({
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<MapPinIcon />
|
||||
{option.ip}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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]">
|
||||
"{truncatedText}"
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AIContextMenu.displayName = "AIContextMenu";
|
||||
export default AIContextMenu;
|
||||
Loaded 20 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user