Compare commits

..
74 changed files with 936 additions and 2971 deletions

No files matched your search

-1
View File
@@ -1,7 +1,6 @@
# 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 agentgateway-stub.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 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
+1 -35
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 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 " 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 "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
@@ -540,7 +540,6 @@ initEnvironment() {
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
@@ -613,30 +612,6 @@ 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
{
@@ -843,15 +818,6 @@ 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}
+13 -145
View File
@@ -46,7 +46,8 @@ async function getApiContext(
]);
const request = response.request();
const authHeader = (await request.allHeaders())["authorization"] || "";
const authHeader =
(await request.allHeaders())["authorization"] || "";
const token = authHeader.replace("Bearer ", "");
const url = new URL(request.url());
const origin = `${url.protocol}//${url.host}`;
@@ -75,35 +76,15 @@ async function apiDelete(page: Page, path: string): Promise<void> {
});
}
async function apiPost<T>(page: Page, path: string, body: unknown): Promise<T> {
async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.post(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
data: body,
data,
});
if (!resp.ok()) {
throw new Error(
`POST ${path} returned ${resp.status()}: ${await resp.text()}`,
);
}
return resp.json();
}
async function apiPut<T>(page: Page, path: string, body: unknown): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.put(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
data: body,
});
if (!resp.ok()) {
throw new Error(
`PUT ${path} returned ${resp.status()}: ${await resp.text()}`,
);
}
return resp.json();
}
/** List all groups. */
export async function listGroups(page: Page): Promise<Group[]> {
return apiGet<Group[]>(page, "/groups");
}
@@ -127,12 +108,10 @@ export async function deletePeersByPrefix(page: Page, prefix: string) {
}
}
/** Create a group by name. */
export async function createGroup(page: Page, name: string): Promise<Group> {
return apiPost<Group>(page, "/groups", { name, peers: [] });
}
/** Delete a group by ID. */
export async function deleteGroup(page: Page, groupId: string) {
await apiDelete(page, `/groups/${groupId}`);
}
@@ -280,10 +259,7 @@ export async function deleteRouteById(page: Page, routeId: string) {
await apiDelete(page, `/routes/${routeId}`);
}
export async function deleteRoutesByNetworkIdPrefix(
page: Page,
prefix: string,
) {
export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) {
const routes = await listRoutes(page);
const toDelete = routes.filter((r) => r.network_id.startsWith(prefix));
for (const r of toDelete) {
@@ -362,13 +338,8 @@ type NotificationChannel = {
enabled: boolean;
};
export async function listNotificationChannels(
page: Page,
): Promise<NotificationChannel[]> {
return apiGet<NotificationChannel[]>(
page,
"/integrations/notifications/channels",
);
export async function listNotificationChannels(page: Page): Promise<NotificationChannel[]> {
return apiGet<NotificationChannel[]>(page, "/integrations/notifications/channels");
}
export async function deleteNotificationChannel(page: Page, channelId: string) {
@@ -382,10 +353,7 @@ export async function deleteAllNotificationChannels(page: Page) {
}
}
export async function deleteNotificationChannelsByType(
page: Page,
type: string,
) {
export async function deleteNotificationChannelsByType(page: Page, type: string) {
const channels = await listNotificationChannels(page);
const toDelete = channels.filter((c) => c.type === type);
for (const c of toDelete) {
@@ -400,9 +368,7 @@ type NameserverGroup = {
name: string;
};
export async function listNameserverGroups(
page: Page,
): Promise<NameserverGroup[]> {
export async function listNameserverGroups(page: Page): Promise<NameserverGroup[]> {
return apiGet<NameserverGroup[]>(page, "/dns/nameservers");
}
@@ -410,10 +376,7 @@ export async function deleteNameserverGroupById(page: Page, id: string) {
await apiDelete(page, `/dns/nameservers/${id}`);
}
export async function deleteNameserverGroupsByPrefix(
page: Page,
prefix: string,
) {
export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) {
const groups = await listNameserverGroups(page);
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
for (const g of toDelete) {
@@ -428,16 +391,11 @@ type ReverseProxyService = {
name: string;
};
export async function listReverseProxyServices(
page: Page,
): Promise<ReverseProxyService[]> {
export async function listReverseProxyServices(page: Page): Promise<ReverseProxyService[]> {
return apiGet<ReverseProxyService[]>(page, "/reverse-proxies/services");
}
export async function deleteReverseProxyServiceById(
page: Page,
serviceId: string,
) {
export async function deleteReverseProxyServiceById(page: Page, serviceId: string) {
await apiDelete(page, `/reverse-proxies/services/${serviceId}`);
}
@@ -491,13 +449,7 @@ export async function waitForProxyClustersOnline(
throw new Error(
`Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join(
", ",
)}; got ${JSON.stringify(
last.map((c) => ({
a: c.address,
online: c.online,
n: c.connected_proxies,
})),
)}`,
)}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`,
);
}
@@ -509,8 +461,6 @@ type User = {
name: string;
role: string;
status: string;
auto_groups: string[];
is_blocked: boolean;
is_current: boolean;
};
@@ -588,85 +538,3 @@ export async function supportsAgentNetworkSettingsBootstrap(
!!settings && typeof settings === "object" && "proxy_address" in settings
);
}
/**
* Whether the management build under test serves the caller-scoped
* GET /agent-network/agent-config answer that backs the Connect Agent page.
* The endpoint answers 200 for every authenticated caller (an unconfigured
* caller gets configured=false, never an error), so any non-OK status means
* the build predates it.
*/
export async function supportsAgentNetworkAgentConfig(
page: Page,
): Promise<boolean> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.get(
`${origin}/api/agent-network/agent-config`,
{ headers: { Authorization: `Bearer ${token}` } },
);
return resp.ok();
}
type AgentNetworkPolicy = {
id: string;
name: string;
};
/** List Agent Network policies. */
export async function listAgentNetworkPolicies(
page: Page,
): Promise<AgentNetworkPolicy[]> {
return apiGet<AgentNetworkPolicy[]>(page, "/agent-network/policies");
}
/** Create an Agent Network policy. */
export async function createAgentNetworkPolicy(
page: Page,
body: {
name: string;
source_groups: string[];
destination_provider_ids: string[];
enabled?: boolean;
},
): Promise<AgentNetworkPolicy> {
return apiPost<AgentNetworkPolicy>(page, "/agent-network/policies", {
enabled: true,
...body,
});
}
/** Delete all Agent Network policies whose name starts with the prefix. */
export async function deleteAgentNetworkPoliciesByPrefix(
page: Page,
prefix: string,
) {
const policies = await listAgentNetworkPolicies(page);
for (const p of policies) {
if (p.name.startsWith(prefix)) {
await apiDelete(page, `/agent-network/policies/${p.id}`);
}
}
}
/** The user the captured token belongs to. */
export async function getCurrentUser(page: Page): Promise<User> {
const users = await apiGet<User[]>(page, "/users");
const current = users.find((u) => u.is_current);
if (!current) {
throw new Error("no is_current user in the /users answer");
}
return current;
}
/** Replace a user's auto-groups, keeping role and blocked state. */
export async function updateUserAutoGroups(
page: Page,
user: User,
autoGroups: string[],
): Promise<void> {
await apiPut(page, `/users/${user.id}`, {
role: user.role,
auto_groups: autoGroups,
is_blocked: !!user.is_blocked,
});
}
-25
View File
@@ -24,31 +24,6 @@ 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,156 +0,0 @@
/**
* 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);
}
});
});
+18 -81
View File
@@ -3,9 +3,9 @@
*
* Walks the Kimi provider lifecycle end to end against the real backend:
* pick kimi_api from the catalog (prefilled host, catalog models with
* pricing), connect it, then verify the Kimi-gated config surfaces on the
* Connect Agent page (Kimi CLI tab, Kimi backend option in the Claude Code
* tab) that only render when a Kimi provider is connected.
* pricing), connect it, then verify the Kimi-gated config surfaces in the
* "Configure Your Agent" modal (Kimi CLI tab, Kimi backend option in the
* Claude Code tab) that only render when a Kimi provider is connected.
*
* The kimi_api catalog entry ships with newer management builds. When the
* backend under test predates it, the whole suite skips instead of failing —
@@ -19,57 +19,20 @@
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
* set via addInitScript on a dedicated context below.
*/
import { type Browser, expect, type Page,test } from "@playwright/test";
import {
createAgentNetworkPolicy,
createGroup,
deleteAgentNetworkPoliciesByPrefix,
deleteAgentNetworkProvidersByPrefix,
deleteGroup,
getCurrentUser,
listAgentNetworkCatalog,
listGroups,
supportsAgentNetworkAgentConfig,
supportsAgentNetworkSettingsBootstrap,
updateUserAutoGroups,
} from "../helpers/api";
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
supportsAgentNetworkSettingsBootstrap,
} from "../helpers/api";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const KIMI_CATALOG_ID = "kimi_api";
const KIMI_CATALOG_NAME = "Kimi (Moonshot AI) API";
const PROVIDER_PREFIX = "e2e-kimi-";
/**
* Remove every fixture this spec creates: the policy granting the caller the
* provider, the group carrying that grant (detached from the caller first —
* a group referenced by a user's auto-groups refuses deletion), and the
* provider itself. Runs before the test too, so leftovers from an interrupted
* run never poison the next one.
*/
async function cleanupKimiFixtures(page: Page) {
await deleteAgentNetworkPoliciesByPrefix(page, PROVIDER_PREFIX);
const fixtureGroups = (await listGroups(page)).filter((g) =>
g.name.startsWith(PROVIDER_PREFIX),
);
if (fixtureGroups.length > 0) {
const fixtureGroupIds = new Set(fixtureGroups.map((g) => g.id));
const caller = await getCurrentUser(page);
if ((caller.auto_groups ?? []).some((id) => fixtureGroupIds.has(id))) {
await updateUserAutoGroups(
page,
caller,
(caller.auto_groups ?? []).filter((id) => !fixtureGroupIds.has(id)),
);
}
for (const g of fixtureGroups) {
await deleteGroup(page, g.id);
}
}
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
}
async function newAgentNetworkPage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
@@ -108,17 +71,8 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
"management build has no POST /agent-network/settings, so the " +
"wizard cannot bootstrap the account before the first create",
);
// The Kimi-gated config surfaces live on the Connect Agent page, whose
// caller-scoped GET /agent-network/agent-config answer ships with
// newer management builds. The suite starts running once the backend
// under test carries it.
test.skip(
!(await supportsAgentNetworkAgentConfig(page)),
"management build has no GET /agent-network/agent-config, so the " +
"Connect Agent page cannot render the agent config",
);
await cleanupKimiFixtures(page);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
@@ -199,36 +153,17 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
.click({ force: true });
const created = await Promise.race([createResponse, bootstrapRejected]);
expect([200, 201]).toContain(created.status());
const createdProvider = (await created.json()) as { id: string };
// Row lands in the providers table.
await expect(page.getByText(providerName).first()).toBeVisible();
// ---- Kimi-gated agent config surfaces ----
// The agent config lives inline on the Connect Agent page — the
// providers page keeps only the endpoint URL and Copy. That page's
// answer is caller-scoped: it offers only providers the caller's own
// policies authorize, so grant the caller the new provider through a
// dedicated group + policy (removed again by cleanupKimiFixtures).
const caller = await getCurrentUser(page);
const grantGroup = await createGroup(page, `${PROVIDER_PREFIX}grant`);
await updateUserAutoGroups(page, caller, [
...(caller.auto_groups ?? []),
grantGroup.id,
]);
await createAgentNetworkPolicy(page, {
name: `${PROVIDER_PREFIX}policy`,
source_groups: [grantGroup.id],
destination_provider_ids: [createdProvider.id],
});
await page
.getByRole("button", { name: "Agent Config" })
.click({ force: true });
await page.goto("/agent-network/connect");
// Kimi CLI tab only renders when a kimi_api provider is connected. The
// longer timeout covers the page's caller-scoped agent-config fetch.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible({
timeout: 15_000,
});
// Kimi CLI tab only renders when a kimi_api provider is connected.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
@@ -257,8 +192,10 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
await expect(page.getByText('wire_api = "responses"')).toBeVisible();
await expect(page.getByText('wire_api = "chat"')).not.toBeVisible();
await page.keyboard.press("Escape");
// ---- cleanup ----
await cleanupKimiFixtures(page);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
} finally {
await close();
}
@@ -1,195 +0,0 @@
/**
* 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,17 +364,13 @@ 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/);
// 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.evaluate(
() => (document.activeElement as HTMLElement | null)?.blur(),
);
await page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0);
@@ -19,7 +19,6 @@ import {
dismissBlockingOverlays,
enterDraft,
openControlCenter,
openGroupView,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
@@ -38,6 +37,7 @@ 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,9 +49,7 @@ test.describe
src.id,
dst.id,
);
// 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 openControlCenter(page, "groups");
await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
+14 -7
View File
@@ -18,7 +18,6 @@ import {
clickContextMenuItem,
dismissBlockingOverlays,
openControlCenter,
openGroupView,
resetDraftState,
switchFlowView,
} from "../helpers/control-center";
@@ -48,6 +47,8 @@ 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);
@@ -63,10 +64,15 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
const policyNode = canvasNode(page, `policy-${policy.id}`);
// 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);
// 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;
}
await expect(policyNode).toBeVisible({ timeout: 10_000 });
return { src, dst, policy, policyNode };
}
@@ -506,6 +512,7 @@ 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);
@@ -522,7 +529,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
[],
);
await openGroupView(page, src.name);
await openControlCenter(page, "groups");
const groupNode = canvasNode(page, `group-${dst.id}`);
await expect(groupNode).toBeVisible({ timeout: 15_000 });
@@ -603,7 +610,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
await openGroupView(page, src.name);
await openControlCenter(page, "groups");
const policyNode = canvasNode(page, `policy-${p1.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
await expect(
+12 -93
View File
@@ -13,80 +13,12 @@ const credentials: Record<TestUser, { username: string; password: string }> = {
user: { username: "user@localhost.test", password: "testMe123@" },
};
// Temporary: the CI failures for the user fixture report only which locator
// timed out, which cannot distinguish a blank page from a rendered app with an
// empty sidebar. Dump what the page actually holds, into the run log. Remove
// once the user login is green again.
async function reportPageState(
page: import("@playwright/test").Page,
user: TestUser,
pageErrors: string[],
failedRequests: string[],
) {
console.log(`--- ${user}: page state after login ---`);
console.log(`url: ${page.url()}`);
try {
const body = (await page.locator("body").innerText()).trim();
console.log(`body text (${body.length} chars): ${body.slice(0, 500)}`);
console.log(
`nav items in dom: ${await page
.getByTestId("left-navigation-item")
.count()}`,
);
} catch (e) {
console.log(`could not read the body: ${String(e)}`);
}
if (pageErrors.length > 0) {
console.log(`page errors:\n${pageErrors.join("\n")}`);
} else {
console.log("page errors: none");
}
if (failedRequests.length > 0) {
console.log(`failed requests:\n${failedRequests.join("\n")}`);
}
}
async function loginAndSave(
page: import("@playwright/test").Page,
user: TestUser,
) {
const { username, password } = credentials[user];
const pageErrors: string[] = [];
const failedRequests: string[] = [];
page.on("pageerror", (err) => {
// The message alone came back empty for the crash under investigation, so
// record what was actually thrown along with where it came from.
pageErrors.push(
`pageerror: ${String(err)}\n${(err.stack ?? "no stack").slice(0, 800)}`,
);
});
page.on("console", (msg) => {
if (msg.type() === "error") pageErrors.push(`console: ${msg.text()}`);
});
// The body of a rejected API call carries the reason (permission denied,
// blocked, pending approval), which is what separates a misprovisioned test
// user from a dashboard that cannot render one.
const bodies: Promise<void>[] = [];
page.on("response", (resp) => {
if (resp.status() < 400) return;
const line = `${resp.status()} ${resp.url()}`;
if (!resp.url().includes("/api/")) {
failedRequests.push(line);
return;
}
bodies.push(
resp
.text()
.then((body) => {
failedRequests.push(`${line}\n body: ${body.slice(0, 200)}`);
})
.catch(() => {
failedRequests.push(`${line} (body unavailable)`);
}),
);
});
await page.goto("/");
await page.locator("input[id=loginName]").waitFor({ state: "visible" });
@@ -104,33 +36,20 @@ async function loginAndSave(
const modal = page.getByTestId("setup-netbird-modal");
const approval = page.getByText("User Approval Pending");
let after_login: "2fa" | "app" | "modal" | "approval";
try {
after_login = await Promise.race([
skipButton.waitFor({ timeout: 15_000 }).then(() => "2fa" as const),
appNav.waitFor({ timeout: 15_000 }).then(() => "app" as const),
modal.waitFor({ timeout: 15_000 }).then(() => "modal" as const),
approval.waitFor({ timeout: 15_000 }).then(() => "approval" as const),
]);
} catch (e) {
await Promise.allSettled(bodies);
await reportPageState(page, user, pageErrors, failedRequests);
throw e;
}
const after_login = await Promise.race([
skipButton.waitFor({ timeout: 15_000 }).then(() => "2fa" as const),
appNav.waitFor({ timeout: 15_000 }).then(() => "app" as const),
modal.waitFor({ timeout: 15_000 }).then(() => "modal" as const),
approval.waitFor({ timeout: 15_000 }).then(() => "approval" as const),
]);
if (after_login === "2fa") {
await skipButton.click();
try {
await Promise.race([
appNav.waitFor({ timeout: 15_000 }),
modal.waitFor({ timeout: 15_000 }),
approval.waitFor({ timeout: 15_000 }),
]);
} catch (e) {
await Promise.allSettled(bodies);
await reportPageState(page, user, pageErrors, failedRequests);
throw e;
}
await Promise.race([
appNav.waitFor({ timeout: 15_000 }),
modal.waitFor({ timeout: 15_000 }),
approval.waitFor({ timeout: 15_000 }),
]);
}
// Dismiss setup modal if present
@@ -186,7 +105,7 @@ test.describe("Global Setup", () => {
25_000,
);
} catch (err) {
// eslint-disable-next-line no-console
console.warn(
`[setup] proxy clusters not confirmed online; reverse-proxy specs may be affected: ${
(err as Error).message
+90 -1
View File
@@ -1065,6 +1065,9 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1081,6 +1084,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1097,6 +1103,9 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1113,6 +1122,9 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1129,6 +1141,9 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1145,6 +1160,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1161,6 +1179,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1177,6 +1198,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1193,6 +1217,9 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1215,6 +1242,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1237,6 +1267,9 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1259,6 +1292,9 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1281,6 +1317,9 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1303,6 +1342,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1325,6 +1367,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1347,6 +1392,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1575,6 +1623,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1591,6 +1642,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1607,6 +1661,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1623,6 +1680,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3038,6 +3098,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3055,6 +3118,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3072,6 +3138,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3089,6 +3158,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3106,6 +3178,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3123,6 +3198,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7960,6 +8038,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7981,6 +8062,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8002,6 +8086,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8023,6 +8110,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8782,7 +8872,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -8,8 +8,8 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import * as Tabs from "@radix-ui/react-tabs";
import { ExternalLinkIcon, Gauge, ScrollText, ServerIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { lazy, Suspense, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import React, { lazy, Suspense, useEffect, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
@@ -38,32 +38,15 @@ export default function AgentNetworkConfigurationPage() {
const { only: agentNetworkOnly } = useAgentNetworkMode();
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const router = useRouter();
const pathname = usePathname();
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
// Clusters is a reverse-proxy surface (its table and controls run on the
// services permission), so it stays hidden from roles that only hold
// agent_network.settings, e.g. agent_network_admin. Unknown ?tab= values
// fall back to the first tab so a matching content pane always renders.
const canReadClusters = !!permission?.services?.read;
const selectableTabs = useMemo(() => {
const tabs = new Set([TAB_BUDGET_SETTINGS, TAB_LOG_SETTINGS]);
if (canReadClusters) tabs.add(TAB_CLUSTERS);
return tabs;
}, [canReadClusters]);
// The ?tab= query is the single source of truth. Trigger clicks push it
// themselves; onChange covers Radix's keyboard navigation, which fires
// onValueChange without a click.
const tab =
queryTab && selectableTabs.has(queryTab) ? queryTab : TAB_BUDGET_SETTINGS;
const onTabChange = (value: string) => {
router.push(`${pathname}?tab=${value}`, { scroll: false });
};
useEffect(() => {
if (queryTab) setTab(queryTab);
}, [queryTab]);
return (
<PageContainer>
<VerticalTabs value={tab} onChange={onTabChange}>
<VerticalTabs value={tab} onChange={setTab}>
<VerticalTabs.List>
<VerticalTabs.Trigger value={TAB_BUDGET_SETTINGS}>
<Gauge size={14} />
@@ -73,16 +56,14 @@ export default function AgentNetworkConfigurationPage() {
<ScrollText size={14} />
Log Collection
</VerticalTabs.Trigger>
{canReadClusters && (
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
<ServerIcon size={14} />
Clusters
</VerticalTabs.Trigger>
)}
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
<ServerIcon size={14} />
Clusters
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess
page={"Configuration"}
hasAccess={permission?.["agent_network.settings"]?.read}
hasAccess={permission?.services?.read}
>
<GroupsProvider>
<PeersProvider>
@@ -115,28 +96,26 @@ export default function AgentNetworkConfigurationPage() {
</Suspense>
</Tabs.Content>
{canReadClusters && (
<Tabs.Content value={TAB_CLUSTERS} className={"w-full"}>
<ConfigTabHeader
label={"Clusters"}
href={"/agent-network/configuration?tab=clusters"}
<Tabs.Content value={TAB_CLUSTERS} className={"w-full"}>
<ConfigTabHeader
label={"Clusters"}
href={"/agent-network/configuration?tab=clusters"}
>
{agentNetworkOnly
? "Proxy clusters route your agents' traffic to AI providers and run on your own infrastructure. Add multiple clusters to scale your environment."
: "Proxy clusters route inbound traffic to your services. Shared clusters are run by the platform; account clusters (self-hosted) run on your own infrastructure."}{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
{agentNetworkOnly
? "Proxy clusters route your agents' traffic to AI providers and run on your own infrastructure. Add multiple clusters to scale your environment."
: "Proxy clusters route inbound traffic to your services. Shared clusters are run by the platform; account clusters (self-hosted) run on your own infrastructure."}{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</ConfigTabHeader>
<Suspense fallback={<SkeletonTable />}>
<ClustersTable />
</Suspense>
</Tabs.Content>
)}
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</ConfigTabHeader>
<Suspense fallback={<SkeletonTable />}>
<ClustersTable />
</Suspense>
</Tabs.Content>
</div>
</AIProvidersProvider>
</PeersProvider>
@@ -1,101 +0,0 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import React from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import PageContainer from "@/layouts/PageContainer";
import { AgentConnectTabs } from "@/modules/agent-network/AgentConnectTabs";
import EndpointBadge from "@/modules/agent-network/EndpointBadge";
import ConnectProvidersTable from "@/modules/agent-network/table/ConnectProvidersTable";
import {
APIMeSetup,
useMyAgentNetworkSetup,
} from "@/modules/agent-network/useMyAgentNetworkSetup";
// ConnectAgentPage is the caller-scoped self-service view: the endpoint to
// configure tools with and the per-tool config that goes with it — the one
// place the agent config lives — plus the providers and models the caller's
// own policies allow. It needs no agent_network permission (the backing
// endpoint answers for the caller only), so every role, including plain users
// in the limited view, gets the same config. A caller no policy covers yet
// still gets it, with an empty provider list carrying the explanation. The caller's own usage lives on the regular Usage & Logs page,
// which the server scopes to them.
export default function ConnectAgentPage() {
const { setup, isLoading } = useMyAgentNetworkSetup();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/agent-network/connect"}
label={"Agent Network"}
icon={<AgentNetworkIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/agent-network/connect"}
label={"Connect Agent"}
active
/>
</Breadcrumbs>
<h1>Connect Your Agent</h1>
<Paragraph>
Point your agent at the NetBird endpoint as its base URL. No provider
API key is required on the client. NetBird authenticates you through
your identity provider and authorizes each request against your access
policies.
</Paragraph>
{isLoading ? (
<div className={"mt-4"}>
<SkeletonTable />
</div>
) : (
<ConnectAgentSetup setup={setup} />
)}
</div>
</PageContainer>
);
}
function ConnectAgentSetup({ setup }: { setup?: APIMeSetup }) {
const providers = setup?.providers ?? [];
// EndpointBadge builds https:// URLs from a bare host.
const bareEndpoint = (setup?.endpoint ?? "").replace(/^https?:\/\//, "");
const providerIds = providers.map((provider) => provider.catalog_id);
return (
<>
{/* The server hands the endpoint to every member of an account that has
Agent Network set up, covered by a policy or not, so this renders for
everyone; it stays guarded because an account with no endpoint yet
has nothing to copy and no snippet that would work. */}
{bareEndpoint && (
<>
<div className={"mt-4"}>
<EndpointBadge endpoint={bareEndpoint} />
</div>
<div className={"max-w-3xl"}>
{/* Same 16px step the endpoint card sits below the description
by. */}
<AgentConnectTabs
endpoint={bareEndpoint}
className={"mt-4"}
listClassName={"px-0"}
contentClassName={"px-0 py-2"}
providerIds={providerIds}
/>
</div>
</>
)}
<div className={"max-w-3xl"}>
<h2 className={"text-base mt-8 mb-0"}>Your Providers &amp; Models</h2>
<ConnectProvidersTable providers={providers} />
</div>
</>
);
}
@@ -43,7 +43,7 @@ export default function AgentNetworkPoliciesPage() {
<RestrictedAccess
page={"Policies"}
hasAccess={permission?.["agent_network.policies"]?.read}
hasAccess={permission?.services?.read}
>
<AIProvidersProvider>
<Suspense fallback={<SkeletonTable />}>
@@ -6,28 +6,93 @@ import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon, Globe } from "lucide-react";
import React, { Suspense } from "react";
import useCopyToClipboard from "@hooks/useCopyToClipboard";
import { Copy, ExternalLinkIcon, Globe, Plug } from "lucide-react";
import React, { Suspense, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PageContainer from "@/layouts/PageContainer";
import AgentConnectModal from "@/modules/agent-network/AgentConnectModal";
import AIProviderModal from "@/modules/agent-network/AIProviderModal";
import AIProvidersProvider, {
useAIProviders,
} from "@/modules/agent-network/AIProvidersProvider";
import EndpointBadge from "@/modules/agent-network/EndpointBadge";
import AgentProvidersTable from "@/modules/agent-network/table/AgentProvidersTable";
import InlineLink from "@components/InlineLink";
function EndpointBadge({ endpoint }: { endpoint: string }) {
const [, copy] = useCopyToClipboard(`https://${endpoint}`);
const [connectOpen, setConnectOpen] = useState(false);
return (
<div
className={
"inline-flex items-center gap-3 rounded-lg border border-nb-gray-800 bg-nb-gray-900/40 p-3 min-w-[300px]"
}
>
<div className={"flex flex-col"}>
<div
className={
"text-[10px] text-nb-gray-400 uppercase tracking-wider font-medium inline-flex items-center gap-1.5"
}
>
API Base URL
<HelpTooltip
iconSize={11}
content={
<>
Use this URL as the base URL when configuring your AI agents or
LLM SDK clients (e.g. OpenAI&apos;s
<code className={"font-mono"}> base_url</code>, Anthropic&apos;s{" "}
<code className={"font-mono"}>baseURL</code>, or any HTTP
client). Calls hit NetBird first, get authorised by your
policies, and only then reach the upstream provider.
</>
}
/>
</div>
<code
className={
"font-mono text-xs text-nb-gray-100 leading-tight mt-0.5 whitespace-nowrap"
}
>
https://{endpoint}
</code>
</div>
<button
type={"button"}
className={
"inline-flex items-center gap-1.5 rounded-md border border-nb-gray-700 bg-nb-gray-800/60 px-2.5 py-1.5 text-[11px] font-medium text-nb-gray-200 hover:bg-nb-gray-800 hover:text-white transition-colors shrink-0"
}
onClick={() => copy("Endpoint copied to clipboard")}
aria-label={"Copy endpoint"}
>
<Copy size={12} />
Copy
</button>
<button
type={"button"}
className={
"inline-flex items-center gap-1.5 rounded-md border border-nb-gray-700 bg-nb-gray-800/60 px-2.5 py-1.5 text-[11px] font-medium text-nb-gray-200 hover:bg-nb-gray-800 hover:text-white transition-colors shrink-0"
}
onClick={() => setConnectOpen(true)}
aria-label={"Agent config"}
>
<Plug size={12} />
Agent Config
</button>
<AgentConnectModal
open={connectOpen}
onOpenChange={setConnectOpen}
endpoint={endpoint}
/>
</div>
);
}
function EndpointHeader() {
const { settings, settingsLoading, openWizard } = useAIProviders();
const { permission } = usePermissions();
if (settingsLoading) return null;
if (!settings) {
// The bootstrap CTA opens the provider wizard, so only callers who can
// actually connect a provider get it; read-only viewers (usage_viewer)
// see nothing until an admin sets the endpoint up.
if (!permission?.["agent_network.providers"]?.create) return null;
return (
<button
type={"button"}
@@ -103,10 +168,10 @@ export default function AgentNetworkProvidersPage() {
<PageContainer>
{/* Gate the whole surface: AIProvidersProvider and EndpointHeader fetch
agent-network state, so they must not mount for users without
read on the providers submodule. */}
services.read. */}
<RestrictedAccess
page={"Providers"}
hasAccess={permission?.["agent_network.providers"]?.read}
hasAccess={permission?.services?.read}
>
<AIProvidersProvider>
<div className={"p-default py-6"}>
@@ -8,7 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { LayoutDashboard, ScrollText } from "lucide-react";
import dayjs from "dayjs";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { Suspense, useMemo, useState } from "react";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
@@ -18,13 +18,14 @@ import PageContainer from "@/layouts/PageContainer";
import AgentAccessLogTable from "@/modules/agent-network/AgentAccessLogTable";
import AgentOverviewPanel from "@/modules/agent-network/AgentOverviewPanel";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
import { useMyAgentNetworkSetup } from "@/modules/agent-network/useMyAgentNetworkSetup";
// Tab ids — kept stable so ?tab=<id> URL hand-off works (e.g.
// /agent-network/usage?tab=access-logs), the same way Settings deep-links tabs.
const TAB_USAGE = "usage";
const TAB_ACCESS_LOGS = "access-logs";
const VALID_TABS = new Set([TAB_USAGE, TAB_ACCESS_LOGS]);
// UsageAndLogsPage surfaces the live access log and the spend dashboard.
// Budget rules and log-collection controls live under the separate
// Configuration entry. Providers are mounted once at the top so switching
@@ -35,31 +36,9 @@ export default function UsageAndLogsPage() {
const router = useRouter();
const pathname = usePathname();
const canReadUsage = !!permission?.["agent_network.usage"]?.read;
const canReadLogs = !!permission?.["agent_network.logs"]?.read;
// Callers without the account-wide grants still get this page once their
// own Agent Network setup is configured: the server self-scopes the usage
// and log endpoints to them, so the same tabs render their own data.
const { configured: mySetupConfigured } = useMyAgentNetworkSetup();
const canUseUsage = canReadUsage || mySetupConfigured;
const canUseLogs = canReadLogs || mySetupConfigured;
// Each tab maps to its own permission submodule (usage_viewer, for one,
// reads usage but not the request-level log). Only permitted tabs are
// selectable; a deep link to a forbidden or unknown tab falls back to the
// first permitted one.
const allowedTabs = useMemo(() => {
const tabs = new Set<string>();
if (canUseUsage) tabs.add(TAB_USAGE);
if (canUseLogs) tabs.add(TAB_ACCESS_LOGS);
return tabs;
}, [canUseUsage, canUseLogs]);
const defaultTab = canUseUsage ? TAB_USAGE : TAB_ACCESS_LOGS;
// The ?tab= query is the single source of truth; onTabChange below pushes
// it, so deep links, back/forward and clicks all resolve the same way.
const queryTab = searchParams.get("tab") ?? "";
const tab = allowedTabs.has(queryTab) ? queryTab : defaultTab;
const initialTab = VALID_TABS.has(queryTab) ? queryTab : TAB_USAGE;
const [tab, setTab] = useState(initialTab);
// Access-log view mode: flat per-request rows, or grouped by provider session.
// Each mode hits its own endpoint and the provider is remounted on toggle
@@ -76,8 +55,16 @@ export default function UsageAndLogsPage() {
[],
);
// Keep the tab in sync when the ?tab= query changes (deep links / back-forward).
// Also reset to the default when ?tab= is removed or invalid, so navigating
// back to the bare URL doesn't leave the previous tab selected.
useEffect(() => {
setTab(VALID_TABS.has(queryTab) ? queryTab : TAB_USAGE);
}, [queryTab]);
// Reflect the active tab in the URL so it's shareable, like Settings.
const onTabChange = (value: string) => {
setTab(value);
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
@@ -107,7 +94,7 @@ export default function UsageAndLogsPage() {
<RestrictedAccess
page={"Usage & Logs"}
hasAccess={canUseUsage || canUseLogs}
hasAccess={permission?.services?.read}
>
<GroupsProvider>
<PeersProvider>
@@ -115,53 +102,45 @@ export default function UsageAndLogsPage() {
<Tabs
value={tab}
onValueChange={onTabChange}
defaultValue={initialTab}
className={"pt-4 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
{canUseUsage && (
<TabsTrigger value={TAB_USAGE}>
<LayoutDashboard size={16} />
Usage
</TabsTrigger>
)}
{canUseLogs && (
<TabsTrigger value={TAB_ACCESS_LOGS}>
<ScrollText size={16} />
Access Logs
</TabsTrigger>
)}
<TabsTrigger value={TAB_USAGE}>
<LayoutDashboard size={16} />
Usage
</TabsTrigger>
<TabsTrigger value={TAB_ACCESS_LOGS}>
<ScrollText size={16} />
Access Logs
</TabsTrigger>
</TabsList>
{canUseUsage && (
<TabsContent value={TAB_USAGE} className={"pb-8"}>
<Suspense fallback={<SkeletonTable />}>
<AgentOverviewPanel selfScoped={!canReadUsage} />
</Suspense>
</TabsContent>
)}
<TabsContent value={TAB_USAGE} className={"pb-8"}>
<Suspense fallback={<SkeletonTable />}>
<AgentOverviewPanel />
</Suspense>
</TabsContent>
{canUseLogs && (
<TabsContent value={TAB_ACCESS_LOGS} className={"pb-8"}>
<Suspense fallback={<SkeletonTable />}>
<ServerPaginationProvider
key={groupBySession ? "sessions" : "flat"}
url={
groupBySession
? "/agent-network/access-log-sessions"
: "/agent-network/access-logs"
}
defaultPageSize={25}
defaultFilters={defaultAccessLogFilters}
>
<AgentAccessLogTable
grouped={groupBySession}
onGroupedChange={setGroupBySession}
selfScoped={!canReadLogs}
/>
</ServerPaginationProvider>
</Suspense>
</TabsContent>
)}
<TabsContent value={TAB_ACCESS_LOGS} className={"pb-8"}>
<Suspense fallback={<SkeletonTable />}>
<ServerPaginationProvider
key={groupBySession ? "sessions" : "flat"}
url={
groupBySession
? "/agent-network/access-log-sessions"
: "/agent-network/access-logs"
}
defaultPageSize={25}
defaultFilters={defaultAccessLogFilters}
>
<AgentAccessLogTable
grouped={groupBySession}
onGroupedChange={setGroupBySession}
/>
</ServerPaginationProvider>
</Suspense>
</TabsContent>
</Tabs>
</AIProvidersProvider>
</PeersProvider>
+2 -7
View File
@@ -70,7 +70,6 @@ import ReverseProxiesProvider, {
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
@@ -431,7 +430,6 @@ const PeerOverviewTabContent = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { selectedGroups, setSelectedGroups } = usePeerSettings();
const isSSHSupported = isSSHSupportedOnOS(peer?.os);
return (
<div className={"px-8"}>
@@ -460,14 +458,11 @@ const PeerOverviewTabContent = () => {
</div>
)}
{isSSHSupported && <PeerSSHToggle />}
<PeerSSHToggle />
<div>
<Label>Remote Access</Label>
<HelpText>
Connect directly to this peer via{" "}
{isSSHSupported ? "SSH or RDP" : "RDP"}.
</HelpText>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
+10 -21
View File
@@ -93,15 +93,10 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
const userRequest = useApiCall<User>("/users");
const isServiceUser = !!user?.is_service_user;
const { mutate } = useSWRConfig();
const { loggedInUser, isOwner, isOwnerOrAdmin, isUser } = useLoggedInUser();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
const { permission } = usePermissions();
// The management API rejects taking the owner role away from a user unless
// the caller is an owner themselves ("only owners can remove owner role from
// their user"), so don't offer the change to anyone else.
const cannotChangeOwnerRole = user.role === Role.Owner && !isOwner;
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initialGroups,
@@ -255,26 +250,20 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
/>
</div>
)}
<div>
<Label>User Role</Label>
<HelpText>
{cannotChangeOwnerRole
? "Only the account owner can change the owner's role."
: "Set a role for the user to assign access permissions."}
</HelpText>
{/* 320px: enough for the longest role name and for the tab row
in the dropdown, without stretching across the column. */}
<div className={"max-w-[320px]"}>
<div className={"flex items-start"}>
<div className={"w-2/3"}>
<Label>User Role</Label>
<HelpText>
Set a role for the user to assign access permissions.
</HelpText>
</div>
<div className={"w-1/3"}>
<UserRoleSelector
value={role}
onChange={setRole}
hideOwner={isServiceUser}
currentUser={user}
disabled={
isLoggedInUser ||
!permission.users.update ||
cannotChangeOwnerRole
}
disabled={isLoggedInUser || !permission.users.update}
/>
</div>
</div>
Loaded 20 of 74 files, more files were not shown because too many files have changed in this diff. Show more