mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ba28e40e | ||
|
|
cfe797cba2 | ||
|
|
56a2e5f567 | ||
|
|
2727afd42d | ||
|
|
81f54b6864 | ||
|
|
500266b0cd | ||
|
|
cba2613e68 | ||
|
|
fe32179dc1 | ||
|
|
1bad86b277 | ||
|
|
3c72444e83 | ||
|
|
386fb12013 | ||
|
|
e3cc74522c |
No files matched your search
@@ -1,6 +1,7 @@
|
||||
# Ignore zitadel environment
|
||||
.env
|
||||
Caddyfile
|
||||
agentgateway-stub.Caddyfile
|
||||
management.json
|
||||
turnserver.conf
|
||||
zitadel.env
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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, ".."),
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Agent Network agentgateway provider spec.
|
||||
*
|
||||
* Exercises the catalog-driven provider flow against a management build that
|
||||
* includes the agentgateway catalog entry. Older builds skip the test.
|
||||
*/
|
||||
import {
|
||||
deleteAgentNetworkProvidersByPrefix,
|
||||
listAgentNetworkCatalog,
|
||||
supportsAgentNetworkSettingsBootstrap,
|
||||
} from "../helpers/api";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { expect, test } from "../helpers/fixtures";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
const AGENTGATEWAY_CATALOG_ID = "agentgateway";
|
||||
const PROVIDER_PREFIX = "e2e-agentgateway-";
|
||||
|
||||
test.describe
|
||||
.serial("Agent Network agentgateway provider @agent-network", () => {
|
||||
test("connect agentgateway with its trusted identity mapping", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await page.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
|
||||
const catalog = await listAgentNetworkCatalog(page);
|
||||
test.skip(
|
||||
!catalog.some((entry) => entry.id === AGENTGATEWAY_CATALOG_ID),
|
||||
`management catalog has no ${AGENTGATEWAY_CATALOG_ID} entry`,
|
||||
);
|
||||
test.skip(
|
||||
!(await supportsAgentNetworkSettingsBootstrap(page)),
|
||||
"management build does not support Agent Network settings bootstrap",
|
||||
);
|
||||
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
try {
|
||||
await navigateTo(page, "/agent-network/providers");
|
||||
|
||||
await page
|
||||
.getByTestId("connect-agent-network-provider")
|
||||
.first()
|
||||
.click({ force: true });
|
||||
await page
|
||||
.getByTestId("agent-network-provider-type")
|
||||
.click({ force: true });
|
||||
await page
|
||||
.getByTestId("select-dropdown-search")
|
||||
.fill(AGENTGATEWAY_CATALOG_ID);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-option-agentgateway")
|
||||
.click({ force: true });
|
||||
|
||||
// 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);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-api-key")
|
||||
.fill("e2e-agentgateway-virtual-key");
|
||||
|
||||
const providerName = generateRandomName(PROVIDER_PREFIX);
|
||||
await page.getByTestId("agent-network-provider-name").fill(providerName);
|
||||
|
||||
await page
|
||||
.getByTestId("agent-network-provider-models-tab")
|
||||
.click({ force: true });
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-models-help"),
|
||||
).toContainText("Empty = all catalog models");
|
||||
await page
|
||||
.getByTestId("agent-network-provider-continue")
|
||||
.click({ force: true });
|
||||
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-mappings-tab"),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-user-mapping"),
|
||||
).toContainText("x-netbird-user-id");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-groups-mapping"),
|
||||
).toContainText("x-netbird-groups");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-groups-guidance"),
|
||||
).toContainText("must not be used as an agentgateway authorization claim");
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/agent-network/providers") &&
|
||||
response.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("agent-network-provider-submit").click({
|
||||
force: true,
|
||||
});
|
||||
const created = await createResponse;
|
||||
expect([200, 201]).toContain(created.status());
|
||||
|
||||
const body = created.request().postDataJSON();
|
||||
expect(body).toMatchObject({
|
||||
provider_id: AGENTGATEWAY_CATALOG_ID,
|
||||
name: providerName,
|
||||
upstream_url: upstreamURL,
|
||||
models: [],
|
||||
metadata_disabled: false,
|
||||
});
|
||||
expect(body).not.toHaveProperty("identity_header_user_id");
|
||||
expect(body).not.toHaveProperty("identity_header_groups");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-modal"),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(page.getByTestId(providerName)).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`provider-models-${providerName}`),
|
||||
).toHaveText("All Models");
|
||||
|
||||
await page.getByTestId(providerName).click({ force: true });
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-modal"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-upstream-url"),
|
||||
).toHaveValue(upstreamURL);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-mappings-tab")
|
||||
.click({ force: true });
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-user-mapping"),
|
||||
).toContainText("x-netbird-user-id");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-groups-mapping"),
|
||||
).toContainText("x-netbird-groups");
|
||||
await page
|
||||
.getByTestId("agent-network-provider-modal")
|
||||
.getByTestId("modal-close")
|
||||
.click({ force: true });
|
||||
} finally {
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Agent Network provider save: what the operator sees when the backend refuses.
|
||||
*
|
||||
* Saving a provider checks its upstream URL and credential against the vendor
|
||||
* before storing anything, so a 422 naming the field to correct is an ordinary
|
||||
* outcome of the form rather than a server fault. Four things have to hold,
|
||||
* and each one has been wrong at some point:
|
||||
*
|
||||
* - while the check runs the submit says so and refuses a second click. The
|
||||
* vendor call is the slow part and an unreachable upstream drags it out to
|
||||
* a timeout, which looked exactly like a button that had not registered
|
||||
* the first click.
|
||||
* - exactly ONE toast, the shared "Request failed with status code N", which
|
||||
* quotes the API's own sentence. A second toast from the save path said the
|
||||
* same thing under a vaguer title.
|
||||
* - the toast is styled as a failure. notify() renders green with a check
|
||||
* mark unless told otherwise, so a refusal announced itself as a success.
|
||||
* - the modal stays open with the typed values intact. The API never returns
|
||||
* an API key, so closing the form loses it with nowhere to retype it.
|
||||
*
|
||||
* The refusal is mocked rather than provoked: the backend check ships with a
|
||||
* management build these tests do not pin, and what is under test here is the
|
||||
* dashboard's handling of the response, not the vendor call that produces it.
|
||||
*
|
||||
* Like the other agent-network specs, this one builds its own context rather
|
||||
* than taking the shared dashboardAsOwner fixture: the Agent Network menu is
|
||||
* deployment-gated behind a localStorage override that has to be in place
|
||||
* before the first navigation, which addInitScript on an own context is the
|
||||
* way to do. The route interception below is a second reason — the shared page
|
||||
* is worker-scoped, and a 422 left on it would follow every later test.
|
||||
*/
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
const PROVIDERS_ENDPOINT = /\/api\/agent-network\/providers(\?|$)/;
|
||||
const PROVIDER_PREFIX = "e2e-refused-";
|
||||
const TITLE_TESTID = "notification-title";
|
||||
|
||||
// The message a refused save carries: the backend names which of the two
|
||||
// fields is at fault, without a status code and without echoing the URL.
|
||||
const REFUSAL = "the upstream url could not be reached: no such host";
|
||||
|
||||
// Matched case-insensitively on purpose. The backend lowercases its messages
|
||||
// (WriteError does, and the copy is written for it) while the toast uppercases
|
||||
// the first character before rendering. Asserting either spelling would pin
|
||||
// the test to that transform rather than to the sentence the operator reads.
|
||||
const REFUSAL_TEXT = new RegExp(
|
||||
REFUSAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
"i",
|
||||
);
|
||||
|
||||
async function newAgentNetworkPage(browser: Browser): Promise<{
|
||||
page: Page;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
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 loginToApp(page, "owner");
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
// How long the mocked create takes to answer. The real check is a vendor round
|
||||
// trip, so a save is never instant; holding the response gives the in-flight
|
||||
// assertions a window to run in without racing the toast.
|
||||
const REFUSAL_DELAY_MS = 1500;
|
||||
|
||||
// refuseProviderCreate answers the create with the 422 the credential check
|
||||
// produces. Only POST is intercepted: the page still lists providers, and the
|
||||
// settings bootstrap that may precede the create is left alone.
|
||||
async function refuseProviderCreate(page: Page) {
|
||||
await page.route(PROVIDERS_ENDPOINT, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
const origin = route.request().headers()["origin"] || "*";
|
||||
await new Promise((resolve) => setTimeout(resolve, REFUSAL_DELAY_MS));
|
||||
await route.fulfill({
|
||||
status: 422,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"access-control-allow-origin": origin,
|
||||
},
|
||||
body: JSON.stringify({ code: 422, message: REFUSAL }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe
|
||||
.serial("Agent Network refused provider save @agent-network", () => {
|
||||
test("reports the refusal once and keeps the form open", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await newAgentNetworkPage(browser);
|
||||
try {
|
||||
await refuseProviderCreate(page);
|
||||
|
||||
// navigateTo rather than goto: it dismisses the setup modal that greets a
|
||||
// fresh account and clears the scroll lock it leaves behind, either of
|
||||
// which swallows the clicks below.
|
||||
await navigateTo(page, "/agent-network/providers");
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
const providerName = generateRandomName(PROVIDER_PREFIX);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-name")
|
||||
.fill(providerName);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-api-key")
|
||||
.fill("sk-e2e-refused-key");
|
||||
|
||||
// The submit lives on the Models tab — the Provider tab's primary button
|
||||
// only advances to it.
|
||||
await page.getByRole("tab", { name: "Models" }).click({ force: true });
|
||||
const submit = page.getByTestId("agent-network-provider-submit");
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
const refusal = page.waitForResponse(
|
||||
(resp) =>
|
||||
PROVIDERS_ENDPOINT.test(resp.url()) &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await submit.click({ force: true });
|
||||
|
||||
// ---- the wait is visible while it lasts ----
|
||||
// A second create is not idempotent, so the button has to say it is
|
||||
// working and stop taking clicks rather than sit there looking untouched.
|
||||
// Asserted before the response is awaited: the mock holds it open for
|
||||
// exactly this window.
|
||||
await expect(submit).toContainText("Connecting provider");
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
expect((await refusal).status()).toBe(422);
|
||||
|
||||
// ---- the toast says what the API said ----
|
||||
const title = page.getByTestId(TITLE_TESTID).first();
|
||||
await expect(title).toContainText("Request failed with status code 422");
|
||||
await expect(
|
||||
page.locator("[data-toast-notification]").first(),
|
||||
).toContainText(REFUSAL_TEXT);
|
||||
|
||||
// ---- and only that toast ----
|
||||
// The save path used to add its own on top, so the count is the
|
||||
// assertion rather than the presence of the right one.
|
||||
await expect(page.locator("[data-toast-notification]")).toHaveCount(1);
|
||||
await expect(page.getByText("Failed to connect provider")).toHaveCount(0);
|
||||
|
||||
// ---- styled as a failure, not a success ----
|
||||
// notify() paints the icon tile green with a check unless the caller
|
||||
// says otherwise, which is how a refusal once looked like a success.
|
||||
//
|
||||
// A failure reaches the tile two ways — notify()'s own error state, and
|
||||
// a caller passing its own colour and icon, which is what the shared
|
||||
// request-failed toast does — so what has to hold is the negative: this
|
||||
// is not the default success tile.
|
||||
const icon = page.getByTestId("notification-icon");
|
||||
await expect(icon).toBeVisible();
|
||||
await expect(icon).not.toHaveAttribute("data-variant", "success");
|
||||
|
||||
// ---- the form is still there, still holding what was typed ----
|
||||
// The submit only exists while the modal is open, so its presence is the
|
||||
// check that nothing closed underneath the toast. It also has to come
|
||||
// back out of the in-flight state, or the retry the toast asks for is
|
||||
// impossible.
|
||||
await expect(submit).toBeVisible();
|
||||
await expect(submit).toBeEnabled();
|
||||
await expect(submit).toContainText("Connect Provider");
|
||||
await page.getByRole("tab", { name: "Provider" }).click({ force: true });
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-name"),
|
||||
).toHaveValue(providerName);
|
||||
// The key matters most: the API never returns one, so a form that lost
|
||||
// it leaves the operator with nothing to correct.
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-api-key"),
|
||||
).toHaveValue("sk-e2e-refused-key");
|
||||
} 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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
Generated
+827
-8
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -180,6 +180,22 @@ export default function Notification<T>({
|
||||
>
|
||||
<div className={"flex items-center gap-4"}>
|
||||
<div
|
||||
data-testid={"notification-icon"}
|
||||
// Whether a toast reads as a success or a failure is carried by
|
||||
// the tile's colour alone, which a test can only assert as a
|
||||
// tailwind class. Naming the state says the same thing without
|
||||
// pinning the palette. "success" is the default green tick
|
||||
// specifically, so a caller that supplied its own colour is
|
||||
// "custom" rather than folded in with it.
|
||||
data-variant={
|
||||
loading
|
||||
? "loading"
|
||||
: error
|
||||
? "error"
|
||||
: backgroundColor || icon
|
||||
? "custom"
|
||||
: "success"
|
||||
}
|
||||
className={classNames(
|
||||
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
|
||||
loading
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { notify } from "@components/Notification";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -217,6 +218,18 @@ function fromAPI(p: APIProvider): AIProvider {
|
||||
};
|
||||
}
|
||||
|
||||
// notify() renders green with a check mark unless it is told otherwise: its red
|
||||
// styling comes from the promise path, and none of these use it. A failure that
|
||||
// looks like a success is worse than saying nothing, so every failure toast in
|
||||
// this file goes through here.
|
||||
function notifyFailure(props: { title: string; description: string }) {
|
||||
return notify({
|
||||
...props,
|
||||
backgroundColor: "bg-red-500",
|
||||
icon: <IconCircleX size={20} />,
|
||||
});
|
||||
}
|
||||
|
||||
function toAPIModels(models: ProviderModel[]): APIProviderModel[] {
|
||||
// undefined cache rates stay undefined so JSON.stringify omits the
|
||||
// key: an omitted rate inherits NetBird's default, an explicit 0
|
||||
@@ -497,7 +510,13 @@ type AIProvidersContextValue = {
|
||||
closeWizard: () => void;
|
||||
isWizardOpen: boolean;
|
||||
addProvider: (input: ProviderConnectInput) => Promise<AIProvider | undefined>;
|
||||
updateProvider: (id: string, updates: ProviderUpdateInput) => Promise<void>;
|
||||
// Resolves false when the save was refused — the backend checks a provider's
|
||||
// url and credential before storing them, so a rejected edit must leave the
|
||||
// form open with what the operator typed still in it.
|
||||
updateProvider: (
|
||||
id: string,
|
||||
updates: ProviderUpdateInput,
|
||||
) => Promise<boolean>;
|
||||
toggleProvider: (id: string) => Promise<void>;
|
||||
deleteProvider: (id: string) => Promise<void>;
|
||||
addPolicy: (
|
||||
@@ -609,6 +628,11 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
(agentNetworkEnabled && !!permission?.["agent_network.providers"]?.read) ||
|
||||
mySetupConfigured,
|
||||
);
|
||||
// Default error handling on purpose: a failed save raises the shared
|
||||
// "Request failed with status code N" toast, which carries the message the
|
||||
// API sent — for a refused provider that is the sentence naming the url or
|
||||
// the credential. The save paths below stay silent on failure rather than
|
||||
// adding a second toast that says the same thing in different words.
|
||||
const providersApi = useApiCall<APIProvider>("/agent-network/providers");
|
||||
|
||||
const { data: apiPolicies, mutate: mutatePolicies } = useFetchApi<
|
||||
@@ -688,21 +712,24 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
|
||||
const addProvider = useCallback(
|
||||
async (input: ProviderConnectInput) => {
|
||||
let created: APIProvider;
|
||||
try {
|
||||
const created = await providersApi.post(toCreateRequest(input));
|
||||
await mutate();
|
||||
notify({
|
||||
title: "AI provider connected",
|
||||
description: `${created.name} is now available on your agent network endpoint.`,
|
||||
});
|
||||
return fromAPI(created);
|
||||
} catch (err) {
|
||||
notify({
|
||||
title: "Failed to connect provider",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
created = await providersApi.post(toCreateRequest(input));
|
||||
} catch {
|
||||
// Reported already by the shared request-failed toast. Returning
|
||||
// undefined is what keeps the modal open on the fields to correct.
|
||||
return undefined;
|
||||
}
|
||||
// Outside the catch: the provider exists from here on, and a failed
|
||||
// revalidation is a stale list rather than a failed create. Reporting it
|
||||
// as one would hold the modal open on a form whose next submit creates a
|
||||
// second provider.
|
||||
await mutate().catch(() => undefined);
|
||||
notify({
|
||||
title: "AI provider connected",
|
||||
description: `${created.name} is now available on your agent network endpoint.`,
|
||||
});
|
||||
return fromAPI(created);
|
||||
},
|
||||
[providersApi, mutate],
|
||||
);
|
||||
@@ -710,7 +737,16 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
const updateProvider = useCallback(
|
||||
async (id: string, updates: ProviderUpdateInput) => {
|
||||
const existing = (apiProviders ?? []).find((p) => p.id === id);
|
||||
if (!existing) return;
|
||||
if (!existing) {
|
||||
// The update merges onto the record as this browser last saw it, so a
|
||||
// provider deleted elsewhere leaves nothing to merge onto. Silence
|
||||
// here read as a save that did nothing.
|
||||
notifyFailure({
|
||||
title: "Provider not updated",
|
||||
description: "This provider is no longer available. Reload the page.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const merged: APIProviderRequest = {
|
||||
provider_id: updates.providerId ?? existing.provider_id,
|
||||
name: updates.name ?? existing.name,
|
||||
@@ -737,17 +773,18 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
};
|
||||
try {
|
||||
await providersApi.put(merged, `/${id}`);
|
||||
await mutate();
|
||||
notify({
|
||||
title: "Provider updated",
|
||||
description: "Settings saved.",
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
title: "Failed to update provider",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} catch {
|
||||
// Reported already by the shared request-failed toast.
|
||||
return false;
|
||||
}
|
||||
// See addProvider: a failed revalidation is a stale list, not a failed
|
||||
// write, and must not send the operator back to resubmit one.
|
||||
await mutate().catch(() => undefined);
|
||||
notify({
|
||||
title: "Provider updated",
|
||||
description: "Settings saved.",
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[apiProviders, providersApi, mutate],
|
||||
);
|
||||
@@ -771,7 +808,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Endpoint will be torn down on next mapping update.",
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to remove provider",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -791,7 +828,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
});
|
||||
return policyFromAPI(created);
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to create policy",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -827,7 +864,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Settings saved.",
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to update policy",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -855,7 +892,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Policy deleted.",
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to remove policy",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -875,7 +912,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
});
|
||||
return guardrailFromAPI(created);
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to create guardrail",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -904,7 +941,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Settings saved.",
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to update guardrail",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -924,7 +961,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
"Existing policies still reference this guardrail until you detach it.",
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to remove guardrail",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -944,7 +981,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
});
|
||||
return budgetRuleFromAPI(created);
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to create global limit",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -978,7 +1015,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Settings saved.",
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to update global limit",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -1006,7 +1043,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Global limit deleted.",
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to remove global limit",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -1025,7 +1062,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
} catch (err) {
|
||||
const code = (err as { code?: number })?.code;
|
||||
if (code !== 409) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to set up the agent network endpoint",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -1047,7 +1084,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
// row there is nothing to echo — and no row to update; the backend
|
||||
// would 404 the PUT anyway.
|
||||
if (!settings) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to update account controls",
|
||||
description: "Agent Network has not been set up yet.",
|
||||
});
|
||||
@@ -1062,7 +1099,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({
|
||||
notifyFailure({
|
||||
title: "Failed to update account controls",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
|
||||
Loaded 20 of 30 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user