mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7fd24244d | ||
|
|
9230b3e4e3 | ||
|
|
19ad8d6806 | ||
|
|
38fc2970e6 | ||
|
|
4ec4d29e84 | ||
|
|
944d1b5bf6 | ||
|
|
c325c170ab |
No files matched your search
@@ -0,0 +1,77 @@
|
||||
#!/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}"
|
||||
@@ -18,6 +18,8 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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
|
||||
@@ -66,6 +66,8 @@ 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}
|
||||
@@ -91,6 +93,8 @@ 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
|
||||
@@ -149,7 +153,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_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_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"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
|
||||
Generated
+1
-21
@@ -172,7 +172,6 @@
|
||||
"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",
|
||||
@@ -1456,7 +1455,6 @@
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
@@ -3097,7 +3095,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -3107,7 +3104,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -3166,7 +3162,6 @@
|
||||
"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",
|
||||
@@ -3634,8 +3629,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.1",
|
||||
@@ -3674,7 +3668,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -4100,7 +4093,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4236,7 +4228,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -4754,7 +4745,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -5276,7 +5266,6 @@
|
||||
"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",
|
||||
@@ -5406,7 +5395,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -6791,7 +6779,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -7625,7 +7612,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -7833,7 +7819,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -7894,7 +7879,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -8767,7 +8751,6 @@
|
||||
"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",
|
||||
@@ -8934,7 +8917,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9087,7 +9069,6 @@
|
||||
"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"
|
||||
@@ -9415,7 +9396,6 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ export default function AccessControlPage() {
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Access Control Policies</h1>
|
||||
<Paragraph>
|
||||
Create rules to manage access in your network and define what peers
|
||||
can connect.{" "}
|
||||
Policies connect users and agents to your network resources,
|
||||
controlling what each identity can reach.{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"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}</>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"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's
|
||||
<code className={"font-mono"}> base_url</code>, Anthropic'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's
|
||||
<code className={"font-mono"}> base_url</code>,
|
||||
Anthropic'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,15 @@ 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,
|
||||
@@ -19,10 +23,16 @@ 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";
|
||||
@@ -37,6 +47,13 @@ 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";
|
||||
@@ -44,13 +61,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";
|
||||
|
||||
@@ -58,7 +75,9 @@ export default function ControlCenter() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<PoliciesProvider>
|
||||
<ControlCenterView />
|
||||
<AIProvidersProvider>
|
||||
<ControlCenterView />
|
||||
</AIProvidersProvider>
|
||||
</PoliciesProvider>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
@@ -98,6 +117,31 @@ 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 ||
|
||||
@@ -117,6 +161,17 @@ 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(
|
||||
@@ -497,6 +552,16 @@ 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 },
|
||||
@@ -504,6 +569,92 @@ 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;
|
||||
@@ -990,6 +1141,18 @@ 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 },
|
||||
@@ -1607,8 +1770,21 @@ 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);
|
||||
};
|
||||
@@ -1652,6 +1828,7 @@ 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-", "") : "";
|
||||
@@ -1659,6 +1836,9 @@ 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);
|
||||
@@ -1675,6 +1855,10 @@ function ControlCenterView() {
|
||||
setSelectedPolicy(policyId);
|
||||
setPolicyModalOpen(true);
|
||||
}
|
||||
if (agentPolicyId) {
|
||||
setSelectedAgentPolicy(agentPolicyId);
|
||||
setAgentPolicyModalOpen(true);
|
||||
}
|
||||
},
|
||||
[onNetworkSelect, onGroupSelect, onDestinationGroupSelect, currentView],
|
||||
);
|
||||
@@ -1704,6 +1888,16 @@ 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 &&
|
||||
@@ -1798,7 +1992,10 @@ function ControlCenterView() {
|
||||
<FlowSelector value={currentView} onChange={onViewChange} />
|
||||
)}
|
||||
|
||||
{currentView === "networks" && (
|
||||
{/* 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" && (
|
||||
<div className={"w-64"}>
|
||||
<SelectDropdown
|
||||
variant={"secondary"}
|
||||
@@ -1814,7 +2011,7 @@ function ControlCenterView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNetwork && currentNetwork && (
|
||||
{!isAgentNetworkOnly() && selectedNetwork && currentNetwork && (
|
||||
<NetworkRoutingPeerCount network={currentNetwork} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,155 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default function PeersIndex() {
|
||||
redirect("/peers/users");
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +1,7 @@
|
||||
"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 { 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>
|
||||
);
|
||||
// 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");
|
||||
}
|
||||
@@ -1,129 +1,7 @@
|
||||
"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 { 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>
|
||||
);
|
||||
// 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");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ 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") {
|
||||
@@ -142,6 +141,10 @@ 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 = ({
|
||||
@@ -175,17 +178,21 @@ export const submitHubspotForm = async ({
|
||||
fields,
|
||||
hubspotQueryId,
|
||||
gaId,
|
||||
portalId,
|
||||
}: FormProps) => {
|
||||
try {
|
||||
if (!config.hubspotPortalId || !id) return;
|
||||
const resolvedPortalId = portalId || config.hubspotPortalId;
|
||||
if (!resolvedPortalId || !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(formsApiUrl + `/${id}`, {
|
||||
return fetch(
|
||||
`https://api.hsforms.com/submissions/v3/integration/submit/${resolvedPortalId}/${id}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
submittedAt: dayjs().valueOf(),
|
||||
|
||||
@@ -58,6 +58,24 @@ 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,
|
||||
@@ -79,19 +97,14 @@ export function DatePickerWithRange({
|
||||
const displayDateValue = useMemo(() => {
|
||||
if (!value) return "Select date range";
|
||||
|
||||
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";
|
||||
const preset = dateRangePresetLabel(value);
|
||||
if (preset) return preset;
|
||||
|
||||
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, isActive]);
|
||||
}, [value]);
|
||||
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import AddPeerButton from "@components/ui/AddPeerButton";
|
||||
import AddPeerDropdown from "@components/ui/AddPeerDropdown";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -8,17 +8,10 @@ import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
|
||||
type Props = {
|
||||
showBackground?: boolean;
|
||||
// When set, tailors the empty-state copy and threads isUserDevice
|
||||
// through AddPeerButton so the right Install NetBird flow opens:
|
||||
// true → User Devices empty state (browser/SSO flow, mobile tabs).
|
||||
// false → Servers empty state (setup-key flow, no mobile tabs).
|
||||
// undefined → legacy/global empty state (no kind preference).
|
||||
isUserDevice?: boolean;
|
||||
};
|
||||
|
||||
export const NoPeersGettingStarted = ({
|
||||
showBackground = true,
|
||||
isUserDevice,
|
||||
}: Readonly<Props>) => {
|
||||
return (
|
||||
<GetStartedTest
|
||||
@@ -35,7 +28,7 @@ export const NoPeersGettingStarted = ({
|
||||
"It looks like you don't have any connected machines.\n" +
|
||||
"Get started by adding one to your network."
|
||||
}
|
||||
button={<AddPeerButton isUserDevice={isUserDevice} />}
|
||||
button={<AddPeerDropdown />}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more in our{" "}
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface SelectOption {
|
||||
searchValue?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
// Optional section label. When any option carries a `group`, the
|
||||
// dropdown renders a header above each group and orders sections by
|
||||
// the first option that names them.
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface SelectDropdownProps {
|
||||
@@ -104,6 +108,25 @@ export function SelectDropdown({
|
||||
});
|
||||
}, [options, debouncedSearch]);
|
||||
|
||||
// When options carry a `group`, split them into ordered sections so a
|
||||
// header can render above each. Section order follows the first option
|
||||
// that names the group; empty groups (after search) drop out. Returns
|
||||
// null when no option is grouped so the flat render path is used.
|
||||
const groupedItems = React.useMemo(() => {
|
||||
if (!filteredItems.some((item) => item.group)) return null;
|
||||
const order: string[] = [];
|
||||
const byGroup = new Map<string, SelectOption[]>();
|
||||
for (const item of filteredItems) {
|
||||
const group = item.group ?? "";
|
||||
if (!byGroup.has(group)) {
|
||||
byGroup.set(group, []);
|
||||
order.push(group);
|
||||
}
|
||||
byGroup.get(group)?.push(item);
|
||||
}
|
||||
return order.map((group) => ({ group, items: byGroup.get(group) ?? [] }));
|
||||
}, [filteredItems]);
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className={"flex items-center gap-2"}>
|
||||
@@ -237,18 +260,52 @@ export function SelectDropdown({
|
||||
}}
|
||||
>
|
||||
<CommandGroup>
|
||||
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
|
||||
{filteredItems.map((option) => (
|
||||
<SelectDropdownItem
|
||||
option={option}
|
||||
toggle={toggle}
|
||||
key={option.value}
|
||||
iconSize={iconSize}
|
||||
showValue={showValues}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{groupedItems ? (
|
||||
groupedItems.map(({ group, items }) => (
|
||||
<div key={group || "_ungrouped"}>
|
||||
{group && (
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-nb-gray-400",
|
||||
compact ? "pt-1" : "pt-2",
|
||||
)}
|
||||
>
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-1 w-full",
|
||||
compact ? "pb-1" : "pb-2",
|
||||
)}
|
||||
>
|
||||
{items.map((option) => (
|
||||
<SelectDropdownItem
|
||||
option={option}
|
||||
toggle={toggle}
|
||||
key={option.value}
|
||||
iconSize={iconSize}
|
||||
showValue={showValues}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
|
||||
{filteredItems.map((option) => (
|
||||
<SelectDropdownItem
|
||||
option={option}
|
||||
toggle={toggle}
|
||||
key={option.value}
|
||||
iconSize={iconSize}
|
||||
showValue={showValues}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
|
||||
Loaded 20 of 70 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user