Compare commits

..
Author SHA1 Message Date
nsadeghi97 0a34fc6b81 Show a login screen instead of auto-redirecting to the IdP 2026-07-24 17:24:36 +02:00
72 changed files with 490 additions and 11680 deletions

No files matched your search

-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env bash
#
# Build the netbird-dashboard Docker image for linux/amd64 and export it as a
# portable tarball that can be loaded and run on a remote server.
#
# The dashboard image is nginx serving a Next.js *static export*: the Dockerfile
# only COPYs the pre-built out/ directory, so we build out/ on the host first
# (the static files are arch-independent) and then assemble the amd64 image.
#
# Usage:
# ./build.sh # build + export netbird-dashboard.tar.gz
# IMAGE_TAG=v1 ./build.sh # override the image tag
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
#
set -euo pipefail
# Always run relative to this script's directory (the project root).
cd "$(dirname "$0")"
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
IMAGE_TAG="${IMAGE_TAG:-amd64}"
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
# Build the static export (out/) unless told to reuse an existing one. Next 16
# no longer runs ESLint during `next build`; types are checked by the build via
# tsc (verify separately with `npx tsc --noEmit`).
if [[ "${SKIP_BUILD:-0}" != "1" ]]; then
if [[ ! -d node_modules ]]; then
echo "==> Installing dependencies (npm ci)"
npm ci
fi
echo "==> Building static export (next build) into out/"
npx next build
fi
if [[ ! -d out ]]; then
echo "ERROR: out/ not found — the static export did not build." >&2
exit 1
fi
# The .dockerignore lives under docker/, but Docker reads it from the build
# context root. Stage it for the duration of the build so node_modules/.next
# don't get shipped into the build context.
cleanup() { rm -f .dockerignore; }
trap cleanup EXIT
cp docker/.dockerignore .dockerignore
echo "==> Building ${IMAGE} for linux/amd64"
docker build \
--platform linux/amd64 \
-f docker/Dockerfile \
-t "${IMAGE}" \
.
echo "==> Exporting ${IMAGE} to ${OUTPUT}"
docker save "${IMAGE}" | gzip > "${OUTPUT}"
echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
echo
echo "Copy and run on the server:"
echo " scp ${OUTPUT} user@your-server:/tmp/"
echo " docker load < /tmp/${OUTPUT}"
echo
echo "Run (nginx serves on container port 80). Set the runtime env vars the"
echo "dashboard templates at startup — for the Agent Network onboarding test,"
echo "flip NETBIRD_AGENT_NETWORK_ONLY=true:"
echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\"
echo " -p 8080:80 \\"
echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\"
echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\"
echo " -e AUTH_CLIENT_ID=netbird-dashboard \\"
echo " -e AUTH_AUDIENCE=netbird-dashboard \\"
echo " -e AUTH_SUPPORTED_SCOPES='openid profile email' \\"
echo " -e USE_AUTH0=false \\"
echo " -e NETBIRD_AGENT_NETWORK_ONLY=true \\"
echo " ${IMAGE}"
-2
View File
@@ -18,8 +18,6 @@
"wasmPath": "$NETBIRD_WASM_PATH",
"licensed": "$NETBIRD_LICENSED",
"cloud": "$NETBIRD_CLOUD",
"agentNetworkOnly": "$NETBIRD_AGENT_NETWORK_ONLY",
"agentNetworkEnabled": "$NETBIRD_AGENT_NETWORK_ENABLED",
"hubspotPortalId": "$NETBIRD_HUBSPOT_PORTAL_ID",
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
-12
View File
@@ -1,12 +0,0 @@
# The image only needs the pre-built static export (out/) and the docker/
# config files. Keep the build context small by dropping everything heavy and
# irrelevant — without this, `docker build .` ships node_modules and .next.
node_modules
.next
.git
.github
e2e
src
public
*.tar.gz
*.tar
+1 -5
View File
@@ -66,8 +66,6 @@ export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
export NETBIRD_CSP=${NETBIRD_CSP}
export NETBIRD_LICENSED=${NETBIRD_LICENSED:-false}
export NETBIRD_CLOUD=${NETBIRD_CLOUD:-false}
export NETBIRD_AGENT_NETWORK_ONLY=${NETBIRD_AGENT_NETWORK_ONLY:-false}
export NETBIRD_AGENT_NETWORK_ENABLED=${NETBIRD_AGENT_NETWORK_ENABLED:-false}
export NETBIRD_HUBSPOT_PORTAL_ID=${NETBIRD_HUBSPOT_PORTAL_ID}
export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID}
export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID}
@@ -93,8 +91,6 @@ fi
# Add AUTH_AUTHORITY to CSP
if [[ -n "${AUTH_AUTHORITY}" ]]; then
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY"
AUTH_AUTHORITY_ORIGIN=$(echo "$AUTH_AUTHORITY" | sed -E 's|^(https?://[^/]+).*|\1|')
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY_ORIGIN"
fi
# Add AUTH_AUDIENCE to CSP
@@ -153,7 +149,7 @@ sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /
}
# replace ENVs in the config
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
+21 -1
View File
@@ -172,6 +172,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1455,6 +1456,7 @@
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.59.1"
},
@@ -3095,6 +3097,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3104,6 +3107,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3162,6 +3166,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -3629,7 +3634,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@xyflow/react": {
"version": "12.10.1",
@@ -3668,6 +3674,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4093,6 +4100,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4228,6 +4236,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -4745,6 +4754,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -5266,6 +5276,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5395,6 +5406,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6779,6 +6791,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7612,6 +7625,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -7819,6 +7833,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7879,6 +7894,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8751,6 +8767,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -8917,6 +8934,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9069,6 +9087,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9396,6 +9415,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+2 -2
View File
@@ -40,8 +40,8 @@ export default function AccessControlPage() {
</Breadcrumbs>
<h1 ref={headingRef}>Access Control Policies</h1>
<Paragraph>
Policies connect users and agents to your network resources,
controlling what each identity can reach.{" "}
Create rules to manage access in your network and define what peers
can connect.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
@@ -1,120 +0,0 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import * as Tabs from "@radix-ui/react-tabs";
import { Gauge, ScrollText } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { Suspense, useEffect, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PageContainer from "@/layouts/PageContainer";
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
const TAB_BUDGET_SETTINGS = "budget-settings";
const TAB_LOG_SETTINGS = "log-settings";
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
// Usage & Logs — global limits and log-collection controls — using the same
// vertical-tab layout (and per-tab breadcrumb/heading/description) as the main
// Settings page so it reads as "settings for the Agent Network".
export default function AgentNetworkConfigurationPage() {
const { permission } = usePermissions();
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
useEffect(() => {
if (queryTab) setTab(queryTab);
}, [queryTab]);
return (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
<VerticalTabs.List>
<VerticalTabs.Trigger value={TAB_BUDGET_SETTINGS}>
<Gauge size={14} />
Global Limits
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value={TAB_LOG_SETTINGS}>
<ScrollText size={14} />
Log Collection
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess
page={"Configuration"}
hasAccess={permission?.services?.read}
>
<GroupsProvider>
<PeersProvider>
<AIProvidersProvider>
<div className={"border-l border-nb-gray-930 w-full"}>
<Tabs.Content
value={TAB_BUDGET_SETTINGS}
className={"w-full"}
>
<ConfigTabHeader
label={"Global Limits"}
href={"/agent-network/configuration?tab=budget-settings"}
>
Account-wide caps on token usage and spend, applied across
every policy. Scope a limit to specific groups or users,
or leave it account-wide.
</ConfigTabHeader>
{/* DataTable applies its own p-default, so it is rendered
directly (no extra wrapper) to align with the header. */}
<Suspense fallback={<SkeletonTable />}>
<AgentBudgetRulesTable />
</Suspense>
</Tabs.Content>
<Tabs.Content value={TAB_LOG_SETTINGS} className={"w-full"}>
{/* Self-contained tab (own breadcrumb + heading + save),
mirroring the Settings > Authentication layout. */}
<Suspense fallback={<SkeletonTable />}>
<AgentAccountControlsCard />
</Suspense>
</Tabs.Content>
</div>
</AIProvidersProvider>
</PeersProvider>
</GroupsProvider>
</RestrictedAccess>
</VerticalTabs>
</PageContainer>
);
}
// ConfigTabHeader mirrors the breadcrumb + heading + description block each
// Settings tab renders at the top of its content.
function ConfigTabHeader({
label,
href,
children,
}: {
label: string;
href: string;
children?: React.ReactNode;
}) {
return (
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/agent-network/providers"}
label={"Agent Network"}
icon={<AgentNetworkIcon size={16} />}
/>
<Breadcrumbs.Item href={href} label={label} active />
</Breadcrumbs>
<h1>{label}</h1>
{children && <Paragraph>{children}</Paragraph>}
</div>
);
}
@@ -1,19 +0,0 @@
"use client";
import { isAgentNetworkEnabled } from "@utils/netbird";
import { notFound } from "next/navigation";
import * as React from "react";
// Gates the entire Agent Network route tree behind the NETBIRD_AGENT_NETWORK
// flag. When disabled, these routes don't exist as far as the user is
// concerned — the dashboard behaves exactly as it did without the feature.
export default function AgentNetworkLayout({
children,
}: {
children: React.ReactNode;
}) {
if (!isAgentNetworkEnabled()) {
notFound();
}
return <>{children}</>;
}
@@ -1,56 +0,0 @@
"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, { Suspense } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PageContainer from "@/layouts/PageContainer";
import AgentPoliciesTable from "@/modules/agent-network/AgentPoliciesTable";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
export default function AgentNetworkPoliciesPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/agent-network/providers"}
label={"Agent Network"}
icon={<AgentNetworkIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/agent-network/policies"}
label={"Policies"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Policies</h1>
<Paragraph>
Bind IdP groups to providers: Engineering gets Claude, Finance
doesn&apos;t. Enforce token limits, budgets, and guardrails.
</Paragraph>
</div>
<RestrictedAccess
page={"Policies"}
hasAccess={permission?.services?.read}
>
<AIProvidersProvider>
<Suspense fallback={<SkeletonTable />}>
<AgentPoliciesTable headingTarget={portalTarget} />
</Suspense>
</AIProvidersProvider>
</RestrictedAccess>
</PageContainer>
);
}
@@ -1,213 +0,0 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import { HelpTooltip } from "@components/HelpTooltip";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useCopyToClipboard from "@hooks/useCopyToClipboard";
import { Copy, ExternalLinkIcon, Globe, Plug } from "lucide-react";
import React, { Suspense, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PageContainer from "@/layouts/PageContainer";
import AgentConnectModal from "@/modules/agent-network/AgentConnectModal";
import AIProviderModal from "@/modules/agent-network/AIProviderModal";
import AIProvidersProvider, {
useAIProviders,
} from "@/modules/agent-network/AIProvidersProvider";
import AgentProvidersTable from "@/modules/agent-network/table/AgentProvidersTable";
import InlineLink from "@components/InlineLink";
function EndpointBadge({ endpoint }: { endpoint: string }) {
const [, copy] = useCopyToClipboard(`https://${endpoint}`);
const [connectOpen, setConnectOpen] = useState(false);
return (
<div
className={
"inline-flex items-center gap-3 rounded-lg border border-nb-gray-800 bg-nb-gray-900/40 p-3 min-w-[300px]"
}
>
<div className={"flex flex-col"}>
<div
className={
"text-[10px] text-nb-gray-400 uppercase tracking-wider font-medium inline-flex items-center gap-1.5"
}
>
API Base URL
<HelpTooltip
iconSize={11}
content={
<>
Use this URL as the base URL when configuring your AI agents or
LLM SDK clients (e.g. OpenAI&apos;s
<code className={"font-mono"}> base_url</code>, Anthropic&apos;s{" "}
<code className={"font-mono"}>baseURL</code>, or any HTTP
client). Calls hit NetBird first, get authorised by your
policies, and only then reach the upstream provider.
</>
}
/>
</div>
<code
className={
"font-mono text-xs text-nb-gray-100 leading-tight mt-0.5 whitespace-nowrap"
}
>
https://{endpoint}
</code>
</div>
<button
type={"button"}
className={
"inline-flex items-center gap-1.5 rounded-md border border-nb-gray-700 bg-nb-gray-800/60 px-2.5 py-1.5 text-[11px] font-medium text-nb-gray-200 hover:bg-nb-gray-800 hover:text-white transition-colors shrink-0"
}
onClick={() => copy("Endpoint copied to clipboard")}
aria-label={"Copy endpoint"}
>
<Copy size={12} />
Copy
</button>
<button
type={"button"}
className={
"inline-flex items-center gap-1.5 rounded-md border border-nb-gray-700 bg-nb-gray-800/60 px-2.5 py-1.5 text-[11px] font-medium text-nb-gray-200 hover:bg-nb-gray-800 hover:text-white transition-colors shrink-0"
}
onClick={() => setConnectOpen(true)}
aria-label={"Agent config"}
>
<Plug size={12} />
Agent Config
</button>
<AgentConnectModal
open={connectOpen}
onOpenChange={setConnectOpen}
endpoint={endpoint}
/>
</div>
);
}
function EndpointHeader() {
const { settings, settingsLoading, openWizard } = useAIProviders();
if (settingsLoading) return null;
if (!settings) {
return (
<button
type={"button"}
onClick={openWizard}
className={
"inline-flex items-center gap-3 rounded-lg border border-dashed border-nb-gray-800 bg-nb-gray-900/20 p-3 text-left hover:border-nb-gray-700 hover:bg-nb-gray-900/40 transition-colors cursor-pointer min-w-[300px]"
}
>
<div
className={
"h-8 w-8 rounded-md bg-nb-gray-900 flex items-center justify-center shrink-0"
}
>
<Globe size={14} className={"text-nb-gray-500"} />
</div>
<div className={"flex flex-col min-w-0"}>
<div
className={
"text-[10px] text-nb-gray-500 uppercase tracking-wider font-medium inline-flex items-center gap-1.5"
}
>
API Base URL
<span onClick={(e) => e.stopPropagation()}>
<HelpTooltip
iconSize={11}
content={
<>
Use this URL as the base URL when configuring your AI agents
or LLM SDK clients (e.g. OpenAI&apos;s
<code className={"font-mono"}> base_url</code>,
Anthropic&apos;s{" "}
<code className={"font-mono"}>baseURL</code>, or any HTTP
client). Calls hit NetBird first, get authorised by your
policies, and only then reach the upstream provider.
</>
}
/>
</span>
</div>
<span className={"text-xs text-nb-gray-400 leading-tight mt-0.5"}>
Connect your first provider to set up your agent network endpoint.
</span>
</div>
</button>
);
}
return <EndpointBadge endpoint={settings.endpoint} />;
}
function PageBody({
headingTarget,
}: {
headingTarget: HTMLHeadingElement | null;
}) {
const { isWizardOpen, closeWizard } = useAIProviders();
return (
<>
<Suspense fallback={<SkeletonTable />}>
<AgentProvidersTable headingTarget={headingTarget} />
</Suspense>
<AIProviderModal open={isWizardOpen} onOpenChange={closeWizard} />
</>
);
}
export default function AgentNetworkProvidersPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
{/* Gate the whole surface: AIProvidersProvider and EndpointHeader fetch
agent-network state, so they must not mount for users without
services.read. */}
<RestrictedAccess
page={"Providers"}
hasAccess={permission?.services?.read}
>
<AIProvidersProvider>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/agent-network/providers"}
label={"Agent Network"}
icon={<AgentNetworkIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/agent-network/providers"}
label={"Providers"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Providers</h1>
<Paragraph>
Connect AI providers and gateways like LiteLLM, OpenAI, and
Anthropic through one keyless endpoint, accessible only via
NetBird’s tunnel.
<InlineLink
href={"https://docs.netbird.io/agent-network/providers"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
<div className={"mt-4"}>
<EndpointHeader />
</div>
</div>
<PageBody headingTarget={portalTarget} />
</AIProvidersProvider>
</RestrictedAccess>
</PageContainer>
);
}
@@ -1,138 +0,0 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { LayoutDashboard, ScrollText } from "lucide-react";
import dayjs from "dayjs";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
import PageContainer from "@/layouts/PageContainer";
import AgentAccessLogTable from "@/modules/agent-network/AgentAccessLogTable";
import AgentOverviewPanel from "@/modules/agent-network/AgentOverviewPanel";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
// Tab ids — kept stable so ?tab=<id> URL hand-off works (e.g.
// /agent-network/usage?tab=access-logs), the same way Settings deep-links tabs.
const TAB_USAGE = "usage";
const TAB_ACCESS_LOGS = "access-logs";
const VALID_TABS = new Set([TAB_USAGE, TAB_ACCESS_LOGS]);
// UsageAndLogsPage surfaces the live access log and the spend dashboard.
// Budget rules and log-collection controls live under the separate
// Configuration entry. Providers are mounted once at the top so switching
// tabs is instant — no re-fetch on tab change.
export default function UsageAndLogsPage() {
const { permission } = usePermissions();
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const queryTab = searchParams.get("tab") ?? "";
const initialTab = VALID_TABS.has(queryTab) ? queryTab : TAB_USAGE;
const [tab, setTab] = useState(initialTab);
// Default the access-log view to the last 14 days. Computed once on mount so
// the window is stable across re-renders; resetting filters returns here.
const defaultAccessLogFilters = useMemo(
() => ({
start_date: dayjs().subtract(14, "day").startOf("day").toISOString(),
end_date: dayjs().endOf("day").toISOString(),
}),
[],
);
// Keep the tab in sync when the ?tab= query changes (deep links / back-forward).
// Also reset to the default when ?tab= is removed or invalid, so navigating
// back to the bare URL doesn't leave the previous tab selected.
useEffect(() => {
setTab(VALID_TABS.has(queryTab) ? queryTab : TAB_USAGE);
}, [queryTab]);
// Reflect the active tab in the URL so it's shareable, like Settings.
const onTabChange = (value: string) => {
setTab(value);
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
};
return (
<PageContainer>
<div className={"p-default py-6 pb-0"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/agent-network/providers"}
label={"Agent Network"}
icon={<AgentNetworkIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/agent-network/usage"}
label={"Usage & Logs"}
active={true}
/>
</Breadcrumbs>
<h1>Usage & Logs</h1>
<Paragraph>
Per-request audit with real caller identity, cost attribution, and
budget controls.
</Paragraph>
</div>
<RestrictedAccess
page={"Usage & Logs"}
hasAccess={permission?.services?.read}
>
<GroupsProvider>
<PeersProvider>
<AIProvidersProvider>
<Tabs
value={tab}
onValueChange={onTabChange}
defaultValue={initialTab}
className={"pt-4 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={TAB_USAGE}>
<LayoutDashboard size={16} />
Usage
</TabsTrigger>
<TabsTrigger value={TAB_ACCESS_LOGS}>
<ScrollText size={16} />
Access Logs
</TabsTrigger>
</TabsList>
<TabsContent value={TAB_USAGE} className={"pb-8"}>
<Suspense fallback={<SkeletonTable />}>
<AgentOverviewPanel />
</Suspense>
</TabsContent>
<TabsContent value={TAB_ACCESS_LOGS} className={"pb-8"}>
<Suspense fallback={<SkeletonTable />}>
<ServerPaginationProvider
url={"/agent-network/access-logs"}
defaultPageSize={25}
defaultFilters={defaultAccessLogFilters}
>
<AgentAccessLogTable />
</ServerPaginationProvider>
</Suspense>
</TabsContent>
</Tabs>
</AIProvidersProvider>
</PeersProvider>
</GroupsProvider>
</RestrictedAccess>
</PageContainer>
);
}
+8 -205
View File
@@ -4,15 +4,11 @@ import "@xyflow/react/dist/style.css";
import Button from "@components/Button";
import InlineLink from "@components/InlineLink";
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { SelectDropdown, SelectOption } from "@components/select/SelectDropdown";
import SquareIcon from "@components/SquareIcon";
import GetStartedTest from "@components/ui/GetStartedTest";
import { SmallBadge } from "@components/ui/SmallBadge";
import useFetchApi from "@utils/api";
import { isAgentNetworkEnabled, isAgentNetworkOnly } from "@utils/netbird";
import {
Background,
Edge,
@@ -23,16 +19,10 @@ import {
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
useReactFlow
} from "@xyflow/react";
import { forEach, orderBy, sortBy } from "lodash";
import {
ArrowLeftIcon,
ExternalLinkIcon,
LayoutGridIcon,
MessageSquareShareIcon,
NetworkIcon,
} from "lucide-react";
import { ArrowLeftIcon, ExternalLinkIcon, LayoutGridIcon, MessageSquareShareIcon, NetworkIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
@@ -47,13 +37,6 @@ import { Policy } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer";
import { AccessControlUpdateModal } from "@/modules/access-control/AccessControlModal";
import AgentPolicyModal from "@/modules/agent-network/AgentPolicyModal";
import AIProvidersProvider, {
APIPolicy,
APIProvider,
useAIProviders,
} from "@/modules/agent-network/AIProvidersProvider";
import { AIProviderId } from "@/modules/agent-network/data/mockData";
import { FlowSelector, FlowView } from "@/modules/control-center/FlowSelector";
import { NetworkRoutingPeerCount } from "@/modules/control-center/NetworkRoutingPeerCount";
import { ControlCenterCurrentUserBadge } from "@/modules/control-center/user/ControlCenterCurrentUserBadge";
@@ -61,13 +44,13 @@ import { EDGE_TYPES } from "@/modules/control-center/utils/edges";
import {
getFirstGroup,
getPolicyProtocolAndPortText,
getResourcePolicyByGroups,
getResourcePolicyByGroups
} from "@/modules/control-center/utils/helpers";
import {
applyD3ForceLayout,
applyD3HierarchicalLayout,
DEFAULT_MAX_ZOOM,
DEFAULT_MIN_ZOOM,
DEFAULT_MIN_ZOOM
} from "@/modules/control-center/utils/layouts";
import { NODE_TYPES } from "@/modules/control-center/utils/nodes";
@@ -75,9 +58,7 @@ export default function ControlCenter() {
return (
<ReactFlowProvider>
<PoliciesProvider>
<AIProvidersProvider>
<ControlCenterView />
</AIProvidersProvider>
<ControlCenterView />
</PoliciesProvider>
</ReactFlowProvider>
);
@@ -117,31 +98,6 @@ function ControlCenterView() {
"/users?service_user=false",
);
// Agent-network data feeds the provider / agent-policy nodes that
// overlay the Group and Peer views. Gated on the feature flag so we
// don't hit these endpoints when the feature is disabled — the graph
// then behaves exactly as it did without agent-network.
const { data: agentProviders } = useFetchApi<APIProvider[]>(
"/agent-network/providers",
true,
true,
isAgentNetworkEnabled(),
);
const { data: agentPolicies } = useFetchApi<APIPolicy[]>(
"/agent-network/policies",
true,
true,
isAgentNetworkEnabled(),
);
// providerById lets the overlay look up a Provider's display payload
// by id without re-deriving it from the array on every call.
const providerById = useMemo(() => {
const m = new Map<string, APIProvider>();
(agentProviders ?? []).forEach((p) => m.set(p.id, p));
return m;
}, [agentProviders]);
const isLoading =
isPoliciesLoading ||
isPeersLoading ||
@@ -161,17 +117,6 @@ function ControlCenterView() {
const [policyModalOpen, setPolicyModalOpen] = useState(false);
// Agent-network policy nodes (id prefix `agent-policy-`) open a
// different modal than access-control policies — they're managed by
// the AIProvidersProvider context and use AgentPolicyModal.
const [selectedAgentPolicy, setSelectedAgentPolicy] = useState("");
const [agentPolicyModalOpen, setAgentPolicyModalOpen] = useState(false);
const { policies: agentPolicyDomain } = useAIProviders();
const currentAgentPolicy = useMemo(
() => agentPolicyDomain?.find((p) => p.id === selectedAgentPolicy),
[agentPolicyDomain, selectedAgentPolicy],
);
const networkOptions: SelectOption[] = useMemo(() => {
let allNetworks = sortBy(
networks?.map(
@@ -552,16 +497,6 @@ function ControlCenterView() {
addDestinationResourceNodes(policy, allNodes, allEdges);
});
// Agent-network overlay: append every Provider this group can reach
// via an agent-network policy, with a direct edge from the source
// group node. No-op when the feature is off (agentPolicies is empty).
addAgentNetworkProviderNodes(
groupId,
`select-group-node`,
allNodes,
allEdges,
);
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "group", {
policy: { width: 500, spacing: 60 },
destinationGroup: { width: 1000, spacing: 100 },
@@ -569,92 +504,6 @@ function ControlCenterView() {
});
};
// addAgentNetworkProviderNodes walks every agent-network policy that
// authorises `groupId` and lays out:
//
// sourceNodeId → agent-policy-<policyId> → provider-<providerId>
//
// for each destination provider on each matching policy, mirroring the
// access-control layout. Idempotent — node/edge ids are stable so the
// same provider/policy referenced from multiple groups isn't
// duplicated. Returns immediately when there are no agent policies
// (i.e. the feature is disabled or unused).
const addAgentNetworkProviderNodes = (
groupId: string,
sourceNodeId: string,
allNodes: Node[],
allEdges: Edge[],
) => {
if (!agentPolicies || agentPolicies.length === 0) return;
const matching = agentPolicies.filter((p) =>
(p.source_groups ?? []).includes(groupId),
);
if (matching.length === 0) return;
matching.forEach((policy) => {
const dests = policy.destination_provider_ids ?? [];
if (dests.length === 0) return;
const policyNodeId = `agent-policy-${policy.id}`;
if (!allNodes.some((n) => n.id === policyNodeId)) {
allNodes.push({
id: policyNodeId,
type: "agentPolicyNode",
data: {
id: policy.id,
name: policy.name,
enabled: policy.enabled !== false,
},
position: { x: 0, y: 0 },
});
}
const sourceEdgeId = `agent-src-${groupId}-${policy.id}`;
if (!allEdges.some((e) => e.id === sourceEdgeId)) {
allEdges.push({
id: sourceEdgeId,
source: sourceNodeId,
target: policyNodeId,
type: "in",
data: { enabled: policy.enabled !== false, type: "bezier" },
});
}
dests.forEach((providerId) => {
const provider = providerById.get(providerId);
if (!provider) return;
const providerNodeId = `provider-${providerId}`;
if (!allNodes.some((n) => n.id === providerNodeId)) {
allNodes.push({
id: providerNodeId,
type: "providerNode",
data: {
id: provider.id,
providerId: provider.provider_id as AIProviderId,
name: provider.name,
upstreamUrl: provider.upstream_url,
enabled: provider.enabled,
},
position: { x: 0, y: 0 },
});
}
const destEdgeId = `agent-dst-${policy.id}-${providerId}`;
if (!allEdges.some((e) => e.id === destEdgeId)) {
allEdges.push({
id: destEdgeId,
source: policyNodeId,
target: providerNodeId,
type: "in",
data: {
enabled: policy.enabled !== false && provider.enabled !== false,
type: "bezier",
},
});
}
});
});
};
const applySingleNetworkView = (networkId: string) => {
if (isLoading) return;
if (layoutInitialized) return;
@@ -1141,18 +990,6 @@ function ControlCenterView() {
addDestinationResourceNodes(policy, allNodes, allEdges);
});
// Agent-network overlay: union the providers reachable through any
// group this peer belongs to, connecting each from the peer
// selector node. No-op when the feature is off.
peerGroups.forEach((g) => {
addAgentNetworkProviderNodes(
g.id ?? "",
`select-peer-node`,
allNodes,
allEdges,
);
});
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "peer", {
policy: { width: 500, spacing: 60 },
destinationGroup: { width: 1000, spacing: 100 },
@@ -1770,21 +1607,8 @@ function ControlCenterView() {
selectedUser,
isLoading,
layoutInitialized,
// Re-run the layout when agent-network data lands so the provider /
// agent-policy nodes get added even if their fetch resolved after
// the initial render. Stable (undefined) when the feature is off.
agentPolicies,
agentProviders,
]);
// When the agent-network fetch resolves, drop layoutInitialized so the
// layout effect re-runs and incorporates the freshly loaded
// providers/policies (otherwise the `if (layoutInitialized) return`
// gate short-circuits it).
useEffect(() => {
setLayoutInitialized(false);
}, [agentPolicies, agentProviders]);
const resetView = () => {
setLayoutInitialized(false);
};
@@ -1828,7 +1652,6 @@ function ControlCenterView() {
_node.type === "groupNode" || _node.type === "sourceGroupNode";
const isDestinationNode = _node.type === "destinationGroupNode";
const isPolicyNode = _node.type === "policyNode";
const isAgentPolicyNode = _node.type === "agentPolicyNode";
const networkId = isNetworkNode ? _node.id.replace("network-", "") : "";
const groupId = isGroupNode ? _node.id.replace("group-", "") : "";
@@ -1836,9 +1659,6 @@ function ControlCenterView() {
? _node.id.replace("group-", "")
: "";
const policyId = isPolicyNode ? _node.id.replace("policy-", "") : "";
const agentPolicyId = isAgentPolicyNode
? _node.id.replace("agent-policy-", "")
: "";
if (networkId && currentView === FlowView.NETWORKS) {
onNetworkSelect(networkId);
@@ -1855,10 +1675,6 @@ function ControlCenterView() {
setSelectedPolicy(policyId);
setPolicyModalOpen(true);
}
if (agentPolicyId) {
setSelectedAgentPolicy(agentPolicyId);
setAgentPolicyModalOpen(true);
}
},
[onNetworkSelect, onGroupSelect, onDestinationGroupSelect, currentView],
);
@@ -1888,16 +1704,6 @@ function ControlCenterView() {
onOpenChange={setPolicyModalOpen}
/>
)}
{currentAgentPolicy && agentPolicyModalOpen && (
<AgentPolicyModal
open={agentPolicyModalOpen}
onOpenChange={(o) => {
setAgentPolicyModalOpen(o);
if (!o) setSelectedAgentPolicy("");
}}
policy={currentAgentPolicy}
/>
)}
<div style={{ width: "100%", height: "100%" }} className={"relative"}>
{currentView === FlowView.PEERS &&
!isPeersLoading &&
@@ -1992,10 +1798,7 @@ function ControlCenterView() {
<FlowSelector value={currentView} onChange={onViewChange} />
)}
{/* Networks is dropped as a top-level pivot in the
agent-network repackaging — keep the dropdown + per-network
chrome for everyone else so flag-off behaviour is unchanged. */}
{!isAgentNetworkOnly() && currentView === "networks" && (
{currentView === "networks" && (
<div className={"w-64"}>
<SelectDropdown
variant={"secondary"}
@@ -2011,7 +1814,7 @@ function ControlCenterView() {
</div>
)}
{!isAgentNetworkOnly() && selectedNetwork && currentNetwork && (
{selectedNetwork && currentNetwork && (
<NetworkRoutingPeerCount network={currentNetwork} />
)}
</div>
+4 -154
View File
@@ -1,155 +1,5 @@
"use client";
import { redirect } from "next/navigation";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { lazy, Suspense, useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import useDistributorRedirect from "@/cloud/distributor/useDistributorRedirect";
import { useBypassedPeers } from "@/cloud/edr/useBypass";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import type { PeersTableKind } from "@/modules/peers/PeersTable";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function PeersPage() {
const { isRestricted } = usePermissions();
const { isLoading: isDistributorRedirecting } = useDistributorRedirect();
if (isDistributorRedirecting) return <FullScreenLoading />;
return (
<PageContainer>
{isRestricted ? (
<PeersBlockedView />
) : (
// Suspense boundary required because PeersView reads useSearchParams,
// which the static export build otherwise rejects.
<Suspense fallback={<FullScreenLoading />}>
<PeersProvider>
<PeersView />
</PeersProvider>
</Suspense>
)}
</PageContainer>
);
}
function PeersView() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
// The selected subset is URL-backed (?kind=), so it survives remounts (e.g.
// SWR revalidate-on-focus unmounting the page) and is shareable. No param =
// unselected = all peers.
const kindParam = searchParams?.get("kind");
const kind: PeersTableKind | undefined =
kindParam === "servers"
? "servers"
: kindParam === "users"
? "users"
: undefined;
const onKindChange = (next: PeersTableKind | undefined) => {
const params = new URLSearchParams(searchParams?.toString() ?? "");
if (next) params.set("kind", next);
else params.delete("kind");
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
};
const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers();
const { isBypassed } = useBypassedPeers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
// The kind filter classifies peers by whether their owner is a real user vs
// a service/no-user, so we must wait until both peers and users have loaded
// before joining them — otherwise peers temporarily render with
// peer.user === undefined and get misclassified.
const isLoading = isPeersLoading || isUsersLoading;
const peersWithUser = useMemo(() => {
if (!peers || !users) return undefined;
return peers.map((peer) => ({
...peer,
user: users.find((u) => u.id === peer.user_id),
force_approved: peer.id ? isBypassed(peer.id) : false,
}));
}, [peers, users, isBypassed]);
return (
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/peers"}
label={"Peers"}
icon={<PeerIcon size={13} />}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>Peers</h1>
<Paragraph>
User devices and headless machines, such as servers and autonomous agents, connected to your network.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
kind={kind}
onKindChange={onKindChange}
/>
</Suspense>
</>
);
}
function PeersBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new device to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird and log in using your email account.
After that you should be connected. If you have further questions
check out our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent header={false} footer={false} isUserDevice />
</div>
</div>
</div>
);
}
export default function PeersIndex() {
redirect("/peers/users");
}
+128 -5
View File
@@ -1,7 +1,130 @@
import { redirect } from "next/navigation";
"use client";
// User Devices and Servers are merged into a single /peers table with an
// in-page switch. Keep the old route as a redirect that opens the right tab.
export default function ServersRedirect() {
redirect("/peers?kind=servers");
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense, useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import { useBypassedPeers } from "@/cloud/edr/useBypass";
import useDistributorRedirect from "@/cloud/distributor/useDistributorRedirect";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function ServersPage() {
const { isRestricted } = usePermissions();
const { isLoading: isDistributorRedirecting } = useDistributorRedirect();
if (isDistributorRedirecting) return <FullScreenLoading />;
return (
<PageContainer>
{isRestricted ? (
<ServersBlockedView />
) : (
<PeersProvider>
<ServersView />
</PeersProvider>
)}
</PageContainer>
);
}
function ServersView() {
const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers();
const { isBypassed } = useBypassedPeers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
// The kind filter classifies peers by whether their owner is a real
// user vs a service/no-user, so we must wait until both peers and
// users have loaded before joining them — otherwise peers temporarily
// render with peer.user === undefined and get misclassified.
const isLoading = isPeersLoading || isUsersLoading;
const peersWithUser = useMemo(() => {
if (!peers || !users) return undefined;
return peers.map((peer) => ({
...peer,
user: users.find((u) => u.id === peer.user_id),
force_approved: peer.id ? isBypassed(peer.id) : false,
}));
}, [peers, users, isBypassed]);
return (
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/peers/servers"}
label={"Servers"}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>Servers</h1>
<Paragraph>
Servers, VMs, autonomous agents and other unattended machines with no
user behind them, typically enrolled with a setup key.{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
kind={"servers"}
/>
</Suspense>
</>
);
}
function ServersBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new server to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird on the server and enroll it using a
setup key. If you have further questions check out our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent header={false} footer={false} isUserDevice={false} />
</div>
</div>
</div>
);
}
+127 -5
View File
@@ -1,7 +1,129 @@
import { redirect } from "next/navigation";
"use client";
// User Devices and Servers are merged into a single /peers table with an
// in-page switch. Keep the old route as a redirect that opens the right tab.
export default function UserDevicesRedirect() {
redirect("/peers?kind=users");
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense, useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import { useBypassedPeers } from "@/cloud/edr/useBypass";
import useDistributorRedirect from "@/cloud/distributor/useDistributorRedirect";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function UserDevicesPage() {
const { isRestricted } = usePermissions();
const { isLoading: isDistributorRedirecting } = useDistributorRedirect();
if (isDistributorRedirecting) return <FullScreenLoading />;
return (
<PageContainer>
{isRestricted ? (
<UserDevicesBlockedView />
) : (
<PeersProvider>
<UserDevicesView />
</PeersProvider>
)}
</PageContainer>
);
}
function UserDevicesView() {
const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers();
const { isBypassed } = useBypassedPeers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
// The kind filter classifies peers by whether their owner is a real
// user vs a service/no-user, so we must wait until both peers and
// users have loaded before joining them — otherwise peers temporarily
// render with peer.user === undefined and get misclassified.
const isLoading = isPeersLoading || isUsersLoading;
const peersWithUser = useMemo(() => {
if (!peers || !users) return undefined;
return peers.map((peer) => ({
...peer,
user: users.find((u) => u.id === peer.user_id),
force_approved: peer.id ? isBypassed(peer.id) : false,
}));
}, [peers, users, isBypassed]);
return (
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/peers/users"}
label={"User Devices"}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>User Devices</h1>
<Paragraph>
Laptops, phones and other personal devices with a user behind them,
typically added when the user signs in with SSO.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
kind={"users"}
/>
</Suspense>
</>
);
}
function UserDevicesBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new device to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird and log in using your email account.
After that you should be connected. If you have further questions
check out our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent header={false} footer={false} isUserDevice />
</div>
</div>
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function AgentNetworkIcon(props: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
d="M12 2L13.5 7L18.5 8.5L13.5 10L12 15L10.5 10L5.5 8.5L10.5 7L12 2Z"
fill="currentColor"
/>
<path
d="M18 14L18.75 16.25L21 17L18.75 17.75L18 20L17.25 17.75L15 17L17.25 16.25L18 14Z"
fill="currentColor"
/>
<path
d="M6 14L6.6 15.8L8.4 16.4L6.6 17L6 18.8L5.4 17L3.6 16.4L5.4 15.8L6 14Z"
fill="currentColor"
/>
</svg>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import Button from "@components/Button";
import Image from "next/image";
import * as React from "react";
import { ArrowRight } from "lucide-react";
import NetBirdLogoFull from "@/assets/netbird-full.svg";
type Props = {
onLogin: () => void;
};
export const LoginScreen = ({ onLogin }: Props) => {
return (
<div className={"flex w-screen h-screen bg-nb-gray-950"}>
<div
className={
"relative flex flex-col w-full lg:w-1/2 px-8 sm:px-16 lg:px-24"
}
>
<div className={"pt-10"}>
<Image src={NetBirdLogoFull} height={24} alt={"NetBird Logo"} />
</div>
<div className={"flex flex-col justify-center flex-1 max-w-md"}>
<h1 className={"text-3xl font-medium text-white mb-2"}>
Welcome back
</h1>
<p className={"text-nb-gray-300 mb-8"}>
Sign in to access your NetBird network.
</p>
<Button variant={"primary"} size={"lg"} onClick={onLogin}>
Sign in
<ArrowRight size={16} />
</Button>
<p className={"text-sm text-nb-gray-400 mt-8"}>
Don&apos;t have an account yet?{" "}
<a
href={"https://netbird.io/pricing"}
target={"_blank"}
rel={"noreferrer"}
className={"text-netbird underline underline-offset-4"}
>
Get started
</a>
</p>
</div>
</div>
<div
className={
"hidden lg:flex flex-col items-center justify-center w-1/2 bg-nb-gray-920 border-l border-nb-gray-900 px-16"
}
>
<div
className={
"relative w-full max-w-md rounded-2xl bg-nb-gray-950 border border-nb-gray-900 p-6 shadow-xl"
}
>
<div className={"flex items-center justify-between mb-6"}>
<span className={"text-sm text-nb-gray-300"}>Connected peers</span>
<span className={"flex items-center gap-2 text-xs text-nb-gray-400"}>
<span className={"w-2 h-2 rounded-full bg-netbird"} /> Active
<span className={"w-2 h-2 rounded-full bg-nb-blue ml-2"} /> Idle
</span>
</div>
<div className={"flex flex-col gap-3"}>
{[90, 76, 64, 48, 33, 22].map((w, i) => (
<div key={i} className={"h-3 w-full rounded-full bg-nb-gray-900"}>
<div
className={
i % 2 === 0
? "h-3 rounded-full bg-netbird"
: "h-3 rounded-full bg-nb-blue"
}
style={{ width: `${w}%` }}
/>
</div>
))}
</div>
</div>
<p className={"text-sm text-nb-gray-400 mt-10"}>
Secure networking, zero config
</p>
<h2
className={
"text-2xl font-medium text-white text-center mt-2 max-w-md"
}
>
Connect your devices into a secure private network in minutes.
</h2>
</div>
</div>
);
};
+4 -13
View File
@@ -2,6 +2,7 @@ import { OidcSecure, useOidc } from "@axa-fr/react-oidc";
import { usePathname } from "next/navigation";
import * as React from "react";
import { useEffect } from "react";
import { LoginScreen } from "@/auth/LoginScreen";
const QUERY_PARAMS_KEY = "netbird-query-params";
@@ -72,19 +73,9 @@ export const SecureProvider = ({ children }: Props) => {
}
}, [isAuthenticated, currentPath]);
useEffect(() => {
let timeout: NodeJS.Timeout | undefined = undefined;
if (!isAuthenticated) {
timeout = setTimeout(async () => {
if (!isAuthenticated) {
await login(currentPath);
}
}, 1500);
}
return () => {
clearTimeout(timeout);
};
}, [currentPath, isAuthenticated, login]);
if (!isAuthenticated) {
return <LoginScreen onLogin={() => login(currentPath)} />;
}
return (
<>
+4 -11
View File
@@ -11,6 +11,7 @@ import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useAccount } from "@/modules/account/useAccount";
const config = loadConfig();
const formsApiUrl = `https://api.hsforms.com/submissions/v3/integration/submit/${config.hubspotPortalId}`; // Hubspot v2 Forms API
const detectDeviceType = () => {
if (typeof navigator === "undefined") {
@@ -141,10 +142,6 @@ type FormProps = {
onSuccess?: () => void;
hubspotQueryId?: string;
gaId?: string;
// portalId overrides config.hubspotPortalId for callers that target a fixed
// HubSpot portal regardless of the deployment's env (e.g. the self-hosted
// Agent Network signup, which always reports to NetBird's portal).
portalId?: string;
};
export const HubspotForm = ({
@@ -178,21 +175,17 @@ export const submitHubspotForm = async ({
fields,
hubspotQueryId,
gaId,
portalId,
}: FormProps) => {
try {
const resolvedPortalId = portalId || config.hubspotPortalId;
if (!resolvedPortalId || !id) return;
if (!config.hubspotPortalId || !id) return;
// Do not submit forms for excluded accounts, e.g., synthetic test users
const email = fields?.find((field) => field?.name === "email")?.value;
if (email && config.analyticsExcludedEmails?.includes(email)) {
if (email && config.analyticsExcludedEmails.includes(email)) {
return;
}
return fetch(
`https://api.hsforms.com/submissions/v3/integration/submit/${resolvedPortalId}/${id}`,
{
return fetch(formsApiUrl + `/${id}`, {
method: "POST",
body: JSON.stringify({
submittedAt: dayjs().valueOf(),
+8 -21
View File
@@ -58,24 +58,6 @@ const isEqualDateRange = (a: DateRange | undefined, b: DateRange) => {
return aFromDay === bFromDay && aToDay === bToDay;
};
// dateRangePresetLabel returns the human label of the matching quick-range
// preset (e.g. "Last 14 Days", "Last Month") for a value, or null when the
// value is a custom range (or empty). Shared so date-filter chips read the same
// way as the picker's own button.
export function dateRangePresetLabel(
value: DateRange | undefined,
): string | null {
if (!value?.from && !value?.to) return null;
if (isEqualDateRange(value, defaultRanges.allTime)) return "All Time";
if (isEqualDateRange(value, defaultRanges.lastMonth)) return "Last Month";
if (isEqualDateRange(value, defaultRanges.last14Days)) return "Last 14 Days";
if (isEqualDateRange(value, defaultRanges.last2Days)) return "Last 2 Days";
if (isEqualDateRange(value, defaultRanges.last7Days)) return "Last 7 Days";
if (isEqualDateRange(value, defaultRanges.yesterday)) return "Yesterday";
if (isEqualDateRange(value, defaultRanges.today)) return "Today";
return null;
}
export function DatePickerWithRange({
className,
value,
@@ -97,14 +79,19 @@ export function DatePickerWithRange({
const displayDateValue = useMemo(() => {
if (!value) return "Select date range";
const preset = dateRangePresetLabel(value);
if (preset) return preset;
if (isActive.allTime) return "All Time";
if (isActive.lastMonth) return "Last Month";
if (isActive.last14Days) return "Last 14 Days";
if (isActive.last2Days) return "Last 2 Days";
if (isActive.last7Days) return "Last 7 Days";
if (isActive.yesterday) return "Yesterday";
if (isActive.today) return "Today";
if (!value.to) return dayjs(value.from).format("MMM DD, YYYY").toString();
return `${dayjs(value.from).format("MMM DD, YYYY")} - ${dayjs(
value.to,
).format("MMM DD, YYYY")}`;
}, [value]);
}, [value, isActive]);
const [calendarOpen, setCalendarOpen] = useState(false);
Loaded 20 of 72 files, more files were not shown because too many files have changed in this diff. Show more