Compare commits

..
Author SHA1 Message Date
bcmmbaga 8a75b0de3e implement workspace one edr 2026-06-25 21:25:49 +03:00
234 changed files with 3868 additions and 46478 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
+3 -38
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
@@ -141,18 +137,8 @@ CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' |
CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
# upgrade-insecure-requests tells the browser to rewrite every http subresource,
# fetch and WebSocket to https/wss. That is correct for https deployments but breaks
# plain-http ones (e.g. local or self-hosted over http), where it would rewrite the
# working http/ws endpoints to unreachable https/wss. Emit it only when the backend
# is served over https, matching the ws:// vs wss:// scheme logic above.
CSP_UPGRADE_INSECURE=" upgrade-insecure-requests;"
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* || "$AUTH_AUTHORITY" == http://* ]]; then
CSP_UPGRADE_INSECURE=""
fi
# Update CSP in nginx config
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';$CSP_UPGRADE_INSECURE"
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
echo "CSP header: $CSP_HEADER"
@@ -163,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"
@@ -171,25 +157,4 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
cp "$f" "$f".copy
envsubst "$ENV_STR" < "$f".copy > "$f"
rm "$f".copy
done
# Reload nginx so the patched CSP header takes effect.
# supervisord starts nginx (priority 100) before this script (priority 201) and
# never reloads it, so without this nginx keeps serving the static default.conf
# CSP that has no connect-src, breaking OIDC discovery over plain HTTP.
reloaded=false
for i in $(seq 1 10); do
if nginx -s reload 2>/dev/null; then
echo "Reloaded nginx to apply updated CSP configuration"
reloaded=true
break
fi
echo "Waiting for nginx to be ready before reload (attempt $i)..."
sleep 1
done
if [[ "$reloaded" != true ]]; then
echo "Failed to reload nginx after patching CSP header" >&2
nginx -t || true
exit 1
fi
done
-1
View File
@@ -218,7 +218,6 @@ await visitByNavigation(page, "Policies"); // Click child
| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` |
| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` |
| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` |
| Control Center | `control-center-draft.spec.ts`, `control-center-draft-connections.spec.ts`, `control-center-draft-matrix.spec.ts` | `@control-center` |
## Debugging
-14
View File
@@ -89,25 +89,11 @@ async function apiDelete(page: Page, path: string): Promise<void> {
});
}
async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.post(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
data,
});
return resp.json();
}
/** List all groups. */
export async function listGroups(page: Page): Promise<Group[]> {
return apiGet<Group[]>(page, "/groups");
}
/** Create a group directly via the API (test setup). */
export async function createGroup(page: Page, name: string): Promise<Group> {
return apiPost<Group>(page, "/groups", { name, peers: [] });
}
/** Delete a group by ID. */
export async function deleteGroup(page: Page, groupId: string) {
await apiDelete(page, `/groups/${groupId}`);
-200
View File
@@ -1,200 +0,0 @@
import { Page, Locator, expect } from "@playwright/test";
import { navigateTo } from "./auth";
export const CHANGES_KEY = "netbird-control-center-draft-changes";
export const CANVAS_KEY = "netbird-control-center-draft-canvas";
export async function openControlCenter(page: Page) {
await navigateTo(page, "/control-center");
// The canvas hides behind cc-prefit until the first fitView; wait for the
// pane itself so later mouse coordinates are meaningful.
await expect(page.locator(".react-flow__pane")).toBeVisible();
}
export async function enterDraft(page: Page) {
await page.getByTestId("cc-mode-draft").click();
// The toolbar slides in with a spring animation — waiting for the Add
// button also guarantees draft mode is fully active.
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
}
export async function exitDraftDiscarding(page: Page) {
const draftTab = page.getByTestId("cc-mode-draft");
if ((await draftTab.getAttribute("data-state")) !== "active") return;
await page.getByTestId("cc-mode-live").click();
const confirm = page.getByTestId("confirmation.confirm");
if (await confirm.isVisible({ timeout: 1000 }).catch(() => false)) {
await confirm.click();
}
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
}
/** Clears draft localStorage and makes sure we're on a clean live canvas. */
export async function resetDraftState(page: Page) {
await openControlCenter(page);
await page.evaluate(
([changes, canvas]) => {
localStorage.removeItem(changes);
localStorage.removeItem(canvas);
},
[CHANGES_KEY, CANVAS_KEY],
);
await exitDraftDiscarding(page);
}
export async function readDraftChanges(page: Page): Promise<any[]> {
const raw = await page.evaluate(
(key) => localStorage.getItem(key),
CHANGES_KEY,
);
return raw ? JSON.parse(raw) : [];
}
async function centerOf(locator: Locator) {
const box = await locator.boundingBox();
if (!box) throw new Error("element has no layout");
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
async function mouseDrag(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
// Intermediate moves so drag thresholds and drop-target detection see a
// realistic pointer path.
const steps = 12;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
from.x + ((to.x - from.x) * i) / steps,
from.y + ((to.y - from.y) * i) / steps,
);
}
await page.mouse.up();
}
/**
* Drags a components-panel template onto the canvas. The panel uses a custom
* pointer-based drag (not HTML5 DnD), so raw mouse events are required.
*/
export async function dragTemplateToCanvas(
page: Page,
templateTestId: string,
target?: { x: number; y: number },
) {
const item = page.getByTestId(templateTestId);
if (!(await item.isVisible().catch(() => false))) {
await page.getByTestId("cc-toolbar-add").click();
await expect(item).toBeVisible();
}
const pane = page.locator(".react-flow__pane");
const paneBox = await pane.boundingBox();
if (!paneBox) throw new Error("canvas pane not laid out");
const to = target ?? {
// Land right of the panel, roughly canvas center.
x: paneBox.x + paneBox.width * 0.6,
y: paneBox.y + paneBox.height * 0.5,
};
await mouseDrag(page, await centerOf(item), to);
// Close the panel so it doesn't cover nodes for the next interaction.
await page.keyboard.press("Escape");
}
export function canvasNode(page: Page, dataIdPrefix: string) {
return page.locator(`.react-flow__node[data-id^="${dataIdPrefix}"]`);
}
/**
* Connects two canvas nodes by dragging from a connect handle onto the target
* node (FullAreaTargetHandle spans the whole node). Handle side matters:
* dragging from `sr` (right) puts the source node on a policy's SOURCE side,
* from `sl` (left) on the DESTINATION side. Resources only have `sl`.
*/
export async function connectNodes(
page: Page,
source: Locator,
target: Locator,
handleSide: "sr" | "sl" = "sr",
) {
// Connect handles fade in on node hover.
await source.hover();
const handle = source.locator(
`.react-flow__handle[data-handleid="${handleSide}-connect"]`,
);
await expect(handle).toBeAttached();
await mouseDrag(page, await centerOf(handle), await centerOf(target));
}
/** Positions relative to the canvas pane (fractions of width/height). */
export async function panePoint(page: Page, fx: number, fy: number) {
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("canvas pane not laid out");
return { x: pane.x + pane.width * fx, y: pane.y + pane.height * fy };
}
/** Right-clicks empty canvas and clicks a creation item ("New Group", …). */
export async function createViaCanvasMenu(
page: Page,
itemText: string,
at?: { fx: number; fy: number },
) {
const point = await panePoint(page, at?.fx ?? 0.6, at?.fy ?? 0.5);
await page.mouse.move(point.x, point.y);
await page.mouse.click(point.x, point.y, { button: "right" });
const menu = page.getByTestId("cc-canvas-context-menu");
await expect(menu).toBeVisible();
await menu.getByRole("button", { name: itemText }).click();
}
export async function readDraftCanvas(page: Page): Promise<any | null> {
const raw = await page.evaluate(
(key) => localStorage.getItem(key),
CANVAS_KEY,
);
return raw ? JSON.parse(raw) : null;
}
/** Drags one canvas node onto another (e.g. a peer into a group). */
export async function dragNodeOnto(page: Page, node: Locator, target: Locator) {
await mouseDrag(page, await centerOf(node), await centerOf(target));
}
/** Right-clicks a node and clicks the given context-menu item. */
export async function clickContextMenuItem(
page: Page,
node: Locator,
itemText: string,
) {
await node.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible();
await menu.getByRole("button", { name: itemText, exact: true }).click();
}
export function reviewButton(page: Page) {
return page.getByTestId("cc-draft-review");
}
export async function expectChangeCount(page: Page, count: number) {
if (count === 0) {
await expect(reviewButton(page)).toBeDisabled();
} else {
await expect(reviewButton(page)).toBeEnabled();
await expect(reviewButton(page)).toContainText(String(count));
}
}
/**
* Walks the create-policy modal wizard (policy → posture checks → general)
* and submits. Assumes both sides are already filled in.
*/
export async function submitCreatePolicyModal(page: Page, name?: string) {
await page.getByTestId("policy-continue").click();
await page.getByTestId("policy-continue").click();
if (name) {
await page.getByTestId("policy-name").fill(name);
}
await page.getByTestId("submit-policy").click();
}
@@ -1,353 +0,0 @@
import { expect } from "@playwright/test";
import { test } from "../helpers/fixtures";
import { createGroup, deleteGroup, listGroups } from "../helpers/api";
import { generateRandomName } from "../helpers/utils";
import {
canvasNode,
clickContextMenuItem,
connectNodes,
dragNodeOnto,
dragTemplateToCanvas,
enterDraft,
expectChangeCount,
readDraftChanges,
resetDraftState,
reviewButton,
submitCreatePolicyModal,
} from "../helpers/control-center";
test.describe.serial(
"Control Center Draft Connections @control-center",
() => {
test.beforeEach(async ({ dashboardAsOwner: page }) => {
await resetDraftState(page);
});
test("Should create a policy by connecting two groups", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
// Two groups, placed apart so the connect drag has a clear path.
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await expect(groups).toHaveCount(2);
await expectChangeCount(page, 2);
// Drag from the first group's right handle onto the second group —
// the create-policy modal opens prefilled with both sides.
await connectNodes(page, groups.nth(0), groups.nth(1));
await expect(page.getByTestId("policy-name")).toBeVisible();
await submitCreatePolicyModal(page);
// A complete policy lands on canvas with two edges and a tracked
// create-policy change (2 groups + 1 policy = 3).
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
await expect(page.locator(".react-flow__edge")).toHaveCount(2);
await expectChangeCount(page, 3);
const changes = await readDraftChanges(page);
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(1);
});
test("Should keep a placeholder-peer policy out of the changeset until the peer installs", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-peer-user-device", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const group = canvasNode(page, "group-new-").first();
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
await expect(peer).toHaveCount(1);
await connectNodes(page, peer, group);
await expect(page.getByTestId("policy-name")).toBeVisible();
await submitCreatePolicyModal(page);
// The policy node and edges exist on canvas…
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
// …but a policy referencing an uninstalled placeholder peer is
// incomplete: only the group create and the pending peer install are
// tracked.
const changes = await readDraftChanges(page);
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(0);
expect(changes.filter((c) => c.type === "install-peer")).toHaveLength(1);
await expectChangeCount(page, 2);
});
test("Should rename a group and propagate the name into the policy changeset", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await submitCreatePolicyModal(page);
await expectChangeCount(page, 3);
// Rename the source group via its context menu.
const sourceGroup = groups.nth(0);
const newName = generateRandomName("cc-renamed-");
await clickContextMenuItem(page, sourceGroup, "Rename");
await page.getByTestId("cc-rename-input").fill(newName);
await page.getByTestId("cc-rename-submit").click();
// The canvas node shows the new name…
await expect(sourceGroup).toContainText(newName);
// …the rename folded into the create-group entry (still 3 changes)…
await expectChangeCount(page, 3);
const changes = await readDraftChanges(page);
expect(
changes.some((c) => c.type === "create-group" && c.name === newName),
).toBe(true);
// …and the policy's group reference followed the rename.
const policy = changes.find((c) => c.type === "create-policy");
const rule = policy.policy.rules[0];
const referenced = [...(rule.sources ?? []), ...(rule.destinations ?? [])]
.map((g) => (typeof g === "string" ? g : g.name));
expect(referenced).toContain(newName);
// Reopening the policy editor must show the renamed group SELECTED in
// the group selector (the modal seeds from the canvas policy node).
const policyNode = canvasNode(page, "policy-new-");
await clickContextMenuItem(page, policyNode, "Edit");
await expect(page.getByTestId("source-group-selector")).toContainText(
newName,
);
await page.keyboard.press("Escape");
});
test("Should create a group inline in the policy modal and show it on canvas", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await expect(page.getByTestId("policy-name")).toBeVisible();
// Type a brand-new group name into the destination selector and add it.
const inlineName = generateRandomName("cc-inline-");
await page.getByTestId("destination-group-selector").click();
await page
.getByTestId("destination-group-selector-search")
.fill(inlineName);
await page.keyboard.press("Enter");
await page.keyboard.press("Escape"); // close the selector dropdown
await submitCreatePolicyModal(page);
// The inline group exists on canvas as its own draft node and as a
// create-group change.
await expect(
page.locator(".react-flow__node", { hasText: inlineName }),
).toHaveCount(1);
const changes = await readDraftChanges(page);
expect(
changes.some(
(c) => c.type === "create-group" && c.name === inlineName,
),
).toBe(true);
});
test("Should edit a draft policy and reflect the change on the canvas", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await submitCreatePolicyModal(page);
const policyNode = canvasNode(page, "policy-new-");
await expect(policyNode).toHaveCount(1);
// Edit via context menu, rename the policy on the General tab.
const newName = generateRandomName("cc-policy-");
await clickContextMenuItem(page, policyNode, "Edit");
await expect(page.getByTestId("policy-name")).toBeVisible();
await page.getByTestId("policy-name").fill(newName);
await page.getByTestId("submit-policy").click();
// Canvas node text and the pending create-policy entry both updated.
await expect(policyNode).toContainText(newName);
const changes = await readDraftChanges(page);
const policy = changes.find((c) => c.type === "create-policy");
expect(policy.policy.name).toBe(newName);
});
test("Should remove a draft policy from the canvas without a confirmation", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await submitCreatePolicyModal(page);
await expectChangeCount(page, 3);
// Remove is canvas-only: no confirm dialog, pending create cancelled,
// endpoint groups stay on canvas.
const policyNode = canvasNode(page, "policy-new-");
await clickContextMenuItem(page, policyNode, "Remove");
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
await expect(policyNode).toHaveCount(0);
await expect(page.locator(".react-flow__edge")).toHaveCount(0);
await expect(groups).toHaveCount(2);
await expectChangeCount(page, 2);
});
test("Should drop a peer into a group and track the membership", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-peer-user-device", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const group = canvasNode(page, "group-new-").first();
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
await expect(peer).toHaveCount(1);
// Dragging the peer NODE onto the group absorbs it as a member: the
// peer node leaves the canvas and the group's counter increments.
await dragNodeOnto(page, peer, group);
await expect(peer).toHaveCount(0);
await expect(group).toContainText(/1\s*peer/i);
// The membership folded into the group's create entry.
const changes = await readDraftChanges(page);
const createGroupChange = changes.find(
(c) => c.type === "create-group",
);
expect(createGroupChange.peerIds).toHaveLength(1);
// group create + the peer's pending install step
await expectChangeCount(page, 2);
// The group's Details panel lists the dropped (placeholder) peer even
// though it isn't in the API peer list.
await clickContextMenuItem(page, group, "Details");
await expect(
page.getByText("User Device", { exact: false }).last(),
).toBeVisible();
await page.keyboard.press("Escape");
});
test("Should delete an existing group in draft and deploy the deletion", async ({
dashboardAsOwner: page,
}) => {
// Seed a real group via the API, then reload so the dashboard and the
// components panel know it.
const name = generateRandomName("cc-delete-");
const seeded = await createGroup(page, name);
await page.reload();
await expect(page.locator(".react-flow__pane")).toBeVisible();
await enterDraft(page);
// Pull the existing group onto the canvas from the components panel
// (search narrows the virtualized list so the row is rendered).
await page.getByTestId("cc-toolbar-add").click();
await page.getByPlaceholder(/Search components/).fill(name);
await dragTemplateToCanvas(page, `cc-panel-group-${seeded.id}`);
const groupNode = page.locator(
`.react-flow__node[data-id="group-${seeded.id}"]`,
);
await expect(groupNode).toHaveCount(1);
// Delete (not Remove) an existing group: confirmed, tracked, deployed.
await clickContextMenuItem(page, groupNode, "Delete");
await expect(
page.getByText(`Delete group "${name}"?`),
).toBeVisible();
await page.getByTestId("confirmation.confirm").click();
await expect(groupNode).toHaveCount(0);
await expectChangeCount(page, 1);
await reviewButton(page).click();
const deleteResponse = page.waitForResponse(
(resp) =>
resp.url().includes(`/api/groups/${seeded.id}`) &&
resp.request().method() === "DELETE",
{ timeout: 30_000 },
);
await page.getByTestId("cc-deploy").click({ force: true });
const response = await deleteResponse;
expect(response.status()).toBeLessThan(300);
const remaining = await listGroups(page);
expect(remaining.some((g) => g.id === seeded.id)).toBe(false);
});
test.afterAll(async ({ browser }) => {
// Safety net: clear any seeded groups left behind by failed runs.
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
const page = await context.newPage();
await page.goto("/peers");
const groups = await listGroups(page).catch(() => []);
for (const g of groups) {
if (g.name.startsWith("cc-delete-") || g.name.startsWith("cc-inline-")) {
await deleteGroup(page, g.id).catch(() => {});
}
}
await context.close();
});
},
);
File diff suppressed because it is too large. Load diff
-156
View File
@@ -1,156 +0,0 @@
import { Page, expect } from "@playwright/test";
import { test } from "../helpers/fixtures";
import { deleteGroup, listGroups } from "../helpers/api";
import {
CHANGES_KEY,
CANVAS_KEY,
canvasNode,
dragTemplateToCanvas,
enterDraft,
expectChangeCount,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
test.describe.serial("Control Center Draft Mode @control-center", () => {
test.beforeEach(async ({ dashboardAsOwner: page }) => {
// Every test starts from a clean live view: no leftover draft storage
// from a previous (possibly failed) test.
await resetDraftState(page);
});
test("Should enter and leave draft mode without changes", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await expect(page.getByTestId("cc-draft-cancel")).toBeVisible();
await expectChangeCount(page, 0);
// No pending changes — switching back to live must not ask to confirm.
await page.getByTestId("cc-mode-live").click();
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
});
test("Should add a draft group via drag and drop and track the change", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
await expect(canvasNode(page, "group-new-")).toHaveCount(1);
await expectChangeCount(page, 1);
// The changeset is persisted to localStorage on every update.
await expect
.poll(() =>
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
)
.toContain("create-group");
});
test("Should undo and redo the dropped group", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
const groupNode = canvasNode(page, "group-new-");
await expect(groupNode).toHaveCount(1);
// History snapshots are debounced (300ms) — wait for Undo to arm
// instead of clicking immediately.
const undo = page.getByTestId("cc-toolbar-undo");
await expect(undo).toBeEnabled();
await undo.click();
await expect(groupNode).toHaveCount(0);
await expectChangeCount(page, 0);
const redo = page.getByTestId("cc-toolbar-redo");
await expect(redo).toBeEnabled();
await redo.click();
await expect(groupNode).toHaveCount(1);
await expectChangeCount(page, 1);
});
test("Should restore the draft from storage after a reload", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
await expectChangeCount(page, 1);
// Wait for the canvas snapshot to land in storage before reloading.
await expect
.poll(() => page.evaluate((key) => localStorage.getItem(key), CANVAS_KEY))
.toContain("group-new-");
await page.reload();
await expect(page.locator(".react-flow__pane")).toBeVisible();
// A reload returns to live; re-entering draft restores the snapshot
// instead of rebuilding from the live view.
await enterDraft(page);
await expect(canvasNode(page, "group-new-")).toHaveCount(1);
await expectChangeCount(page, 1);
});
test("Should confirm before discarding draft changes", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
await expectChangeCount(page, 1);
// Cancelling the confirmation keeps the draft.
await page.getByTestId("cc-draft-cancel").click();
await expect(page.getByText("Discard draft changes?")).toBeVisible();
await page.getByTestId("confirmation.cancel").click();
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
await expectChangeCount(page, 1);
// Confirming discards and returns to live with storage cleared.
await page.getByTestId("cc-draft-cancel").click();
await page.getByTestId("confirmation.confirm").click();
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
await expect
.poll(() =>
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
)
.toBeNull();
});
test("Should deploy a created group to the live account", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
await expectChangeCount(page, 1);
await reviewButton(page).click();
await expect(page.getByText("Review & Deploy")).toBeVisible();
// The single pending change is a group create.
await expect(page.getByText("Create", { exact: true })).toBeVisible();
const createResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/api/groups") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("cc-deploy").click({ force: true });
const response = await createResponse;
expect([200, 201]).toContain(response.status());
const created = await response.json();
// Deploy exits the draft back to the rebuilt live view.
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
await expect
.poll(() =>
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
)
.toBeNull();
// The group really exists in the account — then clean it up.
const groups = await listGroups(page);
expect(groups.some((g) => g.id === created.id)).toBe(true);
await deleteGroup(page, created.id);
});
});
+46 -1914
View File
File diff suppressed because it is too large. Load diff
+2 -8
View File
@@ -18,8 +18,6 @@
"test:clean": "cd ./e2e/environment && sh clean-test-env.sh",
"test:dev": "cross-env APP_ENV=test next dev -p 1337",
"test": "npx playwright test --config=e2e/playwright.config.ts",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:ui": "npx playwright test --config=e2e/playwright.config.ts --ui",
"test:ci": "cross-env APP_ENV=test next build && npx playwright test --config=e2e/playwright.config.ts"
},
@@ -105,17 +103,13 @@
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@playwright/test": "^1.52.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/chroma-js": "^3.1.1",
"@types/js-cookie": "^3.0.6",
"@playwright/test": "^1.52.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.1.6",
"jsdom": "^29.1.1",
"postcss": "^8",
"prettier": "3.0.3",
"tailwindcss": "^3.4.17",
"vitest": "^4.1.10"
"tailwindcss": "^3.4.17"
}
}
+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,151 +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);
// Access-log view mode: flat per-request rows, or grouped by provider session.
// Each mode hits its own endpoint and the provider is remounted on toggle
// (keyed below) so the two response shapes never cross.
const [groupBySession, setGroupBySession] = useState(false);
// 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
key={groupBySession ? "sessions" : "flat"}
url={
groupBySession
? "/agent-network/access-log-sessions"
: "/agent-network/access-logs"
}
defaultPageSize={25}
defaultFilters={defaultAccessLogFilters}
>
<AgentAccessLogTable
grouped={groupBySession}
onGroupedChange={setGroupBySession}
/>
</ServerPaginationProvider>
</Suspense>
</TabsContent>
</Tabs>
</AIProvidersProvider>
</PeersProvider>
</GroupsProvider>
</RestrictedAccess>
</PageContainer>
);
}
File diff suppressed because it is too large. Load diff
+86 -2
View File
@@ -46,7 +46,6 @@ import {
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode";
import { EditPeerNameModal } from "@/modules/peers/EditPeerNameModal";
import React, { useMemo, useState } from "react";
import Skeleton from "react-loading-skeleton";
import { useSWRConfig } from "swr";
@@ -275,7 +274,7 @@ const PeerHeader = () => {
<PencilIcon size={16} />
</div>
</ModalTrigger>
<EditPeerNameModal
<EditNameModal
onSuccess={(newName) => {
updatePeer(newName).then(() => {
setName(newName);
@@ -740,6 +739,91 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
);
}
interface ModalProps {
onSuccess: (name: string) => void;
peer: Peer;
initialName: string;
}
function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
const [name, setName] = useState(initialName);
const isDisabled = useMemo(() => {
if (name === peer.name) return true;
const trimmedName = trim(name);
return trimmedName.length === 0;
}, [name, peer]);
const domainNamePreview = useMemo(() => {
let punyName = toASCII(name.toLowerCase());
punyName = punyName.replace(/[^a-z0-9]/g, "-");
let domain = "";
if (peer.dns_label) {
const labelList = peer.dns_label.split(".");
if (labelList.length > 1) {
labelList.splice(0, 1);
domain = "." + labelList.join(".");
}
}
return punyName + domain;
}, [name, peer]);
return (
<ModalContent maxWidthClass={"max-w-md"}>
<form>
<ModalHeader
title={"Edit Peer Name"}
description={"Set an easily identifiable name for your peer."}
color={"blue"}
/>
<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
placeholder={"e.g., AWS Servers"}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<Card className={"w-full px-6 pt-5 pb-4"}>
<Label>
<Globe size={15} />
Domain Name Preview
</Label>
<HelpText className={"mt-2"}>
If the domain name already exists, we add an increment number
suffix to it.
</HelpText>
<div className={"text-netbird text-sm break-all whitespace-normal"}>
{domainNamePreview}
</div>
</Card>
</div>
<ModalFooter className={"items-center"} separator={false}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => onSuccess(name)}
disabled={isDisabled}
type={"submit"}
>
Save
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
);
}
function EditableValue({
value,
canEdit,
Loaded 20 of 234 files, more files were not shown because too many files have changed in this diff. Show more