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
Maycon Santos cba2613e68 Add anonymization level and upload URL to the debug bundle modal (#753)
Surfaces the two bundle parameters added to the management API: an
anonymization level (Default/Strict, shown when anonymization is enabled)
and an optional upload service URL that overrides the default upload
server. The upload URL is validated as an https URL before the job can be
submitted; both fields are omitted from the request when left at their
defaults so the peer applies its own.
2026-09-03 20:05:02 +02:00
Maycon Santos fe32179dc1 Pin the group view's group, and give agentgateway a reachable upstream (#788)
Stabilize the flaky Control Center and agentgateway e2e specs (#788)

  Three separate causes behind the recent e2e failures.

  The group view ranked every group in the account by peer count to decide what
  to display, so with two workers it could land on a group another spec had just
  created. openGroupView() selects the group by name instead.

  Management now checks a provider's upstream URL when the provider is saved and
  rejects it unless the URL returns a model list, so the agentgateway spec's
  placeholder host stopped working. The test environment runs a stub that answers
  the check.

  The Backspace test blurred the group panel's search input before that input had
  taken focus, so it reclaimed focus and swallowed the key.
2026-09-03 18:38:12 +02:00
Eduard Gert 1bad86b277 Show peer-sourced policies in the Control Center peer view (#786)
The peer view matched policies only by source group membership, so a
policy whose source is the peer itself never appeared in that peer's
view. There was no group fallback either: a rule carrying a
sourceResource has its sources nulled on save, so the group check could
only ever return false for exactly these policies.

Such policies are reachable from the canvas itself, since dragging a
connection off a peer node writes a sourceResource rather than a group.
A user could create a peer-sourced policy, deploy it, drill into that
same peer and not see it.

Match rule.sourceResource against the peer id alongside the group check.
The rendering side needs no change, as select-peer-node is the peer and
its edge to the policy is drawn unconditionally. Draft mode is fixed
transitively: useDraft harvests visible policies from the live view's
nodes, and already draws a sourceResource peer as a source card.
2026-09-03 14:29:06 +02:00
25 changed files with 2720 additions and 105 deletions

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
# Ignore zitadel environment
.env
Caddyfile
agentgateway-stub.Caddyfile
management.json
turnserver.conf
zitadel.env
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
+35 -1
View File
@@ -532,7 +532,7 @@ initEnvironment() {
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
@@ -540,6 +540,7 @@ initEnvironment() {
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
@@ -612,6 +613,30 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
}
# Saving an Agent Network provider makes management call the upstream and
# refuses the save unless it answers a model listing, so a provider spec needs
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
# no public one; this stub stands in for it, inside the compose network where
# management can reach it and without depending on a vendor being up.
renderAgentGatewayStub() {
cat <<'EOF'
{
admin off
auto_https off
}
:8088 {
handle /v1/models {
header Content-Type application/json
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
}
handle {
respond 404
}
}
EOF
}
renderCaddyfile() {
cat <<EOF
{
@@ -818,6 +843,15 @@ services:
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Stand-in upstream for the Agent Network provider specs: answers the model
# listing management probes before it accepts a provider save. Reachable
# only from inside the compose network, at http://agentgateway-stub:8088.
agentgateway-stub:
image: caddy
restart: unless-stopped
networks: [ netbird ]
volumes:
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
+25
View File
@@ -24,6 +24,31 @@ export async function openControlCenter(page: Page, tab?: FlowView) {
await dismissBlockingOverlays(page);
}
/**
* Opens the groups view already showing `groupName`.
*
* The view picks its own group on first render (getFirstGroup: the highest
* peers_count that is a policy source), and that ranking runs over every group
* in the account — including ones a spec on the other worker seeded moments
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
* select the group by name and let the view rebuild around it.
*/
export async function openGroupView(page: Page, groupName: string) {
await openControlCenter(page, "groups");
const selector = canvasNode(page, "select-group-node");
await expect(selector).toBeVisible();
// Unforced on purpose: the opening fitView animates the canvas, and the
// actionability check is what waits for the node to stop moving.
await selector.click();
await page.getByTestId("select-dropdown-search").fill(groupName);
await page
.locator('[role="option"]')
.filter({ hasText: groupName })
.first()
.click({ force: true });
await expect(selector).toContainText(groupName);
}
export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
+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, ".."),
@@ -59,7 +59,12 @@ test.describe
.getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true });
const upstreamURL = "https://agentgateway.e2e.example";
// Saving a provider makes management call the upstream, and it refuses
// the save with a 422 unless the upstream answers a model listing — so a
// made-up host cannot be used here. agentgateway is self-hosted and has
// no public endpoint to point at, so create-test-env.sh runs a stub that
// answers that probe inside the compose network.
const upstreamURL = "http://agentgateway-stub:8088";
await page
.getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL);
+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();
}
});
});
@@ -364,13 +364,17 @@ test.describe.serial("Control Center Draft Matrix @control-center", () => {
}) => {
const group = await place(page, "group", 0.6, 0.5);
await expectChangeCount(page, 1);
// React Flow ignores delete keys while an input is focused, and the panel's
// search takes focus on click, so blur it before pressing Backspace.
await group.click();
await expect(group).toHaveClass(/selected/);
await page.evaluate(
() => (document.activeElement as HTMLElement | null)?.blur(),
);
// React Flow ignores delete keys while an input is focused, and the panel
// the click opens takes focus into its search — from an effect, about
// 100ms later. Blurring before that lands leaves the input to claim focus
// afterwards and swallow the Backspace, so wait for the focus, then drop
// it.
const panelSearch = page.locator("#cc-group-panel input").first();
await expect(panelSearch).toBeFocused();
await panelSearch.blur();
await expect(panelSearch).not.toBeFocused();
await page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0);
@@ -19,6 +19,7 @@ import {
dismissBlockingOverlays,
enterDraft,
openControlCenter,
openGroupView,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
@@ -37,7 +38,6 @@ test.describe
await deleteGroupsByPrefix(page, PREFIX);
});
// Clean-slated so the draft group view's auto-select is deterministic.
async function seedPolicyGroupView(page: Page) {
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -49,7 +49,9 @@ test.describe
src.id,
dst.id,
);
await openControlCenter(page, "groups");
// Selected by name: the view's own pick ranks every group in the account,
// so a peer-bearing group from another spec on the other worker wins it.
await openGroupView(page, src.name);
await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
+7 -14
View File
@@ -18,6 +18,7 @@ import {
clickContextMenuItem,
dismissBlockingOverlays,
openControlCenter,
openGroupView,
resetDraftState,
switchFlowView,
} from "../helpers/control-center";
@@ -47,8 +48,6 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
});
async function seedPolicyAndOpenGroupView(page: Page, enabled = true) {
// The group view auto-selects a group that HAS a policy, so stale policies
// would make the rendered group non-deterministic.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -64,15 +63,10 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
const policyNode = canvasNode(page, `policy-${policy.id}`);
// A stale SWR read or a build/fit race can fail to place the node.
for (let attempt = 0; attempt < 3; attempt++) {
await openControlCenter(page, "groups");
const shown = await policyNode
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (shown) break;
}
// Selecting src by name rather than trusting the view's own pick: the
// auto-select ranks every group in the account, so a peer-bearing group
// another spec seeded on the other worker outranks this one.
await openGroupView(page, src.name);
await expect(policyNode).toBeVisible({ timeout: 10_000 });
return { src, dst, policy, policyNode };
}
@@ -512,7 +506,6 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
test("Should save group resource membership in live mode (correct payload)", async ({
dashboardAsOwner: page,
}) => {
// Clean slate so the group view deterministically lands on our group.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -529,7 +522,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
[],
);
await openControlCenter(page, "groups");
await openGroupView(page, src.name);
const groupNode = canvasNode(page, `group-${dst.id}`);
await expect(groupNode).toBeVisible({ timeout: 15_000 });
@@ -610,7 +603,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
await openControlCenter(page, "groups");
await openGroupView(page, src.name);
const policyNode = canvasNode(page, `policy-${p1.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
await expect(
+827 -8
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -112,6 +112,7 @@
"jsdom": "^29.1.1",
"postcss": "^8",
"prettier": "3.0.3",
"serve": "^14.2.6",
"tailwindcss": "^3.4.17",
"vitest": "^4.1.10"
}
@@ -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>
);
}
-1
View File
@@ -35,7 +35,6 @@ export interface Account {
local_mfa_enabled?: boolean;
ipv6_enabled_groups?: string[];
network_range_v6?: string;
debug_bundle_upload_url?: string;
agent_network_only?: boolean;
dashboard_features?: AccountDashboardFeatures;
};
+7
View File
@@ -17,7 +17,14 @@ export interface Workload {
// Parameters for bundle job
export interface BundleJobParameters {
anonymize: boolean;
// Anonymization level applied when anonymize is enabled. "default" keeps
// internal IP ranges readable; "strict" also anonymizes them. Omitted or
// empty resolves to the default on the peer.
anonymize_level?: "default" | "strict";
bundle_for: boolean;
bundle_for_time: number;
log_file_count: number;
// Upload service URL the peer requests an upload URL from. Empty selects the
// default upload server.
upload_url?: string;
}
+14 -4
View File
@@ -1,15 +1,19 @@
"use client";
import { ScrollArea } from "@components/ScrollArea";
import { SmallBadge } from "@components/ui/SmallBadge";
import { cn } from "@utils/helpers";
import { isNetBirdCloud } from "@utils/netbird";
import * as React from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon";
import IntegrationIcon from "@/assets/icons/IntegrationIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import { DistributorNavigation } from "@/cloud/distributor/DistributorNavigation";
@@ -24,10 +28,6 @@ import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode
import { useMyAgentNetworkSetup } from "@/modules/agent-network/useMyAgentNetworkSetup";
import { NavigationUsageInfo } from "@/modules/billing/NavigationUsageInfo";
import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation";
import { SmallBadge } from "@components/ui/SmallBadge";
import * as React from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
type Props = {
fullWidth?: boolean;
@@ -260,6 +260,16 @@ export default function Navigation({
permission?.["agent_network.providers"]?.read
}
/>
<SidebarItem
label="Playground"
isChild
href={"/agent-network/playground"}
exactPathMatch={true}
visible={
agentNetworkSurface &&
permission?.["agent_network.providers"]?.create
}
/>
<SidebarItem
label="Policies"
isChild
@@ -0,0 +1,147 @@
"use client";
import { Callout } from "@components/Callout";
import { useEffect, useMemo, useRef, useState } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider";
import { PlaygroundRequest, PlaygroundResponse, usePlaygroundApi } from "./api";
import PlaygroundPrincipalSelector, {
PlaygroundPrincipalSelection,
} from "./PlaygroundPrincipalSelector";
import PlaygroundRequestEditor, {
DEFAULT_PLAYGROUND_REQUEST,
RawPlaygroundRequest,
validatePlaygroundRequest,
} from "./PlaygroundRequestEditor";
import PlaygroundResult from "./PlaygroundResult";
export default function AgentNetworkPlayground() {
const { permission } = usePermissions();
const { users = [], isLoading: usersLoading } = useUsers();
const { peers = [], isLoading: peersLoading } = usePeers();
const { groups = [], isLoading: groupsLoading } = useGroups();
const { settings, settingsLoading } = useAIProviders();
const api = usePlaygroundApi();
const canUsePeer = Boolean(
permission?.peers?.read && permission?.users?.read,
);
const canUseGroup = Boolean(permission?.groups?.read);
const canReadSettings = Boolean(permission?.["agent_network.settings"]?.read);
const [principal, setPrincipal] = useState<PlaygroundPrincipalSelection>({
mode: canUsePeer ? "peer" : "group",
});
const [request, setRequest] = useState<RawPlaygroundRequest>(
DEFAULT_PLAYGROUND_REQUEST,
);
const [result, setResult] = useState<PlaygroundResponse>();
const [error, setError] = useState<string>();
const [running, setRunning] = useState(false);
const controllerRef = useRef<AbortController | undefined>(undefined);
useEffect(() => {
if (principal.mode === "peer" && !canUsePeer && canUseGroup) {
setPrincipal({ mode: "group" });
} else if (principal.mode === "group" && !canUseGroup && canUsePeer) {
setPrincipal({ mode: "peer" });
}
}, [canUseGroup, canUsePeer, principal.mode]);
const validationError = useMemo(
() => validatePlaygroundRequest(request),
[request],
);
const principalID =
principal.mode === "peer" ? principal.peer?.id : principal.group?.id;
const loading = usersLoading || peersLoading || groupsLoading;
const canRun =
!running && !loading && Boolean(principalID) && !validationError;
const run = async () => {
if (!canRun || !principalID) return;
const controller = new AbortController();
controllerRef.current = controller;
setRunning(true);
setError(undefined);
const payload: PlaygroundRequest = {
principal: { kind: principal.mode, id: principalID },
method: request.method,
path: request.path,
headers: request.headers.map((header) => ({
name: header.name,
values: [header.value],
})),
body: request.body,
};
try {
const response = await api.post(payload, "", {
signal: controller.signal,
});
setResult(response);
} catch (cause) {
if (!controller.signal.aborted) {
const apiError = cause as { message?: string; requestId?: string };
setError(
`${apiError.message || "Playground request failed."}${
apiError.requestId ? ` Request ID: ${apiError.requestId}` : ""
}`,
);
}
} finally {
if (controllerRef.current === controller) {
controllerRef.current = undefined;
}
setRunning(false);
}
};
return (
<div className="flex min-w-0 flex-col gap-4">
{canReadSettings && !settings && !settingsLoading && (
<div role="alert">
<Callout variant="warning">
Configure the Agent Network endpoint and connect a
playground-capable proxy before running a request.
</Callout>
</div>
)}
<Callout variant="warning">
This sends a real provider request. It can incur cost and updates usage,
budgets, and access logs for the emulated identity.
</Callout>
<div className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-2">
<div
role="region"
aria-label="Request builder"
className="flex min-w-0 flex-col gap-6"
>
<PlaygroundPrincipalSelector
value={principal}
onChange={setPrincipal}
users={users}
peers={peers}
groups={groups}
canUsePeer={canUsePeer}
canUseGroup={canUseGroup}
disabled={running}
/>
<PlaygroundRequestEditor
value={request}
onChange={setRequest}
disabled={running}
error={validationError}
running={running}
canRun={canRun}
onRun={run}
onCancel={() => controllerRef.current?.abort()}
/>
</div>
<PlaygroundResult result={result} running={running} error={error} />
</div>
</div>
);
}
@@ -0,0 +1,423 @@
"use client";
import Button from "@components/Button";
import ButtonGroup from "@components/ButtonGroup";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import { DropdownInfoText } from "@components/DropdownInfoText";
import { DropdownInput } from "@components/DropdownInput";
import { Label } from "@components/Label";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { UserListItem } from "@components/UserSelector";
import { Check, ChevronDown } from "lucide-react";
import { type ChangeEvent, useState } from "react";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import { User } from "@/interfaces/User";
import { PlaygroundPrincipalKind } from "./api";
export type PlaygroundPrincipalSelection = {
mode: PlaygroundPrincipalKind;
user?: User;
peer?: Peer;
group?: Group;
};
type Props = {
value: PlaygroundPrincipalSelection;
onChange: (value: PlaygroundPrincipalSelection) => void;
users: User[];
peers: Peer[];
groups: Group[];
canUsePeer: boolean;
canUseGroup: boolean;
disabled: boolean;
};
function UserPicker({
value,
users,
disabled,
onSelect,
}: Readonly<{
value?: User;
users: User[];
disabled: boolean;
onSelect: (user: User) => void;
}>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredUsers = users.filter((user) =>
`${user.name} ${user.email ?? ""}`.toLowerCase().includes(normalizedQuery),
);
const triggerLabel =
value?.name || (users.length ? "Select a user..." : "No users available.");
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<PopoverTrigger asChild>
<Button
id="playground-user"
variant="input"
aria-label={triggerLabel}
disabled={disabled}
className="w-full min-w-0 justify-between text-left"
>
{value ? (
<UserListItem user={value} variant="selected" />
) : (
<span className="min-w-0 truncate">{triggerLabel}</span>
)}
<ChevronDown
aria-hidden
size={15}
className="shrink-0 text-nb-gray-500"
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<DropdownInput
aria-label="Search users"
value={query}
onChange={(nextQuery: string | ChangeEvent<HTMLInputElement>) =>
setQuery(
typeof nextQuery === "string"
? nextQuery
: nextQuery.target.value,
)
}
placeholder="Search users..."
hideEnterIcon
/>
<div
role="listbox"
aria-label="Users"
className="max-h-64 overflow-y-auto p-1"
>
{filteredUsers.length ? (
filteredUsers.map((user) => {
const selected = user.id === value?.id;
return (
<button
key={user.id}
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onSelect(user);
setOpen(false);
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left outline-none hover:bg-nb-gray-910 focus-visible:bg-nb-gray-910"
>
<UserListItem user={user} />
{selected && (
<Check
aria-hidden
size={15}
className="shrink-0 text-netbird"
/>
)}
</button>
);
})
) : (
<DropdownInfoText className="mb-4 px-3">
No users available.
</DropdownInfoText>
)}
</div>
</PopoverContent>
</Popover>
);
}
function GroupPicker({
value,
groups,
disabled,
onSelect,
}: Readonly<{
value?: Group;
groups: Group[];
disabled: boolean;
onSelect: (group: Group) => void;
}>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const selectableGroups = groups.filter((group) => Boolean(group.id));
const filteredGroups = selectableGroups.filter((group) =>
group.name.toLowerCase().includes(normalizedQuery),
);
const triggerLabel = value?.name
? value.name
: selectableGroups.length
? "Select a group..."
: "No groups available.";
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<PopoverTrigger asChild>
<Button
id="playground-group"
variant="input"
aria-label={triggerLabel}
disabled={disabled}
className="w-full min-w-0 justify-between text-left"
>
<span className="flex min-w-0 items-center gap-2 truncate">
{value && (
<GroupBadgeIcon id={value.id} issued={value.issued} size={14} />
)}
<span className="truncate">{triggerLabel}</span>
</span>
<ChevronDown
aria-hidden
size={15}
className="shrink-0 text-nb-gray-500"
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<DropdownInput
aria-label="Search groups"
value={query}
onChange={(nextQuery: string | ChangeEvent<HTMLInputElement>) =>
setQuery(
typeof nextQuery === "string"
? nextQuery
: nextQuery.target.value,
)
}
placeholder="Search groups..."
hideEnterIcon
/>
<div
role="listbox"
aria-label="Groups"
className="max-h-64 overflow-y-auto p-1"
>
{filteredGroups.length ? (
filteredGroups.map((group) => {
const selected = group.id === value?.id;
const peerCount = group.peers_count ?? group.peers?.length ?? 0;
return (
<button
key={group.id}
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onSelect(group);
setOpen(false);
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left outline-none hover:bg-nb-gray-910 focus-visible:bg-nb-gray-910"
>
<GroupBadgeIcon
id={group.id}
issued={group.issued}
size={14}
/>
<span className="min-w-0 flex-1 truncate text-sm text-nb-gray-100">
{group.name}
</span>
<span className="shrink-0 text-xs text-nb-gray-500">
{peerCount} {peerCount === 1 ? "peer" : "peers"}
</span>
{selected && (
<Check
aria-hidden
size={15}
className="shrink-0 text-netbird"
/>
)}
</button>
);
})
) : (
<DropdownInfoText className="mb-4 px-3">
No groups available.
</DropdownInfoText>
)}
</div>
</PopoverContent>
</Popover>
);
}
export default function PlaygroundPrincipalSelector({
value,
onChange,
users,
peers,
groups,
canUsePeer,
canUseGroup,
disabled,
}: Readonly<Props>) {
const userPeers = value.user
? peers.filter((peer) => peer.user_id === value.user?.id)
: [];
return (
<section className="min-w-0" aria-labelledby="playground-identity-heading">
<Card className="w-full">
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<h2
id="playground-identity-heading"
className="shrink-0 text-sm font-semibold text-nb-gray-100"
>
Identity
</h2>
<div role="radiogroup" aria-label="Identity mode">
<ButtonGroup>
<ButtonGroup.Button
role="radio"
aria-checked={value.mode === "peer"}
aria-describedby="peer-mode-description"
variant={value.mode === "peer" ? "tertiary" : "secondary"}
disabled={disabled || !canUsePeer}
onClick={() =>
onChange({
mode: "peer",
user: value.user,
peer: value.peer,
})
}
>
Peer-backed user
</ButtonGroup.Button>
<ButtonGroup.Button
role="radio"
aria-checked={value.mode === "group"}
aria-describedby="group-mode-description"
variant={value.mode === "group" ? "tertiary" : "secondary"}
disabled={disabled || !canUseGroup}
onClick={() =>
onChange({ mode: "group", group: value.group })
}
>
Synthetic group
</ButtonGroup.Button>
</ButtonGroup>
</div>
<span id="peer-mode-description" className="sr-only">
Applies per-user and group policy limits.
</span>
<span id="group-mode-description" className="sr-only">
Exercises one group without a user identity.
</span>
</div>
<div className="border-t border-nb-gray-900 pt-4">
{value.mode === "peer" ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="min-w-0">
<Label htmlFor="playground-user">User</Label>
<UserPicker
value={value.user}
users={users}
disabled={disabled || !canUsePeer || users.length === 0}
onSelect={(user) =>
onChange({ mode: "peer", user, peer: undefined })
}
/>
</div>
<div className="min-w-0">
<Label htmlFor="playground-peer">Peer</Label>
<Select
value={value.peer?.id ?? ""}
disabled={disabled || !value.user || userPeers.length === 0}
onValueChange={(peerID) =>
onChange({
mode: "peer",
user: value.user,
peer: userPeers.find((peer) => peer.id === peerID),
})
}
>
<SelectTrigger id="playground-peer" aria-label="Peer">
<SelectValue
placeholder={
value.user && userPeers.length === 0
? "This user has no peers"
: "Select a peer..."
}
/>
</SelectTrigger>
<SelectContent>
{userPeers.map((peer) => (
<SelectItem
key={peer.id}
value={peer.id!}
description={peer.ip}
>
{peer.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{value.user && userPeers.length === 0 && (
<div className="sm:col-span-2">
<Callout variant="warning">
This user has no peers to emulate.
</Callout>
</div>
)}
</div>
) : (
<div className="min-w-0">
<Label htmlFor="playground-group">Group</Label>
<GroupPicker
value={value.group}
groups={groups}
disabled={
disabled ||
!canUseGroup ||
!groups.some((group) => Boolean(group.id))
}
onSelect={(group) => onChange({ mode: "group", group })}
/>
</div>
)}
</div>
{value.mode === "group" && (
<Callout variant="info">
Synthetic group identity: per-user limits do not apply.
</Callout>
)}
</div>
</Card>
</section>
);
}
@@ -0,0 +1,310 @@
"use client";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { Textarea } from "@components/Textarea";
import { Loader2, Play, Plus, Square, Trash2 } from "lucide-react";
const MAX_REQUEST_BODY_BYTES = 1 << 20;
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const PROTECTED_HEADERS: Record<string, true> = {
authorization: true,
connection: true,
"content-length": true,
cookie: true,
forwarded: true,
host: true,
"keep-alive": true,
"proxy-authorization": true,
"proxy-connection": true,
te: true,
trailer: true,
"transfer-encoding": true,
upgrade: true,
};
export type PlaygroundHeaderRow = {
id: string;
name: string;
value: string;
};
export type RawPlaygroundRequest = {
method: "GET" | "POST";
path: string;
headers: PlaygroundHeaderRow[];
body: string;
};
export const DEFAULT_PLAYGROUND_REQUEST: RawPlaygroundRequest = {
method: "POST",
path: "/v1/chat/completions",
headers: [
{ id: "content-type", name: "Content-Type", value: "application/json" },
],
body: JSON.stringify(
{
model: "",
messages: [{ role: "user", content: "" }],
stream: false,
},
null,
2,
),
};
export function validatePlaygroundRequest(request: RawPlaygroundRequest) {
const bodyBytes = new TextEncoder().encode(request.body).byteLength;
if (bodyBytes > MAX_REQUEST_BODY_BYTES) {
return "Request body must not exceed 1 MiB.";
}
if (!request.path.startsWith("/") || request.path.startsWith("//")) {
return "Path must be origin-form and start with /.";
}
try {
const parsed = new URL(request.path, "https://playground.invalid");
if (parsed.origin !== "https://playground.invalid") {
return "Path must not contain a scheme or host.";
}
} catch {
return "Path is not a valid origin-form URL.";
}
for (const header of request.headers) {
if (!HEADER_NAME.test(header.name)) {
return `Header name ${header.name || "(empty)"} is invalid.`;
}
const lowerName = header.name.toLowerCase();
if (PROTECTED_HEADERS[lowerName] || lowerName.startsWith("x-forwarded-")) {
return `Header ${header.name} is managed by NetBird and cannot be set.`;
}
if (header.value.includes("\r") || header.value.includes("\n")) {
return `Header ${header.name} contains an invalid line break.`;
}
}
return undefined;
}
type Props = {
value: RawPlaygroundRequest;
onChange: (value: RawPlaygroundRequest) => void;
disabled: boolean;
error?: string;
running: boolean;
canRun: boolean;
onRun: () => void;
onCancel: () => void;
};
export default function PlaygroundRequestEditor({
value,
onChange,
disabled,
error,
running,
canRun,
onRun,
onCancel,
}: Readonly<Props>) {
const updateHeader = (
index: number,
field: "name" | "value",
next: string,
) => {
onChange({
...value,
headers: value.headers.map((header, headerIndex) =>
headerIndex === index ? { ...header, [field]: next } : header,
),
});
};
return (
<section className="min-w-0" aria-labelledby="playground-request-heading">
<Card className="w-full">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-nb-gray-900 px-4 py-3">
<h2
id="playground-request-heading"
className="text-sm font-semibold text-nb-gray-100"
>
Raw request
</h2>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="primary"
size="xs"
disabled={!canRun}
onClick={onRun}
>
{running ? (
<Loader2 aria-hidden size={15} className="animate-spin" />
) : (
<Play aria-hidden size={15} />
)}
Run live request
</Button>
{running && (
<Button variant="danger-outline" size="xs" onClick={onCancel}>
<Square aria-hidden size={14} /> Cancel
</Button>
)}
</div>
</div>
<div className="space-y-5 p-4">
<div className="grid gap-3 sm:grid-cols-[7.5rem_minmax(0,1fr)]">
<div>
<Label htmlFor="playground-method">Method</Label>
<Select
value={value.method}
disabled={disabled}
onValueChange={(method) =>
onChange({
...value,
method: method as "GET" | "POST",
})
}
>
<SelectTrigger
id="playground-method"
aria-label="Method"
className="font-mono"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
</SelectContent>
</Select>
</div>
<div className="min-w-0">
<Label htmlFor="playground-path">Path</Label>
<Input
id="playground-path"
aria-label="Path"
className="font-mono"
value={value.path}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, path: event.target.value })
}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label as="div">Headers</Label>
<Button
size="xs"
variant="secondary"
disabled={disabled}
onClick={() =>
onChange({
...value,
headers: [
...value.headers,
{ id: crypto.randomUUID(), name: "", value: "" },
],
})
}
>
<Plus aria-hidden size={14} /> Add header
</Button>
</div>
{value.headers.map((header, index) => {
const nameID = `playground-header-name-${header.id}`;
const valueID = `playground-header-value-${header.id}`;
return (
<div
key={header.id}
className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)_32px]"
>
<label htmlFor={nameID} className="sr-only">
Header {index + 1} name
</label>
<Input
id={nameID}
aria-label={`Header ${index + 1} name`}
className="min-w-0 font-mono"
value={header.name}
placeholder="Header name"
disabled={disabled}
onChange={(event) =>
updateHeader(index, "name", event.target.value)
}
/>
<label htmlFor={valueID} className="sr-only">
Header {index + 1} value
</label>
<Input
id={valueID}
aria-label={`Header ${index + 1} value`}
className="min-w-0 font-mono"
value={header.value}
placeholder="Header value"
disabled={disabled}
onChange={(event) =>
updateHeader(index, "value", event.target.value)
}
/>
<Button
aria-label={`Remove header ${index + 1}`}
variant="danger-outline"
disabled={disabled}
className="h-8 w-8 p-0"
onClick={() =>
onChange({
...value,
headers: value.headers.filter(
(_, headerIndex) => headerIndex !== index,
),
})
}
>
<Trash2 aria-hidden size={14} />
</Button>
</div>
);
})}
</div>
<div>
<Label htmlFor="playground-body">Body</Label>
<Textarea
id="playground-body"
aria-label="Body"
className="min-h-[260px] overflow-auto font-mono text-xs leading-5"
value={value.body}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, body: event.target.value })
}
resize
/>
<div className="mt-1.5 text-right font-mono text-[10px] text-nb-gray-500">
{new TextEncoder().encode(value.body).byteLength.toLocaleString()}{" "}
/ {MAX_REQUEST_BODY_BYTES.toLocaleString()} bytes
</div>
</div>
{error && (
<div role="alert">
<Callout variant="error">{error}</Callout>
</div>
)}
</div>
</Card>
</section>
);
}
Loaded 20 of 25 files, more files were not shown because too many files have changed in this diff. Show more