Compare commits

...
26 changed files with 3502 additions and 212 deletions

No files matched your search

+4 -1
View File
@@ -5,6 +5,9 @@ const nextConfig = {
unoptimized: true,
},
reactStrictMode: false,
experimental: {
externalDir: true,
},
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
@@ -12,4 +15,4 @@ const nextConfig = {
},
};
module.exports = nextConfig;
module.exports = nextConfig;
+2323 -40
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -18,6 +18,7 @@
"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",
@@ -49,7 +50,7 @@
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"commander": "^14.0.3","chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
@@ -80,8 +81,11 @@
"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",
+9 -1
View File
@@ -2,6 +2,7 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import ExplainButton from "@components/ExplainButton";
import Card from "@components/Card";
import {
DropdownMenu,
@@ -99,6 +100,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
<PageContainer>
<NetworkAccessControlProvider>
<NetworkProvider network={network}>
<div>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
@@ -131,7 +133,10 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
description={network.description}
/>
</div>
<NetworkActions />
<div className="flex items-center gap-2">
<ExplainButton />
<NetworkActions />
</div>
</div>
</div>
@@ -192,6 +197,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
/>
</TabsContent>
</Tabs>
</div>
</NetworkProvider>
</NetworkAccessControlProvider>
</PageContainer>
@@ -282,6 +288,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
<Card.List>
<Card.ListItem
tooltip={false}
data-nb-explain="High Availability"
label={
<>
<ServerIcon size={16} />
@@ -327,6 +334,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
/>
<Card.ListItem
tooltip={false}
data-nb-explain="Active Policies"
label={
policyCount > 0 ? (
<>
+195
View File
@@ -200,3 +200,198 @@ 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);
}
@@ -0,0 +1,253 @@
"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
@@ -0,0 +1,327 @@
"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>
);
}
@@ -0,0 +1,74 @@
"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;
@@ -0,0 +1,29 @@
"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>
);
}
+4 -2
View File
@@ -563,7 +563,8 @@ export function DataTable<TData, TValue>({
}
}}
>
{row.getVisibleCells().map((cell) => (
{row.getVisibleCells().map((cell) => {
return (
<TableCellComponent
key={cell.id}
className={cn("relative", tableCellClassName)}
@@ -585,7 +586,8 @@ export function DataTable<TData, TValue>({
)}
</div>
</TableCellComponent>
))}
);
})}
</TableRowComponent>
{expandedRow && isExpanded && (
+25 -1
View File
@@ -20,6 +20,24 @@ import ErrorBoundaryProvider from "@/contexts/ErrorBoundary";
import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider";
import InstanceSetupProvider from "@/contexts/InstanceSetupProvider";
import { NavigationEvents } from "@/contexts/NavigationEvents";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { AIAssistantProvider } from "@netbirdio/explain/client";
import type { Message } from "@netbirdio/explain/client";
function renderMessage(message: Message) {
if (message.role === "assistant") {
return (
<div className="nb-markdown-body">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>
{message.content}
</Markdown>
</div>
);
}
return <>{message.content}</>;
}
const inter = localFont({
src: "../assets/fonts/Inter.ttf",
@@ -51,7 +69,13 @@ export default function AppLayout({
<InstanceSetupProvider>
<OIDCProvider>
<TooltipProvider delayDuration={0}>
{children}
<AIAssistantProvider
endpoint={process.env.NEXT_PUBLIC_AI_SERVER_URL || "http://localhost:3080/api/ai/chat"}
apiKey={process.env.NEXT_PUBLIC_AI_API_KEY || "nb-ai-dev-key-change-me"}
renderMessage={renderMessage}
>
{children}
</AIAssistantProvider>
</TooltipProvider>
</OIDCProvider>
</InstanceSetupProvider>
@@ -1,6 +1,7 @@
"use client";
import Button from "@components/Button";
import ExplainButton from "@components/ExplainButton";
import { Callout } from "@components/Callout";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
@@ -232,18 +233,23 @@ export function AccessControlModalContent({
return (
<ModalContent maxWidthClass={"max-w-3xl"}>
<ModalHeader
icon={<AccessControlIcon className={"fill-netbird"} />}
title={
policy
? "Update Access Control Policy"
: "Create New Access Control Policy"
}
description={
"Use this policy to restrict access to groups of resources."
}
color={"netbird"}
/>
<div className="flex items-start justify-between">
<ModalHeader
icon={<AccessControlIcon className={"fill-netbird"} />}
title={
policy
? "Update Access Control Policy"
: "Create New Access Control Policy"
}
description={
"Use this policy to restrict access to groups of resources."
}
color={"netbird"}
/>
<div className="pr-12 pt-2">
<ExplainButton />
</div>
</div>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
<TabsList justify={"start"} className={"px-8"}>
@@ -268,6 +274,7 @@ export function AccessControlModalContent({
<div
className={"flex justify-between items-center gap-10"}
data-cy={"protocol-wrapper"}
data-nb-explain="Protocol"
>
<div className={"w-full"}>
<Label>Protocol</Label>
@@ -323,7 +330,7 @@ export function AccessControlModalContent({
</div>
<div className={"flex gap-6 items-center"}>
<div className={"w-full self-start"}>
<div className={"w-full self-start"} data-nb-explain="Source">
<Label className={"mb-2"}>
<FolderDown size={15} />
Source
@@ -366,7 +373,7 @@ export function AccessControlModalContent({
destinationResource={destinationResource}
/>
<div className={"w-full self-start"}>
<div className={"w-full self-start"} data-nb-explain="Destination">
<Label className={"mb-2"}>
<FolderInput size={15} />
Destination
@@ -443,6 +450,7 @@ export function AccessControlModalContent({
)}
<div
className={"flex justify-between items-center gap-10 mt-2"}
data-nb-explain="SSH Access"
>
<div className={"w-full"}>
<Label className={"flex items-center gap-2"}>
@@ -469,6 +477,7 @@ export function AccessControlModalContent({
</div>
) : (
<div
data-nb-explain="Ports"
className={cn(
"mb-2 mt-2",
portDisabled && "opacity-30 pointer-events-none",
@@ -497,20 +506,22 @@ export function AccessControlModalContent({
</div>
)}
<FancyToggleSwitch
value={enabled}
onChange={setEnabled}
disabled={
!permission.policies.update || !permission.policies.create
}
label={
<>
<Power size={15} />
Enable Policy
</>
}
helpText={"Use this switch to enable or disable the policy."}
/>
<div data-nb-explain="Enable Policy">
<FancyToggleSwitch
value={enabled}
onChange={setEnabled}
disabled={
!permission.policies.update || !permission.policies.create
}
label={
<>
<Power size={15} />
Enable Policy
</>
}
helpText={"Use this switch to enable or disable the policy."}
/>
</div>
</div>
</TabsContent>
<PostureCheckTab
@@ -21,7 +21,7 @@ export default function AccessControlDirectionCell({
firstRule?.destinationResource?.type !== "peer";
return (
<div className={"flex h-full"}>
<div className={"flex h-full"} data-nb-explain="Traffic Direction">
{bidirectional && !isSingleResource ? (
<Badge variant={"green"} className={"py-2 px-4"}>
<LongArrowBidirectionalIcon
@@ -15,7 +15,7 @@ export default function AccessControlPostureCheckCell({ policy }: Props) {
return policy.source_posture_checks &&
policy.source_posture_checks.length > 0 ? (
<div className={"flex"}>
<div className={"flex"} data-nb-explain="Posture Checks">
<Badge variant={"gray"} useHover={true}>
<ShieldCheck size={14} className={"text-green-500"} />
{policy.source_posture_checks.length} Posture Check(s)
+18 -12
View File
@@ -1,6 +1,7 @@
"use client";
import Button from "@components/Button";
import ExplainButton from "@components/ExplainButton";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
@@ -91,19 +92,24 @@ const Content = ({ network, onCreated, onUpdated }: ContentProps) => {
return (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<NetworkRoutesIcon className={"fill-netbird"} />}
title={network ? "Update Network" : "Add Network"}
description={
network
? network.name
: "Access internal resources in LANs and VPC by adding a network."
}
color={"netbird"}
/>
<div className="flex items-start justify-between">
<ModalHeader
icon={<NetworkRoutesIcon className={"fill-netbird"} />}
title={network ? "Update Network" : "Add Network"}
description={
network
? network.name
: "Access internal resources in LANs and VPC by adding a network."
}
color={"netbird"}
/>
<div className="pr-12 pt-2">
<ExplainButton />
</div>
</div>
<Separator />
<div className={"px-8 flex-col flex gap-6 py-6"}>
<div>
<div data-nb-explain="Network Name">
<Label>Network Name</Label>
<HelpText>Provide a unique name for the network.</HelpText>
<Input
@@ -113,7 +119,7 @@ const Content = ({ network, onCreated, onUpdated }: ContentProps) => {
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<div data-nb-explain="Network Description">
<Label>Description (optional)</Label>
<HelpText>
Write a short description to add more context to this network.
@@ -124,7 +124,7 @@ export default function NetworkResourceAccessControl({
return (
<div className={"px-8 flex-col flex gap-6"}>
<div>
<div data-nb-explain="Access Control Policies">
<Label>Access Control Policies</Label>
<HelpText>
Define which source groups are allowed to access this resource. You
@@ -34,13 +34,15 @@ import {
AccordionItem,
AccordionTrigger,
} from "@components/Accordion";
import React, { useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Group } from "@/interfaces/Group";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import NetworkResourceAccessControl from "@/modules/networks/resources/NetworkResourceAccessControl";
import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput";
import { useAIAssistant } from "@netbirdio/explain/client";
import ExplainButton from "@components/ExplainButton";
type Props = {
open?: boolean;
@@ -112,6 +114,15 @@ export function ResourceModalContent({
const { confirm } = useDialog();
const { setExplainContext, clearExplainContext } = useAIAssistant();
useEffect(() => {
setExplainContext({
modalName: resource ? "Edit Resource" : "Add Resource",
pageName: "Networks",
});
return () => clearExplainContext();
}, [resource, setExplainContext, clearExplainContext]);
// Access control policies
const [policies, setPolicies] = useState<Policy[]>([]);
const { createPoliciesForResource } = usePolicies();
@@ -217,20 +228,26 @@ export function ResourceModalContent({
return (
<ModalContent
data-nb-explain
maxWidthClass={
tab === "access-control" ? "max-w-[790px]" : "max-w-[680px]"
}
>
<ModalHeader
icon={<WorkflowIcon size={20} />}
title={resource ? "Edit Resource" : "Add Resource"}
description={
resource
? `${resource.name}`
: `Add new resource to "${network?.name}"`
}
color={"yellow"}
/>
<div className="flex items-start justify-between">
<ModalHeader
icon={<WorkflowIcon size={20} />}
title={resource ? "Edit Resource" : "Add Resource"}
description={
resource
? `${resource.name}`
: `Add new resource to "${network?.name}"`
}
color={"yellow"}
/>
<div className="pr-12 pt-2">
<ExplainButton />
</div>
</div>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
<TabsList justify={"start"} className={"px-8"}>
@@ -249,7 +266,7 @@ export function ResourceModalContent({
<TabsContent value={"resource"} className={"pb-4"}>
<div className={"px-8 flex-col flex gap-6"}>
<div>
<div data-nb-explain>
<Label>Name</Label>
<HelpText>
Set an easily identifiable name for your resource
@@ -264,6 +281,7 @@ export function ResourceModalContent({
onChange={(e) => setName(e.target.value)}
/>
</div>
<div data-nb-explain>
<ResourceSingleAddressInput
value={address}
onChange={setAddress}
@@ -297,6 +315,7 @@ export function ResourceModalContent({
</>
}
/>
</div>
<Accordion
type={"multiple"}
@@ -314,7 +333,7 @@ export function ResourceModalContent({
</AccordionTrigger>
<AccordionContent className={""}>
<div className={"flex flex-col gap-6 pb-4 pt-2"}>
<div>
<div data-nb-explain>
<Label>Description</Label>
<HelpText>
Write a short description to add more context to this
@@ -326,7 +345,7 @@ export function ResourceModalContent({
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div>
<div data-nb-explain>
<Label>Resource Groups</Label>
<HelpText className={"mt-1"}>
Add this resource to a group (e.g., Databases, Web
@@ -462,3 +481,4 @@ export function ResourceModalContent({
</ModalContent>
);
}
@@ -55,7 +55,7 @@ export const ResourceEnabledCell = ({
}, [resource]);
return (
<div className={"flex"}>
<div className={"flex"} data-nb-explain="Resource Active Toggle">
<ToggleSwitch
checked={isChecked}
size={"small"}
@@ -69,7 +69,7 @@ export const ResourceSingleAddressInput = ({
}, [error]);
return (
<div className={className}>
<div className={className} data-nb-explain="Resource Address">
<Label>{label}</Label>
<HelpText>{description}</HelpText>
<Input
@@ -77,7 +77,7 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
return groups.map((group) => group.name).join(", ");
},
header: ({ column }) => {
return <DataTableHeader column={column}>Resource Groups</DataTableHeader>;
return <DataTableHeader column={column}><span data-nb-explain="Resource Groups">Resource Groups</span></DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceGroupCell resource={row.original} />;
@@ -87,7 +87,7 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
id: "policies",
accessorKey: "id",
header: ({ column }) => {
return <DataTableHeader column={column}>Policies</DataTableHeader>;
return <DataTableHeader column={column}><span data-nb-explain="Access Control Policies">Policies</span></DataTableHeader>;
},
cell: ({ row }) => {
return <ResourcePolicyCell resource={row.original} />;
@@ -96,7 +96,9 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
{
id: "expose_service",
accessorKey: "id",
header: "",
header: ({ column }) => {
return <DataTableHeader column={column}><span data-nb-explain="Expose Service">Expose Service</span></DataTableHeader>;
},
cell: ({ row }) => {
return <ResourceExposeServiceCell resource={row.original} />;
},
Loaded 20 of 26 files, more files were not shown because too many files have changed in this diff. Show more