Compare commits

..
Author SHA1 Message Date
jnfrati 07ba28e40e Run playground requests without settings read access 2026-09-04 14:19:24 +02:00
jnfrati cfe797cba2 Register playground mocks before app startup 2026-09-04 13:43:29 +02:00
jnfrati 56a2e5f567 Declare the Playwright static server dependency 2026-09-04 13:16:17 +02:00
jnfrati 2727afd42d Allow time for the Playwright server to start 2026-09-04 13:06:24 +02:00
jnfrati 81f54b6864 Merge remote-tracking branch 'origin/main' into agent-network-playground
# Conflicts:
#	src/layouts/Navigation.tsx
2026-09-04 12:39:55 +02:00
jnfrati 500266b0cd Add Agent Network request playground
Let operators execute provider-native requests as a selected peer or group and inspect the live identity and policy decision. Keep the raw request surface flexible while matching the existing Agent Network dashboard patterns.
2026-09-04 12:34:06 +02:00
208 changed files with 6128 additions and 2162 deletions

No files matched your search

-29
View File
@@ -98,24 +98,6 @@ jobs:
type=ref,event=pr
type=sha
- name: Docker meta (rootless)
id: meta_rootless
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_IMAGE }}
${{ env.GHCR_IMAGE }}
flavor: |
latest=false
suffix=-rootless,onlatest=true
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=ref,event=pr
type=sha
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
@@ -141,18 +123,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push rootless Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.rootless
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta_rootless.outputs.tags }}
labels: ${{ steps.meta_rootless.outputs.labels }}
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta_rootless.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
+1 -6
View File
@@ -35,9 +35,6 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# local UI sandbox (throwaway preview harness — never commit)
/src/app/sandbox/
# config
.local*config*.json
.test-config.json
@@ -56,6 +53,4 @@ playwright-report*.zip
/public/ironrdp-pkg/
/public/netbird.wasm
.idea
src/.local-config*
# local mock data (throwaway preview — never commit)
/src/utils/mockData.ts
src/.local-config*
+12 -24
View File
@@ -11,7 +11,6 @@
# ./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)
# ROOTLESS=1 ./build.sh # build the amd64-rootless image variant
#
set -euo pipefail
@@ -20,26 +19,8 @@ cd "$(dirname "$0")"
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
IMAGE_TAG="${IMAGE_TAG:-amd64}"
DOCKERFILE="docker/Dockerfile"
CONTAINER_PORT=80
OUTPUT_SUFFIX=""
case "${ROOTLESS:-0}" in
0) ;;
1)
DOCKERFILE="docker/Dockerfile.rootless"
CONTAINER_PORT=8080
OUTPUT_SUFFIX="-rootless"
[[ "${IMAGE_TAG}" == *-rootless ]] || IMAGE_TAG="${IMAGE_TAG}-rootless"
;;
*)
echo "ERROR: ROOTLESS must be 0 or 1." >&2
exit 1
;;
esac
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
OUTPUT="${OUTPUT:-${IMAGE_NAME}${OUTPUT_SUFFIX}.tar.gz}"
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
@@ -58,10 +39,17 @@ if [[ ! -d out ]]; then
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 "${DOCKERFILE}" \
-f docker/Dockerfile \
-t "${IMAGE}" \
.
@@ -72,13 +60,13 @@ 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 " docker load < /tmp/${OUTPUT}"
echo
echo "Run (nginx serves on container port ${CONTAINER_PORT}). Set the runtime env vars the"
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:${CONTAINER_PORT} \\"
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 \\"
File renamed without changes.
-29
View File
@@ -1,29 +0,0 @@
FROM alpine:3.24
RUN apk add --no-cache bash ca-certificates gettext nginx tzdata && \
rm -rf /var/cache/apk/* && \
mkdir -p /tmp/nginx
STOPSIGNAL SIGINT
EXPOSE 8080
WORKDIR /usr/share/nginx/html
COPY docker/default.conf /etc/nginx/http.d/default.conf
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/rootless.conf /etc/nginx/http.d/rootless.conf
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
COPY out/ /usr/share/nginx/html/
RUN sed -i \
-e 's/listen 80 default_server;/listen 8080 default_server;/' \
-e 's/listen \[::\]:80 default_server;/listen [::]:8080 default_server;/' \
/etc/nginx/http.d/default.conf && \
sed -i '/^user nginx;$/d' /etc/nginx/nginx.conf && \
chmod +x /usr/local/init_react_envs.sh && \
chgrp -R 0 /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx && \
chmod -R g=u /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx
USER 10001:0
ENTRYPOINT ["/usr/local/init_react_envs.sh"]
CMD ["/usr/sbin/nginx", "-e", "/dev/stderr", "-g", "pid /tmp/nginx.pid;"]
+10 -46
View File
@@ -1,59 +1,23 @@
# NetBird Dashboard
NetBird Dashboard is NetBird's management server UI.
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
## Tags
```latest``` ```vX.X.X``` not available yet.
Each published image tag also has a rootless variant with the `-rootless`
suffix. For example, `main` is also published as `main-rootless`.
```main``` built on every PR being merged to the repository
## How to use this image
HTTP:
HTTP run:
```shell
docker run -d --rm -p 80:80 netbirdio/dashboard:main
docker run -d --rm -p 80:80 wiretrustee/dashboard:main
```
Using an SSL certificate from Let's Encrypt:
Using SSL certificate from Let's Encrypt®:
```shell
docker run -d --rm -p 80:80 -p 443:443 \
-e LETSENCRYPT_DOMAIN=app.mydomain.com \
-e LETSENCRYPT_EMAIL=hello@mydomain.com \
netbirdio/dashboard:main
```
For certificate generation, the server needs a public IP and a domain name
pointing to it.
### Rootless and OpenShift
The rootless image listens on port `8080`, runs as UID `10001` by default, and
supports OpenShift's arbitrary UID when the container retains GID `0`:
```shell
docker run -d --rm \
--user 12345:0 \
-p 8080:8080 \
-e AUTH_AUTHORITY=https://idp.example.com \
-e AUTH_CLIENT_ID=netbird-dashboard \
-e AUTH_AUDIENCE=netbird-dashboard \
-e AUTH_SUPPORTED_SCOPES='openid profile email' \
-e USE_AUTH0=false \
-e NETBIRD_MGMT_API_ENDPOINT=https://api.example.com \
netbirdio/dashboard:main-rootless
```
The rootless image does not include Certbot or expose HTTPS. Terminate TLS at
the OpenShift Route, Ingress, or another reverse proxy.
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
## Environment variables
- `NGINX_SSL_PORT` changes the port that Nginx listens to. Defaults to `443`.
- `LETSENCRYPT_DOMAIN` enables Certbot for the specified domain. Defaults to
`none`.
- `LETSENCRYPT_EMAIL` is used to register the certificate request. Defaults to
`example@local`.
These certificate variables apply only to the standard image.
* ```NGINX_SSL_PORT``` Changes the port that Nginx listens to. Defaults to ```443```
* ```LETSENCRYPT_DOMAIN``` Enables Certbot`s client execution for the specified domain. Defaults to ```none```
* ```LETSENCRYPT_EMAIL``` Email used in Certbot`s client execution to register the certificate request. Defaults to ```example@local```
-6
View File
@@ -173,12 +173,6 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
rm "$f".copy
done
# When a command is provided, initialization happens before the server starts.
# The rootless image uses this path so nginx never needs to be reloaded.
if (( $# > 0 )); then
exec "$@"
fi
# 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
-7
View File
@@ -1,7 +0,0 @@
# nginx includes this file inside the http context.
# All runtime state stays in a directory writable by the arbitrary UID used by OpenShift.
client_body_temp_path /tmp/nginx/client_temp;
proxy_temp_path /tmp/nginx/proxy_temp;
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
scgi_temp_path /tmp/nginx/scgi_temp;
-10
View File
@@ -926,16 +926,6 @@ services:
# GeoLite2 download removes a startup stall of up to 2 minutes
# when pkgs.netbird.io is slow.
- NB_PROXY_DISABLE_GEOLOCATION=true
# Declare the private capability so this cluster can host the agent
# network endpoint. That endpoint is a private service — reachable only
# from connected peers — and management refuses to bootstrap it onto a
# cluster without a private-capable proxy; the connect-provider wizard
# reads the same flag (supports_private) to pick its cluster. Without
# it no cluster qualifies and the wizard stops at the provider tab,
# which strands every spec that walks through it. The no-ports proxy
# below stays centralised, so the pick has to skip past a cluster that
# does not qualify.
- NB_PROXY_PRIVATE=true
volumes:
- ./proxy-certs:/certs:ro
command: [
+1 -1
View File
@@ -35,7 +35,7 @@ export default defineConfig({
},
testDir: "./tests",
webServer: {
command: "npx serve@latest out -p 1337 --no-request-logging",
command: "npx serve out -p 1337 --no-request-logging",
port: 1337,
reuseExistingServer: true,
cwd: path.resolve(__dirname, ".."),
@@ -1,206 +0,0 @@
/**
* Agent Network bootstrap-cluster spec.
*
* The account's agent network endpoint is assigned once, from the proxy
* cluster the connect-provider wizard pins it to, and it is immutable after
* that. Not every live cluster can host it: the endpoint is a private service,
* reachable only from connected peers and authorised by their tunnel identity,
* which only a cluster with private capabilities can serve. Management
* reports that per cluster as supports_private and refuses a
* bootstrap onto a cluster without it.
*
* This spec pins the wizard's half of that contract: it picks a
* private-capable cluster even when a centralised one is listed first, and it
* blocks setup — with a message naming what is missing — when no listed
* cluster qualifies. Both the settings and domains routes are mocked, so the
* assertions do not depend on which clusters the test backend happens to have
* connected, and no provider is created against it.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
// bug this spec guards against.
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
// Listed second, and the only valid pick.
const PRIVATE_CLUSTER = "private.proxy.netbird.io";
const UNBOOTSTRAPPED_DEFAULTS = {
endpoint: "",
proxy_address: "",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
...UNBOOTSTRAPPED_DEFAULTS,
endpoint: `violet.${PRIVATE_CLUSTER}`,
proxy_address: PRIVATE_CLUSTER,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
function freeDomain(name: string, supportsPrivate: boolean) {
return {
id: name,
domain: name,
validated: true,
type: "free",
supports_private: supportsPrivate,
supports_custom_ports: true,
require_subdomain: true,
};
}
async function json(route: import("@playwright/test").Route, body: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/**
* Opens the providers page for an account that has not been bootstrapped,
* with the given clusters on the domains endpoint. Returns the page plus a
* promise-friendly getter for the bootstrap POST body, which is captured and
* answered locally so the real backend is never mutated.
*/
async function openWizard(
browser: Browser,
domains: ReturnType<typeof freeDomain>[],
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
try {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await page.route("**/api/agent-network/settings", (route) => {
const method = route.request().method();
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
return route.continue();
});
await page.route("**/api/reverse-proxies/domains", (route) =>
route.request().method() === "GET"
? json(route, domains)
: route.continue(),
);
// Keep the create hermetic: the wizard only reaches this once the
// bootstrap it does first has succeeded.
await page.route("**/api/agent-network/providers", (route) =>
route.request().method() === "POST"
? json(route, { id: "prov-e2e", name: "e2e-provider" })
: route.continue(),
);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}
/** Fills the provider tab with the minimum a create needs. */
async function fillProviderTab(page: Page) {
await page
.getByPlaceholder("https://api.openai.com")
.fill("https://api.openai.com");
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
}
test.describe("Agent Network bootstrap cluster @agent-network", () => {
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
freeDomain(PRIVATE_CLUSTER, true),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
// The unusable cluster is listed, but it is not a blocker: a usable one
// exists, so the wizard proceeds without the warning.
await expect(
page.getByTestId("agent-network-no-cluster-callout"),
).toHaveCount(0);
await fillProviderTab(page);
const bootstrap = page.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/settings") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page
.getByRole("button", { name: "Continue" })
.click({ force: true });
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
const payload = (await bootstrap).request().postDataJSON();
expect(payload.proxy_address).toBe(PRIVATE_CLUSTER);
} finally {
await close();
}
});
test("blocks setup when no listed cluster has private capabilities", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
const callout = page.getByTestId("agent-network-no-cluster-callout");
await expect(callout).toBeVisible({ timeout: 10_000 });
await expect(callout).toContainText("private capabilities");
// The fix lives in the agent network's own Clusters section, not the
// generic Reverse Proxy page, so that is where the callout sends users.
await expect(callout.getByRole("link")).toHaveAttribute(
"href",
"/agent-network/configuration?tab=clusters",
);
// With nothing to pin to there is no bootstrap to make, so the wizard
// cannot advance — rather than sending a request management refuses.
await fillProviderTab(page);
await expect(
page.getByRole("button", { name: "Continue" }),
).toBeDisabled();
} finally {
await close();
}
});
});
+2 -1
View File
@@ -12,7 +12,7 @@
* item, which toggles purely on the focused-view flag and does not depend on
* premium permission modules.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
@@ -149,6 +149,7 @@ const FOCUS_HIDDEN_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"];
// Agent Network view routes that make up the focused surface.
const AGENT_VIEW_PATHS = [
"/agent-network/providers",
"/agent-network/playground",
"/agent-network/policies",
"/agent-network/usage",
"/agent-network/configuration",
+426
View File
@@ -0,0 +1,426 @@
import {
type Browser,
expect,
type Page,
type Route,
test,
} from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const ALLOW = { create: true, read: true, update: true, delete: true };
const DENY = { create: false, read: false, update: false, delete: false };
const SETTINGS = {
endpoint: "violet.proxy.example.com",
proxy_address: "proxy.example.com",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const ALICE = {
id: "user-alice",
email: "alice@example.com",
name: "Alice",
role: "user",
status: "active",
auto_groups: ["group-engineering"],
permissions: { is_restricted: false, modules: {} },
};
const BOB = {
...ALICE,
id: "user-bob",
email: "bob@example.com",
name: "Bob",
auto_groups: [],
};
function currentUser(peerRead = true, groupRead = true) {
return {
...ALICE,
id: "owner-1",
email: "owner@example.com",
name: "Owner",
role: "owner",
is_current: true,
permissions: {
is_restricted: false,
modules: {
services: ALLOW,
"agent_network.providers": ALLOW,
peers: peerRead ? ALLOW : DENY,
users: peerRead ? ALLOW : DENY,
groups: groupRead ? ALLOW : DENY,
},
},
};
}
async function fulfillJSON(route: Route, body: unknown) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function mockPlaygroundDependencies(
page: Page,
opts: { peerRead?: boolean; groupRead?: boolean } = {},
) {
await page.route("**/api/users/current**", (route) =>
fulfillJSON(route, currentUser(opts.peerRead, opts.groupRead)),
);
await page.route("**/api/users?**", (route) => {
const serviceUser = new URL(route.request().url()).searchParams.get(
"service_user",
);
return fulfillJSON(route, serviceUser === "true" ? [] : [ALICE, BOB]);
});
await page.route("**/api/peers**", (route) =>
fulfillJSON(route, [
{
id: "peer-alice",
name: "Alice Laptop",
hostname: "alice-laptop",
ip: "100.64.0.10",
user_id: ALICE.id,
},
{
id: "peer-bob",
name: "Bob Laptop",
hostname: "bob-laptop",
ip: "100.64.0.11",
user_id: BOB.id,
},
]),
);
await page.route("**/api/groups**", (route) =>
fulfillJSON(route, [
{ id: "group-engineering", name: "Engineering", peers: [] },
{ id: "group-sales", name: "Sales", peers: [] },
]),
);
await page.route("**/api/agent-network/settings**", (route) =>
fulfillJSON(route, SETTINGS),
);
for (const path of ["providers", "policies", "guardrails", "budget-rules"]) {
await page.route(`**/api/agent-network/${path}**`, (route) =>
fulfillJSON(route, []),
);
}
}
async function openPlayground(
browser: Browser,
opts: { peerRead?: boolean; groupRead?: boolean } = {},
) {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([key, value]) =>
window.localStorage.setItem(key as string, value as string),
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await loginToApp(page, "owner");
await mockPlaygroundDependencies(page, opts);
await page.goto("/agent-network/playground");
return { page, close: () => context.close() };
}
async function selectAlicePeer(page: Page) {
await page.getByRole("button", { name: "Select a user..." }).click();
await page.getByText("Alice", { exact: true }).click();
const peerSelect = page.getByRole("combobox", { name: "Peer" });
await peerSelect.click();
const alicePeer = page.getByRole("option", { name: /Alice Laptop/ });
await expect(alicePeer).toBeVisible();
await expect(page.getByRole("option", { name: /Bob Laptop/ })).toHaveCount(0);
await alicePeer.click();
}
test.describe("Agent Network playground @agent-network", () => {
test("runs peer and synthetic group requests with raw result states", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser);
const requests: unknown[] = [];
let call = 0;
await page.route("**/api/agent-network/playground", async (route) => {
requests.push(route.request().postDataJSON());
call += 1;
if (call === 1) {
return fulfillJSON(route, {
status_code: 200,
headers: [
{ name: "Content-Type", values: ["text/event-stream"] },
{ name: "X-Test", values: ["one", "two"] },
],
body: 'data: {"delta":"pong"}\n\n',
body_encoding: "utf8",
body_truncated: true,
identity: {
user_id: ALICE.id,
user_email: ALICE.email,
group_ids: ["group-engineering"],
group_names: ["Engineering"],
},
policy: {
decision: "allow",
reason: "",
provider_surface: "openai",
model: "gpt-4o",
resolved_provider_id: "provider-1",
authorising_group_ids: ["group-engineering"],
selected_policy_id: "policy-1",
attribution_group_id: "group-engineering",
},
});
}
return fulfillJSON(route, {
status_code: 403,
headers: [{ name: "Content-Type", values: ["application/json"] }],
body: "/wA=",
body_encoding: "base64",
body_truncated: false,
identity: {
user_id: "",
user_email: "",
group_ids: ["group-sales"],
group_names: ["Sales"],
},
policy: {
decision: "deny",
reason: "no_authorised_provider",
provider_surface: "openai",
model: "gpt-4o",
resolved_provider_id: "",
authorising_group_ids: [],
selected_policy_id: "",
attribution_group_id: "",
},
});
});
try {
await expect(
page.getByRole("heading", { level: 1, name: "Playground" }),
).toBeVisible();
await expect(
page.getByText(
"This sends a real provider request. It can incur cost and updates usage, budgets, and access logs for the emulated identity.",
),
).toBeVisible();
await selectAlicePeer(page);
await page.getByRole("button", { name: "Run live request" }).click();
const response = page.getByRole("region", {
name: "Response inspector",
});
await expect(response.getByText("HTTP 200")).toBeVisible();
await expect(response).toContainText('data: {"delta":"pong"}');
await expect(response.getByText(/Response exceeded 8 MiB/)).toBeVisible();
expect(requests[0]).toMatchObject({
principal: { kind: "peer", id: "peer-alice" },
});
await response.getByRole("tab", { name: /Headers/i }).click();
await expect(
response.getByText("Content-Type", { exact: true }),
).toBeVisible();
await expect(response.getByText("one", { exact: true })).toBeVisible();
await expect(response.getByText("two", { exact: true })).toBeVisible();
await response.getByRole("tab", { name: /Details/i }).click();
await expect(
response.getByText(ALICE.email, { exact: true }),
).toBeVisible();
await expect(
response.getByText("Engineering (group-engineering)", { exact: true }),
).toBeVisible();
await expect(
response.getByText("provider-1", { exact: true }),
).toBeVisible();
await expect(
response.getByText("policy-1", { exact: true }),
).toBeVisible();
await page.getByRole("radio", { name: /Synthetic group/ }).click();
await page.getByRole("button", { name: "Select a group..." }).click();
await page.getByText("Sales", { exact: true }).click();
await expect(
page.getByText(
"Synthetic group identity: per-user limits do not apply.",
),
).toBeVisible();
await page.getByRole("button", { name: "Run live request" }).click();
await expect(response.getByText("HTTP 403")).toBeVisible();
await response.getByRole("tab", { name: /Body/i }).click();
const bodyPanel = response.getByRole("tabpanel", { name: /Body/i });
await expect(bodyPanel.getByText("base64")).toBeVisible();
await expect(bodyPanel.getByText("/wA=")).toBeVisible();
expect(requests[1]).toMatchObject({
principal: { kind: "group", id: "group-sales" },
});
} finally {
await close();
}
});
test("blocks protected headers and cancels an in-flight request", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser);
await page.route("**/api/agent-network/playground", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2_000));
return fulfillJSON(route, {});
});
try {
await selectAlicePeer(page);
const headerName = page.getByLabel("Header 1 name");
await headerName.fill("Authorization");
await expect(page.getByText(/managed by NetBird/)).toBeVisible();
await expect(
page.getByRole("button", { name: "Run live request" }),
).toBeDisabled();
await headerName.fill("Content-Type");
await page.getByRole("button", { name: "Run live request" }).click();
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Cancel" })).toHaveCount(0);
await expect(
page.getByRole("region", { name: "Response inspector" }),
).not.toContainText("Request failed");
} finally {
await close();
}
});
test("shows management failures with their request ID", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser);
await page.route("**/api/agent-network/playground", (route) =>
route.fulfill({
status: 503,
headers: {
"Content-Type": "application/json",
"X-Request-Id": "request-503",
"Access-Control-Expose-Headers": "X-Request-Id",
},
body: JSON.stringify({
code: 503,
message: "No playground-capable proxy is connected.",
}),
}),
);
try {
await selectAlicePeer(page);
await page.getByRole("button", { name: "Run live request" }).click();
const response = page.getByRole("region", {
name: "Response inspector",
});
await expect(response.getByRole("alert")).toContainText(
"No playground-capable proxy is connected.",
);
await expect(response.getByRole("alert")).toContainText(
"Request ID: request-503",
);
await expect(response).not.toContainText("HTTP 503");
} finally {
await close();
}
});
test("keeps the request and response workspace responsive", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser);
try {
const request = page.getByRole("region", { name: "Request builder" });
const response = page.getByRole("region", {
name: "Response inspector",
});
await page.setViewportSize({ width: 1440, height: 1000 });
await expect(request).toBeVisible();
await expect(response).toBeVisible();
const desktopRequest = await request.boundingBox();
const desktopResponse = await response.boundingBox();
expect(desktopRequest).not.toBeNull();
expect(desktopResponse).not.toBeNull();
expect(Math.abs(desktopRequest!.y - desktopResponse!.y)).toBeLessThan(4);
expect(desktopResponse!.x).toBeGreaterThanOrEqual(
desktopRequest!.x + desktopRequest!.width,
);
await page.setViewportSize({ width: 1024, height: 900 });
const narrowRequest = await request.boundingBox();
const narrowResponse = await response.boundingBox();
expect(narrowRequest).not.toBeNull();
expect(narrowResponse).not.toBeNull();
expect(narrowResponse!.y).toBeGreaterThanOrEqual(
narrowRequest!.y + narrowRequest!.height,
);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth ===
document.documentElement.clientWidth,
),
).toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
await expect(
page.getByRole("heading", { level: 1, name: "Playground" }),
).toBeVisible();
await expect(
request.getByRole("button", { name: "Run live request" }),
).toBeVisible();
await expect(request).toBeVisible();
await expect(response).toBeVisible();
const mobileRequest = await request.boundingBox();
const mobileResponse = await response.boundingBox();
expect(mobileRequest).not.toBeNull();
expect(mobileResponse).not.toBeNull();
expect(mobileResponse!.y).toBeGreaterThanOrEqual(
mobileRequest!.y + mobileRequest!.height,
);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth ===
document.documentElement.clientWidth,
),
).toBe(true);
} finally {
await close();
}
});
test("disables only the principal mode without read permission", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser, {
peerRead: false,
groupRead: true,
});
try {
await expect(
page.getByRole("radio", { name: /Peer-backed user/ }),
).toBeDisabled();
await expect(
page.getByRole("radio", { name: /Synthetic group/ }),
).toBeEnabled();
} finally {
await close();
}
});
});
+1289 -577
View File
File diff suppressed because it is too large. Load diff
+5 -8
View File
@@ -48,7 +48,7 @@
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "4.17.24",
"@types/node": "20.19.43",
"@types/node": "20.10.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-window": "^1.8.8",
@@ -75,10 +75,11 @@
"js-cookie": "^3.0.7",
"lodash": "4.18.1",
"lucide-react": "^0.566.0",
"next": "16.3.4",
"next": "16.3.0",
"punycode": "^2.3.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.0",
"react-confetti-explosion": "^3.0.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.4",
"react-ga4": "^2.1.0",
@@ -100,6 +101,7 @@
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@playwright/test": "^1.52.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
@@ -110,13 +112,8 @@
"jsdom": "^29.1.1",
"postcss": "^8",
"prettier": "3.0.3",
"serve": "^14.2.6",
"tailwindcss": "^3.4.17",
"vitest": "^4.1.10"
},
"allowScripts": {
"@axa-fr/oidc-client": false,
"@axa-fr/react-oidc": false,
"fsevents": false,
"unrs-resolver": false
}
}
@@ -60,7 +60,7 @@ export default function Integrations() {
(!isNetBirdCloud() && (permission?.settings?.read ?? false))
}
>
<div className={"w-full"}>
<div className={"border-l border-nb-gray-930 w-full"}>
<IdentityProviderTab />
<SSOTab />
<EventStreamingTab />
@@ -87,7 +87,7 @@ export default function AgentNetworkConfigurationPage() {
<GroupsProvider>
<PeersProvider>
<AIProvidersProvider>
<div className={"w-full"}>
<div className={"border-l border-nb-gray-930 w-full"}>
<Tabs.Content
value={TAB_BUDGET_SETTINGS}
className={"w-full"}
@@ -0,0 +1,64 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PageContainer from "@/layouts/PageContainer";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
import AgentNetworkPlayground from "@/modules/agent-network/playground/AgentNetworkPlayground";
function PlaygroundPageContent() {
const { permission } = usePermissions();
const canReadPrincipal = Boolean(
(permission?.peers?.read && permission?.users?.read) ||
permission?.groups?.read,
);
return (
<RestrictedAccess page="Playground identities" hasAccess={canReadPrincipal}>
<PeersProvider>
<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/playground"
label="Playground"
active
/>
</Breadcrumbs>
<h1>Playground</h1>
<Paragraph>
Emulate a peer-backed user or a synthetic group and inspect the
real policy, provider, accounting, and response path.
</Paragraph>
<div className="mt-6">
<AgentNetworkPlayground />
</div>
</div>
</AIProvidersProvider>
</PeersProvider>
</RestrictedAccess>
);
}
export default function AgentNetworkPlaygroundPage() {
const { permission } = usePermissions();
return (
<PageContainer>
<RestrictedAccess
page="Agent Network Playground"
hasAccess={permission?.["agent_network.providers"]?.create}
>
<PlaygroundPageContent />
</RestrictedAccess>
</PageContainer>
);
}
+2 -13
View File
@@ -15,11 +15,6 @@ import React, { useState } from "react";
import { useSWRConfig } from "swr";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
import { useTheme } from "@/contexts/ThemeProvider";
import {
canvasBackground,
canvasDotColor,
} from "@/modules/control-center/utils/canvas-theme";
import PoliciesProvider from "@/contexts/PoliciesProvider";
import { Network } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
@@ -110,8 +105,6 @@ const PRO_OPTIONS = { hideAttribution: true };
const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: EMPTY_STATE_ZOOM };
function ControlCenterCanvas() {
const { resolvedTheme } = useTheme();
const isLight = resolvedTheme === "light";
const canvas = useCanvasState();
const ui = useControlCenterUI();
const draft = useDraft();
@@ -290,7 +283,7 @@ function ControlCenterCanvas() {
onInit={stableOnInit}
maxZoom={DEFAULT_MAX_ZOOM}
minZoom={DEFAULT_MIN_ZOOM}
colorMode={isLight ? "light" : "dark"}
colorMode={"dark"}
panOnDrag={canInteract}
panOnScroll={draft.isSelectMode && !emptyState}
zoomOnScroll={canInteract}
@@ -304,11 +297,7 @@ function ControlCenterCanvas() {
selectionOnDrag={draft.isSelectMode && !emptyState}
selectionMode={SelectionMode.Partial}
>
<Background
bgColor={canvasBackground(resolvedTheme)}
gap={20}
color={canvasDotColor(resolvedTheme)}
/>
<Background bgColor={"#181a1d"} gap={20} color={"#717171"} />
<CanvasContextMenu onOpenChange={setContextMenuOpen} />
<NodeContextMenu
position={nodeContextMenuPos}
+1 -1
View File
@@ -115,7 +115,7 @@ const GroupDetailsName = () => {
>
<div
className={cn(
"flex h-8 w-8 items-center justify-center gap-2 text-nb-gray-300 hover:text-nb-gray-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer",
"flex h-8 w-8 items-center justify-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer",
!isAllowedToRename &&
"opacity-40 cursor-not-allowed pointer-events-none",
)}
Loaded 20 of 208 files, more files were not shown because too many files have changed in this diff. Show more