Compare commits

..
50 changed files with 2408 additions and 5523 deletions

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
## Issue ticket number and link
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [ ] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
-105
View File
@@ -1,105 +0,0 @@
name: Docs Acknowledgement
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
docs-ack:
name: Require docs PR URL or explicit "not needed"
runs-on: ubuntu-latest
steps:
- name: Read PR body
id: body
shell: bash
run: |
set -euo pipefail
BODY_B64=$(jq -r '.pull_request.body // "" | @base64' "$GITHUB_EVENT_PATH")
{
echo "body_b64=$BODY_B64"
} >> "$GITHUB_OUTPUT"
- name: Validate checkbox selection
id: validate
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
if ! body="$(printf '%s' "$BODY_B64" | base64 -d)"; then
echo "::error::Failed to decode PR body from base64. Data may be corrupted or missing."
exit 1
fi
added_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*I added/updated documentation' | wc -l | tr -d '[:space:]' || true)
noneed_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*Documentation is \*\*not needed\*\*' | wc -l | tr -d '[:space:]' || true)
total=$((added_checked + noneed_checked))
if [ "$total" -ne 1 ]; then
echo "::error::You must check exactly one docs option in the PR template (either 'docs added' OR 'not needed')."
exit 1
fi
if [ "$added_checked" -eq 1 ]; then
echo "mode=added" >> "$GITHUB_OUTPUT"
else
echo "mode=noneed" >> "$GITHUB_OUTPUT"
fi
- name: Extract docs PR URL (when 'docs added')
if: steps.validate.outputs.mode == 'added'
id: extract
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
body="$(printf '%s' "$BODY_B64" | base64 -d)"
# Strictly require HTTPS and that it's a PR in netbirdio/docs
# e.g., https://github.com/netbirdio/docs/pull/1234
url="$(printf '%s' "$body" | grep -Eo 'https://github\.com/netbirdio/docs/pull/[0-9]+' | head -n1 || true)"
if [ -z "${url:-}" ]; then
echo "::error::You checked 'docs added' but didn't include a valid HTTPS PR link to netbirdio/docs (e.g., https://github.com/netbirdio/docs/pull/1234)."
exit 1
fi
pr_number="$(printf '%s' "$url" | sed -E 's#.*/pull/([0-9]+)$#\1#')"
{
echo "url=$url"
echo "pr_number=$pr_number"
} >> "$GITHUB_OUTPUT"
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
uses: actions/github-script@v7
id: verify
env:
PR_NUMBER: ${{ steps.extract.outputs.pr_number }}
with:
script: |
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const { data } = await github.rest.pulls.get({
owner: 'netbirdio',
repo: 'docs',
pull_number: prNumber
});
// Allow open or merged PRs
const ok = data.state === 'open' || data.merged === true;
core.setOutput('state', data.state);
core.setOutput('merged', String(!!data.merged));
if (!ok) {
core.setFailed(`Docs PR #${prNumber} exists but is neither open nor merged (state=${data.state}, merged=${data.merged}).`);
}
result-encoding: string
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: All good
run: echo "Documentation requirement satisfied âś…"
+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;
+44 -2327
View File
File diff suppressed because it is too large. Load diff
+2 -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",
@@ -68,7 +67,7 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.566.0",
"lucide-react": "^0.562.0",
"next": "^16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
@@ -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",
+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 ? (
<>
-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);
}
+1 -1
View File
@@ -74,7 +74,7 @@ export const buttonVariants = cva(
"",
],
"danger-text": [
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50",
],
"default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
+1 -1
View File
@@ -98,7 +98,7 @@ const SelectItem = React.forwardRef<
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<span className="flex-shrink-0">{icon}</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
@@ -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>
);
}
+7 -25
View File
@@ -48,9 +48,6 @@ interface SelectDropdownProps {
children?: React.ReactNode;
maxHeight?: number;
triggerClassName?: string;
iconSize?: number;
truncate?: boolean;
compact?: boolean;
}
export function SelectDropdown({
@@ -71,9 +68,6 @@ export function SelectDropdown({
children,
maxHeight,
triggerClassName,
iconSize = 14,
truncate = false,
compact = false,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -113,18 +107,15 @@ export function SelectDropdown({
const SelectedItem = () => {
return (
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
truncate && "min-w-0",
)}
>
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
{selected?.label}
</span>
<span className={"text-nb-gray-200"}>{selected?.label}</span>
</div>
</div>
);
@@ -225,22 +216,20 @@ export function SelectDropdown({
<ScrollArea
className={cn(
"overflow-y-auto flex flex-col gap-1",
compact ? "pl-1 pr-1" : "pl-2 pr-3",
!showSearch && (compact ? "pt-1" : "pt-2"),
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
!showSearch && "pt-2",
)}
style={{
maxHeight: maxHeight ?? 380,
}}
>
<CommandGroup>
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
<div className={"grid grid-cols-1 gap-1 pb-2 w-full"}>
{filteredItems.map((option) => (
<SelectDropdownItem
option={option}
toggle={toggle}
key={option.value}
iconSize={iconSize}
showValue={showValues}
size={size}
/>
@@ -260,13 +249,11 @@ const SelectDropdownItem = ({
toggle,
showValue = false,
size = "sm",
iconSize = 14,
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
size: "xs" | "sm";
iconSize?: number;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -298,12 +285,7 @@ const SelectDropdownItem = ({
option?.disabled && "cursor-not-allowed",
)}
>
{option.icon && (
<div className={"shrink-0"}>
<option.icon size={iconSize} width={iconSize} />
</div>
)}
{option.icon && <option.icon size={14} width={14} />}
{option?.renderItem && option.renderItem()}
{!option?.renderItem && (
<div
+2 -15
View File
@@ -53,7 +53,6 @@ declare module "@tanstack/table-core" {
}
interface SortingFns {
checkbox: SortingFn<unknown>;
datetime: SortingFn<unknown>;
}
}
@@ -100,15 +99,6 @@ const arrIncludesSomeExact: FilterFn<any> = (
return value.some((val) => val === rowValue);
};
const datetimeSort: SortingFn<any> = (rowA, rowB, columnId) => {
const aConnected = rowA.original?.connected;
const bConnected = rowB.original?.connected;
if (aConnected !== bConnected) return aConnected ? 1 : -1;
const a = dayjs(rowA.getValue(columnId)).valueOf();
const b = dayjs(rowB.getValue(columnId)).valueOf();
return a - b;
};
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
const valueA =
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
@@ -334,7 +324,6 @@ export function DataTable<TData, TValue>({
},
sortingFns: {
checkbox: checkboxSort,
datetime: datetimeSort,
},
getRowId: useRowId ? (row) => row.id : undefined,
onRowSelectionChange: setRowSelection,
@@ -563,8 +552,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 +574,7 @@ export function DataTable<TData, TValue>({
)}
</div>
</TableCellComponent>
);
})}
))}
</TableRowComponent>
{expandedRow && isExpanded && (
+2 -9
View File
@@ -14,7 +14,6 @@ type Props = {
center?: boolean;
className?: string;
sorting?: boolean;
onSort?: () => void;
name?: string;
};
export default function DataTableHeader({
@@ -24,20 +23,14 @@ export default function DataTableHeader({
center,
className,
sorting = true,
onSort,
name,
}: Props) {
const serverPagination = useOptionalServerPagination();
const handleSort = () => {
if (onSort) {
onSort();
} else {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
}
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
if (name && serverPagination?.setSort) {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
serverPagination.setSort(name, direction);
}
};
+2 -8
View File
@@ -9,11 +9,8 @@ import { useCountries } from "@/contexts/CountryProvider";
type Props = {
value: string;
onChange: (value: string) => void;
iconSize?: number;
popoverWidth?: "auto" | "content" | number;
truncate?: boolean;
};
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
export const CountrySelector = ({ value, onChange }: Props) => {
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
@@ -25,7 +22,7 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
}) =>
createElement(RoundedFlag, {
country: country.country_code,
size: iconSize,
size: 20,
...props,
});
return {
@@ -45,10 +42,7 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
searchPlaceholder={"Search country..."}
value={value}
onChange={onChange}
iconSize={iconSize}
options={countryList || []}
popoverWidth={popoverWidth}
truncate={truncate}
/>
</div>
);
+2 -10
View File
@@ -13,11 +13,7 @@ const CountryContext = React.createContext(
countries: Country[] | undefined;
isLoading: boolean;
getRegionByPeer: (peer: Peer) => string;
getRegionText: (
country_code: string,
city_name: string,
subdivision_code?: string,
) => string;
getRegionText: (country_code: string, city_name: string, subdivision_code?: string) => string;
},
);
@@ -25,11 +21,7 @@ export default function CountryProvider({ children }: Props) {
const { isRestricted } = usePermissions();
const getRegionByPeer = (peer: Peer) => "Unknown";
const getRegionText = (
country_code: string,
city_name: string,
subdivision_code?: string,
) => "Unknown";
const getRegionText = (country_code: string, city_name: string, _subdivision_code?: string) => "Unknown";
return isRestricted ? (
<CountryContext.Provider
+7 -11
View File
@@ -22,13 +22,6 @@ export interface ReverseProxy {
meta?: ReverseProxyMeta;
}
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
}
export interface ReverseProxyMeta {
created_at: string;
status: ReverseProxyStatus;
@@ -94,6 +87,13 @@ export interface HeaderAuthConfig {
value: string;
}
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
}
export interface ReverseProxyDomain {
id: string;
domain: string;
@@ -101,7 +101,6 @@ export interface ReverseProxyDomain {
type: ReverseProxyDomainType;
target_cluster?: string;
supports_custom_ports?: boolean;
require_subdomain?: boolean;
}
export enum ReverseProxyDomainType {
@@ -198,8 +197,5 @@ export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
export const REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";
+1 -25
View File
@@ -20,24 +20,6 @@ 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",
@@ -69,13 +51,7 @@ export default function AppLayout({
<InstanceSetupProvider>
<OIDCProvider>
<TooltipProvider delayDuration={0}>
<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>
{children}
</TooltipProvider>
</OIDCProvider>
</InstanceSetupProvider>
Loaded 20 of 50 files, more files were not shown because too many files have changed in this diff. Show more